Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-07-29
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
# Design: Configurable Sync Timing

## Context

Sync timing is driven by two module-level constants consumed only in `SyncProvider.tsx`:
`SYNC_INTERVAL_MS` (5 min) at the periodic `setInterval` (line ~369) and `SYNC_DEBOUNCE_MS`
(15 s) inside `schedulePush` (line ~224). The app already has a first-class **synced settings**
mechanism (IndexedDB `settings` table + push/pull with LWW/local-dirty-wins), used today by
`accent_color`, `default_box`, `day_boundary`, and custom accents, plus a localStorage cache for
fast first-paint reads. This change turns the two timing constants into synced settings, reusing
that mechanism end-to-end. Driven by FR1–FR8 of the proposal.

## Goals / Non-Goals

**Goals:**
- Make both timing values user-configurable and cross-device synced (FR1, FR2, FR5).
- Apply changes at runtime without an app reload (FR3, FR4).
- Preserve exact current behavior by default (G3, M1).

**Non-Goals:**
- No backend, wire-schema, or IndexedDB-schema changes (NG3) — `settings` already stores arbitrary key/value.
- No configurable ping/refresh timing (NG1); no per-entity overrides (NG2); no master pause switch (NG4).

## Decisions

### D1: Store as synced settings, mirror `day_boundary`

Both values become entries in the `settings` store, added to `SYNCED_SETTING_KEYS`. `SettingsService`
gains typed getters (`getSyncIntervalMinutes`, `getAutoSyncDelaySeconds`) that parse, validate, and
default (invalid → default; no clamping — see D2). `useSettings` exposes read state + setters and mirrors writes into the localStorage cache
(`syncCache`) exactly as `dayBoundary` does, so `SyncProvider` reads correct timing before IndexedDB
finishes loading.

*Alternative considered:* localStorage-only preference (like theme/language). Rejected — the
requirement is explicit cross-device sync (G2), which the `settings` path already provides for free.

### D2: Values stored as strings; parse at the edge

Settings values are strings on the wire. We store minutes/seconds as decimal integer strings
(`"5"`, `"15"`, `""`). Parsing/clamping/defaulting happens in `SettingsService` getters, which return
either a positive number or a sentinel for "disabled/immediate". Empty string is a valid, meaningful
value (interval → disabled; delay → immediate), distinct from "absent" (→ default). Milliseconds
never appear in storage or UI — conversion (`* 60000` / `* 1000`) happens only where the timers are set.

Invalid values (non-numeric, non-integer, out-of-range) fall back to the **default** — they are never
clamped: clamping `"5000"` to 1440 would silently invent a value the user never chose. Self-healing is
read-only with respect to the settings store: the getter returns the default and removes the invalid
localStorage cache entry, but never rewrites the IndexedDB value (no `needsSync` write → no
self-inflicted sync cycle). An invalid stored value is replaced only by an explicit user write or a
valid pulled value (FR7).

### D3: Debounce reads current value via ref (T3)

`schedulePush` uses `setTimeout` afresh on every call, so we keep the effective delay in a
`delayMsRef` updated whenever the setting changes; `schedulePush` reads `delayMsRef.current` at
schedule time. A `0`/empty delay schedules `setTimeout(sync, 0)` — an immediate (next-tick) sync that
still coalesces synchronous bursts.

*Alternative considered:* recreate the debounce closure via `useCallback` deps. Works, but a ref
avoids re-subscribing every consumer of `schedulePush` and keeps the mutex/timer refs stable.

### D4: Periodic interval recreated by an effect keyed on the value (T2)

Unlike the debounce, the periodic sync is a long-lived `setInterval` created once. To apply changes
at runtime we move it into a `useEffect` keyed on the effective interval value: the cleanup clears the
old interval and the effect body creates a new one. When the value is "disabled" the effect creates
no interval (and clears any existing one). This is the one genuinely new control-flow wrinkle versus
the debounce.

*Alternative considered:* a self-rescheduling `setTimeout` chain that re-reads the value each tick.
Rejected — an effect keyed on the value is simpler, declarative, and matches existing SyncProvider
timer patterns; teardown on unmount already exists.

### D5: Constants become defaults, not sources of truth

`SYNC_INTERVAL_MS` / `SYNC_DEBOUNCE_MS` are retained and reused as the default when the setting is
absent, guaranteeing byte-for-byte current behavior (M1). New sibling constants:
`DEFAULT_SYNC_INTERVAL_MIN = 5`, `MIN/MAX_SYNC_INTERVAL_MIN = 1/1440`,
`DEFAULT_AUTO_SYNC_DELAY_SEC = 15`, `MIN/MAX_AUTO_SYNC_DELAY_SEC = 0/900`. No hardcoded literals in
logic (code-style rule). To avoid drift between the two representations of the same default, the ms
constants are redefined as derived values: `SYNC_INTERVAL_MS = DEFAULT_SYNC_INTERVAL_MIN * 60_000`,
`SYNC_DEBOUNCE_MS = DEFAULT_AUTO_SYNC_DELAY_SEC * 1000`.

### D6: UI reuses the `DayBoundarySection` pattern

A new `SyncTimingSection` renders two labeled integer inputs with unit suffixes, `aria-describedby`
help text, and a `SyncIndicator` per key, committing on blur/Enter with revert-on-invalid. It is
placed in `AccountSyncSection` above `ServerSection`. If persisting a committed value fails, the input
reverts to the last stored value and the failure is visibly indicated (UX5); the sync engine keeps
running on the previous timing.

### D7: Value propagation into SyncProvider (local writes and pull)

`SyncProvider` owns both timers and must learn about new values even when the settings UI is not
mounted, so it never depends on `useSettings` component state. It reads both settings itself
(localStorage cache synchronously at start-up, then `SettingsService`) and re-reads them when:

- the existing `sync_complete` event fires after a sync — this covers values arriving via **pull**
(FR3, FR4, G2, U4);
- a new `SYNC_TIMING_CHANGED_EVENT` (constant in `src/constants/index.ts`) is dispatched by the
`useSettings` setters after a local write, mirroring the existing `DAY_BOUNDARY_CHANGED_EVENT`
pattern.

The re-read updates `delayMsRef` (D3) and the state value driving the interval-recreation effect
(D4). Both listeners are removed on unmount.

*Alternative considered:* subscribing `useSettings` to `sync_complete` and pushing values down.
Rejected — `useSettings` state is per-component and absent while the settings page is closed; the
owner of the timers must be the subscriber.

## Consequences

Positive:
- Each device can be tuned to its workflow, while defaults keep today's behavior byte-for-byte (G3, M1).
- Reuses the existing settings sync path and the existing `sync_complete` event end-to-end — no new
infrastructure, schema, or protocol.
- Pulled values take effect without an app reload (U4) through the same re-read path as local writes.

Negative:
- `SyncProvider` gains two event subscriptions and a re-read path — more moving parts in an already
central component.
- Both timer lifecycles now depend on runtime state; tests must cover recreation and teardown, not
only creation.

## Risks / Trade-offs

- **Cross-device lag** → A changed value reaches another device only on its next pull; the new timing
"catches up" one sync cycle later. Acceptable and inherent to LWW sync.
- **Self-referential write triggers a push** → `useSettings` calls `schedulePush()` after every
write, including writing the timing settings themselves. `schedulePush` reads `delayMsRef.current`
synchronously, but the setter updates that ref asynchronously (via `SYNC_TIMING_CHANGED_EVENT` →
`reloadSyncTimingSettings`), so *this* push is scheduled with the *previous* delay; the new value
takes effect from the next push onward. The practical effect is a one-time timing lag on the
setting-write's own push (only noticeable when *lowering* the delay), never a correctness issue:
it is still exactly one sync per write, bounded by NFR-P1, and all subsequent edits use the new value.
- **Both disabled at once** (interval empty + delay empty/0) → Background sync then relies only on
mount, `online`, manual click, and debounced-after-edit. This is a valid user choice; UX3 help text
makes the consequence visible. Not a bug.
- **Stale/corrupt cached value at start-up** → Self-healing getters fall back to the default and
remove the bad cache entry (FR7), so the sync engine never receives `NaN`/negative timeouts.
- **Immediate delay hammering** → `0` ms still coalesces synchronous bursts via debounce reset; worst
case is one sync shortly after each distinct edit, which the mutex serializes.

## Migration Plan

No data migration. On first load after deploy, neither key exists → getters return defaults →
identical behavior. When the user first sets a value it is created with `needsSync` and propagates
normally. Rollback is safe: removing the feature reverts to the constants; any stored `sync_interval`
/`auto_sync_delay` settings become inert extra rows in the `settings` table (ignored).

## Open Questions

None — bounds and disable semantics are confirmed (interval 1–1440 min, empty = disabled; delay
0–900 s, 0/empty = immediate; defaults 5 min / 15 s).
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
# Configurable Sync Timing

## Why

Sync timing is currently hardcoded: the post-edit debounce is fixed at 15 seconds (`SYNC_DEBOUNCE_MS`) and the periodic background sync at 5 minutes (`SYNC_INTERVAL_MS`). Different users have different needs — some want near-instant persistence across devices, others want to conserve battery/network by syncing rarely. Making both delays user-configurable and synchronized across devices lets each person tune the app to their workflow without touching code.

## What Changes

- **ADDED** two user-configurable, cross-device-synced settings:
- `sync_interval` — periodic sync interval in **minutes** (default 5, range 1–1440, empty = periodic sync disabled).
- `auto_sync_delay` — post-edit debounce in **seconds** (default 15, range 0–900, 0/empty = sync immediately).
- **ADDED** a sync-timing controls group in the **Account & Sync** settings section, placed **above** the existing Server connection UI, ordered `sync_interval` then `auto_sync_delay`, each with a `SyncIndicator`.
- **MODIFIED** `SyncProvider` so the debounced push (T3) reads the current `auto_sync_delay` at schedule time, and the periodic interval (T2) is torn down and recreated when `sync_interval` changes (including when a new value arrives via pull).
- **MODIFIED** the existing constants `SYNC_INTERVAL_MS` / `SYNC_DEBOUNCE_MS` to become **default values** rather than the sole source of truth.
- No backend/protocol changes — both values are stored as ordinary synced settings (string key/value), reusing the existing settings sync path.

## Capabilities

### New Capabilities
- `sync-timing-settings`: The two synced timing preferences — their storage (IndexedDB `settings` + localStorage cache), defaults, integer bounds, validation/self-healing, disable semantics, and the Account & Sync UI controls.

### Modified Capabilities
- `sync-orchestration`: Triggers T2 (periodic sync) and T3 (debounced push) SHALL read their timing from the configurable settings instead of fixed constants; the periodic interval SHALL be recreated on change; an empty/disabled `sync_interval` SHALL suppress the periodic interval; a `0`/empty `auto_sync_delay` SHALL schedule an immediate sync.
- `settings-page-sections`: The Account & Sync section SHALL contain the sync-timing controls above the Server connection UI.

## Impact

- **Code (packages/client):**
- `src/constants/index.ts` — new keys in `STORAGE_KEYS`, new default/bounds constants, new `SYNC_TIMING_CHANGED_EVENT` constant, add both keys to `SYNCED_SETTING_KEYS`.
- `src/services/SettingsService.ts` — typed getters with defaults/validation for the two new settings.
- `src/hooks/useSettings.ts` — read + setters + localStorage cache (mirroring `dayBoundary`).
- `src/app/providers/SyncProvider.tsx` — dynamic debounce (ref), interval recreation effect, re-read of both settings on `sync_complete` (pull) and on the local timing-change event.
- `src/components/settings/AccountSyncSection.tsx` + new `SyncTimingSection.tsx` — UI controls.
- `src/locales/ru.json`, `src/locales/en.json` — i18n keys.
- **No changes** to backend, sync protocol wire schema, or IndexedDB schema (the `settings` table already stores arbitrary key/value pairs).

## Goals

- **G1**: Both sync-timing values are configurable from the UI and take effect without an app reload.
- **G2**: Both values synchronize across devices using the existing settings sync path (LWW, local-dirty-wins).
- **G3**: Defaults preserve today's behavior exactly (5 min interval, 15 s debounce) for users who never change them.

## Non-Goals

- **NG1**: Making ping-recovery timing (`PING_INTERVAL_MS`, `MAX_PING_ATTEMPTS`) or silent-refresh limits configurable.
- **NG2**: Per-entity or per-context sync-timing overrides.
- **NG3**: Any backend, wire-schema, or IndexedDB-schema changes.
- **NG4**: A global "pause all sync" master switch (empty values already disable the respective trigger).

## Users & Scenarios

- **U1**: A multi-device user lowers `auto_sync_delay` to 0 so edits propagate to their other devices almost immediately.
- **U2**: A battery/bandwidth-conscious user raises `sync_interval` to 60 min and relies mostly on manual/debounced sync.
- **U3**: A user clears `sync_interval` to disable periodic background sync entirely, keeping only start-up, `online`, manual, and debounced-after-edit syncs.
- **U4**: A user changes the interval on their laptop; on next pull their phone adopts the new interval without a reload.

## Requirements

### Functional

- **FR1**: The system SHALL store `sync_interval` (minutes) as a synced setting; default 5; accepted integer range 1–1440; empty value means "periodic sync disabled".
- **FR2**: The system SHALL store `auto_sync_delay` (seconds) as a synced setting; default 15; accepted integer range 0–900; `0` or empty means "sync immediately".
- **FR3**: The periodic sync trigger (T2) SHALL use the current `sync_interval`; when the value changes at runtime the interval SHALL be recreated with the new period; when the value is empty/disabled no periodic interval SHALL run.
- **FR4**: The debounced push trigger (T3) SHALL use the current `auto_sync_delay` at schedule time; a `0`/empty value SHALL schedule an immediate sync.
- **FR5**: Both settings SHALL be added to `SYNCED_SETTING_KEYS` and follow the existing settings sync path (push when dirty, LWW/local-dirty-wins on pull), with no backend changes.
- **FR6**: Both settings SHALL be cached in localStorage on write and read from cache on start-up for correct timing before IndexedDB finishes loading, defaulting to G3 defaults when absent.
- **FR7**: Invalid or corrupted stored values (non-numeric, non-integer, or out-of-range) SHALL self-heal to the default for both settings, never crashing the sync engine. Self-healing SHALL be read-only with respect to the settings store: the getter returns the default and removes the invalid localStorage cache entry, but SHALL NOT rewrite the stored value or trigger a sync. (Reverting UI input to the last valid value is covered by UX1.)
- **FR8**: The Account & Sync section SHALL render the two controls above the Server connection UI, ordered `sync_interval` then `auto_sync_delay`, each with a `SyncIndicator` for its key.

### Non-Functional

#### Performance
- **NFR-P1**: Changing either setting SHALL apply within one debounce/interval cycle and SHALL NOT trigger more than one sync cycle as a direct result of the write.

#### Accessibility
- **NFR-A1**: Each control SHALL have an associated `<label>`, `aria-describedby` help text, and be fully keyboard-operable (focus, type, Enter/blur to commit).

#### Responsive
- **NFR-R1**: The controls SHALL render correctly from 320px to 2560px viewport widths within the accordion section.

## UX Acceptance Criteria

- **UX1**: Inputs accept only integers within range; out-of-range or non-numeric entries revert to the last valid value on blur/Enter (empty is preserved where allowed).
- **UX2**: Units are shown next to each input ("min" / "sec") and never displayed in milliseconds.
- **UX3**: When `sync_interval` is empty, help text indicates periodic background sync is off; when `auto_sync_delay` is `0`/empty, help text indicates edits sync immediately.
- **UX4**: A `SyncIndicator` next to each control shows it is a cross-device synced setting.
- **UX5**: If persisting a changed value fails, the input reverts to the last stored value and the failure is visibly indicated; sync keeps running on the previous timing.

## UI States Matrix

| Network | Data | UI |
|-----------|---------------------------------|------------------------------------------------------------|
| Online | Settings loaded from IndexedDB | Inputs show stored values, `SyncIndicator` = synced |
| Online | Local edit not yet pushed | `SyncIndicator` = pending until next sync clears the flag |
| Offline | Settings cached in localStorage | Inputs editable, changes queued, `SyncIndicator` = pending |
| Loading | IndexedDB not yet read | Inputs show localStorage-cached values (or defaults) |
| Sync pull | Newer server value arrives | Input updates to server value (unless local pending wins) |
| Any | Setting write fails | Input reverts to last stored value, error feedback (UX5) |

## Behavior

See `features/sync_timing_settings.feature` and `features/sync_orchestration.feature` (tagged `@configurable-sync-timing`).

## Affected IA

No changes — new controls live inside the existing Settings > Account & Sync section.

## Success Metrics

- **M1**: With no user change, sync timing is byte-for-byte the current behavior (interval 300000 ms, debounce 15000 ms) — verified by unit tests.
- **M2**: Changing `sync_interval` at runtime recreates the periodic interval with the new period (asserted in tests); no app reload required.
- **M3**: Both settings round-trip through push/pull with LWW/local-dirty-wins — verified by settings sync tests.
- **M4**: Mutation score ≥95% (min 90%) on new domain/service code.

## Open Questions

- None outstanding — bounds and disable semantics confirmed (interval 1–1440 min, empty disables; delay 0–900 s, 0/empty = immediate).
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
## MODIFIED Requirements

### Requirement: Account & Sync section contents

The Account & Sync section SHALL contain the sync-timing controls (periodic sync interval, then auto sync delay) followed by the server connection UI (existing ServerSection component). The sync-timing controls SHALL appear above the server connection UI. # implements FR8 of configurable-sync-timing

#### Scenario: Account & Sync section displays server connection
- **WHEN** the Account & Sync section is expanded
- **THEN** it displays the server connection interface

#### Scenario: Sync-timing controls appear above server connection
- **WHEN** the Account & Sync section is expanded
- **THEN** the periodic sync interval control and the auto sync delay control are displayed above the server connection interface
- **AND** the periodic sync interval control appears before the auto sync delay control
Loading
Loading