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>
This commit is contained in:
Johannes Millan 2026-06-08 13:43:38 +02:00 committed by GitHub
parent 59c785c72b
commit eff41c041d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
43 changed files with 2441 additions and 122 deletions

View file

@ -164,6 +164,40 @@ from PostgreSQL RepeatableRead snapshot isolation alone.
---
### 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&nbsp;#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

View file

@ -0,0 +1,182 @@
# 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](../../ARCHITECTURE-DECISIONS.md)** 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 | **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` + `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 days**`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").
### 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** |

View file

@ -58,22 +58,22 @@ Every task has a project assignment. When you create a task, it's assigned to th
When you view a project, you see all tasks that belong to it. When you view a tag, you see tasks from multiple projects that have that tag, and each task shows its project as a badge.
## Archiving Projects
## Completing Projects
When a project is complete and you no longer need it in your active workflow, you can **archive** it instead of deleting it.
When you finish with a project, you **complete** it to retire it from your active workflow and celebrate the result. Right-click the project in the sidebar (or click the `⋮` button) and choose **Complete project**.
To archive a project, right-click it in the sidebar (or click the `⋮` button) and choose **Archive project**.
If the project still has **unfinished tasks**, you're asked what to do with them first: **Move to Inbox** (the default — they stay actionable), **Mark as done**, or **Cancel**. Before the project is completed, a final confirmation asks you to confirm the action.
Archiving a project makes it fully **dormant**: the project and all its data are preserved, but it stops generating noise across the app. Specifically, an archived project:
On completion you get a full-screen **celebration** with a short summary — tasks done, time tracked, days worked, and how many days the project ran from its first worked day to completion (time-based stats are hidden when you don't track time).
A completed project becomes fully **dormant** — all its data is preserved, but it stops generating noise across the app. Specifically, it:
- Is removed from the sidebar project list and the main visibility checklist
- Has its tasks hidden from Today, Tags, Boards, Overdue, Deadline, and Scheduled views
- Has its repeating tasks suspended — no new instances are generated
- Has its reminders suppressed — no notifications fire for its tasks
All data is kept intact. Archiving is safe to use even if the project still has active tasks, open deadlines, or configured repeating tasks — everything is simply invisible until you unarchive.
To **restore** a project, click the eye icon in the Projects section header to open the visibility menu, then navigate to **Archived projects**. From there you can either click the project title to open it (an archived banner appears at the top with a one-click restore) or click the **Restore project** icon directly in the row — all tasks, repeating configs, and reminders become active again immediately. If the project was hidden from the sidebar when archived, the snack message offers a **Show in sidebar** action so it reappears in the sidebar.
**Finding and reopening** completed projects: click the eye icon in the Projects section header to open the visibility menu, then navigate to **Archived projects**, where completed projects appear with a trophy badge and their completion date. Use the **Reopen** action there to clear the completed state and bring the project back to the sidebar with all tasks, repeating configs, and reminders active again.
## Related

View file

@ -678,4 +678,33 @@ export class ProjectPage extends BasePage {
// Wait for project to be removed from sidebar
await projectTreeItem.waitFor({ state: 'hidden', timeout: 5000 });
}
/**
* Opens the right-click context menu for a project in the sidebar.
*/
async openProjectContextMenu(projectName: string): Promise<void> {
const fullProjectName = this.applyPrefix(projectName);
const projectsTree = this.page
.locator('nav-list-tree')
.filter({ hasText: 'Projects' });
// Ensure the Projects group is expanded
const projectsGroupBtn = projectsTree.locator('nav-item button').first();
const projectTreeItem = projectsTree
.locator('[role="treeitem"]')
.filter({ hasText: fullProjectName })
.first();
await projectsGroupBtn.waitFor({ state: 'visible', timeout: 5000 });
if ((await projectsGroupBtn.getAttribute('aria-expanded')) !== 'true') {
await projectsGroupBtn.click();
}
await projectTreeItem.waitFor({ state: 'visible', timeout: 5000 });
await projectTreeItem.click({ button: 'right' });
await this.page
.locator('.mat-mdc-menu-content')
.waitFor({ state: 'visible', timeout: 3000 });
}
}

View file

@ -0,0 +1,66 @@
import { expect, test } from '../../fixtures/test.fixture';
import { ProjectPage } from '../../pages/project.page';
import { WorkViewPage } from '../../pages/work-view.page';
import { expectNoGlobalError } from '../../utils/assertions';
test.describe('Project completion', () => {
let projectPage: ProjectPage;
let workViewPage: WorkViewPage;
test.beforeEach(async ({ page, testPrefix }) => {
projectPage = new ProjectPage(page, testPrefix);
workViewPage = new WorkViewPage(page, testPrefix);
await workViewPage.waitForTaskList();
});
test('complete a project and see the celebration', async ({ page }) => {
// Arrange: a project with one done and one unfinished task
await projectPage.createProject('Test Project');
await projectPage.navigateToProjectByName('Test Project');
await workViewPage.waitForTaskList();
await workViewPage.addTask('Completion task 1', true);
await workViewPage.addTask('Completion task 2');
const firstTask = page.locator('task').first();
await firstTask.hover();
const doneBtn = firstTask.locator('done-toggle');
await doneBtn.waitFor({ state: 'visible' });
await doneBtn.click();
// Act: complete the project from the sidebar context menu
await projectPage.openProjectContextMenu('Test Project');
await page
.locator('.mat-mdc-menu-content button')
.filter({ hasText: /complete project/i })
.click();
// The unfinished task triggers the resolve prompt → mark it done
const resolveDialog = page.locator('dialog-complete-resolve-tasks');
await expect(resolveDialog).toBeVisible();
await resolveDialog.getByRole('button', { name: /mark as done/i }).click();
// Confirm before final completion
const confirmDialog = page.locator('dialog-confirm');
await expect(confirmDialog).toBeVisible();
await confirmDialog.getByRole('button', { name: /complete project/i }).click();
// Celebration dialog with stats
const celebration = page.locator('dialog-project-complete');
await expect(celebration).toBeVisible();
const celebrationPanel = page.locator('.project-complete-fullscreen-dialog');
await expect(celebrationPanel).toBeVisible();
const panelBox = await celebrationPanel.boundingBox();
const viewport = page.viewportSize();
expect(panelBox?.width ?? 0).toBeGreaterThan((viewport?.width ?? 0) * 0.95);
expect(panelBox?.height ?? 0).toBeGreaterThan((viewport?.height ?? 0) * 0.95);
await expect(celebration.getByText(/project complete/i)).toBeVisible();
await expect(celebration.getByText('Test Project')).toBeVisible();
// Close the celebration. There is no reopen/undo here — completion resolves
// tasks irreversibly; reactivation lives on the archived-projects page.
await celebration.locator('.actions button').click();
await expect(celebration).toBeHidden();
await expectNoGlobalError(page);
});
});

View file

@ -30,6 +30,7 @@ import { LS } from './core/persistence/storage-keys.const';
import { BannerId } from './core/banner/banner.model';
import { T } from './t.const';
import { GlobalThemeService } from './core/theme/global-theme.service';
import { resolveBgImageToDataUrl } from './core/theme/resolve-bg-image-to-data-url.util';
import { LanguageService } from './core/language/language.service';
import { WorkContextService } from './features/work-context/work-context.service';
import { SyncTriggerService } from './imex/sync/sync-trigger.service';
@ -282,33 +283,12 @@ export class AppComponent implements OnDestroy, AfterViewInit {
effect(() => {
const bgImage = this._globalThemeService.backgroundImg();
const currentRequestId = ++bgResolveRequestId;
if (!bgImage) {
this.resolvedBgImage.set(null);
return;
}
if (!IS_ELECTRON || !bgImage.startsWith('file://')) {
this.resolvedBgImage.set(bgImage);
return;
}
const readLocalImageAsDataUrl = window.ea?.readLocalImageAsDataUrl;
if (!readLocalImageAsDataUrl) {
this.resolvedBgImage.set(null);
return;
}
readLocalImageAsDataUrl(bgImage)
.then((dataUrl) => {
if (currentRequestId === bgResolveRequestId) {
this.resolvedBgImage.set(dataUrl || null);
}
})
.catch(() => {
if (currentRequestId === bgResolveRequestId) {
this.resolvedBgImage.set(null);
}
});
void resolveBgImageToDataUrl(bgImage).then((resolved) => {
// Ignore stale resolutions when the source changed mid-read.
if (currentRequestId === bgResolveRequestId) {
this.resolvedBgImage.set(resolved);
}
});
});
this._syncTriggerService.afterInitialSyncDoneAndDataLoadedInitially$

View file

@ -74,16 +74,23 @@
(click)="restoreProject()"
mat-menu-item
>
<mat-icon>unarchive</mat-icon>
<span class="text">{{ T.F.PROJECT.ARCHIVED_PROJECTS.UNARCHIVE | translate }}</span>
<mat-icon>{{ (isDone$ | async) ? 'replay' : 'unarchive' }}</mat-icon>
<span class="text">
{{
((isDone$ | async)
? T.F.PROJECT.COMPLETE.REOPEN
: T.F.PROJECT.ARCHIVED_PROJECTS.UNARCHIVE
) | translate
}}
</span>
</button>
} @else {
<button
(click)="archiveProject()"
(click)="completeProject()"
mat-menu-item
>
<mat-icon>archive</mat-icon>
<span class="text">{{ T.MH.ARCHIVE_PROJECT | translate }}</span>
<mat-icon>check_circle</mat-icon>
<span class="text">{{ T.MH.COMPLETE_PROJECT | translate }}</span>
</button>
}
<button

View file

@ -12,6 +12,10 @@ import { ShareService } from '../../core/share/share.service';
import { Store } from '@ngrx/store';
import { TranslateModule } from '@ngx-translate/core';
import { WorkContextType } from '../../features/work-context/work-context.model';
import { DialogCompleteResolveTasksComponent } from '../../features/project/dialog-complete-resolve-tasks/dialog-complete-resolve-tasks.component';
import { DialogConfirmComponent } from '../../ui/dialog-confirm/dialog-confirm.component';
import { DateService } from '../../core/date/date.service';
import { DialogProjectCompleteComponent } from '../../features/project/dialog-project-complete/dialog-project-complete.component';
describe('WorkContextMenuComponent', () => {
let component: WorkContextMenuComponent;
@ -19,8 +23,10 @@ describe('WorkContextMenuComponent', () => {
let mockProjectService: jasmine.SpyObj<ProjectService>;
let mockWorkContextService: { activeWorkContextId: string | undefined };
let mockMatDialog: jasmine.SpyObj<MatDialog>;
let resolveResult$: any;
let confirmResult$: any;
let router: Router;
const logicalDoneOn = new Date(2026, 5, 5, 1, 0, 0).getTime();
// Finds the rendered mat-menu-item whose icon matches `iconName`.
const menuButtonByIcon = (iconName: string): HTMLButtonElement | null => {
@ -32,14 +38,29 @@ describe('WorkContextMenuComponent', () => {
beforeEach(() => {
mockProjectService = jasmine.createSpyObj('ProjectService', [
'archive',
'unarchive',
'complete',
'reopen',
'getCompletionInfo',
'moveTasksToInbox',
'markTasksDone',
'getByIdOnce$',
'getByIdLive$',
]);
mockProjectService.getByIdOnce$.and.returnValue(
of({ id: 'project-123', title: 'Demo project' } as any),
);
// Default: nothing unfinished → completion skips the resolve prompt.
mockProjectService.getCompletionInfo.and.returnValue(
Promise.resolve({
topLevelTasks: [],
allTasks: [],
unfinishedTasks: [],
topLevelTasksWithUnfinishedWork: [],
}),
);
mockProjectService.moveTasksToInbox.and.returnValue(Promise.resolve());
mockProjectService.markTasksDone.and.returnValue(Promise.resolve());
mockProjectService.getByIdLive$.and.returnValue(
of({ id: 'project-123', title: 'Demo project' } as any),
);
@ -49,10 +70,17 @@ describe('WorkContextMenuComponent', () => {
mockShareService.getShareSupport.and.returnValue(Promise.resolve('none'));
mockMatDialog = jasmine.createSpyObj('MatDialog', ['open']);
resolveResult$ = of(undefined);
confirmResult$ = of(true);
mockMatDialog.open.and.callFake(
() => ({ afterClosed: () => confirmResult$ }) as MatDialogRef<unknown>,
);
mockMatDialog.open.and.callFake((componentOrTemplateRef) => {
if (componentOrTemplateRef === DialogCompleteResolveTasksComponent) {
return { afterClosed: () => resolveResult$ } as MatDialogRef<unknown>;
}
if (componentOrTemplateRef === DialogConfirmComponent) {
return { afterClosed: () => confirmResult$ } as MatDialogRef<unknown>;
}
return { afterClosed: () => of(undefined) } as MatDialogRef<unknown>;
});
TestBed.configureTestingModule({
imports: [WorkContextMenuComponent, TranslateModule.forRoot()],
@ -69,6 +97,12 @@ describe('WorkContextMenuComponent', () => {
{ provide: WorkContextMarkdownService, useValue: {} },
{ provide: ShareService, useValue: mockShareService },
{ provide: Store, useValue: jasmine.createSpyObj('Store', ['dispatch']) },
{
provide: DateService,
useValue: {
getLogicalTodayDate: () => new Date(logicalDoneOn),
},
},
],
});
@ -80,31 +114,181 @@ describe('WorkContextMenuComponent', () => {
spyOn(router, 'navigateByUrl').and.returnValue(Promise.resolve(true));
});
describe('archiveProject()', () => {
it('archives after confirmation', async () => {
await component.archiveProject();
expect(mockMatDialog.open).toHaveBeenCalled();
expect(mockProjectService.archive).toHaveBeenCalledWith('project-123');
});
describe('completeProject()', () => {
const undoneInfo = {
topLevelTasks: [{ id: 't1', isDone: false } as any],
allTasks: [{ id: 't1', isDone: false } as any],
unfinishedTasks: [{ id: 't1', isDone: false } as any],
topLevelTasksWithUnfinishedWork: [{ id: 't1', isDone: false } as any],
};
it('navigates away when archiving the currently active project', async () => {
it('completes the project and navigates away when it is active', async () => {
mockWorkContextService.activeWorkContextId = 'project-123';
await component.archiveProject();
await component.completeProject();
expect(mockProjectService.complete).toHaveBeenCalledWith(
'project-123',
logicalDoneOn,
);
expect(router.navigateByUrl).toHaveBeenCalledWith('/');
});
it('does not navigate when archiving a non-active project', async () => {
it('does not navigate when completing a non-active project', async () => {
mockWorkContextService.activeWorkContextId = 'other-project';
await component.archiveProject();
await component.completeProject();
expect(mockProjectService.complete).toHaveBeenCalled();
expect(router.navigateByUrl).not.toHaveBeenCalled();
});
it('does nothing when the confirmation is cancelled', async () => {
it('moves unfinished tasks to the Inbox when chosen, then completes', async () => {
mockProjectService.getCompletionInfo.and.returnValue(Promise.resolve(undoneInfo));
resolveResult$ = of('inbox');
await component.completeProject();
expect(mockProjectService.moveTasksToInbox).toHaveBeenCalledWith(
undoneInfo.topLevelTasksWithUnfinishedWork,
);
expect(mockProjectService.complete).toHaveBeenCalled();
// Resolution must run BEFORE the flag flip, else the project is archived
// while its unfinished tasks are still attached.
expect(mockProjectService.moveTasksToInbox).toHaveBeenCalledBefore(
mockProjectService.complete,
);
});
it('resolves using the initially captured info, not a re-fetch', async () => {
const refreshedInfo = {
topLevelTasks: [{ id: 't2', isDone: false } as any],
allTasks: [{ id: 't2', isDone: false } as any],
unfinishedTasks: [{ id: 't2', isDone: false } as any],
topLevelTasksWithUnfinishedWork: [{ id: 't2', isDone: false } as any],
};
// First call gates the resolve prompt; the second (post-resolution) only
// feeds the celebration stats — it must NOT change which tasks get resolved.
mockProjectService.getCompletionInfo.and.returnValues(
Promise.resolve(undoneInfo),
Promise.resolve(refreshedInfo),
);
resolveResult$ = of('inbox');
await component.completeProject();
expect(mockProjectService.moveTasksToInbox).toHaveBeenCalledWith(
undoneInfo.topLevelTasksWithUnfinishedWork,
);
});
it('recomputes completion info after resolving so stats reflect the final list', async () => {
mockProjectService.getCompletionInfo.and.returnValue(Promise.resolve(undoneInfo));
resolveResult$ = of('markDone');
await component.completeProject();
// Once to gate the prompt, once after resolution for the stats.
expect(mockProjectService.getCompletionInfo).toHaveBeenCalledTimes(2);
});
it('fetches completion info only once when there is no unfinished work', async () => {
await component.completeProject();
expect(mockProjectService.getCompletionInfo).toHaveBeenCalledTimes(1);
});
it('shows an error and aborts when completion info cannot be loaded', async () => {
spyOn(console, 'error');
const snackSpy = spyOn(TestBed.inject(SnackService), 'open');
mockProjectService.getCompletionInfo.and.returnValue(
Promise.reject(new Error('archive load failed')),
);
await component.completeProject();
expect(mockProjectService.complete).not.toHaveBeenCalled();
expect(snackSpy).toHaveBeenCalled();
});
it('marks all unfinished tasks done when chosen, then completes', async () => {
mockProjectService.getCompletionInfo.and.returnValue(Promise.resolve(undoneInfo));
resolveResult$ = of('markDone');
await component.completeProject();
expect(mockProjectService.markTasksDone).toHaveBeenCalledWith(
undoneInfo.unfinishedTasks,
);
expect(mockProjectService.complete).toHaveBeenCalled();
// Resolution must run BEFORE the flag flip.
expect(mockProjectService.markTasksDone).toHaveBeenCalledBefore(
mockProjectService.complete,
);
});
it('does not move unfinished tasks when Inbox is chosen but confirmation is cancelled', async () => {
mockProjectService.getCompletionInfo.and.returnValue(Promise.resolve(undoneInfo));
resolveResult$ = of('inbox');
confirmResult$ = of(false);
mockWorkContextService.activeWorkContextId = 'project-123';
await component.archiveProject();
expect(mockProjectService.archive).not.toHaveBeenCalled();
expect(router.navigateByUrl).not.toHaveBeenCalled();
await component.completeProject();
expect(mockProjectService.complete).not.toHaveBeenCalled();
expect(
mockMatDialog.open.calls
.allArgs()
.some((args) => args[0] === DialogProjectCompleteComponent),
).toBe(false);
});
it('does not mark unfinished tasks done when chosen but confirmation is cancelled', async () => {
mockProjectService.getCompletionInfo.and.returnValue(Promise.resolve(undoneInfo));
resolveResult$ = of('markDone');
confirmResult$ = of(false);
await component.completeProject();
expect(mockProjectService.complete).not.toHaveBeenCalled();
expect(
mockMatDialog.open.calls
.allArgs()
.some((args) => args[0] === DialogProjectCompleteComponent),
).toBe(false);
});
it('aborts without completing when the unfinished-task prompt is cancelled', async () => {
mockProjectService.getCompletionInfo.and.returnValue(Promise.resolve(undoneInfo));
resolveResult$ = of(undefined);
await component.completeProject();
expect(mockProjectService.complete).not.toHaveBeenCalled();
});
it('asks for confirmation before completing', async () => {
await component.completeProject();
expect(mockMatDialog.open).toHaveBeenCalledWith(
DialogConfirmComponent,
jasmine.objectContaining({
data: jasmine.objectContaining({
message: jasmine.any(String),
}),
}),
);
expect(mockProjectService.complete).toHaveBeenCalled();
const confirmCall = mockMatDialog.open.calls
.all()
.find((call) => call.args[0] === DialogConfirmComponent);
expect((confirmCall as any)?.invocationOrder).toBeLessThan(
(mockProjectService.complete.calls.first() as any).invocationOrder,
);
});
it('opens the celebration as a fullscreen overlay', async () => {
await component.completeProject();
expect(mockMatDialog.open).toHaveBeenCalledWith(
DialogProjectCompleteComponent,
jasmine.objectContaining({
panelClass: 'project-complete-fullscreen-dialog',
ariaLabelledBy: 'project-complete-title',
}),
);
});
it('aborts without completing when confirmation is cancelled', async () => {
confirmResult$ = of(false);
await component.completeProject();
expect(mockProjectService.complete).not.toHaveBeenCalled();
});
});
@ -143,10 +327,13 @@ describe('WorkContextMenuComponent', () => {
});
describe('rendered archive/restore action', () => {
it('renders Restore (and wires it up) for an archived project', () => {
it('renders Restore (and wires it up) for an archived project', async () => {
mockProjectService.getByIdLive$.and.returnValue(
of({ id: 'project-123', isArchived: true } as any),
);
mockProjectService.getByIdOnce$.and.returnValue(
of({ id: 'project-123', isArchived: true, isDone: false } as any),
);
mockProjectService.unarchive.and.returnValue(Promise.resolve());
fixture.detectChanges();
@ -155,17 +342,41 @@ describe('WorkContextMenuComponent', () => {
expect(restoreBtn).toBeTruthy();
restoreBtn!.click();
await fixture.whenStable();
expect(mockProjectService.unarchive).toHaveBeenCalledWith('project-123');
});
it('renders Archive for a non-archived project', () => {
it('renders Reopen for a completed project', async () => {
mockProjectService.getByIdLive$.and.returnValue(
of({ id: 'project-123', isArchived: true, isDone: true } as any),
);
mockProjectService.getByIdOnce$.and.returnValue(
of({ id: 'project-123', isArchived: true, isDone: true } as any),
);
fixture.detectChanges();
const reopenBtn = menuButtonByIcon('replay');
expect(reopenBtn).toBeTruthy();
reopenBtn!.click();
await fixture.whenStable();
expect(mockProjectService.reopen).toHaveBeenCalledWith('project-123', {
id: 'project-123',
isArchived: true,
isDone: true,
} as any);
});
it('renders Complete but not Archive for a non-archived project', async () => {
mockProjectService.getByIdLive$.and.returnValue(
of({ id: 'project-123', isArchived: false } as any),
);
fixture.detectChanges();
// Archive was collapsed into Complete — it's the single retire path.
expect(menuButtonByIcon('unarchive')).toBeNull();
expect(menuButtonByIcon('archive')).toBeTruthy();
expect(menuButtonByIcon('archive')).toBeNull();
expect(menuButtonByIcon('check_circle')).toBeTruthy();
});
});
});

View file

@ -16,7 +16,20 @@ import { first, map } from 'rxjs/operators';
import { WorkContextService } from '../../features/work-context/work-context.service';
import { Router, RouterLink, RouterModule } from '@angular/router';
import { ProjectService } from '../../features/project/project.service';
import {
ProjectCompletionInfo,
ProjectService,
} from '../../features/project/project.service';
import { Project } from '../../features/project/project.model';
import {
getProjectCompletionStats,
ProjectCompletionStats,
} from '../../features/project/project-completion-stats.util';
import { DialogProjectCompleteComponent } from '../../features/project/dialog-project-complete/dialog-project-complete.component';
import {
DialogCompleteResolveTasksComponent,
ResolveUnfinishedTasksChoice,
} from '../../features/project/dialog-complete-resolve-tasks/dialog-complete-resolve-tasks.component';
import { SectionService } from '../../features/section/section.service';
import { DialogPromptComponent } from '../../ui/dialog-prompt/dialog-prompt.component';
import { MatMenuItem } from '@angular/material/menu';
@ -31,6 +44,7 @@ import { TaskSharedActions } from '../../root-store/meta/task-shared.actions';
import { TaskWithSubTasks } from '../../features/tasks/task.model';
import { firstValueFrom, Observable, of } from 'rxjs';
import { AsyncPipe } from '@angular/common';
import { DateService } from '../../core/date/date.service';
import { openWorkContextSettingsDialog } from '../../features/work-context/dialog-work-context-settings/open-work-context-settings-dialog';
@Component({
@ -52,6 +66,7 @@ export class WorkContextMenuComponent implements OnInit {
private _shareService = inject(ShareService);
private _cd = inject(ChangeDetectorRef);
private _store = inject(Store);
private _dateService = inject(DateService);
// TODO: Skipped for migration because:
// This input is used in a control flow expression (e.g. `@if` or `*ngIf`)
@ -61,6 +76,7 @@ export class WorkContextMenuComponent implements OnInit {
TODAY_TAG_ID: string = TODAY_TAG.id as string;
isForProject: boolean = true;
isArchived$: Observable<boolean> = of(false);
isDone$: Observable<boolean> = of(false);
base: string = 'project';
shareSupport: ShareSupport = 'none';
@ -76,6 +92,9 @@ export class WorkContextMenuComponent implements OnInit {
this.isArchived$ = this._projectService
.getByIdLive$(this.contextId)
.pipe(map((project) => !!project?.isArchived));
this.isDone$ = this._projectService
.getByIdLive$(this.contextId)
.pipe(map((project) => !!project?.isDone));
}
const support = await this._shareService.getShareSupport();
this._setShareSupport(support);
@ -131,37 +150,146 @@ export class WorkContextMenuComponent implements OnInit {
}
}
async archiveProject(): Promise<void> {
async completeProject(): Promise<void> {
const project = await firstValueFrom(
this._projectService.getByIdOnce$(this.contextId),
);
if (!project) {
return;
}
const isConfirmed = await firstValueFrom(
const info = await this._getCompletionInfoOrNotify();
if (!info) {
return;
}
let resolution: ResolveUnfinishedTasksChoice | undefined;
// Auto-archiving would otherwise bury live, undone work — ask first.
if (info.unfinishedTasks.length) {
resolution = await this._promptResolveUnfinishedTasks(
project.title,
info.unfinishedTasks.length,
);
if (!resolution) {
return;
}
}
if (!(await this._confirmCompletion(project.title))) {
return;
}
// Resolve unfinished work via the normal per-task actions BEFORE completing,
// so every downstream effect (issue sync, reminders, repeat-cfg) and
// per-entity conflict detection fires naturally. Completion itself is then a
// plain single-entity project flag flip.
await this._applyResolution(resolution, info);
// Recompute after resolution so the stats reflect the final task list.
let statsInfo = info;
if (resolution) {
const refreshed = await this._getCompletionInfoOrNotify();
if (!refreshed) {
return;
}
statsInfo = refreshed;
}
const doneOn = this._dateService.getLogicalTodayDate().getTime();
const stats = getProjectCompletionStats(
statsInfo.topLevelTasks,
statsInfo.allTasks,
doneOn,
);
const activeId = this._workContextService.activeWorkContextId;
this._projectService.complete(this.contextId, doneOn);
// Navigate away BEFORE opening the celebration: MatDialog's closeOnNavigation
// (default true) would otherwise dismiss the dialog the moment we leave the
// now-completed project's route.
if (activeId === this.contextId) {
await this._router.navigateByUrl('/');
}
this._openCelebrationDialog(project, stats);
}
private async _applyResolution(
resolution: ResolveUnfinishedTasksChoice | undefined,
info: ProjectCompletionInfo,
): Promise<void> {
if (resolution === 'inbox') {
await this._projectService.moveTasksToInbox(info.topLevelTasksWithUnfinishedWork);
} else if (resolution === 'markDone') {
await this._projectService.markTasksDone(info.unfinishedTasks);
}
}
private _openCelebrationDialog(project: Project, stats: ProjectCompletionStats): void {
// Fullscreen sizing lives in the .project-complete-fullscreen-dialog
// panelClass (handles dvh + mobile safe-areas); don't duplicate it here.
this._matDialog.open(DialogProjectCompleteComponent, {
restoreFocus: true,
panelClass: 'project-complete-fullscreen-dialog',
ariaLabelledBy: 'project-complete-title',
data: { project, stats },
});
}
private async _getCompletionInfoOrNotify(): Promise<ProjectCompletionInfo | null> {
try {
return await this._projectService.getCompletionInfo(this.contextId);
} catch (err) {
console.error(err);
this._snackService.open({ type: 'ERROR', msg: T.F.PROJECT.COMPLETE.ERROR });
return null;
}
}
private _promptResolveUnfinishedTasks(
title: string,
nr: number,
): Promise<ResolveUnfinishedTasksChoice | undefined> {
return firstValueFrom(
this._matDialog
.open(DialogCompleteResolveTasksComponent, {
restoreFocus: true,
data: { title, nr },
})
.afterClosed(),
);
}
private _confirmCompletion(title: string): Promise<boolean> {
return firstValueFrom(
this._matDialog
.open(DialogConfirmComponent, {
restoreFocus: true,
data: {
message: T.F.PROJECT.D_CONFIRM_ARCHIVE.MSG,
okTxt: T.F.PROJECT.D_CONFIRM_ARCHIVE.OK,
translateParams: { title: project.title },
title: T.F.PROJECT.COMPLETE.CONFIRM.TITLE,
titleIcon: 'check_circle',
message: T.F.PROJECT.COMPLETE.CONFIRM.MSG,
translateParams: { title },
okTxt: T.MH.COMPLETE_PROJECT,
},
})
.afterClosed(),
);
if (!isConfirmed) {
return;
}
const activeId = this._workContextService.activeWorkContextId;
this._projectService.archive(this.contextId);
if (activeId === this.contextId) {
await this._router.navigateByUrl('/');
}
}
async restoreProject(): Promise<void> {
await this._projectService.unarchive(this.contextId);
const project = await firstValueFrom(
this._projectService.getByIdOnce$(this.contextId),
);
if (!project) {
return;
}
if (project.isDone) {
this._projectService.reopen(this.contextId, project);
} else {
await this._projectService.unarchive(this.contextId);
}
}
async duplicateProject(): Promise<void> {

View file

@ -1,6 +1,6 @@
export interface ConfettiConfig {
particleCount?: number;
angel?: number;
angle?: number;
spread?: number;
startVelocity?: number;
decay?: number;
@ -13,6 +13,7 @@ export interface ConfettiConfig {
shapes?: (string | Shape)[];
scalar?: number;
zIndex?: number;
disableForReducedMotion?: boolean;
}
interface Shape {
@ -22,4 +23,11 @@ interface Shape {
bitmap?: ImageBitmap;
}
export type CanvasConfetti = (props: ConfettiConfig) => Promise<void> | null;
/**
* A confetti instance bound to a specific canvas (via `confetti.create`).
* `reset()` stops the animation loop and removes the auto-resize listener.
*/
export interface ConfettiInstance {
(props: ConfettiConfig): Promise<void> | null;
reset: () => void;
}

View file

@ -1,7 +1,7 @@
import { inject, Injectable } from '@angular/core';
import { GlobalConfigService } from '../../features/config/global-config.service';
import { ConfettiConfig } from './confetti.model';
import { ConfettiConfig, ConfettiInstance } from './confetti.model';
@Injectable({
providedIn: 'root',
@ -10,13 +10,40 @@ export class ConfettiService {
private readonly _configService = inject(GlobalConfigService);
async createConfetti(props: ConfettiConfig): Promise<void> {
const misc = this._configService.misc();
if (misc && misc.isDisableAnimations) {
if (this._isDisabled()) {
return;
}
const confettiModule = await import('canvas-confetti');
confettiModule.default(props);
confettiModule.default({ disableForReducedMotion: true, ...props });
}
async createConfettiOnCanvas(
canvas: HTMLCanvasElement,
props: ConfettiConfig,
): Promise<ConfettiInstance | undefined> {
if (this._isDisabled()) {
return undefined;
}
const confettiModule = await import('canvas-confetti');
const confetti: ConfettiInstance = confettiModule.default.create(canvas, {
resize: true,
});
// Fire without awaiting completion so the caller keeps the handle and can
// reset() to tear down the rAF loop + window resize listener if the dialog
// closes before the ~5s animation finishes.
void confetti({ disableForReducedMotion: true, ...props });
return confetti;
}
private _isDisabled(): boolean {
const misc = this._configService.misc();
// Honor the OS "reduce motion" setting for every confetti caller (not just
// the in-app animations toggle) — intentional, app-wide a11y behavior.
return (
!!misc?.isDisableAnimations ||
!!globalThis.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches
);
}
}

View file

@ -41,6 +41,7 @@ describe('action-type-codes', () => {
expect(encodeActionType('[Task Shared] addTask')).toBe('HA');
expect(encodeActionType('[Tag] Add Tag')).toBe('GA');
expect(encodeActionType('[Project] Add Project')).toBe('PA');
expect(encodeActionType('[Project] Complete Project')).toBe('PCO');
});
it('should return original action type for unknown action types', () => {
@ -54,6 +55,7 @@ describe('action-type-codes', () => {
expect(decodeActionType('HA')).toBe('[Task Shared] addTask');
expect(decodeActionType('GA')).toBe('[Tag] Add Tag');
expect(decodeActionType('PA')).toBe('[Project] Add Project');
expect(decodeActionType('PCO')).toBe('[Project] Complete Project');
});
it('should return code as-is for unknown codes (assumed to be full action type)', () => {

View file

@ -134,6 +134,8 @@ export const ACTION_TYPE_TO_CODE: Record<ActionType, string> = {
[ActionType.PROJECT_UPDATE_ADVANCED_CFG]: 'PC',
[ActionType.PROJECT_ARCHIVE]: 'PX',
[ActionType.PROJECT_UNARCHIVE]: 'PR',
[ActionType.PROJECT_COMPLETE]: 'PCO',
[ActionType.PROJECT_REOPEN]: 'PRO',
[ActionType.PROJECT_TOGGLE_HIDE]: 'PH',
[ActionType.PROJECT_MOVE_TASK_IN_BACKLOG]: 'PM',
[ActionType.PROJECT_MOVE_TASK_UP_BACKLOG]: 'PMU',

View file

@ -0,0 +1,24 @@
import { resolveBgImageToDataUrl } from './resolve-bg-image-to-data-url.util';
describe('resolveBgImageToDataUrl', () => {
it('returns null for empty input', async () => {
expect(await resolveBgImageToDataUrl(null)).toBeNull();
expect(await resolveBgImageToDataUrl(undefined)).toBeNull();
expect(await resolveBgImageToDataUrl('')).toBeNull();
});
it('passes through non-file URLs unchanged', async () => {
const dataUrl = 'data:image/gif;base64,R0lGODlhAQABAA==';
expect(await resolveBgImageToDataUrl(dataUrl)).toBe(dataUrl);
expect(await resolveBgImageToDataUrl('https://example.com/bg.png')).toBe(
'https://example.com/bg.png',
);
});
// Outside Electron (the unit-test environment) file:// URLs are not inlined
// and pass through unchanged; the on-disk read path only runs under Electron.
it('does not inline file:// URLs outside Electron', async () => {
const url = 'file:///home/user/bg.png';
expect(await resolveBgImageToDataUrl(url)).toBe(url);
});
});

View file

@ -0,0 +1,33 @@
import { IS_ELECTRON } from '../../app.constants';
/**
* Resolve a background-image URL to something a CSS `background` can render.
*
* On Electron a `file://` URL has to be read off disk and inlined as a data
* URL (the renderer can't load arbitrary `file://` paths); every other URL
* passes through unchanged. Returns null when there is no image or the read
* fails, so callers fall back to no background.
*
* Note: callers that react to a changing source should guard against stale
* results themselves (e.g. a request-id), since this resolves asynchronously.
*/
export const resolveBgImageToDataUrl = async (
bgImage: string | null | undefined,
): Promise<string | null> => {
if (!bgImage) {
return null;
}
if (!IS_ELECTRON || !bgImage.startsWith('file://')) {
return bgImage;
}
const readLocalImageAsDataUrl = window.ea?.readLocalImageAsDataUrl;
if (!readLocalImageAsDataUrl) {
return null;
}
try {
return (await readLocalImageAsDataUrl(bgImage)) || null;
} catch {
// A missing/unreadable file just means "no background" — fall back quietly.
return null;
}
};

View file

@ -0,0 +1,35 @@
<h2 mat-dialog-title>{{ T.MH.COMPLETE_PROJECT | translate }}</h2>
<mat-dialog-content>
<p>
<strong>{{ data.title }}</strong>
{{
(data.nr === 1
? T.F.PROJECT.COMPLETE.RESOLVE.MSG_ONE
: T.F.PROJECT.COMPLETE.RESOLVE.MSG
) | translate: { nr: data.nr }
}}
</p>
</mat-dialog-content>
<mat-dialog-actions align="end">
<button
mat-button
(click)="cancel()"
>
{{ T.G.CANCEL | translate }}
</button>
<button
mat-button
(click)="markDone()"
>
{{ T.F.PROJECT.COMPLETE.RESOLVE.MARK_DONE | translate }}
</button>
<button
mat-flat-button
color="primary"
(click)="moveToInbox()"
>
{{ T.F.PROJECT.COMPLETE.RESOLVE.MOVE_TO_INBOX | translate }}
</button>
</mat-dialog-actions>

View file

@ -0,0 +1,46 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import {
MAT_DIALOG_DATA,
MatDialogActions,
MatDialogContent,
MatDialogRef,
MatDialogTitle,
} from '@angular/material/dialog';
import { MatButton } from '@angular/material/button';
import { TranslatePipe } from '@ngx-translate/core';
import { T } from '../../../t.const';
export interface DialogCompleteResolveTasksData {
title: string;
nr: number;
}
export type ResolveUnfinishedTasksChoice = 'inbox' | 'markDone';
@Component({
selector: 'dialog-complete-resolve-tasks',
templateUrl: './dialog-complete-resolve-tasks.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [MatDialogTitle, MatDialogContent, MatDialogActions, MatButton, TranslatePipe],
})
export class DialogCompleteResolveTasksComponent {
private readonly _matDialogRef =
inject<
MatDialogRef<DialogCompleteResolveTasksComponent, ResolveUnfinishedTasksChoice>
>(MatDialogRef);
readonly data = inject<DialogCompleteResolveTasksData>(MAT_DIALOG_DATA);
readonly T: typeof T = T;
cancel(): void {
this._matDialogRef.close(undefined);
}
moveToInbox(): void {
this._matDialogRef.close('inbox');
}
markDone(): void {
this._matDialogRef.close('markDone');
}
}

View file

@ -0,0 +1,105 @@
<section
class="complete-overlay"
[class.is-disable-background-tint]="isDisableBackgroundTint()"
[style.--project-complete-primary]="projectPrimaryColor()"
>
@if (resolvedBgImage(); as bgImage) {
<div
class="project-bg-image"
[class.is-blurred]="backgroundImageBlur() > 0"
[style.background]="'url(' + bgImage + ')'"
[style.filter]="backgroundImageBlurFilter()"
></div>
<div
class="project-bg-overlay"
[style.opacity]="backgroundOverlayOpacity()"
></div>
}
<canvas
#confettiCanvas
class="confetti-canvas"
aria-hidden="true"
></canvas>
<button
mat-icon-button
class="close-btn"
[attr.aria-label]="T.G.CLOSE | translate"
[matTooltip]="T.G.CLOSE | translate"
(click)="close()"
>
<mat-icon>close</mat-icon>
</button>
<div class="celebration-content">
<header class="hero">
<div class="trophy">
<mat-icon>emoji_events</mat-icon>
</div>
<h2
id="project-complete-title"
class="complete-title"
>
{{ T.F.PROJECT.COMPLETE.CELEBRATION.TITLE | translate }}
</h2>
<p class="project-title">{{ data.project.title }}</p>
</header>
<div class="stats-grid">
<div class="stat">
<span class="stat-value"
>{{ data.stats.nrOfTasksDone }}/{{ data.stats.nrOfTasksTotal }}</span
>
<span class="stat-label">{{
T.F.PROJECT.COMPLETE.CELEBRATION.TASKS_DONE | translate
}}</span>
</div>
@if (data.stats.timeSpent > 0) {
<div class="stat">
<span class="stat-value">{{ data.stats.timeSpent | msToString }}</span>
<span class="stat-label">{{
T.F.PROJECT.COMPLETE.CELEBRATION.TIME_SPENT | translate
}}</span>
</div>
<div class="stat">
<span class="stat-value">{{ data.stats.nrOfDaysWorked }}</span>
<span class="stat-label">{{
T.F.PROJECT.COMPLETE.CELEBRATION.DAYS_WORKED | translate
}}</span>
</div>
}
</div>
<div class="details">
@if (data.stats.durationDays > 0) {
<p class="duration">
{{
T.F.PROJECT.COMPLETE.CELEBRATION.DURATION
| translate: { nr: data.stats.durationDays }
}}
</p>
}
@if (data.stats.startedOn) {
<p class="date-range">
{{ T.F.PROJECT.COMPLETE.CELEBRATION.STARTED | translate }}:
{{ data.stats.startedOn | date }} ·
{{ T.F.PROJECT.COMPLETE.CELEBRATION.FINISHED | translate }}:
{{ data.stats.doneOn | date }}
</p>
}
</div>
<div class="actions">
<button
mat-flat-button
color="primary"
(click)="close()"
>
{{ T.F.PROJECT.COMPLETE.CELEBRATION.DONE_BTN | translate }}
</button>
</div>
</div>
</section>

View file

@ -0,0 +1,272 @@
:host {
display: block;
width: 100%;
height: 100%;
overflow: hidden;
}
.complete-overlay {
--project-complete-primary-color: var(
--project-complete-primary,
var(--palette-primary-500)
);
position: relative;
display: flex;
width: 100%;
height: 100%;
min-height: 0;
overflow: hidden;
color: var(--text-color-most-intense);
background: var(--bg);
&::before {
position: absolute;
inset: 0;
z-index: 0;
display: block;
pointer-events: none;
background:
radial-gradient(
ellipse 120% 80% at 0% -10%,
color-mix(in srgb, var(--project-complete-primary-color) 22%, transparent) 0%,
transparent 65%
),
radial-gradient(
ellipse 90% 70% at 100% 100%,
color-mix(in srgb, var(--project-complete-primary-color) 10%, transparent) 0%,
transparent 60%
);
content: '';
}
&.is-disable-background-tint::before {
display: none;
}
}
.project-bg-image,
.project-bg-overlay {
position: absolute;
inset: 0;
z-index: 0;
pointer-events: none;
}
.project-bg-image {
width: 100%;
height: 100%;
background-size: cover !important;
background-position: center !important;
background-attachment: fixed;
&.is-blurred {
transform: scale(1.12);
}
}
.project-bg-overlay {
background-color: var(--c-bg-overlay);
}
.confetti-canvas {
position: absolute;
inset: 0;
z-index: 2;
width: 100%;
height: 100%;
pointer-events: none;
}
.close-btn {
position: absolute;
z-index: 3;
top: calc(var(--safe-area-top, 0px) + var(--s2));
right: calc(var(--safe-area-right, 0px) + var(--s2));
background: var(--bg-lightest);
box-shadow: var(--whiteframe-shadow-1dp);
}
.celebration-content {
position: relative;
z-index: 1;
display: flex;
flex: 1 1 auto;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 0;
overflow: auto;
gap: var(--s4);
padding: calc(var(--safe-area-top, 0px) + var(--s7))
calc(var(--safe-area-right, 0px) + var(--s3))
calc(var(--safe-area-bottom, 0px) + var(--s5))
calc(var(--safe-area-left, 0px) + var(--s3));
}
.hero {
display: flex;
flex-direction: column;
align-items: center;
width: min(720px, 100%);
text-align: center;
}
.trophy {
display: grid;
place-items: center;
width: 104px;
height: 104px;
margin-bottom: var(--s2);
color: var(--project-complete-primary-color);
background: var(--bg-lightest);
border: 1px solid var(--extra-border-color);
border-radius: 50%;
box-shadow: var(--whiteframe-shadow-4dp);
mat-icon {
width: 56px;
height: 56px;
font-size: 56px;
line-height: 56px;
}
}
.complete-title {
margin: 0;
font-size: 44px;
font-weight: 800;
line-height: 1.05;
}
.project-title {
max-width: 100%;
margin: var(--s2) 0 0;
color: var(--text-color-muted);
font-size: 18px;
font-weight: 500;
line-height: 1.35;
overflow-wrap: anywhere;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(144px, 1fr));
width: min(720px, 100%);
gap: calc(var(--s) + var(--s-half));
}
.stat {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 112px;
padding: var(--s2);
background: var(--bg-lightest);
border: 1px solid var(--extra-border-color);
border-radius: 8px;
}
.stat-value {
max-width: 100%;
color: var(--text-color-most-intense);
font-size: 32px;
font-weight: bold;
line-height: 1.1;
overflow-wrap: anywhere;
}
.stat-label {
margin-top: var(--s);
color: var(--text-color-muted);
font-size: 13px;
line-height: 1.3;
text-align: center;
}
.details {
width: min(720px, 100%);
}
.duration {
text-align: center;
font-weight: 500;
margin: 0;
}
.date-range {
color: var(--text-color-muted);
text-align: center;
font-size: 13px;
line-height: 1.4;
margin: var(--s) 0 0;
}
.actions {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: calc(var(--s) + var(--s-half));
button {
min-width: 164px;
}
}
@media (max-width: 599px) {
.close-btn {
top: calc(var(--safe-area-top, 0px) + var(--s));
right: calc(var(--safe-area-right, 0px) + var(--s));
}
.celebration-content {
justify-content: flex-start;
gap: calc(var(--s2) + var(--s-half));
padding: calc(var(--safe-area-top, 0px) + var(--s9))
calc(var(--safe-area-right, 0px) + var(--s2))
calc(var(--safe-area-bottom, 0px) + var(--s3))
calc(var(--safe-area-left, 0px) + var(--s2));
}
.trophy {
width: 84px;
height: 84px;
margin-bottom: var(--s2);
mat-icon {
width: 46px;
height: 46px;
font-size: 46px;
line-height: 46px;
}
}
.complete-title {
font-size: 32px;
}
.project-title {
font-size: 16px;
}
.stats-grid {
grid-template-columns: 1fr;
}
.stat {
min-height: 92px;
}
.stat-value {
font-size: 28px;
}
.actions {
width: 100%;
button {
width: 100%;
}
}
}

View file

@ -0,0 +1,139 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { WritableSignal, signal } from '@angular/core';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { provideRouter } from '@angular/router';
import { By } from '@angular/platform-browser';
import { TranslateModule } from '@ngx-translate/core';
import {
DialogProjectCompleteComponent,
DialogProjectCompleteData,
} from './dialog-project-complete.component';
import { ConfettiService } from '../../../core/confetti/confetti.service';
import { ConfettiInstance } from '../../../core/confetti/confetti.model';
import { GlobalConfigService } from '../../config/global-config.service';
import { createProject } from '../project.test-helper';
import { GlobalThemeService } from '../../../core/theme/global-theme.service';
describe('DialogProjectCompleteComponent', () => {
let fixture: ComponentFixture<DialogProjectCompleteComponent>;
let component: DialogProjectCompleteComponent;
let confettiService: jasmine.SpyObj<ConfettiService>;
let dialogRef: jasmine.SpyObj<MatDialogRef<DialogProjectCompleteComponent>>;
let misc: { isDisableCelebration?: boolean; isDisableAnimations?: boolean };
let isDarkTheme: WritableSignal<boolean>;
let data: DialogProjectCompleteData;
beforeEach(() => {
misc = { isDisableCelebration: false, isDisableAnimations: false };
isDarkTheme = signal(false);
data = {
project: createProject({
id: 'project-1',
title: 'Completed Project',
theme: {
primary: '#123456',
backgroundImageLight:
'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw==',
backgroundImageDark:
'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw==',
backgroundOverlayOpacity: 65,
backgroundImageBlur: 4,
},
}),
stats: {
nrOfTasksDone: 2,
nrOfTasksTotal: 2,
timeSpent: 0,
nrOfDaysWorked: 0,
startedOn: null,
doneOn: new Date(2026, 5, 5).getTime(),
durationDays: 0,
},
};
confettiService = jasmine.createSpyObj('ConfettiService', ['createConfettiOnCanvas']);
confettiService.createConfettiOnCanvas.and.returnValue(Promise.resolve(undefined));
dialogRef = jasmine.createSpyObj('MatDialogRef', ['close']);
TestBed.configureTestingModule({
imports: [DialogProjectCompleteComponent, TranslateModule.forRoot()],
providers: [
provideRouter([]),
{ provide: MAT_DIALOG_DATA, useValue: data },
{ provide: MatDialogRef, useValue: dialogRef },
{ provide: ConfettiService, useValue: confettiService },
{ provide: GlobalConfigService, useValue: { misc: () => misc } },
{ provide: GlobalThemeService, useValue: { isDarkTheme } },
],
});
fixture = TestBed.createComponent(DialogProjectCompleteComponent);
component = fixture.componentInstance;
});
it('creates confetti on the component canvas', async () => {
fixture.detectChanges();
await fixture.whenStable();
expect(confettiService.createConfettiOnCanvas).toHaveBeenCalledWith(
jasmine.any(HTMLCanvasElement),
jasmine.objectContaining({ particleCount: 160 }),
);
});
it('does not create confetti when celebration is disabled', async () => {
misc.isDisableCelebration = true;
fixture.detectChanges();
await fixture.whenStable();
expect(confettiService.createConfettiOnCanvas).not.toHaveBeenCalled();
});
it('resets confetti that finishes loading after the dialog is destroyed', async () => {
const resetSpy = jasmine.createSpy('reset');
const instance: ConfettiInstance = Object.assign(() => Promise.resolve(), {
reset: resetSpy,
});
confettiService.createConfettiOnCanvas.and.returnValue(Promise.resolve(instance));
fixture.detectChanges(); // kicks off ngAfterViewInit's async confetti load
component.ngOnDestroy(); // dialog closed before the load resolves
await fixture.whenStable();
expect(resetSpy).toHaveBeenCalled();
});
it('uses the completed project background image and theme values', async () => {
fixture.detectChanges();
await fixture.whenStable();
// Background image now resolves asynchronously; flush the resolved signal
// into the DOM before asserting.
fixture.detectChanges();
const nativeElement = fixture.nativeElement as HTMLElement;
const overlay = nativeElement.querySelector('.complete-overlay') as HTMLElement;
const bgImage = nativeElement.querySelector('.project-bg-image') as HTMLElement;
const bgOverlay = nativeElement.querySelector('.project-bg-overlay') as HTMLElement;
expect(overlay.style.getPropertyValue('--project-complete-primary')).toBe('#123456');
expect(bgImage.style.background).toContain('data:image/gif');
expect(bgImage.style.filter).toBe('blur(4px)');
expect(bgImage.classList).toContain('is-blurred');
expect(bgOverlay.style.opacity).toBe('0.65');
});
it('closes the dialog', () => {
component.close();
expect(dialogRef.close).toHaveBeenCalled();
});
it('renders only a close action', () => {
fixture.detectChanges();
const actionButtons = fixture.debugElement.queryAll(By.css('.actions button'));
expect(actionButtons.length).toBe(1);
});
});

View file

@ -0,0 +1,138 @@
import {
AfterViewInit,
ChangeDetectionStrategy,
Component,
ElementRef,
OnDestroy,
ViewChild,
computed,
effect,
inject,
signal,
} from '@angular/core';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { DatePipe } from '@angular/common';
import { MatButton, MatIconButton } from '@angular/material/button';
import { MatIcon } from '@angular/material/icon';
import { TranslatePipe } from '@ngx-translate/core';
import { T } from '../../../t.const';
import { Project } from '../project.model';
import { ProjectCompletionStats } from '../project-completion-stats.util';
import { ConfettiService } from '../../../core/confetti/confetti.service';
import { ConfettiInstance } from '../../../core/confetti/confetti.model';
import { GlobalConfigService } from '../../config/global-config.service';
import { MsToStringPipe } from '../../../ui/duration/ms-to-string.pipe';
import { MatTooltip } from '@angular/material/tooltip';
import { GlobalThemeService } from '../../../core/theme/global-theme.service';
import { resolveBgImageToDataUrl } from '../../../core/theme/resolve-bg-image-to-data-url.util';
import { normalizeBackgroundImageBlur } from '../../work-context/work-context.const';
export interface DialogProjectCompleteData {
project: Project;
stats: ProjectCompletionStats;
}
@Component({
selector: 'dialog-project-complete',
templateUrl: './dialog-project-complete.component.html',
styleUrls: ['./dialog-project-complete.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
MatButton,
MatIconButton,
MatIcon,
MatTooltip,
DatePipe,
TranslatePipe,
MsToStringPipe,
],
})
export class DialogProjectCompleteComponent implements AfterViewInit, OnDestroy {
private readonly _matDialogRef =
inject<MatDialogRef<DialogProjectCompleteComponent>>(MatDialogRef);
private readonly _confettiService = inject(ConfettiService);
private readonly _configService = inject(GlobalConfigService);
private readonly _globalThemeService = inject(GlobalThemeService);
readonly data = inject<DialogProjectCompleteData>(MAT_DIALOG_DATA);
readonly T: typeof T = T;
readonly resolvedBgImage = signal<string | null>(null);
readonly isDisableBackgroundTint = computed(
() => !!this.data.project.theme?.isDisableBackgroundTint,
);
readonly projectPrimaryColor = computed(() => this.data.project.theme?.primary ?? null);
readonly backgroundOverlayOpacity = computed(
() => (this.data.project.theme?.backgroundOverlayOpacity ?? 20) * 0.01,
);
readonly backgroundImageBlur = computed(() =>
normalizeBackgroundImageBlur(this.data.project.theme?.backgroundImageBlur),
);
readonly backgroundImageBlurFilter = computed(() => {
const blur = this.backgroundImageBlur();
return blur > 0 ? `blur(${blur}px)` : 'none';
});
private readonly _backgroundImage = computed(() => {
const theme = this.data.project.theme;
return (
(this._globalThemeService.isDarkTheme()
? theme?.backgroundImageDark
: theme?.backgroundImageLight) || null
);
});
private _bgResolveRequestId = 0;
private _confettiInstance?: ConfettiInstance;
private _isDestroyed = false;
@ViewChild('confettiCanvas')
private readonly _confettiCanvas?: ElementRef<HTMLCanvasElement>;
constructor() {
effect(() => {
const bgImage = this._backgroundImage();
const currentRequestId = ++this._bgResolveRequestId;
void resolveBgImageToDataUrl(bgImage).then((resolved) => {
// Ignore stale resolutions when the source changed mid-read.
if (currentRequestId === this._bgResolveRequestId) {
this.resolvedBgImage.set(resolved);
}
});
});
}
async ngAfterViewInit(): Promise<void> {
// ConfettiService already honors isDisableAnimations; also respect the
// dedicated celebration toggle (the dialog still shows, just without confetti).
if (this._configService.misc()?.isDisableCelebration) {
return;
}
const canvas = this._confettiCanvas?.nativeElement;
if (!canvas) {
return;
}
const instance = await this._confettiService.createConfettiOnCanvas(canvas, {
particleCount: 160,
startVelocity: 45,
spread: 360,
ticks: 320,
origin: { x: 0.5, y: 0.35 },
});
// The dialog may have closed while the confetti module was still loading —
// tear the instance down right away instead of leaking its rAF loop.
if (this._isDestroyed) {
instance?.reset();
return;
}
this._confettiInstance = instance;
}
ngOnDestroy(): void {
// Tear down the confetti rAF loop + resize listener if the dialog closes
// before the ~5s animation finishes (otherwise it draws to a detached canvas).
this._isDestroyed = true;
this._confettiInstance?.reset();
}
close(): void {
this._matDialogRef.close();
}
}

View file

@ -0,0 +1,81 @@
import { getProjectCompletionStats } from './project-completion-stats.util';
import { Task } from '../tasks/task.model';
const task = (p: Partial<Task>): Task => ({ ...p }) as Task;
// Computed keys avoid the object-literal naming-convention lint rule on date strings.
const DAY_1 = '2026-06-01';
const DAY_2 = '2026-06-02';
const DAY_3 = '2026-06-03';
const DAY_4 = '2026-06-04';
const DAY_5 = '2026-06-05';
describe('getProjectCompletionStats', () => {
it('returns zeros for an empty project', () => {
const stats = getProjectCompletionStats([], [], 1000);
expect(stats.nrOfTasksTotal).toBe(0);
expect(stats.nrOfTasksDone).toBe(0);
expect(stats.timeSpent).toBe(0);
expect(stats.nrOfDaysWorked).toBe(0);
expect(stats.startedOn).toBeNull();
expect(stats.durationDays).toBe(0);
});
it('counts done/total over top-level tasks only', () => {
const top = [
task({ isDone: true, timeSpent: 0, timeSpentOnDay: {} }),
task({ isDone: false, timeSpent: 0, timeSpentOnDay: {} }),
task({ isDone: true, timeSpent: 0, timeSpentOnDay: {} }),
];
const stats = getProjectCompletionStats(top, top, 1000);
expect(stats.nrOfTasksTotal).toBe(3);
expect(stats.nrOfTasksDone).toBe(2);
});
it('sums time over top-level tasks only (no subtask double-count)', () => {
const parent = task({ isDone: true, timeSpent: 5000, timeSpentOnDay: {} });
const subTask = task({ isDone: true, timeSpent: 2000, timeSpentOnDay: {} });
// allTasks includes the subtask, but timeSpent must come from top-level only.
const stats = getProjectCompletionStats([parent], [parent, subTask], 1000);
expect(stats.timeSpent).toBe(5000);
});
it('unions worked days across parents and subtasks, ignoring zero days', () => {
const parent = task({
isDone: true,
timeSpent: 10,
timeSpentOnDay: { [DAY_1]: 10, [DAY_2]: 0 },
});
const subTask = task({
isDone: true,
timeSpent: 5,
timeSpentOnDay: { [DAY_3]: 5 },
});
const stats = getProjectCompletionStats([parent], [parent, subTask], Date.now());
// DAY_1 and DAY_3 have time; DAY_2 is zero → excluded.
expect(stats.nrOfDaysWorked).toBe(2);
});
it('computes calendar duration inclusive of first and last day', () => {
const parent = task({
isDone: true,
timeSpent: 10,
timeSpentOnDay: { [DAY_1]: 10, [DAY_4]: 10 },
});
const doneOn = new Date(2026, 5, 5, 14, 0, 0).getTime(); // 2026-06-05
const stats = getProjectCompletionStats([parent], [parent], doneOn);
// started 06-01 → done 06-05 inclusive = 5 days.
expect(stats.durationDays).toBe(5);
});
it('reports a single-day project as duration 1', () => {
const parent = task({
isDone: true,
timeSpent: 10,
timeSpentOnDay: { [DAY_5]: 10 },
});
const doneOn = new Date(2026, 5, 5, 18, 0, 0).getTime();
const stats = getProjectCompletionStats([parent], [parent], doneOn);
expect(stats.durationDays).toBe(1);
});
});

View file

@ -0,0 +1,67 @@
import { Task } from '../tasks/task.model';
import { getDiffInDays } from '../../util/get-diff-in-days';
import { dateStrToUtcDate } from '../../util/date-str-to-utc-date';
export interface ProjectCompletionStats {
nrOfTasksDone: number;
nrOfTasksTotal: number;
/** Sum of tracked time in ms (0 when time tracking is unused). */
timeSpent: number;
nrOfDaysWorked: number;
/** Local-midnight ms of the earliest worked day, or null if never worked. */
startedOn: number | null;
doneOn: number;
/** Calendar days from first worked day to completion, inclusive (0 if never worked). */
durationDays: number;
}
/**
* Live completion stats for the celebration + trophy view.
*
* Computed from the still-live store (completing a project only sets a flag it
* does NOT move tasks to the archive store), so this is accurate at completion
* time. It can drift if tasks are later deleted/manually-archived an accepted
* tradeoff of computing live instead of snapshotting.
*
* @param topLevelTasks the project's parent tasks (taskIds + backlogTaskIds)
* @param allTasks parents + subtasks used only to union worked-day keys
* @param doneOn completion timestamp (ms)
*/
export const getProjectCompletionStats = (
topLevelTasks: Task[],
allTasks: Task[],
doneOn: number,
): ProjectCompletionStats => {
const nrOfTasksTotal = topLevelTasks.length;
const nrOfTasksDone = topLevelTasks.filter((t) => t.isDone).length;
// A parent's timeSpent already aggregates its subtasks, so sum top-level only
// — summing subtasks too would double-count.
const timeSpent = topLevelTasks.reduce((acc, t) => acc + (t.timeSpent || 0), 0);
const workedDays = new Set<string>();
allTasks.forEach((t) => {
Object.keys(t.timeSpentOnDay || {}).forEach((dayStr) => {
if ((t.timeSpentOnDay[dayStr] || 0) > 0) {
workedDays.add(dayStr);
}
});
});
const sortedDays = Array.from(workedDays).sort();
const nrOfDaysWorked = sortedDays.length;
// timeSpentOnDay keys are 'YYYY-MM-DD'. dateStrToUtcDate parses them as LOCAL
// midnight (avoids the UTC-midnight day-shift in non-UTC zones); getDiffInDays
// rounds the calendar-day delta (DST-safe).
const startedOn = nrOfDaysWorked ? dateStrToUtcDate(sortedDays[0]).getTime() : null;
const durationDays =
startedOn !== null ? getDiffInDays(new Date(startedOn), new Date(doneOn)) + 1 : 0;
return {
nrOfTasksDone,
nrOfTasksTotal,
timeSpent,
nrOfDaysWorked,
startedOn,
doneOn,
durationDays,
};
};

View file

@ -11,6 +11,8 @@ export const _MISSING_PROJECT_ = 'missing project';
export const DEFAULT_PROJECT: Project = {
isHiddenFromMenu: false,
isArchived: false,
isDone: false,
doneOn: null,
isEnableBacklog: false,
backlogTaskIds: [],
noteIds: [],

View file

@ -13,6 +13,11 @@ export interface ProjectBasicCfg {
title: string;
// TODO remove maybe
isArchived?: boolean;
// Completed projects are a celebrated finish; completing also sets isArchived
// (so the project hides from the active menu), but isDone stays distinct so a
// finish can be told apart from a quietly-shelved archive.
isDone?: boolean;
doneOn?: number | null;
isHiddenFromMenu?: boolean;
isEnableBacklog?: boolean;
taskIds: string[];

View file

@ -3,7 +3,7 @@ import { ProjectService } from './project.service';
import { MockStore, provideMockStore } from '@ngrx/store/testing';
import { MatDialog } from '@angular/material/dialog';
import { selectTaskFeatureState } from '../tasks/store/task.selectors';
import { TaskState } from '../tasks/task.model';
import { Task, TaskState } from '../tasks/task.model';
import { TaskService } from '../tasks/task.service';
import { Store, StoreModule } from '@ngrx/store';
import { createProject } from './project.test-helper';
@ -88,6 +88,11 @@ describe('ProjectService', () => {
taskService = jasmine.createSpyObj('TaskService', [
'add',
'createNewTaskWithDefaults',
'getByIdWithSubTaskData$',
'moveToProject',
'setDone',
'setUnDone',
'getAllTasksForProject',
]);
taskService.createNewTaskWithDefaults.and.callFake(() => {
taskCounter++;
@ -96,6 +101,13 @@ describe('ProjectService', () => {
title: `New Task ${taskCounter}`,
});
});
taskService.getAllTasksForProject.and.callFake((projectId: string) =>
Promise.resolve(
Object.values(initialTaskState.entities).filter(
(task): task is Task => task?.projectId === projectId,
),
),
);
workContextService = jasmine.createSpyObj('WorkContextService', [
'getWorkContextById$',
'onWorkContextChange$',
@ -288,19 +300,6 @@ describe('ProjectService', () => {
}));
});
describe('archive', () => {
it('dispatches archiveProject and opens a plain snack', () => {
const dispatchSpy = spyOn(store, 'dispatch').and.callThrough();
service.archive('project-1');
const types = dispatchSpy.calls.allArgs().map((args: any) => args[0]?.type);
expect(types).toContain('[Project] Archive Project');
expect(snackService.open).toHaveBeenCalledWith({
ico: 'archive',
msg: T.F.PROJECT.S.ARCHIVED,
});
});
});
describe('unarchive', () => {
it('dispatches unarchiveProject and shows a plain restore snack', async () => {
const dispatchSpy = spyOn(store, 'dispatch').and.callThrough();
@ -353,4 +352,195 @@ describe('ProjectService', () => {
});
});
});
describe('complete', () => {
it('dispatches the plain completeProject project action', () => {
const dispatchSpy = spyOn(store, 'dispatch').and.callThrough();
service.complete('project-1', 12345);
const completeAction = dispatchSpy.calls
.allArgs()
.map((args: any) => args[0])
.find((a: any) => a?.type === '[Project] Complete Project');
expect(completeAction).toBeTruthy();
expect(completeAction.id).toBe('project-1');
expect(completeAction.doneOn).toBe(12345);
});
it('does not show an undo snack (completion is not reversible)', () => {
service.complete('project-1', 1);
expect(snackService.open).not.toHaveBeenCalled();
});
});
describe('reopen', () => {
it('dispatches reopenProject and shows a snack', () => {
const dispatchSpy = spyOn(store, 'dispatch').and.callThrough();
service.reopen('project-1');
const types = dispatchSpy.calls.allArgs().map((args: any) => args[0]?.type);
expect(types).toContain('[Project] Reopen Project');
expect(snackService.open).toHaveBeenCalledWith(
jasmine.objectContaining({ msg: T.F.PROJECT.S.REOPENED }),
);
});
it('offers to show the project in the menu when reopening a hidden project', () => {
const dispatchSpy = spyOn(store, 'dispatch').and.callThrough();
service.reopen('project-1', { isHiddenFromMenu: true });
const snackArg = snackService.open.calls.mostRecent().args[0] as any;
expect(snackArg.actionStr).toBe(T.F.PROJECT.S.SHOW_IN_MENU);
dispatchSpy.calls.reset();
snackArg.actionFn();
const types = dispatchSpy.calls.allArgs().map((args: any) => args[0]?.type);
expect(types).toContain('[Project] Toggle hide from menu');
});
});
describe('getCompletionInfo', () => {
beforeEach(() => {
store.setState({
projects: {
ids: ['project-1'],
entities: {
/* eslint-disable @typescript-eslint/naming-convention */
'project-1': createProject({
id: 'project-1',
title: 'Project 1',
taskIds: ['task-1', 'task-2'],
}),
/* eslint-enable @typescript-eslint/naming-convention */
},
},
});
});
it('returns top-level tasks, all tasks incl. subtasks, and unfinished tasks', async () => {
const info = await service.getCompletionInfo('project-1');
expect(info.topLevelTasks.map((t) => t.id)).toEqual(['task-1', 'task-2']);
// task-1 has sub-task-1 → included in allTasks, after its parent
expect(info.allTasks.map((t) => t.id)).toEqual(['task-1', 'sub-task-1', 'task-2']);
expect(info.unfinishedTasks.map((t) => t.id)).toEqual([
'task-1',
'sub-task-1',
'task-2',
]);
expect(info.topLevelTasksWithUnfinishedWork.map((t) => t.id)).toEqual([
'task-1',
'task-2',
]);
});
it('keeps a done parent with an unfinished subtask in topLevelTasksWithUnfinishedWork', async () => {
store.overrideSelector(selectTaskFeatureState, {
...initialTaskState,
entities: {
...initialTaskState.entities,
/* eslint-disable-next-line @typescript-eslint/naming-convention */
'task-1': { ...initialTaskState.entities['task-1'], isDone: true } as any,
},
});
store.refreshState();
const info = await service.getCompletionInfo('project-1');
expect(info.unfinishedTasks.map((t) => t.id)).toEqual(['sub-task-1', 'task-2']);
expect(info.topLevelTasksWithUnfinishedWork.map((t) => t.id)).toEqual([
'task-1',
'task-2',
]);
});
it('includes archived project tasks in stats lists without resolving them as unfinished work', async () => {
const archivedParent = createTask({
id: 'archived-task',
title: 'Archived Task',
projectId: 'project-1',
isDone: true,
subTaskIds: ['archived-sub-task'],
});
const archivedSubTask = createTask({
id: 'archived-sub-task',
title: 'Archived Sub Task',
projectId: 'project-1',
parentId: 'archived-task',
isDone: false,
});
taskService.getAllTasksForProject.and.returnValue(
Promise.resolve([
initialTaskState.entities['task-1']!,
initialTaskState.entities['sub-task-1']!,
initialTaskState.entities['task-2']!,
archivedParent,
archivedSubTask,
]),
);
const info = await service.getCompletionInfo('project-1');
expect(info.topLevelTasks.map((t) => t.id)).toEqual([
'task-1',
'task-2',
'archived-task',
]);
expect(info.allTasks.map((t) => t.id)).toEqual([
'task-1',
'sub-task-1',
'task-2',
'archived-task',
'archived-sub-task',
]);
expect(info.unfinishedTasks.map((t) => t.id)).toEqual([
'task-1',
'sub-task-1',
'task-2',
]);
expect(info.topLevelTasksWithUnfinishedWork.map((t) => t.id)).toEqual([
'task-1',
'task-2',
]);
});
});
describe('resolve unfinished completion tasks', () => {
it('moves top-level task trees with unfinished work to the Inbox', async () => {
const task = { ...initialTaskState.entities['task-1']!, isDone: true };
const taskWithSubTasks = {
...task,
subTasks: [initialTaskState.entities['sub-task-1']!],
};
taskService.getByIdWithSubTaskData$.and.returnValue(of(taskWithSubTasks as any));
await service.moveTasksToInbox([task]);
expect(taskService.getByIdWithSubTaskData$).toHaveBeenCalledWith('task-1');
expect(taskService.moveToProject).toHaveBeenCalledWith(
taskWithSubTasks as any,
'INBOX_PROJECT',
);
expect(taskService.setUnDone).toHaveBeenCalledWith('task-1');
});
it('does not re-open an unfinished task moved to the Inbox', async () => {
const task = { ...initialTaskState.entities['task-1']!, isDone: false };
taskService.getByIdWithSubTaskData$.and.returnValue(
of({ ...task, subTasks: [] } as any),
);
await service.moveTasksToInbox([task]);
expect(taskService.moveToProject).toHaveBeenCalled();
expect(taskService.setUnDone).not.toHaveBeenCalled();
});
it('marks every unfinished task done, including subtasks', async () => {
const parent = initialTaskState.entities['task-1']!;
const subTask = initialTaskState.entities['sub-task-1']!;
await service.markTasksDone([parent, subTask]);
expect(taskService.setDone).toHaveBeenCalledWith('task-1');
expect(taskService.setDone).toHaveBeenCalledWith('sub-task-1');
// Exactly the passed set — no dropped or double-dispatched tasks.
expect(taskService.setDone).toHaveBeenCalledTimes(2);
});
});
});

View file

@ -20,17 +20,18 @@ import { Task, TaskState } from '../tasks/task.model';
import { WorkContextService } from '../work-context/work-context.service';
import {
addProject,
archiveProject,
completeProject,
moveProjectTaskToBacklogList,
moveProjectTaskToBacklogListAuto,
moveProjectTaskToRegularListAuto,
reopenProject,
toggleHideFromMenu,
unarchiveProject,
updateProject,
updateProjectOrder,
} from './store/project.actions';
import { TaskSharedActions } from '../../root-store/meta/task-shared.actions';
import { DEFAULT_PROJECT } from './project.const';
import { DEFAULT_PROJECT, INBOX_PROJECT } from './project.const';
import {
selectArchivedProjects,
selectProjectById,
@ -52,6 +53,53 @@ import { LOCAL_ACTIONS } from '../../util/local-actions.token';
import { DateService } from '../../core/date/date.service';
import { getDeadlineAutoPlanFields } from '../tasks/util/get-deadline-auto-plan-fields';
export interface ProjectCompletionInfo {
topLevelTasks: Task[];
allTasks: Task[];
unfinishedTasks: Task[];
topLevelTasksWithUnfinishedWork: Task[];
}
/**
* Depth-first flatten of a task tree into a flat list (each parent immediately
* before its subtasks), de-duplicated via a shared `seen` set so a task reached
* from more than one parent is included only once.
*/
const flattenTaskTree = (
topLevelTasks: Task[],
entities: Record<string, Task | undefined>,
): Task[] => {
const result: Task[] = [];
const seen = new Set<string>();
const visit = (task: Task): void => {
if (seen.has(task.id)) {
return;
}
seen.add(task.id);
result.push(task);
(task.subTaskIds ?? []).forEach((subId) => {
const sub = entities[subId];
if (sub) {
visit(sub);
}
});
};
topLevelTasks.forEach(visit);
return result;
};
/** True if the task itself or any of its (live) subtasks is still unfinished. */
const hasUnfinishedWork = (
task: Task,
unfinishedTaskIds: Set<string>,
entities: Record<string, Task | undefined>,
): boolean =>
unfinishedTaskIds.has(task.id) ||
(task.subTaskIds ?? []).some((subId) => {
const sub = entities[subId];
return !!sub && hasUnfinishedWork(sub, unfinishedTaskIds, entities);
});
@Injectable({
providedIn: 'root',
})
@ -142,14 +190,6 @@ export class ProjectService {
);
}
archive(projectId: string): void {
this._store$.dispatch(archiveProject({ id: projectId }));
this._snackService.open({
ico: 'archive',
msg: T.F.PROJECT.S.ARCHIVED,
});
}
async unarchive(projectId: string): Promise<void> {
const project = await firstValueFrom(this.getByIdOnce$(projectId));
this._store$.dispatch(unarchiveProject({ id: projectId }));
@ -168,6 +208,129 @@ export class ProjectService {
);
}
complete(projectId: string, doneOn: number): void {
// Single-entity flag flip. Unfinished-task resolution (move-to-inbox /
// mark-done) is dispatched separately as normal per-task actions before
// this call — see moveTasksToInbox / markTasksDone and the completion flow
// in work-context-menu. No undo affordance: that resolution can't be fully
// restored by reopen, so the fullscreen celebration is the feedback and
// reactivation lives on the archived-projects page.
this._store$.dispatch(completeProject({ id: projectId, doneOn }));
}
/**
* Carry unfinished work forward into the Inbox before completing a project.
* Uses the normal per-task move action so every downstream effect (issue
* sync, reminders, repeat-cfg) and per-entity conflict detection fires
* naturally. Done tasks are explicitly re-opened (setUnDone) so carried-over
* work is actionable again in the Inbox the move itself keeps isDone.
* The trailing flush is the bulk-dispatch guard (sync-model Rule #6).
*/
async moveTasksToInbox(tasks: Task[]): Promise<void> {
for (const task of tasks) {
const withSubTasks = await firstValueFrom(
this._taskService.getByIdWithSubTaskData$(task.id),
);
this._taskService.moveToProject(withSubTasks, INBOX_PROJECT.id);
if (task.isDone) {
this._taskService.setUnDone(task.id);
}
}
await new Promise((resolve) => setTimeout(resolve, 0));
}
/** Mark a project's unfinished tasks done via the normal per-task action. */
async markTasksDone(tasks: Task[]): Promise<void> {
tasks.forEach((task) => this._taskService.setDone(task.id));
await new Promise((resolve) => setTimeout(resolve, 0));
}
reopen(projectId: string, project?: Pick<Project, 'isHiddenFromMenu'>): void {
this._store$.dispatch(reopenProject({ id: projectId }));
this._snackService.open(
project?.isHiddenFromMenu
? {
ico: 'replay',
msg: T.F.PROJECT.S.REOPENED,
actionStr: T.F.PROJECT.S.SHOW_IN_MENU,
actionFn: () => this._store$.dispatch(toggleHideFromMenu({ id: projectId })),
}
: {
ico: 'replay',
msg: T.F.PROJECT.S.REOPENED,
},
);
}
/**
* Tasks of a project for the completion flow. Stats include live and archived
* project tasks, while unfinished resolution only considers active tasks that
* can still be moved or marked done from the live store.
*/
async getCompletionInfo(projectId: string): Promise<ProjectCompletionInfo> {
const project = await firstValueFrom(this.getByIdOnce$(projectId));
if (!project) {
return {
topLevelTasks: [],
allTasks: [],
unfinishedTasks: [],
topLevelTasksWithUnfinishedWork: [],
};
}
const stats = await this._getCompletionStatsTasks(projectId, project);
const resolvable = await this._getResolvableTasks(project);
return { ...stats, ...resolvable };
}
/**
* Stats lists for the celebration: count live AND archived project tasks, so
* work the user archived earlier still counts toward the finished total.
*/
private async _getCompletionStatsTasks(
projectId: string,
project: Project,
): Promise<Pick<ProjectCompletionInfo, 'topLevelTasks' | 'allTasks'>> {
const ids = [...(project.taskIds ?? []), ...(project.backlogTaskIds ?? [])];
const idSet = new Set(ids);
const projectTasks = await this._taskService.getAllTasksForProject(projectId);
const projectTaskById = new Map(projectTasks.map((task) => [task.id, task]));
const topLevelTasks = [
...ids,
...projectTasks
.filter((task) => !task.parentId && !idSet.has(task.id))
.map((task) => task.id),
]
.map((id) => projectTaskById.get(id))
.filter((t): t is Task => !!t);
const allTasks = flattenTaskTree(topLevelTasks, Object.fromEntries(projectTaskById));
return { topLevelTasks, allTasks };
}
/**
* Resolvable lists for the unfinished-task prompt: only LIVE tasks can still
* be moved or marked done, so the archive is intentionally ignored here.
*/
private async _getResolvableTasks(
project: Project,
): Promise<
Pick<ProjectCompletionInfo, 'unfinishedTasks' | 'topLevelTasksWithUnfinishedWork'>
> {
const taskState = await firstValueFrom(this._store$.select(selectTaskFeatureState));
const ids = [...(project.taskIds ?? []), ...(project.backlogTaskIds ?? [])];
const activeTopLevelTasks = ids
.map((id) => taskState.entities[id])
.filter((t): t is Task => !!t);
const activeAllTasks = flattenTaskTree(activeTopLevelTasks, taskState.entities);
const unfinishedTasks = activeAllTasks.filter((t) => !t.isDone);
const unfinishedTaskIds = new Set(unfinishedTasks.map((t) => t.id));
return {
unfinishedTasks,
topLevelTasksWithUnfinishedWork: activeTopLevelTasks.filter((t) =>
hasUnfinishedWork(t, unfinishedTaskIds, taskState.entities),
),
};
}
getByIdOnce$(id: string): Observable<Project | undefined> {
if (!id) {
throw new Error('No id given');
@ -311,6 +474,10 @@ export class ProjectService {
taskIds: [],
backlogTaskIds: [],
noteIds: [],
// A duplicate is a fresh, active project — never inherit completed/archived state.
isDone: false,
doneOn: null,
isArchived: false,
});
const noteState = await firstValueFrom(this._store$.select(selectNoteFeatureState));

View file

@ -99,6 +99,32 @@ export const unarchiveProject = createAction(
}),
);
export const completeProject = createAction(
'[Project] Complete Project',
(projectProps: { id: string; doneOn: number }) => ({
...projectProps,
meta: {
isPersistent: true,
entityType: 'PROJECT',
entityId: projectProps.id,
opType: OpType.Update, // Completing is an update (also flips isArchived)
} satisfies PersistentActionMeta,
}),
);
export const reopenProject = createAction(
'[Project] Reopen Project',
(projectProps: { id: string }) => ({
...projectProps,
meta: {
isPersistent: true,
entityType: 'PROJECT',
entityId: projectProps.id,
opType: OpType.Update,
} satisfies PersistentActionMeta,
}),
);
export const toggleHideFromMenu = createAction(
'[Project] Toggle hide from menu',
(projectProps: { id: string }) => ({

View file

@ -3,9 +3,11 @@ import { fakeEntityStateFromArray } from '../../../util/fake-entity-state-from-a
import { Project } from '../project.model';
import {
archiveProject,
completeProject,
moveProjectTaskInBacklogList,
moveProjectTaskToBacklogList,
moveProjectTaskToRegularList,
reopenProject,
unarchiveProject,
updateProjectOrder,
} from './project.actions';
@ -550,5 +552,81 @@ describe('projectReducer', () => {
);
expect((afterReArchive.entities as any).P1.isArchived).toBeTrue();
});
it('should clear completed state to preserve isDone implies isArchived', () => {
const s = fakeEntityStateFromArray([
{ id: 'P1', isArchived: true, isDone: true, doneOn: 123 },
] as Partial<Project>[]);
const r = projectReducer(s as any, unarchiveProject({ id: 'P1' }) as any);
expect((r.entities as any).P1.isArchived).toBeFalse();
expect((r.entities as any).P1.isDone).toBeFalse();
expect((r.entities as any).P1.doneOn).toBeNull();
});
});
describe('completeProject', () => {
it('should set isDone, doneOn and isArchived together', () => {
const s = fakeEntityStateFromArray([
{ id: 'P1', isArchived: false, isDone: false },
{ id: 'P2', isArchived: false, isDone: false },
] as Partial<Project>[]);
const r = projectReducer(
s as any,
completeProject({ id: 'P1', doneOn: 1234 }) as any,
);
expect((r.entities as any).P1.isDone).toBeTrue();
expect((r.entities as any).P1.doneOn).toBe(1234);
// Completing auto-archives → hides from the active menu.
expect((r.entities as any).P1.isArchived).toBeTrue();
});
it('should not affect other projects', () => {
const s = fakeEntityStateFromArray([
{ id: 'P1', isArchived: false, isDone: false },
{ id: 'P2', isArchived: false, isDone: false },
] as Partial<Project>[]);
const r = projectReducer(s as any, completeProject({ id: 'P1', doneOn: 1 }) as any);
expect((r.entities as any).P2.isDone).toBeFalse();
expect((r.entities as any).P2.isArchived).toBeFalse();
});
it('should never complete INBOX_PROJECT', () => {
const s = fakeEntityStateFromArray([
{ id: INBOX_PROJECT.id, isArchived: false, isDone: false },
] as Partial<Project>[]);
const r = projectReducer(
s as any,
completeProject({ id: INBOX_PROJECT.id, doneOn: 1 }) as any,
);
expect((r.entities as any)[INBOX_PROJECT.id].isDone).toBeFalse();
expect((r.entities as any)[INBOX_PROJECT.id].isArchived).toBeFalse();
});
});
describe('reopenProject', () => {
it('should clear isDone, doneOn and isArchived (round-trip from complete)', () => {
const s = fakeEntityStateFromArray([
{ id: 'P1', isArchived: false, isDone: false },
] as Partial<Project>[]);
const completed = projectReducer(
s as any,
completeProject({ id: 'P1', doneOn: 999 }) as any,
);
expect((completed.entities as any).P1.isDone).toBeTrue();
const reopened = projectReducer(
completed as any,
reopenProject({ id: 'P1' }) as any,
);
expect((reopened.entities as any).P1.isDone).toBeFalse();
expect((reopened.entities as any).P1.doneOn).toBeNull();
// Reopen also un-archives → returns to the active menu.
expect((reopened.entities as any).P1.isArchived).toBeFalse();
});
});
});

View file

@ -27,6 +27,7 @@ import {
addProject,
addProjects,
archiveProject,
completeProject,
loadProjects,
moveAllProjectBacklogTasksToRegularList,
moveProjectTaskDownInBacklogList,
@ -38,6 +39,7 @@ import {
moveProjectTaskToRegularListAuto,
moveProjectTaskToTopInBacklogList,
moveProjectTaskUpInBacklogList,
reopenProject,
toggleHideFromMenu,
unarchiveProject,
updateProject,
@ -182,6 +184,43 @@ export const projectReducer = createReducer<ProjectState>(
id,
changes: {
isArchived: false,
isDone: false,
doneOn: null,
},
},
state,
),
),
// Completing a project marks it done AND archives it (hide from active menu).
// isDone stays distinct from isArchived so a finish ≠ a quiet shelve.
// Task resolution (move-to-inbox / mark-done) is decoupled — it runs as the
// normal per-task actions from the completion flow, not bundled in here.
on(completeProject, (state, { id, doneOn }) => {
if (id === INBOX_PROJECT.id) return state;
return projectAdapter.updateOne(
{
id,
changes: {
isDone: true,
doneOn,
isArchived: true,
},
},
state,
);
}),
// Reopen reverts a completed project back to active. It clears the done flags
// only; tasks resolved at completion time are not restored (by design).
on(reopenProject, (state, { id }) =>
projectAdapter.updateOne(
{
id,
changes: {
isDone: false,
doneOn: null,
isArchived: false,
},
},
state,

View file

@ -0,0 +1,39 @@
import {
selectArchivedProjects,
selectArchivedProjectIds,
selectArrayOfArchivedProjectIds,
} from './project.selectors';
import { Project } from '../project.model';
const p = (o: Partial<Project>): Project => ({ ...o }) as Project;
describe('project.selectors (completion)', () => {
const projects = [
p({ id: 'active', isArchived: false, isDone: false }),
p({ id: 'archived', isArchived: true, isDone: false }),
p({ id: 'done', isArchived: true, isDone: true }),
];
// REGRESSION (multi-review CRITICAL): completing a project sets isArchived, so
// it MUST remain in selectArchivedProjects — that selector feeds
// selectArchivedProjectIds, which task.selectors uses to keep archived
// projects' tasks out of Today/Overdue. Narrowing it would leak completed
// projects' (done, dueDay-carrying) tasks back into those lists.
it('selectArchivedProjects still includes completed projects', () => {
expect(
selectArchivedProjects
.projector(projects)
.map((x) => x.id)
.sort(),
).toEqual(['archived', 'done']);
});
it('selectArchivedProjectIds includes completed project ids (task-filter guard)', () => {
const archived = selectArchivedProjects.projector(projects);
const idArr = selectArrayOfArchivedProjectIds.projector(archived);
const idSet = selectArchivedProjectIds.projector(idArr);
expect(idSet.has('done')).toBe(true);
expect(idSet.has('archived')).toBe(true);
expect(idSet.has('active')).toBe(false);
});
});

View file

@ -27,6 +27,9 @@ export const selectUnarchivedVisibleProjects = createSelector(
export const selectArchivedProjects = createSelector(selectAllProjects, (projects) =>
projects.filter((p) => p.isArchived),
);
// NOTE: completing a project also sets isArchived, so completed projects are a
// subset of selectArchivedProjects — keep that one as-is, since it drives
// active-task filtering (selectArchivedProjectIds in task.selectors).
export const selectArchivedProjectsSortedByTitle = createSelector(
selectArchivedProjects,
(projects) => [...projects].sort((a, b) => a.title.localeCompare(b.title)),

View file

@ -12,8 +12,8 @@ describe('ActionType enum', () => {
const enumValues = Object.values(ActionType) as string[];
const mappingKeys = Object.keys(ACTION_TYPE_TO_CODE);
it('should have exactly 144 members', () => {
expect(enumValues.length).toBe(144);
it('should have exactly 146 members', () => {
expect(enumValues.length).toBe(146);
});
it('should have 1:1 correspondence with ACTION_TYPE_TO_CODE', () => {

View file

@ -113,6 +113,8 @@ export enum ActionType {
PROJECT_UPDATE_ADVANCED_CFG = '[Project] Update Project Advanced Cfg',
PROJECT_ARCHIVE = '[Project] Archive Project',
PROJECT_UNARCHIVE = '[Project] Unarchive Project',
PROJECT_COMPLETE = '[Project] Complete Project',
PROJECT_REOPEN = '[Project] Reopen Project',
PROJECT_TOGGLE_HIDE = '[Project] Toggle hide from menu',
PROJECT_MOVE_TASK_IN_BACKLOG = '[Project] Move Task in Backlog',
PROJECT_MOVE_TASK_UP_BACKLOG = '[Project] Move Task Up in Backlog',

View file

@ -44,15 +44,37 @@
>{{ project.icon || DEFAULT_PROJECT_ICON }}</mat-icon
>
<span class="project-title">{{ project.title }}</span>
@if (project.isDone) {
<span class="completed-badge">
<mat-icon aria-hidden="true">emoji_events</mat-icon>
@if (project.doneOn) {
{{
T.F.PROJECT.COMPLETE.COMPLETED_ON
| translate: { date: (project.doneOn | date) }
}}
}
</span>
}
</a>
<button
mat-icon-button
[matTooltip]="T.F.PROJECT.ARCHIVED_PROJECTS.UNARCHIVE | translate"
[attr.aria-label]="T.F.PROJECT.ARCHIVED_PROJECTS.UNARCHIVE | translate"
(click)="unarchive(project)"
>
<mat-icon>unarchive</mat-icon>
</button>
@if (project.isDone) {
<button
mat-icon-button
[matTooltip]="T.F.PROJECT.COMPLETE.REOPEN | translate"
[attr.aria-label]="T.F.PROJECT.COMPLETE.REOPEN | translate"
(click)="reopen(project)"
>
<mat-icon>replay</mat-icon>
</button>
} @else {
<button
mat-icon-button
[matTooltip]="T.F.PROJECT.ARCHIVED_PROJECTS.UNARCHIVE | translate"
[attr.aria-label]="T.F.PROJECT.ARCHIVED_PROJECTS.UNARCHIVE | translate"
(click)="unarchive(project)"
>
<mat-icon>unarchive</mat-icon>
</button>
}
</li>
}
</ul>

View file

@ -61,3 +61,18 @@
text-overflow: ellipsis;
white-space: nowrap;
}
.completed-badge {
display: inline-flex;
align-items: center;
gap: var(--s-half);
flex-shrink: 0;
font-size: 12px;
opacity: 0.7;
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
}
}

View file

@ -9,13 +9,14 @@ import { ProjectService } from '../../features/project/project.service';
import { Project } from '../../features/project/project.model';
import { selectArchivedProjectsSortedByTitle } from '../../features/project/store/project.selectors';
const makeProject = (id: string, title: string): Project =>
const makeProject = (id: string, title: string, over: Partial<Project> = {}): Project =>
({
id,
title,
isArchived: true,
isHiddenFromMenu: false,
theme: { primary: '#fff' },
...over,
}) as unknown as Project;
describe('ArchivedProjectsPageComponent', () => {
@ -24,7 +25,7 @@ describe('ArchivedProjectsPageComponent', () => {
let projectService: jasmine.SpyObj<ProjectService>;
const setUp = async (projects: Project[]): Promise<void> => {
projectService = jasmine.createSpyObj('ProjectService', ['unarchive']);
projectService = jasmine.createSpyObj('ProjectService', ['unarchive', 'reopen']);
await TestBed.configureTestingModule({
imports: [
@ -90,4 +91,12 @@ describe('ArchivedProjectsPageComponent', () => {
btn.nativeElement.click();
expect(projectService.unarchive).toHaveBeenCalledWith('p-a');
});
it('calls ProjectService.reopen for completed projects', async () => {
const project = makeProject('p-a', 'Alpha', { isDone: true, doneOn: 1234 });
await setUp([project]);
const btn = fixture.debugElement.query(By.css('.project-row button'));
btn.nativeElement.click();
expect(projectService.reopen).toHaveBeenCalledWith('p-a', project);
});
});

View file

@ -13,6 +13,7 @@ import { MatFormField, MatLabel, MatSuffix } from '@angular/material/form-field'
import { MatInput } from '@angular/material/input';
import { FormsModule } from '@angular/forms';
import { RouterLink } from '@angular/router';
import { DatePipe } from '@angular/common';
import { TranslatePipe } from '@ngx-translate/core';
import { selectArchivedProjectsSortedByTitle } from '../../features/project/store/project.selectors';
import { ProjectService } from '../../features/project/project.service';
@ -36,6 +37,7 @@ import { T } from '../../t.const';
MatSuffix,
FormsModule,
RouterLink,
DatePipe,
TranslatePipe,
],
})
@ -62,4 +64,8 @@ export class ArchivedProjectsPageComponent {
unarchive(project: Project): void {
this._projectService.unarchive(project.id);
}
reopen(project: Project): void {
this._projectService.reopen(project.id, project);
}
}

View file

@ -1,11 +1,23 @@
@if (currentProject()?.isArchived) {
<div class="archived-notice">
<span>{{ T.F.PROJECT.ARCHIVED_NOTICE | translate }}</span>
<span>
{{
(currentProject()?.isDone
? T.F.PROJECT.COMPLETE.NOTICE
: T.F.PROJECT.ARCHIVED_NOTICE
) | translate
}}
</span>
<button
mat-button
(click)="restoreProject()"
>
{{ T.F.PROJECT.ARCHIVED_PROJECTS.UNARCHIVE | translate }}
{{
(currentProject()?.isDone
? T.F.PROJECT.COMPLETE.REOPEN
: T.F.PROJECT.ARCHIVED_PROJECTS.UNARCHIVE
) | translate
}}
</button>
</div>
}

View file

@ -39,7 +39,11 @@ export class ProjectTaskPageComponent {
restoreProject(): void {
const project = this.currentProject();
if (project) {
this._projectService.unarchive(project.id);
if (project.isDone) {
this._projectService.reopen(project.id, project);
} else {
this._projectService.unarchive(project.id);
}
}
}
}

View file

@ -957,6 +957,32 @@ const T = {
D_DELETE: {
MSG: 'F.PROJECT.D_DELETE.MSG',
},
COMPLETE: {
COMPLETED_ON: 'F.PROJECT.COMPLETE.COMPLETED_ON',
REOPEN: 'F.PROJECT.COMPLETE.REOPEN',
NOTICE: 'F.PROJECT.COMPLETE.NOTICE',
ERROR: 'F.PROJECT.COMPLETE.ERROR',
RESOLVE: {
MSG: 'F.PROJECT.COMPLETE.RESOLVE.MSG',
MSG_ONE: 'F.PROJECT.COMPLETE.RESOLVE.MSG_ONE',
MOVE_TO_INBOX: 'F.PROJECT.COMPLETE.RESOLVE.MOVE_TO_INBOX',
MARK_DONE: 'F.PROJECT.COMPLETE.RESOLVE.MARK_DONE',
},
CONFIRM: {
TITLE: 'F.PROJECT.COMPLETE.CONFIRM.TITLE',
MSG: 'F.PROJECT.COMPLETE.CONFIRM.MSG',
},
CELEBRATION: {
TITLE: 'F.PROJECT.COMPLETE.CELEBRATION.TITLE',
STARTED: 'F.PROJECT.COMPLETE.CELEBRATION.STARTED',
FINISHED: 'F.PROJECT.COMPLETE.CELEBRATION.FINISHED',
DURATION: 'F.PROJECT.COMPLETE.CELEBRATION.DURATION',
TASKS_DONE: 'F.PROJECT.COMPLETE.CELEBRATION.TASKS_DONE',
TIME_SPENT: 'F.PROJECT.COMPLETE.CELEBRATION.TIME_SPENT',
DAYS_WORKED: 'F.PROJECT.COMPLETE.CELEBRATION.DAYS_WORKED',
DONE_BTN: 'F.PROJECT.COMPLETE.CELEBRATION.DONE_BTN',
},
},
FORM_BASIC: {
L_ENABLE_BACKLOG: 'F.PROJECT.FORM_BASIC.L_ENABLE_BACKLOG',
L_IS_HIDDEN_FROM_MENU: 'F.PROJECT.FORM_BASIC.L_IS_HIDDEN_FROM_MENU',
@ -990,6 +1016,7 @@ const T = {
ARCHIVED: 'F.PROJECT.S.ARCHIVED',
CREATED: 'F.PROJECT.S.CREATED',
DELETED: 'F.PROJECT.S.DELETED',
REOPENED: 'F.PROJECT.S.REOPENED',
SHOW_IN_MENU: 'F.PROJECT.S.SHOW_IN_MENU',
UNARCHIVED: 'F.PROJECT.S.UNARCHIVED',
UNARCHIVED_HIDDEN_FROM_MENU: 'F.PROJECT.S.UNARCHIVED_HIDDEN_FROM_MENU',
@ -2652,6 +2679,7 @@ const T = {
CREATE_PROJECT: 'MH.CREATE_PROJECT',
CREATE_TAG: 'MH.CREATE_TAG',
ARCHIVE_PROJECT: 'MH.ARCHIVE_PROJECT',
COMPLETE_PROJECT: 'MH.COMPLETE_PROJECT',
DELETE_PROJECT: 'MH.DELETE_PROJECT',
DELETE_TAG: 'MH.DELETE_TAG',
DISABLE_FEATURE: 'MH.DISABLE_FEATURE',

View file

@ -941,6 +941,32 @@
"D_DELETE": {
"MSG": "Are you sure you want to delete the project \"{{title}}\"?"
},
"COMPLETE": {
"COMPLETED_ON": "Completed {{date}}",
"REOPEN": "Reopen",
"NOTICE": "This project is completed.",
"ERROR": "Could not complete project. Please try again.",
"RESOLVE": {
"MSG": "still has {{nr}} unfinished tasks. What should happen to them?",
"MSG_ONE": "still has {{nr}} unfinished task. What should happen to it?",
"MOVE_TO_INBOX": "Move to Inbox",
"MARK_DONE": "Mark as done"
},
"CONFIRM": {
"TITLE": "Complete project?",
"MSG": "Complete <strong>{{title}}</strong> and move it to archived projects?"
},
"CELEBRATION": {
"TITLE": "Project complete!",
"STARTED": "Started",
"FINISHED": "Finished",
"DURATION": "Completed in {{nr}} days",
"TASKS_DONE": "Tasks completed",
"TIME_SPENT": "Time tracked",
"DAYS_WORKED": "Days worked",
"DONE_BTN": "Close"
}
},
"FORM_BASIC": {
"L_ENABLE_BACKLOG": "Enable Project Backlog",
"L_IS_HIDDEN_FROM_MENU": "Hide project from sidebar",
@ -973,6 +999,7 @@
"ARCHIVED": "Archived Project",
"CREATED": "Created project <strong>{{title}}</strong>. You can select it from the sidebar.",
"DELETED": "Deleted Project",
"REOPENED": "Project reopened",
"SHOW_IN_MENU": "Show in sidebar",
"UNARCHIVED": "Project restored",
"UNARCHIVED_HIDDEN_FROM_MENU": "Project restored, but still hidden from the sidebar",
@ -2591,6 +2618,7 @@
"CREATE_PROJECT": "Create project",
"CREATE_TAG": "Create tag",
"ARCHIVE_PROJECT": "Archive project",
"COMPLETE_PROJECT": "Complete project",
"DELETE_PROJECT": "Delete project",
"DELETE_TAG": "Delete tag",
"DISABLE_FEATURE": "Disable feature",

View file

@ -160,6 +160,34 @@ body .mat-mdc-dialog-surface {
}
}
.project-complete-fullscreen-dialog {
width: 100vw !important;
height: 100vh !important;
max-width: 100vw !important;
max-height: 100vh !important;
margin: 0 !important;
@supports (height: 100dvh) {
height: 100dvh !important;
max-height: 100dvh !important;
}
.mat-mdc-dialog-surface {
width: 100%;
height: 100%;
max-height: none;
overflow: hidden;
background: transparent;
border-radius: 0;
box-shadow: none;
}
}
body.isNativeMobile .project-complete-fullscreen-dialog {
width: calc(100vw - var(--safe-area-left) - var(--safe-area-right)) !important;
height: calc(100dvh - var(--safe-area-top) - var(--safe-area-bottom)) !important;
}
// FORMS
// --------
.mat-mdc-form-field {