* fix(op-log): lock snapshot save to prevent lost-update window saveCurrentStateAsSnapshot() read NgRx state then lastSeq without holding OPERATION_LOG lock. An op appended between the two reads would get seq <= lastAppliedOpSeq but its effect would be absent from the snapshot. On next hydration the tail replay would start after that seq, silently skipping the op forever. Fix: wrap in lockService.request(LOCK_NAMES.OPERATION_LOG, ...) and read lastSeq BEFORE state snapshot so the worst interleaving degrades to harmless re-replay (idempotent) rather than a missed op. Fixes #8308 * fix(op-log): address review feedback on snapshot lock PR - Amend JSDoc idempotency claim: syncTimeSpent is additive on re-replay - Add inline note about compaction's opposite read order and worse failure mode - Add lock regression tests (#8308): lock acquired, read order, error handling Co-Authored-By: Claude <noreply@anthropic.com> * refactor(sync): remove unused error classes, dialog, and constructor-time logging Phase 1 of #8325: clean up orphaned sync error types and their UI. Removed error classes that are no longer thrown anywhere: - NoEtagAPIError, FileExistsAPIError (unused API errors) - RevMismatchForModelError, SyncInvalidTimeValuesError (superseded by file-based flow) - RevMapModelMismatchErrorOnDownload/Upload, NoRemoteModelFile, NoRemoteMetaFile - LockPresentError, LockFromLocalClientPresentError, MetaNotReadyError, InvalidRevMapError Removed DialogSyncErrorComponent and all references in SyncWrapperService (_forceDownload, _handleIncoherentTimestampsDialog, _handleIncompleteSyncDialog, _openSyncErrorDialog, _extractModelIdFromError). Removed constructor-time logging from JsonParseError, ModelValidationError, DataValidationFailedError — these errors are logged at the catch site; redundant construction-time logs risk leaking user data. Cleaned up dead translation keys (D_INCOMPLETE_SYNC block, DIALOG_RESULT_ERROR, ERROR_DATA_IS_CURRENTLY_WRITTEN) from en.json and t.const.ts. Updated file-based-sync-flowchart.md to reflect the removed error types. --------- Co-authored-by: Claude <noreply@anthropic.com>
24 KiB
Plainspace Integration Plan
Integrating Plainspace (plainspace.org — repo Johannesjo/spaces) into Super
Productivity (SP) so that:
- A project can be made shared on Plainspace directly from the project create/edit dialog.
- For such shared projects, SP shows — by task ownership (see the model in
§1):
- My list — tasks assigned to me, as regular, editable SP tasks.
- A read‑only claim pool — unclaimed (unassigned) tasks you can claim; claiming assigns the task to you in Plainspace and imports it as an SP task.
- Tasks assigned to others are not represented in SP at all.
Conceptual note (revised): an earlier draft mirrored "assigned to others" into SP as a standing read-only list. We dropped that — SP is a personal focus tool, and a permanent wall of others' non-actionable tasks works against it and creates a stale second copy of the Plainspace board. The model is now: only mine + unclaimed appear in SP; claiming is the bridge that turns shared work into your work. See the conversation rationale captured in §7.
Status: planning + prototype. The Plainspace HTTP API contract is not yet pinned down in this document (see Open questions); the prototype is built against an assumed contract isolated behind a single API service so it can be corrected in one place once the real API is known.
Implemented today (mock-backed):
- §4 — the
PLAINSPACEissue provider (providers/plainspace/): config form,PlainspaceApiService(mock mode viaPLAINSPACE_USE_MOCK),PlainspaceCommonInterfacesServiceimplementingIssueServiceInterface, registered inissue.model.ts/issue.const.ts/issue.service.ts+ icon. Only tasks assigned to me import via the issue→backlog pipeline.- §5 — account / identity:
PlainspaceAccountService(signals:account,isLoggedIn,currentUserId; localStorage-persisted, never synced) with a mocklogin/logout. "Mine" comes from the signed-in identity, and the share toggle prompts sign-in if needed.- §6 — the "Share on Plainspace" toggle in the create-project dialog, which (after sign-in) provisions a (mock) space and a bound provider via
PlainspaceShareService.- §7 — the read-only claim pool:
PlainspaceClaimPoolServicefeeds unclaimed tasks (mock) throughproject-task-page→work-viewinto a collapsed-by-default panel (PlainspaceClaimPoolComponent). A Claim action assigns the task to me and imports it as an SP task. Shows only for shared projects.Still design-only: §8 write-back, and the real HTTP API + real auth (all
PlainspaceApiServicecalls, the login, and claim are mocked — see §10). The claim pool does not yet auto-poll (loads on project open / provider change / after a claim).
1. Guiding decisions (agreed)
| Decision | Choice |
|---|---|
| Source of truth for shared data | Plainspace backend. SP reads/writes shared tasks via Plainspace's API as a separate channel from SP's own op‑log sync. |
| Integration shape | Model Plainspace as a regular issue provider (like Jira/Redmine) "for the most part". |
| Identity | Plainspace account login (token-based). The authenticated account defines "me" — which tasks are mine vs unclaimed. |
| v1 scope | Plan the full feature; build a working prototype (UI + provider scaffold against an assumed/mock API). |
Why "issue provider" is the right host
SP already has a mature, well-factored issue-provider system (Jira, GitLab, CalDAV, OpenProject, Trello, Redmine, Azure DevOps, Nextcloud Deck). It gives us, for free:
- Per-provider config + Formly config form, stored in the issue-provider NgRx
store and bindable to a specific project via
defaultProjectId. - Search in the add-task bar, "add issue as task", attachments mapping.
- Auto-import to backlog (
getNewIssuesToAddToBacklog) and polling for fresh data (getFreshDataForIssueTask) with a configurablepollInterval. - A clean single interface to implement:
IssueServiceInterface.
This means "Plainspace issues assigned to me" flow through the existing issue→task pipeline with almost no new core code. The only genuinely new surface is the read-only claim pool, because that data is not imported as SP tasks until claimed.
The one important nuance
The standard issue-provider flow turns issues into SP tasks. We only do that for tasks assigned to me. Unclaimed tasks are shown (a read-only pool) but not auto-imported — claiming is a deliberate act that assigns the task to me and then imports it. Tasks assigned to others are not represented in SP at all. (Auto-importing unclaimed work as if it were yours has the same problem as the old others-list, just subtler: two members connecting the same space would both "own" the same unclaimed task locally.) See §7 for the full ownership model.
2. Architecture overview
┌─────────────────────────────────────────────┐
│ Plainspace API │
│ spaces (projects) · tasks · members · auth │
└───────────────┬───────────────┬─────────────┘
│ │
(issue-provider channel) (shared-project channel)
│ │
┌────────────────────────────────▼──┐ ┌────────▼───────────────────────────┐
│ PlainspaceApiService (HTTP) │ │ PlainspaceAccountService (auth/me) │
│ PlainspaceCommonInterfacesService │ │ PlainspaceClaimPoolService │
│ implements IssueServiceInterface │ │ (unclaimed tasks + claim action) │
└───────────────┬────────────────────┘ └───────────────┬────────────────────┘
│ │
┌───────────────▼────────────────┐ ┌────────────────▼───────────────────┐
│ Existing issue→task pipeline │ │ Read-only claim-pool panel │
│ → real SP tasks (assigned to me)│ claim │ in work-view (unclaimed tasks) │
└─────────────────────────────────┘◄───────┴─────────────────────────────────────┘
│ │
└──────────────► Project work view ◄───────┘
(My list) (Claim pool)
- Issue-provider channel = the Jira-like path. Registers a
PLAINSPACEprovider, bound per project viadefaultProjectId(= the SP project the space maps to). Imports assigned-to-me issues, polls them for freshness. - Shared-project channel = the new bits: account login, the unclaimed claim pool (+ claim → import), and creating a space when a project is shared.
SP's own op-log/vector-clock sync is untouched: shared data does not flow through it. (Doing so would mean teaching the single-user op-log to carry multi-user ops — explicitly rejected as too risky.)
3. Data model changes
3.1 New issue-provider config (PlainspaceCfg)
New folder src/app/features/issue/providers/plainspace/. Config interface (mirrors
RedmineCfg):
export interface PlainspaceCfg extends BaseIssueProviderCfg {
host: string | null; // plainspace.org or self-hosted base URL
spaceId: string | null; // the Plainspace "space" this provider is bound to
token?: string | null; // PAT (pat_…) authorizing this provider's API calls
}
Where the token lives (as built). The PAT is stored on
PlainspaceCfg.tokenand authorizes everyPlainspaceApiServicecall — exactly like Jira'spasswordor CalDAV'spassword, and like them it is part of synced issue-provider state and is included in plaintext backups/exports. This is a deliberate parity choice (a provider works on a fresh device after sync without re-pasting), and is the accepted secret-handling posture for issue providers. The account store (§3.3, local-onlylocalStorage) holds a token too, but only to bootstrap the "Share on Plainspace" flow, which needs a token before any provider exists; the provider runtime reads onlycfg.token. An earlier draft said the token was not stored in the cfg — that was never the case in the shipped code.
3.2 Plainspace issue/task shapes (assumed — single source to fix later)
// src/app/features/issue/providers/plainspace/plainspace-issue.model.ts
export interface PlainspaceMember {
id: string;
name: string;
avatarUrl?: string;
}
export interface PlainspaceIssue {
id: string;
title: string;
isDone: boolean;
assigneeId: string | null; // null = unassigned
assignee?: PlainspaceMember | null;
updatedAt: string; // ISO
url?: string;
// ...extend once the real API is known
}
3.3 Account / identity (new, small store)
// src/app/features/plainspace/plainspace-account.model.ts
export interface PlainspaceAccount {
host: string; // which plainspace instance
userId: string; // "me"
displayName: string;
token: string; // bearer token (stored like other provider creds)
}
Stored per SP profile alongside other credentials (same mechanism existing providers use for secrets). One account → many spaces.
3.4 No change to the SP Task model in v1
- "Mine/unassigned" tasks are normal SP tasks; their Plainspace origin is already
captured by the existing
issueId/issueProviderId/issueTypefields. - "Assigned to others" tasks are not SP tasks, so they need no
Taskfield. Anassigneefield on SP tasks is explicitly deferred (would touch the hot-path task component and sync) — see Future work.
3.5 Project ↔ space link
The link is expressed entirely through the issue-provider instance:
IssueProviderPlainspace.defaultProjectId = SP project id, and
PlainspaceCfg.spaceId = remote space id. No new field on Project is strictly
required. (Optional convenience flag Project.isSharedOnPlainspace could be added
later for menu/badge rendering, but is not needed for correctness.)
4. Phase 1 — Plainspace issue provider scaffold
Goal: PLAINSPACE exists as a first-class issue provider; my/unassigned issues
import as tasks and poll. Pattern reference: Redmine (simplest built-in).
4.1 Central registration (4 edits)
src/app/features/issue/issue.model.ts- add
'PLAINSPACE'toBuiltInIssueProviderKey+BUILT_IN_KEYS - add
PlainspaceCfgtoIssueIntegrationCfgunion andIssueIntegrationCfgsmap - add issue type to
IssueData/IssueDataReduced(+IssueDataReducedMap) - add
IssueProviderPlainspace extends IssueProviderBase, PlainspaceCfg(issueProviderKey: 'PLAINSPACE') and add it to theIssueProviderunion andIssueProviderTypeMap.
- add
src/app/features/issue/issue.const.tsPLAINSPACE_TYPE, add toISSUE_PROVIDER_TYPES,ISSUE_PROVIDER_ICON_MAP,ISSUE_PROVIDER_HUMANIZED,DEFAULT_ISSUE_PROVIDER_CFGS,ISSUE_PROVIDER_FORM_CFGS_MAP,ISSUE_STR_MAP.
src/app/features/issue/issue.service.ts- import + inject
PlainspaceCommonInterfacesService, add toISSUE_SERVICE_MAP.
- import + inject
- Provider icon: add
src/assets/icons/plainspace.svgand register it inGlobalThemeService(_initIcons(), theaddSvgIcon(...)block) — theISSUE_PROVIDER_ICON_MAPvalue only names the icon, it does not register it. NoteISSUE_PROVIDER_HUMANIZEDis a plain string ('Plainspace'), not aTkey, so no translation entry is needed for the provider name itself.
Not strictly 4 files: adding
'PLAINSPACE'toBuiltInIssueProviderKeyalso widensIssueProviderKey, so the existingTask.issueTypefield gains'PLAINSPACE'as a valid value. No newTaskfield, but it is a (safe, additive) type-surface change to be aware of.
4.2 New provider files (providers/plainspace/)
| File | Responsibility |
|---|---|
plainspace.model.ts |
PlainspaceCfg |
plainspace-issue.model.ts |
PlainspaceIssue, PlainspaceMember |
plainspace.const.ts |
DEFAULT_PLAINSPACE_CFG, PLAINSPACE_POLL_INTERVAL |
plainspace-cfg-form.const.ts |
Formly config form + ..._CONFIG_FORM_SECTION (host, advanced common fields) |
plainspace-api.service.ts |
All HTTP: searchIssues$, getById$, getTasksForSpace$, getMembers$, createSpace$, plus mock mode |
plainspace-common-interfaces.service.ts |
implements IssueServiceInterface (extends BaseIssueProviderService) |
plainspace-issue-map.util.ts |
PlainspaceIssue → SearchResultItem and → getAddTaskData |
4.3 IssueServiceInterface implementation notes
isEnabled(cfg)→cfg.isEnabled && !!cfg.host && !!cfg.spaceIdand an account token present.getAddTaskData(issue)→{ title, isDone, issuePoints? }. Filter at the source: only my/unassigned issues are ever offered to this path (see 4.4).getNewIssuesToAddToBacklog(providerId, existingIds)→ fetch space tasks whereassigneeId === me || assigneeId == null, minusexistingIds.getFreshDataForIssueTask(task)→ re-fetch by id, returnisDone/title changes (never overwrite user scheduling).pollInterval→PLAINSPACE_POLL_INTERVAL(e.g. 5 min). Reuses existingpoll-issue-updates.effects.tsandpoll-to-backlog.effects.ts.
4.4 The mine/unassigned filter
Centralize in plainspace-api.service (getMyAndUnassignedTasks$) so both the
backlog import and the search path only ever see items that are valid to import.
"Assigned to others" is fetched by a sibling method and never reaches the issue
pipeline.
5. Phase 2 — Account login / identity
Goal: establish "me" so the assigned/unassigned split is meaningful.
src/app/features/plainspace/plainspace-account.service.ts— login (token exchange), store/clear account, exposeme$(signal) andcurrentUserId.- Login UI: a button in the Plainspace provider config form (
testConnectiondoubles as "verify login"), and/or a small dialog. Token persisted via the same secret-storage path other providers use. isEnabledand the shared-tasks fetch both depend on a valid account; surface a clear "not logged in" state.
Auth mechanism (token vs OAuth redirect) depends on what Plainspace exposes — see
Open questions. If OAuth is required,
reuse the existing src/app/plugins/oauth/ helpers.
6. Phase 3 — "Share on Plainspace" in the project create/edit dialog
Goal: a toggle in dialog-create-project that provisions a Plainspace space and
wires up the provider binding.
- Form: add an
isShareOnPlainspacecheckbox toCREATE_PROJECT_BASIC_CONFIG_FORM_CONFIG(src/app/features/project/project-form-cfg.const.ts). Gate it behind "account logged in" (show a login affordance if not). - On submit (in
dialog-create-project.component.ts), after the project is created viaprojectService.add():- If
isShareOnPlainspaceand logged in →PlainspaceApiService.createSpace$( { title })→ returnsspaceId. - Create a
PLAINSPACEissue-provider instance with{ host, spaceId, isEnabled: true, defaultProjectId: <newProjectId>, isAutoAddToBacklog: true }via the issue-provider store.
- If
- Edit mode: same toggle reflects whether a bound Plainspace provider exists; turning it on later provisions the space + provider; turning it off should prompt (unlink vs delete remote) — keep v1 to unlink only (disable provider, leave remote space intact) to avoid destructive surprises.
- i18n: new strings via
T/en.jsononly.
7. Ownership model & the claim pool (revised)
The conceptual question that reshaped this feature: which tasks from a shared space should appear in a personal focus app at all? Answer — by ownership state:
| Plainspace state | In SP? | Treatment |
|---|---|---|
| Assigned to me | Yes — the point | First-class SP tasks: schedule, time-track, complete. |
| Unclaimed | As a pool, not list | Read-only "claim pool"; Claim → assign-to-self → SP task. |
| Assigned to others | No | Not represented in SP (Plainspace board is the team view). |
| Done (others/unass.) | No | Irrelevant to the individual. |
Claiming is the bridge between the collective space and the personal app: the only way unclaimed work becomes yours. This avoids mirroring a stale, non-actionable copy of other people's tasks into a focus tool. (We also skip per-task assignee badges: with only mine + unclaimed shown, ownership is implicit, and SP already shows a provider icon on issue-linked tasks.)
7.1 Read-only claim-pool component
src/app/features/plainspace/claim-pool/claim-pool.component.ts
- Input:
tasks: PlainspaceSharedTask[](unclaimed) +projectId. - Flat read-only rows: title, "open in Plainspace" link, Claim button. No drag/drop, scheduling, time tracking, or task-store interaction.
Deliberately a lightweight standalone component, not the hot-path
task.component— these are foreign tasks until claimed.
7.2 Data flow
PlainspaceClaimPoolService.unclaimedTasksForProject$(projectId)finds the bound enabledPLAINSPACEprovider and returns its unclaimed tasks (assigneeId === null && !isDone), refreshing after a claim.claim(projectId, taskId)→PlainspaceApiService.claimTask$(assign-to-me) →IssueService.addTaskFromIssue(... isAddToBacklog)→ pool refreshes.project-task-pagederivesunclaimedTasksand passes it (+projectId) intowork-view.
7.3 Layout
A collapsible section (mirrors overdue/done panels), collapsed by default
(it's a pool you reach for, not your active work); state persisted in
localStorage (LS.PLAINSPACE_CLAIM_POOL_HIDDEN).
8. Phase 5 — Polling, refresh & write-back
- Reads: reuse issue polling for my tasks; add a light timer in
PlainspaceClaimPoolServiceto refresh the claim pool (same interval), only while the shared project is open. Today it refreshes on project open / provider change / after a claim — no timer yet. - Writes (mine): completing/editing a my imported task should optionally
push back to Plainspace via
updateIssueFromTask(the optional interface hook). Start read-mostly: import + status sync for done-state only; expand later. - Claim: the one write already implemented — assign an unclaimed task to me
(
claimTask$), then import it. Real mode would POST the assignment. - Offline: all Plainspace calls must fail soft (empty lists, cached last values) and never block the SP UI; SP remains fully usable offline.
9. Prototype scope (this iteration)
A runnable prototype that demonstrates the UX end-to-end against a mock
Plainspace backend (toggled by a flag in PlainspaceApiService), so it works with
no live server and is trivially swapped for the real API:
PLAINSPACEprovider registered + config form (Phase 1 skeleton).PlainspaceApiServicewith a mock mode returning canned spaces, members, and tasks (mix of mine / unassigned / others).- "Share on Plainspace" toggle in the create dialog that, in mock mode, fakes space creation and provisions the provider binding (Phase 3).
- The "Assigned to others" read-only panel wired into the work view for shared projects (Phase 4) — the visually novel part.
- My/unassigned issues importing into the normal list via the issue pipeline.
Out of prototype scope: real auth handshake, write-back, attachments, subtasks, production error/empty states polish.
10. Open questions / blocking decisions
These need answers (ideally the Plainspace API docs / the Johannesjo/spaces
repo, which I could not access from this environment) to move the prototype onto
the real backend:
- Auth: token/API-key, or OAuth redirect flow? Endpoint(s)? How is "me" (current user id) returned?
- Spaces API: create space (
POST?), list spaces, get members of a space. - Tasks API: list/get tasks for a space; fields available (esp.
assigneeId, done state, ordering); search; pagination. - Write-back: can SP create/update tasks and assignments? Required for the "share" flow to push SP tasks up, vs. pull-only.
- Hosting: is it always plainspace.org, or self-hostable (host field needed)?
- CORS/Electron: does the API allow browser-origin calls, or must requests go through the Electron main process (like some providers do)?
11. Future work
- Optional
Project.isSharedOnPlainspaceflag for menu badges. - A real
assigneeconcept on SP tasks (hot-path + sync implications — separate design). - Reassign-from-SP, presence/avatars, comments, two-way task creation.
12. Risks
- Cross-client forward-compat (rollout-gating). Adding
'PLAINSPACE'to the built-inIssueProviderKeyunion widens a synced, typia-validated type (issueProviderstate, andtask.issueType). A client built before this change has an AOT-baked validator that does not know the'PLAINSPACE'literal, so when a newer client creates a Plainspace provider and syncs it, the older client will reject the incoming model as corrupt (the documented "typia rejects unknown union members" failure → false data-corruption dialog / rejected sync). This is inherent to adding any new built-in provider key, and the mitigation is release sequencing: the validator-aware build must reach the fleet before any client can emit aPLAINSPACEprovider. Do not back-port the ability to create a Plainspace provider to a client that can't validate the key. (The alternative — modelling Plainspace as aplugin:-style opaque key, which the validator already accepts — is forward-compatible but is a larger change and is the wrong shape for a built-in provider; revisit only if simultaneous rollout can't be guaranteed.) - Sync correctness: keep shared data out of the op-log; never route Plainspace fetches through NgRx persisted actions. Imported "my" tasks follow the existing, already-correct issue-task path.
- Hot path: the claim pool is a new lightweight component, not
task.component; verify against large lists. - API assumptions: all isolated in
PlainspaceApiService+plainspace-issue.model.tsso the real contract changes one layer. - Privacy: the PAT is stored in synced provider cfg like other provider
secrets (see §3.1); no analytics; log only ids (
Log.log({ id })).