Skip to main content

Offline & sync

The app stays usable without a perfect connection. Two ambient signals drive this: connectivity (can we reach the network?) and per-store staleness (is cached data current?). Both are soft hints — they escalate to a real error only when a view has no usable data at all. This pairs with error handling.

Connectivity

@enode/core/connectivity is a module store seeded from navigator.onLine and updated by the online/offline events. apiRequest also feeds it real reachability: markOnline() on any response, markOffline() when it wraps a NetworkError/TimeoutError. So ordinary traffic is a free heartbeat — no polling. useIsOnline() reads it; the ambient OfflineBanner (mounted in the root layout) shows a soft pill while offline and a brief “Back online” on reconnect.

apiRequest also enforces a 15 s timeout (AbortController), so a stalled request becomes a TimeoutError (→ offline) instead of an endless spinner.

The store-status convention

@enode/core/store-kit unifies the previously ad-hoc per-store flags (lastLoadOK vs data|null + loadError vs per-entry status) into one shape. A store embeds createStoreStatus() and calls the transitions around its fetch:

const status = createStoreStatus();
// in loadX:
status.markLoading();
try { /* fetch */ status.markReady(/* hasData */ true); }
catch (e) { status.markError(e); }

Views read it via hooks:

const f = useFreshness(exercisesStatus);
// f: { status, hasData, isStale, isOffline, canWork }

canWork === hasData — the rule that encodes “a hint is usually enough; only a dataless view is an error”.

The view decision tree

For any data-driven view:

  1. Has cached data? → render it. If isStale (last refresh failed or offline), show a subtle StalenessHint above. The user keeps working.
  2. No data + offline? → an offline empty state with retry.
  3. No data + error? → a real error state (with the backend userMessage) plus retry / report.

The exercise picker (ExercisePickerDrawer) is the reference implementation: a failed refresh degrades to a hint over the cached list rather than a blocking error.

Adoption

The convention is applied to the view-driving stores (starting with exercises); other prefetch stores adopt it incrementally — embed createStoreStatus(), call the transitions, read with useFreshness. New stores should follow the same shape.

Offline training persistence

The store-status convention above is the read side. The write and durability side — crash-safe in-session persistence, a deferred upload outbox, and downloading a day for fully-offline training — lives in @enode/core/offline and is specified by ADR 0002 — offline training persistence:

  • Plan A persists the live session tree + draft to IndexedDB and queues the finished workout's wire payload before the network call, draining it when connectivity returns (so a finish never blocks on signal).
  • Plan B ("make the day offline-ready") caches the day's workouts and each athlete's exercise instances, and snapshots the global reference catalogues (metrics, text-content, equipments, exercise-variations, tags, display-bases) so the app can cold-start with no network. Reference stores now snapshot themselves on each successful load (reference-cache.ts) and fall back to the snapshot when their fetch fails. An athlete whose exercises aren't cached is flagged in the athlete picker and blocked (with an explanation) at session start.

Still a non-goal: caching media/Lottie assets for offline (the lazy useMedia path 404s without signal).