"use client"; import Link from "next/link"; import { useEffect, useMemo, useRef, useState } from "react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { Check, Plus, Trash2, CalendarDays, Flag, Hash, Link2, X, Inbox as InboxIcon, Filter, ChevronDown, Search } from "lucide-react"; import { endOfDay, startOfDay, addDays, isBefore, format } from "date-fns"; import { api } from "@/lib/client/api"; import { cn, fmtDate } from "@/lib/utils"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Badge, Empty, Kbd, Menu, MenuTrigger, MenuContent, MenuItem, MenuSeparator, Popover, PopoverTrigger, PopoverContent, Spinner, Switch } from "@/components/ui/primitives"; import { toast } from "@/components/ui/toast"; import { parseTaskInput } from "./parse"; import type { TreeNode } from "@/components/shell/types"; export interface Task { id: string; title: string; status: string; priority: "low" | "medium" | "high" | null; dueAt: string | null; completedAt: string | null; project: string | null; pageId: string | null; pageTitle: string | null; pageIcon: string | null; createdAt: string; updatedAt: string } type GroupId = "overdue" | "today" | "week" | "later" | "nodate" | "done"; const GROUPS: { id: GroupId; label: string }[] = [{ id: "overdue", label: "Overdue" }, { id: "today", label: "Today" }, { id: "week", label: "This week" }, { id: "later", label: "Later" }, { id: "nodate", label: "No date" }, { id: "done", label: "Done" }]; const PRIORITIES = ["high", "medium", "low"] as const; const prioTone = (p: string | null) => (p === "high" ? "danger" : p === "low" ? "neutral" : "warning") as "danger" | "neutral" | "warning"; const toLocalInput = (iso: string | null) => (iso ? format(new Date(iso), "yyyy-MM-dd'T'HH:mm") : ""); const fromLocalInput = (v: string) => (v ? new Date(v).toISOString() : null); function groupOf(t: Task, now: Date): GroupId { if (t.status === "done") return "done"; if (!t.dueAt) return "nodate"; const d = new Date(t.dueAt); if (isBefore(d, startOfDay(now))) return "overdue"; if (!isBefore(endOfDay(now), d)) return "today"; if (!isBefore(endOfDay(addDays(now, 6)), d)) return "week"; return "later"; } function PagePicker({ value, label, onChange, tree }: { value: string | null; label: string | null; onChange: (id: string | null, title: string | null) => void; tree: TreeNode[] }) { const [q, setQ] = useState(""); const [open, setOpen] = useState(false); const hits = useMemo(() => { const s = q.trim().toLowerCase(); return (s ? tree.filter((n) => n.title.toLowerCase().includes(s)) : tree).slice(0, 30); }, [q, tree]); return (
setQ(e.target.value)} placeholder="Search pages…" className="h-8 flex-1 bg-transparent text-[13px] outline-none" />
{hits.length ? hits.map((n) => ) :
No match
}
); } function TaskRow({ t, tree, projects, onPatch, onDelete, editing, setEditing }: { t: Task; tree: TreeNode[]; projects: string[]; onPatch: (id: string, patch: Record) => Promise; onDelete: (id: string) => void; editing: boolean; setEditing: (v: boolean) => void }) { const [draft, setDraft] = useState({ title: t.title, dueAt: toLocalInput(t.dueAt), priority: (t.priority ?? "medium") as "low" | "medium" | "high", project: t.project ?? "", pageId: t.pageId, pageTitle: t.pageTitle }); const [busy, setBusy] = useState(false); const titleRef = useRef(null); useEffect(() => { if (editing) { setDraft({ title: t.title, dueAt: toLocalInput(t.dueAt), priority: (t.priority ?? "medium") as any, project: t.project ?? "", pageId: t.pageId, pageTitle: t.pageTitle }); setTimeout(() => titleRef.current?.focus(), 0); } /* eslint-disable-next-line */ }, [editing]); async function save() { if (!draft.title.trim()) return; setBusy(true); try { await onPatch(t.id, { title: draft.title.trim(), dueAt: fromLocalInput(draft.dueAt), priority: draft.priority, project: draft.project.trim() || null, pageId: draft.pageId }); setEditing(false); } finally { setBusy(false); } } const due = t.dueAt ? new Date(t.dueAt) : null; const overdue = due && t.status !== "done" && isBefore(due, startOfDay(new Date())); if (editing) return (
{ if (e.key === "Escape") { e.preventDefault(); setEditing(false); } else if (e.key === "Enter" && !(e.target as HTMLElement).closest("[data-no-enter]")) { e.preventDefault(); save(); } }}> setDraft({ ...draft, title: e.target.value })} placeholder="Task title" className="mb-2" />
{PRIORITIES.map((p) => )}
setDraft({ ...draft, pageId: id, pageTitle: title })} tree={tree} />
); return (
{t.priority && t.priority !== "medium" && {t.priority}} {due && {fmtDate(due, { month: "short", day: "numeric", hour: due.getHours() !== 17 || due.getMinutes() !== 0 ? "numeric" : undefined, minute: due.getHours() !== 17 || due.getMinutes() !== 0 ? "2-digit" : undefined })}}
); } export function TasksInbox() { const qc = useQueryClient(); const [showDone, setShowDone] = useState(false); const [project, setProject] = useState(null); const [prio, setPrio] = useState(null); const { data: tasks = [], isLoading } = useQuery({ queryKey: ["tasks", showDone], queryFn: () => api(`/api/tasks${showDone ? "?done=1" : ""}`), staleTime: 5_000 }); const { data: tree = [] } = useQuery({ queryKey: ["tree"], queryFn: () => api("/api/tree"), staleTime: 10_000 }); const [input, setInput] = useState(""); const [adding, setAdding] = useState(false); const [editingId, setEditingId] = useState(null); const inputRef = useRef(null); const preview = useMemo(() => (input.trim() ? parseTaskInput(input) : null), [input]); const projects = useMemo(() => Array.from(new Set(tasks.map((t) => t.project).filter((x): x is string => Boolean(x)))).sort(), [tasks]); const now = new Date(); const filtered = tasks.filter((t) => (!project || t.project === project) && (!prio || t.priority === prio)); const grouped = useMemo(() => { const m: Record = { overdue: [], today: [], week: [], later: [], nodate: [], done: [] }; for (const t of filtered) m[groupOf(t, now)].push(t); return m; /* eslint-disable-next-line */ }, [filtered]); const invalidate = () => { qc.invalidateQueries({ queryKey: ["tasks"] }); qc.invalidateQueries({ queryKey: ["dashboard"] }); }; useEffect(() => { const onKey = (e: KeyboardEvent) => { const el = e.target as HTMLElement; const typing = el.tagName === "INPUT" || el.tagName === "TEXTAREA" || el.isContentEditable; if (!typing && e.key === "n" && !e.metaKey && !e.ctrlKey && !e.altKey) { e.preventDefault(); inputRef.current?.focus(); } }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, []); async function add() { const p = preview; if (!p || !p.title) return; setAdding(true); try { await api("/api/tasks", { method: "POST", json: { title: p.title, dueAt: p.dueAt?.toISOString() ?? null, priority: p.priority ?? "medium", project: p.project ?? (project || null) } }); setInput(""); invalidate(); } catch (e: any) { toast("Could not add task", { description: e.message, tone: "danger" }); } finally { setAdding(false); } } async function patch(id: string, body: Record) { qc.setQueryData(["tasks", showDone], (old) => old?.map((t) => (t.id === id ? { ...t, ...body, completedAt: body.status === "done" ? new Date().toISOString() : body.status === "todo" ? null : t.completedAt } as Task : t))); try { await api("/api/tasks", { method: "PATCH", json: { id, ...body } }); } catch (e: any) { toast("Update failed", { description: e.message, tone: "danger" }); } finally { invalidate(); } } async function remove(id: string) { qc.setQueryData(["tasks", showDone], (old) => old?.filter((t) => t.id !== id)); try { await api(`/api/tasks?id=${id}`, { method: "DELETE" }); toast("Task deleted"); } catch (e: any) { toast("Delete failed", { description: e.message, tone: "danger" }); } finally { invalidate(); } } const open = tasks.filter((t) => t.status !== "done").length; return (

Tasks

{open} open{grouped.overdue.length ? · {grouped.overdue.length} overdue : null}

setProject(null)}>All projects {!project && "✓"}{projects.length ? projects.map((p) => setProject(p)}>{p} {project === p && "✓"}) :
No project yet — add #name when creating a task
}
setPrio(null)}>Any priority {!prio && "✓"}{PRIORITIES.map((p) => setPrio(p)} className="capitalize">{p} {prio === p && "✓"})}
setInput(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter" && !e.nativeEvent.isComposing) { e.preventDefault(); add(); } else if (e.key === "Escape") { setInput(""); (e.target as HTMLInputElement).blur(); } }} placeholder='Add a task… e.g. "Send abstract to JIMF nov 15 !high #thesis"' className="h-9 min-w-0 flex-1 bg-transparent text-[14px] outline-none placeholder:text-fg-faint" />
{preview ? <>{preview.title}{preview.dueAt && {fmtDate(preview.dueAt, { weekday: "short", month: "short", day: "numeric", hour: "numeric", minute: "2-digit" })}}{preview.priority && {preview.priority}}{(preview.project ?? project) && #{preview.project ?? project}} : Understands tomorrow, next monday, in 3 days, nov 15, at 9am, !high, #project. Press n to focus.}
{isLoading ?
: !filtered.length ? } title={tasks.length ? "No task matches the filters" : "Inbox zero"} hint={tasks.length ? "Clear the project or priority filter." : "Add your first task above. Tasks with a due date also show on the home dashboard."} action={tasks.length ? : undefined} /> : (
{GROUPS.filter((g) => grouped[g.id].length).map((g) => (
{g.label}{grouped[g.id].length}
{grouped[g.id].map((t) => setEditingId(v ? t.id : null)} />)}
))}
)}
); }