Local-first sync: on-device store + offline mutation queue, idempotent server upserts
All checks were successful
Build & Release APK / build (push) Successful in 10m54s

Screens render synchronously from an AsyncStorage-backed store; every
mutation applies locally and drains to the API in the background
(last-write-wins, client-generated UUIDs). Sydney round-trips are off the
interaction path and the app works fully offline.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-07 19:19:51 -07:00
parent 4d6031d007
commit ad16522461
13 changed files with 417 additions and 216 deletions

View File

@@ -34,7 +34,7 @@ export const api = {
return req<Task[]>(`/v1/tasks${q ? `?${q}` : ""}`);
},
get: (id: string) => req<Task>(`/v1/tasks/${id}`),
create: (data: Partial<Task> & { title: string }) =>
create: (data: Partial<Task> & { title: string; id?: string }) =>
req<Task>("/v1/tasks", { method: "POST", body: JSON.stringify(data) }),
update: (id: string, data: Partial<Task>) =>
req<Task>(`/v1/tasks/${id}`, { method: "PATCH", body: JSON.stringify(data) }),
@@ -42,7 +42,7 @@ export const api = {
},
projects: {
list: () => req<Project[]>("/v1/projects"),
create: (data: Partial<Project> & { name: string }) =>
create: (data: Partial<Project> & { name: string; id?: string }) =>
req<Project>("/v1/projects", { method: "POST", body: JSON.stringify(data) }),
update: (id: string, data: Partial<Project>) =>
req<Project>(`/v1/projects/${id}`, { method: "PATCH", body: JSON.stringify(data) }),

254
app/src/lib/store.ts Normal file
View File

@@ -0,0 +1,254 @@
// Local-first store — the single source of truth for the UI.
//
// Screens render synchronously from the in-memory state (hydrated from
// AsyncStorage at launch). Mutations apply locally immediately and enqueue an
// op; a background worker drains the queue to the API and then pulls the
// server state, so the network is never on the interaction path. Single-user
// last-write-wins: rows with queued local ops keep their local version during
// a pull; everything else takes the server's.
import AsyncStorage from "@react-native-async-storage/async-storage";
import * as Crypto from "expo-crypto";
import { api } from "./api";
import { getConfig } from "./config";
import type { Project, Task } from "./types";
export interface State {
tasks: Task[];
projects: Project[];
hydrated: boolean;
syncing: boolean;
pending: number;
syncError: string | null;
}
type Op =
| { kind: "task.create" | "task.update"; id: string; data: Partial<Task> }
| { kind: "project.create" | "project.update"; id: string; data: Partial<Project> };
const DATA_KEY = "store_v1";
const QUEUE_KEY = "queue_v1";
let state: State = {
tasks: [],
projects: [],
hydrated: false,
syncing: false,
pending: 0,
syncError: null,
};
let queue: Op[] = [];
const listeners = new Set<() => void>();
export function getState(): State {
return state;
}
export function subscribe(listener: () => void): () => void {
listeners.add(listener);
return () => listeners.delete(listener);
}
function setState(patch: Partial<State>) {
state = { ...state, ...patch, pending: queue.length };
for (const l of listeners) l();
}
function persistData() {
void AsyncStorage.setItem(DATA_KEY, JSON.stringify({ tasks: state.tasks, projects: state.projects }));
}
function persistQueue() {
void AsyncStorage.setItem(QUEUE_KEY, JSON.stringify(queue));
}
export async function hydrate() {
if (state.hydrated) return;
const [data, q] = await Promise.all([AsyncStorage.getItem(DATA_KEY), AsyncStorage.getItem(QUEUE_KEY)]);
if (q) queue = JSON.parse(q);
const parsed = data ? JSON.parse(data) : { tasks: [], projects: [] };
setState({ tasks: parsed.tasks, projects: parsed.projects, hydrated: true });
void sync();
}
// ---- mutations (instant; network happens later) ----
function now() {
return new Date().toISOString();
}
// Fields the API accepts — everything else (timestamps, completed_at) is local
// bookkeeping until the server's authoritative row arrives via pull().
const TASK_FIELDS = ["title", "notes", "status", "project_id", "context", "waiting_for", "due_date", "defer_date", "sort_order"] as const;
const PROJECT_FIELDS = ["name", "status", "notes", "sort_order"] as const;
function pickFields<T>(data: Partial<T>, fields: readonly (keyof T & string)[]): Partial<T> {
const out: Partial<T> = {};
for (const f of fields) if (f in data) out[f] = data[f];
return out;
}
function enqueue(op: Op) {
// Coalesce consecutive updates to the same row into one op.
const last = queue[queue.length - 1];
if (last && op.kind === last.kind && op.kind.endsWith("update") && last.id === op.id) {
last.data = { ...last.data, ...op.data };
} else {
queue.push(op);
}
persistQueue();
setState({});
void sync();
}
export function createTask(data: Partial<Task> & { title: string }): Task {
const task: Task = {
id: Crypto.randomUUID(),
title: data.title,
notes: data.notes ?? "",
status: data.status ?? "inbox",
project_id: data.project_id ?? null,
context: data.context ?? null,
waiting_for: data.waiting_for ?? null,
due_date: data.due_date ?? null,
defer_date: data.defer_date ?? null,
completed_at: null,
sort_order: data.sort_order ?? 0,
created_at: now(),
updated_at: now(),
};
setState({ tasks: [task, ...state.tasks] });
persistData();
enqueue({ kind: "task.create", id: task.id, data: pickFields(task, TASK_FIELDS) });
return task;
}
export function updateTask(id: string, patch: Partial<Task>) {
setState({
tasks: state.tasks.map((t) => {
if (t.id !== id) return t;
const next = { ...t, ...patch, updated_at: now() };
// Mirror the server's completed_at rule so the UI is right pre-sync.
next.completed_at = next.status === "done" ? (t.completed_at ?? now()) : null;
return next;
}),
});
persistData();
enqueue({ kind: "task.update", id, data: pickFields(patch, TASK_FIELDS) });
}
export function trashTask(id: string) {
updateTask(id, { status: "trashed" });
}
export function createProject(data: Partial<Project> & { name: string }): Project {
const project: Project = {
id: Crypto.randomUUID(),
name: data.name,
status: data.status ?? "active",
notes: data.notes ?? "",
sort_order: data.sort_order ?? 0,
created_at: now(),
updated_at: now(),
};
setState({ projects: [project, ...state.projects] });
persistData();
enqueue({ kind: "project.create", id: project.id, data: pickFields(project, PROJECT_FIELDS) });
return project;
}
export function updateProject(id: string, patch: Partial<Project>) {
setState({ projects: state.projects.map((p) => (p.id === id ? { ...p, ...patch, updated_at: now() } : p)) });
persistData();
enqueue({ kind: "project.update", id, data: pickFields(patch, PROJECT_FIELDS) });
}
export function dropProject(id: string) {
updateProject(id, { status: "dropped" });
}
// ---- sync ----
let syncRunning = false;
let syncAgain = false;
export async function sync() {
if (syncRunning) {
syncAgain = true;
return;
}
const { token } = await getConfig();
if (!token) return;
syncRunning = true;
setState({ syncing: true });
try {
while (queue.length) {
await push(queue[0]);
queue.shift();
persistQueue();
setState({});
}
await pull();
setState({ syncError: null });
} catch (e) {
setState({ syncError: e instanceof Error ? e.message : String(e) });
} finally {
syncRunning = false;
setState({ syncing: false });
if (syncAgain) {
syncAgain = false;
void sync();
}
}
}
async function push(op: Op) {
try {
if (op.kind === "task.create") await api.tasks.create({ id: op.id, ...op.data } as never);
else if (op.kind === "task.update") await api.tasks.update(op.id, op.data as Partial<Task>);
else if (op.kind === "project.create") await api.projects.create({ id: op.id, ...op.data } as never);
else await api.projects.update(op.id, op.data as Partial<Project>);
} catch (e) {
// 4xx (bad data, row gone) would poison the queue forever — drop the op.
// Anything else (offline, 5xx) rethrows and we retry on the next sync.
const status = (e as { status?: number }).status;
if (status && status >= 400 && status < 500 && status !== 401) return;
throw e;
}
}
async function pull() {
const [tasks, projects] = await Promise.all([api.tasks.list(), api.projects.list()]);
// Rows with ops still queued keep their local version; the next sync pass
// pushes those ops and then this merge converges to the server state.
const dirty = new Set(queue.map((o) => o.id));
const keepLocal = <T extends { id: string }>(local: T[], server: T[]) => {
const serverIds = new Set(server.map((r) => r.id));
return [
...server.map((r) => (dirty.has(r.id) ? local.find((l) => l.id === r.id) ?? r : r)),
...local.filter((l) => dirty.has(l.id) && !serverIds.has(l.id)),
];
};
setState({
tasks: keepLocal(state.tasks, tasks),
projects: keepLocal(state.projects, projects),
});
persistData();
}
// ---- selectors ----
export function tasksByStatus(s: State, status: Task["status"]): Task[] {
return s.tasks
.filter((t) => t.status === status)
.sort((a, b) => a.sort_order - b.sort_order || b.created_at.localeCompare(a.created_at));
}
export function activeProjects(s: State): Project[] {
return s.projects
.filter((p) => p.status !== "dropped")
.sort((a, b) => a.sort_order - b.sort_order || b.created_at.localeCompare(a.created_at));
}
export function openTaskCount(s: State, projectId: string): number {
return s.tasks.filter((t) => t.project_id === projectId && t.status !== "done" && t.status !== "trashed").length;
}

8
app/src/lib/useStore.ts Normal file
View File

@@ -0,0 +1,8 @@
import { useSyncExternalStore } from "react";
import { getState, subscribe, type State } from "./store";
// Subscribe a component to the local-first store. The whole state object is
// the snapshot (replaced immutably on every change), so derive lists inline.
export function useStore(): State {
return useSyncExternalStore(subscribe, getState, getState);
}