completeTask returns an undo closure that restores status + sort slot and trashes the occurrence a repeat spawned. One global snackbar (5s, replace-on-new) mounted in the root layout; every checkbox and Clarify's save-as-done go through completeWithUndo. bun tests for the store. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YE5FmDZmFnWxH6RXCZLcjj
432 lines
15 KiB
TypeScript
432 lines
15 KiB
TypeScript
// 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<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,
|
|
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 = { ...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<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 {
|
|
// "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<Task>) {
|
|
applyTaskUpdate(id, patch);
|
|
}
|
|
|
|
/** Applies a patch; returns the next occurrence if completing spawned one. */
|
|
function applyTaskUpdate(id: string, patch: Partial<Task>): 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<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, 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<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() {
|
|
// 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 = <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 ----
|
|
|
|
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<string>();
|
|
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)),
|
|
),
|
|
};
|
|
}
|