import { useState } from "react"; import { createRoute, useParams, useNavigate } from "@tanstack/react-router"; import { Route as appRoute } from "../../_app"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; import { Calendar, Clock, Flag, FolderKanban, LayoutGrid, ListTodo, Plus, Repeat, Trash2, X, } from "lucide-react"; import { differenceInCalendarDays, format, parseISO } from "date-fns"; import { api, useApiQuery } from "@/lib/api"; import { useRealtime } from "@/hooks/use-realtime"; import { useOptimisticPatch } from "@/hooks/use-optimistic-patch"; import { EntityDetailPage } from "@/components/entities/detail-page"; import { InlineDate, InlineEdit, InlineSelect, InlineText, InlineTextarea, type InlineSelectOption, } from "@/components/entities/inline-edit"; import { EntityActivity } from "@/components/entities/entity-activity"; import { EntityComments } from "@/components/entities/entity-comments"; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger, } from "@/components/ui/alert-dialog"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Checkbox } from "@/components/ui/checkbox"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { Progress } from "@/components/ui/progress"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { Textarea } from "@/components/ui/textarea"; import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; import { LoadingState, ErrorState } from "@/components/state"; import { PRIORITY, PROJECT_STATUS } from "@/lib/status-colors"; import type { Cycle, Module, PaginatedResponse, Project, Section, State, Task } from "@/lib/types"; import { cn } from "@/lib/utils"; const PROJECT_STATUS_OPTIONS: InlineSelectOption[] = [ { value: "active", label: "Active" }, { value: "paused", label: "Paused" }, { value: "completed", label: "Completed" }, { value: "archived", label: "Archived" }, ]; const SECTION_STATUS_OPTIONS: InlineSelectOption[] = [ { value: "planned", label: "Planned" }, { value: "in_progress", label: "In Progress" }, { value: "complete", label: "Complete" }, ]; const MODULE_STATUS_OPTIONS: InlineSelectOption[] = [ { value: "planned", label: "Planned" }, { value: "in_progress", label: "In Progress" }, { value: "completed", label: "Completed" }, { value: "cancelled", label: "Cancelled" }, ]; const MODULE_STATUS: Record = { planned: { label: "Planned", badge: "bg-slate-500 text-white", dot: "bg-slate-400" }, in_progress: { label: "In Progress", badge: "bg-blue-500 text-white", dot: "bg-blue-500" }, completed: { label: "Completed", badge: "bg-green-500 text-white", dot: "bg-green-500" }, cancelled: { label: "Cancelled", badge: "bg-red-500 text-white", dot: "bg-red-400" }, }; const SECTION_STATUS: Record = { planned: { label: "Planned", badge: "bg-slate-500 text-white", dot: "bg-slate-400" }, in_progress: { label: "In Progress", badge: "bg-blue-500 text-white", dot: "bg-blue-500" }, complete: { label: "Complete", badge: "bg-green-500 text-white", dot: "bg-green-500" }, }; const NO_SECTION = "__none__"; type PatchFn = (vars: { id: string; data: Record }) => void; function errorMessage(err: unknown): string { return err instanceof Error ? err.message : "Something went wrong"; } function targetCountdown( targetDate: string | null ): { text: string; className: string } | null { if (!targetDate) return null; const days = differenceInCalendarDays(parseISO(targetDate), new Date()); if (days > 0) { return { text: `${days} ${days === 1 ? "day" : "days"} left`, className: "text-muted-foreground", }; } if (days === 0) { return { text: "Due today", className: "text-muted-foreground" }; } const overdue = Math.abs(days); return { text: `Overdue by ${overdue} ${overdue === 1 ? "day" : "days"}`, className: "text-destructive", }; } function ProjectDetail() { const { id } = useParams({ from: Route.id }); const navigate = useNavigate(); const queryClient = useQueryClient(); useRealtime({ enabled: true }); const { data: project, isLoading, isError, error, refetch } = useApiQuery( ["project", id], "/projects/" + id ); const { patch } = useOptimisticPatch({ entityKey: ["project", id], listKeys: [["projects"], ["active-projects"], ["analytics-projects"]], patchUrl: (pid) => `/projects/${pid}`, applyPatch: (current, data) => ({ ...current, ...data }), }); const deleteMutation = useMutation({ mutationFn: () => api.delete(`/projects/${id}`), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["projects"] }); toast.success("Project deleted"); navigate({ to: "/projects" }); }, onError: (err) => toast.error(errorMessage(err)), }); if (isLoading) return ; if (isError) { return refetch()} />; } if (!project) return ; return ( patch({ id, data: { name } })} placeholder="Untitled project" /> } icon={} badges={ <> ( {PROJECT_STATUS[v]?.label ?? v} )} onSave={(status) => patch({ id, data: { status } })} /> patch({ id, data: { color: color || null } })} showEditIcon={false} title="Edit color" display={() => ( )} renderEdit={(v, onChange, commit) => ( { onChange(e.target.value); commit(e.target.value); }} /> )} /> } actions={ Delete Project Are you sure you want to delete "{project.name}"? This action cannot be undone. Cancel deleteMutation.mutate()} > Delete } tabs={[ { value: "overview", label: "Overview", content: , }, { value: "tasks", label: "Tasks", content: }, { value: "sections", label: "Sections", content: }, { value: "modules", label: "Modules", content: }, { value: "cycles", label: "Cycles", content: }, { value: "activity", label: "Activity", content: , }, { value: "comments", label: "Comments", content: , }, ]} /> ); } function Overview({ project, patch }: { project: Project; patch: PatchFn }) { const countdown = targetCountdown(project.targetDate); return (

Description

patch({ id: project.id, data: { description: description || null } }) } placeholder="Add a description…" />

Progress

{project.progress}%

{project.completedCount} of {project.taskCount} tasks done · {project.progress}%

patch({ id: project.id, data: { targetDate } })} /> {countdown && ( {countdown.text} )}
{project.tags && project.tags.length > 0 ? (

Tags

{project.tags.map((t) => ( {t.name} ))}
) : null}
Created {format(parseISO(project.createdAt), "MMM d, yyyy HH:mm")} Updated {format(parseISO(project.updatedAt), "MMM d, yyyy HH:mm")}
); } function ProjectTasks({ project }: { project: Project }) { const navigate = useNavigate(); const queryClient = useQueryClient(); const [newTitle, setNewTitle] = useState(""); const [sectionId, setSectionId] = useState(""); const [pendingId, setPendingId] = useState(null); const sections = project.sections || []; const tasks = project.tasks || []; const { data: statesData } = useApiQuery<{ items: State[] }>( ["states", project.id], "/states?projectId=" + project.id, { enabled: !!project.id } ); const projectStates = statesData?.items || []; const completedStateId = projectStates.find((s) => s.group === "completed")?.id; const uncompletedStateId = projectStates.find((s) => s.group !== "completed" && s.group !== "cancelled")?.id; const refresh = () => { queryClient.invalidateQueries({ queryKey: ["project", project.id] }); queryClient.invalidateQueries({ queryKey: ["projects"] }); queryClient.invalidateQueries({ queryKey: ["tasks"] }); }; const addMutation = useMutation({ mutationFn: ({ title, sectionId }: { title: string; sectionId: string | null }) => api.post("/tasks", { title, projectId: project.id, domain: project.domainId, sectionId, }), onSuccess: () => { setNewTitle(""); toast.success("Task added"); refresh(); }, onError: (err) => toast.error(errorMessage(err)), }); const toggleMutation = useMutation({ mutationFn: ({ taskId, completed }: { taskId: string; completed: boolean }) => api.patch(`/tasks/${taskId}`, { stateId: completed ? completedStateId || null : uncompletedStateId || null }), onMutate: (vars) => setPendingId(vars.taskId), onSettled: () => setPendingId(null), onSuccess: refresh, onError: (err) => toast.error(errorMessage(err)), }); const submitNewTask = () => { const title = newTitle.trim(); if (!title || addMutation.isPending) return; addMutation.mutate({ title, sectionId: sectionId && sectionId !== NO_SECTION ? sectionId : null, }); }; const sectionIdSet = new Set(sections.map((s) => s.id)); const tasksBySection = new Map(); const unassigned: Task[] = []; for (const task of tasks) { if (task.sectionId && sectionIdSet.has(task.sectionId)) { const bucket = tasksBySection.get(task.sectionId) ?? []; bucket.push(task); tasksBySection.set(task.sectionId, bucket); } else { unassigned.push(task); } } const openTask = (taskId: string) => navigate({ to: "/tasks/$id", params: { id: taskId } }); return (
setNewTitle(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); submitNewTask(); } }} placeholder="New task title…" className="h-9" />
{tasks.length === 0 ? (

No tasks yet — add the first one above.

) : (
{sections.map((section) => (
{section.name} {tasksBySection.get(section.id)?.length ?? 0}
{(tasksBySection.get(section.id) ?? []).map((task) => ( toggleMutation.mutate(vars)} onOpen={() => openTask(task.id)} /> ))}
))} {unassigned.length > 0 ? (
Unassigned {unassigned.length}
{unassigned.map((task) => ( toggleMutation.mutate(vars)} onOpen={() => openTask(task.id)} /> ))}
) : null}
)}
); } function TaskRow({ task, pending, projectStates, onToggle, onOpen, }: { task: Task; pending: boolean; projectStates: State[]; onToggle: (vars: { taskId: string; completed: boolean }) => void; onOpen: () => void; }) { const state = task.stateId ? projectStates.find((s) => s.id === task.stateId) : null; const isCompleted = state?.group === "completed"; return (
onToggle({ taskId: task.id, completed: isCompleted, }) } aria-label={ "Mark " + task.title + " " + (isCompleted ? "as not done" : "as done") } /> {state ? ( ) : ( )} {PRIORITY[task.priority]?.label ?? task.priority} {task.dueDate ? ( {format(parseISO(task.dueDate), "MMM d")} ) : null}
); } function Sections({ project }: { project: Project }) { const queryClient = useQueryClient(); const [newName, setNewName] = useState(""); const [kind, setKind] = useState<"section" | "milestone">("section"); const sections = project.sections || []; const tasks = project.tasks || []; const refresh = () => { queryClient.invalidateQueries({ queryKey: ["project", project.id] }); queryClient.invalidateQueries({ queryKey: ["projects"] }); }; const addMutation = useMutation({ mutationFn: ({ name, kind }: { name: string; kind: "section" | "milestone" }) => api.post
(`/projects/${project.id}/sections`, { name, kind }), onSuccess: () => { setNewName(""); toast.success("Section added"); refresh(); }, onError: (err) => toast.error(errorMessage(err)), }); const renameMutation = useMutation({ mutationFn: ({ sid, name }: { sid: string; name: string }) => api.patch
(`/projects/${project.id}/sections/${sid}`, { name }), onSuccess: refresh, onError: (err) => toast.error(errorMessage(err)), }); const statusMutation = useMutation({ mutationFn: ({ sid, status }: { sid: string; status: Section["status"] }) => api.patch
(`/projects/${project.id}/sections/${sid}`, { status }), onSuccess: refresh, onError: (err) => toast.error(errorMessage(err)), }); const dateMutation = useMutation({ mutationFn: ({ sid, targetDate }: { sid: string; targetDate: string | null }) => api.patch
(`/projects/${project.id}/sections/${sid}`, { targetDate }), onSuccess: refresh, onError: (err) => toast.error(errorMessage(err)), }); const deleteMutation = useMutation({ mutationFn: (sid: string) => api.delete(`/projects/${project.id}/sections/${sid}`), onSuccess: () => { toast.success("Section deleted"); refresh(); }, onError: (err) => toast.error(errorMessage(err)), }); const submitNewSection = () => { const name = newName.trim(); if (!name || addMutation.isPending) return; addMutation.mutate({ name, kind }); }; return (
setNewName(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); submitNewSection(); } }} placeholder="New section name…" className="h-9" /> { if (v) setKind(v as "section" | "milestone"); }} > Section Milestone
{sections.length === 0 ? (

No sections yet.

) : (
{sections.map((section) => { const taskCount = tasks.filter((t) => t.sectionId === section.id).length; return (
{section.kind === "milestone" ? ( <> Milestone ) : ( "Section" )} renameMutation.mutate({ sid: section.id, name })} className="text-sm" /> ( {SECTION_STATUS[v]?.label ?? v} )} onSave={(status) => statusMutation.mutate({ sid: section.id, status: status as Section["status"], }) } /> dateMutation.mutate({ sid: section.id, targetDate }) } /> {taskCount} {taskCount === 1 ? "task" : "tasks"}
); })}
)}
); } function ProjectModules({ project }: { project: Project }) { const navigate = useNavigate(); const queryClient = useQueryClient(); const [createOpen, setCreateOpen] = useState(false); const [editModule, setEditModule] = useState(null); const [newName, setNewName] = useState(""); const [newDescription, setNewDescription] = useState(""); const [selectedModuleId, setSelectedModuleId] = useState(null); const { data: modulesData, isLoading } = useApiQuery>( ["modules", project.id], "/projects/" + project.id + "/modules?limit=200" ); const modules = modulesData?.items || []; const { data: moduleDetail } = useApiQuery( ["module", selectedModuleId || ""], "/projects/" + project.id + "/modules/" + selectedModuleId, { enabled: !!selectedModuleId } ); const { data: statesData } = useApiQuery<{ items: State[] }>( ["states", project.id], "/states?projectId=" + project.id, { enabled: !!project.id } ); const projectStates = statesData?.items || []; const refresh = () => { queryClient.invalidateQueries({ queryKey: ["modules", project.id] }); if (selectedModuleId) queryClient.invalidateQueries({ queryKey: ["module", selectedModuleId] }); queryClient.invalidateQueries({ queryKey: ["tasks"] }); }; const createMutation = useMutation({ mutationFn: (data: { name: string; description?: string }) => api.post(`/projects/${project.id}/modules`, data), onSuccess: () => { setCreateOpen(false); setNewName(""); setNewDescription(""); toast.success("Module created"); refresh(); }, onError: (err) => toast.error(errorMessage(err)), }); const updateMutation = useMutation({ mutationFn: ({ id, data }: { id: string; data: Record }) => api.patch(`/projects/${project.id}/modules/${id}`, data), onSuccess: () => { setEditModule(null); toast.success("Module updated"); refresh(); }, onError: (err) => toast.error(errorMessage(err)), }); const deleteMutation = useMutation({ mutationFn: (id: string) => api.delete(`/projects/${project.id}/modules/${id}`), onSuccess: () => { toast.success("Module deleted"); if (selectedModuleId === editModule?.id) setSelectedModuleId(null); setEditModule(null); refresh(); }, onError: (err) => toast.error(errorMessage(err)), }); const addTaskMutation = useMutation({ mutationFn: ({ moduleId, taskId }: { moduleId: string; taskId: string }) => api.post(`/projects/${project.id}/modules/${moduleId}/tasks`, { taskId }), onSuccess: refresh, onError: (err) => toast.error(errorMessage(err)), }); const removeTaskMutation = useMutation({ mutationFn: ({ moduleId, taskId }: { moduleId: string; taskId: string }) => api.delete(`/projects/${project.id}/modules/${moduleId}/tasks/${taskId}`), onSuccess: refresh, onError: (err) => toast.error(errorMessage(err)), }); const tasks = project.tasks || []; const moduleTasks = moduleDetail?.tasks || []; const moduleTaskIds = new Set(moduleTasks.map((t) => t.id)); const unassignedTasks = tasks.filter((t) => !t.moduleId && !moduleTaskIds.has(t.id)); return (

Modules

{isLoading ? ( ) : modules.length === 0 ? (

No modules yet. Create one to organize tasks.

) : (
{modules.map((mod) => (
setSelectedModuleId(selectedModuleId === mod.id ? null : mod.id)} >
{mod.name} {MODULE_STATUS[mod.status]?.label ?? mod.status}
{mod.description && (

{mod.description}

)}
))}
)} {selectedModuleId && moduleDetail && (

Tasks in {moduleDetail.name}

{moduleTasks.length === 0 ? (

No tasks in this module.

) : (
{moduleTasks.map((t) => (
))}
)} {unassignedTasks.length > 0 && (

Add task:

{unassignedTasks.slice(0, 10).map((t) => (
{t.title}
))}
)}
)} New Module
setNewName(e.target.value)} />