The USE_REMOTE crash-resume spec reloads Client B mid-sync. While
isSyncInProgress is set, startup.service's beforeunload handler calls
preventDefault(), so Chrome raises a beforeunload prompt (the preceding
button click supplies the user activation it requires). Playwright
auto-accepts such prompts only when no 'dialog' listener is registered —
but installDevErrorDialogHandler registers one on every page and early-
returned on beforeunload, leaving the prompt unanswered. The navigation
then never starts and reload() times out (observed on SuperSync shard
6/6; the earlier load→domcontentloaded de-flake could not help because
navigation is blocked before any lifecycle event).
Accept beforeunload in the shared fallback, restoring Playwright's
default; other dialog types keep flowing to spec-specific handlers.
Corrects the two crash-resume comments that encoded the wrong theory.
* feat(schedule): allow 'day' as a persisted time-view mode
* feat(schedule): compute single-day range and header for day view
* feat(schedule): add day-view toggle and single-day labels
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JzGvAaeT2TrZcEUA6fYPSZ
* docs(schedule): note the day view mode in the Schedule wiki
* fix(schedule): make the day-view header compact and locale-aware
Address review feedback on the single-day view:
- Build the day title with a locale-aware Intl skeleton instead of a
hardcoded 'EEE, MMM d, yyyy' pattern, which pinned en-US field ordering.
- Shrink the title responsively so it never gets ellipsis-clipped: show
the full weekday + date + year on roomy widths, and fall back to a
compact month + day below TABLET, where the toggle group and nav
controls leave too little room. The weekday still shows in the
day-column header.
- Rename the .day-view-btn test hook to .e2e-day-view-btn per convention.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* refactor(schedule): rename week-month-* classes to time-view-*
The toggle group now has three buttons (Day, Week, Month), so the
week-month-selector / week-month-btn names no longer describe it. Rename
to time-view-selector / time-view-btn. Pure CSS class rename, no behavior
change.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* refactor(schedule): complete time-view-* rename in zen theme
The class rename left zen.css targeting .week-month-*, so its overrides
matched nothing. The :not(.active) rule is the load-bearing one: its
!important suppresses the toggle's hover highlight, which zen exists to
strip.
Co-authored-by: Jon Kilroy <jkusa@users.noreply.github.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Johannes Millan <johannes.millan@gmail.com>
Co-authored-by: Jon Kilroy <jkusa@users.noreply.github.com>
Drop disableDisjointMerge from the production conflict-resolution entry
point. With the merge frozen (#9061), concurrent edits to different fields
of one entity resolve by whole-entity LWW: the later side wins a full
'replace' snapshot with a dominating clock and the other side's edit is
silently and permanently lost on every client (a rename dies when another
device marks the task done). The journal half of the freeze stays.
Wire-safe: v18.14.0 predates lwwUpdateMode entirely and applies LWW Update
payloads via updateOne, so the merge's 'patch' union-delta merges cleanly
on released clients (#8874's back-compat envelope design).
Restores the strengthened 3.1 disjoint-merge E2E parked out of #9089 (it
fails deterministically while the merge is frozen) and adds a unit
regression for the exact rename-vs-done pair; the caller guard spec now
pins that the merge stays enabled on the production resolve path.
* fix(sync): deduplicate surgical sync retries
Persist split-file configuration and acknowledge operation IDs already committed remotely after a lost upload response.
* fix(sync): preserve import author during clock pruning
Keep the active causal full-state author in oversized stored clocks and reuse the stored protected IDs when classifying response-loss retries.
* test(sync): harden supersync failure coverage
Exercise real upload endpoints and exact operation IDs across response loss, validation failures, schema blockers, full-state boundaries, vector pruning, and concurrent edits.
* fix(sync): heal a corrupt primary on duplicate-only uploads
The .bak recovery path caches the CORRUPT primary's rev precisely so this
cycle's conditional overwrite repairs sync-ops.json. The duplicate-retry
short-circuit read that cache and returned before the write, leaving the
primary corrupt whenever the recovered buffer already held every pending
op. Flag the recovered entry and let those uploads fall through.
Also stop synthesising a serverSeq for ops already in the buffer: the
field is optional, the upload and download paths number ops differently,
and a mixed batch could hand two ops the same value.
* perf(sync): look the full-state author up once per upload
Batch upload is off by default, so the guarded batch path was not the one
serving production: the serial path queries the causal full-state author
per op inside a single transaction, and a clock of 21-50 entries passes
validation and trips the guard on every one of them. Memoize the author
per transaction and resolve it lazily, so only an op whose clock actually
overflows pays, and only once. This also retires the batch pre-scan and
its loop-carried author, leaving both paths on one mechanism.
Report the lookup through ProcessOperationResult so the upload summary
stops under-reporting round-trips, and record why reconstructing the
stored protected set loosens id-collision detection.
* test(sync): wait for the committed title in renameTask
renameTask blurred the textarea, slept 300ms and returned without ever
checking the rename landed. Blur -> dispatch -> re-render outruns that
delay on a loaded machine, so a following sync uploads without the rename
op and the caller asserts against a task that was never renamed — which
is what supersync 3.1 hits on CI but never locally.
Wait for the new title instead, mirroring markTaskDone's done-state wait
and the e2e no-waitForTimeout rule.
* test(sync): dispatch focus so renameTask actually commits
renameTask relied on el.focus() to emit a focus event, but these tests
drive two clients as separate pages and only one page can hold focus, so
on CI the event often never fires. TaskTitleComponent then keeps
_isFocused=false, and resetToLastExternalValueTrigger resets tmpValue to
the stored title on the next task-object emission. Blur therefore
computes wasChanged=false, task.component skips update(), and the rename
is silently dropped without ever becoming an op.
That is supersync 3.1: client A's rename lives only in tmpValue, A syncs
and uploads nothing, B uploads its done op, A downloads it, the task ref
changes and the title reverts to the original — exactly the state the CI
artifact captured. A real user always has real focus, so the app itself
is unaffected.
Dispatch focus explicitly, mirroring the synthetic input/blur already
used here. Also correct the previous commit's claim: the toBeVisible
wait matches tmpValue, a component-local signal rendered in both
template branches, so it never observed the committed title and could
not have fixed this.
* docs: revert incidental prettier reformat of unrelated docs
The master merge ran prettier across files it pulled in, reformatting
three documents this branch has no business touching: markdown table
cell padding plus *emphasis* -> _emphasis_, with no content change.
handover.md documents two unrelated branches entirely.
Restores them to master. vector-clocks.md keeps its edits — those are
this branch's own and describe the pruning protection.
* refactor(sync): drop the full-state author lookup's roundtrip accounting
resolveFullStateAuthor memoizes per transaction, so the lookup it counts
fires at most once per upload — the plumbing existed to report a number
that is always 0 or 1, on a log line already counting dozens. The memo
and its own accounting cancelled out.
Removing didQuery lets resolveFullStateAuthor return string | undefined
and getPruneProtectedIds return string[], instead of both carrying a
tuple purely to feed the counter.
uploadDbRoundtrips and the batch path's own counter are untouched.
* test(sync): assert the committed store title in renameTask
The focus dispatch did not fix supersync 3.1 — the shard failed again
with an identical snapshot (original title, done, rename gone), so that
diagnosis was wrong. Stop guessing at the trigger and make the helper
able to observe the thing in question.
task-title renders tmpValue, a component-local signal, in BOTH its
editing and idle branches. Every DOM assertion here therefore matches as
soon as the synthetic input event fires, whether or not an op was ever
captured — which is why two rounds of "wait for the title" changed
nothing. Read the store instead, via the __e2eTestHelpers.store hook the
timeSpent helper already uses.
This is a diagnostic as much as a fix: it splits the two remaining
explanations. If renameTask now fails, the rename never becomes an op
and the bug is in how the test drives the edit. If it passes and 3.1
still fails at the merge assertion, the op is captured and lost during
sync — a real defect, and the test is right to fail.
* test(sync): move the 3.1 disjoint-merge rewrite out to #9095
3.1 was the last red shard, and it turned out to be right: the
store-backed renameTask passes, so the rename IS captured as an op, and
the test still fails at the merge assertion — the op is committed and
then lost during sync. Filed as #9095.
That bug is pre-existing and cannot be reached by anything in this PR:
the file-based adapter is not used by SuperSync, and the server-side
author memo only engages for clocks over 20 entries where this test
carries about three. 3.1 is also the only test here that exercises
neither of this PR's fixes — it races a title change against a
move-to-done, which is conflict resolution, not retry dedup or clock
pruning. So it moves to #9095 rather than holding verified sync fixes
red.
The rest of the hardening stays: the fault injections whose globs never
matched a real endpoint, the schema-mismatch test that asserted nothing,
and the compaction suite that called an endpoint which never existed are
what actually cover the fixes here.
Restoring the old 3.1 puts a misleading test back, so it now carries a
comment saying why it proves little and where the real one lives. The
strengthened version is kept on test/issue-9095-disjoint-merge-repro.
---------
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
The REPAIR paths built their vector clock from the per-tab in-memory
clock cache and then REPLACED the durable clock with it. A stale cache
(another tab advanced the clock) regressed the durable clock, letting
subsequent captures reuse counters already shipped — silently corrupting
cross-device dominance comparisons.
- createRepairOperation: route through
appendMixedSourceBatchSkipDuplicates; the in-transaction rebase makes
regression unrepresentable. State cache stores the clock actually
written.
- replaceRejectedRepair: rebase the replacement clock onto the durable
clock inside its transaction (shared rebaseLocalClockOnDurable helper).
- Drop client-side clock pruning from repair op building: inert under
the rebase, and it dropped client IDs the server still tracks (false
CONCURRENT). Server prunes after conflict detection.
- Rename appendWithVectorClockUpdate -> appendWithVectorClockOverwrite
and document the derivation invariant; the capture path (its only
remaining production caller) already satisfies it.
* fix(planner): don't erase day-scheduled subtasks on parent plan (#9019)
Planning a parent task ran removeTasksFromPlannerDays(task.subTaskIds),
which stripped its subtasks from EVERY planner day. As a result, any
day-scheduling the user had given a subtask was silently wiped the moment
its parent was planned anywhere, so those subtasks disappeared from the
planner and schedule (timed subtasks survived via a separate, plannerDays-
independent path).
handlePlanTaskForDay now collapses the parent's subtasks on the target day
only, matching the target-day-only behaviour handleTransferTask already
had. Subtasks scheduled for other days keep their scheduling and stay
visible. Add removeTasksFromSinglePlannerDay helper (reuses removeTasksFromList)
for this; the explicit "SinglePlannerDay" name keeps it from being confused
with the all-days removeTasksFromPlannerDays (the mix-up that caused the bug).
Same-day subtasks still fold into the parent by design: showing them as
siblings would double-count their time (the parent estimate rolls up its
subtasks) in the day total and on the schedule timeline.
No schema barrier: the op payload and planner.days/dueDay shapes are
unchanged, so this is a pure reducer behaviour fix, not a new payload
semantic (op-log rules 2.5). In a mixed-version fleet a not-yet-upgraded
client still applies the old all-days removal for these ops, but the task's
dueDay is preserved either way and state re-converges once clients upgrade.
* test(planner): e2e for day-scheduled subtask visibility (#9019)
Drives the real UI: add a task + subtask, schedule the subtask for a
day (day-only, via the 'S' shortcut + quick-access Tomorrow), plan the
parent for a different day, then assert the subtask is still visible on
its own day in the planner. Fails against the pre-fix all-days removal,
passes with the target-day-only fix (both verified locally).
* fix(supersync): exclude legacy REPAIR from retention cleanup pruning
The daily-cleanup fallback (used when `latestFullStateSeq` is absent, i.e.
legacy/pre-marker installs) selected the pruning boundary with a raw
`opType: { in: [SYNC_IMPORT, BACKUP_IMPORT, REPAIR] }` filter that includes
legacy REPAIR rows (`repairBaseServerSeq` NULL). Such a repair carries no
causal base cursor proving its state is current as of its seq, so pruning
history behind it can permanently drop ops committed between its logical
base and its seq for any device replaying from before it.
#8971 migrated five full-state queries to `CAUSAL_FULL_STATE_OPERATION_WHERE`
but missed this one — the single query whose result directly authorizes a
DELETE. Route it through the same causal-only predicate; a legacy-repair-only
user then yields `protectedFromSeq === null` and is skipped (no deletion),
the safe default already implemented below.
Adds a regression test whose mock `findFirst` honours the causal
`repairBaseServerSeq` predicate.
* fix(supersync): gate misc→tasks conflict alias on the split boundary
`detectConflictForEntity` and `prefetchLatestEntityOpsForBatch` looked up
the legacy `GLOBAL_CONFIG:misc` alias for an incoming `tasks` write with
`schemaVersion < CURRENT_SCHEMA_VERSION`. Once v4 shipped, that aliases
post-split v2/v3 misc writes — disjoint from `tasks` — and fabricates false
`CONFLICT_CONCURRENT` rejections of tasks-settings writes for multi-device
users.
Gate both read-side lookups on the fixed `MISC_TASKS_SPLIT_SCHEMA_VERSION`
(2), matching the `isLegacyMiscConfigOperation` incoming gate and the
warning comment it already carries. Drops the now-unused
`CURRENT_SCHEMA_VERSION` import.
Adds real-PostgreSQL integration coverage for both changed lookups
(`detectConflictForEntity` and the batch `prefetchLatestEntityOpsForBatch`):
a post-split v2/v3 misc write must not alias, a legacy v1 one still must.
* fix(sync): re-upload LWW local-win ops after a WS-triggered download
A WS-triggered download that resolves a conflict against pending local ops
appends LWW local-win replacement ops straight to the op-log, bypassing the
capture effect. Unlike `ImmediateUploadService` and the main sync loop, this
path had no follow-up upload, so the preserved edit sat unsynced until the
next user edit or periodic sync — an unbounded window for manual-sync-only
users.
Re-upload when `localWinOpsCreated > 0`, mirroring the other two paths, and
surface the same terminal outcomes `ImmediateUploadService` does — permanent
rejection / rejected-full-state baseline → ERROR, mandatory-but-missing key →
UNKNOWN_OR_CHANGED — so a preserved edit that fails to converge is not left
silent (the WS path never claims IN_SYNC). Single follow-up only, matching the
sibling side channel.
* fix(supersync): validate the primary latestFullStateSeq marker as causal before pruning
The scheduled retention cleanup trusted `state.latestFullStateSeq` as a pruning
boundary whenever it was `<= lastSnapshotSeq`, with no check that the marked op is
a causal full-state operation. The earlier fix (37bf818) hardened only the
fallback query used when the marker is absent.
Installs upgraded from before #8973 can carry a `latestFullStateSeq` set from a
legacy REPAIR (repairBaseServerSeq NULL) through the old isFullStateOpType gate,
and that migration shipped no backfill to clear stale markers. Trusting such a
marker prunes history behind a repair the replay path deliberately refuses as a
boundary (LegacyRepairReplayUnsupportedError) — permanent, cross-device loss.
Validate the marked op against CAUSAL_FULL_STATE_OPERATION_WHERE before it can
authorize a DELETE; a stale marker drops to the (now causal-only) fallback query
or the user is skipped. Updates the happy-path test to a causal boundary, adds a
stale-marker regression test, and teaches the mock findFirst to honour an exact
serverSeq predicate.
* test(e2e): de-flake USE_REMOTE crash-resume reload
`page.reload()` defaulted to waiting for the `load` event, which can never
fire while an active SuperSync WebSocket/sync connection keeps the page
"loading" — so the reload timed out at 30s (observed flake:
`page.reload: Timeout 30000ms exceeded`). Three sibling sync specs already
document this hang and work around it with close()+newPage(), but that drops
sessionStorage, which this test asserts on across the reload.
Wait only for `domcontentloaded` (with 60s headroom) instead; `waitForAppReady`
— which itself only needs `domcontentloaded` — remains the real readiness gate,
and the reload still preserves sessionStorage.
* fix(sync): make task replay values deterministic
Capture logical dates and timestamps in task actions and backfill legacy operations. Carry per-day task totals so own-operation replay is idempotent while foreign time remains additive.
Fixes#8957
* fix(sync): make time snapshots replay-safe
Exclude pending task-time batches from op-log and file-sync snapshots so their later additive operations cannot overlap snapshot state. Preserve concurrent direct credits and normalize legacy replay dates deterministically.
Fixes#8957
* fix(sync): align snapshots with queued task time
Exclude accumulator and in-flight task-time deltas from operation-log snapshots so later delta operations cannot double-count them. Capture file and direct-upload snapshots at the same operation-log boundary, and flush or clear queued time around destructive state replacement.
* fix(tasks): flush queued time at task boundaries
Persist queued timer deltas before absolute short-syntax edits, and clear them whenever project, schedule, or repeat cleanup deletes the owning task. This prevents stale batches from recreating or overwriting removed task time.
* fix(sync): preserve concurrent task time deltas
Treat concurrent task-time batches as commuting updates on both client and server, while retaining causal stale-operation checks. Reject malformed identities, dates, durations, and unsafe timestamps before replay or persistence.
* test(sync): cover task time snapshot replay
Exercise seeded snapshot and restart invariants plus a real three-client SuperSync convergence path with the initial time inside the snapshot.
* fix(sync): select action type for legacy conflicts
* fix(sync): guard full-state apply against late local ops
Recheck pending work inside the upload and operation-log locks before destructive imports. Defer same-tab actions through the cutoff and keep cursors and acknowledgements unchanged when dialog resolution is required.
Closes#8310
* test(sync): cover late multi-tab full-state race
Use the real Web Lock boundary and shared IndexedDB to prove a sibling-tab operation blocks destructive full-state apply.
* chore(ui): removes dead utilities and op-log leftovers [#8260 - Tier A]
* update readme and remove-unused-log-imports.ts
* restore benchmark test and make runnable
Reloads client B at the exact atomic-baseline-commit cutoff, then
verifies the next sync detects the interrupted rebuild, redoes the raw
download, keeps the first attempt's pre-replace backup, finishes on the
remote state, and offers an Undo that restores B's original import.
- USE_REMOTE: fix guaranteed self-deadlock — flushPendingWrites() was
called while already holding the non-reentrant sp_op_log lock its own
Phase 2 re-acquires, so every "Use Server Data" hung 30s and aborted.
New OperationWriteFlushService.flushThenRunExclusive owns the bounded
flush->lock->recheck barrier; ServerMigrationService reuses it, which
also bounds its previously unbounded snapshot-cutoff retry loop.
- USE_REMOTE: make the rebuild crash-resumable. A raw_rebuild_incomplete
META marker is set atomically with the baseline replacement and cleared
after the replay commits; the next sync redoes the raw rebuild instead
of the normal download (which excludes own ops server-side and would
silently lose the un-replayed suffix). The resume keeps the first
attempt's pre-replace backup instead of overwriting the single slot
with the partial baseline.
- Deferred actions: serialize overlapping drains (two concurrent drains
could mint two ops for one user intent), stop the drain at the first
transient failure instead of persisting successors out of order (the
older edit would win LWW everywhere via clock inversion), abandon
permanently invalid actions after one attempt (typed
PermanentDeferredWriteError) instead of retrying + snacking on every
sync forever, and latch the buffer-size warnings to once per stuck
window (previously a blocking dev dialog per action past 100); drop
the no-op 5000 "hard cap" tier.
- writeOperation: post-append bookkeeping failures no longer propagate
into the deferred retry loop, which re-appended the same action under
a fresh op id (double-apply of additive payloads).
- remote-apply: full-state cleanup retains every full-state op of the
current batch, so an archive_pending import can no longer be deleted
before its archive retry (startup replay would lose a change already
visible at runtime).
- Hydration: sanitize a malformed stored schemaVersion per-op instead of
failing the whole boot into recovery; strict parsing stays on the
receive/upload paths (0 still parses so a below-minimum remote op
surfaces as VERSION_UNSUPPORTED, not a generic migration failure).
- Server: request fingerprints are computed lazily — only when a dedup
entry exists, and otherwise after the rate-limit/pre-quota gates but
before uploadOps mutates the parsed ops. Fixes the pre-gate hashing of
full payloads (authenticated DoS-cost regression) and repairs three
sync-fixes retry tests to model true identical-body retries.
- Delete dead code: failed-op-ids util (+spec) and MAX_VERSION_SKIP (the
removed forward-compat band); fix the stale clock-merge parity comment.
sync-core 210/210, shared-schema 50/50, super-sync-server 825/825, and
all touched Angular specs pass.
Client A already synced a populated state, so importing a backup diverges
from the server and A's own sync raises the sync-import conflict gate.
syncAndWait() defaulted to USE_REMOTE, which discarded A's import before it
reached the server, leaving Client B with nothing to conflict against, so
the PHASE 5 conflict dialog never appeared (run 29090279243, SuperSync 1/6).
Pass { useLocal: true } so A force-uploads the import as a new SYNC_IMPORT
the server keeps, letting B's pending simple-counter change trigger the
conflict dialog as intended.
Make remote replay and authoritative state replacement crash-resumable and atomic. Preserve pending changes until piggyback processing commits, and stop cleanly on incompatible operations. Validate sync identities and schemas while strengthening request deduplication and privacy-safe diagnostics.
Regression test for the widened incoming-full-state conflict gate and the
piggyback pre-upload pending-snapshot fix: a pending SIMPLE_COUNTER change
(non-TASK/PROJECT/TAG/NOTE) must trigger the conflict dialog instead of
being silently discarded by an incoming SYNC_IMPORT from another client.
Not yet run (SuperSync docker stack is unavailable in this sandbox); run via
npm run e2e:supersync:file or the E2E Tests (Scheduled) workflow.
* fix(task): schedule overdue tasks for today via Shift+T and guard stale task focus (#8851)
* fix(planner): only bind data-task-id on planner-task when focusable
The unconditional data-task-id host binding broke the e2e done-confirmation
strategy in e2e/pages/task.page.ts, which selects its wait branch based on
the attribute's presence. Boards render planner-task cards without an inner
<task> element, so the id-based wait could never resolve (#8851).
* refactor(tasks): share overdue predicate across selector and util
The rebase onto #8858 re-inlined the overdue comparison into
selectOverdueTaskIds and dropped the delegation to isTaskOverdue, so the
two overdue definitions (the overdue list vs. the Shift+T "Add to Today"
path) were duplicated and free to drift — the exact footgun #8851 set out
to avoid, and the util's JSDoc still claimed the selector delegated to it.
Extract isTaskOverdueByThreshold as the single source of truth for the
comparison and getLogicalTodayStartMs for the boundary. isTaskOverdue and
selectOverdueTaskIds both route through it, so they cannot drift. The
selector still computes the threshold once per recompute (no per-task date
parsing), preserving #8858's perf posture. No behavior change.
* test(e2e): base markTaskAsDone strategy on <task> host, not attr
planner-task now carries data-task-id in the Planner overdue list (#8851),
so keying the done-confirmation strategy off data-task-id presence would send
a wrapper down the <task> path — document.querySelectorAll('task') finds no
match and the wait hangs 10s. Key it off the element actually being a <task>
instead. No behavior change for real <task> rows; every wrapper keeps the
300ms wrapper path. Verified: worklog-basic (real task) and boards #7498
(planner-task wrapper) both pass.
---------
Co-authored-by: Johannes Millan <johannes.millan@gmail.com>
* feat(task-repeat): default skip-overdue for new everyday recurring tasks
New recurring configs now default skipOverdue ("Don't let overdue instances
pile up") to ON only for a plain everyday schedule — the "Daily" preset or a
CUSTOM every-single-day cycle. That is the one case where the option is both
useful and provably safe: everyday tasks are the only schedule that actually
piles up (one empty overdue copy per missed day), and today is always
scheduled so a missed instance regenerates the same day and can never
silently vanish (it cannot even drop to zero).
Every other schedule stays OFF — workday/weekly/monthly/yearly and every-N-day
custom cycles keep their one missed occurrence visible, so a real obligation
(pay rent on the 1st, renew the domain) never disappears until its next
occurrence. Deriving the default purely from the effective schedule means the
"Daily" preset and a CUSTOM every-day cycle (the same schedule entered two
ways) get the same default — no "same schedule, different default" surprise.
The default is seeded from the chosen schedule in both config-creation paths:
the repeat dialog (re-derived from the final schedule on save, and skipped
when the user explicitly toggled the Advanced checkbox) and the inline
add-task-bar recurrence. Existing configs and DEFAULT_TASK_REPEAT_CFG
(skipOverdue: false) are unchanged; only newly created configs are affected.
Supersedes the broader daily+Mon-Fri variant, dropping the schedule-change
checkbox re-sync machinery: an Advanced checkbox opened on a Daily config and
then switched may briefly show a stale ON, but save always persists the safe
re-derived value.
* docs(task-repeat): clarify custom every-day default; signpost baseline
Multi-review follow-ups (no behavior change):
- Wiki 2.06 + 4.13: a CUSTOM every-single-day cycle also defaults skipOverdue
ON (it is the same schedule as the Daily preset); "off" now reads "custom
cycles longer than a day" so the docs match getDefaultSkipOverdue.
- Comment on DEFAULT_TASK_REPEAT_CFG.skipOverdue pointing to the schedule-aware
creation default, so the model's `false` baseline is not mistaken for the
effective default.
- Tighten the save() display-gap comment: the cosmetic seeded-ON/persist-OFF
gap applies to any new non-Daily config whose Advanced panel is opened
untouched, not only a Daily→switch.
* refactor(task-repeat): use type-only import in skip-overdue predicate
TaskRepeatCfgCopy is used only in type position; match the codebase's import type convention (58 other files). No behavior change.
Android/mobile soft keyboards hide the Enter key or deliver it as a composing
keydown that onKeydown filters out (to protect CJK candidate confirmation), so
the inline sub-task draft could not be committed at all: neither Enter, tapping
away, nor any on-screen control saved the text.
- Add an always-visible submit button beside the input as an accessible,
discoverable commit target (mouse-clickable, screen-reader labelled). Its
mousedown is preventDefaulted so a desktop click keeps input focus, commits,
and keeps the field open for rapid entry — mirroring the main add-task bar.
On touch the tap commits via the blur path below.
- Commit the draft on blur, but only on touch (isTouchActive): the natural
mobile "done" gesture is tapping away, and the soft-keyboard Enter is
unreliable there (#8791). Desktop keeps click-away-to-cancel — Enter and the
button are the reliable commit paths — so blur no longer silently creates a
task, and a user can move to the button without the draft being committed out
from under them. Escape still discards on all devices.
Both commit paths read the live input value, so IME/predictive-text buffering
no longer strands the text.
Closes#8856. Supersedes the standalone #8791 commit-on-blur branch.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Since #8785, a fresh client with no last-synced baseline gets an
OVERWRITE_WARNING_UNKNOWN confirmation after clicking Keep remote. The
test never accepted it, so the confirm backdrop blocked the later
triggerSync() click and timed out. Accept the confirmation, mirroring
the sibling webdav conflict tests.
* feat(tasks): move add-task-bar toggles into the actions row
The add-to-top/bottom and search toggles used to overlay the input's
top-right corner. They now sit at the start of the actions row (create
mode) and the search info row (search mode), prepended so they scroll
together with the rest of the row, separated by a dashed divider and
sized to match the neighbouring action chips.
A new ng-content slot on add-task-bar-actions lets the parent project
the toggles into the scrollable row; they are still rendered in the
search row when the action bar is not, so search stays toggleable.
* feat(tasks): wrap long add-task-bar titles onto multiple lines
The title field is now an auto-growing textarea (cdkTextareaAutosize) so a
long title wraps into view instead of scrolling off the right edge, which
is especially helpful on narrow screens. It stays single-line in meaning:
Enter still submits and pasted newlines are collapsed to spaces so no line
break ever reaches the task title.
cdkTextareaAutosize sizes the textarea to rows*line-height and ignores its
own padding, so the vertical breathing room lives on a wrapper element and
the field grows a line at a time. matInput is dropped (there is no
mat-form-field here and it broke the autosize height calc).
E2E selectors that targeted the title by the `input` tag now use the
tag-agnostic `.main-input` class.
* feat(tasks): refine add-task-bar toggles, note field and input autosize
- Move the note control from a labeled chip into an icon toggle in the
left group (search · note · add-to-top/bottom); hidden in search mode.
- Renumber the input shortcuts: Ctrl+1 add-to-bottom, Ctrl+2 search,
Ctrl+3 note, Ctrl+4-9 the action chips.
- Keep the submit (+) button's space reserved while empty via opacity
(visibility is re-asserted by mat-icon's anti-FOUC rule, so it stayed
visible).
- Animate the note field expand/collapse with the shared @expandFade.
- Fix a cdkTextareaAutosize height lag: the directive measures the
textarea with its horizontal padding stripped but its width unchanged,
so padding on the textarea made the measured wrap width wider than the
rendered one and the field grew a few chars after the text had already
wrapped (clipped line + scrollbar). Keep the title and note textareas
padding-free and move the gutters to their wrappers so measured and
rendered wrap widths match.
* feat(tasks): improve add-task-bar a11y, shortcut order and toggle emphasis
- Renumber the input shortcuts to match the on-screen left-to-right order:
Ctrl+1 search, Ctrl+2 note, Ctrl+3 add-to-top/bottom (chips stay Ctrl+4-9);
update the shortcut hints embedded in the tooltips accordingly.
- Add aria-label to every icon-only button and aria-pressed to the search
and note toggles so screen readers announce name and on/off state.
- Take the invisible submit (+) out of the tab order (disabled while empty).
- Guard the note toggle (and its Ctrl+2) to create mode; it is a no-op while
searching, where the note field does not render.
- Escape now dismisses an open task-suggestion list before closing the bar.
- Un-dim the toggles (0.6 + :focus-visible so keyboard focus isn't faint) and
give the submit + full-strength primary tint so the mobile CTA reads as the
primary action.
- Use the edit_note glyph for the note toggle (clearer than a chat bubble).
* refactor(tasks): apply add-task-bar multi-review cleanups
- De-duplicate the tooltip/aria-label expressions with @let (search,
backlog, add-to-bottom) so the two attributes can never drift.
- Split the Ctrl-shortcut map into local toggles (1-3) and action
shortcuts (4-9) so stopPropagation derives from structure instead of a
parallel key array that had to be kept in sync.
- Restore the "note attached" cue: the note toggle carries .has-value
(undimmed + tinted) when a collapsed note still holds text, distinct
from the .active pill shown while the note field is open.
- Remove dead code: the vestigial switch-add-to-btn class on the submit
button, the unused .search-input rule, a stale duplicated SCSS comment,
and the orphaned NOTE_BUTTON translation key.
- Document that the projected .inline-action-controls styles must stay in
the parent stylesheet (ng-content keeps the parent's encapsulation).
* feat(tasks): use notes bubble icon and restore expanded note draft
Match the notes icon used across the task feature (task list item,
detail panel, archived-task dialog) instead of the one-off edit_note
glyph. The glyph flips outline -> filled once the note field has text,
mirroring the detail panel's empty / has-notes states.
Also start the note field expanded when reopening the bar with a
persisted draft note, so it is visible rather than hidden behind the
collapsed toggle.
* fix(tasks): repair startup-overlay selector for add-task-bar textarea
The add-task-bar title field became a <textarea class="main-input">, but
StartupOverlayService still queried 'add-task-bar.global input'. A CSS input
type selector does not match a textarea, so on Android cold-start the partial-
text handoff (cursor position + focus) never ran and the overlay cleared only
via the 3s safety timeout. Target .main-input and retype to HTMLTextAreaElement.
Also address multi-review nits on the same branch:
- guard expandNote() in search mode so Ctrl+Enter cannot strand isNoteExpanded
(mirrors toggleNote()), with a spec covering it
- drop the unused fadeAnimation import and animations entry
- type the inputEl viewChild as ElementRef<HTMLTextAreaElement> (matches noteEl)
- repoint WorkViewPage.addBtn to .e2e-add-task-submit (.switch-add-to-btn now
belongs to the add-to-backlog toggle)
* test(tasks): clear add-task-bar sessionStorage between component specs
* docs(tasks): update add-task-bar keyboard shortcuts for new Ctrl mapping
The finish-day button is a routerLink inside an @if (hasDoneTasks()) /
@else swap, so marking a task done re-creates the element. A one-shot
click can land before Angular wires the new element's routerLink and
silently no-ops, hanging archiveDoneTasks for the full 30s waitForURL.
Retry the click via expect().toPass() until navigation actually starts.
Recurrence of the race first addressed in b8d1dbe261.
#8709 added a mandatory disableClose "Encrypt before first upload?" modal on
every fresh file-based sync setup. The setupWebdavSync helper dismissed it with
locator.isVisible({ timeout }), but isVisible() returns the CURRENT state
immediately and never polls — the timeout is effectively ignored. The modal
opens only after save()'s awaited provider-auth check and a lazy import() of the
dialog chunk, so it appears a beat after the Save click; isVisible() raced it,
returned false, and the Skip click was never issued. The leftover backdrop then
blocked every following interaction, failing ~11-12 @webdav specs per run with
"cdk-overlay-backdrop intercepts pointer events" and conflict dialogs that never
appeared. It passed only when the modal happened to already be up at the instant
of the check, which is why the failures looked flaky.
Use waitFor({ state: 'visible' }), which actually polls until the modal appears,
then click Skip and confirm it closes. Apply the same wait to the encrypt-at-
setup fill path. Verified green via a full @webdav CI run.
* feat(sync): offer E2EE at setup for file-based providers
File-based providers (WebDAV, Nextcloud, Dropbox, OneDrive, local file)
support optional client-side encryption but, unlike SuperSync, have no
mandatory-encryption upload guard — so their first setup sync would ship
plaintext before a user could enable E2EE.
Offer to set an encryption password during first-time setup and persist it
as part of the SAME config save: the key goes to the provider's privateCfg
and isEncryptionEnabled to the global config, atomically with isEnabled.
The normal first sync then encrypts from the first op via the standard
download-first flow — no separate snapshot-overwrite and no plaintext-upload
race. Skipping keeps the existing unencrypted behavior; works offline too.
Reuses DialogEnableEncryptionComponent in a new side-effect-free
collectPasswordOnly mode (returns the password, writes nothing), with a
provider-aware Skip action for the disableClose setup modal.
* test(sync): skip file-based setup encryption prompt in webdav e2e helper
Fresh file-based setup now opens the optional "Encrypt before first upload?"
dialog before the config is persisted, so setupWebdavSync would stall on the
disableClose modal. Dismiss it via a new Skip selector so every WebDAV spec
proceeds unencrypted, exactly as before; encryption specs still enable it
afterwards via enableEncryption().
* test(sync): add e2e for file-based setup-time encryption
Adds a @webdav @encryption spec that configures WebDAV with the encryption
password set in the setup dialog, then asserts the first upload's remote
sync-data.json does NOT contain the task title in plaintext (compression is
off by default, so a plaintext upload would) — proving the first sync is
encrypted — and that a second client with the same password decrypts and
receives the task without overwriting the remote.
setupWebdavSync gains an encryptAtSetup option (fills the new dialog instead
of skipping it); adds e2e selectors on the setup dialog password/confirm/
submit controls.
* ci(e2e): allow targeting the webdav job via workflow_dispatch grep
The webdav e2e job hardcoded --grep "@webdav"; add a webdav_grep dispatch
input (default @webdav, mirroring the supersync job) so a specific WebDAV
test can be run on demand.
* test(sync): fix remote sync-file path in setup-encryption e2e
Non-production builds nest the file under a /DEV segment
(sync-providers.factory.ts). The raw-fetch assertion omitted it and 404'd;
the CI trace confirmed the app uploaded to <folder>/DEV/sync-data.json and
that the first upload was encrypted.
* test(sync): cover joining an unencrypted remote with setup-time encryption
Adds the data-safety edge case for file-based setup-time E2EE: a client that
sets a password at setup while joining a remote that already holds UNENCRYPTED
data. Asserts the join reads and preserves the existing data (does not overwrite
the remote with the joining client's empty state) and that a subsequent write
upgrades the remote to encrypted (no plaintext titles remain).
Standardize dialog actions: primary=mat-flat-button color=primary,
secondary/cancel=mat-button, destructive=color=warn. Drop dead Bootstrap
'btn btn-primary' classes and decorative check/close icons. Document the
convention in docs/styling-guide.md and update e2e selectors that keyed
on the old stroked variant.
Closes#8683
* fix(sync): never upload plaintext ops for E2EE-mandatory providers
SuperSync's first-time setup ran an initial sync (dialog-sync-cfg save ->
sync(true)) BEFORE the user chose an encryption password, so all local ops
(incl. issue-provider credentials) were uploaded to the server in cleartext.
Completing setup deleted them; aborting left them stored indefinitely, breaking
the E2EE promise (GHSA-9v8x-68pf-p5x7).
Add an optional `isEncryptionMandatory` capability to OperationSyncCapable
(true for SuperSync) and refuse to upload in the op-log upload path while no
usable key is configured. Downloads still run (merge-first) and the encryption-
enable flow performs the first, encrypted upload, so the setup flow and prompt
are unchanged. File-based providers, where unencrypted sync is a legitimate
user choice, leave the flag unset.
* fix(sync): fail closed on plaintext snapshot for E2EE-mandatory providers
Multi-review hardening for GHSA-9v8x-68pf-p5x7. The op-upload guard closes the
reported leak, but SnapshotUploadService.deleteAndReuploadWithNewEncryption could
still push a plaintext snapshot for SuperSync on two adjacent paths: the (today
UI-unreachable) disable-encryption flow, and a keyless import declaring
isEncryptionEnabled:true. Reject an unencrypted snapshot for an encryption-
mandatory provider before any destructive deleteAllData, so the "never transmit
plaintext" invariant holds regardless of caller.
Also lower the mandatory-encryption upload-skip log from warn to normal: it is an
expected by-design skip during the pre-encryption setup window and would
otherwise fire on every auto-sync cycle.
* fix(sync): consolidate op-log into the encryption-enable snapshot
Follow-up to the GHSA-9v8x-68pf-p5x7 upload guard. With the guard, first-time
SuperSync setup leaves the whole local history unsynced until encryption is
enabled; the enable-snapshot then represents that full state, but the ops were
re-uploaded incrementally on top of it on the next sync (redundant server op-log
bloat, and previously untested at whole-history volume).
deleteAndReuploadWithNewEncryption now captures the pending ops the snapshot
subsumes (before the destructive delete, under runWithSyncBlocked + a modal
dialog so the set is stable) and marks them synced after a successful upload —
mirroring planRegularOpsAfterFullStateUpload in the op-log upload path, which
this direct snapshot upload bypasses. Also fixes the same latent redundancy in
the enable-from-settings-with-pending-ops flow.
Adds a multi-client e2e: local task history exists before first-time encrypted
setup, then a second client with the same password receives exactly those tasks
with no duplicates, conflicts, or errors.
* ci(e2e): add optional grep input to manual SuperSync e2e dispatch
* fix(sync): capture subsumed ops before state snapshot to prevent mark-synced data loss
deleteAndReuploadWithNewEncryption captured getUnsynced() AFTER the full-state
snapshot, so an op created in that window was marked synced yet absent from the
snapshot — silently lost. Capture the unsynced set first: every marked-synced op
is then guaranteed present in the snapshot, and a concurrent op arriving after
the capture is left unsynced and re-uploaded next sync (idempotent by op id).
* test(sync): mock WebCrypto so mandatory-encryption guard test reaches the guard
The 'enabling without a usable key' case passed isEncryptionEnabled: true but did
not mock WebCrypto, so the availability check threw WebCryptoNotAvailableError in
non-secure CI before the /unencrypted snapshot/ guard under test.
Issue #7330 ("Data damage detected ... Repair attempted but failed")
recurred on SIMPLE_COUNTER for users already on >= v18.6.0, where the
original TASK-only fix didn't reach.
Root cause is the same partial-LWW-recreate path: a concurrent
delete-vs-update across devices resurrects a counter (LWW resolves
local-delete + remote-update to 'remote'), and lwwUpdateMetaReducer
recreated it from the {id}-only delete payload. Because SIMPLE_COUNTER
had no RECREATE_FALLBACK entry, the recreated counter was missing
required fields - most often `type`, an enum typia rejects and that
dataRepair/autoFixTypiaErrors had no rule for - so post-sync validation
dead-ended on the repair dialog every sync.
Fix mirrors the TASK fix, two layers kept in lockstep by requiredKeys:
- Register SIMPLE_COUNTER in RECREATE_FALLBACK (defaults from
EMPTY_SIMPLE_COUNTER, type=ClickCounter) so the meta-reducer recreate
path backfills required fields and the bad state is never created.
- Add a simpleCounter.<id>.<field>===undefined branch to
autoFixTypiaErrors to heal copies already corrupt on disk.
Tests: auto-fix + meta-reducer unit specs (incl. a real-typia
appDataValidators.simpleCounter proof), a full validate->repair->validate
integration repro, and a deterministic SuperSync delete-vs-update e2e
asserting no native repair dialog fires.
* feat(sync): nudge long-time users without sync to set it up
Offline-first means a user who never configures sync has no backup at
all; clearing browser data or losing the device wipes everything. Once
the app has clearly been used for a while AND holds real data, show a
calm, low-priority banner once encouraging sync setup.
Trigger combines wall-clock age (>= 7 days since a lazily-seeded
FIRST_USE_TIMESTAMP) with a task-count gate (>= 20 tasks), so it is
robust both to users who restart many times a day and to those who
leave the app running for weeks (where app-start count fails), and
never nags an empty/dormant install. Shown at most once: both "Set up
sync" and "Not now" persist a dismissed flag; a configured provider
suppresses it entirely.
Reuses the existing banner system and mirrors NoteStartupBannerService.
* refactor(sync): apply review feedback to data-safety nudge
- Rename LS.FIRST_USE_TIMESTAMP -> SYNC_SAFETY_FIRST_SEEN and document that
it is not a true install date (seeded at upgrade time for existing
installs), so no other feature reuses it as one.
- Tests: add an unhydrated-config (undefined) skip case and assert the
task-count selector, guarding the earlier race fix and a selector swap.
- i18n: match each locale's existing register for the nudge message
(pt/cs/sk/tr -> formal, id -> informal) per translation review.
* test(planner): stabilize add-subtask-from-detail focus race
Wait for the detail panel's deferred open-time auto-focus to settle before
opening the inline subtask draft. The auto-focus lands ~200ms after the panel
opens and, if it fires after the draft input is focused, steals focus and
blur-closes the input — making toBeFocused() flake with 'element(s) not found'.
Mirrors the guard already used in add-subtask-with-detail-panel-open.spec.ts.
* feat(focus-mode): make preparation opt-in, smooth start transition
The full-screen preparation countdown is now opt-in (off by default) via a
new isShowPreparation config flag; the deprecated isSkipPreparation is kept
for synced-config back-compat. By default, starting a session now plays a
brief inline rocket launch from the play button, then begins.
Smooth the prep->running swap: the clock/controls cross-fade sequentially
(old fades out, then new fades in) via a new fadeSwap animation.
Fix the focus task-selector panel that rendered transparent (undefined
--c-bg-raised) -> opaque highest-elevation surface on the standard scrim.
* fix(focus-mode): guard re-entrant start, reduced-motion, clock cross-fade
- Ignore a re-entrant startSession() while the inline launch is playing
(keyboard Enter / double-click on the still-focused FAB) and disable the
play button during launch, so a second timer can't reset the new session.
- Skip the inline rocket launch + its 800ms delay under prefers-reduced-motion
and start immediately (no invisible dead delay for motion-sensitive users).
- Cross-fade the clock digits with the duration slider (fade out before fade
in) instead of a hard visibility toggle.
- Fix stale e2e launch-duration comment (~600ms -> ~800ms).
* fix(tasks): make detail-panel add-subtask work in the Planner (#8617)
The detail panel and context menu delegated "Add subtask" to
AddSubtaskInputService.requestOpen(), a signal consumed only by the
<task> row that renders the parent. The Planner renders tasks as
<planner-task>, so the request was dropped and nothing happened
(regression from #8423; Today view still worked).
- task-detail-panel now hosts its own inline <add-subtask-input>
(works in every view; also fixes the input opening behind the
bottom panel on mobile). It controls the sub-task section's
expansion via a signal and focuses the input after the expand
animation completes; animates in/out with [@expandFade].
- task-detail-item gains expandedChange/afterExpand outputs so the
panel can control/observe the Material expansion panel.
- context menu addSubTask() reverts to direct addSubTaskTo() (a
transient menu has no place to host the inline draft).
Adds a Planner e2e repro and updates the affected unit specs.
* fix(tasks): address multi-review findings for #8617 add-subtask
- Focus the inline input via a deferred timeout in onSubTasksAfterExpand:
with animations disabled Material fires afterExpand synchronously within
the same CD pass, before the addSubtaskInput viewChild is committed, so
the first collapsed→expand "Add subtask" click left the input unfocused.
- Reset isSubTasksExpanded on task switch so the sub-task section doesn't
stay sticky-expanded across tasks (the panel instance is reused).
- Return focus to the "Add subtask" button when the draft is closed via
Escape, instead of dropping focus to <body>.
- Refresh the now-stale AddSubtaskInputService doc comment.
- Assert the draft input is focused in the Planner e2e (the focus path was
previously uncovered).
* fix(tasks): keep context-menu add-subtask on the inline-draft bus
The earlier context-menu change to addSubTaskTo() was unnecessary: the
context menu's "Add sub-task" entry is gated behind isAdvancedControls,
which only the <task> row enables. planner-task and schedule-event leave
it false, so the entry is hidden there — meaning the menu action is only
ever reachable from a rendered <task> row, where requestOpen() works.
Reverting restores the v18.12 inline-draft UX for that path and shrinks
the diff. (#8617 was only ever reachable via the detail panel, which the
self-hosted input fix already covers.)
* test(tasks): cover add-subtask focus with animations disabled
Guards the deferred-focus fix: with animations disabled Material fires
the expansion panel's afterExpand synchronously, before the panel's
add-subtask-input viewChild is committed. Verified this test fails
without the setTimeout deferral and passes with it.
* feat(tasks): add quick "done" icon button to reminder dialog footer
Promote marking a reminder's task as done from the overflow menu to a
one-click icon button beside the snooze split-button. Uses done_all for
the multi-task view and check for the single-task view, with a tooltip
and aria-label sourced from existing translation keys.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TYst27M7X994sRwdz8Q6XV
* style(tasks): match reminder done button to outlined snooze control
The quick done button rendered as a borderless mat-icon-button, clashing
with the outlined snooze split-button and filled primary CTA beside it.
Switch it to a compact, outlined square (mat-stroked-button) so it reads
as a peer of the snooze control.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TYst27M7X994sRwdz8Q6XV
* refactor(tasks): equalize reminder done button spacing, drop menu duplicate
- Cancel Material's adjacent dialog-action button margin in the footer row
so the done button has equal flex-gap spacing on both sides (the split-
button host isn't a button-base, so the margin only landed on one side).
- Remove the now-redundant done/complete item from the overflow menu since
it is a visible button, and drop the orphaned DONE translation key.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TYst27M7X994sRwdz8Q6XV
* style(tasks): widen reminder footer button gap to var(--s)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TYst27M7X994sRwdz8Q6XV
* fix(tasks): show prominent reminder done button only for single task
For multiple reminders, demote complete-all back into the overflow menu
and rely on the existing per-row check buttons. Bulk-complete is the most
destructive footer action with no undo, and 'I finished all of these' is
the least likely bulk intent — so it should not occupy a prominent,
single-click slot. The visible done button now only appears for a single
reminder.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TYst27M7X994sRwdz8Q6XV
* test(reminders): dismiss deadline reminder via new done button
---------
Co-authored-by: Claude <noreply@anthropic.com>
An overdue scheduled reminder kept re-opening its modal every ~10s (the
reminder worker's check interval) and on every app resume. Dismissing the
dialog via backdrop / Escape / Android back runs none of the clear logic
(ngOnDestroy only clears deadline reminders), so the worker re-emitted the
still-active reminder and the module re-opened the modal indefinitely. On
mobile this reads as a fully frozen app where no controls respond.
Add a short in-memory UI cooldown: on a passive dismiss, scheduled reminders
are suppressed from re-opening for 5 minutes. The reminder itself stays
active — it re-nudges after the cooldown and on cold start — and explicit
actions (snooze / done / add-to-today / reschedule) are unaffected. The
cooldown is presentation-only; no synced state is touched.
Related to #8551 (a dropped done-from-notification leaves a task overdue and
active), the common way to reach this state on Android.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The scheduled master E2E (run 27927390789, SuperSync 3/6 shard) flaked on
"Bidirectional sync works after encryption change via import". The stuck
overlay was the "Decryption Failed" dialog (dialog-handle-decrypt-error):
it fires asynchronously after a re-sync, and the setupSuperSync dialog
loop only handles it within its bounded rounds. A late re-fire was never
dismissed, so the final one-shot expect(...).toHaveCount(0) watched the
disableClose dialog for the full 15s and timed out.
Replace the one-shot late-dialog wait + close assertion with a converging
expect.toPass() poll that dismisses whichever late, non-self-closing dialog
is present each round (enable-encryption or decrypt-error), then re-checks
the overlay is empty — handling appearances that land early, late, or never.
Extract the decrypt-error handling into a shared _handleDecryptErrorDialog
helper reused by the loop and the drain.
Verified via prettier/eslint/tsc; not run live (the @supersync docker e2e
can't reach the server from the dev sandbox).
* refactor(tasks): restructure reminder dialog footer into primary + overflow
The footer showed up to four visually identical stroked buttons (Snooze,
Done, Add to Today, Start) with no hierarchy. Reduce to a single filled
primary action plus a Snooze menu and an overflow (kebab) menu:
- Primary: Start (single) / Schedule for today (multiple)
- Snooze button keeps the quick-defer menu (10/30/60 min, tomorrow)
- New overflow menu holds the rest (Done/Complete, Add to Today, edit,
unschedule, dismiss-keep-today)
All existing actions remain available; only their grouping changes.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bqs4hesfHeJATSCr4mRoXE
* refactor(tasks): add dropdown caret to reminder snooze button
The Snooze button opens a menu but had no visible affordance signalling
that. Add a trailing arrow_drop_down icon (native Material iconPositionEnd
slot) so the menu trigger reads as such, surfaced by the review.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bqs4hesfHeJATSCr4mRoXE
* test(reminders): open overflow menu to reach Done in deadline specs
The reminder dialog's "Done" action moved from a footer button into the
new overflow ("More actions") menu, so the two deadline e2e specs must
open that menu before clicking Done.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bqs4hesfHeJATSCr4mRoXE
* refactor(tasks): refine reminder footer hierarchy after UX review
Follow-up to the footer restructure, addressing review findings:
- Surface a one-tap "Done" (check) icon for single reminders, so the most
common reminder response isn't two taps deep in the overflow menu. Left
out for the multi-task case, where a one-tap complete-all is a footgun
and per-row checks already exist.
- Make the primary action deadline-aware: deadline reminders now default
to the safe "Add to Today" instead of "Start" (which sets the current
task and reorders Today as a side effect). Start moves into the overflow
for deadlines; scheduled reminders keep Start as primary.
- Move "Edit (reschedule)" into the Snooze ("when") menu so all
time-deferral actions are grouped and the overflow holds pure
dispositions.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bqs4hesfHeJATSCr4mRoXE
* test(reminders): click one-tap Done icon in deadline specs
The single-task reminder footer now surfaces "Done" as a one-tap icon
button (aria-label "Mark as done"), so the deadline specs click it
directly instead of opening the overflow menu.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bqs4hesfHeJATSCr4mRoXE
* refactor(tasks): put primary reminder action on the right, Done in menu
Per UX feedback:
- Reverse the footer button order so the filled primary action is always
on the right (overflow on the left, Snooze in the middle).
- Move "Done" back into the overflow menu instead of surfacing it as a
standalone icon button.
Update the deadline e2e specs to open the overflow menu before clicking
Done accordingly.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bqs4hesfHeJATSCr4mRoXE
* feat(tasks): allow dismissing reminder dialog + snooze split-button
Three reminder-dialog improvements:
- Click-away to dismiss: backdrop click / Escape now close the dialog and
dismiss the reminders (clearing the reminder while keeping the task and
its schedule). disableClose is kept and the events are handled in the
dialog, so the worker does not immediately re-open it in a loop.
- Snooze split-button: a single click snoozes 10 min (the common default,
previously a hidden double-click) and a joined dropdown caret opens the
full options menu. Uses a new shared .g-split-btn style.
- Overflow button: replace the lone vertical "three dots" icon with a
low-emphasis labeled "More" button, giving a clean emphasis ramp
(More < Snooze < primary) with the primary kept on the right.
Updates the deadline e2e specs and wiki for the new dismiss behavior, and
adds unit tests for the backdrop/Escape dismissal.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bqs4hesfHeJATSCr4mRoXE
* fix(tasks): make reminder dialog close on backdrop/Escape
Per product decision, clicking the backdrop or pressing Escape now simply
closes the reminder dialog (drop disableClose) rather than dismissing the
reminders. Deadline reminders are still cleared on close (avoiding the 10s
re-fire loop); scheduled reminders stay active and the worker re-shows them
until the user acts on them.
Reverts the earlier intercept-and-dismiss approach (and its unit tests);
updates the wiki accordingly.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bqs4hesfHeJATSCr4mRoXE
* feat(tasks): add split-button UI component, restructure reminder footer
Add a reusable <split-button> UI component (src/app/ui/split-button): a
primary default action joined flush to a compact, centered overflow trigger
that opens a passed-in menu. The joined treatment lives in the shared style
layer (styles/components/split-button.scss) and now renders with no gap
between the two halves and a properly centered trigger icon.
Use it in the reminder dialog footer: limit the footer to two actions —
"Snooze 10m" (showing the snooze duration) and the primary CTA (Start /
Add to Today) — and fold the former "More actions" and snooze-options menus
into a single overflow menu opened by the icon button right of snooze.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bqs4hesfHeJATSCr4mRoXE
* fix(tasks): fuse split-button halves and update reminder dialog test
The split-button trigger was pushed 8px away from the default action by
Angular Material's `.mat-mdc-dialog-actions .mat-mdc-button-base +
.mat-mdc-button-base` rule (specificity 0,3,0), which outweighed the
joining `margin-left: -1px`. Match Material's selector hooks so the
negative margin wins and the two halves render flush inside dialogs.
Also drop the stale `disableClose: true` assertion from the reminder
module spec to match the now-dismissable dialog (fixes failing CI).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bqs4hesfHeJATSCr4mRoXE
* fix(tasks): clarify reminder dialog "add to today" button label
The multi-task primary button said "Schedule for today", which is both long
and misleading: those tasks are already scheduled — the action drops their
time and keeps them on Today (all day), or for deadlines adds them to Today
while keeping the deadline date.
Key the label on deadline vs schedule instead of single vs multiple:
- all deadlines -> "Add to Today" (added to today, deadline preserved)
- scheduled/mixed -> "Today" (already scheduled; kept on today, all day)
Reuses the existing TODAY_TAG_TITLE string; SCHEDULE_FOR_TODAY remains for
the per-row tooltips.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bqs4hesfHeJATSCr4mRoXE
* fix(tasks): distinguish deadline vs scheduled in reminder today-button
After weighing options, label the reminder dialog's primary today-action by
what it actually does in each case:
- all deadlines -> "Add to Today" (task is added to Today; deadline kept)
- scheduled/mixed -> "Keep in Today" (already scheduled; kept on Today, all
day, with the specific time dropped)
This replaces the bare, verb-less "Today" with accurate wording consistent
with the dialog's existing "keep in Today" vocabulary. Adds the
KEEP_IN_TODAY en string (other locales fall back to English via fallbackLang).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bqs4hesfHeJATSCr4mRoXE
* chore(reminders): drop unused SNOOZE_OPTIONS string, regenerate t.const.ts
The reminder-dialog footer trigger uses MORE_ACTIONS, not SNOOZE_OPTIONS,
which was a dead key from an earlier iteration. Remove it and regenerate
t.const.ts through prettier so the file is back to the formatted form
(the prior commit had committed raw generator output, churning ~760 lines).
* refactor(ui): trim unused split-button inputs, add unit spec
The split-button's only consumer never overrides color or triggerIcon, so
drop both inputs (and the deprecated ThemePalette type) and inline the
defaults. Add a unit spec covering content projection, mainClick, menu
wiring, disabled state, and the trigger aria-label.
---------
Co-authored-by: Claude <noreply@anthropic.com>
* fix(focus-mode): preserve flowtime break settings across mode switch
Switching the Flowtime break mode (Ratio-based <-> Rule-based) hides the
inactive section. Formly resets hidden fields by default, wiping the
user's break rules / percentage on a Rule -> Ratio -> Rule round-trip.
Set resetOnHide: false at every level of the breakRules repeat (field,
fieldArray and each inner input) plus on breakPercentage, so the hidden
side keeps its values. Setting it only on the top-level field preserves
the rows but strips their values, which is why a heavier cache/restore
approach appeared necessary.
Adds regression specs that drive the real breakMode control (not the
internal model) so they reproduce the dialog's actual behaviour.
Part of #7581
* test(focus-mode): real-DOM e2e for flowtime mode-switch preservation
Drives the actual mat-select Rule -> Ratio -> Rule round-trip and a real
backspace-clear in the running app. Verified to fail without the
resetOnHide fix (rule inputs are stripped after the round-trip), so it
guards the #7581 regression at the layer where it actually reproduced.
* fix(tasks): keep subtask input open after add
* fix(tasks): keep subtask input open after add
* perf(tasks): avoid task row effect reactivity
* fix(tasks): address subtask input review
* feat(tasks): ease in the inline subtask input instead of popping
* fix(tasks): consume inline subtask-input request to prevent stale focus-steal
Address multi-review findings on the inline subtask input:
- The open request was held in a signal that was never cleared, so a task row re-created with the same id (e.g. navigating away from a project and back) re-ran its open effect on init and re-opened the input, stealing focus with no user action. Reset via consume() once the row acts on it.
- Drop the redundant requestId counter (a fresh request value already re-fires the effect); request payload is now just the parentId string.
- Add an aria-label to the input (placeholder is not an accessible name).
- _commit() returns void (its boolean result was unused).
* feat(tasks): animate the inline subtask input out and tighten its top gap
- Use the expandFade animation (enter + leave) so the input collapses out instead of vanishing. Caveat: when the input is the only thing in .sub-tasks (adding the first subtask, then cancelling without creating any), the enclosing @if collapses in the same tick and Angular skips the child leave animation, so it's instant in that one case.
- Reduce the input's top margin from --s-half (4px) to --s-quarter (2px).
* feat(tasks): refocus the task row when the subtask draft is cancelled with Escape
The inline subtask input's 'closed' output now reports why it closed ('escape' vs 'blur'). On Escape (a keyboard cancel) the host task calls focusSelf() so keyboard navigation continues from that row; on blur it doesn't, since focus already moved elsewhere. focusSelf() is a no-op on touch.
Covered by unit tests (reason emitted, host refocus only on escape) and an e2e assertion that the task row is focused after Escape.
* feat(tasks): return focus to the task the subtask draft was opened from
Escape previously refocused the parent row that hosts the input. Capture the originating task (which may be a subtask) when opening the draft — before focus moves into the input and the parent row claims focusedTaskId — and restore that on Escape. Falls back to the host row when no origin was captured; no-op on touch.
Focus-by-id prefers the last #t-<id> instance (the detail side-panel one) to match the existing inline-edit focus convention when a task renders twice.
Covered by unit tests and an e2e proving focus returns to the originating subtask, not its parent.
* test(tasks): update subtask e2e for the inline draft input flow
The add-subtask UX changed from 'press a -> empty subtask title focused for edit' to an inline draft input (type + Enter to commit, stays open for rapid entry). Update every e2e site that added subtasks via the old textarea selector:
- WorkViewPage.addSubTask helper: wait for .e2e-add-subtask-input, fill + Enter, then Escape to close the draft for a clean post-state (fixes simple-subtask, add-to-today, drag-task-into-subtask, finish-day-quick-history, and the sync specs that use the helper).
- add-subtask-with-detail-panel-open.spec.ts: assert the draft input opens focused from panel/main-list focus; rewrite the multi-subtask case to repeated Enter (the new rapid-entry path).
- supersync-archive-subtasks + supersync-lww-conflict: same inline-draft flow (not runnable in-sandbox; fixed by analogy).
Verified locally: detail-panel-open 3/3, simple-subtask, add-to-today 5/5, drag-into-subtask, finish-day-quick-history all green.
---------
Co-authored-by: Johannes Millan <johannes.millan@gmail.com>
* fix(tasks): respect auto-add-to-today setting when completing tasks
Completing an unscheduled top-level task stamped a completion-day dueDay
even when "Automatically add worked-on tasks to today" was off (discussion
#8463). The meta-reducer updateDoneOnForTask synthesizes that dueDay
ungated by the setting, and the gated effect autoAddTodayTagOnMarkAsDone
had become dead (the reducer set dueDay before its \!task.dueDay filter ran).
Gating the reducer on the synced config would diverge on replay since ops
apply in causal arrival order, so the decision is frozen into the op at
completion time instead: getMarkDoneTaskChanges passes an explicit
dueDay: null for an unscheduled top-level task when the setting is off,
which trips the reducer's hasScheduleInUpdate guard and skips synthesis.
null (not undefined) survives serialization for deterministic replay; the
reducer itself is left untouched so legacy/replay behavior is unchanged.
Applied at every completion producer: TaskService.setDone (the funnel for
the context menu, reminders, bulk project-done, Android, etc.), the four
components that dispatched updateTask directly (task-list, schedule-event,
focus-mode-main/break now route through setDone), and the moveTaskToDone$
and auto-mark-parent effects. The dead autoAddTodayTagOnMarkAsDone effect
is removed so completion-dating has a single source of truth.
Only future completions are affected; already-dated tasks are unchanged.
* fix(tasks): freeze offset-correct completion day into the op
Follow-up to the auto-add-to-today fix. When auto-add is on, the completion
day was still synthesized by the reducer, which derives it from doneOn via
the offset-blind getDbDateStr during replay while the live path used the
offset-adjusted todayStr. For users with a custom "start of next day" this
made the stamped dueDay differ between the originating device (logical day)
and a replaying device (calendar day).
getMarkDoneTaskChanges now freezes the offset-adjusted logical day
(DateService.todayStr) into the op for unscheduled top-level completions
(dueDay: todayStr when on, dueDay: null when off). Producers pass todayStr;
the reducer applies the explicit value and its synthesis stays only as a
legacy fallback. Replay reproduces the frozen value, so the day is both
offset-correct and identical across devices. TODAY_TAG membership is
unchanged (the dueDay-change branch fires identically for an explicit vs
synthesized today).
* refactor(tasks): record only doneOn on completion, never stamp dueDay
Completion no longer synthesizes or freezes a dueDay (reverting the Option A
gating and the May-18 completion-day stamp). The Today "Done" list is driven
by isDone/doneOn, not dueDay, so completed tasks still show there. Existing
schedules are preserved on completion; the isAutoAddWorkedOnToToday setting
now gates only the time-tracking auto-add path. Removes the dead
autoAddTodayTagOnMarkAsDone effect, the reducer dueDay synthesis, and the
converter's legacy dueDay back-fill (keeping the doneOn back-fill).
* refactor(tasks): simplify done-today counter, document legacy-op sanitizer
Multi-review follow-up. The done-today counter now counts via the flat
selectAllTasks instead of selectAllTasksWithSubTasks + flattenTasks (same
count, no per-emission nesting/allocation). Adds a comment clarifying that
sanitizeDoneScheduleChanges now exists only as legacy-op replay defense.
* test(workflow): pin finish-day archive of unscheduled completed tasks
Regression guard for discussion #8463. Since completion now records only
doneOn (no dueDay), this e2e proves an unscheduled completed task still
(a) shows in the Today Done list and (b) is cleared to the archive on
Finish Day — both driven by isDone/doneOn, not dueDay. Fails if a future
change scopes the Today Done list or the finish-day archive by dueDay.
* chore: drop stray plugin-dev lockfile changes from e2e build
The e2e plugin build bumped vite in packages/plugin-dev lockfiles; that
churn was unintentionally swept into the test commit and is unrelated to
this PR. Restore both lockfiles to the base version.
* test(archive): archived completion no longer stamps dueDay
The TaskArchiveService.updateTask path runs the same task-shared meta-reducer
as live tasks, so #8463's "completion records only doneOn, never dueDay" now
applies there too. Update the assertion: completing an archived task sets
doneOn but leaves dueDay undefined.
Apply verified findings from a multi-agent review of the resize-close fix:
- Guard the navigation listener on `dialogRef.getState() === OPEN`. A
breakpoint-crossing resize can emit more than one popstate, and
MatDialogRef.close() does not check its own state — a second call would
re-run the exit animation and schedule a duplicate close timeout. The
guard makes the second+ fires no-ops (the saved result still resolves
once, so this was churn, not data loss).
- Add unit tests asserting the navigation-close actually PERSISTS the
edit (the point of #8434), both via `changed` when alive and via the
direct dispatch after a breakpoint switch destroys the host (#8432),
plus a test that a second popstate while closing does not re-close.
- e2e: hover the notes area before clicking the opacity-hidden
fullscreen control, matching the real user flow.
- Tighten the duplicated explanatory comment.
No behavior change to the fix itself. 77 inline-markdown unit tests and
the note-resize e2e pass.
The fullscreen markdown editor is a detached CDK overlay opened with
MatDialog's default closeOnNavigation. That maps to the overlay's
disposeOnNavigation, which on any Location change (popstate/hashchange)
disposes the overlay with NO result. Resizing the window across the
mobile layout breakpoint fires such a navigation, so the editor was torn
down and the in-flight note silently dropped.
Open the dialog with closeOnNavigation: false and reinstate close-on-
navigation by subscribing to the same Location signal, but close through
the dialog's save path so the edit is persisted instead of discarded.
This also keeps the Android back-button closing the editor (it routes
through history.back -> popstate) and now saves on that path too.
The listener is not tied to takeUntilDestroyed so it still fires after a
breakpoint switch destroys this host mid-edit; the save then lands via
the existing destroyed-host dispatch (#8432). Torn down in afterClosed.
Covered by inline-markdown unit tests and a new note-resize e2e.
Add an e2e regression guard asserting the context-resolved
/active/daily-summary route opens the daily summary instead of falling
through to the wildcard and redirecting to the start page. The unit spec
only pins the navigation string; this verifies the string is a navigable
route.