diff --git a/README.md b/README.md
index eb226a8..0046fce 100644
--- a/README.md
+++ b/README.md
@@ -14,7 +14,6 @@ Minimal Getting Things Done app. One Expo/React Native codebase for iPhone, Andr
- **Quick add**: the title field parses tokens live — `Pay rent tmr p1 @home #Sydney ~30m monthly` — and mirrors them into the form.
- **Repeat**: daily / weekdays / weekly / monthly / yearly; completing spawns the next occurrence (lapsed tasks catch up to today).
- **Defer**: deferred tasks are hidden from action lists until their date; a footer link reveals them.
- - **Weekly review**: overdue, stalled (14d), aging waiting-fors (7d), someday sweep, deferred preview, stuck projects — with one-tap triage per row. Done and Trash (restorable) live at the bottom of Review.
- `server/` — Bun API + Dockerfile + compose. Deployed at `~/personal/gtd` on rehbock.xyz, joined to `personal-db_default` + `caddy_net`.
## Develop
diff --git a/app/app.json b/app/app.json
index ecab87e..2e43f8e 100644
--- a/app/app.json
+++ b/app/app.json
@@ -2,7 +2,7 @@
"expo": {
"name": "GTD",
"slug": "gtd",
- "version": "1.23",
+ "version": "1.24",
"orientation": "portrait",
"icon": "./assets/images/icon.png",
"scheme": "gtd",
@@ -10,7 +10,7 @@
"ios": {
"icon": "./assets/expo.icon",
"bundleIdentifier": "xyz.rehbock.gtd",
- "buildNumber": "23",
+ "buildNumber": "24",
"supportsTablet": false,
"infoPlist": {
"ITSAppUsesNonExemptEncryption": false
diff --git a/app/eas.json b/app/eas.json
index 6208b45..57a188d 100644
--- a/app/eas.json
+++ b/app/eas.json
@@ -9,8 +9,8 @@
"buildType": "apk"
},
"env": {
- "VERSION_CODE": "23",
- "VERSION_NAME": "1.23"
+ "VERSION_CODE": "24",
+ "VERSION_NAME": "1.24"
},
"node": "22.20.0",
"ios": {
diff --git a/app/src/app/(tabs)/_layout.tsx b/app/src/app/(tabs)/_layout.tsx
new file mode 100644
index 0000000..999ec82
--- /dev/null
+++ b/app/src/app/(tabs)/_layout.tsx
@@ -0,0 +1,78 @@
+import { Link, useRouter } from "expo-router";
+import { Tabs } from "expo-router/js-tabs";
+import { Pressable, StyleSheet, View } from "react-native";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
+import AreaToggle from "../../components/AreaToggle";
+import Icon from "../../components/Icon";
+import TabBar, { TAB_BAR_CONTENT_HEIGHT, tabBarInset } from "../../components/TabBar";
+import { haptic } from "../../lib/haptics";
+import { C, shadow, space } from "../../lib/theme";
+
+/**
+ * The three top-level screens share one header (the personal/work pill plus
+ * the settings gear) and one capture button. The FAB lives here rather than
+ * on each screen so it never moves: same spot above the bar whichever tab is
+ * open, which is what makes it a reflex.
+ */
+export default function TabsLayout() {
+ const router = useRouter();
+ const insets = useSafeAreaInsets();
+ return (
+
+ }
+ screenOptions={{
+ headerStyle: { backgroundColor: C.bg },
+ headerTintColor: C.text,
+ headerShadowVisible: false,
+ headerTitle: () => ,
+ headerTitleAlign: "center",
+ headerRight: () => (
+
+ [{ paddingHorizontal: space.sm }, pressed && { opacity: 0.5 }]}
+ >
+
+
+
+ ),
+ sceneStyle: { backgroundColor: C.bg },
+ }}
+ >
+
+
+
+
+
+ [
+ s.fab,
+ { bottom: TAB_BAR_CONTENT_HEIGHT + tabBarInset(insets.bottom) + space.lg },
+ pressed && { transform: [{ scale: 0.94 }] },
+ ]}
+ onPress={() => {
+ haptic.tap();
+ router.push("/add");
+ }}
+ >
+
+
+
+ );
+}
+
+const s = StyleSheet.create({
+ fab: {
+ position: "absolute",
+ right: space.xl,
+ width: 56,
+ height: 56,
+ borderRadius: 28,
+ backgroundColor: C.accent,
+ alignItems: "center",
+ justifyContent: "center",
+ ...shadow.fab,
+ },
+});
diff --git a/app/src/app/(tabs)/index.tsx b/app/src/app/(tabs)/index.tsx
new file mode 100644
index 0000000..cb2dbb2
--- /dev/null
+++ b/app/src/app/(tabs)/index.tsx
@@ -0,0 +1,144 @@
+import { useRouter } from "expo-router";
+import { Platform, Pressable, StyleSheet, Text, View } from "react-native";
+import { NestableDraggableFlatList, NestableScrollContainer, ScaleDecorator } from "react-native-draggable-flatlist";
+import WebSortableList from "../../components/WebSortableList";
+import Icon from "../../components/Icon";
+import TaskRow, { TaskSeparator } from "../../components/TaskRow";
+import { cardEdges, Empty, Screen, Title } from "../../components/ui";
+import { haptic } from "../../lib/haptics";
+import { reorderTask, todayTasks, visibleTasksByStatus } from "../../lib/store";
+import { C, radius, shadow, space, type } from "../../lib/theme";
+import { useStore } from "../../lib/useStore";
+
+const PREVIEW = 3;
+
+/**
+ * One small pill of status — overdue count, offline, sync trouble. Sits under
+ * the title instead of in its own section, so a bad day costs one line, not a
+ * card, and a good day costs nothing.
+ */
+function StatusPill({ tone, text, onPress }: { tone: string; text: string; onPress?: () => void }) {
+ return (
+ [s.pill, { backgroundColor: `${tone}14` }, pressed && { opacity: 0.6 }]}
+ >
+
+ {text}
+ {!!onPress && }
+
+ );
+}
+
+export default function Home() {
+ const router = useRouter();
+ const store = useStore();
+ const next = visibleTasksByStatus(store, "next");
+ const { overdue } = todayTasks(store);
+ const preview = next.slice(0, PREVIEW);
+ const attention = overdue.length > 0 || store.offline || !!store.syncError;
+
+ return (
+
+
+ Next up
+
+ {attention && (
+
+ {overdue.length > 0 && (
+ router.push("/today")} />
+ )}
+ {store.offline && (
+ 0 ? `Offline · ${store.pending} queued` : "Offline"}
+ />
+ )}
+ {!!store.syncError && (
+ router.push("/settings")} />
+ )}
+
+ )}
+
+ {preview.length === 0 ? (
+ Nothing queued. Add the next thing on your mind.
+ ) : Platform.OS === "web" ? (
+ reorderTask(id, "next", to)}
+ separator={}
+ renderItem={(t, i) => (
+
+
+
+ )}
+ />
+ ) : (
+ // Draggable so the "do this now" slot is a hand-picked choice, not
+ // just whatever sorted first. Preview indices are next[0..N], so a
+ // drop reorders the real Next list.
+ t.id}
+ activationDistance={12}
+ onDragBegin={() => haptic.pickup()}
+ onDragEnd={({ data, from, to }) => {
+ if (from !== to) {
+ haptic.success();
+ reorderTask(data[to].id, "next", to);
+ }
+ }}
+ renderItem={({ item, drag, isActive, getIndex }) => {
+ const i = getIndex() ?? 0;
+ return (
+
+
+
+
+
+ );
+ }}
+ ItemSeparatorComponent={TaskSeparator}
+ />
+ )}
+
+ {next.length > PREVIEW && (
+ router.push("/list/next")}
+ style={({ pressed }) => [s.viewAll, pressed && { opacity: 0.6 }]}
+ >
+ All {next.length} next actions
+
+
+ )}
+
+
+ );
+}
+
+const s = StyleSheet.create({
+ content: { paddingHorizontal: space.lg, paddingBottom: 96 },
+ lifted: { borderRadius: radius.lg, ...shadow.fab },
+
+ strip: { flexDirection: "row", flexWrap: "wrap", gap: space.sm, marginBottom: space.md },
+ pill: {
+ flexDirection: "row",
+ alignItems: "center",
+ gap: 6,
+ paddingVertical: 5,
+ paddingHorizontal: space.md,
+ borderRadius: radius.pill,
+ },
+ dot: { width: 6, height: 6, borderRadius: 999 },
+ pillText: { ...type.meta, fontWeight: "600" },
+
+ viewAll: {
+ flexDirection: "row",
+ alignItems: "center",
+ gap: space.xs,
+ paddingVertical: space.md,
+ paddingHorizontal: space.xs,
+ },
+ viewAllText: { ...type.body, color: C.accent, fontWeight: "500" },
+});
diff --git a/app/src/app/(tabs)/lists.tsx b/app/src/app/(tabs)/lists.tsx
new file mode 100644
index 0000000..82ac071
--- /dev/null
+++ b/app/src/app/(tabs)/lists.tsx
@@ -0,0 +1,230 @@
+// The Lists board: four quadrants, one screen.
+//
+// Next up, Waiting, Someday and Projects used to be four separate launcher
+// buttons. Here they share a 2×2 board so the whole open-loop picture fits
+// in one glance. On a desktop window each quadrant is a full pane with its
+// own scroll; on a phone each is a card showing the first few rows, and the
+// quadrant header opens the full list.
+import { useRouter, type Href } from "expo-router";
+import { Pressable, ScrollView, StyleSheet, Text, useWindowDimensions, View, type StyleProp, type ViewStyle } from "react-native";
+import Icon, { type IconName } from "../../components/Icon";
+import { Card, Separator } from "../../components/ui";
+import { dueMeta } from "../../lib/dates";
+import { haptic } from "../../lib/haptics";
+import { PRIORITY_COLOR } from "../../lib/priority";
+import { activeProjects, openTaskCount, visibleTasksByStatus } from "../../lib/store";
+import { C, space, type } from "../../lib/theme";
+import { completeWithUndo } from "../../lib/undo";
+import { useStore } from "../../lib/useStore";
+import type { Project, Task } from "../../lib/types";
+
+/** Two panes side by side need roughly this much to stay readable. */
+const WIDE = 720;
+/** Rows a phone-sized quadrant shows before handing off to the full list. */
+const PREVIEW = 4;
+
+function TaskMini({ task, wide }: { task: Task; wide: boolean }) {
+ const router = useRouter();
+ const due = task.due_date ? dueMeta(task.due_date) : null;
+ const dueColor = due?.tone === "overdue" ? C.danger : due?.tone === "today" ? C.warn : C.muted;
+ return (
+ [s.row, pressed && s.rowPressed]}
+ onPress={() => router.push(`/task/${task.id}`)}
+ >
+ completeWithUndo(task)}
+ />
+
+ {task.title}
+
+ {/* Phone-width cards can't spare the room; overdue still shows as a dot. */}
+ {due && wide ? (
+ {due.label}
+ ) : due?.tone === "overdue" ? (
+
+ ) : null}
+
+ );
+}
+
+function ProjectMini({ project, open }: { project: Project; open: number }) {
+ const router = useRouter();
+ return (
+ [s.row, pressed && s.rowPressed]}
+ onPress={() => router.push(`/project/${project.id}`)}
+ >
+
+
+ {project.name}
+
+ {open === 0 ? "" : String(open)}
+
+
+ );
+}
+
+type Pane = {
+ key: string;
+ icon: IconName;
+ title: string;
+ /** Full-screen list the header opens. */
+ href: Href;
+ count: number;
+ empty: string;
+ rows: React.ReactNode[];
+};
+
+function Quadrant({ pane, wide, style }: { pane: Pane; wide: boolean; style?: StyleProp }) {
+ const router = useRouter();
+ const shown = wide ? pane.rows : pane.rows.slice(0, PREVIEW);
+ const hidden = pane.rows.length - shown.length;
+ const open = () => {
+ haptic.tap();
+ router.push(pane.href);
+ };
+ const body = (
+ <>
+ {shown.length === 0 && {pane.empty}}
+ {shown.map((row, i) => (
+
+ {i > 0 && }
+ {row}
+
+ ))}
+ >
+ );
+ return (
+
+ [s.paneHeader, pressed && { backgroundColor: C.surface2 }]}
+ >
+
+ {pane.title}
+ {pane.count > 0 && {pane.count}}
+
+
+
+
+ {wide ? (
+
+ {body}
+
+ ) : (
+
+ {body}
+ {hidden > 0 && (
+ [s.more, pressed && { opacity: 0.6 }]}>
+ {hidden} more
+
+ )}
+
+ )}
+
+ );
+}
+
+export default function Lists() {
+ const store = useStore();
+ const { width } = useWindowDimensions();
+ const wide = width >= WIDE;
+
+ const next = visibleTasksByStatus(store, "next");
+ const waiting = visibleTasksByStatus(store, "waiting");
+ const someday = visibleTasksByStatus(store, "someday");
+ const projects = activeProjects(store).filter((p) => p.status === "active");
+ const taskRows = (tasks: Task[]) => tasks.map((t) => );
+
+ const panes: Pane[] = [
+ { key: "next", icon: "next", title: "Next up", href: "/list/next", count: next.length, empty: "Nothing next.", rows: taskRows(next) },
+ { key: "waiting", icon: "waiting", title: "Waiting", href: "/list/waiting", count: waiting.length, empty: "Not waiting on anyone.", rows: taskRows(waiting) },
+ { key: "someday", icon: "someday", title: "Someday", href: "/list/someday", count: someday.length, empty: "No someday items.", rows: taskRows(someday) },
+ {
+ key: "projects",
+ icon: "projects",
+ title: "Projects",
+ href: "/projects",
+ count: projects.length,
+ empty: "No projects yet.",
+ rows: projects.map((p) => ),
+ },
+ ];
+
+ if (wide) {
+ return (
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+
+ // Two columns that wrap. The container grows to the viewport and the two
+ // lines share its height (alignContent: stretch), so the board always looks
+ // like four quadrants — even when every list is empty.
+ const col = (width - space.lg * 2 - space.md) / 2;
+ return (
+
+ {panes.map((p) => (
+
+ ))}
+
+ );
+}
+
+const s = StyleSheet.create({
+ board: { flex: 1, padding: space.lg, gap: space.md },
+ boardRow: { flex: 1, flexDirection: "row", gap: space.md },
+ grid: {
+ flexGrow: 1,
+ flexDirection: "row",
+ flexWrap: "wrap",
+ alignContent: "stretch",
+ gap: space.md,
+ paddingHorizontal: space.lg,
+ paddingTop: space.md,
+ paddingBottom: 96,
+ },
+
+ pane: { minHeight: 150 },
+ paneHeader: {
+ flexDirection: "row",
+ alignItems: "center",
+ gap: space.sm,
+ paddingHorizontal: space.lg,
+ paddingVertical: space.md,
+ },
+ paneTitle: { fontSize: 15, fontWeight: "600", letterSpacing: -0.1, color: C.text },
+ paneCount: { ...type.meta, color: C.muted, fontWeight: "600" },
+
+ row: {
+ flexDirection: "row",
+ alignItems: "center",
+ gap: space.sm,
+ paddingHorizontal: space.lg,
+ paddingVertical: space.sm + 3,
+ },
+ rowPressed: { backgroundColor: C.surface2 },
+ check: { width: 18, height: 18, borderRadius: 6, borderWidth: 1.5 },
+ projectDot: { width: 18, height: 18, borderRadius: 5, backgroundColor: C.surface2 },
+ rowTitle: { flex: 1, fontSize: 15, letterSpacing: -0.1, color: C.text },
+ rowMeta: { ...type.meta, color: C.muted },
+ dueDot: { width: 6, height: 6, borderRadius: 999 },
+
+ empty: { ...type.meta, color: C.muted, paddingHorizontal: space.lg, paddingVertical: space.md },
+ more: { paddingHorizontal: space.lg, paddingVertical: space.sm + 2 },
+ moreText: { ...type.meta, color: C.accent, fontWeight: "500" },
+});
diff --git a/app/src/app/today.tsx b/app/src/app/(tabs)/today.tsx
similarity index 71%
rename from app/src/app/today.tsx
rename to app/src/app/(tabs)/today.tsx
index fec724c..8946fd8 100644
--- a/app/src/app/today.tsx
+++ b/app/src/app/(tabs)/today.tsx
@@ -1,16 +1,16 @@
import { SectionList, StyleSheet, View } from "react-native";
-import TaskRow, { TaskSeparator } from "../components/TaskRow";
-import { cardEdges, Empty, SectionHeader } from "../components/ui";
-import { todayTasks } from "../lib/store";
-import { C, space } from "../lib/theme";
-import { useStore } from "../lib/useStore";
+import TaskRow, { TaskSeparator } from "../../components/TaskRow";
+import { cardEdges, Empty, SectionHeader, Title } from "../../components/ui";
+import { todayTasks } from "../../lib/store";
+import { C, space } from "../../lib/theme";
+import { useStore } from "../../lib/useStore";
export default function Today() {
const store = useStore();
const { overdue, today } = todayTasks(store);
const sections = [
...(overdue.length ? [{ title: "Overdue", data: overdue, color: C.danger }] : []),
- { title: "Today", data: today, color: C.textSecondary },
+ { title: "Due today", data: today, color: C.textSecondary },
];
const empty = overdue.length === 0 && today.length === 0;
@@ -19,6 +19,7 @@ export default function Today() {
style={s.screen}
sections={sections}
keyExtractor={(t) => t.id}
+ ListHeaderComponent={Today}
// Each section is its own rounded card, so the first and last rows in a
// section round their outer corners.
renderItem={({ item, index, section }) => (
@@ -40,5 +41,5 @@ export default function Today() {
const s = StyleSheet.create({
screen: { flex: 1, backgroundColor: C.bg },
- content: { paddingHorizontal: space.lg, paddingBottom: 40 },
+ content: { paddingHorizontal: space.lg, paddingBottom: 96 },
});
diff --git a/app/src/app/_layout.tsx b/app/src/app/_layout.tsx
index 06783e9..1ff541a 100644
--- a/app/src/app/_layout.tsx
+++ b/app/src/app/_layout.tsx
@@ -1,14 +1,12 @@
import NetInfo from "@react-native-community/netinfo";
-import { Link, Stack } from "expo-router";
+import { Stack } from "expo-router";
import { StatusBar } from "expo-status-bar";
import { useEffect } from "react";
-import { AppState, Pressable } from "react-native";
+import { AppState } from "react-native";
import { GestureHandlerRootView } from "react-native-gesture-handler";
import { getState, hydrate, subscribe, sync } from "../lib/store";
-import AreaToggle from "../components/AreaToggle";
-import Icon from "../components/Icon";
import { ToastHost } from "../components/Toast";
-import { C, space } from "../lib/theme";
+import { C } from "../lib/theme";
import { refreshNextUpWidget } from "../widget/refresh";
export default function RootLayout() {
@@ -51,34 +49,12 @@ export default function RootLayout() {
contentStyle: { backgroundColor: C.bg },
}}
>
- ,
- headerTitleAlign: "center",
- headerRight: () => (
-
- [{ paddingHorizontal: space.sm }, pressed && { opacity: 0.5 }]}
- >
-
-
-
- ),
- }}
- />
+
-
-
diff --git a/app/src/app/index.tsx b/app/src/app/index.tsx
deleted file mode 100644
index 3556a63..0000000
--- a/app/src/app/index.tsx
+++ /dev/null
@@ -1,227 +0,0 @@
-import { useFocusEffect, useRouter } from "expo-router";
-import { Platform, Pressable, StyleSheet, Text, View } from "react-native";
-import { NestableDraggableFlatList, NestableScrollContainer, ScaleDecorator } from "react-native-draggable-flatlist";
-import WebSortableList from "../components/WebSortableList";
-import Icon, { type IconName } from "../components/Icon";
-import TaskRow, { TaskSeparator } from "../components/TaskRow";
-import { Card, cardEdges, Empty, Screen, SectionHeader, Title } from "../components/ui";
-import { haptic } from "../lib/haptics";
-import { daysSince, useLastReview } from "../lib/review";
-import { reorderTask, todayTasks, visibleTasksByStatus } from "../lib/store";
-import { C, radius, shadow, space, type } from "../lib/theme";
-import { useStore } from "../lib/useStore";
-
-const MENU: { icon: IconName; label: string; href: string }[] = [
- { icon: "today", label: "Today", href: "/today" },
- { icon: "next", label: "Next", href: "/list/next" },
- { icon: "waiting", label: "Waiting", href: "/list/waiting" },
- { icon: "someday", label: "Someday", href: "/list/someday" },
- { icon: "projects", label: "Projects", href: "/projects" },
- { icon: "review", label: "Review", href: "/review" },
-];
-
-const PREVIEW = 3;
-
-/**
- * One tappable line of status — overdue, review due, sync trouble. Rendered as
- * a real row with a chevron rather than coloured micro-text, so it reads as
- * something you can act on.
- */
-function Alert({ tone, text, onPress }: { tone: string; text: string; onPress?: () => void }) {
- const body = (
-
-
- {text}
- {!!onPress && }
-
- );
- if (!onPress) return body;
- return (
- pressed && { opacity: 0.6 }}>
- {body}
-
- );
-}
-
-export default function Home() {
- const router = useRouter();
- const store = useStore();
- const next = visibleTasksByStatus(store, "next");
- const { overdue } = todayTasks(store);
- const [lastReview, reloadReview] = useLastReview();
- useFocusEffect(reloadReview);
- const reviewDays = daysSince(lastReview);
- const reviewDue = lastReview === null || (reviewDays !== null && reviewDays >= 7);
- const preview = next.slice(0, PREVIEW);
-
- return (
-
-
- Next up
-
- {preview.length === 0 ? (
- Nothing queued. Add the next thing on your mind.
- ) : Platform.OS === "web" ? (
- reorderTask(id, "next", to)}
- separator={}
- renderItem={(t, i) => (
-
-
-
- )}
- />
- ) : (
- // Draggable so the "do this now" slot is a hand-picked choice, not
- // just whatever sorted first. Preview indices are next[0..N], so a
- // drop reorders the real Next list.
- t.id}
- activationDistance={12}
- onDragBegin={() => haptic.pickup()}
- onDragEnd={({ data, from, to }) => {
- if (from !== to) {
- haptic.success();
- reorderTask(data[to].id, "next", to);
- }
- }}
- renderItem={({ item, drag, isActive, getIndex }) => {
- const i = getIndex() ?? 0;
- return (
-
-
-
-
-
- );
- }}
- ItemSeparatorComponent={TaskSeparator}
- />
- )}
-
- {next.length > PREVIEW && (
- router.push("/list/next")}
- style={({ pressed }) => [s.viewAll, pressed && { opacity: 0.6 }]}
- >
- All {next.length} next actions
-
-
- )}
-
- {(overdue.length > 0 || reviewDue || store.offline || !!store.syncError) && (
- <>
- Needs attention
-
- {overdue.length > 0 && (
- router.push("/today")}
- />
- )}
- {reviewDue && (
- router.push("/review")}
- />
- )}
- {store.offline && (
- 0
- ? `Offline — ${store.pending} change${store.pending === 1 ? "" : "s"} queued`
- : "Offline"
- }
- />
- )}
- {!!store.syncError && }
-
- >
- )}
-
-
-
- {MENU.map((m) => (
- [s.tab, pressed && { opacity: 0.5 }]}
- onPress={() => {
- haptic.tap();
- router.push(m.href);
- }}
- >
-
- {m.label}
-
- ))}
-
-
- [s.fab, pressed && { transform: [{ scale: 0.94 }] }]}
- onPress={() => {
- haptic.tap();
- router.push("/add");
- }}
- >
-
-
-
- );
-}
-
-const s = StyleSheet.create({
- content: { paddingHorizontal: space.lg, paddingBottom: 120 },
- lifted: { borderRadius: radius.lg, ...shadow.fab },
-
- viewAll: {
- flexDirection: "row",
- alignItems: "center",
- gap: space.xs,
- paddingVertical: space.md,
- paddingHorizontal: space.xs,
- },
- viewAllText: { ...type.body, color: C.accent, fontWeight: "500" },
-
- alert: {
- flexDirection: "row",
- alignItems: "center",
- gap: space.md,
- paddingVertical: space.md + 1,
- paddingHorizontal: space.lg,
- },
- dot: { width: 7, height: 7, borderRadius: 999 },
- alertText: { ...type.body, flex: 1, fontWeight: "500" },
-
- tabBar: {
- flexDirection: "row",
- backgroundColor: C.surface,
- borderTopWidth: StyleSheet.hairlineWidth,
- borderTopColor: C.border,
- paddingTop: space.md,
- paddingBottom: space.xxl,
- },
- tab: { flex: 1, alignItems: "center", gap: 5 },
- tabLabel: { fontSize: 11, color: C.textSecondary, letterSpacing: 0.1 },
-
- fab: {
- position: "absolute",
- right: space.xl,
- bottom: 104,
- width: 56,
- height: 56,
- borderRadius: 28,
- backgroundColor: C.accent,
- alignItems: "center",
- justifyContent: "center",
- ...shadow.fab,
- },
-});
diff --git a/app/src/app/review.tsx b/app/src/app/review.tsx
deleted file mode 100644
index 772f9be..0000000
--- a/app/src/app/review.tsx
+++ /dev/null
@@ -1,208 +0,0 @@
-// Weekly review — GTD's keystone habit. One pass, one decision per row:
-// each row has exactly three quick actions (do next / someday / trash) plus
-// done on the checkbox, so nothing needs a trip through the edit modal.
-import { useRouter } from "expo-router";
-import { Pressable, ScrollView, StyleSheet, Text, View } from "react-native";
-import Icon from "../components/Icon";
-import { Button, Card, Empty, SectionHeader } from "../components/ui";
-import { friendlyDate } from "../lib/dates";
-import { haptic } from "../lib/haptics";
-import { PRIORITY_COLOR } from "../lib/priority";
-import { daysSince, markReviewed, useLastReview } from "../lib/review";
-import { reviewBuckets, trashTask, updateTask } from "../lib/store";
-import { completeWithUndo } from "../lib/undo";
-import { C, radius, space, type } from "../lib/theme";
-import { useStore } from "../lib/useStore";
-import type { Task } from "../lib/types";
-
-function ReviewRow({
- task,
- showDefer,
- last,
-}: {
- task: Task;
- showDefer?: boolean;
- /** The card already draws the bottom edge — don't double it up. */
- last?: boolean;
-}) {
- const router = useRouter();
- const act = (fn: () => void) => () => {
- haptic.select();
- fn();
- };
- return (
-
- completeWithUndo(task)}
- >
-
-
- router.push(`/task/${task.id}`)}>
-
- {task.title}
-
-
- {[
- task.status,
- task.waiting_for && `→ ${task.waiting_for}`,
- showDefer && task.defer_date && `resurfaces ${friendlyDate(task.defer_date)}`,
- ]
- .filter(Boolean)
- .join(" · ")}
-
-
- {task.status !== "next" && (
- [s.action, pressed && s.actionPressed]}
- hitSlop={6}
- onPress={act(() => updateTask(task.id, { status: "next" }))}
- >
-
-
- )}
- {task.status !== "someday" && (
- [s.action, pressed && s.actionPressed]}
- hitSlop={6}
- onPress={act(() => updateTask(task.id, { status: "someday" }))}
- >
-
-
- )}
- [s.action, pressed && s.actionPressed]}
- hitSlop={6}
- onPress={act(() => trashTask(task.id))}
- >
- Drop
-
-
- );
-}
-
-function Section({ title, hint, tasks, showDefer }: { title: string; hint: string; tasks: Task[]; showDefer?: boolean }) {
- if (tasks.length === 0) return null;
- return (
-
-
- {title} ({tasks.length})
-
- {hint}
-
- {tasks.map((t, i) => (
-
- ))}
-
-
- );
-}
-
-export default function Review() {
- const router = useRouter();
- const store = useStore();
- const buckets = reviewBuckets(store);
- const [last, reload] = useLastReview();
- const days = daysSince(last);
-
- const empty =
- !buckets.overdue.length &&
- !buckets.staleNext.length &&
- !buckets.agingWaiting.length &&
- !buckets.someday.length &&
- !buckets.deferred.length &&
- !buckets.stuckProjects.length;
-
- return (
-
-
- {last ? (days === 0 ? "Last review: today" : `Last review: ${days}d ago`) : "First review — welcome."}
-
-
-
-
-
-
-
-
- {buckets.stuckProjects.length > 0 && (
-
- Stuck projects ({buckets.stuckProjects.length})
- Active, but no next action — decide the next step or park them.
-
- {buckets.stuckProjects.map((p, i) => (
- [
- s.row,
- i === buckets.stuckProjects.length - 1 && s.rowLast,
- pressed && { backgroundColor: C.surface2 },
- ]}
- onPress={() => router.push(`/project/${p.id}`)}
- >
- {p.name}
-
-
- ))}
-
-
- )}
-
- {empty && All clear. Nothing needs a decision.}
-
-
- );
-}
-
-const s = StyleSheet.create({
- content: { padding: space.lg, paddingBottom: 48 },
- lastReview: { ...type.meta, color: C.textSecondary, marginBottom: space.xs },
- sectionHint: { ...type.meta, color: C.muted, paddingHorizontal: space.xs, marginBottom: space.sm },
- row: {
- flexDirection: "row",
- alignItems: "center",
- gap: space.md,
- paddingVertical: space.md,
- paddingHorizontal: space.lg,
- borderBottomWidth: StyleSheet.hairlineWidth,
- borderBottomColor: C.separator,
- backgroundColor: C.surface,
- },
- rowLast: { borderBottomWidth: 0 },
- check: { paddingRight: 2 },
- checkbox: { width: 21, height: 21, borderRadius: 7, borderWidth: 1.5 },
- title: { ...type.body, color: C.text },
- meta: { ...type.meta, fontSize: 12, color: C.muted, marginTop: 2 },
- action: {
- minWidth: 30,
- height: 28,
- paddingHorizontal: space.sm,
- borderRadius: radius.sm,
- alignItems: "center",
- justifyContent: "center",
- },
- actionPressed: { backgroundColor: C.surface2 },
- dropText: { ...type.meta, color: C.danger, fontWeight: "500" },
- archiveRow: { flexDirection: "row", justifyContent: "center", gap: space.sm, marginTop: space.xs },
- archiveLink: { ...type.meta, color: C.textSecondary, padding: space.sm },
- archiveDot: { color: C.muted, alignSelf: "center" },
-});
diff --git a/app/src/app/settings.tsx b/app/src/app/settings.tsx
index f76060f..c816811 100644
--- a/app/src/app/settings.tsx
+++ b/app/src/app/settings.tsx
@@ -1,8 +1,9 @@
import * as Application from "expo-application";
import { useRouter } from "expo-router";
import { useEffect, useState } from "react";
-import { ScrollView, StyleSheet, Text } from "react-native";
-import { Button, Field } from "../components/ui";
+import { Pressable, ScrollView, StyleSheet, Text, View } from "react-native";
+import Icon from "../components/Icon";
+import { Button, Card, Field, SectionHeader, Separator } from "../components/ui";
import { api } from "../lib/api";
import { DEFAULT_URL, getConfig, setConfig } from "../lib/config";
import { sync } from "../lib/store";
@@ -45,6 +46,24 @@ export default function Settings() {
/>
{status && {status}}
+
+ {/* Done and Trash are archives, not lists you work from — they live
+ behind the gear rather than on the bar. */}
+ Archive
+
+ {(["done", "trashed"] as const).map((st, i) => (
+
+ {i > 0 && }
+ router.push(`/list/${st}`)}
+ style={({ pressed }) => [s.archiveRow, pressed && { backgroundColor: C.surface2 }]}
+ >
+ {st === "done" ? "Done" : "Trash"}
+
+
+
+ ))}
+
{/* CI stamps versionName from the release tag — confirms which build Obtainium installed. */}
GTD v{Application.nativeApplicationVersion ?? "dev"}
@@ -54,5 +73,13 @@ export default function Settings() {
const s = StyleSheet.create({
content: { padding: space.lg },
error: { ...type.meta, color: C.danger, marginTop: space.xs },
+ archiveRow: {
+ flexDirection: "row",
+ alignItems: "center",
+ justifyContent: "space-between",
+ paddingHorizontal: space.lg,
+ paddingVertical: space.md + 2,
+ },
+ archiveText: { ...type.body, color: C.text },
version: { ...type.meta, color: C.muted, textAlign: "center", marginTop: space.xxl },
});
diff --git a/app/src/components/Icon.tsx b/app/src/components/Icon.tsx
index aacaba6..6b2e7d1 100644
--- a/app/src/components/Icon.tsx
+++ b/app/src/components/Icon.tsx
@@ -13,7 +13,7 @@ export type IconName =
| "waiting"
| "someday"
| "projects"
- | "review"
+ | "grid"
| "settings"
| "flag"
| "repeat"
@@ -170,37 +170,18 @@ export default function Icon({ name, size = 22, color = C.textSecondary, bg = C.
);
- case "review":
+ case "grid":
+ // Four squares — the Lists board's own shape.
return (
- {/* an open ring plus an arrowhead reads as "cycle" without an SVG path */}
-
-
+
+ {[0, 1, 2, 3].map((i) => (
+
+ ))}
+
);
diff --git a/app/src/components/TabBar.tsx b/app/src/components/TabBar.tsx
new file mode 100644
index 0000000..3620836
--- /dev/null
+++ b/app/src/components/TabBar.tsx
@@ -0,0 +1,70 @@
+// The three-tab bar under every top-level screen: Next up, Today, Lists.
+//
+// Three is the number a thumb can hit without looking. The old launcher had
+// six buttons and a second row of alerts; everything that was there is still
+// reachable, but through the Lists board or the header, so the bar only
+// carries the places you go many times a day.
+import type { BottomTabBarProps } from "expo-router/js-tabs";
+import { Pressable, StyleSheet, Text, View } from "react-native";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
+import { haptic } from "../lib/haptics";
+import { C, space } from "../lib/theme";
+import { useStore } from "../lib/useStore";
+import { AREA_TONE } from "./AreaToggle";
+import Icon, { type IconName } from "./Icon";
+
+const TABS: Record = {
+ index: { icon: "next", label: "Next up" },
+ today: { icon: "today", label: "Today" },
+ lists: { icon: "grid", label: "Lists" },
+};
+
+const ICON = 22;
+const LABEL = 14;
+/** Bar height above the safe-area inset — the FAB sits this far up plus a gutter. */
+export const TAB_BAR_CONTENT_HEIGHT = space.md + ICON + 5 + LABEL;
+export const tabBarInset = (bottom: number) => Math.max(bottom, space.md);
+
+export default function TabBar({ state, navigation }: BottomTabBarProps) {
+ const insets = useSafeAreaInsets();
+ // The active tab takes the lens colour, so Work mode reads as Work all the
+ // way down the screen, not just in the header pill.
+ const tone = AREA_TONE[useStore().area];
+ return (
+
+ {state.routes.map((route, i) => {
+ const focused = state.index === i;
+ const tab = TABS[route.name] ?? { icon: "next" as IconName, label: route.name };
+ return (
+ [s.tab, pressed && { opacity: 0.5 }]}
+ onPress={() => {
+ const e = navigation.emit({ type: "tabPress", target: route.key, canPreventDefault: true });
+ if (focused || e.defaultPrevented) return;
+ haptic.tap();
+ navigation.navigate(route.name);
+ }}
+ >
+
+ {tab.label}
+
+ );
+ })}
+
+ );
+}
+
+const s = StyleSheet.create({
+ bar: {
+ flexDirection: "row",
+ backgroundColor: C.surface,
+ borderTopWidth: StyleSheet.hairlineWidth,
+ borderTopColor: C.border,
+ paddingTop: space.md,
+ },
+ tab: { flex: 1, alignItems: "center", gap: 5 },
+ label: { fontSize: 11, lineHeight: LABEL, color: C.textSecondary, letterSpacing: 0.1 },
+});
diff --git a/app/src/components/WebSortableList.tsx b/app/src/components/WebSortableList.tsx
index 49e8429..ae90f73 100644
--- a/app/src/components/WebSortableList.tsx
+++ b/app/src/components/WebSortableList.tsx
@@ -5,7 +5,7 @@
// move/up listeners, and on release the target slot is computed from measured
// row heights and handed to onReorder (the same store mutation native uses).
import { useState, type ReactNode } from "react";
-import { StyleSheet, Text, View, type GestureResponderEvent } from "react-native";
+import { StyleSheet, Text, View, type GestureResponderEvent, type ViewStyle } from "react-native";
import { C, space } from "../lib/theme";
export default function WebSortableList({
@@ -77,7 +77,7 @@ export default function WebSortableList({
>
{renderItem(item, i)}
{!disabled && (
- startDrag(i, e as unknown as GestureResponderEvent)} style={s.handle}>
+ startDrag(i, e as unknown as GestureResponderEvent)} style={handleStyle}>
⠿
)}
@@ -106,10 +106,10 @@ const s = StyleSheet.create({
width: 44,
alignItems: "center",
justifyContent: "center",
- // @ts-expect-error web-only style
- cursor: "grab",
- touchAction: "none",
- userSelect: "none",
},
handleGlyph: { color: C.muted, fontSize: 15, paddingHorizontal: space.sm },
});
+
+// Pointer affordances react-native-web honours but RN's ViewStyle doesn't
+// type ("grab" is not a CursorValue) — cast once, here, instead of per use.
+const handleStyle = { ...s.handle, cursor: "grab", touchAction: "none", userSelect: "none" } as unknown as ViewStyle;
diff --git a/app/src/components/ui.tsx b/app/src/components/ui.tsx
index faa530c..cdc7743 100644
--- a/app/src/components/ui.tsx
+++ b/app/src/components/ui.tsx
@@ -5,7 +5,7 @@ import { C, radius, shadow, space, type } from "../lib/theme";
* Screen background + consistent gutter. Every screen sits on the grouped
* background so white cards read as raised paper.
*/
-export function Screen({ children, style }: { children: React.ReactNode; style?: ViewStyle }) {
+export function Screen({ children, style }: { children: React.ReactNode; style?: StyleProp }) {
return {children};
}
@@ -32,7 +32,7 @@ export function SectionHeader({ children, tone }: { children: React.ReactNode; t
* Rounded white group. Rows inside are separated by inset hairlines rather
* than full-bleed dividers, so the card keeps a clean silhouette.
*/
-export function Card({ children, style }: { children: React.ReactNode; style?: ViewStyle }) {
+export function Card({ children, style }: { children: React.ReactNode; style?: StyleProp }) {
return {children};
}
diff --git a/app/src/lib/review.ts b/app/src/lib/review.ts
deleted file mode 100644
index 325db85..0000000
--- a/app/src/lib/review.ts
+++ /dev/null
@@ -1,29 +0,0 @@
-// Last-completed-review timestamp, local only — the review is a habit, not
-// synced data.
-import AsyncStorage from "@react-native-async-storage/async-storage";
-import { useCallback, useEffect, useState } from "react";
-
-const KEY = "review_last_v1";
-
-export async function getLastReview(): Promise {
- return AsyncStorage.getItem(KEY);
-}
-
-export async function markReviewed(): Promise {
- await AsyncStorage.setItem(KEY, new Date().toISOString());
-}
-
-export function daysSince(iso: string | null): number | null {
- if (!iso) return null;
- return Math.floor((Date.now() - new Date(iso).getTime()) / 86_400_000);
-}
-
-/** [lastReviewIso, reload] — call reload on focus to pick up changes. */
-export function useLastReview(): [string | null, () => void] {
- const [last, setLast] = useState(null);
- const reload = useCallback(() => {
- void getLastReview().then(setLast);
- }, []);
- useEffect(reload, [reload]);
- return [last, reload];
-}
diff --git a/app/src/lib/store.test.ts b/app/src/lib/store.test.ts
index e62fea0..2b52252 100644
--- a/app/src/lib/store.test.ts
+++ b/app/src/lib/store.test.ts
@@ -104,15 +104,14 @@ describe("area lens", () => {
expect(store.tasksByStatus(store.getState(), "next").some((t) => t.id === legacy.id)).toBe(true);
});
- test("today, review and contexts respect the lens", () => {
+ test("today and contexts respect the lens", () => {
store.createTask({ title: "p", due_date: "2000-01-01", context: "@home" });
store.setArea("work");
store.createTask({ title: "w", due_date: "2000-01-01", context: "@office" });
const s = store.getState();
expect(store.todayTasks(s).overdue.every((t) => t.area === "work")).toBe(true);
expect(store.todayTasks(s).overdue.map((t) => t.title)).toContain("w");
- expect(store.reviewBuckets(s).overdue.map((t) => t.title)).toContain("w");
- expect(store.reviewBuckets(s).overdue.map((t) => t.title)).not.toContain("p");
+ expect(store.todayTasks(s).overdue.map((t) => t.title)).not.toContain("p");
expect(store.knownContexts(s)).toEqual(["@office"]);
});
diff --git a/app/src/lib/store.ts b/app/src/lib/store.ts
index 9ac0085..79d5363 100644
--- a/app/src/lib/store.ts
+++ b/app/src/lib/store.ts
@@ -426,43 +426,3 @@ export function knownContexts(s: State): string[] {
}
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 = inArea(s, 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)),
- ),
- };
-}