diff --git a/ARCHITECTURE-DECISIONS.md b/ARCHITECTURE-DECISIONS.md index b525a867b8..78f4fa23df 100644 --- a/ARCHITECTURE-DECISIONS.md +++ b/ARCHITECTURE-DECISIONS.md @@ -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 #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 diff --git a/docs/plans/2026-06-05-project-completion.md b/docs/plans/2026-06-05-project-completion.md new file mode 100644 index 0000000000..295f57377a --- /dev/null +++ b/docs/plans/2026-06-05-project-completion.md @@ -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** | diff --git a/docs/wiki/4.06-Project-View.md b/docs/wiki/4.06-Project-View.md index 6e866a5326..be9271ddec 100644 --- a/docs/wiki/4.06-Project-View.md +++ b/docs/wiki/4.06-Project-View.md @@ -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 diff --git a/e2e/pages/project.page.ts b/e2e/pages/project.page.ts index 98d259e7fc..e0d2b8d529 100644 --- a/e2e/pages/project.page.ts +++ b/e2e/pages/project.page.ts @@ -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 { + 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 }); + } } diff --git a/e2e/tests/project/project-completion.spec.ts b/e2e/tests/project/project-completion.spec.ts new file mode 100644 index 0000000000..0ec75e53f5 --- /dev/null +++ b/e2e/tests/project/project-completion.spec.ts @@ -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); + }); +}); diff --git a/src/app/app.component.ts b/src/app/app.component.ts index 4a2f2adb85..433a6faad3 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -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$ diff --git a/src/app/core-ui/work-context-menu/work-context-menu.component.html b/src/app/core-ui/work-context-menu/work-context-menu.component.html index a612de1049..776be9e31d 100644 --- a/src/app/core-ui/work-context-menu/work-context-menu.component.html +++ b/src/app/core-ui/work-context-menu/work-context-menu.component.html @@ -74,16 +74,23 @@ (click)="restoreProject()" mat-menu-item > - unarchive - {{ T.F.PROJECT.ARCHIVED_PROJECTS.UNARCHIVE | translate }} + {{ (isDone$ | async) ? 'replay' : 'unarchive' }} + + {{ + ((isDone$ | async) + ? T.F.PROJECT.COMPLETE.REOPEN + : T.F.PROJECT.ARCHIVED_PROJECTS.UNARCHIVE + ) | translate + }} + } @else { } + + + diff --git a/src/app/features/project/dialog-complete-resolve-tasks/dialog-complete-resolve-tasks.component.ts b/src/app/features/project/dialog-complete-resolve-tasks/dialog-complete-resolve-tasks.component.ts new file mode 100644 index 0000000000..454b56f1e4 --- /dev/null +++ b/src/app/features/project/dialog-complete-resolve-tasks/dialog-complete-resolve-tasks.component.ts @@ -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 + >(MatDialogRef); + + readonly data = inject(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'); + } +} diff --git a/src/app/features/project/dialog-project-complete/dialog-project-complete.component.html b/src/app/features/project/dialog-project-complete/dialog-project-complete.component.html new file mode 100644 index 0000000000..aefb27eb5f --- /dev/null +++ b/src/app/features/project/dialog-project-complete/dialog-project-complete.component.html @@ -0,0 +1,105 @@ +
+ @if (resolvedBgImage(); as bgImage) { +
+
+ } + + + + + +
+
+
+ emoji_events +
+

+ {{ T.F.PROJECT.COMPLETE.CELEBRATION.TITLE | translate }} +

+

{{ data.project.title }}

+
+ +
+
+ {{ data.stats.nrOfTasksDone }}/{{ data.stats.nrOfTasksTotal }} + {{ + T.F.PROJECT.COMPLETE.CELEBRATION.TASKS_DONE | translate + }} +
+ + @if (data.stats.timeSpent > 0) { +
+ {{ data.stats.timeSpent | msToString }} + {{ + T.F.PROJECT.COMPLETE.CELEBRATION.TIME_SPENT | translate + }} +
+
+ {{ data.stats.nrOfDaysWorked }} + {{ + T.F.PROJECT.COMPLETE.CELEBRATION.DAYS_WORKED | translate + }} +
+ } +
+ +
+ @if (data.stats.durationDays > 0) { +

+ {{ + T.F.PROJECT.COMPLETE.CELEBRATION.DURATION + | translate: { nr: data.stats.durationDays } + }} +

+ } + + @if (data.stats.startedOn) { +

+ {{ T.F.PROJECT.COMPLETE.CELEBRATION.STARTED | translate }}: + {{ data.stats.startedOn | date }} · + {{ T.F.PROJECT.COMPLETE.CELEBRATION.FINISHED | translate }}: + {{ data.stats.doneOn | date }} +

+ } +
+ +
+ +
+
+
diff --git a/src/app/features/project/dialog-project-complete/dialog-project-complete.component.scss b/src/app/features/project/dialog-project-complete/dialog-project-complete.component.scss new file mode 100644 index 0000000000..d4e214f817 --- /dev/null +++ b/src/app/features/project/dialog-project-complete/dialog-project-complete.component.scss @@ -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%; + } + } +} diff --git a/src/app/features/project/dialog-project-complete/dialog-project-complete.component.spec.ts b/src/app/features/project/dialog-project-complete/dialog-project-complete.component.spec.ts new file mode 100644 index 0000000000..2f7c462743 --- /dev/null +++ b/src/app/features/project/dialog-project-complete/dialog-project-complete.component.spec.ts @@ -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; + let component: DialogProjectCompleteComponent; + let confettiService: jasmine.SpyObj; + let dialogRef: jasmine.SpyObj>; + let misc: { isDisableCelebration?: boolean; isDisableAnimations?: boolean }; + let isDarkTheme: WritableSignal; + + 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); + }); +}); diff --git a/src/app/features/project/dialog-project-complete/dialog-project-complete.component.ts b/src/app/features/project/dialog-project-complete/dialog-project-complete.component.ts new file mode 100644 index 0000000000..fa423dc762 --- /dev/null +++ b/src/app/features/project/dialog-project-complete/dialog-project-complete.component.ts @@ -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); + private readonly _confettiService = inject(ConfettiService); + private readonly _configService = inject(GlobalConfigService); + private readonly _globalThemeService = inject(GlobalThemeService); + + readonly data = inject(MAT_DIALOG_DATA); + readonly T: typeof T = T; + readonly resolvedBgImage = signal(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; + + 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 { + // 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(); + } +} diff --git a/src/app/features/project/project-completion-stats.util.spec.ts b/src/app/features/project/project-completion-stats.util.spec.ts new file mode 100644 index 0000000000..eaa4b4cb4b --- /dev/null +++ b/src/app/features/project/project-completion-stats.util.spec.ts @@ -0,0 +1,81 @@ +import { getProjectCompletionStats } from './project-completion-stats.util'; +import { Task } from '../tasks/task.model'; + +const task = (p: Partial): 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); + }); +}); diff --git a/src/app/features/project/project-completion-stats.util.ts b/src/app/features/project/project-completion-stats.util.ts new file mode 100644 index 0000000000..43f9c50ef9 --- /dev/null +++ b/src/app/features/project/project-completion-stats.util.ts @@ -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(); + 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, + }; +}; diff --git a/src/app/features/project/project.const.ts b/src/app/features/project/project.const.ts index 66dc1a0043..44042435db 100644 --- a/src/app/features/project/project.const.ts +++ b/src/app/features/project/project.const.ts @@ -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: [], diff --git a/src/app/features/project/project.model.ts b/src/app/features/project/project.model.ts index 8fab551ba6..d4b2ef2c69 100644 --- a/src/app/features/project/project.model.ts +++ b/src/app/features/project/project.model.ts @@ -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[]; diff --git a/src/app/features/project/project.service.spec.ts b/src/app/features/project/project.service.spec.ts index 0b330c3565..fa1c72da07 100644 --- a/src/app/features/project/project.service.spec.ts +++ b/src/app/features/project/project.service.spec.ts @@ -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); + }); + }); }); diff --git a/src/app/features/project/project.service.ts b/src/app/features/project/project.service.ts index 730898d3e4..3bb115c277 100644 --- a/src/app/features/project/project.service.ts +++ b/src/app/features/project/project.service.ts @@ -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, +): Task[] => { + const result: Task[] = []; + const seen = new Set(); + 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, + entities: Record, +): 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 { 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 { + 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 { + tasks.forEach((task) => this._taskService.setDone(task.id)); + await new Promise((resolve) => setTimeout(resolve, 0)); + } + + reopen(projectId: string, project?: Pick): 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 { + 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> { + 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 + > { + 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 { 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)); diff --git a/src/app/features/project/store/project.actions.ts b/src/app/features/project/store/project.actions.ts index 432690416a..d65b2ab41a 100644 --- a/src/app/features/project/store/project.actions.ts +++ b/src/app/features/project/store/project.actions.ts @@ -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 }) => ({ diff --git a/src/app/features/project/store/project.reducer.spec.ts b/src/app/features/project/store/project.reducer.spec.ts index 8b92de08a4..c360c1581b 100644 --- a/src/app/features/project/store/project.reducer.spec.ts +++ b/src/app/features/project/store/project.reducer.spec.ts @@ -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[]); + + 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[]); + + 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[]); + + 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[]); + + 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[]); + + 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(); + }); }); }); diff --git a/src/app/features/project/store/project.reducer.ts b/src/app/features/project/store/project.reducer.ts index f4e0559f8b..135ed833e6 100644 --- a/src/app/features/project/store/project.reducer.ts +++ b/src/app/features/project/store/project.reducer.ts @@ -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( 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, diff --git a/src/app/features/project/store/project.selectors.spec.ts b/src/app/features/project/store/project.selectors.spec.ts new file mode 100644 index 0000000000..e19c721441 --- /dev/null +++ b/src/app/features/project/store/project.selectors.spec.ts @@ -0,0 +1,39 @@ +import { + selectArchivedProjects, + selectArchivedProjectIds, + selectArrayOfArchivedProjectIds, +} from './project.selectors'; +import { Project } from '../project.model'; + +const p = (o: Partial): 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); + }); +}); diff --git a/src/app/features/project/store/project.selectors.ts b/src/app/features/project/store/project.selectors.ts index a33c5f74eb..533d8ea773 100644 --- a/src/app/features/project/store/project.selectors.ts +++ b/src/app/features/project/store/project.selectors.ts @@ -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)), diff --git a/src/app/op-log/core/action-types.enum.spec.ts b/src/app/op-log/core/action-types.enum.spec.ts index 7beea6e0d2..e06988d6d6 100644 --- a/src/app/op-log/core/action-types.enum.spec.ts +++ b/src/app/op-log/core/action-types.enum.spec.ts @@ -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', () => { diff --git a/src/app/op-log/core/action-types.enum.ts b/src/app/op-log/core/action-types.enum.ts index 431b4051ae..c9dcb680b7 100644 --- a/src/app/op-log/core/action-types.enum.ts +++ b/src/app/op-log/core/action-types.enum.ts @@ -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', diff --git a/src/app/pages/archived-projects-page/archived-projects-page.component.html b/src/app/pages/archived-projects-page/archived-projects-page.component.html index e40ab464cf..e279ec994b 100644 --- a/src/app/pages/archived-projects-page/archived-projects-page.component.html +++ b/src/app/pages/archived-projects-page/archived-projects-page.component.html @@ -44,15 +44,37 @@ >{{ project.icon || DEFAULT_PROJECT_ICON }} {{ project.title }} + @if (project.isDone) { + + + @if (project.doneOn) { + {{ + T.F.PROJECT.COMPLETE.COMPLETED_ON + | translate: { date: (project.doneOn | date) } + }} + } + + } - + @if (project.isDone) { + + } @else { + + } } diff --git a/src/app/pages/archived-projects-page/archived-projects-page.component.scss b/src/app/pages/archived-projects-page/archived-projects-page.component.scss index 5415696c5c..229f604e6f 100644 --- a/src/app/pages/archived-projects-page/archived-projects-page.component.scss +++ b/src/app/pages/archived-projects-page/archived-projects-page.component.scss @@ -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; + } +} diff --git a/src/app/pages/archived-projects-page/archived-projects-page.component.spec.ts b/src/app/pages/archived-projects-page/archived-projects-page.component.spec.ts index 3da4a29052..204c0156aa 100644 --- a/src/app/pages/archived-projects-page/archived-projects-page.component.spec.ts +++ b/src/app/pages/archived-projects-page/archived-projects-page.component.spec.ts @@ -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 => ({ 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; const setUp = async (projects: Project[]): Promise => { - 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); + }); }); diff --git a/src/app/pages/archived-projects-page/archived-projects-page.component.ts b/src/app/pages/archived-projects-page/archived-projects-page.component.ts index 0b3be50e34..b27ecc6c27 100644 --- a/src/app/pages/archived-projects-page/archived-projects-page.component.ts +++ b/src/app/pages/archived-projects-page/archived-projects-page.component.ts @@ -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); + } } diff --git a/src/app/pages/project-task-page/project-task-page.component.html b/src/app/pages/project-task-page/project-task-page.component.html index a62dd61c4f..08ed9b80a6 100644 --- a/src/app/pages/project-task-page/project-task-page.component.html +++ b/src/app/pages/project-task-page/project-task-page.component.html @@ -1,11 +1,23 @@ @if (currentProject()?.isArchived) {
- {{ T.F.PROJECT.ARCHIVED_NOTICE | translate }} + + {{ + (currentProject()?.isDone + ? T.F.PROJECT.COMPLETE.NOTICE + : T.F.PROJECT.ARCHIVED_NOTICE + ) | translate + }} +
} diff --git a/src/app/pages/project-task-page/project-task-page.component.ts b/src/app/pages/project-task-page/project-task-page.component.ts index 894e1d1e46..ffc0230e53 100644 --- a/src/app/pages/project-task-page/project-task-page.component.ts +++ b/src/app/pages/project-task-page/project-task-page.component.ts @@ -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); + } } } } diff --git a/src/app/t.const.ts b/src/app/t.const.ts index 2c22b2ec32..b781681663 100644 --- a/src/app/t.const.ts +++ b/src/app/t.const.ts @@ -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', diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json index 65f24a7d36..4ef5adecce 100644 --- a/src/assets/i18n/en.json +++ b/src/assets/i18n/en.json @@ -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 {{title}} 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 {{title}}. 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", diff --git a/src/styles/components/_overwrite-material.scss b/src/styles/components/_overwrite-material.scss index 9181369aca..ce781751f5 100644 --- a/src/styles/components/_overwrite-material.scss +++ b/src/styles/components/_overwrite-material.scss @@ -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 {