mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-28 18:20:42 +00:00
feat(project): project completion experience (#8036)
* feat(project): add project completion with celebration + trophy view
Completing a project marks it done and archives it, with a celebration
dialog (confetti + live stats) and a prompt to resolve unfinished tasks
(move to Inbox / mark done). Completed projects show a trophy badge and
a Reopen action on the archived-projects page.
isDone stays distinct from isArchived; selectArchivedProjects is left
intact so completed projects' tasks stay filtered out of Today/Overdue.
Append/merge deferred to #8032. Plan: docs/plans/2026-06-05-project-completion.md
* refactor(project): drop Archive menu item; Complete is the retire path
Archive and Complete produced near-identical end states; collapse to one
user-facing action. Removes the 'Archive project' menu item and handler so
Complete is the single way to retire a project. The archiveProject action,
reducer and ProjectService.archive() stay (needed for op-log replay of
historical archive ops and the legacy unarchive/restore path). Wiki updated;
menu specs cover the Complete flow.
* fix(project): keep completion dialog open for the active project
MatDialog closeOnNavigation (default true) dismissed the celebration the
moment completing the currently-active project navigated to '/'. Navigate
first, then open the dialog.
* test(project): e2e for completion flow (complete, celebrate, reopen)
Covers the resolve-unfinished-tasks prompt, the celebration dialog with
stats, the trophy badge on the archived page, and reopening. Adds an
openProjectContextMenu page-object helper.
* feat(project): confirm project completion
* feat(project): show completion celebration fullscreen
* fix(project): harden completion celebration flow
* style(project): use spacing tokens in completion dialog
* fix(project): align completion screen with project context
* style(project): soften completion screen coloring
* style(project): reuse completion screen surfaces
* fix: refine project completion dialog actions
* fix: complete projects with atomic task resolution
* fix: restore archive path in project completion UI
* refactor(project): remove dead completion code
The project-level completeProject action (OpType.Update) was never
dispatched — completion goes exclusively through the atomic
TaskSharedActions.completeProject (OpType.Batch) meta-reducer. Drop the
dead action, its reducer case, the PROJECT_COMPLETE enum member and the
immutable 'PCO' op-log code (which would otherwise be permanently
reserved for an op that can never be produced); enum count 147->146.
Also remove the unused selectCompletedProjects / selectPlainArchivedProjects
selectors (no consumers) and the misspelled, unused 'angel' confetti
field, keeping the regression tests that guard selectArchivedProjects.
* fix(project): make project completion non-reversible
Completion resolves a project's open tasks (move-to-inbox / mark-done),
which reopen cannot truly restore, so the "Reopen"/"Undo" affordances on
the completion path were misleading. Drop the celebration dialog's Reopen
button and the post-complete undo snack; the fullscreen celebration is
the feedback and deliberate reactivation still lives on the
archived-projects page (Project.reopen kept for it).
Also restore the project title param on the archive confirm dialog (it
was rendering a raw {{title}} placeholder), remove the now-unused
moveTasksToInbox / markTasksDone resolution helpers (the meta-reducer
resolves tasks atomically), and drop the orphaned UNDO / S.COMPLETED
i18n keys. Updates the completion e2e to the close flow and asserts the
resolution props are forwarded to the atomic action.
* fix(tasks): cancel native reminders for project-completed tasks
Completing a project marks its unfinished tasks done inside the
meta-reducer (no per-task updateTask), so unscheduleDoneTask$'s
native-reminder cancellation is bypassed and an OS-scheduled Android
notification could still fire for a now-done task. Add a local-only
effect that cancels native reminders for the force-completed task ids.
Local-only by design: it dispatches no actions (the persistent
dismissReminderOnly/clearDeadlineReminder would each be an extra synced
op), and done tasks are already filtered from reminders$ on all
platforms — only the native Android notification needs explicit removal.
* test(project): drop TaskService spies orphaned by helper removal
getByIdWithSubTaskData$/moveToProject/setDone/setUnDone were only used
by the removed moveTasksToInbox/markTasksDone helpers and their deleted
tests.
* feat(sync): affectedEntities multi-entity conflict detection for atomic completion
WIP checkpoint of the atomic completeProject approach. The Batch op declares
every touched entity (PROJECT, INBOX, TASKs, TODAY_TAG) via a new
affectedEntities field threaded through op-log capture, conflict detection,
the sync server (+Prisma migration) and shared-schema. Per-effect
completeProject handlers (issue two-way-sync, time-block, repeat-cfg)
re-derive the task changes the atomic op bypasses.
* revert(sync): remove affectedEntities multi-entity conflict detection
Reverts 0893a86162. The affectedEntities feature existed solely to make the
atomic completeProject Batch op sync-correct (its only producer). Decoupling
project completion into normal per-task ops (next commit) makes the existing
per-entity conflict detection and effects fire naturally, so this entire
layer — sync-core, super-sync-server, shared-schema, op-log plumbing, the
Prisma migration, and the per-effect completeProject listeners — is no longer
needed. Preserved in history via the checkpoint commit.
* refactor(project): decouple completion from task resolution (Option C)
Completion was an atomic multi-entity Batch op (completeProject) that marked
tasks done / moved them to Inbox inside the project-shared meta-reducer.
Because it bypassed the normal per-task actions, every downstream consumer had
to be taught about it separately — conflict detection (the affectedEntities
feature, reverted in the previous commit), native-reminder cancellation, issue
two-way-sync, time-block and repeat-cfg effects.
Decouple instead: completion is now a plain single-entity PROJECT flag flip
(completeProject = OpType.Update, mirroring archiveProject). Unfinished-task
resolution runs first as the normal per-task actions (moveToOtherProject /
updateTask isDone) from the completion flow, so the existing effects and
per-entity conflict detection fire naturally — no special-casing anywhere.
- project.actions/reducer: restore plain completeProject action + on() handler
- project.service: complete() is a flag dispatch; restore moveTasksToInbox /
markTasksDone (normal per-task dispatch + Rule #6 flush)
- work-context-menu: resolve unfinished work before the flag flip
- drop the completeProject meta-reducer block, the Batch action, the
TASK_SHARED_COMPLETE_PROJECT op code, and the reminder-cancel effect
(unscheduleDoneTask$ already cancels native reminders on the normal path);
current-task clearing is covered by the existing task-internal effect
Net: ~190 LOC removed here on top of ~1565 (affectedEntities + a Prisma
migration) in the revert. Completion's task resolution is not undone either
way, so the atomic bundle never bought a clean reversal.
* docs(project): record decoupled-completion decision (ADR #5)
Document why project completion uses decoupled per-task resolution + a plain
single-entity flag flip instead of an atomic multi-entity op: the atomic op
forced a cross-stack affectedEntities conflict-detection feature and per-effect
listeners, for an undo guarantee it never delivered. Adds ARCHITECTURE-DECISIONS
#5 and a revision note + corrected undo/bulk-mechanic notes in the plan doc.
* test(project): pin completion ordering + resolution edge cases
Address multi-agent review of the decoupled-completion refactor:
- assert resolution (moveTasksToInbox / markTasksDone) runs BEFORE the
completeProject flag flip (toHaveBeenCalledBefore) — the core invariant of
the decoupled design that was previously not pinned
- cover the not-done branch of moveTasksToInbox (no setUnDone) and assert
markTasksDone dispatches exactly the passed set
- add explicit PCO encode/decode round-trip assertions
- document the inbox-path current-task carry-forward nuance in ADR #5
Composition is covered end-to-end by e2e/tests/project/project-completion.spec.ts.
* refactor(project): tighten completion flow per review
- collapse 3x getCompletionInfo() to <=2: gate the resolve prompt once,
recompute only after a resolution; each call now wrapped with an error
snack so a failed archive load no longer aborts silently
- drop the dead post-confirm re-prompt branch (unreachable between two
sequential single-user modals)
- reuse getDiffInDays + dateStrToUtcDate in completion-stats util instead
of hand-rolled local-midnight/duration helpers
- use a Set for the top-level-task membership check (was O(n*m))
- drop redundant inline dialog sizing; the panelClass owns fullscreen
- remove dead --project-complete-accent test assertion
* refactor(project): finish review follow-ups for completion flow
- W3: reset the celebration confetti instance on dialog destroy so its
rAF loop + window resize listener are torn down when the dialog closes
before the animation ends (ConfettiService now returns the handle and
fires without awaiting completion)
- S2: extract resolveBgImageToDataUrl() shared by app.component and the
celebration dialog (was duplicated file://->data-url resolution)
- S3: split completeProject() into _getCompletionInfoOrNotify (dedupes the
error handling), _promptResolveUnfinishedTasks and _confirmCompletion
- W2: keep prefers-reduced-motion gating app-wide (a11y) + document intent
* fix(project): close confetti teardown race on early dialog close
If the celebration dialog is dismissed while canvas-confetti is still
loading, the instance was assigned after ngOnDestroy ran, so reset() never
fired and the rAF loop + resize listener leaked. Guard with an _isDestroyed
flag and reset the instance immediately if it arrives post-destroy.
Also drop the now-dead CanvasConfetti type alias (superseded by
ConfettiInstance, zero references).
* docs(project): drop non-existent undo-snack from completion wiki
* refactor(project): extract completion task-tree and dialog helpers
* refactor(project): hide Archive menu item; Complete is the retire path
* refactor(project): drop dead archive(), reuse resolve-choice type
Multi-review follow-ups on the completion feature:
- Remove orphaned ProjectService.archive() (+ unused import, spec) — the
menu collapsed Archive into Complete, leaving no caller. The
archiveProject action/reducer stay for op-log decode of historical ops.
- Reuse the exported ResolveUnfinishedTasksChoice type instead of
re-spelling the union three times in work-context-menu.
- Fix misleading moveTasksToInbox comment (setUnDone re-opens, not move).
- Note the as-shipped deviations (no extra selectors, no celebration
effect) in the design plan so they aren't hunted for later.
---------
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
This commit is contained in:
parent
59c785c72b
commit
eff41c041d
43 changed files with 2441 additions and 122 deletions
|
|
@ -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
|
||||
|
|
|
|||
182
docs/plans/2026-06-05-project-completion.md
Normal file
182
docs/plans/2026-06-05-project-completion.md
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
# Project completion experience
|
||||
|
||||
**Date:** 2026-06-05 (rev. after multi-agent review)
|
||||
**Status:** ✅ Implemented on `feat/completing-projects-48eeb4` — state layer, stats util, service, celebration + resolve dialogs, menu wiring, trophy badge on the archived page, translations, wiki. Verified: unit tests (reducer 34, selectors 4, stats 6) + existing specs (menu 10, service 12, page 5) green; dev build exit 0; eslint + int:test clean.
|
||||
**Branch:** `feat/completing-projects-48eeb4`
|
||||
|
||||
> **Revision 2026-06-06 — completion is decoupled from task resolution (Option C).**
|
||||
> A later iteration made completion one **atomic** multi-entity op (`completeProject` `Batch`) that marked/moved unfinished tasks inside the project-shared meta-reducer. That bypassed the normal per-task actions, so it needed a new cross-stack `affectedEntities` conflict-detection feature (~1,565 LOC + a Prisma migration) plus dedicated `completeProject` listeners in the reminder / issue-sync / time-block / repeat-cfg effects — and it still didn't give a reversible undo (`reopenProject` clears project flags only). We **reverted all of it** and kept the **simple** mechanic below: resolve via the normal per-task actions, then a plain single-entity `completeProject` flag flip. See **[ARCHITECTURE-DECISIONS.md #5](../../ARCHITECTURE-DECISIONS.md)** for the full rationale; atomic implementation preserved at commit `0893a86162`.
|
||||
|
||||
> **Deviations from the plan below (as shipped).** Two pieces sketched in the sections that follow were dropped as unnecessary: (1) the `selectCompletedProjects` / `selectPlainArchivedProjects` selectors were never added — the trophy page reads `isDone` inline off `selectArchivedProjectsSortedByTitle`; (2) there is **no celebration effect** — the confetti dialog opens directly from the `completeProject()` click handler in `work-context-menu.component.ts`, which is inherently local, so a replayed/remote op can't pop it (the Rule #1 concern the planned effect guarded against never arises). Treat selector/effect references below as historical design intent, not the shipped shape.
|
||||
|
||||
**Scope:** Give projects a rewarding "done" state. The **append/merge** half ("fold a project's tasks into another") was split out to issue **#8032** after review (YAGNI-adjacent + materially heavier than first scoped).
|
||||
|
||||
## Problem
|
||||
|
||||
Two real friction points drive this:
|
||||
|
||||
1. **A complex chunk of work has no good home.** Today the options are a heavyweight permanent project, or a "mega task" with an ever-growing subtask list. The mega task feels bad: the parent is one perpetually-unchecked item hanging over you, and progress is buried inside it instead of visible as moving pieces.
|
||||
2. **Finishing big work isn't rewarding.** The only end-state for a project is `isArchived` (`project.model.ts:15`, even marked `// TODO remove maybe`). Archiving is _"shove it out of sight"_ — semantically the opposite of celebrating a finish.
|
||||
|
||||
### Key insight — no nesting, no new entity
|
||||
|
||||
Both pains are about **a container you can _finish_**, not about hierarchy. Nesting projects-in-projects works against pain #2 (a sub-project inside a never-ending parent still leaves the parent hanging) and drags in aggregation/cascade/sync cost for little benefit. The lightweight "dump space" people want **is just a regular `Project`** with a missing lifecycle operation: **complete** it → reward + a place to look back. Grouping of related projects is already covered by the menu-tree **folders**; small breakdowns by nested subtasks. So this plan adds **one operation on the existing entity**, not a new type.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- No nested/parent-child projects, no roll-up of time/progress from children.
|
||||
- No new "mini project" entity or UI concept.
|
||||
- No append/merge (→ #8032).
|
||||
- No change to how archiving itself works — completion piggybacks on the `isArchived` flag for menu-hiding, but `isDone` stays a distinct flag so a celebrated finish ≠ a quiet archive.
|
||||
|
||||
## ⚠️ Correction from review — what archiving actually does
|
||||
|
||||
The first draft assumed completing→auto-archiving would run the `ArchiveOperationHandler` and move done tasks into the archive store. **That is false** and was verified against source:
|
||||
|
||||
- `archiveProject` is a pure `isArchived: true` flag flip (`project.reducer.ts:166-177`); the project archive _effects_ are commented out (`project.effects.ts:72`, "CURRENTLY NOT IMPLEMENTED").
|
||||
- `archiveProject` is **not** in `ARCHIVE_AFFECTING_ACTION_TYPES` (`archive-operation-handler.service.ts:40-54`). Only `moveToArchive` / `deleteProject` etc. move tasks to IndexedDB.
|
||||
|
||||
**Implications that shape this plan:**
|
||||
|
||||
- A completed project's tasks **stay live** in the NgRx store. Archiving only hides the project from the active menu (via the `!isArchived` filters).
|
||||
- ⇒ **Stats can be computed live** from the still-live tasks; no snapshot needed (decision below).
|
||||
- ⇒ **Reopen is trivially safe** — tasks never left, so un-archiving fully restores the project. No archive-restore logic.
|
||||
- ⚠️ Done tasks of a completed project remain visible in worklog/search/metrics. Acceptable (they're history), but noted.
|
||||
|
||||
## Resolved decisions
|
||||
|
||||
| # | Decision | Resolution |
|
||||
| --- | ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Q1 | Auto-archive on complete | **Yes** — `completeProject` also sets `isArchived: true` ("complete and out of the way"). This is a flag flip only — menu-hiding, **no** task cleanup (see correction above). |
|
||||
| Q2 | Unfinished tasks | **Prompt** (a plain confirm), default **Move to Inbox** with the count shown; plus "Mark them done" / "Cancel". |
|
||||
| Q3 | Stats live vs. snapshot | **Compute live** — no `completionStats` field. The "mandatory snapshot" reason was based on the false archive premise. |
|
||||
| Q4 | Completion surface | **Split:** `DialogConfirm` for the unfinished-task resolve step, then a **separate** celebration component. |
|
||||
| Q5 | Trophy view | **No new page.** Add a "Completed on X" badge + live stats + **Reopen** to completed rows of the existing archived-projects page, and improve that page. |
|
||||
| Q6 | Append/merge | **Deferred → #8032.** |
|
||||
|
||||
### Done vs. Archived — selector wiring (review-critical)
|
||||
|
||||
`isDone` ⇒ also `isArchived`. **Do NOT narrow `selectArchivedProjects`.** It feeds task-list filtering — `selectArchivedProjectIds` is consumed by `task.selectors.ts:104,181` (`selectTaskEntitiesInActiveProjects`, `selectAllTasksInActiveProjects` → Today/Overdue). Narrowing it to `isArchived && !isDone` would **leak completed projects' tasks back into Today/Overdue** (incl. done tasks still carrying `dueDay`/`dueWithTime`, Rule #5). Instead:
|
||||
|
||||
- **Keep** `selectArchivedProjects` = `isArchived` (covers completed too) → task filtering + menu-hiding stay correct, unchanged.
|
||||
- **Add** `selectCompletedProjects` = `isDone` → highlights/filters completed rows on the trophy page.
|
||||
- **Add** `selectPlainArchivedProjects` = `isArchived && !isDone` → page-only, if we want to visually separate "finished" from "shelved".
|
||||
- **Reopen** clears `isDone` + `doneOn` **and** `isArchived: false` (returns to the active menu).
|
||||
|
||||
### Data model
|
||||
|
||||
Add to `ProjectBasicCfg` (`src/app/features/project/project.model.ts`), mirroring `Task` (`isDone` + `doneOn`):
|
||||
|
||||
```ts
|
||||
export interface ProjectBasicCfg {
|
||||
title: string;
|
||||
isArchived?: boolean;
|
||||
isDone?: boolean; // NEW — completed (also implies isArchived)
|
||||
doneOn?: number | null; // NEW — completion timestamp (ms)
|
||||
isHiddenFromMenu?: boolean;
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
Both new fields **optional** → forward-compatible for sync (typia accepts missing optional fields; only new _required_ fields / literal-union members break old clients; verified `createValidate` does not reject excess props). Default in `DEFAULT_PROJECT` (`project.const.ts:11`): `isDone: false, doneOn: null`. `INBOX_PROJECT` can never be completed (guard like archive). **plugin-api note:** `ProjectCopy` extends the plugin-api `Project`; the new fields live on the app-side `ProjectBasicCfg` and compile fine without touching `packages/plugin-api`. Plugins won't see completion state — intentional (matches non-goals); revisit only if a plugin needs it.
|
||||
|
||||
### Sync-correctness (CLAUDE.md rules)
|
||||
|
||||
- **`completeProject` / `reopenProject` are plain project Updates** (`OpType.Update`, `entityType:'PROJECT'`), modeled exactly like `archiveProject` (`project.actions.ts:76-100`) → captured by the op-log capture effect automatically via `meta`. **Add `ActionType` enum entries** (`action-types.enum.ts`, section P) — the immutable wire format (review caught this omission).
|
||||
- **Must NOT** be added to `ARCHIVE_AFFECTING_ACTION_TYPES`.
|
||||
- **The celebration effect injects `LOCAL_ACTIONS`** (Rule #1) → a remote/replayed `completeProject` never pops a dialog / fires confetti on another device.
|
||||
- **`doneOn` is computed at the call site** (via `DateService`) and passed as a prop — never `Date.now()` in the reducer (Rule #4).
|
||||
- **LWW note (accept):** a concurrent remote `updateProject` (e.g. rename) vs local `completeProject` resolves by coarse whole-entity LWW — same as `archiveProject` today; completion has no archive-win protection, so it _can_ be lost to a concurrent unrelated edit. Not a regression; documented.
|
||||
|
||||
---
|
||||
|
||||
## Implementation
|
||||
|
||||
### 1. State + actions
|
||||
|
||||
- `project.actions.ts`: add `completeProject({ id, doneOn })` and `reopenProject({ id })` (mirror archive, `OpType.Update`). Add matching `ActionType` enum entries.
|
||||
- `project.reducer.ts` (next to archive cases `:166-189`):
|
||||
- `completeProject` → `{ isDone: true, doneOn, isArchived: true }`.
|
||||
- `reopenProject` → `{ isDone: false, doneOn: null, isArchived: false }`.
|
||||
- Guard `INBOX_PROJECT`.
|
||||
- `project.selectors.ts`: add `selectCompletedProjects` + `selectPlainArchivedProjects` (see selector wiring above). **Leave `selectArchivedProjects` unchanged.**
|
||||
- `project.service.ts`: `complete(id)` / `reopen(id)` wrappers (mirror `archive()`/`unarchive()` `:145`).
|
||||
|
||||
### 2. Completion flow
|
||||
|
||||
Trigger: a "Complete project" item in the project context menu (`work-context-menu.component.{ts,html}:79-111`), beside Archive. Order/group it and add microcopy so "Complete" vs "Archive" is legible (both end up `isArchived`; only one celebrates).
|
||||
|
||||
1. **Resolve unfinished tasks** (only if any undone tasks across `taskIds` + `backlogTaskIds`, incl. subtasks). Open a `DialogConfirmComponent`-style prompt showing **the count**, with:
|
||||
- **Move to Inbox** _(default)_ — safe carry-forward.
|
||||
- **Mark them done** — "close enough."
|
||||
- **Cancel.**
|
||||
- _Bulk mechanic (chosen — Option C):_ no bulk action exists today, and we deliberately did **not** add one. Loop the existing per-task action (`moveToOtherProject` / `updateTask isDone`) and apply the **Rule #6 flush** (`await new Promise(r => setTimeout(r, 0))`) after the loop. (A single atomic meta-reducer op was tried and **reverted** — see the Revision note above and ARCHITECTURE-DECISIONS.md #5. Trade-off: N+1 ops per completion, accepted.)
|
||||
2. `ProjectService.complete(id)` dispatches `completeProject` (reducer sets done + archived).
|
||||
3. **Celebrate** (section 3).
|
||||
4. If the completed project was active, navigate to `/` (archive already does this; also clear any selected-task/detail-panel pointing at the now-hidden project — cf. recent fix `d44cb1138d`).
|
||||
5. **Undo:** none. Completion is **not reversible** via a snack — its task resolution (move-to-inbox / mark-done) can't be cleanly restored by `reopenProject`, which only clears the project flags. The fullscreen celebration is the feedback; reactivation lives on the archived-projects page.
|
||||
|
||||
### 3. Celebration (separate component)
|
||||
|
||||
A small `ProjectCompleteCelebrationComponent` (dialog), reusing the layout language of `focus-mode/focus-mode-session-done` and the "summary-point" grid of `daily-summary`:
|
||||
|
||||
- **Confetti** via `ConfettiService.createConfetti()` — gate on **both** `isDisableAnimations` and `isDisableCelebration` (no confetti → dialog still shows).
|
||||
- "🎉 Project complete" + project title + the **stats grid** (section 4).
|
||||
- Primary **Done**; secondary **View completed projects** → the archived page (trophy section).
|
||||
- Reopen is offered via the post-complete snack (step 2.5) and on the trophy page, not here.
|
||||
|
||||
### 4. Stats (computed live)
|
||||
|
||||
Computed on demand for the celebration and the trophy rows, from the still-live tasks:
|
||||
|
||||
- **Tasks done / total** — count project tasks (`taskIds` + `backlogTaskIds`; decide subtask inclusion, state it consistently) by `isDone`.
|
||||
- **Hours worked** — sum `task.timeSpent` over the project's **parent** tasks only (a parent's `timeSpent` already includes subtasks — `task.reducer.util.ts:53-72`; summing both double-counts). Alternatively read `TimeTrackingState.project[projectId]`.
|
||||
- **Days worked** — distinct `timeSpentOnDay` keys across tasks.
|
||||
- **Finished in N days** — `startedOn`→`doneOn` calendar span. `startedOn` = earliest `timeSpentOnDay` key, fallback `project.created`. **This is the one stat that works with time-tracking off** — feature it.
|
||||
- `worklog/util/get-time-spent-for-day.util.ts` aggregates per-day; reuse.
|
||||
|
||||
**Degrade gracefully:** many users don't track time. When `timeSpent === 0`, **hide** hours/days rows (don't show "0h over 0 days" — demotivating). Drop "avg per day" (vanity, prone to "0.4h/day").
|
||||
|
||||
### 5. Trophy view (improve the archived page)
|
||||
|
||||
Completed projects already land on `/archived-projects` (they're `isArchived`). Rather than a new page:
|
||||
|
||||
- On completed rows (`selectCompletedProjects`), show a **trophy/badge + "Completed on `doneOn`"** + the live stats, and offer **Reopen** (`reopenProject`) instead of Unarchive.
|
||||
- **Improve the page** generally (it's currently a bare list): clearer layout, the stat readout, sort by `doneOn`, and make it more discoverable (the celebration's "View completed projects" links here; consider a findable entry rather than only the visibility menu).
|
||||
- Optionally use `selectPlainArchivedProjects` to visually separate "Finished" from "Shelved".
|
||||
|
||||
### 6. Testing
|
||||
|
||||
- Reducer: `completeProject` sets `isDone`+`doneOn`+`isArchived:true`; `reopenProject` clears all three; INBOX guarded.
|
||||
- **Regression (review-critical):** completing a project keeps its (done, `dueDay`-carrying) tasks **out of** Today/Overdue — i.e. `selectArchivedProjects` still includes completed projects and the task-filtering selectors are unchanged. Add an explicit test.
|
||||
- Selector: `selectCompletedProjects` = `isDone`; `selectPlainArchivedProjects` = `isArchived && !isDone`.
|
||||
- Stats: live math — no double-count of parent+subtask time; `finished in N days` with time-tracking off.
|
||||
- Effect: celebration effect uses `LOCAL_ACTIONS` (no confetti/dialog on replayed/remote `completeProject`).
|
||||
- Translations: `en.json` only, via `T`. User-facing → update docs per `docs/documentation-guide.md`.
|
||||
|
||||
## Risks
|
||||
|
||||
- **Selector leak (mitigated):** the §"Done vs Archived" wiring + the regression test exist specifically to prevent completed tasks reappearing in Today/Overdue. Audit all `selectArchivedProjects`/`selectArchivedProjectIds` consumers (`project.service.ts:83`, `magic-nav-config.service.ts:85`, `archived-projects-page.component.ts:52`, `task.selectors.ts:104,181`, `task-repeat-cfg.selectors.ts:22`).
|
||||
- **Discoverability:** auto-archive makes a completed project vanish instantly; the reward is a one-shot unless the trophy page is findable. Undo snack + an improved, reachable trophy page mitigate.
|
||||
- **Inbox flood:** "Move to Inbox" on a big dump-space project can dump many tasks into Inbox — hence showing the count, and offering "Mark done".
|
||||
- **Live-stat drift (accepted):** if tasks are later deleted/manually-archived, recomputed stats shift. Acceptable for a retrospective view; this is the cost of choosing live-compute over a snapshot.
|
||||
|
||||
## Open items
|
||||
|
||||
- Trophy-page improvement scope (how far to take the redesign).
|
||||
- Unfinished-task default — confirm Inbox vs. mark-done after seeing it in use.
|
||||
- Subtask inclusion in tasks-done count (product call).
|
||||
|
||||
## Key files
|
||||
|
||||
| Area | File |
|
||||
| ----------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Model / defaults | `src/app/features/project/project.model.ts`, `project.const.ts` |
|
||||
| Actions / reducer / selectors | `src/app/features/project/store/project.actions.ts`, `project.reducer.ts`, `project.selectors.ts`; `action-types.enum.ts` |
|
||||
| Service | `src/app/features/project/project.service.ts` |
|
||||
| Context menu / trigger | `src/app/core-ui/work-context-menu/work-context-menu.component.{ts,html}` |
|
||||
| Trophy page | `src/app/pages/archived-projects-page/` (enhance) |
|
||||
| Reward | `src/app/core/confetti/confetti.service.ts`; ref `features/focus-mode/focus-mode-session-done/`, `pages/daily-summary/` |
|
||||
| Stats | `src/app/features/tasks/store/task.reducer.util.ts` (rollup caveat), `features/time-tracking/time-tracking.model.ts`, `features/worklog/util/get-time-spent-for-day.util.ts` |
|
||||
| Resolve dialog | `src/app/ui/dialog-confirm/dialog-confirm.component.ts` |
|
||||
| Append/merge (deferred) | issue **#8032** |
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -678,4 +678,33 @@ export class ProjectPage extends BasePage {
|
|||
// Wait for project to be removed from sidebar
|
||||
await projectTreeItem.waitFor({ state: 'hidden', timeout: 5000 });
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the right-click context menu for a project in the sidebar.
|
||||
*/
|
||||
async openProjectContextMenu(projectName: string): Promise<void> {
|
||||
const fullProjectName = this.applyPrefix(projectName);
|
||||
|
||||
const projectsTree = this.page
|
||||
.locator('nav-list-tree')
|
||||
.filter({ hasText: 'Projects' });
|
||||
|
||||
// Ensure the Projects group is expanded
|
||||
const projectsGroupBtn = projectsTree.locator('nav-item button').first();
|
||||
const projectTreeItem = projectsTree
|
||||
.locator('[role="treeitem"]')
|
||||
.filter({ hasText: fullProjectName })
|
||||
.first();
|
||||
await projectsGroupBtn.waitFor({ state: 'visible', timeout: 5000 });
|
||||
if ((await projectsGroupBtn.getAttribute('aria-expanded')) !== 'true') {
|
||||
await projectsGroupBtn.click();
|
||||
}
|
||||
|
||||
await projectTreeItem.waitFor({ state: 'visible', timeout: 5000 });
|
||||
await projectTreeItem.click({ button: 'right' });
|
||||
|
||||
await this.page
|
||||
.locator('.mat-mdc-menu-content')
|
||||
.waitFor({ state: 'visible', timeout: 3000 });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
66
e2e/tests/project/project-completion.spec.ts
Normal file
66
e2e/tests/project/project-completion.spec.ts
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
import { expect, test } from '../../fixtures/test.fixture';
|
||||
import { ProjectPage } from '../../pages/project.page';
|
||||
import { WorkViewPage } from '../../pages/work-view.page';
|
||||
import { expectNoGlobalError } from '../../utils/assertions';
|
||||
|
||||
test.describe('Project completion', () => {
|
||||
let projectPage: ProjectPage;
|
||||
let workViewPage: WorkViewPage;
|
||||
|
||||
test.beforeEach(async ({ page, testPrefix }) => {
|
||||
projectPage = new ProjectPage(page, testPrefix);
|
||||
workViewPage = new WorkViewPage(page, testPrefix);
|
||||
await workViewPage.waitForTaskList();
|
||||
});
|
||||
|
||||
test('complete a project and see the celebration', async ({ page }) => {
|
||||
// Arrange: a project with one done and one unfinished task
|
||||
await projectPage.createProject('Test Project');
|
||||
await projectPage.navigateToProjectByName('Test Project');
|
||||
await workViewPage.waitForTaskList();
|
||||
await workViewPage.addTask('Completion task 1', true);
|
||||
await workViewPage.addTask('Completion task 2');
|
||||
|
||||
const firstTask = page.locator('task').first();
|
||||
await firstTask.hover();
|
||||
const doneBtn = firstTask.locator('done-toggle');
|
||||
await doneBtn.waitFor({ state: 'visible' });
|
||||
await doneBtn.click();
|
||||
|
||||
// Act: complete the project from the sidebar context menu
|
||||
await projectPage.openProjectContextMenu('Test Project');
|
||||
await page
|
||||
.locator('.mat-mdc-menu-content button')
|
||||
.filter({ hasText: /complete project/i })
|
||||
.click();
|
||||
|
||||
// The unfinished task triggers the resolve prompt → mark it done
|
||||
const resolveDialog = page.locator('dialog-complete-resolve-tasks');
|
||||
await expect(resolveDialog).toBeVisible();
|
||||
await resolveDialog.getByRole('button', { name: /mark as done/i }).click();
|
||||
|
||||
// Confirm before final completion
|
||||
const confirmDialog = page.locator('dialog-confirm');
|
||||
await expect(confirmDialog).toBeVisible();
|
||||
await confirmDialog.getByRole('button', { name: /complete project/i }).click();
|
||||
|
||||
// Celebration dialog with stats
|
||||
const celebration = page.locator('dialog-project-complete');
|
||||
await expect(celebration).toBeVisible();
|
||||
const celebrationPanel = page.locator('.project-complete-fullscreen-dialog');
|
||||
await expect(celebrationPanel).toBeVisible();
|
||||
const panelBox = await celebrationPanel.boundingBox();
|
||||
const viewport = page.viewportSize();
|
||||
expect(panelBox?.width ?? 0).toBeGreaterThan((viewport?.width ?? 0) * 0.95);
|
||||
expect(panelBox?.height ?? 0).toBeGreaterThan((viewport?.height ?? 0) * 0.95);
|
||||
await expect(celebration.getByText(/project complete/i)).toBeVisible();
|
||||
await expect(celebration.getByText('Test Project')).toBeVisible();
|
||||
|
||||
// Close the celebration. There is no reopen/undo here — completion resolves
|
||||
// tasks irreversibly; reactivation lives on the archived-projects page.
|
||||
await celebration.locator('.actions button').click();
|
||||
await expect(celebration).toBeHidden();
|
||||
|
||||
await expectNoGlobalError(page);
|
||||
});
|
||||
});
|
||||
|
|
@ -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$
|
||||
|
|
|
|||
|
|
@ -74,16 +74,23 @@
|
|||
(click)="restoreProject()"
|
||||
mat-menu-item
|
||||
>
|
||||
<mat-icon>unarchive</mat-icon>
|
||||
<span class="text">{{ T.F.PROJECT.ARCHIVED_PROJECTS.UNARCHIVE | translate }}</span>
|
||||
<mat-icon>{{ (isDone$ | async) ? 'replay' : 'unarchive' }}</mat-icon>
|
||||
<span class="text">
|
||||
{{
|
||||
((isDone$ | async)
|
||||
? T.F.PROJECT.COMPLETE.REOPEN
|
||||
: T.F.PROJECT.ARCHIVED_PROJECTS.UNARCHIVE
|
||||
) | translate
|
||||
}}
|
||||
</span>
|
||||
</button>
|
||||
} @else {
|
||||
<button
|
||||
(click)="archiveProject()"
|
||||
(click)="completeProject()"
|
||||
mat-menu-item
|
||||
>
|
||||
<mat-icon>archive</mat-icon>
|
||||
<span class="text">{{ T.MH.ARCHIVE_PROJECT | translate }}</span>
|
||||
<mat-icon>check_circle</mat-icon>
|
||||
<span class="text">{{ T.MH.COMPLETE_PROJECT | translate }}</span>
|
||||
</button>
|
||||
}
|
||||
<button
|
||||
|
|
|
|||
|
|
@ -12,6 +12,10 @@ import { ShareService } from '../../core/share/share.service';
|
|||
import { Store } from '@ngrx/store';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { WorkContextType } from '../../features/work-context/work-context.model';
|
||||
import { DialogCompleteResolveTasksComponent } from '../../features/project/dialog-complete-resolve-tasks/dialog-complete-resolve-tasks.component';
|
||||
import { DialogConfirmComponent } from '../../ui/dialog-confirm/dialog-confirm.component';
|
||||
import { DateService } from '../../core/date/date.service';
|
||||
import { DialogProjectCompleteComponent } from '../../features/project/dialog-project-complete/dialog-project-complete.component';
|
||||
|
||||
describe('WorkContextMenuComponent', () => {
|
||||
let component: WorkContextMenuComponent;
|
||||
|
|
@ -19,8 +23,10 @@ describe('WorkContextMenuComponent', () => {
|
|||
let mockProjectService: jasmine.SpyObj<ProjectService>;
|
||||
let mockWorkContextService: { activeWorkContextId: string | undefined };
|
||||
let mockMatDialog: jasmine.SpyObj<MatDialog>;
|
||||
let resolveResult$: any;
|
||||
let confirmResult$: any;
|
||||
let router: Router;
|
||||
const logicalDoneOn = new Date(2026, 5, 5, 1, 0, 0).getTime();
|
||||
|
||||
// Finds the rendered mat-menu-item whose icon matches `iconName`.
|
||||
const menuButtonByIcon = (iconName: string): HTMLButtonElement | null => {
|
||||
|
|
@ -32,14 +38,29 @@ describe('WorkContextMenuComponent', () => {
|
|||
|
||||
beforeEach(() => {
|
||||
mockProjectService = jasmine.createSpyObj('ProjectService', [
|
||||
'archive',
|
||||
'unarchive',
|
||||
'complete',
|
||||
'reopen',
|
||||
'getCompletionInfo',
|
||||
'moveTasksToInbox',
|
||||
'markTasksDone',
|
||||
'getByIdOnce$',
|
||||
'getByIdLive$',
|
||||
]);
|
||||
mockProjectService.getByIdOnce$.and.returnValue(
|
||||
of({ id: 'project-123', title: 'Demo project' } as any),
|
||||
);
|
||||
// Default: nothing unfinished → completion skips the resolve prompt.
|
||||
mockProjectService.getCompletionInfo.and.returnValue(
|
||||
Promise.resolve({
|
||||
topLevelTasks: [],
|
||||
allTasks: [],
|
||||
unfinishedTasks: [],
|
||||
topLevelTasksWithUnfinishedWork: [],
|
||||
}),
|
||||
);
|
||||
mockProjectService.moveTasksToInbox.and.returnValue(Promise.resolve());
|
||||
mockProjectService.markTasksDone.and.returnValue(Promise.resolve());
|
||||
mockProjectService.getByIdLive$.and.returnValue(
|
||||
of({ id: 'project-123', title: 'Demo project' } as any),
|
||||
);
|
||||
|
|
@ -49,10 +70,17 @@ describe('WorkContextMenuComponent', () => {
|
|||
mockShareService.getShareSupport.and.returnValue(Promise.resolve('none'));
|
||||
|
||||
mockMatDialog = jasmine.createSpyObj('MatDialog', ['open']);
|
||||
resolveResult$ = of(undefined);
|
||||
confirmResult$ = of(true);
|
||||
mockMatDialog.open.and.callFake(
|
||||
() => ({ afterClosed: () => confirmResult$ }) as MatDialogRef<unknown>,
|
||||
);
|
||||
mockMatDialog.open.and.callFake((componentOrTemplateRef) => {
|
||||
if (componentOrTemplateRef === DialogCompleteResolveTasksComponent) {
|
||||
return { afterClosed: () => resolveResult$ } as MatDialogRef<unknown>;
|
||||
}
|
||||
if (componentOrTemplateRef === DialogConfirmComponent) {
|
||||
return { afterClosed: () => confirmResult$ } as MatDialogRef<unknown>;
|
||||
}
|
||||
return { afterClosed: () => of(undefined) } as MatDialogRef<unknown>;
|
||||
});
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
imports: [WorkContextMenuComponent, TranslateModule.forRoot()],
|
||||
|
|
@ -69,6 +97,12 @@ describe('WorkContextMenuComponent', () => {
|
|||
{ provide: WorkContextMarkdownService, useValue: {} },
|
||||
{ provide: ShareService, useValue: mockShareService },
|
||||
{ provide: Store, useValue: jasmine.createSpyObj('Store', ['dispatch']) },
|
||||
{
|
||||
provide: DateService,
|
||||
useValue: {
|
||||
getLogicalTodayDate: () => new Date(logicalDoneOn),
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
|
|
@ -80,31 +114,181 @@ describe('WorkContextMenuComponent', () => {
|
|||
spyOn(router, 'navigateByUrl').and.returnValue(Promise.resolve(true));
|
||||
});
|
||||
|
||||
describe('archiveProject()', () => {
|
||||
it('archives after confirmation', async () => {
|
||||
await component.archiveProject();
|
||||
expect(mockMatDialog.open).toHaveBeenCalled();
|
||||
expect(mockProjectService.archive).toHaveBeenCalledWith('project-123');
|
||||
});
|
||||
describe('completeProject()', () => {
|
||||
const undoneInfo = {
|
||||
topLevelTasks: [{ id: 't1', isDone: false } as any],
|
||||
allTasks: [{ id: 't1', isDone: false } as any],
|
||||
unfinishedTasks: [{ id: 't1', isDone: false } as any],
|
||||
topLevelTasksWithUnfinishedWork: [{ id: 't1', isDone: false } as any],
|
||||
};
|
||||
|
||||
it('navigates away when archiving the currently active project', async () => {
|
||||
it('completes the project and navigates away when it is active', async () => {
|
||||
mockWorkContextService.activeWorkContextId = 'project-123';
|
||||
await component.archiveProject();
|
||||
await component.completeProject();
|
||||
expect(mockProjectService.complete).toHaveBeenCalledWith(
|
||||
'project-123',
|
||||
logicalDoneOn,
|
||||
);
|
||||
expect(router.navigateByUrl).toHaveBeenCalledWith('/');
|
||||
});
|
||||
|
||||
it('does not navigate when archiving a non-active project', async () => {
|
||||
it('does not navigate when completing a non-active project', async () => {
|
||||
mockWorkContextService.activeWorkContextId = 'other-project';
|
||||
await component.archiveProject();
|
||||
await component.completeProject();
|
||||
expect(mockProjectService.complete).toHaveBeenCalled();
|
||||
expect(router.navigateByUrl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does nothing when the confirmation is cancelled', async () => {
|
||||
it('moves unfinished tasks to the Inbox when chosen, then completes', async () => {
|
||||
mockProjectService.getCompletionInfo.and.returnValue(Promise.resolve(undoneInfo));
|
||||
resolveResult$ = of('inbox');
|
||||
await component.completeProject();
|
||||
expect(mockProjectService.moveTasksToInbox).toHaveBeenCalledWith(
|
||||
undoneInfo.topLevelTasksWithUnfinishedWork,
|
||||
);
|
||||
expect(mockProjectService.complete).toHaveBeenCalled();
|
||||
// Resolution must run BEFORE the flag flip, else the project is archived
|
||||
// while its unfinished tasks are still attached.
|
||||
expect(mockProjectService.moveTasksToInbox).toHaveBeenCalledBefore(
|
||||
mockProjectService.complete,
|
||||
);
|
||||
});
|
||||
|
||||
it('resolves using the initially captured info, not a re-fetch', async () => {
|
||||
const refreshedInfo = {
|
||||
topLevelTasks: [{ id: 't2', isDone: false } as any],
|
||||
allTasks: [{ id: 't2', isDone: false } as any],
|
||||
unfinishedTasks: [{ id: 't2', isDone: false } as any],
|
||||
topLevelTasksWithUnfinishedWork: [{ id: 't2', isDone: false } as any],
|
||||
};
|
||||
// First call gates the resolve prompt; the second (post-resolution) only
|
||||
// feeds the celebration stats — it must NOT change which tasks get resolved.
|
||||
mockProjectService.getCompletionInfo.and.returnValues(
|
||||
Promise.resolve(undoneInfo),
|
||||
Promise.resolve(refreshedInfo),
|
||||
);
|
||||
resolveResult$ = of('inbox');
|
||||
|
||||
await component.completeProject();
|
||||
|
||||
expect(mockProjectService.moveTasksToInbox).toHaveBeenCalledWith(
|
||||
undoneInfo.topLevelTasksWithUnfinishedWork,
|
||||
);
|
||||
});
|
||||
|
||||
it('recomputes completion info after resolving so stats reflect the final list', async () => {
|
||||
mockProjectService.getCompletionInfo.and.returnValue(Promise.resolve(undoneInfo));
|
||||
resolveResult$ = of('markDone');
|
||||
|
||||
await component.completeProject();
|
||||
|
||||
// Once to gate the prompt, once after resolution for the stats.
|
||||
expect(mockProjectService.getCompletionInfo).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('fetches completion info only once when there is no unfinished work', async () => {
|
||||
await component.completeProject();
|
||||
expect(mockProjectService.getCompletionInfo).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('shows an error and aborts when completion info cannot be loaded', async () => {
|
||||
spyOn(console, 'error');
|
||||
const snackSpy = spyOn(TestBed.inject(SnackService), 'open');
|
||||
mockProjectService.getCompletionInfo.and.returnValue(
|
||||
Promise.reject(new Error('archive load failed')),
|
||||
);
|
||||
|
||||
await component.completeProject();
|
||||
|
||||
expect(mockProjectService.complete).not.toHaveBeenCalled();
|
||||
expect(snackSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('marks all unfinished tasks done when chosen, then completes', async () => {
|
||||
mockProjectService.getCompletionInfo.and.returnValue(Promise.resolve(undoneInfo));
|
||||
resolveResult$ = of('markDone');
|
||||
await component.completeProject();
|
||||
expect(mockProjectService.markTasksDone).toHaveBeenCalledWith(
|
||||
undoneInfo.unfinishedTasks,
|
||||
);
|
||||
expect(mockProjectService.complete).toHaveBeenCalled();
|
||||
// Resolution must run BEFORE the flag flip.
|
||||
expect(mockProjectService.markTasksDone).toHaveBeenCalledBefore(
|
||||
mockProjectService.complete,
|
||||
);
|
||||
});
|
||||
|
||||
it('does not move unfinished tasks when Inbox is chosen but confirmation is cancelled', async () => {
|
||||
mockProjectService.getCompletionInfo.and.returnValue(Promise.resolve(undoneInfo));
|
||||
resolveResult$ = of('inbox');
|
||||
confirmResult$ = of(false);
|
||||
mockWorkContextService.activeWorkContextId = 'project-123';
|
||||
await component.archiveProject();
|
||||
expect(mockProjectService.archive).not.toHaveBeenCalled();
|
||||
expect(router.navigateByUrl).not.toHaveBeenCalled();
|
||||
|
||||
await component.completeProject();
|
||||
|
||||
expect(mockProjectService.complete).not.toHaveBeenCalled();
|
||||
expect(
|
||||
mockMatDialog.open.calls
|
||||
.allArgs()
|
||||
.some((args) => args[0] === DialogProjectCompleteComponent),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('does not mark unfinished tasks done when chosen but confirmation is cancelled', async () => {
|
||||
mockProjectService.getCompletionInfo.and.returnValue(Promise.resolve(undoneInfo));
|
||||
resolveResult$ = of('markDone');
|
||||
confirmResult$ = of(false);
|
||||
|
||||
await component.completeProject();
|
||||
|
||||
expect(mockProjectService.complete).not.toHaveBeenCalled();
|
||||
expect(
|
||||
mockMatDialog.open.calls
|
||||
.allArgs()
|
||||
.some((args) => args[0] === DialogProjectCompleteComponent),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('aborts without completing when the unfinished-task prompt is cancelled', async () => {
|
||||
mockProjectService.getCompletionInfo.and.returnValue(Promise.resolve(undoneInfo));
|
||||
resolveResult$ = of(undefined);
|
||||
await component.completeProject();
|
||||
expect(mockProjectService.complete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('asks for confirmation before completing', async () => {
|
||||
await component.completeProject();
|
||||
expect(mockMatDialog.open).toHaveBeenCalledWith(
|
||||
DialogConfirmComponent,
|
||||
jasmine.objectContaining({
|
||||
data: jasmine.objectContaining({
|
||||
message: jasmine.any(String),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(mockProjectService.complete).toHaveBeenCalled();
|
||||
const confirmCall = mockMatDialog.open.calls
|
||||
.all()
|
||||
.find((call) => call.args[0] === DialogConfirmComponent);
|
||||
expect((confirmCall as any)?.invocationOrder).toBeLessThan(
|
||||
(mockProjectService.complete.calls.first() as any).invocationOrder,
|
||||
);
|
||||
});
|
||||
|
||||
it('opens the celebration as a fullscreen overlay', async () => {
|
||||
await component.completeProject();
|
||||
expect(mockMatDialog.open).toHaveBeenCalledWith(
|
||||
DialogProjectCompleteComponent,
|
||||
jasmine.objectContaining({
|
||||
panelClass: 'project-complete-fullscreen-dialog',
|
||||
ariaLabelledBy: 'project-complete-title',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('aborts without completing when confirmation is cancelled', async () => {
|
||||
confirmResult$ = of(false);
|
||||
await component.completeProject();
|
||||
expect(mockProjectService.complete).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -143,10 +327,13 @@ describe('WorkContextMenuComponent', () => {
|
|||
});
|
||||
|
||||
describe('rendered archive/restore action', () => {
|
||||
it('renders Restore (and wires it up) for an archived project', () => {
|
||||
it('renders Restore (and wires it up) for an archived project', async () => {
|
||||
mockProjectService.getByIdLive$.and.returnValue(
|
||||
of({ id: 'project-123', isArchived: true } as any),
|
||||
);
|
||||
mockProjectService.getByIdOnce$.and.returnValue(
|
||||
of({ id: 'project-123', isArchived: true, isDone: false } as any),
|
||||
);
|
||||
mockProjectService.unarchive.and.returnValue(Promise.resolve());
|
||||
fixture.detectChanges();
|
||||
|
||||
|
|
@ -155,17 +342,41 @@ describe('WorkContextMenuComponent', () => {
|
|||
expect(restoreBtn).toBeTruthy();
|
||||
|
||||
restoreBtn!.click();
|
||||
await fixture.whenStable();
|
||||
expect(mockProjectService.unarchive).toHaveBeenCalledWith('project-123');
|
||||
});
|
||||
|
||||
it('renders Archive for a non-archived project', () => {
|
||||
it('renders Reopen for a completed project', async () => {
|
||||
mockProjectService.getByIdLive$.and.returnValue(
|
||||
of({ id: 'project-123', isArchived: true, isDone: true } as any),
|
||||
);
|
||||
mockProjectService.getByIdOnce$.and.returnValue(
|
||||
of({ id: 'project-123', isArchived: true, isDone: true } as any),
|
||||
);
|
||||
fixture.detectChanges();
|
||||
|
||||
const reopenBtn = menuButtonByIcon('replay');
|
||||
expect(reopenBtn).toBeTruthy();
|
||||
|
||||
reopenBtn!.click();
|
||||
await fixture.whenStable();
|
||||
expect(mockProjectService.reopen).toHaveBeenCalledWith('project-123', {
|
||||
id: 'project-123',
|
||||
isArchived: true,
|
||||
isDone: true,
|
||||
} as any);
|
||||
});
|
||||
|
||||
it('renders Complete but not Archive for a non-archived project', async () => {
|
||||
mockProjectService.getByIdLive$.and.returnValue(
|
||||
of({ id: 'project-123', isArchived: false } as any),
|
||||
);
|
||||
fixture.detectChanges();
|
||||
|
||||
// Archive was collapsed into Complete — it's the single retire path.
|
||||
expect(menuButtonByIcon('unarchive')).toBeNull();
|
||||
expect(menuButtonByIcon('archive')).toBeTruthy();
|
||||
expect(menuButtonByIcon('archive')).toBeNull();
|
||||
expect(menuButtonByIcon('check_circle')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -16,7 +16,20 @@ import { first, map } from 'rxjs/operators';
|
|||
import { WorkContextService } from '../../features/work-context/work-context.service';
|
||||
import { Router, RouterLink, RouterModule } from '@angular/router';
|
||||
|
||||
import { ProjectService } from '../../features/project/project.service';
|
||||
import {
|
||||
ProjectCompletionInfo,
|
||||
ProjectService,
|
||||
} from '../../features/project/project.service';
|
||||
import { Project } from '../../features/project/project.model';
|
||||
import {
|
||||
getProjectCompletionStats,
|
||||
ProjectCompletionStats,
|
||||
} from '../../features/project/project-completion-stats.util';
|
||||
import { DialogProjectCompleteComponent } from '../../features/project/dialog-project-complete/dialog-project-complete.component';
|
||||
import {
|
||||
DialogCompleteResolveTasksComponent,
|
||||
ResolveUnfinishedTasksChoice,
|
||||
} from '../../features/project/dialog-complete-resolve-tasks/dialog-complete-resolve-tasks.component';
|
||||
import { SectionService } from '../../features/section/section.service';
|
||||
import { DialogPromptComponent } from '../../ui/dialog-prompt/dialog-prompt.component';
|
||||
import { MatMenuItem } from '@angular/material/menu';
|
||||
|
|
@ -31,6 +44,7 @@ import { TaskSharedActions } from '../../root-store/meta/task-shared.actions';
|
|||
import { TaskWithSubTasks } from '../../features/tasks/task.model';
|
||||
import { firstValueFrom, Observable, of } from 'rxjs';
|
||||
import { AsyncPipe } from '@angular/common';
|
||||
import { DateService } from '../../core/date/date.service';
|
||||
import { openWorkContextSettingsDialog } from '../../features/work-context/dialog-work-context-settings/open-work-context-settings-dialog';
|
||||
|
||||
@Component({
|
||||
|
|
@ -52,6 +66,7 @@ export class WorkContextMenuComponent implements OnInit {
|
|||
private _shareService = inject(ShareService);
|
||||
private _cd = inject(ChangeDetectorRef);
|
||||
private _store = inject(Store);
|
||||
private _dateService = inject(DateService);
|
||||
|
||||
// TODO: Skipped for migration because:
|
||||
// This input is used in a control flow expression (e.g. `@if` or `*ngIf`)
|
||||
|
|
@ -61,6 +76,7 @@ export class WorkContextMenuComponent implements OnInit {
|
|||
TODAY_TAG_ID: string = TODAY_TAG.id as string;
|
||||
isForProject: boolean = true;
|
||||
isArchived$: Observable<boolean> = of(false);
|
||||
isDone$: Observable<boolean> = of(false);
|
||||
base: string = 'project';
|
||||
shareSupport: ShareSupport = 'none';
|
||||
|
||||
|
|
@ -76,6 +92,9 @@ export class WorkContextMenuComponent implements OnInit {
|
|||
this.isArchived$ = this._projectService
|
||||
.getByIdLive$(this.contextId)
|
||||
.pipe(map((project) => !!project?.isArchived));
|
||||
this.isDone$ = this._projectService
|
||||
.getByIdLive$(this.contextId)
|
||||
.pipe(map((project) => !!project?.isDone));
|
||||
}
|
||||
const support = await this._shareService.getShareSupport();
|
||||
this._setShareSupport(support);
|
||||
|
|
@ -131,37 +150,146 @@ export class WorkContextMenuComponent implements OnInit {
|
|||
}
|
||||
}
|
||||
|
||||
async archiveProject(): Promise<void> {
|
||||
async completeProject(): Promise<void> {
|
||||
const project = await firstValueFrom(
|
||||
this._projectService.getByIdOnce$(this.contextId),
|
||||
);
|
||||
if (!project) {
|
||||
return;
|
||||
}
|
||||
const isConfirmed = await firstValueFrom(
|
||||
|
||||
const info = await this._getCompletionInfoOrNotify();
|
||||
if (!info) {
|
||||
return;
|
||||
}
|
||||
|
||||
let resolution: ResolveUnfinishedTasksChoice | undefined;
|
||||
// Auto-archiving would otherwise bury live, undone work — ask first.
|
||||
if (info.unfinishedTasks.length) {
|
||||
resolution = await this._promptResolveUnfinishedTasks(
|
||||
project.title,
|
||||
info.unfinishedTasks.length,
|
||||
);
|
||||
if (!resolution) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!(await this._confirmCompletion(project.title))) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Resolve unfinished work via the normal per-task actions BEFORE completing,
|
||||
// so every downstream effect (issue sync, reminders, repeat-cfg) and
|
||||
// per-entity conflict detection fires naturally. Completion itself is then a
|
||||
// plain single-entity project flag flip.
|
||||
await this._applyResolution(resolution, info);
|
||||
|
||||
// Recompute after resolution so the stats reflect the final task list.
|
||||
let statsInfo = info;
|
||||
if (resolution) {
|
||||
const refreshed = await this._getCompletionInfoOrNotify();
|
||||
if (!refreshed) {
|
||||
return;
|
||||
}
|
||||
statsInfo = refreshed;
|
||||
}
|
||||
|
||||
const doneOn = this._dateService.getLogicalTodayDate().getTime();
|
||||
const stats = getProjectCompletionStats(
|
||||
statsInfo.topLevelTasks,
|
||||
statsInfo.allTasks,
|
||||
doneOn,
|
||||
);
|
||||
|
||||
const activeId = this._workContextService.activeWorkContextId;
|
||||
this._projectService.complete(this.contextId, doneOn);
|
||||
|
||||
// Navigate away BEFORE opening the celebration: MatDialog's closeOnNavigation
|
||||
// (default true) would otherwise dismiss the dialog the moment we leave the
|
||||
// now-completed project's route.
|
||||
if (activeId === this.contextId) {
|
||||
await this._router.navigateByUrl('/');
|
||||
}
|
||||
|
||||
this._openCelebrationDialog(project, stats);
|
||||
}
|
||||
|
||||
private async _applyResolution(
|
||||
resolution: ResolveUnfinishedTasksChoice | undefined,
|
||||
info: ProjectCompletionInfo,
|
||||
): Promise<void> {
|
||||
if (resolution === 'inbox') {
|
||||
await this._projectService.moveTasksToInbox(info.topLevelTasksWithUnfinishedWork);
|
||||
} else if (resolution === 'markDone') {
|
||||
await this._projectService.markTasksDone(info.unfinishedTasks);
|
||||
}
|
||||
}
|
||||
|
||||
private _openCelebrationDialog(project: Project, stats: ProjectCompletionStats): void {
|
||||
// Fullscreen sizing lives in the .project-complete-fullscreen-dialog
|
||||
// panelClass (handles dvh + mobile safe-areas); don't duplicate it here.
|
||||
this._matDialog.open(DialogProjectCompleteComponent, {
|
||||
restoreFocus: true,
|
||||
panelClass: 'project-complete-fullscreen-dialog',
|
||||
ariaLabelledBy: 'project-complete-title',
|
||||
data: { project, stats },
|
||||
});
|
||||
}
|
||||
|
||||
private async _getCompletionInfoOrNotify(): Promise<ProjectCompletionInfo | null> {
|
||||
try {
|
||||
return await this._projectService.getCompletionInfo(this.contextId);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
this._snackService.open({ type: 'ERROR', msg: T.F.PROJECT.COMPLETE.ERROR });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private _promptResolveUnfinishedTasks(
|
||||
title: string,
|
||||
nr: number,
|
||||
): Promise<ResolveUnfinishedTasksChoice | undefined> {
|
||||
return firstValueFrom(
|
||||
this._matDialog
|
||||
.open(DialogCompleteResolveTasksComponent, {
|
||||
restoreFocus: true,
|
||||
data: { title, nr },
|
||||
})
|
||||
.afterClosed(),
|
||||
);
|
||||
}
|
||||
|
||||
private _confirmCompletion(title: string): Promise<boolean> {
|
||||
return firstValueFrom(
|
||||
this._matDialog
|
||||
.open(DialogConfirmComponent, {
|
||||
restoreFocus: true,
|
||||
data: {
|
||||
message: T.F.PROJECT.D_CONFIRM_ARCHIVE.MSG,
|
||||
okTxt: T.F.PROJECT.D_CONFIRM_ARCHIVE.OK,
|
||||
translateParams: { title: project.title },
|
||||
title: T.F.PROJECT.COMPLETE.CONFIRM.TITLE,
|
||||
titleIcon: 'check_circle',
|
||||
message: T.F.PROJECT.COMPLETE.CONFIRM.MSG,
|
||||
translateParams: { title },
|
||||
okTxt: T.MH.COMPLETE_PROJECT,
|
||||
},
|
||||
})
|
||||
.afterClosed(),
|
||||
);
|
||||
if (!isConfirmed) {
|
||||
return;
|
||||
}
|
||||
const activeId = this._workContextService.activeWorkContextId;
|
||||
this._projectService.archive(this.contextId);
|
||||
if (activeId === this.contextId) {
|
||||
await this._router.navigateByUrl('/');
|
||||
}
|
||||
}
|
||||
|
||||
async restoreProject(): Promise<void> {
|
||||
await this._projectService.unarchive(this.contextId);
|
||||
const project = await firstValueFrom(
|
||||
this._projectService.getByIdOnce$(this.contextId),
|
||||
);
|
||||
if (!project) {
|
||||
return;
|
||||
}
|
||||
if (project.isDone) {
|
||||
this._projectService.reopen(this.contextId, project);
|
||||
} else {
|
||||
await this._projectService.unarchive(this.contextId);
|
||||
}
|
||||
}
|
||||
|
||||
async duplicateProject(): Promise<void> {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
export interface ConfettiConfig {
|
||||
particleCount?: number;
|
||||
angel?: number;
|
||||
angle?: number;
|
||||
spread?: number;
|
||||
startVelocity?: number;
|
||||
decay?: number;
|
||||
|
|
@ -13,6 +13,7 @@ export interface ConfettiConfig {
|
|||
shapes?: (string | Shape)[];
|
||||
scalar?: number;
|
||||
zIndex?: number;
|
||||
disableForReducedMotion?: boolean;
|
||||
}
|
||||
|
||||
interface Shape {
|
||||
|
|
@ -22,4 +23,11 @@ interface Shape {
|
|||
bitmap?: ImageBitmap;
|
||||
}
|
||||
|
||||
export type CanvasConfetti = (props: ConfettiConfig) => Promise<void> | null;
|
||||
/**
|
||||
* A confetti instance bound to a specific canvas (via `confetti.create`).
|
||||
* `reset()` stops the animation loop and removes the auto-resize listener.
|
||||
*/
|
||||
export interface ConfettiInstance {
|
||||
(props: ConfettiConfig): Promise<void> | null;
|
||||
reset: () => void;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { inject, Injectable } from '@angular/core';
|
||||
|
||||
import { GlobalConfigService } from '../../features/config/global-config.service';
|
||||
import { ConfettiConfig } from './confetti.model';
|
||||
import { ConfettiConfig, ConfettiInstance } from './confetti.model';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
|
|
@ -10,13 +10,40 @@ export class ConfettiService {
|
|||
private readonly _configService = inject(GlobalConfigService);
|
||||
|
||||
async createConfetti(props: ConfettiConfig): Promise<void> {
|
||||
const misc = this._configService.misc();
|
||||
|
||||
if (misc && misc.isDisableAnimations) {
|
||||
if (this._isDisabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const confettiModule = await import('canvas-confetti');
|
||||
confettiModule.default(props);
|
||||
confettiModule.default({ disableForReducedMotion: true, ...props });
|
||||
}
|
||||
|
||||
async createConfettiOnCanvas(
|
||||
canvas: HTMLCanvasElement,
|
||||
props: ConfettiConfig,
|
||||
): Promise<ConfettiInstance | undefined> {
|
||||
if (this._isDisabled()) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const confettiModule = await import('canvas-confetti');
|
||||
const confetti: ConfettiInstance = confettiModule.default.create(canvas, {
|
||||
resize: true,
|
||||
});
|
||||
// Fire without awaiting completion so the caller keeps the handle and can
|
||||
// reset() to tear down the rAF loop + window resize listener if the dialog
|
||||
// closes before the ~5s animation finishes.
|
||||
void confetti({ disableForReducedMotion: true, ...props });
|
||||
return confetti;
|
||||
}
|
||||
|
||||
private _isDisabled(): boolean {
|
||||
const misc = this._configService.misc();
|
||||
// Honor the OS "reduce motion" setting for every confetti caller (not just
|
||||
// the in-app animations toggle) — intentional, app-wide a11y behavior.
|
||||
return (
|
||||
!!misc?.isDisableAnimations ||
|
||||
!!globalThis.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ describe('action-type-codes', () => {
|
|||
expect(encodeActionType('[Task Shared] addTask')).toBe('HA');
|
||||
expect(encodeActionType('[Tag] Add Tag')).toBe('GA');
|
||||
expect(encodeActionType('[Project] Add Project')).toBe('PA');
|
||||
expect(encodeActionType('[Project] Complete Project')).toBe('PCO');
|
||||
});
|
||||
|
||||
it('should return original action type for unknown action types', () => {
|
||||
|
|
@ -54,6 +55,7 @@ describe('action-type-codes', () => {
|
|||
expect(decodeActionType('HA')).toBe('[Task Shared] addTask');
|
||||
expect(decodeActionType('GA')).toBe('[Tag] Add Tag');
|
||||
expect(decodeActionType('PA')).toBe('[Project] Add Project');
|
||||
expect(decodeActionType('PCO')).toBe('[Project] Complete Project');
|
||||
});
|
||||
|
||||
it('should return code as-is for unknown codes (assumed to be full action type)', () => {
|
||||
|
|
|
|||
|
|
@ -134,6 +134,8 @@ export const ACTION_TYPE_TO_CODE: Record<ActionType, string> = {
|
|||
[ActionType.PROJECT_UPDATE_ADVANCED_CFG]: 'PC',
|
||||
[ActionType.PROJECT_ARCHIVE]: 'PX',
|
||||
[ActionType.PROJECT_UNARCHIVE]: 'PR',
|
||||
[ActionType.PROJECT_COMPLETE]: 'PCO',
|
||||
[ActionType.PROJECT_REOPEN]: 'PRO',
|
||||
[ActionType.PROJECT_TOGGLE_HIDE]: 'PH',
|
||||
[ActionType.PROJECT_MOVE_TASK_IN_BACKLOG]: 'PM',
|
||||
[ActionType.PROJECT_MOVE_TASK_UP_BACKLOG]: 'PMU',
|
||||
|
|
|
|||
24
src/app/core/theme/resolve-bg-image-to-data-url.util.spec.ts
Normal file
24
src/app/core/theme/resolve-bg-image-to-data-url.util.spec.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import { resolveBgImageToDataUrl } from './resolve-bg-image-to-data-url.util';
|
||||
|
||||
describe('resolveBgImageToDataUrl', () => {
|
||||
it('returns null for empty input', async () => {
|
||||
expect(await resolveBgImageToDataUrl(null)).toBeNull();
|
||||
expect(await resolveBgImageToDataUrl(undefined)).toBeNull();
|
||||
expect(await resolveBgImageToDataUrl('')).toBeNull();
|
||||
});
|
||||
|
||||
it('passes through non-file URLs unchanged', async () => {
|
||||
const dataUrl = 'data:image/gif;base64,R0lGODlhAQABAA==';
|
||||
expect(await resolveBgImageToDataUrl(dataUrl)).toBe(dataUrl);
|
||||
expect(await resolveBgImageToDataUrl('https://example.com/bg.png')).toBe(
|
||||
'https://example.com/bg.png',
|
||||
);
|
||||
});
|
||||
|
||||
// Outside Electron (the unit-test environment) file:// URLs are not inlined
|
||||
// and pass through unchanged; the on-disk read path only runs under Electron.
|
||||
it('does not inline file:// URLs outside Electron', async () => {
|
||||
const url = 'file:///home/user/bg.png';
|
||||
expect(await resolveBgImageToDataUrl(url)).toBe(url);
|
||||
});
|
||||
});
|
||||
33
src/app/core/theme/resolve-bg-image-to-data-url.util.ts
Normal file
33
src/app/core/theme/resolve-bg-image-to-data-url.util.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import { IS_ELECTRON } from '../../app.constants';
|
||||
|
||||
/**
|
||||
* Resolve a background-image URL to something a CSS `background` can render.
|
||||
*
|
||||
* On Electron a `file://` URL has to be read off disk and inlined as a data
|
||||
* URL (the renderer can't load arbitrary `file://` paths); every other URL
|
||||
* passes through unchanged. Returns null when there is no image or the read
|
||||
* fails, so callers fall back to no background.
|
||||
*
|
||||
* Note: callers that react to a changing source should guard against stale
|
||||
* results themselves (e.g. a request-id), since this resolves asynchronously.
|
||||
*/
|
||||
export const resolveBgImageToDataUrl = async (
|
||||
bgImage: string | null | undefined,
|
||||
): Promise<string | null> => {
|
||||
if (!bgImage) {
|
||||
return null;
|
||||
}
|
||||
if (!IS_ELECTRON || !bgImage.startsWith('file://')) {
|
||||
return bgImage;
|
||||
}
|
||||
const readLocalImageAsDataUrl = window.ea?.readLocalImageAsDataUrl;
|
||||
if (!readLocalImageAsDataUrl) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return (await readLocalImageAsDataUrl(bgImage)) || null;
|
||||
} catch {
|
||||
// A missing/unreadable file just means "no background" — fall back quietly.
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
<h2 mat-dialog-title>{{ T.MH.COMPLETE_PROJECT | translate }}</h2>
|
||||
|
||||
<mat-dialog-content>
|
||||
<p>
|
||||
<strong>{{ data.title }}</strong>
|
||||
{{
|
||||
(data.nr === 1
|
||||
? T.F.PROJECT.COMPLETE.RESOLVE.MSG_ONE
|
||||
: T.F.PROJECT.COMPLETE.RESOLVE.MSG
|
||||
) | translate: { nr: data.nr }
|
||||
}}
|
||||
</p>
|
||||
</mat-dialog-content>
|
||||
|
||||
<mat-dialog-actions align="end">
|
||||
<button
|
||||
mat-button
|
||||
(click)="cancel()"
|
||||
>
|
||||
{{ T.G.CANCEL | translate }}
|
||||
</button>
|
||||
<button
|
||||
mat-button
|
||||
(click)="markDone()"
|
||||
>
|
||||
{{ T.F.PROJECT.COMPLETE.RESOLVE.MARK_DONE | translate }}
|
||||
</button>
|
||||
<button
|
||||
mat-flat-button
|
||||
color="primary"
|
||||
(click)="moveToInbox()"
|
||||
>
|
||||
{{ T.F.PROJECT.COMPLETE.RESOLVE.MOVE_TO_INBOX | translate }}
|
||||
</button>
|
||||
</mat-dialog-actions>
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
|
||||
import {
|
||||
MAT_DIALOG_DATA,
|
||||
MatDialogActions,
|
||||
MatDialogContent,
|
||||
MatDialogRef,
|
||||
MatDialogTitle,
|
||||
} from '@angular/material/dialog';
|
||||
import { MatButton } from '@angular/material/button';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { T } from '../../../t.const';
|
||||
|
||||
export interface DialogCompleteResolveTasksData {
|
||||
title: string;
|
||||
nr: number;
|
||||
}
|
||||
|
||||
export type ResolveUnfinishedTasksChoice = 'inbox' | 'markDone';
|
||||
|
||||
@Component({
|
||||
selector: 'dialog-complete-resolve-tasks',
|
||||
templateUrl: './dialog-complete-resolve-tasks.component.html',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [MatDialogTitle, MatDialogContent, MatDialogActions, MatButton, TranslatePipe],
|
||||
})
|
||||
export class DialogCompleteResolveTasksComponent {
|
||||
private readonly _matDialogRef =
|
||||
inject<
|
||||
MatDialogRef<DialogCompleteResolveTasksComponent, ResolveUnfinishedTasksChoice>
|
||||
>(MatDialogRef);
|
||||
|
||||
readonly data = inject<DialogCompleteResolveTasksData>(MAT_DIALOG_DATA);
|
||||
readonly T: typeof T = T;
|
||||
|
||||
cancel(): void {
|
||||
this._matDialogRef.close(undefined);
|
||||
}
|
||||
|
||||
moveToInbox(): void {
|
||||
this._matDialogRef.close('inbox');
|
||||
}
|
||||
|
||||
markDone(): void {
|
||||
this._matDialogRef.close('markDone');
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,105 @@
|
|||
<section
|
||||
class="complete-overlay"
|
||||
[class.is-disable-background-tint]="isDisableBackgroundTint()"
|
||||
[style.--project-complete-primary]="projectPrimaryColor()"
|
||||
>
|
||||
@if (resolvedBgImage(); as bgImage) {
|
||||
<div
|
||||
class="project-bg-image"
|
||||
[class.is-blurred]="backgroundImageBlur() > 0"
|
||||
[style.background]="'url(' + bgImage + ')'"
|
||||
[style.filter]="backgroundImageBlurFilter()"
|
||||
></div>
|
||||
<div
|
||||
class="project-bg-overlay"
|
||||
[style.opacity]="backgroundOverlayOpacity()"
|
||||
></div>
|
||||
}
|
||||
|
||||
<canvas
|
||||
#confettiCanvas
|
||||
class="confetti-canvas"
|
||||
aria-hidden="true"
|
||||
></canvas>
|
||||
|
||||
<button
|
||||
mat-icon-button
|
||||
class="close-btn"
|
||||
[attr.aria-label]="T.G.CLOSE | translate"
|
||||
[matTooltip]="T.G.CLOSE | translate"
|
||||
(click)="close()"
|
||||
>
|
||||
<mat-icon>close</mat-icon>
|
||||
</button>
|
||||
|
||||
<div class="celebration-content">
|
||||
<header class="hero">
|
||||
<div class="trophy">
|
||||
<mat-icon>emoji_events</mat-icon>
|
||||
</div>
|
||||
<h2
|
||||
id="project-complete-title"
|
||||
class="complete-title"
|
||||
>
|
||||
{{ T.F.PROJECT.COMPLETE.CELEBRATION.TITLE | translate }}
|
||||
</h2>
|
||||
<p class="project-title">{{ data.project.title }}</p>
|
||||
</header>
|
||||
|
||||
<div class="stats-grid">
|
||||
<div class="stat">
|
||||
<span class="stat-value"
|
||||
>{{ data.stats.nrOfTasksDone }}/{{ data.stats.nrOfTasksTotal }}</span
|
||||
>
|
||||
<span class="stat-label">{{
|
||||
T.F.PROJECT.COMPLETE.CELEBRATION.TASKS_DONE | translate
|
||||
}}</span>
|
||||
</div>
|
||||
|
||||
@if (data.stats.timeSpent > 0) {
|
||||
<div class="stat">
|
||||
<span class="stat-value">{{ data.stats.timeSpent | msToString }}</span>
|
||||
<span class="stat-label">{{
|
||||
T.F.PROJECT.COMPLETE.CELEBRATION.TIME_SPENT | translate
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="stat-value">{{ data.stats.nrOfDaysWorked }}</span>
|
||||
<span class="stat-label">{{
|
||||
T.F.PROJECT.COMPLETE.CELEBRATION.DAYS_WORKED | translate
|
||||
}}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="details">
|
||||
@if (data.stats.durationDays > 0) {
|
||||
<p class="duration">
|
||||
{{
|
||||
T.F.PROJECT.COMPLETE.CELEBRATION.DURATION
|
||||
| translate: { nr: data.stats.durationDays }
|
||||
}}
|
||||
</p>
|
||||
}
|
||||
|
||||
@if (data.stats.startedOn) {
|
||||
<p class="date-range">
|
||||
{{ T.F.PROJECT.COMPLETE.CELEBRATION.STARTED | translate }}:
|
||||
{{ data.stats.startedOn | date }} ·
|
||||
{{ T.F.PROJECT.COMPLETE.CELEBRATION.FINISHED | translate }}:
|
||||
{{ data.stats.doneOn | date }}
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button
|
||||
mat-flat-button
|
||||
color="primary"
|
||||
(click)="close()"
|
||||
>
|
||||
{{ T.F.PROJECT.COMPLETE.CELEBRATION.DONE_BTN | translate }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
|
@ -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%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,139 @@
|
|||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { WritableSignal, signal } from '@angular/core';
|
||||
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { By } from '@angular/platform-browser';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import {
|
||||
DialogProjectCompleteComponent,
|
||||
DialogProjectCompleteData,
|
||||
} from './dialog-project-complete.component';
|
||||
import { ConfettiService } from '../../../core/confetti/confetti.service';
|
||||
import { ConfettiInstance } from '../../../core/confetti/confetti.model';
|
||||
import { GlobalConfigService } from '../../config/global-config.service';
|
||||
import { createProject } from '../project.test-helper';
|
||||
import { GlobalThemeService } from '../../../core/theme/global-theme.service';
|
||||
|
||||
describe('DialogProjectCompleteComponent', () => {
|
||||
let fixture: ComponentFixture<DialogProjectCompleteComponent>;
|
||||
let component: DialogProjectCompleteComponent;
|
||||
let confettiService: jasmine.SpyObj<ConfettiService>;
|
||||
let dialogRef: jasmine.SpyObj<MatDialogRef<DialogProjectCompleteComponent>>;
|
||||
let misc: { isDisableCelebration?: boolean; isDisableAnimations?: boolean };
|
||||
let isDarkTheme: WritableSignal<boolean>;
|
||||
|
||||
let data: DialogProjectCompleteData;
|
||||
|
||||
beforeEach(() => {
|
||||
misc = { isDisableCelebration: false, isDisableAnimations: false };
|
||||
isDarkTheme = signal(false);
|
||||
data = {
|
||||
project: createProject({
|
||||
id: 'project-1',
|
||||
title: 'Completed Project',
|
||||
theme: {
|
||||
primary: '#123456',
|
||||
backgroundImageLight:
|
||||
'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw==',
|
||||
backgroundImageDark:
|
||||
'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw==',
|
||||
backgroundOverlayOpacity: 65,
|
||||
backgroundImageBlur: 4,
|
||||
},
|
||||
}),
|
||||
stats: {
|
||||
nrOfTasksDone: 2,
|
||||
nrOfTasksTotal: 2,
|
||||
timeSpent: 0,
|
||||
nrOfDaysWorked: 0,
|
||||
startedOn: null,
|
||||
doneOn: new Date(2026, 5, 5).getTime(),
|
||||
durationDays: 0,
|
||||
},
|
||||
};
|
||||
confettiService = jasmine.createSpyObj('ConfettiService', ['createConfettiOnCanvas']);
|
||||
confettiService.createConfettiOnCanvas.and.returnValue(Promise.resolve(undefined));
|
||||
dialogRef = jasmine.createSpyObj('MatDialogRef', ['close']);
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
imports: [DialogProjectCompleteComponent, TranslateModule.forRoot()],
|
||||
providers: [
|
||||
provideRouter([]),
|
||||
{ provide: MAT_DIALOG_DATA, useValue: data },
|
||||
{ provide: MatDialogRef, useValue: dialogRef },
|
||||
{ provide: ConfettiService, useValue: confettiService },
|
||||
{ provide: GlobalConfigService, useValue: { misc: () => misc } },
|
||||
{ provide: GlobalThemeService, useValue: { isDarkTheme } },
|
||||
],
|
||||
});
|
||||
|
||||
fixture = TestBed.createComponent(DialogProjectCompleteComponent);
|
||||
component = fixture.componentInstance;
|
||||
});
|
||||
|
||||
it('creates confetti on the component canvas', async () => {
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
|
||||
expect(confettiService.createConfettiOnCanvas).toHaveBeenCalledWith(
|
||||
jasmine.any(HTMLCanvasElement),
|
||||
jasmine.objectContaining({ particleCount: 160 }),
|
||||
);
|
||||
});
|
||||
|
||||
it('does not create confetti when celebration is disabled', async () => {
|
||||
misc.isDisableCelebration = true;
|
||||
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
|
||||
expect(confettiService.createConfettiOnCanvas).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('resets confetti that finishes loading after the dialog is destroyed', async () => {
|
||||
const resetSpy = jasmine.createSpy('reset');
|
||||
const instance: ConfettiInstance = Object.assign(() => Promise.resolve(), {
|
||||
reset: resetSpy,
|
||||
});
|
||||
confettiService.createConfettiOnCanvas.and.returnValue(Promise.resolve(instance));
|
||||
|
||||
fixture.detectChanges(); // kicks off ngAfterViewInit's async confetti load
|
||||
component.ngOnDestroy(); // dialog closed before the load resolves
|
||||
await fixture.whenStable();
|
||||
|
||||
expect(resetSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uses the completed project background image and theme values', async () => {
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
// Background image now resolves asynchronously; flush the resolved signal
|
||||
// into the DOM before asserting.
|
||||
fixture.detectChanges();
|
||||
|
||||
const nativeElement = fixture.nativeElement as HTMLElement;
|
||||
const overlay = nativeElement.querySelector('.complete-overlay') as HTMLElement;
|
||||
const bgImage = nativeElement.querySelector('.project-bg-image') as HTMLElement;
|
||||
const bgOverlay = nativeElement.querySelector('.project-bg-overlay') as HTMLElement;
|
||||
|
||||
expect(overlay.style.getPropertyValue('--project-complete-primary')).toBe('#123456');
|
||||
expect(bgImage.style.background).toContain('data:image/gif');
|
||||
expect(bgImage.style.filter).toBe('blur(4px)');
|
||||
expect(bgImage.classList).toContain('is-blurred');
|
||||
expect(bgOverlay.style.opacity).toBe('0.65');
|
||||
});
|
||||
|
||||
it('closes the dialog', () => {
|
||||
component.close();
|
||||
|
||||
expect(dialogRef.close).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('renders only a close action', () => {
|
||||
fixture.detectChanges();
|
||||
|
||||
const actionButtons = fixture.debugElement.queryAll(By.css('.actions button'));
|
||||
|
||||
expect(actionButtons.length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,138 @@
|
|||
import {
|
||||
AfterViewInit,
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
ElementRef,
|
||||
OnDestroy,
|
||||
ViewChild,
|
||||
computed,
|
||||
effect,
|
||||
inject,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
|
||||
import { DatePipe } from '@angular/common';
|
||||
import { MatButton, MatIconButton } from '@angular/material/button';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { T } from '../../../t.const';
|
||||
import { Project } from '../project.model';
|
||||
import { ProjectCompletionStats } from '../project-completion-stats.util';
|
||||
import { ConfettiService } from '../../../core/confetti/confetti.service';
|
||||
import { ConfettiInstance } from '../../../core/confetti/confetti.model';
|
||||
import { GlobalConfigService } from '../../config/global-config.service';
|
||||
import { MsToStringPipe } from '../../../ui/duration/ms-to-string.pipe';
|
||||
import { MatTooltip } from '@angular/material/tooltip';
|
||||
import { GlobalThemeService } from '../../../core/theme/global-theme.service';
|
||||
import { resolveBgImageToDataUrl } from '../../../core/theme/resolve-bg-image-to-data-url.util';
|
||||
import { normalizeBackgroundImageBlur } from '../../work-context/work-context.const';
|
||||
|
||||
export interface DialogProjectCompleteData {
|
||||
project: Project;
|
||||
stats: ProjectCompletionStats;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'dialog-project-complete',
|
||||
templateUrl: './dialog-project-complete.component.html',
|
||||
styleUrls: ['./dialog-project-complete.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [
|
||||
MatButton,
|
||||
MatIconButton,
|
||||
MatIcon,
|
||||
MatTooltip,
|
||||
DatePipe,
|
||||
TranslatePipe,
|
||||
MsToStringPipe,
|
||||
],
|
||||
})
|
||||
export class DialogProjectCompleteComponent implements AfterViewInit, OnDestroy {
|
||||
private readonly _matDialogRef =
|
||||
inject<MatDialogRef<DialogProjectCompleteComponent>>(MatDialogRef);
|
||||
private readonly _confettiService = inject(ConfettiService);
|
||||
private readonly _configService = inject(GlobalConfigService);
|
||||
private readonly _globalThemeService = inject(GlobalThemeService);
|
||||
|
||||
readonly data = inject<DialogProjectCompleteData>(MAT_DIALOG_DATA);
|
||||
readonly T: typeof T = T;
|
||||
readonly resolvedBgImage = signal<string | null>(null);
|
||||
readonly isDisableBackgroundTint = computed(
|
||||
() => !!this.data.project.theme?.isDisableBackgroundTint,
|
||||
);
|
||||
readonly projectPrimaryColor = computed(() => this.data.project.theme?.primary ?? null);
|
||||
readonly backgroundOverlayOpacity = computed(
|
||||
() => (this.data.project.theme?.backgroundOverlayOpacity ?? 20) * 0.01,
|
||||
);
|
||||
readonly backgroundImageBlur = computed(() =>
|
||||
normalizeBackgroundImageBlur(this.data.project.theme?.backgroundImageBlur),
|
||||
);
|
||||
readonly backgroundImageBlurFilter = computed(() => {
|
||||
const blur = this.backgroundImageBlur();
|
||||
return blur > 0 ? `blur(${blur}px)` : 'none';
|
||||
});
|
||||
private readonly _backgroundImage = computed(() => {
|
||||
const theme = this.data.project.theme;
|
||||
return (
|
||||
(this._globalThemeService.isDarkTheme()
|
||||
? theme?.backgroundImageDark
|
||||
: theme?.backgroundImageLight) || null
|
||||
);
|
||||
});
|
||||
private _bgResolveRequestId = 0;
|
||||
private _confettiInstance?: ConfettiInstance;
|
||||
private _isDestroyed = false;
|
||||
|
||||
@ViewChild('confettiCanvas')
|
||||
private readonly _confettiCanvas?: ElementRef<HTMLCanvasElement>;
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
const bgImage = this._backgroundImage();
|
||||
const currentRequestId = ++this._bgResolveRequestId;
|
||||
void resolveBgImageToDataUrl(bgImage).then((resolved) => {
|
||||
// Ignore stale resolutions when the source changed mid-read.
|
||||
if (currentRequestId === this._bgResolveRequestId) {
|
||||
this.resolvedBgImage.set(resolved);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async ngAfterViewInit(): Promise<void> {
|
||||
// ConfettiService already honors isDisableAnimations; also respect the
|
||||
// dedicated celebration toggle (the dialog still shows, just without confetti).
|
||||
if (this._configService.misc()?.isDisableCelebration) {
|
||||
return;
|
||||
}
|
||||
const canvas = this._confettiCanvas?.nativeElement;
|
||||
if (!canvas) {
|
||||
return;
|
||||
}
|
||||
const instance = await this._confettiService.createConfettiOnCanvas(canvas, {
|
||||
particleCount: 160,
|
||||
startVelocity: 45,
|
||||
spread: 360,
|
||||
ticks: 320,
|
||||
origin: { x: 0.5, y: 0.35 },
|
||||
});
|
||||
// The dialog may have closed while the confetti module was still loading —
|
||||
// tear the instance down right away instead of leaking its rAF loop.
|
||||
if (this._isDestroyed) {
|
||||
instance?.reset();
|
||||
return;
|
||||
}
|
||||
this._confettiInstance = instance;
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
// Tear down the confetti rAF loop + resize listener if the dialog closes
|
||||
// before the ~5s animation finishes (otherwise it draws to a detached canvas).
|
||||
this._isDestroyed = true;
|
||||
this._confettiInstance?.reset();
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this._matDialogRef.close();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,81 @@
|
|||
import { getProjectCompletionStats } from './project-completion-stats.util';
|
||||
import { Task } from '../tasks/task.model';
|
||||
|
||||
const task = (p: Partial<Task>): Task => ({ ...p }) as Task;
|
||||
|
||||
// Computed keys avoid the object-literal naming-convention lint rule on date strings.
|
||||
const DAY_1 = '2026-06-01';
|
||||
const DAY_2 = '2026-06-02';
|
||||
const DAY_3 = '2026-06-03';
|
||||
const DAY_4 = '2026-06-04';
|
||||
const DAY_5 = '2026-06-05';
|
||||
|
||||
describe('getProjectCompletionStats', () => {
|
||||
it('returns zeros for an empty project', () => {
|
||||
const stats = getProjectCompletionStats([], [], 1000);
|
||||
expect(stats.nrOfTasksTotal).toBe(0);
|
||||
expect(stats.nrOfTasksDone).toBe(0);
|
||||
expect(stats.timeSpent).toBe(0);
|
||||
expect(stats.nrOfDaysWorked).toBe(0);
|
||||
expect(stats.startedOn).toBeNull();
|
||||
expect(stats.durationDays).toBe(0);
|
||||
});
|
||||
|
||||
it('counts done/total over top-level tasks only', () => {
|
||||
const top = [
|
||||
task({ isDone: true, timeSpent: 0, timeSpentOnDay: {} }),
|
||||
task({ isDone: false, timeSpent: 0, timeSpentOnDay: {} }),
|
||||
task({ isDone: true, timeSpent: 0, timeSpentOnDay: {} }),
|
||||
];
|
||||
const stats = getProjectCompletionStats(top, top, 1000);
|
||||
expect(stats.nrOfTasksTotal).toBe(3);
|
||||
expect(stats.nrOfTasksDone).toBe(2);
|
||||
});
|
||||
|
||||
it('sums time over top-level tasks only (no subtask double-count)', () => {
|
||||
const parent = task({ isDone: true, timeSpent: 5000, timeSpentOnDay: {} });
|
||||
const subTask = task({ isDone: true, timeSpent: 2000, timeSpentOnDay: {} });
|
||||
// allTasks includes the subtask, but timeSpent must come from top-level only.
|
||||
const stats = getProjectCompletionStats([parent], [parent, subTask], 1000);
|
||||
expect(stats.timeSpent).toBe(5000);
|
||||
});
|
||||
|
||||
it('unions worked days across parents and subtasks, ignoring zero days', () => {
|
||||
const parent = task({
|
||||
isDone: true,
|
||||
timeSpent: 10,
|
||||
timeSpentOnDay: { [DAY_1]: 10, [DAY_2]: 0 },
|
||||
});
|
||||
const subTask = task({
|
||||
isDone: true,
|
||||
timeSpent: 5,
|
||||
timeSpentOnDay: { [DAY_3]: 5 },
|
||||
});
|
||||
const stats = getProjectCompletionStats([parent], [parent, subTask], Date.now());
|
||||
// DAY_1 and DAY_3 have time; DAY_2 is zero → excluded.
|
||||
expect(stats.nrOfDaysWorked).toBe(2);
|
||||
});
|
||||
|
||||
it('computes calendar duration inclusive of first and last day', () => {
|
||||
const parent = task({
|
||||
isDone: true,
|
||||
timeSpent: 10,
|
||||
timeSpentOnDay: { [DAY_1]: 10, [DAY_4]: 10 },
|
||||
});
|
||||
const doneOn = new Date(2026, 5, 5, 14, 0, 0).getTime(); // 2026-06-05
|
||||
const stats = getProjectCompletionStats([parent], [parent], doneOn);
|
||||
// started 06-01 → done 06-05 inclusive = 5 days.
|
||||
expect(stats.durationDays).toBe(5);
|
||||
});
|
||||
|
||||
it('reports a single-day project as duration 1', () => {
|
||||
const parent = task({
|
||||
isDone: true,
|
||||
timeSpent: 10,
|
||||
timeSpentOnDay: { [DAY_5]: 10 },
|
||||
});
|
||||
const doneOn = new Date(2026, 5, 5, 18, 0, 0).getTime();
|
||||
const stats = getProjectCompletionStats([parent], [parent], doneOn);
|
||||
expect(stats.durationDays).toBe(1);
|
||||
});
|
||||
});
|
||||
67
src/app/features/project/project-completion-stats.util.ts
Normal file
67
src/app/features/project/project-completion-stats.util.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import { Task } from '../tasks/task.model';
|
||||
import { getDiffInDays } from '../../util/get-diff-in-days';
|
||||
import { dateStrToUtcDate } from '../../util/date-str-to-utc-date';
|
||||
|
||||
export interface ProjectCompletionStats {
|
||||
nrOfTasksDone: number;
|
||||
nrOfTasksTotal: number;
|
||||
/** Sum of tracked time in ms (0 when time tracking is unused). */
|
||||
timeSpent: number;
|
||||
nrOfDaysWorked: number;
|
||||
/** Local-midnight ms of the earliest worked day, or null if never worked. */
|
||||
startedOn: number | null;
|
||||
doneOn: number;
|
||||
/** Calendar days from first worked day to completion, inclusive (0 if never worked). */
|
||||
durationDays: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Live completion stats for the celebration + trophy view.
|
||||
*
|
||||
* Computed from the still-live store (completing a project only sets a flag — it
|
||||
* does NOT move tasks to the archive store), so this is accurate at completion
|
||||
* time. It can drift if tasks are later deleted/manually-archived — an accepted
|
||||
* tradeoff of computing live instead of snapshotting.
|
||||
*
|
||||
* @param topLevelTasks the project's parent tasks (taskIds + backlogTaskIds)
|
||||
* @param allTasks parents + subtasks — used only to union worked-day keys
|
||||
* @param doneOn completion timestamp (ms)
|
||||
*/
|
||||
export const getProjectCompletionStats = (
|
||||
topLevelTasks: Task[],
|
||||
allTasks: Task[],
|
||||
doneOn: number,
|
||||
): ProjectCompletionStats => {
|
||||
const nrOfTasksTotal = topLevelTasks.length;
|
||||
const nrOfTasksDone = topLevelTasks.filter((t) => t.isDone).length;
|
||||
// A parent's timeSpent already aggregates its subtasks, so sum top-level only
|
||||
// — summing subtasks too would double-count.
|
||||
const timeSpent = topLevelTasks.reduce((acc, t) => acc + (t.timeSpent || 0), 0);
|
||||
|
||||
const workedDays = new Set<string>();
|
||||
allTasks.forEach((t) => {
|
||||
Object.keys(t.timeSpentOnDay || {}).forEach((dayStr) => {
|
||||
if ((t.timeSpentOnDay[dayStr] || 0) > 0) {
|
||||
workedDays.add(dayStr);
|
||||
}
|
||||
});
|
||||
});
|
||||
const sortedDays = Array.from(workedDays).sort();
|
||||
const nrOfDaysWorked = sortedDays.length;
|
||||
// timeSpentOnDay keys are 'YYYY-MM-DD'. dateStrToUtcDate parses them as LOCAL
|
||||
// midnight (avoids the UTC-midnight day-shift in non-UTC zones); getDiffInDays
|
||||
// rounds the calendar-day delta (DST-safe).
|
||||
const startedOn = nrOfDaysWorked ? dateStrToUtcDate(sortedDays[0]).getTime() : null;
|
||||
const durationDays =
|
||||
startedOn !== null ? getDiffInDays(new Date(startedOn), new Date(doneOn)) + 1 : 0;
|
||||
|
||||
return {
|
||||
nrOfTasksDone,
|
||||
nrOfTasksTotal,
|
||||
timeSpent,
|
||||
nrOfDaysWorked,
|
||||
startedOn,
|
||||
doneOn,
|
||||
durationDays,
|
||||
};
|
||||
};
|
||||
|
|
@ -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: [],
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -20,17 +20,18 @@ import { Task, TaskState } from '../tasks/task.model';
|
|||
import { WorkContextService } from '../work-context/work-context.service';
|
||||
import {
|
||||
addProject,
|
||||
archiveProject,
|
||||
completeProject,
|
||||
moveProjectTaskToBacklogList,
|
||||
moveProjectTaskToBacklogListAuto,
|
||||
moveProjectTaskToRegularListAuto,
|
||||
reopenProject,
|
||||
toggleHideFromMenu,
|
||||
unarchiveProject,
|
||||
updateProject,
|
||||
updateProjectOrder,
|
||||
} from './store/project.actions';
|
||||
import { TaskSharedActions } from '../../root-store/meta/task-shared.actions';
|
||||
import { DEFAULT_PROJECT } from './project.const';
|
||||
import { DEFAULT_PROJECT, INBOX_PROJECT } from './project.const';
|
||||
import {
|
||||
selectArchivedProjects,
|
||||
selectProjectById,
|
||||
|
|
@ -52,6 +53,53 @@ import { LOCAL_ACTIONS } from '../../util/local-actions.token';
|
|||
import { DateService } from '../../core/date/date.service';
|
||||
import { getDeadlineAutoPlanFields } from '../tasks/util/get-deadline-auto-plan-fields';
|
||||
|
||||
export interface ProjectCompletionInfo {
|
||||
topLevelTasks: Task[];
|
||||
allTasks: Task[];
|
||||
unfinishedTasks: Task[];
|
||||
topLevelTasksWithUnfinishedWork: Task[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Depth-first flatten of a task tree into a flat list (each parent immediately
|
||||
* before its subtasks), de-duplicated via a shared `seen` set so a task reached
|
||||
* from more than one parent is included only once.
|
||||
*/
|
||||
const flattenTaskTree = (
|
||||
topLevelTasks: Task[],
|
||||
entities: Record<string, Task | undefined>,
|
||||
): Task[] => {
|
||||
const result: Task[] = [];
|
||||
const seen = new Set<string>();
|
||||
const visit = (task: Task): void => {
|
||||
if (seen.has(task.id)) {
|
||||
return;
|
||||
}
|
||||
seen.add(task.id);
|
||||
result.push(task);
|
||||
(task.subTaskIds ?? []).forEach((subId) => {
|
||||
const sub = entities[subId];
|
||||
if (sub) {
|
||||
visit(sub);
|
||||
}
|
||||
});
|
||||
};
|
||||
topLevelTasks.forEach(visit);
|
||||
return result;
|
||||
};
|
||||
|
||||
/** True if the task itself or any of its (live) subtasks is still unfinished. */
|
||||
const hasUnfinishedWork = (
|
||||
task: Task,
|
||||
unfinishedTaskIds: Set<string>,
|
||||
entities: Record<string, Task | undefined>,
|
||||
): boolean =>
|
||||
unfinishedTaskIds.has(task.id) ||
|
||||
(task.subTaskIds ?? []).some((subId) => {
|
||||
const sub = entities[subId];
|
||||
return !!sub && hasUnfinishedWork(sub, unfinishedTaskIds, entities);
|
||||
});
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
|
|
@ -142,14 +190,6 @@ export class ProjectService {
|
|||
);
|
||||
}
|
||||
|
||||
archive(projectId: string): void {
|
||||
this._store$.dispatch(archiveProject({ id: projectId }));
|
||||
this._snackService.open({
|
||||
ico: 'archive',
|
||||
msg: T.F.PROJECT.S.ARCHIVED,
|
||||
});
|
||||
}
|
||||
|
||||
async unarchive(projectId: string): Promise<void> {
|
||||
const project = await firstValueFrom(this.getByIdOnce$(projectId));
|
||||
this._store$.dispatch(unarchiveProject({ id: projectId }));
|
||||
|
|
@ -168,6 +208,129 @@ export class ProjectService {
|
|||
);
|
||||
}
|
||||
|
||||
complete(projectId: string, doneOn: number): void {
|
||||
// Single-entity flag flip. Unfinished-task resolution (move-to-inbox /
|
||||
// mark-done) is dispatched separately as normal per-task actions before
|
||||
// this call — see moveTasksToInbox / markTasksDone and the completion flow
|
||||
// in work-context-menu. No undo affordance: that resolution can't be fully
|
||||
// restored by reopen, so the fullscreen celebration is the feedback and
|
||||
// reactivation lives on the archived-projects page.
|
||||
this._store$.dispatch(completeProject({ id: projectId, doneOn }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Carry unfinished work forward into the Inbox before completing a project.
|
||||
* Uses the normal per-task move action so every downstream effect (issue
|
||||
* sync, reminders, repeat-cfg) and per-entity conflict detection fires
|
||||
* naturally. Done tasks are explicitly re-opened (setUnDone) so carried-over
|
||||
* work is actionable again in the Inbox — the move itself keeps isDone.
|
||||
* The trailing flush is the bulk-dispatch guard (sync-model Rule #6).
|
||||
*/
|
||||
async moveTasksToInbox(tasks: Task[]): Promise<void> {
|
||||
for (const task of tasks) {
|
||||
const withSubTasks = await firstValueFrom(
|
||||
this._taskService.getByIdWithSubTaskData$(task.id),
|
||||
);
|
||||
this._taskService.moveToProject(withSubTasks, INBOX_PROJECT.id);
|
||||
if (task.isDone) {
|
||||
this._taskService.setUnDone(task.id);
|
||||
}
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
/** Mark a project's unfinished tasks done via the normal per-task action. */
|
||||
async markTasksDone(tasks: Task[]): Promise<void> {
|
||||
tasks.forEach((task) => this._taskService.setDone(task.id));
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
reopen(projectId: string, project?: Pick<Project, 'isHiddenFromMenu'>): void {
|
||||
this._store$.dispatch(reopenProject({ id: projectId }));
|
||||
this._snackService.open(
|
||||
project?.isHiddenFromMenu
|
||||
? {
|
||||
ico: 'replay',
|
||||
msg: T.F.PROJECT.S.REOPENED,
|
||||
actionStr: T.F.PROJECT.S.SHOW_IN_MENU,
|
||||
actionFn: () => this._store$.dispatch(toggleHideFromMenu({ id: projectId })),
|
||||
}
|
||||
: {
|
||||
ico: 'replay',
|
||||
msg: T.F.PROJECT.S.REOPENED,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tasks of a project for the completion flow. Stats include live and archived
|
||||
* project tasks, while unfinished resolution only considers active tasks that
|
||||
* can still be moved or marked done from the live store.
|
||||
*/
|
||||
async getCompletionInfo(projectId: string): Promise<ProjectCompletionInfo> {
|
||||
const project = await firstValueFrom(this.getByIdOnce$(projectId));
|
||||
if (!project) {
|
||||
return {
|
||||
topLevelTasks: [],
|
||||
allTasks: [],
|
||||
unfinishedTasks: [],
|
||||
topLevelTasksWithUnfinishedWork: [],
|
||||
};
|
||||
}
|
||||
const stats = await this._getCompletionStatsTasks(projectId, project);
|
||||
const resolvable = await this._getResolvableTasks(project);
|
||||
return { ...stats, ...resolvable };
|
||||
}
|
||||
|
||||
/**
|
||||
* Stats lists for the celebration: count live AND archived project tasks, so
|
||||
* work the user archived earlier still counts toward the finished total.
|
||||
*/
|
||||
private async _getCompletionStatsTasks(
|
||||
projectId: string,
|
||||
project: Project,
|
||||
): Promise<Pick<ProjectCompletionInfo, 'topLevelTasks' | 'allTasks'>> {
|
||||
const ids = [...(project.taskIds ?? []), ...(project.backlogTaskIds ?? [])];
|
||||
const idSet = new Set(ids);
|
||||
const projectTasks = await this._taskService.getAllTasksForProject(projectId);
|
||||
const projectTaskById = new Map(projectTasks.map((task) => [task.id, task]));
|
||||
const topLevelTasks = [
|
||||
...ids,
|
||||
...projectTasks
|
||||
.filter((task) => !task.parentId && !idSet.has(task.id))
|
||||
.map((task) => task.id),
|
||||
]
|
||||
.map((id) => projectTaskById.get(id))
|
||||
.filter((t): t is Task => !!t);
|
||||
const allTasks = flattenTaskTree(topLevelTasks, Object.fromEntries(projectTaskById));
|
||||
return { topLevelTasks, allTasks };
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolvable lists for the unfinished-task prompt: only LIVE tasks can still
|
||||
* be moved or marked done, so the archive is intentionally ignored here.
|
||||
*/
|
||||
private async _getResolvableTasks(
|
||||
project: Project,
|
||||
): Promise<
|
||||
Pick<ProjectCompletionInfo, 'unfinishedTasks' | 'topLevelTasksWithUnfinishedWork'>
|
||||
> {
|
||||
const taskState = await firstValueFrom(this._store$.select(selectTaskFeatureState));
|
||||
const ids = [...(project.taskIds ?? []), ...(project.backlogTaskIds ?? [])];
|
||||
const activeTopLevelTasks = ids
|
||||
.map((id) => taskState.entities[id])
|
||||
.filter((t): t is Task => !!t);
|
||||
const activeAllTasks = flattenTaskTree(activeTopLevelTasks, taskState.entities);
|
||||
const unfinishedTasks = activeAllTasks.filter((t) => !t.isDone);
|
||||
const unfinishedTaskIds = new Set(unfinishedTasks.map((t) => t.id));
|
||||
return {
|
||||
unfinishedTasks,
|
||||
topLevelTasksWithUnfinishedWork: activeTopLevelTasks.filter((t) =>
|
||||
hasUnfinishedWork(t, unfinishedTaskIds, taskState.entities),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
getByIdOnce$(id: string): Observable<Project | undefined> {
|
||||
if (!id) {
|
||||
throw new Error('No id given');
|
||||
|
|
@ -311,6 +474,10 @@ export class ProjectService {
|
|||
taskIds: [],
|
||||
backlogTaskIds: [],
|
||||
noteIds: [],
|
||||
// A duplicate is a fresh, active project — never inherit completed/archived state.
|
||||
isDone: false,
|
||||
doneOn: null,
|
||||
isArchived: false,
|
||||
});
|
||||
|
||||
const noteState = await firstValueFrom(this._store$.select(selectNoteFeatureState));
|
||||
|
|
|
|||
|
|
@ -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 }) => ({
|
||||
|
|
|
|||
|
|
@ -3,9 +3,11 @@ import { fakeEntityStateFromArray } from '../../../util/fake-entity-state-from-a
|
|||
import { Project } from '../project.model';
|
||||
import {
|
||||
archiveProject,
|
||||
completeProject,
|
||||
moveProjectTaskInBacklogList,
|
||||
moveProjectTaskToBacklogList,
|
||||
moveProjectTaskToRegularList,
|
||||
reopenProject,
|
||||
unarchiveProject,
|
||||
updateProjectOrder,
|
||||
} from './project.actions';
|
||||
|
|
@ -550,5 +552,81 @@ describe('projectReducer', () => {
|
|||
);
|
||||
expect((afterReArchive.entities as any).P1.isArchived).toBeTrue();
|
||||
});
|
||||
|
||||
it('should clear completed state to preserve isDone implies isArchived', () => {
|
||||
const s = fakeEntityStateFromArray([
|
||||
{ id: 'P1', isArchived: true, isDone: true, doneOn: 123 },
|
||||
] as Partial<Project>[]);
|
||||
|
||||
const r = projectReducer(s as any, unarchiveProject({ id: 'P1' }) as any);
|
||||
expect((r.entities as any).P1.isArchived).toBeFalse();
|
||||
expect((r.entities as any).P1.isDone).toBeFalse();
|
||||
expect((r.entities as any).P1.doneOn).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('completeProject', () => {
|
||||
it('should set isDone, doneOn and isArchived together', () => {
|
||||
const s = fakeEntityStateFromArray([
|
||||
{ id: 'P1', isArchived: false, isDone: false },
|
||||
{ id: 'P2', isArchived: false, isDone: false },
|
||||
] as Partial<Project>[]);
|
||||
|
||||
const r = projectReducer(
|
||||
s as any,
|
||||
completeProject({ id: 'P1', doneOn: 1234 }) as any,
|
||||
);
|
||||
expect((r.entities as any).P1.isDone).toBeTrue();
|
||||
expect((r.entities as any).P1.doneOn).toBe(1234);
|
||||
// Completing auto-archives → hides from the active menu.
|
||||
expect((r.entities as any).P1.isArchived).toBeTrue();
|
||||
});
|
||||
|
||||
it('should not affect other projects', () => {
|
||||
const s = fakeEntityStateFromArray([
|
||||
{ id: 'P1', isArchived: false, isDone: false },
|
||||
{ id: 'P2', isArchived: false, isDone: false },
|
||||
] as Partial<Project>[]);
|
||||
|
||||
const r = projectReducer(s as any, completeProject({ id: 'P1', doneOn: 1 }) as any);
|
||||
expect((r.entities as any).P2.isDone).toBeFalse();
|
||||
expect((r.entities as any).P2.isArchived).toBeFalse();
|
||||
});
|
||||
|
||||
it('should never complete INBOX_PROJECT', () => {
|
||||
const s = fakeEntityStateFromArray([
|
||||
{ id: INBOX_PROJECT.id, isArchived: false, isDone: false },
|
||||
] as Partial<Project>[]);
|
||||
|
||||
const r = projectReducer(
|
||||
s as any,
|
||||
completeProject({ id: INBOX_PROJECT.id, doneOn: 1 }) as any,
|
||||
);
|
||||
expect((r.entities as any)[INBOX_PROJECT.id].isDone).toBeFalse();
|
||||
expect((r.entities as any)[INBOX_PROJECT.id].isArchived).toBeFalse();
|
||||
});
|
||||
});
|
||||
|
||||
describe('reopenProject', () => {
|
||||
it('should clear isDone, doneOn and isArchived (round-trip from complete)', () => {
|
||||
const s = fakeEntityStateFromArray([
|
||||
{ id: 'P1', isArchived: false, isDone: false },
|
||||
] as Partial<Project>[]);
|
||||
|
||||
const completed = projectReducer(
|
||||
s as any,
|
||||
completeProject({ id: 'P1', doneOn: 999 }) as any,
|
||||
);
|
||||
expect((completed.entities as any).P1.isDone).toBeTrue();
|
||||
|
||||
const reopened = projectReducer(
|
||||
completed as any,
|
||||
reopenProject({ id: 'P1' }) as any,
|
||||
);
|
||||
expect((reopened.entities as any).P1.isDone).toBeFalse();
|
||||
expect((reopened.entities as any).P1.doneOn).toBeNull();
|
||||
// Reopen also un-archives → returns to the active menu.
|
||||
expect((reopened.entities as any).P1.isArchived).toBeFalse();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ import {
|
|||
addProject,
|
||||
addProjects,
|
||||
archiveProject,
|
||||
completeProject,
|
||||
loadProjects,
|
||||
moveAllProjectBacklogTasksToRegularList,
|
||||
moveProjectTaskDownInBacklogList,
|
||||
|
|
@ -38,6 +39,7 @@ import {
|
|||
moveProjectTaskToRegularListAuto,
|
||||
moveProjectTaskToTopInBacklogList,
|
||||
moveProjectTaskUpInBacklogList,
|
||||
reopenProject,
|
||||
toggleHideFromMenu,
|
||||
unarchiveProject,
|
||||
updateProject,
|
||||
|
|
@ -182,6 +184,43 @@ export const projectReducer = createReducer<ProjectState>(
|
|||
id,
|
||||
changes: {
|
||||
isArchived: false,
|
||||
isDone: false,
|
||||
doneOn: null,
|
||||
},
|
||||
},
|
||||
state,
|
||||
),
|
||||
),
|
||||
|
||||
// Completing a project marks it done AND archives it (hide from active menu).
|
||||
// isDone stays distinct from isArchived so a finish ≠ a quiet shelve.
|
||||
// Task resolution (move-to-inbox / mark-done) is decoupled — it runs as the
|
||||
// normal per-task actions from the completion flow, not bundled in here.
|
||||
on(completeProject, (state, { id, doneOn }) => {
|
||||
if (id === INBOX_PROJECT.id) return state;
|
||||
return projectAdapter.updateOne(
|
||||
{
|
||||
id,
|
||||
changes: {
|
||||
isDone: true,
|
||||
doneOn,
|
||||
isArchived: true,
|
||||
},
|
||||
},
|
||||
state,
|
||||
);
|
||||
}),
|
||||
|
||||
// Reopen reverts a completed project back to active. It clears the done flags
|
||||
// only; tasks resolved at completion time are not restored (by design).
|
||||
on(reopenProject, (state, { id }) =>
|
||||
projectAdapter.updateOne(
|
||||
{
|
||||
id,
|
||||
changes: {
|
||||
isDone: false,
|
||||
doneOn: null,
|
||||
isArchived: false,
|
||||
},
|
||||
},
|
||||
state,
|
||||
|
|
|
|||
39
src/app/features/project/store/project.selectors.spec.ts
Normal file
39
src/app/features/project/store/project.selectors.spec.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import {
|
||||
selectArchivedProjects,
|
||||
selectArchivedProjectIds,
|
||||
selectArrayOfArchivedProjectIds,
|
||||
} from './project.selectors';
|
||||
import { Project } from '../project.model';
|
||||
|
||||
const p = (o: Partial<Project>): Project => ({ ...o }) as Project;
|
||||
|
||||
describe('project.selectors (completion)', () => {
|
||||
const projects = [
|
||||
p({ id: 'active', isArchived: false, isDone: false }),
|
||||
p({ id: 'archived', isArchived: true, isDone: false }),
|
||||
p({ id: 'done', isArchived: true, isDone: true }),
|
||||
];
|
||||
|
||||
// REGRESSION (multi-review CRITICAL): completing a project sets isArchived, so
|
||||
// it MUST remain in selectArchivedProjects — that selector feeds
|
||||
// selectArchivedProjectIds, which task.selectors uses to keep archived
|
||||
// projects' tasks out of Today/Overdue. Narrowing it would leak completed
|
||||
// projects' (done, dueDay-carrying) tasks back into those lists.
|
||||
it('selectArchivedProjects still includes completed projects', () => {
|
||||
expect(
|
||||
selectArchivedProjects
|
||||
.projector(projects)
|
||||
.map((x) => x.id)
|
||||
.sort(),
|
||||
).toEqual(['archived', 'done']);
|
||||
});
|
||||
|
||||
it('selectArchivedProjectIds includes completed project ids (task-filter guard)', () => {
|
||||
const archived = selectArchivedProjects.projector(projects);
|
||||
const idArr = selectArrayOfArchivedProjectIds.projector(archived);
|
||||
const idSet = selectArchivedProjectIds.projector(idArr);
|
||||
expect(idSet.has('done')).toBe(true);
|
||||
expect(idSet.has('archived')).toBe(true);
|
||||
expect(idSet.has('active')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
@ -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)),
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -44,15 +44,37 @@
|
|||
>{{ project.icon || DEFAULT_PROJECT_ICON }}</mat-icon
|
||||
>
|
||||
<span class="project-title">{{ project.title }}</span>
|
||||
@if (project.isDone) {
|
||||
<span class="completed-badge">
|
||||
<mat-icon aria-hidden="true">emoji_events</mat-icon>
|
||||
@if (project.doneOn) {
|
||||
{{
|
||||
T.F.PROJECT.COMPLETE.COMPLETED_ON
|
||||
| translate: { date: (project.doneOn | date) }
|
||||
}}
|
||||
}
|
||||
</span>
|
||||
}
|
||||
</a>
|
||||
<button
|
||||
mat-icon-button
|
||||
[matTooltip]="T.F.PROJECT.ARCHIVED_PROJECTS.UNARCHIVE | translate"
|
||||
[attr.aria-label]="T.F.PROJECT.ARCHIVED_PROJECTS.UNARCHIVE | translate"
|
||||
(click)="unarchive(project)"
|
||||
>
|
||||
<mat-icon>unarchive</mat-icon>
|
||||
</button>
|
||||
@if (project.isDone) {
|
||||
<button
|
||||
mat-icon-button
|
||||
[matTooltip]="T.F.PROJECT.COMPLETE.REOPEN | translate"
|
||||
[attr.aria-label]="T.F.PROJECT.COMPLETE.REOPEN | translate"
|
||||
(click)="reopen(project)"
|
||||
>
|
||||
<mat-icon>replay</mat-icon>
|
||||
</button>
|
||||
} @else {
|
||||
<button
|
||||
mat-icon-button
|
||||
[matTooltip]="T.F.PROJECT.ARCHIVED_PROJECTS.UNARCHIVE | translate"
|
||||
[attr.aria-label]="T.F.PROJECT.ARCHIVED_PROJECTS.UNARCHIVE | translate"
|
||||
(click)="unarchive(project)"
|
||||
>
|
||||
<mat-icon>unarchive</mat-icon>
|
||||
</button>
|
||||
}
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,13 +9,14 @@ import { ProjectService } from '../../features/project/project.service';
|
|||
import { Project } from '../../features/project/project.model';
|
||||
import { selectArchivedProjectsSortedByTitle } from '../../features/project/store/project.selectors';
|
||||
|
||||
const makeProject = (id: string, title: string): Project =>
|
||||
const makeProject = (id: string, title: string, over: Partial<Project> = {}): Project =>
|
||||
({
|
||||
id,
|
||||
title,
|
||||
isArchived: true,
|
||||
isHiddenFromMenu: false,
|
||||
theme: { primary: '#fff' },
|
||||
...over,
|
||||
}) as unknown as Project;
|
||||
|
||||
describe('ArchivedProjectsPageComponent', () => {
|
||||
|
|
@ -24,7 +25,7 @@ describe('ArchivedProjectsPageComponent', () => {
|
|||
let projectService: jasmine.SpyObj<ProjectService>;
|
||||
|
||||
const setUp = async (projects: Project[]): Promise<void> => {
|
||||
projectService = jasmine.createSpyObj('ProjectService', ['unarchive']);
|
||||
projectService = jasmine.createSpyObj('ProjectService', ['unarchive', 'reopen']);
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [
|
||||
|
|
@ -90,4 +91,12 @@ describe('ArchivedProjectsPageComponent', () => {
|
|||
btn.nativeElement.click();
|
||||
expect(projectService.unarchive).toHaveBeenCalledWith('p-a');
|
||||
});
|
||||
|
||||
it('calls ProjectService.reopen for completed projects', async () => {
|
||||
const project = makeProject('p-a', 'Alpha', { isDone: true, doneOn: 1234 });
|
||||
await setUp([project]);
|
||||
const btn = fixture.debugElement.query(By.css('.project-row button'));
|
||||
btn.nativeElement.click();
|
||||
expect(projectService.reopen).toHaveBeenCalledWith('p-a', project);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,23 @@
|
|||
@if (currentProject()?.isArchived) {
|
||||
<div class="archived-notice">
|
||||
<span>{{ T.F.PROJECT.ARCHIVED_NOTICE | translate }}</span>
|
||||
<span>
|
||||
{{
|
||||
(currentProject()?.isDone
|
||||
? T.F.PROJECT.COMPLETE.NOTICE
|
||||
: T.F.PROJECT.ARCHIVED_NOTICE
|
||||
) | translate
|
||||
}}
|
||||
</span>
|
||||
<button
|
||||
mat-button
|
||||
(click)="restoreProject()"
|
||||
>
|
||||
{{ T.F.PROJECT.ARCHIVED_PROJECTS.UNARCHIVE | translate }}
|
||||
{{
|
||||
(currentProject()?.isDone
|
||||
? T.F.PROJECT.COMPLETE.REOPEN
|
||||
: T.F.PROJECT.ARCHIVED_PROJECTS.UNARCHIVE
|
||||
) | translate
|
||||
}}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -941,6 +941,32 @@
|
|||
"D_DELETE": {
|
||||
"MSG": "Are you sure you want to delete the project \"{{title}}\"?"
|
||||
},
|
||||
"COMPLETE": {
|
||||
"COMPLETED_ON": "Completed {{date}}",
|
||||
"REOPEN": "Reopen",
|
||||
"NOTICE": "This project is completed.",
|
||||
"ERROR": "Could not complete project. Please try again.",
|
||||
"RESOLVE": {
|
||||
"MSG": "still has {{nr}} unfinished tasks. What should happen to them?",
|
||||
"MSG_ONE": "still has {{nr}} unfinished task. What should happen to it?",
|
||||
"MOVE_TO_INBOX": "Move to Inbox",
|
||||
"MARK_DONE": "Mark as done"
|
||||
},
|
||||
"CONFIRM": {
|
||||
"TITLE": "Complete project?",
|
||||
"MSG": "Complete <strong>{{title}}</strong> and move it to archived projects?"
|
||||
},
|
||||
"CELEBRATION": {
|
||||
"TITLE": "Project complete!",
|
||||
"STARTED": "Started",
|
||||
"FINISHED": "Finished",
|
||||
"DURATION": "Completed in {{nr}} days",
|
||||
"TASKS_DONE": "Tasks completed",
|
||||
"TIME_SPENT": "Time tracked",
|
||||
"DAYS_WORKED": "Days worked",
|
||||
"DONE_BTN": "Close"
|
||||
}
|
||||
},
|
||||
"FORM_BASIC": {
|
||||
"L_ENABLE_BACKLOG": "Enable Project Backlog",
|
||||
"L_IS_HIDDEN_FROM_MENU": "Hide project from sidebar",
|
||||
|
|
@ -973,6 +999,7 @@
|
|||
"ARCHIVED": "Archived Project",
|
||||
"CREATED": "Created project <strong>{{title}}</strong>. You can select it from the sidebar.",
|
||||
"DELETED": "Deleted Project",
|
||||
"REOPENED": "Project reopened",
|
||||
"SHOW_IN_MENU": "Show in sidebar",
|
||||
"UNARCHIVED": "Project restored",
|
||||
"UNARCHIVED_HIDDEN_FROM_MENU": "Project restored, but still hidden from the sidebar",
|
||||
|
|
@ -2591,6 +2618,7 @@
|
|||
"CREATE_PROJECT": "Create project",
|
||||
"CREATE_TAG": "Create tag",
|
||||
"ARCHIVE_PROJECT": "Archive project",
|
||||
"COMPLETE_PROJECT": "Complete project",
|
||||
"DELETE_PROJECT": "Delete project",
|
||||
"DELETE_TAG": "Delete tag",
|
||||
"DISABLE_FEATURE": "Disable feature",
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue