docs/plans/2026-06-05-project-completion.md
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).
Two real friction points drive this:
isArchived (project.model.ts:15, even marked // TODO remove maybe). Archiving is "shove it out of sight" — semantically the opposite of celebrating a finish.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.
isArchived flag for menu-hiding, but isDone stays a distinct flag so a celebrated finish ≠ a quiet archive.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:
!isArchived filters).| # | 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. |
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:
selectArchivedProjects = isArchived (covers completed too) → task filtering + menu-hiding stay correct, unchanged.selectCompletedProjects = isDone → highlights/filters completed rows on the trophy page.selectPlainArchivedProjects = isArchived && !isDone → page-only, if we want to visually separate "finished" from "shelved".isDone + doneOn and isArchived: false (returns to the active menu).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.
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).ARCHIVE_AFFECTING_ACTION_TYPES.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).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.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 }.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).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).
taskIds + backlogTaskIds, incl. subtasks). Open a DialogConfirmComponent-style prompt showing the count, with:
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) dispatches completeProject (reducer sets done + archived)./ (archive already does this; also clear any selected-task/detail-panel pointing at the now-hidden project — cf. recent fix d44cb1138d).reopenProject, which only clears the project flags. The fullscreen celebration is the feedback; reactivation lives on the archived-projects page.A small ProjectCompleteCelebrationComponent (dialog), reusing the layout language of focus-mode/focus-mode-session-done and the "summary-point" grid of daily-summary:
ConfettiService.createConfetti() — gate on both isDisableAnimations and isDisableCelebration (no confetti → dialog still shows).Computed on demand for the celebration and the trophy rows, from the still-live tasks:
taskIds + backlogTaskIds; decide subtask inclusion, state it consistently) by isDone.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].timeSpentOnDay keys across tasks.startedOn→doneOn 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").
Completed projects already land on /archived-projects (they're isArchived). Rather than a new page:
selectCompletedProjects), show a trophy/badge + "Completed on doneOn" + the live stats, and offer Reopen (reopenProject) instead of Unarchive.doneOn, and make it more discoverable (the celebration's "View completed projects" links here; consider a findable entry rather than only the visibility menu).selectPlainArchivedProjects to visually separate "Finished" from "Shelved".completeProject sets isDone+doneOn+isArchived:true; reopenProject clears all three; INBOX guarded.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.selectCompletedProjects = isDone; selectPlainArchivedProjects = isArchived && !isDone.finished in N days with time-tracking off.LOCAL_ACTIONS (no confetti/dialog on replayed/remote completeProject).en.json only, via T. User-facing → update docs per docs/documentation-guide.md.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).| 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 |