diff --git a/README.md b/README.md
index 2c378b1..270a556 100644
--- a/README.md
+++ b/README.md
@@ -9,7 +9,11 @@ Minimal Getting Things Done app. One Expo/React Native codebase for iPhone, Andr
## Layout
-- `app/` — Expo app (expo-router, TypeScript). Tabs: Inbox / Next / Waiting / Someday / Projects; tap a task to clarify (list, project, context, waiting-for, due/defer dates).
+- `app/` — Expo app (expo-router, TypeScript). Home shows the top 3 next actions plus a launcher menu: Today / Next / Waiting / Someday / Projects / Review. There is no inbox — every task is born fully scoped (title, priority, due date). Tap a task to clarify (list, project, context, waiting-for, due/defer dates, repeat).
+ - **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/eslint.config.js b/app/eslint.config.js
new file mode 100644
index 0000000..ba708ed
--- /dev/null
+++ b/app/eslint.config.js
@@ -0,0 +1,10 @@
+// https://docs.expo.dev/guides/using-eslint/
+const { defineConfig } = require('eslint/config');
+const expoConfig = require("eslint-config-expo/flat");
+
+module.exports = defineConfig([
+ expoConfig,
+ {
+ ignores: ["dist/*"],
+ }
+]);
diff --git a/app/package.json b/app/package.json
index e49905b..7053016 100644
--- a/app/package.json
+++ b/app/package.json
@@ -34,6 +34,8 @@
},
"devDependencies": {
"@types/react": "~19.2.2",
+ "eslint": "^9.0.0",
+ "eslint-config-expo": "~57.0.1",
"typescript": "~6.0.3"
},
"scripts": {
diff --git a/app/src/app/_layout.tsx b/app/src/app/_layout.tsx
index 69948af..fecf69d 100644
--- a/app/src/app/_layout.tsx
+++ b/app/src/app/_layout.tsx
@@ -57,6 +57,7 @@ export default function RootLayout() {
+
diff --git a/app/src/app/add.tsx b/app/src/app/add.tsx
index f3ca933..ef57f79 100644
--- a/app/src/app/add.tsx
+++ b/app/src/app/add.tsx
@@ -1,14 +1,17 @@
import { useRouter } from "expo-router";
-import { useState } from "react";
-import { Pressable, ScrollView, StyleSheet, Text, View } from "react-native";
+import { useRef, useState } from "react";
+import { Pressable, ScrollView, StyleSheet, Text } from "react-native";
import DateField from "../components/DateField";
+import EstimateChips from "../components/EstimateChips";
import PriorityChips from "../components/PriorityChips";
import { Chips, Field } from "../components/ui";
import { haptic } from "../lib/haptics";
+import { parseQuickAdd, type ParsedQuickAdd } from "../lib/quickadd";
+import { REPEAT_LABEL, REPEATS } from "../lib/repeat";
import { activeProjects, createTask } from "../lib/store";
import { C } from "../lib/theme";
import { useStore } from "../lib/useStore";
-import type { Task, TaskStatus } from "../lib/types";
+import type { Repeat, Task, TaskStatus } from "../lib/types";
const BOXES: { key: TaskStatus; label: string }[] = [
{ key: "next", label: "Next up" },
@@ -16,37 +19,62 @@ const BOXES: { key: TaskStatus; label: string }[] = [
{ key: "someday", label: "Someday" },
];
-const ESTIMATES = [15, 30, 45, 60, 90, 120];
-
export default function AddTask() {
const router = useRouter();
const store = useStore();
- const [title, setTitle] = useState("");
+ const [raw, setRaw] = useState("");
const [notes, setNotes] = useState("");
const [priority, setPriority] = useState(null);
const [estimate, setEstimate] = useState(null);
const [due, setDue] = useState("");
+ const [repeat, setRepeat] = useState(null);
const [box, setBox] = useState("next");
+ const [waitingFor, setWaitingFor] = useState("");
const [projectId, setProjectId] = useState(null);
+ const [context, setContext] = useState(null);
const projects = activeProjects(store).filter((p) => p.status === "active");
const projectOptions = ["none", ...projects.map((p) => p.id)];
const projectLabels = Object.fromEntries([["none", "None"], ...projects.map((p) => [p.id, p.name])]);
+ // The title field understands tokens ("Pay rent tmr p1 @home ~30m monthly")
+ // and mirrors each one into the form controls below as you type, so capture
+ // is one line but every inference stays visible and correctable. A token
+ // only writes its field when it changes — chips tapped by hand stick.
+ const prevParsed = useRef({ title: "" });
+ const onTitleChange = (text: string) => {
+ setRaw(text);
+ const p = parseQuickAdd(text, projects);
+ const prev = prevParsed.current;
+ if (p.priority !== prev.priority && p.priority) setPriority(p.priority);
+ if (p.due_date !== prev.due_date && p.due_date) setDue(p.due_date);
+ if (p.estimate_min !== prev.estimate_min && p.estimate_min) setEstimate(p.estimate_min);
+ if (p.repeat !== prev.repeat && p.repeat) setRepeat(p.repeat);
+ if (p.project_id !== prev.project_id && p.project_id) setProjectId(p.project_id);
+ if (p.context !== prev.context) setContext(p.context ?? null);
+ prevParsed.current = p;
+ };
+
+ const cleanTitle = parseQuickAdd(raw, projects).title.trim() || raw.trim();
+ const tokensUsed = raw.trim() !== cleanTitle;
+
// Every task must be born fully scoped: title + priority + due date.
const dueValid = /^\d{4}-\d{2}-\d{2}$/.test(due.trim());
- const ready = title.trim().length > 0 && priority !== null && dueValid;
+ const ready = cleanTitle.length > 0 && priority !== null && dueValid;
const save = () => {
if (!ready || priority === null) return;
createTask({
- title: title.trim(),
+ title: cleanTitle,
notes,
status: box,
priority,
estimate_min: estimate,
due_date: due.trim(),
+ repeat,
project_id: projectId,
+ context,
+ waiting_for: box === "waiting" ? waitingFor.trim() || null : null,
});
haptic.success();
router.back();
@@ -54,39 +82,32 @@ export default function AddTask() {
return (
-
+ {tokensUsed && (
+
+ → {cleanTitle || "…"}
+ {context ? ` · ${context}` : ""}
+
+ )}
setDue(v ?? "")} />
-
- Estimated time
-
- {ESTIMATES.map((m) => {
- const active = estimate === m;
- return (
- {
- haptic.select();
- setEstimate(active ? null : m);
- }}
- >
-
- {m < 60 ? `${m}m` : m % 60 ? `${Math.floor(m / 60)}h${m % 60}` : `${m / 60}h`}
-
-
- );
- })}
-
-
+ {
+ haptic.select();
+ setRepeat(v === "none" ? null : (v as Repeat));
+ }}
+ />
+
b.key)}
@@ -97,6 +118,9 @@ export default function AddTask() {
setBox(b);
}}
/>
+ {box === "waiting" && (
+
+ )}
setProjectId(v === "none" ? null : v)}
/>
+
{ready ? "Add task" : "Needs a title, priority & due date"}
@@ -114,18 +146,7 @@ export default function AddTask() {
}
const s = StyleSheet.create({
- label: { color: C.muted, fontSize: 13, marginBottom: 6, textTransform: "uppercase", letterSpacing: 0.5 },
- rowWrap: { flexDirection: "row", flexWrap: "wrap", gap: 8, marginBottom: 12 },
- chip: {
- paddingVertical: 8,
- paddingHorizontal: 14,
- borderRadius: 20,
- backgroundColor: C.surface,
- borderWidth: 1,
- borderColor: C.border,
- },
- chipActive: { backgroundColor: C.surface2, borderColor: C.accent },
- chipText: { color: C.muted, fontSize: 14 },
+ parsedHint: { color: C.accent, fontSize: 13, marginTop: -8, marginBottom: 12 },
save: {
backgroundColor: C.accent,
borderRadius: 10,
diff --git a/app/src/app/index.tsx b/app/src/app/index.tsx
index 4bb83d8..6745f67 100644
--- a/app/src/app/index.tsx
+++ b/app/src/app/index.tsx
@@ -1,8 +1,9 @@
-import { useRouter } from "expo-router";
+import { useFocusEffect, useRouter } from "expo-router";
import { Pressable, StyleSheet, Text, View } from "react-native";
import TaskRow from "../components/TaskRow";
import { haptic } from "../lib/haptics";
-import { tasksByStatus, todayTasks } from "../lib/store";
+import { daysSince, useLastReview } from "../lib/review";
+import { todayTasks, visibleTasksByStatus } from "../lib/store";
import { C } from "../lib/theme";
import { useStore } from "../lib/useStore";
@@ -12,13 +13,18 @@ const MENU = [
{ icon: "⏳", label: "Waiting", href: "/list/waiting" },
{ icon: "💭", label: "Someday", href: "/list/someday" },
{ icon: "📁", label: "Projects", href: "/projects" },
+ { icon: "🔄", label: "Review", href: "/review" },
] as const;
export default function Home() {
const router = useRouter();
const store = useStore();
- const next = tasksByStatus(store, "next");
+ 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);
return (
@@ -38,6 +44,13 @@ export default function Home() {
{overdue.length} overdue →
)}
+ {reviewDue && (
+ router.push("/review")}>
+
+ {lastReview === null ? "no review yet — start one →" : `review overdue (${reviewDays}d) →`}
+
+
+ )}
{store.offline && (
offline{store.pending > 0 ? ` — ${store.pending} change${store.pending === 1 ? "" : "s"} queued` : ""}
@@ -80,6 +93,7 @@ const s = StyleSheet.create({
empty: { color: C.muted, textAlign: "center", marginTop: 48, fontSize: 15 },
viewAll: { color: C.muted, fontSize: 14, padding: 16 },
overdue: { color: C.danger, fontSize: 14, paddingHorizontal: 16, paddingBottom: 8 },
+ reviewNudge: { color: C.warn, fontSize: 14, paddingHorizontal: 16, paddingBottom: 8 },
error: { color: C.danger, paddingHorizontal: 16, paddingTop: 8 },
offline: { color: C.muted, fontSize: 13, paddingHorizontal: 16, paddingTop: 8 },
menu: {
diff --git a/app/src/app/list/[status].tsx b/app/src/app/list/[status].tsx
index 642eba2..0c2d842 100644
--- a/app/src/app/list/[status].tsx
+++ b/app/src/app/list/[status].tsx
@@ -5,17 +5,17 @@ import type { TaskStatus } from "../../lib/types";
const TITLES: Record = {
next: "Next up",
waiting: "Waiting for",
- scheduled: "Scheduled",
someday: "Someday",
done: "Done",
+ trashed: "Trash",
};
const HINTS: Record = {
next: "Nothing next. Tap + on the home screen.",
waiting: "Not waiting on anyone.",
- scheduled: "Nothing scheduled.",
someday: "No someday/maybe items.",
done: "Nothing done yet.",
+ trashed: "Trash is empty.",
};
export default function ListByStatus() {
diff --git a/app/src/app/review.tsx b/app/src/app/review.tsx
new file mode 100644
index 0000000..a528fc3
--- /dev/null
+++ b/app/src/app/review.tsx
@@ -0,0 +1,171 @@
+// 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 { Button } 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 { C } from "../lib/theme";
+import { useStore } from "../lib/useStore";
+import type { Task } from "../lib/types";
+
+function ReviewRow({ task, showDefer }: { task: Task; showDefer?: boolean }) {
+ const router = useRouter();
+ const act = (fn: () => void) => () => {
+ haptic.select();
+ fn();
+ };
+ return (
+
+ {
+ haptic.success();
+ updateTask(task.id, { status: "done" });
+ })}
+ >
+
+
+ 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" && (
+ updateTask(task.id, { status: "next" }))}>
+ ▶️
+
+ )}
+ {task.status !== "someday" && (
+ updateTask(task.id, { status: "someday" }))}>
+ 💭
+
+ )}
+ trashTask(task.id))}>
+ 🗑️
+
+
+ );
+}
+
+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) => (
+
+ ))}
+
+ );
+}
+
+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) => (
+ router.push(`/project/${p.id}`)}>
+ {p.name}
+ open →
+
+ ))}
+
+ )}
+
+ {empty && All clear. Nothing needs a decision.}
+
+
+ );
+}
+
+const s = StyleSheet.create({
+ lastReview: { color: C.muted, fontSize: 13, marginBottom: 12 },
+ sectionTitle: {
+ color: C.text,
+ fontSize: 15,
+ fontWeight: "700",
+ paddingTop: 12,
+ },
+ sectionHint: { color: C.muted, fontSize: 13, marginBottom: 4 },
+ row: {
+ flexDirection: "row",
+ alignItems: "center",
+ gap: 10,
+ paddingVertical: 12,
+ borderBottomWidth: StyleSheet.hairlineWidth,
+ borderBottomColor: C.border,
+ },
+ check: { paddingRight: 2 },
+ checkCircle: { width: 20, height: 20, borderRadius: 10, borderWidth: 2 },
+ title: { color: C.text, fontSize: 15 },
+ meta: { color: C.muted, fontSize: 12, marginTop: 2 },
+ action: { paddingHorizontal: 4 },
+ actionText: { fontSize: 17 },
+ empty: { color: C.muted, textAlign: "center", marginVertical: 32, fontSize: 15 },
+ archiveRow: { flexDirection: "row", justifyContent: "center", gap: 8, marginTop: 4 },
+ archiveLink: { color: C.muted, fontSize: 14, padding: 6 },
+ archiveDot: { color: C.muted, alignSelf: "center" },
+});
diff --git a/app/src/app/settings.tsx b/app/src/app/settings.tsx
index 305710d..092bd05 100644
--- a/app/src/app/settings.tsx
+++ b/app/src/app/settings.tsx
@@ -23,7 +23,7 @@ export default function Settings() {
const save = async () => {
await setConfig(url, token);
try {
- await api.tasks.list({ status: "inbox" });
+ await api.tasks.list({ status: "next" });
void sync();
router.back();
} catch (e) {
diff --git a/app/src/app/task/[id].tsx b/app/src/app/task/[id].tsx
index 8045d86..d365551 100644
--- a/app/src/app/task/[id].tsx
+++ b/app/src/app/task/[id].tsx
@@ -1,13 +1,16 @@
import { useLocalSearchParams, useRouter } from "expo-router";
import { useState } from "react";
-import { ScrollView, Text } from "react-native";
+import { Pressable, ScrollView, StyleSheet, Text, View } from "react-native";
import DateField from "../../components/DateField";
+import EstimateChips from "../../components/EstimateChips";
import PriorityChips from "../../components/PriorityChips";
import { Button, Chips, Field } from "../../components/ui";
-import { activeProjects, trashTask, updateTask } from "../../lib/store";
+import { haptic } from "../../lib/haptics";
+import { REPEAT_LABEL, REPEATS } from "../../lib/repeat";
+import { activeProjects, knownContexts, trashTask, updateTask } from "../../lib/store";
import { C } from "../../lib/theme";
import { useStore } from "../../lib/useStore";
-import type { Task, TaskStatus } from "../../lib/types";
+import type { Repeat, Task, TaskStatus } from "../../lib/types";
const STATUSES: TaskStatus[] = ["next", "waiting", "someday", "done"];
@@ -35,6 +38,7 @@ export default function TaskDetail() {
defer_date: draft.defer_date,
priority: draft.priority,
estimate_min: draft.estimate_min,
+ repeat: draft.repeat,
});
router.back();
};
@@ -42,18 +46,30 @@ export default function TaskDetail() {
const projects = activeProjects(store).filter((p) => p.status === "active" || p.id === draft.project_id);
const projectOptions = ["none", ...projects.map((p) => p.id)];
const projectLabels = Object.fromEntries([["none", "None"], ...projects.map((p) => [p.id, p.name])]);
+ const contexts = knownContexts(store);
+
+ if (draft.status === "trashed") {
+ return (
+
+ {draft.title}
+ In the trash.
+
+ );
+ }
return (
set({ title: v })} />
set({ priority })} />
- set({ estimate_min: Number(v.replace(/\D/g, "")) || null })}
- placeholder="30"
- keyboardType="number-pad"
- />
+ set({ estimate_min })} />
set({ status })} />
+ {contexts.length > 0 && (
+
+ {contexts.map((c) => (
+ {
+ haptic.select();
+ set({ context: draft.context === c ? null : c });
+ }}
+ >
+ {c}
+
+ ))}
+
+ )}
{draft.status === "waiting" && (
)}
set({ due_date: v })} clearable />
+ set({ repeat: v === "none" ? null : (v as Repeat) })}
+ />
set({ defer_date: v })} clearable />
+ {!!draft.defer_date && (
+ Hidden from lists until then — it resurfaces on the day.
+ )}
);
}
+
+const s = StyleSheet.create({
+ trashedTitle: { color: C.text, fontSize: 18, marginBottom: 6 },
+ trashedMeta: { color: C.muted, fontSize: 14, marginBottom: 18 },
+ contextRow: { flexDirection: "row", flexWrap: "wrap", gap: 8, marginTop: -6, marginBottom: 14 },
+ contextChip: {
+ paddingVertical: 6,
+ paddingHorizontal: 12,
+ borderRadius: 16,
+ backgroundColor: C.surface,
+ borderWidth: 1,
+ borderColor: C.border,
+ },
+ contextChipActive: { backgroundColor: C.surface2, borderColor: C.accent },
+ contextText: { color: C.muted, fontSize: 13 },
+ deferHint: { color: C.muted, fontSize: 13, marginTop: -8, marginBottom: 12 },
+});
diff --git a/app/src/components/EstimateChips.tsx b/app/src/components/EstimateChips.tsx
new file mode 100644
index 0000000..553f52d
--- /dev/null
+++ b/app/src/components/EstimateChips.tsx
@@ -0,0 +1,56 @@
+// Preset estimate chips — one tap beats a numeric keyboard. A non-preset
+// value (from quick-add, e.g. ~25m) shows up as its own chip so it's never
+// invisible or lost.
+import { Pressable, StyleSheet, Text, View } from "react-native";
+import { haptic } from "../lib/haptics";
+import { formatEstimate } from "../lib/priority";
+import { C } from "../lib/theme";
+
+const PRESETS = [15, 30, 45, 60, 90, 120];
+
+export default function EstimateChips({
+ value,
+ onChange,
+}: {
+ value: number | null;
+ onChange: (v: number | null) => void;
+}) {
+ const options = value && !PRESETS.includes(value) ? [...PRESETS, value].sort((a, b) => a - b) : PRESETS;
+ return (
+
+ Estimated time
+
+ {options.map((m) => {
+ const active = value === m;
+ return (
+ {
+ haptic.select();
+ onChange(active ? null : m);
+ }}
+ >
+ {formatEstimate(m)}
+
+ );
+ })}
+
+
+ );
+}
+
+const s = StyleSheet.create({
+ label: { color: C.muted, fontSize: 13, marginBottom: 6, textTransform: "uppercase", letterSpacing: 0.5 },
+ rowWrap: { flexDirection: "row", flexWrap: "wrap", gap: 8 },
+ chip: {
+ paddingVertical: 8,
+ paddingHorizontal: 14,
+ borderRadius: 20,
+ backgroundColor: C.surface,
+ borderWidth: 1,
+ borderColor: C.border,
+ },
+ chipActive: { backgroundColor: C.surface2, borderColor: C.accent },
+ chipText: { color: C.muted, fontSize: 14 },
+});
diff --git a/app/src/components/TaskListScreen.tsx b/app/src/components/TaskListScreen.tsx
index 0fd384f..813a05b 100644
--- a/app/src/components/TaskListScreen.tsx
+++ b/app/src/components/TaskListScreen.tsx
@@ -1,13 +1,20 @@
import { useState } from "react";
-import { RefreshControl, StyleSheet, Text, View } from "react-native";
+import { Pressable, RefreshControl, StyleSheet, Text, View } from "react-native";
import DraggableFlatList, { ScaleDecorator } from "react-native-draggable-flatlist";
import { haptic } from "../lib/haptics";
-import { reorderTask, sync, tasksByStatus } from "../lib/store";
+import { reorderTask, sync, tasksByStatus, visibleTasksByStatus } from "../lib/store";
import { C } from "../lib/theme";
import { useStore } from "../lib/useStore";
import type { TaskStatus } from "../lib/types";
import TaskRow from "./TaskRow";
+// Deferred tasks are hidden from these lists until their date — you can't act
+// on them yet, so they shouldn't cost attention. A one-line footer keeps them
+// discoverable (trust needs "hidden, not gone").
+const HIDES_DEFERRED: TaskStatus[] = ["next", "waiting", "someday"];
+// Archives read newest-first and aren't hand-ordered.
+const ARCHIVE: TaskStatus[] = ["done", "trashed"];
+
export default function TaskListScreen({
status,
emptyHint,
@@ -17,7 +24,18 @@ export default function TaskListScreen({
}) {
const store = useStore();
const [refreshing, setRefreshing] = useState(false);
- const tasks = tasksByStatus(store, status);
+ const [showDeferred, setShowDeferred] = useState(false);
+ const hidesDeferred = HIDES_DEFERRED.includes(status);
+ const archive = ARCHIVE.includes(status);
+
+ const all = tasksByStatus(store, status);
+ let tasks = hidesDeferred && !showDeferred ? visibleTasksByStatus(store, status) : all;
+ if (archive) {
+ tasks = [...all].sort((a, b) =>
+ (b.completed_at ?? b.updated_at).localeCompare(a.completed_at ?? a.updated_at),
+ );
+ }
+ const hiddenCount = all.length - tasks.length;
return (
@@ -27,7 +45,7 @@ export default function TaskListScreen({
activationDistance={12}
onDragBegin={() => haptic.pickup()}
onDragEnd={({ data, from, to }) => {
- if (from !== to) {
+ if (!archive && from !== to) {
haptic.success();
reorderTask(data[to].id, status, to);
}
@@ -35,7 +53,7 @@ export default function TaskListScreen({
renderItem={({ item, drag, isActive }) => (
-
+
)}
@@ -51,6 +69,15 @@ export default function TaskListScreen({
/>
}
ListEmptyComponent={{emptyHint}}
+ ListFooterComponent={
+ hidesDeferred && (hiddenCount > 0 || showDeferred) ? (
+ setShowDeferred((v) => !v)}>
+
+ {showDeferred ? "hide deferred" : `⏸ ${hiddenCount} deferred — show`}
+
+
+ ) : null
+ }
contentContainerStyle={{ paddingBottom: 32 }}
/>
@@ -61,4 +88,5 @@ const s = StyleSheet.create({
screen: { flex: 1, backgroundColor: C.bg },
activeRow: { backgroundColor: C.surface2, borderRadius: 12 },
empty: { color: C.muted, textAlign: "center", marginTop: 48, fontSize: 15 },
+ deferredFooter: { color: C.muted, fontSize: 13, textAlign: "center", paddingVertical: 14 },
});
diff --git a/app/src/components/TaskRow.tsx b/app/src/components/TaskRow.tsx
index 98369e7..7850d6b 100644
--- a/app/src/components/TaskRow.tsx
+++ b/app/src/components/TaskRow.tsx
@@ -1,6 +1,7 @@
import { useRouter } from "expo-router";
import { Pressable, StyleSheet, Text, View } from "react-native";
import Animated, { FadeIn } from "react-native-reanimated";
+import { dueMeta, friendlyDate, isDeferred } from "../lib/dates";
import { haptic } from "../lib/haptics";
import { formatEstimate, PRIORITY_COLOR } from "../lib/priority";
import { updateTask } from "../lib/store";
@@ -21,15 +22,21 @@ export default function TaskRow({
onLongPress?: () => void;
}) {
const router = useRouter();
- const meta = [
+ // Due date is rendered separately so it can carry a tone color; the reader
+ // sees "due today" in amber, never does date math on an ISO string.
+ const due = task.due_date ? dueMeta(task.due_date) : null;
+ const dueColor = due?.tone === "overdue" ? C.danger : due?.tone === "today" ? C.warn : C.muted;
+ const base = [
showStatus ? task.status : null,
+ task.repeat && "↻",
task.context,
task.waiting_for && `→ ${task.waiting_for}`,
formatEstimate(task.estimate_min),
- task.due_date && `due ${task.due_date}`,
+ isDeferred(task.defer_date) && `⏸ until ${friendlyDate(task.defer_date!)}`,
]
.filter(Boolean)
.join(" · ");
+ const meta = base || due;
const toggle = () => {
const done = task.status !== "done";
@@ -52,7 +59,13 @@ export default function TaskRow({
{task.title}
- {!!meta && {meta}}
+ {!!meta && (
+
+ {base}
+ {!!base && !!due && " · "}
+ {due && {due.label}}
+
+ )}
diff --git a/app/src/lib/dates.ts b/app/src/lib/dates.ts
new file mode 100644
index 0000000..0c6d5ec
--- /dev/null
+++ b/app/src/lib/dates.ts
@@ -0,0 +1,51 @@
+// Date helpers for human-friendly display. All task dates are local ISO
+// "YYYY-MM-DD" strings; comparisons are lexicographic.
+
+const DAY_MS = 86_400_000;
+
+export function todayIso(): string {
+ const d = new Date();
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
+}
+
+export function fromIso(s: string): Date {
+ const [y, m, d] = s.split("-").map(Number);
+ return new Date(y, m - 1, d);
+}
+
+export function toIso(d: Date): string {
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
+}
+
+/** Deferred = hidden from action lists until the date arrives. */
+export function isDeferred(deferDate: string | null, today = todayIso()): boolean {
+ return !!deferDate && deferDate > today;
+}
+
+export function daysFromToday(dateIso: string): number {
+ return Math.round((fromIso(dateIso).getTime() - fromIso(todayIso()).getTime()) / DAY_MS);
+}
+
+/** "Today" / "Tomorrow" / "Mon 18 Aug" — no ISO date math for the reader. */
+export function friendlyDate(dateIso: string): string {
+ const diff = daysFromToday(dateIso);
+ if (diff === 0) return "Today";
+ if (diff === 1) return "Tomorrow";
+ if (diff === -1) return "Yesterday";
+ const d = fromIso(dateIso);
+ const wd = d.toLocaleDateString("en-AU", { weekday: "short" });
+ const mo = d.toLocaleDateString("en-AU", { month: "short" });
+ const yr = d.getFullYear() !== new Date().getFullYear() ? ` ${d.getFullYear()}` : "";
+ return `${wd} ${d.getDate()} ${mo}${yr}`;
+}
+
+export type DueTone = "overdue" | "today" | "later";
+
+/** Row label for a due date, with a tone the UI can color. */
+export function dueMeta(dueIso: string): { label: string; tone: DueTone } {
+ const diff = daysFromToday(dueIso);
+ if (diff < 0) return { label: diff === -1 ? "1d overdue" : `${-diff}d overdue`, tone: "overdue" };
+ if (diff === 0) return { label: "due today", tone: "today" };
+ if (diff === 1) return { label: "due tomorrow", tone: "later" };
+ return { label: `due ${friendlyDate(dueIso)}`, tone: "later" };
+}
diff --git a/app/src/lib/quickadd.ts b/app/src/lib/quickadd.ts
index 70022ab..d297f6e 100644
--- a/app/src/lib/quickadd.ts
+++ b/app/src/lib/quickadd.ts
@@ -1,8 +1,12 @@
// Natural-language quick add, Todoist style:
-// "Pay rent tomorrow p1 @home #Sydney ~30m"
+// "Pay rent tomorrow p1 @home #Sydney ~30m monthly"
// → title "Pay rent", due tomorrow, priority 1, context @home,
-// project "Sydney" (matched by name prefix), estimate 30 min.
-import type { Project, Task } from "./types";
+// project "Sydney" (matched by name prefix), estimate 30 min, repeats monthly.
+//
+// The add form parses the title field live and mirrors every recognized token
+// into a visible form control, so a false positive (e.g. "fri" in a name) is
+// seen and fixable, never silent.
+import type { Project, Repeat, Task } from "./types";
export interface ParsedQuickAdd {
title: string;
@@ -11,9 +15,17 @@ export interface ParsedQuickAdd {
context?: string;
project_id?: string;
estimate_min?: number;
+ repeat?: Repeat;
}
const WEEKDAYS = ["sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday"];
+const REPEAT_WORDS: Record = {
+ daily: "daily",
+ weekdays: "weekdays",
+ weekly: "weekly",
+ monthly: "monthly",
+ yearly: "yearly",
+};
function iso(d: Date): string {
const y = d.getFullYear();
@@ -26,7 +38,8 @@ function parseDateWord(word: string): string | undefined {
const w = word.toLowerCase();
const today = new Date();
if (w === "today" || w === "tod") return iso(today);
- if (w === "tomorrow" || w === "tmr" || w === "tom") {
+ // No "tom" alias — it's a name far more often than a date.
+ if (w === "tomorrow" || w === "tmr") {
const d = new Date(today);
d.setDate(d.getDate() + 1);
return iso(d);
@@ -77,6 +90,11 @@ export function parseQuickAdd(input: string, projects: Project[]): ParsedQuickAd
out.estimate_min = est;
continue;
}
+ const rep = REPEAT_WORDS[token.toLowerCase()];
+ if (rep && out.repeat === undefined) {
+ out.repeat = rep;
+ continue;
+ }
if (out.due_date === undefined) {
const due = parseDateWord(token);
if (due) {
diff --git a/app/src/lib/repeat.ts b/app/src/lib/repeat.ts
new file mode 100644
index 0000000..7e990f0
--- /dev/null
+++ b/app/src/lib/repeat.ts
@@ -0,0 +1,65 @@
+// Recurrence rules. Completing a repeating task spawns the next occurrence
+// (see store.updateTask) — the done row keeps its history, the clone carries
+// the rule forward.
+import { fromIso, toIso } from "./dates";
+import type { Repeat } from "./types";
+
+export const REPEATS: Repeat[] = ["daily", "weekdays", "weekly", "monthly", "yearly"];
+
+export const REPEAT_LABEL: Record = {
+ daily: "Daily",
+ weekdays: "Weekdays",
+ weekly: "Weekly",
+ monthly: "Monthly",
+ yearly: "Yearly",
+};
+
+function addDays(d: Date, n: number): Date {
+ const r = new Date(d);
+ r.setDate(r.getDate() + n);
+ return r;
+}
+
+/** Same day-of-month n months later, clamped to the month's last day. */
+function addMonthsClamped(d: Date, n: number): Date {
+ const r = new Date(d.getFullYear(), d.getMonth() + n, 1);
+ const last = new Date(r.getFullYear(), r.getMonth() + 1, 0).getDate();
+ r.setDate(Math.min(d.getDate(), last));
+ return r;
+}
+
+function step(d: Date, repeat: Repeat): Date {
+ switch (repeat) {
+ case "daily":
+ return addDays(d, 1);
+ case "weekdays": {
+ let n = addDays(d, 1);
+ while (n.getDay() === 0 || n.getDay() === 6) n = addDays(n, 1);
+ return n;
+ }
+ case "weekly":
+ return addDays(d, 7);
+ case "monthly":
+ return addMonthsClamped(d, 1);
+ case "yearly":
+ return addMonthsClamped(d, 12);
+ }
+}
+
+/**
+ * Next occurrence strictly after the base date, catching up to today for
+ * lapsed tasks (a monthly bill 3 months overdue advances to the next
+ * anchor-day on or after today, not three stale dates in the past).
+ */
+export function nextOccurrence(baseIso: string | null, repeat: Repeat, todayIsoStr: string): string {
+ let d = fromIso(baseIso ?? todayIsoStr);
+ let guard = 0;
+ do {
+ d = step(d, repeat);
+ } while (toIso(d) < todayIsoStr && ++guard < 5000);
+ return toIso(d);
+}
+
+export function shiftDays(dateIso: string, n: number): string {
+ return toIso(addDays(fromIso(dateIso), n));
+}
diff --git a/app/src/lib/review.ts b/app/src/lib/review.ts
new file mode 100644
index 0000000..325db85
--- /dev/null
+++ b/app/src/lib/review.ts
@@ -0,0 +1,29 @@
+// 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.ts b/app/src/lib/store.ts
index 5ddd70a..dc66948 100644
--- a/app/src/lib/store.ts
+++ b/app/src/lib/store.ts
@@ -10,6 +10,8 @@ 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 {
@@ -81,7 +83,7 @@ function now() {
// 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", "sort_order"] as const;
+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 {
@@ -104,11 +106,13 @@ function enqueue(op: Op) {
}
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: data.status ?? "inbox",
+ status,
project_id: data.project_id ?? null,
context: data.context ?? null,
waiting_for: data.waiting_for ?? null,
@@ -116,11 +120,12 @@ export function createTask(data: Partial & { title: string }): Task {
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 === (data.status ?? "next")).map((t) => t.sort_order)) - 1,
+ Math.min(0, ...state.tasks.filter((t) => t.status === status).map((t) => t.sort_order)) - 1,
created_at: now(),
updated_at: now(),
};
@@ -131,6 +136,7 @@ export function createTask(data: Partial & { title: string }): Task {
}
export function updateTask(id: string, patch: Partial) {
+ const before = state.tasks.find((t) => t.id === id);
setState({
tasks: state.tasks.map((t) => {
if (t.id !== id) return t;
@@ -142,6 +148,39 @@ export function updateTask(id: string, patch: Partial) {
});
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") {
+ spawnNextOccurrence(before);
+ }
+}
+
+function spawnNextOccurrence(t: 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;
+ 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) {
@@ -229,7 +268,14 @@ async function push(op: Op) {
}
async function pull() {
- const [tasks, projects] = await Promise.all([api.tasks.list(), api.projects.list()]);
+ // 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));
@@ -268,6 +314,15 @@ 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);
@@ -303,3 +358,52 @@ export function activeProjects(s: State): Project[] {
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)),
+ ),
+ };
+}
diff --git a/app/src/lib/theme.ts b/app/src/lib/theme.ts
index 5969fbf..087ef43 100644
--- a/app/src/lib/theme.ts
+++ b/app/src/lib/theme.ts
@@ -8,4 +8,5 @@ export const C = {
accent: "#5B8DEF",
done: "#4CAF7D",
danger: "#E05B5B",
-};
+ warn: "#E0A93E",
+} as const;
diff --git a/app/src/lib/types.ts b/app/src/lib/types.ts
index 2b569c9..14da65b 100644
--- a/app/src/lib/types.ts
+++ b/app/src/lib/types.ts
@@ -1,5 +1,6 @@
export type TaskStatus = "inbox" | "next" | "waiting" | "scheduled" | "someday" | "done" | "trashed";
export type ProjectStatus = "active" | "someday" | "completed" | "dropped";
+export type Repeat = "daily" | "weekdays" | "weekly" | "monthly" | "yearly";
export interface Task {
id: string;
@@ -13,6 +14,7 @@ export interface Task {
defer_date: string | null;
priority: 1 | 2 | 3 | 4;
estimate_min: number | null;
+ repeat: Repeat | null;
completed_at: string | null;
sort_order: number;
created_at: string;
diff --git a/server/index.ts b/server/index.ts
index 97ce54c..48e73ac 100644
--- a/server/index.ts
+++ b/server/index.ts
@@ -5,12 +5,17 @@ const sql = new SQL(process.env.DATABASE_URL!);
const API_TOKEN = process.env.API_TOKEN!;
if (!API_TOKEN) throw new Error("API_TOKEN not set");
+// schema.sql is idempotent (create/alter-if-not-exists) — apply it at boot so
+// a deployed server is never newer than the database it queries.
+await sql.unsafe(await Bun.file(new URL("schema.sql", import.meta.url)).text());
+
const TASK_COLS = `id, title, notes, status, project_id, context, waiting_for,
due_date::text as due_date, defer_date::text as defer_date,
- priority, estimate_min, completed_at, sort_order, created_at, updated_at`;
+ priority, estimate_min, repeat, completed_at, sort_order, created_at, updated_at`;
const PROJECT_COLS = `id, name, status, notes, sort_order, created_at, updated_at`;
const TASK_STATUSES = ["inbox", "next", "waiting", "scheduled", "someday", "done", "trashed"];
+const REPEATS = ["daily", "weekdays", "weekly", "monthly", "yearly"];
const PROJECT_STATUSES = ["active", "someday", "completed", "dropped"];
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
@@ -28,11 +33,11 @@ function err(message: string, status: number) {
}
// Fields a client may set. completed_at is derived from status server-side.
-const TASK_FIELDS = ["title", "notes", "status", "project_id", "context", "waiting_for", "due_date", "defer_date", "priority", "estimate_min", "sort_order"];
+const TASK_FIELDS = ["title", "notes", "status", "project_id", "context", "waiting_for", "due_date", "defer_date", "priority", "estimate_min", "repeat", "sort_order"];
const PROJECT_FIELDS = ["name", "status", "notes", "sort_order"];
// Only these may be cleared to NULL; notes/title are NOT NULL and keep "".
-const NULLABLE = new Set(["project_id", "context", "waiting_for", "due_date", "defer_date", "estimate_min"]);
+const NULLABLE = new Set(["project_id", "context", "waiting_for", "due_date", "defer_date", "estimate_min", "repeat"]);
function pick(body: Record, fields: string[]) {
const out: Record = {};
@@ -73,16 +78,17 @@ async function handle(req: Request): Promise {
const t = pick(body, TASK_FIELDS);
if (typeof t.title !== "string" || !t.title.trim()) return err("title required", 400);
if (t.status && !TASK_STATUSES.includes(t.status as string)) return err("bad status", 400);
+ if (t.repeat && !REPEATS.includes(t.repeat as string)) return err("bad repeat", 400);
const id = typeof body.id === "string" && UUID_RE.test(body.id) ? body.id : null;
// Client-supplied id + on-conflict no-op make offline-queue retries idempotent.
const rows = await sql.unsafe(
- `insert into tasks (id, title, notes, status, project_id, context, waiting_for, due_date, defer_date, priority, estimate_min, sort_order)
- values (coalesce($1::uuid, gen_random_uuid()), $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
+ `insert into tasks (id, title, notes, status, project_id, context, waiting_for, due_date, defer_date, priority, estimate_min, repeat, sort_order)
+ values (coalesce($1::uuid, gen_random_uuid()), $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
on conflict (id) do nothing
returning ${TASK_COLS}`,
- [id, t.title, t.notes ?? "", t.status ?? "inbox", t.project_id ?? null, t.context ?? null,
+ [id, t.title, t.notes ?? "", t.status ?? "next", t.project_id ?? null, t.context ?? null,
t.waiting_for ?? null, t.due_date ?? null, t.defer_date ?? null, t.priority ?? 4,
- t.estimate_min ?? null, t.sort_order ?? 0]);
+ t.estimate_min ?? null, t.repeat ?? null, t.sort_order ?? 0]);
if (rows.length) return json(rows[0], 201);
const existing = await sql.unsafe(`select ${TASK_COLS} from tasks where id = $1`, [id]);
return json(existing[0], 200);
@@ -97,17 +103,18 @@ async function handle(req: Request): Promise {
const id = m[1];
const patch = method === "DELETE" ? { status: "trashed" } : pick(body, TASK_FIELDS);
if (patch.status && !TASK_STATUSES.includes(patch.status as string)) return err("bad status", 400);
+ if (patch.repeat && !REPEATS.includes(patch.repeat as string)) return err("bad repeat", 400);
const existing = await sql.unsafe(`select ${TASK_COLS} from tasks where id = $1`, [id]);
if (!existing.length) return err("not found", 404);
const cur = { ...existing[0], ...patch };
const completedAt = cur.status === "done" ? (existing[0].completed_at ?? new Date()) : null;
const rows = await sql.unsafe(
`update tasks set title=$2, notes=$3, status=$4, project_id=$5, context=$6, waiting_for=$7,
- due_date=$8, defer_date=$9, priority=$10, estimate_min=$11, completed_at=$12, sort_order=$13,
- updated_at=now()
+ due_date=$8, defer_date=$9, priority=$10, estimate_min=$11, repeat=$12, completed_at=$13,
+ sort_order=$14, updated_at=now()
where id=$1 returning ${TASK_COLS}`,
[id, cur.title, cur.notes, cur.status, cur.project_id, cur.context, cur.waiting_for,
- cur.due_date, cur.defer_date, cur.priority, cur.estimate_min, completedAt, cur.sort_order]);
+ cur.due_date, cur.defer_date, cur.priority, cur.estimate_min, cur.repeat, completedAt, cur.sort_order]);
return json(rows[0]);
}
diff --git a/server/schema.sql b/server/schema.sql
index 796d830..c5831e0 100644
--- a/server/schema.sql
+++ b/server/schema.sql
@@ -25,6 +25,7 @@ create table if not exists tasks (
defer_date date, -- hide from lists until this date
priority int not null default 4 check (priority between 1 and 4), -- 1=urgent … 4=low
estimate_min int check (estimate_min > 0),
+ repeat text check (repeat in ('daily', 'weekdays', 'weekly', 'monthly', 'yearly')),
completed_at timestamptz,
sort_order double precision not null default 0,
created_at timestamptz not null default now(),
@@ -33,3 +34,7 @@ create table if not exists tasks (
create index if not exists tasks_status_idx on tasks (status);
create index if not exists tasks_project_idx on tasks (project_id);
+
+-- Columns added after the initial deploy (create-if-not-exists won't alter).
+alter table tasks add column if not exists repeat text
+ check (repeat in ('daily', 'weekdays', 'weekly', 'monthly', 'yearly'));