Todoist-style: priorities P1-P4, estimates, FAB add form, Today view, natural quick add, drag-and-drop with haptics
All checks were successful
Build & Release APK / build (push) Successful in 1m53s

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-07 19:46:11 -07:00
parent 035a114e58
commit 0487bc7e4e
17 changed files with 752 additions and 123 deletions

24
app/src/lib/haptics.ts Normal file
View File

@@ -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();
},
};

24
app/src/lib/priority.ts Normal file
View File

@@ -0,0 +1,24 @@
import type { Task } from "./types";
export const PRIORITIES: Task["priority"][] = [1, 2, 3, 4];
export const PRIORITY_LABEL: Record<Task["priority"], string> = {
1: "Urgent",
2: "High",
3: "Medium",
4: "Low",
};
export const PRIORITY_COLOR: Record<Task["priority"], string> = {
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`;
}

92
app/src/lib/quickadd.ts Normal file
View File

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

View File

@@ -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<T>(data: Partial<T>, fields: readonly (keyof T & string)[]): Partial<T> {
@@ -111,6 +111,8 @@ export function createTask(data: Partial<Task> & { 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[] {

View File

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