Table of Contents
08 — Active timers and durable saves
Technical reference. For task-based help, see the corresponding user guide.
Documentation status: complete for the current implementation
Reviewed: 2026-07-21
Primary users: family members; assigned coaches for in-app timers
Surfaces: mobile web app, native wrapper and platform API
Purpose
Active timers let care continue while the user navigates the app, reloads the WebView or briefly loses connectivity. Stopping a timer creates a durable review draft rather than immediately inventing a final activity. Idempotent activity creation, a persistent offline outbox and server reconciliation prevent lost or duplicate logs.
Timers are available for Breastfeeding, Sleep and Pumping only.
User workflow
Start and continue a timer
- Choose a timer quick action from the relevant tracker; Breastfeeding and Pumping also preselect left/right.
- The app creates a UUID-backed timer locally immediately, recording family subject, baby, activity type, start time, timezone, editable details and note.
- It then upserts the same timer to the platform. If the network request cannot complete, the pending upsert remains locally recoverable and retries.
- Navigating away does not stop it. Up to three running timers appear as draggable floating bubbles; selecting a bubble returns to the correct family, baby, tracker and timer.
- Reload/login summary and realtime updates reconcile the local list with the authoritative server list.
There is one canonical active timer for a given family/baby/activity-type scope. Different timer types and different babies may run concurrently. Reconciliation prefers the authoritative/latest timer for a duplicated scope while protecting a newer unsynced local start.
Stop for review
Selecting Stop removes the running timer from the active UI immediately and records a six-hour local tombstone so an older summary cannot resurrect it. The app retains a stopped draft locally and sends POST /active-timers/:id/stop.
The platform atomically removes the active timer and stores a stopped draft containing the authoritative start/stop values, details, note and timezone. If the stop request is offline, the client retries it when connectivity returns. On reload, local and server stopped drafts are merged by timer ID.
The user then reviews the normal activity form:
- Save creates the final activity with both
activeTimerIdand a UUIDclientMutationId; - Discard deletes the unresolved timer/draft without creating an activity;
- leaving the page keeps the stopped draft available to resume later.
Durable final save
The platform handles final activity creation and timer closure as one idempotent operation. Repeating a timed-out save with the same mutation ID returns the existing result instead of producing a duplicate. Once confirmed, the app clears the stopped draft and refreshes Home/history.
If offline, the create moves into the activity outbox. The outbox suppresses the matching timer draft while it is pending so one care event is not displayed as both an unresolved timer and a queued activity.
Offline activity outbox
The persistent outbox applies to new activity creates, including timer completions and manual/quick creates.
- Each entry is scoped to the API base, authenticated user and family subject, and retains its UUID mutation ID.
- A background sweep runs every 15 seconds and when the browser reports that it is online.
- Retryable network, timeout, rate-limit and server failures use a future retry time rather than busy-looping.
- Pending and failed entries are visible in the matching tracker history.
- The user can retry one item, discard one, discard all current-scope items or clear corrupt stored entries.
- Authorization/subject changes fail safely; an entry is never replayed into a different family context.
Existing activity edits and deletes do not use this outbox. They require a successful online platform response.
Reconciliation and duplicate prevention
| Mechanism | Problem solved |
|---|---|
| Timer UUID | Gives local, native and server representations the same stable identity. |
| Scope reconciliation | Collapses competing timers for the same family/baby/type without affecting other scopes. |
| Pending-upsert protection | Stops a stale server summary from replacing a newer local timer that has not synced yet. |
| Six-hour stop tombstone | Prevents a delayed summary/realtime response from reviving a stopped timer. |
| Stopped draft | Preserves the reviewable result across route changes, reloads and stop-request retries. |
activeTimerId | Lets the final server transaction close the exact timer/draft it came from. |
clientMutationId | Makes create retries return one activity, not multiple copies. |
| Realtime summary refresh | Reconciles changes completed by another session or the native wrapper. |
Native sleep timer surfaces
For a family actor in self-mode, the Sleep tracker can mirror a running timer to supported native surfaces. Current wrapper code includes an iOS Widget/Live Activity path (Live Activities require iOS 16.1+) and Android widget/live-notification support. Availability still depends on the installed native wrapper and operating-system capabilities; the in-app timer remains usable when native synchronization fails.
The secure handoff works as follows:
- The WebView upserts the Sleep timer with
requestNativeActionToken: true. - The platform returns a narrow, expiring action token for that timer; it does not expose a Supabase access token to native storage.
- The wrapper displays baby/timer state and a deep link back to the correct Sleep route.
- A native completion calls
POST /native/sleep-timers/:id/completewith the action token and completion time. - The platform validates and consumes the token, creates the Sleep activity and closes the timer atomically. A realtime refresh then removes it from the WebView.
Coach view-as does not request a native action token. Coach timers remain in the authenticated WebView because a device widget must not become a reusable proxy for coach access to a family.
API contract
| Method and path | Request / result |
|---|---|
PUT /active-timers/:id | Upsert babyId, timer activity type, startedAt, IANA timeZone, optional details/note and optional native-token request. Returns the canonical timer and, when authorized, token metadata. |
POST /active-timers/:id/stop | Submit baby/type/start/stop/timezone/details/note. Atomically closes the active timer and returns a stopped draft. |
DELETE /active-timers/:id | Clear a running timer or unresolved stopped draft; optional scope metadata protects retries. |
POST /activities | Create the final idempotent activity using activeTimerId and clientMutationId. |
POST /native/sleep-timers/:id/complete | Token-authenticated native Sleep completion; no ordinary bearer session is accepted as a substitute for the action token. |
GET /families/me | Includes active timers and stopped drafts for authenticated reconciliation. |
All ordinary timer endpoints resolve actor, family subject, baby ownership and coach permissions through the API. The native completion endpoint is public only in the HTTP-auth sense: possession, validity, expiry and one-time use of the narrowly scoped action token are mandatory.
User-visible failure states
| State | Expected behavior |
|---|---|
| Timer upsert is offline | Keep the local timer running and retry synchronization; show no false “saved” server claim. |
| Stop request is offline | Remove the running bubble, retain the review draft and retry the stop. |
| Reload during review | Restore the stopped draft locally/from summary and reopen the correct form. |
| Final create is offline | Show a queued/failed activity and permit retry/discard without reviving the timer. |
| Stale summary contains stopped timer | Ignore it while the tombstone is valid. |
| Native widget sync fails | Continue with the in-app timer; native display is an enhancement, not the care-record authority. |
| Native token is expired/consumed | Reject completion without creating a duplicate; reopen the app to reconcile/review. |
| Coach assignment changes | Stop further subject writes and report that access changed; never replay queued work under another subject. |
Security and lifecycle rules
- The platform, not localStorage, is the cross-device source of truth.
- Local timer, draft and outbox records are identity/subject scoped and are reconciled on auth changes.
- Native storage receives timer display data and a narrow action token, never the family's Supabase bearer token.
- Deleting a baby/account removes its timer state with the dependent subject data.
- Coach timer writes require the live assignment and
canWriteActivities; native family-self completion is deliberately excluded.
Acceptance checklist
- A running timer survives navigation/reload and reopens the correct baby/type.
- Multiple babies/types can run concurrently while duplicate scope timers reconcile to one canonical timer.
- Stop immediately removes the active bubble and produces a restorable review draft.
- Offline stop/create retries do not lose the draft or create duplicate activities.
- Users can understand and recover pending, failed and corrupt outbox states.
- Native Sleep completion consumes a narrow action token and atomically produces one activity.
- Coach view-as never provisions a family-self native action token.
Source evidence
mobile-web/src/timers/ActiveTimersContext.tsxmobile-web/src/timers/stoppedTimerDrafts.tsmobile-web/src/timers/serverActiveTimers.tsmobile-web/src/components/FloatingTimerBubbles.tsxmobile-web/src/offline/ActivityCreateOutboxContext.tsxmobile-web/src/offline/activity-create-outbox-runner.tsmobile-web/src/routes/ActivityTrackingScreen.tsxmobile-wrapper/src/native/sleepTimerWidget.tsmobile-wrapper/ios/SleepTimerWidget/SleepTimerWidget.swiftmobile-wrapper/android/app/src/main/java/com/dreamsforlittles/SleepTimerWidgetProvider.ktplatform/apps/api/src/routes/family-routes.tsplatform/packages/shared/src/family.ts
