super-productivity/ARCHITECTURE-DECISIONS.md
Johannes Millan eff41c041d
feat(project): project completion experience (#8036)
* feat(project): add project completion with celebration + trophy view

Completing a project marks it done and archives it, with a celebration
dialog (confetti + live stats) and a prompt to resolve unfinished tasks
(move to Inbox / mark done). Completed projects show a trophy badge and
a Reopen action on the archived-projects page.

isDone stays distinct from isArchived; selectArchivedProjects is left
intact so completed projects' tasks stay filtered out of Today/Overdue.
Append/merge deferred to #8032. Plan: docs/plans/2026-06-05-project-completion.md

* refactor(project): drop Archive menu item; Complete is the retire path

Archive and Complete produced near-identical end states; collapse to one
user-facing action. Removes the 'Archive project' menu item and handler so
Complete is the single way to retire a project. The archiveProject action,
reducer and ProjectService.archive() stay (needed for op-log replay of
historical archive ops and the legacy unarchive/restore path). Wiki updated;
menu specs cover the Complete flow.

* fix(project): keep completion dialog open for the active project

MatDialog closeOnNavigation (default true) dismissed the celebration the
moment completing the currently-active project navigated to '/'. Navigate
first, then open the dialog.

* test(project): e2e for completion flow (complete, celebrate, reopen)

Covers the resolve-unfinished-tasks prompt, the celebration dialog with
stats, the trophy badge on the archived page, and reopening. Adds an
openProjectContextMenu page-object helper.

* feat(project): confirm project completion

* feat(project): show completion celebration fullscreen

* fix(project): harden completion celebration flow

* style(project): use spacing tokens in completion dialog

* fix(project): align completion screen with project context

* style(project): soften completion screen coloring

* style(project): reuse completion screen surfaces

* fix: refine project completion dialog actions

* fix: complete projects with atomic task resolution

* fix: restore archive path in project completion UI

* refactor(project): remove dead completion code

The project-level completeProject action (OpType.Update) was never
dispatched — completion goes exclusively through the atomic
TaskSharedActions.completeProject (OpType.Batch) meta-reducer. Drop the
dead action, its reducer case, the PROJECT_COMPLETE enum member and the
immutable 'PCO' op-log code (which would otherwise be permanently
reserved for an op that can never be produced); enum count 147->146.

Also remove the unused selectCompletedProjects / selectPlainArchivedProjects
selectors (no consumers) and the misspelled, unused 'angel' confetti
field, keeping the regression tests that guard selectArchivedProjects.

* fix(project): make project completion non-reversible

Completion resolves a project's open tasks (move-to-inbox / mark-done),
which reopen cannot truly restore, so the "Reopen"/"Undo" affordances on
the completion path were misleading. Drop the celebration dialog's Reopen
button and the post-complete undo snack; the fullscreen celebration is
the feedback and deliberate reactivation still lives on the
archived-projects page (Project.reopen kept for it).

Also restore the project title param on the archive confirm dialog (it
was rendering a raw {{title}} placeholder), remove the now-unused
moveTasksToInbox / markTasksDone resolution helpers (the meta-reducer
resolves tasks atomically), and drop the orphaned UNDO / S.COMPLETED
i18n keys. Updates the completion e2e to the close flow and asserts the
resolution props are forwarded to the atomic action.

* fix(tasks): cancel native reminders for project-completed tasks

Completing a project marks its unfinished tasks done inside the
meta-reducer (no per-task updateTask), so unscheduleDoneTask$'s
native-reminder cancellation is bypassed and an OS-scheduled Android
notification could still fire for a now-done task. Add a local-only
effect that cancels native reminders for the force-completed task ids.

Local-only by design: it dispatches no actions (the persistent
dismissReminderOnly/clearDeadlineReminder would each be an extra synced
op), and done tasks are already filtered from reminders$ on all
platforms — only the native Android notification needs explicit removal.

* test(project): drop TaskService spies orphaned by helper removal

getByIdWithSubTaskData$/moveToProject/setDone/setUnDone were only used
by the removed moveTasksToInbox/markTasksDone helpers and their deleted
tests.

* feat(sync): affectedEntities multi-entity conflict detection for atomic completion

WIP checkpoint of the atomic completeProject approach. The Batch op declares
every touched entity (PROJECT, INBOX, TASKs, TODAY_TAG) via a new
affectedEntities field threaded through op-log capture, conflict detection,
the sync server (+Prisma migration) and shared-schema. Per-effect
completeProject handlers (issue two-way-sync, time-block, repeat-cfg)
re-derive the task changes the atomic op bypasses.

* revert(sync): remove affectedEntities multi-entity conflict detection

Reverts 0893a86162. The affectedEntities feature existed solely to make the
atomic completeProject Batch op sync-correct (its only producer). Decoupling
project completion into normal per-task ops (next commit) makes the existing
per-entity conflict detection and effects fire naturally, so this entire
layer — sync-core, super-sync-server, shared-schema, op-log plumbing, the
Prisma migration, and the per-effect completeProject listeners — is no longer
needed. Preserved in history via the checkpoint commit.

* refactor(project): decouple completion from task resolution (Option C)

Completion was an atomic multi-entity Batch op (completeProject) that marked
tasks done / moved them to Inbox inside the project-shared meta-reducer.
Because it bypassed the normal per-task actions, every downstream consumer had
to be taught about it separately — conflict detection (the affectedEntities
feature, reverted in the previous commit), native-reminder cancellation, issue
two-way-sync, time-block and repeat-cfg effects.

Decouple instead: completion is now a plain single-entity PROJECT flag flip
(completeProject = OpType.Update, mirroring archiveProject). Unfinished-task
resolution runs first as the normal per-task actions (moveToOtherProject /
updateTask isDone) from the completion flow, so the existing effects and
per-entity conflict detection fire naturally — no special-casing anywhere.

- project.actions/reducer: restore plain completeProject action + on() handler
- project.service: complete() is a flag dispatch; restore moveTasksToInbox /
  markTasksDone (normal per-task dispatch + Rule #6 flush)
- work-context-menu: resolve unfinished work before the flag flip
- drop the completeProject meta-reducer block, the Batch action, the
  TASK_SHARED_COMPLETE_PROJECT op code, and the reminder-cancel effect
  (unscheduleDoneTask$ already cancels native reminders on the normal path);
  current-task clearing is covered by the existing task-internal effect

Net: ~190 LOC removed here on top of ~1565 (affectedEntities + a Prisma
migration) in the revert. Completion's task resolution is not undone either
way, so the atomic bundle never bought a clean reversal.

* docs(project): record decoupled-completion decision (ADR #5)

Document why project completion uses decoupled per-task resolution + a plain
single-entity flag flip instead of an atomic multi-entity op: the atomic op
forced a cross-stack affectedEntities conflict-detection feature and per-effect
listeners, for an undo guarantee it never delivered. Adds ARCHITECTURE-DECISIONS
#5 and a revision note + corrected undo/bulk-mechanic notes in the plan doc.

* test(project): pin completion ordering + resolution edge cases

Address multi-agent review of the decoupled-completion refactor:
- assert resolution (moveTasksToInbox / markTasksDone) runs BEFORE the
  completeProject flag flip (toHaveBeenCalledBefore) — the core invariant of
  the decoupled design that was previously not pinned
- cover the not-done branch of moveTasksToInbox (no setUnDone) and assert
  markTasksDone dispatches exactly the passed set
- add explicit PCO encode/decode round-trip assertions
- document the inbox-path current-task carry-forward nuance in ADR #5

Composition is covered end-to-end by e2e/tests/project/project-completion.spec.ts.

* refactor(project): tighten completion flow per review

- collapse 3x getCompletionInfo() to <=2: gate the resolve prompt once,
  recompute only after a resolution; each call now wrapped with an error
  snack so a failed archive load no longer aborts silently
- drop the dead post-confirm re-prompt branch (unreachable between two
  sequential single-user modals)
- reuse getDiffInDays + dateStrToUtcDate in completion-stats util instead
  of hand-rolled local-midnight/duration helpers
- use a Set for the top-level-task membership check (was O(n*m))
- drop redundant inline dialog sizing; the panelClass owns fullscreen
- remove dead --project-complete-accent test assertion

* refactor(project): finish review follow-ups for completion flow

- W3: reset the celebration confetti instance on dialog destroy so its
  rAF loop + window resize listener are torn down when the dialog closes
  before the animation ends (ConfettiService now returns the handle and
  fires without awaiting completion)
- S2: extract resolveBgImageToDataUrl() shared by app.component and the
  celebration dialog (was duplicated file://->data-url resolution)
- S3: split completeProject() into _getCompletionInfoOrNotify (dedupes the
  error handling), _promptResolveUnfinishedTasks and _confirmCompletion
- W2: keep prefers-reduced-motion gating app-wide (a11y) + document intent

* fix(project): close confetti teardown race on early dialog close

If the celebration dialog is dismissed while canvas-confetti is still
loading, the instance was assigned after ngOnDestroy ran, so reset() never
fired and the rAF loop + resize listener leaked. Guard with an _isDestroyed
flag and reset the instance immediately if it arrives post-destroy.

Also drop the now-dead CanvasConfetti type alias (superseded by
ConfettiInstance, zero references).

* docs(project): drop non-existent undo-snack from completion wiki

* refactor(project): extract completion task-tree and dialog helpers

* refactor(project): hide Archive menu item; Complete is the retire path

* refactor(project): drop dead archive(), reuse resolve-choice type

Multi-review follow-ups on the completion feature:
- Remove orphaned ProjectService.archive() (+ unused import, spec) — the
  menu collapsed Archive into Complete, leaving no caller. The
  archiveProject action/reducer stay for op-log decode of historical ops.
- Reuse the exported ResolveUnfinishedTasksChoice type instead of
  re-spelling the union three times in work-context-menu.
- Fix misleading moveTasksToInbox comment (setUnDone re-opens, not move).
- Note the as-shipped deviations (no extra selectors, no celebration
  effect) in the design plan so they aren't hunted for later.

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-06-08 13:43:38 +02:00

13 KiB

Architecture Decision Records

This document tracks significant architectural decisions and patterns in the Super Productivity codebase. When making changes that affect these patterns, reference this document and update it if needed.

Active Patterns & Decisions

1. dueDay/dueWithTime Mutual Exclusivity Pattern

Status: Active (since commit 400ca8c1, 2026-01-29)

Decision: The task.dueDay and task.dueWithTime fields are mutually exclusive in new data. When setting dueWithTime, dueDay must be cleared (set to undefined). When reading, dueWithTime takes priority over dueDay.

Rationale:

  • Prevents state inconsistency bugs where both fields had conflicting values
  • Single source of truth for task scheduling
  • Simpler state management

Implementation:

  • Writing: Clear dueDay when setting dueWithTime (in meta-reducers)
  • Reading: Check dueWithTime first; only check dueDay if dueWithTime is not set (in selectors)
  • Legacy Data: Old data with both fields works via priority pattern (no migration needed)

Key Files:

When to Update This Pattern:

  • Adding new date/time scheduling fields
  • Modifying task scheduling logic
  • Working with task selectors that check due dates

2. TODAY_TAG Virtual Tag Pattern

Status: Active (established pattern)

Decision: TODAY_TAG (ID: 'TODAY') is a virtual tag whose membership is determined by task.dueWithTime or task.dueDay, not by task.tagIds. The tag's taskIds field stores only the ordering of tasks, not membership.

Key Invariant: TODAY_TAG.id must NEVER be added to task.tagIds

Rationale:

  • Uniform move operations across all tags (virtual and regular)
  • Single source of truth for "today" membership (date fields, not tagIds)
  • Self-healing ordering (stale entries automatically filtered)
  • Natural integration with planner (which uses date fields)

Related: Uses the dueDay/dueWithTime mutual exclusivity pattern (Decision #1)

Key Files:

When to Update This Pattern:

  • Adding new virtual tags
  • Modifying tag membership logic
  • Working with today's task list

3. Sync Package Boundary Direction

Status: Active (since May 2026)

Decision: Operation-log sync code is split by dependency direction: src/app composes host-specific wiring, @sp/sync-providers owns bundled provider implementations, and @sp/sync-core owns framework-agnostic reusable sync primitives.

Rationale:

  • Keeps reusable sync algorithms independent of Angular, NgRx, app models, and provider implementations
  • Prevents provider IDs, app action/entity enums, validation schemas, UI, OAuth, and platform bridges from leaking into the core engine package
  • Gives boundary lint a clear rule: packages never import app code, and providers consume only public sync-core exports

Implementation:

  • ESLint rejects Angular, NgRx, app, shared-schema, sync-core deep imports, and dynamic imports inside package sources
  • @sp/sync-core has no runtime dependencies and owns vector-clock algorithms used by client/server compatibility paths
  • packages/shared-schema compatibility-re-exports generic vector-clock algorithms from @sp/sync-core; @sp/sync-core must not import @sp/shared-schema
  • @sp/sync-providers depends on public @sp/sync-core plus provider runtime helpers, while app factories inject credentials, platform bridges, validators, OAuth routing, and config

Documentation: docs/sync-and-op-log/package-boundaries.md

Key Files:

When to Update This Pattern:

  • Moving sync code between app and packages
  • Adding a package export or dependency
  • Adding a provider implementation or plugin-facing provider contract
  • Changing vector-clock ownership or shared-schema compatibility

4. Batch Uploads Under RepeatableRead

Status: Active (since May 2026)

Decision: SuperSync batch uploads derive conflict-safety from the shared user_sync_state.lastSeq row write that reserves server sequence numbers, not from PostgreSQL RepeatableRead snapshot isolation alone.

Rationale:

  • PostgreSQL RepeatableRead does not provide full serializable snapshot isolation
  • Two concurrent upload transactions can both pass conflict prefetch checks when they read the same pre-insert snapshot
  • Reserving sequence numbers through one user_sync_state.lastSeq row forces accepted writers for the same user to serialize on that row lock
  • If two batches race, the later writer blocks on the row and the transaction retry path handles the serialization failure rather than silently accepting conflicting operations

Implementation:

  • Batch upload conflict detection runs in memory against prefetched latest entity rows and updates that map as operations are accepted
  • Accepted operations reserve one contiguous sequence range with INSERT ... ON CONFLICT ... DO UPDATE SET last_seq = last_seq + delta
  • The batch insert does not use skipDuplicates; an unexpected unique conflict aborts the transaction and lets the request retry
  • Removing or sharding the lastSeq write requires replacing this safety mechanism with an equivalent per-user serialization primitive

Documentation: docs/sync-and-op-log/diagrams/02-server-sync.md

Key Files:

When to Update This Pattern:

  • Changing upload conflict detection
  • Changing server sequence assignment
  • Changing transaction isolation for upload operations
  • Introducing multi-writer or multi-region upload processing

5. Project Completion: Decoupled Resolution over Atomic Multi-Entity Op

Status: Active (since 2026-06-06, branch feat/completing-projects-48eeb4)

Decision: "Complete project" is a plain single-entity PROJECT flag flip (completeProject, OpType.Update, mirroring archiveProject → sets isDone/doneOn/isArchived). The accompanying resolution of unfinished tasks ("move to Inbox" / "mark done") runs first, as the normal per-task actions (moveToOtherProject / updateTask isDone) dispatched in a loop with the Rule #6 bulk-dispatch flush — not bundled into a single atomic multi-entity op.

Rationale: An earlier iteration made completion one atomic Batch op (completeProject) that marked/moved tasks inside the project-shared meta-reducer. Because that op deliberately routed around the normal per-task actions, every system that observes those actions had to be re-taught about completeProject separately:

  • Conflict detection needed a whole new affectedEntities multi-entity-ref feature threaded through sync-core, the sync server (+ a Prisma migration), shared-schema and the op-log — ~1,565 LOC, of which completeProject was the only producer.
  • Native-reminder cancellation, issue two-way-sync, time-block sync and repeat-cfg effects each needed a dedicated completeProject listener to re-derive the task changes the atomic op skipped.

The atomic op's headline benefit — reversing the whole thing as one unit — was never realized: reopenProject only clears the project flags; it does not un-move or un-complete the resolved tasks. So the bundle paid a large cross-cutting cost for an undo guarantee it didn't provide. Decoupling makes the existing effects and per-entity conflict detection fire naturally and deletes ~1,750 LOC total (revert + decouple). Trade-off accepted: completion now emits N+1 ops (one per resolved task + the flag flip) instead of one, and there is a brief intermediate state — both fine for a rare, user-initiated action whose resolution is not atomically reversible anyway. One behavioral nuance vs. the old atomic op: when unfinished work is moved to Inbox, a task that was being actively tracked stays the current task (it was carried forward, not finished — consistent with Inbox's carry-forward intent); the mark-done path stops tracking the current task via the existing autoSetNextTask$ effect. The atomic op cleared the current task in both cases; the decoupled design intentionally keeps it for the carry-forward case.

Implementation:

  • Action/reducer: completeProject({ id, doneOn }) in project.actions.ts; on(completeProject) flag flip in project.reducer.ts (guards INBOX_PROJECT). reopenProject clears the flags only.
  • Service: ProjectService.complete(id, doneOn) dispatches the flag flip; moveTasksToInbox() / markTasksDone() loop the normal per-task actions + setTimeout(0) flush.
  • Flow: work-context-menu resolves unfinished work before calling complete().
  • Do NOT reintroduce a multi-entity completeProject op or affectedEntities for it without re-justifying the full downstream cost above. Prior atomic implementation is preserved in history at commit 0893a86162.

Key Files:

When to Update This Decision:

  • Adding a true bulk meta-reducer action for general use (revisit whether completion should adopt it)
  • Reworking how completion resolves unfinished tasks
  • Any proposal to make completion a single synced op again

How to Use This Document

When Making Architectural Changes

  1. Before implementing: Check if your change affects any active pattern
  2. During implementation: Follow the documented patterns
  3. After implementation: Update this document if you've:
    • Changed an existing pattern
    • Added a new architectural pattern
    • Made a decision that affects future development

When to Add a New Decision

Add a new decision record when:

  • The decision affects multiple files/modules
  • Future developers need to understand "why" not just "what"
  • The pattern needs to be followed consistently across the codebase
  • The decision prevents a specific class of bugs

Decision Record Template

### N. [Pattern/Decision Name]

**Status**: ✅ Active | 🚧 Draft | ⚠️ Deprecated | ❌ Superseded

**Decision**: [One-sentence summary of the decision]

**Rationale**:

- [Why was this decision made?]
- [What problems does it solve?]

**Implementation**:

- [How is it implemented?]
- [Key techniques or patterns used]

**Documentation**: [Link to detailed docs]

**Key Files**: [List of primary files implementing this pattern]

**When to Update This Pattern**: [Scenarios when someone should review/update this]


Commit Reference

When committing changes related to these patterns, reference this document and the specific decision:

feat(tasks): implement feature X

Uses dueDay/dueWithTime mutual exclusivity pattern (ARCHITECTURE-DECISIONS.md #1)