Commit graph

308 commits

Author SHA1 Message Date
Johannes Millan
57591bd4e6
feat(sync): diagnose encrypted operation failures (#9331)
* feat(sync): add encrypted operation diagnostics

Capture immutable encrypted SuperSync histories through the read-only download API, then classify per-operation decryption failures offline without exposing plaintext or secrets. Document the recovery-safe workflow and cover pagination, integrity, and CLI safeguards for #9256.

* test(sync): reproduce final-page decryption failure

Exercise the real browser, encryption, pagination, and SuperSync server path with a valid encrypted full-state page followed by one corrupted operation. Verify the correct key reaches the final page, recovery remains atomic, and retry restarts from sequence zero for #9256.

* feat(sync): attribute encrypted download failures

Retain server envelopes through decryption so exportable logs can identify the failing operation and sequence without recording user content or credentials.

Preserve the existing atomic abort and retry behavior, with real encrypted final-page coverage for #9256.

* refactor(sync): drop server-side encrypted-ops diagnostic tool

* feat(sync): classify whole failed encrypted batch client-side

* fix(sync): address multi-review findings on decrypt diagnosis

* test(sync): cover decryptBatchSettled; correct errorName triage docs
2026-07-28 16:32:54 +02:00
Johannes Millan
2b2a36ae56
fix(sync): reject uploads behind state replacements (#9330)
Persist the latest SYNC_IMPORT/BACKUP_IMPORT boundary and reject incremental uploads whose cursor has not observed it. Reconcile upgrade rows lazily, preserve explicit account-reset recovery, and cover cache, quota, PostgreSQL, and encrypted-client paths.
2026-07-28 11:28:29 +02:00
Johannes Millan
76d9836ac3
fix(sync): preserve section semantics during conflict replay (#9326)
* fix(sync): preserve section semantics during conflict replay

Add real-client regression coverage for SECTION convergence and REPAIR recovery, plus durable WebDAV migration stages, released-client policy, and Capacitor lifecycle I/O.\n\nUse fail-closed SECTION metadata validation and causal replay so concurrent move, removal, and reorder operations cannot collapse into lossy entity snapshots.\n\nRefs #9262

* fix(sync): harden section replay safety

Defer semantic replay when retained or pending operations can reorder the transition, while allowing valid null and same-section placements.

Verify release provenance and strengthen WebDAV and fresh-client recovery coverage.

* fix(sync): make section replay state-based and atomic

Project section replacements against a stable reducer snapshot so later accepted actions and deleted anchors are represented. Persist replacements and predecessor rejection in one operation-log transaction.

* fix(sync): preserve scoped section replay order

Scope SECTION projection to the originating work context and preserve Project/Tag ordering when a removed task is later re-added.

Persist dependent placement compensations in current predecessor order and cover the real server rejection path.

* fix(sync): preserve legacy section removal ordering

Pair projected section removals with an exact work-context state update so v18.4.0-v18.4.3 retain task ordering.

* test(sync): require complete legacy compensation state

* fix(sync): avoid duplicate rejected-op recovery

* test(sync): prove single-cycle rejected-op recovery
2026-07-27 16:01:07 +02:00
Johannes Millan
33b91716b1
test: close critical E2E and CI coverage gaps (#9274)
* test(worklog): verify archived duration corrections persist

* ci(android): run JVM tests on pull requests

Run the Play and F-Droid debug JVM suites for Android-affecting changes while keeping a stable required-check status on unrelated PRs.\n\nCloses #8734

* test(supersync): run repair causality integration test

Include the PostgreSQL repair-causality regression in the CI integration script's explicit test list.\n\nCloses #8773

* ci(sync): close provider E2E path gaps

Select both real-provider suites for every persistent action owner and the client clock, date, and replay guards. Mirror the same coverage on master and release pushes.\n\nRefs #8733\nRefs #9262

* ci(android): run instrumentation tests on emulator

Use the AndroidX test runner and execute the Play debug instrumentation suite on an API 35 emulator for Android-affecting pull requests. This gates the on-device CursorWindow data-loss regression.\n\nRefs #8401\nRefs #9262

* ci(shared-schema): run package tests

* ci(plugins): discover test suites from manifests

Closes #8733

* test(boards): verify board creation persists

* test(migration): require legacy error recovery flow

* test(navigation): remove redundant route smoke

* test(keyboard): verify default shortcut behavior

* test(migration): verify error acknowledgement cleanup

* test(worklog): verify corrected time CSV export

* test(keyboard): verify remapped shortcut persistence

* test(recurring): verify occurrence and series removal

* test(mobile): add WebKit touch smoke

* test(pwa): verify persisted offline reload

* test(pwa): verify update activation

* test(performance): verify large-list row reuse

* test(webdav): cover split migration recovery

Exercise a real v2-to-v3 migration through WebDAV when the primary tombstone commits but its response is lost. Verify restart recovery, backup neutralization, pending-data survival, and the split-disabled no-write guard.
2026-07-24 10:57:29 +02:00
Johannes Millan
238b91aa59
test(e2e): accept beforeunload prompt in shared dialog fallback (#9122)
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.
2026-07-17 17:34:29 +02:00
Johannes Millan
e5a9ff7866
fix(sync): unfreeze the disjoint-field merge to stop data loss (#9095) (#9101)
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.
2026-07-17 11:50:46 +02:00
Johannes Millan
b50d5f6d96
fix(sync): dedupe surgical-sync retries and keep the import author through pruning (#9089)
* 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>
2026-07-17 00:46:01 +02:00
Johannes Millan
d1ff7963d0
fix(sync): rebase repair op clocks on the durable clock (#8939) (#9080)
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.
2026-07-16 17:13:57 +02:00
Johannes Millan
a4dee9d5f7
fix(sync): isolate provider encryption settings + enforce critical e2e coverage (#9044)
* docs(plugins): add microsoft 365 calendar provider plan

* docs(plugins): add microsoft 365 calendar provider plan

* docs(ios): plan internal testflight builds

* fix(sync): isolate provider encryption settings

* test: enforce critical end-to-end coverage
2026-07-15 14:24:49 +02:00
Johannes Millan
18262eb1f3
fix(sync): retention pruning, misc→tasks alias boundary & WS local-win re-upload (#9028)
* 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.
2026-07-15 13:12:04 +02:00
Johannes Millan
f665536ef3
test(sync): cover project delete-wins conflicts (#9021) 2026-07-15 10:18:20 +02:00
Johannes Millan
bd67174863
fix(sync): make task and time replay deterministic (#8979)
* 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
2026-07-13 21:53:46 +02:00
Johannes Millan
6ac06df6c2
fix(sync): guard full-state apply against late local ops (#8976)
* 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.
2026-07-13 20:04:00 +02:00
Johannes Millan
01d12cacb7 test(sync): target transient rejection assertion 2026-07-11 13:18:03 +02:00
Johannes Millan
5d568dcf8c test(sync): verify non-task conflict convergence 2026-07-10 19:37:55 +02:00
Johannes Millan
19b6fed044 test(e2e): cover USE_REMOTE interrupted rebuild crash resume
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.
2026-07-10 17:15:51 +02:00
Johannes Millan
fc20f81c2c test(sync): force the upload-piggyback conflict race 2026-07-10 16:55:18 +02:00
Johannes Millan
a8f29a3f62 test(sync): avoid conflict-gate e2e deadlock 2026-07-10 16:49:07 +02:00
Johannes Millan
0df3e81155 fix(sync): remediate multi-agent review findings
- 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.
2026-07-10 16:47:29 +02:00
Johannes Millan
d212740254 test(e2e): resolve A's post-import conflict as USE_LOCAL in gate test
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.
2026-07-10 16:09:35 +02:00
Johannes Millan
f38342eeb9 fix(sync): harden replay and conflict recovery
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.
2026-07-10 15:24:51 +02:00
Johannes Millan
6e6ce34c15 test(e2e): incoming SYNC_IMPORT must prompt on pending non-task work
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.
2026-07-10 13:36:52 +02:00
Johannes Millan
b7839e9615 fix(e2e): accept overwrite confirmation in provider-switch conflict test
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.
2026-07-07 18:27:49 +02:00
Johannes Millan
10a47888a6
feat(sync): offer E2EE before first upload for file-based providers (#8709)
* 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).
2026-07-02 17:17:01 +02:00
Johannes Millan
d00e2df354
refactor(ui): unify primary/secondary action button treatment across dialogs (#8706)
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
2026-07-02 14:42:59 +02:00
Johannes Millan
63253f8e0c
fix(sync): never transmit plaintext operations for E2EE-mandatory providers (#8670)
* 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.
2026-07-01 17:56:39 +02:00
Johannes Millan
44030bb06e
fix(op-log): backfill SIMPLE_COUNTER fields on LWW recreate (#7330) (#8646)
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.
2026-06-30 15:40:25 +02:00
Symon Baikov
d0da0aea93
Codex/issue 8081 keep subtask input open (#8423)
* 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>
2026-06-18 17:58:45 +02:00
Johannes Millan
82e7150821 test(sync): unblock #8331 e2e by stripping conflict-response piggyback
The #8331 transient-download regression test failed on every scheduled
run since it landed (abortedResolutionGets stayed 0). The CI trace shows
why: B's pre-upload download is correctly stripped and B's upload IS
rejected CONFLICT_CONCURRENT, but the ops-upload conflict comes back as
HTTP 200 whose body piggybacks the conflicting remote op in `newOps`.
The client LWW-resolves the conflict from that piggyback in-place, so
RejectedOpsHandlerService never issues the separate resolution-download
GET the #8331 fix guards — the path the test arms and aborts.

Fix: the route handler now also forwards B's upload POST to the real
server (keeping the rejection server-computed) and strips
`newOps`/`hasMorePiggyback` from the response. With no piggybacked ops,
the client falls back to the resolution-download GET, which the test
aborts — exercising the guarded catch. A new `strippedConflictPiggybacks`
guard asserts this fired. This stripped-piggyback condition is real in
production when the conflict falls beyond PIGGYBACK_LIMIT (500 ops).

No product code changed; behavior under test is unchanged.
2026-06-13 17:36:16 +02:00
Johannes Millan
d2367e3bac test(sync): deterministically provoke CONFLICT_CONCURRENT in #8331 e2e
The held-POST + re-entrant A-sync ordering was still racy: B's upload was
accepted (200) instead of rejected, so the resolution-download catch under
test never ran and abortedResolutionGets stayed 0 (runs 27465357634 and
27467427713).

Switch to a deterministic hybrid that keeps the real server conflict:
- pre-sync A so its concurrent op is unconditionally on the server;
- strip ops from B's pre-upload download (return the REAL response with an
  emptied ops list) so B never merges A's edit and uploads its original
  concurrent op -> the real server computes a genuine vector-clock
  CONFLICT_CONCURRENT;
- abort every post-upload resolution GET to drive the guarded catch.

Pin latestSeq back to the request's sinceSeq on the stripped download: the
client persists lastServerSeq even on an empty download, so keeping the
advanced seq would make B skip A's edit forever and break convergence.

Add a strippedPreUploadDownloads setup guard alongside abortedResolutionGets
so a setup that fails to provoke the conflict fails loudly, not vacuously.
The countRejectedOps discriminator and end-to-end convergence are unchanged.
2026-06-13 16:02:54 +02:00
Johannes Millan
d865d21fa3 test(sync): force deterministic CONFLICT_CONCURRENT in #8331 transient-download e2e
The test relied on Client B's upload being rejected CONFLICT_CONCURRENT to
exercise the resolution-download catch. But the sync engine downloads before it
uploads (sync-wrapper: downloadRemoteOps -> uploadPendingOps), so when A's
concurrent edit was already on the server (pre-synced at step 4) B downloaded
and merged it, then uploaded a dominating op the server ACCEPTED (200) - the
rejection path was never reached and abortedResolutionGets stayed 0. Run
27465357634 failed exactly this way.

Force the one ordering that guarantees a rejection: let B's pre-upload download
run while the server still has no concurrent op, then land A's edit in the
window between B's download and B's upload by syncing A inside B's held upload
POST. B never merges, so its original pending edit rides into the resolution
path and the server returns a real CONFLICT_CONCURRENT. Keeps the real
two-client conflict and the unchanged countRejectedOps/convergence assertions.
2026-06-13 14:54:16 +02:00
Johannes Millan
56334897cc
fix(sync): persist lastServerSeq after piggybacked ops are applied (#8304) (#8372)
* fix(sync): persist lastServerSeq after piggybacked ops applied #8304

The upload path persisted lastServerSeq covering piggybacked ops before
those ops were applied (processRemoteOps runs in the caller, after the
upload lock releases). A crash or a cancelled SYNC_IMPORT dialog in that
window advanced the seq past unstored ops, so the next download skipped
them forever.

The upload service now defers the seq persist to the caller once it
collects piggybacked ops; the orchestrator persists only after
processRemoteOps succeeds, and skips it on the cancel/USE_REMOTE/USE_LOCAL
early-returns. The non-piggyback path still persists in-loop (no loss
risk). Mirrors the download path's 'persist AFTER ops stored' invariant.

Updates 3 upload specs that encoded the buggy persist-before-apply and
adds upload-side ordering + cancel-path regression specs.

* test(sync): add crash-window regression for deferred lastServerSeq #8304

* test(sync): cross-service integration test for piggyback seq persistence #8304

Wires the real OperationLogUploadService + real OperationLogSyncService over one
in-memory provider seq. Verifies the seq is not advanced on cancel/crash and only
advanced after processRemoteOps on success. Fails against pre-fix code (all 3).

* test(sync): e2e guard for cancel-reconvergence of incoming SYNC_IMPORT

Multi-client SuperSync spec covering a scenario no existing test does: an incoming
SYNC_IMPORT conflicting with a LOCAL PENDING op -> conflict dialog -> CANCEL -> the
next sync RE-OFFERS it -> USE_REMOTE reconverges. Documents that it routes through
the (already-correct) download path, so it is a real-server guard for the shared
cancel->re-offer->reconverge contract, NOT a #8304 fix regression (that path is
race-only and covered by the unit + cross-service integration specs). Requires docker.
2026-06-13 14:22:23 +02:00
Johannes Millan
217796b742
fix(sync): keep pending ops on transient download failure (#8331) (#8371)
* feat(sync): show actionable error on persistent WebDAV 409

When a WebDAV PUT keeps returning 409 Conflict even after the parent
collection is created, the Base URL / Sync Folder Path is misconfigured
(the classic Synology / raw-WebDAV setup mistake). Previously the raw
"HTTP 409 Conflict" (or a bare "Unknown error") reached the user with no
hint at the cause.

Throw a dedicated WebDavSyncFolderUnusableSPError with a privacy-safe,
actionable message (no path, no response body) at the spot that already
detects this condition. Mirrors NetworkUnavailableSPError: a fixed
user-facing message matched by instanceof.

* ci(release): revive contributors section in release notes

* fix(sync): keep pending ops on transient download failure

When an upload is rejected CONFLICT_CONCURRENT, the handler downloads to
resolve. A transient failure during that download used to call markRejected()
on the still-pending local edit — terminal (rejectedAt is never cleared, so the
op never re-enters getUnsynced()), so the edit stayed applied locally but
silently stopped syncing to other devices.

- Drop the markRejected loop in the catch; log + re-throw so the ops stay
  pending and re-resolve on the next sync.
- Roll back the per-entity resolution-attempt increment on a thrown download: a
  transient failure must not consume the cap (which would otherwise eventually
  markRejected via the exceeded branch — re-introducing the same data loss).
  The cap still bounds genuine non-progress loops, whose download succeeds and
  never reaches this catch.

Adds unit regression tests (single + repeated transient throws never reject)
and a SuperSync e2e that fails the resolution download past the provider retry
budget, asserts the pending edit is not rejected, then converges. Fixes #8331.
2026-06-13 13:22:53 +02:00
Johannes Millan
3d2c811e78 chore(task-repeat): revert RRULE Phase 1 from master (#7948) Develop the full RFC-5545 RRULE epic on the long-running feat/rrule-epic branch and merge once complete and testable in final form, rather than landing 13 phases into master one half-state at a time. Work preserved on feat/rrule-epic. Not yet shipped (post-v18.9.1), so no user impact. 2026-06-09 13:56:38 +02:00
Johannes Millan
ccbd66d3d9 test(e2e): retry supersync time-estimate dialog until input binds
The fill('10m') could fire its `input` event before the duration input's
value-accessor (an `input` HostListener) was wired, so the typed value
never reached the model and submit() saved an empty time — the panel
stayed "-/-" and toContainText('10m') timed out (scheduled run
27197862391, SuperSync 1/6). Retry the whole open→fill→submit cycle so a
fill that didn't stick is re-applied once the control binds.
2026-06-09 13:56:15 +02:00
Filip
1718b0a8b8
feat(task-repeat): RFC 5545 RRULE recurring schedules — EPIC · Phase 1/13 (Closes #4020) (#7948)
* feat(task-repeat): add cron / natural-language recurring schedules

Add a CRON repeat cycle so a single recurring task can express schedules
the day/week/month/year cycles cannot (e.g. every Saturday, March through
November). The cron expression drives occurrence generation; all other
schedule fields are ignored for CRON cfgs.

- Occurrence engine: getNewestPossibleCronDueDate / getNextCronOccurrence in
  cron-occurrence.util.ts (cron-parser), wired into the existing
  getNewestPossibleDueDate / getNextRepeatOccurrence dispatchers. Recurrence
  stays day-granular: sub-daily crons create at most one task per day.
- English to cron via the crono-eng WASM module (src/assets/crono-eng.wasm,
  loaded lazily), with a builtin regex parser as fallback until/if the module
  is unavailable. naturalLanguageToCron() also passes raw cron through.
- Cron to english live preview under the input via cronstrue; the field shows
  the interpreted cron + humanized reading as the user types and warns when an
  expression is sub-daily.
- Invalid / unrecognized input shows an error snack and blocks save.
- Add-task bar: @+<cron-or-phrase> short syntax attaches a CRON repeat cfg.
- tools/build-crono-wasm.js regenerates the WASM asset from the sibling
  crono-eng Zig project (committed asset is the source of truth for builds).

* fix(task-repeat): cron edge cases, clearer warnings, and corpus tests

Hardening + UX pass on the cron repeat cycle, driven by verifying the full
crono-eng corpus (415 phrases) end to end.

Fixes
- Silent never-fires: crono-eng can translate phrases (a specific year, "nearest
  weekday"/W, "n-to-last day"/L-n, biweekly #-lists) into Quartz forms the
  recurrence engine (cron-parser) cannot run. These passed UI validation yet a
  task would never be created. naturalLanguageToCron now gates its output on
  cron-parser, so such input is rejected at the field instead.
- Off-by-one for midnight crons: cron-parser's next() is exclusive of an
  exact-boundary currentDate, so getNextCronOccurrence skipped the first
  eligible day for 00:00 schedules (e.g. first occurrence landed a day late).
  Seed the search one ms before the lower-bound day so a midnight fire stays
  eligible; now matches the non-cron daily convention.

UX
- Live preview shows the interpreted cron + plain-English reading below the
  field, and warns when the time of day is ignored (day-granular engine) or the
  schedule is sub-daily ("runs at most once per day").
- Friendlier validation/snack message with examples instead of terse jargon.

Tests
- tools/test-crono-wasm.js (npm run test:crono): corpus-driven harness asserting
  the committed WASM matches all 415 corpus crons, and that cron-parser +
  cronstrue accept them — classifying the 27 known engine-unsupported forms so
  it stays a regression guard.
- cron-occurrence.util.spec.ts: occurrence engine across daily/weekly/monthly/
  month-range/sub-daily, varied times, start-date and last-creation gating.
- parse-natural-cron.util.spec.ts: raw passthrough, phrase recognition, null
  cases, loader resilience.
- task-repeat-cfg-form.cron.spec.ts: field validator + live-preview/time-warning.

* fix(task-repeat): live cron preview + @+ title strip; add E2E

Fixes found by adding end-to-end coverage of the cron feature.

- Live preview did not render: Formly's mat-hint does not reflect a dynamic
  expressionProperties.description, so the interpreted-cron/English preview never
  updated as the user typed. Move it into the dialog template, driven by a
  computed signal off the form's valueChanges (getCronPreview in cron-preview.util),
  so it updates live and persists after applying. The time-of-day-ignored and
  sub-daily warnings now render as proper translated lines.
- "@+<phrase>" short syntax left the clause in the title: shortSyntax set
  taskChanges.title for the cron strip, but the later parseTimeSpentChanges
  reassignment wiped it, so a bare "Foo @+every day" submitted the raw title.
  Strip into the working title and emit the cleaned title at the end.
- shortSyntax returned cronExpression unconditionally (undefined when absent),
  breaking 38 exact-match assertions; only include the key when set.

Tests
- e2e/tests/recurring/cron-repeat.spec.ts: @+ short-syntax attaches a CRON cfg
  (title stripped); full dialog flow (phrase → live preview + time warning →
  save → CRON cfg persisted).
- short-syntax.spec: @+ extraction/stripping, with and without other syntax.
- getCronPreview unit tests (interpreted cron, English, timed/sub-daily flags).

* test(task-repeat): property/fuzz/metamorphic/edge cron tests; fix first-occurrence

Large second testing pass (≈+58 unit tests, +2 E2E, new property harness),
which surfaced one gap and one upstream quirk.

Fix
- getFirstRepeatOccurrence returned null for CRON cfgs (no case → default), so a
  timed CRON task's first instance and the startDate-moved-earlier path fell
  back to today instead of the cron's first fire. Add getFirstCronOccurrence
  (first fire on/after startDate) and route CRON through it.

Tests
- cron-occurrence.invariants.spec: property/invariant battery over many crons
  (no-throw, result strictly-after / on-or-before bounds, determinism,
  monotonicity), calendar edges (leap-day, year rollover, DST spring/fall),
  pathological "Feb 30" (null, no hang), and getFirstCronOccurrence /
  getFirstRepeatOccurrence routing.
- parse-natural-cron.metamorphic.spec: relational tests (case/whitespace/order/
  irrelevant-text invariance, idempotence, determinism, raw passthrough).
- short-syntax.spec: more @+ cases (raw cron after @+, bare @ is not cron,
  unrecognized phrase ignored, clause stops at the next delimiter).
- tools/test-crono-properties.js (npm run test:crono:props): WASM determinism,
  marshalling fuzz (empty/oversized/unicode/boundary), English→cron→English
  round-trip, and occurrence simulation across every runnable corpus cron.
- e2e: invalid phrase blocks save + shows inline error (Save disabled);
  raw cron expression preview + save. Shared dialog-open helper.

Note: documented a cron-parser DST quirk — prev() skips the spring-forward
midnight, so "newest due" can land a day earlier on that date (day-granular,
once a year; lastTaskCreationDay prevents duplicates).

* test(task-repeat): selector integration, serialization, and WASM buffer-reuse

- selectors.spec: CRON cases for the selectors that drive task creation —
  selectAllUnprocessedTaskRepeatCfgs (due today, overdue, already-created,
  paused, future start, invalid expr, deleted instance) and
  selectTaskRepeatCfgsForExactDay (exact-day match). Proves a CRON cfg is
  picked up by addAllDueToday's selector path.
- cron-occurrence.invariants.spec: a CRON cfg survives a JSON round-trip with
  identical occurrence behavior (sync / backup integrity).
- test-crono-properties.js: buffer-reuse checks — a short translation after a
  long one is uncontaminated, and results stay deterministic across a 600-call
  interleaved loop (the WASM module reuses static input/output buffers).

* test(task-repeat): cross-timezone day-resolution harness

The Karma suite only runs in the host timezone (Chrome on Windows ignores the
TZ env var — the reason the pre-existing *.tz.spec fails), so cron day-resolution
was never verified across zones. Add a Node harness (npm run test:crono:tz) that
spawns a child process per timezone — Node honors TZ — and asserts day-class
invariants in each: weekly-Monday resolves to a Monday, monthly day-15 to the
15th, daily to the next calendar day, time-of-day never shifts the day, and a
full-year daily iteration is monotonic with 366 distinct days (no DST skip in
next()). Covers fractional offsets (India +05:30, Chatham +12:45/+13:45) and
Southern-hemisphere DST (Sydney). Mirrors getNextCronOccurrence's core math.

* fix(task-repeat): DST-safe newest-due; verify real engine across timezones

Closes the time-based reliability gaps from the previous testing pass.

Fix
- getNewestPossibleCronDueDate no longer uses cron-parser's prev(), which skips
  the spring-forward midnight in DST zones (a daily cron then looked like it
  didn't fire that day). It now walks days backward and asks "does the cron fire
  during this local day?" via a next()-based probe (next() is monotonic across
  DST). Spring-forward and fall-back now resolve the exact day.

Tests
- main.ts exposes the real occurrence utils on __e2eTestHelpers.cron (dev/stage
  only, stripped from production).
- e2e/cron-timezone.spec: runs the REAL engine under five forced browser
  timezones (Playwright timezoneId — reliable regardless of host OS, unlike the
  Karma TZ env). Each asserts the timezone was actually applied (live UTC
  offset) AND that day-class results are correct, incl. fractional offsets
  (Chatham +12:45) and the spring-forward day.
- test-crono-tz.js: also asserts a daily cron fires on every calendar day of the
  year in all 8 zones (regression guard for the prev() skip).
- invariants.spec: spring-forward / fall-back now assert the exact day for both
  next() and newest (no longer hedged).
- effects.spec: a CRON cfg's first occurrence is computed through the real
  updateTaskAfterMakingItRepeatable$ effect path (live new Date()).

* test(task-repeat): dev jumpDay clock helper + live day-change e2e

Adds __e2eTestHelpers.jumpDay(n) / resetClock() (dev/stage only, stripped from
production) so recurring/day-change behavior can be fast-forwarded from the
DevTools console without touching the OS clock: jumpDay shifts Date.now() by a
cumulative day offset and forces the day-change re-sample so addAllDueToday()
runs. New e2e asserts jumpDay(1) creates the next daily instance — covering the
live day-change -> creation path end to end.

* feat(task-repeat): add "create a task for each missed occurrence" option

By default, opening the app after several scheduled occurrences were
missed creates only a single instance for the most recent occurrence.
When enabled (advanced section), a separate overdue task is created for
every missed occurrence instead, oldest first, capped at the 30 most
recent to avoid flooding the list / emitting a large sync batch.

- new getAllMissedDueDates() util reuses getNextRepeatOccurrence so the
  per-cycle and CRON occurrence logic stays single-sourced and DST-safe
- service multi-spawn path is gated to the catch-up flow (target day is
  today or earlier) and is mutually exclusive with skipOverdue /
  waitForCompletion; the single newest-occurrence path is unchanged
- form checkbox hides when "skip overdue" is set, and vice versa

Part of #4020

* fix(task-repeat): re-anchor lastTaskCreationDay when cron expression changes

cronExpression was missing from SCHEDULE_AFFECTING_FIELDS, so editing a
CRON schedule did not re-run rescheduleTaskOnRepeatCfgUpdate$. A
previously computed (often future) lastTaskCreationDay then stuck around
and silently suppressed every occurrence until real time caught up to it
— e.g. a "Jan-Aug" cron set while the clock was past August anchored to
Jan 1 next year and produced no tasks in between.

Adding 'cronExpression' makes a cron edit re-anchor from the current
date, consistent with repeatCycle / repeatEvery / weekday edits.

Part of #4020

* feat(tasks): surface @+ recurring short syntax in autocomplete and help

The `@+<schedule>` add-task short syntax existed but was undiscoverable.

- add recurring examples to the `@` autocomplete suggestions: typing `@+`
  now autocompletes phrases like `@+every monday` or
  `@+every saturday from march through november`
- document `@+` in the Settings -> Short Syntax help text

Part of #4020

* fix(tasks): remove @+ recurring autocomplete entries

The `@+` examples added under the `@` mention trigger kept the suggestion
dropdown open while typing `@+<phrase>`, so pressing Enter selected a
suggestion instead of submitting the task — breaking the headline
type-and-go flow. The `@+` syntax stays documented in Settings -> Short
Syntax; only the dropdown entries are removed.

Part of #4020

* refactor(task-repeat): show cron only inside Custom recurring config

Cron appeared twice in the recurring-config dropdown: as a top-level
"Cron / natural language" quick-setting AND as a cycle inside "Custom
recurring config". Drop the duplicate top-level option; cron is now
reached via Custom -> repeatCycle = Cron.

- remove the CRON quick-setting option from the dropdown builder
- keep the repeatCycle select visible when CRON is chosen (only hide
  repeatEvery) so the user can switch back
- map existing cron configs (incl. legacy quickSetting 'CRON') to CUSTOM
  on load so the dropdown matches the stored cycle
- update the cron E2E to drive the dialog via Custom -> Cron

Part of #4020

* test(task-repeat): make @+ short-syntax e2e date-independent

The test asserted the `@+every monday` task appears in the Today view, but
a weekly schedule's first instance is the next Monday — so on any non-Monday
the task is correctly scheduled for a future day and isn't in Today, making
the test fail depending on the day it runs. Verify the attached CRON config
and the stripped title via the store instead, which is view- and
date-independent.

Part of #4020

* feat(task-repeat): RFC 5545 RRULE recurring schedules [WIP]

Overhaul recurring tasks from the bespoke repeatCycle format to RFC 5545
RRULE as the canonical recurrence representation (legacy fields kept
populated for old-client forward-compat). Adds a structured RRULE builder,
RRULE-backed quick presets, per-instance due-date derivation, multiple end
conditions (COUNT / UNTIL / after-N-completions), an occurrence heatmap with
completion simulation, natural-language @+ short syntax, and a REST API for
recurring tasks. Migration is lazy (on dialog open) so existing configs keep
firing untouched until edited.

WIP: some required features and tests are still outstanding, and it needs
much more real-world (user-based) testing before it is merge-ready.

* feat(task-repeat): per-occurrence overrides, due-date control move, @+ preview, NL fix [WIP]

- RECURRENCE-ID: per-instance overrides (move / re-time / re-title) surfaced to
  the engine as RDATE + EXDATE so every projection stays consistent.
- Move the due-date derivation control out of the rrule builder into the dialog
  so it applies to all recurring configs (presets + Custom).
- Live @+ recurrence preview (humanized rule + next occurrence date) in the
  add-task-bar, so a far-off rule is obvious before submit.
- Fix @+ "every other <weekday> in <month>" mis-parsing to YEARLY;INTERVAL=2
  ("every 2 years"); weekly-style interval + weekdays now stays WEEKLY.

* refactor(task-repeat): trim PR to engine+builder+migration+preview; fix curator review

Trim the WIP recurring-schedules PR to its reviewable core — RFC 5545 occurrence
engine, structured RRULE builder, legacy<->RRULE migration (both directions),
live text preview, quick-setting presets, and tests — and defer the rest to
follow-up sub-MRs: natural-language @+, due-date derivation, ends-after-N-
completions, missed-occurrence backfill, heatmap preview + simulation, REST
recurring creation, and RECURRENCE-ID per-instance overrides. The full
implementation is preserved on branch feat/recurring-full.

Curator review fixes:
- quickSetting forward-compat: never persist a value outside the released
  (master) union. The newer preset literals AND 'RRULE' map to 'CUSTOM' at every
  persist boundary (dialog save, add-task-bar, data-repair downgrade); the rich
  value drives the dialog UI in-memory only and the builder reconstructs from the
  opaque rrule on open. Fixes old/mobile typia sync-validation treating such
  tasks as corrupt.
- Malformed rrule now falls back to the legacy schedule fields (guarded by
  isRRuleValid) instead of silently stopping the task.
- Reverse converter maps a BYDAY-less FREQ=WEEKLY onto the start weekday, so the
  legacy WEEKLY engine still fires on old clients.
- Stop logging the raw rule body (the raw-override field makes it free-text user
  input; log history is exportable).
- DRY: share normalizeWeekdays / toNumArray between the two converters.

* refactor(task-repeat): clamp quickSetting at the action creators

The op-log replays the action payload, not reduced state
(operation-capture.service.ts), so a reducer-level clamp would not keep an
out-of-union quickSetting off the wire. Clamp in the addTaskRepeatCfgToTask /
updateTaskRepeatCfg / updateTaskRepeatCfgs creators instead -- the single
boundary every dispatcher passes through, so old/mobile clients always replay a
value their typia union accepts. The newer preset literals and 'RRULE' stay
in-memory in the dialog form only.

Removes the now-redundant per-call-site clamps in the dialog save path and the
add-task-bar; the @+ and REST paths inherit the clamp for free when their phases
return. Adds an action-creator spec covering the clamp at each entry point.

* feat(task-repeat): nth-weekday rows support multiple weekdays per ordinal

Each ordinal row (1st/2nd/3rd/4th/last) now multi-selects weekdays via toggle
buttons, so one line can mean "the 1st Monday and the 1st Tuesday" -> BYDAY=1MO,1TU.
The per-row ordinal dropdown excludes positions already used by other rows (each
ordinal anchors at most one line), and the add button hides once all are used.
Parsing groups weekdays that share an ordinal back into one row. Applies to both
the monthly and yearly nth-weekday modes; updates the helper copy accordingly.

* test(task-repeat): complex RRULE coverage + day-by-day create-loop sim

Adds high-complexity coverage for the occurrence engine and builder:
- engine variants x settings: per-day ordinals, BYSETPOS last-weekday,
  BYMONTHDAY=-1, seasonal BYMONTH, BYWEEKNO/BYYEARDAY, leap years, mixed with
  COUNT / UNTIL / EXDATE / lastTaskCreationDay
- builder forward + mode-detection + lossless round-trips (incl. multi-weekday
  ordinals and sub-daily raw-override)
- cfg->engine routing for complex rules with deletedInstanceDates and the
  malformed-rrule legacy fallback
- a "day-march" simulator that drives getNewestPossibleDueDate one day at a time
  with lastTaskCreationDay fed back like the service, asserting the created
  stream has no dupes/skips, honors EXDATE/COUNT, is idempotent within a day,
  and documents Phase-1 app-closed catch-up (newest missed only)

Also trims the deferred @+ short-syntax e2e (returns with its phase) and fixes
the dialog-flow e2e to target the current .rrule-result preview selector.

* feat(task-repeat): custom ordinal input for nth-weekday rows and BYSETPOS

The nth-weekday ordinal dropdown and the weekday-set 'which occurrence'
dropdown each gain a 'custom…' option that reveals a free-form input,
allowing any non-zero ordinal (e.g. -2 = 2nd-to-last, 5FR) and
comma-separated BYSETPOS lists (e.g. 2,-1). Parsed rules with such values
now render structurally instead of falling back to the raw override.

* feat(task-repeat): make weekday-set occurrence (BYSETPOS) a multi-select

Replace the single-value 'which occurrence' dropdown with toggle buttons
matching the weekday toggles, so several occurrences can combine (e.g.
first + last weekday = BYSETPOS=1,-1). 'Every' clears the narrowing; the
custom input stays for values without a toggle.

* fix(task-repeat): address review findings on rrule migration fidelity

- Yearly builder rules seed BYMONTH from the start month: a bare
  FREQ=YEARLY;BYMONTHDAY=n expands across every month (RFC 5545) and
  fired monthly for fresh yearly configs.
- quickSetting change handler passes the selected start date, so
  date-writing presets no longer overwrite a user-picked future anchor
  with today.
- Remove the createForEachMissed checkbox + copy: the engine has no
  backfill support yet, so the setting silently did nothing (and wrote
  an undeclared field into synced state).
- rruleToLegacyTaskRepeatCfg always resets the monthly anchor fields so
  stale nth-weekday/last-day anchors can't survive the merge, sets
  monthlyLastDay only for a pure BYMONTHDAY=-1 rule, and aligns
  startDate to the rule's first occurrence for date-anchored
  monthly/yearly rules (legacy clients read day/month from startDate);
  save() re-derives the fallback so later startDate edits stay aligned.
- _normalizeMonthlyAnchor keeps monthlyLastDay on RRULE saves — it is
  the derived old-client fallback for BYMONTHDAY=-1, not a stale preset
  flag.
- Legacy CUSTOM migration preserves clamp semantics: day > 28 anchors
  emit BYMONTHDAY=<d>,-1;BYSETPOS=1 (the day, or the last day of
  shorter months) instead of a plain BYMONTHDAY that skips such months;
  the builder serializer emits BYSETPOS in day-of-month modes so these
  rules round-trip structurally.
- Regenerate package-lock.json to drop stale cron-parser/cronstrue
  entries left over from the earlier cron approach.

* fix(task-repeat): harden rrule builder + legacy fallback after deep review

Correctness:
- Clear bySetPos (and its custom-input state) on monthly/yearly mode and
  frequency switches: a BYSETPOS left over from the weekday-set mode
  silently narrowed day-of-month rules (BYMONTHDAY=15;BYSETPOS=2 never
  fires) with no UI to see or clear it.
- Move startDate alignment out of rruleToLegacyTaskRepeatCfg into a new
  arithmetic getAlignedStartDate, applied once at save and only when the
  schedule actually changed: the occurrence-search version corrupted the
  clamp idiom (aligned a day-31 rule onto Feb 28/29 so old clients fired
  on the wrong day forever), collapsed multi-day BYMONTHDAY lists to
  their earliest day, rewrote the visible start-date field live on every
  builder interaction, cost ~60ms/keystroke for sparse rules, and put
  startDate into the change diff on title-only edits — retriggering the
  issue-#7373 reschedule. Weekday-anchored rules are no longer aligned.
- Emit the monthly-anchor resets as null/false instead of undefined (in
  the converter AND the presets' MONTHLY_ANCHOR_RESET): JSON.stringify
  drops undefined keys from op-log payloads, so the reset never reached
  remote clients and stale nth-weekday/last-day anchors survived there.
  The anchor model fields now allow null; the nth-weekday engine guard
  strips it.
- Enforce the YEARLY BYMONTH guard in the serializer: yearly date mode
  without months now emits a plain FREQ=YEARLY (anniversary) instead of
  a bare BYMONTHDAY that fires monthly; parsed bare yearly rules fall
  back to the raw override, preserving their semantics verbatim.
- Drop the auto-seeded BYMONTH when switching away from YEARLY (it
  silently constrained the new rule to one month) and seed from the
  CURRENT start date rather than the ngOnInit-captured month.
- Clamp custom nth ordinals per frequency (±5 monthly, ±53 yearly) —
  BYDAY=10MO is valid RFC but matches nothing, ever — and reject an
  ordinal another row already anchors (duplicate rows collapse on
  reload). Re-clamp poses when switching into MONTHLY.
- Drop BYSETPOS=0 on parse (parseString accepts it; re-emitting creates
  a rule the occurrence engine silently treats as dead).
- Predefined set-position toggles close the explicitly-opened custom
  input (both rendered active with contradictory state).

Cleanup:
- Extract parseIntList (4 cloned int-list sanitizers), pushBySetPos /
  pushByDaySet (4 copy-pasted emit blocks), _clampedMonthDayPart
  (monthly/yearly clamp duplication); share the monthly/yearly
  weekday-set template block via ngTemplateOutlet; parse BYSETPOS via a
  computed signal instead of per-CD string parsing.
- Correct the BYMONTHDAY hint: it claimed short months clamp to their
  last day, but plain BYMONTHDAY skips them.

* test: make timezone demo specs host-independent

Six .tz.spec demonstration tests branched on the host's CURRENT
getTimezoneOffset() (or a literal 'America/Los_Angeles' name check)
while asserting against January test instants. In DST-observing zones
the summer offset differs from the January one (e.g. New York: EDT 240
vs EST 300), so the wrong branch was taken and the suite failed every
summer on US-east machines, blocking pre-push hooks.

Branch on the offset AT the test instant instead, with the correct
longitude threshold for each instant (07:00 UTC flips the local day at
UTC-7, 06:00 UTC at UTC-6, midnight UTC at UTC±0) — the tests now pass
in any host timezone year-round.

* test: pin browser timezone to Europe/Berlin in karma config

Re-enable the previously commented-out TZ pin so timezone-sensitive
specs run deterministically where Chrome honors the env var
(Linux/macOS, incl. CI). Verified empirically that headless Chrome on
Windows IGNORES TZ and keeps the host zone — the *.tz.spec.ts files
keep their offset-at-test-instant branching as the Windows backstop.

* test: make supersync dump/restore helpers cross-platform

createFullDump/restoreFullDump used POSIX-only shell constructs (output
redirection to /tmp, 'cat | psql', 'rm -f'). On Windows the restore
pipeline failed AFTER the 'DROP SCHEMA public CASCADE' had already
succeeded, leaving the test database without any tables — every
subsequent spec in the run then failed in createTestUser with 'table
public.users does not exist' (5 cascading failures + dozens of
never-ran tests).

Capture pg_dump via execSync stdout + fs.writeFileSync, feed the
restore through stdin (execSync input), use os.tmpdir() and
fs.unlinkSync. Verified: full dump-restore spec plus the three spec
files it previously poisoned all pass on Windows (14/14).

* test(e2e): round-trip coverage for new rrule builder widgets

Covers the four gaps hand-testing would otherwise need to catch, using
the dialog's live result band as the oracle (.rrule-result__expr =
exact assembled rule, .rrule-result__next = engine-computed upcoming
dates — no waiting for real time):

- custom nth ordinal (-2 = 2nd-to-last weekday) emits the right BYDAY
  and persists verbatim
- BYSETPOS multi-select (first + last) emits BYSETPOS=1,-1 and persists
- mode switch drops a weekday-set BYSETPOS instead of leaking it into a
  day-of-month rule (dead-rule regression)
- switching to YEARLY seeds BYMONTH and the upcoming dates are strictly
  one per year

Persistence asserted via the NgRx store: saving a recurring cfg
re-plans the task onto its first occurrence (usually not today), so a
UI reopen from the work view is date-dependent; the parse-to-widget
rendering side is covered by the dialog/builder unit specs.

* fix(add-task-bar): open the repeat dialog for the custom recurring option

The repeat quick-setting menu emits the 'RRULE' value for 'Custom
recurring config', but the add-task-bar still branched on the legacy
'CUSTOM' value — picking the option fell through to the preset path and
silently created a weekly-fallback cfg instead of opening the dialog.

Route both 'RRULE' and 'CUSTOM' through the dialog path, preselect the
RRULE builder in the dialog via a new initialQuickSetting dialog input
(the user explicitly asked for a custom rule), and show the tune icon
for the menu entry again. Covered by a new e2e: menu pick → task
created → builder dialog opens → saved rule persists verbatim.

* fix(task-repeat): address review on rrule alignment + anchor sync safety

- getAlignedStartDate re-anchors onto the rule's actual first occurrence
  (occurrence-set-neutral for INTERVAL/COUNT/UNTIL) instead of pure date
  math that shifted INTERVAL cadence and skipped valid clamped occurrences
- save() always re-derives the legacy fallback fields against the final
  startDate, not only when alignment moved it
- monthly anchors never persist null (released clients' typia schema only
  allows absent-or-numeric): undefined resets everywhere, null stripped at
  the action-creator boundary, model reverted to master signature
- round-trip guard ignores BYSETPOS=0 so the cleaned rule is emitted
  instead of being preserved verbatim via raw override

* docs(wiki): fix MD024 duplicate headings on repeating-tasks page

* fix(task-repeat): harden rrule save guards + restore preset labels on reopen

Correctness (from deep review of the branch diff):
- reject COUNT combined with repeatFromCompletionDate at save: completion
  re-anchors startDate/lastTaskCreationDay, restarting the COUNT window, so
  the series would never terminate
- reject parseable rules that yield zero occurrences (e.g.
  FREQ=YEARLY;BYMONTH=2;BYMONTHDAY=30): they persisted as silently dead
  recurrences with the legacy fallback bypassed
- map the builder's single-weekday BYSETPOS form (BYDAY=FR;BYSETPOS=-1,
  'last Friday') onto the legacy nth-weekday anchor; old clients previously
  fell back to startDate's day-of-month — a wrong recurrence

Polish:
- infer the preset back from the stored rrule when quickSetting was
  wire-clamped to CUSTOM, so Weekends & co. reopen under their label
  instead of the raw builder; new QUICK_SETTING_PRESETS single source
  replaces the hand-maintained KNOWN_PRESETS set
- extract shared safeParseRRuleOptions + FREQ_TO_CYCLE (six drifted
  hand-rolled parse wrappers); memoize isRRuleValid (per-cfg routing guard
  on every overdue scan); share noonUtc/toLocalNoon between engine and
  preview; fold toMonthArray onto toNumArray; merge the duplicate
  _toPersisted action-creator helpers
- trim the wiki section documenting the create-for-each-missed feature
  that was deliberately cut from this PR

* fix(task-repeat): address curator review on anchor validity + fallback phase

- never emit/persist an out-of-union monthlyWeekOfMonth: range-guard the
  BYDAY ordinal in rruleToLegacyTaskRepeatCfg (the builder's custom input
  allows ±5, the synced model only 1..4|-1), sanitize null AND out-of-range
  anchors at the action-creator boundary, and mirror the repair in
  data-repair — an out-of-union value trips released clients' typia
  validation/repair flow
- reject sub-daily frequencies (raw override FREQ=HOURLY/...) at save: the
  day-granular engine would accept but silently collapse them to ~daily,
  and they have no legacy repeatCycle for old clients
- log hygiene: the update-all-instances flow now logs changed keys + task
  ids instead of raw changes/task objects (title, notes and rrule body are
  user content; the log history is exportable); engine warns log the error
  name only, since rrule.js messages can embed the free-text rule body
- align single-BYDAY weekly rules onto the engine's first occurrence: the
  engine groups weeks by WKST while the legacy fallback counts rolling
  7-day blocks from startDate, so biweekly cfgs whose start is off the
  pattern day fired on OPPOSITE alternating weeks on old vs new clients
  (and WKST shifted the engine's phase, which legacy cannot express);
  after re-anchoring the cadence is exactly interval*7 days in both
- diff dialog saves against the PROCESSED cfg: the lazy legacy->rrule
  migration (and preset inference) no longer leak into the change set of a
  title-only edit — rrule is schedule-affecting, so that leak relocated
  today's live instance on unrelated edits; empty change sets skip the
  update dispatch entirely instead of creating a no-op sync op

* fix(task-repeat): keep presets rrule-backed + builder mode for completion cfgs

- stop stripping the preset-generated rrule at save: getQuickSettingUpdates
  OVERWRITES the rule with the preset's canonical one, so the old 'clear on
  switch away from builder' branch broke the every-cfg-carries-its-rrule
  contract — and an rrule:undefined clear would not even propagate (dropped
  by the op-log JSON wire, leaving remote clients on the old rule); the spec
  asserting the stale behavior is inverted
- completion-relative cfgs always open in builder mode: the schedule-type
  toggle only exists there, so preset inference (or a stored faithful preset
  label) would hide the one control that explains how the cfg fires
- monthly anchor clears: document the wire gap properly instead of flipping
  values again — null trips released clients' blocking data-repair confirm
  dialog on every sync (typia has no null on these fields), undefined clears
  only locally; durable clears require shipping '| null' on both fields in a
  release FIRST, then switching the reset value (sequenced migration note in
  the model + an op-log JSON round-trip spec pinning current behavior)
- replace 6 hardcoded rrule-builder placeholders with translation keys
- revert the settings help text advertising the deferred @+ short syntax

* test(e2e): drop CUSTOM weekday-checkbox spec superseded by rrule builder

The #8025 e2e (merged in from master) drives the legacy CUSTOM recurring
form's cycle select + weekday checkboxes, which this branch removes — the
RRULE builder replaces that UI and its weekday toggles are covered by
rrule-builder-roundtrip.spec.ts. The #8025 failure mode (a saved weekly
cfg that never fires) is blocked generally at save by the first-occurrence
probe.

* test(e2e): de-flake worklog history day-row wait

The worklog is rebuilt from the archive on the history navigation, so the
day row only appears once that async load + month-expand animation settles.
The bare `waitFor` fell back to the 15s action timeout, which CI load
(prod build, 3 workers) could exceed. Use a web-first `expect().toBeVisible()`
with 30s headroom instead.

* fix(task-repeat-cfg): clear repeat-from-completion when switching to a preset

The "from completion" schedule-type toggle exists only inside the RRULE
builder. Selecting a preset hides it, but the flag stuck on the cfg, so a
preset could keep firing relative to completion with no visible control.

Clear it at the save() edit boundary, but only when actually set, so an
untouched preset save stays an empty-diff no-op (#7373 class) rather than
dispatching a spurious undefined->false op. The ADD path already starts from
a false default, so the dialog is the only path that needs it.

Also reword the monthly-anchor clear comments: the cross-client divergence on
an nth-weekday -> day-of-month switch is an inherent, unfixable limitation for
pre-rrule clients (no in-schema "no anchor" value; a `| null` migration would
help an empty client band), not a deferred TODO. Make the round-trip test pin
this explicitly and start the monthlyLastDay case from `true` so it proves the
`false` clear survives the wire instead of passing vacuously.
2026-06-09 11:25:35 +02:00
Johannes Millan
3ff0eebf92
fix(sync): make 'Use Server Data' recoverable + guard the destructive choice (#8107) (#8151)
* 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).
2026-06-08 16:34:59 +02:00
Johannes Millan
7c03c84fad
feat(history): merge Quick History and Worklog into a unified History view (#8033)
* feat(history): merge Quick History and Worklog into one view

- single /history route; legacy worklog & quick-history redirect to it
- one History menu entry; navigate-to-task targets history
- show per-day work start-end as its own column in the day table
- show enabled habits/simple-counters (icon + tooltip) in the expanded day
- extract HistoryTaskRowComponent for archived task rows
- remove WorklogComponent and QuickHistoryComponent

* fix(history): restore quick history behavior

* refactor(history): collapse to a single unified view

- remove the Worklog/Quick History toggle; show one full all-time
  history tree (year -> month -> week -> day)
- drop the quick-history mode, significance filter and ?view= param
- extract shared HistoryDayMetaComponent; reuse HistoryTaskRowComponent
  in the Daily Summary "This Week" widget
- convert project colors + dateStr auto-expand to signals
- update e2e specs to the single-view selectors

* refactor(history): remove dead quick-history code and i18n keys

After merging Quick History into the unified History view, the
quick-history data pipeline had no consumers. Remove it:
- _quickHistoryData$, quickHistoryWeeks$, _loadQuickHistoryForWorkContext
- map-archive-to-worklog-weeks util (+ tz spec) and WorklogYearsWithWeeks
- orphaned F.QUICK_HISTORY, MH.QUICK_HISTORY, MH.WORKLOG translation keys

Keep WorklogWeekSimple (base of the still-used WorklogWeek).

* docs(history): reflect Quick History merge into unified History

The quick-history and worklog routes are now legacy aliases for the
single unified History view; drop the stale 'current-year mode/toggle'
framing. Concepts-note prose flagged for human review.

* feat(history): improve a11y and i18n of the unified history view

Accessibility:
- week table uses <thead> + <th scope=col>; icon-only work-times
  header gets a translated aria-label
- expandable day row: real <button> disclosure control carrying
  aria-expanded/aria-controls (keyboard + AT path) instead of a
  role=button <tr>; row keeps a pointer click
- archived-task title is now a <button> (was a non-focusable <span>)

i18n: translate previously hardcoded aria-labels (export/restore) and
the week-range tooltip; reuse VIEW_TASK_DETAILS/RESTORE_TASK_FROM_ARCHIVE,
add EXPORT_DATA + WEEK_RANGE_TOOLTIP keys.

Polish (carried in): convert worklogData$ to a toSignal signal (drop
AsyncPipe + dead animations), relocate the shared row to
features/worklog/worklog-task-row (WorklogTaskRowComponent) to break a
worklog<->history cycle, make template-unused services private, tighten
projectColor input type. SCSS focus rings use focus-ring tokens.

e2e: locate task titles via td.title button after the span->button change.
2026-06-05 18:10:56 +02:00
Johannes Millan
dc0378edd6
[codex] Fix sync import conflict from startup example tasks (#7976)
* fix(sync): ignore startup example tasks during import

* fix(sync): defer startup example tasks until initial sync completes

Switch ExampleTasksService to afterInitialSyncDoneStrict$ so example tasks
are not created before the first remote import lands. On a fresh synced
client the imported tasks are then already in the store and the length===0
guard skips creation entirely — closing the same conflict for file-based
providers (Dropbox/WebDAV), which the op-log marker gate does not cover.

The isExampleTask marker + gate exclusion stay as a safety net for the
narrow case where example tasks are created on a still-empty server and an
import arrives before they are uploaded.

Also dedupe the two markRejected call sites into _discardExampleTaskOps and
document the local-only read (a remote isExampleTask flag can never bypass
the conflict dialog) and the intentional mixed-case behavior.

* test(sync): cover mixed-pending and empty-discard import gate cases

Add a SyncImportConflictGateService test for the mixed case (real pending
work + startup example tasks): the conflict dialog is still shown, but the
example-task id is reported in discardablePendingOpIds and intentionally
left for the caller to keep on USE_LOCAL — locking the documented invariant.

Also assert markRejected is not called on the config-only silent-accept path,
pinning the empty-array guard in _discardExampleTaskOps.

* test(sync): integration coverage for example-task import gate

Run the real OperationLogStoreService + SyncImportConflictGateService against
IndexedDB (no mocks) to cover the seam the unit specs stub:
- example-task creates persisted with their real multi-entity payload are
  recognized as non-meaningful and reported as discardable
- after markRejected they are actually excluded from getUnsynced() (never
  uploaded), while a pending config op is preserved
- real user work alongside example tasks still triggers the dialog
- a remote-sourced example-task op is never discardable (gate reads local
  pending only)

Verified load-bearing: removing the gate exclusion turns the first case red.

* fix(sync): mark onboarding done when example tasks are skipped

EXAMPLE_TASKS_CREATED was only written after creating example tasks, so a
synced client that skipped creation because real tasks already existed never
set the flag — and could recreate onboarding tasks on a later startup when
the task list happened to be empty.

Set the flag whenever tasks already exist at the initial-sync gate, so
onboarding runs at most once per client.

* docs(sync): note known limits of the example-task import gate

Document two accepted, narrow residuals of syncing onboarding example tasks:
- upload→piggyback path: example ops accepted in the same upload round are
  already synced and not in the discard list; state stays correct because the
  SYNC_IMPORT filter drops them as CONCURRENT on receivers.
- file-based snapshot path: hasMeaningfulStoreData() counts example tasks (no
  in-state marker), so a fresh Dropbox/WebDAV client that made example tasks
  while sync was disabled can still see the conflict dialog.

* test(sync): cover fresh-client example-task import gate (e2e)

Add a SuperSync e2e proving a fresh client with first-run example tasks accepts
an incoming SYNC_IMPORT without a conflict dialog, plus a backward-compatible
{ allowExampleTasks } opt-in to createSimulatedClient (the harness otherwise
pre-sets SUP_EXAMPLE_TASKS_CREATED).

Uses waitForInitialSync:false + a race between the conflict dialog and sync
completion so the test actually fails when the dialog appears (pre-fix), and
asserts all four onboarding titles are absent. Guards the op-log isExampleTask
marker/gate path (not the afterInitialSyncDoneStrict$ timing, which a fresh
e2e client cannot exercise since sync is disabled at boot).

* docs(sync): link example-task import-gate limitations to #7985
2026-06-03 15:57:53 +02:00
Johannes Millan
87212472fa test(e2e): scope supersync backup-revert DB restore to test user
The backup-revert test restored via a global DROP SCHEMA public CASCADE + full pg_dump on the shared test Postgres. Under Playwright fullyParallel (3 workers/shard) this reverted concurrent @supersync tests' in-flight data mid-run, flaking supersync-snapshot-vector-clock (server latestSeq regressed, e.g. 4 to 1).

Replace with a per-user snapshot/restore of operations, user_sync_state and sync_devices via a COPY round-trip in one transaction, preserving exact last_seq so the SYNC_IMPORT_EXISTS path still fires. Other users' rows are never touched. Also correct the misleading #7810 comment in the snapshot test.

Verified green: both files pass across --repeat-each=10 with workers=3 (the parallel race reproduced, now clean).

Refs #7810
2026-06-01 16:33:18 +02:00
Johannes Millan
827aede24f test(e2e): add sync-stall recovery to flaky supersync setup and migration
setupSuperSync now re-triggers sync once if the sync-state icon never
appears, recovering first-client setup stalls under parallel CI load.
The multi-migration test re-syncs Client B when an op hasn't downloaded
yet, since B has auto-sync disabled and DOM polling alone can't recover
a not-yet-pulled task. Both paths run only after the original wait times
out, so passing runs are unaffected.
2026-05-28 13:58:19 +02:00
Johannes Millan
aa1c13e805 test(e2e): instrument snapshot-vector-clock flake for diagnosis (#7810)
Capture per-page network traffic (untruncated response bodies) and IDB
snapshot state (op-log contents, last_server_seq) around the failing
A.syncAndWait at Phase 2. Adds timing marks so the dump segments by
phase and dumps on both inner and late failures. Documents that the
snapshot fast-forward path is not in play at this assertion despite the
test name.
2026-05-27 15:17:35 +02:00
Johannes Millan
77f83c5687
test(e2e): harden failure signals and provider gates (#7753)
* test: harden e2e failure signals

Fail otherwise-passing E2E tests on browser runtime errors, keep Playwright retries disabled, preserve Docker E2E exit codes, and make plugin/WebDAV setup failures hard failures instead of logged or skipped conditions.

* test: harden provider e2e runners

Make WebDAV and SuperSync runner scripts require provider readiness, preserve cleanup and argument forwarding, and fail manual sync clients on uncaught page errors.

* ci: require providers for scheduled e2e

Set required-provider flags in scheduled WebDAV and SuperSync jobs, and remove the duplicate provider runner scripts while keeping local npm aliases inline.

* test: catch e2e teardown pageerrors and tighten fixture

- closeClient now asserts runtime errors AFTER context.close() so
  pageerrors emitted during teardown (Angular destroy hooks, late RxJS
  errors) are captured instead of dropped. Matches the pattern in
  guardContextCloseWithRuntimeErrorCheck.
- test.fixture.ts isolatedContext now spreads Playwright's merged
  contextOptions instead of destructuring 23 fields by hand. Future
  option additions propagate automatically; the page fixture uses the
  shared attachPageErrorCollector and only fails on pageerror (not
  console.error, which is too noisy). Guards against a configured 0
  timeout being treated as undefined.
- plugin-loading.spec.ts second test now hard-asserts that the plugin
  menu entry reappears after re-enable, matching the first test instead
  of silently logging when not visible.

* test(sync): stabilize ImmediateUploadService spec

Two complementary fixes for flaky failures observed under full-suite
random-order runs where the upload pipeline silently never fires:

- Pin navigator.onLine = true in beforeEach (restored in afterEach).
  isOnline() inside _canUpload reads navigator.onLine directly. The
  keyboard-layout spec replaces the whole navigator and the is-online
  spec spies on it; if order or restoration ever drifts, every "should
  fire upload" test fails trivially while the "should NOT" tests pass.
- Replace tick(2100) with tick(2000); flush(). The await chain inside
  withSession() (provider.isReady, withSession entry, uploadPendingOps,
  optional LWW re-upload) requires more microtask drain than tick's
  fixed-time window reliably provides under load. flush() drains the
  pipeline regardless.

* test(e2e): guard skipOnboarding init script against data: frames

The new page-error collector started failing plugin specs because
addInitScript runs in every frame — including the empty data:text/html
iframe that plugin-index swaps in on destroy — and localStorage access
in a data: URL throws SecurityError. Wrap the four setItem calls in
try/catch so the helper noops in storage-less frames.
2026-05-23 20:33:04 +02:00
Johannes Millan
508998c6a1
Improve on sync (#7736)
* fix(android): restore share title derivation and dedupe shared tasks

Commit d32f7037a3 accidentally reverted the EXTRA_SUBJECT handling from
edb102534e, so the share handler stopped sending the page subject and
defaulted the title to the literal "Shared Content". The frontend's
subject -> title -> derived title chain then always fell through to that
placeholder, producing blank-looking shared tasks.

- Restore EXTRA_SUBJECT extraction; leave title/subject empty when absent
  so the frontend can derive a meaningful title from the URL or note.
- Ignore empty/blank shared text instead of creating a useless task.
- Skip handleIntent() on Activity recreation (config change) so the same
  share Intent isn't re-processed into a duplicate task.
- Extract buildTaskTitle/readableUrl as pure functions with unit tests
  and guard the effect against empty payloads.

* fix(snack): scale error/warning snack duration with message length

Long error messages (e.g. multi-sentence sync errors) were auto-dismissed
after a fixed 8s, too short to read. Error/warning snacks now stay visible
proportional to message length (~90ms/char), clamped to 10-30s.

* feat(tasks): skip undo snack when deleting a blank task

A sub task or parent task with an empty title and no data (notes, time,
estimate, attachments, issue link, reminder, repeat, scheduling,
deadline, non-blank sub tasks) no longer shows the undo-delete snack.

* fix(sync): include archive data in REPAIR operations

validateAndRepairCurrentState built the REPAIR op from the synchronous
getStateSnapshot(), which hardcodes empty archiveYoung/archiveOld
(archives live in IndexedDB, not NgRx state). The resulting REPAIR op
carried empty archives, so every other client that applied it
overwrote its archive with nothing — wiping archived tasks on all
devices except the one that ran the repair.

- Use getStateSnapshotAsync() so the REPAIR op carries real archives.
- Extend the empty-archive overwrite guard in
  ArchiveOperationHandler._handleLoadAllData() to also cover OpType.Repair
  (previously only SYNC_IMPORT/BACKUP_IMPORT), as defense in depth.

* test(sync): add archive REPAIR round-trip integration test

Wires the real StateSnapshotService, ArchiveDbAdapter, ArchiveStoreService
and ArchiveOperationHandler against real IndexedDB to verify archive data
survives the REPAIR-op round-trip:

- getStateSnapshotAsync() loads IndexedDB archives; getStateSnapshot() does not
- archive round-trips from client A's IndexedDB through a REPAIR op into a
  fresh client B's IndexedDB
- a REPAIR op carrying empty archives no longer wipes a client that has
  archive data (empty-archive guard regression)

* perf(sync): skip archive IndexedDB reads when post-sync state is valid

validateAndRepairCurrentState validated the full async snapshot (two
IndexedDB archive reads + structured-clone deserialization) on every
Checkpoint D, even when state was valid and no repair was needed. It now
validates the cheap synchronous snapshot first and only loads the async
snapshot (with archives) when a repair is actually required — the rare
path. The REPAIR op still carries archive data.

Also addresses multi-review follow-ups:
- archive-operation-handler: reword the empty-archive guard comment so it
  no longer over-promises reconciliation for REPAIR ops.
- archive-repair-roundtrip test: add isPersistent to the applied-op meta
  to match the real applier; scope the file docstring accurately.

* fix(task-repeat-cfg): schedule inbox task for today when made recurring

When an Inbox task (no dueDay) was made repeatable via the dialog with a
recurrence starting today, it stayed unscheduled. The TODAY-first-occurrence
branch of updateTaskAfterMakingItRepeatable$ derived currentDueDay from
task.created as a fallback, so a task created today looked already scheduled
and dueDay was never set.

Key the decision on task.dueDay directly. Skip timed tasks and tasks that
already have dueWithTime, since dueDay/dueWithTime are mutually exclusive and
timed scheduling is handled by addRepeatCfgToTaskUpdateTask$.

Closes #7725

* fix(tasks): correct monthly first/last-day recurrence anchoring

The "Every month on the first day" and "Every month on the last day"
quick settings scheduled the first task instance in the past. Both
presets produced a backdated startDate (1st of the current month;
hardcoded January 31), which getFirstRepeatOccurrence returns verbatim
for monthly recurrences.

- MONTHLY_FIRST_DAY now anchors startDate to the next 1st-of-month
  that is today or later.
- MONTHLY_LAST_DAY anchors startDate to the current month's last day
  and sets a new monthlyLastDay flag, so the occurrence engine clamps
  to month-end every month regardless of startDate's day-of-month.
- _normalizeMonthlyAnchor strips a stale monthlyLastDay flag when a
  config leaves the preset (CUSTOM mode has no control for it).

Closes #7726

* fix(task-repeat-cfg): re-anchor start date after instance deleted

When the user moved a repeat config's startDate earlier after deleting
its only live task instance, the stale lastTaskCreationDay anchor kept
suppressing every projected/created instance between the new startDate
and the old anchor.

rescheduleTaskOnRepeatCfgUpdate$ only re-anchored lastTaskCreationDay
when a live task instance existed (the #7423 fix) — it returned early
before the re-anchoring when there was none. Hoist the
isStartDateMovedEarlier detection above that early return: when no live
instance exists but startDate moved earlier, re-anchor to the day
before the new first occurrence so it and every following day is
created and projected fresh.

Closes #7724

* test(task-repeat-cfg): cover startDate re-anchor with no live instance

Add coverage for the #7724 fix beyond the effect unit test:

- Selector integration tests: feed a config re-anchored to the day
  before the new startDate through selectTaskRepeatCfgsForExactDay and
  assert it projects the new startDate and every following day, while
  still excluding the anchor day and earlier. Also documents that the
  stale anchor suppresses the gap days.
- E2E reproduction (recurring-move-start-date-earlier-no-instance):
  create a recurring task, delete its live instance, move startDate
  earlier via a transparent projection, and assert the new days appear
  in the planner. Verified to fail on pre-fix code.

* feat(sync): move clientId from pf into SUP_OPS for atomic rotation

Migrate the sync clientId out of the legacy `pf` IndexedDB database into
`SUP_OPS` (new `client_id` store, schema v6). The clientId write now joins
the atomic transaction in `runDestructiveStateReplacement`, so destructive
flows (clean-slate, backup-restore) rotate it atomically with
OPS/STATE_CACHE/VECTOR_CLOCK instead of a hand-rolled cross-database
two-phase commit.

- ClientIdService rewritten: SUP_OPS-backed via an independent connection,
  inline one-time pf->SUP_OPS migration, error-aware resolver. Read
  failures propagate (getOrGenerateClientId never mints a fresh id over a
  transient error — that would orphan the device's non-regenerable
  identity); loadClientId never throws.
- Delete withRotation, generateNewClientId and the CAS/rollback machinery.
- Extract pure generateClientId() + isValidClientIdFormat() into
  core/util/generate-client-id.ts.
- pf becomes a read-only, one-time migration source (never written/deleted).

Closes #7732

* fix(sync): dedup SUP_OPS connection open in ClientIdService

Address multi-agent review findings on the clientId migration:

- _getSupOpsDb() shares a single in-flight open via _supOpsDbPromise;
  concurrent cold-start callers previously each opened their own
  SUP_OPS connection, leaking all but the last.
- _putClientIdIfAbsent() collapsed to a single tx.done / exit point.
- db-upgrade.spec.ts: cover the v6 client_id store; the createObjectStore
  count assertions were stale and failing after the schema bump.
- operation-log-migration.service.ts: correct a misleading comment about
  the genesis-op clientId fallback.

* refactor(sync): align ClientIdService SUP_OPS open with in-house idiom

Re-review of the connection-leak fix recommended matching
OperationLogStoreService._ensureInit's pattern:

- _getSupOpsDb() clears the in-flight promise in .catch (failure only)
  instead of an unconditional finally — the resolved handle lives in
  _supOpsDb, so the promise field is pure in-flight coordination state.
- close/versionchange handlers now also null _supOpsDbPromise, so a
  stale (closed) connection is never re-handed-out.
- Add a regression test asserting _openSupOpsDb runs exactly once for
  concurrent cold-start callers.

* docs(sync): link the single-connection follow-up to #7735

Reference the tracked follow-up issue from the ClientIdService JSDoc
and the plan's out-of-scope section, so the deliberate trade-off (one
extra SUP_OPS connection) is traceable rather than forgotten.

* test(sync): update ClientIdService spies for getOrGenerateClientId

The op-log capture effect now resolves the clientId via
getOrGenerateClientId() (was loadClientId() ?? generateNewClientId()).
These two specs still mocked only loadClientId, so the effect called an
undefined method, captured no op, and 4 tests failed in the full suite.

- task-done-replay.integration.spec.ts
- operation-log-lock-reentry.regression.spec.ts

* test(sync): open SUP_OPS versionless in e2e read helpers

DB_VERSION was bumped 5->6; five e2e helpers still opened SUP_OPS at
the hardcoded old version 5 to read state after the app had already
upgraded it to v6, throwing VersionError. Open versionless instead —
matches the ~10 other e2e files that already do, and is future-proof
against the next schema bump.

- migration/legacy-data-migration.spec.ts (x2)
- sync/supersync-legacy-migration-sync.spec.ts
- sync/webdav-legacy-migration-sync.spec.ts
- recurring/invalid-clock-string-bug-7067.spec.ts
2026-05-22 17:49:25 +02:00
johannesjo
c10c7a79e4 test(supersync): stabilize migration CI checks 2026-05-16 20:35:47 +02:00
Johannes Millan
86497d24de test(sync): cover incompatible supersync password change 2026-05-13 11:08:54 +02:00
Johannes Millan
5a924eeadd test(sync): scope keep-local validation assertion 2026-05-12 18:26:08 +02:00
Johannes Millan
22dbfde12b test(sync): fix concurrent time tracking assertion 2026-05-09 19:28:19 +02:00
Johannes Millan
4b5fc3fb33 test: stabilize flaky e2e specs 2026-05-09 18:11:40 +02:00
Johannes Millan
44e0762fba
Feat/issue 7330 bc1a3f (#7522)
* fix(sync): strip same-batch-archived task IDs from TAG/PROJECT LWW payloads

Issue #7330. lwwUpdateMetaReducer's orphan filter only sees taskState as it
is when each op runs. A TAG LWW Update applied before its sibling archive
op in the same bulk batch escapes the filter, leaving TODAY_TAG (or any
tag/project) referencing a task the very next op removes — user-visible
as "archived tasks reappear in today's view" on hibernate-wake.

bulkOperationsMetaReducer already collects archivingOrDeletingEntityIds
for the wholesale TASK LWW Update skip; this commit reuses that set to
pre-clean taskIds (and PROJECT-only backlogTaskIds) on TAG/PROJECT LWW
Update payloads before convertOpToAction. Cross-batch ordering is not
addressed — see open issue.

* fix(sync): surface post-sync validation failure as ERROR, not IN_SYNC

Issue #7330. The reporter saw "State validation failed after sync. Some
data may be inconsistent." while sync simultaneously reported status
IN_SYNC. validateAfterSync's boolean was discarded above the snackbar
layer (#6571 only added the snackbar; nothing flowed up to the status
pipeline).

Plumb the boolean through:
- validateAfterSync: void -> boolean
- _validateAndRepairAfterResolution: void -> boolean
- autoResolveConflictsLWW + processRemoteOps: add validationFailed
- DownloadOutcome.ops_processed + UploadOutcome.completed: add
  validationFailed
- sync-wrapper: if either result.validationFailed, setSyncStatus('ERROR')
  and return HANDLED_ERROR before the IN_SYNC mark

Tests: assert validateAfterSync returns the boolean, assert
processRemoteOps surfaces validationFailed, assert sync-wrapper sets
ERROR (not IN_SYNC) when either download or upload reports
validationFailed.

* fix(sync): always set top-level id on LWW Update payloads

lwwUpdateMetaReducer drops LWW Updates whose payload lacks a top-level
id ("Entity data has no id"). Two creation paths could produce such
payloads:

- createLWWUpdateOp passed entityState through unchanged, so a malformed
  selector result silently produced an unusable LWW op.
- _convertToLWWUpdatesIfNeeded built mergedEntity by spreading baseEntity
  and updateChanges; when baseEntity lacked id (corrupt DELETE payload)
  and updateChanges had id stripped, the merge had no id either.

Both sites now force the canonical entityId onto the payload. (#7330)

* fix(sync): close 3 secondary gaps in #7330 fixes

Multi-agent review surfaced three additional code paths mirroring the
same defects we fixed at the primary path:

G1: bulkOperationsMetaReducer's pre-scan only saw op.entityIds /
op.entityId for archive/delete ops. moveToArchive declares only
top-level task IDs, but the reducer cascades to subtasks via
[t.id, ...t.subTasks.map(st => st.id)]. deleteTask cascades the same
way via taskToDelete.subTaskIds. Same-batch TAG/PROJECT LWW Updates
referencing those subtasks slipped through the strip. Add
collectCascadedSubTaskIds to harvest subtask IDs from archive/delete
payloads.

G2: SyncWrapperService's LWW re-upload retry loop captured only
reuploadResult.localWinOpsCreated, throwing away validationFailed.
A retry-pass piggybacked download with failing post-sync validation
still reported IN_SYNC. Track reuploadValidationFailed across
retries and OR into the gate before IN_SYNC.

G3: OperationLogSyncService's downloadCallback (used by the
rejected-op handler) converted the download outcome into
DownloadResultForRejection, which has no validationFailed field.
A nested download triggering validation failure was lost before
uploadPendingOps returned. Route the boolean via the existing
piggybackValidationFailed flag in the closure scope.

Also fixes one cosmetic logging field (reuploadValidationFailed
included alongside download/upload in the ERROR log).

* fix(sync): defense-in-depth id derivation in lwwUpdateMetaReducer

Multi-agent review surfaced two follow-up improvements for #7330:

1. Consumer-side id fallback: a future LWW Update producer that
   forgets to backfill payload.id would silently regress the
   "Entity data has no id" bail. Recover from action.meta.entityId,
   which convertOpToAction always populates from the canonical
   op.entityId. Now a single consumer guards the whole class of
   missing-id producer regressions.

2. Tighten 7 conflict-resolution.service.spec assertions from
   jasmine.objectContaining({ localWinOpsCreated: N }) back to
   strict toEqual({...}). The loosening (added when validationFailed
   was introduced) hid future stray-field regressions; the validation
   path now returns a deterministic shape per code branch, so we can
   assert it.

* fix(sync): close issues found in follow-up review (#7521)

Multi-agent review of the prior #7330 follow-up commits surfaced four
real issues. All addressed here:

CRITICAL — singleton id pollution at producer + consumer
  Singletons use entityId='*' as a sentinel. Both the producer-side
  payload-id backfill (createLWWUpdateOp + _convertToLWWUpdatesIfNeeded)
  and the consumer-side meta.entityId fallback in lwwUpdateMetaReducer
  injected `id: '*'` into payloads. For singletons (GLOBAL_CONFIG,
  app-state, time-tracking) this leaks a synthetic `id: '*'` field
  into singleton feature state when the reducer spreads entityData.
  Gate id-injection on entityId \!== '*' at all three sites and move
  the consumer fallback past the singleton branch.

WARNING — null-safety in collectCascadedSubTaskIds
  `'actionPayload' in payload` check could pass for malformed
  payload {actionPayload: null}, then crash on the next property
  access. Tighten to a typeof check.

WARNING — retries-exhausted path bypasses validationFailed gate
  When the LWW re-upload loop exits via MAX retries with pending
  ops still > 0, sync-wrapper used UNKNOWN_OR_CHANGED without
  consulting reuploadValidationFailed. Validation failure during
  a retry pass is now elevated to ERROR — a more honest signal
  than UNKNOWN_OR_CHANGED, and consistent with the user-visible
  "State validation failed" snackbar they already saw.

SUGGESTION — devError on consumer fallback
  Producer regressions should scream loudly in dev, not slip
  through silently. The fallback now emits devError + applies
  the recovery, so a future LWW producer that forgets payload.id
  surfaces in dev rather than only-when-the-bail-fires.

* fix(sync): close validationFailed gap in USE_REMOTE and DELETE_MULTIPLE cascade

Three issues found by multi-agent review of the #7330 sync fixes:

1. forceDownloadRemoteState() discarded the validationFailed result
   from processRemoteOps. A USE_REMOTE conflict-resolution path that
   applied corrupt remote state would still mark sync IN_SYNC despite
   the snackbar. Now returns { validationFailed } and propagates it
   through _handleSyncImportConflict and DownloadOutcome.no_new_ops
   to SyncWrapperService, plus the two direct callers (manual force
   download and first-sync conflict resolution).

2. sync-wrapper retry-exhaustion priority: when LWW retries exhausted
   AND the initial download/upload had reported validationFailed, the
   wrapper returned UNKNOWN_OR_CHANGED instead of ERROR. The hoisted
   downloadValidationFailed/uploadValidationFailed are now checked
   inside the retry-exhaustion branch alongside reuploadValidationFailed.

3. bulk-hydration pre-scan handled DELETE_MULTIPLE in the loop but the
   collectCascadedSubTaskIds helper early-returned for that action type.
   Since deleteTasks payload only carries flat taskIds, subtask cascade
   must be derived from initial batch state (mirroring what
   handleDeleteTasks does at apply time). Co-batched TAG/PROJECT LWW
   Updates referencing those subtasks could otherwise still leak.

Adds regression tests for each path.

* refactor(sync): centralize LWW id backfill, extract singleton sentinel constant

Follow-up simplifications from the multi-agent review of #7330 fixes.

- Extract SINGLETON_ENTITY_ID = '*' + isSingletonEntityId() helper in
  entity-registry.ts. Replaces the bare '*' literal across
  conflict-resolution.service.ts, lww-update.meta-reducer.ts,
  validate-operation-payload.ts, operation-log-recovery.service.ts,
  and operation-log-migration.service.ts. (S2)

- Add LWW Update payload.id backfill at the apply boundary in
  convertOpToAction. Every applied LWW op now has its top-level id set
  from op.entityId (excluding singletons). The redundant defense-in-depth
  block in lwwUpdateMetaReducer (which derived id from meta.entityId) is
  removed — the converter is now the single chokepoint, with producers
  (createLWWUpdateOp, _convertToLWWUpdatesIfNeeded) keeping their
  enforcement for an explicit on-disk shape. (S1)

- stripBatchArchivedTaskIdsFromLwwPayload: drop non-string entries
  rather than preserving them, and skip the cleaned[] allocation when
  no rewrite is needed (two-pass with early exit on first hit). (S5+S7)

* refactor(sync): extract bulk-archive util, unify orphan-task filters, type validation propagation

Continued review follow-ups for #7330. Behavior unchanged.

- Move payload-archaeology helpers (collectCascadedSubTaskIds,
  stripBatchArchivedTaskIdsFromLwwPayload) out of bulk-hydration.meta-reducer
  into a co-located bulk-archive-filter.util.ts. The meta-reducer body
  drops back to its dispatcher role; the helpers gain an independent
  test surface and clearer naming. (S3)

- Add a shared filterTaskIdArraysFromTagOrProjectPayload helper. Both
  filterOrphanedTaskIdsFromEntityData (lww-update.meta-reducer; predicate:
  not in live state) and stripBatchArchivedTaskIdsFromLwwPayload (bulk
  meta-reducer; predicate: in same-batch archive set) now wrap it. The
  two callers run at different layers because their predicates resolve
  at different times — the shared helper is the array-walking +
  payload-cloning + warn-logging core. (S4)

- Replace the closure-captured piggybackValidationFailed boolean in
  uploadPendingOps with typed plumbing: validationFailed flows through
  DownloadResultForRejection and RejectionHandlingResult. The closure
  smuggle was fragile; the typed return is grep-able and survives
  refactors. The wider session-latch refactor proposed in review is
  deferred — current typed plumbing is well-tested. (S6 minimal)

* refactor(sync): replace validationFailed plumbing with session latch + integration test

Final review follow-up for #7330. Behavior unchanged; surface area shrinks.

The validation-failed signal previously rode through 7 typed boundaries:
DownloadOutcome.{ops_processed,no_new_ops}.validationFailed,
UploadOutcome.completed.validationFailed, processRemoteOps return,
forceDownloadRemoteState return, _handleSyncImportConflict return,
DownloadResultForRejection.validationFailed, RejectionHandlingResult
.validationFailed, plus a closure-captured piggybackValidationFailed in
uploadPendingOps. Threading it correctly required every call site to
remember to forward the boolean — a new variant or path that forgot
would silently let IN_SYNC ride over corrupt state.

- Add SyncSessionValidationService — a no-dep singleton with reset() /
  setFailed() / hasFailed(). RemoteOpsProcessingService.validateAfterSync
  and ConflictResolutionService.autoResolveConflictsLWW flip the latch
  when validation reports corruption. SyncWrapperService resets it at
  every entry point (sync(), _forceDownload(), resolveSyncConflict
  USE_REMOTE) and reads it once before deciding IN_SYNC vs ERROR.

- Drop validationFailed from DownloadOutcome variants, UploadOutcome,
  DownloadResultForRejection, RejectionHandlingResult, processRemoteOps
  return, autoResolveConflictsLWW return, forceDownloadRemoteState
  return, and _handleSyncImportConflict return. ~120 LOC of plumbing
  collapsed to single latch reads.

- Add a focused integration test
  (post-sync-validation.integration.spec.ts) that wires real
  RemoteOpsProcessingService + ConflictResolutionService against a
  stubbed ValidateStateService. Asserts the latch flips on validation
  failure and survives discarded-boolean callers — catching plumbing
  regressions a future code path could otherwise sneak past.

Net change vs current branch: ~120 LOC deleted from production sources,
~270 LOC added in new service + tests (latch unit tests + integration).

* fix(sync): close 3 latch-bypass gaps from codex review

1. SyncHydrationService.hydrateFromRemoteSync runs validateAndRepair()
   directly and previously dropped the result on failure. Snapshot
   hydration (file-based providers, USE_REMOTE force-download) would
   silently accept corrupt remote data — the wrapper would see a clean
   latch and report IN_SYNC. Now flips
   SyncSessionValidationService.setFailed() when isValid is false.

2. WsTriggeredDownloadService called downloadRemoteOps() outside the
   wrapper session contract, so any validation failure during a realtime
   apply was either dropped (next sync()'s reset cleared it) or leaked
   into the next session. The service now resets the latch up-front and
   reads it after the download, surfacing failures as
   setSyncStatus('ERROR').

3. convertOpToAction only backfilled payload.id when missing. A
   malformed/older remote LWW op with payload.id \!= op.entityId would
   slip through and update the WRONG entity in lwwUpdateMetaReducer
   (which trusts entityData.id). Now forces id from op.entityId for
   non-singleton LWW ops, making "entityId is canonical" a hard
   invariant at the apply boundary.

Adds regression tests for each: integration test for the snapshot path,
two new tests on ws-triggered-download (latch flip → ERROR; latch reset
between sessions), and a converter test for the entityId-mismatch case.

* test(sync): unstick three failing supersync e2e tests

Three independent flakes surfaced in the same run; root-caused from
playwright traces:

- daily-summary: time-estimate row icon was renamed timer→hourglass_empty
  on master (eca8d211a) but the selector was never updated on this branch.
  Cherry-pick of master's 4be835017 selector update.

- multi-migration: 120s test budget is too tight for 3 sequential
  setupSuperSync + 4 syncAndWait cycles under parallel @supersync load.
  Trace shows test reaches the post-sync 1.5s grace wait — Client B has
  already received all 3 tasks — and times out there. Bumped just this
  test to 180s.

- lww-conflict notification: done-toggle dispatches updateTask
  asynchronously; without an explicit wait, sync runs as a no-op (latch
  trace shows uploaded=0, status=IN_SYNC at 2459705) and the queued
  updateTask lands ~13ms later, flipping hasNoPendingOps back and hiding
  the check icon → syncCheckIcon waitFor times out. Added the same
  expect(...).toHaveClass(/isDone/) wait that the file's other LWW tests
  (lines 83, 376, 389) already use.

* refactor(sync): self-enforcing session-validation contract via withSession()

The previous SyncSessionValidationService API was reset() / setFailed() /
hasFailed() with a doc-comment contract: "every sync entry point must call
reset() before doing work." A new entry point added later (e.g., a
background download path) that forgot the reset would inherit a
leaked-failed latch from a prior session, and #7330's IN_SYNC-vs-ERROR
decision would misfire silently. The maintenance hazard was the largest
reason this code wasn't self-protecting.

Replace with a callback API:

  withSession<T>(work: () => Promise<T>): Promise<T>

Resets the latch at entry, marks a session active for the duration of
work, clears the marker on completion or error. setFailed() and reset()
log a noisy SyncLog.err if called outside an active session — a runtime
guard for "validation fired without an entry point opening a session."
Nested withSession() calls are detected and run in the outer session's
context (no inner reset clobbers outer state).

Three production entry points migrated:
- SyncWrapperService._sync() — body extracted to _syncBody(providerId)
  to avoid a 400-line indent diff
- SyncWrapperService._forceDownload()
- WsTriggeredDownloadService._downloadOps()

The fourth caller of reset() — the USE_REMOTE branch in
_handleLocalDataConflict — keeps using reset(), now correctly within the
session opened by _sync()'s withSession. It's a sub-scope re-scoping
inside an existing session, not a top-level entry point. The new
reset() docs make this distinction explicit.

Test surface:
- sync-session-validation.service.spec.ts rewritten: 12 tests cover the
  callback semantics, nested-session guard, and outside-session warnings.
- post-sync-validation.integration.spec.ts wraps each scenario in
  withSession() (mirrors production); replaces the old "reset() between
  sessions" test with one that asserts withSession() entry resets state.
- sync-wrapper.service.spec.ts unchanged — its tests fire setFailed()
  inside mocked download/upload callbacks, which now run inside _sync()'s
  session. 107/107 still pass.
- WsTriggeredDownloadService spec: latch.reset() in setup → _resetForTest()
  to avoid the new outside-session warning; the "stale latch" test seeds
  internal state via the test-only helper.

reset() and setFailed() are still public — they're load-bearing for
the USE_REMOTE sub-scope and for validation services that need to flip
the latch from inside session-wrapped flows. The contract is now:
top-level entry points use withSession; validation sites use setFailed
unchanged; only USE_REMOTE recovery uses reset.

#7330

* fix(sync): warn when convertOpToAction rewrites a mismatched payload.id

The id-rewrite guard added in 41b47be4 silently fixes producer/wire bugs
(an LWW op whose payload.id disagrees with op.entityId would otherwise
update the wrong entity in lwwUpdateMetaReducer). Correct in direction,
but invisible — if the assumption that "no legitimate code path produces
mismatched ops" ever breaks, we'd only learn from a user-reported corrupt
entity.

Log a SyncLog.warn with ids only (actionType, entityType, entityId,
payloadId) when the rewrite fires. Never log payload content — op log is
exportable and #7330 already caused us to audit that surface for user
data leaks.

Two new tests: warning fires with the expected id pair on mismatch; no
warning on the happy path. Sanity assertion verifies payload content
(title) doesn't appear in the log call args.

#7330

* test(sync): static check enumerates withSession() entry-point allow-list

CI-time guard for the latch maintenance hazard: greps the eight production
sync sources for `.withSession(` callers and asserts the count matches an
explicit allow-list (3 today: _sync, _forceDownload, _downloadOps).

Brittle on purpose. A future contributor adding a 5th sync entry point
will see this fail and have to read the contract in
sync-session-validation.service.ts before updating ALLOWED_ENTRY_POINTS.
That's the friction the runtime guards (the new withSession callback
API + outside-session warnings) can't deliver on their own — they catch
forgotten resets, but not "added a new top-level entry point without
considering whether it's a session boundary at all."

Wired into `npm run lint` via a new lint:sync-sessions step. Tried a
Karma spec first, but Karma doesn't serve the workspace at /base/ paths
without explicit config — a Node script via existing tools/ infrastructure
is simpler and runs the same in CI.

Verified by appending a fake 4th caller to operation-log-sync.service.ts
and confirming the script exits 1 with a contributor-friendly message
pointing at the contract file. Reverted before this commit.

#7330

* fix(sync): wrap ImmediateUploadService._performUpload in withSession()

Fourth #7330 entry point. uploadPendingOps() processes piggybacked
remote ops, which run validateAfterSync(); on corruption, validation
flips the SyncSessionValidationService latch. Without an explicit
withSession() wrapper here, the latch flip would either fire outside
any session (logged as a contract violation, dropped by the next
normal sync's reset) or — worse — go unread while _performUpload
claimed IN_SYNC based purely on result.uploadedCount. That reproduces
the exact #7330 surface on the immediate-upload path.

Wrap _performUpload's body in latch.withSession(...) and read
hasFailed() before any IN_SYNC / deferred-checkmark decision. On
failure (including a thrown upload after the latch flipped) emit
ERROR; transient errors with no latch flip remain silent.

Allow-list: ImmediateUploadService._performUpload added to
tools/check-sync-session-entry-points.js (now 4 entry points).

Tests: 5 new specs cover failure during piggybacked-op processing,
clean upload, LWW re-upload pass, latch reset between sessions, and
upload-throws-after-latch-flip.

Plan: docs/plans/2026-05-08-sync-run-service-refactor.md proposes a
type-enforced runner to replace the contract+lint pair. Deferred —
this PR closes the user-visible bug; the runner refactor is
value-over-time.

* chore(sync): drop withSession() entry-point lint check + deferred runner plan

The static check enumerates withSession() *callers* and asserts the set
matches an allow-list. It catches "added a withSession call without
updating the list" but not the inverse — "added a sync entry point
that should call withSession() but doesn't." _performUpload() was the
existence proof that the lint was silent on the actual failure mode.
Net: false confidence + maintenance churn for a problem nobody has.
The 4-entry-point contract is small enough for code review.

The deferred runner-refactor plan doc proposes a SyncRunService that
mints a SyncRunContext to enforce the contract via types. Two reviews
converged on "don't do it":
  - net more code (re-introduces the typed plumbing the latch was
    chosen to avoid in b3cbdbd41),
  - 3 of 4 entry points keep their own status logic (UNKNOWN_OR_CHANGED,
    deferred-checkmark) so the runner shell adds little structural value,
  - the hazard it closes (forgot withSession on entry point #5) is
    theoretical until that contributor exists.
Drop the doc rather than leave a Status:Proposal file rotting in repo.

withSession() itself stays — reset-and-only-reset at session entry is
load-bearing against leaked-failed latches; cheap insurance.

#7330

* docs(sync): correct entry-point list in SyncSessionValidationService

The top-of-file docstring listed `resolveSyncConflict() (USE_REMOTE
branch)` as a 4th entry point and omitted `ImmediateUploadService
._performUpload()` (added in 22f68027). Both inaccurate after the
ImmediateUploadService wrap and contradict the inline `reset()`
docs at line 104, which correctly identify USE_REMOTE as a sub-scope
inside `_sync()`'s session, not a session boundary.

Replace with the actual four entry points and call out USE_REMOTE
separately as a sub-scope re-scope.

#7330
2026-05-09 00:12:58 +02:00