super-productivity/docs/plans/2026-06-05-project-completion.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

19 KiB

Project completion experience

Date: 2026-06-05 (rev. after multi-agent review) Status: Implemented on feat/completing-projects-48eeb4 — state layer, stats util, service, celebration + resolve dialogs, menu wiring, trophy badge on the archived page, translations, wiki. Verified: unit tests (reducer 34, selectors 4, stats 6) + existing specs (menu 10, service 12, page 5) green; dev build exit 0; eslint + int:test clean. Branch: feat/completing-projects-48eeb4

Revision 2026-06-06 — completion is decoupled from task resolution (Option C). A later iteration made completion one atomic multi-entity op (completeProject Batch) that marked/moved unfinished tasks inside the project-shared meta-reducer. That bypassed the normal per-task actions, so it needed a new cross-stack affectedEntities conflict-detection feature (~1,565 LOC + a Prisma migration) plus dedicated completeProject listeners in the reminder / issue-sync / time-block / repeat-cfg effects — and it still didn't give a reversible undo (reopenProject clears project flags only). We reverted all of it and kept the simple mechanic below: resolve via the normal per-task actions, then a plain single-entity completeProject flag flip. See ARCHITECTURE-DECISIONS.md #5 for the full rationale; atomic implementation preserved at commit 0893a86162.

Deviations from the plan below (as shipped). Two pieces sketched in the sections that follow were dropped as unnecessary: (1) the selectCompletedProjects / selectPlainArchivedProjects selectors were never added — the trophy page reads isDone inline off selectArchivedProjectsSortedByTitle; (2) there is no celebration effect — the confetti dialog opens directly from the completeProject() click handler in work-context-menu.component.ts, which is inherently local, so a replayed/remote op can't pop it (the Rule #1 concern the planned effect guarded against never arises). Treat selector/effect references below as historical design intent, not the shipped shape.

Scope: Give projects a rewarding "done" state. The append/merge half ("fold a project's tasks into another") was split out to issue #8032 after review (YAGNI-adjacent + materially heavier than first scoped).

Problem

Two real friction points drive this:

  1. A complex chunk of work has no good home. Today the options are a heavyweight permanent project, or a "mega task" with an ever-growing subtask list. The mega task feels bad: the parent is one perpetually-unchecked item hanging over you, and progress is buried inside it instead of visible as moving pieces.
  2. Finishing big work isn't rewarding. The only end-state for a project is isArchived (project.model.ts:15, even marked // TODO remove maybe). Archiving is "shove it out of sight" — semantically the opposite of celebrating a finish.

Key insight — no nesting, no new entity

Both pains are about a container you can finish, not about hierarchy. Nesting projects-in-projects works against pain #2 (a sub-project inside a never-ending parent still leaves the parent hanging) and drags in aggregation/cascade/sync cost for little benefit. The lightweight "dump space" people want is just a regular Project with a missing lifecycle operation: complete it → reward + a place to look back. Grouping of related projects is already covered by the menu-tree folders; small breakdowns by nested subtasks. So this plan adds one operation on the existing entity, not a new type.

Non-goals

  • No nested/parent-child projects, no roll-up of time/progress from children.
  • No new "mini project" entity or UI concept.
  • No append/merge (→ #8032).
  • No change to how archiving itself works — completion piggybacks on the isArchived flag for menu-hiding, but isDone stays a distinct flag so a celebrated finish ≠ a quiet archive.

⚠️ Correction from review — what archiving actually does

The first draft assumed completing→auto-archiving would run the ArchiveOperationHandler and move done tasks into the archive store. That is false and was verified against source:

  • archiveProject is a pure isArchived: true flag flip (project.reducer.ts:166-177); the project archive effects are commented out (project.effects.ts:72, "CURRENTLY NOT IMPLEMENTED").
  • archiveProject is not in ARCHIVE_AFFECTING_ACTION_TYPES (archive-operation-handler.service.ts:40-54). Only moveToArchive / deleteProject etc. move tasks to IndexedDB.

Implications that shape this plan:

  • A completed project's tasks stay live in the NgRx store. Archiving only hides the project from the active menu (via the !isArchived filters).
  • Stats can be computed live from the still-live tasks; no snapshot needed (decision below).
  • Reopen is trivially safe — tasks never left, so un-archiving fully restores the project. No archive-restore logic.
  • ⚠️ Done tasks of a completed project remain visible in worklog/search/metrics. Acceptable (they're history), but noted.

Resolved decisions

# Decision Resolution
Q1 Auto-archive on complete YescompleteProject also sets isArchived: true ("complete and out of the way"). This is a flag flip only — menu-hiding, no task cleanup (see correction above).
Q2 Unfinished tasks Prompt (a plain confirm), default Move to Inbox with the count shown; plus "Mark them done" / "Cancel".
Q3 Stats live vs. snapshot Compute live — no completionStats field. The "mandatory snapshot" reason was based on the false archive premise.
Q4 Completion surface Split: DialogConfirm for the unfinished-task resolve step, then a separate celebration component.
Q5 Trophy view No new page. Add a "Completed on X" badge + live stats + Reopen to completed rows of the existing archived-projects page, and improve that page.
Q6 Append/merge Deferred → #8032.

Done vs. Archived — selector wiring (review-critical)

isDone ⇒ also isArchived. Do NOT narrow selectArchivedProjects. It feeds task-list filtering — selectArchivedProjectIds is consumed by task.selectors.ts:104,181 (selectTaskEntitiesInActiveProjects, selectAllTasksInActiveProjects → Today/Overdue). Narrowing it to isArchived && !isDone would leak completed projects' tasks back into Today/Overdue (incl. done tasks still carrying dueDay/dueWithTime, Rule #5). Instead:

  • Keep selectArchivedProjects = isArchived (covers completed too) → task filtering + menu-hiding stay correct, unchanged.
  • Add selectCompletedProjects = isDone → highlights/filters completed rows on the trophy page.
  • Add selectPlainArchivedProjects = isArchived && !isDone → page-only, if we want to visually separate "finished" from "shelved".
  • Reopen clears isDone + doneOn and isArchived: false (returns to the active menu).

Data model

Add to ProjectBasicCfg (src/app/features/project/project.model.ts), mirroring Task (isDone + doneOn):

export interface ProjectBasicCfg {
  title: string;
  isArchived?: boolean;
  isDone?: boolean; // NEW — completed (also implies isArchived)
  doneOn?: number | null; // NEW — completion timestamp (ms)
  isHiddenFromMenu?: boolean;
  // ...
}

Both new fields optional → forward-compatible for sync (typia accepts missing optional fields; only new required fields / literal-union members break old clients; verified createValidate does not reject excess props). Default in DEFAULT_PROJECT (project.const.ts:11): isDone: false, doneOn: null. INBOX_PROJECT can never be completed (guard like archive). plugin-api note: ProjectCopy extends the plugin-api Project; the new fields live on the app-side ProjectBasicCfg and compile fine without touching packages/plugin-api. Plugins won't see completion state — intentional (matches non-goals); revisit only if a plugin needs it.

Sync-correctness (CLAUDE.md rules)

  • completeProject / reopenProject are plain project Updates (OpType.Update, entityType:'PROJECT'), modeled exactly like archiveProject (project.actions.ts:76-100) → captured by the op-log capture effect automatically via meta. Add ActionType enum entries (action-types.enum.ts, section P) — the immutable wire format (review caught this omission).
  • Must NOT be added to ARCHIVE_AFFECTING_ACTION_TYPES.
  • The celebration effect injects LOCAL_ACTIONS (Rule #1) → a remote/replayed completeProject never pops a dialog / fires confetti on another device.
  • doneOn is computed at the call site (via DateService) and passed as a prop — never Date.now() in the reducer (Rule #4).
  • LWW note (accept): a concurrent remote updateProject (e.g. rename) vs local completeProject resolves by coarse whole-entity LWW — same as archiveProject today; completion has no archive-win protection, so it can be lost to a concurrent unrelated edit. Not a regression; documented.

Implementation

1. State + actions

  • project.actions.ts: add completeProject({ id, doneOn }) and reopenProject({ id }) (mirror archive, OpType.Update). Add matching ActionType enum entries.
  • project.reducer.ts (next to archive cases :166-189):
    • completeProject{ isDone: true, doneOn, isArchived: true }.
    • reopenProject{ isDone: false, doneOn: null, isArchived: false }.
    • Guard INBOX_PROJECT.
  • project.selectors.ts: add selectCompletedProjects + selectPlainArchivedProjects (see selector wiring above). Leave selectArchivedProjects unchanged.
  • project.service.ts: complete(id) / reopen(id) wrappers (mirror archive()/unarchive() :145).

2. Completion flow

Trigger: a "Complete project" item in the project context menu (work-context-menu.component.{ts,html}:79-111), beside Archive. Order/group it and add microcopy so "Complete" vs "Archive" is legible (both end up isArchived; only one celebrates).

  1. Resolve unfinished tasks (only if any undone tasks across taskIds + backlogTaskIds, incl. subtasks). Open a DialogConfirmComponent-style prompt showing the count, with:
    • Move to Inbox (default) — safe carry-forward.
    • Mark them done — "close enough."
    • Cancel.
    • Bulk mechanic (chosen — Option C): no bulk action exists today, and we deliberately did not add one. Loop the existing per-task action (moveToOtherProject / updateTask isDone) and apply the Rule #6 flush (await new Promise(r => setTimeout(r, 0))) after the loop. (A single atomic meta-reducer op was tried and reverted — see the Revision note above and ARCHITECTURE-DECISIONS.md #5. Trade-off: N+1 ops per completion, accepted.)
  2. ProjectService.complete(id) dispatches completeProject (reducer sets done + archived).
  3. Celebrate (section 3).
  4. If the completed project was active, navigate to / (archive already does this; also clear any selected-task/detail-panel pointing at the now-hidden project — cf. recent fix d44cb1138d).
  5. Undo: none. Completion is not reversible via a snack — its task resolution (move-to-inbox / mark-done) can't be cleanly restored by reopenProject, which only clears the project flags. The fullscreen celebration is the feedback; reactivation lives on the archived-projects page.

3. Celebration (separate component)

A small ProjectCompleteCelebrationComponent (dialog), reusing the layout language of focus-mode/focus-mode-session-done and the "summary-point" grid of daily-summary:

  • Confetti via ConfettiService.createConfetti() — gate on both isDisableAnimations and isDisableCelebration (no confetti → dialog still shows).
  • "🎉 Project complete" + project title + the stats grid (section 4).
  • Primary Done; secondary View completed projects → the archived page (trophy section).
  • Reopen is offered via the post-complete snack (step 2.5) and on the trophy page, not here.

4. Stats (computed live)

Computed on demand for the celebration and the trophy rows, from the still-live tasks:

  • Tasks done / total — count project tasks (taskIds + backlogTaskIds; decide subtask inclusion, state it consistently) by isDone.
  • Hours worked — sum task.timeSpent over the project's parent tasks only (a parent's timeSpent already includes subtasks — task.reducer.util.ts:53-72; summing both double-counts). Alternatively read TimeTrackingState.project[projectId].
  • Days worked — distinct timeSpentOnDay keys across tasks.
  • Finished in N daysstartedOndoneOn calendar span. startedOn = earliest timeSpentOnDay key, fallback project.created. This is the one stat that works with time-tracking off — feature it.
  • worklog/util/get-time-spent-for-day.util.ts aggregates per-day; reuse.

Degrade gracefully: many users don't track time. When timeSpent === 0, hide hours/days rows (don't show "0h over 0 days" — demotivating). Drop "avg per day" (vanity, prone to "0.4h/day").

5. Trophy view (improve the archived page)

Completed projects already land on /archived-projects (they're isArchived). Rather than a new page:

  • On completed rows (selectCompletedProjects), show a trophy/badge + "Completed on doneOn" + the live stats, and offer Reopen (reopenProject) instead of Unarchive.
  • Improve the page generally (it's currently a bare list): clearer layout, the stat readout, sort by doneOn, and make it more discoverable (the celebration's "View completed projects" links here; consider a findable entry rather than only the visibility menu).
  • Optionally use selectPlainArchivedProjects to visually separate "Finished" from "Shelved".

6. Testing

  • Reducer: completeProject sets isDone+doneOn+isArchived:true; reopenProject clears all three; INBOX guarded.
  • Regression (review-critical): completing a project keeps its (done, dueDay-carrying) tasks out of Today/Overdue — i.e. selectArchivedProjects still includes completed projects and the task-filtering selectors are unchanged. Add an explicit test.
  • Selector: selectCompletedProjects = isDone; selectPlainArchivedProjects = isArchived && !isDone.
  • Stats: live math — no double-count of parent+subtask time; finished in N days with time-tracking off.
  • Effect: celebration effect uses LOCAL_ACTIONS (no confetti/dialog on replayed/remote completeProject).
  • Translations: en.json only, via T. User-facing → update docs per docs/documentation-guide.md.

Risks

  • Selector leak (mitigated): the §"Done vs Archived" wiring + the regression test exist specifically to prevent completed tasks reappearing in Today/Overdue. Audit all selectArchivedProjects/selectArchivedProjectIds consumers (project.service.ts:83, magic-nav-config.service.ts:85, archived-projects-page.component.ts:52, task.selectors.ts:104,181, task-repeat-cfg.selectors.ts:22).
  • Discoverability: auto-archive makes a completed project vanish instantly; the reward is a one-shot unless the trophy page is findable. Undo snack + an improved, reachable trophy page mitigate.
  • Inbox flood: "Move to Inbox" on a big dump-space project can dump many tasks into Inbox — hence showing the count, and offering "Mark done".
  • Live-stat drift (accepted): if tasks are later deleted/manually-archived, recomputed stats shift. Acceptable for a retrospective view; this is the cost of choosing live-compute over a snapshot.

Open items

  • Trophy-page improvement scope (how far to take the redesign).
  • Unfinished-task default — confirm Inbox vs. mark-done after seeing it in use.
  • Subtask inclusion in tasks-done count (product call).

Key files

Area File
Model / defaults src/app/features/project/project.model.ts, project.const.ts
Actions / reducer / selectors src/app/features/project/store/project.actions.ts, project.reducer.ts, project.selectors.ts; action-types.enum.ts
Service src/app/features/project/project.service.ts
Context menu / trigger src/app/core-ui/work-context-menu/work-context-menu.component.{ts,html}
Trophy page src/app/pages/archived-projects-page/ (enhance)
Reward src/app/core/confetti/confetti.service.ts; ref features/focus-mode/focus-mode-session-done/, pages/daily-summary/
Stats src/app/features/tasks/store/task.reducer.util.ts (rollup caveat), features/time-tracking/time-tracking.model.ts, features/worklog/util/get-time-spent-for-day.util.ts
Resolve dialog src/app/ui/dialog-confirm/dialog-confirm.component.ts
Append/merge (deferred) issue #8032