Back to Super Productivity

Project completion experience

docs/plans/2026-06-05-project-completion.md

18.10.018.6 KB
Original Source

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

#DecisionResolution
Q1Auto-archive on completeYescompleteProject also sets isArchived: true ("complete and out of the way"). This is a flag flip only — menu-hiding, no task cleanup (see correction above).
Q2Unfinished tasksPrompt (a plain confirm), default Move to Inbox with the count shown; plus "Mark them done" / "Cancel".
Q3Stats live vs. snapshotCompute live — no completionStats field. The "mandatory snapshot" reason was based on the false archive premise.
Q4Completion surfaceSplit: DialogConfirm for the unfinished-task resolve step, then a separate celebration component.
Q5Trophy viewNo new page. Add a "Completed on X" badge + live stats + Reopen to completed rows of the existing archived-projects page, and improve that page.
Q6Append/mergeDeferred → #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):

ts
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

AreaFile
Model / defaultssrc/app/features/project/project.model.ts, project.const.ts
Actions / reducer / selectorssrc/app/features/project/store/project.actions.ts, project.reducer.ts, project.selectors.ts; action-types.enum.ts
Servicesrc/app/features/project/project.service.ts
Context menu / triggersrc/app/core-ui/work-context-menu/work-context-menu.component.{ts,html}
Trophy pagesrc/app/pages/archived-projects-page/ (enhance)
Rewardsrc/app/core/confetti/confetti.service.ts; ref features/focus-mode/focus-mode-session-done/, pages/daily-summary/
Statssrc/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 dialogsrc/app/ui/dialog-confirm/dialog-confirm.component.ts
Append/merge (deferred)issue #8032