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:
- Has cached data? → render it. If
isStale(last refresh failed or offline), show a subtleStalenessHintabove. The user keeps working. - No data + offline? → an offline empty state with retry.
- 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).