// 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 { daysFromToday, isDeferred, todayIso } from "./dates"; import { nextOccurrence, shiftDays } from "./repeat"; import type { Project, Task } from "./types"; export interface State { tasks: Task[]; projects: Project[]; hydrated: boolean; syncing: boolean; pending: number; /** True when the last sync failed for network reasons — expected offline. */ offline: boolean; syncError: string | null; } type Op = | { kind: "task.create" | "task.update"; id: string; data: Partial } | { kind: "project.create" | "project.update"; id: string; data: Partial }; const DATA_KEY = "store_v1"; const QUEUE_KEY = "queue_v1"; let state: State = { tasks: [], projects: [], hydrated: false, syncing: false, pending: 0, offline: false, 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, ...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", "priority", "estimate_min", "repeat", "sort_order"] as const; const PROJECT_FIELDS = ["name", "status", "notes", "sort_order"] as const; function pickFields(data: Partial, fields: readonly (keyof T & string)[]): Partial { const out: Partial = {}; 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 & { title: string }): Task { // "next" — this app has no inbox; a task without an explicit box is actionable. const status = data.status ?? "next"; const task: Task = { id: Crypto.randomUUID(), title: data.title, notes: data.notes ?? "", status, 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, priority: data.priority ?? 4, estimate_min: data.estimate_min ?? null, repeat: data.repeat ?? null, completed_at: null, // New tasks land at the top of their list. sort_order: data.sort_order ?? Math.min(0, ...state.tasks.filter((t) => t.status === status).map((t) => t.sort_order)) - 1, 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) { applyTaskUpdate(id, patch); } /** Applies a patch; returns the next occurrence if completing spawned one. */ function applyTaskUpdate(id: string, patch: Partial): Task | null { const before = state.tasks.find((t) => t.id === id); 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) }); // Completing a repeating task spawns the next occurrence. Un-checking the // done row later leaves the spawn in place — it's one tap to trash, versus // silently deleting a row the user may have edited. if (before && before.repeat && before.status !== "done" && patch.status === "done") { return spawnNextOccurrence(before); } return null; } /** * Complete a task and hand back an undo. Undo puts the task back where it was * (same status and sort slot) and trashes the occurrence a repeat spawned, so * a mis-tap leaves no trace. Returns null when there's nothing to complete. */ export function completeTask(id: string, revertTo?: Task["status"]): (() => void) | null { const before = state.tasks.find((t) => t.id === id); if (!before || before.status === "done") return null; const spawned = applyTaskUpdate(id, { status: "done" }); const back = revertTo ?? before.status; return () => { updateTask(id, { status: back }); if (spawned) trashTask(spawned.id); }; } function spawnNextOccurrence(t: Task): Task { const today = todayIso(); const due = t.due_date ? nextOccurrence(t.due_date, t.repeat!, today) : null; // Keep the same lead time between defer and due (e.g. "surfaces 3 days before"). const defer = due && t.due_date && t.defer_date ? shiftDays(t.defer_date, daysFromToday(due) - daysFromToday(t.due_date)) : t.due_date ? null : t.defer_date ? nextOccurrence(t.defer_date, t.repeat!, today) : null; return createTask({ title: t.title, notes: t.notes, status: t.status, project_id: t.project_id, context: t.context, waiting_for: t.waiting_for, due_date: due, defer_date: defer, priority: t.priority, estimate_min: t.estimate_min, repeat: t.repeat, }); } export function trashTask(id: string) { updateTask(id, { status: "trashed" }); } export function createProject(data: Partial & { 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) { 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, offline: false }); } catch (e) { const msg = e instanceof Error ? e.message : String(e); // fetch() network failures (airplane mode, no wifi, DNS) are the expected // offline case, not an error — the queue simply waits for connectivity. const isNetwork = e instanceof TypeError || /network request failed|unable to resolve|fetch failed|timeout/i.test(msg); setState(isNetwork ? { offline: true, syncError: null } : { syncError: msg }); } 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); 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); } 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() { // The default list excludes trashed; fetch trash separately so the Trash // screen (and restore) survive a sync. const [open, trashed, projects] = await Promise.all([ api.tasks.list(), api.tasks.list({ status: "trashed" }), api.projects.list(), ]); const tasks = [...open, ...trashed]; // 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 = (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 ---- function byPriorityDue(a: Task, b: Task): number { return ( a.priority - b.priority || (a.due_date ?? "9999").localeCompare(b.due_date ?? "9999") || a.sort_order - b.sort_order || b.created_at.localeCompare(a.created_at) ); } // Manual arrangement wins: sort_order is the canonical list order (set by // drag-to-reorder); priority/due only break ties for rows that never moved. function byManualOrder(a: Task, b: Task): number { return a.sort_order - b.sort_order || byPriorityDue(a, b); } export function tasksByStatus(s: State, status: Task["status"]): Task[] { return s.tasks.filter((t) => t.status === status).sort(byManualOrder); } /** * The action lists hide deferred tasks until their date arrives — that's the * whole point of a tickler. Review and the "show deferred" toggle see them. */ export function visibleTasksByStatus(s: State, status: Task["status"]): Task[] { const today = todayIso(); return tasksByStatus(s, status).filter((t) => !isDeferred(t.defer_date, today)); } /** Re-slot a task at `index` within its status list (fractional sort_order). */ export function reorderTask(id: string, status: Task["status"], index: number) { const list = tasksByStatus(getState(), status).filter((t) => t.id !== id); const prev = list[index - 1]?.sort_order; const next = list[index]?.sort_order; const sort_order = prev !== undefined && next !== undefined ? (prev + next) / 2 : prev !== undefined ? prev + 1 : next !== undefined ? next - 1 : 0; updateTask(id, { sort_order }); } /** Overdue + due-today + deferred-arriving-today, across all open lists. */ export function todayTasks(s: State): { overdue: Task[]; today: Task[] } { const now = new Date(); const todayIso = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`; const open = s.tasks.filter((t) => t.status !== "done" && t.status !== "trashed"); return { overdue: open.filter((t) => t.due_date && t.due_date < todayIso).sort(byPriorityDue), today: open .filter((t) => t.due_date === todayIso || (t.defer_date === todayIso && !t.due_date)) .sort(byPriorityDue), }; } 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; } /** Distinct contexts on open tasks — suggestion chips beat retyping "@homr". */ export function knownContexts(s: State): string[] { const set = new Set(); for (const t of s.tasks) { if (t.context && t.status !== "done" && t.status !== "trashed") set.add(t.context); } return [...set].sort(); } // ---- weekly review ---- const STALE_DAYS = 14; const WAITING_DAYS = 7; function olderThanDays(ts: string, days: number): boolean { return Date.now() - new Date(ts).getTime() > days * 86_400_000; } export interface ReviewBuckets { overdue: Task[]; staleNext: Task[]; agingWaiting: Task[]; someday: Task[]; deferred: Task[]; stuckProjects: Project[]; } export function reviewBuckets(s: State): ReviewBuckets { const today = todayIso(); const open = s.tasks.filter((t) => t.status !== "done" && t.status !== "trashed"); const overdue = open.filter((t) => t.due_date && t.due_date < today).sort(byPriorityDue); const overdueIds = new Set(overdue.map((t) => t.id)); const actionable = new Set(["next", "waiting"]); return { overdue, staleNext: open .filter((t) => t.status === "next" && !overdueIds.has(t.id) && olderThanDays(t.updated_at, STALE_DAYS)) .sort(byPriorityDue), agingWaiting: open .filter((t) => t.status === "waiting" && olderThanDays(t.updated_at, WAITING_DAYS)) .sort((a, b) => a.updated_at.localeCompare(b.updated_at)), someday: tasksByStatus(s, "someday"), deferred: open.filter((t) => isDeferred(t.defer_date, today)).sort((a, b) => a.defer_date!.localeCompare(b.defer_date!)), stuckProjects: activeProjects(s).filter( (p) => p.status === "active" && !s.tasks.some((t) => t.project_id === p.id && actionable.has(t.status)), ), }; }