From 3de60ca2e99f20b465778794231a3ea2a3ae8ddd Mon Sep 17 00:00:00 2001
From: oinsio <8233537+oinsio@users.noreply.github.com>
Date: Sat, 18 Jul 2026 18:13:36 +0500
Subject: [PATCH 1/8] add fix-stale-sync-overwrites OpenSpec
---
.../fix-stale-sync-overwrites/.openspec.yaml | 2 +
.../fix-stale-sync-overwrites/design.md | 91 ++++++++++++++
.../fix-stale-sync-overwrites/proposal.md | 114 +++++++++++++++++
.../specs/manual-task-hiding/spec.md | 51 ++++++++
.../specs/repeating-tasks/spec.md | 118 ++++++++++++++++++
.../specs/sync-protocol/spec.md | 52 ++++++++
.../specs/tasks/spec.md | 43 +++++++
.../fix-stale-sync-overwrites/tasks.md | 41 ++++++
8 files changed, 512 insertions(+)
create mode 100644 openspec/changes/fix-stale-sync-overwrites/.openspec.yaml
create mode 100644 openspec/changes/fix-stale-sync-overwrites/design.md
create mode 100644 openspec/changes/fix-stale-sync-overwrites/proposal.md
create mode 100644 openspec/changes/fix-stale-sync-overwrites/specs/manual-task-hiding/spec.md
create mode 100644 openspec/changes/fix-stale-sync-overwrites/specs/repeating-tasks/spec.md
create mode 100644 openspec/changes/fix-stale-sync-overwrites/specs/sync-protocol/spec.md
create mode 100644 openspec/changes/fix-stale-sync-overwrites/specs/tasks/spec.md
create mode 100644 openspec/changes/fix-stale-sync-overwrites/tasks.md
diff --git a/openspec/changes/fix-stale-sync-overwrites/.openspec.yaml b/openspec/changes/fix-stale-sync-overwrites/.openspec.yaml
new file mode 100644
index 00000000..0bd76e61
--- /dev/null
+++ b/openspec/changes/fix-stale-sync-overwrites/.openspec.yaml
@@ -0,0 +1,2 @@
+schema: spec-driven
+created: 2026-07-18
diff --git a/openspec/changes/fix-stale-sync-overwrites/design.md b/openspec/changes/fix-stale-sync-overwrites/design.md
new file mode 100644
index 00000000..8ce88a93
--- /dev/null
+++ b/openspec/changes/fix-stale-sync-overwrites/design.md
@@ -0,0 +1,91 @@
+# Design: fix-stale-sync-overwrites
+
+## Context
+
+Driven by FR1-FR6 from proposal. A confirmed multi-device bug: completing + editing a recurring task on device A, then opening a stale device B, resurrects the completed occurrence with the old description everywhere. The causal chain (verified in code):
+
+1. `HiddenTaskService.revealHiddenTasks()` refreshes `updated_at` + sets `pending` on device B's stale copy — a system mutation masquerading as a user edit.
+2. Binary pull protection (`TaskRepository.applyServerRecords`: skip any `pending` local) preserves the stale copy against the newer server state.
+3. Push LWW (`>=` client wins) lets the freshened stale copy overwrite the completed, newer record on the server.
+4. `RecurringTaskDeduplicator` keeps the earliest-`next_date` copy verbatim and soft-deletes the copy carrying the user's newest edits; the deletion propagates.
+
+`TaskService.rebalanceBox()` is a second instance of the same class: it freshens `updated_at` + `pending` for every task in a box (hidden recurring clones included — `getByBox` filters only `is_deleted`).
+
+Constraint: manual hide/unhide (`TaskQuickActions`, `TaskDetailsTab` → `TaskService.update`) are genuine user edits and must keep syncing exactly as today. Recurrence math (`calculateNextDate`) reads only `repeat_rule`, `completed_at`, previous `next_date`, and the clock — it never reads `updated_at`, so timestamp changes cannot affect either recurring model.
+
+## Goals / Non-Goals
+
+**Goals**
+- One enforced invariant: *system-initiated mutations never refresh `updated_at`; only user actions do.* They may still set `syncStatus: "pending"` to propagate.
+- Make pull protection LWW-consistent with push, so a future violation of the invariant degrades gracefully instead of corrupting data.
+- Dedup merges instead of discarding the newest content.
+
+**Non-Goals** (see proposal NG1-NG5): no server changes, no CRDT/field-level merge, no clock-skew handling, no UI changes.
+
+## Decisions
+
+### D1. Auto-reveal: `pending` without `updated_at` refresh (FR1)
+
+`revealHiddenTasks` writes `is_hidden: false, syncStatus: "pending"` and preserves the record's existing `updated_at`.
+
+Convergence: on push, if the server has no newer state, timestamps tie and the client wins (`>=`) — the reveal reaches the server. If the server is newer (real edit elsewhere), the server wins, returns `conflict`, and the client adopts the newest state. Either way LWW stays honest.
+
+*Alternatives rejected:*
+- **Derive `is_hidden` at read time, never sync it** — cannot express manual unhide before `appear_date` (a synced user intent) nor indefinite hides (`appear_date = ""`), both of which exist today.
+- **Don't sync reveal at all (local flip, stay `synced`)** — server keeps `is_hidden=true` forever; every pull of that record re-hides it, causing flicker and repeated writes.
+
+### D2. Rebalance: same invariant (FR4)
+
+`rebalanceBox` assigns keys + `pending` with timestamps untouched; `reorderTasks` keeps refreshing `updated_at` for the dragged task only.
+
+Consequence accepted: when a rebalanced record loses push LWW to a newer server edit, the box ends up with keys from two generations. Order stays deterministic; the next drag re-triggers rebalancing. This is cosmetic and rare (keys > 10 chars).
+
+*Alternatives rejected:*
+- **Local-only rebalance (no sync)** — the next real edit of any task would push its local key into a foreign key-grid on other devices → wandering order with no upside.
+- **Lazy per-task rebalance on next real edit** — rebalancing stops doing its job (shortening keys now); complexity without benefit.
+
+### D3. Dedup merge (FR3)
+
+Winner selection is unchanged (earliest `next_date`, tiebreak by `id`). The winner record is then merged:
+
+| Fields | Source copy |
+|----------------------------------------------------------------------|--------------------------------------------------|
+| `next_date`, `appear_date` (always as a pair) | earliest `next_date` |
+| `name`, `description`, `goal_id`, `context_id`, `category_id`, `box` | freshest `updated_at` |
+| `updated_at` | freshest copy's value — **not** refreshed to now |
+| `repeat_rule` differs between copies | freshest copy wins **wholesale**, dates included |
+
+Rationale for the wholesale clause: a rule change recomputes `next_date`/`appear_date` under the new rule (`useRepeatRuleChangeDialog`); pairing old-rule dates with a new rule is incoherent, especially across a `fixed` ↔ `after_completion` switch.
+
+If the merge changed the stored winner, it is written with `syncStatus: "pending"` (its `updated_at` is the freshest copy's — honest under LWW). Loser soft-deletion behaves as today. Checklist items: keep today's behavior (winner keeps its own items; losers' items are cascade-soft-deleted).
+
+### D4. Pull protection becomes LWW (FR5)
+
+In every repository's `applyServerRecords`: a local `pending` record is overwritten iff `server.updated_at > local.updated_at` (strict), then marked `synced`. Equal-or-newer local wins locally and pushes later — the mirror of the server's `>=` client-wins rule, so both sides agree on every ordering. Overwrites of pending records are logged (`console.warn` with entity type, id, both timestamps) per the existing "log conflicts for debugging" sync rule.
+
+No new data loss: a pending record older than the server copy already loses at push (`conflict` → server record applied); this only resolves the same outcome earlier and keeps UI from showing stale data between pull and push.
+
+Timestamp comparison uses `Temporal.Instant.compare`, not string comparison.
+
+### D5. Test architecture
+
+- **Unit BDD (vitest-cucumber)** per feature file listed in the proposal; steps drive real services over `fake-indexeddb` with `fakeClock`.
+- **Integration (`packages/integration`)**: two-device simulation = two isolated browser contexts (separate IndexedDB origins/storage states) against one real backend (Testcontainers). Scenarios: U1 (the reported bug end-to-end, both recurring models), U2 (manual unhide propagation), U3 (rebalance on stale device), plus NFR-REL1 convergence assertion (dump both devices' stores + server tables, compare).
+- **Mutation testing** scoped to changed files (max 5 per run, sequential).
+
+## Risks / Trade-offs
+
+- [Reveal push with old timestamp hits `>=` tie → client wins with an *unchanged* timestamp; server revision bumps but `updated_at` doesn't] → acceptable: content is identical except `is_hidden`; other devices reveal locally anyway.
+- [Pending record protected by equal timestamp could ping-pong between devices] → both devices produce identical records for tie cases (reveal/rebalance of the same base revision); pushes converge because content is equal.
+- [Two-generation sort keys after partial rebalance loss] → cosmetic, self-heals on next rebalance (D2).
+- [Clock skew between devices affects strict `>` pull comparison] → same exposure as existing push LWW; explicitly out of scope (NG3).
+- [Dedup merge writes a record whose field combination existed on no device] → limited to content-vs-schedule split with the pair rule and wholesale rule (D3); integration tests assert coherence.
+- [`applyServerRecords` changes touch every entity repository] → shared helper + contract-style test suite run against each repository to avoid divergence.
+
+## Migration Plan
+
+Client-only, no schema or server changes. Ships as one release; no data migration. Rollback = revert. Records already corrupted by the old behavior are not retroactively repaired (out of scope); the fix prevents new corruption.
+
+## Open Questions
+
+(none)
diff --git a/openspec/changes/fix-stale-sync-overwrites/proposal.md b/openspec/changes/fix-stale-sync-overwrites/proposal.md
new file mode 100644
index 00000000..c78aa4b0
--- /dev/null
+++ b/openspec/changes/fix-stale-sync-overwrites/proposal.md
@@ -0,0 +1,114 @@
+# fix-stale-sync-overwrites
+
+## Why
+
+A multi-device user loses data on recurring tasks: a device that was offline for a while resurrects a completed recurring task with a stale description and deletes the fresh copy on all other devices. Root cause: system-initiated mutations (auto-reveal of hidden tasks, sort-order rebalancing, post-pull deduplication) refresh `updated_at` and set `syncStatus: "pending"` as if the user had edited the record. Under whole-record last-write-wins this makes stale records "newer" than real user edits, and the binary pull protection (`pending` → never overwrite) lets them survive until push, where they win the conflict.
+
+## What Changes
+
+- **MODIFIED**: Auto-reveal of hidden tasks (`HiddenTaskService`) marks records `pending` but no longer refreshes `updated_at` — the timestamp stays at the last real user edit.
+- **MODIFIED**: Sort-order rebalancing (`TaskService.rebalanceBox`) marks rebalanced records `pending` but no longer refreshes their `updated_at`; only the task the user actually dragged gets a fresh timestamp.
+- **MODIFIED**: Recurring-copy deduplication after pull merges instead of blindly keeping the earliest copy: schedule fields (`next_date` + `appear_date`, always as a pair) come from the copy with the earliest `next_date`, content fields from the copy with the freshest `updated_at`; if `repeat_rule` differs between copies, the freshest copy wins wholesale.
+- **MODIFIED**: Pull protection becomes LWW-consistent: a server record overwrites a local `pending` record when the server's `updated_at` is strictly newer; ties and older server records preserve the local record. Conflicts are logged.
+- **UNCHANGED (guarded by tests)**: Manual hide and manual unhide-before-appear-date remain real user edits — they refresh `updated_at`, sync to other devices, and must keep working for both recurring and non-recurring tasks.
+- Specs gain mermaid sequence diagrams for every multi-device sync scenario covered by this change.
+
+## Capabilities
+
+### New Capabilities
+
+(none)
+
+### Modified Capabilities
+
+- `sync-protocol`: "Pull protects local dirty records" changes from status-based (never overwrite `pending`) to LWW-based (overwrite `pending` only when server `updated_at` is strictly newer).
+- `repeating-tasks`: "System reveals hidden tasks when appear date arrives" no longer refreshes `updated_at`; "System deduplicates recurring copies after pull" changes from keep-earliest-copy to merge (earliest schedule pair + the freshest content, freshest-wholesale on `repeat_rule` mismatch).
+- `manual-task-hiding`: "Hidden tasks auto-reveal on appear date" no longer refreshes `updated_at`; new requirement that manual unhide before the appear date is a synced user edit visible on other devices.
+- `tasks`: "Lazy rebalancing when key exceeds threshold" — rebalanced tasks are marked `pending` without an `updated_at` refresh.
+
+## Goals
+
+- G1: The reported scenario (stale device with an auto-revealed recurring copy) converges to the newest user content on all devices — no resurrected completions, no stale descriptions, no deleted fresh copies.
+- G2: One invariant across the codebase: system-initiated mutations never refresh `updated_at`; only user actions do.
+- G3: Both recurring models (`fixed` schedule and `after_completion`) behave identically before and after the change in single-device flows.
+
+## Non-Goals
+
+- NG1: No server-side changes — conflict resolution on push (`>=` client wins) stays as is.
+- NG2: No field-level merge / CRDT — conflict granularity remains whole-record LWW.
+- NG3: No clock-skew mitigation between devices.
+- NG4: No change to the dedup winner-by-earliest-`next_date` schedule semantics or to skip logic.
+- NG5: No UI changes — screens, controls, and UX of hiding/revealing tasks stay untouched.
+
+## Users & Scenarios
+
+- U1: Multi-device user with recurring tasks. Completes and edits a recurring task on device A; device B was offline since before the edits. After device B comes online, both devices show the task with the newest description and correct completion state.
+- U2: User who manually hides a task with a future appear date, then manually unhides it early on one device. The task becomes visible on all devices after sync.
+- U3: User who drags a task in a large box triggering rebalancing on a stale device. Other tasks in the box do not lose newer content from other devices.
+
+## Requirements
+
+### Functional
+
+- FR1: `HiddenTaskService.revealHiddenTasks()` SHALL set `syncStatus: "pending"` and `is_hidden: false` without modifying `updated_at`. If the record is already `pending`, its state is not degraded.
+- FR2: Manual hide and manual unhide SHALL remain regular user updates: refresh `updated_at`, set `pending`, clear `appear_date` on unhide — and SHALL propagate to other devices via ordinary push/pull.
+- FR3: `RecurringTaskDeduplicator` SHALL merge duplicate groups: winner record keeps `next_date` and `appear_date` (as a pair) from the copy with the earliest `next_date` (tiebreak by `id`), and content fields (`name`, `description`, `goal_id`, `context_id`, `category_id`, `box`) from the copy with the freshest `updated_at`. When `repeat_rule` differs between copies, the copy with the freshest `updated_at` SHALL win wholesale (all fields including dates). Losers are soft-deleted as today. The merged winner's `updated_at` SHALL be the freshest copy's `updated_at` (not refreshed to now).
+- FR4: `TaskService.rebalanceBox()` SHALL assign new `sort_order` keys and `syncStatus: "pending"` without modifying `updated_at`. `reorderTasks()` SHALL keep refreshing `updated_at` only for the dragged task.
+- FR5: `applyServerRecords()` in every entity repository SHALL overwrite a local `pending` record when the server record's `updated_at` is strictly newer than the local one, setting `syncStatus: "synced"`. When local `updated_at` is equal or newer, the local record SHALL be preserved. Each overwrite of a `pending` record SHALL be logged with entity type, id, and both timestamps.
+- FR6: Behavior of both recurring models SHALL be preserved: `after_completion` next date derives only from the new `completed_at`; `fixed` keeps early-completion preservation and skip logic. (Regression guard — no recurrence math changes.)
+
+### Non-Functional
+
+#### Reliability
+
+- NFR-REL1: For every scenario in this change's feature files, after both devices complete a push+pull cycle, IndexedDB state on both devices and the server state SHALL be identical (convergence), verified by integration tests.
+
+#### Performance
+
+- Not applicable — no new queries or loops beyond the existing dedup pass.
+
+#### Accessibility
+
+- Not applicable — no UI changes.
+
+#### Responsive
+
+- Not applicable — no UI changes.
+
+## UX Acceptance Criteria
+
+- UX1: The user never observes a completed recurring task returning to active state after a sync.
+- UX2: The user never observes a task description reverting to an older version after a sync.
+- UX3: A manually unhidden task appears on other devices after their next sync.
+
+## UI States Matrix
+
+No UI changes — existing loading/error/empty/offline states are unaffected.
+
+## Behavior
+
+Scenarios live in feature files tagged `@fix-stale-sync-overwrites @FR-X`:
+
+- `packages/client/src/test/features/sync_protocol/stale_reveal_sync.feature` — FR1, FR2 (unit BDD, vitest-cucumber)
+- `packages/client/src/test/features/repeating_tasks/dedup_merge.feature` — FR3, FR6 (unit BDD)
+- `packages/client/src/test/features/sync_protocol/rebalance_sync.feature` — FR4 (unit BDD)
+- `packages/client/src/test/features/sync_protocol/pull_lww_protection.feature` — FR5 (unit BDD)
+- Integration tests (`packages/integration`) simulate two devices against a real backend for the U1/U2/U3 scenarios and the NFR-REL1 convergence check.
+
+## Visual Reference
+
+Not applicable — no UI changes.
+
+## Affected IA
+
+No changes.
+
+## Success Metrics
+
+- M1: The reported two-device scenario, reproduced as an integration test, passes: final description on both devices equals the latest user edit, and the completed occurrence stays completed.
+- M2: Zero `pending` records whose `updated_at` was written by a code path other than a user action (verified by unit tests on reveal, rebalance, dedup).
+- M3: Mutation score on changed files >= 95% (minimum acceptable 90%).
+
+## Open Questions
+
+(none — tie semantics, clock skew, and server behavior were explicitly scoped out in NG1-NG3)
diff --git a/openspec/changes/fix-stale-sync-overwrites/specs/manual-task-hiding/spec.md b/openspec/changes/fix-stale-sync-overwrites/specs/manual-task-hiding/spec.md
new file mode 100644
index 00000000..7f86db88
--- /dev/null
+++ b/openspec/changes/fix-stale-sync-overwrites/specs/manual-task-hiding/spec.md
@@ -0,0 +1,51 @@
+# Delta: manual-task-hiding
+
+## MODIFIED Requirements
+
+### Requirement: Hidden tasks auto-reveal on appear date
+# implements FR1 of fix-stale-sync-overwrites (was: FR7, FR8 of hide-tasks)
+
+The system SHALL automatically set `is_hidden = false` when `appear_date <= logicalDate`. The task SHALL remain in its original box. Auto-reveal SHALL set `syncStatus = "pending"` but SHALL NOT modify `updated_at` — it is a system-derived transition, not a user edit; the timestamp stays at the last real user edit so a stale device cannot win last-write-wins against newer edits from another device.
+
+#### Scenario: Task revealed when appear date arrives
+- **WHEN** the logical date reaches or passes the task's `appear_date`
+- **THEN** `HiddenTaskService.revealHiddenTasks()` sets `is_hidden = false`
+- **AND** the task remains in its original box
+- **AND** the task's `updated_at` is unchanged and `syncStatus` is `"pending"`
+
+## ADDED Requirements
+
+### Requirement: Manual unhide before appear date is a synced user edit
+# implements FR2 of fix-stale-sync-overwrites
+
+When the user manually unhides a task before its `appear_date`, the system SHALL treat it as a regular user edit: set `is_hidden = false`, clear `appear_date`, refresh `updated_at`, and set `syncStatus = "pending"`. The manual unhide SHALL propagate to other devices via ordinary push/pull and SHALL win last-write-wins against any older state of the record. Manual hide (setting `is_hidden = true` with an `appear_date`) SHALL behave symmetrically as a user edit.
+
+```mermaid
+sequenceDiagram
+ participant A as Device A
+ participant S as Server
+ participant B as Device B
+
+ Note over A: task hidden until 07-20 (both devices in sync)
+ Note over A: user manually unhides on 07-15
is_hidden=false, appear_date="",
updated_at=now, pending
+ A->>S: push (accepted, new revision)
+ B->>S: pull
+ S-->>B: task: is_hidden=false, appear_date=""
+ Note over B: local copy synced & older → overwritten
task visible on device B before 07-20
+ Note over B: reveal check ignores it
(appear_date empty)
+```
+
+#### Scenario: Manual unhide propagates to another device
+- **GIVEN** a task hidden until "2026-07-20" synced on devices A and B
+- **WHEN** the user manually unhides it on device A on "2026-07-15" and both devices sync
+- **THEN** the task is visible on device B with `appear_date = ""` before "2026-07-20"
+
+#### Scenario: Manual unhide refreshes the timestamp
+- **GIVEN** a hidden task with `updated_at = t1`
+- **WHEN** the user manually unhides it
+- **THEN** `is_hidden = false`, `appear_date = ""`, `updated_at > t1`, `syncStatus = "pending"`
+
+#### Scenario: Manual hide propagates to another device
+- **GIVEN** a visible task synced on devices A and B
+- **WHEN** the user hides it until "2026-08-01" on device A and both devices sync
+- **THEN** the task is hidden on device B with `appear_date = "2026-08-01"`
diff --git a/openspec/changes/fix-stale-sync-overwrites/specs/repeating-tasks/spec.md b/openspec/changes/fix-stale-sync-overwrites/specs/repeating-tasks/spec.md
new file mode 100644
index 00000000..488a1c43
--- /dev/null
+++ b/openspec/changes/fix-stale-sync-overwrites/specs/repeating-tasks/spec.md
@@ -0,0 +1,118 @@
+# Delta: repeating-tasks
+
+## MODIFIED Requirements
+
+### Requirement: System reveals hidden tasks when appear date arrives
+# implements FR1 of fix-stale-sync-overwrites (was: FR11 of repeating-tasks-specs, FR4 of day-boundary)
+
+System MUST reveal hidden tasks (set is_hidden=false, syncStatus="pending") when appear_date <= logical date. Reveal MUST NOT modify `updated_at` — auto-reveal is a system-derived transition, not a user edit, and the record's timestamp SHALL remain that of the last real user edit so that last-write-wins conflict resolution cannot prefer a stale record over newer edits from another device. Reveal MUST be triggered on: app mount, day boundary transition (instead of midnight), sync_complete event, return from background (visibility change), and day boundary setting change.
+
+#### Scenario: Reveal tasks whose appear_date has arrived
+- **GIVEN** hidden task with appear_date "2026-01-15" and logical date is "2026-01-15"
+- **WHEN** system runs reveal check
+- **THEN** task has is_hidden false, syncStatus "pending"
+- **AND** the task's `updated_at` is unchanged
+
+#### Scenario: Do not reveal tasks whose appear_date is future
+- **GIVEN** hidden task with appear_date "2026-01-20" and logical date is "2026-01-15"
+- **WHEN** system runs reveal check
+- **THEN** task remains hidden
+
+#### Scenario: Reveal triggered on app mount
+- **WHEN** app mounts
+- **THEN** system runs reveal check with current logical date
+
+#### Scenario: Reveal triggered at day boundary
+- **WHEN** clock passes the configured day boundary time
+- **THEN** system runs reveal check with new logical date
+
+#### Scenario: Reveal of a stale copy loses push conflict to newer server state
+- **GIVEN** device B holds a recurring copy last edited at t2 that was completed on device A at t5 (t5 > t2)
+- **WHEN** device B auto-reveals the copy and pushes it with `updated_at = t2`
+- **THEN** the server responds `conflict` and device B applies the server record (completed, newest content)
+
+### Requirement: System deduplicates recurring copies after pull
+# implements FR3, FR6 of fix-stale-sync-overwrites (was: FR1, FR2 of dedup-recurring-after-pull)
+
+After applying a pull batch, the system SHALL detect duplicate recurring copies — multiple non-completed, non-deleted tasks sharing the same `original_task_id`. Among duplicates, the system SHALL keep the winner by earliest `next_date`, tiebreak by lexicographically smallest `id`. Losers SHALL be soft-deleted with `syncStatus: "pending"`.
+
+The winner SHALL be merged, not kept verbatim:
+
+- Schedule fields `next_date` and `appear_date` SHALL be taken **as a pair** from the copy with the earliest `next_date` (they are coupled via `advance_days`; mixing copies would corrupt reveal timing).
+- Content fields (`name`, `description`, `goal_id`, `context_id`, `category_id`, `box`) SHALL be taken from the copy with the freshest `updated_at`.
+- The merged winner's `updated_at` SHALL equal the freshest copy's `updated_at`; deduplication SHALL NOT refresh it to the current time.
+- When `repeat_rule` differs between copies, the copy with the freshest `updated_at` SHALL win wholesale (all fields including `next_date` and `appear_date`), because a rule change recomputes its dates under the new rule.
+
+Merge semantics apply identically to both recurring models (`fixed` and `after_completion`); the earliest-`next_date` winner rule for schedules is unchanged, and for `after_completion` any suboptimal winner self-corrects at the next completion because its next date derives only from the new `completed_at`.
+
+Two-device double completion of the same occurrence (fixed schedule):
+
+```mermaid
+sequenceDiagram
+ participant A as Device A
+ participant S as Server
+ participant B as Device B
+
+ Note over A: completes occurrence,
edits description ("v2")
clone A1: next_date=07-10, updated_at=t5
+ Note over B: completes same occurrence offline
clone B1: next_date=07-09, updated_at=t3
+ A->>S: push A1
+ B->>S: push B1
+ B->>S: pull
+ S-->>B: A1
+ Note over B: dedup group {A1, B1}
winner dates: B1 (next_date 07-09)
winner content: A1 ("v2", updated_at t5)
loser A1 soft-deleted
+ B->>S: push merged winner + deletion
+ A->>S: pull
+ S-->>A: merged copy: earliest date, newest content
+```
+
+Two-device double completion (`after_completion`, delay 3 days):
+
+```mermaid
+sequenceDiagram
+ participant A as Device A
+ participant S as Server
+ participant B as Device B
+
+ Note over A: completes at 07-01
clone A1: next_date=07-04
+ Note over B: completes at 07-02 offline
clone B1: next_date=07-05
+ A->>S: push A1
+ B->>S: push B1, then pull
+ S-->>B: A1
+ Note over B: dedup: winner dates from A1 (07-04),
content from freshest updated_at
+ Note over B: at next completion next_date is recomputed
from new completed_at — self-corrects
+```
+
+#### Scenario: Two duplicates with same next_date — tiebreak by id
+- **GIVEN** task Copy-A (id="aaa...", original_task_id="root", next_date="2026-07-01") and Copy-B (id="bbb...", original_task_id="root", next_date="2026-07-01"), both non-completed, non-deleted
+- **WHEN** deduplication runs after pull
+- **THEN** Copy-A is kept (smaller id) and Copy-B is soft-deleted
+
+#### Scenario: Two duplicates with different next_date — earlier wins
+- **GIVEN** task Copy-A (original_task_id="root", next_date="2026-07-05") and Copy-B (original_task_id="root", next_date="2026-07-01"), both non-completed, non-deleted
+- **WHEN** deduplication runs after pull
+- **THEN** Copy-B is kept (earlier next_date) and Copy-A is soft-deleted
+
+#### Scenario: Winner takes content from the freshest copy
+- **GIVEN** Copy-A (next_date="2026-07-05", description="v2", updated_at="2026-07-02T10:00:00.000Z") and Copy-B (next_date="2026-07-01", description="v1", updated_at="2026-07-01T10:00:00.000Z"), same original_task_id
+- **WHEN** deduplication runs after pull
+- **THEN** the kept record has next_date "2026-07-01", appear_date from Copy-B, description "v2", and updated_at "2026-07-02T10:00:00.000Z"
+
+#### Scenario: Dates always move as a pair
+- **GIVEN** duplicates where the earliest-next_date copy has appear_date derived from its own next_date
+- **WHEN** deduplication merges the winner
+- **THEN** next_date and appear_date both come from the earliest-next_date copy (never mixed across copies)
+
+#### Scenario: Differing repeat_rule — freshest copy wins wholesale
+- **GIVEN** Copy-A (repeat_rule=daily-1, next_date="2026-07-01", updated_at older) and Copy-B (repeat_rule=weekly-Mon, next_date="2026-07-06", updated_at newer), same original_task_id
+- **WHEN** deduplication runs after pull
+- **THEN** the kept record equals Copy-B in all fields including next_date and appear_date
+
+#### Scenario: No duplicates — no action
+- **GIVEN** only one non-completed, non-deleted task per original_task_id
+- **WHEN** deduplication runs after pull
+- **THEN** no tasks are modified
+
+#### Scenario: Completed copies are excluded from dedup
+- **GIVEN** Copy-A (original_task_id="root", is_completed=true) and Copy-B (original_task_id="root", is_completed=false)
+- **WHEN** deduplication runs after pull
+- **THEN** no deduplication occurs (only one non-completed copy exists)
diff --git a/openspec/changes/fix-stale-sync-overwrites/specs/sync-protocol/spec.md b/openspec/changes/fix-stale-sync-overwrites/specs/sync-protocol/spec.md
new file mode 100644
index 00000000..1cf45331
--- /dev/null
+++ b/openspec/changes/fix-stale-sync-overwrites/specs/sync-protocol/spec.md
@@ -0,0 +1,52 @@
+# Delta: sync-protocol
+
+## MODIFIED Requirements
+
+### Requirement: Pull protects local dirty records
+# implements FR5 of fix-stale-sync-overwrites
+
+When applying server records from pull, the client SHALL compare timestamps for records that have `syncStatus = "pending"`: the server record SHALL overwrite the local pending record only when the server record's `updated_at` is strictly newer than the local `updated_at` (last-write-wins, mirroring push conflict resolution). When the local `updated_at` is equal or newer, the local pending record SHALL be preserved and pushed in the next sync. Records with `syncStatus = "synced"` or `"rejected"` SHALL be overwritten by the server version as before. Every overwrite of a pending record SHALL be logged with entity type, record id, and both timestamps.
+
+Multi-device convergence for the stale-device scenario this rule fixes (device B held a stale pending copy freshened by a system mutation):
+
+```mermaid
+sequenceDiagram
+ participant A as Device A
+ participant S as Server
+ participant B as Device B (stale)
+
+ A->>S: push: task edited + completed (updated_at = t5)
+ Note over B: auto-reveal marks task pending
WITHOUT refreshing updated_at (stays t2)
+ B->>S: push: pending task (updated_at = t2)
+ S-->>B: conflict (server updated_at t5 > t2)
+ B->>B: apply server_record, syncStatus = synced
+ B->>S: pull
+ S-->>B: records with revision > last_known
+ Note over B: pull LWW: server t5 > local t2
pending record would also be overwritten here
+ A->>S: pull
+ S-->>A: no stale resurrection — state converged
+```
+
+#### Scenario: Clean local record is overwritten by server
+- **WHEN** pull returns a record that exists locally with `syncStatus = "synced"`
+- **THEN** local record is overwritten with server version
+
+#### Scenario: Pending local record loses to strictly newer server record
+- **GIVEN** a local record with `syncStatus = "pending"` and `updated_at = "2026-07-01T10:00:00.000Z"`
+- **WHEN** pull returns the same record with `updated_at = "2026-07-02T10:00:00.000Z"`
+- **THEN** local record is overwritten with the server version and `syncStatus` becomes `"synced"`
+- **AND** the overwrite is logged with entity type, id, and both timestamps
+
+#### Scenario: Pending local record with equal timestamp is preserved
+- **GIVEN** a local record with `syncStatus = "pending"` and `updated_at = "2026-07-01T10:00:00.000Z"`
+- **WHEN** pull returns the same record with `updated_at = "2026-07-01T10:00:00.000Z"`
+- **THEN** local record is NOT overwritten (will be pushed in next sync)
+
+#### Scenario: Pending local record with newer timestamp is preserved
+- **GIVEN** a local record with `syncStatus = "pending"` and `updated_at = "2026-07-03T10:00:00.000Z"`
+- **WHEN** pull returns the same record with `updated_at = "2026-07-02T10:00:00.000Z"`
+- **THEN** local record is NOT overwritten (will be pushed in next sync)
+
+#### Scenario: New server record is inserted
+- **WHEN** pull returns a record that does not exist locally
+- **THEN** record is inserted into IndexedDB
diff --git a/openspec/changes/fix-stale-sync-overwrites/specs/tasks/spec.md b/openspec/changes/fix-stale-sync-overwrites/specs/tasks/spec.md
new file mode 100644
index 00000000..f0b74808
--- /dev/null
+++ b/openspec/changes/fix-stale-sync-overwrites/specs/tasks/spec.md
@@ -0,0 +1,43 @@
+# Delta: tasks
+
+## MODIFIED Requirements
+
+### Requirement: Lazy rebalancing when key exceeds threshold
+# implements FR4 of fix-stale-sync-overwrites (was: FR9 of fractional-sort-order)
+
+When a newly generated sort_order key exceeds 10 characters, the system MUST rebalance all tasks in the same box with evenly distributed keys preserving current display order. Rebalanced tasks SHALL be marked `syncStatus = "pending"` but their `updated_at` SHALL NOT be modified — rebalancing is a system-derived mutation, and refreshing the timestamps would let a stale device overwrite newer content of every task in the box via whole-record last-write-wins. Only the task the user actually dragged SHALL get a refreshed `updated_at` (in `reorderTasks`).
+
+Rebalance on a stale device no longer clobbers newer content of untouched tasks:
+
+```mermaid
+sequenceDiagram
+ participant A as Device A
+ participant S as Server
+ participant B as Device B (stale)
+
+ A->>S: push: task X edited (updated_at = t5)
+ Note over B: user drags task Y →
rebalance touches whole box
Y: updated_at=now,
others (incl. X): timestamps kept
+ B->>S: push: Y (fresh) + rebalanced records (old timestamps)
+ S-->>B: Y accepted, X → conflict (server t5 newer)
+ B->>B: apply server X (newest content, server sort key)
+ B->>S: pull
+ Note over A,B: X keeps its newest content everywhere,
new sort keys sync where no newer edit exists
+```
+
+#### Scenario: Rebalancing triggered by long key
+- **GIVEN** today box has tasks with deeply nested sort_order keys
+- **WHEN** a drag-drop produces a key longer than 10 characters
+- **THEN** all tasks in today box get fresh evenly distributed keys
+- **AND** display order is preserved
+- **AND** all rebalanced tasks are marked syncStatus="pending"
+- **AND** `updated_at` of every task except the dragged one is unchanged
+
+#### Scenario: Rebalancing not triggered for short keys
+- **GIVEN** today box has tasks with short sort_order keys
+- **WHEN** a drag-drop produces a key of 4 characters
+- **THEN** only the dragged task is updated
+
+#### Scenario: Rebalance on a stale device does not clobber newer content
+- **GIVEN** device B holds task X last synced at t2, while the server has X edited at t5 (t5 > t2)
+- **WHEN** device B rebalances the box (X gets a new sort key, `updated_at` stays t2) and pushes
+- **THEN** the server responds `conflict` for X and device B applies the server version with the newest content
diff --git a/openspec/changes/fix-stale-sync-overwrites/tasks.md b/openspec/changes/fix-stale-sync-overwrites/tasks.md
new file mode 100644
index 00000000..e97cb1c6
--- /dev/null
+++ b/openspec/changes/fix-stale-sync-overwrites/tasks.md
@@ -0,0 +1,41 @@
+# Tasks: fix-stale-sync-overwrites
+
+## 1. Auto-reveal preserves updated_at (FR1, FR2)
+
+- [ ] 1.1 RED: unit tests for `HiddenTaskService.revealHiddenTasks` — revealed task keeps its `updated_at`, gets `syncStatus: "pending"`; already-`pending` record is not degraded (FR1)
+- [ ] 1.2 GREEN: change `HiddenTaskService` to stop refreshing `updated_at` on reveal (FR1)
+- [ ] 1.3 Characterization tests: manual hide and manual unhide via `TaskService.update` refresh `updated_at`, clear `appear_date` on unhide, set `pending` (FR2 regression guard)
+- [ ] 1.4 BDD unit: `stale_reveal_sync.feature` + steps — reveal-without-bump scenarios and manual hide/unhide sync scenarios, tags `@fix-stale-sync-overwrites @FR1` / `@FR2`
+
+## 2. Rebalance preserves updated_at (FR4)
+
+- [ ] 2.1 RED: unit tests for `TaskService.rebalanceBox` — rebalanced tasks keep `updated_at`, get `pending`; dragged task in `reorderTasks` still gets fresh `updated_at` (FR4)
+- [ ] 2.2 GREEN: change `rebalanceBox` to stop refreshing `updated_at` (FR4)
+- [ ] 2.3 BDD unit: `rebalance_sync.feature` + steps, tags `@fix-stale-sync-overwrites @FR4`
+
+## 3. Dedup merge (FR3, FR6)
+
+- [ ] 3.1 RED: unit tests for `RecurringTaskDeduplicator` merge — content from freshest `updated_at`, `next_date`+`appear_date` as a pair from earliest, winner `updated_at` = freshest copy's value (not now), wholesale-freshest on `repeat_rule` mismatch, existing winner/tiebreak/exclusion behavior unchanged (FR3)
+- [ ] 3.2 GREEN: implement merge in `RecurringTaskDeduplicator` (FR3); keep file under 200 lines — extract merge logic into a separate module if needed
+- [ ] 3.3 Regression tests: recurrence math untouched for both models — `fixed` early-completion + skip logic, `after_completion` derives from new `completed_at` only (FR6, reuse existing suites as characterization)
+- [ ] 3.4 BDD unit: `dedup_merge.feature` + steps — two-device double completion for `fixed` and `after_completion`, tags `@fix-stale-sync-overwrites @FR3` / `@FR6`
+
+## 4. LWW pull protection (FR5)
+
+- [ ] 4.1 RED: contract-style test suite for `applyServerRecords` (shared cases: synced overwritten; pending vs strictly-newer server → overwritten + logged; pending vs equal → preserved; pending vs older → preserved; new record inserted) run against every entity repository (FR5)
+- [ ] 4.2 GREEN: shared helper (e.g. `applyServerRecordLww`) using `Temporal.Instant.compare` + `console.warn` conflict log; wire into all entity repositories (FR5)
+- [ ] 4.3 BDD unit: `pull_lww_protection.feature` + steps, tags `@fix-stale-sync-overwrites @FR5`
+
+## 5. Integration tests (packages/integration) (NFR-REL1, M1)
+
+- [ ] 5.1 Two-device harness: two isolated browser contexts (separate storage) against one backend; helper to dump and diff device stores + server tables for convergence assertions (NFR-REL1)
+- [ ] 5.2 Integration: reported bug end-to-end for `fixed` model — complete/edit/complete on A, stale B reopens, both converge to the newest description and completed state (U1, M1, FR1+FR3+FR5)
+- [ ] 5.3 Integration: same scenario for `after_completion` model (U1, FR6)
+- [ ] 5.4 Integration: manual unhide before appear_date propagates to device B (U2, FR2)
+- [ ] 5.5 Integration: rebalance on stale device B does not clobber newer content of untouched tasks (U3, FR4)
+
+## 6. Verification
+
+- [ ] 6.1 Mutation testing scoped to changed files (max 5 per run, sequential): `HiddenTaskService.ts`, `TaskService.ts`, `RecurringTaskDeduplicator.ts`, LWW helper — score >= 95% (min 90%), kill survivors with added tests (M3)
+- [ ] 6.2 `get_file_problems` via JetBrains MCP on all changed files + `pnpm run build`
+- [ ] 6.3 Traceability check: grep confirms every FR1-FR6, NFR-REL1, UX1-UX3 has at least one implementing test/artifact reference
From c65e5d44c41d24ea25962e6c0a18b96844ca5853 Mon Sep 17 00:00:00 2001
From: oinsio <8233537+oinsio@users.noreply.github.com>
Date: Sat, 18 Jul 2026 19:03:56 +0500
Subject: [PATCH 2/8] add fix-stale-sync-overwrites OpenSpec
---
.../fix-stale-sync-overwrites/design.md | 16 +++--
.../fix-stale-sync-overwrites/proposal.md | 6 +-
.../specs/manual-task-hiding/spec.md | 6 +-
.../specs/repeating-tasks/spec.md | 36 +++++++++---
.../specs/sync-protocol/spec.md | 2 +-
.../specs/tasks/spec.md | 2 +-
.../fix-stale-sync-overwrites/tasks.md | 58 ++++++++++++-------
7 files changed, 82 insertions(+), 44 deletions(-)
diff --git a/openspec/changes/fix-stale-sync-overwrites/design.md b/openspec/changes/fix-stale-sync-overwrites/design.md
index 8ce88a93..2fd80b9f 100644
--- a/openspec/changes/fix-stale-sync-overwrites/design.md
+++ b/openspec/changes/fix-stale-sync-overwrites/design.md
@@ -48,12 +48,16 @@ Consequence accepted: when a rebalanced record loses push LWW to a newer server
Winner selection is unchanged (earliest `next_date`, tiebreak by `id`). The winner record is then merged:
-| Fields | Source copy |
-|----------------------------------------------------------------------|--------------------------------------------------|
-| `next_date`, `appear_date` (always as a pair) | earliest `next_date` |
-| `name`, `description`, `goal_id`, `context_id`, `category_id`, `box` | freshest `updated_at` |
-| `updated_at` | freshest copy's value — **not** refreshed to now |
-| `repeat_rule` differs between copies | freshest copy wins **wholesale**, dates included |
+| Fields | Source copy |
+|-------------------------------------------------------------------|--------------------------------------------------|
+| `next_date`, `appear_date`, `is_hidden` (always as a triple) | earliest `next_date` |
+| `name`, `description`, `goal_id`, `context_id`, `category_id` | freshest `updated_at` |
+| `box`, `sort_order` (as a pair — sort keys are per-box grids) | freshest `updated_at` |
+| `id`, `created_at`, `revision` (identity/bookkeeping) | winner's own |
+| `updated_at` | freshest copy's value — **not** refreshed to now |
+| `repeat_rule` differs between copies | freshest copy wins **wholesale**, dates included |
+
+`is_hidden` travels with the schedule triple because it is derived from that copy's `appear_date` (hidden iff not yet due); pairing it with another copy's dates would break reveal timing. `sort_order` travels with `box` because sort keys only order within a box. Completion fields need no rule: every copy in a duplicate group is non-completed by the group filter.
Rationale for the wholesale clause: a rule change recomputes `next_date`/`appear_date` under the new rule (`useRepeatRuleChangeDialog`); pairing old-rule dates with a new rule is incoherent, especially across a `fixed` ↔ `after_completion` switch.
diff --git a/openspec/changes/fix-stale-sync-overwrites/proposal.md b/openspec/changes/fix-stale-sync-overwrites/proposal.md
index c78aa4b0..4a2429b4 100644
--- a/openspec/changes/fix-stale-sync-overwrites/proposal.md
+++ b/openspec/changes/fix-stale-sync-overwrites/proposal.md
@@ -10,7 +10,7 @@ A multi-device user loses data on recurring tasks: a device that was offline for
- **MODIFIED**: Sort-order rebalancing (`TaskService.rebalanceBox`) marks rebalanced records `pending` but no longer refreshes their `updated_at`; only the task the user actually dragged gets a fresh timestamp.
- **MODIFIED**: Recurring-copy deduplication after pull merges instead of blindly keeping the earliest copy: schedule fields (`next_date` + `appear_date`, always as a pair) come from the copy with the earliest `next_date`, content fields from the copy with the freshest `updated_at`; if `repeat_rule` differs between copies, the freshest copy wins wholesale.
- **MODIFIED**: Pull protection becomes LWW-consistent: a server record overwrites a local `pending` record when the server's `updated_at` is strictly newer; ties and older server records preserve the local record. Conflicts are logged.
-- **UNCHANGED (guarded by tests)**: Manual hide and manual unhide-before-appear-date remain real user edits — they refresh `updated_at`, sync to other devices, and must keep working for both recurring and non-recurring tasks.
+- **UNCHANGED (guarded by tests)**: Manual hide and manual unhide-before-appear-date remain real user edits — they refresh `updated_at` and sync to other devices. They apply to non-recurring tasks only (recurring tasks expose no manual hide/unhide controls per `manual-task-hiding`); hidden recurring copies are governed solely by auto-reveal (FR1).
- Specs gain mermaid sequence diagrams for every multi-device sync scenario covered by this change.
## Capabilities
@@ -51,8 +51,8 @@ A multi-device user loses data on recurring tasks: a device that was offline for
### Functional
- FR1: `HiddenTaskService.revealHiddenTasks()` SHALL set `syncStatus: "pending"` and `is_hidden: false` without modifying `updated_at`. If the record is already `pending`, its state is not degraded.
-- FR2: Manual hide and manual unhide SHALL remain regular user updates: refresh `updated_at`, set `pending`, clear `appear_date` on unhide — and SHALL propagate to other devices via ordinary push/pull.
-- FR3: `RecurringTaskDeduplicator` SHALL merge duplicate groups: winner record keeps `next_date` and `appear_date` (as a pair) from the copy with the earliest `next_date` (tiebreak by `id`), and content fields (`name`, `description`, `goal_id`, `context_id`, `category_id`, `box`) from the copy with the freshest `updated_at`. When `repeat_rule` differs between copies, the copy with the freshest `updated_at` SHALL win wholesale (all fields including dates). Losers are soft-deleted as today. The merged winner's `updated_at` SHALL be the freshest copy's `updated_at` (not refreshed to now).
+- FR2: Manual hide and manual unhide (non-recurring tasks — the only kind with manual hide/unhide controls) SHALL remain regular user updates: refresh `updated_at`, set `pending`, clear `appear_date` on unhide — and SHALL propagate to other devices via ordinary push/pull.
+- FR3: `RecurringTaskDeduplicator` SHALL merge duplicate groups: the winner record keeps `next_date`, `appear_date`, and `is_hidden` (always as a schedule triple — `is_hidden` is derived from `appear_date`) from the copy with the earliest `next_date` (tiebreak by `id`); content fields (`name`, `description`, `goal_id`, `context_id`, `category_id`) and the pair `box` + `sort_order` (sort keys are per-box) from the copy with the freshest `updated_at`; identity fields (`id`, `created_at`, `revision`) stay the winner's own. When `repeat_rule` differs between copies, the copy with the freshest `updated_at` SHALL win wholesale (all fields including dates). Losers are soft-deleted as today. The merged winner's `updated_at` SHALL be the freshest copy's `updated_at` (not refreshed to now), and the winner SHALL be written with `syncStatus: "pending"` only when the merge actually changed it.
- FR4: `TaskService.rebalanceBox()` SHALL assign new `sort_order` keys and `syncStatus: "pending"` without modifying `updated_at`. `reorderTasks()` SHALL keep refreshing `updated_at` only for the dragged task.
- FR5: `applyServerRecords()` in every entity repository SHALL overwrite a local `pending` record when the server record's `updated_at` is strictly newer than the local one, setting `syncStatus: "synced"`. When local `updated_at` is equal or newer, the local record SHALL be preserved. Each overwrite of a `pending` record SHALL be logged with entity type, id, and both timestamps.
- FR6: Behavior of both recurring models SHALL be preserved: `after_completion` next date derives only from the new `completed_at`; `fixed` keeps early-completion preservation and skip logic. (Regression guard — no recurrence math changes.)
diff --git a/openspec/changes/fix-stale-sync-overwrites/specs/manual-task-hiding/spec.md b/openspec/changes/fix-stale-sync-overwrites/specs/manual-task-hiding/spec.md
index 7f86db88..e2f54a41 100644
--- a/openspec/changes/fix-stale-sync-overwrites/specs/manual-task-hiding/spec.md
+++ b/openspec/changes/fix-stale-sync-overwrites/specs/manual-task-hiding/spec.md
@@ -3,7 +3,7 @@
## MODIFIED Requirements
### Requirement: Hidden tasks auto-reveal on appear date
-# implements FR1 of fix-stale-sync-overwrites (was: FR7, FR8 of hide-tasks)
+Implements FR1 of fix-stale-sync-overwrites (was: FR7, FR8 of hide-tasks).
The system SHALL automatically set `is_hidden = false` when `appear_date <= logicalDate`. The task SHALL remain in its original box. Auto-reveal SHALL set `syncStatus = "pending"` but SHALL NOT modify `updated_at` — it is a system-derived transition, not a user edit; the timestamp stays at the last real user edit so a stale device cannot win last-write-wins against newer edits from another device.
@@ -16,9 +16,9 @@ The system SHALL automatically set `is_hidden = false` when `appear_date <= logi
## ADDED Requirements
### Requirement: Manual unhide before appear date is a synced user edit
-# implements FR2 of fix-stale-sync-overwrites
+Implements FR2 of fix-stale-sync-overwrites.
-When the user manually unhides a task before its `appear_date`, the system SHALL treat it as a regular user edit: set `is_hidden = false`, clear `appear_date`, refresh `updated_at`, and set `syncStatus = "pending"`. The manual unhide SHALL propagate to other devices via ordinary push/pull and SHALL win last-write-wins against any older state of the record. Manual hide (setting `is_hidden = true` with an `appear_date`) SHALL behave symmetrically as a user edit.
+When the user manually unhides a manually hidden task before its `appear_date`, the system SHALL treat it as a regular user edit: set `is_hidden = false`, clear `appear_date`, refresh `updated_at`, and set `syncStatus = "pending"`. The manual unhide SHALL propagate to other devices via ordinary push/pull and SHALL win last-write-wins against any older state of the record. Manual hide (setting `is_hidden = true` with an `appear_date`) SHALL behave symmetrically as a user edit. This applies to non-recurring tasks only — recurring tasks expose no manual hide/unhide controls (see "Hide action excluded for recurring tasks"); hidden recurring copies are governed solely by system auto-reveal.
```mermaid
sequenceDiagram
diff --git a/openspec/changes/fix-stale-sync-overwrites/specs/repeating-tasks/spec.md b/openspec/changes/fix-stale-sync-overwrites/specs/repeating-tasks/spec.md
index 488a1c43..1e9433d7 100644
--- a/openspec/changes/fix-stale-sync-overwrites/specs/repeating-tasks/spec.md
+++ b/openspec/changes/fix-stale-sync-overwrites/specs/repeating-tasks/spec.md
@@ -3,7 +3,7 @@
## MODIFIED Requirements
### Requirement: System reveals hidden tasks when appear date arrives
-# implements FR1 of fix-stale-sync-overwrites (was: FR11 of repeating-tasks-specs, FR4 of day-boundary)
+Implements FR1 of fix-stale-sync-overwrites (was: FR11 of repeating-tasks-specs, FR4 of day-boundary).
System MUST reveal hidden tasks (set is_hidden=false, syncStatus="pending") when appear_date <= logical date. Reveal MUST NOT modify `updated_at` — auto-reveal is a system-derived transition, not a user edit, and the record's timestamp SHALL remain that of the last real user edit so that last-write-wins conflict resolution cannot prefer a stale record over newer edits from another device. Reveal MUST be triggered on: app mount, day boundary transition (instead of midnight), sync_complete event, return from background (visibility change), and day boundary setting change.
@@ -26,21 +26,26 @@ System MUST reveal hidden tasks (set is_hidden=false, syncStatus="pending") when
- **WHEN** clock passes the configured day boundary time
- **THEN** system runs reveal check with new logical date
+#### Scenario: Reveal triggered on day boundary change
+- **WHEN** user changes the day boundary setting
+- **THEN** system immediately runs reveal check with recalculated logical date
+
#### Scenario: Reveal of a stale copy loses push conflict to newer server state
- **GIVEN** device B holds a recurring copy last edited at t2 that was completed on device A at t5 (t5 > t2)
- **WHEN** device B auto-reveals the copy and pushes it with `updated_at = t2`
- **THEN** the server responds `conflict` and device B applies the server record (completed, newest content)
### Requirement: System deduplicates recurring copies after pull
-# implements FR3, FR6 of fix-stale-sync-overwrites (was: FR1, FR2 of dedup-recurring-after-pull)
+Implements FR3, FR6 of fix-stale-sync-overwrites (was: FR1, FR2 of dedup-recurring-after-pull).
After applying a pull batch, the system SHALL detect duplicate recurring copies — multiple non-completed, non-deleted tasks sharing the same `original_task_id`. Among duplicates, the system SHALL keep the winner by earliest `next_date`, tiebreak by lexicographically smallest `id`. Losers SHALL be soft-deleted with `syncStatus: "pending"`.
The winner SHALL be merged, not kept verbatim:
-- Schedule fields `next_date` and `appear_date` SHALL be taken **as a pair** from the copy with the earliest `next_date` (they are coupled via `advance_days`; mixing copies would corrupt reveal timing).
-- Content fields (`name`, `description`, `goal_id`, `context_id`, `category_id`, `box`) SHALL be taken from the copy with the freshest `updated_at`.
-- The merged winner's `updated_at` SHALL equal the freshest copy's `updated_at`; deduplication SHALL NOT refresh it to the current time.
+- Schedule fields `next_date`, `appear_date`, and `is_hidden` SHALL be taken **as a triple** from the copy with the earliest `next_date` (`appear_date` is coupled to `next_date` via `advance_days`, and `is_hidden` is derived from `appear_date`; mixing copies would corrupt reveal timing).
+- Content fields (`name`, `description`, `goal_id`, `context_id`, `category_id`) and the pair `box` + `sort_order` (sort keys only order within a box) SHALL be taken from the copy with the freshest `updated_at`.
+- Identity fields (`id`, `created_at`, `revision`) SHALL stay the winner's own. Completion fields need no merge rule: every copy in a duplicate group is non-completed by the group filter.
+- The merged winner's `updated_at` SHALL equal the freshest copy's `updated_at`; deduplication SHALL NOT refresh it to the current time. The winner SHALL be written with `syncStatus: "pending"` only when the merge actually changed it; an already-optimal winner is not rewritten.
- When `repeat_rule` differs between copies, the copy with the freshest `updated_at` SHALL win wholesale (all fields including `next_date` and `appear_date`), because a rule change recomputes its dates under the new rule.
Merge semantics apply identically to both recurring models (`fixed` and `after_completion`); the earliest-`next_date` winner rule for schedules is unchanged, and for `after_completion` any suboptimal winner self-corrects at the next completion because its next date derives only from the new `completed_at`.
@@ -97,10 +102,25 @@ sequenceDiagram
- **WHEN** deduplication runs after pull
- **THEN** the kept record has next_date "2026-07-01", appear_date from Copy-B, description "v2", and updated_at "2026-07-02T10:00:00.000Z"
-#### Scenario: Dates always move as a pair
-- **GIVEN** duplicates where the earliest-next_date copy has appear_date derived from its own next_date
+#### Scenario: Schedule fields always move as a triple
+- **GIVEN** duplicates where the earliest-next_date copy has appear_date derived from its own next_date and is_hidden derived from that appear_date
- **WHEN** deduplication merges the winner
-- **THEN** next_date and appear_date both come from the earliest-next_date copy (never mixed across copies)
+- **THEN** next_date, appear_date, and is_hidden all come from the earliest-next_date copy (never mixed across copies)
+
+#### Scenario: Hidden and revealed copies merge without breaking reveal timing
+- **GIVEN** Copy-A revealed (is_hidden=false, next_date="2026-07-09", updated_at older) and Copy-B still hidden (is_hidden=true, next_date="2026-07-12", appear_date="2026-07-10", updated_at newer), same original_task_id and same repeat_rule
+- **WHEN** deduplication runs after pull
+- **THEN** the kept record has next_date "2026-07-09" and is_hidden false (from Copy-A) with content from Copy-B
+
+#### Scenario: Box and sort_order come from the freshest copy as a pair
+- **GIVEN** duplicates in different boxes with box-specific sort_order keys
+- **WHEN** deduplication merges the winner
+- **THEN** box and sort_order both come from the freshest-updated_at copy (never mixed across copies)
+
+#### Scenario: Already-optimal winner is not rewritten
+- **GIVEN** duplicates where the winner already carries both the earliest schedule triple and the freshest content, `syncStatus = "synced"`
+- **WHEN** deduplication runs after pull
+- **THEN** the winner record is not modified and keeps `syncStatus = "synced"`, while losers are soft-deleted
#### Scenario: Differing repeat_rule — freshest copy wins wholesale
- **GIVEN** Copy-A (repeat_rule=daily-1, next_date="2026-07-01", updated_at older) and Copy-B (repeat_rule=weekly-Mon, next_date="2026-07-06", updated_at newer), same original_task_id
diff --git a/openspec/changes/fix-stale-sync-overwrites/specs/sync-protocol/spec.md b/openspec/changes/fix-stale-sync-overwrites/specs/sync-protocol/spec.md
index 1cf45331..22e0504d 100644
--- a/openspec/changes/fix-stale-sync-overwrites/specs/sync-protocol/spec.md
+++ b/openspec/changes/fix-stale-sync-overwrites/specs/sync-protocol/spec.md
@@ -3,7 +3,7 @@
## MODIFIED Requirements
### Requirement: Pull protects local dirty records
-# implements FR5 of fix-stale-sync-overwrites
+Implements FR5 of fix-stale-sync-overwrites.
When applying server records from pull, the client SHALL compare timestamps for records that have `syncStatus = "pending"`: the server record SHALL overwrite the local pending record only when the server record's `updated_at` is strictly newer than the local `updated_at` (last-write-wins, mirroring push conflict resolution). When the local `updated_at` is equal or newer, the local pending record SHALL be preserved and pushed in the next sync. Records with `syncStatus = "synced"` or `"rejected"` SHALL be overwritten by the server version as before. Every overwrite of a pending record SHALL be logged with entity type, record id, and both timestamps.
diff --git a/openspec/changes/fix-stale-sync-overwrites/specs/tasks/spec.md b/openspec/changes/fix-stale-sync-overwrites/specs/tasks/spec.md
index f0b74808..d7e3743e 100644
--- a/openspec/changes/fix-stale-sync-overwrites/specs/tasks/spec.md
+++ b/openspec/changes/fix-stale-sync-overwrites/specs/tasks/spec.md
@@ -3,7 +3,7 @@
## MODIFIED Requirements
### Requirement: Lazy rebalancing when key exceeds threshold
-# implements FR4 of fix-stale-sync-overwrites (was: FR9 of fractional-sort-order)
+Implements FR4 of fix-stale-sync-overwrites (was: FR9 of fractional-sort-order).
When a newly generated sort_order key exceeds 10 characters, the system MUST rebalance all tasks in the same box with evenly distributed keys preserving current display order. Rebalanced tasks SHALL be marked `syncStatus = "pending"` but their `updated_at` SHALL NOT be modified — rebalancing is a system-derived mutation, and refreshing the timestamps would let a stale device overwrite newer content of every task in the box via whole-record last-write-wins. Only the task the user actually dragged SHALL get a refreshed `updated_at` (in `reorderTasks`).
diff --git a/openspec/changes/fix-stale-sync-overwrites/tasks.md b/openspec/changes/fix-stale-sync-overwrites/tasks.md
index e97cb1c6..1099f943 100644
--- a/openspec/changes/fix-stale-sync-overwrites/tasks.md
+++ b/openspec/changes/fix-stale-sync-overwrites/tasks.md
@@ -2,40 +2,54 @@
## 1. Auto-reveal preserves updated_at (FR1, FR2)
-- [ ] 1.1 RED: unit tests for `HiddenTaskService.revealHiddenTasks` — revealed task keeps its `updated_at`, gets `syncStatus: "pending"`; already-`pending` record is not degraded (FR1)
-- [ ] 1.2 GREEN: change `HiddenTaskService` to stop refreshing `updated_at` on reveal (FR1)
-- [ ] 1.3 Characterization tests: manual hide and manual unhide via `TaskService.update` refresh `updated_at`, clear `appear_date` on unhide, set `pending` (FR2 regression guard)
-- [ ] 1.4 BDD unit: `stale_reveal_sync.feature` + steps — reveal-without-bump scenarios and manual hide/unhide sync scenarios, tags `@fix-stale-sync-overwrites @FR1` / `@FR2`
+- [ ] 1.1 RED: unit tests for `HiddenTaskService.revealHiddenTasks` — revealed task keeps its `updated_at`, gets `syncStatus: "pending"` (FR1)
+- [ ] 1.2 RED: unit test — reveal of an already-`pending` record keeps `pending` and does not degrade its state (FR1)
+- [ ] 1.3 GREEN: change `HiddenTaskService` to stop refreshing `updated_at` on reveal; update JSDoc traceability to `Implements FR1 of fix-stale-sync-overwrites` (FR1)
+- [ ] 1.4 Characterization tests: manual hide via `TaskService.update` refreshes `updated_at`, sets `appear_date`, sets `pending` (FR2 regression guard)
+- [ ] 1.5 Characterization tests: manual unhide via `TaskService.update` refreshes `updated_at`, clears `appear_date`, sets `pending` (FR2 regression guard)
+- [ ] 1.6 BDD unit: `stale_reveal_sync.feature` + steps — reveal-without-bump scenarios, tags `@fix-stale-sync-overwrites @FR1`
+- [ ] 1.7 BDD unit: manual hide/unhide sync scenarios (non-recurring tasks) in the same feature, tags `@fix-stale-sync-overwrites @FR2`
## 2. Rebalance preserves updated_at (FR4)
-- [ ] 2.1 RED: unit tests for `TaskService.rebalanceBox` — rebalanced tasks keep `updated_at`, get `pending`; dragged task in `reorderTasks` still gets fresh `updated_at` (FR4)
-- [ ] 2.2 GREEN: change `rebalanceBox` to stop refreshing `updated_at` (FR4)
-- [ ] 2.3 BDD unit: `rebalance_sync.feature` + steps, tags `@fix-stale-sync-overwrites @FR4`
+- [ ] 2.1 RED: unit tests for `TaskService.rebalanceBox` — rebalanced tasks keep `updated_at`, get `pending` (FR4)
+- [ ] 2.2 RED: unit test — dragged task in `reorderTasks` still gets a fresh `updated_at` (FR4 regression guard)
+- [ ] 2.3 GREEN: change `rebalanceBox` to stop refreshing `updated_at`; add traceability comment `Implements FR4 of fix-stale-sync-overwrites` (FR4)
+- [ ] 2.4 BDD unit: `rebalance_sync.feature` + steps, tags `@fix-stale-sync-overwrites @FR4`
## 3. Dedup merge (FR3, FR6)
-- [ ] 3.1 RED: unit tests for `RecurringTaskDeduplicator` merge — content from freshest `updated_at`, `next_date`+`appear_date` as a pair from earliest, winner `updated_at` = freshest copy's value (not now), wholesale-freshest on `repeat_rule` mismatch, existing winner/tiebreak/exclusion behavior unchanged (FR3)
-- [ ] 3.2 GREEN: implement merge in `RecurringTaskDeduplicator` (FR3); keep file under 200 lines — extract merge logic into a separate module if needed
-- [ ] 3.3 Regression tests: recurrence math untouched for both models — `fixed` early-completion + skip logic, `after_completion` derives from new `completed_at` only (FR6, reuse existing suites as characterization)
-- [ ] 3.4 BDD unit: `dedup_merge.feature` + steps — two-device double completion for `fixed` and `after_completion`, tags `@fix-stale-sync-overwrites @FR3` / `@FR6`
+- [ ] 3.1 RED: merge tests — content fields (`name`, `description`, `goal_id`, `context_id`, `category_id`) come from the freshest-`updated_at` copy (FR3)
+- [ ] 3.2 RED: merge tests — `next_date` + `appear_date` + `is_hidden` come as a schedule triple from the earliest-`next_date` copy, never mixed across copies (FR3)
+- [ ] 3.3 RED: merge tests — `box` + `sort_order` come as a pair from the freshest copy; `id`, `created_at`, `revision` stay the winner's own (FR3)
+- [ ] 3.4 RED: merge tests — winner `updated_at` equals the freshest copy's value (not refreshed to now); on `repeat_rule` mismatch the freshest copy wins wholesale, dates included (FR3)
+- [ ] 3.5 RED: merge tests — winner is written with `pending` only when the merge changed it; an already-optimal winner is not rewritten and keeps `synced` (FR3)
+- [ ] 3.6 Characterization tests: existing behavior unchanged — winner selection (earliest `next_date`, id tiebreak), completed/deleted copies excluded, loser soft-delete + checklist cascade (FR3)
+- [ ] 3.7 GREEN: implement merge in `RecurringTaskDeduplicator` — extract merge logic into a separate module to keep files under 200 lines; update traceability comments to reference FR3 of fix-stale-sync-overwrites (FR3)
+- [ ] 3.8 Regression tests: recurrence math untouched for both models — `fixed` early-completion + skip logic, `after_completion` derives from new `completed_at` only (FR6, reuse existing suites as characterization)
+- [ ] 3.9 BDD unit: `dedup_merge.feature` + steps — two-device double completion for `fixed` and `after_completion`, tags `@fix-stale-sync-overwrites @FR3` / `@FR6`
## 4. LWW pull protection (FR5)
-- [ ] 4.1 RED: contract-style test suite for `applyServerRecords` (shared cases: synced overwritten; pending vs strictly-newer server → overwritten + logged; pending vs equal → preserved; pending vs older → preserved; new record inserted) run against every entity repository (FR5)
-- [ ] 4.2 GREEN: shared helper (e.g. `applyServerRecordLww`) using `Temporal.Instant.compare` + `console.warn` conflict log; wire into all entity repositories (FR5)
-- [ ] 4.3 BDD unit: `pull_lww_protection.feature` + steps, tags `@fix-stale-sync-overwrites @FR5`
+- [ ] 4.1 RED: contract-style shared test suite for `applyServerRecords` (cases: `synced` overwritten; `rejected` overwritten; `pending` vs strictly newer server → overwritten, logged, marked `synced`; `pending` vs equal → preserved; `pending` vs older → preserved; new record inserted) (FR5)
+- [ ] 4.2 GREEN: shared helper (e.g. `applyServerRecordLww`) using `Temporal.Instant.compare` + `console.warn` conflict log with entity type, id, and both timestamps (FR5)
+- [ ] 4.3 GREEN: wire the helper into `TaskRepository`, `GoalRepository`, `ContextRepository`, `CategoryRepository`; run the contract suite against each (FR5)
+- [ ] 4.4 GREEN: wire the helper into `IdeaRepository`, `ChecklistRepository`, `AttachmentRepository`; run the contract suite against each (`SettingsRepository` excluded — settings sync uses timestamp filtering and has no `applyServerRecords`) (FR5)
+- [ ] 4.5 BDD unit: `pull_lww_protection.feature` + steps, tags `@fix-stale-sync-overwrites @FR5`
## 5. Integration tests (packages/integration) (NFR-REL1, M1)
-- [ ] 5.1 Two-device harness: two isolated browser contexts (separate storage) against one backend; helper to dump and diff device stores + server tables for convergence assertions (NFR-REL1)
-- [ ] 5.2 Integration: reported bug end-to-end for `fixed` model — complete/edit/complete on A, stale B reopens, both converge to the newest description and completed state (U1, M1, FR1+FR3+FR5)
-- [ ] 5.3 Integration: same scenario for `after_completion` model (U1, FR6)
-- [ ] 5.4 Integration: manual unhide before appear_date propagates to device B (U2, FR2)
-- [ ] 5.5 Integration: rebalance on stale device B does not clobber newer content of untouched tasks (U3, FR4)
+- [ ] 5.1 Two-device harness: two isolated browser contexts with separate storage states for the same user against one backend — reuse existing `page-lifecycle.ts` / `server-api.ts` infrastructure (NFR-REL1)
+- [ ] 5.2 Convergence helper: dump both devices' IndexedDB stores + server tables and diff them for equality assertions (NFR-REL1)
+- [ ] 5.3 Integration: reported bug end-to-end for the `fixed` model — complete/edit on A, stale B reopens and auto-reveals, both devices converge to the newest description and completed occurrence stays completed; convergence assertion (U1, M1, FR1+FR3+FR5, NFR-REL1)
+- [ ] 5.4 Integration: same scenario for the `after_completion` model; convergence assertion (U1, FR6, NFR-REL1)
+- [ ] 5.5 Integration: manual unhide before `appear_date` on device A propagates to device B; convergence assertion (U2, FR2, NFR-REL1)
+- [ ] 5.6 Integration: rebalance on stale device B does not clobber newer content of untouched tasks (U3, FR4, NFR-REL1)
## 6. Verification
-- [ ] 6.1 Mutation testing scoped to changed files (max 5 per run, sequential): `HiddenTaskService.ts`, `TaskService.ts`, `RecurringTaskDeduplicator.ts`, LWW helper — score >= 95% (min 90%), kill survivors with added tests (M3)
-- [ ] 6.2 `get_file_problems` via JetBrains MCP on all changed files + `pnpm run build`
-- [ ] 6.3 Traceability check: grep confirms every FR1-FR6, NFR-REL1, UX1-UX3 has at least one implementing test/artifact reference
+- [ ] 6.1 Mutation run 1 (scoped, max 5 files, wait for completion): `HiddenTaskService.ts`, `TaskService.ts`, `RecurringTaskDeduplicator.ts`, extracted merge module, LWW helper — score >= 95% (min 90%), kill survivors with added tests (M3)
+- [ ] 6.2 Mutation run 2 (only after run 1 finishes): `TaskRepository.ts`, `GoalRepository.ts`, `ContextRepository.ts`, `CategoryRepository.ts` — same threshold (M3)
+- [ ] 6.3 Mutation run 3 (only after run 2 finishes): `IdeaRepository.ts`, `ChecklistRepository.ts`, `AttachmentRepository.ts` — same threshold (M3)
+- [ ] 6.4 `get_file_problems` via JetBrains MCP on all changed files + `pnpm run build`
+- [ ] 6.5 Traceability check: grep confirms every FR1-FR6, NFR-REL1, UX1-UX3 has at least one implementing test/artifact reference; BDD scenarios carry `@fix-stale-sync-overwrites @FR-X` tags
From 51f238c72737d97c87e3d094e6958f509ff93bb0 Mon Sep 17 00:00:00 2001
From: oinsio <8233537+oinsio@users.noreply.github.com>
Date: Sun, 19 Jul 2026 09:25:13 +0500
Subject: [PATCH 3/8] implement fix-stale-sync-overwrites OpenSpec
---
.../fix-stale-sync-overwrites/tasks.md | 98 ++++--
...ntRepository.applyServerRecordsLww.test.ts | 34 ++
.../AttachmentRepository.test-setup.ts | 18 +
.../db/repositories/AttachmentRepository.ts | 20 +-
...ryRepository.applyServerRecordsLww.test.ts | 33 ++
.../CategoryRepository.test-setup.ts | 18 +
.../src/db/repositories/CategoryRepository.ts | 20 +-
...stRepository.applyServerRecordsLww.test.ts | 34 ++
.../db/repositories/ChecklistRepository.ts | 20 +-
...xtRepository.applyServerRecordsLww.test.ts | 33 ++
.../ContextRepository.test-setup.ts | 18 +
...alRepository.applyServerRecordsLww.test.ts | 33 ++
.../repositories/GoalRepository.test-setup.ts | 18 +
...eaRepository.applyServerRecordsLww.test.ts | 33 ++
.../repositories/IdeaRepository.test-setup.ts | 18 +
...skRepository.applyServerRecordsLww.test.ts | 35 ++
.../src/db/repositories/TaskRepository.ts | 20 +-
.../db/repositories/applyServerRecordLww.ts | 50 +++
.../src/services/HiddenTaskService.test.ts | 158 +++++++--
.../client/src/services/HiddenTaskService.ts | 8 +-
.../RecurringTaskDeduplicator.merge.ts | 96 ++++++
.../RecurringTaskDeduplicator.test.ts | 112 ++++++
.../src/services/RecurringTaskDeduplicator.ts | 28 +-
.../src/services/TaskService.complete.test.ts | 10 +
.../services/TaskService.mutations.test.ts | 120 ++++++-
.../TaskService.recurring-completion.test.ts | 14 +-
packages/client/src/services/TaskService.ts | 13 +-
.../steps/attachment_repository_sync.steps.ts | 16 +-
.../repeating_tasks/dedup_merge.feature | 29 ++
.../sync_protocol/pull_lww_protection.feature | 38 +++
.../sync_protocol/rebalance_sync.feature | 16 +
.../sync_protocol/stale_reveal_sync.feature | 32 ++
.../steps/rebalance_sync.steps.ts | 143 ++++++++
.../integration/src/convergence-helpers.ts | 142 ++++++++
packages/integration/src/page-actions.ts | 53 +++
packages/integration/src/test-helpers.ts | 6 +
.../multi-device-stale-rebalance.spec.ts | 319 ++++++++++++++++++
37 files changed, 1815 insertions(+), 91 deletions(-)
create mode 100644 packages/client/src/db/repositories/AttachmentRepository.applyServerRecordsLww.test.ts
create mode 100644 packages/client/src/db/repositories/AttachmentRepository.test-setup.ts
create mode 100644 packages/client/src/db/repositories/CategoryRepository.applyServerRecordsLww.test.ts
create mode 100644 packages/client/src/db/repositories/CategoryRepository.test-setup.ts
create mode 100644 packages/client/src/db/repositories/ChecklistRepository.applyServerRecordsLww.test.ts
create mode 100644 packages/client/src/db/repositories/ContextRepository.applyServerRecordsLww.test.ts
create mode 100644 packages/client/src/db/repositories/ContextRepository.test-setup.ts
create mode 100644 packages/client/src/db/repositories/GoalRepository.applyServerRecordsLww.test.ts
create mode 100644 packages/client/src/db/repositories/GoalRepository.test-setup.ts
create mode 100644 packages/client/src/db/repositories/IdeaRepository.applyServerRecordsLww.test.ts
create mode 100644 packages/client/src/db/repositories/IdeaRepository.test-setup.ts
create mode 100644 packages/client/src/db/repositories/TaskRepository.applyServerRecordsLww.test.ts
create mode 100644 packages/client/src/db/repositories/applyServerRecordLww.ts
create mode 100644 packages/client/src/services/RecurringTaskDeduplicator.merge.ts
create mode 100644 packages/client/src/test/features/repeating_tasks/dedup_merge.feature
create mode 100644 packages/client/src/test/features/sync_protocol/pull_lww_protection.feature
create mode 100644 packages/client/src/test/features/sync_protocol/rebalance_sync.feature
create mode 100644 packages/client/src/test/features/sync_protocol/stale_reveal_sync.feature
create mode 100644 packages/client/src/test/features/sync_protocol/steps/rebalance_sync.steps.ts
create mode 100644 packages/integration/src/convergence-helpers.ts
create mode 100644 packages/integration/src/tests/multi-device-stale-rebalance.spec.ts
diff --git a/openspec/changes/fix-stale-sync-overwrites/tasks.md b/openspec/changes/fix-stale-sync-overwrites/tasks.md
index 1099f943..fa01c70a 100644
--- a/openspec/changes/fix-stale-sync-overwrites/tasks.md
+++ b/openspec/changes/fix-stale-sync-overwrites/tasks.md
@@ -2,54 +2,80 @@
## 1. Auto-reveal preserves updated_at (FR1, FR2)
-- [ ] 1.1 RED: unit tests for `HiddenTaskService.revealHiddenTasks` — revealed task keeps its `updated_at`, gets `syncStatus: "pending"` (FR1)
-- [ ] 1.2 RED: unit test — reveal of an already-`pending` record keeps `pending` and does not degrade its state (FR1)
-- [ ] 1.3 GREEN: change `HiddenTaskService` to stop refreshing `updated_at` on reveal; update JSDoc traceability to `Implements FR1 of fix-stale-sync-overwrites` (FR1)
-- [ ] 1.4 Characterization tests: manual hide via `TaskService.update` refreshes `updated_at`, sets `appear_date`, sets `pending` (FR2 regression guard)
-- [ ] 1.5 Characterization tests: manual unhide via `TaskService.update` refreshes `updated_at`, clears `appear_date`, sets `pending` (FR2 regression guard)
-- [ ] 1.6 BDD unit: `stale_reveal_sync.feature` + steps — reveal-without-bump scenarios, tags `@fix-stale-sync-overwrites @FR1`
-- [ ] 1.7 BDD unit: manual hide/unhide sync scenarios (non-recurring tasks) in the same feature, tags `@fix-stale-sync-overwrites @FR2`
+- [x] 1.1 RED: unit tests for `HiddenTaskService.revealHiddenTasks` — revealed task keeps its `updated_at`, gets `syncStatus: "pending"` (FR1)
+- [x] 1.2 RED: unit test — reveal of an already-`pending` record keeps `pending` and does not degrade its state (FR1)
+- [x] 1.3 GREEN: change `HiddenTaskService` to stop refreshing `updated_at` on reveal; update JSDoc traceability to `Implements FR1 of fix-stale-sync-overwrites` (FR1)
+- [x] 1.4 Characterization tests: manual hide via `TaskService.update` refreshes `updated_at`, sets `appear_date`, sets `pending` (FR2 regression guard)
+- [x] 1.5 Characterization tests: manual unhide via `TaskService.update` refreshes `updated_at`, clears `appear_date`, sets `pending` (FR2 regression guard)
+- [x] 1.6 BDD unit: `stale_reveal_sync.feature` + steps — reveal-without-bump scenarios, tags `@fix-stale-sync-overwrites @FR1`
+- [x] 1.7 BDD unit: manual hide/unhide sync scenarios (non-recurring tasks) in the same feature, tags `@fix-stale-sync-overwrites @FR2`
## 2. Rebalance preserves updated_at (FR4)
-- [ ] 2.1 RED: unit tests for `TaskService.rebalanceBox` — rebalanced tasks keep `updated_at`, get `pending` (FR4)
-- [ ] 2.2 RED: unit test — dragged task in `reorderTasks` still gets a fresh `updated_at` (FR4 regression guard)
-- [ ] 2.3 GREEN: change `rebalanceBox` to stop refreshing `updated_at`; add traceability comment `Implements FR4 of fix-stale-sync-overwrites` (FR4)
-- [ ] 2.4 BDD unit: `rebalance_sync.feature` + steps, tags `@fix-stale-sync-overwrites @FR4`
+- [x] 2.1 RED: unit tests for `TaskService.rebalanceBox` — rebalanced tasks keep `updated_at`, get `pending` (FR4)
+- [x] 2.2 RED: unit test — dragged task in `reorderTasks` still gets a fresh `updated_at` (FR4 regression guard)
+- [x] 2.3 GREEN: change `rebalanceBox` to stop refreshing `updated_at`; add traceability comment `Implements FR4 of fix-stale-sync-overwrites` (FR4)
+- [x] 2.4 BDD unit: `rebalance_sync.feature` + steps, tags `@fix-stale-sync-overwrites @FR4`
## 3. Dedup merge (FR3, FR6)
-- [ ] 3.1 RED: merge tests — content fields (`name`, `description`, `goal_id`, `context_id`, `category_id`) come from the freshest-`updated_at` copy (FR3)
-- [ ] 3.2 RED: merge tests — `next_date` + `appear_date` + `is_hidden` come as a schedule triple from the earliest-`next_date` copy, never mixed across copies (FR3)
-- [ ] 3.3 RED: merge tests — `box` + `sort_order` come as a pair from the freshest copy; `id`, `created_at`, `revision` stay the winner's own (FR3)
-- [ ] 3.4 RED: merge tests — winner `updated_at` equals the freshest copy's value (not refreshed to now); on `repeat_rule` mismatch the freshest copy wins wholesale, dates included (FR3)
-- [ ] 3.5 RED: merge tests — winner is written with `pending` only when the merge changed it; an already-optimal winner is not rewritten and keeps `synced` (FR3)
-- [ ] 3.6 Characterization tests: existing behavior unchanged — winner selection (earliest `next_date`, id tiebreak), completed/deleted copies excluded, loser soft-delete + checklist cascade (FR3)
-- [ ] 3.7 GREEN: implement merge in `RecurringTaskDeduplicator` — extract merge logic into a separate module to keep files under 200 lines; update traceability comments to reference FR3 of fix-stale-sync-overwrites (FR3)
-- [ ] 3.8 Regression tests: recurrence math untouched for both models — `fixed` early-completion + skip logic, `after_completion` derives from new `completed_at` only (FR6, reuse existing suites as characterization)
-- [ ] 3.9 BDD unit: `dedup_merge.feature` + steps — two-device double completion for `fixed` and `after_completion`, tags `@fix-stale-sync-overwrites @FR3` / `@FR6`
+- [x] 3.1 RED: merge tests — content fields (`name`, `description`, `goal_id`, `context_id`, `category_id`) come from the freshest-`updated_at` copy (FR3)
+- [x] 3.2 RED: merge tests — `next_date` + `appear_date` + `is_hidden` come as a schedule triple from the earliest-`next_date` copy, never mixed across copies (FR3)
+- [x] 3.3 RED: merge tests — `box` + `sort_order` come as a pair from the freshest copy; `id`, `created_at`, `revision` stay the winner's own (FR3)
+- [x] 3.4 RED: merge tests — winner `updated_at` equals the freshest copy's value (not refreshed to now); on `repeat_rule` mismatch the freshest copy wins wholesale, dates included (FR3)
+- [x] 3.5 RED: merge tests — winner is written with `pending` only when the merge changed it; an already-optimal winner is not rewritten and keeps `synced` (FR3)
+- [x] 3.6 Characterization tests: existing behavior unchanged — winner selection (earliest `next_date`, id tiebreak), completed/deleted copies excluded, loser soft-delete + checklist cascade (FR3)
+- [x] 3.7 GREEN: implement merge in `RecurringTaskDeduplicator` — extract merge logic into a separate module to keep files under 200 lines; update traceability comments to reference FR3 of fix-stale-sync-overwrites (FR3)
+- [x] 3.8 Regression tests: recurrence math untouched for both models — `fixed` early-completion + skip logic, `after_completion` derives from new `completed_at` only (FR6, reuse existing suites as characterization)
+- [x] 3.9 BDD unit: `dedup_merge.feature` + steps — two-device double completion for `fixed` and `after_completion`, tags `@fix-stale-sync-overwrites @FR3` / `@FR6`
## 4. LWW pull protection (FR5)
-- [ ] 4.1 RED: contract-style shared test suite for `applyServerRecords` (cases: `synced` overwritten; `rejected` overwritten; `pending` vs strictly newer server → overwritten, logged, marked `synced`; `pending` vs equal → preserved; `pending` vs older → preserved; new record inserted) (FR5)
-- [ ] 4.2 GREEN: shared helper (e.g. `applyServerRecordLww`) using `Temporal.Instant.compare` + `console.warn` conflict log with entity type, id, and both timestamps (FR5)
-- [ ] 4.3 GREEN: wire the helper into `TaskRepository`, `GoalRepository`, `ContextRepository`, `CategoryRepository`; run the contract suite against each (FR5)
-- [ ] 4.4 GREEN: wire the helper into `IdeaRepository`, `ChecklistRepository`, `AttachmentRepository`; run the contract suite against each (`SettingsRepository` excluded — settings sync uses timestamp filtering and has no `applyServerRecords`) (FR5)
-- [ ] 4.5 BDD unit: `pull_lww_protection.feature` + steps, tags `@fix-stale-sync-overwrites @FR5`
+- [x] 4.1 RED: contract-style shared test suite for `applyServerRecords` (cases: `synced` overwritten; `rejected` overwritten; `pending` vs strictly newer server → overwritten, logged, marked `synced`; `pending` vs equal → preserved; `pending` vs older → preserved; new record inserted) (FR5)
+- [x] 4.2 GREEN: shared helper (e.g. `applyServerRecordLww`) using `Temporal.Instant.compare` + `console.warn` conflict log with entity type, id, and both timestamps (FR5)
+- [x] 4.3 GREEN: wire the helper into `TaskRepository`, `GoalRepository`, `ContextRepository`, `CategoryRepository`; run the contract suite against each (FR5)
+- [x] 4.4 GREEN: wire the helper into `IdeaRepository`, `ChecklistRepository`, `AttachmentRepository`; run the contract suite against each (`SettingsRepository` excluded — settings sync uses timestamp filtering and has no `applyServerRecords`) (FR5)
+- [x] 4.5 BDD unit: `pull_lww_protection.feature` + steps, tags `@fix-stale-sync-overwrites @FR5`
## 5. Integration tests (packages/integration) (NFR-REL1, M1)
-- [ ] 5.1 Two-device harness: two isolated browser contexts with separate storage states for the same user against one backend — reuse existing `page-lifecycle.ts` / `server-api.ts` infrastructure (NFR-REL1)
-- [ ] 5.2 Convergence helper: dump both devices' IndexedDB stores + server tables and diff them for equality assertions (NFR-REL1)
-- [ ] 5.3 Integration: reported bug end-to-end for the `fixed` model — complete/edit on A, stale B reopens and auto-reveals, both devices converge to the newest description and completed occurrence stays completed; convergence assertion (U1, M1, FR1+FR3+FR5, NFR-REL1)
-- [ ] 5.4 Integration: same scenario for the `after_completion` model; convergence assertion (U1, FR6, NFR-REL1)
-- [ ] 5.5 Integration: manual unhide before `appear_date` on device A propagates to device B; convergence assertion (U2, FR2, NFR-REL1)
-- [ ] 5.6 Integration: rebalance on stale device B does not clobber newer content of untouched tasks (U3, FR4, NFR-REL1)
+- [x] 5.1 Two-device harness: two isolated browser contexts with separate storage states for the same user against one backend — reuse existing `page-lifecycle.ts` / `server-api.ts` infrastructure (NFR-REL1)
+- [x] 5.2 Convergence helper: dump both devices' IndexedDB stores + server tables and diff them for equality assertions (NFR-REL1)
+- [x] 5.3 Integration: reported bug end-to-end for the `fixed` model — complete/edit on A, stale B reopens and auto-reveals, both devices converge to the newest description and completed occurrence stays completed; convergence assertion (U1, M1, FR1+FR3+FR5, NFR-REL1)
+- [x] 5.4 Integration: same scenario for the `after_completion` model; convergence assertion (U1, FR6, NFR-REL1)
+- [x] 5.5 Integration: manual unhide before `appear_date` on device A propagates to device B; convergence assertion (U2, FR2, NFR-REL1)
+- [x] 5.6 Integration: rebalance on stale device B does not clobber newer content of untouched tasks (U3, FR4, NFR-REL1)
## 6. Verification
-- [ ] 6.1 Mutation run 1 (scoped, max 5 files, wait for completion): `HiddenTaskService.ts`, `TaskService.ts`, `RecurringTaskDeduplicator.ts`, extracted merge module, LWW helper — score >= 95% (min 90%), kill survivors with added tests (M3)
-- [ ] 6.2 Mutation run 2 (only after run 1 finishes): `TaskRepository.ts`, `GoalRepository.ts`, `ContextRepository.ts`, `CategoryRepository.ts` — same threshold (M3)
-- [ ] 6.3 Mutation run 3 (only after run 2 finishes): `IdeaRepository.ts`, `ChecklistRepository.ts`, `AttachmentRepository.ts` — same threshold (M3)
-- [ ] 6.4 `get_file_problems` via JetBrains MCP on all changed files + `pnpm run build`
-- [ ] 6.5 Traceability check: grep confirms every FR1-FR6, NFR-REL1, UX1-UX3 has at least one implementing test/artifact reference; BDD scenarios carry `@fix-stale-sync-overwrites @FR-X` tags
+- [x] 6.1 Mutation run 1 (scoped, max 5 files, wait for completion): `HiddenTaskService.ts`, `TaskService.ts`, `RecurringTaskDeduplicator.ts`, extracted merge module, LWW helper — score >= 95% (min 90%), kill survivors with added tests (M3)
+- [x] 6.2 Mutation run 2 (only after run 1 finishes): `TaskRepository.ts`, `GoalRepository.ts`, `ContextRepository.ts`, `CategoryRepository.ts` — same threshold (M3)
+- [x] 6.3 Mutation run 3 (only after run 2 finishes): `IdeaRepository.ts`, `ChecklistRepository.ts`, `AttachmentRepository.ts` — same threshold (M3)
+- [x] 6.4 `get_file_problems` via JetBrains MCP on all changed files + `pnpm run build`
+- [x] 6.5 Traceability check: grep confirms every FR1-FR6, NFR-REL1, UX1-UX3 has at least one implementing test/artifact reference; BDD scenarios carry `@fix-stale-sync-overwrites @FR-X` tags
+
+## 7. Review fixes: Temporal comparison in dedup merge + traceability marks (FR3, NFR-REL1, M2)
+
+Impact analysis for 7.1-7.4: `findFreshestUpdatedAtCopy` is reachable only via `mergeWinner` → `RecurringTaskDeduplicator.deduplicate` → `SyncService` pull path (`SyncService.ts:178`) — no other feature consumes it. For in-format timestamps (`toISOTimestamp` always emits fixed-precision `...000Z`) lexicographic and instant order coincide, so the swap is behavior-preserving and all existing FR3 tests must stay green unchanged. Divergence exists only for out-of-format data: same instant in different representations (localeCompare gives an arbitrary winner, `Temporal.Instant.compare` gives a correct tie → first copy in group order wins) and malformed `updated_at` (`Temporal.Instant.from` throws instead of silently misordering — same exposure the FR5 LWW helper already has in the same pull path). `sortByWinnerPriority` keeps `localeCompare` intentionally: `next_date` is date-only and `id` is not a timestamp — out of scope per the Temporal rule.
+
+- [ ] 7.1 Characterization test (pre-refactor guard): two copies with an equal `updated_at` — the first copy in group order stays the freshest-content source (tie semantics of the `reduce` documented before touching it) (FR3)
+- [ ] 7.2 REFACTOR: `findFreshestUpdatedAtCopy` compares via `Temporal.Instant.compare` (import from `@/lib/temporal`) instead of `String.prototype.localeCompare`, preserving tie semantics (keep `freshest` when compare <= 0) (FR3, aligns with design D4 convention)
+- [ ] 7.3 Scoped test run (one command, foreground): `RecurringTaskDeduplicator.merge.test.ts`, `RecurringTaskDeduplicator.test.ts`, `dedup_merge.steps.ts` — all green with no test edits beyond 7.1 (FR3)
+- [ ] 7.4 Scoped mutation run on `src/services/RecurringTaskDeduplicator.merge.ts` (wait for completion; no concurrent runs): diff-scoped kill rate stays 100%, whole-file score >= 95% (M3)
+- [ ] 7.5 Traceability: add `NFR-REL1` (and `M1` where the scenario implements it) to the `// implements ... of fix-stale-sync-overwrites` header comment of all four integration specs (`multi-device-stale-recurring-fixed`, `multi-device-stale-recurring-after-completion`, `multi-device-manual-unhide`, `multi-device-stale-rebalance`) (NFR-REL1, M1)
+- [ ] 7.6 Traceability: add `// Verifies M2 of fix-stale-sync-overwrites` comments to the unit tests asserting `updated_at` is untouched by system mutations: reveal (`HiddenTaskService.test.ts`), rebalance (`TaskService.reorder.test.ts`), dedup merge (`RecurringTaskDeduplicator.merge.test.ts`) (M2)
+- [ ] 7.7 Verification: traceability grep confirms NFR-REL1/M1/M2 references resolve; `get_file_problems` on touched files + `pnpm run build`
+- [ ] 7.8 Spec wording: align the wholesale clause with the implemented (and correct) behavior — replace "when `repeat_rule` differs between copies" with "when `repeat_rule` differs between the schedule winner and the freshest-`updated_at` copy" in `proposal.md` (What Changes bullet, Modified Capabilities bullet, FR3), `design.md` (D3 table row), and the delta spec `specs/repeating-tasks/spec.md` (wholesale bullet). Rationale stays: only those two copies' fields ever mix, so only their rule mismatch makes a merge incoherent; a differing rule on a stale loser contributes no fields and must NOT trigger wholesale (FR3)
+- [ ] 7.9 Pinning test for the 3-copy edge: schedule winner and freshest copy share `repeat_rule`, a stale loser carries a different one — normal merge applies (schedule triple from the winner + content from the freshest), NOT wholesale; loser is soft-deleted as usual (FR3)
+
+## 8. Review fixes: file-size invariant for test files (process-invariants: 100-200 target, 300 hard cap)
+
+Behavior-preserving reorganization — no test logic changes, no TDD cycle (refactoring exemption). Split boundaries follow the existing `describe` blocks; use the `split-large-tests` skill for 8.1-8.3. Shared builders/seed helpers go into a `RecurringTaskDeduplicator.test-setup.ts` module (established repo pattern: `*.test-setup.ts` next to repository tests). New files keep the `.test.ts` suffix so the vitest glob picks them up. BDD `.steps.ts` files are all within limits (max 282) — not in scope.
+
+- [ ] 8.1 Split `RecurringTaskDeduplicator.merge.test.ts` (598) into ~3 files by merge aspect, each <= 200: content fields from freshest copy (name/description/goal/context/category + equal-`updated_at` tie), schedule triple + box/sort_order pair coupling, identity fields + winner `updated_at` + wholesale `repeat_rule` + pending-only-when-changed; extract shared task builders into the test-setup module (FR3 coverage unchanged)
+- [ ] 8.2 Split `RecurringTaskDeduplicator.test.ts` (692) into ~3 files by concern, each <= 200: winner selection + tiebreak + filtering; loser soft-delete + checklist cascade + multiple/three-plus groups; skip optimization + guard describes; reuse the same test-setup module (FR3 characterization coverage unchanged)
+- [ ] 8.3 Split `HiddenTaskService.test.ts` (300): move the `revealHiddenTasks preserving updated_at` describe (FR1 block) into a sibling `HiddenTaskService.reveal-preserves-updated-at.test.ts`; both files end up <= 200 (FR1 coverage unchanged)
+- [ ] 8.4 Slim `multi-device-stale-rebalance.spec.ts` (319): move `dragTaskOnto` into `page-actions.ts` and `buildInboxTaskPayload` into a shared integration helper module; the spec keeps only the U3 scenario and lands <= 200 (U3/FR4/NFR-REL1 coverage unchanged)
+- [ ] 8.5 Verification: scoped vitest run (one command, foreground) over all files produced by 8.1-8.3 — green, and the total test count of the split groups equals the pre-split count (no scenario silently dropped)
+- [ ] 8.6 Verification: scoped mutation run on `src/services/RecurringTaskDeduplicator.ts,src/services/RecurringTaskDeduplicator.merge.ts,src/services/HiddenTaskService.ts` (wait for completion) — scores unchanged, confirming Stryker still discovers the renamed test files (M3)
+- [ ] 8.7 Verification: `wc -l` confirms every touched test file <= 200 (hard cap 300 nowhere needed); `get_file_problems` on new/changed files + `pnpm run build`
diff --git a/packages/client/src/db/repositories/AttachmentRepository.applyServerRecordsLww.test.ts b/packages/client/src/db/repositories/AttachmentRepository.applyServerRecordsLww.test.ts
new file mode 100644
index 00000000..e2d087ff
--- /dev/null
+++ b/packages/client/src/db/repositories/AttachmentRepository.applyServerRecordsLww.test.ts
@@ -0,0 +1,34 @@
+import type { WireAttachment } from "@clear-progress/contract";
+import { describe } from "vitest";
+import { buildAttachment } from "@/test/factories/attachmentFactory";
+import type { Attachment } from "@/types/entities";
+import { db } from "../database";
+import { createAttachmentRepositorySetup } from "./AttachmentRepository.test-setup";
+import { runApplyServerRecordsLwwContractTests } from "./applyServerRecordsLww.contract";
+
+/**
+ * Contract tests for FR5 of fix-stale-sync-overwrites.
+ * Instantiates the shared LWW pull protection contract suite against
+ * AttachmentRepository.
+ */
+describe("AttachmentRepository applyServerRecords LWW contract", () => {
+ const { getRepository } = createAttachmentRepositorySetup();
+
+ function toWireAttachment(attachment: Attachment): WireAttachment {
+ const { syncStatus: _syncStatus, ...wireAttachment } = attachment;
+ return wireAttachment;
+ }
+
+ runApplyServerRecordsLwwContractTests({
+ entityName: "attachment",
+ applyServerRecords: (records) =>
+ getRepository().applyServerRecords(records),
+ getLocalRecord: (id) => db.attachments.get(id),
+ putLocalRecord: async (record) => {
+ await db.attachments.put(record);
+ },
+ buildLocalRecord: (overrides) => buildAttachment(overrides),
+ buildServerRecord: (overrides) =>
+ toWireAttachment(buildAttachment(overrides)),
+ });
+});
diff --git a/packages/client/src/db/repositories/AttachmentRepository.test-setup.ts b/packages/client/src/db/repositories/AttachmentRepository.test-setup.ts
new file mode 100644
index 00000000..89eca4c1
--- /dev/null
+++ b/packages/client/src/db/repositories/AttachmentRepository.test-setup.ts
@@ -0,0 +1,18 @@
+import { beforeEach } from "vitest";
+import { db } from "../database";
+import { AttachmentRepository } from "./AttachmentRepository";
+
+export function createAttachmentRepositorySetup(): {
+ getRepository: () => AttachmentRepository;
+} {
+ let attachmentRepository: AttachmentRepository;
+
+ beforeEach(async () => {
+ await db.attachments.clear();
+ attachmentRepository = new AttachmentRepository();
+ });
+
+ return {
+ getRepository: () => attachmentRepository,
+ };
+}
diff --git a/packages/client/src/db/repositories/AttachmentRepository.ts b/packages/client/src/db/repositories/AttachmentRepository.ts
index b181ea30..a1b3fdd8 100644
--- a/packages/client/src/db/repositories/AttachmentRepository.ts
+++ b/packages/client/src/db/repositories/AttachmentRepository.ts
@@ -4,6 +4,9 @@ import { ClientAttachmentSchema } from "@/schemas/entities";
import type { Attachment, ISOTimestamp } from "@/types/entities";
import { toISOTimestamp } from "@/utils/dateHelpers";
import { db } from "../database";
+import { shouldOverwritePendingLocalRecord } from "./applyServerRecordLww";
+
+const ATTACHMENT_ENTITY_NAME = "attachment";
export class AttachmentRepository {
async getAll(): Promise {
@@ -127,11 +130,26 @@ export class AttachmentRepository {
}
}
+ /**
+ * Implements FR5 of fix-stale-sync-overwrites.
+ * A local `pending` record is overwritten only when the server record's
+ * `updated_at` is strictly newer (LWW pull protection). Local records with
+ * any other syncStatus, or no local record at all, are always overwritten.
+ */
async applyServerRecords(records: WireAttachment[]): Promise {
await db.transaction("rw", db.attachments, async () => {
for (const serverRecord of records) {
const localRecord = await db.attachments.get(serverRecord.id);
- if (localRecord?.syncStatus !== "pending") {
+ const shouldWrite =
+ localRecord?.syncStatus !== "pending" ||
+ shouldOverwritePendingLocalRecord({
+ entityName: ATTACHMENT_ENTITY_NAME,
+ id: serverRecord.id,
+ localUpdatedAt: localRecord.updated_at,
+ serverUpdatedAt: serverRecord.updated_at,
+ });
+
+ if (shouldWrite) {
const attachment: Attachment = {
...serverRecord,
created_at: serverRecord.created_at as ISOTimestamp,
diff --git a/packages/client/src/db/repositories/CategoryRepository.applyServerRecordsLww.test.ts b/packages/client/src/db/repositories/CategoryRepository.applyServerRecordsLww.test.ts
new file mode 100644
index 00000000..b9ee7147
--- /dev/null
+++ b/packages/client/src/db/repositories/CategoryRepository.applyServerRecordsLww.test.ts
@@ -0,0 +1,33 @@
+import type { WireCategory } from "@clear-progress/contract";
+import { describe } from "vitest";
+import { buildCategory } from "@/test/factories/categoryFactory";
+import type { Category } from "@/types/entities";
+import { db } from "../database";
+import { runApplyServerRecordsLwwContractTests } from "./applyServerRecordsLww.contract";
+import { createCategoryRepositorySetup } from "./CategoryRepository.test-setup";
+
+/**
+ * Contract tests for FR5 of fix-stale-sync-overwrites.
+ * Instantiates the shared LWW pull protection contract suite against
+ * CategoryRepository.
+ */
+describe("CategoryRepository applyServerRecords LWW contract", () => {
+ const { getRepository } = createCategoryRepositorySetup();
+
+ function toWireCategory(category: Category): WireCategory {
+ const { syncStatus: _syncStatus, ...wireCategory } = category;
+ return wireCategory;
+ }
+
+ runApplyServerRecordsLwwContractTests({
+ entityName: "category",
+ applyServerRecords: (records) =>
+ getRepository().applyServerRecords(records),
+ getLocalRecord: (id) => db.categories.get(id),
+ putLocalRecord: async (record) => {
+ await db.categories.put(record);
+ },
+ buildLocalRecord: (overrides) => buildCategory(overrides),
+ buildServerRecord: (overrides) => toWireCategory(buildCategory(overrides)),
+ });
+});
diff --git a/packages/client/src/db/repositories/CategoryRepository.test-setup.ts b/packages/client/src/db/repositories/CategoryRepository.test-setup.ts
new file mode 100644
index 00000000..abff09a2
--- /dev/null
+++ b/packages/client/src/db/repositories/CategoryRepository.test-setup.ts
@@ -0,0 +1,18 @@
+import { beforeEach } from "vitest";
+import { db } from "../database";
+import { CategoryRepository } from "./CategoryRepository";
+
+export function createCategoryRepositorySetup(): {
+ getRepository: () => CategoryRepository;
+} {
+ let categoryRepository: CategoryRepository;
+
+ beforeEach(async () => {
+ await db.categories.clear();
+ categoryRepository = new CategoryRepository();
+ });
+
+ return {
+ getRepository: () => categoryRepository,
+ };
+}
diff --git a/packages/client/src/db/repositories/CategoryRepository.ts b/packages/client/src/db/repositories/CategoryRepository.ts
index 34d426da..7b35f2dc 100644
--- a/packages/client/src/db/repositories/CategoryRepository.ts
+++ b/packages/client/src/db/repositories/CategoryRepository.ts
@@ -2,6 +2,9 @@ import type { WireCategory } from "@clear-progress/contract";
import { ClientCategorySchema } from "@/schemas/entities";
import type { Category, ISOTimestamp } from "@/types/entities";
import { db } from "../database";
+import { shouldOverwritePendingLocalRecord } from "./applyServerRecordLww";
+
+const CATEGORY_ENTITY_NAME = "category";
export class CategoryRepository {
async getAll(): Promise {
@@ -55,11 +58,26 @@ export class CategoryRepository {
.toArray();
}
+ /**
+ * Implements FR5 of fix-stale-sync-overwrites.
+ * A local `pending` record is overwritten only when the server record's
+ * `updated_at` is strictly newer (LWW pull protection). Local records with
+ * any other syncStatus, or no local record at all, are always overwritten.
+ */
async applyServerRecords(records: WireCategory[]): Promise {
await db.transaction("rw", db.categories, async () => {
for (const serverRecord of records) {
const localRecord = await db.categories.get(serverRecord.id);
- if (localRecord?.syncStatus !== "pending") {
+ const shouldWrite =
+ localRecord?.syncStatus !== "pending" ||
+ shouldOverwritePendingLocalRecord({
+ entityName: CATEGORY_ENTITY_NAME,
+ id: serverRecord.id,
+ localUpdatedAt: localRecord.updated_at,
+ serverUpdatedAt: serverRecord.updated_at,
+ });
+
+ if (shouldWrite) {
const category: Category = {
...serverRecord,
created_at: serverRecord.created_at as ISOTimestamp,
diff --git a/packages/client/src/db/repositories/ChecklistRepository.applyServerRecordsLww.test.ts b/packages/client/src/db/repositories/ChecklistRepository.applyServerRecordsLww.test.ts
new file mode 100644
index 00000000..450c8102
--- /dev/null
+++ b/packages/client/src/db/repositories/ChecklistRepository.applyServerRecordsLww.test.ts
@@ -0,0 +1,34 @@
+import type { WireChecklistItem } from "@clear-progress/contract";
+import { describe } from "vitest";
+import { buildChecklistItem } from "@/test/factories/checklistItemFactory";
+import type { ChecklistItem } from "@/types/entities";
+import { db } from "../database";
+import { runApplyServerRecordsLwwContractTests } from "./applyServerRecordsLww.contract";
+import { createChecklistRepositorySetup } from "./ChecklistRepository.test-setup";
+
+/**
+ * Contract tests for FR5 of fix-stale-sync-overwrites.
+ * Instantiates the shared LWW pull protection contract suite against
+ * ChecklistRepository.
+ */
+describe("ChecklistRepository applyServerRecords LWW contract", () => {
+ const { getRepository } = createChecklistRepositorySetup();
+
+ function toWireChecklistItem(item: ChecklistItem): WireChecklistItem {
+ const { syncStatus: _syncStatus, ...wireChecklistItem } = item;
+ return wireChecklistItem;
+ }
+
+ runApplyServerRecordsLwwContractTests({
+ entityName: "checklist item",
+ applyServerRecords: (records) =>
+ getRepository().applyServerRecords(records),
+ getLocalRecord: (id) => db.checklist_items.get(id),
+ putLocalRecord: async (record) => {
+ await db.checklist_items.put(record);
+ },
+ buildLocalRecord: (overrides) => buildChecklistItem(overrides),
+ buildServerRecord: (overrides) =>
+ toWireChecklistItem(buildChecklistItem(overrides)),
+ });
+});
diff --git a/packages/client/src/db/repositories/ChecklistRepository.ts b/packages/client/src/db/repositories/ChecklistRepository.ts
index 73ab9fea..bb1c6411 100644
--- a/packages/client/src/db/repositories/ChecklistRepository.ts
+++ b/packages/client/src/db/repositories/ChecklistRepository.ts
@@ -2,6 +2,9 @@ import type { WireChecklistItem } from "@clear-progress/contract";
import { ClientChecklistItemSchema } from "@/schemas/entities";
import type { ChecklistItem, ISOTimestamp } from "@/types/entities";
import { db } from "../database";
+import { shouldOverwritePendingLocalRecord } from "./applyServerRecordLww";
+
+const CHECKLIST_ITEM_ENTITY_NAME = "checklist item";
export class ChecklistRepository {
async getAll(): Promise {
@@ -72,11 +75,26 @@ export class ChecklistRepository {
.toArray();
}
+ /**
+ * Implements FR5 of fix-stale-sync-overwrites.
+ * A local `pending` record is overwritten only when the server record's
+ * `updated_at` is strictly newer (LWW pull protection). Local records with
+ * any other syncStatus, or no local record at all, are always overwritten.
+ */
async applyServerRecords(records: WireChecklistItem[]): Promise {
await db.transaction("rw", db.checklist_items, async () => {
for (const serverRecord of records) {
const localRecord = await db.checklist_items.get(serverRecord.id);
- if (localRecord?.syncStatus !== "pending") {
+ const shouldWrite =
+ localRecord?.syncStatus !== "pending" ||
+ shouldOverwritePendingLocalRecord({
+ entityName: CHECKLIST_ITEM_ENTITY_NAME,
+ id: serverRecord.id,
+ localUpdatedAt: localRecord.updated_at,
+ serverUpdatedAt: serverRecord.updated_at,
+ });
+
+ if (shouldWrite) {
const item: ChecklistItem = {
...serverRecord,
created_at: serverRecord.created_at as ISOTimestamp,
diff --git a/packages/client/src/db/repositories/ContextRepository.applyServerRecordsLww.test.ts b/packages/client/src/db/repositories/ContextRepository.applyServerRecordsLww.test.ts
new file mode 100644
index 00000000..6d59d79e
--- /dev/null
+++ b/packages/client/src/db/repositories/ContextRepository.applyServerRecordsLww.test.ts
@@ -0,0 +1,33 @@
+import type { WireContext } from "@clear-progress/contract";
+import { describe } from "vitest";
+import { buildContext } from "@/test/factories/contextFactory";
+import type { Context } from "@/types/entities";
+import { db } from "../database";
+import { runApplyServerRecordsLwwContractTests } from "./applyServerRecordsLww.contract";
+import { createContextRepositorySetup } from "./ContextRepository.test-setup";
+
+/**
+ * Contract tests for FR5 of fix-stale-sync-overwrites.
+ * Instantiates the shared LWW pull protection contract suite against
+ * ContextRepository.
+ */
+describe("ContextRepository applyServerRecords LWW contract", () => {
+ const { getRepository } = createContextRepositorySetup();
+
+ function toWireContext(context: Context): WireContext {
+ const { syncStatus: _syncStatus, ...wireContext } = context;
+ return wireContext;
+ }
+
+ runApplyServerRecordsLwwContractTests({
+ entityName: "context",
+ applyServerRecords: (records) =>
+ getRepository().applyServerRecords(records),
+ getLocalRecord: (id) => db.contexts.get(id),
+ putLocalRecord: async (record) => {
+ await db.contexts.put(record);
+ },
+ buildLocalRecord: (overrides) => buildContext(overrides),
+ buildServerRecord: (overrides) => toWireContext(buildContext(overrides)),
+ });
+});
diff --git a/packages/client/src/db/repositories/ContextRepository.test-setup.ts b/packages/client/src/db/repositories/ContextRepository.test-setup.ts
new file mode 100644
index 00000000..2aec712b
--- /dev/null
+++ b/packages/client/src/db/repositories/ContextRepository.test-setup.ts
@@ -0,0 +1,18 @@
+import { beforeEach } from "vitest";
+import { db } from "../database";
+import { ContextRepository } from "./ContextRepository";
+
+export function createContextRepositorySetup(): {
+ getRepository: () => ContextRepository;
+} {
+ let contextRepository: ContextRepository;
+
+ beforeEach(async () => {
+ await db.contexts.clear();
+ contextRepository = new ContextRepository();
+ });
+
+ return {
+ getRepository: () => contextRepository,
+ };
+}
diff --git a/packages/client/src/db/repositories/GoalRepository.applyServerRecordsLww.test.ts b/packages/client/src/db/repositories/GoalRepository.applyServerRecordsLww.test.ts
new file mode 100644
index 00000000..30b994dc
--- /dev/null
+++ b/packages/client/src/db/repositories/GoalRepository.applyServerRecordsLww.test.ts
@@ -0,0 +1,33 @@
+import type { WireGoal } from "@clear-progress/contract";
+import { describe } from "vitest";
+import { buildGoal } from "@/test/factories/goalFactory";
+import type { Goal } from "@/types/entities";
+import { db } from "../database";
+import { runApplyServerRecordsLwwContractTests } from "./applyServerRecordsLww.contract";
+import { createGoalRepositorySetup } from "./GoalRepository.test-setup";
+
+/**
+ * Contract tests for FR5 of fix-stale-sync-overwrites.
+ * Instantiates the shared LWW pull protection contract suite against
+ * GoalRepository.
+ */
+describe("GoalRepository applyServerRecords LWW contract", () => {
+ const { getRepository } = createGoalRepositorySetup();
+
+ function toWireGoal(goal: Goal): WireGoal {
+ const { syncStatus: _syncStatus, ...wireGoal } = goal;
+ return wireGoal;
+ }
+
+ runApplyServerRecordsLwwContractTests({
+ entityName: "goal",
+ applyServerRecords: (records) =>
+ getRepository().applyServerRecords(records),
+ getLocalRecord: (id) => db.goals.get(id),
+ putLocalRecord: async (record) => {
+ await db.goals.put(record);
+ },
+ buildLocalRecord: (overrides) => buildGoal(overrides),
+ buildServerRecord: (overrides) => toWireGoal(buildGoal(overrides)),
+ });
+});
diff --git a/packages/client/src/db/repositories/GoalRepository.test-setup.ts b/packages/client/src/db/repositories/GoalRepository.test-setup.ts
new file mode 100644
index 00000000..b6a294fb
--- /dev/null
+++ b/packages/client/src/db/repositories/GoalRepository.test-setup.ts
@@ -0,0 +1,18 @@
+import { beforeEach } from "vitest";
+import { db } from "../database";
+import { GoalRepository } from "./GoalRepository";
+
+export function createGoalRepositorySetup(): {
+ getRepository: () => GoalRepository;
+} {
+ let goalRepository: GoalRepository;
+
+ beforeEach(async () => {
+ await db.goals.clear();
+ goalRepository = new GoalRepository();
+ });
+
+ return {
+ getRepository: () => goalRepository,
+ };
+}
diff --git a/packages/client/src/db/repositories/IdeaRepository.applyServerRecordsLww.test.ts b/packages/client/src/db/repositories/IdeaRepository.applyServerRecordsLww.test.ts
new file mode 100644
index 00000000..1ccf4964
--- /dev/null
+++ b/packages/client/src/db/repositories/IdeaRepository.applyServerRecordsLww.test.ts
@@ -0,0 +1,33 @@
+import type { WireIdea } from "@clear-progress/contract";
+import { describe } from "vitest";
+import { buildIdea } from "@/test/factories/ideaFactory";
+import type { Idea } from "@/types/entities";
+import { db } from "../database";
+import { runApplyServerRecordsLwwContractTests } from "./applyServerRecordsLww.contract";
+import { createIdeaRepositorySetup } from "./IdeaRepository.test-setup";
+
+/**
+ * Contract tests for FR5 of fix-stale-sync-overwrites.
+ * Instantiates the shared LWW pull protection contract suite against
+ * IdeaRepository.
+ */
+describe("IdeaRepository applyServerRecords LWW contract", () => {
+ const { getRepository } = createIdeaRepositorySetup();
+
+ function toWireIdea(idea: Idea): WireIdea {
+ const { syncStatus: _syncStatus, ...wireIdea } = idea;
+ return wireIdea;
+ }
+
+ runApplyServerRecordsLwwContractTests({
+ entityName: "idea",
+ applyServerRecords: (records) =>
+ getRepository().applyServerRecords(records),
+ getLocalRecord: (id) => db.ideas.get(id),
+ putLocalRecord: async (record) => {
+ await db.ideas.put(record);
+ },
+ buildLocalRecord: (overrides) => buildIdea(overrides),
+ buildServerRecord: (overrides) => toWireIdea(buildIdea(overrides)),
+ });
+});
diff --git a/packages/client/src/db/repositories/IdeaRepository.test-setup.ts b/packages/client/src/db/repositories/IdeaRepository.test-setup.ts
new file mode 100644
index 00000000..646acca7
--- /dev/null
+++ b/packages/client/src/db/repositories/IdeaRepository.test-setup.ts
@@ -0,0 +1,18 @@
+import { beforeEach } from "vitest";
+import { db } from "../database";
+import { IdeaRepository } from "./IdeaRepository";
+
+export function createIdeaRepositorySetup(): {
+ getRepository: () => IdeaRepository;
+} {
+ let ideaRepository: IdeaRepository;
+
+ beforeEach(async () => {
+ await db.ideas.clear();
+ ideaRepository = new IdeaRepository();
+ });
+
+ return {
+ getRepository: () => ideaRepository,
+ };
+}
diff --git a/packages/client/src/db/repositories/TaskRepository.applyServerRecordsLww.test.ts b/packages/client/src/db/repositories/TaskRepository.applyServerRecordsLww.test.ts
new file mode 100644
index 00000000..e30cbc4a
--- /dev/null
+++ b/packages/client/src/db/repositories/TaskRepository.applyServerRecordsLww.test.ts
@@ -0,0 +1,35 @@
+import type { WireTask } from "@clear-progress/contract";
+import { describe } from "vitest";
+import { buildTask } from "@/test/factories/taskFactory";
+import type { Task } from "@/types/entities";
+import { db } from "../database";
+import { runApplyServerRecordsLwwContractTests } from "./applyServerRecordsLww.contract";
+import { createTaskRepositorySetup } from "./TaskRepository.test-setup";
+
+/**
+ * Contract-style RED tests for FR5 of fix-stale-sync-overwrites.
+ * Proves `runApplyServerRecordsLwwContractTests` runs against TaskRepository
+ * and is red under the current (pre-LWW) `applyServerRecords` implementation.
+ * Tasks 4.3/4.4 will instantiate the same suite against the other 6
+ * repositories once the shared `applyServerRecordLww` helper exists.
+ */
+describe("TaskRepository applyServerRecords LWW contract", () => {
+ const { getRepository } = createTaskRepositorySetup();
+
+ function toWireTask(task: Task): WireTask {
+ const { syncStatus: _syncStatus, ...wireTask } = task;
+ return wireTask;
+ }
+
+ runApplyServerRecordsLwwContractTests({
+ entityName: "task",
+ applyServerRecords: (records) =>
+ getRepository().applyServerRecords(records),
+ getLocalRecord: (id) => db.tasks.get(id),
+ putLocalRecord: async (record) => {
+ await db.tasks.put(record);
+ },
+ buildLocalRecord: (overrides) => buildTask(overrides),
+ buildServerRecord: (overrides) => toWireTask(buildTask(overrides)),
+ });
+});
diff --git a/packages/client/src/db/repositories/TaskRepository.ts b/packages/client/src/db/repositories/TaskRepository.ts
index c408a125..3fe569b1 100644
--- a/packages/client/src/db/repositories/TaskRepository.ts
+++ b/packages/client/src/db/repositories/TaskRepository.ts
@@ -5,6 +5,9 @@ import type { Box } from "@/types/common";
import type { ISODate, ISOTimestamp, Task } from "@/types/entities";
import { sanitizeDateOnly } from "@/utils/dateHelpers";
import { db } from "../database";
+import { shouldOverwritePendingLocalRecord } from "./applyServerRecordLww";
+
+const TASK_ENTITY_NAME = "task";
export class TaskRepository {
async getAll(): Promise {
@@ -134,11 +137,26 @@ export class TaskRepository {
.toArray();
}
+ /**
+ * Implements FR5 of fix-stale-sync-overwrites.
+ * A local `pending` record is overwritten only when the server record's
+ * `updated_at` is strictly newer (LWW pull protection). Local records with
+ * any other syncStatus, or no local record at all, are always overwritten.
+ */
async applyServerRecords(records: WireTask[]): Promise {
await db.transaction("rw", db.tasks, async () => {
for (const serverRecord of records) {
const localRecord = await db.tasks.get(serverRecord.id);
- if (localRecord?.syncStatus !== "pending") {
+ const shouldWrite =
+ localRecord?.syncStatus !== "pending" ||
+ shouldOverwritePendingLocalRecord({
+ entityName: TASK_ENTITY_NAME,
+ id: serverRecord.id,
+ localUpdatedAt: localRecord.updated_at,
+ serverUpdatedAt: serverRecord.updated_at,
+ });
+
+ if (shouldWrite) {
const sanitizedRecord: Task = {
...serverRecord,
next_date: (sanitizeDateOnly(serverRecord.next_date) || "") as
diff --git a/packages/client/src/db/repositories/applyServerRecordLww.ts b/packages/client/src/db/repositories/applyServerRecordLww.ts
new file mode 100644
index 00000000..1b26d856
--- /dev/null
+++ b/packages/client/src/db/repositories/applyServerRecordLww.ts
@@ -0,0 +1,50 @@
+import { Temporal } from "@/lib/temporal";
+
+const PULL_CONFLICT_LOG_MESSAGE_TEMPLATE = (entityName: string): string =>
+ `Sync conflict: pending local ${entityName} record overwritten by a newer server record`;
+
+/**
+ * Decides, per FR5 of fix-stale-sync-overwrites, whether a local `pending`
+ * record must be overwritten by an incoming server record during
+ * `applyServerRecords()`.
+ *
+ * A local `pending` record is overwritten iff the server record's
+ * `updated_at` is strictly newer than the local record's `updated_at`
+ * (compared via `Temporal.Instant.compare`, never string comparison).
+ * Equal-or-newer local `updated_at` preserves the local record untouched.
+ *
+ * Local records with any other `syncStatus` (`synced`, `rejected`) or no
+ * local record at all are always overwritten — callers should not consult
+ * this helper for those cases at all, or may pass them through directly.
+ *
+ * When an overwrite of a `pending` record happens, logs a conflict warning
+ * via `console.warn` with the entity type, record id, and both timestamps.
+ *
+ * Implements FR5 of fix-stale-sync-overwrites.
+ */
+export function shouldOverwritePendingLocalRecord(params: {
+ entityName: string;
+ id: string;
+ localUpdatedAt: string;
+ serverUpdatedAt: string;
+}): boolean {
+ const { entityName, id, localUpdatedAt, serverUpdatedAt } = params;
+
+ const isServerStrictlyNewer =
+ Temporal.Instant.compare(
+ Temporal.Instant.from(serverUpdatedAt),
+ Temporal.Instant.from(localUpdatedAt),
+ ) > 0;
+
+ if (!isServerStrictlyNewer) {
+ return false;
+ }
+
+ console.warn(PULL_CONFLICT_LOG_MESSAGE_TEMPLATE(entityName), {
+ id,
+ localUpdatedAt,
+ serverUpdatedAt,
+ });
+
+ return true;
+}
diff --git a/packages/client/src/services/HiddenTaskService.test.ts b/packages/client/src/services/HiddenTaskService.test.ts
index d3c15559..915346a1 100644
--- a/packages/client/src/services/HiddenTaskService.test.ts
+++ b/packages/client/src/services/HiddenTaskService.test.ts
@@ -170,45 +170,131 @@ describe("HiddenTaskService", () => {
});
});
- it("should update updated_at timestamp when revealing", async () => {
- const clock = fakeClock("2026-04-16T10:30:00Z");
- service = new HiddenTaskService(mockTaskRepository, clock);
+ // implements FR1 of fix-stale-sync-overwrites
+ describe("revealHiddenTasks preserving updated_at", () => {
+ it("should NOT modify updated_at when revealing a hidden task", async () => {
+ const clock = fakeClock("2026-04-16T10:30:00Z");
+ service = new HiddenTaskService(mockTaskRepository, clock);
- const hiddenTask: Task = {
- id: "task-timestamp-1",
- name: "Hidden task",
- description: "",
- box: "today",
- goal_id: "",
- context_id: "",
- category_id: "",
- is_completed: false,
- completed_at: "" as ISOTimestamp,
- repeat_rule: "",
- is_hidden: true,
- next_date: "" as ISODate,
- appear_date: "2026-04-16" as ISODate,
- original_task_id: "",
- sort_order: "0",
- is_deleted: false,
- created_at: "2026-04-15T10:00:00.000Z" as ISOTimestamp,
- updated_at: "2026-04-15T10:00:00.000Z" as ISOTimestamp,
- revision: 1,
- syncStatus: "synced" as const,
- };
+ const hiddenTask: Task = {
+ id: "task-preserve-updated-at-1",
+ name: "Hidden task",
+ description: "",
+ box: "today",
+ goal_id: "",
+ context_id: "",
+ category_id: "",
+ is_completed: false,
+ completed_at: "" as ISOTimestamp,
+ repeat_rule: "",
+ is_hidden: true,
+ next_date: "" as ISODate,
+ appear_date: "2026-04-16" as ISODate,
+ original_task_id: "",
+ sort_order: "0",
+ is_deleted: false,
+ created_at: "2026-04-15T10:00:00.000Z" as ISOTimestamp,
+ updated_at: "2026-04-15T10:00:00.000Z" as ISOTimestamp,
+ revision: 1,
+ syncStatus: "synced" as const,
+ };
- (
- mockTaskRepository.getTasksToReveal as ReturnType
- ).mockResolvedValue([hiddenTask]);
- (mockTaskRepository.update as ReturnType).mockResolvedValue(
- undefined,
- );
+ (
+ mockTaskRepository.getTasksToReveal as ReturnType
+ ).mockResolvedValue([hiddenTask]);
+ (mockTaskRepository.update as ReturnType).mockResolvedValue(
+ undefined,
+ );
- const revealed = await service.revealHiddenTasks();
+ const revealed = await service.revealHiddenTasks();
- expect(revealed[0].updated_at).toMatch(
- /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/,
- );
- expect(revealed[0].updated_at).not.toBe("2026-04-15T10:00:00.000Z");
+ expect(revealed[0].updated_at).toBe("2026-04-15T10:00:00.000Z");
+ expect(mockTaskRepository.update).toHaveBeenCalledWith(
+ expect.objectContaining({
+ id: "task-preserve-updated-at-1",
+ updated_at: "2026-04-15T10:00:00.000Z",
+ }),
+ );
+ });
+
+ it("should set syncStatus to pending when revealing a hidden task", async () => {
+ const clock = fakeClock("2026-04-16T10:30:00Z");
+ service = new HiddenTaskService(mockTaskRepository, clock);
+
+ const hiddenTask: Task = {
+ id: "task-preserve-updated-at-2",
+ name: "Hidden task",
+ description: "",
+ box: "today",
+ goal_id: "",
+ context_id: "",
+ category_id: "",
+ is_completed: false,
+ completed_at: "" as ISOTimestamp,
+ repeat_rule: "",
+ is_hidden: true,
+ next_date: "" as ISODate,
+ appear_date: "2026-04-16" as ISODate,
+ original_task_id: "",
+ sort_order: "0",
+ is_deleted: false,
+ created_at: "2026-04-15T10:00:00.000Z" as ISOTimestamp,
+ updated_at: "2026-04-15T10:00:00.000Z" as ISOTimestamp,
+ revision: 1,
+ syncStatus: "synced" as const,
+ };
+
+ (
+ mockTaskRepository.getTasksToReveal as ReturnType
+ ).mockResolvedValue([hiddenTask]);
+ (mockTaskRepository.update as ReturnType).mockResolvedValue(
+ undefined,
+ );
+
+ const revealed = await service.revealHiddenTasks();
+
+ expect(revealed[0].syncStatus).toBe("pending");
+ expect(revealed[0].is_hidden).toBe(false);
+ });
+
+ it("should keep syncStatus as pending without degrading state when task is already pending", async () => {
+ const clock = fakeClock("2026-04-16T10:30:00Z");
+ service = new HiddenTaskService(mockTaskRepository, clock);
+
+ const alreadyPendingHiddenTask: Task = {
+ id: "task-preserve-updated-at-3",
+ name: "Hidden task already pending",
+ description: "",
+ box: "today",
+ goal_id: "",
+ context_id: "",
+ category_id: "",
+ is_completed: false,
+ completed_at: "" as ISOTimestamp,
+ repeat_rule: "",
+ is_hidden: true,
+ next_date: "" as ISODate,
+ appear_date: "2026-04-16" as ISODate,
+ original_task_id: "",
+ sort_order: "0",
+ is_deleted: false,
+ created_at: "2026-04-15T10:00:00.000Z" as ISOTimestamp,
+ updated_at: "2026-04-15T10:00:00.000Z" as ISOTimestamp,
+ revision: 1,
+ syncStatus: "pending" as const,
+ };
+
+ (
+ mockTaskRepository.getTasksToReveal as ReturnType
+ ).mockResolvedValue([alreadyPendingHiddenTask]);
+ (mockTaskRepository.update as ReturnType).mockResolvedValue(
+ undefined,
+ );
+
+ const revealed = await service.revealHiddenTasks();
+
+ expect(revealed[0].syncStatus).toBe("pending");
+ expect(revealed[0].updated_at).toBe("2026-04-15T10:00:00.000Z");
+ });
});
});
diff --git a/packages/client/src/services/HiddenTaskService.ts b/packages/client/src/services/HiddenTaskService.ts
index 6b14a40f..6104901a 100644
--- a/packages/client/src/services/HiddenTaskService.ts
+++ b/packages/client/src/services/HiddenTaskService.ts
@@ -1,7 +1,6 @@
import type { TaskRepository } from "@/db/repositories/TaskRepository";
import { type Clock, systemClock } from "@/lib/temporal";
import type { ISODate, Task } from "@/types/entities";
-import { toISOTimestamp } from "@/utils/dateHelpers";
export class HiddenTaskService {
constructor(
@@ -9,7 +8,10 @@ export class HiddenTaskService {
private readonly clock: Clock = systemClock,
) {}
- /** Implements FR4 of day-boundary */
+ /**
+ * Implements FR1 of fix-stale-sync-overwrites (preserves updated_at on reveal).
+ * Implements FR4 of day-boundary (current-date resolution for reveal check).
+ */
async revealHiddenTasks(logicalDate?: ISODate): Promise {
const currentDate = logicalDate ?? this.clock.plainDateISO().toString();
const tasksToReveal =
@@ -19,14 +21,12 @@ export class HiddenTaskService {
return [];
}
- const now = toISOTimestamp(this.clock);
const revealedTasks: Task[] = [];
for (const task of tasksToReveal) {
const updatedTask: Task = {
...task,
is_hidden: false,
- updated_at: now,
syncStatus: "pending" as const,
};
diff --git a/packages/client/src/services/RecurringTaskDeduplicator.merge.ts b/packages/client/src/services/RecurringTaskDeduplicator.merge.ts
new file mode 100644
index 00000000..594889e4
--- /dev/null
+++ b/packages/client/src/services/RecurringTaskDeduplicator.merge.ts
@@ -0,0 +1,96 @@
+import type { Task } from "@/types/entities";
+
+/**
+ * Merges a duplicate recurring-task group into a single winner record.
+ * Implements FR3 of fix-stale-sync-overwrites.
+ */
+
+const SCHEDULE_TRIPLE_FIELDS = [
+ "next_date",
+ "appear_date",
+ "is_hidden",
+] as const;
+const CONTENT_FIELDS = [
+ "name",
+ "description",
+ "goal_id",
+ "context_id",
+ "category_id",
+] as const;
+const BOX_PAIR_FIELDS = ["box", "sort_order"] as const;
+const MERGE_RELEVANT_FIELDS = [
+ ...SCHEDULE_TRIPLE_FIELDS,
+ ...CONTENT_FIELDS,
+ ...BOX_PAIR_FIELDS,
+ "repeat_rule",
+ "updated_at",
+] as const;
+
+/**
+ * Finds the copy with the freshest `updated_at` among all copies in a
+ * duplicate group (schedule winner included, per FR3: the winner itself
+ * may already be the freshest).
+ */
+function findFreshestUpdatedAtCopy(copies: Task[]): Task {
+ return copies.reduce((freshest, candidate) =>
+ candidate.updated_at.localeCompare(freshest.updated_at) > 0
+ ? candidate
+ : freshest,
+ );
+}
+
+/**
+ * Computes the merged winner record per FR3 / Decision D3:
+ * - schedule triple (next_date, appear_date, is_hidden) from the schedule winner
+ * - content fields + box/sort_order pair from the freshest-updated_at copy
+ * - identity fields (id, created_at, revision) stay the schedule winner's own
+ * - updated_at is the freshest copy's own value, never refreshed to now
+ * - if repeat_rule differs between the schedule winner and the freshest
+ * copy, the freshest copy wins wholesale (all fields, dates included)
+ *
+ * @param scheduleWinner - the copy with the earliest next_date (tiebreak by id)
+ * @param allCopiesInGroup - every active copy in the duplicate group, winner included
+ */
+export function mergeWinner(
+ scheduleWinner: Task,
+ allCopiesInGroup: Task[],
+): Task {
+ const freshestCopy = findFreshestUpdatedAtCopy(allCopiesInGroup);
+
+ if (freshestCopy.repeat_rule !== scheduleWinner.repeat_rule) {
+ return {
+ ...freshestCopy,
+ id: scheduleWinner.id,
+ created_at: scheduleWinner.created_at,
+ revision: scheduleWinner.revision,
+ syncStatus: scheduleWinner.syncStatus,
+ };
+ }
+
+ return {
+ ...scheduleWinner,
+ name: freshestCopy.name,
+ description: freshestCopy.description,
+ goal_id: freshestCopy.goal_id,
+ context_id: freshestCopy.context_id,
+ category_id: freshestCopy.category_id,
+ box: freshestCopy.box,
+ sort_order: freshestCopy.sort_order,
+ updated_at: freshestCopy.updated_at,
+ };
+}
+
+/**
+ * Compares the merged winner to the schedule winner's originally stored
+ * values on the merge-relevant fields only (identity/bookkeeping fields
+ * are excluded — they never change). Used to decide whether the merge
+ * actually changed anything and thus whether to persist an update.
+ */
+export function hasMergeChangedWinner(
+ mergedWinner: Task,
+ originalScheduleWinner: Task,
+): boolean {
+ return MERGE_RELEVANT_FIELDS.some(
+ (field) => mergedWinner[field] !== originalScheduleWinner[field],
+ );
+}
diff --git a/packages/client/src/services/RecurringTaskDeduplicator.test.ts b/packages/client/src/services/RecurringTaskDeduplicator.test.ts
index 75986fc0..df4ae3a0 100644
--- a/packages/client/src/services/RecurringTaskDeduplicator.test.ts
+++ b/packages/client/src/services/RecurringTaskDeduplicator.test.ts
@@ -287,6 +287,46 @@ describe("RecurringTaskDeduplicator", () => {
const active = await db.tasks.get(UUID_A);
expect(active?.is_deleted).toBe(false);
});
+
+ // FR1: a completed copy sharing original_task_id with two active
+ // duplicates must not be pulled into the dup group — the group is
+ // formed only from the two non-completed, non-deleted copies, so the
+ // winner is chosen among those two and the completed copy is untouched.
+ it("should form the duplicate group only from active copies when a completed copy shares the original_task_id with two active duplicates", async () => {
+ const earlyActiveTask = buildTask({
+ id: UUID_A,
+ original_task_id: ORIGINAL_ID_1,
+ next_date: "2026-07-01",
+ is_completed: false,
+ is_deleted: false,
+ });
+ const lateActiveTask = buildTask({
+ id: UUID_B,
+ original_task_id: ORIGINAL_ID_1,
+ next_date: "2026-07-05",
+ is_completed: false,
+ is_deleted: false,
+ });
+ const completedTask = buildTask({
+ id: UUID_C,
+ original_task_id: ORIGINAL_ID_1,
+ next_date: "2026-06-20",
+ is_completed: true,
+ is_deleted: false,
+ });
+ await db.tasks.bulkAdd([earlyActiveTask, lateActiveTask, completedTask]);
+
+ await deduplicator.deduplicate([ORIGINAL_ID_1]);
+
+ const earlyActiveResult = await db.tasks.get(UUID_A);
+ const lateActiveResult = await db.tasks.get(UUID_B);
+ const completedResult = await db.tasks.get(UUID_C);
+ expect(earlyActiveResult?.is_deleted).toBe(false);
+ expect(lateActiveResult?.is_deleted).toBe(true);
+ expect(lateActiveResult?.syncStatus).toBe("pending");
+ expect(completedResult?.is_deleted).toBe(false);
+ expect(completedResult?.syncStatus).toBe("synced");
+ });
});
// FR3: cascade to checklist items
@@ -530,6 +570,78 @@ describe("RecurringTaskDeduplicator", () => {
expect(updateSpy).not.toHaveBeenCalled();
});
+
+ // Guards the `continue` statement directly: a group can only reach this
+ // guard with activeCopies.length < MINIMUM_DUPLICATE_GROUP_SIZE (0 or 1)
+ // since findDuplicateRecurringGroups never emits an empty group in
+ // practice — so length-1 groups are a legitimate but observationally
+ // equivalent case (merging a single copy against itself is a no-op
+ // either way). Forcing an empty-array group via a mocked repository
+ // makes the two mutants observable: without the `continue` (or with the
+ // guard replaced by `if (false)`), the loop falls through to
+ // `sortByWinnerPriority([])` and then destructures an undefined
+ // scheduleWinner, which throws when merging — proving `continue` really
+ // skips groups below the minimum size rather than merely coinciding with
+ // a no-op merge.
+ it("should not throw when a mocked group is empty, proving continue actually skips it", async () => {
+ const taskRepository = new TaskRepository();
+ const checklistRepository = new ChecklistRepository();
+ const clock = fakeClock(FIXED_CLOCK_TIMESTAMP);
+ vi.spyOn(
+ taskRepository,
+ "findDuplicateRecurringGroups",
+ ).mockResolvedValue(new Map([[ORIGINAL_ID_1, []]]));
+ const localDeduplicator = new RecurringTaskDeduplicator(
+ taskRepository,
+ checklistRepository,
+ clock,
+ );
+
+ await expect(
+ localDeduplicator.deduplicate([ORIGINAL_ID_1]),
+ ).resolves.toBeUndefined();
+ });
+
+ // Guards the `continue` statement itself (not just the guarded call):
+ // replacing `continue` with an empty block would still skip the update
+ // for the singleton group (since the guard body has no other statements)
+ // but would fall through to loop code the real implementation intends to
+ // skip, rather than jumping to the next group. Processing a singleton
+ // group FIRST and a real duplicate group SECOND in the same call proves
+ // the loop actually advances past the singleton to still dedupe group 2 —
+ // an `if (...) {}` mutant that fails to skip to the next iteration would
+ // still coincidentally pass this too, but combined with the single-copy
+ // assertions above, it locks down that iteration order is unaffected.
+ it("should still deduplicate a later group after an earlier singleton group in the same call", async () => {
+ const singleTask = buildTask({
+ id: UUID_A,
+ original_task_id: ORIGINAL_ID_1,
+ next_date: "2026-07-01",
+ is_completed: false,
+ is_deleted: false,
+ });
+ const groupTwoWinner = buildTask({
+ id: UUID_B,
+ original_task_id: ORIGINAL_ID_2,
+ next_date: "2026-07-02",
+ is_completed: false,
+ is_deleted: false,
+ });
+ const groupTwoLoser = buildTask({
+ id: UUID_C,
+ original_task_id: ORIGINAL_ID_2,
+ next_date: "2026-07-08",
+ is_completed: false,
+ is_deleted: false,
+ });
+ await db.tasks.bulkAdd([singleTask, groupTwoWinner, groupTwoLoser]);
+
+ await deduplicator.deduplicate([ORIGINAL_ID_1, ORIGINAL_ID_2]);
+
+ expect((await db.tasks.get(UUID_A))?.syncStatus).toBe("synced");
+ expect((await db.tasks.get(UUID_B))?.is_deleted).toBe(false);
+ expect((await db.tasks.get(UUID_C))?.is_deleted).toBe(true);
+ });
});
// Mutant 5: id tiebreak when next_date is equal
diff --git a/packages/client/src/services/RecurringTaskDeduplicator.ts b/packages/client/src/services/RecurringTaskDeduplicator.ts
index 28f931cc..04fe79ec 100644
--- a/packages/client/src/services/RecurringTaskDeduplicator.ts
+++ b/packages/client/src/services/RecurringTaskDeduplicator.ts
@@ -4,12 +4,16 @@ import type { TaskRepository } from "@/db/repositories/TaskRepository";
import { type Clock, systemClock } from "@/lib/temporal";
import type { Task } from "@/types/entities";
import { toISOTimestamp } from "@/utils/dateHelpers";
+import {
+ hasMergeChangedWinner,
+ mergeWinner,
+} from "./RecurringTaskDeduplicator.merge";
const MINIMUM_DUPLICATE_GROUP_SIZE = 2;
/**
* Deduplicates recurring task copies after a pull batch.
- * Implements FR1, FR2, FR3, FR5 of dedup-recurring-after-pull.
+ * Implements FR3 of fix-stale-sync-overwrites.
*/
export class RecurringTaskDeduplicator {
constructor(
@@ -20,7 +24,7 @@ export class RecurringTaskDeduplicator {
/**
* Deduplicates recurring copies for the given original_task_id values.
- * Implements FR1, FR2, FR3, FR5 of dedup-recurring-after-pull.
+ * Implements FR3 of fix-stale-sync-overwrites.
*
* @param originalTaskIds - original_task_id values from the pull batch
*/
@@ -40,10 +44,12 @@ export class RecurringTaskDeduplicator {
}
const sortedCopies = this.sortByWinnerPriority(activeCopies);
- const [, ...losers] = sortedCopies;
+ const [scheduleWinner, ...losers] = sortedCopies;
const now = toISOTimestamp(this.clock);
+ await this.mergeAndUpdateWinner(scheduleWinner, activeCopies);
+
for (const loser of losers) {
await this.softDeleteTask(loser, now);
await this.cascadeSoftDeleteChecklistItems(loser.id, now);
@@ -51,6 +57,22 @@ export class RecurringTaskDeduplicator {
}
}
+ private async mergeAndUpdateWinner(
+ scheduleWinner: Task,
+ activeCopies: Task[],
+ ): Promise {
+ const mergedWinner = mergeWinner(scheduleWinner, activeCopies);
+
+ if (!hasMergeChangedWinner(mergedWinner, scheduleWinner)) {
+ return;
+ }
+
+ await this.taskRepository.update({
+ ...mergedWinner,
+ syncStatus: RECORD_SYNC_STATUS.PENDING,
+ });
+ }
+
private extractUniqueNonEmptyIds(originalTaskIds: string[]): string[] {
return [...new Set(originalTaskIds.filter((id) => id !== ""))];
}
diff --git a/packages/client/src/services/TaskService.complete.test.ts b/packages/client/src/services/TaskService.complete.test.ts
index 9685d9e2..d5f76fe9 100644
--- a/packages/client/src/services/TaskService.complete.test.ts
+++ b/packages/client/src/services/TaskService.complete.test.ts
@@ -30,6 +30,16 @@ describe("TaskService", () => {
expect(completed.completed_at).not.toBe("");
});
+ it("should throw an error naming the missing task id when task is not found", async () => {
+ const missingTaskId = "missing-task-id";
+ const { taskService } = createTestContext({
+ getById: vi.fn().mockResolvedValue(undefined),
+ });
+ await expect(taskService.complete(missingTaskId)).rejects.toThrow(
+ `Task not found: ${missingTaskId}`,
+ );
+ });
+
it("should return not_recurring status when repeat_rule is empty", async () => {
const task = buildTask({ repeat_rule: "" });
const { taskService } = createTestContext({
diff --git a/packages/client/src/services/TaskService.mutations.test.ts b/packages/client/src/services/TaskService.mutations.test.ts
index f9e57c48..b2817143 100644
--- a/packages/client/src/services/TaskService.mutations.test.ts
+++ b/packages/client/src/services/TaskService.mutations.test.ts
@@ -1,6 +1,6 @@
import { describe, expect, it, vi } from "vitest";
import { BOX } from "@/constants";
-import { Temporal } from "@/lib/temporal";
+import { fakeClock, Temporal } from "@/lib/temporal";
import { buildTask } from "@/test/factories/taskFactory";
import { toISOTimestamp } from "@/utils/dateHelpers";
import { createTestContext } from "./TaskService-test-utils";
@@ -31,6 +31,124 @@ describe("TaskService", () => {
});
});
+ describe("manual hide via update", () => {
+ // Characterization tests: FR2 of fix-stale-sync-overwrites requires manual
+ // hide to remain a regular user edit. These document EXISTING behavior
+ // that must NOT change.
+ it("should refresh updated_at to now when manually hiding a task", async () => {
+ const staleTimestamp = toISOTimestamp(
+ fakeClock("2025-01-01T00:00:00.000Z"),
+ );
+ const task = buildTask({
+ is_hidden: false,
+ appear_date: "",
+ updated_at: staleTimestamp,
+ });
+ const { taskService } = createTestContext({
+ getById: vi.fn().mockResolvedValue(task),
+ });
+
+ const hidden = await taskService.update(task.id, {
+ is_hidden: true,
+ appear_date: "2026-08-01",
+ });
+
+ expect(hidden.updated_at).not.toBe(staleTimestamp);
+ });
+
+ it("should set appear_date to the given value when manually hiding a task", async () => {
+ const task = buildTask({ is_hidden: false, appear_date: "" });
+ const { taskService } = createTestContext({
+ getById: vi.fn().mockResolvedValue(task),
+ });
+
+ const hidden = await taskService.update(task.id, {
+ is_hidden: true,
+ appear_date: "2026-08-01",
+ });
+
+ expect(hidden.appear_date).toBe("2026-08-01");
+ expect(hidden.is_hidden).toBe(true);
+ });
+
+ it("should set syncStatus to pending when manually hiding a task", async () => {
+ const task = buildTask({
+ is_hidden: false,
+ appear_date: "",
+ syncStatus: "synced" as const,
+ });
+ const { taskService } = createTestContext({
+ getById: vi.fn().mockResolvedValue(task),
+ });
+
+ const hidden = await taskService.update(task.id, {
+ is_hidden: true,
+ appear_date: "2026-08-01",
+ });
+
+ expect(hidden.syncStatus).toBe("pending");
+ });
+ });
+
+ describe("manual unhide via update", () => {
+ // Characterization tests: FR2 of fix-stale-sync-overwrites requires manual
+ // unhide to remain a regular user edit. These document EXISTING behavior
+ // that must NOT change.
+ it("should refresh updated_at to now when manually unhiding a task", async () => {
+ const staleTimestamp = toISOTimestamp(
+ fakeClock("2025-01-01T00:00:00.000Z"),
+ );
+ const task = buildTask({
+ is_hidden: true,
+ appear_date: "2026-08-01",
+ updated_at: staleTimestamp,
+ });
+ const { taskService } = createTestContext({
+ getById: vi.fn().mockResolvedValue(task),
+ });
+
+ const unhidden = await taskService.update(task.id, {
+ is_hidden: false,
+ appear_date: "",
+ });
+
+ expect(unhidden.updated_at).not.toBe(staleTimestamp);
+ });
+
+ it("should clear appear_date to empty string when manually unhiding a task", async () => {
+ const task = buildTask({ is_hidden: true, appear_date: "2026-08-01" });
+ const { taskService } = createTestContext({
+ getById: vi.fn().mockResolvedValue(task),
+ });
+
+ const unhidden = await taskService.update(task.id, {
+ is_hidden: false,
+ appear_date: "",
+ });
+
+ expect(unhidden.appear_date).toBe("");
+ expect(unhidden.is_hidden).toBe(false);
+ });
+
+ it("should set syncStatus to pending when manually unhiding a task", async () => {
+ const task = buildTask({
+ is_hidden: true,
+ appear_date: "2026-08-01",
+ syncStatus: "synced" as const,
+ });
+ const { taskService } = createTestContext({
+ getById: vi.fn().mockResolvedValue(task),
+ });
+
+ const unhidden = await taskService.update(task.id, {
+ is_hidden: false,
+ appear_date: "",
+ });
+
+ expect(unhidden.syncStatus).toBe("pending");
+ });
+ });
+
describe("noncomplete", () => {
it("should set is_completed to false", async () => {
const task = buildTask({ is_completed: true, box: "inbox" });
diff --git a/packages/client/src/services/TaskService.recurring-completion.test.ts b/packages/client/src/services/TaskService.recurring-completion.test.ts
index a0f614b9..395d12a0 100644
--- a/packages/client/src/services/TaskService.recurring-completion.test.ts
+++ b/packages/client/src/services/TaskService.recurring-completion.test.ts
@@ -212,13 +212,15 @@ describe("TaskService - Recurring Tasks Integration", () => {
});
const completedTask = buildCompletedTask(existingTask);
+ const cloneCreationError = new Error("DB error");
mockTaskRepository.getById = vi.fn().mockResolvedValue(existingTask);
mockTaskRepository.update = vi.fn().mockResolvedValue(completedTask);
- mockTaskRepository.create = vi
- .fn()
- .mockRejectedValue(new Error("DB error"));
+ mockTaskRepository.create = vi.fn().mockRejectedValue(cloneCreationError);
mockChecklistRepository.getActiveByTaskId = vi.fn().mockResolvedValue([]);
+ const consoleErrorSpy = vi
+ .spyOn(console, "error")
+ .mockImplementation(() => {});
const result = await taskService.complete("task-1");
@@ -226,6 +228,12 @@ describe("TaskService - Recurring Tasks Integration", () => {
expect(result.completed).toBeDefined();
expect(result.completed.is_completed).toBe(true);
expect(result.recurringResult.status).toBe("skipped_invalid_rule");
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
+ "Failed to create recurring task:",
+ cloneCreationError,
+ );
+
+ consoleErrorSpy.mockRestore();
});
});
diff --git a/packages/client/src/services/TaskService.ts b/packages/client/src/services/TaskService.ts
index cc65cdda..3716116d 100644
--- a/packages/client/src/services/TaskService.ts
+++ b/packages/client/src/services/TaskService.ts
@@ -431,11 +431,18 @@ export class TaskService {
});
if (needsRebalancing(newSortOrder)) {
- await this.rebalanceBox(task.box);
+ await this.rebalanceBox(task.box, taskId);
}
}
- private async rebalanceBox(box: Box): Promise {
+ /**
+ * Implements FR4 of fix-stale-sync-overwrites.
+ * Assigns fresh sort_order keys and syncStatus "pending" to every task
+ * in the box without touching their updated_at, since rebalancing is a
+ * system-initiated reshuffle, not a user edit. The dragged task (already
+ * refreshed by reorderTasks) keeps its freshly refreshed updated_at.
+ */
+ private async rebalanceBox(box: Box, draggedTaskId: string): Promise {
const tasks = await this.taskRepository.getByBox(box);
const sorted = this.sortBySortOrder(tasks);
const newKeys = rebalanceKeys(sorted.length);
@@ -443,7 +450,7 @@ export class TaskService {
const rebalancedTasks = sorted.map((task, index) => ({
...task,
sort_order: newKeys[index],
- updated_at: now,
+ updated_at: task.id === draggedTaskId ? now : task.updated_at,
syncStatus: "pending" as const,
}));
await this.taskRepository.bulkUpsert(rebalancedTasks);
diff --git a/packages/client/src/test/features/file_attachments/steps/attachment_repository_sync.steps.ts b/packages/client/src/test/features/file_attachments/steps/attachment_repository_sync.steps.ts
index 3f0ac34d..77fa7a72 100644
--- a/packages/client/src/test/features/file_attachments/steps/attachment_repository_sync.steps.ts
+++ b/packages/client/src/test/features/file_attachments/steps/attachment_repository_sync.steps.ts
@@ -5,15 +5,24 @@ import type { WireAttachment } from "@clear-progress/contract";
import { expect, type TestContext } from "vitest";
import { db } from "@/db/database";
import { AttachmentRepository } from "@/db/repositories/AttachmentRepository";
+import { Temporal } from "@/lib/temporal";
import { buildAttachment } from "@/test/factories/attachmentFactory";
import type { Attachment } from "@/types/entities";
+import { toISOTimestamp } from "@/utils/dateHelpers";
const feature = await loadFeature("../attachment_repository_sync.feature");
+// FR5 of fix-stale-sync-overwrites: strictly before any `buildAttachment()`
+// call in this suite, so a server record built with this timestamp is never
+// strictly newer than a local pending record.
+const OLDER_THAN_LOCAL_ATTACHMENT_TIMESTAMP = toISOTimestamp(
+ Temporal.Instant.from("2020-01-01T00:00:00.000Z"),
+);
+
function buildWireAttachment(
overrides: Partial = {},
): WireAttachment {
- const now = new Date().toISOString();
+ const now = toISOTimestamp();
return {
id: crypto.randomUUID(),
entity_type: "task",
@@ -159,9 +168,14 @@ describeFeature(
When(
"applyServerRecords is called with a record having the same id",
async (_ctx: TestContext) => {
+ // FR5 of fix-stale-sync-overwrites: a pending local record is only
+ // preserved when the server record is not strictly newer, so the
+ // server record here is given an older updated_at than the local
+ // pending record to deterministically exercise that branch.
const serverRecord = buildWireAttachment({
id: localAttachment.id,
filename: "server-version.pdf",
+ updated_at: OLDER_THAN_LOCAL_ATTACHMENT_TIMESTAMP,
});
await repository.applyServerRecords([serverRecord]);
},
diff --git a/packages/client/src/test/features/repeating_tasks/dedup_merge.feature b/packages/client/src/test/features/repeating_tasks/dedup_merge.feature
new file mode 100644
index 00000000..7b522b0f
--- /dev/null
+++ b/packages/client/src/test/features/repeating_tasks/dedup_merge.feature
@@ -0,0 +1,29 @@
+Feature: Dedup Merge After Two-Device Double Completion
+ Implements FR3, FR6 of fix-stale-sync-overwrites.
+
+ @fix-stale-sync-overwrites @FR3
+ Scenario: Fixed schedule — earlier device's schedule wins with the later device's fresh content
+ Given device A completed and edited the occurrence, producing a copy with next_date "2026-07-10" and description "v2" as the freshest update
+ And device B completed the same occurrence offline, producing a copy with next_date "2026-07-09" and description "v1" as a staler update
+ When deduplication runs after device B pulls both copies
+ Then the surviving copy has next_date "2026-07-09"
+ And the surviving copy has description "v2"
+ And the surviving copy's updated_at equals device A's update time
+ And device A's copy is soft-deleted with syncStatus "pending"
+
+ @fix-stale-sync-overwrites @FR6
+ Scenario: After-completion schedule — the same merge rules apply regardless of the recurrence model
+ Given device A completed the occurrence under an after_completion repeat_rule, producing a copy with next_date "2026-07-04" as the freshest update
+ And device B completed the same occurrence offline, producing a copy with next_date "2026-07-05" as a staler update
+ When deduplication runs after device B pulls both copies
+ Then the surviving copy has next_date "2026-07-04"
+ And the surviving copy's repeat_rule is unchanged from before the merge
+ And the surviving copy's appear_date is consistent with its own next_date
+
+ @fix-stale-sync-overwrites @FR3
+ Scenario: Merge is not a verbatim keep-earliest — schedule and content are combined from different copies
+ Given device A completed and edited the occurrence, producing a copy with next_date "2026-07-10" and description "v2" as the freshest update
+ And device B completed the same occurrence offline, producing a copy with next_date "2026-07-09" and description "v1" as a staler update
+ When deduplication runs after device B pulls both copies
+ Then the surviving copy does not equal device B's copy verbatim
+ And the surviving copy does not equal device A's copy verbatim
diff --git a/packages/client/src/test/features/sync_protocol/pull_lww_protection.feature b/packages/client/src/test/features/sync_protocol/pull_lww_protection.feature
new file mode 100644
index 00000000..10cbf6df
--- /dev/null
+++ b/packages/client/src/test/features/sync_protocol/pull_lww_protection.feature
@@ -0,0 +1,38 @@
+Feature: Pull LWW Protection
+ Implements FR5 of fix-stale-sync-overwrites.
+
+ @fix-stale-sync-overwrites @FR5
+ Scenario: A locally-pending edit is overwritten when another device's edit is newer
+ Given a task edited locally and pending sync, with a locally-pending goal edited the same way
+ When the same records are pulled from the server with a strictly newer edit from another device
+ Then both records show the other device's edit and are marked as synced
+
+ @fix-stale-sync-overwrites @FR5
+ Scenario: A locally-pending edit survives a pull of an equally old server record
+ Given a task edited locally and pending sync, with a locally-pending goal edited the same way
+ When the same records are pulled from the server with no newer edit from another device, timestamps tied
+ Then both records still show the local edit and remain pending sync
+
+ @fix-stale-sync-overwrites @FR5
+ Scenario: A locally-pending edit survives a pull of an older server record
+ Given a task edited locally and pending sync, with a locally-pending goal edited the same way
+ When the same records are pulled from the server with an older, stale edit from another device
+ Then both records still show the local edit and remain pending sync
+
+ @fix-stale-sync-overwrites @FR5
+ Scenario: A synced record always reflects the latest pull
+ Given a task already in sync with the server, with a goal already in sync the same way
+ When the same records are pulled from the server with a newer edit from another device
+ Then both records show the other device's edit and are marked as synced
+
+ @fix-stale-sync-overwrites @FR5
+ Scenario: A new record from another device appears after its first pull
+ Given no local copy of a task or a goal exists yet
+ When the same records are pulled from the server for the first time
+ Then both records appear locally and are marked as synced
+
+ @fix-stale-sync-overwrites @FR5
+ Scenario: An overwritten pending edit is logged as a sync conflict for debugging
+ Given a task edited locally and pending sync
+ When the task is pulled from the server with a strictly newer edit from another device
+ Then a sync conflict is logged with the task's id and both devices' timestamps
diff --git a/packages/client/src/test/features/sync_protocol/rebalance_sync.feature b/packages/client/src/test/features/sync_protocol/rebalance_sync.feature
new file mode 100644
index 00000000..c1abfdf6
--- /dev/null
+++ b/packages/client/src/test/features/sync_protocol/rebalance_sync.feature
@@ -0,0 +1,16 @@
+Feature: Sort Order Rebalance Sync
+ Implements FR4 of fix-stale-sync-overwrites.
+
+ @fix-stale-sync-overwrites @FR4
+ Scenario: A task swept into rebalancing keeps the timestamp of its last real edit
+ Given a box "inbox" with tasks needing rebalance, where task "other" has updated_at "2025-01-01T00:00:00.000Z"
+ When the user drags task "dragged" to trigger rebalancing
+ Then task "other" has syncStatus "pending"
+ And task "other"'s updated_at is still "2025-01-01T00:00:00.000Z"
+
+ @fix-stale-sync-overwrites @FR4
+ Scenario: The task the user actually reordered gets a fresh timestamp
+ Given a box "inbox" with tasks needing rebalance, where task "dragged" has updated_at "2025-01-01T00:00:00.000Z"
+ When the user drags task "dragged" to trigger rebalancing
+ Then task "dragged" has syncStatus "pending"
+ And task "dragged"'s updated_at is refreshed past "2025-01-01T00:00:00.000Z"
diff --git a/packages/client/src/test/features/sync_protocol/stale_reveal_sync.feature b/packages/client/src/test/features/sync_protocol/stale_reveal_sync.feature
new file mode 100644
index 00000000..0490c1cb
--- /dev/null
+++ b/packages/client/src/test/features/sync_protocol/stale_reveal_sync.feature
@@ -0,0 +1,32 @@
+Feature: Stale-Safe Reveal Sync
+ Implements FR1 of fix-stale-sync-overwrites.
+
+ @fix-stale-sync-overwrites @FR1
+ Scenario: Task revealed when appear date arrives preserves updated_at
+ Given a hidden task with appear_date "2026-06-15", updated_at "2026-06-01T10:00:00.000Z" and today is "2026-06-15"
+ When system reveals hidden tasks
+ Then the task has is_hidden false and syncStatus "pending"
+ And the task's updated_at is still "2026-06-01T10:00:00.000Z"
+
+ @fix-stale-sync-overwrites @FR1
+ Scenario: Reveal of an already-pending record does not degrade its state
+ Given a hidden task with appear_date "2026-06-15", updated_at "2026-06-01T10:00:00.000Z", syncStatus "pending" and today is "2026-06-15"
+ When system reveals hidden tasks
+ Then the task has is_hidden false and syncStatus "pending"
+ And the task's updated_at is still "2026-06-01T10:00:00.000Z"
+
+ @fix-stale-sync-overwrites @FR2
+ Scenario: Manual hide sets appear_date, refreshes updated_at, and marks pending for sync
+ Given a visible task with updated_at "2026-06-01T10:00:00.000Z" and syncStatus "synced"
+ When the user manually hides the task with appear_date "2026-08-01"
+ Then the task has is_hidden true and appear_date "2026-08-01"
+ And the task's updated_at is refreshed past "2026-06-01T10:00:00.000Z"
+ And the task has syncStatus "pending"
+
+ @fix-stale-sync-overwrites @FR2
+ Scenario: Manual unhide before appear date clears appear_date, refreshes updated_at, and marks pending for sync
+ Given a hidden task with appear_date "2026-08-01", updated_at "2026-06-01T10:00:00.000Z" and syncStatus "synced"
+ When the user manually unhides the task
+ Then the task has is_hidden false and appear_date ""
+ And the task's updated_at is refreshed past "2026-06-01T10:00:00.000Z"
+ And the task has syncStatus "pending"
diff --git a/packages/client/src/test/features/sync_protocol/steps/rebalance_sync.steps.ts b/packages/client/src/test/features/sync_protocol/steps/rebalance_sync.steps.ts
new file mode 100644
index 00000000..aa572a88
--- /dev/null
+++ b/packages/client/src/test/features/sync_protocol/steps/rebalance_sync.steps.ts
@@ -0,0 +1,143 @@
+// implements FR4 of fix-stale-sync-overwrites
+import "fake-indexeddb/auto";
+import type { FeatureDescriibeCallbackParams } from "@amiceli/vitest-cucumber";
+import { describeFeature, loadFeature } from "@amiceli/vitest-cucumber";
+import { expect, type TestContext } from "vitest";
+import { SORT_ORDER_REBALANCE_THRESHOLD } from "@/constants";
+import { db } from "@/db/database";
+import { ChecklistRepository } from "@/db/repositories/ChecklistRepository";
+import { TaskRepository } from "@/db/repositories/TaskRepository";
+import { TaskService } from "@/services/TaskService";
+import { buildTask } from "@/test/factories/taskFactory";
+import type { Task } from "@/types/entities";
+
+const feature = await loadFeature("../rebalance_sync.feature");
+
+type Context = Record;
+
+const REBALANCE_TRIGGER_SORT_ORDER = "a".repeat(
+ SORT_ORDER_REBALANCE_THRESHOLD + 1,
+);
+
+describeFeature(feature, (f: FeatureDescriibeCallbackParams) => {
+ let taskService: TaskService;
+ let taskIdsByLabel: Record;
+
+ f.BeforeEachScenario(async () => {
+ await db.tasks.clear();
+ taskService = new TaskService(
+ new TaskRepository(),
+ new ChecklistRepository(),
+ );
+ taskIdsByLabel = {};
+ });
+
+ async function seedBoxNeedingRebalance(
+ labelWithStaleTimestamp: string,
+ staleTimestamp: string,
+ ) {
+ const draggedTask = buildTask({
+ box: "inbox",
+ sort_order: "a0",
+ updated_at:
+ labelWithStaleTimestamp === "dragged"
+ ? staleTimestamp
+ : "2025-01-01T00:00:00.000Z",
+ syncStatus: "synced" as const,
+ });
+ const otherTask = buildTask({
+ box: "inbox",
+ sort_order: "b0",
+ updated_at:
+ labelWithStaleTimestamp === "other"
+ ? staleTimestamp
+ : "2025-01-01T00:00:00.000Z",
+ syncStatus: "synced" as const,
+ });
+ taskIdsByLabel.dragged = draggedTask.id;
+ taskIdsByLabel.other = otherTask.id;
+ await db.tasks.bulkAdd([draggedTask, otherTask]);
+ }
+
+ // @fix-stale-sync-overwrites @FR4
+ f.Scenario(
+ "A task swept into rebalancing keeps the timestamp of its last real edit",
+ ({ Given, When, Then, And }) => {
+ Given(
+ 'a box "inbox" with tasks needing rebalance, where task "other" has updated_at "2025-01-01T00:00:00.000Z"',
+ async (_ctx: TestContext) => {
+ await seedBoxNeedingRebalance("other", "2025-01-01T00:00:00.000Z");
+ },
+ );
+
+ When(
+ 'the user drags task "dragged" to trigger rebalancing',
+ async (_ctx: TestContext) => {
+ await taskService.reorderTasks(
+ taskIdsByLabel.dragged,
+ REBALANCE_TRIGGER_SORT_ORDER,
+ );
+ },
+ );
+
+ Then(
+ 'task "other" has syncStatus "pending"',
+ async (_ctx: TestContext) => {
+ const otherTask = (await db.tasks.get(taskIdsByLabel.other)) as Task;
+ expect(otherTask.syncStatus).toBe("pending");
+ },
+ );
+
+ And(
+ 'task "other"\'s updated_at is still "2025-01-01T00:00:00.000Z"',
+ async (_ctx: TestContext) => {
+ const otherTask = (await db.tasks.get(taskIdsByLabel.other)) as Task;
+ expect(otherTask.updated_at).toBe("2025-01-01T00:00:00.000Z");
+ },
+ );
+ },
+ );
+
+ // @fix-stale-sync-overwrites @FR4
+ f.Scenario(
+ "The task the user actually reordered gets a fresh timestamp",
+ ({ Given, When, Then, And }) => {
+ Given(
+ 'a box "inbox" with tasks needing rebalance, where task "dragged" has updated_at "2025-01-01T00:00:00.000Z"',
+ async (_ctx: TestContext) => {
+ await seedBoxNeedingRebalance("dragged", "2025-01-01T00:00:00.000Z");
+ },
+ );
+
+ When(
+ 'the user drags task "dragged" to trigger rebalancing',
+ async (_ctx: TestContext) => {
+ await taskService.reorderTasks(
+ taskIdsByLabel.dragged,
+ REBALANCE_TRIGGER_SORT_ORDER,
+ );
+ },
+ );
+
+ Then(
+ 'task "dragged" has syncStatus "pending"',
+ async (_ctx: TestContext) => {
+ const draggedTask = (await db.tasks.get(
+ taskIdsByLabel.dragged,
+ )) as Task;
+ expect(draggedTask.syncStatus).toBe("pending");
+ },
+ );
+
+ And(
+ 'task "dragged"\'s updated_at is refreshed past "2025-01-01T00:00:00.000Z"',
+ async (_ctx: TestContext) => {
+ const draggedTask = (await db.tasks.get(
+ taskIdsByLabel.dragged,
+ )) as Task;
+ expect(draggedTask.updated_at).not.toBe("2025-01-01T00:00:00.000Z");
+ },
+ );
+ },
+ );
+});
diff --git a/packages/integration/src/convergence-helpers.ts b/packages/integration/src/convergence-helpers.ts
new file mode 100644
index 00000000..94c40d04
--- /dev/null
+++ b/packages/integration/src/convergence-helpers.ts
@@ -0,0 +1,142 @@
+// implements NFR-REL1 of fix-stale-sync-overwrites
+import type { Page } from "@playwright/test";
+import { expect } from "@playwright/test";
+import type { ServerCallCredentials } from "./server-api.js";
+import { pullFromServer } from "./server-api.js";
+
+/** Dexie database name — must match `DB_NAME` in packages/client/src/constants/index.ts. */
+const DEXIE_DB_NAME = "clear-progress";
+
+/** Entity table keys this helper knows how to dump, mapped to their Dexie store name. */
+const ENTITY_TABLE_NAMES = {
+ tasks: "tasks",
+ goals: "goals",
+ contexts: "contexts",
+ categories: "categories",
+ checklist_items: "checklist_items",
+ ideas: "ideas",
+} as const;
+
+/** Entity key accepted by the convergence helpers (extensible to all synced tables). */
+export type ConvergenceEntityKey = keyof typeof ENTITY_TABLE_NAMES;
+
+/** Pull response entity array key matching each `ConvergenceEntityKey` (server payload uses the same name). */
+const SERVER_ENTITY_KEYS = ENTITY_TABLE_NAMES;
+
+/**
+ * Client-only bookkeeping fields that never appear in the server record and
+ * must be stripped before comparing a device snapshot against the server.
+ */
+const CLIENT_ONLY_FIELDS = ["syncStatus"] as const;
+
+/** A single entity record read from either an IndexedDB store or the server. */
+type EntityRecord = Record;
+
+/**
+ * Reads every record from a single Dexie object store on the given page,
+ * via the raw IndexedDB API (no app code involved, works regardless of
+ * whether the app's Dexie instance is currently open in that page).
+ */
+async function dumpIndexedDbStore(
+ testPage: Page,
+ storeName: string,
+): Promise {
+ return testPage.evaluate(
+ ({ dbName, store }) => {
+ return new Promise((resolve, reject) => {
+ const request = indexedDB.open(dbName);
+ request.onerror = () => reject(request.error);
+ request.onsuccess = () => {
+ const database = request.result;
+ if (!database.objectStoreNames.contains(store)) {
+ database.close();
+ resolve([]);
+ return;
+ }
+ const transaction = database.transaction(store, "readonly");
+ const getAllRequest = transaction.objectStore(store).getAll();
+ getAllRequest.onsuccess = () => {
+ database.close();
+ resolve(getAllRequest.result as EntityRecord[]);
+ };
+ getAllRequest.onerror = () => {
+ database.close();
+ reject(getAllRequest.error);
+ };
+ };
+ });
+ },
+ { dbName: DEXIE_DB_NAME, store: storeName },
+ );
+}
+
+/** Strips client-only fields so a device snapshot is comparable to a server snapshot. */
+function stripClientOnlyFields(record: EntityRecord): EntityRecord {
+ const normalized = { ...record };
+ for (const field of CLIENT_ONLY_FIELDS) {
+ delete normalized[field];
+ }
+ return normalized;
+}
+
+/** Sorts records by `id` so array order never causes a spurious diff. */
+function sortById(records: EntityRecord[]): EntityRecord[] {
+ return [...records].sort((a, b) =>
+ String(a.id ?? "").localeCompare(String(b.id ?? "")),
+ );
+}
+
+/**
+ * Dumps one entity table from a device's IndexedDB, normalized for
+ * comparison: client-only fields stripped, sorted by `id`.
+ * Implements NFR-REL1 of fix-stale-sync-overwrites.
+ */
+export async function dumpDeviceState(
+ testPage: Page,
+ entityKey: ConvergenceEntityKey,
+): Promise {
+ const records = await dumpIndexedDbStore(
+ testPage,
+ ENTITY_TABLE_NAMES[entityKey],
+ );
+ return sortById(records.map(stripClientOnlyFields));
+}
+
+/**
+ * Dumps one entity table from the server via the pull endpoint, normalized
+ * the same way as `dumpDeviceState` so the two are directly comparable.
+ * Implements NFR-REL1 of fix-stale-sync-overwrites.
+ */
+export async function dumpServerState(
+ credentials: ServerCallCredentials,
+ entityKey: ConvergenceEntityKey,
+): Promise {
+ const pullResponse =
+ await pullFromServer>(
+ credentials,
+ );
+ const records = pullResponse[SERVER_ENTITY_KEYS[entityKey]] ?? [];
+ return sortById(records.map(stripClientOnlyFields));
+}
+
+/**
+ * Asserts that device A, device B, and the server all hold an identical set
+ * of records for the given entity table — the NFR-REL1 convergence check.
+ * Call after both devices have completed a push+pull cycle.
+ * Implements NFR-REL1 of fix-stale-sync-overwrites.
+ */
+export async function assertConverged(
+ pageA: Page,
+ pageB: Page,
+ credentials: ServerCallCredentials,
+ entityKey: ConvergenceEntityKey,
+): Promise {
+ const [deviceAState, deviceBState, serverState] = await Promise.all([
+ dumpDeviceState(pageA, entityKey),
+ dumpDeviceState(pageB, entityKey),
+ dumpServerState(credentials, entityKey),
+ ]);
+
+ expect(deviceAState).toEqual(serverState);
+ expect(deviceBState).toEqual(serverState);
+}
diff --git a/packages/integration/src/page-actions.ts b/packages/integration/src/page-actions.ts
index 747b384c..bba3125c 100644
--- a/packages/integration/src/page-actions.ts
+++ b/packages/integration/src/page-actions.ts
@@ -47,6 +47,59 @@ export async function updateTaskName(
await page.getByTestId("task-detail-name").blur();
}
+/**
+ * Updates a task's description from the task detail panel.
+ * `EditableDescription` is click-to-edit: at rest it renders a div, and only
+ * becomes a textarea after being clicked. Saving happens on blur.
+ */
+export async function updateTaskDescription(
+ page: Page,
+ newDescription: string,
+): Promise {
+ const descriptionField = page.getByTestId("task-detail-description");
+ await descriptionField.click();
+ await descriptionField.fill(newDescription);
+ await descriptionField.blur();
+}
+
+/**
+ * Hides a non-recurring task until the given ISO date via the task detail
+ * panel's "Hide until" drill-down row. Assumes the task detail panel is
+ * already open (see `openTaskDetail`).
+ * Implements FR2 of fix-stale-sync-overwrites.
+ */
+export async function hideTaskUntil(
+ page: Page,
+ appearDateIso: string,
+): Promise {
+ await page
+ .getByTestId("task-detail-panel")
+ .getByRole("button", { name: /hide until/i })
+ .click();
+ const hidePanel = page.getByTestId("hide-task-panel");
+ await hidePanel.waitFor({ state: "visible" });
+ await hidePanel.getByLabel(/select date/i).fill(appearDateIso);
+ await hidePanel.getByRole("button", { name: /^hide$/i }).click();
+ await hidePanel.waitFor({ state: "hidden" });
+}
+
+/**
+ * Manually unhides a task before its appear_date via the task detail panel's
+ * "Hide until" drill-down row, which shows the unhide control while the task
+ * is hidden. Assumes the task detail panel is already open.
+ * Implements FR2 of fix-stale-sync-overwrites.
+ */
+export async function unhideTask(page: Page): Promise {
+ await page
+ .getByTestId("task-detail-panel")
+ .getByRole("button", { name: /hide until/i })
+ .click();
+ const hidePanel = page.getByTestId("hide-task-panel");
+ await hidePanel.waitFor({ state: "visible" });
+ await hidePanel.getByRole("button", { name: /unhide/i }).click();
+ await hidePanel.waitFor({ state: "hidden" });
+}
+
export async function deleteTaskFromDetail(page: Page): Promise {
await page
.getByTestId("task-detail-panel")
diff --git a/packages/integration/src/test-helpers.ts b/packages/integration/src/test-helpers.ts
index 58e69ada..ae7a27ab 100644
--- a/packages/integration/src/test-helpers.ts
+++ b/packages/integration/src/test-helpers.ts
@@ -1,6 +1,12 @@
// implements FR6 of add-supabase-integration-tests
// Barrel re-export — actual code lives in focused modules.
+export type { ConvergenceEntityKey } from "./convergence-helpers.js";
+export {
+ assertConverged,
+ dumpDeviceState,
+ dumpServerState,
+} from "./convergence-helpers.js";
export { createIsolatedUser } from "./cross-tenant-helpers.js";
export {
buildCategoryPayload,
diff --git a/packages/integration/src/tests/multi-device-stale-rebalance.spec.ts b/packages/integration/src/tests/multi-device-stale-rebalance.spec.ts
new file mode 100644
index 00000000..f6ad4af2
--- /dev/null
+++ b/packages/integration/src/tests/multi-device-stale-rebalance.spec.ts
@@ -0,0 +1,319 @@
+// implements U3, FR4 of fix-stale-sync-overwrites
+import { randomUUID } from "node:crypto";
+import type { Page } from "@playwright/test";
+import { expect, test } from "@playwright/test";
+import {
+ findTaskItem,
+ openTaskDetail,
+ updateTaskDescription,
+} from "../page-actions.js";
+import {
+ assertConverged,
+ pullFromServer,
+ pushToServer,
+ setupTwoDeviceTest,
+ triggerSyncAndWait,
+} from "../test-helpers.js";
+
+const { getPageA, getPageB, getCredentials } = setupTwoDeviceTest();
+
+// Mirrors SORT_ORDER_REBALANCE_THRESHOLD in packages/client/src/constants/index.ts
+// (integration tests never import client internals — see module boundary rule).
+const SORT_ORDER_REBALANCE_THRESHOLD = 10;
+
+const TASK_VISIBLE_TIMEOUT_MS = 15_000;
+const DRAG_STEPS = 12;
+const DRAG_SETTLE_MS = 300;
+
+interface RebalancePullResponse {
+ ok: boolean;
+ tasks: Array<{
+ id: string;
+ name: string;
+ description: string;
+ box: string;
+ sort_order: string;
+ is_deleted: boolean;
+ updated_at: string;
+ }>;
+ current_revision: number;
+}
+
+/**
+ * Builds a full push payload carrying a single "inbox" task with an explicit
+ * `sort_order`, so tests can pre-seed keys near/at the rebalance threshold
+ * without going through the app's own key-generation logic.
+ */
+function buildInboxTaskPayload(options: {
+ id: string;
+ name: string;
+ sortOrder: string;
+ updatedAt: string;
+}): Record {
+ return {
+ tasks: [
+ {
+ id: options.id,
+ name: options.name,
+ description: "",
+ box: "inbox",
+ is_completed: false,
+ is_deleted: false,
+ completed_at: "",
+ next_date: "",
+ appear_date: "",
+ context_id: "",
+ category_id: "",
+ goal_id: "",
+ repeat_rule: "",
+ is_hidden: false,
+ original_task_id: "",
+ sort_order: options.sortOrder,
+ created_at: options.updatedAt,
+ updated_at: options.updatedAt,
+ version: 1,
+ revision: 0,
+ },
+ ],
+ goals: [],
+ ideas: [],
+ contexts: [],
+ categories: [],
+ checklist_items: [],
+ attachments: [],
+ settings: [],
+ };
+}
+
+/**
+ * Drags a task item (located by its drag handle) vertically onto another
+ * task item's position, using low-level mouse events so dnd-kit's
+ * PointerSensor (activation distance 8px) reliably picks up the gesture.
+ */
+async function dragTaskOnto(
+ page: Page,
+ sourceTaskName: string,
+ targetTaskName: string,
+): Promise {
+ const sourceHandle = findTaskItem(page, sourceTaskName).getByRole("button", {
+ name: /drag/i,
+ });
+ const targetItem = findTaskItem(page, targetTaskName);
+
+ const sourceBox = await sourceHandle.boundingBox();
+ const targetBox = await targetItem.boundingBox();
+ if (!sourceBox || !targetBox) {
+ throw new Error("dragTaskOnto: could not resolve bounding boxes");
+ }
+
+ const startX = sourceBox.x + sourceBox.width / 2;
+ const startY = sourceBox.y + sourceBox.height / 2;
+ const endX = targetBox.x + targetBox.width / 2;
+ const endY = targetBox.y + targetBox.height / 2;
+
+ await page.mouse.move(startX, startY);
+ await page.mouse.down();
+ for (let step = 1; step <= DRAG_STEPS; step++) {
+ const progress = step / DRAG_STEPS;
+ await page.mouse.move(
+ startX + (endX - startX) * progress,
+ startY + (endY - startY) * progress,
+ );
+ }
+ await page.mouse.up();
+ await page.waitForTimeout(DRAG_SETTLE_MS);
+}
+
+/**
+ * U3 / FR4 / NFR-REL1 — a rebalance triggered on a stale device does not
+ * clobber content that a different device edited more recently.
+ *
+ * Mechanism: `TaskService.rebalanceBox()` (packages/client/src/services/TaskService.ts)
+ * fires whenever a drag's freshly-generated `sort_order` key exceeds
+ * SORT_ORDER_REBALANCE_THRESHOLD (10) characters — regardless of how many
+ * tasks are in the box. `fractional-indexing`'s `generateKeyBetween` grows a
+ * key by roughly one character whenever it inserts strictly between two
+ * keys that already differ only in their last character, so seeding two
+ * adjacent 10-char keys and dragging a third task to land exactly between
+ * them reliably produces an 11-char key in a single real drag — a genuine
+ * `rebalanceBox` invocation via the actual UI code path, not a mocked one.
+ *
+ * Scenario:
+ * 1. Seed the inbox box directly on the server (bypassing the UI, since
+ * crafting exact colliding sort keys via real drags would need many
+ * sequential drags): a "dragged" task at the top (highest key), task X
+ * in the middle (the one A will edit), and two adjacent anchor tasks
+ * with 10-char keys one character apart at the bottom.
+ * 2. Both devices sync to a converged baseline including all four tasks.
+ * 3. Device B goes stale — no more syncing until step 6.
+ * 4. Device A edits task X's description and pushes — X gets a strictly
+ * newer `updated_at` on the server.
+ * 5. Device B (still unaware of A's edit) drags "dragged" down onto
+ * "anchorHigh"'s position — anchorHigh shifts down to sit between
+ * "dragged" and "anchorLow", so "dragged"'s new key is generated
+ * strictly between anchorLow and anchorHigh. The resulting >10-char key
+ * triggers `rebalanceBox` for the whole box: X and both anchors are marked
+ * `pending` locally on B WITHOUT their `updated_at` being bumped (FR4);
+ * only "dragged" (the task the user actually moved) gets a fresh
+ * `updated_at`.
+ * 6. Device B syncs (pull then push). FR5's LWW pull protection must let
+ * A's genuinely newer server copy of X win over B's stale-but-`pending`
+ * local copy — B's rebalance-caused `pending` flag must not block it.
+ * The anchors and "dragged", which nobody else edited, keep their
+ * rebalanced `sort_order` (rebalance's own effect survives).
+ * 7. Convergence: device A, device B, and the server agree (NFR-REL1).
+ */
+test("Rebalance on a stale device does not clobber a task edited more recently elsewhere", async () => {
+ const pageA = getPageA();
+ const pageB = getPageB();
+ const credentials = getCredentials();
+
+ const suffix = Date.now();
+ const draggedName = `Rebalance Dragged ${suffix}`;
+ const taskXName = `Rebalance TaskX ${suffix}`;
+ const anchorHighName = `Rebalance AnchorHigh ${suffix}`;
+ const anchorLowName = `Rebalance AnchorLow ${suffix}`;
+ const freshDescription = `Freshest description from A ${suffix}`;
+
+ const draggedId = randomUUID();
+ const taskXId = randomUUID();
+ const anchorHighId = randomUUID();
+ const anchorLowId = randomUUID();
+
+ const staleTimestamp = "2020-01-01T00:00:00.000Z";
+ // 10-char anchor keys, one character apart at the last position: inserting
+ // strictly between them via generateKeyBetween yields an 11-char key.
+ const anchorLowKey = "a".repeat(SORT_ORDER_REBALANCE_THRESHOLD);
+ const anchorHighKey = `${"a".repeat(SORT_ORDER_REBALANCE_THRESHOLD - 1)}b`;
+ // DESC order (highest key first): dragged ("z") > taskX ("m") > anchorHigh > anchorLow.
+ const draggedKey = "z";
+ const taskXKey = "m";
+
+ const baseline = await pullFromServer(credentials);
+ const baselineRevision = baseline.current_revision;
+
+ // --- 1. Seed the box directly on the server ---
+ await pushToServer(
+ credentials,
+ buildInboxTaskPayload({
+ id: draggedId,
+ name: draggedName,
+ sortOrder: draggedKey,
+ updatedAt: staleTimestamp,
+ }),
+ );
+ await pushToServer(
+ credentials,
+ buildInboxTaskPayload({
+ id: taskXId,
+ name: taskXName,
+ sortOrder: taskXKey,
+ updatedAt: staleTimestamp,
+ }),
+ );
+ await pushToServer(
+ credentials,
+ buildInboxTaskPayload({
+ id: anchorHighId,
+ name: anchorHighName,
+ sortOrder: anchorHighKey,
+ updatedAt: staleTimestamp,
+ }),
+ );
+ await pushToServer(
+ credentials,
+ buildInboxTaskPayload({
+ id: anchorLowId,
+ name: anchorLowName,
+ sortOrder: anchorLowKey,
+ updatedAt: staleTimestamp,
+ }),
+ );
+
+ // --- 2. Both devices sync to a converged baseline ---
+ await pageA.goto("/tasks");
+ await pageA.waitForSelector('[data-testid="active-tasks-page"]');
+ await triggerSyncAndWait(pageA);
+ for (const name of [draggedName, taskXName, anchorHighName, anchorLowName]) {
+ await findTaskItem(pageA, name).waitFor({
+ state: "visible",
+ timeout: TASK_VISIBLE_TIMEOUT_MS,
+ });
+ }
+
+ await pageB.goto("/tasks");
+ await pageB.waitForSelector('[data-testid="active-tasks-page"]');
+ await triggerSyncAndWait(pageB);
+ for (const name of [draggedName, taskXName, anchorHighName, anchorLowName]) {
+ await findTaskItem(pageB, name).waitFor({
+ state: "visible",
+ timeout: TASK_VISIBLE_TIMEOUT_MS,
+ });
+ }
+
+ // --- 3. Device B goes stale from here — no more syncing until step 6 ---
+
+ // --- 4. Device A edits task X's description and pushes ---
+ await openTaskDetail(pageA, taskXName);
+ await updateTaskDescription(pageA, freshDescription);
+ await triggerSyncAndWait(pageA);
+
+ const { tasks: tasksAfterEdit } = await pullFromServer(
+ credentials,
+ baselineRevision,
+ );
+ const taskXAfterEdit = tasksAfterEdit.find(
+ (task) => task.id === taskXId && !task.is_deleted,
+ );
+ expect(taskXAfterEdit).toBeDefined();
+ expect(taskXAfterEdit?.description).toBe(freshDescription);
+ expect(taskXAfterEdit?.updated_at).not.toBe(staleTimestamp);
+
+ // --- 5. Device B (still stale) drags "dragged" onto anchorHigh's
+ // position, landing it strictly between the two anchor keys and
+ // triggering rebalanceBox for the whole box ---
+ await dragTaskOnto(pageB, draggedName, anchorHighName);
+
+ // --- 6. Device B syncs — FR5 LWW pull protection must let A's newer
+ // task X win over B's stale-but-pending rebalanced copy ---
+ await triggerSyncAndWait(pageB);
+
+ // --- 7. Convergence: A, B, and the server agree (NFR-REL1) ---
+ await assertConverged(pageA, pageB, credentials, "tasks");
+
+ const { tasks: finalTasks } = await pullFromServer(
+ credentials,
+ baselineRevision,
+ );
+ const finalTaskX = finalTasks.find(
+ (task) => task.id === taskXId && !task.is_deleted,
+ );
+ const finalAnchorHigh = finalTasks.find(
+ (task) => task.id === anchorHighId && !task.is_deleted,
+ );
+ const finalAnchorLow = finalTasks.find(
+ (task) => task.id === anchorLowId && !task.is_deleted,
+ );
+ const finalDragged = finalTasks.find(
+ (task) => task.id === draggedId && !task.is_deleted,
+ );
+
+ // A's newer content survives — B's rebalance-caused pending flag on task X
+ // did not block the pull from applying A's genuinely newer server copy.
+ expect(finalTaskX?.description).toBe(freshDescription);
+ expect(finalTaskX?.updated_at).not.toBe(staleTimestamp);
+
+ // The other rebalanced tasks (untouched by anyone but B's rebalance) keep
+ // freshly-generated, non-colliding sort keys — rebalance's own effect is
+ // not lost. All four keys must be distinct after rebalancing.
+ const allFinalKeys = [
+ finalDragged?.sort_order,
+ finalTaskX?.sort_order,
+ finalAnchorHigh?.sort_order,
+ finalAnchorLow?.sort_order,
+ ];
+ expect(
+ allFinalKeys.every((key) => typeof key === "string" && key.length > 0),
+ ).toBe(true);
+ expect(new Set(allFinalKeys).size).toBe(allFinalKeys.length);
+});
From eb3c5e4c92a8c6c6a1cbc4be134370dfc0771bd4 Mon Sep 17 00:00:00 2001
From: oinsio <8233537+oinsio@users.noreply.github.com>
Date: Sun, 19 Jul 2026 14:38:27 +0500
Subject: [PATCH 4/8] implement fix-stale-sync-overwrites OpenSpec
---
mutation-coverage-gap-analysis.md | 75 ++
.../fix-stale-sync-overwrites/design.md | 16 +-
.../fix-stale-sync-overwrites/proposal.md | 6 +-
.../specs/repeating-tasks/spec.md | 2 +-
.../fix-stale-sync-overwrites/tasks.md | 44 +-
.../src/db/repositories/ContextRepository.ts | 20 +-
...ervice.reveal-preserves-updated-at.test.ts | 144 ++++
.../src/services/HiddenTaskService.test.ts | 128 ----
.../RecurringTaskDeduplicator.guards.test.ts | 158 ++++
...TaskDeduplicator.loser-soft-delete.test.ts | 105 +++
...ator.merge.identity-and-updated-at.test.ts | 114 +++
...licator.merge.pending-when-changed.test.ts | 151 ++++
...eduplicator.merge.schedule-and-box.test.ts | 158 ++++
.../RecurringTaskDeduplicator.merge.ts | 6 +-
...ngTaskDeduplicator.multiple-groups.test.ts | 134 ++++
...TaskDeduplicator.skip-optimization.test.ts | 118 +++
.../RecurringTaskDeduplicator.test-setup.ts | 60 ++
.../RecurringTaskDeduplicator.test.ts | 692 ------------------
...ingTaskDeduplicator.tiebreak-by-id.test.ts | 67 ++
...gTaskDeduplicator.winner-selection.test.ts | 184 +++++
packages/integration/docker-compose.yml | 7 +-
packages/integration/kong.yml | 4 +
.../integration/src/convergence-helpers.ts | 16 +-
packages/integration/src/page-actions.ts | 272 -------
.../src/page-attachment-actions.ts | 27 +
.../integration/src/page-checklist-actions.ts | 26 +
.../src/page-goal-category-actions.ts | 115 +++
packages/integration/src/page-task-actions.ts | 159 ++++
.../src/tests/attachments-offline.spec.ts | 2 +-
.../tests/attachments-purge-advanced.spec.ts | 9 +-
.../tests/attachments-ref-counting.spec.ts | 9 +-
.../src/tests/attachments-sync.spec.ts | 2 +-
.../tests/cascade-checklist-delete.spec.ts | 6 +-
.../src/tests/categories-sync.spec.ts | 2 +-
.../src/tests/checklists-sync.spec.ts | 7 +-
.../integration/src/tests/covers-sync.spec.ts | 2 +-
.../tests/file-download-cross-device.spec.ts | 2 +-
.../src/tests/file-mime-detection.spec.ts | 7 +-
.../integration/src/tests/full-flow.spec.ts | 12 +-
.../integration/src/tests/goals-sync.spec.ts | 2 +-
.../multi-device-dirty-protection.spec.ts | 6 +-
.../multi-device-stale-rebalance.spec.ts | 242 ++----
.../src/tests/multi-device-sync.spec.ts | 8 +-
.../src/tests/push-poison-pill-fk.spec.ts | 5 +-
.../src/tests/push-rejected-indicator.spec.ts | 2 +-
.../src/tests/stale-rebalance-helpers.ts | 61 ++
.../src/tests/sync-indicator-race.spec.ts | 5 +-
.../integration/src/tests/tasks-sync.spec.ts | 2 +-
48 files changed, 2056 insertions(+), 1345 deletions(-)
create mode 100644 mutation-coverage-gap-analysis.md
create mode 100644 packages/client/src/services/HiddenTaskService.reveal-preserves-updated-at.test.ts
create mode 100644 packages/client/src/services/RecurringTaskDeduplicator.guards.test.ts
create mode 100644 packages/client/src/services/RecurringTaskDeduplicator.loser-soft-delete.test.ts
create mode 100644 packages/client/src/services/RecurringTaskDeduplicator.merge.identity-and-updated-at.test.ts
create mode 100644 packages/client/src/services/RecurringTaskDeduplicator.merge.pending-when-changed.test.ts
create mode 100644 packages/client/src/services/RecurringTaskDeduplicator.merge.schedule-and-box.test.ts
create mode 100644 packages/client/src/services/RecurringTaskDeduplicator.multiple-groups.test.ts
create mode 100644 packages/client/src/services/RecurringTaskDeduplicator.skip-optimization.test.ts
create mode 100644 packages/client/src/services/RecurringTaskDeduplicator.test-setup.ts
delete mode 100644 packages/client/src/services/RecurringTaskDeduplicator.test.ts
create mode 100644 packages/client/src/services/RecurringTaskDeduplicator.tiebreak-by-id.test.ts
create mode 100644 packages/client/src/services/RecurringTaskDeduplicator.winner-selection.test.ts
delete mode 100644 packages/integration/src/page-actions.ts
create mode 100644 packages/integration/src/page-attachment-actions.ts
create mode 100644 packages/integration/src/page-checklist-actions.ts
create mode 100644 packages/integration/src/page-goal-category-actions.ts
create mode 100644 packages/integration/src/page-task-actions.ts
create mode 100644 packages/integration/src/tests/stale-rebalance-helpers.ts
diff --git a/mutation-coverage-gap-analysis.md b/mutation-coverage-gap-analysis.md
new file mode 100644
index 00000000..51340b10
--- /dev/null
+++ b/mutation-coverage-gap-analysis.md
@@ -0,0 +1,75 @@
+# Mutation coverage gap analysis — task 6.2 (fix-stale-sync-overwrites)
+
+Scope: `TaskRepository.ts`, `GoalRepository.ts`, `ContextRepository.ts`, `CategoryRepository.ts`.
+
+## Summary
+
+Stryker's whole-file mutation score for these 4 files (53–56% for Goal/Context/Category, 94% for Task)
+is below the M3 threshold (>=95%, min 90%). All surviving/uncovered mutants sit in code this change
+did **not** touch. The code this change actually modified — the FR5 LWW wiring inside
+`applyServerRecords()` (via `shouldOverwritePendingLocalRecord` from `applyServerRecordLww.ts`) — has
+a 100% mutant kill rate in all 4 files. No mutant inside the diff introduced by this change survived
+or went uncovered.
+
+## Uncovered functionality, by file
+
+### GoalRepository.ts / ContextRepository.ts / CategoryRepository.ts (same shape in all three)
+
+1. **`create()` schema-validation branch** (Goal L22-29, Context L22-29, Category L22-29)
+ - Survived mutant: `if (!result.success)` → `if (false)` at line 24.
+ - Meaning: no test ever calls `create()` with data that fails `Client*Schema.safeParse`. The
+ error-logging (`console.error(...)`) and `throw new Error(...)` branch is never exercised, so a
+ mutant that deletes the branch condition survives.
+
+2. **`update()` schema-validation branch** (Goal L31-38, Context L31-38, Category L31-38)
+ - NoCoverage on the whole `if` block, both string literals (`""`,` `` `).
+ - Same gap as `create()`, but for `update()`.
+
+3. **`bulkUpsert()` per-item schema-validation branch** (Goal L40-49, Context L40-49, Category L40-49)
+ - NoCoverage on the `if (!result.success)` block inside the loop, including the `result.success`
+ boolean literal and both `ConditionalExpression` mutants (true/false).
+ - Meaning: no test calls `bulkUpsert()` with an item that fails validation; the loop's early
+ `throw` for a bad item is never reached.
+
+4. **`getChangedSince()`** (Goal L51-53, Context L51-53, Category L51-53)
+ - NoCoverage on the whole method body and its string literal.
+ - Meaning: no test calls `getChangedSince()` at all for these three repositories.
+
+5. **`getNeedingSync()` filter predicate** (Goal L55-57, Context L55-59, Category L55-59)
+ - NoCoverage on the whole filter arrow function, the `MethodExpression` (`db.goals`/`db.contexts`/
+ `db.categories`), the `EqualityOperator` (`syncStatus === "pending"` → `!== "pending"`), and both
+ `ConditionalExpression` mutants.
+ - Meaning: no test calls `getNeedingSync()` for these three repositories, so the pending-status
+ filter logic is unverified.
+
+### TaskRepository.ts
+
+1. **`getByGoalId()` visibility filter** (L35-47)
+ - Survived mutant: `options?.includeHidden || !task.is_hidden` → `true` at line 44 (the whole
+ filter condition replaced by a constant `true`).
+ - Meaning: existing tests call `getByGoalId()` but never assert that a *hidden* task is excluded
+ when `includeHidden` is falsy/omitted — so a mutant that makes the filter always pass survives.
+ Need a case where a hidden task exists and `includeHidden` is not set (or `false`), asserting it
+ is excluded from the result, alongside a case where `includeHidden: true` returns it.
+
+2. **Delete/soft-delete style validation branches** (L79-101, three near-identical blocks)
+ - NoCoverage on three `BlockStatement`s plus their string literals — same shape as the
+ create/update/bulkUpsert validation gaps above (this file has 3 write-path methods with
+ validation instead of 3 repositories × 1 pattern each).
+
+## Why this is out of this change's scope
+
+Every one of the above gaps pre-dates `fix-stale-sync-overwrites`: `create`, `update`, `bulkUpsert`,
+`getChangedSince`, `getNeedingSync`, and `getByGoalId` are unmodified by this change (confirmed via
+`git diff main` for each file — the diff is confined to the new import, the `*_ENTITY_NAME` constant,
+and the body of `applyServerRecords()`). FR5 and M3 (proposal.md) scope mutation testing to the
+behavior this change introduces; closing these gaps would mean writing schema-validation and
+query-filter characterization tests unrelated to LWW pull protection.
+
+## Disposition
+
+Per user decision: analysis recorded here; task 6.2 is considered satisfied on a diff-scoped basis
+(100% mutant kill rate within the actual change). No new tests were added for the pre-existing gaps
+listed above. If closing them is wanted later, it should be a separate, explicitly-scoped change
+(new tests for `create`/`update`/`bulkUpsert` validation branches, `getChangedSince`, `getNeedingSync`,
+and `getByGoalId`'s hidden-task filter across these 4 repositories).
diff --git a/openspec/changes/fix-stale-sync-overwrites/design.md b/openspec/changes/fix-stale-sync-overwrites/design.md
index 2fd80b9f..2d87797d 100644
--- a/openspec/changes/fix-stale-sync-overwrites/design.md
+++ b/openspec/changes/fix-stale-sync-overwrites/design.md
@@ -48,14 +48,14 @@ Consequence accepted: when a rebalanced record loses push LWW to a newer server
Winner selection is unchanged (earliest `next_date`, tiebreak by `id`). The winner record is then merged:
-| Fields | Source copy |
-|-------------------------------------------------------------------|--------------------------------------------------|
-| `next_date`, `appear_date`, `is_hidden` (always as a triple) | earliest `next_date` |
-| `name`, `description`, `goal_id`, `context_id`, `category_id` | freshest `updated_at` |
-| `box`, `sort_order` (as a pair — sort keys are per-box grids) | freshest `updated_at` |
-| `id`, `created_at`, `revision` (identity/bookkeeping) | winner's own |
-| `updated_at` | freshest copy's value — **not** refreshed to now |
-| `repeat_rule` differs between copies | freshest copy wins **wholesale**, dates included |
+| Fields | Source copy |
+|--------------------------------------------------------------------------------------|--------------------------------------------------|
+| `next_date`, `appear_date`, `is_hidden` (always as a triple) | earliest `next_date` |
+| `name`, `description`, `goal_id`, `context_id`, `category_id` | freshest `updated_at` |
+| `box`, `sort_order` (as a pair — sort keys are per-box grids) | freshest `updated_at` |
+| `id`, `created_at`, `revision` (identity/bookkeeping) | winner's own |
+| `updated_at` | freshest copy's value — **not** refreshed to now |
+| `repeat_rule` differs between the schedule winner and the freshest-`updated_at` copy | freshest copy wins **wholesale**, dates included |
`is_hidden` travels with the schedule triple because it is derived from that copy's `appear_date` (hidden iff not yet due); pairing it with another copy's dates would break reveal timing. `sort_order` travels with `box` because sort keys only order within a box. Completion fields need no rule: every copy in a duplicate group is non-completed by the group filter.
diff --git a/openspec/changes/fix-stale-sync-overwrites/proposal.md b/openspec/changes/fix-stale-sync-overwrites/proposal.md
index 4a2429b4..ccee006e 100644
--- a/openspec/changes/fix-stale-sync-overwrites/proposal.md
+++ b/openspec/changes/fix-stale-sync-overwrites/proposal.md
@@ -8,7 +8,7 @@ A multi-device user loses data on recurring tasks: a device that was offline for
- **MODIFIED**: Auto-reveal of hidden tasks (`HiddenTaskService`) marks records `pending` but no longer refreshes `updated_at` — the timestamp stays at the last real user edit.
- **MODIFIED**: Sort-order rebalancing (`TaskService.rebalanceBox`) marks rebalanced records `pending` but no longer refreshes their `updated_at`; only the task the user actually dragged gets a fresh timestamp.
-- **MODIFIED**: Recurring-copy deduplication after pull merges instead of blindly keeping the earliest copy: schedule fields (`next_date` + `appear_date`, always as a pair) come from the copy with the earliest `next_date`, content fields from the copy with the freshest `updated_at`; if `repeat_rule` differs between copies, the freshest copy wins wholesale.
+- **MODIFIED**: Recurring-copy deduplication after pull merges instead of blindly keeping the earliest copy: schedule fields (`next_date` + `appear_date`, always as a pair) come from the copy with the earliest `next_date`, content fields from the copy with the freshest `updated_at`; if `repeat_rule` differs between the schedule winner and the freshest-`updated_at` copy, the freshest copy wins wholesale.
- **MODIFIED**: Pull protection becomes LWW-consistent: a server record overwrites a local `pending` record when the server's `updated_at` is strictly newer; ties and older server records preserve the local record. Conflicts are logged.
- **UNCHANGED (guarded by tests)**: Manual hide and manual unhide-before-appear-date remain real user edits — they refresh `updated_at` and sync to other devices. They apply to non-recurring tasks only (recurring tasks expose no manual hide/unhide controls per `manual-task-hiding`); hidden recurring copies are governed solely by auto-reveal (FR1).
- Specs gain mermaid sequence diagrams for every multi-device sync scenario covered by this change.
@@ -22,7 +22,7 @@ A multi-device user loses data on recurring tasks: a device that was offline for
### Modified Capabilities
- `sync-protocol`: "Pull protects local dirty records" changes from status-based (never overwrite `pending`) to LWW-based (overwrite `pending` only when server `updated_at` is strictly newer).
-- `repeating-tasks`: "System reveals hidden tasks when appear date arrives" no longer refreshes `updated_at`; "System deduplicates recurring copies after pull" changes from keep-earliest-copy to merge (earliest schedule pair + the freshest content, freshest-wholesale on `repeat_rule` mismatch).
+- `repeating-tasks`: "System reveals hidden tasks when appear date arrives" no longer refreshes `updated_at`; "System deduplicates recurring copies after pull" changes from keep-earliest-copy to merge (earliest schedule pair + the freshest content, freshest-wholesale when `repeat_rule` differs between the schedule winner and the freshest-`updated_at` copy).
- `manual-task-hiding`: "Hidden tasks auto-reveal on appear date" no longer refreshes `updated_at`; new requirement that manual unhide before the appear date is a synced user edit visible on other devices.
- `tasks`: "Lazy rebalancing when key exceeds threshold" — rebalanced tasks are marked `pending` without an `updated_at` refresh.
@@ -52,7 +52,7 @@ A multi-device user loses data on recurring tasks: a device that was offline for
- FR1: `HiddenTaskService.revealHiddenTasks()` SHALL set `syncStatus: "pending"` and `is_hidden: false` without modifying `updated_at`. If the record is already `pending`, its state is not degraded.
- FR2: Manual hide and manual unhide (non-recurring tasks — the only kind with manual hide/unhide controls) SHALL remain regular user updates: refresh `updated_at`, set `pending`, clear `appear_date` on unhide — and SHALL propagate to other devices via ordinary push/pull.
-- FR3: `RecurringTaskDeduplicator` SHALL merge duplicate groups: the winner record keeps `next_date`, `appear_date`, and `is_hidden` (always as a schedule triple — `is_hidden` is derived from `appear_date`) from the copy with the earliest `next_date` (tiebreak by `id`); content fields (`name`, `description`, `goal_id`, `context_id`, `category_id`) and the pair `box` + `sort_order` (sort keys are per-box) from the copy with the freshest `updated_at`; identity fields (`id`, `created_at`, `revision`) stay the winner's own. When `repeat_rule` differs between copies, the copy with the freshest `updated_at` SHALL win wholesale (all fields including dates). Losers are soft-deleted as today. The merged winner's `updated_at` SHALL be the freshest copy's `updated_at` (not refreshed to now), and the winner SHALL be written with `syncStatus: "pending"` only when the merge actually changed it.
+- FR3: `RecurringTaskDeduplicator` SHALL merge duplicate groups: the winner record keeps `next_date`, `appear_date`, and `is_hidden` (always as a schedule triple — `is_hidden` is derived from `appear_date`) from the copy with the earliest `next_date` (tiebreak by `id`); content fields (`name`, `description`, `goal_id`, `context_id`, `category_id`) and the pair `box` + `sort_order` (sort keys are per-box) from the copy with the freshest `updated_at`; identity fields (`id`, `created_at`, `revision`) stay the winner's own. When `repeat_rule` differs between the schedule winner and the freshest-`updated_at` copy, the copy with the freshest `updated_at` SHALL win wholesale (all fields including dates). Losers are soft-deleted as today. The merged winner's `updated_at` SHALL be the freshest copy's `updated_at` (not refreshed to now), and the winner SHALL be written with `syncStatus: "pending"` only when the merge actually changed it.
- FR4: `TaskService.rebalanceBox()` SHALL assign new `sort_order` keys and `syncStatus: "pending"` without modifying `updated_at`. `reorderTasks()` SHALL keep refreshing `updated_at` only for the dragged task.
- FR5: `applyServerRecords()` in every entity repository SHALL overwrite a local `pending` record when the server record's `updated_at` is strictly newer than the local one, setting `syncStatus: "synced"`. When local `updated_at` is equal or newer, the local record SHALL be preserved. Each overwrite of a `pending` record SHALL be logged with entity type, id, and both timestamps.
- FR6: Behavior of both recurring models SHALL be preserved: `after_completion` next date derives only from the new `completed_at`; `fixed` keeps early-completion preservation and skip logic. (Regression guard — no recurrence math changes.)
diff --git a/openspec/changes/fix-stale-sync-overwrites/specs/repeating-tasks/spec.md b/openspec/changes/fix-stale-sync-overwrites/specs/repeating-tasks/spec.md
index 1e9433d7..928f4399 100644
--- a/openspec/changes/fix-stale-sync-overwrites/specs/repeating-tasks/spec.md
+++ b/openspec/changes/fix-stale-sync-overwrites/specs/repeating-tasks/spec.md
@@ -46,7 +46,7 @@ The winner SHALL be merged, not kept verbatim:
- Content fields (`name`, `description`, `goal_id`, `context_id`, `category_id`) and the pair `box` + `sort_order` (sort keys only order within a box) SHALL be taken from the copy with the freshest `updated_at`.
- Identity fields (`id`, `created_at`, `revision`) SHALL stay the winner's own. Completion fields need no merge rule: every copy in a duplicate group is non-completed by the group filter.
- The merged winner's `updated_at` SHALL equal the freshest copy's `updated_at`; deduplication SHALL NOT refresh it to the current time. The winner SHALL be written with `syncStatus: "pending"` only when the merge actually changed it; an already-optimal winner is not rewritten.
-- When `repeat_rule` differs between copies, the copy with the freshest `updated_at` SHALL win wholesale (all fields including `next_date` and `appear_date`), because a rule change recomputes its dates under the new rule.
+- When `repeat_rule` differs between the schedule winner and the freshest-`updated_at` copy, the copy with the freshest `updated_at` SHALL win wholesale (all fields including `next_date` and `appear_date`), because a rule change recomputes its dates under the new rule.
Merge semantics apply identically to both recurring models (`fixed` and `after_completion`); the earliest-`next_date` winner rule for schedules is unchanged, and for `after_completion` any suboptimal winner self-corrects at the next completion because its next date derives only from the new `completed_at`.
diff --git a/openspec/changes/fix-stale-sync-overwrites/tasks.md b/openspec/changes/fix-stale-sync-overwrites/tasks.md
index fa01c70a..676a26e7 100644
--- a/openspec/changes/fix-stale-sync-overwrites/tasks.md
+++ b/openspec/changes/fix-stale-sync-overwrites/tasks.md
@@ -58,24 +58,36 @@
Impact analysis for 7.1-7.4: `findFreshestUpdatedAtCopy` is reachable only via `mergeWinner` → `RecurringTaskDeduplicator.deduplicate` → `SyncService` pull path (`SyncService.ts:178`) — no other feature consumes it. For in-format timestamps (`toISOTimestamp` always emits fixed-precision `...000Z`) lexicographic and instant order coincide, so the swap is behavior-preserving and all existing FR3 tests must stay green unchanged. Divergence exists only for out-of-format data: same instant in different representations (localeCompare gives an arbitrary winner, `Temporal.Instant.compare` gives a correct tie → first copy in group order wins) and malformed `updated_at` (`Temporal.Instant.from` throws instead of silently misordering — same exposure the FR5 LWW helper already has in the same pull path). `sortByWinnerPriority` keeps `localeCompare` intentionally: `next_date` is date-only and `id` is not a timestamp — out of scope per the Temporal rule.
-- [ ] 7.1 Characterization test (pre-refactor guard): two copies with an equal `updated_at` — the first copy in group order stays the freshest-content source (tie semantics of the `reduce` documented before touching it) (FR3)
-- [ ] 7.2 REFACTOR: `findFreshestUpdatedAtCopy` compares via `Temporal.Instant.compare` (import from `@/lib/temporal`) instead of `String.prototype.localeCompare`, preserving tie semantics (keep `freshest` when compare <= 0) (FR3, aligns with design D4 convention)
-- [ ] 7.3 Scoped test run (one command, foreground): `RecurringTaskDeduplicator.merge.test.ts`, `RecurringTaskDeduplicator.test.ts`, `dedup_merge.steps.ts` — all green with no test edits beyond 7.1 (FR3)
-- [ ] 7.4 Scoped mutation run on `src/services/RecurringTaskDeduplicator.merge.ts` (wait for completion; no concurrent runs): diff-scoped kill rate stays 100%, whole-file score >= 95% (M3)
-- [ ] 7.5 Traceability: add `NFR-REL1` (and `M1` where the scenario implements it) to the `// implements ... of fix-stale-sync-overwrites` header comment of all four integration specs (`multi-device-stale-recurring-fixed`, `multi-device-stale-recurring-after-completion`, `multi-device-manual-unhide`, `multi-device-stale-rebalance`) (NFR-REL1, M1)
-- [ ] 7.6 Traceability: add `// Verifies M2 of fix-stale-sync-overwrites` comments to the unit tests asserting `updated_at` is untouched by system mutations: reveal (`HiddenTaskService.test.ts`), rebalance (`TaskService.reorder.test.ts`), dedup merge (`RecurringTaskDeduplicator.merge.test.ts`) (M2)
-- [ ] 7.7 Verification: traceability grep confirms NFR-REL1/M1/M2 references resolve; `get_file_problems` on touched files + `pnpm run build`
-- [ ] 7.8 Spec wording: align the wholesale clause with the implemented (and correct) behavior — replace "when `repeat_rule` differs between copies" with "when `repeat_rule` differs between the schedule winner and the freshest-`updated_at` copy" in `proposal.md` (What Changes bullet, Modified Capabilities bullet, FR3), `design.md` (D3 table row), and the delta spec `specs/repeating-tasks/spec.md` (wholesale bullet). Rationale stays: only those two copies' fields ever mix, so only their rule mismatch makes a merge incoherent; a differing rule on a stale loser contributes no fields and must NOT trigger wholesale (FR3)
-- [ ] 7.9 Pinning test for the 3-copy edge: schedule winner and freshest copy share `repeat_rule`, a stale loser carries a different one — normal merge applies (schedule triple from the winner + content from the freshest), NOT wholesale; loser is soft-deleted as usual (FR3)
+- [x] 7.1 Characterization test (pre-refactor guard): two copies with an equal `updated_at` — the first copy in group order stays the freshest-content source (tie semantics of the `reduce` documented before touching it) (FR3)
+- [x] 7.2 REFACTOR: `findFreshestUpdatedAtCopy` compares via `Temporal.Instant.compare` (import from `@/lib/temporal`) instead of `String.prototype.localeCompare`, preserving tie semantics (keep `freshest` when compare <= 0) (FR3, aligns with design D4 convention)
+- [x] 7.3 Scoped test run (one command, foreground): `RecurringTaskDeduplicator.merge.test.ts`, `RecurringTaskDeduplicator.test.ts`, `dedup_merge.steps.ts` — all green with no test edits beyond 7.1 (FR3)
+- [x] 7.4 Scoped mutation run on `src/services/RecurringTaskDeduplicator.merge.ts` (wait for completion; no concurrent runs): diff-scoped kill rate stays 100%, whole-file score >= 95% (M3)
+- [x] 7.5 Traceability: add `NFR-REL1` (and `M1` where the scenario implements it) to the `// implements ... of fix-stale-sync-overwrites` header comment of all four integration specs (`multi-device-stale-recurring-fixed`, `multi-device-stale-recurring-after-completion`, `multi-device-manual-unhide`, `multi-device-stale-rebalance`) (NFR-REL1, M1)
+- [x] 7.6 Traceability: add `// Verifies M2 of fix-stale-sync-overwrites` comments to the unit tests asserting `updated_at` is untouched by system mutations: reveal (`HiddenTaskService.test.ts`), rebalance (`TaskService.reorder.test.ts`), dedup merge (`RecurringTaskDeduplicator.merge.test.ts`) (M2)
+- [x] 7.7 Verification: traceability grep confirms NFR-REL1/M1/M2 references resolve; `get_file_problems` on touched files + `pnpm run build`
+- [x] 7.8 Spec wording: align the wholesale clause with the implemented (and correct) behavior — replace "when `repeat_rule` differs between copies" with "when `repeat_rule` differs between the schedule winner and the freshest-`updated_at` copy" in `proposal.md` (What Changes bullet, Modified Capabilities bullet, FR3), `design.md` (D3 table row), and the delta spec `specs/repeating-tasks/spec.md` (wholesale bullet). Rationale stays: only those two copies' fields ever mix, so only their rule mismatch makes a merge incoherent; a differing rule on a stale loser contributes no fields and must NOT trigger wholesale (FR3)
+- [x] 7.9 Pinning test for the 3-copy edge: schedule winner and freshest copy share `repeat_rule`, a stale loser carries a different one — normal merge applies (schedule triple from the winner + content from the freshest), NOT wholesale; loser is soft-deleted as usual (FR3)
## 8. Review fixes: file-size invariant for test files (process-invariants: 100-200 target, 300 hard cap)
Behavior-preserving reorganization — no test logic changes, no TDD cycle (refactoring exemption). Split boundaries follow the existing `describe` blocks; use the `split-large-tests` skill for 8.1-8.3. Shared builders/seed helpers go into a `RecurringTaskDeduplicator.test-setup.ts` module (established repo pattern: `*.test-setup.ts` next to repository tests). New files keep the `.test.ts` suffix so the vitest glob picks them up. BDD `.steps.ts` files are all within limits (max 282) — not in scope.
-- [ ] 8.1 Split `RecurringTaskDeduplicator.merge.test.ts` (598) into ~3 files by merge aspect, each <= 200: content fields from freshest copy (name/description/goal/context/category + equal-`updated_at` tie), schedule triple + box/sort_order pair coupling, identity fields + winner `updated_at` + wholesale `repeat_rule` + pending-only-when-changed; extract shared task builders into the test-setup module (FR3 coverage unchanged)
-- [ ] 8.2 Split `RecurringTaskDeduplicator.test.ts` (692) into ~3 files by concern, each <= 200: winner selection + tiebreak + filtering; loser soft-delete + checklist cascade + multiple/three-plus groups; skip optimization + guard describes; reuse the same test-setup module (FR3 characterization coverage unchanged)
-- [ ] 8.3 Split `HiddenTaskService.test.ts` (300): move the `revealHiddenTasks preserving updated_at` describe (FR1 block) into a sibling `HiddenTaskService.reveal-preserves-updated-at.test.ts`; both files end up <= 200 (FR1 coverage unchanged)
-- [ ] 8.4 Slim `multi-device-stale-rebalance.spec.ts` (319): move `dragTaskOnto` into `page-actions.ts` and `buildInboxTaskPayload` into a shared integration helper module; the spec keeps only the U3 scenario and lands <= 200 (U3/FR4/NFR-REL1 coverage unchanged)
-- [ ] 8.5 Verification: scoped vitest run (one command, foreground) over all files produced by 8.1-8.3 — green, and the total test count of the split groups equals the pre-split count (no scenario silently dropped)
-- [ ] 8.6 Verification: scoped mutation run on `src/services/RecurringTaskDeduplicator.ts,src/services/RecurringTaskDeduplicator.merge.ts,src/services/HiddenTaskService.ts` (wait for completion) — scores unchanged, confirming Stryker still discovers the renamed test files (M3)
-- [ ] 8.7 Verification: `wc -l` confirms every touched test file <= 200 (hard cap 300 nowhere needed); `get_file_problems` on new/changed files + `pnpm run build`
+- [x] 8.1 Split `RecurringTaskDeduplicator.merge.test.ts` (598) into ~3 files by merge aspect, each <= 200: content fields from the freshest copy (name/description/goal/context/category + equal-`updated_at` tie), schedule triple + box/sort_order pair coupling, identity fields + winner `updated_at` + wholesale `repeat_rule` + pending-only-when-changed; extract shared task builders into the test-setup module (FR3 coverage unchanged)
+- [x] 8.2 Split `RecurringTaskDeduplicator.test.ts` (692) into ~3 files by concern, each <= 200: winner selection + tiebreak + filtering; loser soft-delete + checklist cascade + multiple/three-plus groups; skip optimization + guard describes; reuse the same test-setup module (FR3 characterization coverage unchanged)
+- [x] 8.3 Split `HiddenTaskService.test.ts` (300): move the `revealHiddenTasks preserving updated_at` describe (FR1 block) into a sibling `HiddenTaskService.reveal-preserves-updated-at.test.ts`; both files end up <= 200 (FR1 coverage unchanged)
+- [x] 8.4 Slim `multi-device-stale-rebalance.spec.ts` (319): move `dragTaskOnto` into `page-actions.ts` and `buildInboxTaskPayload` into a shared integration helper module; the spec keeps only the U3 scenario and lands <= 200 (U3/FR4/NFR-REL1 coverage unchanged)
+- [x] 8.4a Follow-up: `page-actions.ts` grew to 312 lines after 8.4 added `dragTaskOnto`, breaching the 300-line hard cap. Split it by action category (task actions, checklist actions, goal/category/context actions, attachment actions) into sibling files under 200 lines each, reusing existing repo module patterns; update all importers; no behavior change (process-invariants file-size cap)
+- [x] 8.5 Verification: scoped vitest run (one command, foreground) over all files produced by 8.1-8.3 — green, and the total test count of the split groups equals the pre-split count (no scenario silently dropped)
+- [x] 8.6 Verification: scoped mutation run on `src/services/RecurringTaskDeduplicator.ts,src/services/RecurringTaskDeduplicator.merge.ts,src/services/HiddenTaskService.ts` (wait for completion) — scores unchanged, confirming Stryker still discovers the renamed test files (M3)
+- [x] 8.7 Verification: `wc -l` confirms every touched test file <= 200 (hard cap 300 nowhere needed) — confirmed, all pass; `get_file_problems` on new/changed files — clean. At 8.7 time, `pnpm run preflight` failed on 4 pre-existing integration spec bugs from section 5 (unrelated to the section-8 reorg — see 9.1-9.4 below); investigation confirmed all section-8 moved/split code is byte-identical to its pre-move source and correctly imported, so section 8's own scope was verified complete. Those 4 specs were real bugs (not flakes) and are now fixed in section 9; `pnpm run preflight` and `pnpm run build` are both green.
+
+## 9. Follow-up: pre-existing integration spec bugs surfaced by preflight (found during 8.7 verification, not caused by section 8's reorg) — FIXED
+
+Root causes (all fixed): the specs assumed tasks stay clickable from `/tasks` in states the app correctly hides/filters them; deeper issues surfaced once those were fixed — invalid seeded sort keys poisoning shared-inbox key generation, a strict-mode drag-handle locator collision, missing final pull-on-A convergence rounds, a token refresh triggered by the faked clock jump (Kong CORS + short JWT), and an over-broad shared-user convergence assertion. See per-item notes below.
+
+- [x] 9.1 Fixed `multi-device-stale-recurring-fixed.spec.ts` and `multi-device-stale-recurring-after-completion.spec.ts`: the description edit now precedes completion (the new occurrence is cloned from the original's fields at completion time, so both occurrences carry the fresh description), preceded by a clean `/tasks` navigation so the detail panel stays mounted; a final `triggerSyncAndWait(pageA)` pulls B's reveal so all three converge (U1, FR6, NFR-REL1)
+- [x] 9.2 Fixed `multi-device-manual-unhide.spec.ts`: click the `command-bar-eye-toggle` control (the actual sidebar reveal toggle; `hidden-tasks-toggle` is an older e2e locator) on `pageA` before step 4's `openTaskDetail` so the hidden task is clickable (U2, FR2, NFR-REL1)
+- [x] 9.3 Fixed `multi-device-stale-rebalance.spec.ts`: the seeded tasks live in the inbox box, so the flow now runs on `/inbox` (not `/tasks`, which shows only today/week/later); `dragTaskOnto` matches the drag handle by its exact "Drag task" label (a task named "…Dragged" collided with `/drag/i`); seed keys are valid fractional-indexing keys ("ac"/"ab", not "z"/"m" which pass the app's lax check but throw in `generateKeyBetween`, poisoning inbox key generation for later specs); a final `triggerSyncAndWait(pageA)` pulls B's rebalance (U3, FR4, NFR-REL1)
+- [x] 9.4 Verification: `pnpm run preflight` green (85 integration + 7959 client tests pass). Also fixed two cross-cutting harness issues surfaced once the specs progressed past the clickability bugs: (a) Kong CORS now allows the `x-supabase-api-version` header, and the test backend issues 7-day JWTs (`GOTRUE_JWT_EXP`) so a device's day-long clock jump never triggers a token refresh that raced/failed under the faked clock — the `advanceClockPastDate` helper uses `page.clock.install({ time }) + resume()`; (b) `assertConverged` takes an optional record filter so the shared-user convergence check is scoped to each test's own records (a clock-advanced device also reveals unrelated specs' hidden occurrences) (NFR-REL1, M1)
+
+Still not in scope for this change (flag separately, do not fix here): `TaskService.complete()`'s broad `catch` (lines ~212-219) mislabels any exception during recurring-copy creation as `skipped_invalid_rule`, surfacing a misleading "Repeat rule issue" dialog; it masked the invalid-sort-key root cause during 9.3's investigation.
diff --git a/packages/client/src/db/repositories/ContextRepository.ts b/packages/client/src/db/repositories/ContextRepository.ts
index 38b9b267..859a570d 100644
--- a/packages/client/src/db/repositories/ContextRepository.ts
+++ b/packages/client/src/db/repositories/ContextRepository.ts
@@ -2,6 +2,9 @@ import type { WireContext } from "@clear-progress/contract";
import { ClientContextSchema } from "@/schemas/entities";
import type { Context, ISOTimestamp } from "@/types/entities";
import { db } from "../database";
+import { shouldOverwritePendingLocalRecord } from "./applyServerRecordLww";
+
+const CONTEXT_ENTITY_NAME = "context";
export class ContextRepository {
async getAll(): Promise {
@@ -55,11 +58,26 @@ export class ContextRepository {
.toArray();
}
+ /**
+ * Implements FR5 of fix-stale-sync-overwrites.
+ * A local `pending` record is overwritten only when the server record's
+ * `updated_at` is strictly newer (LWW pull protection). Local records with
+ * any other syncStatus, or no local record at all, are always overwritten.
+ */
async applyServerRecords(records: WireContext[]): Promise {
await db.transaction("rw", db.contexts, async () => {
for (const serverRecord of records) {
const localRecord = await db.contexts.get(serverRecord.id);
- if (localRecord?.syncStatus !== "pending") {
+ const shouldWrite =
+ localRecord?.syncStatus !== "pending" ||
+ shouldOverwritePendingLocalRecord({
+ entityName: CONTEXT_ENTITY_NAME,
+ id: serverRecord.id,
+ localUpdatedAt: localRecord.updated_at,
+ serverUpdatedAt: serverRecord.updated_at,
+ });
+
+ if (shouldWrite) {
const context: Context = {
...serverRecord,
created_at: serverRecord.created_at as ISOTimestamp,
diff --git a/packages/client/src/services/HiddenTaskService.reveal-preserves-updated-at.test.ts b/packages/client/src/services/HiddenTaskService.reveal-preserves-updated-at.test.ts
new file mode 100644
index 00000000..189ebd61
--- /dev/null
+++ b/packages/client/src/services/HiddenTaskService.reveal-preserves-updated-at.test.ts
@@ -0,0 +1,144 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import type { TaskRepository } from "@/db/repositories/TaskRepository";
+import { fakeClock } from "@/lib/temporal";
+import type { ISODate, ISOTimestamp, Task } from "@/types/entities";
+import { HiddenTaskService } from "./HiddenTaskService";
+
+// implements FR1 of fix-stale-sync-overwrites
+describe("revealHiddenTasks preserving updated_at", () => {
+ let mockTaskRepository: TaskRepository;
+ let service: HiddenTaskService;
+
+ beforeEach(() => {
+ mockTaskRepository = {
+ getTasksToReveal: vi.fn(),
+ update: vi.fn(),
+ } as unknown as TaskRepository;
+ });
+
+ // Verifies M2 of fix-stale-sync-overwrites
+ it("should NOT modify updated_at when revealing a hidden task", async () => {
+ const clock = fakeClock("2026-04-16T10:30:00Z");
+ service = new HiddenTaskService(mockTaskRepository, clock);
+
+ const hiddenTask: Task = {
+ id: "task-preserve-updated-at-1",
+ name: "Hidden task",
+ description: "",
+ box: "today",
+ goal_id: "",
+ context_id: "",
+ category_id: "",
+ is_completed: false,
+ completed_at: "" as ISOTimestamp,
+ repeat_rule: "",
+ is_hidden: true,
+ next_date: "" as ISODate,
+ appear_date: "2026-04-16" as ISODate,
+ original_task_id: "",
+ sort_order: "0",
+ is_deleted: false,
+ created_at: "2026-04-15T10:00:00.000Z" as ISOTimestamp,
+ updated_at: "2026-04-15T10:00:00.000Z" as ISOTimestamp,
+ revision: 1,
+ syncStatus: "synced" as const,
+ };
+
+ (
+ mockTaskRepository.getTasksToReveal as ReturnType
+ ).mockResolvedValue([hiddenTask]);
+ (mockTaskRepository.update as ReturnType).mockResolvedValue(
+ undefined,
+ );
+
+ const revealed = await service.revealHiddenTasks();
+
+ expect(revealed[0].updated_at).toBe("2026-04-15T10:00:00.000Z");
+ expect(mockTaskRepository.update).toHaveBeenCalledWith(
+ expect.objectContaining({
+ id: "task-preserve-updated-at-1",
+ updated_at: "2026-04-15T10:00:00.000Z",
+ }),
+ );
+ });
+
+ it("should set syncStatus to pending when revealing a hidden task", async () => {
+ const clock = fakeClock("2026-04-16T10:30:00Z");
+ service = new HiddenTaskService(mockTaskRepository, clock);
+
+ const hiddenTask: Task = {
+ id: "task-preserve-updated-at-2",
+ name: "Hidden task",
+ description: "",
+ box: "today",
+ goal_id: "",
+ context_id: "",
+ category_id: "",
+ is_completed: false,
+ completed_at: "" as ISOTimestamp,
+ repeat_rule: "",
+ is_hidden: true,
+ next_date: "" as ISODate,
+ appear_date: "2026-04-16" as ISODate,
+ original_task_id: "",
+ sort_order: "0",
+ is_deleted: false,
+ created_at: "2026-04-15T10:00:00.000Z" as ISOTimestamp,
+ updated_at: "2026-04-15T10:00:00.000Z" as ISOTimestamp,
+ revision: 1,
+ syncStatus: "synced" as const,
+ };
+
+ (
+ mockTaskRepository.getTasksToReveal as ReturnType
+ ).mockResolvedValue([hiddenTask]);
+ (mockTaskRepository.update as ReturnType).mockResolvedValue(
+ undefined,
+ );
+
+ const revealed = await service.revealHiddenTasks();
+
+ expect(revealed[0].syncStatus).toBe("pending");
+ expect(revealed[0].is_hidden).toBe(false);
+ });
+
+ it("should keep syncStatus as pending without degrading state when task is already pending", async () => {
+ const clock = fakeClock("2026-04-16T10:30:00Z");
+ service = new HiddenTaskService(mockTaskRepository, clock);
+
+ const alreadyPendingHiddenTask: Task = {
+ id: "task-preserve-updated-at-3",
+ name: "Hidden task already pending",
+ description: "",
+ box: "today",
+ goal_id: "",
+ context_id: "",
+ category_id: "",
+ is_completed: false,
+ completed_at: "" as ISOTimestamp,
+ repeat_rule: "",
+ is_hidden: true,
+ next_date: "" as ISODate,
+ appear_date: "2026-04-16" as ISODate,
+ original_task_id: "",
+ sort_order: "0",
+ is_deleted: false,
+ created_at: "2026-04-15T10:00:00.000Z" as ISOTimestamp,
+ updated_at: "2026-04-15T10:00:00.000Z" as ISOTimestamp,
+ revision: 1,
+ syncStatus: "pending" as const,
+ };
+
+ (
+ mockTaskRepository.getTasksToReveal as ReturnType
+ ).mockResolvedValue([alreadyPendingHiddenTask]);
+ (mockTaskRepository.update as ReturnType).mockResolvedValue(
+ undefined,
+ );
+
+ const revealed = await service.revealHiddenTasks();
+
+ expect(revealed[0].syncStatus).toBe("pending");
+ expect(revealed[0].updated_at).toBe("2026-04-15T10:00:00.000Z");
+ });
+});
diff --git a/packages/client/src/services/HiddenTaskService.test.ts b/packages/client/src/services/HiddenTaskService.test.ts
index 915346a1..94c7757e 100644
--- a/packages/client/src/services/HiddenTaskService.test.ts
+++ b/packages/client/src/services/HiddenTaskService.test.ts
@@ -169,132 +169,4 @@ describe("HiddenTaskService", () => {
);
});
});
-
- // implements FR1 of fix-stale-sync-overwrites
- describe("revealHiddenTasks preserving updated_at", () => {
- it("should NOT modify updated_at when revealing a hidden task", async () => {
- const clock = fakeClock("2026-04-16T10:30:00Z");
- service = new HiddenTaskService(mockTaskRepository, clock);
-
- const hiddenTask: Task = {
- id: "task-preserve-updated-at-1",
- name: "Hidden task",
- description: "",
- box: "today",
- goal_id: "",
- context_id: "",
- category_id: "",
- is_completed: false,
- completed_at: "" as ISOTimestamp,
- repeat_rule: "",
- is_hidden: true,
- next_date: "" as ISODate,
- appear_date: "2026-04-16" as ISODate,
- original_task_id: "",
- sort_order: "0",
- is_deleted: false,
- created_at: "2026-04-15T10:00:00.000Z" as ISOTimestamp,
- updated_at: "2026-04-15T10:00:00.000Z" as ISOTimestamp,
- revision: 1,
- syncStatus: "synced" as const,
- };
-
- (
- mockTaskRepository.getTasksToReveal as ReturnType
- ).mockResolvedValue([hiddenTask]);
- (mockTaskRepository.update as ReturnType).mockResolvedValue(
- undefined,
- );
-
- const revealed = await service.revealHiddenTasks();
-
- expect(revealed[0].updated_at).toBe("2026-04-15T10:00:00.000Z");
- expect(mockTaskRepository.update).toHaveBeenCalledWith(
- expect.objectContaining({
- id: "task-preserve-updated-at-1",
- updated_at: "2026-04-15T10:00:00.000Z",
- }),
- );
- });
-
- it("should set syncStatus to pending when revealing a hidden task", async () => {
- const clock = fakeClock("2026-04-16T10:30:00Z");
- service = new HiddenTaskService(mockTaskRepository, clock);
-
- const hiddenTask: Task = {
- id: "task-preserve-updated-at-2",
- name: "Hidden task",
- description: "",
- box: "today",
- goal_id: "",
- context_id: "",
- category_id: "",
- is_completed: false,
- completed_at: "" as ISOTimestamp,
- repeat_rule: "",
- is_hidden: true,
- next_date: "" as ISODate,
- appear_date: "2026-04-16" as ISODate,
- original_task_id: "",
- sort_order: "0",
- is_deleted: false,
- created_at: "2026-04-15T10:00:00.000Z" as ISOTimestamp,
- updated_at: "2026-04-15T10:00:00.000Z" as ISOTimestamp,
- revision: 1,
- syncStatus: "synced" as const,
- };
-
- (
- mockTaskRepository.getTasksToReveal as ReturnType
- ).mockResolvedValue([hiddenTask]);
- (mockTaskRepository.update as ReturnType).mockResolvedValue(
- undefined,
- );
-
- const revealed = await service.revealHiddenTasks();
-
- expect(revealed[0].syncStatus).toBe("pending");
- expect(revealed[0].is_hidden).toBe(false);
- });
-
- it("should keep syncStatus as pending without degrading state when task is already pending", async () => {
- const clock = fakeClock("2026-04-16T10:30:00Z");
- service = new HiddenTaskService(mockTaskRepository, clock);
-
- const alreadyPendingHiddenTask: Task = {
- id: "task-preserve-updated-at-3",
- name: "Hidden task already pending",
- description: "",
- box: "today",
- goal_id: "",
- context_id: "",
- category_id: "",
- is_completed: false,
- completed_at: "" as ISOTimestamp,
- repeat_rule: "",
- is_hidden: true,
- next_date: "" as ISODate,
- appear_date: "2026-04-16" as ISODate,
- original_task_id: "",
- sort_order: "0",
- is_deleted: false,
- created_at: "2026-04-15T10:00:00.000Z" as ISOTimestamp,
- updated_at: "2026-04-15T10:00:00.000Z" as ISOTimestamp,
- revision: 1,
- syncStatus: "pending" as const,
- };
-
- (
- mockTaskRepository.getTasksToReveal as ReturnType
- ).mockResolvedValue([alreadyPendingHiddenTask]);
- (mockTaskRepository.update as ReturnType).mockResolvedValue(
- undefined,
- );
-
- const revealed = await service.revealHiddenTasks();
-
- expect(revealed[0].syncStatus).toBe("pending");
- expect(revealed[0].updated_at).toBe("2026-04-15T10:00:00.000Z");
- });
- });
});
diff --git a/packages/client/src/services/RecurringTaskDeduplicator.guards.test.ts b/packages/client/src/services/RecurringTaskDeduplicator.guards.test.ts
new file mode 100644
index 00000000..85afc3bc
--- /dev/null
+++ b/packages/client/src/services/RecurringTaskDeduplicator.guards.test.ts
@@ -0,0 +1,158 @@
+// implements FR1, FR2, FR3, FR5 of dedup-recurring-after-pull
+import "fake-indexeddb/auto";
+import { describe, expect, it, vi } from "vitest";
+import { db } from "@/db/database";
+import { ChecklistRepository } from "@/db/repositories/ChecklistRepository";
+import { TaskRepository } from "@/db/repositories/TaskRepository";
+import { fakeClock } from "@/lib/temporal";
+import { buildTask } from "@/test/factories/taskFactory";
+import { RecurringTaskDeduplicator } from "./RecurringTaskDeduplicator";
+import {
+ createRecurringTaskDeduplicatorMergeSetup,
+ ORIGINAL_ID_1,
+ ORIGINAL_ID_2,
+ UUID_A,
+ UUID_B,
+ UUID_C,
+} from "./RecurringTaskDeduplicator.test-setup";
+
+const FIXED_CLOCK_TIMESTAMP = "2026-07-10T12:00:00Z";
+
+describe("RecurringTaskDeduplicator", () => {
+ const { getDeduplicator } = createRecurringTaskDeduplicatorMergeSetup();
+
+ // Mutants 1-2: early return guard when ids are empty
+ describe("optimization guard — empty ids skip repository call", () => {
+ it("should not call findDuplicateRecurringGroups when ids list is empty", async () => {
+ const taskRepository = new TaskRepository();
+ const checklistRepository = new ChecklistRepository();
+ const clock = fakeClock(FIXED_CLOCK_TIMESTAMP);
+ const findSpy = vi.spyOn(taskRepository, "findDuplicateRecurringGroups");
+ const localDeduplicator = new RecurringTaskDeduplicator(
+ taskRepository,
+ checklistRepository,
+ clock,
+ );
+
+ await localDeduplicator.deduplicate([]);
+
+ expect(findSpy).not.toHaveBeenCalled();
+ });
+
+ it("should not call findDuplicateRecurringGroups when all ids are empty strings", async () => {
+ const taskRepository = new TaskRepository();
+ const checklistRepository = new ChecklistRepository();
+ const clock = fakeClock(FIXED_CLOCK_TIMESTAMP);
+ const findSpy = vi.spyOn(taskRepository, "findDuplicateRecurringGroups");
+ const localDeduplicator = new RecurringTaskDeduplicator(
+ taskRepository,
+ checklistRepository,
+ clock,
+ );
+
+ await localDeduplicator.deduplicate(["", ""]);
+
+ expect(findSpy).not.toHaveBeenCalled();
+ });
+ });
+
+ // Mutants 3-4: continue guard when group has single copy
+ describe("optimization guard — single copy skips update", () => {
+ it("should not call update when group has only one active copy", async () => {
+ const taskRepository = new TaskRepository();
+ const checklistRepository = new ChecklistRepository();
+ const clock = fakeClock(FIXED_CLOCK_TIMESTAMP);
+ const updateSpy = vi.spyOn(taskRepository, "update");
+ const localDeduplicator = new RecurringTaskDeduplicator(
+ taskRepository,
+ checklistRepository,
+ clock,
+ );
+
+ const singleTask = buildTask({
+ id: UUID_A,
+ original_task_id: ORIGINAL_ID_1,
+ next_date: "2026-07-01",
+ is_completed: false,
+ is_deleted: false,
+ });
+ await db.tasks.add(singleTask);
+
+ await localDeduplicator.deduplicate([ORIGINAL_ID_1]);
+
+ expect(updateSpy).not.toHaveBeenCalled();
+ });
+
+ // Guards the `continue` statement directly: a group can only reach this
+ // guard with activeCopies.length < MINIMUM_DUPLICATE_GROUP_SIZE (0 or 1)
+ // since findDuplicateRecurringGroups never emits an empty group in
+ // practice — so length-1 groups are a legitimate but observationally
+ // equivalent case (merging a single copy against itself is a no-op
+ // either way). Forcing an empty-array group via a mocked repository
+ // makes the two mutants observable: without the `continue` (or with the
+ // guard replaced by `if (false)`), the loop falls through to
+ // `sortByWinnerPriority([])` and then destructures an undefined
+ // scheduleWinner, which throws when merging — proving `continue` really
+ // skips groups below the minimum size rather than merely coinciding with
+ // a no-op merge.
+ it("should not throw when a mocked group is empty, proving continue actually skips it", async () => {
+ const taskRepository = new TaskRepository();
+ const checklistRepository = new ChecklistRepository();
+ const clock = fakeClock(FIXED_CLOCK_TIMESTAMP);
+ vi.spyOn(
+ taskRepository,
+ "findDuplicateRecurringGroups",
+ ).mockResolvedValue(new Map([[ORIGINAL_ID_1, []]]));
+ const localDeduplicator = new RecurringTaskDeduplicator(
+ taskRepository,
+ checklistRepository,
+ clock,
+ );
+
+ await expect(
+ localDeduplicator.deduplicate([ORIGINAL_ID_1]),
+ ).resolves.toBeUndefined();
+ });
+
+ // Guards the `continue` statement itself (not just the guarded call):
+ // replacing `continue` with an empty block would still skip the update
+ // for the singleton group (since the guard body has no other statements)
+ // but would fall through to loop code the real implementation intends to
+ // skip, rather than jumping to the next group. Processing a singleton
+ // group FIRST and a real duplicate group SECOND in the same call proves
+ // the loop actually advances past the singleton to still dedupe group 2 —
+ // an `if (...) {}` mutant that fails to skip to the next iteration would
+ // still coincidentally pass this too, but combined with the single-copy
+ // assertions above, it locks down that iteration order is unaffected.
+ it("should still deduplicate a later group after an earlier singleton group in the same call", async () => {
+ const singleTask = buildTask({
+ id: UUID_A,
+ original_task_id: ORIGINAL_ID_1,
+ next_date: "2026-07-01",
+ is_completed: false,
+ is_deleted: false,
+ });
+ const groupTwoWinner = buildTask({
+ id: UUID_B,
+ original_task_id: ORIGINAL_ID_2,
+ next_date: "2026-07-02",
+ is_completed: false,
+ is_deleted: false,
+ });
+ const groupTwoLoser = buildTask({
+ id: UUID_C,
+ original_task_id: ORIGINAL_ID_2,
+ next_date: "2026-07-08",
+ is_completed: false,
+ is_deleted: false,
+ });
+ await db.tasks.bulkAdd([singleTask, groupTwoWinner, groupTwoLoser]);
+
+ await getDeduplicator().deduplicate([ORIGINAL_ID_1, ORIGINAL_ID_2]);
+
+ expect((await db.tasks.get(UUID_A))?.syncStatus).toBe("synced");
+ expect((await db.tasks.get(UUID_B))?.is_deleted).toBe(false);
+ expect((await db.tasks.get(UUID_C))?.is_deleted).toBe(true);
+ });
+ });
+});
diff --git a/packages/client/src/services/RecurringTaskDeduplicator.loser-soft-delete.test.ts b/packages/client/src/services/RecurringTaskDeduplicator.loser-soft-delete.test.ts
new file mode 100644
index 00000000..50d72cb4
--- /dev/null
+++ b/packages/client/src/services/RecurringTaskDeduplicator.loser-soft-delete.test.ts
@@ -0,0 +1,105 @@
+// implements FR1, FR2, FR3, FR5 of dedup-recurring-after-pull
+import "fake-indexeddb/auto";
+import { describe, expect, it } from "vitest";
+import { db } from "@/db/database";
+import { buildChecklistItem } from "@/test/factories/checklistItemFactory";
+import { buildTask } from "@/test/factories/taskFactory";
+import {
+ createRecurringTaskDeduplicatorMergeSetup,
+ ORIGINAL_ID_1,
+ UUID_A,
+ UUID_B,
+} from "./RecurringTaskDeduplicator.test-setup";
+
+describe("RecurringTaskDeduplicator", () => {
+ const { getDeduplicator } = createRecurringTaskDeduplicatorMergeSetup();
+
+ // FR2: loser soft-delete fields
+ describe("loser soft-delete", () => {
+ it("should set updated_at on loser to current clock timestamp", async () => {
+ const taskA = buildTask({
+ id: UUID_A,
+ original_task_id: ORIGINAL_ID_1,
+ next_date: "2026-07-01",
+ is_completed: false,
+ is_deleted: false,
+ });
+ const taskB = buildTask({
+ id: UUID_B,
+ original_task_id: ORIGINAL_ID_1,
+ next_date: "2026-07-05",
+ is_completed: false,
+ is_deleted: false,
+ });
+ await db.tasks.bulkAdd([taskA, taskB]);
+
+ await getDeduplicator().deduplicate([ORIGINAL_ID_1]);
+
+ const loser = await db.tasks.get(UUID_B);
+ expect(loser?.updated_at).toBe("2026-07-10T12:00:00.000Z");
+ });
+ });
+
+ // FR3: cascade to checklist items
+ describe("checklist cascade", () => {
+ it("should soft-delete checklist items of loser task", async () => {
+ const winnerTask = buildTask({
+ id: UUID_A,
+ original_task_id: ORIGINAL_ID_1,
+ next_date: "2026-07-01",
+ is_completed: false,
+ is_deleted: false,
+ });
+ const loserTask = buildTask({
+ id: UUID_B,
+ original_task_id: ORIGINAL_ID_1,
+ next_date: "2026-07-10",
+ is_completed: false,
+ is_deleted: false,
+ });
+ const checklistItem = buildChecklistItem({
+ task_id: UUID_B,
+ is_deleted: false,
+ });
+ await db.tasks.bulkAdd([winnerTask, loserTask]);
+ await db.checklist_items.add(checklistItem);
+
+ await getDeduplicator().deduplicate([ORIGINAL_ID_1]);
+
+ const item = await db.checklist_items.get(checklistItem.id);
+ expect(item?.is_deleted).toBe(true);
+ expect(item?.syncStatus).toBe("pending");
+ expect(item?.updated_at).toBe("2026-07-10T12:00:00.000Z");
+ });
+
+ it("should not modify checklist items of winner task", async () => {
+ const winnerTask = buildTask({
+ id: UUID_A,
+ original_task_id: ORIGINAL_ID_1,
+ next_date: "2026-07-01",
+ is_completed: false,
+ is_deleted: false,
+ });
+ const loserTask = buildTask({
+ id: UUID_B,
+ original_task_id: ORIGINAL_ID_1,
+ next_date: "2026-07-10",
+ is_completed: false,
+ is_deleted: false,
+ });
+ const winnerItem = buildChecklistItem({
+ task_id: UUID_A,
+ is_deleted: false,
+ syncStatus: "synced",
+ });
+ await db.tasks.bulkAdd([winnerTask, loserTask]);
+ await db.checklist_items.add(winnerItem);
+
+ await getDeduplicator().deduplicate([ORIGINAL_ID_1]);
+
+ const item = await db.checklist_items.get(winnerItem.id);
+ expect(item?.is_deleted).toBe(false);
+ expect(item?.syncStatus).toBe("synced");
+ });
+ });
+});
diff --git a/packages/client/src/services/RecurringTaskDeduplicator.merge.identity-and-updated-at.test.ts b/packages/client/src/services/RecurringTaskDeduplicator.merge.identity-and-updated-at.test.ts
new file mode 100644
index 00000000..fab8abe3
--- /dev/null
+++ b/packages/client/src/services/RecurringTaskDeduplicator.merge.identity-and-updated-at.test.ts
@@ -0,0 +1,114 @@
+// implements FR3 of fix-stale-sync-overwrites
+import "fake-indexeddb/auto";
+import { describe, expect, it } from "vitest";
+import { RECORD_SYNC_STATUS } from "@/constants";
+import { db } from "@/db/database";
+import { buildTask } from "@/test/factories/taskFactory";
+import {
+ createRecurringTaskDeduplicatorMergeSetup,
+ FIXED_CLOCK_TIMESTAMP,
+} from "./RecurringTaskDeduplicator.test-setup";
+
+// Third scenario reused here: proves identity/bookkeeping fields (id,
+// created_at, revision) always stay the winner's own regardless of which
+// copy is freshest.
+const UUID_BOX_SCHEDULE_WINNER = "00000000-0000-4000-a000-000000000030";
+const UUID_BOX_FRESH_CONTENT_LOSER = "00000000-0000-4000-a000-000000000031";
+const ORIGINAL_ID_3 = "33333333-3333-4333-a333-333333333333";
+
+// Fourth scenario: proves the merged winner's updated_at is taken verbatim
+// from the freshest copy — never refreshed to the clock's "now".
+const UUID_UPDATED_AT_SCHEDULE_WINNER = "00000000-0000-4000-a000-000000000040";
+const UUID_UPDATED_AT_FRESH_CONTENT_LOSER =
+ "00000000-0000-4000-a000-000000000041";
+const ORIGINAL_ID_4 = "44444444-4444-4444-a444-444444444444";
+
+describe("RecurringTaskDeduplicator — content merge from freshest updated_at", () => {
+ const { getDeduplicator } = createRecurringTaskDeduplicatorMergeSetup();
+
+ // FR3: id, created_at and revision are identity/bookkeeping fields that
+ // always stay the schedule winner's own, regardless of which copy is
+ // freshest by updated_at or which has different content. The winner
+ // record survives (only the loser is soft-deleted), so these fields are
+ // never overwritten by the merge.
+ describe("identity fields stay the winner's own regardless of merge", () => {
+ it("should keep id, created_at and revision as the schedule winner's own values", async () => {
+ const scheduleWinner = buildTask({
+ id: UUID_BOX_SCHEDULE_WINNER,
+ original_task_id: ORIGINAL_ID_3,
+ next_date: "2026-07-01",
+ created_at: "2026-01-01T00:00:00.000Z",
+ revision: 5,
+ name: "Stale name",
+ description: "Stale description",
+ box: "inbox",
+ sort_order: "stale-sort-key",
+ is_completed: false,
+ is_deleted: false,
+ updated_at: "2026-06-01T10:00:00.000Z",
+ });
+ const freshContentLoser = buildTask({
+ id: UUID_BOX_FRESH_CONTENT_LOSER,
+ original_task_id: ORIGINAL_ID_3,
+ next_date: "2026-07-10",
+ created_at: "2026-02-02T00:00:00.000Z",
+ revision: 9,
+ name: "Fresh name",
+ description: "Fresh description",
+ box: "later",
+ sort_order: "fresh-sort-key",
+ is_completed: false,
+ is_deleted: false,
+ updated_at: "2026-07-09T10:00:00.000Z",
+ });
+ await db.tasks.bulkAdd([scheduleWinner, freshContentLoser]);
+
+ await getDeduplicator().deduplicate([ORIGINAL_ID_3]);
+
+ const survivor = await db.tasks.get(UUID_BOX_SCHEDULE_WINNER);
+ expect(survivor?.id).toBe(UUID_BOX_SCHEDULE_WINNER);
+ expect(survivor?.created_at).toBe("2026-01-01T00:00:00.000Z");
+ expect(survivor?.revision).toBe(5);
+ });
+ });
+
+ // FR3: the merged winner's updated_at must equal the freshest copy's
+ // updated_at verbatim, never refreshed to the current clock time — unlike
+ // a loser's soft-delete updated_at, which the implementation does set to
+ // now. FIXED_CLOCK_TIMESTAMP here is a third distinct timestamp from both
+ // copies' updated_at, so a bug that clobbers the winner's updated_at with
+ // "now" is caught.
+ // Verifies M2 of fix-stale-sync-overwrites
+ describe("winner's updated_at takes the freshest copy's value, never refreshed to now", () => {
+ it("should set the merged winner's updated_at to the freshest copy's updated_at, not the current clock time", async () => {
+ const scheduleWinner = buildTask({
+ id: UUID_UPDATED_AT_SCHEDULE_WINNER,
+ original_task_id: ORIGINAL_ID_4,
+ next_date: "2026-07-01",
+ name: "Stale name",
+ description: "Stale description",
+ is_completed: false,
+ is_deleted: false,
+ updated_at: "2026-06-01T10:00:00.000Z",
+ });
+ const freshContentLoser = buildTask({
+ id: UUID_UPDATED_AT_FRESH_CONTENT_LOSER,
+ original_task_id: ORIGINAL_ID_4,
+ next_date: "2026-07-10",
+ name: "Fresh name",
+ description: "Fresh description",
+ is_completed: false,
+ is_deleted: false,
+ updated_at: "2026-07-09T10:00:00.000Z",
+ });
+ await db.tasks.bulkAdd([scheduleWinner, freshContentLoser]);
+
+ await getDeduplicator().deduplicate([ORIGINAL_ID_4]);
+
+ const survivor = await db.tasks.get(UUID_UPDATED_AT_SCHEDULE_WINNER);
+ expect(survivor?.updated_at).toBe("2026-07-09T10:00:00.000Z");
+ expect(survivor?.updated_at).not.toBe(FIXED_CLOCK_TIMESTAMP);
+ expect(survivor?.syncStatus).toBe(RECORD_SYNC_STATUS.PENDING);
+ });
+ });
+});
diff --git a/packages/client/src/services/RecurringTaskDeduplicator.merge.pending-when-changed.test.ts b/packages/client/src/services/RecurringTaskDeduplicator.merge.pending-when-changed.test.ts
new file mode 100644
index 00000000..b0cce043
--- /dev/null
+++ b/packages/client/src/services/RecurringTaskDeduplicator.merge.pending-when-changed.test.ts
@@ -0,0 +1,151 @@
+// implements FR3 of fix-stale-sync-overwrites
+import "fake-indexeddb/auto";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { RECORD_SYNC_STATUS } from "@/constants";
+import { db } from "@/db/database";
+import { ChecklistRepository } from "@/db/repositories/ChecklistRepository";
+import { TaskRepository } from "@/db/repositories/TaskRepository";
+import { fakeClock } from "@/lib/temporal";
+import { buildTask } from "@/test/factories/taskFactory";
+import { RecurringTaskDeduplicator } from "./RecurringTaskDeduplicator";
+import { FIXED_CLOCK_TIMESTAMP } from "./RecurringTaskDeduplicator.test-setup";
+
+// Sixth scenario: proves the winner is written with syncStatus "pending"
+// ONLY when the merge actually changed its stored record, per FR3's last
+// sentence. Two subcases below use the same original_task_id family but
+// distinct task ids so each test starts from a clean, independent group.
+const UUID_CHANGED_WINNER = "00000000-0000-4000-a000-000000000060";
+const UUID_CHANGED_LOSER = "00000000-0000-4000-a000-000000000061";
+const ORIGINAL_ID_6 = "66666666-6666-4666-a666-666666666666";
+
+const UUID_OPTIMAL_WINNER = "00000000-0000-4000-a000-000000000070";
+const UUID_OPTIMAL_LOSER = "00000000-0000-4000-a000-000000000071";
+const ORIGINAL_ID_7 = "77777777-7777-4777-a777-777777777777";
+
+// FR3 (last sentence): the winner SHALL be written with syncStatus
+// "pending" only when the merge actually changed the stored record. This
+// requires the deduplicator to call taskRepository.update() for the
+// winner whenever content/schedule/box fields differ from what the
+// winner already had on disk — something the current implementation
+// never does (it only calls update() for losers' soft-deletes), so this
+// first test is expected to FAIL until the merge-and-conditionally-write
+// behavior is implemented.
+//
+// This file constructs its own TaskRepository/ChecklistRepository/clock
+// (rather than using the shared test-setup factory) because each test
+// spies on taskRepository.update() directly.
+describe("RecurringTaskDeduplicator — content merge from freshest updated_at", () => {
+ beforeEach(async () => {
+ await db.tasks.clear();
+ await db.checklist_items.clear();
+ });
+
+ describe("winner syncStatus is pending only when the merge actually changed it", () => {
+ it("should write the winner with syncStatus pending and call update when the merge changes its stored content", async () => {
+ const taskRepository = new TaskRepository();
+ const checklistRepository = new ChecklistRepository();
+ const clock = fakeClock(FIXED_CLOCK_TIMESTAMP);
+ const updateSpy = vi.spyOn(taskRepository, "update");
+ const localDeduplicator = new RecurringTaskDeduplicator(
+ taskRepository,
+ checklistRepository,
+ clock,
+ );
+
+ const scheduleWinner = buildTask({
+ id: UUID_CHANGED_WINNER,
+ original_task_id: ORIGINAL_ID_6,
+ next_date: "2026-07-01",
+ name: "Stale name",
+ description: "Stale description",
+ box: "inbox",
+ sort_order: "stale-sort-key",
+ is_completed: false,
+ is_deleted: false,
+ updated_at: "2026-06-01T10:00:00.000Z",
+ syncStatus: "synced",
+ });
+ const freshContentLoser = buildTask({
+ id: UUID_CHANGED_LOSER,
+ original_task_id: ORIGINAL_ID_6,
+ next_date: "2026-07-10",
+ name: "Fresh name",
+ description: "Fresh description",
+ box: "later",
+ sort_order: "fresh-sort-key",
+ is_completed: false,
+ is_deleted: false,
+ updated_at: "2026-07-09T10:00:00.000Z",
+ });
+ await db.tasks.bulkAdd([scheduleWinner, freshContentLoser]);
+
+ await localDeduplicator.deduplicate([ORIGINAL_ID_6]);
+
+ const survivor = await db.tasks.get(UUID_CHANGED_WINNER);
+ expect(survivor?.name).toBe("Fresh name");
+ expect(survivor?.syncStatus).toBe(RECORD_SYNC_STATUS.PENDING);
+ expect(updateSpy).toHaveBeenCalledWith(
+ expect.objectContaining({ id: UUID_CHANGED_WINNER }),
+ );
+ });
+
+ // Spec scenario: "Already-optimal winner is not rewritten." The winner
+ // already carries both the earliest schedule triple AND the freshest
+ // content/box/sort_order — merging is a no-op. This may already pass
+ // today, since the current implementation never calls update() for the
+ // schedule winner at all, so its syncStatus naturally stays whatever it
+ // started as. Kept here as a characterization/regression guard so a
+ // future merge implementation cannot start unconditionally rewriting
+ // the winner.
+ it("should not rewrite the winner when it is already optimal, keeping syncStatus synced", async () => {
+ const taskRepository = new TaskRepository();
+ const checklistRepository = new ChecklistRepository();
+ const clock = fakeClock(FIXED_CLOCK_TIMESTAMP);
+ const updateSpy = vi.spyOn(taskRepository, "update");
+ const localDeduplicator = new RecurringTaskDeduplicator(
+ taskRepository,
+ checklistRepository,
+ clock,
+ );
+
+ const optimalWinner = buildTask({
+ id: UUID_OPTIMAL_WINNER,
+ original_task_id: ORIGINAL_ID_7,
+ next_date: "2026-07-01",
+ appear_date: "2026-06-28",
+ is_hidden: false,
+ name: "Freshest name",
+ description: "Freshest description",
+ box: "inbox",
+ sort_order: "optimal-sort-key",
+ is_completed: false,
+ is_deleted: false,
+ updated_at: "2026-07-09T10:00:00.000Z",
+ syncStatus: "synced",
+ });
+ const staleLoser = buildTask({
+ id: UUID_OPTIMAL_LOSER,
+ original_task_id: ORIGINAL_ID_7,
+ next_date: "2026-07-10",
+ appear_date: "2026-07-08",
+ is_hidden: true,
+ name: "Stale name",
+ description: "Stale description",
+ box: "later",
+ sort_order: "stale-sort-key",
+ is_completed: false,
+ is_deleted: false,
+ updated_at: "2026-06-01T10:00:00.000Z",
+ });
+ await db.tasks.bulkAdd([optimalWinner, staleLoser]);
+
+ await localDeduplicator.deduplicate([ORIGINAL_ID_7]);
+
+ const survivor = await db.tasks.get(UUID_OPTIMAL_WINNER);
+ expect(survivor?.syncStatus).toBe("synced");
+ expect(updateSpy).not.toHaveBeenCalledWith(
+ expect.objectContaining({ id: UUID_OPTIMAL_WINNER }),
+ );
+ });
+ });
+});
diff --git a/packages/client/src/services/RecurringTaskDeduplicator.merge.schedule-and-box.test.ts b/packages/client/src/services/RecurringTaskDeduplicator.merge.schedule-and-box.test.ts
new file mode 100644
index 00000000..5a434d9f
--- /dev/null
+++ b/packages/client/src/services/RecurringTaskDeduplicator.merge.schedule-and-box.test.ts
@@ -0,0 +1,158 @@
+// implements FR3 of fix-stale-sync-overwrites
+import "fake-indexeddb/auto";
+import { describe, expect, it } from "vitest";
+import { db } from "@/db/database";
+import { buildTask } from "@/test/factories/taskFactory";
+import {
+ createRecurringTaskDeduplicatorMergeSetup,
+ ORIGINAL_ID_1,
+ UUID_FRESH_CONTENT_LOSER,
+ UUID_SCHEDULE_WINNER,
+} from "./RecurringTaskDeduplicator.test-setup";
+
+// Second scenario: schedule winner is NOT the freshest-updated_at copy,
+// used to prove the schedule triple (next_date + appear_date + is_hidden)
+// stays coupled to the schedule winner's copy even though content merges
+// from the other copy.
+const UUID_HIDDEN_SCHEDULE_WINNER = "00000000-0000-4000-a000-000000000020";
+const UUID_REVEALED_FRESH_CONTENT_LOSER =
+ "00000000-0000-4000-a000-000000000021";
+const ORIGINAL_ID_2 = "22222222-2222-4222-a222-222222222222";
+
+// Third scenario: proves box + sort_order travel together as a pair from
+// the freshest-updated_at copy.
+const UUID_BOX_SCHEDULE_WINNER = "00000000-0000-4000-a000-000000000030";
+const UUID_BOX_FRESH_CONTENT_LOSER = "00000000-0000-4000-a000-000000000031";
+const ORIGINAL_ID_3 = "33333333-3333-4333-a333-333333333333";
+
+describe("RecurringTaskDeduplicator — content merge from freshest updated_at", () => {
+ const { getDeduplicator } = createRecurringTaskDeduplicatorMergeSetup();
+
+ // FR3: next_date, appear_date and is_hidden always travel together as a
+ // schedule triple from the earliest-next_date copy, even when that copy
+ // is not the freshest-updated_at copy (content merges from the other one).
+ describe("schedule triple (next_date, appear_date, is_hidden) stays coupled to the schedule winner", () => {
+ it("should take appear_date and is_hidden from the SAME copy as next_date, not from the freshest-content copy", async () => {
+ // Schedule winner: earliest next_date, but stale content and NOT the
+ // freshest-updated_at copy. Its own appear_date/is_hidden must survive
+ // the merge untouched by the other copy's schedule fields.
+ const scheduleWinner = buildTask({
+ id: UUID_SCHEDULE_WINNER,
+ original_task_id: ORIGINAL_ID_1,
+ next_date: "2026-07-01",
+ appear_date: "2026-06-28",
+ is_hidden: false,
+ name: "Stale name",
+ description: "Stale description",
+ is_completed: false,
+ is_deleted: false,
+ updated_at: "2026-06-01T10:00:00.000Z",
+ });
+ // Freshest-content copy: later next_date/appear_date and a different
+ // is_hidden value. Without a real schedule-triple merge, a naive
+ // "take is_hidden/appear_date from the freshest copy" implementation
+ // would leak this copy's schedule fields onto the winner.
+ const freshContentLoser = buildTask({
+ id: UUID_FRESH_CONTENT_LOSER,
+ original_task_id: ORIGINAL_ID_1,
+ next_date: "2026-07-10",
+ appear_date: "2026-07-08",
+ is_hidden: true,
+ name: "Fresh name",
+ description: "Fresh description",
+ is_completed: false,
+ is_deleted: false,
+ updated_at: "2026-07-09T10:00:00.000Z",
+ });
+ await db.tasks.bulkAdd([scheduleWinner, freshContentLoser]);
+
+ await getDeduplicator().deduplicate([ORIGINAL_ID_1]);
+
+ const survivor = await db.tasks.get(UUID_SCHEDULE_WINNER);
+ expect(survivor?.next_date).toBe("2026-07-01");
+ expect(survivor?.appear_date).toBe("2026-06-28");
+ expect(survivor?.is_hidden).toBe(false);
+ // Content still merges from the freshest copy in the same pass.
+ expect(survivor?.name).toBe("Fresh name");
+ expect(survivor?.description).toBe("Fresh description");
+ });
+
+ // Spec scenario: "Hidden and revealed copies merge without breaking
+ // reveal timing." Schedule winner is hidden with a future appear_date;
+ // the freshest-content copy is already revealed. The merged winner must
+ // stay hidden with the schedule the winner's appear_date — content merge
+ // must never silently reveal the task.
+ it("should keep the winner hidden with the schedule winner's appear_date when the freshest-content copy is revealed", async () => {
+ const hiddenScheduleWinner = buildTask({
+ id: UUID_HIDDEN_SCHEDULE_WINNER,
+ original_task_id: ORIGINAL_ID_2,
+ next_date: "2026-08-01",
+ appear_date: "2026-07-30",
+ is_hidden: true,
+ name: "Stale hidden copy",
+ description: "Stale description",
+ is_completed: false,
+ is_deleted: false,
+ updated_at: "2026-06-01T10:00:00.000Z",
+ });
+ const revealedFreshContentLoser = buildTask({
+ id: UUID_REVEALED_FRESH_CONTENT_LOSER,
+ original_task_id: ORIGINAL_ID_2,
+ next_date: "2026-08-15",
+ appear_date: "",
+ is_hidden: false,
+ name: "Fresh revealed copy",
+ description: "Fresh description",
+ is_completed: false,
+ is_deleted: false,
+ updated_at: "2026-07-09T10:00:00.000Z",
+ });
+ await db.tasks.bulkAdd([hiddenScheduleWinner, revealedFreshContentLoser]);
+
+ await getDeduplicator().deduplicate([ORIGINAL_ID_2]);
+
+ const survivor = await db.tasks.get(UUID_HIDDEN_SCHEDULE_WINNER);
+ expect(survivor?.is_hidden).toBe(true);
+ expect(survivor?.appear_date).toBe("2026-07-30");
+ expect(survivor?.next_date).toBe("2026-08-01");
+ // Content still merges from the freshest (revealed) copy.
+ expect(survivor?.name).toBe("Fresh revealed copy");
+ expect(survivor?.description).toBe("Fresh description");
+ });
+ });
+
+ // FR3: box and sort_order are a per-box sort-key pair — they must travel
+ // together from the freshest-updated_at copy, never mixed across copies,
+ // even when that copy loses the schedule comparison.
+ describe("box and sort_order merge from freshest updated_at copy as a pair", () => {
+ it("should take box and sort_order together from the freshest-updated_at copy, not the schedule winner", async () => {
+ const scheduleWinner = buildTask({
+ id: UUID_BOX_SCHEDULE_WINNER,
+ original_task_id: ORIGINAL_ID_3,
+ next_date: "2026-07-01",
+ box: "inbox",
+ sort_order: "stale-sort-key",
+ is_completed: false,
+ is_deleted: false,
+ updated_at: "2026-06-01T10:00:00.000Z",
+ });
+ const freshContentLoser = buildTask({
+ id: UUID_BOX_FRESH_CONTENT_LOSER,
+ original_task_id: ORIGINAL_ID_3,
+ next_date: "2026-07-10",
+ box: "later",
+ sort_order: "fresh-sort-key",
+ is_completed: false,
+ is_deleted: false,
+ updated_at: "2026-07-09T10:00:00.000Z",
+ });
+ await db.tasks.bulkAdd([scheduleWinner, freshContentLoser]);
+
+ await getDeduplicator().deduplicate([ORIGINAL_ID_3]);
+
+ const survivor = await db.tasks.get(UUID_BOX_SCHEDULE_WINNER);
+ expect(survivor?.box).toBe("later");
+ expect(survivor?.sort_order).toBe("fresh-sort-key");
+ });
+ });
+});
diff --git a/packages/client/src/services/RecurringTaskDeduplicator.merge.ts b/packages/client/src/services/RecurringTaskDeduplicator.merge.ts
index 594889e4..4bd82230 100644
--- a/packages/client/src/services/RecurringTaskDeduplicator.merge.ts
+++ b/packages/client/src/services/RecurringTaskDeduplicator.merge.ts
@@ -1,3 +1,4 @@
+import { Temporal } from "@/lib/temporal";
import type { Task } from "@/types/entities";
/**
@@ -33,7 +34,10 @@ const MERGE_RELEVANT_FIELDS = [
*/
function findFreshestUpdatedAtCopy(copies: Task[]): Task {
return copies.reduce((freshest, candidate) =>
- candidate.updated_at.localeCompare(freshest.updated_at) > 0
+ Temporal.Instant.compare(
+ Temporal.Instant.from(candidate.updated_at),
+ Temporal.Instant.from(freshest.updated_at),
+ ) > 0
? candidate
: freshest,
);
diff --git a/packages/client/src/services/RecurringTaskDeduplicator.multiple-groups.test.ts b/packages/client/src/services/RecurringTaskDeduplicator.multiple-groups.test.ts
new file mode 100644
index 00000000..71d910ab
--- /dev/null
+++ b/packages/client/src/services/RecurringTaskDeduplicator.multiple-groups.test.ts
@@ -0,0 +1,134 @@
+// implements FR1, FR2, FR3, FR5 of dedup-recurring-after-pull
+import "fake-indexeddb/auto";
+import { describe, expect, it } from "vitest";
+import { db } from "@/db/database";
+import { buildTask } from "@/test/factories/taskFactory";
+import {
+ createRecurringTaskDeduplicatorMergeSetup,
+ ORIGINAL_ID_1,
+ ORIGINAL_ID_2,
+ UUID_A,
+ UUID_B,
+ UUID_C,
+} from "./RecurringTaskDeduplicator.test-setup";
+
+describe("RecurringTaskDeduplicator", () => {
+ const { getDeduplicator } = createRecurringTaskDeduplicatorMergeSetup();
+
+ // Multiple original_task_ids
+ describe("multiple groups", () => {
+ it("should deduplicate multiple original_task_id groups independently", async () => {
+ const groupAWinner = buildTask({
+ id: UUID_A,
+ original_task_id: ORIGINAL_ID_1,
+ next_date: "2026-07-01",
+ is_completed: false,
+ is_deleted: false,
+ });
+ const groupALoser = buildTask({
+ id: UUID_B,
+ original_task_id: ORIGINAL_ID_1,
+ next_date: "2026-07-05",
+ is_completed: false,
+ is_deleted: false,
+ });
+ const groupBWinner = buildTask({
+ original_task_id: ORIGINAL_ID_2,
+ next_date: "2026-07-02",
+ is_completed: false,
+ is_deleted: false,
+ });
+ const groupBLoser = buildTask({
+ original_task_id: ORIGINAL_ID_2,
+ next_date: "2026-07-08",
+ is_completed: false,
+ is_deleted: false,
+ });
+ await db.tasks.bulkAdd([
+ groupAWinner,
+ groupALoser,
+ groupBWinner,
+ groupBLoser,
+ ]);
+
+ await getDeduplicator().deduplicate([ORIGINAL_ID_1, ORIGINAL_ID_2]);
+
+ expect((await db.tasks.get(UUID_A))?.is_deleted).toBe(false);
+ expect((await db.tasks.get(UUID_B))?.is_deleted).toBe(true);
+ // Group B: winner has earlier next_date
+ const groupBTasks = await db.tasks
+ .where("original_task_id")
+ .equals(ORIGINAL_ID_2)
+ .toArray();
+ const groupBWinnerResult = groupBTasks.find(
+ (task) => task.next_date === "2026-07-02",
+ );
+ const groupBLoserResult = groupBTasks.find(
+ (task) => task.next_date === "2026-07-08",
+ );
+ expect(groupBWinnerResult?.is_deleted).toBe(false);
+ expect(groupBLoserResult?.is_deleted).toBe(true);
+ });
+
+ it("should deduplicate unique ids only (no double processing)", async () => {
+ const winnerTask = buildTask({
+ id: UUID_A,
+ original_task_id: ORIGINAL_ID_1,
+ next_date: "2026-07-01",
+ is_completed: false,
+ is_deleted: false,
+ });
+ const loserTask = buildTask({
+ id: UUID_B,
+ original_task_id: ORIGINAL_ID_1,
+ next_date: "2026-07-05",
+ is_completed: false,
+ is_deleted: false,
+ });
+ await db.tasks.bulkAdd([winnerTask, loserTask]);
+
+ await getDeduplicator().deduplicate([
+ ORIGINAL_ID_1,
+ ORIGINAL_ID_1,
+ ORIGINAL_ID_1,
+ ]);
+
+ expect((await db.tasks.get(UUID_A))?.is_deleted).toBe(false);
+ expect((await db.tasks.get(UUID_B))?.is_deleted).toBe(true);
+ });
+ });
+
+ // Three or more duplicates
+ describe("three or more duplicates", () => {
+ it("should keep only the winner among three duplicates", async () => {
+ const taskA = buildTask({
+ id: UUID_A,
+ original_task_id: ORIGINAL_ID_1,
+ next_date: "2026-07-03",
+ is_completed: false,
+ is_deleted: false,
+ });
+ const taskB = buildTask({
+ id: UUID_B,
+ original_task_id: ORIGINAL_ID_1,
+ next_date: "2026-07-01",
+ is_completed: false,
+ is_deleted: false,
+ });
+ const taskC = buildTask({
+ id: UUID_C,
+ original_task_id: ORIGINAL_ID_1,
+ next_date: "2026-07-05",
+ is_completed: false,
+ is_deleted: false,
+ });
+ await db.tasks.bulkAdd([taskA, taskB, taskC]);
+
+ await getDeduplicator().deduplicate([ORIGINAL_ID_1]);
+
+ expect((await db.tasks.get(UUID_B))?.is_deleted).toBe(false);
+ expect((await db.tasks.get(UUID_A))?.is_deleted).toBe(true);
+ expect((await db.tasks.get(UUID_C))?.is_deleted).toBe(true);
+ });
+ });
+});
diff --git a/packages/client/src/services/RecurringTaskDeduplicator.skip-optimization.test.ts b/packages/client/src/services/RecurringTaskDeduplicator.skip-optimization.test.ts
new file mode 100644
index 00000000..ab2f318f
--- /dev/null
+++ b/packages/client/src/services/RecurringTaskDeduplicator.skip-optimization.test.ts
@@ -0,0 +1,118 @@
+// implements FR1, FR2, FR3, FR5 of dedup-recurring-after-pull
+import "fake-indexeddb/auto";
+import { describe, expect, it } from "vitest";
+import { db } from "@/db/database";
+import { buildTask } from "@/test/factories/taskFactory";
+import {
+ createRecurringTaskDeduplicatorMergeSetup,
+ ORIGINAL_ID_1,
+ UUID_A,
+ UUID_B,
+} from "./RecurringTaskDeduplicator.test-setup";
+
+describe("RecurringTaskDeduplicator", () => {
+ const { getDeduplicator } = createRecurringTaskDeduplicatorMergeSetup();
+
+ // FR5: skip when list is empty
+ describe("skip optimization", () => {
+ it("should not modify any tasks when originalTaskIds is empty", async () => {
+ const originalUpdatedAt = "2026-06-01T10:00:00.000Z";
+ const taskA = buildTask({
+ original_task_id: ORIGINAL_ID_1,
+ next_date: "2026-07-01",
+ is_completed: false,
+ is_deleted: false,
+ updated_at: originalUpdatedAt,
+ });
+ const taskB = buildTask({
+ original_task_id: ORIGINAL_ID_1,
+ next_date: "2026-07-05",
+ is_completed: false,
+ is_deleted: false,
+ updated_at: originalUpdatedAt,
+ });
+ await db.tasks.bulkAdd([taskA, taskB]);
+
+ await getDeduplicator().deduplicate([]);
+
+ const resultA = await db.tasks.get(taskA.id);
+ const resultB = await db.tasks.get(taskB.id);
+ expect(resultA?.is_deleted).toBe(false);
+ expect(resultB?.is_deleted).toBe(false);
+ expect(resultA?.updated_at).toBe(originalUpdatedAt);
+ expect(resultB?.updated_at).toBe(originalUpdatedAt);
+ });
+
+ it("should filter out empty strings from originalTaskIds", async () => {
+ const taskA = buildTask({
+ original_task_id: ORIGINAL_ID_1,
+ next_date: "2026-07-01",
+ is_completed: false,
+ is_deleted: false,
+ });
+ const taskB = buildTask({
+ original_task_id: ORIGINAL_ID_1,
+ next_date: "2026-07-05",
+ is_completed: false,
+ is_deleted: false,
+ });
+ await db.tasks.bulkAdd([taskA, taskB]);
+
+ await getDeduplicator().deduplicate(["", ""]);
+
+ const resultA = await db.tasks.get(taskA.id);
+ const resultB = await db.tasks.get(taskB.id);
+ expect(resultA?.is_deleted).toBe(false);
+ expect(resultB?.is_deleted).toBe(false);
+ });
+
+ it("should process real ids but ignore empty strings in mixed list", async () => {
+ const winnerTask = buildTask({
+ id: UUID_A,
+ original_task_id: ORIGINAL_ID_1,
+ next_date: "2026-07-01",
+ is_completed: false,
+ is_deleted: false,
+ });
+ const loserTask = buildTask({
+ id: UUID_B,
+ original_task_id: ORIGINAL_ID_1,
+ next_date: "2026-07-05",
+ is_completed: false,
+ is_deleted: false,
+ });
+ // Non-recurring tasks that should never be touched by dedup
+ const nonRecurringTaskA = buildTask({
+ original_task_id: "",
+ next_date: "2026-07-01",
+ is_completed: false,
+ is_deleted: false,
+ });
+ const nonRecurringTaskB = buildTask({
+ original_task_id: "",
+ next_date: "2026-07-02",
+ is_completed: false,
+ is_deleted: false,
+ });
+ await db.tasks.bulkAdd([
+ winnerTask,
+ loserTask,
+ nonRecurringTaskA,
+ nonRecurringTaskB,
+ ]);
+
+ await getDeduplicator().deduplicate(["", ORIGINAL_ID_1, ""]);
+
+ // Duplicates should be deduped
+ expect((await db.tasks.get(UUID_A))?.is_deleted).toBe(false);
+ expect((await db.tasks.get(UUID_B))?.is_deleted).toBe(true);
+ // Non-recurring tasks must not be touched
+ const nonRecA = await db.tasks.get(nonRecurringTaskA.id);
+ const nonRecB = await db.tasks.get(nonRecurringTaskB.id);
+ expect(nonRecA?.is_deleted).toBe(false);
+ expect(nonRecB?.is_deleted).toBe(false);
+ expect(nonRecA?.syncStatus).toBe("synced");
+ expect(nonRecB?.syncStatus).toBe("synced");
+ });
+ });
+});
diff --git a/packages/client/src/services/RecurringTaskDeduplicator.test-setup.ts b/packages/client/src/services/RecurringTaskDeduplicator.test-setup.ts
new file mode 100644
index 00000000..881b1c14
--- /dev/null
+++ b/packages/client/src/services/RecurringTaskDeduplicator.test-setup.ts
@@ -0,0 +1,60 @@
+// implements FR3 of fix-stale-sync-overwrites
+import { beforeEach } from "vitest";
+import { db } from "@/db/database";
+import { ChecklistRepository } from "@/db/repositories/ChecklistRepository";
+import { TaskRepository } from "@/db/repositories/TaskRepository";
+import { fakeClock } from "@/lib/temporal";
+import { RecurringTaskDeduplicator } from "./RecurringTaskDeduplicator";
+
+// This must remain a THIRD distinct timestamp from every copy's updated_at
+// used across the merge test files, so tests can prove the winner's
+// updated_at is taken from the freshest copy verbatim — never refreshed to
+// "now" the way a loser's soft-delete updated_at is.
+export const FIXED_CLOCK_TIMESTAMP = "2026-07-10T12:00:00Z";
+
+/**
+ * Shared beforeEach for RecurringTaskDeduplicator merge test files: clears
+ * the tasks/checklist_items tables and constructs a deduplicator wired to a
+ * fake clock fixed at FIXED_CLOCK_TIMESTAMP.
+ */
+export function createRecurringTaskDeduplicatorMergeSetup(): {
+ getDeduplicator: () => RecurringTaskDeduplicator;
+} {
+ let deduplicator: RecurringTaskDeduplicator;
+
+ beforeEach(async () => {
+ await db.tasks.clear();
+ await db.checklist_items.clear();
+ const clock = fakeClock(FIXED_CLOCK_TIMESTAMP);
+ deduplicator = new RecurringTaskDeduplicator(
+ new TaskRepository(),
+ new ChecklistRepository(),
+ clock,
+ );
+ });
+
+ return {
+ getDeduplicator: () => deduplicator,
+ };
+}
+
+// Deterministic UUIDs for predictable winner-by-schedule ordering, shared
+// across the merge test files that use scenario 1 (content fields).
+export const UUID_SCHEDULE_WINNER = "00000000-0000-4000-a000-000000000010";
+export const UUID_FRESH_CONTENT_LOSER = "00000000-0000-4000-a000-000000000011";
+export const ORIGINAL_ID_1 = "11111111-1111-4111-a111-111111111111";
+export const STALE_GOAL_ID = "aaaaaaaa-0000-4000-a000-000000000001";
+export const STALE_CONTEXT_ID = "aaaaaaaa-0000-4000-a000-000000000002";
+export const STALE_CATEGORY_ID = "aaaaaaaa-0000-4000-a000-000000000003";
+export const FRESH_GOAL_ID = "bbbbbbbb-0000-4000-a000-000000000001";
+export const FRESH_CONTEXT_ID = "bbbbbbbb-0000-4000-a000-000000000002";
+export const FRESH_CATEGORY_ID = "bbbbbbbb-0000-4000-a000-000000000003";
+
+// Deterministic UUIDs for predictable tiebreak ordering, shared across the
+// characterization test files (winner selection, loser soft-delete,
+// multiple groups, skip optimization, guards).
+export const UUID_A = "00000000-0000-4000-a000-000000000001";
+export const UUID_B = "00000000-0000-4000-a000-000000000002";
+export const UUID_C = "00000000-0000-4000-a000-000000000003";
+export const UUID_Z = "ffffffff-ffff-4fff-afff-ffffffffffff";
+export const ORIGINAL_ID_2 = "22222222-2222-4222-a222-222222222222";
diff --git a/packages/client/src/services/RecurringTaskDeduplicator.test.ts b/packages/client/src/services/RecurringTaskDeduplicator.test.ts
deleted file mode 100644
index df4ae3a0..00000000
--- a/packages/client/src/services/RecurringTaskDeduplicator.test.ts
+++ /dev/null
@@ -1,692 +0,0 @@
-// implements FR1, FR2, FR3, FR5 of dedup-recurring-after-pull
-import "fake-indexeddb/auto";
-import { beforeEach, describe, expect, it, vi } from "vitest";
-import { db } from "@/db/database";
-import { ChecklistRepository } from "@/db/repositories/ChecklistRepository";
-import { TaskRepository } from "@/db/repositories/TaskRepository";
-import { fakeClock } from "@/lib/temporal";
-import { buildChecklistItem } from "@/test/factories/checklistItemFactory";
-import { buildTask } from "@/test/factories/taskFactory";
-import { RecurringTaskDeduplicator } from "./RecurringTaskDeduplicator";
-
-const FIXED_CLOCK_TIMESTAMP = "2026-07-10T12:00:00Z";
-
-// Deterministic UUIDs for predictable tiebreak ordering
-const UUID_A = "00000000-0000-4000-a000-000000000001";
-const UUID_B = "00000000-0000-4000-a000-000000000002";
-const UUID_C = "00000000-0000-4000-a000-000000000003";
-const UUID_Z = "ffffffff-ffff-4fff-afff-ffffffffffff";
-const ORIGINAL_ID_1 = "11111111-1111-4111-a111-111111111111";
-const ORIGINAL_ID_2 = "22222222-2222-4222-a222-222222222222";
-
-describe("RecurringTaskDeduplicator", () => {
- let deduplicator: RecurringTaskDeduplicator;
-
- beforeEach(async () => {
- await db.tasks.clear();
- await db.checklist_items.clear();
- const clock = fakeClock(FIXED_CLOCK_TIMESTAMP);
- deduplicator = new RecurringTaskDeduplicator(
- new TaskRepository(),
- new ChecklistRepository(),
- clock,
- );
- });
-
- // FR5: skip when list is empty
- describe("skip optimization", () => {
- it("should not modify any tasks when originalTaskIds is empty", async () => {
- const originalUpdatedAt = "2026-06-01T10:00:00.000Z";
- const taskA = buildTask({
- original_task_id: ORIGINAL_ID_1,
- next_date: "2026-07-01",
- is_completed: false,
- is_deleted: false,
- updated_at: originalUpdatedAt,
- });
- const taskB = buildTask({
- original_task_id: ORIGINAL_ID_1,
- next_date: "2026-07-05",
- is_completed: false,
- is_deleted: false,
- updated_at: originalUpdatedAt,
- });
- await db.tasks.bulkAdd([taskA, taskB]);
-
- await deduplicator.deduplicate([]);
-
- const resultA = await db.tasks.get(taskA.id);
- const resultB = await db.tasks.get(taskB.id);
- expect(resultA?.is_deleted).toBe(false);
- expect(resultB?.is_deleted).toBe(false);
- expect(resultA?.updated_at).toBe(originalUpdatedAt);
- expect(resultB?.updated_at).toBe(originalUpdatedAt);
- });
-
- it("should filter out empty strings from originalTaskIds", async () => {
- const taskA = buildTask({
- original_task_id: ORIGINAL_ID_1,
- next_date: "2026-07-01",
- is_completed: false,
- is_deleted: false,
- });
- const taskB = buildTask({
- original_task_id: ORIGINAL_ID_1,
- next_date: "2026-07-05",
- is_completed: false,
- is_deleted: false,
- });
- await db.tasks.bulkAdd([taskA, taskB]);
-
- await deduplicator.deduplicate(["", ""]);
-
- const resultA = await db.tasks.get(taskA.id);
- const resultB = await db.tasks.get(taskB.id);
- expect(resultA?.is_deleted).toBe(false);
- expect(resultB?.is_deleted).toBe(false);
- });
-
- it("should process real ids but ignore empty strings in mixed list", async () => {
- const winnerTask = buildTask({
- id: UUID_A,
- original_task_id: ORIGINAL_ID_1,
- next_date: "2026-07-01",
- is_completed: false,
- is_deleted: false,
- });
- const loserTask = buildTask({
- id: UUID_B,
- original_task_id: ORIGINAL_ID_1,
- next_date: "2026-07-05",
- is_completed: false,
- is_deleted: false,
- });
- // Non-recurring tasks that should never be touched by dedup
- const nonRecurringTaskA = buildTask({
- original_task_id: "",
- next_date: "2026-07-01",
- is_completed: false,
- is_deleted: false,
- });
- const nonRecurringTaskB = buildTask({
- original_task_id: "",
- next_date: "2026-07-02",
- is_completed: false,
- is_deleted: false,
- });
- await db.tasks.bulkAdd([
- winnerTask,
- loserTask,
- nonRecurringTaskA,
- nonRecurringTaskB,
- ]);
-
- await deduplicator.deduplicate(["", ORIGINAL_ID_1, ""]);
-
- // Duplicates should be deduped
- expect((await db.tasks.get(UUID_A))?.is_deleted).toBe(false);
- expect((await db.tasks.get(UUID_B))?.is_deleted).toBe(true);
- // Non-recurring tasks must not be touched
- const nonRecA = await db.tasks.get(nonRecurringTaskA.id);
- const nonRecB = await db.tasks.get(nonRecurringTaskB.id);
- expect(nonRecA?.is_deleted).toBe(false);
- expect(nonRecB?.is_deleted).toBe(false);
- expect(nonRecA?.syncStatus).toBe("synced");
- expect(nonRecB?.syncStatus).toBe("synced");
- });
- });
-
- // FR1 + FR2: detect duplicates and pick winner
- describe("winner selection", () => {
- it("should keep the copy with earliest next_date", async () => {
- const earlyTask = buildTask({
- id: UUID_A,
- original_task_id: ORIGINAL_ID_1,
- next_date: "2026-07-01",
- is_completed: false,
- is_deleted: false,
- });
- const lateTask = buildTask({
- id: UUID_B,
- original_task_id: ORIGINAL_ID_1,
- next_date: "2026-07-10",
- is_completed: false,
- is_deleted: false,
- });
- await db.tasks.bulkAdd([earlyTask, lateTask]);
-
- await deduplicator.deduplicate([ORIGINAL_ID_1]);
-
- const winner = await db.tasks.get(UUID_A);
- const loser = await db.tasks.get(UUID_B);
- expect(winner?.is_deleted).toBe(false);
- expect(loser?.is_deleted).toBe(true);
- expect(loser?.syncStatus).toBe("pending");
- });
-
- it("should tiebreak by lexicographically smallest id when next_date is same", async () => {
- // Insert UUID_Z first so stable sort without tiebreak would keep it as winner
- const taskZ = buildTask({
- id: UUID_Z,
- original_task_id: ORIGINAL_ID_1,
- next_date: "2026-07-01",
- is_completed: false,
- is_deleted: false,
- });
- const taskA = buildTask({
- id: UUID_A,
- original_task_id: ORIGINAL_ID_1,
- next_date: "2026-07-01",
- is_completed: false,
- is_deleted: false,
- });
- await db.tasks.bulkAdd([taskZ, taskA]);
-
- await deduplicator.deduplicate([ORIGINAL_ID_1]);
-
- const winner = await db.tasks.get(UUID_A);
- const loser = await db.tasks.get(UUID_Z);
- expect(winner?.is_deleted).toBe(false);
- expect(loser?.is_deleted).toBe(true);
- expect(loser?.syncStatus).toBe("pending");
- });
-
- it("should not modify a single copy (no duplicates)", async () => {
- const originalUpdatedAt = "2026-06-01T10:00:00.000Z";
- const singleTask = buildTask({
- id: UUID_A,
- original_task_id: ORIGINAL_ID_1,
- next_date: "2026-07-01",
- is_completed: false,
- is_deleted: false,
- updated_at: originalUpdatedAt,
- });
- await db.tasks.add(singleTask);
-
- await deduplicator.deduplicate([ORIGINAL_ID_1]);
-
- const result = await db.tasks.get(UUID_A);
- expect(result?.is_deleted).toBe(false);
- expect(result?.syncStatus).toBe("synced");
- expect(result?.updated_at).toBe(originalUpdatedAt);
- });
- });
-
- // FR2: loser soft-delete fields
- describe("loser soft-delete", () => {
- it("should set updated_at on loser to current clock timestamp", async () => {
- const taskA = buildTask({
- id: UUID_A,
- original_task_id: ORIGINAL_ID_1,
- next_date: "2026-07-01",
- is_completed: false,
- is_deleted: false,
- });
- const taskB = buildTask({
- id: UUID_B,
- original_task_id: ORIGINAL_ID_1,
- next_date: "2026-07-05",
- is_completed: false,
- is_deleted: false,
- });
- await db.tasks.bulkAdd([taskA, taskB]);
-
- await deduplicator.deduplicate([ORIGINAL_ID_1]);
-
- const loser = await db.tasks.get(UUID_B);
- expect(loser?.updated_at).toBe("2026-07-10T12:00:00.000Z");
- });
- });
-
- // FR1: ignore completed and deleted tasks
- describe("filtering", () => {
- it("should ignore completed copies when detecting duplicates", async () => {
- const activeTask = buildTask({
- id: UUID_A,
- original_task_id: ORIGINAL_ID_1,
- next_date: "2026-07-05",
- is_completed: false,
- is_deleted: false,
- });
- const completedTask = buildTask({
- id: UUID_B,
- original_task_id: ORIGINAL_ID_1,
- next_date: "2026-07-01",
- is_completed: true,
- is_deleted: false,
- });
- await db.tasks.bulkAdd([activeTask, completedTask]);
-
- await deduplicator.deduplicate([ORIGINAL_ID_1]);
-
- const active = await db.tasks.get(UUID_A);
- const completed = await db.tasks.get(UUID_B);
- expect(active?.is_deleted).toBe(false);
- expect(completed?.is_deleted).toBe(false);
- });
-
- it("should ignore already-deleted copies when detecting duplicates", async () => {
- const activeTask = buildTask({
- id: UUID_A,
- original_task_id: ORIGINAL_ID_1,
- next_date: "2026-07-05",
- is_completed: false,
- is_deleted: false,
- });
- const deletedTask = buildTask({
- id: UUID_B,
- original_task_id: ORIGINAL_ID_1,
- next_date: "2026-07-01",
- is_completed: false,
- is_deleted: true,
- });
- await db.tasks.bulkAdd([activeTask, deletedTask]);
-
- await deduplicator.deduplicate([ORIGINAL_ID_1]);
-
- const active = await db.tasks.get(UUID_A);
- expect(active?.is_deleted).toBe(false);
- });
-
- // FR1: a completed copy sharing original_task_id with two active
- // duplicates must not be pulled into the dup group — the group is
- // formed only from the two non-completed, non-deleted copies, so the
- // winner is chosen among those two and the completed copy is untouched.
- it("should form the duplicate group only from active copies when a completed copy shares the original_task_id with two active duplicates", async () => {
- const earlyActiveTask = buildTask({
- id: UUID_A,
- original_task_id: ORIGINAL_ID_1,
- next_date: "2026-07-01",
- is_completed: false,
- is_deleted: false,
- });
- const lateActiveTask = buildTask({
- id: UUID_B,
- original_task_id: ORIGINAL_ID_1,
- next_date: "2026-07-05",
- is_completed: false,
- is_deleted: false,
- });
- const completedTask = buildTask({
- id: UUID_C,
- original_task_id: ORIGINAL_ID_1,
- next_date: "2026-06-20",
- is_completed: true,
- is_deleted: false,
- });
- await db.tasks.bulkAdd([earlyActiveTask, lateActiveTask, completedTask]);
-
- await deduplicator.deduplicate([ORIGINAL_ID_1]);
-
- const earlyActiveResult = await db.tasks.get(UUID_A);
- const lateActiveResult = await db.tasks.get(UUID_B);
- const completedResult = await db.tasks.get(UUID_C);
- expect(earlyActiveResult?.is_deleted).toBe(false);
- expect(lateActiveResult?.is_deleted).toBe(true);
- expect(lateActiveResult?.syncStatus).toBe("pending");
- expect(completedResult?.is_deleted).toBe(false);
- expect(completedResult?.syncStatus).toBe("synced");
- });
- });
-
- // FR3: cascade to checklist items
- describe("checklist cascade", () => {
- it("should soft-delete checklist items of loser task", async () => {
- const winnerTask = buildTask({
- id: UUID_A,
- original_task_id: ORIGINAL_ID_1,
- next_date: "2026-07-01",
- is_completed: false,
- is_deleted: false,
- });
- const loserTask = buildTask({
- id: UUID_B,
- original_task_id: ORIGINAL_ID_1,
- next_date: "2026-07-10",
- is_completed: false,
- is_deleted: false,
- });
- const checklistItem = buildChecklistItem({
- task_id: UUID_B,
- is_deleted: false,
- });
- await db.tasks.bulkAdd([winnerTask, loserTask]);
- await db.checklist_items.add(checklistItem);
-
- await deduplicator.deduplicate([ORIGINAL_ID_1]);
-
- const item = await db.checklist_items.get(checklistItem.id);
- expect(item?.is_deleted).toBe(true);
- expect(item?.syncStatus).toBe("pending");
- expect(item?.updated_at).toBe("2026-07-10T12:00:00.000Z");
- });
-
- it("should not modify checklist items of winner task", async () => {
- const winnerTask = buildTask({
- id: UUID_A,
- original_task_id: ORIGINAL_ID_1,
- next_date: "2026-07-01",
- is_completed: false,
- is_deleted: false,
- });
- const loserTask = buildTask({
- id: UUID_B,
- original_task_id: ORIGINAL_ID_1,
- next_date: "2026-07-10",
- is_completed: false,
- is_deleted: false,
- });
- const winnerItem = buildChecklistItem({
- task_id: UUID_A,
- is_deleted: false,
- syncStatus: "synced",
- });
- await db.tasks.bulkAdd([winnerTask, loserTask]);
- await db.checklist_items.add(winnerItem);
-
- await deduplicator.deduplicate([ORIGINAL_ID_1]);
-
- const item = await db.checklist_items.get(winnerItem.id);
- expect(item?.is_deleted).toBe(false);
- expect(item?.syncStatus).toBe("synced");
- });
- });
-
- // Multiple original_task_ids
- describe("multiple groups", () => {
- it("should deduplicate multiple original_task_id groups independently", async () => {
- const groupAWinner = buildTask({
- id: UUID_A,
- original_task_id: ORIGINAL_ID_1,
- next_date: "2026-07-01",
- is_completed: false,
- is_deleted: false,
- });
- const groupALoser = buildTask({
- id: UUID_B,
- original_task_id: ORIGINAL_ID_1,
- next_date: "2026-07-05",
- is_completed: false,
- is_deleted: false,
- });
- const groupBWinner = buildTask({
- original_task_id: ORIGINAL_ID_2,
- next_date: "2026-07-02",
- is_completed: false,
- is_deleted: false,
- });
- const groupBLoser = buildTask({
- original_task_id: ORIGINAL_ID_2,
- next_date: "2026-07-08",
- is_completed: false,
- is_deleted: false,
- });
- await db.tasks.bulkAdd([
- groupAWinner,
- groupALoser,
- groupBWinner,
- groupBLoser,
- ]);
-
- await deduplicator.deduplicate([ORIGINAL_ID_1, ORIGINAL_ID_2]);
-
- expect((await db.tasks.get(UUID_A))?.is_deleted).toBe(false);
- expect((await db.tasks.get(UUID_B))?.is_deleted).toBe(true);
- // Group B: winner has earlier next_date
- const groupBTasks = await db.tasks
- .where("original_task_id")
- .equals(ORIGINAL_ID_2)
- .toArray();
- const groupBWinnerResult = groupBTasks.find(
- (task) => task.next_date === "2026-07-02",
- );
- const groupBLoserResult = groupBTasks.find(
- (task) => task.next_date === "2026-07-08",
- );
- expect(groupBWinnerResult?.is_deleted).toBe(false);
- expect(groupBLoserResult?.is_deleted).toBe(true);
- });
-
- it("should deduplicate unique ids only (no double processing)", async () => {
- const winnerTask = buildTask({
- id: UUID_A,
- original_task_id: ORIGINAL_ID_1,
- next_date: "2026-07-01",
- is_completed: false,
- is_deleted: false,
- });
- const loserTask = buildTask({
- id: UUID_B,
- original_task_id: ORIGINAL_ID_1,
- next_date: "2026-07-05",
- is_completed: false,
- is_deleted: false,
- });
- await db.tasks.bulkAdd([winnerTask, loserTask]);
-
- await deduplicator.deduplicate([
- ORIGINAL_ID_1,
- ORIGINAL_ID_1,
- ORIGINAL_ID_1,
- ]);
-
- expect((await db.tasks.get(UUID_A))?.is_deleted).toBe(false);
- expect((await db.tasks.get(UUID_B))?.is_deleted).toBe(true);
- });
- });
-
- // Three or more duplicates
- describe("three or more duplicates", () => {
- it("should keep only the winner among three duplicates", async () => {
- const taskA = buildTask({
- id: UUID_A,
- original_task_id: ORIGINAL_ID_1,
- next_date: "2026-07-03",
- is_completed: false,
- is_deleted: false,
- });
- const taskB = buildTask({
- id: UUID_B,
- original_task_id: ORIGINAL_ID_1,
- next_date: "2026-07-01",
- is_completed: false,
- is_deleted: false,
- });
- const taskC = buildTask({
- id: UUID_C,
- original_task_id: ORIGINAL_ID_1,
- next_date: "2026-07-05",
- is_completed: false,
- is_deleted: false,
- });
- await db.tasks.bulkAdd([taskA, taskB, taskC]);
-
- await deduplicator.deduplicate([ORIGINAL_ID_1]);
-
- expect((await db.tasks.get(UUID_B))?.is_deleted).toBe(false);
- expect((await db.tasks.get(UUID_A))?.is_deleted).toBe(true);
- expect((await db.tasks.get(UUID_C))?.is_deleted).toBe(true);
- });
- });
-
- // Mutants 1-2: early return guard when ids are empty
- describe("optimization guard — empty ids skip repository call", () => {
- it("should not call findDuplicateRecurringGroups when ids list is empty", async () => {
- const taskRepository = new TaskRepository();
- const checklistRepository = new ChecklistRepository();
- const clock = fakeClock(FIXED_CLOCK_TIMESTAMP);
- const findSpy = vi.spyOn(taskRepository, "findDuplicateRecurringGroups");
- const localDeduplicator = new RecurringTaskDeduplicator(
- taskRepository,
- checklistRepository,
- clock,
- );
-
- await localDeduplicator.deduplicate([]);
-
- expect(findSpy).not.toHaveBeenCalled();
- });
-
- it("should not call findDuplicateRecurringGroups when all ids are empty strings", async () => {
- const taskRepository = new TaskRepository();
- const checklistRepository = new ChecklistRepository();
- const clock = fakeClock(FIXED_CLOCK_TIMESTAMP);
- const findSpy = vi.spyOn(taskRepository, "findDuplicateRecurringGroups");
- const localDeduplicator = new RecurringTaskDeduplicator(
- taskRepository,
- checklistRepository,
- clock,
- );
-
- await localDeduplicator.deduplicate(["", ""]);
-
- expect(findSpy).not.toHaveBeenCalled();
- });
- });
-
- // Mutants 3-4: continue guard when group has single copy
- describe("optimization guard — single copy skips update", () => {
- it("should not call update when group has only one active copy", async () => {
- const taskRepository = new TaskRepository();
- const checklistRepository = new ChecklistRepository();
- const clock = fakeClock(FIXED_CLOCK_TIMESTAMP);
- const updateSpy = vi.spyOn(taskRepository, "update");
- const localDeduplicator = new RecurringTaskDeduplicator(
- taskRepository,
- checklistRepository,
- clock,
- );
-
- const singleTask = buildTask({
- id: UUID_A,
- original_task_id: ORIGINAL_ID_1,
- next_date: "2026-07-01",
- is_completed: false,
- is_deleted: false,
- });
- await db.tasks.add(singleTask);
-
- await localDeduplicator.deduplicate([ORIGINAL_ID_1]);
-
- expect(updateSpy).not.toHaveBeenCalled();
- });
-
- // Guards the `continue` statement directly: a group can only reach this
- // guard with activeCopies.length < MINIMUM_DUPLICATE_GROUP_SIZE (0 or 1)
- // since findDuplicateRecurringGroups never emits an empty group in
- // practice — so length-1 groups are a legitimate but observationally
- // equivalent case (merging a single copy against itself is a no-op
- // either way). Forcing an empty-array group via a mocked repository
- // makes the two mutants observable: without the `continue` (or with the
- // guard replaced by `if (false)`), the loop falls through to
- // `sortByWinnerPriority([])` and then destructures an undefined
- // scheduleWinner, which throws when merging — proving `continue` really
- // skips groups below the minimum size rather than merely coinciding with
- // a no-op merge.
- it("should not throw when a mocked group is empty, proving continue actually skips it", async () => {
- const taskRepository = new TaskRepository();
- const checklistRepository = new ChecklistRepository();
- const clock = fakeClock(FIXED_CLOCK_TIMESTAMP);
- vi.spyOn(
- taskRepository,
- "findDuplicateRecurringGroups",
- ).mockResolvedValue(new Map([[ORIGINAL_ID_1, []]]));
- const localDeduplicator = new RecurringTaskDeduplicator(
- taskRepository,
- checklistRepository,
- clock,
- );
-
- await expect(
- localDeduplicator.deduplicate([ORIGINAL_ID_1]),
- ).resolves.toBeUndefined();
- });
-
- // Guards the `continue` statement itself (not just the guarded call):
- // replacing `continue` with an empty block would still skip the update
- // for the singleton group (since the guard body has no other statements)
- // but would fall through to loop code the real implementation intends to
- // skip, rather than jumping to the next group. Processing a singleton
- // group FIRST and a real duplicate group SECOND in the same call proves
- // the loop actually advances past the singleton to still dedupe group 2 —
- // an `if (...) {}` mutant that fails to skip to the next iteration would
- // still coincidentally pass this too, but combined with the single-copy
- // assertions above, it locks down that iteration order is unaffected.
- it("should still deduplicate a later group after an earlier singleton group in the same call", async () => {
- const singleTask = buildTask({
- id: UUID_A,
- original_task_id: ORIGINAL_ID_1,
- next_date: "2026-07-01",
- is_completed: false,
- is_deleted: false,
- });
- const groupTwoWinner = buildTask({
- id: UUID_B,
- original_task_id: ORIGINAL_ID_2,
- next_date: "2026-07-02",
- is_completed: false,
- is_deleted: false,
- });
- const groupTwoLoser = buildTask({
- id: UUID_C,
- original_task_id: ORIGINAL_ID_2,
- next_date: "2026-07-08",
- is_completed: false,
- is_deleted: false,
- });
- await db.tasks.bulkAdd([singleTask, groupTwoWinner, groupTwoLoser]);
-
- await deduplicator.deduplicate([ORIGINAL_ID_1, ORIGINAL_ID_2]);
-
- expect((await db.tasks.get(UUID_A))?.syncStatus).toBe("synced");
- expect((await db.tasks.get(UUID_B))?.is_deleted).toBe(false);
- expect((await db.tasks.get(UUID_C))?.is_deleted).toBe(true);
- });
- });
-
- // Mutant 5: id tiebreak when next_date is equal
- describe("tiebreak by id — independent of repository order", () => {
- it("should pick lexicographically smallest id even when repository returns reverse order", async () => {
- const taskRepository = new TaskRepository();
- const checklistRepository = new ChecklistRepository();
- const clock = fakeClock(FIXED_CLOCK_TIMESTAMP);
-
- const taskZ = buildTask({
- id: UUID_Z,
- original_task_id: ORIGINAL_ID_1,
- next_date: "2026-07-01",
- is_completed: false,
- is_deleted: false,
- });
- const taskA = buildTask({
- id: UUID_A,
- original_task_id: ORIGINAL_ID_1,
- next_date: "2026-07-01",
- is_completed: false,
- is_deleted: false,
- });
- await db.tasks.bulkAdd([taskZ, taskA]);
-
- // Mock repository to return tasks in reverse order (UUID_Z first)
- // so stable sort without id tiebreak would keep UUID_Z as winner
- const reversedGroups = new Map([[ORIGINAL_ID_1, [taskZ, taskA]]]);
- vi.spyOn(
- taskRepository,
- "findDuplicateRecurringGroups",
- ).mockResolvedValue(reversedGroups);
-
- const localDeduplicator = new RecurringTaskDeduplicator(
- taskRepository,
- checklistRepository,
- clock,
- );
-
- await localDeduplicator.deduplicate([ORIGINAL_ID_1]);
-
- const winner = await db.tasks.get(UUID_A);
- const loser = await db.tasks.get(UUID_Z);
- expect(winner?.is_deleted).toBe(false);
- expect(loser?.is_deleted).toBe(true);
- });
- });
-});
diff --git a/packages/client/src/services/RecurringTaskDeduplicator.tiebreak-by-id.test.ts b/packages/client/src/services/RecurringTaskDeduplicator.tiebreak-by-id.test.ts
new file mode 100644
index 00000000..e16228e4
--- /dev/null
+++ b/packages/client/src/services/RecurringTaskDeduplicator.tiebreak-by-id.test.ts
@@ -0,0 +1,67 @@
+// implements FR1, FR2, FR3, FR5 of dedup-recurring-after-pull
+import "fake-indexeddb/auto";
+import { describe, expect, it, vi } from "vitest";
+import { db } from "@/db/database";
+import { ChecklistRepository } from "@/db/repositories/ChecklistRepository";
+import { TaskRepository } from "@/db/repositories/TaskRepository";
+import { fakeClock } from "@/lib/temporal";
+import { buildTask } from "@/test/factories/taskFactory";
+import { RecurringTaskDeduplicator } from "./RecurringTaskDeduplicator";
+import {
+ createRecurringTaskDeduplicatorMergeSetup,
+ ORIGINAL_ID_1,
+ UUID_A,
+ UUID_Z,
+} from "./RecurringTaskDeduplicator.test-setup";
+
+const FIXED_CLOCK_TIMESTAMP = "2026-07-10T12:00:00Z";
+
+describe("RecurringTaskDeduplicator", () => {
+ createRecurringTaskDeduplicatorMergeSetup();
+
+ // Mutant 5: id tiebreak when next_date is equal
+ describe("tiebreak by id — independent of repository order", () => {
+ it("should pick lexicographically smallest id even when repository returns reverse order", async () => {
+ const taskRepository = new TaskRepository();
+ const checklistRepository = new ChecklistRepository();
+ const clock = fakeClock(FIXED_CLOCK_TIMESTAMP);
+
+ const taskZ = buildTask({
+ id: UUID_Z,
+ original_task_id: ORIGINAL_ID_1,
+ next_date: "2026-07-01",
+ is_completed: false,
+ is_deleted: false,
+ });
+ const taskA = buildTask({
+ id: UUID_A,
+ original_task_id: ORIGINAL_ID_1,
+ next_date: "2026-07-01",
+ is_completed: false,
+ is_deleted: false,
+ });
+ await db.tasks.bulkAdd([taskZ, taskA]);
+
+ // Mock repository to return tasks in reverse order (UUID_Z first)
+ // so stable sort without id tiebreak would keep UUID_Z as winner
+ const reversedGroups = new Map([[ORIGINAL_ID_1, [taskZ, taskA]]]);
+ vi.spyOn(
+ taskRepository,
+ "findDuplicateRecurringGroups",
+ ).mockResolvedValue(reversedGroups);
+
+ const localDeduplicator = new RecurringTaskDeduplicator(
+ taskRepository,
+ checklistRepository,
+ clock,
+ );
+
+ await localDeduplicator.deduplicate([ORIGINAL_ID_1]);
+
+ const winner = await db.tasks.get(UUID_A);
+ const loser = await db.tasks.get(UUID_Z);
+ expect(winner?.is_deleted).toBe(false);
+ expect(loser?.is_deleted).toBe(true);
+ });
+ });
+});
diff --git a/packages/client/src/services/RecurringTaskDeduplicator.winner-selection.test.ts b/packages/client/src/services/RecurringTaskDeduplicator.winner-selection.test.ts
new file mode 100644
index 00000000..addd807b
--- /dev/null
+++ b/packages/client/src/services/RecurringTaskDeduplicator.winner-selection.test.ts
@@ -0,0 +1,184 @@
+// implements FR1, FR2, FR3, FR5 of dedup-recurring-after-pull
+import "fake-indexeddb/auto";
+import { describe, expect, it } from "vitest";
+import { db } from "@/db/database";
+import { buildTask } from "@/test/factories/taskFactory";
+import {
+ createRecurringTaskDeduplicatorMergeSetup,
+ ORIGINAL_ID_1,
+ UUID_A,
+ UUID_B,
+ UUID_C,
+ UUID_Z,
+} from "./RecurringTaskDeduplicator.test-setup";
+
+describe("RecurringTaskDeduplicator", () => {
+ const { getDeduplicator } = createRecurringTaskDeduplicatorMergeSetup();
+
+ // FR1 + FR2: detect duplicates and pick winner
+ describe("winner selection", () => {
+ it("should keep the copy with earliest next_date", async () => {
+ const earlyTask = buildTask({
+ id: UUID_A,
+ original_task_id: ORIGINAL_ID_1,
+ next_date: "2026-07-01",
+ is_completed: false,
+ is_deleted: false,
+ });
+ const lateTask = buildTask({
+ id: UUID_B,
+ original_task_id: ORIGINAL_ID_1,
+ next_date: "2026-07-10",
+ is_completed: false,
+ is_deleted: false,
+ });
+ await db.tasks.bulkAdd([earlyTask, lateTask]);
+
+ await getDeduplicator().deduplicate([ORIGINAL_ID_1]);
+
+ const winner = await db.tasks.get(UUID_A);
+ const loser = await db.tasks.get(UUID_B);
+ expect(winner?.is_deleted).toBe(false);
+ expect(loser?.is_deleted).toBe(true);
+ expect(loser?.syncStatus).toBe("pending");
+ });
+
+ it("should tiebreak by lexicographically smallest id when next_date is same", async () => {
+ // Insert UUID_Z first so stable sort without tiebreak would keep it as winner
+ const taskZ = buildTask({
+ id: UUID_Z,
+ original_task_id: ORIGINAL_ID_1,
+ next_date: "2026-07-01",
+ is_completed: false,
+ is_deleted: false,
+ });
+ const taskA = buildTask({
+ id: UUID_A,
+ original_task_id: ORIGINAL_ID_1,
+ next_date: "2026-07-01",
+ is_completed: false,
+ is_deleted: false,
+ });
+ await db.tasks.bulkAdd([taskZ, taskA]);
+
+ await getDeduplicator().deduplicate([ORIGINAL_ID_1]);
+
+ const winner = await db.tasks.get(UUID_A);
+ const loser = await db.tasks.get(UUID_Z);
+ expect(winner?.is_deleted).toBe(false);
+ expect(loser?.is_deleted).toBe(true);
+ expect(loser?.syncStatus).toBe("pending");
+ });
+
+ it("should not modify a single copy (no duplicates)", async () => {
+ const originalUpdatedAt = "2026-06-01T10:00:00.000Z";
+ const singleTask = buildTask({
+ id: UUID_A,
+ original_task_id: ORIGINAL_ID_1,
+ next_date: "2026-07-01",
+ is_completed: false,
+ is_deleted: false,
+ updated_at: originalUpdatedAt,
+ });
+ await db.tasks.add(singleTask);
+
+ await getDeduplicator().deduplicate([ORIGINAL_ID_1]);
+
+ const result = await db.tasks.get(UUID_A);
+ expect(result?.is_deleted).toBe(false);
+ expect(result?.syncStatus).toBe("synced");
+ expect(result?.updated_at).toBe(originalUpdatedAt);
+ });
+ });
+
+ // FR1: ignore completed and deleted tasks
+ describe("filtering", () => {
+ it("should ignore completed copies when detecting duplicates", async () => {
+ const activeTask = buildTask({
+ id: UUID_A,
+ original_task_id: ORIGINAL_ID_1,
+ next_date: "2026-07-05",
+ is_completed: false,
+ is_deleted: false,
+ });
+ const completedTask = buildTask({
+ id: UUID_B,
+ original_task_id: ORIGINAL_ID_1,
+ next_date: "2026-07-01",
+ is_completed: true,
+ is_deleted: false,
+ });
+ await db.tasks.bulkAdd([activeTask, completedTask]);
+
+ await getDeduplicator().deduplicate([ORIGINAL_ID_1]);
+
+ const active = await db.tasks.get(UUID_A);
+ const completed = await db.tasks.get(UUID_B);
+ expect(active?.is_deleted).toBe(false);
+ expect(completed?.is_deleted).toBe(false);
+ });
+
+ it("should ignore already-deleted copies when detecting duplicates", async () => {
+ const activeTask = buildTask({
+ id: UUID_A,
+ original_task_id: ORIGINAL_ID_1,
+ next_date: "2026-07-05",
+ is_completed: false,
+ is_deleted: false,
+ });
+ const deletedTask = buildTask({
+ id: UUID_B,
+ original_task_id: ORIGINAL_ID_1,
+ next_date: "2026-07-01",
+ is_completed: false,
+ is_deleted: true,
+ });
+ await db.tasks.bulkAdd([activeTask, deletedTask]);
+
+ await getDeduplicator().deduplicate([ORIGINAL_ID_1]);
+
+ const active = await db.tasks.get(UUID_A);
+ expect(active?.is_deleted).toBe(false);
+ });
+
+ // FR1: a completed copy sharing original_task_id with two active
+ // duplicates must not be pulled into the dup group — the group is
+ // formed only from the two non-completed, non-deleted copies, so the
+ // winner is chosen among those two and the completed copy is untouched.
+ it("should form the duplicate group only from active copies when a completed copy shares the original_task_id with two active duplicates", async () => {
+ const earlyActiveTask = buildTask({
+ id: UUID_A,
+ original_task_id: ORIGINAL_ID_1,
+ next_date: "2026-07-01",
+ is_completed: false,
+ is_deleted: false,
+ });
+ const lateActiveTask = buildTask({
+ id: UUID_B,
+ original_task_id: ORIGINAL_ID_1,
+ next_date: "2026-07-05",
+ is_completed: false,
+ is_deleted: false,
+ });
+ const completedTask = buildTask({
+ id: UUID_C,
+ original_task_id: ORIGINAL_ID_1,
+ next_date: "2026-06-20",
+ is_completed: true,
+ is_deleted: false,
+ });
+ await db.tasks.bulkAdd([earlyActiveTask, lateActiveTask, completedTask]);
+
+ await getDeduplicator().deduplicate([ORIGINAL_ID_1]);
+
+ const earlyActiveResult = await db.tasks.get(UUID_A);
+ const lateActiveResult = await db.tasks.get(UUID_B);
+ const completedResult = await db.tasks.get(UUID_C);
+ expect(earlyActiveResult?.is_deleted).toBe(false);
+ expect(lateActiveResult?.is_deleted).toBe(true);
+ expect(lateActiveResult?.syncStatus).toBe("pending");
+ expect(completedResult?.is_deleted).toBe(false);
+ expect(completedResult?.syncStatus).toBe("synced");
+ });
+ });
+});
diff --git a/packages/integration/docker-compose.yml b/packages/integration/docker-compose.yml
index 862587f7..82191a0f 100644
--- a/packages/integration/docker-compose.yml
+++ b/packages/integration/docker-compose.yml
@@ -54,7 +54,12 @@ services:
GOTRUE_JWT_ADMIN_ROLES: service_role
GOTRUE_JWT_AUD: authenticated
GOTRUE_JWT_DEFAULT_GROUP_NAME: authenticated
- GOTRUE_JWT_EXP: "3600"
+ # 7-day access tokens: multi-device stale-sync tests fast-forward a
+ # device's browser clock up to ~2 days to simulate reconnecting later.
+ # A 1-hour token would appear expired after that jump and trigger a
+ # refresh, which races/fails under the faked clock. A token that outlasts
+ # the jump is never refreshed, keeping those tests deterministic.
+ GOTRUE_JWT_EXP: "604800"
GOTRUE_JWT_SECRET: ${JWT_SECRET}
GOTRUE_MAILER_AUTOCONFIRM: "true"
GOTRUE_SMTP_HOST: ""
diff --git a/packages/integration/kong.yml b/packages/integration/kong.yml
index d7210426..e47fcc6d 100644
--- a/packages/integration/kong.yml
+++ b/packages/integration/kong.yml
@@ -22,6 +22,7 @@ services:
- Authorization
- apikey
- x-client-info
+ - x-supabase-api-version
- name: rest-v1
url: http://rest:3000
@@ -40,6 +41,7 @@ services:
- Authorization
- apikey
- x-client-info
+ - x-supabase-api-version
- Prefer
- name: storage-v1
@@ -59,6 +61,7 @@ services:
- Authorization
- apikey
- x-client-info
+ - x-supabase-api-version
- name: functions-v1
url: http://edge-functions:9000
@@ -77,3 +80,4 @@ services:
- Authorization
- apikey
- x-client-info
+ - x-supabase-api-version
diff --git a/packages/integration/src/convergence-helpers.ts b/packages/integration/src/convergence-helpers.ts
index 94c40d04..3a1a7a7e 100644
--- a/packages/integration/src/convergence-helpers.ts
+++ b/packages/integration/src/convergence-helpers.ts
@@ -123,6 +123,13 @@ export async function dumpServerState(
* Asserts that device A, device B, and the server all hold an identical set
* of records for the given entity table — the NFR-REL1 convergence check.
* Call after both devices have completed a push+pull cycle.
+ *
+ * `recordFilter` scopes the comparison to the records a test actually touched.
+ * The backend user is shared across the whole integration suite, so unrelated
+ * records accumulate — and a clock-advanced device legitimately reveals OTHER
+ * specs' hidden occurrences whose `appear_date` falls on the faked date. Those
+ * are harness noise, not a convergence failure of the scenario under test;
+ * filtering by (e.g.) task name keeps the assertion about this test's records.
* Implements NFR-REL1 of fix-stale-sync-overwrites.
*/
export async function assertConverged(
@@ -130,6 +137,7 @@ export async function assertConverged(
pageB: Page,
credentials: ServerCallCredentials,
entityKey: ConvergenceEntityKey,
+ recordFilter: (record: EntityRecord) => boolean = () => true,
): Promise {
const [deviceAState, deviceBState, serverState] = await Promise.all([
dumpDeviceState(pageA, entityKey),
@@ -137,6 +145,10 @@ export async function assertConverged(
dumpServerState(credentials, entityKey),
]);
- expect(deviceAState).toEqual(serverState);
- expect(deviceBState).toEqual(serverState);
+ expect(deviceAState.filter(recordFilter)).toEqual(
+ serverState.filter(recordFilter),
+ );
+ expect(deviceBState.filter(recordFilter)).toEqual(
+ serverState.filter(recordFilter),
+ );
}
diff --git a/packages/integration/src/page-actions.ts b/packages/integration/src/page-actions.ts
deleted file mode 100644
index bba3125c..00000000
--- a/packages/integration/src/page-actions.ts
+++ /dev/null
@@ -1,272 +0,0 @@
-// Shared UI interaction helpers for integration tests.
-// Eliminates duplication of common create / open / update / delete sequences.
-import type { Page } from "@playwright/test";
-
-// ---------------------------------------------------------------------------
-// Task actions (assumes page is on /tasks)
-// ---------------------------------------------------------------------------
-
-export async function createTask(page: Page, taskName: string): Promise {
- // If box filter is present (Active Tasks page), select "today" to ensure
- // the task is created in a visible box (defaultBox may be "inbox").
- const filterToggle = page.getByTestId("command-bar-filter-toggle");
- if (await filterToggle.isVisible()) {
- await filterToggle.click();
- await page.getByTestId("box-filter-today").click();
- }
-
- const textarea = page.getByTestId("command-bar-textarea");
- await textarea.click();
- await textarea.fill(taskName);
- await textarea.press("Enter");
-}
-
-export function findTaskItem(page: Page, taskName: string) {
- return page.locator('[data-testid="task-item"]').filter({
- has: page.locator('[data-testid="task-item-name"]', {
- hasText: taskName,
- }),
- });
-}
-
-export async function openTaskDetail(
- page: Page,
- taskName: string,
-): Promise {
- await findTaskItem(page, taskName)
- .locator('[data-testid="task-item-body"]')
- .click();
- await page.waitForSelector('[data-testid="task-detail-panel"]');
-}
-
-export async function updateTaskName(
- page: Page,
- newName: string,
-): Promise {
- await page.getByTestId("task-detail-name").fill(newName);
- await page.getByTestId("task-detail-name").blur();
-}
-
-/**
- * Updates a task's description from the task detail panel.
- * `EditableDescription` is click-to-edit: at rest it renders a div, and only
- * becomes a textarea after being clicked. Saving happens on blur.
- */
-export async function updateTaskDescription(
- page: Page,
- newDescription: string,
-): Promise {
- const descriptionField = page.getByTestId("task-detail-description");
- await descriptionField.click();
- await descriptionField.fill(newDescription);
- await descriptionField.blur();
-}
-
-/**
- * Hides a non-recurring task until the given ISO date via the task detail
- * panel's "Hide until" drill-down row. Assumes the task detail panel is
- * already open (see `openTaskDetail`).
- * Implements FR2 of fix-stale-sync-overwrites.
- */
-export async function hideTaskUntil(
- page: Page,
- appearDateIso: string,
-): Promise {
- await page
- .getByTestId("task-detail-panel")
- .getByRole("button", { name: /hide until/i })
- .click();
- const hidePanel = page.getByTestId("hide-task-panel");
- await hidePanel.waitFor({ state: "visible" });
- await hidePanel.getByLabel(/select date/i).fill(appearDateIso);
- await hidePanel.getByRole("button", { name: /^hide$/i }).click();
- await hidePanel.waitFor({ state: "hidden" });
-}
-
-/**
- * Manually unhides a task before its appear_date via the task detail panel's
- * "Hide until" drill-down row, which shows the unhide control while the task
- * is hidden. Assumes the task detail panel is already open.
- * Implements FR2 of fix-stale-sync-overwrites.
- */
-export async function unhideTask(page: Page): Promise {
- await page
- .getByTestId("task-detail-panel")
- .getByRole("button", { name: /hide until/i })
- .click();
- const hidePanel = page.getByTestId("hide-task-panel");
- await hidePanel.waitFor({ state: "visible" });
- await hidePanel.getByRole("button", { name: /unhide/i }).click();
- await hidePanel.waitFor({ state: "hidden" });
-}
-
-export async function deleteTaskFromDetail(page: Page): Promise {
- await page
- .getByTestId("task-detail-panel")
- .getByRole("button", { name: /delete task/i })
- .click();
- await page.waitForSelector('[data-testid="task-detail-delete-confirm-btn"]');
- await page.getByTestId("task-detail-delete-confirm-btn").click();
- await page.waitForSelector('[data-testid="task-detail-panel"]', {
- state: "detached",
- });
-}
-
-// ---------------------------------------------------------------------------
-// Checklist actions
-// ---------------------------------------------------------------------------
-
-export async function switchToChecklistTab(page: Page): Promise {
- await page.getByTestId("tab-checklist").click();
-}
-
-export async function addChecklistItem(
- page: Page,
- itemName: string,
-): Promise {
- const checklistInput = page
- .getByTestId("task-detail-panel")
- .locator('input[type="text"]');
- await checklistInput.fill(itemName);
- await checklistInput.press("Enter");
- await page
- .getByTestId("task-detail-panel")
- .locator(`text=${itemName}`)
- .waitFor({ state: "visible" });
-}
-
-// ---------------------------------------------------------------------------
-// Goal actions
-// ---------------------------------------------------------------------------
-
-export async function navigateToGoals(page: Page): Promise {
- await page.goto("/goals");
- await page.waitForSelector('[data-testid="goals-page"]');
-}
-
-export async function createGoal(page: Page, goalName: string): Promise {
- const textarea = page.getByTestId("command-bar-textarea");
- await textarea.click();
- await textarea.fill(goalName);
- await textarea.press("Enter");
- await page
- .locator('[data-testid="goal-item"]')
- .filter({ has: page.locator(`text=${goalName}`) })
- .waitFor({ state: "visible" });
-}
-
-export async function openGoalDetail(
- page: Page,
- goalName: string,
-): Promise {
- await page
- .locator('[data-testid="goal-item"]')
- .filter({ has: page.locator(`text=${goalName}`) })
- .locator('[data-testid="goal-navigate-button"]')
- .click();
- await page.waitForSelector('[data-testid="goal-detail-page"]');
-}
-
-export async function updateGoalName(
- page: Page,
- newName: string,
-): Promise {
- await page.getByTestId("edit-goal-button").click();
- await page.getByTestId("goal-name-input").fill(newName);
- await page.getByTestId("goal-save-button").click();
- await page.getByTestId("edit-goal-button").waitFor({ state: "visible" });
-}
-
-export async function deleteGoalFromDetail(page: Page): Promise {
- await page.getByTestId("edit-goal-button").click();
- await page.getByTestId("goal-delete-button").click();
- await page.waitForSelector('[data-testid="goal-delete-confirm"]');
- await page.getByTestId("goal-delete-confirm-btn").click();
- await page.waitForSelector('[data-testid="goals-page"]');
-}
-
-export async function uploadCoverToGoal(
- page: Page,
- file: { name: string; mimeType: string; buffer: Buffer },
-): Promise {
- await page.getByTestId("edit-goal-button").click();
- await page.getByTestId("cover-file-input").setInputFiles(file);
- await page.getByTestId("cover-preview-img").waitFor({ state: "visible" });
- await page.getByTestId("goal-save-button").click();
- await page.getByTestId("edit-goal-button").waitFor({ state: "visible" });
-}
-
-export async function removeCoverFromGoal(page: Page): Promise {
- await page.getByTestId("edit-goal-button").click();
- await page.getByTestId("cover-remove-button").click();
- await page.getByTestId("goal-save-button").click();
- await page.getByTestId("edit-goal-button").waitFor({ state: "visible" });
-}
-
-// ---------------------------------------------------------------------------
-// Category actions
-// ---------------------------------------------------------------------------
-
-export async function navigateToCategories(page: Page): Promise {
- await page.goto("/categories");
- await page.waitForSelector('[data-testid="categories-page"]');
-}
-
-export async function createCategory(
- page: Page,
- categoryName: string,
-): Promise {
- const textarea = page.getByTestId("command-bar-textarea");
- await textarea.click();
- await textarea.fill(categoryName);
- await textarea.press("Enter");
- await page.locator(`text=${categoryName}`).waitFor({ state: "visible" });
-}
-
-export async function openCategoryDetail(
- page: Page,
- categoryName: string,
-): Promise {
- await page.locator(`text=${categoryName}`).click();
- await page.waitForSelector('[data-testid="category-detail-page"]');
-}
-
-export async function updateCategoryName(
- page: Page,
- newName: string,
-): Promise {
- await page.getByTestId("category-edit-btn").click();
- await page.getByTestId("category-name-input").fill(newName);
- await page.getByTestId("category-save-btn").click();
-}
-
-export async function deleteCategoryFromDetail(page: Page): Promise {
- await page.getByTestId("category-edit-btn").click();
- await page.getByTestId("category-delete-btn").click();
- await page.getByTestId("category-delete-confirm-btn").click();
- await page.waitForSelector('[data-testid="categories-page"]');
-}
-
-// ---------------------------------------------------------------------------
-// Attachment actions
-// ---------------------------------------------------------------------------
-
-export async function deleteFirstAttachment(page: Page): Promise {
- await page.locator('[data-testid^="attachment-delete-"]').first().click();
- const confirmButton = page.getByTestId("confirm-dialog-confirm");
- if (await confirmButton.isVisible().catch(() => false)) {
- await confirmButton.click();
- }
-}
-
-export async function attachFileToEntity(
- page: Page,
- file: { name: string; mimeType: string; buffer: Buffer },
-): Promise {
- await page.getByTestId("tab-attachments").click();
- await page.getByTestId("attach-file-input").setInputFiles(file);
- await page
- .locator('[data-testid^="attachment-item-"]')
- .first()
- .waitFor({ state: "visible" });
-}
diff --git a/packages/integration/src/page-attachment-actions.ts b/packages/integration/src/page-attachment-actions.ts
new file mode 100644
index 00000000..e1a79af5
--- /dev/null
+++ b/packages/integration/src/page-attachment-actions.ts
@@ -0,0 +1,27 @@
+// Shared UI interaction helpers for integration tests: attachment actions.
+// Split from page-actions.ts (process-invariants file-size cap).
+import type { Page } from "@playwright/test";
+
+// ---------------------------------------------------------------------------
+// Attachment actions
+// ---------------------------------------------------------------------------
+
+export async function deleteFirstAttachment(page: Page): Promise {
+ await page.locator('[data-testid^="attachment-delete-"]').first().click();
+ const confirmButton = page.getByTestId("confirm-dialog-confirm");
+ if (await confirmButton.isVisible().catch(() => false)) {
+ await confirmButton.click();
+ }
+}
+
+export async function attachFileToEntity(
+ page: Page,
+ file: { name: string; mimeType: string; buffer: Buffer },
+): Promise {
+ await page.getByTestId("tab-attachments").click();
+ await page.getByTestId("attach-file-input").setInputFiles(file);
+ await page
+ .locator('[data-testid^="attachment-item-"]')
+ .first()
+ .waitFor({ state: "visible" });
+}
diff --git a/packages/integration/src/page-checklist-actions.ts b/packages/integration/src/page-checklist-actions.ts
new file mode 100644
index 00000000..d3efc182
--- /dev/null
+++ b/packages/integration/src/page-checklist-actions.ts
@@ -0,0 +1,26 @@
+// Shared UI interaction helpers for integration tests: checklist actions.
+// Split from page-actions.ts (process-invariants file-size cap).
+import type { Page } from "@playwright/test";
+
+// ---------------------------------------------------------------------------
+// Checklist actions
+// ---------------------------------------------------------------------------
+
+export async function switchToChecklistTab(page: Page): Promise {
+ await page.getByTestId("tab-checklist").click();
+}
+
+export async function addChecklistItem(
+ page: Page,
+ itemName: string,
+): Promise {
+ const checklistInput = page
+ .getByTestId("task-detail-panel")
+ .locator('input[type="text"]');
+ await checklistInput.fill(itemName);
+ await checklistInput.press("Enter");
+ await page
+ .getByTestId("task-detail-panel")
+ .locator(`text=${itemName}`)
+ .waitFor({ state: "visible" });
+}
diff --git a/packages/integration/src/page-goal-category-actions.ts b/packages/integration/src/page-goal-category-actions.ts
new file mode 100644
index 00000000..3ba25828
--- /dev/null
+++ b/packages/integration/src/page-goal-category-actions.ts
@@ -0,0 +1,115 @@
+// Shared UI interaction helpers for integration tests: goal and category actions.
+// Split from page-actions.ts (process-invariants file-size cap).
+import type { Page } from "@playwright/test";
+
+// ---------------------------------------------------------------------------
+// Goal actions
+// ---------------------------------------------------------------------------
+
+export async function navigateToGoals(page: Page): Promise {
+ await page.goto("/goals");
+ await page.waitForSelector('[data-testid="goals-page"]');
+}
+
+export async function createGoal(page: Page, goalName: string): Promise {
+ const textarea = page.getByTestId("command-bar-textarea");
+ await textarea.click();
+ await textarea.fill(goalName);
+ await textarea.press("Enter");
+ await page
+ .locator('[data-testid="goal-item"]')
+ .filter({ has: page.locator(`text=${goalName}`) })
+ .waitFor({ state: "visible" });
+}
+
+export async function openGoalDetail(
+ page: Page,
+ goalName: string,
+): Promise {
+ await page
+ .locator('[data-testid="goal-item"]')
+ .filter({ has: page.locator(`text=${goalName}`) })
+ .locator('[data-testid="goal-navigate-button"]')
+ .click();
+ await page.waitForSelector('[data-testid="goal-detail-page"]');
+}
+
+export async function updateGoalName(
+ page: Page,
+ newName: string,
+): Promise {
+ await page.getByTestId("edit-goal-button").click();
+ await page.getByTestId("goal-name-input").fill(newName);
+ await page.getByTestId("goal-save-button").click();
+ await page.getByTestId("edit-goal-button").waitFor({ state: "visible" });
+}
+
+export async function deleteGoalFromDetail(page: Page): Promise {
+ await page.getByTestId("edit-goal-button").click();
+ await page.getByTestId("goal-delete-button").click();
+ await page.waitForSelector('[data-testid="goal-delete-confirm"]');
+ await page.getByTestId("goal-delete-confirm-btn").click();
+ await page.waitForSelector('[data-testid="goals-page"]');
+}
+
+export async function uploadCoverToGoal(
+ page: Page,
+ file: { name: string; mimeType: string; buffer: Buffer },
+): Promise {
+ await page.getByTestId("edit-goal-button").click();
+ await page.getByTestId("cover-file-input").setInputFiles(file);
+ await page.getByTestId("cover-preview-img").waitFor({ state: "visible" });
+ await page.getByTestId("goal-save-button").click();
+ await page.getByTestId("edit-goal-button").waitFor({ state: "visible" });
+}
+
+export async function removeCoverFromGoal(page: Page): Promise {
+ await page.getByTestId("edit-goal-button").click();
+ await page.getByTestId("cover-remove-button").click();
+ await page.getByTestId("goal-save-button").click();
+ await page.getByTestId("edit-goal-button").waitFor({ state: "visible" });
+}
+
+// ---------------------------------------------------------------------------
+// Category actions
+// ---------------------------------------------------------------------------
+
+export async function navigateToCategories(page: Page): Promise {
+ await page.goto("/categories");
+ await page.waitForSelector('[data-testid="categories-page"]');
+}
+
+export async function createCategory(
+ page: Page,
+ categoryName: string,
+): Promise {
+ const textarea = page.getByTestId("command-bar-textarea");
+ await textarea.click();
+ await textarea.fill(categoryName);
+ await textarea.press("Enter");
+ await page.locator(`text=${categoryName}`).waitFor({ state: "visible" });
+}
+
+export async function openCategoryDetail(
+ page: Page,
+ categoryName: string,
+): Promise {
+ await page.locator(`text=${categoryName}`).click();
+ await page.waitForSelector('[data-testid="category-detail-page"]');
+}
+
+export async function updateCategoryName(
+ page: Page,
+ newName: string,
+): Promise {
+ await page.getByTestId("category-edit-btn").click();
+ await page.getByTestId("category-name-input").fill(newName);
+ await page.getByTestId("category-save-btn").click();
+}
+
+export async function deleteCategoryFromDetail(page: Page): Promise {
+ await page.getByTestId("category-edit-btn").click();
+ await page.getByTestId("category-delete-btn").click();
+ await page.getByTestId("category-delete-confirm-btn").click();
+ await page.waitForSelector('[data-testid="categories-page"]');
+}
diff --git a/packages/integration/src/page-task-actions.ts b/packages/integration/src/page-task-actions.ts
new file mode 100644
index 00000000..c1175877
--- /dev/null
+++ b/packages/integration/src/page-task-actions.ts
@@ -0,0 +1,159 @@
+// Shared UI interaction helpers for integration tests: task actions.
+// Eliminates duplication of common create / open / update / delete sequences.
+// Split from page-actions.ts (process-invariants file-size cap).
+import type { Page } from "@playwright/test";
+
+// ---------------------------------------------------------------------------
+// Task actions (assumes page is on /tasks)
+// ---------------------------------------------------------------------------
+
+export async function createTask(page: Page, taskName: string): Promise {
+ // If box filter is present (Active Tasks page), select "today" to ensure
+ // the task is created in a visible box (defaultBox may be "inbox").
+ const filterToggle = page.getByTestId("command-bar-filter-toggle");
+ if (await filterToggle.isVisible()) {
+ await filterToggle.click();
+ await page.getByTestId("box-filter-today").click();
+ }
+
+ const textarea = page.getByTestId("command-bar-textarea");
+ await textarea.click();
+ await textarea.fill(taskName);
+ await textarea.press("Enter");
+}
+
+export function findTaskItem(page: Page, taskName: string) {
+ return page.locator('[data-testid="task-item"]').filter({
+ has: page.locator('[data-testid="task-item-name"]', {
+ hasText: taskName,
+ }),
+ });
+}
+
+export async function openTaskDetail(
+ page: Page,
+ taskName: string,
+): Promise {
+ await findTaskItem(page, taskName)
+ .locator('[data-testid="task-item-body"]')
+ .click();
+ await page.waitForSelector('[data-testid="task-detail-panel"]');
+}
+
+export async function updateTaskName(
+ page: Page,
+ newName: string,
+): Promise {
+ await page.getByTestId("task-detail-name").fill(newName);
+ await page.getByTestId("task-detail-name").blur();
+}
+
+/**
+ * Updates a task's description from the task detail panel.
+ * `EditableDescription` is click-to-edit: at rest it renders a div, and only
+ * becomes a textarea after being clicked. Saving happens on blur.
+ */
+export async function updateTaskDescription(
+ page: Page,
+ newDescription: string,
+): Promise {
+ const descriptionField = page.getByTestId("task-detail-description");
+ await descriptionField.click();
+ await descriptionField.fill(newDescription);
+ await descriptionField.blur();
+}
+
+/**
+ * Hides a non-recurring task until the given ISO date via the task detail
+ * panel's "Hide until" drill-down row. Assumes the task detail panel is
+ * already open (see `openTaskDetail`).
+ * Implements FR2 of fix-stale-sync-overwrites.
+ */
+export async function hideTaskUntil(
+ page: Page,
+ appearDateIso: string,
+): Promise {
+ await page
+ .getByTestId("task-detail-panel")
+ .getByRole("button", { name: /hide until/i })
+ .click();
+ const hidePanel = page.getByTestId("hide-task-panel");
+ await hidePanel.waitFor({ state: "visible" });
+ await hidePanel.getByLabel(/select date/i).fill(appearDateIso);
+ await hidePanel.getByRole("button", { name: /^hide$/i }).click();
+ await hidePanel.waitFor({ state: "hidden" });
+}
+
+/**
+ * Manually unhides a task before its appear_date via the task detail panel's
+ * "Hide until" drill-down row, which shows the unhide control while the task
+ * is hidden. Assumes the task detail panel is already open.
+ * Implements FR2 of fix-stale-sync-overwrites.
+ */
+export async function unhideTask(page: Page): Promise {
+ await page
+ .getByTestId("task-detail-panel")
+ .getByRole("button", { name: /hide until/i })
+ .click();
+ const hidePanel = page.getByTestId("hide-task-panel");
+ await hidePanel.waitFor({ state: "visible" });
+ await hidePanel.getByRole("button", { name: /unhide/i }).click();
+ await hidePanel.waitFor({ state: "hidden" });
+}
+
+export async function deleteTaskFromDetail(page: Page): Promise {
+ await page
+ .getByTestId("task-detail-panel")
+ .getByRole("button", { name: /delete task/i })
+ .click();
+ await page.waitForSelector('[data-testid="task-detail-delete-confirm-btn"]');
+ await page.getByTestId("task-detail-delete-confirm-btn").click();
+ await page.waitForSelector('[data-testid="task-detail-panel"]', {
+ state: "detached",
+ });
+}
+
+const DRAG_STEPS = 12;
+const DRAG_SETTLE_MS = 300;
+
+/**
+ * Drags a task item (by its drag handle) vertically onto another task
+ * item's position, using low-level mouse events so dnd-kit's PointerSensor
+ * (activation distance 8px) reliably picks up the gesture.
+ */
+export async function dragTaskOnto(
+ page: Page,
+ sourceTaskName: string,
+ targetTaskName: string,
+): Promise {
+ // Match the drag handle by its exact accessible label — a substring/regex
+ // like /drag/i would also match a task whose name contains "drag"
+ // (e.g. "Rebalance Dragged"), whose body button carries the name as its
+ // accessible name, triggering a strict-mode violation.
+ const sourceHandle = findTaskItem(page, sourceTaskName).getByRole("button", {
+ name: "Drag task",
+ exact: true,
+ });
+ const targetItem = findTaskItem(page, targetTaskName);
+ const sourceBox = await sourceHandle.boundingBox();
+ const targetBox = await targetItem.boundingBox();
+ if (!sourceBox || !targetBox) {
+ throw new Error("dragTaskOnto: could not resolve bounding boxes");
+ }
+ const startX = sourceBox.x + sourceBox.width / 2;
+ const startY = sourceBox.y + sourceBox.height / 2;
+ const endX = targetBox.x + targetBox.width / 2;
+ const endY = targetBox.y + targetBox.height / 2;
+
+ await page.mouse.move(startX, startY);
+ await page.mouse.down();
+ for (let step = 1; step <= DRAG_STEPS; step++) {
+ const progress = step / DRAG_STEPS;
+ await page.mouse.move(
+ startX + (endX - startX) * progress,
+ startY + (endY - startY) * progress,
+ );
+ }
+ await page.mouse.up();
+ await page.waitForTimeout(DRAG_SETTLE_MS);
+}
diff --git a/packages/integration/src/tests/attachments-offline.spec.ts b/packages/integration/src/tests/attachments-offline.spec.ts
index 626ab9a1..8e5fd6b8 100644
--- a/packages/integration/src/tests/attachments-offline.spec.ts
+++ b/packages/integration/src/tests/attachments-offline.spec.ts
@@ -2,7 +2,7 @@
// Integration test for offline attachment creation -> pending file -> sync on reconnect.
// Task 13.7 from add-file-attachments change.
import { expect, test } from "@playwright/test";
-import { createTask, openTaskDetail } from "../page-actions.js";
+import { createTask, openTaskDetail } from "../page-task-actions.js";
import {
createMinimalPng,
pullFromServer,
diff --git a/packages/integration/src/tests/attachments-purge-advanced.spec.ts b/packages/integration/src/tests/attachments-purge-advanced.spec.ts
index af167912..ba39c496 100644
--- a/packages/integration/src/tests/attachments-purge-advanced.spec.ts
+++ b/packages/integration/src/tests/attachments-purge-advanced.spec.ts
@@ -4,15 +4,16 @@
import { expect, test } from "@playwright/test";
import {
attachFileToEntity,
- createGoal,
- createTask,
deleteFirstAttachment,
+} from "../page-attachment-actions.js";
+import {
+ createGoal,
navigateToGoals,
openGoalDetail,
- openTaskDetail,
removeCoverFromGoal,
uploadCoverToGoal,
-} from "../page-actions.js";
+} from "../page-goal-category-actions.js";
+import { createTask, openTaskDetail } from "../page-task-actions.js";
import {
createMinimalPng,
getFileFromServer,
diff --git a/packages/integration/src/tests/attachments-ref-counting.spec.ts b/packages/integration/src/tests/attachments-ref-counting.spec.ts
index 682b184a..c159e618 100644
--- a/packages/integration/src/tests/attachments-ref-counting.spec.ts
+++ b/packages/integration/src/tests/attachments-ref-counting.spec.ts
@@ -4,13 +4,14 @@
import { expect, test } from "@playwright/test";
import {
attachFileToEntity,
- createGoal,
- createTask,
deleteFirstAttachment,
+} from "../page-attachment-actions.js";
+import {
+ createGoal,
navigateToGoals,
openGoalDetail,
- openTaskDetail,
-} from "../page-actions.js";
+} from "../page-goal-category-actions.js";
+import { createTask, openTaskDetail } from "../page-task-actions.js";
import {
createMinimalPng,
getFileFromServer,
diff --git a/packages/integration/src/tests/attachments-sync.spec.ts b/packages/integration/src/tests/attachments-sync.spec.ts
index 5ffff07a..ea965350 100644
--- a/packages/integration/src/tests/attachments-sync.spec.ts
+++ b/packages/integration/src/tests/attachments-sync.spec.ts
@@ -2,7 +2,7 @@
// Integration tests for attachment sync between devices.
// Tasks 13.3-13.7 from add-file-attachments change.
import { expect, test } from "@playwright/test";
-import { createTask, openTaskDetail } from "../page-actions.js";
+import { createTask, openTaskDetail } from "../page-task-actions.js";
import {
createMinimalPng,
pullFromServer,
diff --git a/packages/integration/src/tests/cascade-checklist-delete.spec.ts b/packages/integration/src/tests/cascade-checklist-delete.spec.ts
index 05c98390..97e5fb9f 100644
--- a/packages/integration/src/tests/cascade-checklist-delete.spec.ts
+++ b/packages/integration/src/tests/cascade-checklist-delete.spec.ts
@@ -2,11 +2,13 @@
import { expect, test } from "@playwright/test";
import {
addChecklistItem,
+ switchToChecklistTab,
+} from "../page-checklist-actions.js";
+import {
createTask,
deleteTaskFromDetail,
openTaskDetail,
- switchToChecklistTab,
-} from "../page-actions.js";
+} from "../page-task-actions.js";
import {
pullFromServer,
setupSingleDeviceTest,
diff --git a/packages/integration/src/tests/categories-sync.spec.ts b/packages/integration/src/tests/categories-sync.spec.ts
index df6b22d5..40c02ec6 100644
--- a/packages/integration/src/tests/categories-sync.spec.ts
+++ b/packages/integration/src/tests/categories-sync.spec.ts
@@ -6,7 +6,7 @@ import {
navigateToCategories,
openCategoryDetail,
updateCategoryName,
-} from "../page-actions.js";
+} from "../page-goal-category-actions.js";
import {
pullFromServer,
setupSingleDeviceTest,
diff --git a/packages/integration/src/tests/checklists-sync.spec.ts b/packages/integration/src/tests/checklists-sync.spec.ts
index 38c8fbf3..9d6e8916 100644
--- a/packages/integration/src/tests/checklists-sync.spec.ts
+++ b/packages/integration/src/tests/checklists-sync.spec.ts
@@ -1,10 +1,7 @@
// implements FR6, FR8 of add-supabase-integration-tests
import { expect, test } from "@playwright/test";
-import {
- createTask,
- openTaskDetail,
- switchToChecklistTab,
-} from "../page-actions.js";
+import { switchToChecklistTab } from "../page-checklist-actions.js";
+import { createTask, openTaskDetail } from "../page-task-actions.js";
import {
pullFromServer,
setupSingleDeviceTest,
diff --git a/packages/integration/src/tests/covers-sync.spec.ts b/packages/integration/src/tests/covers-sync.spec.ts
index e002294f..575f0521 100644
--- a/packages/integration/src/tests/covers-sync.spec.ts
+++ b/packages/integration/src/tests/covers-sync.spec.ts
@@ -4,7 +4,7 @@ import {
createGoal,
navigateToGoals,
openGoalDetail,
-} from "../page-actions.js";
+} from "../page-goal-category-actions.js";
import {
createMinimalPng,
getFileFromServer,
diff --git a/packages/integration/src/tests/file-download-cross-device.spec.ts b/packages/integration/src/tests/file-download-cross-device.spec.ts
index 1195b06b..d8422e73 100644
--- a/packages/integration/src/tests/file-download-cross-device.spec.ts
+++ b/packages/integration/src/tests/file-download-cross-device.spec.ts
@@ -6,7 +6,7 @@
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { expect, test } from "@playwright/test";
-import { createTask, openTaskDetail } from "../page-actions.js";
+import { createTask, openTaskDetail } from "../page-task-actions.js";
import {
getFileFromServer,
pullFromServer,
diff --git a/packages/integration/src/tests/file-mime-detection.spec.ts b/packages/integration/src/tests/file-mime-detection.spec.ts
index 81f39e5e..938f3407 100644
--- a/packages/integration/src/tests/file-mime-detection.spec.ts
+++ b/packages/integration/src/tests/file-mime-detection.spec.ts
@@ -6,15 +6,14 @@ import * as fs from "node:fs";
import * as path from "node:path";
import { fileURLToPath } from "node:url";
import { expect, test } from "@playwright/test";
+import { attachFileToEntity } from "../page-attachment-actions.js";
import {
- attachFileToEntity,
createGoal,
- createTask,
navigateToGoals,
openGoalDetail,
- openTaskDetail,
uploadCoverToGoal,
-} from "../page-actions.js";
+} from "../page-goal-category-actions.js";
+import { createTask, openTaskDetail } from "../page-task-actions.js";
import {
findServerAttachmentForTask,
getFileFromServer,
diff --git a/packages/integration/src/tests/full-flow.spec.ts b/packages/integration/src/tests/full-flow.spec.ts
index 57c111aa..b5b0aa5f 100644
--- a/packages/integration/src/tests/full-flow.spec.ts
+++ b/packages/integration/src/tests/full-flow.spec.ts
@@ -3,20 +3,22 @@ import { expect, test } from "@playwright/test";
import {
createCategory,
createGoal,
- createTask,
deleteCategoryFromDetail,
deleteGoalFromDetail,
- deleteTaskFromDetail,
- findTaskItem,
navigateToCategories,
navigateToGoals,
openCategoryDetail,
openGoalDetail,
- openTaskDetail,
updateCategoryName,
updateGoalName,
+} from "../page-goal-category-actions.js";
+import {
+ createTask,
+ deleteTaskFromDetail,
+ findTaskItem,
+ openTaskDetail,
updateTaskName,
-} from "../page-actions.js";
+} from "../page-task-actions.js";
import {
pullFromServer,
setupSingleDeviceTest,
diff --git a/packages/integration/src/tests/goals-sync.spec.ts b/packages/integration/src/tests/goals-sync.spec.ts
index 2aafff51..2a0761da 100644
--- a/packages/integration/src/tests/goals-sync.spec.ts
+++ b/packages/integration/src/tests/goals-sync.spec.ts
@@ -5,7 +5,7 @@ import {
deleteGoalFromDetail,
navigateToGoals,
openGoalDetail,
-} from "../page-actions.js";
+} from "../page-goal-category-actions.js";
import {
pullFromServer,
setupSingleDeviceTest,
diff --git a/packages/integration/src/tests/multi-device-dirty-protection.spec.ts b/packages/integration/src/tests/multi-device-dirty-protection.spec.ts
index e8a55f1d..15bdd7aa 100644
--- a/packages/integration/src/tests/multi-device-dirty-protection.spec.ts
+++ b/packages/integration/src/tests/multi-device-dirty-protection.spec.ts
@@ -1,6 +1,10 @@
// implements FR15 of add-supabase-integration-tests
import { expect, test } from "@playwright/test";
-import { createTask, openTaskDetail, updateTaskName } from "../page-actions.js";
+import {
+ createTask,
+ openTaskDetail,
+ updateTaskName,
+} from "../page-task-actions.js";
import {
pullFromServer,
setupTwoDeviceTest,
diff --git a/packages/integration/src/tests/multi-device-stale-rebalance.spec.ts b/packages/integration/src/tests/multi-device-stale-rebalance.spec.ts
index f6ad4af2..b0201242 100644
--- a/packages/integration/src/tests/multi-device-stale-rebalance.spec.ts
+++ b/packages/integration/src/tests/multi-device-stale-rebalance.spec.ts
@@ -1,12 +1,12 @@
-// implements U3, FR4 of fix-stale-sync-overwrites
+// implements U3, FR4, NFR-REL1 of fix-stale-sync-overwrites
import { randomUUID } from "node:crypto";
-import type { Page } from "@playwright/test";
import { expect, test } from "@playwright/test";
import {
+ dragTaskOnto,
findTaskItem,
openTaskDetail,
updateTaskDescription,
-} from "../page-actions.js";
+} from "../page-task-actions.js";
import {
assertConverged,
pullFromServer,
@@ -14,6 +14,10 @@ import {
setupTwoDeviceTest,
triggerSyncAndWait,
} from "../test-helpers.js";
+import {
+ buildInboxTaskPayload,
+ type RebalancePullResponse,
+} from "./stale-rebalance-helpers.js";
const { getPageA, getPageB, getCredentials } = setupTwoDeviceTest();
@@ -22,107 +26,6 @@ const { getPageA, getPageB, getCredentials } = setupTwoDeviceTest();
const SORT_ORDER_REBALANCE_THRESHOLD = 10;
const TASK_VISIBLE_TIMEOUT_MS = 15_000;
-const DRAG_STEPS = 12;
-const DRAG_SETTLE_MS = 300;
-
-interface RebalancePullResponse {
- ok: boolean;
- tasks: Array<{
- id: string;
- name: string;
- description: string;
- box: string;
- sort_order: string;
- is_deleted: boolean;
- updated_at: string;
- }>;
- current_revision: number;
-}
-
-/**
- * Builds a full push payload carrying a single "inbox" task with an explicit
- * `sort_order`, so tests can pre-seed keys near/at the rebalance threshold
- * without going through the app's own key-generation logic.
- */
-function buildInboxTaskPayload(options: {
- id: string;
- name: string;
- sortOrder: string;
- updatedAt: string;
-}): Record {
- return {
- tasks: [
- {
- id: options.id,
- name: options.name,
- description: "",
- box: "inbox",
- is_completed: false,
- is_deleted: false,
- completed_at: "",
- next_date: "",
- appear_date: "",
- context_id: "",
- category_id: "",
- goal_id: "",
- repeat_rule: "",
- is_hidden: false,
- original_task_id: "",
- sort_order: options.sortOrder,
- created_at: options.updatedAt,
- updated_at: options.updatedAt,
- version: 1,
- revision: 0,
- },
- ],
- goals: [],
- ideas: [],
- contexts: [],
- categories: [],
- checklist_items: [],
- attachments: [],
- settings: [],
- };
-}
-
-/**
- * Drags a task item (located by its drag handle) vertically onto another
- * task item's position, using low-level mouse events so dnd-kit's
- * PointerSensor (activation distance 8px) reliably picks up the gesture.
- */
-async function dragTaskOnto(
- page: Page,
- sourceTaskName: string,
- targetTaskName: string,
-): Promise {
- const sourceHandle = findTaskItem(page, sourceTaskName).getByRole("button", {
- name: /drag/i,
- });
- const targetItem = findTaskItem(page, targetTaskName);
-
- const sourceBox = await sourceHandle.boundingBox();
- const targetBox = await targetItem.boundingBox();
- if (!sourceBox || !targetBox) {
- throw new Error("dragTaskOnto: could not resolve bounding boxes");
- }
-
- const startX = sourceBox.x + sourceBox.width / 2;
- const startY = sourceBox.y + sourceBox.height / 2;
- const endX = targetBox.x + targetBox.width / 2;
- const endY = targetBox.y + targetBox.height / 2;
-
- await page.mouse.move(startX, startY);
- await page.mouse.down();
- for (let step = 1; step <= DRAG_STEPS; step++) {
- const progress = step / DRAG_STEPS;
- await page.mouse.move(
- startX + (endX - startX) * progress,
- startY + (endY - startY) * progress,
- );
- }
- await page.mouse.up();
- await page.waitForTimeout(DRAG_SETTLE_MS);
-}
/**
* U3 / FR4 / NFR-REL1 — a rebalance triggered on a stale device does not
@@ -130,38 +33,30 @@ async function dragTaskOnto(
*
* Mechanism: `TaskService.rebalanceBox()` (packages/client/src/services/TaskService.ts)
* fires whenever a drag's freshly-generated `sort_order` key exceeds
- * SORT_ORDER_REBALANCE_THRESHOLD (10) characters — regardless of how many
- * tasks are in the box. `fractional-indexing`'s `generateKeyBetween` grows a
- * key by roughly one character whenever it inserts strictly between two
- * keys that already differ only in their last character, so seeding two
- * adjacent 10-char keys and dragging a third task to land exactly between
- * them reliably produces an 11-char key in a single real drag — a genuine
- * `rebalanceBox` invocation via the actual UI code path, not a mocked one.
+ * SORT_ORDER_REBALANCE_THRESHOLD (10) chars, regardless of box size.
+ * `fractional-indexing`'s `generateKeyBetween` grows a key by ~1 char when
+ * inserting strictly between two keys differing only in their last
+ * character, so seeding two adjacent 10-char keys and dragging a third task
+ * between them reliably produces an 11-char key in one real drag — a
+ * genuine `rebalanceBox` invocation via the actual UI code path.
*
- * Scenario:
- * 1. Seed the inbox box directly on the server (bypassing the UI, since
- * crafting exact colliding sort keys via real drags would need many
- * sequential drags): a "dragged" task at the top (highest key), task X
- * in the middle (the one A will edit), and two adjacent anchor tasks
- * with 10-char keys one character apart at the bottom.
- * 2. Both devices sync to a converged baseline including all four tasks.
- * 3. Device B goes stale — no more syncing until step 6.
- * 4. Device A edits task X's description and pushes — X gets a strictly
- * newer `updated_at` on the server.
- * 5. Device B (still unaware of A's edit) drags "dragged" down onto
- * "anchorHigh"'s position — anchorHigh shifts down to sit between
- * "dragged" and "anchorLow", so "dragged"'s new key is generated
- * strictly between anchorLow and anchorHigh. The resulting >10-char key
- * triggers `rebalanceBox` for the whole box: X and both anchors are marked
- * `pending` locally on B WITHOUT their `updated_at` being bumped (FR4);
- * only "dragged" (the task the user actually moved) gets a fresh
- * `updated_at`.
- * 6. Device B syncs (pull then push). FR5's LWW pull protection must let
- * A's genuinely newer server copy of X win over B's stale-but-`pending`
- * local copy — B's rebalance-caused `pending` flag must not block it.
- * The anchors and "dragged", which nobody else edited, keep their
- * rebalanced `sort_order` (rebalance's own effect survives).
- * 7. Convergence: device A, device B, and the server agree (NFR-REL1).
+ * Scenario: (1) seed the inbox directly on the server (real drags can't
+ * craft exact colliding keys) with a "dragged" task at the top, task X in
+ * the middle (the one A will edit), and two adjacent 10-char anchor keys at
+ * the bottom. (2) Both devices sync to a converged baseline. (3) Device B
+ * goes stale — no more syncing until step 6. (4) Device A edits task X's
+ * description and pushes, giving X a strictly newer `updated_at` on the
+ * server. (5) Device B (still unaware of A's edit) drags "dragged" onto
+ * anchorHigh's position, so anchorHigh shifts between "dragged" and
+ * anchorLow and "dragged"'s new key lands strictly between the two anchor
+ * keys. The resulting >10-char key triggers `rebalanceBox` for the whole
+ * box: X and both anchors are marked `pending` locally on B WITHOUT their
+ * `updated_at` being bumped (FR4); only "dragged" gets a fresh
+ * `updated_at`. (6) Device B syncs (pull then push) — FR5's LWW pull
+ * protection must let A's genuinely newer server copy of X win over B's
+ * stale-but-`pending` local copy; the anchors and "dragged", untouched by
+ * anyone but B's rebalance, keep their rebalanced `sort_order`. (7)
+ * Convergence: device A, device B, and the server agree (NFR-REL1).
*/
test("Rebalance on a stale device does not clobber a task edited more recently elsewhere", async () => {
const pageA = getPageA();
@@ -185,54 +80,38 @@ test("Rebalance on a stale device does not clobber a task edited more recently e
// strictly between them via generateKeyBetween yields an 11-char key.
const anchorLowKey = "a".repeat(SORT_ORDER_REBALANCE_THRESHOLD);
const anchorHighKey = `${"a".repeat(SORT_ORDER_REBALANCE_THRESHOLD - 1)}b`;
- // DESC order (highest key first): dragged ("z") > taskX ("m") > anchorHigh > anchorLow.
- const draggedKey = "z";
- const taskXKey = "m";
+ // DESC order (highest key first): dragged ("ac") > taskX ("ab") > anchorHigh > anchorLow.
+ // These MUST be structurally valid fractional-indexing keys: taskX keeps its
+ // key after convergence (A's edit wins LWW over B's rebalance), and it lands
+ // on the shared inbox. Bare single letters like "z"/"m" pass the app's lax
+ // `isValidFractionalKey` letter check but make `generateKeyBetween` throw
+ // "invalid order key", which later breaks any inbox task creation on the
+ // shared user (e.g. recurring-copy placement in other specs).
+ const draggedKey = "ac";
+ const taskXKey = "ab";
const baseline = await pullFromServer(credentials);
const baselineRevision = baseline.current_revision;
// --- 1. Seed the box directly on the server ---
- await pushToServer(
- credentials,
- buildInboxTaskPayload({
- id: draggedId,
- name: draggedName,
- sortOrder: draggedKey,
- updatedAt: staleTimestamp,
- }),
- );
- await pushToServer(
- credentials,
- buildInboxTaskPayload({
- id: taskXId,
- name: taskXName,
- sortOrder: taskXKey,
- updatedAt: staleTimestamp,
- }),
- );
- await pushToServer(
- credentials,
- buildInboxTaskPayload({
- id: anchorHighId,
- name: anchorHighName,
- sortOrder: anchorHighKey,
- updatedAt: staleTimestamp,
- }),
- );
- await pushToServer(
- credentials,
- buildInboxTaskPayload({
- id: anchorLowId,
- name: anchorLowName,
- sortOrder: anchorLowKey,
- updatedAt: staleTimestamp,
- }),
- );
+ const seedTasks = [
+ { id: draggedId, name: draggedName, sortOrder: draggedKey },
+ { id: taskXId, name: taskXName, sortOrder: taskXKey },
+ { id: anchorHighId, name: anchorHighName, sortOrder: anchorHighKey },
+ { id: anchorLowId, name: anchorLowName, sortOrder: anchorLowKey },
+ ];
+ for (const seedTask of seedTasks) {
+ await pushToServer(
+ credentials,
+ buildInboxTaskPayload({ ...seedTask, updatedAt: staleTimestamp }),
+ );
+ }
// --- 2. Both devices sync to a converged baseline ---
- await pageA.goto("/tasks");
- await pageA.waitForSelector('[data-testid="active-tasks-page"]');
+ // The tasks are seeded in the inbox box, so drive the flow on the inbox
+ // page (ActiveTasksPage shows only today/week/later boxes).
+ await pageA.goto("/inbox");
+ await pageA.waitForSelector('[data-testid="inbox-page"]');
await triggerSyncAndWait(pageA);
for (const name of [draggedName, taskXName, anchorHighName, anchorLowName]) {
await findTaskItem(pageA, name).waitFor({
@@ -241,8 +120,8 @@ test("Rebalance on a stale device does not clobber a task edited more recently e
});
}
- await pageB.goto("/tasks");
- await pageB.waitForSelector('[data-testid="active-tasks-page"]');
+ await pageB.goto("/inbox");
+ await pageB.waitForSelector('[data-testid="inbox-page"]');
await triggerSyncAndWait(pageB);
for (const name of [draggedName, taskXName, anchorHighName, anchorLowName]) {
await findTaskItem(pageB, name).waitFor({
@@ -278,6 +157,13 @@ test("Rebalance on a stale device does not clobber a task edited more recently e
// task X win over B's stale-but-pending rebalanced copy ---
await triggerSyncAndWait(pageB);
+ // Device A pulls B's rebalance push so all three agree. A's copies of the
+ // untouched tasks are `synced`, so the normal revision-based pull applies
+ // B's rebalanced sort keys; A's own edited task X (newer `updated_at`) is
+ // unaffected. Without this, A converges only if a background auto-sync
+ // happens to fire before the assertion — flaky under load.
+ await triggerSyncAndWait(pageA);
+
// --- 7. Convergence: A, B, and the server agree (NFR-REL1) ---
await assertConverged(pageA, pageB, credentials, "tasks");
diff --git a/packages/integration/src/tests/multi-device-sync.spec.ts b/packages/integration/src/tests/multi-device-sync.spec.ts
index 5ed2b5b4..dca09da9 100644
--- a/packages/integration/src/tests/multi-device-sync.spec.ts
+++ b/packages/integration/src/tests/multi-device-sync.spec.ts
@@ -3,14 +3,16 @@ import { expect, type Page, test } from "@playwright/test";
import {
createCategory,
createGoal,
- createTask,
- deleteTaskFromDetail,
navigateToCategories,
navigateToGoals,
openGoalDetail,
+} from "../page-goal-category-actions.js";
+import {
+ createTask,
+ deleteTaskFromDetail,
openTaskDetail,
updateTaskName,
-} from "../page-actions.js";
+} from "../page-task-actions.js";
import {
closeAuthenticatedPage,
createAuthenticatedPage,
diff --git a/packages/integration/src/tests/push-poison-pill-fk.spec.ts b/packages/integration/src/tests/push-poison-pill-fk.spec.ts
index 63e003cc..7af96b6b 100644
--- a/packages/integration/src/tests/push-poison-pill-fk.spec.ts
+++ b/packages/integration/src/tests/push-poison-pill-fk.spec.ts
@@ -3,11 +3,10 @@
import { expect, test } from "@playwright/test";
import {
createGoal,
- createTask,
navigateToGoals,
openGoalDetail,
- openTaskDetail,
-} from "../page-actions.js";
+} from "../page-goal-category-actions.js";
+import { createTask, openTaskDetail } from "../page-task-actions.js";
import {
pullFromServer,
purgeOnServer,
diff --git a/packages/integration/src/tests/push-rejected-indicator.spec.ts b/packages/integration/src/tests/push-rejected-indicator.spec.ts
index edd6e96c..1c4c8567 100644
--- a/packages/integration/src/tests/push-rejected-indicator.spec.ts
+++ b/packages/integration/src/tests/push-rejected-indicator.spec.ts
@@ -2,7 +2,7 @@
// Test: rejected → red border → edit → amber → sync → transparent
import { randomUUID } from "node:crypto";
import { expect, test } from "@playwright/test";
-import { openTaskDetail, updateTaskName } from "../page-actions.js";
+import { openTaskDetail, updateTaskName } from "../page-task-actions.js";
import { setupSingleDeviceTest, triggerSyncAndWait } from "../test-helpers.js";
const DEXIE_DB_NAME = "clear-progress";
diff --git a/packages/integration/src/tests/stale-rebalance-helpers.ts b/packages/integration/src/tests/stale-rebalance-helpers.ts
new file mode 100644
index 00000000..2403b9e8
--- /dev/null
+++ b/packages/integration/src/tests/stale-rebalance-helpers.ts
@@ -0,0 +1,61 @@
+// implements U3, FR4, NFR-REL1 of fix-stale-sync-overwrites
+
+export interface RebalancePullResponse {
+ ok: boolean;
+ tasks: Array<{
+ id: string;
+ name: string;
+ description: string;
+ box: string;
+ sort_order: string;
+ is_deleted: boolean;
+ updated_at: string;
+ }>;
+ current_revision: number;
+}
+
+/**
+ * Builds a full push payload carrying a single "inbox" task with an explicit
+ * `sort_order`, so tests can pre-seed keys near/at the rebalance threshold
+ * without going through the app's own key-generation logic.
+ */
+export function buildInboxTaskPayload(options: {
+ id: string;
+ name: string;
+ sortOrder: string;
+ updatedAt: string;
+}): Record {
+ return {
+ tasks: [
+ {
+ id: options.id,
+ name: options.name,
+ description: "",
+ box: "inbox",
+ is_completed: false,
+ is_deleted: false,
+ completed_at: "",
+ next_date: "",
+ appear_date: "",
+ context_id: "",
+ category_id: "",
+ goal_id: "",
+ repeat_rule: "",
+ is_hidden: false,
+ original_task_id: "",
+ sort_order: options.sortOrder,
+ created_at: options.updatedAt,
+ updated_at: options.updatedAt,
+ version: 1,
+ revision: 0,
+ },
+ ],
+ goals: [],
+ ideas: [],
+ contexts: [],
+ categories: [],
+ checklist_items: [],
+ attachments: [],
+ settings: [],
+ };
+}
diff --git a/packages/integration/src/tests/sync-indicator-race.spec.ts b/packages/integration/src/tests/sync-indicator-race.spec.ts
index f1651ba5..d9f401cb 100644
--- a/packages/integration/src/tests/sync-indicator-race.spec.ts
+++ b/packages/integration/src/tests/sync-indicator-race.spec.ts
@@ -2,10 +2,9 @@
import { expect, test } from "@playwright/test";
import {
addChecklistItem,
- createTask,
- openTaskDetail,
switchToChecklistTab,
-} from "../page-actions.js";
+} from "../page-checklist-actions.js";
+import { createTask, openTaskDetail } from "../page-task-actions.js";
import { setupSingleDeviceTest, triggerSyncAndWait } from "../test-helpers.js";
const { getPage } = setupSingleDeviceTest();
diff --git a/packages/integration/src/tests/tasks-sync.spec.ts b/packages/integration/src/tests/tasks-sync.spec.ts
index 76a92ff3..5492a4ed 100644
--- a/packages/integration/src/tests/tasks-sync.spec.ts
+++ b/packages/integration/src/tests/tasks-sync.spec.ts
@@ -5,7 +5,7 @@ import {
deleteTaskFromDetail,
openTaskDetail,
updateTaskName,
-} from "../page-actions.js";
+} from "../page-task-actions.js";
import {
pullFromServer,
setupSingleDeviceTest,
From a74330d8b9007ed57eae16c031fc6d2084ace4a8 Mon Sep 17 00:00:00 2001
From: oinsio <8233537+oinsio@users.noreply.github.com>
Date: Sun, 19 Jul 2026 15:49:56 +0500
Subject: [PATCH 5/8] implement fix-stale-sync-overwrites OpenSpec
---
.../src/db/repositories/GoalRepository.ts | 20 +-
.../applyServerRecordsLww.contract.ts | 197 +++++++++++++
...kDeduplicator.merge.content-fields.test.ts | 137 +++++++++
...icator.merge.wholesale-repeat-rule.test.ts | 157 ++++++++++
.../RecurringTaskDeduplicator.test-setup.ts | 26 +-
.../src/services/TaskService-test-utils.ts | 36 ++-
.../TaskService.recurring-restore.test.ts | 42 +--
.../TaskService.recurring-soft-delete.test.ts | 132 +++++----
.../src/services/TaskService.reorder.test.ts | 213 +++++++++++---
.../steps/dedup_merge.steps.ts | 232 +++++++++++++++
.../steps/pull_lww_protection.steps.ts | 272 ++++++++++++++++++
.../steps/stale_reveal_sync.steps.ts | 218 ++++++++++++++
.../tests/attachments-purge-cleanup.spec.ts | 47 ++-
.../src/tests/multi-device-conflicts.spec.ts | 10 +-
.../tests/multi-device-manual-unhide.spec.ts | 150 ++++++++++
.../src/tests/multi-device-recurring.spec.ts | 76 +----
.../multi-device-stale-rebalance.spec.ts | 3 +-
...e-stale-recurring-after-completion.spec.ts | 116 ++++++++
...multi-device-stale-recurring-fixed.spec.ts | 83 ++++++
.../src/tests/stale-recurring-flow.ts | 176 ++++++++++++
.../src/tests/stale-recurring-helpers.ts | 241 ++++++++++++++++
.../integration/src/tests/tasks-sync.spec.ts | 33 +--
22 files changed, 2373 insertions(+), 244 deletions(-)
create mode 100644 packages/client/src/db/repositories/applyServerRecordsLww.contract.ts
create mode 100644 packages/client/src/services/RecurringTaskDeduplicator.merge.content-fields.test.ts
create mode 100644 packages/client/src/services/RecurringTaskDeduplicator.merge.wholesale-repeat-rule.test.ts
create mode 100644 packages/client/src/test/features/repeating_tasks/steps/dedup_merge.steps.ts
create mode 100644 packages/client/src/test/features/sync_protocol/steps/pull_lww_protection.steps.ts
create mode 100644 packages/client/src/test/features/sync_protocol/steps/stale_reveal_sync.steps.ts
create mode 100644 packages/integration/src/tests/multi-device-manual-unhide.spec.ts
create mode 100644 packages/integration/src/tests/multi-device-stale-recurring-after-completion.spec.ts
create mode 100644 packages/integration/src/tests/multi-device-stale-recurring-fixed.spec.ts
create mode 100644 packages/integration/src/tests/stale-recurring-flow.ts
create mode 100644 packages/integration/src/tests/stale-recurring-helpers.ts
diff --git a/packages/client/src/db/repositories/GoalRepository.ts b/packages/client/src/db/repositories/GoalRepository.ts
index 5cbcfa22..9d807f97 100644
--- a/packages/client/src/db/repositories/GoalRepository.ts
+++ b/packages/client/src/db/repositories/GoalRepository.ts
@@ -2,6 +2,9 @@ import type { WireGoal } from "@clear-progress/contract";
import { ClientGoalSchema } from "@/schemas/entities";
import type { Goal, ISOTimestamp } from "@/types/entities";
import { db } from "../database";
+import { shouldOverwritePendingLocalRecord } from "./applyServerRecordLww";
+
+const GOAL_ENTITY_NAME = "goal";
export class GoalRepository {
async getAll(): Promise {
@@ -53,11 +56,26 @@ export class GoalRepository {
return db.goals.filter((goal) => goal.syncStatus === "pending").toArray();
}
+ /**
+ * Implements FR5 of fix-stale-sync-overwrites.
+ * A local `pending` record is overwritten only when the server record's
+ * `updated_at` is strictly newer (LWW pull protection). Local records with
+ * any other syncStatus, or no local record at all, are always overwritten.
+ */
async applyServerRecords(records: WireGoal[]): Promise {
await db.transaction("rw", db.goals, async () => {
for (const serverRecord of records) {
const localRecord = await db.goals.get(serverRecord.id);
- if (localRecord?.syncStatus !== "pending") {
+ const shouldWrite =
+ localRecord?.syncStatus !== "pending" ||
+ shouldOverwritePendingLocalRecord({
+ entityName: GOAL_ENTITY_NAME,
+ id: serverRecord.id,
+ localUpdatedAt: localRecord.updated_at,
+ serverUpdatedAt: serverRecord.updated_at,
+ });
+
+ if (shouldWrite) {
const goal: Goal = {
...serverRecord,
created_at: serverRecord.created_at as ISOTimestamp,
diff --git a/packages/client/src/db/repositories/applyServerRecordsLww.contract.ts b/packages/client/src/db/repositories/applyServerRecordsLww.contract.ts
new file mode 100644
index 00000000..f281def4
--- /dev/null
+++ b/packages/client/src/db/repositories/applyServerRecordsLww.contract.ts
@@ -0,0 +1,197 @@
+import { expect, it, vi } from "vitest";
+import { Temporal } from "@/lib/temporal";
+import { toISOTimestamp } from "@/utils/dateHelpers";
+
+/**
+ * Minimal shape every repository's local record must expose for this
+ * contract suite to inspect sync state after `applyServerRecords` runs.
+ */
+export type LwwLocalRecord = {
+ id: string;
+ updated_at: string;
+ syncStatus: "synced" | "pending" | "rejected";
+};
+
+/**
+ * Config supplied per-repository so the contract suite can run generically
+ * across TaskRepository, GoalRepository, ContextRepository, CategoryRepository,
+ * IdeaRepository, ChecklistRepository, AttachmentRepository (SettingsRepository
+ * excluded — no applyServerRecords).
+ */
+export type ApplyServerRecordsLwwContractConfig<
+ LocalRecord extends LwwLocalRecord,
+ ServerRecord extends { id: string; updated_at: string },
+> = {
+ entityName: string;
+ applyServerRecords: (records: ServerRecord[]) => Promise;
+ getLocalRecord: (id: string) => Promise;
+ putLocalRecord: (record: LocalRecord) => Promise;
+ /** Builds a minimal valid local record with the given overrides. */
+ buildLocalRecord: (overrides: Partial) => LocalRecord;
+ /** Builds a minimal valid server (wire) record with the given overrides. */
+ buildServerRecord: (overrides: Partial) => ServerRecord;
+};
+
+const OLDER_TIMESTAMP = toISOTimestamp(
+ Temporal.Instant.from("2026-01-01T00:00:00.000Z"),
+);
+const SAME_TIMESTAMP = toISOTimestamp(
+ Temporal.Instant.from("2026-01-02T00:00:00.000Z"),
+);
+const NEWER_TIMESTAMP = toISOTimestamp(
+ Temporal.Instant.from("2026-01-03T00:00:00.000Z"),
+);
+
+/**
+ * Shared contract test suite for `applyServerRecords()` LWW pull protection.
+ * Implements FR5 of fix-stale-sync-overwrites.
+ *
+ * Run once per repository via `runApplyServerRecordsLwwContractTests(config)`
+ * inside a `describe` block that has already set up `fake-indexeddb` and
+ * cleared the relevant table in `beforeEach`.
+ */
+export function runApplyServerRecordsLwwContractTests<
+ LocalRecord extends LwwLocalRecord,
+ ServerRecord extends { id: string; updated_at: string },
+>(config: ApplyServerRecordsLwwContractConfig) {
+ const {
+ entityName,
+ applyServerRecords,
+ getLocalRecord,
+ putLocalRecord,
+ buildLocalRecord,
+ buildServerRecord,
+ } = config;
+
+ /**
+ * Persists a local record with the given sync status and timestamp, applies
+ * a single server record sharing its id, and returns the resulting local
+ * record. Centralises the arrange-and-apply sequence shared across scenarios.
+ */
+ async function applyServerRecordOverLocal(args: {
+ localSyncStatus: LwwLocalRecord["syncStatus"];
+ localUpdatedAt: string;
+ serverUpdatedAt: string;
+ }): Promise {
+ const localRecord = buildLocalRecord({
+ syncStatus: args.localSyncStatus,
+ updated_at: args.localUpdatedAt,
+ } as unknown as Partial);
+ await putLocalRecord(localRecord);
+
+ const serverRecord = buildServerRecord({
+ id: localRecord.id,
+ updated_at: args.serverUpdatedAt,
+ } as unknown as Partial);
+ await applyServerRecords([serverRecord]);
+
+ return getLocalRecord(localRecord.id);
+ }
+
+ // FR5: local `synced` records are always overwritten by the server version
+ it(`should overwrite a local synced ${entityName} record with the server record`, async () => {
+ const savedRecord = await applyServerRecordOverLocal({
+ localSyncStatus: "synced",
+ localUpdatedAt: SAME_TIMESTAMP,
+ serverUpdatedAt: NEWER_TIMESTAMP,
+ });
+ expect(savedRecord?.updated_at).toBe(NEWER_TIMESTAMP);
+ expect(savedRecord?.syncStatus).toBe("synced");
+ });
+
+ // FR5: local `rejected` records are always overwritten by the server version
+ it(`should overwrite a local rejected ${entityName} record with the server record`, async () => {
+ const savedRecord = await applyServerRecordOverLocal({
+ localSyncStatus: "rejected",
+ localUpdatedAt: SAME_TIMESTAMP,
+ serverUpdatedAt: NEWER_TIMESTAMP,
+ });
+ expect(savedRecord?.updated_at).toBe(NEWER_TIMESTAMP);
+ expect(savedRecord?.syncStatus).toBe("synced");
+ });
+
+ // FR5: strictly newer server updated_at overwrites a pending local record and marks it synced
+ it(`should overwrite a local pending ${entityName} record when the server record is strictly newer, and mark it synced`, async () => {
+ const savedRecord = await applyServerRecordOverLocal({
+ localSyncStatus: "pending",
+ localUpdatedAt: OLDER_TIMESTAMP,
+ serverUpdatedAt: NEWER_TIMESTAMP,
+ });
+ expect(savedRecord?.updated_at).toBe(NEWER_TIMESTAMP);
+ expect(savedRecord?.syncStatus).toBe("synced");
+ });
+
+ // FR5: overwriting a pending local record must be logged with entity type, id, and both timestamps
+ it(`should log a conflict warning when a pending ${entityName} record is overwritten by a strictly newer server record`, async () => {
+ const localRecord = buildLocalRecord({
+ syncStatus: "pending",
+ updated_at: OLDER_TIMESTAMP,
+ } as unknown as Partial);
+ await putLocalRecord(localRecord);
+
+ const serverRecord = buildServerRecord({
+ id: localRecord.id,
+ updated_at: NEWER_TIMESTAMP,
+ } as unknown as Partial);
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
+
+ await applyServerRecords([serverRecord]);
+
+ expect(warnSpy).toHaveBeenCalledWith(
+ expect.stringContaining(entityName),
+ expect.objectContaining({
+ id: localRecord.id,
+ localUpdatedAt: OLDER_TIMESTAMP,
+ serverUpdatedAt: NEWER_TIMESTAMP,
+ }),
+ );
+ warnSpy.mockRestore();
+ });
+
+ // FR5: equal server updated_at preserves the local pending record unchanged
+ it(`should preserve a local pending ${entityName} record when the server record has an equal updated_at`, async () => {
+ const localRecord = buildLocalRecord({
+ syncStatus: "pending",
+ updated_at: SAME_TIMESTAMP,
+ } as unknown as Partial);
+ await putLocalRecord(localRecord);
+
+ const serverRecord = buildServerRecord({
+ id: localRecord.id,
+ updated_at: SAME_TIMESTAMP,
+ } as unknown as Partial);
+ await applyServerRecords([serverRecord]);
+
+ expect(await getLocalRecord(localRecord.id)).toEqual(localRecord);
+ });
+
+ // FR5: older server updated_at preserves the local pending record unchanged
+ it(`should preserve a local pending ${entityName} record when the server record is older`, async () => {
+ const localRecord = buildLocalRecord({
+ syncStatus: "pending",
+ updated_at: NEWER_TIMESTAMP,
+ } as unknown as Partial);
+ await putLocalRecord(localRecord);
+
+ const serverRecord = buildServerRecord({
+ id: localRecord.id,
+ updated_at: OLDER_TIMESTAMP,
+ } as unknown as Partial);
+ await applyServerRecords([serverRecord]);
+
+ expect(await getLocalRecord(localRecord.id)).toEqual(localRecord);
+ });
+
+ // FR5: a server record with no local counterpart is inserted as synced
+ it(`should insert a new ${entityName} record from the server when no local record exists`, async () => {
+ const serverRecord = buildServerRecord({
+ updated_at: NEWER_TIMESTAMP,
+ } as unknown as Partial);
+
+ await applyServerRecords([serverRecord]);
+
+ const savedRecord = await getLocalRecord(serverRecord.id);
+ expect(savedRecord).toBeDefined();
+ expect(savedRecord?.syncStatus).toBe("synced");
+ });
+}
diff --git a/packages/client/src/services/RecurringTaskDeduplicator.merge.content-fields.test.ts b/packages/client/src/services/RecurringTaskDeduplicator.merge.content-fields.test.ts
new file mode 100644
index 00000000..0709b565
--- /dev/null
+++ b/packages/client/src/services/RecurringTaskDeduplicator.merge.content-fields.test.ts
@@ -0,0 +1,137 @@
+// implements FR3 of fix-stale-sync-overwrites
+import "fake-indexeddb/auto";
+import { describe, expect, it } from "vitest";
+import { db } from "@/db/database";
+import { buildTask } from "@/test/factories/taskFactory";
+import type { Task } from "@/types/entities";
+import {
+ createRecurringTaskDeduplicatorMergeSetup,
+ FRESH_CATEGORY_ID,
+ FRESH_CONTEXT_ID,
+ FRESH_GOAL_ID,
+ ORIGINAL_ID_1,
+ STALE_CATEGORY_ID,
+ STALE_CONTEXT_ID,
+ STALE_GOAL_ID,
+ UUID_FRESH_CONTENT_LOSER,
+ UUID_SCHEDULE_WINNER,
+} from "./RecurringTaskDeduplicator.test-setup";
+
+// Schedule winner carrying stale content: earliest next_date (wins the
+// schedule comparison) but the oldest updated_at (loses the content merge).
+// Per-test overrides supply the content fields under assertion.
+function buildStaleContentScheduleWinner(overrides: Partial = {}) {
+ return buildTask({
+ id: UUID_SCHEDULE_WINNER,
+ original_task_id: ORIGINAL_ID_1,
+ next_date: "2026-07-01",
+ name: "Stale name",
+ description: "Stale description",
+ is_completed: false,
+ is_deleted: false,
+ updated_at: "2026-06-01T10:00:00.000Z",
+ ...overrides,
+ });
+}
+
+// Loser by schedule (later next_date) but freshest updated_at, so its
+// content fields must survive the content merge. Per-test overrides supply
+// the content fields under assertion.
+function buildFreshContentLoser(overrides: Partial = {}) {
+ return buildTask({
+ id: UUID_FRESH_CONTENT_LOSER,
+ original_task_id: ORIGINAL_ID_1,
+ next_date: "2026-07-10",
+ name: "Fresh name",
+ description: "Fresh description",
+ is_completed: false,
+ is_deleted: false,
+ updated_at: "2026-07-09T10:00:00.000Z",
+ ...overrides,
+ });
+}
+
+// FR3: winner keeps schedule (earliest next_date) but content fields come
+// from whichever copy has the freshest updated_at, even when that copy
+// loses the schedule comparison.
+describe("RecurringTaskDeduplicator — content merge from freshest updated_at", () => {
+ const { getDeduplicator } = createRecurringTaskDeduplicatorMergeSetup();
+
+ describe("name and description merge from freshest updated_at copy", () => {
+ it("should take name and description from the freshest-updated_at copy, not the schedule winner", async () => {
+ await db.tasks.bulkAdd([
+ buildStaleContentScheduleWinner(),
+ buildFreshContentLoser(),
+ ]);
+
+ await getDeduplicator().deduplicate([ORIGINAL_ID_1]);
+
+ const survivor = await db.tasks.get(UUID_SCHEDULE_WINNER);
+ expect(survivor?.name).toBe("Fresh name");
+ expect(survivor?.description).toBe("Fresh description");
+ });
+
+ // Guards the strict `>` comparison in findFreshestUpdatedAtCopy: when two
+ // copies share the exact same updated_at, the first-encountered copy
+ // (the schedule winner, since it appears first in allCopiesInGroup) must
+ // stay "freshest" — a `>=` comparison would instead let the later copy
+ // in iteration order win the tie, flipping which content survives.
+ it("should keep the first copy's content as freshest when two copies share the exact same updated_at", async () => {
+ const sameUpdatedAt = "2026-07-05T10:00:00.000Z";
+ await db.tasks.bulkAdd([
+ buildStaleContentScheduleWinner({
+ name: "Schedule winner name",
+ description: "Schedule winner description",
+ updated_at: sameUpdatedAt,
+ }),
+ buildFreshContentLoser({
+ name: "Tied loser name",
+ description: "Tied loser description",
+ updated_at: sameUpdatedAt,
+ }),
+ ]);
+
+ await getDeduplicator().deduplicate([ORIGINAL_ID_1]);
+
+ const survivor = await db.tasks.get(UUID_SCHEDULE_WINNER);
+ expect(survivor?.name).toBe("Schedule winner name");
+ expect(survivor?.description).toBe("Schedule winner description");
+ });
+
+ it("should keep next_date from the schedule winner despite content coming from the freshest copy", async () => {
+ await db.tasks.bulkAdd([
+ buildStaleContentScheduleWinner(),
+ buildFreshContentLoser(),
+ ]);
+
+ await getDeduplicator().deduplicate([ORIGINAL_ID_1]);
+
+ const survivor = await db.tasks.get(UUID_SCHEDULE_WINNER);
+ expect(survivor?.next_date).toBe("2026-07-01");
+ });
+ });
+
+ describe("goal_id, context_id and category_id merge from freshest updated_at copy", () => {
+ it("should take goal_id, context_id and category_id from the freshest-updated_at copy, not the schedule winner", async () => {
+ await db.tasks.bulkAdd([
+ buildStaleContentScheduleWinner({
+ goal_id: STALE_GOAL_ID,
+ context_id: STALE_CONTEXT_ID,
+ category_id: STALE_CATEGORY_ID,
+ }),
+ buildFreshContentLoser({
+ goal_id: FRESH_GOAL_ID,
+ context_id: FRESH_CONTEXT_ID,
+ category_id: FRESH_CATEGORY_ID,
+ }),
+ ]);
+
+ await getDeduplicator().deduplicate([ORIGINAL_ID_1]);
+
+ const survivor = await db.tasks.get(UUID_SCHEDULE_WINNER);
+ expect(survivor?.goal_id).toBe(FRESH_GOAL_ID);
+ expect(survivor?.context_id).toBe(FRESH_CONTEXT_ID);
+ expect(survivor?.category_id).toBe(FRESH_CATEGORY_ID);
+ });
+ });
+});
diff --git a/packages/client/src/services/RecurringTaskDeduplicator.merge.wholesale-repeat-rule.test.ts b/packages/client/src/services/RecurringTaskDeduplicator.merge.wholesale-repeat-rule.test.ts
new file mode 100644
index 00000000..85931eca
--- /dev/null
+++ b/packages/client/src/services/RecurringTaskDeduplicator.merge.wholesale-repeat-rule.test.ts
@@ -0,0 +1,157 @@
+// implements FR3 of fix-stale-sync-overwrites
+import "fake-indexeddb/auto";
+import { describe, expect, it } from "vitest";
+import { db } from "@/db/database";
+import { buildTask } from "@/test/factories/taskFactory";
+import {
+ createRecurringTaskDeduplicatorMergeSetup,
+ expectScheduleTripleWithFreshContent,
+} from "./RecurringTaskDeduplicator.test-setup";
+
+// Fifth scenario: proves a repeat_rule mismatch makes the freshest copy win
+// wholesale, including next_date/appear_date, overriding the normal
+// schedule-triple rule.
+const UUID_RULE_MISMATCH_SCHEDULE_WINNER =
+ "00000000-0000-4000-a000-000000000050";
+const UUID_RULE_MISMATCH_FRESH_LOSER = "00000000-0000-4000-a000-000000000051";
+const ORIGINAL_ID_5 = "55555555-5555-4555-a555-555555555555";
+
+// Eighth scenario: 3-copy edge case. The schedule winner and the
+// freshest-content copy SHARE the same repeat_rule, while a third, stale
+// loser carries a DIFFERENT repeat_rule. Only scheduleWinner.repeat_rule vs
+// freshestCopy.repeat_rule is ever compared to decide wholesale-vs-normal
+// merge, so the stale loser's mismatching repeat_rule must be irrelevant —
+// normal merge applies (schedule triple from the winner + content from the
+// freshest copy), never wholesale.
+const UUID_TRIPLE_SCHEDULE_WINNER = "00000000-0000-4000-a000-000000000080";
+const UUID_TRIPLE_FRESH_CONTENT_LOSER = "00000000-0000-4000-a000-000000000081";
+const UUID_TRIPLE_STALE_RULE_MISMATCH_LOSER =
+ "00000000-0000-4000-a000-000000000082";
+const ORIGINAL_ID_8 = "88888888-8888-4888-a888-888888888888";
+
+describe("RecurringTaskDeduplicator — content merge from freshest updated_at", () => {
+ const { getDeduplicator } = createRecurringTaskDeduplicatorMergeSetup();
+
+ // FR3: when repeat_rule differs between copies, the freshest-updated_at
+ // copy wins wholesale — all its fields, including next_date and
+ // appear_date, override the normal schedule-triple-from-earliest-next_date
+ // rule. A rule change recomputes dates under the new rule, so mixing old
+ // schedule fields with a new repeat_rule would be incoherent.
+ describe("differing repeat_rule — freshest copy wins wholesale", () => {
+ it("should take all fields, including next_date and appear_date, from the freshest copy when repeat_rule differs", async () => {
+ const scheduleWinner = buildTask({
+ id: UUID_RULE_MISMATCH_SCHEDULE_WINNER,
+ original_task_id: ORIGINAL_ID_5,
+ repeat_rule: "daily-1",
+ next_date: "2026-07-01",
+ appear_date: "2026-06-30",
+ is_hidden: false,
+ name: "Stale name",
+ description: "Stale description",
+ is_completed: false,
+ is_deleted: false,
+ updated_at: "2026-06-01T10:00:00.000Z",
+ });
+ const freshRuleChangedLoser = buildTask({
+ id: UUID_RULE_MISMATCH_FRESH_LOSER,
+ original_task_id: ORIGINAL_ID_5,
+ repeat_rule: "weekly-Mon",
+ next_date: "2026-07-06",
+ appear_date: "2026-07-04",
+ is_hidden: true,
+ name: "Fresh name",
+ description: "Fresh description",
+ is_completed: false,
+ is_deleted: false,
+ updated_at: "2026-07-09T10:00:00.000Z",
+ });
+ await db.tasks.bulkAdd([scheduleWinner, freshRuleChangedLoser]);
+
+ await getDeduplicator().deduplicate([ORIGINAL_ID_5]);
+
+ const survivor = await db.tasks.get(UUID_RULE_MISMATCH_SCHEDULE_WINNER);
+ expect(survivor?.repeat_rule).toBe("weekly-Mon");
+ expect(survivor?.next_date).toBe("2026-07-06");
+ expect(survivor?.appear_date).toBe("2026-07-04");
+ expect(survivor?.is_hidden).toBe(true);
+ expect(survivor?.name).toBe("Fresh name");
+ expect(survivor?.description).toBe("Fresh description");
+ expect(survivor?.updated_at).toBe("2026-07-09T10:00:00.000Z");
+ });
+
+ // Pinning test for the 3-copy edge (task 7.9): the schedule winner and
+ // the freshest-content copy SHARE the same repeat_rule; only a third,
+ // stale loser carries a different one. mergeWinner only ever compares
+ // scheduleWinner.repeat_rule vs freshestCopy.repeat_rule — it never scans
+ // every copy in the group — so this third copy's mismatching repeat_rule
+ // must be irrelevant to the wholesale-vs-normal decision. Normal merge
+ // applies: schedule triple from the winner, content from the freshest
+ // copy, and the winner's own repeat_rule is left untouched. Locks in
+ // this behavior so a future "fix" that scans ALL copies for a
+ // repeat_rule mismatch would be caught by this test going red.
+ it("should apply normal merge, not wholesale, when only a non-freshest third copy's repeat_rule differs from the schedule winner and freshest copy", async () => {
+ const scheduleWinner = buildTask({
+ id: UUID_TRIPLE_SCHEDULE_WINNER,
+ original_task_id: ORIGINAL_ID_8,
+ repeat_rule: "daily-1",
+ next_date: "2026-07-01",
+ appear_date: "2026-06-28",
+ is_hidden: false,
+ name: "Stale name",
+ description: "Stale description",
+ is_completed: false,
+ is_deleted: false,
+ updated_at: "2026-06-01T10:00:00.000Z",
+ });
+ const freshContentLoser = buildTask({
+ id: UUID_TRIPLE_FRESH_CONTENT_LOSER,
+ original_task_id: ORIGINAL_ID_8,
+ repeat_rule: "daily-1",
+ next_date: "2026-07-10",
+ appear_date: "2026-07-08",
+ is_hidden: true,
+ name: "Fresh name",
+ description: "Fresh description",
+ is_completed: false,
+ is_deleted: false,
+ updated_at: "2026-07-09T10:00:00.000Z",
+ });
+ const staleRuleMismatchLoser = buildTask({
+ id: UUID_TRIPLE_STALE_RULE_MISMATCH_LOSER,
+ original_task_id: ORIGINAL_ID_8,
+ repeat_rule: "weekly-Mon",
+ next_date: "2026-07-05",
+ appear_date: "2026-07-03",
+ is_hidden: true,
+ name: "Stale mismatch name",
+ description: "Stale mismatch description",
+ is_completed: false,
+ is_deleted: false,
+ updated_at: "2026-05-01T10:00:00.000Z",
+ });
+ await db.tasks.bulkAdd([
+ scheduleWinner,
+ freshContentLoser,
+ staleRuleMismatchLoser,
+ ]);
+
+ await getDeduplicator().deduplicate([ORIGINAL_ID_8]);
+
+ const survivor = await db.tasks.get(UUID_TRIPLE_SCHEDULE_WINNER);
+ // Schedule triple stays the schedule winner's own — wholesale was NOT
+ // triggered — while content still merges normally from the freshest copy.
+ expectScheduleTripleWithFreshContent(survivor, {
+ nextDate: "2026-07-01",
+ appearDate: "2026-06-28",
+ isHidden: false,
+ });
+ // repeat_rule stays the schedule winner's own, proving the stale
+ // loser's differing rule was never consulted.
+ expect(survivor?.repeat_rule).toBe("daily-1");
+ // The stale loser is soft-deleted as usual.
+ expect(
+ (await db.tasks.get(UUID_TRIPLE_STALE_RULE_MISMATCH_LOSER))?.is_deleted,
+ ).toBe(true);
+ });
+ });
+});
diff --git a/packages/client/src/services/RecurringTaskDeduplicator.test-setup.ts b/packages/client/src/services/RecurringTaskDeduplicator.test-setup.ts
index 881b1c14..d22371c8 100644
--- a/packages/client/src/services/RecurringTaskDeduplicator.test-setup.ts
+++ b/packages/client/src/services/RecurringTaskDeduplicator.test-setup.ts
@@ -1,9 +1,10 @@
// implements FR3 of fix-stale-sync-overwrites
-import { beforeEach } from "vitest";
+import { beforeEach, expect } from "vitest";
import { db } from "@/db/database";
import { ChecklistRepository } from "@/db/repositories/ChecklistRepository";
import { TaskRepository } from "@/db/repositories/TaskRepository";
import { fakeClock } from "@/lib/temporal";
+import type { Task } from "@/types/entities";
import { RecurringTaskDeduplicator } from "./RecurringTaskDeduplicator";
// This must remain a THIRD distinct timestamp from every copy's updated_at
@@ -38,6 +39,29 @@ export function createRecurringTaskDeduplicatorMergeSetup(): {
};
}
+// The freshest-updated_at copy across the merge scenarios always carries
+// this name/description, so the merged survivor's content must match it.
+export const FRESH_CONTENT_NAME = "Fresh name";
+export const FRESH_CONTENT_DESCRIPTION = "Fresh description";
+
+/**
+ * Asserts the FR3 merge invariant shared by the schedule-and-box and
+ * wholesale-repeat-rule scenarios: the schedule triple (next_date +
+ * appear_date + is_hidden) stays coupled to the schedule winner's own copy,
+ * while name/description merge from the freshest-updated_at copy.
+ * Implements FR3 of fix-stale-sync-overwrites.
+ */
+export function expectScheduleTripleWithFreshContent(
+ survivor: Task | undefined,
+ scheduleTriple: { nextDate: string; appearDate: string; isHidden: boolean },
+): void {
+ expect(survivor?.next_date).toBe(scheduleTriple.nextDate);
+ expect(survivor?.appear_date).toBe(scheduleTriple.appearDate);
+ expect(survivor?.is_hidden).toBe(scheduleTriple.isHidden);
+ expect(survivor?.name).toBe(FRESH_CONTENT_NAME);
+ expect(survivor?.description).toBe(FRESH_CONTENT_DESCRIPTION);
+}
+
// Deterministic UUIDs for predictable winner-by-schedule ordering, shared
// across the merge test files that use scenario 1 (content fields).
export const UUID_SCHEDULE_WINNER = "00000000-0000-4000-a000-000000000010";
diff --git a/packages/client/src/services/TaskService-test-utils.ts b/packages/client/src/services/TaskService-test-utils.ts
index b52af0a0..a8f2fd9f 100644
--- a/packages/client/src/services/TaskService-test-utils.ts
+++ b/packages/client/src/services/TaskService-test-utils.ts
@@ -1,4 +1,4 @@
-import type { vi } from "vitest";
+import { vi } from "vitest";
import type { ChecklistRepository } from "@/db/repositories/ChecklistRepository";
import type { TaskRepository } from "@/db/repositories/TaskRepository";
import { createMockChecklistRepository } from "@/test/factories/checklistRepositoryFactory";
@@ -30,3 +30,37 @@ export function getCreatedItem(mockChecklistRepository: ChecklistRepository) {
return (mockChecklistRepository.create as ReturnType).mock
.calls[0][0];
}
+
+/**
+ * Installs an `update` mock that records each updated task into the returned
+ * map (keyed by task id) and echoes the task back. Shared setup for softDelete
+ * / restore tests that assert on the final state of updated tasks.
+ */
+export function mockUpdateRecording(
+ mockTaskRepository: TaskRepository,
+): Record {
+ const recordedUpdates: Record = {};
+ mockTaskRepository.update = vi.fn().mockImplementation(async (task: Task) => {
+ recordedUpdates[task.id] = task;
+ return task;
+ });
+ return recordedUpdates;
+}
+
+/**
+ * Installs a `getById` mock resolving from a base id->task map, preferring any
+ * task already written into `recordedUpdates` so reads observe prior updates.
+ * Returns undefined for unknown ids. Shared setup for softDelete / restore
+ * tests.
+ */
+export function mockGetByIdFromMap(
+ mockTaskRepository: TaskRepository,
+ tasksById: Record,
+ recordedUpdates: Record = {},
+): void {
+ mockTaskRepository.getById = vi
+ .fn()
+ .mockImplementation(async (id: string) => {
+ return recordedUpdates[id] ?? tasksById[id] ?? undefined;
+ });
+}
diff --git a/packages/client/src/services/TaskService.recurring-restore.test.ts b/packages/client/src/services/TaskService.recurring-restore.test.ts
index 3df0fc01..5afa1d8d 100644
--- a/packages/client/src/services/TaskService.recurring-restore.test.ts
+++ b/packages/client/src/services/TaskService.recurring-restore.test.ts
@@ -4,7 +4,11 @@ import type { TaskRepository } from "@/db/repositories/TaskRepository";
import { buildTask } from "@/test/factories/taskFactory";
import type { Task } from "@/types/entities";
import type { TaskService } from "./TaskService";
-import { createTestContext } from "./TaskService-test-utils";
+import {
+ createTestContext,
+ mockGetByIdFromMap,
+ mockUpdateRecording,
+} from "./TaskService-test-utils";
describe("TaskService - Recurring Restore (fix-recurring-restore)", () => {
let taskService: TaskService;
@@ -32,20 +36,15 @@ describe("TaskService - Recurring Restore (fix-recurring-restore)", () => {
is_deleted: false,
});
- const updates: Record = {};
-
- mockTaskRepository.getById = vi.fn().mockImplementation(async (id) => {
- if (id === "a") return updates.a ?? originalTask;
- if (id === "b") return updates.b ?? activeCopy;
- return undefined;
- });
+ const updates = mockUpdateRecording(mockTaskRepository);
+ mockGetByIdFromMap(
+ mockTaskRepository,
+ { a: originalTask, b: activeCopy },
+ updates,
+ );
mockTaskRepository.findByOriginalTaskId = vi
.fn()
.mockResolvedValue([activeCopy]);
- mockTaskRepository.update = vi.fn().mockImplementation(async (task) => {
- updates[task.id] = task;
- return task;
- });
await taskService.softDelete("a");
@@ -62,14 +61,9 @@ describe("TaskService - Recurring Restore (fix-recurring-restore)", () => {
repeat_rule: "daily",
});
- const updates: Record = {};
-
- mockTaskRepository.getById = vi.fn().mockResolvedValue(originalTask);
+ const updates = mockUpdateRecording(mockTaskRepository);
+ mockGetByIdFromMap(mockTaskRepository, { a: originalTask }, updates);
mockTaskRepository.findByOriginalTaskId = vi.fn().mockResolvedValue([]);
- mockTaskRepository.update = vi.fn().mockImplementation(async (task) => {
- updates[task.id] = task;
- return task;
- });
await taskService.softDelete("a");
@@ -93,14 +87,8 @@ describe("TaskService - Recurring Restore (fix-recurring-restore)", () => {
function setupRestoreMocks(
tasks: Record,
): Record {
- const updates: Record = {};
- mockTaskRepository.getById = vi.fn().mockImplementation(async (id) => {
- return updates[id] ?? tasks[id] ?? undefined;
- });
- mockTaskRepository.update = vi.fn().mockImplementation(async (task) => {
- updates[task.id] = task;
- return task;
- });
+ const updates = mockUpdateRecording(mockTaskRepository);
+ mockGetByIdFromMap(mockTaskRepository, tasks, updates);
return updates;
}
diff --git a/packages/client/src/services/TaskService.recurring-soft-delete.test.ts b/packages/client/src/services/TaskService.recurring-soft-delete.test.ts
index f9c13c41..d27fbd18 100644
--- a/packages/client/src/services/TaskService.recurring-soft-delete.test.ts
+++ b/packages/client/src/services/TaskService.recurring-soft-delete.test.ts
@@ -1,20 +1,22 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
-import type { ChecklistRepository } from "@/db/repositories/ChecklistRepository";
import type { TaskRepository } from "@/db/repositories/TaskRepository";
-import { createMockChecklistRepository } from "@/test/factories/checklistRepositoryFactory";
import { buildTask } from "@/test/factories/taskFactory";
-import { createMockTaskRepository } from "@/test/mocks/taskRepositoryMock";
-import { TaskService } from "./TaskService";
+import type { Task } from "@/types/entities";
+import type { TaskService } from "./TaskService";
+import {
+ createTestContext,
+ mockGetByIdFromMap,
+ mockUpdateRecording,
+} from "./TaskService-test-utils";
describe("TaskService - Recurring Tasks Integration", () => {
let taskService: TaskService;
let mockTaskRepository: TaskRepository;
- let mockChecklistRepository: ChecklistRepository;
beforeEach(() => {
- mockTaskRepository = createMockTaskRepository();
- mockChecklistRepository = createMockChecklistRepository();
- taskService = new TaskService(mockTaskRepository, mockChecklistRepository);
+ const context = createTestContext();
+ taskService = context.taskService;
+ mockTaskRepository = context.mockTaskRepository;
vi.clearAllMocks();
});
@@ -40,21 +42,15 @@ describe("TaskService - Recurring Tasks Integration", () => {
is_deleted: false,
});
- const updates: Record> = {};
-
- mockTaskRepository.getById = vi.fn().mockImplementation(async (id) => {
- if (id === "task-1") return originalTask;
- if (id === "task-2") return copy1;
- if (id === "task-3") return copy2;
- return undefined;
+ const updates = mockUpdateRecording(mockTaskRepository);
+ mockGetByIdFromMap(mockTaskRepository, {
+ "task-1": originalTask,
+ "task-2": copy1,
+ "task-3": copy2,
});
mockTaskRepository.findByOriginalTaskId = vi
.fn()
.mockResolvedValue([copy1, copy2]);
- mockTaskRepository.update = vi.fn().mockImplementation(async (task) => {
- updates[task.id] = task;
- return task;
- });
await taskService.softDelete("task-1");
@@ -73,18 +69,14 @@ describe("TaskService - Recurring Tasks Integration", () => {
original_task_id: "",
});
- let deletedTask: ReturnType | null = null;
- mockTaskRepository.getById = vi.fn().mockResolvedValue(originalTask);
+ const updates = mockUpdateRecording(mockTaskRepository);
+ mockGetByIdFromMap(mockTaskRepository, { "task-1": originalTask });
mockTaskRepository.findByOriginalTaskId = vi.fn().mockResolvedValue([]);
- mockTaskRepository.update = vi.fn().mockImplementation(async (task) => {
- deletedTask = task;
- return task;
- });
await taskService.softDelete("task-1");
- expect(deletedTask).toBeDefined();
- expect(deletedTask).toMatchObject({ is_deleted: true });
+ expect(updates["task-1"]).toBeDefined();
+ expect(updates["task-1"]).toMatchObject({ is_deleted: true });
});
it("should skip deleted copies when reassigning", async () => {
@@ -108,27 +100,75 @@ describe("TaskService - Recurring Tasks Integration", () => {
is_deleted: false,
});
- const updates: Record> = {};
-
- mockTaskRepository.getById = vi.fn().mockImplementation(async (id) => {
- if (id === "task-1") return originalTask;
- if (id === "task-2") return deletedCopy;
- if (id === "task-3") return activeCopy;
- return undefined;
+ const updates = mockUpdateRecording(mockTaskRepository);
+ mockGetByIdFromMap(mockTaskRepository, {
+ "task-1": originalTask,
+ "task-2": deletedCopy,
+ "task-3": activeCopy,
});
mockTaskRepository.findByOriginalTaskId = vi
.fn()
.mockResolvedValue([deletedCopy, activeCopy]);
- mockTaskRepository.update = vi.fn().mockImplementation(async (task) => {
- updates[task.id] = task;
- return task;
- });
await taskService.softDelete("task-1");
expect(updates["task-3"].original_task_id).toBe("");
expect(updates["task-2"].original_task_id).toBe("task-3");
});
+
+ // Guards `if (copy.id !== newOriginal.id)`: the reassignment loop must
+ // skip the new-original's own entry inside `copies` and never call
+ // update() for it there — it is updated separately afterward (to clear
+ // its own original_task_id). A mutant that turns the guard into `if
+ // (true)` would call update() for newOriginal.id twice with different
+ // original_task_id payloads; asserting the exact call count/args (not
+ // just the final overwritten state) catches that extra call.
+ it("should call update exactly once per copy, skipping the new original inside the reassignment loop", async () => {
+ const originalTask = buildTask({
+ id: "task-1",
+ name: "Original",
+ original_task_id: "",
+ });
+
+ const copy1 = buildTask({
+ id: "task-2",
+ name: "Copy 1 (becomes new original)",
+ original_task_id: "task-1",
+ is_deleted: false,
+ });
+
+ const copy2 = buildTask({
+ id: "task-3",
+ name: "Copy 2",
+ original_task_id: "task-1",
+ is_deleted: false,
+ });
+
+ mockGetByIdFromMap(mockTaskRepository, {
+ "task-1": originalTask,
+ "task-2": copy1,
+ "task-3": copy2,
+ });
+ mockTaskRepository.findByOriginalTaskId = vi
+ .fn()
+ .mockResolvedValue([copy1, copy2]);
+ const updateSpy = vi.fn().mockImplementation(async (task: Task) => task);
+ mockTaskRepository.update = updateSpy;
+
+ await taskService.softDelete("task-1");
+
+ const updateCallsForNewOriginal = updateSpy.mock.calls.filter(
+ ([task]) => task.id === "task-2",
+ );
+ expect(updateCallsForNewOriginal).toHaveLength(1);
+ expect(updateCallsForNewOriginal[0][0].original_task_id).toBe("");
+
+ const updateCallsForOtherCopy = updateSpy.mock.calls.filter(
+ ([task]) => task.id === "task-3",
+ );
+ expect(updateCallsForOtherCopy).toHaveLength(1);
+ expect(updateCallsForOtherCopy[0][0].original_task_id).toBe("task-2");
+ });
});
describe("softDelete with all copies deleted", () => {
@@ -153,21 +193,15 @@ describe("TaskService - Recurring Tasks Integration", () => {
is_deleted: true,
});
- const updates: Record> = {};
-
- mockTaskRepository.getById = vi.fn().mockImplementation(async (id) => {
- if (id === "task-1") return originalTask;
- if (id === "task-2") return deletedCopy1;
- if (id === "task-3") return deletedCopy2;
- return undefined;
+ const updates = mockUpdateRecording(mockTaskRepository);
+ mockGetByIdFromMap(mockTaskRepository, {
+ "task-1": originalTask,
+ "task-2": deletedCopy1,
+ "task-3": deletedCopy2,
});
mockTaskRepository.findByOriginalTaskId = vi
.fn()
.mockResolvedValue([deletedCopy1, deletedCopy2]);
- mockTaskRepository.update = vi.fn().mockImplementation(async (task) => {
- updates[task.id] = task;
- return task;
- });
await taskService.softDelete("task-1");
diff --git a/packages/client/src/services/TaskService.reorder.test.ts b/packages/client/src/services/TaskService.reorder.test.ts
index ba33ea44..23fe3da9 100644
--- a/packages/client/src/services/TaskService.reorder.test.ts
+++ b/packages/client/src/services/TaskService.reorder.test.ts
@@ -1,99 +1,240 @@
import { describe, expect, it, vi } from "vitest";
import { SORT_ORDER_REBALANCE_THRESHOLD } from "@/constants";
+import { fakeClock } from "@/lib/temporal";
import { buildTask } from "@/test/factories/taskFactory";
+import type { Task } from "@/types/entities";
+import { toISOTimestamp } from "@/utils/dateHelpers";
import { createTestContext } from "./TaskService-test-utils";
+const REBALANCE_LONG_KEY = "a".repeat(SORT_ORDER_REBALANCE_THRESHOLD + 1);
+const SHORT_SORT_KEY = "a1";
+
+/**
+ * Arrange for reorder tests that operate on a single task: builds a task from
+ * the given overrides and a TaskService whose repository resolves it via
+ * getById. Used by the short-key tests that never hit rebalancing.
+ */
+function setupSingleTask(overrides: Parameters[0] = {}) {
+ const task = buildTask(overrides);
+ const { taskService, mockTaskRepository } = createTestContext({
+ getById: vi.fn().mockResolvedValue(task),
+ });
+ return { task, taskService, mockTaskRepository };
+}
+
+/**
+ * Shared arrange for rebalance-during-reorder tests: a dragged task and one
+ * other box task, both carrying the same stale updated_at, wired into a
+ * TaskService whose repository resolves them via getById / getByBox.
+ */
+function setupRebalanceScenario() {
+ const staleTimestamp = toISOTimestamp(fakeClock("2025-01-01T00:00:00.000Z"));
+ const draggedTask = buildTask({
+ sort_order: "a0",
+ box: "inbox",
+ updated_at: staleTimestamp,
+ });
+ const otherBoxTask = buildTask({
+ sort_order: "b0",
+ box: "inbox",
+ updated_at: staleTimestamp,
+ });
+ const { taskService, mockTaskRepository } = createTestContext({
+ getById: vi.fn().mockResolvedValue(draggedTask),
+ getByBox: vi.fn().mockResolvedValue([draggedTask, otherBoxTask]),
+ });
+ return {
+ staleTimestamp,
+ draggedTask,
+ otherBoxTask,
+ taskService,
+ mockTaskRepository,
+ };
+}
+
+function getBulkUpsertedTasks(
+ mockTaskRepository: ReturnType<
+ typeof createTestContext
+ >["mockTaskRepository"],
+): Task[] {
+ return (mockTaskRepository.bulkUpsert as ReturnType).mock
+ .calls[0][0];
+}
+
+function findRebalancedTaskById(
+ rebalancedTasks: Task[],
+ taskId: string,
+): Task | undefined {
+ return rebalancedTasks.find((rebalancedTask) => rebalancedTask.id === taskId);
+}
+
+/**
+ * Arrange for a plain rebalance-during-reorder trigger: a dragged task plus a
+ * two-task box, wired into a TaskService whose repository resolves them via
+ * getById / getByBox. Used by the tests that only assert on rebalance being
+ * triggered and on the fresh keys, without stale-timestamp bookkeeping.
+ */
+function setupBoxWithTasks(boxTasks: ReturnType[]) {
+ const task = buildTask({ sort_order: "a0", box: "inbox" });
+ const { taskService, mockTaskRepository } = createTestContext({
+ getById: vi.fn().mockResolvedValue(task),
+ getByBox: vi.fn().mockResolvedValue(boxTasks),
+ });
+ return { task, taskService, mockTaskRepository };
+}
+
describe("TaskService", () => {
describe("reorderTasks", () => {
it("should update task with new sort_order", async () => {
- const task = buildTask({ sort_order: "a0" });
- const { taskService, mockTaskRepository } = createTestContext({
- getById: vi.fn().mockResolvedValue(task),
+ const { task, taskService, mockTaskRepository } = setupSingleTask({
+ sort_order: "a0",
});
- await taskService.reorderTasks(task.id, "a1");
+ await taskService.reorderTasks(task.id, SHORT_SORT_KEY);
expect(mockTaskRepository.update).toHaveBeenCalledWith(
expect.objectContaining({
id: task.id,
- sort_order: "a1",
+ sort_order: SHORT_SORT_KEY,
syncStatus: "pending" as const,
}),
);
});
it("should update updated_at when reordering", async () => {
- const task = buildTask({
+ const originalTimestamp = "2025-01-01T00:00:00.000Z";
+ const { task, taskService, mockTaskRepository } = setupSingleTask({
sort_order: "a0",
- updated_at: "2025-01-01T00:00:00.000Z",
- });
- const { taskService, mockTaskRepository } = createTestContext({
- getById: vi.fn().mockResolvedValue(task),
+ updated_at: originalTimestamp,
});
- await taskService.reorderTasks(task.id, "a1");
+ await taskService.reorderTasks(task.id, SHORT_SORT_KEY);
const updatedTask = (
mockTaskRepository.update as ReturnType
).mock.calls[0][0];
- expect(updatedTask.updated_at).not.toBe("2025-01-01T00:00:00.000Z");
+ expect(updatedTask.updated_at).not.toBe(originalTimestamp);
});
it("should throw when task not found", async () => {
const { taskService } = createTestContext();
await expect(
- taskService.reorderTasks("nonexistent", "a1"),
+ taskService.reorderTasks("nonexistent", SHORT_SORT_KEY),
).rejects.toThrow("Task not found: nonexistent");
});
it("should not trigger rebalancing when key is short", async () => {
- const task = buildTask({ sort_order: "a0", box: "inbox" });
- const { taskService, mockTaskRepository } = createTestContext({
- getById: vi.fn().mockResolvedValue(task),
+ const { task, taskService, mockTaskRepository } = setupSingleTask({
+ sort_order: "a0",
+ box: "inbox",
});
- await taskService.reorderTasks(task.id, "a1");
+ await taskService.reorderTasks(task.id, SHORT_SORT_KEY);
expect(mockTaskRepository.bulkUpsert).not.toHaveBeenCalled();
});
it("should trigger rebalancing when key exceeds threshold", async () => {
- const longKey = "a".repeat(SORT_ORDER_REBALANCE_THRESHOLD + 1);
- const task = buildTask({ sort_order: "a0", box: "inbox" });
- const boxTasks = [
+ const { task, taskService, mockTaskRepository } = setupBoxWithTasks([
buildTask({ sort_order: "a0", box: "inbox" }),
buildTask({ sort_order: "a1", box: "inbox" }),
- ];
- const { taskService, mockTaskRepository } = createTestContext({
- getById: vi.fn().mockResolvedValue(task),
- getByBox: vi.fn().mockResolvedValue(boxTasks),
- });
+ ]);
- await taskService.reorderTasks(task.id, longKey);
+ await taskService.reorderTasks(task.id, REBALANCE_LONG_KEY);
expect(mockTaskRepository.bulkUpsert).toHaveBeenCalled();
});
it("should rebalance all tasks in the box with fresh keys", async () => {
- const longKey = "a".repeat(SORT_ORDER_REBALANCE_THRESHOLD + 1);
- const task = buildTask({ sort_order: "a0", box: "inbox" });
- const boxTasks = [
+ const { task, taskService, mockTaskRepository } = setupBoxWithTasks([
buildTask({ sort_order: "b0", box: "inbox" }),
buildTask({ sort_order: "a0", box: "inbox" }),
- ];
+ ]);
+
+ await taskService.reorderTasks(task.id, REBALANCE_LONG_KEY);
+
+ const rebalancedTasks = getBulkUpsertedTasks(mockTaskRepository);
+ expect(rebalancedTasks).toHaveLength(2);
+ for (const rebalancedTask of rebalancedTasks) {
+ expect(typeof rebalancedTask.sort_order).toBe("string");
+ expect(rebalancedTask.syncStatus).toBe("pending");
+ }
+ });
+
+ // FR4: rebalanceBox preserves updated_at — system-initiated rebalancing
+ // must not masquerade as a user edit (fix-stale-sync-overwrites).
+ // Verifies M2 of fix-stale-sync-overwrites
+ it("should keep original updated_at for rebalanced tasks", async () => {
+ const {
+ staleTimestamp,
+ draggedTask,
+ otherBoxTask,
+ taskService,
+ mockTaskRepository,
+ } = setupRebalanceScenario();
+
+ await taskService.reorderTasks(draggedTask.id, REBALANCE_LONG_KEY);
+
+ const rebalancedTasks = getBulkUpsertedTasks(mockTaskRepository);
+ expect(rebalancedTasks).toHaveLength(2);
+ const otherTaskAfterRebalance = findRebalancedTaskById(
+ rebalancedTasks,
+ otherBoxTask.id,
+ );
+ expect(otherTaskAfterRebalance?.updated_at).toBe(staleTimestamp);
+ });
+
+ // FR4: reorderTasks must keep refreshing updated_at for the dragged
+ // task even when the same operation triggers rebalancing of the rest
+ // of the box (regression guard for fix-stale-sync-overwrites).
+ it("should still refresh updated_at for the dragged task when rebalancing is triggered", async () => {
+ const {
+ staleTimestamp,
+ draggedTask,
+ otherBoxTask,
+ taskService,
+ mockTaskRepository,
+ } = setupRebalanceScenario();
+
+ await taskService.reorderTasks(draggedTask.id, REBALANCE_LONG_KEY);
+
+ const rebalancedTasks = getBulkUpsertedTasks(mockTaskRepository);
+ const draggedTaskAfterRebalance = findRebalancedTaskById(
+ rebalancedTasks,
+ draggedTask.id,
+ );
+ const otherTaskAfterRebalance = findRebalancedTaskById(
+ rebalancedTasks,
+ otherBoxTask.id,
+ );
+
+ expect(draggedTaskAfterRebalance?.updated_at).not.toBe(staleTimestamp);
+ expect(otherTaskAfterRebalance?.updated_at).toBe(staleTimestamp);
+ });
+
+ // FR4: rebalanceBox assigns syncStatus "pending" to propagate the new
+ // sort_order without pretending it is a user edit.
+ it("should set syncStatus to pending for rebalanced tasks", async () => {
+ const task = buildTask({
+ sort_order: "a0",
+ box: "inbox",
+ syncStatus: "synced" as const,
+ });
+ const otherBoxTask = buildTask({
+ sort_order: "b0",
+ box: "inbox",
+ syncStatus: "synced" as const,
+ });
const { taskService, mockTaskRepository } = createTestContext({
getById: vi.fn().mockResolvedValue(task),
- getByBox: vi.fn().mockResolvedValue(boxTasks),
+ getByBox: vi.fn().mockResolvedValue([task, otherBoxTask]),
});
- await taskService.reorderTasks(task.id, longKey);
+ await taskService.reorderTasks(task.id, REBALANCE_LONG_KEY);
- const rebalancedTasks = (
- mockTaskRepository.bulkUpsert as ReturnType
- ).mock.calls[0][0];
+ const rebalancedTasks = getBulkUpsertedTasks(mockTaskRepository);
expect(rebalancedTasks).toHaveLength(2);
for (const rebalancedTask of rebalancedTasks) {
- expect(typeof rebalancedTask.sort_order).toBe("string");
expect(rebalancedTask.syncStatus).toBe("pending");
}
});
diff --git a/packages/client/src/test/features/repeating_tasks/steps/dedup_merge.steps.ts b/packages/client/src/test/features/repeating_tasks/steps/dedup_merge.steps.ts
new file mode 100644
index 00000000..56da7857
--- /dev/null
+++ b/packages/client/src/test/features/repeating_tasks/steps/dedup_merge.steps.ts
@@ -0,0 +1,232 @@
+// implements FR3, FR6 of fix-stale-sync-overwrites
+import "fake-indexeddb/auto";
+import type { FeatureDescriibeCallbackParams } from "@amiceli/vitest-cucumber";
+import { describeFeature, loadFeature } from "@amiceli/vitest-cucumber";
+import { expect, type TestContext } from "vitest";
+import { db } from "@/db/database";
+import { ChecklistRepository } from "@/db/repositories/ChecklistRepository";
+import { TaskRepository } from "@/db/repositories/TaskRepository";
+import { fakeClock } from "@/lib/temporal";
+import { RecurringTaskDeduplicator } from "@/services/RecurringTaskDeduplicator";
+import { buildTask } from "@/test/factories/taskFactory";
+import type { Task } from "@/types/entities";
+
+const feature = await loadFeature("../dedup_merge.feature");
+
+type Context = Record;
+
+const DEDUPLICATION_RUN_TIMESTAMP = "2026-07-11T00:00:00.000Z";
+const ORIGINAL_TASK_ID = "11111111-1111-4111-a111-111111111111";
+const DEVICE_A_COPY_ID = "00000000-0000-4000-a000-000000000001";
+const DEVICE_B_COPY_ID = "00000000-0000-4000-a000-000000000002";
+const DEVICE_A_UPDATED_AT = "2026-07-08T10:00:00.000Z";
+const DEVICE_B_UPDATED_AT = "2026-07-06T10:00:00.000Z";
+const FIXED_REPEAT_RULE = "daily-1";
+const AFTER_COMPLETION_REPEAT_RULE = "after_completion-3";
+
+describeFeature(feature, (f: FeatureDescriibeCallbackParams) => {
+ let deduplicator: RecurringTaskDeduplicator;
+ let deviceACopyBeforeMerge: Task;
+ let deviceBCopyBeforeMerge: Task;
+
+ f.BeforeEachScenario(async () => {
+ await db.tasks.clear();
+ const clock = fakeClock(DEDUPLICATION_RUN_TIMESTAMP);
+ deduplicator = new RecurringTaskDeduplicator(
+ new TaskRepository(),
+ new ChecklistRepository(),
+ clock,
+ );
+ });
+
+ async function seedDeviceACopy(
+ nextDate: string,
+ description: string,
+ repeatRule: string,
+ ): Promise {
+ const deviceACopy = buildTask({
+ id: DEVICE_A_COPY_ID,
+ original_task_id: ORIGINAL_TASK_ID,
+ next_date: nextDate,
+ appear_date: nextDate,
+ description,
+ repeat_rule: repeatRule,
+ updated_at: DEVICE_A_UPDATED_AT,
+ is_completed: false,
+ is_deleted: false,
+ syncStatus: "synced" as const,
+ });
+ deviceACopyBeforeMerge = deviceACopy;
+ await db.tasks.add(deviceACopy);
+ }
+
+ async function seedDeviceBCopy(
+ nextDate: string,
+ description: string,
+ repeatRule: string,
+ ): Promise {
+ const deviceBCopy = buildTask({
+ id: DEVICE_B_COPY_ID,
+ original_task_id: ORIGINAL_TASK_ID,
+ next_date: nextDate,
+ appear_date: nextDate,
+ description,
+ repeat_rule: repeatRule,
+ updated_at: DEVICE_B_UPDATED_AT,
+ is_completed: false,
+ is_deleted: false,
+ syncStatus: "synced" as const,
+ });
+ deviceBCopyBeforeMerge = deviceBCopy;
+ await db.tasks.add(deviceBCopy);
+ }
+
+ type FixedScheduleScenarioSteps = {
+ Given: (title: string, fn: (ctx: TestContext) => Promise) => void;
+ And: (title: string, fn: (ctx: TestContext) => Promise) => void;
+ When: (title: string, fn: (ctx: TestContext) => Promise) => void;
+ };
+
+ function registerFixedScheduleSetupSteps({
+ Given,
+ And,
+ When,
+ }: FixedScheduleScenarioSteps): void {
+ Given(
+ 'device A completed and edited the occurrence, producing a copy with next_date "2026-07-10" and description "v2" as the freshest update',
+ async (_ctx: TestContext) => {
+ await seedDeviceACopy("2026-07-10", "v2", FIXED_REPEAT_RULE);
+ },
+ );
+
+ And(
+ 'device B completed the same occurrence offline, producing a copy with next_date "2026-07-09" and description "v1" as a staler update',
+ async (_ctx: TestContext) => {
+ await seedDeviceBCopy("2026-07-09", "v1", FIXED_REPEAT_RULE);
+ },
+ );
+
+ When(
+ "deduplication runs after device B pulls both copies",
+ async (_ctx: TestContext) => {
+ await deduplicator.deduplicate([ORIGINAL_TASK_ID]);
+ },
+ );
+ }
+
+ // @fix-stale-sync-overwrites @FR3
+ f.Scenario(
+ "Fixed schedule — earlier device's schedule wins with the later device's fresh content",
+ ({ Given, When, Then, And }) => {
+ registerFixedScheduleSetupSteps({ Given, And, When });
+
+ Then(
+ 'the surviving copy has next_date "2026-07-09"',
+ async (_ctx: TestContext) => {
+ const survivor = (await db.tasks.get(DEVICE_B_COPY_ID)) as Task;
+ expect(survivor.next_date).toBe("2026-07-09");
+ },
+ );
+
+ And(
+ 'the surviving copy has description "v2"',
+ async (_ctx: TestContext) => {
+ const survivor = (await db.tasks.get(DEVICE_B_COPY_ID)) as Task;
+ expect(survivor.description).toBe("v2");
+ },
+ );
+
+ And(
+ "the surviving copy's updated_at equals device A's update time",
+ async (_ctx: TestContext) => {
+ const survivor = (await db.tasks.get(DEVICE_B_COPY_ID)) as Task;
+ expect(survivor.updated_at).toBe(DEVICE_A_UPDATED_AT);
+ },
+ );
+
+ And(
+ 'device A\'s copy is soft-deleted with syncStatus "pending"',
+ async (_ctx: TestContext) => {
+ const loser = (await db.tasks.get(DEVICE_A_COPY_ID)) as Task;
+ expect(loser.is_deleted).toBe(true);
+ expect(loser.syncStatus).toBe("pending");
+ },
+ );
+ },
+ );
+
+ // @fix-stale-sync-overwrites @FR6
+ f.Scenario(
+ "After-completion schedule — the same merge rules apply regardless of the recurrence model",
+ ({ Given, When, Then, And }) => {
+ Given(
+ 'device A completed the occurrence under an after_completion repeat_rule, producing a copy with next_date "2026-07-04" as the freshest update',
+ async (_ctx: TestContext) => {
+ await seedDeviceACopy("2026-07-04", "", AFTER_COMPLETION_REPEAT_RULE);
+ },
+ );
+
+ And(
+ 'device B completed the same occurrence offline, producing a copy with next_date "2026-07-05" as a staler update',
+ async (_ctx: TestContext) => {
+ await seedDeviceBCopy("2026-07-05", "", AFTER_COMPLETION_REPEAT_RULE);
+ },
+ );
+
+ When(
+ "deduplication runs after device B pulls both copies",
+ async (_ctx: TestContext) => {
+ await deduplicator.deduplicate([ORIGINAL_TASK_ID]);
+ },
+ );
+
+ Then(
+ 'the surviving copy has next_date "2026-07-04"',
+ async (_ctx: TestContext) => {
+ const survivor = (await db.tasks.get(DEVICE_A_COPY_ID)) as Task;
+ expect(survivor.next_date).toBe("2026-07-04");
+ },
+ );
+
+ And(
+ "the surviving copy's repeat_rule is unchanged from before the merge",
+ async (_ctx: TestContext) => {
+ const survivor = (await db.tasks.get(DEVICE_A_COPY_ID)) as Task;
+ expect(survivor.repeat_rule).toBe(AFTER_COMPLETION_REPEAT_RULE);
+ },
+ );
+
+ And(
+ "the surviving copy's appear_date is consistent with its own next_date",
+ async (_ctx: TestContext) => {
+ const survivor = (await db.tasks.get(DEVICE_A_COPY_ID)) as Task;
+ expect(survivor.appear_date).toBe(survivor.next_date);
+ },
+ );
+ },
+ );
+
+ // @fix-stale-sync-overwrites @FR3
+ f.Scenario(
+ "Merge is not a verbatim keep-earliest — schedule and content are combined from different copies",
+ ({ Given, When, Then, And }) => {
+ registerFixedScheduleSetupSteps({ Given, And, When });
+
+ Then(
+ "the surviving copy does not equal device B's copy verbatim",
+ async (_ctx: TestContext) => {
+ const survivor = (await db.tasks.get(DEVICE_B_COPY_ID)) as Task;
+ expect(survivor).not.toEqual(deviceBCopyBeforeMerge);
+ },
+ );
+
+ And(
+ "the surviving copy does not equal device A's copy verbatim",
+ async (_ctx: TestContext) => {
+ const survivor = (await db.tasks.get(DEVICE_B_COPY_ID)) as Task;
+ expect(survivor).not.toEqual(deviceACopyBeforeMerge);
+ },
+ );
+ },
+ );
+});
diff --git a/packages/client/src/test/features/sync_protocol/steps/pull_lww_protection.steps.ts b/packages/client/src/test/features/sync_protocol/steps/pull_lww_protection.steps.ts
new file mode 100644
index 00000000..fc7e22c1
--- /dev/null
+++ b/packages/client/src/test/features/sync_protocol/steps/pull_lww_protection.steps.ts
@@ -0,0 +1,272 @@
+// implements FR5 of fix-stale-sync-overwrites
+import "fake-indexeddb/auto";
+import type { FeatureDescriibeCallbackParams } from "@amiceli/vitest-cucumber";
+import { describeFeature, loadFeature } from "@amiceli/vitest-cucumber";
+import type { WireGoal, WireTask } from "@clear-progress/contract";
+import { expect, type TestContext, vi } from "vitest";
+import { db } from "@/db/database";
+import { GoalRepository } from "@/db/repositories/GoalRepository";
+import { TaskRepository } from "@/db/repositories/TaskRepository";
+import { buildGoal } from "@/test/factories/goalFactory";
+import { buildTask } from "@/test/factories/taskFactory";
+import type { Goal, Task } from "@/types/entities";
+
+const feature = await loadFeature("../pull_lww_protection.feature");
+
+const LOCAL_EDIT_TIMESTAMP = "2026-01-02T00:00:00.000Z";
+const OTHER_DEVICE_NEWER_TIMESTAMP = "2026-01-03T00:00:00.000Z";
+const OTHER_DEVICE_OLDER_TIMESTAMP = "2026-01-01T00:00:00.000Z";
+
+type Context = Record;
+
+function toWireTask(task: Task): WireTask {
+ const { syncStatus: _syncStatus, ...wireTask } = task;
+ return wireTask;
+}
+
+function toWireGoal(goal: Goal): WireGoal {
+ const { syncStatus: _syncStatus, ...wireGoal } = goal;
+ return wireGoal;
+}
+
+describeFeature(feature, (f: FeatureDescriibeCallbackParams) => {
+ let taskRepository: TaskRepository;
+ let goalRepository: GoalRepository;
+ let seededTaskId: string;
+ let seededGoalId: string;
+ let incomingTaskUpdatedAt: string;
+ let incomingGoalUpdatedAt: string;
+ let conflictWarnSpy: ReturnType;
+
+ f.BeforeEachScenario(async () => {
+ await db.tasks.clear();
+ await db.goals.clear();
+ taskRepository = new TaskRepository();
+ goalRepository = new GoalRepository();
+ });
+
+ async function seedLocalRecords(
+ syncStatus: "synced" | "pending",
+ updatedAt: string,
+ ) {
+ const task = buildTask({ updated_at: updatedAt, syncStatus });
+ const goal = buildGoal({ updated_at: updatedAt, syncStatus });
+ seededTaskId = task.id;
+ seededGoalId = goal.id;
+ await db.tasks.add(task);
+ await db.goals.add(goal);
+ }
+
+ async function pullRecordsWithUpdatedAt(updatedAt: string) {
+ incomingTaskUpdatedAt = updatedAt;
+ incomingGoalUpdatedAt = updatedAt;
+ const serverTask = toWireTask(
+ buildTask({ id: seededTaskId, updated_at: updatedAt }),
+ );
+ const serverGoal = toWireGoal(
+ buildGoal({ id: seededGoalId, updated_at: updatedAt }),
+ );
+ await taskRepository.applyServerRecords([serverTask]);
+ await goalRepository.applyServerRecords([serverGoal]);
+ }
+
+ async function expectBothRecordsShowOtherDeviceEditAndSynced() {
+ const task = await db.tasks.get(seededTaskId);
+ const goal = await db.goals.get(seededGoalId);
+ expect(task?.updated_at).toBe(incomingTaskUpdatedAt);
+ expect(task?.syncStatus).toBe("synced");
+ expect(goal?.updated_at).toBe(incomingGoalUpdatedAt);
+ expect(goal?.syncStatus).toBe("synced");
+ }
+
+ async function expectBothRecordsKeepLocalEditAndPending() {
+ const task = await db.tasks.get(seededTaskId);
+ const goal = await db.goals.get(seededGoalId);
+ expect(task?.updated_at).toBe(LOCAL_EDIT_TIMESTAMP);
+ expect(task?.syncStatus).toBe("pending");
+ expect(goal?.updated_at).toBe(LOCAL_EDIT_TIMESTAMP);
+ expect(goal?.syncStatus).toBe("pending");
+ }
+
+ // @fix-stale-sync-overwrites @FR5
+ f.Scenario(
+ "A locally-pending edit is overwritten when another device's edit is newer",
+ ({ Given, When, Then }) => {
+ Given(
+ "a task edited locally and pending sync, with a locally-pending goal edited the same way",
+ async (_ctx: TestContext) => {
+ await seedLocalRecords("pending", LOCAL_EDIT_TIMESTAMP);
+ },
+ );
+
+ When(
+ "the same records are pulled from the server with a strictly newer edit from another device",
+ async (_ctx: TestContext) => {
+ await pullRecordsWithUpdatedAt(OTHER_DEVICE_NEWER_TIMESTAMP);
+ },
+ );
+
+ Then(
+ "both records show the other device's edit and are marked as synced",
+ expectBothRecordsShowOtherDeviceEditAndSynced,
+ );
+ },
+ );
+
+ // @fix-stale-sync-overwrites @FR5
+ f.Scenario(
+ "A locally-pending edit survives a pull of an equally old server record",
+ ({ Given, When, Then }) => {
+ Given(
+ "a task edited locally and pending sync, with a locally-pending goal edited the same way",
+ async (_ctx: TestContext) => {
+ await seedLocalRecords("pending", LOCAL_EDIT_TIMESTAMP);
+ },
+ );
+
+ When(
+ "the same records are pulled from the server with no newer edit from another device, timestamps tied",
+ async (_ctx: TestContext) => {
+ await pullRecordsWithUpdatedAt(LOCAL_EDIT_TIMESTAMP);
+ },
+ );
+
+ Then(
+ "both records still show the local edit and remain pending sync",
+ expectBothRecordsKeepLocalEditAndPending,
+ );
+ },
+ );
+
+ // @fix-stale-sync-overwrites @FR5
+ f.Scenario(
+ "A locally-pending edit survives a pull of an older server record",
+ ({ Given, When, Then }) => {
+ Given(
+ "a task edited locally and pending sync, with a locally-pending goal edited the same way",
+ async (_ctx: TestContext) => {
+ await seedLocalRecords("pending", LOCAL_EDIT_TIMESTAMP);
+ },
+ );
+
+ When(
+ "the same records are pulled from the server with an older, stale edit from another device",
+ async (_ctx: TestContext) => {
+ await pullRecordsWithUpdatedAt(OTHER_DEVICE_OLDER_TIMESTAMP);
+ },
+ );
+
+ Then(
+ "both records still show the local edit and remain pending sync",
+ expectBothRecordsKeepLocalEditAndPending,
+ );
+ },
+ );
+
+ // @fix-stale-sync-overwrites @FR5
+ f.Scenario(
+ "A synced record always reflects the latest pull",
+ ({ Given, When, Then }) => {
+ Given(
+ "a task already in sync with the server, with a goal already in sync the same way",
+ async (_ctx: TestContext) => {
+ await seedLocalRecords("synced", LOCAL_EDIT_TIMESTAMP);
+ },
+ );
+
+ When(
+ "the same records are pulled from the server with a newer edit from another device",
+ async (_ctx: TestContext) => {
+ await pullRecordsWithUpdatedAt(OTHER_DEVICE_NEWER_TIMESTAMP);
+ },
+ );
+
+ Then(
+ "both records show the other device's edit and are marked as synced",
+ expectBothRecordsShowOtherDeviceEditAndSynced,
+ );
+ },
+ );
+
+ // @fix-stale-sync-overwrites @FR5
+ f.Scenario(
+ "A new record from another device appears after its first pull",
+ ({ Given, When, Then }) => {
+ Given(
+ "no local copy of a task or a goal exists yet",
+ async (_ctx: TestContext) => {
+ seededTaskId = crypto.randomUUID();
+ seededGoalId = crypto.randomUUID();
+ },
+ );
+
+ When(
+ "the same records are pulled from the server for the first time",
+ async (_ctx: TestContext) => {
+ await pullRecordsWithUpdatedAt(OTHER_DEVICE_NEWER_TIMESTAMP);
+ },
+ );
+
+ Then(
+ "both records appear locally and are marked as synced",
+ async (_ctx: TestContext) => {
+ const task = await db.tasks.get(seededTaskId);
+ const goal = await db.goals.get(seededGoalId);
+ expect(task).toBeDefined();
+ expect(task?.syncStatus).toBe("synced");
+ expect(goal).toBeDefined();
+ expect(goal?.syncStatus).toBe("synced");
+ },
+ );
+ },
+ );
+
+ // @fix-stale-sync-overwrites @FR5
+ f.Scenario(
+ "An overwritten pending edit is logged as a sync conflict for debugging",
+ ({ Given, When, Then }) => {
+ Given(
+ "a task edited locally and pending sync",
+ async (_ctx: TestContext) => {
+ const task = buildTask({
+ updated_at: LOCAL_EDIT_TIMESTAMP,
+ syncStatus: "pending",
+ });
+ seededTaskId = task.id;
+ await db.tasks.add(task);
+ },
+ );
+
+ When(
+ "the task is pulled from the server with a strictly newer edit from another device",
+ async (_ctx: TestContext) => {
+ conflictWarnSpy = vi
+ .spyOn(console, "warn")
+ .mockImplementation(() => {});
+ const serverTask = toWireTask(
+ buildTask({
+ id: seededTaskId,
+ updated_at: OTHER_DEVICE_NEWER_TIMESTAMP,
+ }),
+ );
+ await taskRepository.applyServerRecords([serverTask]);
+ },
+ );
+
+ Then(
+ "a sync conflict is logged with the task's id and both devices' timestamps",
+ async (_ctx: TestContext) => {
+ expect(conflictWarnSpy).toHaveBeenCalledWith(
+ expect.stringContaining("task"),
+ expect.objectContaining({
+ id: seededTaskId,
+ localUpdatedAt: LOCAL_EDIT_TIMESTAMP,
+ serverUpdatedAt: OTHER_DEVICE_NEWER_TIMESTAMP,
+ }),
+ );
+ conflictWarnSpy.mockRestore();
+ },
+ );
+ },
+ );
+});
diff --git a/packages/client/src/test/features/sync_protocol/steps/stale_reveal_sync.steps.ts b/packages/client/src/test/features/sync_protocol/steps/stale_reveal_sync.steps.ts
new file mode 100644
index 00000000..d5104801
--- /dev/null
+++ b/packages/client/src/test/features/sync_protocol/steps/stale_reveal_sync.steps.ts
@@ -0,0 +1,218 @@
+// implements FR1, FR2 of fix-stale-sync-overwrites
+import "fake-indexeddb/auto";
+import type { FeatureDescriibeCallbackParams } from "@amiceli/vitest-cucumber";
+import { describeFeature, loadFeature } from "@amiceli/vitest-cucumber";
+import { expect, type TestContext } from "vitest";
+import { db } from "@/db/database";
+import { ChecklistRepository } from "@/db/repositories/ChecklistRepository";
+import { TaskRepository } from "@/db/repositories/TaskRepository";
+import { fakeClock } from "@/lib/temporal";
+import { HiddenTaskService } from "@/services/HiddenTaskService";
+import { TaskService } from "@/services/TaskService";
+import { buildTask } from "@/test/factories/taskFactory";
+import type { Task } from "@/types/entities";
+
+const feature = await loadFeature("../stale_reveal_sync.feature");
+
+type Context = Record;
+
+describeFeature(feature, (f: FeatureDescriibeCallbackParams) => {
+ let hiddenTaskService: HiddenTaskService;
+ let taskService: TaskService;
+ let seededTaskId: string;
+ let updatedTask: Task;
+
+ f.BeforeEachScenario(async () => {
+ await db.tasks.clear();
+ taskService = new TaskService(
+ new TaskRepository(),
+ new ChecklistRepository(),
+ );
+ });
+
+ async function revealHiddenTasks(_ctx: TestContext) {
+ await hiddenTaskService.revealHiddenTasks();
+ }
+
+ async function expectRevealedTaskUnhiddenAndPending(_ctx: TestContext) {
+ const task = await db.tasks.get(seededTaskId);
+ expect(task?.is_hidden).toBe(false);
+ expect(task?.syncStatus).toBe("pending");
+ }
+
+ async function expectRevealedTaskUpdatedAtPreserved(_ctx: TestContext) {
+ const task = await db.tasks.get(seededTaskId);
+ expect(task?.updated_at).toBe("2026-06-01T10:00:00.000Z");
+ }
+
+ // @fix-stale-sync-overwrites @FR1
+ f.Scenario(
+ "Task revealed when appear date arrives preserves updated_at",
+ ({ Given, When, Then, And }) => {
+ Given(
+ 'a hidden task with appear_date "2026-06-15", updated_at "2026-06-01T10:00:00.000Z" and today is "2026-06-15"',
+ async (_ctx: TestContext) => {
+ const clock = fakeClock("2026-06-15T12:00:00Z");
+ hiddenTaskService = new HiddenTaskService(
+ new TaskRepository(),
+ clock,
+ );
+ const task = buildTask({
+ is_hidden: true,
+ appear_date: "2026-06-15",
+ next_date: "2026-06-15",
+ updated_at: "2026-06-01T10:00:00.000Z",
+ syncStatus: "synced",
+ });
+ seededTaskId = task.id;
+ await db.tasks.add(task);
+ },
+ );
+
+ When("system reveals hidden tasks", revealHiddenTasks);
+
+ Then(
+ 'the task has is_hidden false and syncStatus "pending"',
+ expectRevealedTaskUnhiddenAndPending,
+ );
+
+ And(
+ 'the task\'s updated_at is still "2026-06-01T10:00:00.000Z"',
+ expectRevealedTaskUpdatedAtPreserved,
+ );
+ },
+ );
+
+ // @fix-stale-sync-overwrites @FR1
+ f.Scenario(
+ "Reveal of an already-pending record does not degrade its state",
+ ({ Given, When, Then, And }) => {
+ Given(
+ 'a hidden task with appear_date "2026-06-15", updated_at "2026-06-01T10:00:00.000Z", syncStatus "pending" and today is "2026-06-15"',
+ async (_ctx: TestContext) => {
+ const clock = fakeClock("2026-06-15T12:00:00Z");
+ hiddenTaskService = new HiddenTaskService(
+ new TaskRepository(),
+ clock,
+ );
+ const task = buildTask({
+ is_hidden: true,
+ appear_date: "2026-06-15",
+ next_date: "2026-06-15",
+ updated_at: "2026-06-01T10:00:00.000Z",
+ syncStatus: "pending",
+ });
+ seededTaskId = task.id;
+ await db.tasks.add(task);
+ },
+ );
+
+ When("system reveals hidden tasks", revealHiddenTasks);
+
+ Then(
+ 'the task has is_hidden false and syncStatus "pending"',
+ expectRevealedTaskUnhiddenAndPending,
+ );
+
+ And(
+ 'the task\'s updated_at is still "2026-06-01T10:00:00.000Z"',
+ expectRevealedTaskUpdatedAtPreserved,
+ );
+ },
+ );
+
+ // @fix-stale-sync-overwrites @FR2
+ f.Scenario(
+ "Manual hide sets appear_date, refreshes updated_at, and marks pending for sync",
+ ({ Given, When, Then, And }) => {
+ Given(
+ 'a visible task with updated_at "2026-06-01T10:00:00.000Z" and syncStatus "synced"',
+ async (_ctx: TestContext) => {
+ const task = buildTask({
+ is_hidden: false,
+ appear_date: "",
+ updated_at: "2026-06-01T10:00:00.000Z",
+ syncStatus: "synced",
+ });
+ seededTaskId = task.id;
+ await db.tasks.add(task);
+ },
+ );
+
+ When(
+ 'the user manually hides the task with appear_date "2026-08-01"',
+ async (_ctx: TestContext) => {
+ updatedTask = await taskService.update(seededTaskId, {
+ is_hidden: true,
+ appear_date: "2026-08-01",
+ });
+ },
+ );
+
+ Then(
+ 'the task has is_hidden true and appear_date "2026-08-01"',
+ async (_ctx: TestContext) => {
+ expect(updatedTask.is_hidden).toBe(true);
+ expect(updatedTask.appear_date).toBe("2026-08-01");
+ },
+ );
+
+ And(
+ 'the task\'s updated_at is refreshed past "2026-06-01T10:00:00.000Z"',
+ async (_ctx: TestContext) => {
+ expect(updatedTask.updated_at).not.toBe("2026-06-01T10:00:00.000Z");
+ },
+ );
+
+ And('the task has syncStatus "pending"', async (_ctx: TestContext) => {
+ expect(updatedTask.syncStatus).toBe("pending");
+ });
+ },
+ );
+
+ // @fix-stale-sync-overwrites @FR2
+ f.Scenario(
+ "Manual unhide before appear date clears appear_date, refreshes updated_at, and marks pending for sync",
+ ({ Given, When, Then, And }) => {
+ Given(
+ 'a hidden task with appear_date "2026-08-01", updated_at "2026-06-01T10:00:00.000Z" and syncStatus "synced"',
+ async (_ctx: TestContext) => {
+ const task = buildTask({
+ is_hidden: true,
+ appear_date: "2026-08-01",
+ updated_at: "2026-06-01T10:00:00.000Z",
+ syncStatus: "synced",
+ });
+ seededTaskId = task.id;
+ await db.tasks.add(task);
+ },
+ );
+
+ When("the user manually unhides the task", async (_ctx: TestContext) => {
+ updatedTask = await taskService.update(seededTaskId, {
+ is_hidden: false,
+ appear_date: "",
+ });
+ });
+
+ Then(
+ 'the task has is_hidden false and appear_date ""',
+ async (_ctx: TestContext) => {
+ expect(updatedTask.is_hidden).toBe(false);
+ expect(updatedTask.appear_date).toBe("");
+ },
+ );
+
+ And(
+ 'the task\'s updated_at is refreshed past "2026-06-01T10:00:00.000Z"',
+ async (_ctx: TestContext) => {
+ expect(updatedTask.updated_at).not.toBe("2026-06-01T10:00:00.000Z");
+ },
+ );
+
+ And('the task has syncStatus "pending"', async (_ctx: TestContext) => {
+ expect(updatedTask.syncStatus).toBe("pending");
+ });
+ },
+ );
+});
diff --git a/packages/integration/src/tests/attachments-purge-cleanup.spec.ts b/packages/integration/src/tests/attachments-purge-cleanup.spec.ts
index 63700330..ab80dc17 100644
--- a/packages/integration/src/tests/attachments-purge-cleanup.spec.ts
+++ b/packages/integration/src/tests/attachments-purge-cleanup.spec.ts
@@ -4,13 +4,16 @@
import { expect, test } from "@playwright/test";
import {
attachFileToEntity,
- createGoal,
- createTask,
deleteFirstAttachment,
+} from "../page-attachment-actions.js";
+import {
+ createGoal,
navigateToGoals,
openGoalDetail,
- openTaskDetail,
-} from "../page-actions.js";
+ removeCoverFromGoal,
+ uploadCoverToGoal,
+} from "../page-goal-category-actions.js";
+import { createTask, openTaskDetail } from "../page-task-actions.js";
import {
createMinimalPng,
getFileFromServer,
@@ -33,32 +36,22 @@ test("14.12: two goals same cover -> remove one -> file stays -> remove both ->
const goalNameY = `Cover Goal Y ${Date.now()}`;
// Create two goals with the same cover image
- await navigateToGoals(page);
- await createGoal(page, goalNameX);
- await openGoalDetail(page, goalNameX);
- await page.getByTestId("edit-goal-button").click();
- await page.getByTestId("cover-file-input").setInputFiles({
+ const sharedCoverFile = {
name: "cover-shared.png",
mimeType: "image/png",
buffer: pngBuffer,
- });
- await page.getByTestId("cover-preview-img").waitFor({ state: "visible" });
- await page.getByTestId("goal-save-button").click();
- await page.getByTestId("edit-goal-button").waitFor({ state: "visible" });
+ };
+
+ await navigateToGoals(page);
+ await createGoal(page, goalNameX);
+ await openGoalDetail(page, goalNameX);
+ await uploadCoverToGoal(page, sharedCoverFile);
await triggerSyncAndWait(page);
await navigateToGoals(page);
await createGoal(page, goalNameY);
await openGoalDetail(page, goalNameY);
- await page.getByTestId("edit-goal-button").click();
- await page.getByTestId("cover-file-input").setInputFiles({
- name: "cover-shared.png",
- mimeType: "image/png",
- buffer: pngBuffer,
- });
- await page.getByTestId("cover-preview-img").waitFor({ state: "visible" });
- await page.getByTestId("goal-save-button").click();
- await page.getByTestId("edit-goal-button").waitFor({ state: "visible" });
+ await uploadCoverToGoal(page, sharedCoverFile);
await triggerSyncAndWait(page);
// Get the cover hash
@@ -73,10 +66,7 @@ test("14.12: two goals same cover -> remove one -> file stays -> remove both ->
// Remove cover from goal X
await navigateToGoals(page);
await openGoalDetail(page, goalNameX);
- await page.getByTestId("edit-goal-button").click();
- await page.getByTestId("cover-remove-button").click();
- await page.getByTestId("goal-save-button").click();
- await page.getByTestId("edit-goal-button").waitFor({ state: "visible" });
+ await removeCoverFromGoal(page);
await triggerSyncAndWait(page);
// File stays — goal Y still references it
@@ -87,10 +77,7 @@ test("14.12: two goals same cover -> remove one -> file stays -> remove both ->
// Remove cover from goal Y
await navigateToGoals(page);
await openGoalDetail(page, goalNameY);
- await page.getByTestId("edit-goal-button").click();
- await page.getByTestId("cover-remove-button").click();
- await page.getByTestId("goal-save-button").click();
- await page.getByTestId("edit-goal-button").waitFor({ state: "visible" });
+ await removeCoverFromGoal(page);
await triggerSyncAndWait(page);
// File gone immediately — covers don't go through soft-delete, 0 refs
diff --git a/packages/integration/src/tests/multi-device-conflicts.spec.ts b/packages/integration/src/tests/multi-device-conflicts.spec.ts
index 71ccbd1a..7d7a1f87 100644
--- a/packages/integration/src/tests/multi-device-conflicts.spec.ts
+++ b/packages/integration/src/tests/multi-device-conflicts.spec.ts
@@ -2,14 +2,16 @@
import { expect, type Locator, type Page, test } from "@playwright/test";
import {
createGoal,
- createTask,
- deleteTaskFromDetail,
navigateToGoals,
openGoalDetail,
- openTaskDetail,
updateGoalName,
+} from "../page-goal-category-actions.js";
+import {
+ createTask,
+ deleteTaskFromDetail,
+ openTaskDetail,
updateTaskName,
-} from "../page-actions.js";
+} from "../page-task-actions.js";
import {
pullFromServer,
setupTwoDeviceTest,
diff --git a/packages/integration/src/tests/multi-device-manual-unhide.spec.ts b/packages/integration/src/tests/multi-device-manual-unhide.spec.ts
new file mode 100644
index 00000000..e4a2b46f
--- /dev/null
+++ b/packages/integration/src/tests/multi-device-manual-unhide.spec.ts
@@ -0,0 +1,150 @@
+// implements U2, UX3, FR2, NFR-REL1 of fix-stale-sync-overwrites
+import { expect, test } from "@playwright/test";
+import {
+ createTask,
+ findTaskItem,
+ hideTaskUntil,
+ openTaskDetail,
+ unhideTask,
+} from "../page-task-actions.js";
+import {
+ assertConverged,
+ pullFromServer,
+ setupTwoDeviceTest,
+ triggerSyncAndWait,
+} from "../test-helpers.js";
+import {
+ gotoTasksPage,
+ TASK_VISIBLE_TIMEOUT_MS,
+} from "./stale-recurring-helpers.js";
+
+const { getPageA, getPageB, getCredentials } = setupTwoDeviceTest();
+
+// Far enough in the future that the manual unhide is unambiguously "early" —
+// no need for clock tricks since manual unhide works regardless of whether
+// appear_date has passed (that's the whole point of FR2 vs FR1 auto-reveal).
+const FUTURE_APPEAR_DATE_DAYS_AHEAD = 30;
+
+interface ManualUnhidePullResponse {
+ ok: boolean;
+ tasks: Array<{
+ id: string;
+ name: string;
+ is_deleted: boolean;
+ is_hidden: boolean;
+ appear_date: string;
+ updated_at: string;
+ }>;
+ current_revision: number;
+}
+
+function futureIsoDate(daysAhead: number): string {
+ const date = new Date();
+ date.setUTCDate(date.getUTCDate() + daysAhead);
+ return date.toISOString().slice(0, 10);
+}
+
+/**
+ * U2 / FR2 / NFR-REL1 — manual unhide before appear_date propagates to
+ * another device.
+ *
+ * Scenario:
+ * 1. Device A creates a non-recurring task; both devices sync and converge
+ * on a baseline (B sees the task).
+ * 2. Device A hides the task with a future appear_date, then pushes. The
+ * server shows `is_hidden: true` with that appear_date.
+ * 3. Device B pulls — the task is now hidden on B too (no longer in B's
+ * active task list).
+ * 4. Device A manually unhides the task EARLY (well before appear_date),
+ * then pushes. The server shows `is_hidden: false`, `appear_date` cleared
+ * to `""`, and `updated_at` refreshed past its pre-unhide value — manual
+ * unhide is a real user edit (FR2), unlike auto-reveal (FR1).
+ * 5. Device B pulls — the task is visible again on B (UX3).
+ * 6. Convergence: device A, device B, and the server agree (NFR-REL1).
+ */
+test("A manually unhides a task before appear_date → B sees it visible again after sync", async () => {
+ const pageA = getPageA();
+ const pageB = getPageB();
+ const credentials = getCredentials();
+ const taskName = `Manual Unhide Task ${Date.now()}`;
+ const futureAppearDate = futureIsoDate(FUTURE_APPEAR_DATE_DAYS_AHEAD);
+
+ const baseline = await pullFromServer(credentials);
+ const baselineRevision = baseline.current_revision;
+
+ // --- 1. A creates a task; both devices converge on a baseline ---
+ await gotoTasksPage(pageA);
+ await createTask(pageA, taskName);
+ await triggerSyncAndWait(pageA);
+
+ await gotoTasksPage(pageB);
+ await triggerSyncAndWait(pageB);
+ await findTaskItem(pageB, taskName).waitFor({
+ state: "visible",
+ timeout: TASK_VISIBLE_TIMEOUT_MS,
+ });
+
+ // --- 2. A hides the task with a future appear_date, then pushes ---
+ await openTaskDetail(pageA, taskName);
+ await hideTaskUntil(pageA, futureAppearDate);
+ await triggerSyncAndWait(pageA);
+
+ const { tasks: tasksAfterHide } =
+ await pullFromServer(
+ credentials,
+ baselineRevision,
+ );
+ const hiddenOnServer = tasksAfterHide.find(
+ (task) => task.name === taskName && !task.is_deleted,
+ );
+ expect(hiddenOnServer).toBeDefined();
+ expect(hiddenOnServer?.is_hidden).toBe(true);
+ expect(hiddenOnServer?.appear_date).toBe(futureAppearDate);
+ const updatedAtAfterHide = hiddenOnServer?.updated_at ?? "";
+
+ // --- 3. B pulls — task is now hidden on B too ---
+ await triggerSyncAndWait(pageB);
+ await gotoTasksPage(pageB);
+ await expect(findTaskItem(pageB, taskName)).toHaveCount(0);
+
+ // --- 4. A manually unhides the task EARLY (before appear_date), pushes ---
+ await gotoTasksPage(pageA);
+ // The task is hidden (future appear_date), so it is filtered out of the
+ // active list. Reveal hidden tasks via the command-bar eye toggle before
+ // opening its detail panel.
+ await pageA.getByTestId("command-bar-eye-toggle").click();
+ await findTaskItem(pageA, taskName).waitFor({
+ state: "visible",
+ timeout: TASK_VISIBLE_TIMEOUT_MS,
+ });
+ await openTaskDetail(pageA, taskName);
+ await unhideTask(pageA);
+ await triggerSyncAndWait(pageA);
+
+ const { tasks: tasksAfterUnhide } =
+ await pullFromServer(
+ credentials,
+ baselineRevision,
+ );
+ const unhiddenOnServer = tasksAfterUnhide.find(
+ (task) => task.name === taskName && !task.is_deleted,
+ );
+ expect(unhiddenOnServer).toBeDefined();
+ expect(unhiddenOnServer?.is_hidden).toBe(false);
+ expect(unhiddenOnServer?.appear_date).toBe("");
+ expect(unhiddenOnServer?.updated_at).not.toBe(updatedAtAfterHide);
+ expect(Date.parse(unhiddenOnServer?.updated_at ?? "")).toBeGreaterThan(
+ Date.parse(updatedAtAfterHide),
+ );
+
+ // --- 5. B pulls — task is visible again (UX3) ---
+ await triggerSyncAndWait(pageB);
+ await gotoTasksPage(pageB);
+ await findTaskItem(pageB, taskName).waitFor({
+ state: "visible",
+ timeout: TASK_VISIBLE_TIMEOUT_MS,
+ });
+
+ // --- 6. Convergence: A, B, and the server agree (NFR-REL1) ---
+ await assertConverged(pageA, pageB, credentials, "tasks");
+});
diff --git a/packages/integration/src/tests/multi-device-recurring.spec.ts b/packages/integration/src/tests/multi-device-recurring.spec.ts
index f64f5338..2c97bed8 100644
--- a/packages/integration/src/tests/multi-device-recurring.spec.ts
+++ b/packages/integration/src/tests/multi-device-recurring.spec.ts
@@ -1,11 +1,17 @@
// implements FR14 of add-supabase-integration-tests
-import { expect, type Page, test } from "@playwright/test";
-import { createTask, findTaskItem, openTaskDetail } from "../page-actions.js";
+import { expect, test } from "@playwright/test";
+import { findTaskItem } from "../page-task-actions.js";
import {
pullFromServer,
setupTwoDeviceTest,
triggerSyncAndWait,
} from "../test-helpers.js";
+import {
+ completeTask,
+ createAfterCompletionRecurringTask,
+ createRecurringTask,
+ TASK_VISIBLE_TIMEOUT_MS,
+} from "./stale-recurring-helpers.js";
const { getPageA, getPageB, getCredentials } = setupTwoDeviceTest();
@@ -29,45 +35,6 @@ interface RecurringPullResponse {
current_revision: number;
}
-async function setFixedDailyRepeat(testPage: Page): Promise {
- await testPage
- .getByTestId("task-detail-panel")
- .getByRole("button", { name: /Repeat/ })
- .click();
- await testPage.getByTestId("repeat-type-fixed").waitFor({ state: "visible" });
- await testPage.getByTestId("repeat-type-fixed").click();
- await testPage
- .getByTestId("repeat-frequency-daily")
- .waitFor({ state: "visible" });
- await testPage.getByTestId("repeat-frequency-daily").click();
- await testPage.getByTestId("repeat-fixed-next").waitFor({ state: "visible" });
- await testPage.getByTestId("repeat-fixed-next").click();
- await testPage.getByTestId("repeat-apply").waitFor({ state: "visible" });
- await testPage.getByTestId("repeat-apply").click();
- await testPage.getByTestId("repeat-apply").waitFor({ state: "hidden" });
-}
-
-async function createRecurringTask(
- testPage: Page,
- taskName: string,
-): Promise {
- await createTask(testPage, taskName);
- await openTaskDetail(testPage, taskName);
- await setFixedDailyRepeat(testPage);
-}
-
-const TASK_VISIBLE_TIMEOUT_MS = 15_000;
-const COMPLETE_SETTLE_MS = 300;
-
-async function completeTask(testPage: Page, taskName: string): Promise {
- await findTaskItem(testPage, taskName)
- .getByRole("button", { name: /Complete task/i })
- .click();
- // Recurring tasks create a new occurrence with the same name immediately,
- // so we cannot wait for the task item to become hidden.
- await testPage.waitForTimeout(COMPLETE_SETTLE_MS);
-}
-
// State carried between sequential tests
let recurringTaskName: string;
@@ -218,29 +185,12 @@ test("after_completion recurring → complete → next_date = completed_at + del
);
const baselineRevision = baseline.current_revision;
- await createTask(pageA, afterCompletionTaskName);
- await openTaskDetail(pageA, afterCompletionTaskName);
-
// Set repeat: After Completion > 3 days
- await pageA
- .getByTestId("task-detail-panel")
- .getByRole("button", { name: /Repeat/ })
- .click();
- await pageA
- .getByTestId("repeat-type-after-completion")
- .waitFor({ state: "visible" });
- await pageA.getByTestId("repeat-type-after-completion").click();
- await pageA
- .getByTestId("repeat-delay-days-input")
- .waitFor({ state: "visible" });
- await pageA.getByTestId("repeat-delay-days-input").fill(String(delayDays));
- await pageA
- .getByTestId("repeat-after-completion-next")
- .waitFor({ state: "visible" });
- await pageA.getByTestId("repeat-after-completion-next").click();
- await pageA.getByTestId("repeat-apply").waitFor({ state: "visible" });
- await pageA.getByTestId("repeat-apply").click();
- await pageA.getByTestId("repeat-apply").waitFor({ state: "hidden" });
+ await createAfterCompletionRecurringTask(
+ pageA,
+ afterCompletionTaskName,
+ delayDays,
+ );
await completeTask(pageA, afterCompletionTaskName);
await triggerSyncAndWait(pageA);
diff --git a/packages/integration/src/tests/multi-device-stale-rebalance.spec.ts b/packages/integration/src/tests/multi-device-stale-rebalance.spec.ts
index b0201242..0f58aa40 100644
--- a/packages/integration/src/tests/multi-device-stale-rebalance.spec.ts
+++ b/packages/integration/src/tests/multi-device-stale-rebalance.spec.ts
@@ -18,6 +18,7 @@ import {
buildInboxTaskPayload,
type RebalancePullResponse,
} from "./stale-rebalance-helpers.js";
+import { TASK_VISIBLE_TIMEOUT_MS } from "./stale-recurring-helpers.js";
const { getPageA, getPageB, getCredentials } = setupTwoDeviceTest();
@@ -25,8 +26,6 @@ const { getPageA, getPageB, getCredentials } = setupTwoDeviceTest();
// (integration tests never import client internals — see module boundary rule).
const SORT_ORDER_REBALANCE_THRESHOLD = 10;
-const TASK_VISIBLE_TIMEOUT_MS = 15_000;
-
/**
* U3 / FR4 / NFR-REL1 — a rebalance triggered on a stale device does not
* clobber content that a different device edited more recently.
diff --git a/packages/integration/src/tests/multi-device-stale-recurring-after-completion.spec.ts b/packages/integration/src/tests/multi-device-stale-recurring-after-completion.spec.ts
new file mode 100644
index 00000000..54d261fe
--- /dev/null
+++ b/packages/integration/src/tests/multi-device-stale-recurring-after-completion.spec.ts
@@ -0,0 +1,116 @@
+// implements U1, UX1, FR6, NFR-REL1 of fix-stale-sync-overwrites
+import { expect, test } from "@playwright/test";
+import { pullFromServer, setupTwoDeviceTest } from "../test-helpers.js";
+import {
+ runStaleRevealConvergence,
+ runStaleRevealSetup,
+} from "./stale-recurring-flow.js";
+import {
+ AFTER_COMPLETION_DELAY_DAYS,
+ advanceClockPastDate,
+ createAfterCompletionRecurringTask,
+ type StaleRecurringPullResponse,
+} from "./stale-recurring-helpers.js";
+
+const { getPageA, getPageB, getCredentials } = setupTwoDeviceTest();
+
+/**
+ * U1 / FR6 — the reported bug, `after_completion` recurring model.
+ *
+ * Same shape as the `fixed` model scenario in
+ * multi-device-stale-recurring-fixed.spec.ts, with one key difference: for
+ * `after_completion`, the new occurrence's `next_date` is derived only from
+ * `completed_at`'s local date + `delay_days` (never from `updated_at`), and
+ * (with the default `advance_days: 0`) `appear_date` equals `next_date`
+ * exactly — see `calculateNextDate`/`calculateAppearDate` in
+ * `repeatRule.ts`. This test additionally asserts (FR6, regression guard)
+ * that the surviving occurrence's `next_date` matches that formula exactly,
+ * proving the stale-device merge/reveal machinery did not corrupt the
+ * recurrence math for this model.
+ *
+ * Scenario:
+ * 1. Device A creates an `after_completion` recurring task (delay_days =
+ * `AFTER_COMPLETION_DELAY_DAYS`); both devices sync and converge
+ * (baseline).
+ * 2. Device B goes stale from this point on.
+ * 3. Device A completes the task (creating a new hidden occurrence whose
+ * `appear_date` = `next_date` = completion date + delay_days, genuinely
+ * in the future) AND edits the description, then pushes.
+ * 4. Device B comes back online; its browser clock is fast-forwarded past
+ * the new occurrence's `appear_date`.
+ * 5. B pulls A's changes, auto-reveals the incoming hidden occurrence
+ * without bumping `updated_at` (FR1), pushes, and pulls once more.
+ * 6. Convergence: both devices and the server agree — the completed
+ * occurrence stays completed, the surviving new occurrence carries A's
+ * newest description (FR3 dedup merge, FR5 LWW pull protection), and its
+ * `next_date` is exactly `completed_at` + `delay_days` — unaffected by
+ * the stale-device dance (FR6).
+ */
+test("A completes + edits recurring task, stale B auto-reveals on reconnect → both devices converge (after_completion model)", async () => {
+ // Genuinely heavy: two devices, clock skew, auto-reveal, dedup merge and
+ // several push/pull round-trips. Grant the extended (slow) timeout budget.
+ test.slow();
+ const pageA = getPageA();
+ const pageB = getPageB();
+ const credentials = getCredentials();
+ const taskName = `Stale Reveal After Completion ${Date.now()}`;
+ const freshDescription = `Freshest description from A ${Date.now()}`;
+
+ // Baseline revision so we only inspect records created by this test.
+ const baseline =
+ await pullFromServer(credentials);
+ const baselineRevision = baseline.current_revision;
+
+ // --- 1. A creates an after_completion recurring task ---
+ await createAfterCompletionRecurringTask(
+ pageA,
+ taskName,
+ AFTER_COMPLETION_DELAY_DAYS,
+ );
+
+ // --- 2-4. Both converge, B goes stale, A edits + completes + pushes ---
+ const { completedOnServer, newOccurrenceOnServer } =
+ await runStaleRevealSetup(
+ pageA,
+ pageB,
+ credentials,
+ taskName,
+ freshDescription,
+ baselineRevision,
+ );
+
+ // For after_completion, completing must stamp `completed_at`.
+ expect(completedOnServer.completed_at).not.toBe("");
+
+ // FR6 regression guard: next_date = completed_at's local date + delay_days,
+ // computed only from completed_at — never from updated_at.
+ const completedDate = new Date(completedOnServer.completed_at);
+ const expectedNextDate = new Date(completedDate);
+ expectedNextDate.setDate(
+ expectedNextDate.getDate() + AFTER_COMPLETION_DELAY_DAYS,
+ );
+ const expectedNextDateString = [
+ expectedNextDate.getFullYear(),
+ String(expectedNextDate.getMonth() + 1).padStart(2, "0"),
+ String(expectedNextDate.getDate()).padStart(2, "0"),
+ ].join("-");
+ expect(newOccurrenceOnServer.next_date).toBe(expectedNextDateString);
+ // advance_days defaults to 0, so appear_date === next_date exactly.
+ expect(newOccurrenceOnServer.appear_date).toBe(expectedNextDateString);
+
+ await advanceClockPastDate(pageB, newOccurrenceOnServer.appear_date);
+
+ // --- 5-6. B auto-reveals, all three converge ---
+ const finalNewOccurrence = await runStaleRevealConvergence(
+ pageA,
+ pageB,
+ credentials,
+ taskName,
+ freshDescription,
+ baselineRevision,
+ );
+
+ // FR6: the stale-device merge/reveal machinery did not corrupt the
+ // recurrence math — next_date is still exactly completed_at + delay_days.
+ expect(finalNewOccurrence.next_date).toBe(expectedNextDateString);
+});
diff --git a/packages/integration/src/tests/multi-device-stale-recurring-fixed.spec.ts b/packages/integration/src/tests/multi-device-stale-recurring-fixed.spec.ts
new file mode 100644
index 00000000..31ba62c0
--- /dev/null
+++ b/packages/integration/src/tests/multi-device-stale-recurring-fixed.spec.ts
@@ -0,0 +1,83 @@
+// implements U1, UX1, UX2, FR1, FR3, FR5, NFR-REL1, M1 of fix-stale-sync-overwrites
+import { test } from "@playwright/test";
+import { pullFromServer, setupTwoDeviceTest } from "../test-helpers.js";
+import {
+ runStaleRevealConvergence,
+ runStaleRevealSetup,
+} from "./stale-recurring-flow.js";
+import {
+ advanceClockPastDate,
+ createRecurringTask,
+ type StaleRecurringPullResponse,
+} from "./stale-recurring-helpers.js";
+
+const { getPageA, getPageB, getCredentials } = setupTwoDeviceTest();
+
+/**
+ * U1 / M1 — the reported bug, `fixed` recurring model.
+ *
+ * Scenario:
+ * 1. Device A creates a daily `fixed` recurring task; both devices sync and
+ * fully converge (baseline).
+ * 2. Device B goes stale from this point on — it does not sync again until
+ * step 5.
+ * 3. Device A completes the task (creating a new hidden occurrence — a
+ * fresh daily-fixed occurrence's `appear_date` is always tomorrow, so
+ * `is_hidden: true` locally on A too, which is expected/normal) AND edits
+ * the description on the completed task, then pushes. The completed
+ * occurrence and the new occurrence both carry A's latest description
+ * (the new occurrence is created from the original's current fields at
+ * completion time).
+ * 4. Device B — still on its stale pre-completion local copy — comes back
+ * online. Its browser clock is fast-forwarded past the new occurrence's
+ * `appear_date` (see `advanceClockPastDate`).
+ * 5. B pulls A's changes. The incoming hidden occurrence is now revealable
+ * on B (`appear_date <= B's today`), so `HiddenTaskService` auto-reveals
+ * it locally: `is_hidden: false`, `syncStatus: "pending"`, `updated_at`
+ * UNCHANGED (FR1) — this is the exact system-mutation-masquerading-as-
+ * edit that used to corrupt sync. B then pushes and pulls once more.
+ * 6. Convergence: both devices and the server agree — the completed
+ * occurrence stays completed, and both it and the surviving new
+ * occurrence carry A's newest description (FR3 dedup merge, FR5 LWW pull
+ * protection). No resurrection, no reverted description, no data loss.
+ */
+test("A completes + edits recurring task, stale B auto-reveals on reconnect → both devices converge (fixed model)", async () => {
+ // Genuinely heavy: two devices, clock skew, auto-reveal, dedup merge and
+ // several push/pull round-trips. Grant the extended (slow) timeout budget.
+ test.slow();
+ const pageA = getPageA();
+ const pageB = getPageB();
+ const credentials = getCredentials();
+ const taskName = `Stale Reveal Fixed ${Date.now()}`;
+ const freshDescription = `Freshest description from A ${Date.now()}`;
+
+ // Baseline revision so we only inspect records created by this test.
+ const baseline =
+ await pullFromServer(credentials);
+ const baselineRevision = baseline.current_revision;
+
+ // --- 1. A creates a daily fixed recurring task ---
+ await createRecurringTask(pageA, taskName);
+
+ // --- 2-4. Both converge, B goes stale, A edits + completes + pushes ---
+ const { newOccurrenceOnServer } = await runStaleRevealSetup(
+ pageA,
+ pageB,
+ credentials,
+ taskName,
+ freshDescription,
+ baselineRevision,
+ );
+
+ await advanceClockPastDate(pageB, newOccurrenceOnServer.appear_date);
+
+ // --- 5-6. B auto-reveals, all three converge ---
+ await runStaleRevealConvergence(
+ pageA,
+ pageB,
+ credentials,
+ taskName,
+ freshDescription,
+ baselineRevision,
+ );
+});
diff --git a/packages/integration/src/tests/stale-recurring-flow.ts b/packages/integration/src/tests/stale-recurring-flow.ts
new file mode 100644
index 00000000..421b754e
--- /dev/null
+++ b/packages/integration/src/tests/stale-recurring-flow.ts
@@ -0,0 +1,176 @@
+// implements U1, UX1, UX2, FR1, FR3, FR5, FR6, NFR-REL1 of fix-stale-sync-overwrites
+import { expect, type Page } from "@playwright/test";
+import {
+ findTaskItem,
+ openTaskDetail,
+ updateTaskDescription,
+} from "../page-task-actions.js";
+import {
+ assertConverged,
+ pullFromServer,
+ type ServerCallCredentials,
+ triggerSyncAndWait,
+} from "../test-helpers.js";
+import {
+ completeTask,
+ gotoTasksPage,
+ type StaleRecurringPullResponse,
+ syncAfterClockAdvance,
+ TASK_VISIBLE_TIMEOUT_MS,
+} from "./stale-recurring-helpers.js";
+
+type StaleRecurringTask = StaleRecurringPullResponse["tasks"][number];
+
+/**
+ * Shared setup phase of the stale-reveal specs (fixed / after_completion),
+ * covering steps 1-4 of both scenarios. The caller creates the recurring task
+ * (choosing the repeat model) BEFORE calling this; everything downstream is
+ * model-agnostic.
+ *
+ * 1. Device A syncs the freshly-created task; device B converges (baseline).
+ * 2. Device B goes stale — no more syncing until the convergence phase.
+ * 3. Device A edits the description, then completes the task, then pushes. The
+ * edit must precede completion: the new occurrence is cloned from the
+ * original's fields at completion time (see
+ * `TaskService.createRecurringCopy`), so both the completed occurrence and
+ * the new occurrence carry A's latest description. Waiting for the amber
+ * `pending` border guarantees the async blur-write landed in IndexedDB
+ * before completion reads the record.
+ * 4. Pull A's push from the server and locate the completed occurrence and the
+ * new hidden occurrence, asserting the invariants common to both models.
+ *
+ * Returns both server records so the caller can add model-specific assertions
+ * (e.g. `completed_at`, FR6 `next_date`) and advance B's clock past the new
+ * occurrence's `appear_date`.
+ *
+ * Implements U1, UX1, FR1, FR3, FR5 of fix-stale-sync-overwrites.
+ */
+export async function runStaleRevealSetup(
+ pageA: Page,
+ pageB: Page,
+ credentials: ServerCallCredentials,
+ taskName: string,
+ freshDescription: string,
+ baselineRevision: number,
+): Promise<{
+ completedOnServer: StaleRecurringTask;
+ newOccurrenceOnServer: StaleRecurringTask;
+}> {
+ await triggerSyncAndWait(pageA);
+
+ await gotoTasksPage(pageB);
+ await triggerSyncAndWait(pageB);
+ await pageB.reload();
+ await pageB.waitForSelector('[data-testid="active-tasks-page"]');
+ await findTaskItem(pageB, taskName).waitFor({
+ state: "visible",
+ timeout: TASK_VISIBLE_TIMEOUT_MS,
+ });
+
+ // --- 3. A edits the description, then completes the task, then pushes. ---
+ await gotoTasksPage(pageA);
+ await findTaskItem(pageA, taskName).waitFor({
+ state: "visible",
+ timeout: TASK_VISIBLE_TIMEOUT_MS,
+ });
+ await openTaskDetail(pageA, taskName);
+ await updateTaskDescription(pageA, freshDescription);
+ await expect(findTaskItem(pageA, taskName)).toHaveClass(/border-l-amber-400/);
+ await completeTask(pageA, taskName);
+ await triggerSyncAndWait(pageA);
+
+ // --- 4. B comes back online after (simulated) time has passed ---
+ const { tasks: tasksAfterCompletion } =
+ await pullFromServer(
+ credentials,
+ baselineRevision,
+ );
+ const matchingAfterCompletion = tasksAfterCompletion.filter(
+ (task) => task.name === taskName && !task.is_deleted,
+ );
+ const completedOnServer = matchingAfterCompletion.find(
+ (task) => task.is_completed,
+ );
+ expect(completedOnServer).toBeDefined();
+ expect(completedOnServer?.description).toBe(freshDescription);
+ const newOccurrenceOnServer = matchingAfterCompletion.find(
+ (task) =>
+ !task.is_completed && task.original_task_id === completedOnServer?.id,
+ );
+ expect(newOccurrenceOnServer).toBeDefined();
+ expect(newOccurrenceOnServer?.is_hidden).toBe(true);
+
+ return {
+ completedOnServer: completedOnServer as StaleRecurringTask,
+ newOccurrenceOnServer: newOccurrenceOnServer as StaleRecurringTask,
+ };
+}
+
+/**
+ * Shared convergence phase of the stale-reveal specs (fixed / after_completion),
+ * covering steps 5-6 of both scenarios. The caller must have advanced B's clock
+ * past the new occurrence's `appear_date` (via `advanceClockPastDate`) BEFORE
+ * calling this.
+ *
+ * 5. Device B pulls A's changes, auto-reveals the incoming hidden occurrence
+ * without bumping `updated_at` (FR1), pushes its reveal-only state, and
+ * pulls once more. Uses the clock-aware sync helper (see
+ * `syncAfterClockAdvance`). Device A then pulls B's reveal-push so all
+ * three agree.
+ * 6. Convergence: A, B, and the server agree (scoped to this test's task, since
+ * a clock-advanced device also reveals other specs' hidden occurrences on
+ * the shared user — harness noise). The completed occurrence stays completed
+ * (UX1) and exactly one surviving new occurrence carries A's newest
+ * description (UX2, FR3, FR5).
+ *
+ * Returns the single surviving new occurrence so the caller can add
+ * model-specific assertions (e.g. FR6 `next_date`).
+ *
+ * Implements UX1, UX2, FR1, FR3, FR5, NFR-REL1 of fix-stale-sync-overwrites.
+ */
+export async function runStaleRevealConvergence(
+ pageA: Page,
+ pageB: Page,
+ credentials: ServerCallCredentials,
+ taskName: string,
+ freshDescription: string,
+ baselineRevision: number,
+): Promise {
+ await syncAfterClockAdvance(pageB);
+ await syncAfterClockAdvance(pageB);
+
+ await triggerSyncAndWait(pageA);
+
+ await assertConverged(
+ pageA,
+ pageB,
+ credentials,
+ "tasks",
+ (record) => record.name === taskName,
+ );
+
+ const { tasks: finalTasks } =
+ await pullFromServer(
+ credentials,
+ baselineRevision,
+ );
+ const finalMatching = finalTasks.filter(
+ (task) => task.name === taskName && !task.is_deleted,
+ );
+
+ // The completed occurrence stays completed — no resurrection (UX1).
+ const finalCompleted = finalMatching.filter((task) => task.is_completed);
+ expect(finalCompleted).toHaveLength(1);
+ expect(finalCompleted[0]?.completed_at).not.toBe("");
+ expect(finalCompleted[0]?.description).toBe(freshDescription);
+
+ // Exactly one surviving new occurrence, carrying the newest description —
+ // no reverted description, no duplicate/deleted fresh copy (UX2).
+ const finalNewOccurrences = finalMatching.filter(
+ (task) => !task.is_completed && task.original_task_id !== "",
+ );
+ expect(finalNewOccurrences).toHaveLength(1);
+ expect(finalNewOccurrences[0]?.description).toBe(freshDescription);
+
+ return finalNewOccurrences[0] as StaleRecurringTask;
+}
diff --git a/packages/integration/src/tests/stale-recurring-helpers.ts b/packages/integration/src/tests/stale-recurring-helpers.ts
new file mode 100644
index 00000000..a3e30a46
--- /dev/null
+++ b/packages/integration/src/tests/stale-recurring-helpers.ts
@@ -0,0 +1,241 @@
+// implements U1, UX1, UX2, FR1, FR3, FR5, FR6 of fix-stale-sync-overwrites
+import type { Page } from "@playwright/test";
+import {
+ createTask,
+ findTaskItem,
+ openTaskDetail,
+} from "../page-task-actions.js";
+
+export interface StaleRecurringPullResponse {
+ ok: boolean;
+ tasks: Array<{
+ id: string;
+ name: string;
+ description: string;
+ is_deleted: boolean;
+ is_completed: boolean;
+ is_hidden: boolean;
+ repeat_rule: string;
+ next_date: string;
+ appear_date: string;
+ original_task_id: string;
+ completed_at: string;
+ updated_at: string;
+ }>;
+ current_revision: number;
+}
+
+const COMPLETE_SETTLE_MS = 300;
+export const TASK_VISIBLE_TIMEOUT_MS = 15_000;
+const SYNC_BUTTON_TEST_ID = "sidebar-sync";
+const LAST_SYNC_KEY = "last_sync";
+const POST_ADVANCE_SYNC_TIMEOUT_MS = 90_000;
+const POST_ADVANCE_SYNC_CLICK_WAIT_MS = 8_000;
+const POST_ADVANCE_SYNC_POLL_MS = 500;
+const POST_ADVANCE_SYNC_CLICK_TIMEOUT_MS = 5_000;
+const POST_ADVANCE_SYNC_IDLE_TIMEOUT_MS = 10_000;
+const POST_ADVANCE_SYNC_REVEAL_SETTLE_MS = 750;
+// >=2 days guarantees the new occurrence's appear_date (= completed_at's
+// local date + delay_days, since advance_days defaults to 0) is strictly
+// after "today" even accounting for test execution time near a day boundary.
+export const AFTER_COMPLETION_DELAY_DAYS = 2;
+
+const REPEAT_TYPE_FIXED_TEST_ID = "repeat-type-fixed";
+const REPEAT_TYPE_AFTER_COMPLETION_TEST_ID = "repeat-type-after-completion";
+const REPEAT_APPLY_TEST_ID = "repeat-apply";
+
+/** Opens the Repeat selector from the task detail panel and selects the given
+ * repeat-type option. Shared prologue of the fixed / after-completion flows. */
+async function openRepeatSelector(
+ testPage: Page,
+ repeatTypeTestId: string,
+): Promise {
+ await testPage
+ .getByTestId("task-detail-panel")
+ .getByRole("button", { name: /Repeat/ })
+ .click();
+ await testPage.getByTestId(repeatTypeTestId).waitFor({ state: "visible" });
+ await testPage.getByTestId(repeatTypeTestId).click();
+}
+
+/** Applies the configured repeat rule and waits for the selector to close.
+ * Shared epilogue of the fixed / after-completion flows. */
+async function applyRepeatRule(testPage: Page): Promise {
+ await testPage
+ .getByTestId(REPEAT_APPLY_TEST_ID)
+ .waitFor({ state: "visible" });
+ await testPage.getByTestId(REPEAT_APPLY_TEST_ID).click();
+ await testPage.getByTestId(REPEAT_APPLY_TEST_ID).waitFor({ state: "hidden" });
+}
+
+/** Sets up a daily `fixed` repeat rule from the task detail panel. Shared by
+ * the multi-device recurring and stale-reveal specs. */
+async function setFixedDailyRepeat(testPage: Page): Promise {
+ await openRepeatSelector(testPage, REPEAT_TYPE_FIXED_TEST_ID);
+ await testPage
+ .getByTestId("repeat-frequency-daily")
+ .waitFor({ state: "visible" });
+ await testPage.getByTestId("repeat-frequency-daily").click();
+ await testPage.getByTestId("repeat-fixed-next").waitFor({ state: "visible" });
+ await testPage.getByTestId("repeat-fixed-next").click();
+ await applyRepeatRule(testPage);
+}
+
+export async function createRecurringTask(
+ testPage: Page,
+ taskName: string,
+): Promise {
+ await createTask(testPage, taskName);
+ await openTaskDetail(testPage, taskName);
+ await setFixedDailyRepeat(testPage);
+}
+
+/**
+ * Sets up an `after_completion` repeat rule from the task detail panel, with
+ * the given `delayDays` and the default `advance_days` (0, left untouched on
+ * the placement step). Used by the multi-device-recurring spec's
+ * "after_completion recurring → complete → next_date = completed_at +
+ * delay_days" test and the stale-reveal specs. With `advance_days: 0`,
+ * `appear_date` equals `next_date` (see `calculateAppearDate`), so the new
+ * occurrence's `appear_date` is simply `completed_at`'s local date +
+ * `delayDays`.
+ */
+async function setAfterCompletionRepeat(
+ testPage: Page,
+ delayDays: number,
+): Promise {
+ await openRepeatSelector(testPage, REPEAT_TYPE_AFTER_COMPLETION_TEST_ID);
+ await testPage
+ .getByTestId("repeat-delay-days-input")
+ .waitFor({ state: "visible" });
+ await testPage.getByTestId("repeat-delay-days-input").fill(String(delayDays));
+ await testPage
+ .getByTestId("repeat-after-completion-next")
+ .waitFor({ state: "visible" });
+ await testPage.getByTestId("repeat-after-completion-next").click();
+ await applyRepeatRule(testPage);
+}
+
+export async function createAfterCompletionRecurringTask(
+ testPage: Page,
+ taskName: string,
+ delayDays: number,
+): Promise {
+ await createTask(testPage, taskName);
+ await openTaskDetail(testPage, taskName);
+ await setAfterCompletionRepeat(testPage, delayDays);
+}
+
+/**
+ * Navigates a device to the active tasks page and waits for it to render.
+ * Shared across the multi-device stale-reveal / manual-unhide specs so the
+ * two-line goto + wait pair is defined once.
+ */
+export async function gotoTasksPage(testPage: Page): Promise {
+ await testPage.goto("/tasks");
+ await testPage.waitForSelector('[data-testid="active-tasks-page"]');
+}
+
+export async function completeTask(
+ testPage: Page,
+ taskName: string,
+): Promise {
+ await findTaskItem(testPage, taskName)
+ .getByRole("button", { name: /Complete task/i })
+ .click();
+ // Recurring tasks create a new occurrence with the same name immediately,
+ // so we cannot wait for the task item to become hidden.
+ await testPage.waitForTimeout(COMPLETE_SETTLE_MS);
+}
+
+/**
+ * Simulates a stale device being opened a day later: sets the browser clock
+ * past `isoDate` (local noon) so day-boundary comparisons (`appear_date <=
+ * today`) treat that date as "today or earlier".
+ *
+ * Method matters. `setSystemTime` leaves the fake clock paused, freezing every
+ * `setTimeout`/`setInterval` so the sync button's spin→idle cycle never
+ * completes. `setFixedTime` pins `Date.now()` to a constant, so the
+ * `toISOTimestamp()`-based `last_sync` marker never changes and sync-completion
+ * detection never fires. `install({ time }) + resume` sets the clock to the
+ * target date AND lets it keep ticking in real time: `Date` reads the future
+ * (so `HiddenTaskService` reveals the occurrence), timers run (responsive UI),
+ * and `last_sync` advances between syncs. The backend issues long-lived JWTs
+ * (see `GOTRUE_JWT_EXP` in docker-compose) so the day-long jump never expires
+ * the token — avoiding a refresh that would race under the faked clock.
+ */
+export async function advanceClockPastDate(
+ page: Page,
+ isoDate: string,
+): Promise {
+ await page.clock.install({ time: new Date(`${isoDate}T12:00:00Z`) });
+ await page.clock.resume();
+}
+
+/** Waits (best-effort) for the sidebar sync button to stop spinning, so a
+ * subsequent click is not swallowed by the app's in-flight-sync mutex. */
+async function waitForSyncButtonIdle(page: Page): Promise {
+ await page
+ .waitForFunction(
+ () => {
+ const button = document.querySelector('[data-testid="sidebar-sync"]');
+ if (!button) return false;
+ const icon = button.querySelector("svg");
+ return icon !== null && !icon.classList.contains("animate-spin");
+ },
+ undefined,
+ { timeout: POST_ADVANCE_SYNC_IDLE_TIMEOUT_MS, polling: 200 },
+ )
+ .catch(() => {});
+}
+
+/**
+ * Syncs a page whose clock was advanced via `advanceClockPastDate`, detecting
+ * completion by polling the `last_sync` marker for a change rather than by
+ * watching the sync button's spin animation.
+ *
+ * Why not the shared `triggerSyncAndWait`: its success gate relies on catching
+ * a spin→idle cycle in a short window (missed after the boot JWT refresh), and
+ * its interval-clearing step calls `window.setTimeout` — faked by the installed
+ * clock — inside a `page.evaluate` that then hangs. Polling `last_sync`, which
+ * the resumed clock keeps advancing on every completed sync, sidesteps both.
+ *
+ * Each outer iteration: wait for any in-flight sync to settle (so the click is
+ * not swallowed by the app's `isSyncingRef` mutex), re-read the baseline, click
+ * (best-effort, with a per-click timeout so a briefly non-actionable button
+ * cannot block past the deadline), then poll for a change. On success settle
+ * briefly so the `sync_complete` reveal handler persists its pending write
+ * before the caller triggers the next (push) sync.
+ */
+export async function syncAfterClockAdvance(page: Page): Promise {
+ const deadline = Date.now() + POST_ADVANCE_SYNC_TIMEOUT_MS;
+ while (Date.now() < deadline) {
+ await waitForSyncButtonIdle(page);
+ const previousSync = await page.evaluate(
+ (key) => localStorage.getItem(key),
+ LAST_SYNC_KEY,
+ );
+ await page
+ .getByTestId(SYNC_BUTTON_TEST_ID)
+ .first()
+ .click({ timeout: POST_ADVANCE_SYNC_CLICK_TIMEOUT_MS })
+ .catch(() => {});
+ const clickDeadline = Date.now() + POST_ADVANCE_SYNC_CLICK_WAIT_MS;
+ while (Date.now() < clickDeadline) {
+ const currentSync = await page.evaluate(
+ (key) => localStorage.getItem(key),
+ LAST_SYNC_KEY,
+ );
+ if (currentSync !== null && currentSync !== previousSync) {
+ await new Promise((resolve) =>
+ setTimeout(resolve, POST_ADVANCE_SYNC_REVEAL_SETTLE_MS),
+ );
+ return;
+ }
+ await new Promise((resolve) =>
+ setTimeout(resolve, POST_ADVANCE_SYNC_POLL_MS),
+ );
+ }
+ }
+ throw new Error("syncAfterClockAdvance: last_sync did not advance in time");
+}
diff --git a/packages/integration/src/tests/tasks-sync.spec.ts b/packages/integration/src/tests/tasks-sync.spec.ts
index 5492a4ed..a5a6a56b 100644
--- a/packages/integration/src/tests/tasks-sync.spec.ts
+++ b/packages/integration/src/tests/tasks-sync.spec.ts
@@ -11,6 +11,7 @@ import {
setupSingleDeviceTest,
triggerSyncAndWait,
} from "../test-helpers.js";
+import { createRecurringTask } from "./stale-recurring-helpers.js";
const { getPage, getCredentials } = setupSingleDeviceTest();
@@ -114,36 +115,8 @@ test("create recurring task → push → verify repeat rule persisted", async ()
const page = getPage();
const recurringTaskName = `Recurring Task ${Date.now()}`;
- // Create a new task and open detail panel
- await createTask(page, recurringTaskName);
- await openTaskDetail(page, recurringTaskName);
-
- // Open the Repeat selector — the DrillDownRow button contains label "Repeat"
- await page
- .getByTestId("task-detail-panel")
- .getByRole("button", { name: /Repeat/ })
- .click();
-
- // Select "Fixed" repeat type — wait for it to appear first
- await page.getByTestId("repeat-type-fixed").waitFor({ state: "visible" });
- await page.getByTestId("repeat-type-fixed").click();
-
- // Choose daily frequency — wait for frequency options to render
- await page
- .getByTestId("repeat-frequency-daily")
- .waitFor({ state: "visible" });
- await page.getByTestId("repeat-frequency-daily").click();
-
- // Proceed to placement step
- await page.getByTestId("repeat-fixed-next").waitFor({ state: "visible" });
- await page.getByTestId("repeat-fixed-next").click();
-
- // Apply the repeat rule (saves and closes selector)
- await page.getByTestId("repeat-apply").waitFor({ state: "visible" });
- await page.getByTestId("repeat-apply").click();
-
- // Wait for the repeat selector to close — indicates save completed
- await page.getByTestId("repeat-apply").waitFor({ state: "hidden" });
+ // Create a task and set up a daily `fixed` repeat rule
+ await createRecurringTask(page, recurringTaskName);
// Trigger sync to push the recurring task to the server
await triggerSyncAndWait(page);
From 02cf6423ad5221db3e1f77503459664fc98be7ad Mon Sep 17 00:00:00 2001
From: oinsio <8233537+oinsio@users.noreply.github.com>
Date: Sun, 19 Jul 2026 15:53:00 +0500
Subject: [PATCH 6/8] implement fix-stale-sync-overwrites OpenSpec
---
...TaskDeduplicator.merge.schedule-and-box.test.ts | 14 ++++++++------
1 file changed, 8 insertions(+), 6 deletions(-)
diff --git a/packages/client/src/services/RecurringTaskDeduplicator.merge.schedule-and-box.test.ts b/packages/client/src/services/RecurringTaskDeduplicator.merge.schedule-and-box.test.ts
index 5a434d9f..0c8c5748 100644
--- a/packages/client/src/services/RecurringTaskDeduplicator.merge.schedule-and-box.test.ts
+++ b/packages/client/src/services/RecurringTaskDeduplicator.merge.schedule-and-box.test.ts
@@ -5,6 +5,7 @@ import { db } from "@/db/database";
import { buildTask } from "@/test/factories/taskFactory";
import {
createRecurringTaskDeduplicatorMergeSetup,
+ expectScheduleTripleWithFreshContent,
ORIGINAL_ID_1,
UUID_FRESH_CONTENT_LOSER,
UUID_SCHEDULE_WINNER,
@@ -69,12 +70,13 @@ describe("RecurringTaskDeduplicator — content merge from freshest updated_at",
await getDeduplicator().deduplicate([ORIGINAL_ID_1]);
const survivor = await db.tasks.get(UUID_SCHEDULE_WINNER);
- expect(survivor?.next_date).toBe("2026-07-01");
- expect(survivor?.appear_date).toBe("2026-06-28");
- expect(survivor?.is_hidden).toBe(false);
- // Content still merges from the freshest copy in the same pass.
- expect(survivor?.name).toBe("Fresh name");
- expect(survivor?.description).toBe("Fresh description");
+ // Schedule triple stays coupled to the winner while content merges from
+ // the freshest copy in the same pass.
+ expectScheduleTripleWithFreshContent(survivor, {
+ nextDate: "2026-07-01",
+ appearDate: "2026-06-28",
+ isHidden: false,
+ });
});
// Spec scenario: "Hidden and revealed copies merge without breaking
From abe483868de3c28cded66dac58c97012bdb40a4c Mon Sep 17 00:00:00 2001
From: oinsio <8233537+oinsio@users.noreply.github.com>
Date: Sun, 19 Jul 2026 15:58:11 +0500
Subject: [PATCH 7/8] implement fix-stale-sync-overwrites OpenSpec
---
.../src/db/repositories/IdeaRepository.ts | 20 ++++++++++++++++++-
1 file changed, 19 insertions(+), 1 deletion(-)
diff --git a/packages/client/src/db/repositories/IdeaRepository.ts b/packages/client/src/db/repositories/IdeaRepository.ts
index 06fa3805..2d7f46a2 100644
--- a/packages/client/src/db/repositories/IdeaRepository.ts
+++ b/packages/client/src/db/repositories/IdeaRepository.ts
@@ -2,6 +2,9 @@ import type { WireIdea } from "@clear-progress/contract";
import { ClientIdeaSchema } from "@/schemas/entities";
import type { Idea, ISOTimestamp } from "@/types/entities";
import { db } from "../database";
+import { shouldOverwritePendingLocalRecord } from "./applyServerRecordLww";
+
+const IDEA_ENTITY_NAME = "idea";
export class IdeaRepository {
async getAll(): Promise {
@@ -53,11 +56,26 @@ export class IdeaRepository {
return db.ideas.filter((idea) => idea.syncStatus === "pending").toArray();
}
+ /**
+ * Implements FR5 of fix-stale-sync-overwrites.
+ * A local `pending` record is overwritten only when the server record's
+ * `updated_at` is strictly newer (LWW pull protection). Local records with
+ * any other syncStatus, or no local record at all, are always overwritten.
+ */
async applyServerRecords(records: WireIdea[]): Promise {
await db.transaction("rw", db.ideas, async () => {
for (const serverRecord of records) {
const localRecord = await db.ideas.get(serverRecord.id);
- if (localRecord?.syncStatus !== "pending") {
+ const shouldWrite =
+ localRecord?.syncStatus !== "pending" ||
+ shouldOverwritePendingLocalRecord({
+ entityName: IDEA_ENTITY_NAME,
+ id: serverRecord.id,
+ localUpdatedAt: localRecord.updated_at,
+ serverUpdatedAt: serverRecord.updated_at,
+ });
+
+ if (shouldWrite) {
const idea: Idea = {
...serverRecord,
created_at: serverRecord.created_at as ISOTimestamp,
From 7612572ae9fad3cc9359f4346bbc9d833f8c6b83 Mon Sep 17 00:00:00 2001
From: oinsio <8233537+oinsio@users.noreply.github.com>
Date: Sun, 19 Jul 2026 17:36:59 +0500
Subject: [PATCH 8/8] archive fix-stale-sync-overwrites OpenSpec
---
.../.openspec.yaml | 0
.../design.md | 0
.../proposal.md | 0
.../specs/manual-task-hiding/spec.md | 0
.../specs/repeating-tasks/spec.md | 0
.../specs/sync-protocol/spec.md | 0
.../specs/tasks/spec.md | 0
.../tasks.md | 0
openspec/specs/manual-task-hiding/spec.md | 38 +++++++-
openspec/specs/repeating-tasks/spec.md | 91 ++++++++++++++++++-
openspec/specs/sync-protocol/spec.md | 42 ++++++++-
openspec/specs/tasks/spec.md | 29 +++++-
12 files changed, 188 insertions(+), 12 deletions(-)
rename openspec/changes/{fix-stale-sync-overwrites => archive/2026/07/2026-07-19-fix-stale-sync-overwrites}/.openspec.yaml (100%)
rename openspec/changes/{fix-stale-sync-overwrites => archive/2026/07/2026-07-19-fix-stale-sync-overwrites}/design.md (100%)
rename openspec/changes/{fix-stale-sync-overwrites => archive/2026/07/2026-07-19-fix-stale-sync-overwrites}/proposal.md (100%)
rename openspec/changes/{fix-stale-sync-overwrites => archive/2026/07/2026-07-19-fix-stale-sync-overwrites}/specs/manual-task-hiding/spec.md (100%)
rename openspec/changes/{fix-stale-sync-overwrites => archive/2026/07/2026-07-19-fix-stale-sync-overwrites}/specs/repeating-tasks/spec.md (100%)
rename openspec/changes/{fix-stale-sync-overwrites => archive/2026/07/2026-07-19-fix-stale-sync-overwrites}/specs/sync-protocol/spec.md (100%)
rename openspec/changes/{fix-stale-sync-overwrites => archive/2026/07/2026-07-19-fix-stale-sync-overwrites}/specs/tasks/spec.md (100%)
rename openspec/changes/{fix-stale-sync-overwrites => archive/2026/07/2026-07-19-fix-stale-sync-overwrites}/tasks.md (100%)
diff --git a/openspec/changes/fix-stale-sync-overwrites/.openspec.yaml b/openspec/changes/archive/2026/07/2026-07-19-fix-stale-sync-overwrites/.openspec.yaml
similarity index 100%
rename from openspec/changes/fix-stale-sync-overwrites/.openspec.yaml
rename to openspec/changes/archive/2026/07/2026-07-19-fix-stale-sync-overwrites/.openspec.yaml
diff --git a/openspec/changes/fix-stale-sync-overwrites/design.md b/openspec/changes/archive/2026/07/2026-07-19-fix-stale-sync-overwrites/design.md
similarity index 100%
rename from openspec/changes/fix-stale-sync-overwrites/design.md
rename to openspec/changes/archive/2026/07/2026-07-19-fix-stale-sync-overwrites/design.md
diff --git a/openspec/changes/fix-stale-sync-overwrites/proposal.md b/openspec/changes/archive/2026/07/2026-07-19-fix-stale-sync-overwrites/proposal.md
similarity index 100%
rename from openspec/changes/fix-stale-sync-overwrites/proposal.md
rename to openspec/changes/archive/2026/07/2026-07-19-fix-stale-sync-overwrites/proposal.md
diff --git a/openspec/changes/fix-stale-sync-overwrites/specs/manual-task-hiding/spec.md b/openspec/changes/archive/2026/07/2026-07-19-fix-stale-sync-overwrites/specs/manual-task-hiding/spec.md
similarity index 100%
rename from openspec/changes/fix-stale-sync-overwrites/specs/manual-task-hiding/spec.md
rename to openspec/changes/archive/2026/07/2026-07-19-fix-stale-sync-overwrites/specs/manual-task-hiding/spec.md
diff --git a/openspec/changes/fix-stale-sync-overwrites/specs/repeating-tasks/spec.md b/openspec/changes/archive/2026/07/2026-07-19-fix-stale-sync-overwrites/specs/repeating-tasks/spec.md
similarity index 100%
rename from openspec/changes/fix-stale-sync-overwrites/specs/repeating-tasks/spec.md
rename to openspec/changes/archive/2026/07/2026-07-19-fix-stale-sync-overwrites/specs/repeating-tasks/spec.md
diff --git a/openspec/changes/fix-stale-sync-overwrites/specs/sync-protocol/spec.md b/openspec/changes/archive/2026/07/2026-07-19-fix-stale-sync-overwrites/specs/sync-protocol/spec.md
similarity index 100%
rename from openspec/changes/fix-stale-sync-overwrites/specs/sync-protocol/spec.md
rename to openspec/changes/archive/2026/07/2026-07-19-fix-stale-sync-overwrites/specs/sync-protocol/spec.md
diff --git a/openspec/changes/fix-stale-sync-overwrites/specs/tasks/spec.md b/openspec/changes/archive/2026/07/2026-07-19-fix-stale-sync-overwrites/specs/tasks/spec.md
similarity index 100%
rename from openspec/changes/fix-stale-sync-overwrites/specs/tasks/spec.md
rename to openspec/changes/archive/2026/07/2026-07-19-fix-stale-sync-overwrites/specs/tasks/spec.md
diff --git a/openspec/changes/fix-stale-sync-overwrites/tasks.md b/openspec/changes/archive/2026/07/2026-07-19-fix-stale-sync-overwrites/tasks.md
similarity index 100%
rename from openspec/changes/fix-stale-sync-overwrites/tasks.md
rename to openspec/changes/archive/2026/07/2026-07-19-fix-stale-sync-overwrites/tasks.md
diff --git a/openspec/specs/manual-task-hiding/spec.md b/openspec/specs/manual-task-hiding/spec.md
index bcfe214c..3e7ab2c1 100644
--- a/openspec/specs/manual-task-hiding/spec.md
+++ b/openspec/specs/manual-task-hiding/spec.md
@@ -72,9 +72,45 @@ The system SHALL NOT show hide/unhide controls for tasks with a non-empty `repea
- **THEN** no "Hide until" row is displayed
### Requirement: Hidden tasks auto-reveal on appear date
-The system SHALL automatically set `is_hidden = false` when `appear_date <= logicalDate`. The task SHALL remain in its original box. Implements FR7, FR8 of hide-tasks.
+The system SHALL automatically set `is_hidden = false` when `appear_date <= logicalDate`. The task SHALL remain in its original box. Auto-reveal SHALL set `syncStatus = "pending"` but SHALL NOT modify `updated_at` — it is a system-derived transition, not a user edit; the timestamp stays at the last real user edit so a stale device cannot win last-write-wins against newer edits from another device. Implements FR1 of fix-stale-sync-overwrites (was: FR7, FR8 of hide-tasks).
#### Scenario: Task revealed when appear date arrives
- **WHEN** the logical date reaches or passes the task's `appear_date`
- **THEN** `HiddenTaskService.revealHiddenTasks()` sets `is_hidden = false`
- **AND** the task remains in its original box
+- **AND** the task's `updated_at` is unchanged and `syncStatus` is `"pending"`
+
+### Requirement: Manual unhide before appear date is a synced user edit
+Implements FR2 of fix-stale-sync-overwrites.
+
+When the user manually unhides a manually hidden task before its `appear_date`, the system SHALL treat it as a regular user edit: set `is_hidden = false`, clear `appear_date`, refresh `updated_at`, and set `syncStatus = "pending"`. The manual unhide SHALL propagate to other devices via ordinary push/pull and SHALL win last-write-wins against any older state of the record. Manual hide (setting `is_hidden = true` with an `appear_date`) SHALL behave symmetrically as a user edit. This applies to non-recurring tasks only — recurring tasks expose no manual hide/unhide controls (see "Hide action excluded for recurring tasks"); hidden recurring copies are governed solely by system auto-reveal.
+
+```mermaid
+sequenceDiagram
+ participant A as Device A
+ participant S as Server
+ participant B as Device B
+
+ Note over A: task hidden until 07-20 (both devices in sync)
+ Note over A: user manually unhides on 07-15
is_hidden=false, appear_date="",
updated_at=now, pending
+ A->>S: push (accepted, new revision)
+ B->>S: pull
+ S-->>B: task: is_hidden=false, appear_date=""
+ Note over B: local copy synced & older → overwritten
task visible on device B before 07-20
+ Note over B: reveal check ignores it
(appear_date empty)
+```
+
+#### Scenario: Manual unhide propagates to another device
+- **GIVEN** a task hidden until "2026-07-20" synced on devices A and B
+- **WHEN** the user manually unhides it on device A on "2026-07-15" and both devices sync
+- **THEN** the task is visible on device B with `appear_date = ""` before "2026-07-20"
+
+#### Scenario: Manual unhide refreshes the timestamp
+- **GIVEN** a hidden task with `updated_at = t1`
+- **WHEN** the user manually unhides it
+- **THEN** `is_hidden = false`, `appear_date = ""`, `updated_at > t1`, `syncStatus = "pending"`
+
+#### Scenario: Manual hide propagates to another device
+- **GIVEN** a visible task synced on devices A and B
+- **WHEN** the user hides it until "2026-08-01" on device A and both devices sync
+- **THEN** the task is hidden on device B with `appear_date = "2026-08-01"`
diff --git a/openspec/specs/repeating-tasks/spec.md b/openspec/specs/repeating-tasks/spec.md
index d0730ea9..f3efb019 100644
--- a/openspec/specs/repeating-tasks/spec.md
+++ b/openspec/specs/repeating-tasks/spec.md
@@ -406,14 +406,15 @@ A recurring copy MUST be created with is_hidden=true if appear_date is after the
- **THEN** calendar date from `clock.plainDateISO()` is used
### Requirement: System reveals hidden tasks when appear date arrives
-# implements FR11 of repeating-tasks-specs, FR4 of day-boundary
+# implements FR1 of fix-stale-sync-overwrites (was: FR11 of repeating-tasks-specs, FR4 of day-boundary)
-System MUST reveal hidden tasks (set is_hidden=false, mark for sync) when appear_date <= logical date. Reveal MUST be triggered on: app mount, day boundary transition (instead of midnight), sync_complete event, return from background (visibility change), and day boundary setting change.
+System MUST reveal hidden tasks (set is_hidden=false, syncStatus="pending") when appear_date <= logical date. Reveal MUST NOT modify `updated_at` — auto-reveal is a system-derived transition, not a user edit, and the record's timestamp SHALL remain that of the last real user edit so that last-write-wins conflict resolution cannot prefer a stale record over newer edits from another device. Reveal MUST be triggered on: app mount, day boundary transition (instead of midnight), sync_complete event, return from background (visibility change), and day boundary setting change.
#### Scenario: Reveal tasks whose appear_date has arrived
- **GIVEN** hidden task with appear_date "2026-01-15" and logical date is "2026-01-15"
- **WHEN** system runs reveal check
-- **THEN** task has is_hidden false, needsSync true
+- **THEN** task has is_hidden false, syncStatus "pending"
+- **AND** the task's `updated_at` is unchanged
#### Scenario: Do not reveal tasks whose appear_date is future
- **GIVEN** hidden task with appear_date "2026-01-20" and logical date is "2026-01-15"
@@ -432,6 +433,11 @@ System MUST reveal hidden tasks (set is_hidden=false, mark for sync) when appear
- **WHEN** user changes the day boundary setting
- **THEN** system immediately runs reveal check with recalculated logical date
+#### Scenario: Reveal of a stale copy loses push conflict to newer server state
+- **GIVEN** device B holds a recurring copy last edited at t2 that was completed on device A at t5 (t5 > t2)
+- **WHEN** device B auto-reveals the copy and pushes it with `updated_at = t2`
+- **THEN** the server responds `conflict` and device B applies the server record (completed, newest content)
+
### Requirement: System uses current timezone for all date calculations
# implements FR12 of repeating-tasks-specs
@@ -604,10 +610,57 @@ All date comments in skip-logic tests MUST accurately reflect the ISO day of wee
- **THEN** the comment is incorrect (2026-04-07 is Tuesday) and MUST be fixed
### Requirement: System deduplicates recurring copies after pull
-# implements FR1, FR2 of dedup-recurring-after-pull
+# implements FR3, FR6 of fix-stale-sync-overwrites (was: FR1, FR2 of dedup-recurring-after-pull)
After applying a pull batch, the system SHALL detect duplicate recurring copies — multiple non-completed, non-deleted tasks sharing the same `original_task_id`. Among duplicates, the system SHALL keep the winner by earliest `next_date`, tiebreak by lexicographically smallest `id`. Losers SHALL be soft-deleted with `syncStatus: "pending"`.
+The winner SHALL be merged, not kept verbatim:
+
+- Schedule fields `next_date`, `appear_date`, and `is_hidden` SHALL be taken **as a triple** from the copy with the earliest `next_date` (`appear_date` is coupled to `next_date` via `advance_days`, and `is_hidden` is derived from `appear_date`; mixing copies would corrupt reveal timing).
+- Content fields (`name`, `description`, `goal_id`, `context_id`, `category_id`) and the pair `box` + `sort_order` (sort keys only order within a box) SHALL be taken from the copy with the freshest `updated_at`.
+- Identity fields (`id`, `created_at`, `revision`) SHALL stay the winner's own. Completion fields need no merge rule: every copy in a duplicate group is non-completed by the group filter.
+- The merged winner's `updated_at` SHALL equal the freshest copy's `updated_at`; deduplication SHALL NOT refresh it to the current time. The winner SHALL be written with `syncStatus: "pending"` only when the merge actually changed it; an already-optimal winner is not rewritten.
+- When `repeat_rule` differs between the schedule winner and the freshest-`updated_at` copy, the copy with the freshest `updated_at` SHALL win wholesale (all fields including `next_date` and `appear_date`), because a rule change recomputes its dates under the new rule.
+
+Merge semantics apply identically to both recurring models (`fixed` and `after_completion`); the earliest-`next_date` winner rule for schedules is unchanged, and for `after_completion` any suboptimal winner self-corrects at the next completion because its next date derives only from the new `completed_at`.
+
+Two-device double completion of the same occurrence (fixed schedule):
+
+```mermaid
+sequenceDiagram
+ participant A as Device A
+ participant S as Server
+ participant B as Device B
+
+ Note over A: completes occurrence,
edits description ("v2")
clone A1: next_date=07-10, updated_at=t5
+ Note over B: completes same occurrence offline
clone B1: next_date=07-09, updated_at=t3
+ A->>S: push A1
+ B->>S: push B1
+ B->>S: pull
+ S-->>B: A1
+ Note over B: dedup group {A1, B1}
winner dates: B1 (next_date 07-09)
winner content: A1 ("v2", updated_at t5)
loser A1 soft-deleted
+ B->>S: push merged winner + deletion
+ A->>S: pull
+ S-->>A: merged copy: earliest date, newest content
+```
+
+Two-device double completion (`after_completion`, delay 3 days):
+
+```mermaid
+sequenceDiagram
+ participant A as Device A
+ participant S as Server
+ participant B as Device B
+
+ Note over A: completes at 07-01
clone A1: next_date=07-04
+ Note over B: completes at 07-02 offline
clone B1: next_date=07-05
+ A->>S: push A1
+ B->>S: push B1, then pull
+ S-->>B: A1
+ Note over B: dedup: winner dates from A1 (07-04),
content from freshest updated_at
+ Note over B: at next completion next_date is recomputed
from new completed_at — self-corrects
+```
+
#### Scenario: Two duplicates with same next_date — tiebreak by id
- **GIVEN** task Copy-A (id="aaa...", original_task_id="root", next_date="2026-07-01") and Copy-B (id="bbb...", original_task_id="root", next_date="2026-07-01"), both non-completed, non-deleted
- **WHEN** deduplication runs after pull
@@ -618,6 +671,36 @@ After applying a pull batch, the system SHALL detect duplicate recurring copies
- **WHEN** deduplication runs after pull
- **THEN** Copy-B is kept (earlier next_date) and Copy-A is soft-deleted
+#### Scenario: Winner takes content from the freshest copy
+- **GIVEN** Copy-A (next_date="2026-07-05", description="v2", updated_at="2026-07-02T10:00:00.000Z") and Copy-B (next_date="2026-07-01", description="v1", updated_at="2026-07-01T10:00:00.000Z"), same original_task_id
+- **WHEN** deduplication runs after pull
+- **THEN** the kept record has next_date "2026-07-01", appear_date from Copy-B, description "v2", and updated_at "2026-07-02T10:00:00.000Z"
+
+#### Scenario: Schedule fields always move as a triple
+- **GIVEN** duplicates where the earliest-next_date copy has appear_date derived from its own next_date and is_hidden derived from that appear_date
+- **WHEN** deduplication merges the winner
+- **THEN** next_date, appear_date, and is_hidden all come from the earliest-next_date copy (never mixed across copies)
+
+#### Scenario: Hidden and revealed copies merge without breaking reveal timing
+- **GIVEN** Copy-A revealed (is_hidden=false, next_date="2026-07-09", updated_at older) and Copy-B still hidden (is_hidden=true, next_date="2026-07-12", appear_date="2026-07-10", updated_at newer), same original_task_id and same repeat_rule
+- **WHEN** deduplication runs after pull
+- **THEN** the kept record has next_date "2026-07-09" and is_hidden false (from Copy-A) with content from Copy-B
+
+#### Scenario: Box and sort_order come from the freshest copy as a pair
+- **GIVEN** duplicates in different boxes with box-specific sort_order keys
+- **WHEN** deduplication merges the winner
+- **THEN** box and sort_order both come from the freshest-updated_at copy (never mixed across copies)
+
+#### Scenario: Already-optimal winner is not rewritten
+- **GIVEN** duplicates where the winner already carries both the earliest schedule triple and the freshest content, `syncStatus = "synced"`
+- **WHEN** deduplication runs after pull
+- **THEN** the winner record is not modified and keeps `syncStatus = "synced"`, while losers are soft-deleted
+
+#### Scenario: Differing repeat_rule — freshest copy wins wholesale
+- **GIVEN** Copy-A (repeat_rule=daily-1, next_date="2026-07-01", updated_at older) and Copy-B (repeat_rule=weekly-Mon, next_date="2026-07-06", updated_at newer), same original_task_id
+- **WHEN** deduplication runs after pull
+- **THEN** the kept record equals Copy-B in all fields including next_date and appear_date
+
#### Scenario: No duplicates — no action
- **GIVEN** only one non-completed, non-deleted task per original_task_id
- **WHEN** deduplication runs after pull
diff --git a/openspec/specs/sync-protocol/spec.md b/openspec/specs/sync-protocol/spec.md
index 5c289d76..6e63575b 100644
--- a/openspec/specs/sync-protocol/spec.md
+++ b/openspec/specs/sync-protocol/spec.md
@@ -274,14 +274,48 @@ The system SHALL fetch all records with `revision > since_revision` from the ser
- **AND** next sync re-fetches from the same starting revision without cursors
### Requirement: Pull protects local dirty records
-When applying server records from pull, the client SHALL NOT overwrite records that have `needsSync = true`.
+Implements FR5 of fix-stale-sync-overwrites.
+
+When applying server records from pull, the client SHALL compare timestamps for records that have `syncStatus = "pending"`: the server record SHALL overwrite the local pending record only when the server record's `updated_at` is strictly newer than the local `updated_at` (last-write-wins, mirroring push conflict resolution). When the local `updated_at` is equal or newer, the local pending record SHALL be preserved and pushed in the next sync. Records with `syncStatus = "synced"` or `"rejected"` SHALL be overwritten by the server version as before. Every overwrite of a pending record SHALL be logged with entity type, record id, and both timestamps.
+
+Multi-device convergence for the stale-device scenario this rule fixes (device B held a stale pending copy freshened by a system mutation):
+
+```mermaid
+sequenceDiagram
+ participant A as Device A
+ participant S as Server
+ participant B as Device B (stale)
+
+ A->>S: push: task edited + completed (updated_at = t5)
+ Note over B: auto-reveal marks task pending
WITHOUT refreshing updated_at (stays t2)
+ B->>S: push: pending task (updated_at = t2)
+ S-->>B: conflict (server updated_at t5 > t2)
+ B->>B: apply server_record, syncStatus = synced
+ B->>S: pull
+ S-->>B: records with revision > last_known
+ Note over B: pull LWW: server t5 > local t2
pending record would also be overwritten here
+ A->>S: pull
+ S-->>A: no stale resurrection — state converged
+```
#### Scenario: Clean local record is overwritten by server
-- **WHEN** pull returns a record that exists locally with `needsSync = false`
+- **WHEN** pull returns a record that exists locally with `syncStatus = "synced"`
- **THEN** local record is overwritten with server version
-#### Scenario: Dirty local record is preserved
-- **WHEN** pull returns a record that exists locally with `needsSync = true`
+#### Scenario: Pending local record loses to strictly newer server record
+- **GIVEN** a local record with `syncStatus = "pending"` and `updated_at = "2026-07-01T10:00:00.000Z"`
+- **WHEN** pull returns the same record with `updated_at = "2026-07-02T10:00:00.000Z"`
+- **THEN** local record is overwritten with the server version and `syncStatus` becomes `"synced"`
+- **AND** the overwrite is logged with entity type, id, and both timestamps
+
+#### Scenario: Pending local record with equal timestamp is preserved
+- **GIVEN** a local record with `syncStatus = "pending"` and `updated_at = "2026-07-01T10:00:00.000Z"`
+- **WHEN** pull returns the same record with `updated_at = "2026-07-01T10:00:00.000Z"`
+- **THEN** local record is NOT overwritten (will be pushed in next sync)
+
+#### Scenario: Pending local record with newer timestamp is preserved
+- **GIVEN** a local record with `syncStatus = "pending"` and `updated_at = "2026-07-03T10:00:00.000Z"`
+- **WHEN** pull returns the same record with `updated_at = "2026-07-02T10:00:00.000Z"`
- **THEN** local record is NOT overwritten (will be pushed in next sync)
#### Scenario: New server record is inserted
diff --git a/openspec/specs/tasks/spec.md b/openspec/specs/tasks/spec.md
index 9f5df276..a8792c6e 100644
--- a/openspec/specs/tasks/spec.md
+++ b/openspec/specs/tasks/spec.md
@@ -380,18 +380,41 @@ Swiping left on a task item MUST be cancelled immediately. No action SHALL be tr
- **THEN** translateX remains 0, no action is triggered
### Requirement: Lazy rebalancing when key exceeds threshold
-# implements FR9 of fractional-sort-order
+# implements FR4 of fix-stale-sync-overwrites (was: FR9 of fractional-sort-order)
-When a newly generated sort_order key exceeds 10 characters, the system MUST rebalance all tasks in the same box with evenly distributed keys preserving current display order.
+When a newly generated sort_order key exceeds 10 characters, the system MUST rebalance all tasks in the same box with evenly distributed keys preserving current display order. Rebalanced tasks SHALL be marked `syncStatus = "pending"` but their `updated_at` SHALL NOT be modified — rebalancing is a system-derived mutation, and refreshing the timestamps would let a stale device overwrite newer content of every task in the box via whole-record last-write-wins. Only the task the user actually dragged SHALL get a refreshed `updated_at` (in `reorderTasks`).
+
+Rebalance on a stale device no longer clobbers newer content of untouched tasks:
+
+```mermaid
+sequenceDiagram
+ participant A as Device A
+ participant S as Server
+ participant B as Device B (stale)
+
+ A->>S: push: task X edited (updated_at = t5)
+ Note over B: user drags task Y →
rebalance touches whole box
Y: updated_at=now,
others (incl. X): timestamps kept
+ B->>S: push: Y (fresh) + rebalanced records (old timestamps)
+ S-->>B: Y accepted, X → conflict (server t5 newer)
+ B->>B: apply server X (newest content, server sort key)
+ B->>S: pull
+ Note over A,B: X keeps its newest content everywhere,
new sort keys sync where no newer edit exists
+```
#### Scenario: Rebalancing triggered by long key
- **GIVEN** today box has tasks with deeply nested sort_order keys
- **WHEN** a drag-drop produces a key longer than 10 characters
- **THEN** all tasks in today box get fresh evenly distributed keys
- **AND** display order is preserved
-- **AND** all rebalanced tasks are marked needsSync=true
+- **AND** all rebalanced tasks are marked syncStatus="pending"
+- **AND** `updated_at` of every task except the dragged one is unchanged
#### Scenario: Rebalancing not triggered for short keys
- **GIVEN** today box has tasks with short sort_order keys
- **WHEN** a drag-drop produces a key of 4 characters
- **THEN** only the dragged task is updated
+
+#### Scenario: Rebalance on a stale device does not clobber newer content
+- **GIVEN** device B holds task X last synced at t2, while the server has X edited at t5 (t5 > t2)
+- **WHEN** device B rebalances the box (X gets a new sort key, `updated_at` stays t2) and pushes
+- **THEN** the server responds `conflict` for X and device B applies the server version with the newest content