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.} + +