In production the esbuild builder fingerprints the @font-face url() and
emits the Material Symbols woff2 to /media/<hash>.woff2, but ngsw-config
only precached the unhashed /assets/fonts copy that nothing loads. The
font the app actually requests was therefore never in the service worker
cache and failed to load offline, leaving icons as their ligature names
(wb_sunny, settings, ...). iOS Safari surfaces this first because, unlike
Chrome, it does not fall back to the HTTP disk cache offline.
- prefetch /media/material-symbols-outlined-*.woff2 (the URL @font-face
actually requests)
- lazily cache the rest of /media (self-hosted Open Sans) so offline text
also keeps its font
- font-display: block so the brief load window shows blank glyph space
rather than the ligature names
* fix(sync): back up local state before USE_REMOTE replace #8107
forceDownloadRemoteState cleared all unsynced local ops and replaced
NgRx state with the server snapshot with no recovery point, making an
unintended 'Use Server Data' conflict choice irreversibly destructive.
Capture a pre-wipe snapshot via the existing single-slot IMPORT_BACKUP
store (BackupService.captureImportBackup) before clearUnsyncedOps, and
abort the replace if the backup fails. Offer a 30s Undo snack afterwards
that restores it (BackupService.restoreImportBackup). Covers both the
conflict-dialog USE_REMOTE path and the manual force-download.
* fix(sync): durable restore entry + one-shot recovery for USE_REMOTE backup #8107
Addresses multi-review findings on the pre-wipe backup:
- Add a persistent 'Restore data from before last sync replace' button to
the sync settings (all providers, gated on a remaining backup), so
recovery survives a missed/replaced Undo snack or a failure that aborts
after the wipe — the snack was previously the only reader of the backup.
- Make restore one-shot: consume the IMPORT_BACKUP slot after a successful
restore, preventing a second restore from toggling back to the replaced
state and avoiding a lingering plaintext snapshot.
- Undo snack: WARNING type (honest framing + dismiss control) and no
auto-dismiss timer instead of SUCCESS/30s.
* test(sync): e2e for USE_REMOTE pre-replace backup + undo #8107
WebDAV two-client conflict: Client B's local task is wiped by 'Keep
remote' (USE_REMOTE), then the Undo snack restores it. Reproduces the
#8107 data loss and verifies recovery. Modeled on the existing
webdav-first-sync-conflict USE_REMOTE test.
Runnable via npm run e2e:webdav:file (needs the docker WebDAV server;
not runnable in the sandbox where published ports are unreachable).
* fix(sync): guard USE_REMOTE in conflict dialog; drop durable restore button #8107
Swap the over-built recovery surface for a root-cause fix:
- Add a confirmation guard before 'Use Server Data' (USE_REMOTE) discards
pending local changes (INCOMING_IMPORT with local ops), mirroring the
existing USE_LOCAL guard. The dialog frames the server as 'recommended',
so this stops a misclick from silently wiping data the user can't tell
is newer than the server's — attacking the cause, not just recovery.
- Revert the durable settings restore button + its one-shot clear /
hasImportBackup (beyond the minimal fix; only that button used them).
Keeps the minimal recovery net: pre-wipe capture + sticky WARNING undo
snack (and its passing WebDAV e2e).
* fix(sync): explain encryption-blocked restore instead of generic error #8107
Defect 2: server-side Restore-from-History can't replay end-to-end-
encrypted ops, so it rejects with a 4xx whose reason mentions encryption
(the provider embeds that reason in the thrown error message). The client
showed a meaningless 'Failed to restore data' (bbinet's screenshot).
Detect the encryption reason in the restore catch and show a dedicated
RESTORE_ENCRYPTED message explaining the limitation; falls back to the
generic message for any other failure.
* fix(sync): guard USE_REMOTE undo against superseded backup slot #8107
The pre-replace backup uses a single IndexedDB slot shared with the
backup-import flow. The Undo snack never expires, so an intervening
import or a second 'Use Server Data' could overwrite the slot before
the user clicks Undo, silently restoring the wrong snapshot.
Thread the backup's savedAt token from capture through the snack to
restore; restoreImportBackup() now refuses when the stored backup no
longer matches the token. Also clear the slot after a successful
restore so a full copy of the replaced state stops lingering in IDB
(uses the previously-uncalled clearImportBackup).
* build: drop unused elevate.exe from Windows build #8135
elevate.exe is bundled by electron-builder's NSIS target solely so
electron-updater can apply privileged per-machine updates. We ship no
in-app auto-updater (electron-updater is not a dependency; the autoUpdater
block in electron/start-app.ts is commented out), so the binary is unused
dead weight and a frequent AV false-positive. Set packElevateHelper: false
to stop bundling it; safe because perMachine is not true.
* fix(tasks): add subtask via "a" shortcut when detail panel is open
With the detail panel open, pressing "a" created a sub-task but did not
focus it for editing.
Two paths were broken:
- Focus inside the panel: the panel auto-focuses a detail item on open, so
focus is not on a <task> row and the global task-shortcut handler drops
the shortcut. The panel now handles taskAddSubTask in its keydown listener
(stopPropagation avoids a double-add when focus is on an in-panel row).
- Focus on the main-list task row: addSubTaskTo -> focusTaskById targeted the
last #t-<id> copy, which is the in-panel copy inside the collapsed (hidden)
sub-task section and cannot take focus. focusTaskById now falls back to the
last focusable copy, so the new sub-task title is focused for editing
(the panel follows focus to the new sub-task).
* fix(notifications): defer mobile permission prompt to first use (#8120)
The startup effect (askPermissionsIfNotGiven$) requested notification
permission ~2s after launch via ensurePermissions(), which contradicts
the documented lazy-prompt design in CapacitorReminderService.initialize()
and lowers iOS/Android grant rates (an unprompted launch-time dialog is
dismissed more often than a contextual one).
Read the permission STATE instead and only warn on explicit 'denied';
'prompt'/'prompt-with-rationale' now stay silent so the OS dialog surfaces
lazily on the first real schedule (when the user creates a reminder).
- add getPermissionState() to CapacitorNotificationService (no prompt) and
refactor checkPermissions() to delegate to it
- add getPermissionState() to CapacitorReminderService ('granted' for the
legacy Android WebView per #7408, 'denied' when unavailable)
- branch the startup effect on the tri-state and defer the exact-alarm
check until notifications are granted
* fix(notifications): check exact alarms after lazy grant
* refactor(notifications): drop dead duplicate snack in exact-alarm catch
ensureExactAlarmPermission() never rejects (it swallows its own errors),
so the .catch() that re-opened the EXACT_ALARM_DENIED snack was an
unreachable duplicate of the .then() warning. Keep a resolving, log-only
catch so a future throw can't cache a rejected promise that every
scheduling effect would re-await, and document the one-shot memo and the
isAndroid() gate.
* build: drop unused elevate.exe from Windows build #8135
elevate.exe is bundled by electron-builder's NSIS target solely so
electron-updater can apply privileged per-machine updates. We ship no
in-app auto-updater (electron-updater is not a dependency; the autoUpdater
block in electron/start-app.ts is commented out), so the binary is unused
dead weight and a frequent AV false-positive. Set packElevateHelper: false
to stop bundling it; safe because perMachine is not true.
* fix(caldav-plugin): make recurring-occurrence edits/deletes safe and quiet #7492
Expanded RRULE occurrences all share one master .ics, so operating on a
single occurrence hit the whole series: updateIssue rewrote the master
(and the next poll pulled it onto every sibling), deleteIssue deleted the
master (the entire series), and getById returned the master's DTSTART,
collapsing every instance onto the first one's time.
Plugin:
- parseCompoundId surfaces occurrenceMs instead of discarding it
- getById re-anchors start/end onto the requested occurrence (timed via
toIcalUtcDateTime, all-day via toIcalDate matching ical.js local-midnight)
- updateIssue/deleteIssue refuse occurrence writes with a marked error
(isExpectedSyncSkip), so the series is never mutated
Host two-way sync:
- editing or deleting a task linked to one occurrence now stays silent (the
user changed their task, not the calendar) and the local change still
applies. Explicit agenda reschedule/delete keep surfacing the honest
"can't change a single occurrence" message (no false success).
Single non-recurring events are unaffected. Full per-occurrence editing
(RECURRENCE-ID overrides + EXDATE) remains a follow-up; it needs an If-Match
primitive and recurrence-value preservation.
* 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>
* fix(metric): use worklog totals for activity heatmap (#7993)
* fix(metric): refresh heatmap year after worklog load
* test(calendar): use flush() to drain dynamic-import promise
requestEvents$ awaits loadIcalModule(), which does a one-time
`await import('ical.js')`. With fakeAsync, `tick(0)` after `req.flush()`
isn't guaranteed to settle the import promise on the first invocation,
making the test order-dependent and flaky in CI.
Replace tick(0) with flush() in the requestEvents$ describe block to
drain the full microtask/macrotask queue.
---------
Co-authored-by: Deepak kudi <deepakkudi23@adsl-172-10-9-116.dsl.sndg02.sbcglobal.net>
Co-authored-by: johannesjo <johannes.millan@gmail.com>
A time-based operator (debounceTime/delay/timer/interval) whose
subscription is torn down before its timer fires leaves a closed
AsyncAction whose pending setInterval still runs scheduler.flush().
rxjs then rethrows Error('executing a cancelled action') synchronously
inside the timer callback, surfacing in an unrelated spec's afterAll,
wedging Chrome and disconnecting the whole Karma session
(order-dependent master flake, e.g. run 27128353155 at ~test 2824).
Patch AsyncAction.prototype.execute in src/test.ts to no-op a cancelled
action instead of returning the Error flush rethrows. A cancelled action
must not run anyway, so this is semantically correct; only the diagnostic
is dropped (warn-once breadcrumb retained). Live actions delegate to the
original execute unchanged. Covers async/asap/animationFrame schedulers
(QueueAction is synchronous, cannot leak a timer).
Add test-harness-async-action-guard.spec.ts: disabling the patch makes
it throw the exact error; with it the flush is a no-op.
Delete 29 plan/design docs whose work has shipped or been superseded
(SuperSync slices, sync-core extraction, encryption-at-rest drafts,
document-mode/Stage-A persistence, calendar/CalDAV concepts, focus-mode
time-tracking sync, etc.).
Kept the still-forward-looking docs (e.g. supersync-encryption-at-rest,
sync-core-simplification-roadmap, calendar-two-way-sync-technical-analysis).
Source comments that cited deleted docs are rewritten into self-contained
inline rationale so no "see docs/..." reference dangles.
* fix(reminder): dismiss reminder dialog when reminders vanish after sync
The reminder dialog's displayed list was only updated by explicit user
actions or by the worker emitting active reminders. The worker only ever
signals reminders that ARE due, never that one has disappeared, so when a
reminder was dismissed, completed or deleted on another device and synced
in, the open dialog kept showing the now-stale entry indefinitely.
Reconcile the displayed reminders against the live store: drop a reminder
once its task loses the relevant remindAt/deadlineRemindAt, is completed,
or is deleted, and close the dialog when nothing is left.
To avoid re-introducing the open-time race (worker snapshot briefly ahead
of the store) that an earlier fix relaxed, a reminder is only auto-dismissed
if it was confirmed present in the store at least once and then vanished.
The reconcile subscription is cancelled eagerly on close, mirroring the
existing onRemindersActive$ handling.
* refactor(reminder): gate dialog reconcile watcher and broaden tests
Follow-up to the reminder-dialog sync-dismiss change, addressing review
feedback:
- getByIdsLive$ (selectTasksById) emits a fresh array on every task
mutation app-wide, so the reconcile watcher ran far more often than
needed. Add a distinctUntilChanged that only lets through changes to the
watched tasks' id/isDone/remindAt/deadlineRemindAt.
- Add tests for: a future-rescheduled reminder is kept (not dropped),
all reminders vanishing at once closes exactly once, isAllDeadline
recomputes on partial drop, redundant re-emission after a partial drop
is a no-op, a dropped reminder does not reappear if the store re-adds it,
and no further close after the dialog is already closed. Strengthen the
destroy test to assert the reconcile path closed the dialog first.
---------
Co-authored-by: Claude <noreply@anthropic.com>
* fix(tasks): hide paste image hint on touch devices
* fix(tasks): remove paste image hint from task detail panel
---------
Co-authored-by: Claude <noreply@anthropic.com>
The worklog 'show worklog after completing tasks' test waited on
waitForSelector('task-list') after Save & go home, but daily-summary
renders its own task-list (planner-day), so that resolved immediately
without waiting for finishDay's deferred router.navigate(['/active/tasks']).
The test then went to /history and the pending redirect clobbered it,
so .week-row never rendered (timeout, screenshot showed Today).
Apply the proven sibling pattern (commit 5c73ec6): gate Save on
waitForURL(TODAY/tasks) before navigating to History, assert the
History URL, and expand via .day-toggle with an aria-expanded check.
markTaskAsDone() threw on locators without data-task-id, breaking the
Boards #7498 specs that pass <planner-task>. Branch on the id instead:
<task> rows keep the dup-row done-state wait; wrapper rows (which can
relocate panels on done) just settle and let the caller assert.
Scope the time-tracking spec's main play button to play-button .play-btn
so it no longer collides with a task's .start-task-btn (same play_arrow
icon) once the task is in the TODAY view.