Commit graph

14624 commits

Author SHA1 Message Date
Parman Mohammadalizadeh
7fba6f55e2
fix(tasks): decode multipart and transfer-encoded eml bodies #8975 (#8999)
* fix(tasks): decode multipart and transfer-encoded eml bodies #8975

Dropping a real-world .eml (e.g. saved from Outlook) onto the add-task
button created a title-only task: the parser only accepted a single,
unencoded text/plain body, but most clients send multipart/alternative
(plain + HTML) with quoted-printable or base64 transfer encoding on the
plain part.

eml-parser.ts now walks multipart/* structures (bounded recursion depth)
for the first supported text/plain leaf, and decodes quoted-printable/
base64 transfer encodings on it. HTML bodies and non-UTF-8/ASCII
charsets are still never decoded, matching the existing threat model
for this untrusted, inert note content.

* fix(tasks): keep unsupported-encoding eml test accurate after decode support

The service-level test for "unsupported body encoding" used a base64
fixture, which the eml-parser fix now legitimately decodes. Switch it
to a genuinely unsupported transfer-encoding token, and add a
dedicated test asserting base64 bodies decode and wrap in the notes
code fence as expected.

* fix(tasks): skip Content-Disposition: attachment parts in eml import

_extractPlainText() picked the first supported text/plain leaf in wire
order regardless of Content-Disposition, so a text/plain attachment
could be imported as the note when the real body was HTML, or shadow
the real body if it appeared first in the multipart structure. Skip
any part (leaf or multipart container) marked as an attachment before
inspecting it further.

Addresses review feedback from @johannesjo on PR #8999.

* fix(tasks): treat non-inline disposition and legacy name= as attachment

Content-Disposition is optional (legacy mail marks a filename via
Content-Type's name= parameter instead), and RFC 2183 §2.8 requires
any disposition type other than inline — recognized or not — to be
treated as attachment. The previous fix only matched the literal
token "attachment", so a text/plain part identified solely by a
legacy name= parameter, or one with an unrecognized disposition type
(e.g. x-download), still shadowed the real body.

_isAttachmentPart() now treats any present disposition other than
inline as an attachment, and falls back to the Content-Type name=
hint only when Content-Disposition is absent entirely.

Addresses further review feedback from @johannesjo on PR #8999.

* fix(tasks): recognize RFC 2231 name*/name*0 attachment filename hints

The legacy Content-Type name= fallback (used when Content-Disposition
is absent) only matched the literal key "name", missing RFC 2231's
encoded (name*) and continuation (name*0, name*0*, name*1, ...)
spellings of the same parameter. A part identified solely by a
continued name*0/name*1 filename hint slipped through undetected and
could be imported as the note instead of producing a title-only task.

_parseContentType() now matches any RFC 2231 spelling of name via a
presence-only regex; the value is never decoded or reassembled since
only the filename hint's existence matters.

Addresses further review feedback from @johannesjo on PR #8999.

* fix(tasks): recognize RFC 2231 filename*/filename*0 disposition hints

Content-Disposition's hasFilename fallback (used when the disposition
type token fails to parse, e.g. a type-less "; filename=...") only
matched the literal key "filename=", missing RFC 2231's encoded
(filename*) and continuation (filename*0, filename*0*, ...) spellings
of the same parameter -- the mirror of the name*/name*0 gap fixed for
Content-Type.

_parseContentDisposition() now matches any RFC 2231 spelling of
filename via a presence-only regex, symmetric to _NAME_PARAM_KEY_RE.

Addresses further review feedback from @johannesjo on PR #8999.
2026-07-15 09:55:39 +02:00
Johannes Millan
7e273a0e5c
fix(sync): recover tasks when a deleteProject loses an LWW conflict (#8997) (#9007)
* docs(plugins): add microsoft 365 calendar provider plan

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

* docs(ios): plan internal testflight builds

* fix(sync): recreate cascaded tasks when a deleteProject loses LWW (#8997)

When a remote deleteProject loses an LWW conflict, its reducer cascade
(tasks removed via removeMany(allTaskIds)) is not compensated, so the
project resurfaces empty and its tasks are lost on every client that
applied the delete and on this client's own hydration replay.

Mirror the TASK-parent recovery from #8990 for PROJECT cascades:
_createTaskRecreationOpsForWinningProject emits recreate-after-delete
TASK snapshots for every still-present task in the delete payload's
allTaskIds, plus relationship/membership patch ops so the exact
regular/backlog lists and subtask links are restored after the entities
exist. enrichDeleteProjectAction expands a stale delete through current
project relationships on replay; the lww meta-reducer validates recreate
rows against present parents/projects.

Covers part (a) of #8997. Parts (b) local-delete-loses and (c)
notes/archived-tasks remain open.

* fix(sync): make deleteProject-loser recovery interruption-safe (#8997)

Hardens the base #8997 recovery for cases where a recreate-after-delete
row is itself caught in a later conflict or delivered out of order.

- createTaskRecreationFollowUpOps re-emits parent/subtask relationships
  and PROJECT membership when a recovery row is rejected and replaced, so
  independent server acceptance cannot drop parent/child links or append
  a backlog task to the regular list.
- _createRemoteWinCompensationForRejectedTaskRecreation reconstructs a
  local snapshot when a remote TASK winner (move-to-project or a
  field-safe update) beats a local recovery row, then restores its
  dependents; opaque/relationship-changing remote winners bail out.
- The superseded-op resolver re-emits the same follow-ups and persists
  each replacement group atomically before retiring the stale rows.
- Preserve the recreate guard when a recovery row wins a later conflict.

Load-bearing, not gold-plating: with this layer stubbed, 8 unit tests
plus the move-winner half of the persistence convergence test fail.

* fix(sync): don't resurrect concurrently-deleted tasks in project recovery (#8997)

Adversarial review of the #8997 recovery found two cross-device residuals
in _createTaskRecreationOpsForWinningProject:

1. (HIGH) The recovery reads task presence from the pre-batch store, so it
   was blind to a delete piggybacked as a non-conflicting op in the same
   sync batch. Device C deletes task t2 while device A wins a project rename
   vs B's deleteProject; A recreated t2, resurrecting it (via a borrowed
   newer timestamp) on every client that applied C's delete while A's own
   delete won locally — a silent split-brain. _collectDeletedTaskIds now
   gathers the batch's deleted TASK ids (single + multi-entity) and recovery
   skips them.

2. (MEDIUM) Recreations borrowed the project's timestamp as their LWW proxy,
   which is unrelated to task content and could clobber a CONCURRENT edit on
   another device. They now use each task's own `modified` (fallback: the
   project timestamp). Clock domination over the delete is unaffected.

Both are proven by new failing-first specs. Note: the sibling subtask path
(_createSubtaskRecreationOpsForWinningParent, #8956) shares the same
same-batch-delete blindness and is a candidate follow-up.

* fix(sync): keep bulk-deleted tasks deleted in project recovery (#8997)

The deleteProject-loser recovery skips tasks a concurrent non-conflicting
op is deleting in the same batch, so it doesn't resurrect them on clients
that applied the delete. Its `_collectDeletedTaskIds` guard only read
`op.entityId`, but a bulk `deleteTasks` (TASK_SHARED_DELETE_MULTIPLE) op
carries every id in `entityIds` and mirrors only the first to `entityId`,
with an empty `entityChanges`. So every id after the first was missed and
recreated with a borrowed newer timestamp — the same split-brain the
single-delete guard closes, via the bulk path.

Union both id sources via `getOpEntityIds` (already used throughout this
file for the identical reason). Adds a failing-first regression test
mirroring the single-delete case with an entityIds-carrying bulk delete.

* fix(sync): exclude conflict-won deletes from project recovery (#8997)

The deleteProject-loser recovery excludes tasks a non-conflicting op is
deleting in the same batch, but a task can also be deleted by its OWN LWW
conflict — this client held a competing edit that lost, and the remote
delete won. Such a delete is applied this batch yet never reaches
`nonConflictingOps`, so recovery read the task from the pre-batch store
and re-emitted a recreation for a deletion that had just won.

Fold the remote-delete winners of resolved conflicts into the same
`_collectDeletedTaskIds` guard so recovery skips them too. Reusing that
helper means bulk deleteTasks winners are covered as well. Failing-first
regression test added.

* test(sync): add replay-convergence tests for project-recovery delete guards (#8997)

The concurrent-delete guards were covered only by emission assertions (was
the recovery row emitted/skipped) — which cannot catch a resurrection that
only manifests after replay. These two real-store integration tests apply
the recovery ops on a fresh client that also applied the concurrent delete
and assert the deleted task stays deleted:

- bulk deleteTasks: every trailing entityIds entry stays deleted
- a task whose own LWW conflict the remote delete won stays deleted

Both fail (task resurrected) when the respective guard is reverted,
confirming they exercise the divergence, not just op emission. The second
also disproves the "borrowed modified timestamp self-corrects" theory: a
recreation dominates the delete by clock/seq, so the task is resurrected
without the guard.
2026-07-14 22:32:23 +02:00
Johannes Millan
29df7e7719
fix(sync): preserve local edits during snapshot hydration (#9010)
* docs(plugins): add microsoft 365 calendar provider plan

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

* docs(ios): plan internal testflight builds

* fix(sync): prevent file-based sync data loss

Preserve post-snapshot operations and stage remote baselines until durable apply.

Use conditional writes and a resumable marker for provider concurrency and legacy migration. Report trustworthy remote timestamps and document transport limits.

Refs #8960

* fix(sync): avoid biased conflict recommendations

Highlight a side only when its timestamp or known change count is strictly greater, leaving unknown and tied metadata neutral.

* fix(sync): preserve local edits during snapshot hydration

Extracts the one reachable-data-loss fix from the #9005 hardening branch
(feat/sync-recovery-hardening). On the default single-file path, a local
action dispatched while an async snapshot hydrate is in flight was
persisted against the old state and then clobbered by loadAllData (or
permanently dropped by markRejected) — a silent edit loss multi-device
users can hit on gap-detected re-sync.

Runs hydration inside writeFlushService.flushThenRunExclusive so capture
stays in deferred mode across the snapshot dispatch, commits the snapshot
baseline atomically (commitFileSnapshotBaseline), then replays the
buffered local intents and their archive side effects onto the new
baseline.

Deliberately excludes #9005's snapshot two-phase-commit, structural
validation, split-migration crash-resume, and gap persistence: reachability
analysis found those defend non-reachable (structural validation),
already-fail-safe (crash-mid-write), or default-off (split sync) failure
modes, at the cost of ~1k lines and two permanent on-disk formats.

* test(sync): add commitFileSnapshotBaseline to hydration spy
2026-07-14 21:53:05 +02:00
Johannes Millan
631e6e9710
fix(locale): localize ISO 8601 calendar weekday/month names (#8987) (#9013)
* docs(plugins): add microsoft 365 calendar provider plan

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

* docs(ios): plan internal testflight builds

* fix(locale): localize ISO calendar weekday/month names (#8987)

PR #8991 localized ISO 8601 weekday labels only in the custom
Schedule/Habits/Planner components. Material <mat-calendar> (schedule-task
dialog, deadline dialog, date-picker inputs, repeat-task heatmap) still
rendered month + weekday names via the global adapter locale 'sv' (the ISO
sentinel), so they showed Swedish and ignored the app language.

Override getDayOfWeekNames, getMonthNames and the spelled-out branch of
format() in CustomDateAdapter to run under a temporary swap to
isoTextLocale() (the UI language) when the ISO option is active. Numeric
dateInput and time-only formats keep the configured locale, so ISO stays
YYYY-MM-DD and the 24h clock is preserved. The swap assigns this.locale
directly (not setLocale) so it fires no spurious localeChanges.

Add unit coverage for the adapter and a real <mat-calendar> integration
spec asserting UI-language headers, a live language switch, and the
non-ISO fallback.
2026-07-14 21:03:15 +02:00
Johannes Millan
95d3b212bc
fix(electron): gate Jira IPC behind a one-shot capability (#9008)
* docs(plugins): add microsoft 365 calendar provider plan

* fix(jira): gate Electron requests behind one-shot capability

Claim privileged Jira IPC before plugin startup and return responses through invoke instead of a broadcast event. Keep arbitrary HTTP(S) Jira hosts supported while rejecting redirects and bounding request resources.

* fix(jira): enforce Electron request capability

Bind privileged Jira IPC to a main-issued renderer-document token and strip raw Electron events from renderer callbacks. Scope image authentication by origin, base path, and resource type while preserving safe redirects and legacy configurations.

* fix(electron): handle payload-only IPC lifecycle

Clear Jira image authentication before replacement and when a new renderer document claims the capability. Parse before-close IDs from payload-only events so pending sync and finish-day hooks can complete.

* fix(electron): address Jira IPC capability review findings

- electron.effects: read ANY_FILE_DOWNLOADED payload at [0] after the
  payload-only IPC refactor (was [1], now undefined -> TypeError on every
  download); guard against a malformed payload
- jira-capability: rotate the token on re-register so a renderer reload
  that reuses the WebFrameMain object is not permanently locked out of
  Jira; invalidates any stale token
- document that the one-shot consumption order, not the bypassable
  main-frame check, is the real capability boundary
- jira-electron-bridge: skip the no-op clearImgHeaders IPC round-trip
  when image auth was never set up (non-Jira detail-panel open/close)
- jira-api: route a synchronous _toElectronRequestInit throw through
  _handleResponse instead of leaking a dangling request-log entry

* test(electron): cover ANY_FILE_DOWNLOADED payload parsing

Extract parseDownloadedFilePayload from ElectronEffects and add a
regression spec pinning the payload-only shape ([file], not [event,
file]) that caused a TypeError on every download. Hardened against
non-array input surfaced by the new test.

* fix(electron): restore Node ambient globals for frontend build
2026-07-14 21:02:27 +02:00
Johannes Millan
75d3cd3c0a
fix(sync): serialize archive read-modify-write vs remote replacement (#8941) (#9006)
* docs(plugins): add microsoft 365 calendar provider plan

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

* docs(ios): plan internal testflight builds

* fix(sync): serialize archive replacements

* test(sync): scope archive-race spec to op-log replacement (#8941)

The integration spec's first case drove SyncHydrationService.hydrateFromRemoteSync
and expected it to serialize against archive compression via the TASK_ARCHIVE
lock. But the hydration-side lock is not part of this PR — it lands with the
snapshot-hydration ordering change in PR #9010 (fix/8960-hydration-race), which
also ships its own sync-hydration.service.spec coverage.

Because hydrateFromRemoteSync never requested the lock here, that test hung
forever on `await hydrationLockRequested` while compressArchive still held the
real navigator.locks `sp_task_archive` lock. The leaked global lock then
cascaded into every downstream spec that acquires it (26 failing tests,
LockAcquisitionTimeoutError).

Drop the hydration-path case (it belongs with #9010) and keep the
runRemoteStateReplacement case, which is exactly the writer this PR serializes.
Trim the now-unused hydration providers/mocks from the setup.
2026-07-14 20:10:30 +02:00
Johannes Millan
8e810edbe7
fix(sync): make marked project deletions win LWW conflicts (#9009)
deleteProject cascade-deletes a project's tasks, notes, sections, repeat
config, and archive data in one reducer pass. When that op lost an LWW
conflict to a concurrent project edit, only the PROJECT entity was
reversed: every client resurrected an empty project and the winning
client's status-blind hydration replay cascaded its tasks away after a
restart (live state != post-restart replay).

Rather than recreate every cascaded entity (payload scales with project
size and cannot restore every side effect safely), give schema-v4
deleteProject operations explicit delete-wins precedence:

- new deleteProject actions carry a shared PROJECT_DELETE_WINS_MARKER; the
  shared LWW planner accepts a host-supplied delete-wins classifier. A
  marked remote delete is applied regardless of timestamps; a marked local
  delete is replaced with one op whose vector clock dominates both sides.
- historical unmarked (schema-v3) deletions keep timestamp-based LWW; the
  absence of the marker (never added by the no-op v3->v4 migration) is the
  real discriminator, and a schema v3->v4 barrier (mirroring v2->v3) makes
  older clients block on the newer-schema gate instead of mis-resolving.

Delete-wins plans reuse the archive-win resolution pipeline, so they
inherit its atomic persistence and losing-op rejection, and disjoint
merge leaves them untouched (the delete must win the whole entity).

Hardening from multi-agent review:

- union allTaskIds/noteIds across multiple concurrent marked deletes for
  the same project, so a single replacement cannot leave orphan tasks on
  clients that only receive it (the task reducer removes by allTaskIds).
- gate the classifier on the AUTHENTICATED payload projectId matching the
  plaintext entityId, so a tampered/replayed delete retargeted onto a live
  entity cannot silently drop a concurrent edit.
- guard a null/undefined delete payload in the classifier instead of
  throwing and wedging the conflict pass.
- pin the server's legacy-misc conflict alias to the fixed v1->v2 split
  boundary, not CURRENT_SCHEMA_VERSION, so this bump does not fabricate
  false GLOBAL_CONFIG:misc/tasks conflicts during rollout.
- bind the marker with a shared const (compiler-checked on producer and
  consumer) and rename _isArchivePlan -> _isWholeEntityWinPlan.

Documents the policy as ARCHITECTURE-DECISIONS.md #7.

Addresses #8997.
2026-07-14 19:58:33 +02:00
Johannes Millan
2864a39c85
fix(sync): file-based provider atomicity & conflict UX (#8960) (#9004)
* docs(plugins): add microsoft 365 calendar provider plan

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

* docs(ios): plan internal testflight builds

* fix(sync): prevent file-based sync data loss

Preserve post-snapshot operations and stage remote baselines until durable apply.

Use conditional writes and a resumable marker for provider concurrency and legacy migration. Report trustworthy remote timestamps and document transport limits.

Refs #8960

* fix(sync): avoid biased conflict recommendations

Highlight a side only when its timestamp or known change count is strictly greater, leaving unknown and tied metadata neutral.

* test(sync): assert options arg on split-file processRemoteOps

The split-file snapshot path now routes post-snapshot ops through
_processRemoteOpsWithStartupCleanup, which calls processRemoteOps with an
(empty) options object. Update the assertion to match the 2-arg call so the
unit suite passes.

* refactor(sync): dedupe strong-ETag regex, document snapshot-op boundary

- Extract the duplicated RFC 7232 strong-entity-tag pattern into a single
  STRONG_ETAG_RE constant with a comment noting why the char class is safe to
  interpolate into an If-Match header (no CR/LF header splitting).
- Explain why sv === undefined ops are classified as snapshot-included: they are
  legacy migration ops fully contained in the snapshot, and _validateSnapshotRef
  enforces a clock-EQUAL boundary. No behavior change.
2026-07-14 19:23:24 +02:00
Johannes Millan
91a1f0eda9
fix(tasks): keep REST project moves atomic across replay (#9001)
* fix(tasks): make REST project moves atomic

Capture affected subtasks in the persisted update action so project moves replay consistently, and repair stale project and section references during archive and restore.

Fixes #8983

* fix(tasks): harden project move replay

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-07-14 17:25:37 +02:00
Erick0412-dev
3ae2360345
feat: suppress idle dialog during active work sessions (#8965)
* @
feat: suppress idle dialog during focus mode sessions

Add a new setting (default: ON) to suppress the idle time dialog when a
focus mode session is actively running. The idle popup no longer
interrupts Pomodoro, Flowtime, or Countdown sessions.

Changes:
- Add isSuppressIdleDuringFocusMode field to IdleConfig
- Add checkbox to Settings → Time Tracking → Idle Handling
- Skip idle trigger in idle.effects.ts when focus session is active
- Add translations for all 28 languages

Closes: #1834
References: #1676
@

* feat: add opt-in setting to suppress idle dialog during work sessions

Address review feedback: narrow scope, default OFF, fix existing-user config merge.

Changes:
- default: isSuppressIdleDuringFocusMode changed from true to false (opt-in)
- reducer: deep-merge idle section on loadAllData so the new field defaults
  properly for users with persisted idle configs
- i18n: revert all non-English translation files, keep only en.json
- effects: fix Prettier/ESLint indentation
- tests: add reducer test for idle section merging + idle-effects spec
- docs: add setting to Settings-and-Preferences.md
Co-Authored-By: Claude <noreply@anthropic.com>

* fix: repair idle-effects test harness for CI

- Use ReplaySubject<void>(1) so onReady$ replay survives late subscription
- Provide LOCAL_ACTIONS token + provideMockActions
- Fix TS2740 type mismatch on chromeInterfaceMock.onReady$
Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-14 16:45:49 +02:00
Johannes Millan
b3d8c74257
fix(tasks): keep add-task bar visible above iOS keyboard (#8995)
* fix(tasks): avoid double-offsetting add-task bar on ios

Use the overlay-only keyboard offset on iOS because Capacitor's native resize mode already shrinks the WebView. Preserve the measured keyboard-height path for Android and other touch builds, with regression coverage for both selectors.

* fix(tasks): scope ios keyboard offset to touch devices

Keep hybrid iOS devices on the existing top-positioned layout while applying the overlay-only keyboard offset to touch-only iOS builds. Replace the stylesheet inspection with rendered layout coverage for resized, overlay, non-iOS, and hybrid cases.

* test(planner): reset mocked selectors after each test

Prevent the mocked task selector from leaking into later Jasmine specs and causing nondeterministic sync convergence failures.

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-07-14 15:39:43 +02:00
Johannes Millan
51bf689bd5
fix(sync): preserve multi-entity conflict recovery (#8990)
* chore: add project-scoped Angular MCP

* chore: update npm for release-age policy

* fix(sync): preserve LWW outcomes across clients

Distinguish replacement snapshots from partial merge operations, protect device-local sync configuration, recreate winning deletes, and resolve every conflicted entity in bulk operations.

Fixes #8956

* fix(sync): harden mixed LWW conflict replay

Preserve unaffected remote and local bulk intents, keep device-local sync settings during replacement replay, and gate replacement semantics behind schema v3.

* fix(sync): recover subtask subtree and harden LWW replay follow-ups

Follow-up hardening for #8956 after multi-agent review:

- Recreate a locally-winning parent's subtasks when a remote bulk delete
  is a mixed winner. The full remote delete is applied (cascade-deleting
  the parent's subtasks via handleDeleteTasks) but only the parent had a
  compensation op, so the subtree was silently lost across devices.
- extractUpdateChanges: scan array-valued payload props instead of guessing
  `${payloadKey}s`, so irregular bulk keys (e.g. taskUpdates) no longer
  return {} and drop a remote winner's changes.
- Degrade gracefully instead of throwing when a remote update wins over a
  local delete with no reconstructable base entity, matching the
  single-entity path (a permanent sync wedge is worse than the bounded
  divergence it already accepts).
- Remove dead code (unused `deleting` set + zero-caller wrapper), use the
  Set-based scoped-bulk-delete filter, type meta via LwwUpdateMode, and
  restore the withLocalOnlySyncSettings rationale comment.

Adds regression tests for the subtree-recovery and irregular-bulk-key paths.

* fix(sync): preserve conflict outcomes during replay

Persist replacement LWW operations and bulk-delete snapshots so reconstructed state matches the result applied live. Bump the op-log DB version to prevent older clients from opening the incompatible schema.

* fix(sync): persist conflict outcomes atomically

Write remote losers, local compensations, and final remote winners in one IndexedDB transaction so crashes cannot expose a partial replay order. Add real-store coverage for live/replay equivalence and transaction rollback.

* fix(sync): preserve multi-entity conflict recovery

* fix(sync): close review gaps in multi-entity conflict recovery

- recreate a winning parent's subtasks when a remote DELETE loses
  outright (single-entity or all-local-win bulk), so clients that
  applied the delete and status-blind hydration replay converge
- apply the combined resolution batch in durable seq order so a
  pending row reused from a prior failed attempt replays identically
  live and after a crash
- restamp converted remote updates carrying the v3 replacement
  envelope to the current schema version
- strip the virtual TODAY tag from LWW task payloads and shallow-merge
  patch-mode singleton payloads instead of replacing feature state
- pin the server snapshot fast-path spec to CURRENT_SCHEMA_VERSION
  (fixes the CI failure from the v2-to-v3 bump)

* test(sync): pin outright-losing delete convergence across clients

Three-way real-reducer/real-store convergence for the pure-loser path
(live == restart replay == originating client), covering both the
same-batch recreate exemption and the cross-batch recreate path.
Verified to fail against the pre-fix service.
2026-07-14 14:10:52 +02:00
Johannes Millan
40d9d845a9
fix(ui): open time picker for Electron touch input (#8989)
* fix(ui): open time picker for Electron touch input

Open the native time picker for touch activation in Electron while preserving mouse, keyboard, pen, and non-Electron behavior.

Closes #8986

* test(planner): reset mocked selectors after scheduling specs
2026-07-14 13:28:27 +02:00
Johannes Millan
519ea02167
fix(android): keep material icons aligned with system font scaling (#8992)
Counter-scale fixed-size font icons by the Android WebView text zoom while preserving accessible text and inline icon scaling. Covers Angular icons, raw Material Symbols, and shared pseudo-element icons.

Fixes #5694
2026-07-14 12:38:12 +02:00
Johannes Millan
ce8ed47328
fix(locale): localize ISO weekday labels (#8991) 2026-07-14 12:23:42 +02:00
Johannes Millan
44df8d1675
fix(sync): surface Dropbox OAuth service failures (#8988) 2026-07-14 11:39:49 +02:00
aakhter
a7a26588e6
fix(sync): keep the conflict summary banner counts live during review (#8946)
* fix(sync): keep the conflict summary banner counts live during review

The banner captured its counts when it opened; the sync-icon badge
updates live but an OPEN banner went stale while the user reviewed
entries on the sync-conflicts page (and lingered with a nonzero count
after everything was reviewed).

Refresh (or dismiss at zero) the banner on every unreviewed-count
change — but only while it is actually still shown, so a banner the
user dismissed is never resurrected by reviewing activity. Adds
BannerService.isShown(id) for that check.

SPAP-35 (deferred LOW from PR #8874 review)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore: retrigger CI (supersync crash-resume e2e flake)

* fix(sync): guard live-refresh banner against dismiss/reorder/phantom-zero races

Follow-up hardening to the SPAP-35 live-refresh banner, from an
independent review of the change. The refresh does an async journal
read between the "is the banner shown?" check and the open/dismiss that
follows, and that gap could misbehave three ways:

- Resurrection: the banner's own DISMISS button bypasses this service,
  so a dismiss landing mid-read left the post-read open() to resurrect
  the banner the user just closed. Re-check isShown() AFTER the read.
- Stale overwrite: bursts of count changes (e.g. "Keep All" marking
  every entry) start concurrent refreshes with no ordering guarantee,
  so a slow older read could reopen after the zero-count dismiss or
  show a stale count. Add a shared monotonic sequence guard across the
  open and refresh paths — last write wins.
- Phantom zero: ConflictJournalService.list() degrades to [] on a
  transient DB error, so a zero read while the count stream still
  reports >0 must not dismiss a valid banner.

Each guard has a regression test that fails if the guard is removed.
No change to the open-on-sync behavior.

SPAP-35

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(sync): make the live conflict banner mutation-aware and coalesced

Addresses maintainer review on #8946:
- Trigger the open-banner refresh on a journal REVISION (a new monotonic signal
  bumped on every mutation), not distinctUntilChanged on the unreviewed count, so
  an equal-total composition change (one remote-win reviewed while one local-win
  is recorded, total unchanged) still refreshes the 'X remote, Y local' breakdown.
- Coalesce mutation bursts (e.g. Keep All over the whole journal) into a single
  trailing refresh via auditTime, so a bulk review no longer fires one full
  journal scan per entry.
- Phantom-zero guard now reads the authoritative unreviewedCount signal.

Also fixes the rebase onto current master: #8945 migrated unreviewedCount$ to a
signal, so the banner's old Observable subscription no longer compiled.

Adds regression tests: an equal-total (1 remote -> 1 local) composition change
refreshes the breakdown (fails on the old count-keyed trigger), and a burst of
reviews coalesces to a single journal scan.

SPAP-35

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 10:55:17 +02:00
aakhter
5b4200df95
fix(sync): enforce conflict-journal retention mid-session, not only at start (#8948)
* fix(sync): enforce conflict-journal retention mid-session, not only at start

pruneOnStart (14 days / newest 200) ran only at startup, so a long-lived
session could grow SUP_CONFLICT_JOURNAL unboundedly. record() now checks
the store count (cheap) and, when it exceeds JOURNAL_MAX_ENTRIES plus a
slack of JOURNAL_PRUNE_SLACK, runs the same age+count prune — amortized
to once every JOURNAL_PRUNE_SLACK records. The prune core is extracted
and shared with pruneOnStart; the observe-only never-throw contract of
record() is unchanged.

SPAP-36 (deferred LOW from PR #8874 review)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(sync): await tx.done with the deletes in the conflict-journal prune

An aborted prune delete transaction rejects both the delete requests and
tx.done. Awaiting them separately (Promise.all(deletes), then await tx.done)
leaves tx.done's rejection unhandled once the delete aggregate rejects first, so
it escapes as a global unhandled rejection during active conflict resolution.
Await both in one Promise.all so tx.done always has a handler. Adds an
aborted-transaction regression test (fails on the old sequencing) and updates
the retention model comment + doc to reflect mid-session pruning.

Addresses maintainer review on #8948.

SPAP-36

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 10:26:10 +02:00
Johannes Millan
329da9b9f3
fix(sync): harden full-state operation recovery (#8973)
* fix(sync): prevent destructive full-state sync races

Use causal ordering and server-sequence preconditions for full-state operations, surface snapshot rejection, deduplicate migrations, and retain queued WebSocket downloads.\n\nFixes #8959

* fix(sync): block uploads after rejected imports

Keep incremental operations behind a durable barrier when a local import or restore is rejected. Release the barrier only after a newer full-state snapshot is accepted, and surface the blocked state instead of reporting sync success.

* fix(sync): harden causal repair recovery

Persist repair base cursors across the client, provider, and server so stale snapshots are rejected before quota or history mutation and legacy repairs cannot poison restore state.

Atomically rebase stale repairs after downloading the missing suffix, preserve rejected-import barriers and WebSocket watermarks, and cover PostgreSQL serialization.

* fix(sync): harden repair recovery edge cases

Block dependent uploads after any rejected full-state operation, reset negotiated capabilities across provider config changes, and bound WebSocket download retries.

Update sync test doubles for causal repairs and the expanded duplicate-operation identity.
2026-07-13 22:58:17 +02:00
Johannes Millan
24318d11cc
fix(sync): reject forged encrypted full-state operations (#8984)
* fix(sync): reject forged encrypted full-state operations

Validate decrypted import and repair payloads against the full-state schema so authenticated ordinary operations cannot be promoted into destructive full-state operations.

Closes #8905

* fix(sync): preserve compatible encrypted full-state payloads

Normalize only known wire and legacy omissions on the validation copy so stripped device-local settings and pre-section backups are not mistaken for metadata tampering.

* test(sync): cover encrypted full-state round trips
2026-07-13 22:57:56 +02:00
Johannes Millan
55d3490e19
fix(sync): preserve conflict cancellation and harden force overwrite (#8981)
* fix(sync): honor sync import conflict outcomes

Propagate nested conflict cancellation through rejected-op handling so it cannot trigger automatic merges or consume the retry budget.

Report force-local resolution failures when the clean-slate upload is blocked, rejected, or accepts no operations.

* fix(sync): harden force overwrite recovery

Verify the exact force-upload operation, preserve clean-slate retries, and roll back rejected replacements. Keep unresolved work retryable without reporting a successful sync.

* test(sync): cover clean-slate rollback in postgres
2026-07-13 21:54:07 +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
5e754d3552
fix(sync): prevent multi-entity conflict corruption (#8980)
* fix(sync): reject disjoint merges for multi-entity ops

Scope conflict field extraction and journal titles to the actual entity. Fall back to whole-op LWW whenever either side is multi-entity so sibling updates are never falsely reported as preserved.\n\nFixes #8944.

* fix(sync): harden multi-entity conflict resolution
2026-07-13 21:53:30 +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
5624f6891d
fix(sync): harden op-log replay and recovery (#8978)
* fix(sync): harden operation-log failure recovery

Keep compaction, archive replacement, legacy recovery, and reducer replay checkpointing deterministic across crashes and concurrent clients.

Refs #8958

* fix(sync): harden remote operation recovery

Persist reducer failures across hydration and fail closed for full-state operations. Serialize recovery and archive mutations, and report skipped emergency compaction accurately.

* fix(sync): preserve operations across replay failures
2026-07-13 20:03:42 +02:00
Johannes Millan
a9ed0b2df2
Merge pull request #8519 from archit-goyal/codex/caldav-empty-dropdown-guidance
Clarify empty CalDAV option loads
2026-07-13 12:46:55 +02:00
Johannes Millan
79e6a48ba6
Merge pull request #8949 from aakhter/SPAP-40-review-hardening
fix(sync): suppress the "Project updated" snack on conflict-review flips
2026-07-13 10:31:06 +02:00
Johannes Millan
88c62567cc
Merge pull request #8945 from aakhter/SPAP-37-unreviewed-count-signal
refactor(sync): migrate conflict-journal unreviewedCount to a Signal
2026-07-13 10:24:23 +02:00
Johannes Millan
8888739b87
Merge pull request #8947 from aakhter/SPAP-38-badge-a11y
fix(sync): bind empty string, not null, for the sync badge description at 0
2026-07-13 10:22:07 +02:00
Aamer Akhter
9e924e6e6e fix(sync): suppress the "Project updated" snack on conflict-review flips
PROJECT flips dispatch updateProject, whose snackUpdateBaseSettings$
effect popped an unconditional "Project updated" snack on top of the
flip flow's own outcome reporting. Add an optional isSkipSnack flag to
updateProject (mirroring updateTask's isIgnoreShortSyntax precedent)
and set it from the flip path.

Also adds a MockStore.resetSelectors() afterEach to the conflict-ui
spec: overrideSelector mutates the shared selector references, and the
new selectProjectById override otherwise leaks into project.service
specs in the same karma bundle.

SPAP-40 item 1 (from the PR #8874 independent review). Item 2 (clearAll
outside the op-log lock) is already fixed on master; item 3 (local-won
post-resolution stale baseline) is a documented follow-up and stays in
the ticket.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 12:48:25 -04:00
Aamer Akhter
f7318b1395 fix(sync): bind empty string, not null, for the sync badge description at 0
matBadgeDescription is a string input; binding null relied on
AriaDescriber.describe() silently no-op'ing on falsy values. Verified
hidden-state semantics: matBadgeHidden hides the badge via CSS only and
MatBadge keeps an aria description active regardless, so at count 0 the
description must be empty — '' is the type-correct value MatBadge
explicitly removes the aria-describedby for.

SPAP-38 (deferred LOW from PR #8874 review)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 15:45:00 -04:00
Aamer Akhter
9958e33b89 refactor(sync): migrate conflict-journal unreviewedCount to a Signal
The repo prefers Signals; unreviewedCount$ was a BehaviorSubject-backed
Observable whose only consumer immediately converted it with toSignal().
Expose a readonly signal directly instead.

SPAP-37

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 15:16:26 -04:00
Johannes Millan
ed0861be79 docs(sync): correct two overclaiming comments from the fix review
- The archive-mutex comment claimed every archive mutation is locked;
  compressArchive, the remote loadAllData import and the time-tracking
  cleanups still write outside it (tracked in #8941). Name them.
- The merge-journal comment described an impossible skipped-duplicate
  path (merged ids are fresh per run); describe the real accepted
  window instead: durable merge, crash before journaling, entry stays
  absent (observe-only log).
2026-07-11 21:05:56 +02:00
Johannes Millan
02c84df821 refactor(sync): apply multi-review cleanups
- Delete the unreachable stage-2 intra-batch duplicate pass: stage 1
  (validateAndClampBatch) reserves every op id including invalid first
  siblings, so validated candidates are unique by construction.
- Delete clearRawRebuildIncomplete: superseded by completeRawRebuild,
  which retires the marker atomically with the recovery token; only
  specs still called it.
- Clear the conflict journal when a USE_REMOTE rebuild completes — the
  documented "cleared whenever the full dataset is replaced" contract
  previously had a single caller (backup import), leaving stale badge
  counts and review entries describing replaced history.
- _notifyResolutionOutcome: drop the win-count parameters left over
  from the removed count snack and gate on resolutions.length.
- Snapshot handler: keep the clean-slate opId invariant local with a
  defense-in-depth 400 instead of relying on the contract superRefine
  in another package.
- Document that the legacy misc->tasks conflict alias only covers the
  per-entity path (GLOBAL_CONFIG writes are single-entity today).
2026-07-11 20:25:49 +02:00
Johannes Millan
867d84a3d1 fix(sync): serialize remote archive side effects behind the archive mutex
isIgnoreDBLock historically meant "sync already holds the op-log DB
lock", but _runTaskArchiveMutation also treated it as permission to skip
the new TASK_ARCHIVE mutex — so every remote archive side effect except
moveToArchive ran unserialized against locked local mutations, and a
concurrent read-modify-write could silently drop one side's archive
write (the exact race the mutex was added to close).

TASK_ARCHIVE is deliberately separate from OPERATION_LOG, so acquiring
it while sync holds the op-log lock is safe — the remote moveToArchive
path has always done exactly that. The mutex is now unconditional, and
the remote flushYoungToOld handler wraps its two-archive read-modify-
write in the same lock instead of writing through the adapter bare.
The isIgnoreDBLock option is retained as inert API surface; removing
the threading is tracked as a follow-up.
2026-07-11 20:21:00 +02:00
Johannes Millan
f572b6d077 fix(sync): persist disjoint merges atomically and exempt them from checkpoint
Review of the #8874 x #8900 merge seam found two defects in STEP 3b:

- The synthesized merged op rode in the apply batch, so the reducer
  checkpoint's pending-only assertion threw on it: every real disjoint
  auto-merge aborted the checkpoint transaction and wedged sync behind
  IncompleteRemoteOperationsError until app restart. Checkpoint now
  covers only pending-appended remote rows; synthetic local ops are
  exempt (their durability contract is the append + upload path).
- appendWithVectorClockUpdate replaced the durable vector clock with a
  clock computed only from the conflict's own ops, regressing the
  client counter and enabling silent cross-device op drops. Merge
  writes now go through appendMixedSourceBatchSkipDuplicates, which
  rebases the merged op on the durable clock in the same transaction —
  also closing the crash window between the remote originals and their
  superseding merged op, and turning duplicate re-appends into skips
  instead of ConstraintErrors.

Two regression tests enforce the coordinator's whole-batch reducer-
commit contract and the pending-only checkpoint against the resolution
flow; the journal keeps recording merges only after a durable append.
2026-07-11 20:14:35 +02:00
Johannes Millan
a026b2017f test(sync): mock the mixed-source batch port in the journal-hook spec
Same #8874-vintage store mock as the disjoint-merge spec: without the
atomic mixed-source batch method every local-wins flow through
autoResolveConflictsLWW threw before journaling.
2026-07-11 18:59:27 +02:00
Johannes Millan
fe544b1f79 test(sync): mock the mixed-source batch port in the disjoint-merge spec
The #8874 spec predates the atomic mixed-source batch and reducer
checkpoint on the store port; its two createSpyObj sites lacked the
methods, so every flow through the local-wins write path threw.
2026-07-11 18:55:51 +02:00
Johannes Millan
291995f343 Merge remote-tracking branch 'origin/master' into feat/sync-55222e
* origin/master: (25 commits)
  refactor(tasks): extract shared task ordering helpers (#8926)
  feat(sync): conflict journal + disjoint-field auto-merge + review UI (#8874)
  refactor(config): reduce duplicated form and selector boilerplate (#8928)
  feat(work-view): show break time today (#8909)
  feat(tasks): navigate from empty add-subtask input (#8916)
  docs(development): add instructions for setting up local tests with Chromium (#8887)
  chore(lint): remove unused eslint-disable directives (#8913)
  fix(accessibility): add ARIA roles, live regions, and alt attributes to banner component (#8888)
  chore(ui): removes dead utilities and op-log leftovers [#8260 - Tier A] (#8892)
  fix(app): hide donation page on macOS (#8915)
  chore(scripts): remove one-off codemod scripts [#8260 - Tier A] (#8893)
  fix(sync): harden SuperSync E2EE against metadata tampering (GHSA-8pxh-mgc7-gp3g) (#8904)
  feat: add Home Assistant Bridge to community plugins (#8891)
  docs(sync): note local-file rev-check/write is non-atomic (#8898) (#8902)
  18.14.0
  fix(tasks): remove postal-mime dep and harden eml import (#8901)
  style(tasks): elevate add-subtask input
  fix(config): restore day-start offset after operation replay (#8899)
  fix(task-repeat): allow selecting day-of-month recurrence (#8896)
  feat(plugins): add Todoist import plugin with Import/Export launcher (#8882)
  ...

# Conflicts:
#	docs/wiki/3.06-User-Data.md
#	src/app/op-log/backup/backup.service.spec.ts
#	src/app/op-log/backup/backup.service.ts
#	src/app/op-log/sync/conflict-resolution.service.ts
2026-07-11 18:50:32 +02:00
Johannes Millan
9d18d02f96 test(sync): spy the locked-internal archive paths, not the public wrappers
The lock-serialization refactor routes internal archive calls through
_updateTasks/_deleteTasks so a held sp_task_archive lock is never
re-acquired; five assertions still spied the public wrappers and never
fired.
2026-07-11 18:14:27 +02:00
Johannes Millan
982f2916ca fix(sync): serialize archive mutations and persist before dispatch
Remote archive side effects must be idempotent and immune to concurrent
read-modify-write races, or a retried operation can duplicate or drop
archive rows.

- TaskArchiveService serializes every archive mutation behind a
  dedicated sp_task_archive web lock (separate from OPERATION_LOG,
  which remote archive handlers already hold non-reentrantly).
- ArchiveService.moveTasksToArchiveAndFlushArchiveIfDue writes tasks
  with setMany over a deduplicated sorted id set, so a retry over a
  partially written archive converges instead of double-adding.
- TaskService._moveToArchive coalesces concurrent archive calls for the
  same ids and persists archive data before dispatching moveToArchive,
  so a full-state snapshot cannot acknowledge the op while its archive
  write is still in flight.
2026-07-11 18:00:55 +02:00
Johannes Millan
ffca0f1397 fix(sync): guard rebuild backup identity and keep undo across reload
The pre-replace import backup and the USE_REMOTE rebuild recovery
marker were matched by timestamp only, so a concurrent capture or a
reload between rebuild completion and snack dismissal could clear or
restore the wrong backup.

- Import backups carry an opaque backupId (uuidv7); clearImportBackup,
  replaceAllForRawRebuild and applyRemoteReplaceWithSnapshot verify the
  expected identity inside their transactions before acting.
- Completing a raw rebuild atomically replaces the incomplete marker
  with a durable raw_rebuild_recovery entry, so the "restore previous
  data" Undo survives a reload; StartupService re-offers it at boot and
  dismissal retires exactly the offered backup.
- SnackService treats a sticky recovery/update action as a single
  persistent slot: unrelated transient snacks no longer destroy a
  visible Undo, and dismissal awaits the snack's dismissFn.
2026-07-11 18:00:30 +02:00
Rushi
f9d65debe9
refactor(tasks): extract shared task ordering helpers (#8926)
* refactor(tasks): extract shared task ordering helpers

Extract getReorderedSubTaskIds (membership check + move shared by
moveSubTaskUp/Down/ToTop/ToBottom) and moveValidIdsToFront (shared by
removeTasksFromTodayTag and localRemoveOverdueFromToday) into pure,
tested utils. No behavior change.

Closes #7912

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(tasks): drop getReorderedSubTaskIds, inline check in reorderSubTask

Review feedback (#8926): the moveSubTask* actions already funnel through
the single reorderSubTask helper, so the extraction added no dedup value.
Keep moveValidIdsToFront, which removes real duplication.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 17:59:50 +02:00
Johannes Millan
bdb0fef9a9 fix(sync): make remote reducer checkpoint atomic with its clock merge
A committed reducer must never be durable without its vector clock, and
a merged clock must never be durable without its reducer checkpoint —
either mismatch lets the next local operation be causally older than
state already visible in NgRx after a crash.

- markArchivePending + separate mergeRemoteOpClocks are replaced by one
  markReducersCommittedAndMergeClocks transaction (ops + vector_clock);
  the clock math is extracted into a pure calculateRemoteClockMerge so
  the standalone merge path keeps identical full-state-reset semantics.
- The sync-core RemoteOperationApplyStorePort gains an
  onRemoteClocksDurable hook so deferred local actions drain exactly
  when clocks are durable, not merely when ops were applied; a
  checkpoint rejection can no longer mask the primary apply error.
- Conflict resolution's local-wins path writes remote losers and rebased
  local compensations in one appendMixedSourceBatchSkipDuplicates
  transaction, so synthetic ops cannot reuse or regress the client
  counter.
- DB_VERSION 7 -> 8 as a deliberate downgrade barrier: released v7
  readers only understand 'failed' and would silently overlook
  outstanding 'archive_pending' work.

op-log suite and sync-core suite cover the checkpoint rollback, atomic
clock merge, mixed-source ordering and drain failure matrix.
2026-07-11 17:59:42 +02:00
aakhter
962c5bbeb1
feat(sync): conflict journal + disjoint-field auto-merge + review UI (#8874)
* feat(sync): conflict-journal foundation (observe-only)

Add a device-local IndexedDB conflict journal that records every sync-
conflict auto-resolution so the discarded ("losing") side is preserved
and reviewable later. Foundation subtask for the conflict-review epic;
no UI here, verifiable purely by unit tests.

- New SUP_CONFLICT_JOURNAL IndexedDB store (own DB; never touches the
  op-log SUP_OPS schema) + ConflictJournalService with record/query API
  (unreviewedCount$, list, markKept, markFlipped, getEntry) and 14-day /
  200-entry retention pruning, wired to run on app start via APP_INITIALIZER.
- Pure classifier buildConflictJournalEntry maps each resolution to the
  agreed taxonomy (newer/tie/delete-wins/noise/clock-corruption-
  suspected; disjoint-merge reserved for the next subtask), capturing the
  loser's field values verbatim. NOISE_FIELDS is limited to metadata
  timestamps (modified/lastModified/created): the list-ordering arrays
  carry membership as well as order, so an overlap on them is surfaced as
  a reviewable conflict rather than silently classified as noise.
- Emission is strictly observe-only: journaling runs after the LWW plan is
  built, wrapped in try/catch (record() swallows its own errors); clock-
  corruption attribution uses a WeakSet side-channel tagged at detection.
  The existing conflict-resolution suite stays 138/138 green, proving LWW
  picks are unchanged. One-sided / sequential / EQUAL updates never become
  conflicts and produce zero journal entries.

SPAP-13

* feat(sync): disjoint-field auto-merge for concurrent edits

When two clients concurrently edit the same entity but different fields
(A changes title, B changes notes), whole-entity LWW previously discarded
one side. Keep both when the non-noise changed-field sets are disjoint.

In _resolveConflictsWithLWW, before LWW picks a winner, each CONCURRENT
conflict is tested for merge eligibility (neither side deleting/archiving;
both changed >=1 real field; non-noise field sets disjoint). If eligible,
synthesize a single merged UPDATE op — the current entity overlaid with
the other side's non-noise fields, noise fields resolved by the greater
(timestamp, clientId) — carrying a vector clock that dominates both sides,
so it propagates through normal sync. The resolution is winner 'merged'
and is journaled reason 'disjoint-merge' / status 'info' (not counted as
unreviewed). Any overlap on a non-noise field, or a delete/archive on
either side, falls through to the existing LWW path unchanged.

Convergence: both clients compute the byte-identical merged entity
(disjoint real fields each owned by one side; noise resolved by the same
global tiebreak) with clocks dominating both originals, so the two
independently-synthesized merged ops carry identical full-entity payloads
and re-resolve to the same state via ordinary LWW without re-merging.

No sync-core/protocol change. Existing conflict-resolution suite stays
138/138; SPAP-13 journal specs stay green.

SPAP-14

* feat(sync): sync conflicts review UI (banner, badge, page, flip)

Builds the conflict-review UI on top of the device-local conflict journal.

- Post-sync summary banner "N conflicts auto-resolved (X remote, Y local
  won)" with REVIEW / DISMISS, replacing the bare LWW_CONFLICTS_AUTO_
  RESOLVED snack at its emission sites; a persistent badge on the sync
  icon bound to unreviewedCount$ (survives banner dismiss).
- New /sync-conflicts page: Unreviewed | History tabs, rows grouped by
  entity type with winner + reason chips, expandable per-field diff
  (LOCAL vs REMOTE, device name + wall-clock time, winner marked),
  per-row KEEP / FLIP and bulk KEEP ALL / FLIP ALL → LOCAL / → REMOTE.
  History renders merged auto-merges as per-field chips.
- Flip dispatches a normal entity update with the loser's journaled field
  values (syncs like a user edit, no history rewind) and marks the entry
  flipped; a stale-flip confirm appears (with the current value shown)
  when the entity changed since resolution.
- i18n under F.SYNC.CONFLICT_REVIEW.

Delete-restore and archived-entity flip are surfaced as unsupported for
now (an update op can't recreate an absent entity) — follow-up.

SPAP-15

* fix(sync): count disjoint-merge ops in localWinOpsCreated

autoResolveConflictsLWW returned only newLocalWinOps.length, excluding the
synthesized merged ops appended in STEP 3b. A sync whose only conflicts were
disjoint-field merges returned 0, so the caller (immediate-upload.service.ts)
skipped the immediate re-upload and reported IN_SYNC while the merged op sat
unsynced until a later cycle.

Count mergedResolutions.length too — each merge appends exactly one pending
local op. Mirrors the rejection-handler path (operation-log-sync.service.ts).
Add a regression spec asserting a merge-only conflict returns
localWinOpsCreated: 1 (fails on the old return, passes now).

Also harden the _corruptionSuspectedConflicts WeakSet doc against a future
refactor that clones EntityConflict between detect and resolve.

Addresses review feedback on PR #8874.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(sync): address second-pass review (delete-lost, archive guard test, polish)

Second-pass review follow-ups (johannesjo). The MEDIUM localWinOpsCreated
item was already fixed in 23e9a56.

Important:
- delete-lost classification: when a delete LOSES to a concurrent newer edit,
  the loser side is a pure Delete op (no field changes), so it fell through to
  `noise`/`info` and never surfaced. Add a `delete-lost` reason classified as
  `unreviewed` (inverse of `delete-wins`), checked before the noise fallthrough.
  + regression spec.
- archive-vs-disjoint-edit test (d2): an archive is an UPDATE, so eligibility
  does NOT reject it — only the `_isArchivePlan` guard does. New test forces
  eligibility TRUE and asserts no merged op is synthesized, so a guard
  regression now fails a test (previously nothing covered it).

Minor:
- pruneOnStart: wrap in try/catch (log + return 0, matching record()'s
  observe-only contract); reset the poisoned `_initPromise` in `_ensureDb` on
  open failure so a transient IndexedDB error can't wedge the service.
- sync-conflicts-page.scss: use `--color-success` token + color-mix tint
  instead of hardcoded #4caf50 / rgba(76,175,80,…); drop the dead
  `--color-warning` fallback (#e6a817 didn't match the real #ff9800 token).
- main-header badge: matBadgeColor warn -> accent (a resolved count is not an
  error); move the count announcement to `matBadgeDescription` and give the
  button a stable sync-action aria-label (it was null at count 0, leaving the
  icon-only button unnamed).
- remove dead i18n key LWW_CONFLICTS_AUTO_RESOLVED (t.const.ts + en.json).
- add direct unit spec for noiseTiebreakSide (incl. equal-timestamp clientId
  determinism) and buildMergedFieldDiffs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(sync): address third-pass review (flip capability, field presence, opaque ops, profile isolation)

Blocking #3 — delete-lost/delete-wins offered a false-success Flip:
canFlip() is now reason/field-aware (refuses delete-lost/delete-wins,
empty loser changes, and relationship-bearing fields whose bare adapter
update would bypass meta-reducer invariants); flip() guards on it and
only marks an entry flipped when an op was actually dispatched.

Blocking #2 — field absent vs side-set-undefined: fieldDiffs now record
per-side presence (localChanged/remoteChanged); loserChangesFor/
winnerChangesFor only emit fields the side actually changed, so Flip no
longer clears winner-only fields and the stale guard no longer compares
undefined. Legacy entries without flags fall back to value-presence
(exact, since op payloads are pure JSON).

Blocking #1 — non-adapter action payloads: per-op extraction now falls
back to capture-time entityChanges (TIME_TRACKING/syncTimeSpent), and
ops with no readable delta (convertToSubTask & co) are "opaque": never
classified noise/info, preserved verbatim as kind:'action' diffs for
review, excluded from flip/stale computations, and never disjoint-merge
eligible (merging would drop the mutation and diverge the two clients).

Profile isolation — switchProfile clears the conflict journal
(ConflictJournalService.clearAll) so a new profile cannot see the
previous profile's entity data or Flip against the wrong dataset.

Also: replace the as-any selector cast with a typed union-member
narrowing, and add the required docs (sync-and-op-log/
conflict-journal-and-review.md incl. the atomicity/no-re-merge
contract, wiki 3.06 + 4.23).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(sync): address fourth-pass review (flip short-syntax, selector throws, restore isolation)

HIGH — a title-only flip matched shortSyntax$'s exact trigger shape, so
#tag/+project/@schedule tokens in the discarded title re-parsed into
cross-entity mutations: the TASK flip now dispatches with
isIgnoreShortSyntax: true (journaled literal value, not user input).

MEDIUM — selectTagById/selectNoteById THROW on a missing entity, so
expanding (or flipping) a TAG/NOTE row whose entity is gone rejected
unhandled before the !current guard: _readCurrentEntity now catches and
returns undefined, and getStaleState/flip degrade gracefully.

MEDIUM — the journal survived backup restores: clearAll() moved to the
BackupService.importCompleteBackup chokepoint, covering every full
dataset replacement (profile switch, JSON import, local-backup restore,
SuperSync restore) instead of only the profile switch.

LOW — ISSUE_PROVIDER's factory selectById is now special-cased (was
rendering the inner selector function as the "current" entity);
schedule/reminder fields (dueDay/dueWithTime/deadline*/reminderId) join
the flip blocklist (renamed FLIP_UNSAFE_FIELDS) since their invariants
live in dedicated flows; loser/winnerChangesFor early-return for merged
entries (their tiebreak diffs carry pickedSide, so the per-diff check
alone did not exclude them).

Docs updated accordingly (dev doc + wiki 3.06/4.23).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(sync): refuse flip for remindAt/deadlineRemindAt (reminder-lifecycle)

FLIP_UNSAFE_FIELDS is a deny-list: any Task field not listed is flippable
via a bare updateTask. reminderId was covered, but the sibling reminder-
lifecycle timestamps remindAt and deadlineRemindAt were not — a conflict on
either alone (e.g. concurrent deadline-reminder-lead edits) would pass
canFlip and, when flipped, write the field without scheduling/cancelling the
actual reminder in ReminderService, leaving a dangling/missing notification.

Add both to FLIP_UNSAFE_FIELDS, document the deny-list drift risk, and pin
the reminder/schedule members with per-field canFlip=false spec cases.

* fix(sync): make disjoint-merge convergent + close flip stale-guard blind spot

Two confirmed cross-device data defects found in the SPAP-14 whole-PR review:

1. Full-entity merged op diverges (CRITICAL). The merged op was a full-entity
   snapshot of each client's CURRENT state, so any field NEITHER conflicting
   side touched rode along. Under ordinary staggered sync (one client already
   applied a third device's edit to that field, the other not yet), the two
   clients synthesize different snapshots that tie under LWW at the identical
   max(timestamp) and diverge PERMANENTLY. Fix: synthesize a PARTIAL delta
   (union of the two sides' changed fields only), derived purely from the ops
   so both clients compute the byte-identical map; lwwUpdateMetaReducer applies
   it via updateOne (shallow merge), leaving untouched fields alone.
   synthesizeMergedEntity -> synthesizeMergedChanges.

   Also: detectConflicts emits one conflict per remote op with no per-entity
   aggregation, so an entity with >=2 concurrent remote ops synthesized multiple
   merged ops whose clocks dominate one another -> a dominated sibling's field
   is silently dropped, falsely journaled as 'kept both'. Fix: refuse merge for
   any entity with >1 conflict this batch and fall back to whole-entity LWW.

2. Flip stale-guard blind to loser-only fields (HIGH). getStaleState only
   compared WINNER-changed fields, but flip writes LOSER-changed fields. A
   post-resolution edit to a loser-only field was undetected and silently
   overwritten. Fix: also flag stale when a loser-only field flip will write
   diverged from the value flip would write; bulk flipAllToSide now SKIPS stale
   entries instead of silently applying them.

Adds regression specs for the un-conflicted-field ride-along, multi-remote-op
refusal, and loser-only stale detection (per-entry + bulk). Full conflict suite
green (disjoint-merge 12, ui 24, conflict-resolution 138, journal 20, hook 6,
util 14, review-util 13, superseded 42, banner 3, page 6).

* fix(sync): scope flip stale-guard loser-only check to remote-won entries

Re-review of efe66d7 found the new loser-only stale check false-positives on
LOCAL-won conflicts: there the loser is the REMOTE side, whose value was never
applied (current holds the un-recorded base), so current != flipVal is the
NORMAL post-resolution state, not an edit. That made flipAllToSide silently skip
legitimate local-won entries and single flip nag with a spurious confirm.

Scope loserOnlyStale to winner==='remote' (the only case where the loser's
optimistically-applied local value persists, giving a valid unedited baseline).
Remote-won silent-loss detection (the originally-reported defect) is unchanged.
Loser-only fields on LOCAL-won entries remain undetectable without a journaled
post-resolution baseline — documented as a follow-up. +2 regression specs
(local-won no-false-positive: getStaleState + bulk flip).

* fix(sync): restrict disjoint-merge to types with a RECREATE_FALLBACK

The merged op is a partial delta. If it wins over a concurrent DELETE on a
passive-observer client (one that already applied that delete), it reaches
lwwUpdateMetaReducer's addOne recreate branch WITHOUT passing through the
full-entity reconstruction in _convertToLWWUpdatesIfNeeded (that runs only for
conflict winners, not non-conflicting remote ops). For a type without a
RECREATE_FALLBACK (NOTE/METRIC/TASK_REPEAT_CFG/ISSUE_PROVIDER) the bare partial
addOne yields a Typia-invalid entity -> 'Repair failed' dead-end; the parent's
full-snapshot merged op recreated validly, so the partial delta is a regression
there.

Refuse disjoint-merge for fallback-less types (fall back to whole-entity LWW,
whose local-win op carries a full snapshot). Residual: fallback types can still
recreate with DEFAULT_* backfill diverging in that rare 3-device race — the same
bounded limitation already documented in recreate-fallback.const.ts. +regression
spec (NOTE disjoint conflict -> LWW, not merged).

* fix(sync): harden conflict-journal failure and lifecycle paths

Three hardening improvements from the final review pass:

1. Journal disjoint merges only AFTER the merged op is durably appended
   (STEP 3b), not at plan time. A 'merged' entry claims both sides were
   kept, which is only true once the op is persisted — an append failure
   could previously leave a phantom 'kept both' journal entry. +regression
   spec (a6): append failure -> no merged journal entry.

2. Extend the never-throw contract to ALL ConflictJournalService methods.
   list() is awaited (via maybeShowSummaryBanner) inside
   autoResolveConflictsLWW's notification step — i.e. after ops were
   already applied — so a transient IndexedDB failure there failed an
   otherwise-completed sync. list -> [], getEntry -> undefined,
   markKept/markFlipped swallowed (entry stays unreviewed, user retries).
   +spec.

3. Recover from abnormal IndexedDB closure: idb's terminated hook now
   drops the memoized _db/_initPromise handles so the next call reopens
   instead of failing on a dead connection for the rest of the session
   (deferred fourth-pass item). +spec.

Plus: reciprocal note in recreate-fallback.const.ts that membership also
opts a type into disjoint-merge, and doc updates for the new contracts.

* test(sync): pin the terminated-hook wiring in the journal recovery spec

The recovery spec called _resetDbHandles() directly, so removing the
terminated: callback from openDB (functionally reverting the force-close
recovery) kept all tests green. Fire the real 'close' event idb listens
for instead (duck-typed FakeEvent for fake-indexeddb) and assert the
handles were cleared. Mutation-verified: deleting the terminated line
now fails this spec.

* fix(sync): clear conflict journal inside the op-log lock on import

Author-review finding: importCompleteBackup released the OPERATION_LOG
lock before clearAll(), leaving a narrow cross-tab window where a
concurrent conflict resolution's fresh post-import journal entry gets
wiped. Clearing inside the lock serializes the clear strictly before any
post-import journaling. (_resetAllLastServerSeqs is journal-independent
local bookkeeping and keeps its persist-first ordering.)

Also documents the merged-op composition residual from the same review:
a merged partial op is not closed under later whole-op LWW composition
in the no-pending-local concurrent-apply path — verified pre-existing
(branch and behavior identical at the pre-SPAP-14 merge-base with plain
user ops), so recorded as a class-level op-log residual rather than
patched here.

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Johannes Millan <johannes.millan@gmail.com>
2026-07-11 17:48:46 +02:00
Rushi
830b7acefa
refactor(config): reduce duplicated form and selector boilerplate (#8928)
- sync-form: shared rootSyncProvider/isSyncProvider/isNotSyncProvider
  helpers replace ~25 repeated parent-depth syncProvider predicates
- keyboard-form: drop two dead commented-out field blocks
- global-config.reducer.spec: itBehavesLikeConfigSectionSelector helper
  replaces 10 duplicated describe blocks (same assertions)
- config-page: shared tab-label ng-template + isSectionExpanded()
  replace 6 duplicated template blocks

No behavior change.

Closes #7922

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 16:11:09 +02:00
J. Loops
7733c4a7e5
feat(work-view): show break time today (#8909) 2026-07-11 15:37:57 +02:00
Johannes Millan
f9610530c9 fix(sync): converge rebuild capture races, unwedge archive recovery
Remediate the findings of the 5-agent necessity review:

- USE_REMOTE: a local capture racing the locked rebuild now throws a
  typed CaptureRacedRebuildError, and forceDownloadRemoteState retries
  phase 2 in-call (bounded, 3 attempts) through the existing
  crash-resume branch — raced ops fold into preservedLocalOps and the
  already-downloaded history is reused (WS downloads and immediate
  uploads stay gated by the marker, so no re-download is needed).
  Previously every attempt aborted while e.g. time tracking dispatched
  continuously, re-downloading the full history per sync trigger and
  churning the Undo snack (now shown on final failure only).
- Hydrator archive retry: pass skipDeferredLocalActions and drain
  explicitly with a caught error. A drain throw from the coordinator's
  finally used to mask the archive result and escalate out of
  hydrateStore() into attemptRecovery(), which can import stale legacy
  data over a correctly hydrated store.
- Incomplete-remote gate: run one in-session archive-only retry when
  the only blockers are quarantined failed/archive_pending ops, so a
  transient archive failure self-heals on the next sync instead of
  wedging until app restart. Never attempted while the rebuild marker
  is set or reducer-uncommitted pending rows exist.
- Boot recovery: StartupService surfaces the pre-replace backup's
  persistent restore snack when a stranded raw_rebuild_incomplete
  marker is found, covering users who boot offline or disable sync
  after finding the app "emptied" by a mid-rebuild crash.
- Snack correctness: latch the USE_REMOTE newer-schema warning once per
  session; guard _notifyBlockedOp and the LWW apply-failure snack with
  hasPendingPersistentAction() so they cannot destroy a visible Undo;
  drop the useless "Update app" action for below-minimum data.
- Strings: MIGRATION_FAILED / VERSION_UNSUPPORTED now describe the
  blocking semantics instead of the removed skip behavior.
- Store: getPendingRemoteOps excludes rejected rows (parity with
  getFailedRemoteOps) so a rejected-but-pending row cannot trip the
  sync gate for a session.
- Server: compute the upload request fingerprint eagerly after the
  rate-limit gate — identical cost to the lazy closure in every path,
  minus the memoization machinery; laziness remains in the snapshot
  handler where it skips hashing multi-MB states.

op-log suite 3004/3004, super-sync-server 831/831, checkFile clean on
all touched files. Nine regression tests pin the new behavior.
2026-07-11 14:49:08 +02:00
Johannes Millan
b52087338e
feat(tasks): navigate from empty add-subtask input (#8916) 2026-07-11 13:33:35 +02:00
Lane Sawyer
5f79e0f7fe
chore(lint): remove unused eslint-disable directives (#8913)
These eslint-disable directives no longer suppress any reported
problems, so ESLint flags them as unused directive warnings. Remove
them to clear the 11 lint warnings.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 13:13:06 +02:00