* 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>
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 (
completeProjectBatch) that marked/moved unfinished tasks inside the project-shared meta-reducer. That bypassed the normal per-task actions, so it needed a new cross-stackaffectedEntitiesconflict-detection feature (~1,565 LOC + a Prisma migration) plus dedicatedcompleteProjectlisteners in the reminder / issue-sync / time-block / repeat-cfg effects — and it still didn't give a reversible undo (reopenProjectclears 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-entitycompleteProjectflag flip. See ARCHITECTURE-DECISIONS.md #5 for the full rationale; atomic implementation preserved at commit0893a86162.
Deviations from the plan below (as shipped). Two pieces sketched in the sections that follow were dropped as unnecessary: (1) the
selectCompletedProjects/selectPlainArchivedProjectsselectors were never added — the trophy page readsisDoneinline offselectArchivedProjectsSortedByTitle; (2) there is no celebration effect — the confetti dialog opens directly from thecompleteProject()click handler inwork-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:
- 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.
- 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
isArchivedflag for menu-hiding, butisDonestays 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:
archiveProjectis a pureisArchived: trueflag flip (project.reducer.ts:166-177); the project archive effects are commented out (project.effects.ts:72, "CURRENTLY NOT IMPLEMENTED").archiveProjectis not inARCHIVE_AFFECTING_ACTION_TYPES(archive-operation-handler.service.ts:40-54). OnlymoveToArchive/deleteProjectetc. 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
!isArchivedfilters). - ⇒ 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 | Yes — completeProject 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+doneOnandisArchived: 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/reopenProjectare plain project Updates (OpType.Update,entityType:'PROJECT'), modeled exactly likearchiveProject(project.actions.ts:76-100) → captured by the op-log capture effect automatically viameta. AddActionTypeenum 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/replayedcompleteProjectnever pops a dialog / fires confetti on another device. doneOnis computed at the call site (viaDateService) and passed as a prop — neverDate.now()in the reducer (Rule #4).- LWW note (accept): a concurrent remote
updateProject(e.g. rename) vs localcompleteProjectresolves by coarse whole-entity LWW — same asarchiveProjecttoday; 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: addcompleteProject({ id, doneOn })andreopenProject({ id })(mirror archive,OpType.Update). Add matchingActionTypeenum 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: addselectCompletedProjects+selectPlainArchivedProjects(see selector wiring above). LeaveselectArchivedProjectsunchanged.project.service.ts:complete(id)/reopen(id)wrappers (mirrorarchive()/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).
- Resolve unfinished tasks (only if any undone tasks across
taskIds+backlogTaskIds, incl. subtasks). Open aDialogConfirmComponent-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.)
ProjectService.complete(id)dispatchescompleteProject(reducer sets done + archived).- Celebrate (section 3).
- 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 fixd44cb1138d). - 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 bothisDisableAnimationsandisDisableCelebration(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) byisDone. - Hours worked — sum
task.timeSpentover the project's parent tasks only (a parent'stimeSpentalready includes subtasks —task.reducer.util.ts:53-72; summing both double-counts). Alternatively readTimeTrackingState.project[projectId]. - Days worked — distinct
timeSpentOnDaykeys across tasks. - Finished in N days —
startedOn→doneOncalendar span.startedOn= earliesttimeSpentOnDaykey, fallbackproject.created. This is the one stat that works with time-tracking off — feature it. worklog/util/get-time-spent-for-day.util.tsaggregates 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 ondoneOn" + 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
selectPlainArchivedProjectsto visually separate "Finished" from "Shelved".
6. Testing
- Reducer:
completeProjectsetsisDone+doneOn+isArchived:true;reopenProjectclears all three; INBOX guarded. - Regression (review-critical): completing a project keeps its (done,
dueDay-carrying) tasks out of Today/Overdue — i.e.selectArchivedProjectsstill 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 dayswith time-tracking off. - Effect: celebration effect uses
LOCAL_ACTIONS(no confetti/dialog on replayed/remotecompleteProject). - Translations:
en.jsononly, viaT. User-facing → update docs perdocs/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/selectArchivedProjectIdsconsumers (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 |