mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-18 00:46:45 +00:00
* 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>
264 lines
13 KiB
Markdown
264 lines
13 KiB
Markdown
# 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**:
|
|
|
|
- [`task.model.ts`](src/app/features/tasks/task.model.ts) - Field definitions with JSDoc
|
|
- [`task-shared-scheduling.reducer.ts`](src/app/root-store/meta/task-shared-meta-reducers/task-shared-scheduling.reducer.ts) - Write implementation
|
|
- [`work-context.selectors.ts`](src/app/features/work-context/store/work-context.selectors.ts) - Read pattern
|
|
- [`planner.selectors.ts`](src/app/features/planner/store/planner.selectors.ts) - Read pattern
|
|
- [`task.selectors.ts`](src/app/features/tasks/store/task.selectors.ts) - Read pattern
|
|
|
|
**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**:
|
|
|
|
- [`tag.const.ts`](src/app/features/tag/tag.const.ts) - TODAY_TAG definition
|
|
- [`work-context.selectors.ts`](src/app/features/work-context/store/work-context.selectors.ts) - Membership computation
|
|
- [`task-shared-helpers.ts`](src/app/root-store/meta/task-shared-meta-reducers/task-shared-helpers.ts) - Invariant enforcement
|
|
|
|
**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`](docs/sync-and-op-log/package-boundaries.md)
|
|
|
|
**Key Files**:
|
|
|
|
- [`packages/sync-core/src/index.ts`](packages/sync-core/src/index.ts) - Core public API
|
|
- [`packages/sync-providers/src/index.ts`](packages/sync-providers/src/index.ts) - Provider public API
|
|
- [`eslint.config.js`](eslint.config.js) - Package boundary enforcement
|
|
- [`src/app/op-log/sync-providers/sync-providers.factory.ts`](src/app/op-log/sync-providers/sync-providers.factory.ts) - App-side provider composition
|
|
|
|
**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`](docs/sync-and-op-log/diagrams/02-server-sync.md)
|
|
|
|
**Key Files**:
|
|
|
|
- [`packages/super-sync-server/src/sync/sync.service.ts`](packages/super-sync-server/src/sync/sync.service.ts) - Upload transaction and batch primitive
|
|
- [`packages/super-sync-server/prisma/schema.prisma`](packages/super-sync-server/prisma/schema.prisma) - `user_sync_state.last_seq`
|
|
|
|
**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**:
|
|
|
|
- [`project.actions.ts`](src/app/features/project/store/project.actions.ts), [`project.reducer.ts`](src/app/features/project/store/project.reducer.ts)
|
|
- [`project.service.ts`](src/app/features/project/project.service.ts) — `complete` / `moveTasksToInbox` / `markTasksDone`
|
|
- [`work-context-menu.component.ts`](src/app/core-ui/work-context-menu/work-context-menu.component.ts) — `completeProject()` flow
|
|
|
|
**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
|
|
|
|
```markdown
|
|
### 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]
|
|
```
|
|
|
|
---
|
|
|
|
## Related Documentation
|
|
|
|
- [`docs/sync-and-op-log/`](docs/sync-and-op-log/) - Operation log architecture
|
|
- [`docs/long-term-plans/`](docs/long-term-plans/) - Future architectural plans
|
|
|
|
---
|
|
|
|
## 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)
|
|
```
|