Repeats, estimates, weekly review: completing a repeating task spawns the next occurrence; estimate chips + quick-add tokens (~30m, monthly); review screen with one-tap triage; server gains repeat column and applies its idempotent schema at boot

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-18 00:06:39 -07:00
parent 6ccd417f2a
commit 4ef3f8c63c
22 changed files with 754 additions and 93 deletions

View File

@@ -9,7 +9,11 @@ Minimal Getting Things Done app. One Expo/React Native codebase for iPhone, Andr
## Layout ## 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`. - `server/` — Bun API + Dockerfile + compose. Deployed at `~/personal/gtd` on rehbock.xyz, joined to `personal-db_default` + `caddy_net`.
## Develop ## Develop

10
app/eslint.config.js Normal file
View File

@@ -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/*"],
}
]);

View File

@@ -34,6 +34,8 @@
}, },
"devDependencies": { "devDependencies": {
"@types/react": "~19.2.2", "@types/react": "~19.2.2",
"eslint": "^9.0.0",
"eslint-config-expo": "~57.0.1",
"typescript": "~6.0.3" "typescript": "~6.0.3"
}, },
"scripts": { "scripts": {

View File

@@ -57,6 +57,7 @@ export default function RootLayout() {
<Stack.Screen name="projects" options={{ title: "Projects" }} /> <Stack.Screen name="projects" options={{ title: "Projects" }} />
<Stack.Screen name="task/[id]" options={{ title: "Clarify", presentation: "modal" }} /> <Stack.Screen name="task/[id]" options={{ title: "Clarify", presentation: "modal" }} />
<Stack.Screen name="project/[id]" options={{ title: "Project" }} /> <Stack.Screen name="project/[id]" options={{ title: "Project" }} />
<Stack.Screen name="review" options={{ title: "Weekly review" }} />
<Stack.Screen name="settings" options={{ title: "Settings", presentation: "modal" }} /> <Stack.Screen name="settings" options={{ title: "Settings", presentation: "modal" }} />
</Stack> </Stack>
</GestureHandlerRootView> </GestureHandlerRootView>

View File

@@ -1,14 +1,17 @@
import { useRouter } from "expo-router"; import { useRouter } from "expo-router";
import { useState } from "react"; import { useRef, useState } from "react";
import { Pressable, ScrollView, StyleSheet, Text, View } from "react-native"; import { Pressable, ScrollView, StyleSheet, Text } from "react-native";
import DateField from "../components/DateField"; import DateField from "../components/DateField";
import EstimateChips from "../components/EstimateChips";
import PriorityChips from "../components/PriorityChips"; import PriorityChips from "../components/PriorityChips";
import { Chips, Field } from "../components/ui"; import { Chips, Field } from "../components/ui";
import { haptic } from "../lib/haptics"; 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 { activeProjects, createTask } from "../lib/store";
import { C } from "../lib/theme"; import { C } from "../lib/theme";
import { useStore } from "../lib/useStore"; 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 }[] = [ const BOXES: { key: TaskStatus; label: string }[] = [
{ key: "next", label: "Next up" }, { key: "next", label: "Next up" },
@@ -16,37 +19,62 @@ const BOXES: { key: TaskStatus; label: string }[] = [
{ key: "someday", label: "Someday" }, { key: "someday", label: "Someday" },
]; ];
const ESTIMATES = [15, 30, 45, 60, 90, 120];
export default function AddTask() { export default function AddTask() {
const router = useRouter(); const router = useRouter();
const store = useStore(); const store = useStore();
const [title, setTitle] = useState(""); const [raw, setRaw] = useState("");
const [notes, setNotes] = useState(""); const [notes, setNotes] = useState("");
const [priority, setPriority] = useState<Task["priority"] | null>(null); const [priority, setPriority] = useState<Task["priority"] | null>(null);
const [estimate, setEstimate] = useState<number | null>(null); const [estimate, setEstimate] = useState<number | null>(null);
const [due, setDue] = useState(""); const [due, setDue] = useState("");
const [repeat, setRepeat] = useState<Repeat | null>(null);
const [box, setBox] = useState<TaskStatus>("next"); const [box, setBox] = useState<TaskStatus>("next");
const [waitingFor, setWaitingFor] = useState("");
const [projectId, setProjectId] = useState<string | null>(null); const [projectId, setProjectId] = useState<string | null>(null);
const [context, setContext] = useState<string | null>(null);
const projects = activeProjects(store).filter((p) => p.status === "active"); const projects = activeProjects(store).filter((p) => p.status === "active");
const projectOptions = ["none", ...projects.map((p) => p.id)]; const projectOptions = ["none", ...projects.map((p) => p.id)];
const projectLabels = Object.fromEntries([["none", "None"], ...projects.map((p) => [p.id, p.name])]); 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<ParsedQuickAdd>({ 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. // Every task must be born fully scoped: title + priority + due date.
const dueValid = /^\d{4}-\d{2}-\d{2}$/.test(due.trim()); 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 = () => { const save = () => {
if (!ready || priority === null) return; if (!ready || priority === null) return;
createTask({ createTask({
title: title.trim(), title: cleanTitle,
notes, notes,
status: box, status: box,
priority, priority,
estimate_min: estimate, estimate_min: estimate,
due_date: due.trim(), due_date: due.trim(),
repeat,
project_id: projectId, project_id: projectId,
context,
waiting_for: box === "waiting" ? waitingFor.trim() || null : null,
}); });
haptic.success(); haptic.success();
router.back(); router.back();
@@ -54,39 +82,32 @@ export default function AddTask() {
return ( return (
<ScrollView style={{ flex: 1, backgroundColor: C.bg }} contentContainerStyle={{ padding: 16 }}> <ScrollView style={{ flex: 1, backgroundColor: C.bg }} contentContainerStyle={{ padding: 16 }}>
<Field label="Task" value={title} onChangeText={setTitle} placeholder="What needs doing?" autoFocus />
<Field <Field
label="Description" label="Task"
value={notes} value={raw}
onChangeText={setNotes} onChangeText={onTitleChange}
placeholder="Details, links, notes…" placeholder="Pay rent tmr p1 @home ~30m"
multiline autoFocus
style={{ minHeight: 64, textAlignVertical: "top" }}
/> />
{tokensUsed && (
<Text style={s.parsedHint}>
→ {cleanTitle || "…"}
{context ? ` · ${context}` : ""}
</Text>
)}
<PriorityChips value={priority} onChange={setPriority} /> <PriorityChips value={priority} onChange={setPriority} />
<DateField label="Due date" value={due || null} onChange={(v) => setDue(v ?? "")} /> <DateField label="Due date" value={due || null} onChange={(v) => setDue(v ?? "")} />
<View style={{ marginBottom: 14 }}> <Chips
<Text style={s.label}>Estimated time</Text> label="Repeat"
<View style={s.rowWrap}> options={["none", ...REPEATS]}
{ESTIMATES.map((m) => { labels={{ none: "None", ...REPEAT_LABEL }}
const active = estimate === m; value={repeat ?? "none"}
return ( onChange={(v) => {
<Pressable
key={m}
style={[s.chip, active && s.chipActive]}
onPress={() => {
haptic.select(); haptic.select();
setEstimate(active ? null : m); setRepeat(v === "none" ? null : (v as Repeat));
}} }}
> />
<Text style={[s.chipText, active && { color: C.text }]}> <EstimateChips value={estimate} onChange={setEstimate} />
{m < 60 ? `${m}m` : m % 60 ? `${Math.floor(m / 60)}h${m % 60}` : `${m / 60}h`}
</Text>
</Pressable>
);
})}
</View>
</View>
<Chips <Chips
label="Box" label="Box"
options={BOXES.map((b) => b.key)} options={BOXES.map((b) => b.key)}
@@ -97,6 +118,9 @@ export default function AddTask() {
setBox(b); setBox(b);
}} }}
/> />
{box === "waiting" && (
<Field label="Waiting for" value={waitingFor} onChangeText={setWaitingFor} placeholder="Who or what?" />
)}
<Chips <Chips
label="Project" label="Project"
options={projectOptions} options={projectOptions}
@@ -104,6 +128,14 @@ export default function AddTask() {
value={projectId ?? "none"} value={projectId ?? "none"}
onChange={(v) => setProjectId(v === "none" ? null : v)} onChange={(v) => setProjectId(v === "none" ? null : v)}
/> />
<Field
label="Description"
value={notes}
onChangeText={setNotes}
placeholder="Details, links, notes…"
multiline
style={{ minHeight: 64, textAlignVertical: "top" }}
/>
<Pressable style={[s.save, !ready && s.saveDisabled]} onPress={save} disabled={!ready}> <Pressable style={[s.save, !ready && s.saveDisabled]} onPress={save} disabled={!ready}>
<Text style={[s.saveText, !ready && { color: C.muted }]}> <Text style={[s.saveText, !ready && { color: C.muted }]}>
{ready ? "Add task" : "Needs a title, priority & due date"} {ready ? "Add task" : "Needs a title, priority & due date"}
@@ -114,18 +146,7 @@ export default function AddTask() {
} }
const s = StyleSheet.create({ const s = StyleSheet.create({
label: { color: C.muted, fontSize: 13, marginBottom: 6, textTransform: "uppercase", letterSpacing: 0.5 }, parsedHint: { color: C.accent, fontSize: 13, marginTop: -8, marginBottom: 12 },
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 },
save: { save: {
backgroundColor: C.accent, backgroundColor: C.accent,
borderRadius: 10, borderRadius: 10,

View File

@@ -1,8 +1,9 @@
import { useRouter } from "expo-router"; import { useFocusEffect, useRouter } from "expo-router";
import { Pressable, StyleSheet, Text, View } from "react-native"; import { Pressable, StyleSheet, Text, View } from "react-native";
import TaskRow from "../components/TaskRow"; import TaskRow from "../components/TaskRow";
import { haptic } from "../lib/haptics"; 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 { C } from "../lib/theme";
import { useStore } from "../lib/useStore"; import { useStore } from "../lib/useStore";
@@ -12,13 +13,18 @@ const MENU = [
{ icon: "⏳", label: "Waiting", href: "/list/waiting" }, { icon: "⏳", label: "Waiting", href: "/list/waiting" },
{ icon: "💭", label: "Someday", href: "/list/someday" }, { icon: "💭", label: "Someday", href: "/list/someday" },
{ icon: "📁", label: "Projects", href: "/projects" }, { icon: "📁", label: "Projects", href: "/projects" },
{ icon: "🔄", label: "Review", href: "/review" },
] as const; ] as const;
export default function Home() { export default function Home() {
const router = useRouter(); const router = useRouter();
const store = useStore(); const store = useStore();
const next = tasksByStatus(store, "next"); const next = visibleTasksByStatus(store, "next");
const { overdue } = todayTasks(store); const { overdue } = todayTasks(store);
const [lastReview, reloadReview] = useLastReview();
useFocusEffect(reloadReview);
const reviewDays = daysSince(lastReview);
const reviewDue = lastReview === null || (reviewDays !== null && reviewDays >= 7);
return ( return (
<View style={s.screen}> <View style={s.screen}>
@@ -38,6 +44,13 @@ export default function Home() {
<Text style={s.overdue}>{overdue.length} overdue →</Text> <Text style={s.overdue}>{overdue.length} overdue →</Text>
</Pressable> </Pressable>
)} )}
{reviewDue && (
<Pressable onPress={() => router.push("/review")}>
<Text style={s.reviewNudge}>
{lastReview === null ? "no review yet — start one →" : `review overdue (${reviewDays}d) →`}
</Text>
</Pressable>
)}
{store.offline && ( {store.offline && (
<Text style={s.offline}> <Text style={s.offline}>
offline{store.pending > 0 ? ` — ${store.pending} change${store.pending === 1 ? "" : "s"} queued` : ""} 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 }, empty: { color: C.muted, textAlign: "center", marginTop: 48, fontSize: 15 },
viewAll: { color: C.muted, fontSize: 14, padding: 16 }, viewAll: { color: C.muted, fontSize: 14, padding: 16 },
overdue: { color: C.danger, fontSize: 14, paddingHorizontal: 16, paddingBottom: 8 }, 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 }, error: { color: C.danger, paddingHorizontal: 16, paddingTop: 8 },
offline: { color: C.muted, fontSize: 13, paddingHorizontal: 16, paddingTop: 8 }, offline: { color: C.muted, fontSize: 13, paddingHorizontal: 16, paddingTop: 8 },
menu: { menu: {

View File

@@ -5,17 +5,17 @@ import type { TaskStatus } from "../../lib/types";
const TITLES: Record<string, string> = { const TITLES: Record<string, string> = {
next: "Next up", next: "Next up",
waiting: "Waiting for", waiting: "Waiting for",
scheduled: "Scheduled",
someday: "Someday", someday: "Someday",
done: "Done", done: "Done",
trashed: "Trash",
}; };
const HINTS: Record<string, string> = { const HINTS: Record<string, string> = {
next: "Nothing next. Tap + on the home screen.", next: "Nothing next. Tap + on the home screen.",
waiting: "Not waiting on anyone.", waiting: "Not waiting on anyone.",
scheduled: "Nothing scheduled.",
someday: "No someday/maybe items.", someday: "No someday/maybe items.",
done: "Nothing done yet.", done: "Nothing done yet.",
trashed: "Trash is empty.",
}; };
export default function ListByStatus() { export default function ListByStatus() {

171
app/src/app/review.tsx Normal file
View File

@@ -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 (
<View style={s.row}>
<Pressable
style={s.check}
hitSlop={8}
onPress={act(() => {
haptic.success();
updateTask(task.id, { status: "done" });
})}
>
<View style={[s.checkCircle, { borderColor: PRIORITY_COLOR[task.priority] }]} />
</Pressable>
<Pressable style={{ flex: 1 }} onPress={() => router.push(`/task/${task.id}`)}>
<Text style={s.title} numberOfLines={2}>
{task.title}
</Text>
<Text style={s.meta}>
{[
task.status,
task.waiting_for && `→ ${task.waiting_for}`,
showDefer && task.defer_date && `resurfaces ${friendlyDate(task.defer_date)}`,
]
.filter(Boolean)
.join(" · ")}
</Text>
</Pressable>
{task.status !== "next" && (
<Pressable style={s.action} hitSlop={6} onPress={act(() => updateTask(task.id, { status: "next" }))}>
<Text style={s.actionText}>▶️</Text>
</Pressable>
)}
{task.status !== "someday" && (
<Pressable style={s.action} hitSlop={6} onPress={act(() => updateTask(task.id, { status: "someday" }))}>
<Text style={s.actionText}>💭</Text>
</Pressable>
)}
<Pressable style={s.action} hitSlop={6} onPress={act(() => trashTask(task.id))}>
<Text style={s.actionText}>🗑️</Text>
</Pressable>
</View>
);
}
function Section({ title, hint, tasks, showDefer }: { title: string; hint: string; tasks: Task[]; showDefer?: boolean }) {
if (tasks.length === 0) return null;
return (
<View style={{ marginBottom: 8 }}>
<Text style={s.sectionTitle}>
{title} ({tasks.length})
</Text>
<Text style={s.sectionHint}>{hint}</Text>
{tasks.map((t) => (
<ReviewRow key={t.id} task={t} showDefer={showDefer} />
))}
</View>
);
}
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 (
<ScrollView style={{ flex: 1, backgroundColor: C.bg }} contentContainerStyle={{ padding: 16, paddingBottom: 48 }}>
<Text style={s.lastReview}>
{last ? (days === 0 ? "Last review: today" : `Last review: ${days}d ago`) : "First review — welcome."}
</Text>
<Section title="Overdue" hint="Reschedule, do, or let go." tasks={buckets.overdue} />
<Section title="Stalled" hint="Next actions untouched for 2+ weeks — still the real next step?" tasks={buckets.staleNext} />
<Section title="Waiting too long" hint="A week without movement — time to nudge?" tasks={buckets.agingWaiting} />
<Section title="Someday sweep" hint="Promote what's ripe, trash what's dead." tasks={buckets.someday} />
<Section title="Deferred" hint="Hidden from lists — just so you know they're coming." tasks={buckets.deferred} showDefer />
{buckets.stuckProjects.length > 0 && (
<View style={{ marginBottom: 8 }}>
<Text style={s.sectionTitle}>Stuck projects ({buckets.stuckProjects.length})</Text>
<Text style={s.sectionHint}>Active, but no next action — decide the next step or park them.</Text>
{buckets.stuckProjects.map((p) => (
<Pressable key={p.id} style={s.row} onPress={() => router.push(`/project/${p.id}`)}>
<Text style={[s.title, { flex: 1 }]}>{p.name}</Text>
<Text style={s.meta}>open →</Text>
</Pressable>
))}
</View>
)}
{empty && <Text style={s.empty}>All clear. Nothing needs a decision.</Text>}
<Button
title="✓ Mark review complete"
onPress={async () => {
haptic.success();
await markReviewed();
reload();
router.back();
}}
/>
<View style={s.archiveRow}>
<Pressable onPress={() => router.push("/list/done")}>
<Text style={s.archiveLink}>Done</Text>
</Pressable>
<Text style={s.archiveDot}>·</Text>
<Pressable onPress={() => router.push("/list/trashed")}>
<Text style={s.archiveLink}>Trash</Text>
</Pressable>
</View>
</ScrollView>
);
}
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" },
});

View File

@@ -23,7 +23,7 @@ export default function Settings() {
const save = async () => { const save = async () => {
await setConfig(url, token); await setConfig(url, token);
try { try {
await api.tasks.list({ status: "inbox" }); await api.tasks.list({ status: "next" });
void sync(); void sync();
router.back(); router.back();
} catch (e) { } catch (e) {

View File

@@ -1,13 +1,16 @@
import { useLocalSearchParams, useRouter } from "expo-router"; import { useLocalSearchParams, useRouter } from "expo-router";
import { useState } from "react"; 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 DateField from "../../components/DateField";
import EstimateChips from "../../components/EstimateChips";
import PriorityChips from "../../components/PriorityChips"; import PriorityChips from "../../components/PriorityChips";
import { Button, Chips, Field } from "../../components/ui"; 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 { C } from "../../lib/theme";
import { useStore } from "../../lib/useStore"; 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"]; const STATUSES: TaskStatus[] = ["next", "waiting", "someday", "done"];
@@ -35,6 +38,7 @@ export default function TaskDetail() {
defer_date: draft.defer_date, defer_date: draft.defer_date,
priority: draft.priority, priority: draft.priority,
estimate_min: draft.estimate_min, estimate_min: draft.estimate_min,
repeat: draft.repeat,
}); });
router.back(); 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 projects = activeProjects(store).filter((p) => p.status === "active" || p.id === draft.project_id);
const projectOptions = ["none", ...projects.map((p) => p.id)]; const projectOptions = ["none", ...projects.map((p) => p.id)];
const projectLabels = Object.fromEntries([["none", "None"], ...projects.map((p) => [p.id, p.name])]); const projectLabels = Object.fromEntries([["none", "None"], ...projects.map((p) => [p.id, p.name])]);
const contexts = knownContexts(store);
if (draft.status === "trashed") {
return (
<ScrollView style={{ flex: 1, backgroundColor: C.bg }} contentContainerStyle={{ padding: 16 }}>
<Text style={s.trashedTitle}>{draft.title}</Text>
<Text style={s.trashedMeta}>In the trash.</Text>
<Button
title="Restore to Next"
onPress={() => {
haptic.success();
updateTask(draft.id, { status: "next" });
router.back();
}}
/>
</ScrollView>
);
}
return ( return (
<ScrollView style={{ flex: 1, backgroundColor: C.bg }} contentContainerStyle={{ padding: 16 }}> <ScrollView style={{ flex: 1, backgroundColor: C.bg }} contentContainerStyle={{ padding: 16 }}>
<Field label="Title" value={draft.title} onChangeText={(v) => set({ title: v })} /> <Field label="Title" value={draft.title} onChangeText={(v) => set({ title: v })} />
<PriorityChips value={draft.priority} onChange={(priority) => set({ priority })} /> <PriorityChips value={draft.priority} onChange={(priority) => set({ priority })} />
<Field <EstimateChips value={draft.estimate_min} onChange={(estimate_min) => set({ estimate_min })} />
label="Estimate (minutes)"
value={draft.estimate_min ? String(draft.estimate_min) : ""}
onChangeText={(v) => set({ estimate_min: Number(v.replace(/\D/g, "")) || null })}
placeholder="30"
keyboardType="number-pad"
/>
<Chips label="List" options={STATUSES} value={draft.status} onChange={(status) => set({ status })} /> <Chips label="List" options={STATUSES} value={draft.status} onChange={(status) => set({ status })} />
<Chips <Chips
label="Project" label="Project"
@@ -69,6 +85,22 @@ export default function TaskDetail() {
placeholder="@home, @computer, @errands…" placeholder="@home, @computer, @errands…"
autoCapitalize="none" autoCapitalize="none"
/> />
{contexts.length > 0 && (
<View style={s.contextRow}>
{contexts.map((c) => (
<Pressable
key={c}
style={[s.contextChip, draft.context === c && s.contextChipActive]}
onPress={() => {
haptic.select();
set({ context: draft.context === c ? null : c });
}}
>
<Text style={[s.contextText, draft.context === c && { color: C.text }]}>{c}</Text>
</Pressable>
))}
</View>
)}
{draft.status === "waiting" && ( {draft.status === "waiting" && (
<Field <Field
label="Waiting for" label="Waiting for"
@@ -78,7 +110,17 @@ export default function TaskDetail() {
/> />
)} )}
<DateField label="Due date" value={draft.due_date} onChange={(v) => set({ due_date: v })} clearable /> <DateField label="Due date" value={draft.due_date} onChange={(v) => set({ due_date: v })} clearable />
<Chips
label="Repeat"
options={["none", ...REPEATS]}
labels={{ none: "None", ...REPEAT_LABEL }}
value={draft.repeat ?? "none"}
onChange={(v) => set({ repeat: v === "none" ? null : (v as Repeat) })}
/>
<DateField label="Defer until" value={draft.defer_date} onChange={(v) => set({ defer_date: v })} clearable /> <DateField label="Defer until" value={draft.defer_date} onChange={(v) => set({ defer_date: v })} clearable />
{!!draft.defer_date && (
<Text style={s.deferHint}>Hidden from lists until then — it resurfaces on the day.</Text>
)}
<Field <Field
label="Notes" label="Notes"
value={draft.notes} value={draft.notes}
@@ -98,3 +140,20 @@ export default function TaskDetail() {
</ScrollView> </ScrollView>
); );
} }
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 },
});

View File

@@ -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 (
<View style={{ marginBottom: 14 }}>
<Text style={s.label}>Estimated time</Text>
<View style={s.rowWrap}>
{options.map((m) => {
const active = value === m;
return (
<Pressable
key={m}
style={[s.chip, active && s.chipActive]}
onPress={() => {
haptic.select();
onChange(active ? null : m);
}}
>
<Text style={[s.chipText, active && { color: C.text }]}>{formatEstimate(m)}</Text>
</Pressable>
);
})}
</View>
</View>
);
}
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 },
});

View File

@@ -1,13 +1,20 @@
import { useState } from "react"; 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 DraggableFlatList, { ScaleDecorator } from "react-native-draggable-flatlist";
import { haptic } from "../lib/haptics"; 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 { C } from "../lib/theme";
import { useStore } from "../lib/useStore"; import { useStore } from "../lib/useStore";
import type { TaskStatus } from "../lib/types"; import type { TaskStatus } from "../lib/types";
import TaskRow from "./TaskRow"; 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({ export default function TaskListScreen({
status, status,
emptyHint, emptyHint,
@@ -17,7 +24,18 @@ export default function TaskListScreen({
}) { }) {
const store = useStore(); const store = useStore();
const [refreshing, setRefreshing] = useState(false); 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 ( return (
<View style={s.screen}> <View style={s.screen}>
@@ -27,7 +45,7 @@ export default function TaskListScreen({
activationDistance={12} activationDistance={12}
onDragBegin={() => haptic.pickup()} onDragBegin={() => haptic.pickup()}
onDragEnd={({ data, from, to }) => { onDragEnd={({ data, from, to }) => {
if (from !== to) { if (!archive && from !== to) {
haptic.success(); haptic.success();
reorderTask(data[to].id, status, to); reorderTask(data[to].id, status, to);
} }
@@ -35,7 +53,7 @@ export default function TaskListScreen({
renderItem={({ item, drag, isActive }) => ( renderItem={({ item, drag, isActive }) => (
<ScaleDecorator activeScale={1.03}> <ScaleDecorator activeScale={1.03}>
<View style={isActive && s.activeRow}> <View style={isActive && s.activeRow}>
<TaskRow task={item} uncheckTo={status} onLongPress={drag} /> <TaskRow task={item} uncheckTo={status === "done" ? "next" : status} onLongPress={archive ? undefined : drag} />
</View> </View>
</ScaleDecorator> </ScaleDecorator>
)} )}
@@ -51,6 +69,15 @@ export default function TaskListScreen({
/> />
} }
ListEmptyComponent={<Text style={s.empty}>{emptyHint}</Text>} ListEmptyComponent={<Text style={s.empty}>{emptyHint}</Text>}
ListFooterComponent={
hidesDeferred && (hiddenCount > 0 || showDeferred) ? (
<Pressable onPress={() => setShowDeferred((v) => !v)}>
<Text style={s.deferredFooter}>
{showDeferred ? "hide deferred" : `⏸ ${hiddenCount} deferred — show`}
</Text>
</Pressable>
) : null
}
contentContainerStyle={{ paddingBottom: 32 }} contentContainerStyle={{ paddingBottom: 32 }}
/> />
</View> </View>
@@ -61,4 +88,5 @@ const s = StyleSheet.create({
screen: { flex: 1, backgroundColor: C.bg }, screen: { flex: 1, backgroundColor: C.bg },
activeRow: { backgroundColor: C.surface2, borderRadius: 12 }, activeRow: { backgroundColor: C.surface2, borderRadius: 12 },
empty: { color: C.muted, textAlign: "center", marginTop: 48, fontSize: 15 }, empty: { color: C.muted, textAlign: "center", marginTop: 48, fontSize: 15 },
deferredFooter: { color: C.muted, fontSize: 13, textAlign: "center", paddingVertical: 14 },
}); });

View File

@@ -1,6 +1,7 @@
import { useRouter } from "expo-router"; import { useRouter } from "expo-router";
import { Pressable, StyleSheet, Text, View } from "react-native"; import { Pressable, StyleSheet, Text, View } from "react-native";
import Animated, { FadeIn } from "react-native-reanimated"; import Animated, { FadeIn } from "react-native-reanimated";
import { dueMeta, friendlyDate, isDeferred } from "../lib/dates";
import { haptic } from "../lib/haptics"; import { haptic } from "../lib/haptics";
import { formatEstimate, PRIORITY_COLOR } from "../lib/priority"; import { formatEstimate, PRIORITY_COLOR } from "../lib/priority";
import { updateTask } from "../lib/store"; import { updateTask } from "../lib/store";
@@ -21,15 +22,21 @@ export default function TaskRow({
onLongPress?: () => void; onLongPress?: () => void;
}) { }) {
const router = useRouter(); 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, showStatus ? task.status : null,
task.repeat && "↻",
task.context, task.context,
task.waiting_for && `→ ${task.waiting_for}`, task.waiting_for && `→ ${task.waiting_for}`,
formatEstimate(task.estimate_min), formatEstimate(task.estimate_min),
task.due_date && `due ${task.due_date}`, isDeferred(task.defer_date) && `⏸ until ${friendlyDate(task.defer_date!)}`,
] ]
.filter(Boolean) .filter(Boolean)
.join(" · "); .join(" · ");
const meta = base || due;
const toggle = () => { const toggle = () => {
const done = task.status !== "done"; const done = task.status !== "done";
@@ -52,7 +59,13 @@ export default function TaskRow({
<Text style={s.title} numberOfLines={2}> <Text style={s.title} numberOfLines={2}>
{task.title} {task.title}
</Text> </Text>
{!!meta && <Text style={s.meta}>{meta}</Text>} {!!meta && (
<Text style={s.meta}>
{base}
{!!base && !!due && " · "}
{due && <Text style={{ color: dueColor }}>{due.label}</Text>}
</Text>
)}
</View> </View>
</Pressable> </Pressable>
</Animated.View> </Animated.View>

51
app/src/lib/dates.ts Normal file
View File

@@ -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" };
}

View File

@@ -1,8 +1,12 @@
// Natural-language quick add, Todoist style: // 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, // → title "Pay rent", due tomorrow, priority 1, context @home,
// project "Sydney" (matched by name prefix), estimate 30 min. // project "Sydney" (matched by name prefix), estimate 30 min, repeats monthly.
import type { Project, Task } from "./types"; //
// 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 { export interface ParsedQuickAdd {
title: string; title: string;
@@ -11,9 +15,17 @@ export interface ParsedQuickAdd {
context?: string; context?: string;
project_id?: string; project_id?: string;
estimate_min?: number; estimate_min?: number;
repeat?: Repeat;
} }
const WEEKDAYS = ["sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday"]; const WEEKDAYS = ["sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday"];
const REPEAT_WORDS: Record<string, Repeat> = {
daily: "daily",
weekdays: "weekdays",
weekly: "weekly",
monthly: "monthly",
yearly: "yearly",
};
function iso(d: Date): string { function iso(d: Date): string {
const y = d.getFullYear(); const y = d.getFullYear();
@@ -26,7 +38,8 @@ function parseDateWord(word: string): string | undefined {
const w = word.toLowerCase(); const w = word.toLowerCase();
const today = new Date(); const today = new Date();
if (w === "today" || w === "tod") return iso(today); 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); const d = new Date(today);
d.setDate(d.getDate() + 1); d.setDate(d.getDate() + 1);
return iso(d); return iso(d);
@@ -77,6 +90,11 @@ export function parseQuickAdd(input: string, projects: Project[]): ParsedQuickAd
out.estimate_min = est; out.estimate_min = est;
continue; continue;
} }
const rep = REPEAT_WORDS[token.toLowerCase()];
if (rep && out.repeat === undefined) {
out.repeat = rep;
continue;
}
if (out.due_date === undefined) { if (out.due_date === undefined) {
const due = parseDateWord(token); const due = parseDateWord(token);
if (due) { if (due) {

65
app/src/lib/repeat.ts Normal file
View File

@@ -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<Repeat, string> = {
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));
}

29
app/src/lib/review.ts Normal file
View File

@@ -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<string | null> {
return AsyncStorage.getItem(KEY);
}
export async function markReviewed(): Promise<void> {
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<string | null>(null);
const reload = useCallback(() => {
void getLastReview().then(setLast);
}, []);
useEffect(reload, [reload]);
return [last, reload];
}

View File

@@ -10,6 +10,8 @@ import AsyncStorage from "@react-native-async-storage/async-storage";
import * as Crypto from "expo-crypto"; import * as Crypto from "expo-crypto";
import { api } from "./api"; import { api } from "./api";
import { getConfig } from "./config"; import { getConfig } from "./config";
import { daysFromToday, isDeferred, todayIso } from "./dates";
import { nextOccurrence, shiftDays } from "./repeat";
import type { Project, Task } from "./types"; import type { Project, Task } from "./types";
export interface State { export interface State {
@@ -81,7 +83,7 @@ function now() {
// Fields the API accepts — everything else (timestamps, completed_at) is local // Fields the API accepts — everything else (timestamps, completed_at) is local
// bookkeeping until the server's authoritative row arrives via pull(). // 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; const PROJECT_FIELDS = ["name", "status", "notes", "sort_order"] as const;
function pickFields<T>(data: Partial<T>, fields: readonly (keyof T & string)[]): Partial<T> { function pickFields<T>(data: Partial<T>, fields: readonly (keyof T & string)[]): Partial<T> {
@@ -104,11 +106,13 @@ function enqueue(op: Op) {
} }
export function createTask(data: Partial<Task> & { title: string }): Task { 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 = { const task: Task = {
id: Crypto.randomUUID(), id: Crypto.randomUUID(),
title: data.title, title: data.title,
notes: data.notes ?? "", notes: data.notes ?? "",
status: data.status ?? "inbox", status,
project_id: data.project_id ?? null, project_id: data.project_id ?? null,
context: data.context ?? null, context: data.context ?? null,
waiting_for: data.waiting_for ?? null, waiting_for: data.waiting_for ?? null,
@@ -116,11 +120,12 @@ export function createTask(data: Partial<Task> & { title: string }): Task {
defer_date: data.defer_date ?? null, defer_date: data.defer_date ?? null,
priority: data.priority ?? 4, priority: data.priority ?? 4,
estimate_min: data.estimate_min ?? null, estimate_min: data.estimate_min ?? null,
repeat: data.repeat ?? null,
completed_at: null, completed_at: null,
// New tasks land at the top of their list. // New tasks land at the top of their list.
sort_order: sort_order:
data.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(), created_at: now(),
updated_at: now(), updated_at: now(),
}; };
@@ -131,6 +136,7 @@ export function createTask(data: Partial<Task> & { title: string }): Task {
} }
export function updateTask(id: string, patch: Partial<Task>) { export function updateTask(id: string, patch: Partial<Task>) {
const before = state.tasks.find((t) => t.id === id);
setState({ setState({
tasks: state.tasks.map((t) => { tasks: state.tasks.map((t) => {
if (t.id !== id) return t; if (t.id !== id) return t;
@@ -142,6 +148,39 @@ export function updateTask(id: string, patch: Partial<Task>) {
}); });
persistData(); persistData();
enqueue({ kind: "task.update", id, data: pickFields(patch, TASK_FIELDS) }); 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) { export function trashTask(id: string) {
@@ -229,7 +268,14 @@ async function push(op: Op) {
} }
async function pull() { 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 // 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. // pushes those ops and then this merge converges to the server state.
const dirty = new Set(queue.map((o) => o.id)); 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); 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). */ /** Re-slot a task at `index` within its status list (fractional sort_order). */
export function reorderTask(id: string, status: Task["status"], index: number) { export function reorderTask(id: string, status: Task["status"], index: number) {
const list = tasksByStatus(getState(), status).filter((t) => t.id !== id); 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 { export function openTaskCount(s: State, projectId: string): number {
return s.tasks.filter((t) => t.project_id === projectId && t.status !== "done" && t.status !== "trashed").length; 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)),
),
};
}

View File

@@ -8,4 +8,5 @@ export const C = {
accent: "#5B8DEF", accent: "#5B8DEF",
done: "#4CAF7D", done: "#4CAF7D",
danger: "#E05B5B", danger: "#E05B5B",
}; warn: "#E0A93E",
} as const;

View File

@@ -1,5 +1,6 @@
export type TaskStatus = "inbox" | "next" | "waiting" | "scheduled" | "someday" | "done" | "trashed"; export type TaskStatus = "inbox" | "next" | "waiting" | "scheduled" | "someday" | "done" | "trashed";
export type ProjectStatus = "active" | "someday" | "completed" | "dropped"; export type ProjectStatus = "active" | "someday" | "completed" | "dropped";
export type Repeat = "daily" | "weekdays" | "weekly" | "monthly" | "yearly";
export interface Task { export interface Task {
id: string; id: string;
@@ -13,6 +14,7 @@ export interface Task {
defer_date: string | null; defer_date: string | null;
priority: 1 | 2 | 3 | 4; priority: 1 | 2 | 3 | 4;
estimate_min: number | null; estimate_min: number | null;
repeat: Repeat | null;
completed_at: string | null; completed_at: string | null;
sort_order: number; sort_order: number;
created_at: string; created_at: string;

View File

@@ -5,12 +5,17 @@ const sql = new SQL(process.env.DATABASE_URL!);
const API_TOKEN = process.env.API_TOKEN!; const API_TOKEN = process.env.API_TOKEN!;
if (!API_TOKEN) throw new Error("API_TOKEN not set"); 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, const TASK_COLS = `id, title, notes, status, project_id, context, waiting_for,
due_date::text as due_date, defer_date::text as defer_date, 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 PROJECT_COLS = `id, name, status, notes, sort_order, created_at, updated_at`;
const TASK_STATUSES = ["inbox", "next", "waiting", "scheduled", "someday", "done", "trashed"]; 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 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}$/; 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. // 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"]; const PROJECT_FIELDS = ["name", "status", "notes", "sort_order"];
// Only these may be cleared to NULL; notes/title are NOT NULL and keep "". // 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<string, unknown>, fields: string[]) { function pick(body: Record<string, unknown>, fields: string[]) {
const out: Record<string, unknown> = {}; const out: Record<string, unknown> = {};
@@ -73,16 +78,17 @@ async function handle(req: Request): Promise<Response> {
const t = pick(body, TASK_FIELDS); const t = pick(body, TASK_FIELDS);
if (typeof t.title !== "string" || !t.title.trim()) return err("title required", 400); 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.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; 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. // Client-supplied id + on-conflict no-op make offline-queue retries idempotent.
const rows = await sql.unsafe( 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) `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) values (coalesce($1::uuid, gen_random_uuid()), $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
on conflict (id) do nothing on conflict (id) do nothing
returning ${TASK_COLS}`, 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.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); if (rows.length) return json(rows[0], 201);
const existing = await sql.unsafe(`select ${TASK_COLS} from tasks where id = $1`, [id]); const existing = await sql.unsafe(`select ${TASK_COLS} from tasks where id = $1`, [id]);
return json(existing[0], 200); return json(existing[0], 200);
@@ -97,17 +103,18 @@ async function handle(req: Request): Promise<Response> {
const id = m[1]; const id = m[1];
const patch = method === "DELETE" ? { status: "trashed" } : pick(body, TASK_FIELDS); 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.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]); const existing = await sql.unsafe(`select ${TASK_COLS} from tasks where id = $1`, [id]);
if (!existing.length) return err("not found", 404); if (!existing.length) return err("not found", 404);
const cur = { ...existing[0], ...patch }; const cur = { ...existing[0], ...patch };
const completedAt = cur.status === "done" ? (existing[0].completed_at ?? new Date()) : null; const completedAt = cur.status === "done" ? (existing[0].completed_at ?? new Date()) : null;
const rows = await sql.unsafe( const rows = await sql.unsafe(
`update tasks set title=$2, notes=$3, status=$4, project_id=$5, context=$6, waiting_for=$7, `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, due_date=$8, defer_date=$9, priority=$10, estimate_min=$11, repeat=$12, completed_at=$13,
updated_at=now() sort_order=$14, updated_at=now()
where id=$1 returning ${TASK_COLS}`, where id=$1 returning ${TASK_COLS}`,
[id, cur.title, cur.notes, cur.status, cur.project_id, cur.context, cur.waiting_for, [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]); return json(rows[0]);
} }

View File

@@ -25,6 +25,7 @@ create table if not exists tasks (
defer_date date, -- hide from lists until this date 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 priority int not null default 4 check (priority between 1 and 4), -- 1=urgent … 4=low
estimate_min int check (estimate_min > 0), estimate_min int check (estimate_min > 0),
repeat text check (repeat in ('daily', 'weekdays', 'weekly', 'monthly', 'yearly')),
completed_at timestamptz, completed_at timestamptz,
sort_order double precision not null default 0, sort_order double precision not null default 0,
created_at timestamptz not null default now(), 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_status_idx on tasks (status);
create index if not exists tasks_project_idx on tasks (project_id); 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'));