Status: Design doc (future work)
Applies to: Taskmaster (Amplify Gen 1 CLI backend, AppSync GraphQL, Cognito User Pool auth, React + TypeScript + Chakra UI, Zustand state)
TaskMaster is GraphQL-backed and uses persisted Zustand stores for fast reloads. Offline mode is still a product feature (not yet implemented) and should be designed/implemented intentionally.
This doc lays out a concrete, step-by-step plan to implement offline mode without creating two competing sources of truth.
- App works readably and predictably when offline:
- user can view previously loaded lists/tasks
- user can create/update/delete tasks/lists while offline
- changes are queued locally and sync when online
- Zustand remains the UI source of truth (single state doorway).
- GraphQL remains the server source of truth.
- Offline sync is an implementation detail behind store actions.
- Minimal backend changes for MVP offline (avoid Lambdas unless needed).
- Good UX:
- show “Offline” badge
- show “Syncing…” state + errors with retry
- never silently lose edits
- Cross-device real-time merges
- Complex CRDT collaboration
- Background sync while app closed (possible later with Service Worker)
- End-to-end encrypted local store
From your schema:
TaskList @model @auth(rules: [{allow: owner}, {allow: groups, groups:["Admin"]}])Task @model @auth(rules: [{allow: owner}, {allow: groups, groups:["Admin"]}])
Key fields:
- TaskList:
id,name,isFavorite,sortOrder, timestamps,owner - Task:
id,listId,sortOrder,parentTaskId,title,description,status,priority,dueAt,completedAt,assigneeId,tagIds, timestamps,owner - Query fields:
tasksByList(listId, sortOrder...)tasksByParent(parentTaskId, sortOrder...)
- Zustand store is the only place components read/write task state.
- Store actions call a single data boundary which chooses:
- online GraphQL (current via
src/api/taskmasterApi.ts) - offline cache (future)
- offline queue + later sync (future)
- online GraphQL (current via
UI does not call GraphQL directly.
UI does not merge patches in localStorage directly.
Everything goes through:
Component → Zustand Action → Repo → (GraphQL | Cache | Queue) → Store update
Today, the “Repo” boundary is the API wrapper (src/api/taskmasterApi.ts) called by store actions.
Why:
- localStorage is synchronous, small, and easy to corrupt with large data
- IndexedDB is built for structured storage, async, and larger datasets
Use localStorage only for:
offline.enabled(boolean)offline.lastSyncAtoffline.schemaVersion
Use IndexedDB for:
- cached TaskLists + Tasks
- operation queue (pending mutations)
- sync error log / dead-letter queue (optional)
Use one of:
idb(tiny wrapper around IndexedDB)Dexie(more ergonomic; slightly heavier)
Pick one and standardize early.
A local cache of the latest known server state (per user):
taskListstable keyed byidtaskstable keyed byid- optional indexes:
tasksByListIdindex onlistIdtasksByParentTaskIdindex onparentTaskId
When offline (or when online but request fails), write an operation:
type OfflineOp =
| { id: string; type: "CREATE_TASKLIST"; createdAt: string; payload: {...}; state: "PENDING"|"FAILED"; attempts: number; lastError?: string; }
| { id: string; type: "UPDATE_TASKLIST"; createdAt: string; payload: {...}; state: ... }
| { id: string; type: "DELETE_TASKLIST"; createdAt: string; payload: {...}; state: ... }
| { id: string; type: "CREATE_TASK"; createdAt: string; payload: {...}; state: ... }
| { id: string; type: "UPDATE_TASK"; createdAt: string; payload: {...}; state: ... }
| { id: string; type: "DELETE_TASK"; createdAt: string; payload: {...}; state: ... }
;Properties:
- id: uuid for the op
- createdAt: ISO timestamp
- attempts: retry count
- lastError: last failure message
- state: pending/failed (and optionally “dead-letter” after N attempts)
When you enqueue an op, you immediately update Zustand and local cache to reflect the change. Later sync either:
- confirms (no change needed), or
- reconciles if server rejects/adjusts.
To avoid headaches: use client-generated UUIDs for new records (TaskList/Task).
Amplify/AppSync supports client-provided IDs in CreateXInput (common pattern).
That means:
- offline create uses real ID immediately
- later sync creates server record with same ID
- no “temp id → real id” mapping required
If you ever move to server-generated IDs, you’ll need a temp-id mapping table. Avoid.
Conflicts happen if:
- user edits same record on two devices
- admin or other process changes record
- sync replays ops against newer server state
v1 policy: “last write wins” (LWW) with guardrails
- include updatedAt in your records
- for each update op:
- fetch server record first (optional in v1; recommended later)
- if server updatedAt is newer than the base you edited, mark conflict
Simplest v1: do not block; push update anyway and let last writer win.
Better v1.1: detect and notify:
- “This task changed on another device. Keep yours or keep server?”
- Only for high-value fields (title/description)
If you later want stronger concurrency:
- add version: Int or use AppSync conflict detection (DataStore-style)
- but that adds complexity; not needed for MVP offline
Offline cache must be per-user. Ensure:
- cache key namespace includes userSub (from Cognito token)
- on sign-out: clear in-memory store and optionally clear cache for that user
Admin-group behavior:
- offline mode still runs under signed-in identity
- admin-only operations should either:
- be disabled offline, or
- be queued like normal (but likely unnecessary for MVP)
Create a repository interface that Zustand uses:
type TaskRepo = {
// Reads
listTaskLists(): Promise<TaskList[]>;
listTasksByList(listId: string): Promise<Task[]>;
listTasksByParent(parentTaskId: string): Promise<Task[]>;
// Writes
createTaskList(input: CreateTaskListInput): Promise<TaskList>;
updateTaskList(input: UpdateTaskListInput): Promise<TaskList>;
deleteTaskList(id: string): Promise<void>;
createTask(input: CreateTaskInput): Promise<Task>;
updateTask(input: UpdateTaskInput): Promise<Task>;
deleteTask(id: string): Promise<void>;
// Sync
flushQueue(): Promise<{ applied: number; failed: number }>;
getQueueState(): Promise<{ pending: number; failed: number }>;
};GraphQLTaskRepo(online only)OfflineCapableTaskRepo(wraps GraphQL + cache + queue)
Or:
TaskRepo = OfflineCapableTaskRepoalways; it decides at runtime.
authSlice:userSub,username,groups,isSignedIn
networkSlice:isOnline,lastOnlineAt
dataSlice:taskListsById,tasksByIdtasksByListId: Record<listId, taskId[]>(or computed selector)
syncSlice:queuePendingCount,queueFailedCountsyncStatus: "idle"|"syncing"|"error"lastSyncAt- actions:
flushQueue(),retryFailedOps()
-
reads:
loadTaskLists()loadTasksByList(listId)
-
writes:
createTaskList(...)(optimistic)updateTaskList(...)deleteTaskList(...)createTask(...)updateTask(...)deleteTask(...)
Each write action:
- updates Zustand immediately (optimistic)
- writes to cache
- attempts online mutation if online
- if offline or mutation fails → enqueue op
- updates sync counts
Implementation notes (current codebase):
- Tasks/lists are already cached in
taskStorewith a TTL for fast reloads. - Store selectors must return stable snapshots (React 19
useSyncExternalStorerequirement).
- app startup (after auth)
- when network transitions offline→online
- user presses “Sync now”
- optionally every N minutes while online
Process ops FIFO:
For each op:
- attempt GraphQL mutation
- on success: mark op done (remove from queue)
- on failure:
- increment attempts
- store lastError
- if attempts > MAX (e.g., 5), mark “FAILED” and stop or continue depending on error type
- Auth errors (token expired, unauthorized):
- stop flush, prompt user to re-auth
- Validation/schema errors:
- mark op failed (likely permanent) and continue
- Throttling/network:
- exponential backoff, continue later
Because create IDs are client-generated, replays are mostly safe:
- If
createTask(id=X)already exists, server may return error. Solutions: - On create failure due to “already exists”, treat as success and drop op.
(You can also “getTask(id)” to confirm, but that’s extra calls.)
When loading lists/tasks:
-
if online:
- fetch from GraphQL
- write to cache
- update Zustand
-
if offline:
- read from cache
- update Zustand
-
show “cached data” indicator
If cache is empty (fresh device, never loaded online):
- show empty state + “Go online once to load your data”
- Top-level badge:
- Online / Offline
- Syncing…
- Sync error (click to see details)
When offline:
- allow create/update/delete normally
- optionally show small “Queued” toast
When sync fails permanently:
- show a “Sync Issues” panel listing failed ops with:
- record info
- error
- “Retry” / “Discard” buttons
Discarding an op should:
- remove it from queue
- optionally revert local optimistic change (hard)
- OR mark record as “needs attention” (easier) For v1, prefer:
- require manual resolution in UI (don’t auto-revert silently)
You can implement offline without backend changes.
Later improvements (optional):
- Add
updatedAtusage or explicit versioning - Add dedicated
UpdateEvent @modelif you want a true feed - Add subscriptions for near-realtime updates
-
Add a small network utility:
window.addEventListener("online"/"offline")- set
isOnlinein Zustand
-
Choose IndexedDB library (
idborDexie) and create:db.ts(schema + openDB)
-
Add auth identity extraction:
- from
fetchAuthSession()getsubandcognito:groups - store in Zustand
authSlice
- from
-
Implement cache tables:
taskLists(key: id)tasks(key: id)ops(key: opId)
-
Implement cache API:
cache.getTaskLists(userSub)cache.putTaskLists(userSub, lists)cache.getTasksByList(userSub, listId)cache.putTasks(userSub, tasks)- clear on sign out (optionally)
- Create
TaskRepointerface - Implement
GraphQLTaskRepousing your minimal operations:createTaskListMinimal,updateTaskListMinimal, ...tasksByListMinimal, ...
- Implement
OfflineCapableTaskRepothat wraps:- GraphQL repo
- cache
- queue
- Create Zustand store with the actions listed earlier
- Ensure all pages read from store, not from ad-hoc data sources
- Implement
flushQueue():- called when online and signed-in
- Add UI:
- “Sync now” button
- “Sync issues” list if failed ops exist
- Convert ListPage first:
loadTasksByList(listId)createTask(...),updateTask(...), etc.
- Convert TaskDetailsPane and create/update flows
- Convert Today/Inbox/Updates to derived selectors
- Add tests:
- queue enqueue logic
- flush retries
- cache read fallback
- Add dev helpers:
- reset IndexedDB + queue
- “simulate offline” toggle (dev only)
- Go online, load data, then go offline
- Reload app offline
- Lists/tasks appear from cache
- Offline create list → appears immediately
- Offline create task → appears immediately
- Offline update task title/status → immediate
- Offline delete task → disappears immediately
- Queue count increases
- Go online → queued ops flush
- Queue count returns to zero
- Server shows same data (AppSync console / dev smoke test)
- Force an auth failure (sign out) during flush → flush stops, prompts re-auth
- Force a validation failure → op moves to failed and shows in UI
The app is currently online-first (GraphQL-backed) with persisted caches.
Recommended approach:
- Keep the UI/store contract stable.
- Add a cache + queue layer behind store actions (or behind the API wrapper) without changing pages/components.
- Treat offline mode as an implementation detail: the same store actions should work online or offline.
- Service Worker background sync
- Push subscriptions to auto-refresh cache when online
- Stronger conflict resolution UI
- Per-field merge strategies
- True event stream model (
UpdateEvent @model) - Compression/encryption for local cache
If you only do one “offline foundation” sprint:
- IndexedDB cache for TaskLists/Tasks keyed by userSub
- Offline queue table + enqueue on failed mutations
- flushQueue on online event
- minimal UI indicators (Offline, Syncing, Errors)
That alone gives you a legit offline mode.
src/
data/
repo/
TaskRepo.ts
GraphQLTaskRepo.ts
OfflineTaskRepo.ts
cache/
db.ts
taskCache.ts
opQueue.ts
sync/
syncEngine.ts
store/
useAppStore.ts
slices/
authSlice.ts
networkSlice.ts
tasksSlice.ts
syncSlice.ts
graphql/
operations.ts (minimal ops)Keep “minimal selection sets” for writes to avoid nested resolver issues:
- Create/Update TaskList returns only scalar fields
- Create/Update Task returns only scalar fields
- Reads:
listTaskListstasksByListtasksByParent
Add nested relationships only when needed and in separate queries.