diff --git a/app/package-lock.json b/app/package-lock.json
index f6f64ed..bf0c3aa 100644
--- a/app/package-lock.json
+++ b/app/package-lock.json
@@ -12,6 +12,7 @@
"@react-native-async-storage/async-storage": "2.2.0",
"expo": "~57.0.11",
"expo-constants": "~57.0.9",
+ "expo-crypto": "~57.0.1",
"expo-device": "~57.0.1",
"expo-font": "~57.0.1",
"expo-glass-effect": "~57.0.1",
@@ -3713,6 +3714,15 @@
"react-native": "*"
}
},
+ "node_modules/expo-crypto": {
+ "version": "57.0.1",
+ "resolved": "https://registry.npmjs.org/expo-crypto/-/expo-crypto-57.0.1.tgz",
+ "integrity": "sha512-xwegXQw3ATgeL1ZuqbSNrGzOeG+zNeh6Z6DSJk825Qpa3TEQQ1kG3ioE1p3g/SNF373BAVz2iBKUTSytlIbBRA==",
+ "license": "MIT",
+ "peerDependencies": {
+ "expo": "*"
+ }
+ },
"node_modules/expo-device": {
"version": "57.0.1",
"resolved": "https://registry.npmjs.org/expo-device/-/expo-device-57.0.1.tgz",
diff --git a/app/package.json b/app/package.json
index a73053f..efb79b6 100644
--- a/app/package.json
+++ b/app/package.json
@@ -7,6 +7,7 @@
"@react-native-async-storage/async-storage": "2.2.0",
"expo": "~57.0.11",
"expo-constants": "~57.0.9",
+ "expo-crypto": "~57.0.1",
"expo-device": "~57.0.1",
"expo-font": "~57.0.1",
"expo-glass-effect": "~57.0.1",
diff --git a/app/src/app/_layout.tsx b/app/src/app/_layout.tsx
index 57cac87..539275a 100644
--- a/app/src/app/_layout.tsx
+++ b/app/src/app/_layout.tsx
@@ -1,8 +1,20 @@
import { Link, Stack } from "expo-router";
import { StatusBar } from "expo-status-bar";
+import { useEffect } from "react";
+import { AppState } from "react-native";
+import { hydrate, sync } from "../lib/store";
import { C } from "../lib/theme";
export default function RootLayout() {
+ useEffect(() => {
+ void hydrate();
+ // Re-sync whenever the app returns to the foreground.
+ const sub = AppState.addEventListener("change", (s) => {
+ if (s === "active") void sync();
+ });
+ return () => sub.remove();
+ }, []);
+
return (
<>
diff --git a/app/src/app/index.tsx b/app/src/app/index.tsx
index a1092a4..c9c10d2 100644
--- a/app/src/app/index.tsx
+++ b/app/src/app/index.tsx
@@ -1,9 +1,9 @@
-import { useFocusEffect, useRouter } from "expo-router";
-import { useCallback, useState } from "react";
+import { useRouter } from "expo-router";
+import { useState } from "react";
import { Pressable, StyleSheet, Text, TextInput, View } from "react-native";
-import { api } from "../lib/api";
+import { activeProjects, createTask, tasksByStatus } from "../lib/store";
import { C } from "../lib/theme";
-import type { Project, Task } from "../lib/types";
+import { useStore } from "../lib/useStore";
const Q = {
next: { title: "Next up", color: "#3DBF77", bg: "#15301F", border: "#1E4D33" },
@@ -25,10 +25,7 @@ function Quadrant({
}) {
const q = Q[kind];
return (
-
+
{q.title}
{count}
@@ -44,42 +41,21 @@ function Quadrant({
export default function Home() {
const router = useRouter();
- const [tasks, setTasks] = useState([]);
- const [projects, setProjects] = useState([]);
+ const store = useStore();
const [draft, setDraft] = useState("");
- const [error, setError] = useState(null);
- const load = useCallback(async () => {
- try {
- const [ts, ps] = await Promise.all([api.tasks.list(), api.projects.list()]);
- setTasks(ts);
- setProjects(ps.filter((p) => p.status === "active"));
- setError(null);
- } catch (e) {
- setError(e instanceof Error ? e.message : String(e));
- }
- }, []);
-
- useFocusEffect(
- useCallback(() => {
- load();
- }, [load]),
- );
-
- const capture = async () => {
+ const capture = () => {
const title = draft.trim();
if (!title) return;
setDraft("");
- try {
- await api.tasks.create({ title });
- } finally {
- load();
- }
+ createTask({ title });
};
- const byStatus = (st: string) => tasks.filter((t) => t.status === st);
- const inboxCount = byStatus("inbox").length;
- const titles = (st: string) => byStatus(st).slice(0, 3).map((t) => t.title);
+ const inbox = tasksByStatus(store, "inbox");
+ const next = tasksByStatus(store, "next");
+ const waiting = tasksByStatus(store, "waiting");
+ const someday = tasksByStatus(store, "someday");
+ const projects = activeProjects(store).filter((p) => p.status === "active");
return (
@@ -95,22 +71,20 @@ export default function Home() {
/>
router.push("/list/inbox")}>
📥 Inbox
- {inboxCount}
+
+ {store.pending > 0 && ⇅ {store.pending}}
+ {inbox.length}
+
- {error && {error}}
+ {store.syncError && {store.syncError}}
- router.push("/list/next")} />
- router.push("/list/waiting")} />
+ t.title)} onPress={() => router.push("/list/next")} />
+ t.title)} onPress={() => router.push("/list/waiting")} />
- router.push("/list/someday")} />
- p.name)}
- onPress={() => router.push("/projects")}
- />
+ t.title)} onPress={() => router.push("/list/someday")} />
+ p.name)} onPress={() => router.push("/projects")} />
@@ -141,14 +115,10 @@ const s = StyleSheet.create({
borderColor: C.border,
},
inboxText: { color: C.text, fontSize: 16, fontWeight: "600" },
+ pendingDot: { color: C.muted, fontSize: 13 },
grid: { flex: 1, marginTop: 10, gap: 10 },
gridRow: { flex: 1, flexDirection: "row", gap: 10 },
- quad: {
- flex: 1,
- borderRadius: 16,
- borderWidth: 1,
- padding: 14,
- },
+ quad: { flex: 1, borderRadius: 16, borderWidth: 1, padding: 14 },
quadHead: { flexDirection: "row", justifyContent: "space-between", marginBottom: 8 },
quadTitle: { fontSize: 17, fontWeight: "700" },
quadCount: { fontSize: 17, fontWeight: "700" },
diff --git a/app/src/app/project/[id].tsx b/app/src/app/project/[id].tsx
index a5dc194..2127753 100644
--- a/app/src/app/project/[id].tsx
+++ b/app/src/app/project/[id].tsx
@@ -1,46 +1,32 @@
-import { Stack, useFocusEffect, useLocalSearchParams, useRouter } from "expo-router";
-import { useCallback, useState } from "react";
+import { Stack, useLocalSearchParams, useRouter } from "expo-router";
+import { useState } from "react";
import { FlatList, Pressable, StyleSheet, Text, TextInput, View } from "react-native";
import { Button, Chips } from "../../components/ui";
-import { api } from "../../lib/api";
+import { createTask, dropProject, updateProject, updateTask } from "../../lib/store";
import { C } from "../../lib/theme";
-import type { Project, ProjectStatus, Task } from "../../lib/types";
+import { useStore } from "../../lib/useStore";
+import type { ProjectStatus } from "../../lib/types";
const STATUSES: ProjectStatus[] = ["active", "someday", "completed"];
export default function ProjectDetail() {
const { id } = useLocalSearchParams<{ id: string }>();
const router = useRouter();
- const [project, setProject] = useState(null);
- const [tasks, setTasks] = useState([]);
+ const store = useStore();
const [draft, setDraft] = useState("");
- const load = useCallback(async () => {
- const [ps, ts] = await Promise.all([api.projects.list(), api.tasks.list({ project_id: id })]);
- setProject(ps.find((p) => p.id === id) ?? null);
- setTasks(ts.filter((t) => t.status !== "done"));
- }, [id]);
+ const project = store.projects.find((p) => p.id === id);
+ if (!project) return Project not found.;
- useFocusEffect(
- useCallback(() => {
- load();
- }, [load]),
- );
+ const tasks = store.tasks
+ .filter((t) => t.project_id === id && t.status !== "done" && t.status !== "trashed")
+ .sort((a, b) => a.sort_order - b.sort_order || b.created_at.localeCompare(a.created_at));
- if (!project) return Loading…;
-
- const add = async () => {
+ const add = () => {
const title = draft.trim();
if (!title) return;
setDraft("");
- await api.tasks.create({ title, status: "next", project_id: id });
- load();
- };
-
- const complete = async (t: Task) => {
- setTasks((cur) => cur.filter((x) => x.id !== t.id));
- await api.tasks.update(t.id, { status: "done" });
- load();
+ createTask({ title, status: "next", project_id: id });
};
return (
@@ -51,10 +37,7 @@ export default function ProjectDetail() {
label="Status"
options={STATUSES}
value={project.status}
- onChange={async (status) => {
- setProject({ ...project, status });
- await api.projects.update(project.id, { status });
- }}
+ onChange={(status) => updateProject(project.id, { status })}
/>
t.id}
renderItem={({ item }) => (
router.push(`/task/${item.id}`)}>
- complete(item)} />
+ updateTask(item.id, { status: "done" })}
+ />
{item.title}
{[item.status, item.context].filter(Boolean).join(" · ")}
@@ -84,8 +72,8 @@ export default function ProjectDetail() {