Local-first sync: on-device store + offline mutation queue, idempotent server upserts
All checks were successful
Build & Release APK / build (push) Successful in 10m54s
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:
@@ -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
254
app/src/lib/store.ts
Normal 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
8
app/src/lib/useStore.ts
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user