diff --git a/app/package-lock.json b/app/package-lock.json
index bf0c3aa..4f35b1a 100644
--- a/app/package-lock.json
+++ b/app/package-lock.json
@@ -16,6 +16,7 @@
"expo-device": "~57.0.1",
"expo-font": "~57.0.1",
"expo-glass-effect": "~57.0.1",
+ "expo-haptics": "~57.0.1",
"expo-image": "~57.0.2",
"expo-linking": "~57.0.5",
"expo-router": "~57.0.11",
@@ -3771,6 +3772,15 @@
"react-native": "*"
}
},
+ "node_modules/expo-haptics": {
+ "version": "57.0.1",
+ "resolved": "https://registry.npmjs.org/expo-haptics/-/expo-haptics-57.0.1.tgz",
+ "integrity": "sha512-8VhbnxlIrfXjP0syZr1JT197nafYicQu9119adOJnX62osU9Cw+PdDnAx/6LxuKJRzQdwxOMq7b7eWjhNL5zAQ==",
+ "license": "MIT",
+ "peerDependencies": {
+ "expo": "*"
+ }
+ },
"node_modules/expo-image": {
"version": "57.0.2",
"resolved": "https://registry.npmjs.org/expo-image/-/expo-image-57.0.2.tgz",
diff --git a/app/package.json b/app/package.json
index efb79b6..84c41ca 100644
--- a/app/package.json
+++ b/app/package.json
@@ -11,6 +11,7 @@
"expo-device": "~57.0.1",
"expo-font": "~57.0.1",
"expo-glass-effect": "~57.0.1",
+ "expo-haptics": "~57.0.1",
"expo-image": "~57.0.2",
"expo-linking": "~57.0.5",
"expo-router": "~57.0.11",
diff --git a/app/src/app/_layout.tsx b/app/src/app/_layout.tsx
index 539275a..47e62c6 100644
--- a/app/src/app/_layout.tsx
+++ b/app/src/app/_layout.tsx
@@ -2,6 +2,7 @@ import { Link, Stack } from "expo-router";
import { StatusBar } from "expo-status-bar";
import { useEffect } from "react";
import { AppState } from "react-native";
+import { GestureHandlerRootView } from "react-native-gesture-handler";
import { hydrate, sync } from "../lib/store";
import { C } from "../lib/theme";
@@ -16,7 +17,7 @@ export default function RootLayout() {
}, []);
return (
- <>
+
+
+
- >
+
);
}
diff --git a/app/src/app/add.tsx b/app/src/app/add.tsx
new file mode 100644
index 0000000..f4bc907
--- /dev/null
+++ b/app/src/app/add.tsx
@@ -0,0 +1,146 @@
+import { useRouter } from "expo-router";
+import { useState } from "react";
+import { Pressable, ScrollView, StyleSheet, Text, View } from "react-native";
+import PriorityChips from "../components/PriorityChips";
+import { Button, Chips, Field } from "../components/ui";
+import { haptic } from "../lib/haptics";
+import { activeProjects, createTask } from "../lib/store";
+import { C } from "../lib/theme";
+import { useStore } from "../lib/useStore";
+import type { Task, TaskStatus } from "../lib/types";
+
+const BOXES: { key: TaskStatus; label: string }[] = [
+ { key: "inbox", label: "Inbox" },
+ { key: "next", label: "Next up" },
+ { key: "waiting", label: "Waiting" },
+ { key: "someday", label: "Someday" },
+];
+
+const ESTIMATES = [15, 30, 45, 60, 90, 120];
+
+function isoToday(offset = 0): string {
+ const d = new Date();
+ d.setDate(d.getDate() + offset);
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
+}
+
+export default function AddTask() {
+ const router = useRouter();
+ const store = useStore();
+ const [title, setTitle] = useState("");
+ const [notes, setNotes] = useState("");
+ const [priority, setPriority] = useState(4);
+ const [estimate, setEstimate] = useState(null);
+ const [due, setDue] = useState("");
+ const [box, setBox] = useState("inbox");
+ const [projectId, setProjectId] = 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])]);
+
+ const save = () => {
+ if (!title.trim()) return;
+ createTask({
+ title: title.trim(),
+ notes,
+ status: box,
+ priority,
+ estimate_min: estimate,
+ due_date: due.trim() || null,
+ project_id: projectId,
+ });
+ haptic.success();
+ router.back();
+ };
+
+ return (
+
+
+
+
+
+ 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`}
+
+
+ );
+ })}
+
+
+
+
+ {[
+ { label: "Due today", v: isoToday() },
+ { label: "Tomorrow", v: isoToday(1) },
+ ].map(({ label, v }) => (
+ {
+ haptic.select();
+ setDue(due === v ? "" : v);
+ }}
+ >
+ {label}
+
+ ))}
+
+
+
+ b.key)}
+ labels={Object.fromEntries(BOXES.map((b) => [b.key, b.label]))}
+ value={box}
+ onChange={(b) => {
+ haptic.select();
+ setBox(b);
+ }}
+ />
+ setProjectId(v === "none" ? null : v)}
+ />
+
+
+ );
+}
+
+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 },
+});
diff --git a/app/src/app/index.tsx b/app/src/app/index.tsx
index c9c10d2..c81324b 100644
--- a/app/src/app/index.tsx
+++ b/app/src/app/index.tsx
@@ -1,9 +1,21 @@
import { useRouter } from "expo-router";
-import { useState } from "react";
+import { useRef, useState } from "react";
import { Pressable, StyleSheet, Text, TextInput, View } from "react-native";
-import { activeProjects, createTask, tasksByStatus } from "../lib/store";
+import { Gesture, GestureDetector } from "react-native-gesture-handler";
+import Animated, {
+ runOnJS,
+ useAnimatedStyle,
+ useSharedValue,
+ withSpring,
+ withTiming,
+} from "react-native-reanimated";
+import { haptic } from "../lib/haptics";
+import { PRIORITY_COLOR } from "../lib/priority";
+import { parseQuickAdd } from "../lib/quickadd";
+import { activeProjects, createTask, tasksByStatus, todayTasks, updateTask } from "../lib/store";
import { C } from "../lib/theme";
import { useStore } from "../lib/useStore";
+import type { Task, TaskStatus } from "../lib/types";
const Q = {
next: { title: "Next up", color: "#3DBF77", bg: "#15301F", border: "#1E4D33" },
@@ -12,30 +24,82 @@ const Q = {
projects: { title: "Projects", color: "#5B9DE8", bg: "#152538", border: "#1F3D5C" },
};
-function Quadrant({
- kind,
- count,
- preview,
- onPress,
+type DropZone = Extract;
+type Rect = { x: number; y: number; w: number; h: number };
+type ZoneRects = Partial>;
+
+const SPRING = { damping: 18, stiffness: 260, mass: 0.6 };
+
+/** Long-press-drag wrapper: spring transforms, haptics, window-coord drop. */
+function Draggable({
+ task,
+ findZone,
+ children,
}: {
- kind: keyof typeof Q;
- count: number;
- preview: string[];
- onPress: () => void;
+ task: Task;
+ findZone: (x: number, y: number) => DropZone | null;
+ children: React.ReactNode;
}) {
- const q = Q[kind];
+ const tx = useSharedValue(0);
+ const ty = useSharedValue(0);
+ const active = useSharedValue(false);
+
+ const drop = (x: number, y: number) => {
+ const zone = findZone(x, y);
+ if (zone && zone !== task.status) {
+ haptic.success();
+ updateTask(task.id, { status: zone });
+ }
+ };
+
+ const pan = Gesture.Pan()
+ .activateAfterLongPress(180)
+ .onStart(() => {
+ active.value = true;
+ runOnJS(haptic.pickup)();
+ })
+ .onUpdate((e) => {
+ tx.value = e.translationX;
+ ty.value = e.translationY;
+ })
+ .onEnd((e) => {
+ runOnJS(drop)(e.absoluteX, e.absoluteY);
+ })
+ .onFinalize(() => {
+ active.value = false;
+ tx.value = withSpring(0, SPRING);
+ ty.value = withSpring(0, SPRING);
+ });
+
+ const style = useAnimatedStyle(() => ({
+ transform: [
+ { translateX: tx.value },
+ { translateY: ty.value },
+ { scale: withTiming(active.value ? 1.06 : 1, { duration: 120 }) },
+ ],
+ zIndex: active.value ? 100 : 0,
+ elevation: active.value ? 8 : 0,
+ opacity: withTiming(active.value ? 0.92 : 1, { duration: 120 }),
+ }));
+
return (
-
-
- {q.title}
- {count}
-
- {preview.map((p, i) => (
-
- {p}
+
+ {children}
+
+ );
+}
+
+function DragRow({ task, findZone }: { task: Task; findZone: (x: number, y: number) => DropZone | null }) {
+ const router = useRouter();
+ return (
+
+ router.push(`/task/${task.id}`)}>
+
+
+ {task.title}
- ))}
-
+
+
);
}
@@ -43,12 +107,29 @@ export default function Home() {
const router = useRouter();
const store = useStore();
const [draft, setDraft] = useState("");
+ const zones = useRef({});
+ const zoneRefs = useRef>>({});
+
+ const measureZone = (zone: DropZone) => () => {
+ zoneRefs.current[zone]?.measureInWindow((x, y, w, h) => {
+ zones.current[zone] = { x, y, w, h };
+ });
+ };
+
+ const findZone = (x: number, y: number): DropZone | null => {
+ for (const [zone, r] of Object.entries(zones.current) as [DropZone, Rect][]) {
+ if (x >= r.x && x <= r.x + r.w && y >= r.y && y <= r.y + r.h) return zone;
+ }
+ return null;
+ };
const capture = () => {
- const title = draft.trim();
- if (!title) return;
+ if (!draft.trim()) return;
+ const parsed = parseQuickAdd(draft, store.projects);
+ if (!parsed.title) return;
setDraft("");
- createTask({ title });
+ haptic.tap();
+ createTask({ ...parsed, status: "inbox" });
};
const inbox = tasksByStatus(store, "inbox");
@@ -56,12 +137,34 @@ export default function Home() {
const waiting = tasksByStatus(store, "waiting");
const someday = tasksByStatus(store, "someday");
const projects = activeProjects(store).filter((p) => p.status === "active");
+ const { overdue, today } = todayTasks(store);
+
+ const quadrant = (zone: Exclude, tasks: Task[], onOpen: () => void) => {
+ const q = Q[zone];
+ return (
+ {
+ zoneRefs.current[zone] = r;
+ }}
+ onLayout={measureZone(zone)}
+ style={[s.quad, { backgroundColor: q.bg, borderColor: q.border }]}
+ >
+
+ {q.title}
+ {tasks.length}
+
+ {tasks.slice(0, 3).map((t) => (
+
+ ))}
+
+ );
+ };
return (
- router.push("/list/inbox")}>
- 📥 Inbox
-
- {store.pending > 0 && ⇅ {store.pending}}
- {inbox.length}
+
+ {
+ zoneRefs.current.inbox = r;
+ }}
+ onLayout={measureZone("inbox")}
+ style={s.pill}
+ >
+ router.push("/list/inbox")}>
+ 📥 Inbox
+ {inbox.length}
+
-
+
+ router.push("/today")}>
+ 📅 Today
+
+ {overdue.length ? `${overdue.length}!` : today.length}
+
+
+
+
+ {inbox.slice(0, 3).map((t) => (
+
+ ))}
{store.syncError && {store.syncError}}
- t.title)} onPress={() => router.push("/list/next")} />
- t.title)} onPress={() => router.push("/list/waiting")} />
+ {quadrant("next", next, () => router.push("/list/next"))}
+ {quadrant("waiting", waiting, () => router.push("/list/waiting"))}
- t.title)} onPress={() => router.push("/list/someday")} />
- p.name)} onPress={() => router.push("/projects")} />
+ {quadrant("someday", someday, () => router.push("/list/someday"))}
+
+ router.push("/projects")}>
+
+ {Q.projects.title}
+ {projects.length}
+
+ {projects.slice(0, 3).map((p) => (
+
+ {p.name}
+
+ ))}
+
+
+ { haptic.tap(); router.push("/add"); }}>
+ +
+
);
}
@@ -102,26 +238,59 @@ const s = StyleSheet.create({
borderWidth: 1,
borderColor: C.border,
},
- inboxPill: {
- flexDirection: "row",
- justifyContent: "space-between",
- alignItems: "center",
- marginTop: 10,
- paddingVertical: 12,
- paddingHorizontal: 16,
+ pillRow: { flexDirection: "row", gap: 10, marginTop: 10 },
+ pill: {
+ flex: 1,
borderRadius: 12,
backgroundColor: C.surface,
borderWidth: 1,
borderColor: C.border,
},
- inboxText: { color: C.text, fontSize: 16, fontWeight: "600" },
- pendingDot: { color: C.muted, fontSize: 13 },
+ pillPress: {
+ flexDirection: "row",
+ justifyContent: "space-between",
+ alignItems: "center",
+ paddingVertical: 12,
+ paddingHorizontal: 14,
+ },
+ pillText: { color: C.text, fontSize: 15, fontWeight: "600" },
+ dragRow: {
+ flexDirection: "row",
+ alignItems: "center",
+ gap: 8,
+ marginTop: 6,
+ paddingVertical: 9,
+ paddingHorizontal: 12,
+ borderRadius: 10,
+ backgroundColor: C.surface2,
+ borderWidth: 1,
+ borderColor: C.border,
+ },
+ dragRowText: { color: C.text, fontSize: 14, flex: 1 },
+ priorityDot: { width: 8, height: 8, borderRadius: 4 },
grid: { flex: 1, marginTop: 10, gap: 10 },
gridRow: { flex: 1, flexDirection: "row", gap: 10 },
- quad: { flex: 1, borderRadius: 16, borderWidth: 1, padding: 14 },
- quadHead: { flexDirection: "row", justifyContent: "space-between", marginBottom: 8 },
- quadTitle: { fontSize: 17, fontWeight: "700" },
- quadCount: { fontSize: 17, fontWeight: "700" },
+ quad: { flex: 1, borderRadius: 16, borderWidth: 1, padding: 12, overflow: "visible" },
+ quadHead: { flexDirection: "row", justifyContent: "space-between", marginBottom: 4 },
+ quadTitle: { fontSize: 16, fontWeight: "700" },
+ quadCount: { fontSize: 16, fontWeight: "700" },
quadItem: { color: C.muted, fontSize: 13, marginBottom: 4 },
error: { color: C.danger, marginTop: 8 },
+ fab: {
+ position: "absolute",
+ right: 20,
+ bottom: 24,
+ width: 58,
+ height: 58,
+ borderRadius: 29,
+ backgroundColor: C.accent,
+ alignItems: "center",
+ justifyContent: "center",
+ elevation: 6,
+ shadowColor: "#000",
+ shadowOpacity: 0.35,
+ shadowRadius: 8,
+ shadowOffset: { width: 0, height: 4 },
+ },
+ fabText: { color: "#FFFFFF", fontSize: 28, lineHeight: 32, fontWeight: "600" },
});
diff --git a/app/src/app/task/[id].tsx b/app/src/app/task/[id].tsx
index e0fce8b..08533fc 100644
--- a/app/src/app/task/[id].tsx
+++ b/app/src/app/task/[id].tsx
@@ -1,6 +1,7 @@
import { useLocalSearchParams, useRouter } from "expo-router";
import { useState } from "react";
import { ScrollView, Text } from "react-native";
+import PriorityChips from "../../components/PriorityChips";
import { Button, Chips, Field } from "../../components/ui";
import { activeProjects, trashTask, updateTask } from "../../lib/store";
import { C } from "../../lib/theme";
@@ -31,6 +32,8 @@ export default function TaskDetail() {
waiting_for: draft.waiting_for,
due_date: draft.due_date,
defer_date: draft.defer_date,
+ priority: draft.priority,
+ estimate_min: draft.estimate_min,
});
router.back();
};
@@ -42,6 +45,14 @@ export default function TaskDetail() {
return (
set({ title: v })} />
+ set({ priority })} />
+ set({ estimate_min: Number(v.replace(/\D/g, "")) || null })}
+ placeholder="30"
+ keyboardType="number-pad"
+ />
set({ status })} />
t.id}
+ renderItem={({ item }) => }
+ renderSectionHeader={({ section }) => (
+ {section.title}
+ )}
+ ListEmptyComponent={Nothing due. Clear runway.}
+ contentContainerStyle={{ paddingBottom: 32 }}
+ stickySectionHeadersEnabled={false}
+ />
+ );
+}
+
+const s = StyleSheet.create({
+ header: { fontSize: 15, fontWeight: "700", paddingHorizontal: 16, paddingTop: 18, paddingBottom: 6 },
+ empty: { color: C.muted, textAlign: "center", marginTop: 48, fontSize: 15 },
+});
diff --git a/app/src/components/PriorityChips.tsx b/app/src/components/PriorityChips.tsx
new file mode 100644
index 0000000..f2e3c3c
--- /dev/null
+++ b/app/src/components/PriorityChips.tsx
@@ -0,0 +1,56 @@
+import { Pressable, StyleSheet, Text, View } from "react-native";
+import { haptic } from "../lib/haptics";
+import { PRIORITIES, PRIORITY_COLOR, PRIORITY_LABEL } from "../lib/priority";
+import { C } from "../lib/theme";
+import type { Task } from "../lib/types";
+
+export default function PriorityChips({
+ value,
+ onChange,
+}: {
+ value: Task["priority"];
+ onChange: (p: Task["priority"]) => void;
+}) {
+ return (
+
+ Priority
+
+ {PRIORITIES.map((p) => {
+ const active = value === p;
+ const color = PRIORITY_COLOR[p];
+ return (
+ {
+ haptic.select();
+ onChange(p);
+ }}
+ >
+
+ {PRIORITY_LABEL[p]}
+
+ );
+ })}
+
+
+ );
+}
+
+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: {
+ flexDirection: "row",
+ alignItems: "center",
+ gap: 7,
+ paddingVertical: 8,
+ paddingHorizontal: 13,
+ borderRadius: 20,
+ backgroundColor: C.surface,
+ borderWidth: 1,
+ borderColor: C.border,
+ },
+ flag: { width: 10, height: 10, borderRadius: 5 },
+ text: { color: C.muted, fontSize: 14 },
+});
diff --git a/app/src/components/TaskListScreen.tsx b/app/src/components/TaskListScreen.tsx
index 543f378..c994ed3 100644
--- a/app/src/components/TaskListScreen.tsx
+++ b/app/src/components/TaskListScreen.tsx
@@ -1,42 +1,12 @@
-import { useRouter } from "expo-router";
import { useState } from "react";
-import {
- FlatList,
- Pressable,
- RefreshControl,
- StyleSheet,
- Text,
- TextInput,
- View,
-} from "react-native";
-import { createTask, sync, tasksByStatus, updateTask } from "../lib/store";
+import { FlatList, RefreshControl, StyleSheet, Text, TextInput, View } from "react-native";
+import { haptic } from "../lib/haptics";
+import { parseQuickAdd } from "../lib/quickadd";
+import { createTask, sync, tasksByStatus } from "../lib/store";
import { C } from "../lib/theme";
import { useStore } from "../lib/useStore";
-import type { Task, TaskStatus } from "../lib/types";
-
-function TaskRow({ task, listStatus }: { task: Task; listStatus: TaskStatus }) {
- const router = useRouter();
- const meta = [task.context, task.waiting_for && `→ ${task.waiting_for}`, task.due_date && `due ${task.due_date}`]
- .filter(Boolean)
- .join(" · ");
- return (
- router.push(`/task/${task.id}`)}>
- updateTask(task.id, { status: task.status === "done" ? listStatus : "done" })}
- >
- {task.status === "done" && }
-
-
-
- {task.title}
-
- {!!meta && {meta}}
-
-
- );
-}
+import type { TaskStatus } from "../lib/types";
+import TaskRow from "./TaskRow";
export default function TaskListScreen({
status,
@@ -53,10 +23,12 @@ export default function TaskListScreen({
const tasks = tasksByStatus(store, status);
const add = () => {
- const title = draft.trim();
- if (!title) return;
+ if (!draft.trim()) return;
+ const parsed = parseQuickAdd(draft, store.projects);
+ if (!parsed.title) return;
setDraft("");
- createTask({ title, status });
+ haptic.tap();
+ createTask({ ...parsed, status });
};
return (
@@ -76,7 +48,7 @@ export default function TaskListScreen({
t.id}
- renderItem={({ item }) => }
+ renderItem={({ item }) => }
refreshControl={
{
+ const done = task.status !== "done";
+ if (done) haptic.success();
+ else haptic.tap();
+ updateTask(task.id, { status: done ? "done" : uncheckTo });
+ };
+
+ return (
+
+ router.push(`/task/${task.id}`)}>
+
+ {task.status === "done" && }
+
+
+
+ {task.title}
+
+ {!!meta && {meta}}
+
+
+
+ );
+}
+
+const s = StyleSheet.create({
+ row: {
+ flexDirection: "row",
+ alignItems: "center",
+ gap: 12,
+ paddingVertical: 14,
+ paddingHorizontal: 16,
+ borderBottomWidth: StyleSheet.hairlineWidth,
+ borderBottomColor: C.border,
+ },
+ checkbox: {
+ width: 22,
+ height: 22,
+ borderRadius: 11,
+ borderWidth: 2,
+ alignItems: "center",
+ justifyContent: "center",
+ },
+ checkboxFill: { width: 12, height: 12, borderRadius: 6, backgroundColor: C.done },
+ title: { color: C.text, fontSize: 16 },
+ meta: { color: C.muted, fontSize: 13, marginTop: 2 },
+});
diff --git a/app/src/lib/haptics.ts b/app/src/lib/haptics.ts
new file mode 100644
index 0000000..473b3f6
--- /dev/null
+++ b/app/src/lib/haptics.ts
@@ -0,0 +1,24 @@
+// Haptics that silently no-op on web.
+import * as Haptics from "expo-haptics";
+import { Platform } from "react-native";
+
+const canBuzz = Platform.OS !== "web";
+
+export const haptic = {
+ /** Light tick — captures, small confirmations. */
+ tap: () => {
+ if (canBuzz) void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
+ },
+ /** Medium thunk — picking up a draggable. */
+ pickup: () => {
+ if (canBuzz) void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
+ },
+ /** Success notification — completing a task, landing a drop. */
+ success: () => {
+ if (canBuzz) void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
+ },
+ /** Selection change — flicking through chips. */
+ select: () => {
+ if (canBuzz) void Haptics.selectionAsync();
+ },
+};
diff --git a/app/src/lib/priority.ts b/app/src/lib/priority.ts
new file mode 100644
index 0000000..0d6479c
--- /dev/null
+++ b/app/src/lib/priority.ts
@@ -0,0 +1,24 @@
+import type { Task } from "./types";
+
+export const PRIORITIES: Task["priority"][] = [1, 2, 3, 4];
+
+export const PRIORITY_LABEL: Record = {
+ 1: "Urgent",
+ 2: "High",
+ 3: "Medium",
+ 4: "Low",
+};
+
+export const PRIORITY_COLOR: Record = {
+ 1: "#E05B5B",
+ 2: "#E0A93E",
+ 3: "#5B8DEF",
+ 4: "#6B7280",
+};
+
+export function formatEstimate(min: number | null): string | null {
+ if (!min) return null;
+ const h = Math.floor(min / 60);
+ const m = min % 60;
+ return h ? (m ? `${h}h${m}` : `${h}h`) : `${m}m`;
+}
diff --git a/app/src/lib/quickadd.ts b/app/src/lib/quickadd.ts
new file mode 100644
index 0000000..70022ab
--- /dev/null
+++ b/app/src/lib/quickadd.ts
@@ -0,0 +1,92 @@
+// Natural-language quick add, Todoist style:
+// "Pay rent tomorrow p1 @home #Sydney ~30m"
+// → title "Pay rent", due tomorrow, priority 1, context @home,
+// project "Sydney" (matched by name prefix), estimate 30 min.
+import type { Project, Task } from "./types";
+
+export interface ParsedQuickAdd {
+ title: string;
+ priority?: Task["priority"];
+ due_date?: string;
+ context?: string;
+ project_id?: string;
+ estimate_min?: number;
+}
+
+const WEEKDAYS = ["sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday"];
+
+function iso(d: Date): string {
+ const y = d.getFullYear();
+ const m = String(d.getMonth() + 1).padStart(2, "0");
+ const day = String(d.getDate()).padStart(2, "0");
+ return `${y}-${m}-${day}`;
+}
+
+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") {
+ const d = new Date(today);
+ d.setDate(d.getDate() + 1);
+ return iso(d);
+ }
+ if (/^\d{4}-\d{2}-\d{2}$/.test(w)) return w;
+ const wd = WEEKDAYS.findIndex((n) => n === w || n.slice(0, 3) === w);
+ if (wd >= 0) {
+ const d = new Date(today);
+ const delta = (wd - d.getDay() + 7) % 7 || 7; // always the *next* occurrence
+ d.setDate(d.getDate() + delta);
+ return iso(d);
+ }
+ return undefined;
+}
+
+function parseEstimate(token: string): number | undefined {
+ const m = token.match(/^~(?:(\d+)h)?(\d+)?m?$/i);
+ if (!m || (!m[1] && !m[2])) return undefined;
+ return (Number(m[1] ?? 0) * 60 + Number(m[2] ?? 0)) || undefined;
+}
+
+export function parseQuickAdd(input: string, projects: Project[]): ParsedQuickAdd {
+ const out: ParsedQuickAdd = { title: "" };
+ const rest: string[] = [];
+
+ for (const token of input.trim().split(/\s+/)) {
+ const p = token.match(/^p([1-4])$/i);
+ if (p && out.priority === undefined) {
+ out.priority = Number(p[1]) as Task["priority"];
+ continue;
+ }
+ if (token.startsWith("@") && token.length > 1 && out.context === undefined) {
+ out.context = token;
+ continue;
+ }
+ if (token.startsWith("#") && token.length > 1 && out.project_id === undefined) {
+ const name = token.slice(1).toLowerCase();
+ const match = projects.find(
+ (pr) => pr.status === "active" && pr.name.toLowerCase().startsWith(name),
+ );
+ if (match) {
+ out.project_id = match.id;
+ continue;
+ }
+ }
+ const est = parseEstimate(token);
+ if (est && out.estimate_min === undefined) {
+ out.estimate_min = est;
+ continue;
+ }
+ if (out.due_date === undefined) {
+ const due = parseDateWord(token);
+ if (due) {
+ out.due_date = due;
+ continue;
+ }
+ }
+ rest.push(token);
+ }
+
+ out.title = rest.join(" ");
+ return out;
+}
diff --git a/app/src/lib/store.ts b/app/src/lib/store.ts
index 93075c5..8b36117 100644
--- a/app/src/lib/store.ts
+++ b/app/src/lib/store.ts
@@ -78,7 +78,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", "sort_order"] as const;
+const TASK_FIELDS = ["title", "notes", "status", "project_id", "context", "waiting_for", "due_date", "defer_date", "priority", "estimate_min", "sort_order"] as const;
const PROJECT_FIELDS = ["name", "status", "notes", "sort_order"] as const;
function pickFields(data: Partial, fields: readonly (keyof T & string)[]): Partial {
@@ -111,6 +111,8 @@ export function createTask(data: Partial & { title: string }): Task {
waiting_for: data.waiting_for ?? null,
due_date: data.due_date ?? null,
defer_date: data.defer_date ?? null,
+ priority: data.priority ?? 4,
+ estimate_min: data.estimate_min ?? null,
completed_at: null,
sort_order: data.sort_order ?? 0,
created_at: now(),
@@ -237,10 +239,30 @@ async function pull() {
// ---- selectors ----
+function byPriorityDue(a: Task, b: Task): number {
+ return (
+ a.priority - b.priority ||
+ (a.due_date ?? "9999").localeCompare(b.due_date ?? "9999") ||
+ a.sort_order - b.sort_order ||
+ b.created_at.localeCompare(a.created_at)
+ );
+}
+
export function tasksByStatus(s: State, status: Task["status"]): Task[] {
- return s.tasks
- .filter((t) => t.status === status)
- .sort((a, b) => a.sort_order - b.sort_order || b.created_at.localeCompare(a.created_at));
+ return s.tasks.filter((t) => t.status === status).sort(byPriorityDue);
+}
+
+/** Overdue + due-today + deferred-arriving-today, across all open lists. */
+export function todayTasks(s: State): { overdue: Task[]; today: Task[] } {
+ const now = new Date();
+ const todayIso = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
+ const open = s.tasks.filter((t) => t.status !== "done" && t.status !== "trashed");
+ return {
+ overdue: open.filter((t) => t.due_date && t.due_date < todayIso).sort(byPriorityDue),
+ today: open
+ .filter((t) => t.due_date === todayIso || (t.defer_date === todayIso && !t.due_date))
+ .sort(byPriorityDue),
+ };
}
export function activeProjects(s: State): Project[] {
diff --git a/app/src/lib/types.ts b/app/src/lib/types.ts
index 632cb82..2b569c9 100644
--- a/app/src/lib/types.ts
+++ b/app/src/lib/types.ts
@@ -11,6 +11,8 @@ export interface Task {
waiting_for: string | null;
due_date: string | null;
defer_date: string | null;
+ priority: 1 | 2 | 3 | 4;
+ estimate_min: number | null;
completed_at: string | null;
sort_order: number;
created_at: string;
diff --git a/server/index.ts b/server/index.ts
index 2bbfc7b..e0318b1 100644
--- a/server/index.ts
+++ b/server/index.ts
@@ -7,7 +7,7 @@ if (!API_TOKEN) throw new Error("API_TOKEN not set");
const TASK_COLS = `id, title, notes, status, project_id, context, waiting_for,
due_date::text as due_date, defer_date::text as defer_date,
- completed_at, sort_order, created_at, updated_at`;
+ priority, estimate_min, 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"];
@@ -28,7 +28,7 @@ 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", "sort_order"];
+const TASK_FIELDS = ["title", "notes", "status", "project_id", "context", "waiting_for", "due_date", "defer_date", "priority", "estimate_min", "sort_order"];
const PROJECT_FIELDS = ["name", "status", "notes", "sort_order"];
function pick(body: Record, fields: string[]) {
@@ -73,12 +73,13 @@ async function handle(req: Request): Promise {
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, sort_order)
- values (coalesce($1::uuid, gen_random_uuid()), $2, $3, $4, $5, $6, $7, $8, $9, $10)
+ `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)
on conflict (id) do nothing
returning ${TASK_COLS}`,
[id, t.title, t.notes ?? "", t.status ?? "inbox", t.project_id ?? null, t.context ?? null,
- t.waiting_for ?? null, t.due_date ?? null, t.defer_date ?? null, t.sort_order ?? 0]);
+ t.waiting_for ?? null, t.due_date ?? null, t.defer_date ?? null, t.priority ?? 4,
+ t.estimate_min ?? 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);
@@ -99,10 +100,11 @@ async function handle(req: Request): Promise {
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, completed_at=$10, sort_order=$11, updated_at=now()
+ due_date=$8, defer_date=$9, priority=$10, estimate_min=$11, completed_at=$12, sort_order=$13,
+ 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, completedAt, cur.sort_order]);
+ cur.due_date, cur.defer_date, cur.priority, cur.estimate_min, completedAt, cur.sort_order]);
return json(rows[0]);
}
diff --git a/server/schema.sql b/server/schema.sql
index 2883e00..796d830 100644
--- a/server/schema.sql
+++ b/server/schema.sql
@@ -23,6 +23,8 @@ create table if not exists tasks (
waiting_for text, -- who/what is being waited on when status = 'waiting'
due_date 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
+ estimate_min int check (estimate_min > 0),
completed_at timestamptz,
sort_order double precision not null default 0,
created_at timestamptz not null default now(),