"use client"; import { useMemo, useState, useTransition } from "react"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { AnimatePresence, motion } from "framer-motion"; import { PlusIcon, Trash2Icon } from "lucide-react"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { Checkbox } from "@/components/ui/checkbox"; import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; import { createTaskAction, deleteTaskAction, toggleTaskAction, updateTaskAction, } from "@/server/actions"; import { cn, relativeDay } from "@/lib/utils"; import { daysBetween } from "@/lib/time"; import { useViewerZone } from "@/components/viewer-zone"; import { DateField } from "@/components/ui/date-field"; import { SubjectPicker, subjectColumns, type SubjectOption, } from "@/components/tasks/subject-picker"; import { SUBJECT_LABEL, type TaskSubjectKind, type TaskSubjectView } from "@/lib/task-subject"; export type TaskRow = { id: string; title: string; detail: string; /** Full ISO, for reading a date off; empty when it has no due date. */ dueISO: string; /** yyyy-mm-dd, what a date input wants. Empty when undated. */ dueDate: string; done: boolean; /** Whatever this one is about, already resolved. Null for a loose task. */ subject: TaskSubjectView | null; }; /** The buckets, in the order a person works down them. */ const BUCKETS = [ { key: "overdue", label: "Overdue" }, { key: "today", label: "Today" }, { key: "week", label: "Next 7 days" }, { key: "later", label: "Later" }, { key: "undated", label: "No date" }, ] as const; type BucketKey = (typeof BUCKETS)[number]["key"]; /** * Which pile a task belongs in, counted in calendar days on the reader's * calendar. Reading the host's parts instead put a task due tonight in * Overdue for anyone west of the server — and, because this renders on both * sides of a hydration, could put it in two different piles at once. */ function bucketOf(dueISO: string, timeZone: string): BucketKey { if (!dueISO) return "undated"; const days = daysBetween(new Date(), new Date(dueISO), timeZone); if (days < 0) return "overdue"; if (days === 0) return "today"; if (days <= 7) return "week"; return "later"; } /** * Everything you owe yourself, as one worked list. * * The bell in the top bar is the glance — what has come round, five words * each. This is the front page, where you actually mean to clear the list, so * a task here can be reworded, re-dated, hooked to a role and deleted — none * of which was possible anywhere in the app before, tasks being write-once and * tick-once. * * Grouped by when rather than by what: an overdue thing and a Thursday thing * are different problems, and a flat list sorted by date makes you work that * out for yourself every time you look. */ export function TaskPanel({ tasks, subjects, }: { tasks: TaskRow[]; /** Everything a task can be hung on, across all six kinds. */ subjects: SubjectOption[]; }) { const zone = useViewerZone(); const router = useRouter(); const [pending, startTransition] = useTransition(); const [draft, setDraft] = useState(""); const [draftDue, setDraftDue] = useState(""); const [draftDetail, setDraftDetail] = useState(""); const [draftSubject, setDraftSubject] = useState<{ kind: TaskSubjectKind; id: string } | null>( null, ); const [showDone, setShowDone] = useState(false); const open = useMemo(() => tasks.filter((task) => !task.done), [tasks]); const done = useMemo(() => tasks.filter((task) => task.done), [tasks]); const grouped = useMemo(() => { const map = new Map(); for (const task of open) { const key = bucketOf(task.dueISO, zone); map.set(key, [...(map.get(key) ?? []), task]); } // Soonest first inside a bucket; undated keeps the order it arrived in, // which is newest-created first. for (const [key, rows] of map) { if (key !== "undated") rows.sort((a, b) => a.dueISO.localeCompare(b.dueISO)); } return map; }, [open, zone]); const act = (work: () => Promise, message?: string) => { startTransition(async () => { try { await work(); if (message) toast.success(message); router.refresh(); } catch (error) { toast.error(error instanceof Error ? error.message : "Could not do that."); } }); }; const add = () => { const title = draft.trim(); if (!title) return; const dueAt = draftDue; const detail = draftDetail.trim(); const subject = draftSubject; // Cleared before the write, not after: a second task typed while the first // is still in flight should start from an empty form. setDraft(""); setDraftDue(""); setDraftDetail(""); setDraftSubject(null); act(() => createTaskAction({ title, detail, dueAt: dueAt || null, ...subjectColumns(subject), }), ); }; return (
setDraft(event.target.value)} onKeyDown={(event) => { if (event.key === "Enter") add(); }} placeholder="Send the follow-up email, ask Priya for the intro…" className="h-11 pl-8 text-base md:h-9 md:text-[13px]" disabled={pending} />
{/* The detail only appears once there is a task to detail. An empty second box above an empty first box is a form; one line is a thing you type into. */} {draft.trim() && (