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, CheckCircle2, Clock, Link2, ListTodo, Plus, RepeatIcon, RotateCcw, Trash2, X, } from "lucide-react"; import { format, parseISO } from "date-fns"; import { api, useApiQuery } from "@/lib/api"; import { useApiDomain } from "@/lib/stores/use-active-domain-store"; import { useRealtime } from "@/hooks/use-realtime"; import { useOptimisticPatch } from "@/hooks/use-optimistic-patch"; import { EntityDetailPage } from "@/components/entities/detail-page"; import { InlineDate, 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 { TagManager } from "@/components/entities/tag-manager"; import { CustomFieldsDisplay } from "@/components/custom-fields/custom-fields-display"; 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 { Input } from "@/components/ui/input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { LoadingState, ErrorState } from "@/components/state"; import { PRIORITY } from "@/lib/status-colors"; import type { PaginatedResponse, Project, State, Task } from "@/lib/types"; import { cn } from "@/lib/utils"; const PRIORITY_OPTIONS: InlineSelectOption[] = [ { value: "low", label: "Low" }, { value: "medium", label: "Medium" }, { value: "high", label: "High" }, { value: "urgent", label: "Urgent" }, ]; const ESTIMATE_OPTIONS: InlineSelectOption[] = [ { value: "", label: "None" }, { value: "15", label: "15 min" }, { value: "30", label: "30 min" }, { value: "45", label: "45 min" }, { value: "60", label: "60 min" }, { value: "90", label: "90 min" }, { value: "120", label: "120 min" }, ]; // Query keys to keep fresh after any task-affecting mutation. Mirrors the // realtime hook's invalidation so list/analytics views never go stale. const LIST_KEYS: string[][] = [ ["tasks"], ["tasks-due"], ["stats"], ["productivity-chart"], ["analytics-daily"], ["analytics-projects"], ]; type PatchFn = (vars: { id: string; data: Record }) => void; function errorMessage(err: unknown): string { return err instanceof Error ? err.message : "Something went wrong"; } function TaskDetail() { const { id } = useParams({ from: Route.id }); const navigate = useNavigate(); const queryClient = useQueryClient(); useRealtime({ enabled: true }); const { data: task, isLoading, isError, error, refetch } = useApiQuery( ["task", id], "/tasks/" + id ); const { patch } = useOptimisticPatch({ entityKey: ["task", id], listKeys: LIST_KEYS, patchUrl: (taskId) => `/tasks/${taskId}`, applyPatch: (current, data) => ({ ...current, ...data }), }); const toggleComplete = useMutation({ mutationFn: () => { const completedStates = projectStates.filter((s) => s.group === "completed"); const uncompletedStates = projectStates.filter((s) => s.group !== "completed"); const isDone = task?.status === "done"; if (isDone && uncompletedStates.length > 0) { return api.patch(`/tasks/${id}`, { stateId: uncompletedStates[0].id }); } else if (!isDone && completedStates.length > 0) { return api.patch(`/tasks/${id}`, { stateId: completedStates[0].id }); } return api.patch(`/tasks/${id}`, { stateId: null }); }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["task", id] }); for (const key of LIST_KEYS) queryClient.invalidateQueries({ queryKey: key }); }, onError: (err) => toast.error(errorMessage(err)), }); const { data: statesData } = useApiQuery<{ items: State[] }>( ["states", task?.projectId || ""], "/states?projectId=" + (task?.projectId || ""), { enabled: !!task?.projectId } ); const projectStates = statesData?.items || []; const currentState = task?.stateId ? projectStates.find((s) => s.id === task.stateId) : null; const deleteMutation = useMutation({ mutationFn: () => api.delete(`/tasks/${id}`), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["tasks"] }); toast.success("Task deleted"); navigate({ to: "/tasks" }); }, onError: (err) => toast.error(errorMessage(err)), }); if (isLoading) return ; if (isError) { return ( refetch()} /> ); } if (!task) return ; const isDone = currentState?.group === "completed"; return ( patch({ id, data: { title } })} placeholder="Untitled task" /> } icon={} badges={ <> {projectStates.length > 0 ? ( ({ value: s.id, label: s.name }))} displayValue={(v) => { if (!v) return No state; const st = projectStates.find((s) => s.id === v); if (!st) return Unknown; return ( {st.name} ); }} onSave={(stateId) => patch({ id, data: { stateId: stateId || null } })} /> ) : ( {currentState?.name || task.status.replace("_", " ")} )} ( {PRIORITY[v]?.label ?? v} )} onSave={(priority) => patch({ id, data: { priority } })} /> } actions={ <> Delete Task Are you sure you want to delete "{task.title}"? This action cannot be undone. Cancel deleteMutation.mutate()} > Delete } tabs={[ { value: "overview", label: "Overview", content: }, { value: "subtasks", label: "Subtasks", content: }, { value: "dependencies", label: "Dependencies", content: }, { value: "activity", label: "Activity", content: , }, { value: "comments", label: "Comments", content: , }, ]} /> ); } function Overview({ task, patch }: { task: Task; patch: PatchFn }) { const navigate = useNavigate(); const activeDomainId = useApiDomain(); const { data: projectsData } = useApiQuery>( ["projects", activeDomainId], "/projects?limit=200" + (activeDomainId ? "&domain=" + activeDomainId : "") ); const projects = projectsData?.items ?? []; const projectNameById = new Map(projects.map((p) => [p.id, p.name])); const projectOptions: InlineSelectOption[] = [ { value: "", label: "No project" }, ...projects.map((p) => ({ value: p.id, label: p.name })), ]; const { data: statesData } = useApiQuery<{ items: State[] }>( ["states", task.projectId || ""], "/states?projectId=" + (task.projectId || ""), { enabled: !!task.projectId } ); const projectStates = statesData?.items || []; const stateOptions: InlineSelectOption[] = [ { value: "", label: "No state" }, ...projectStates.map((s) => ({ value: s.id, label: s.name })), ]; const currentState = task.stateId ? projectStates.find((s) => s.id === task.stateId) : null; return (

Description

patch({ id: task.id, data: { description: description || null } }) } placeholder="Add a description…" />
patch({ id: task.id, data: { dueDate } })} />
v ? ( {v} min ) : ( No estimate ) } onSave={(est) => patch({ id: task.id, data: { estimatedMinutes: est ? Number(est) : null } }) } />
{projectStates.length > 0 && (
{ if (!v) return No state; const st = projectStates.find((s) => s.id === v); if (!st) return {v}; return ( {st.name} ); }} onSave={(stateId) => patch({ id: task.id, data: { stateId: stateId || null } }) } />
)} {task.recurrenceRule ? (
{task.recurrenceRule}
) : null}
Created {format(parseISO(task.createdAt), "MMM d, yyyy HH:mm")} Updated {format(parseISO(task.updatedAt), "MMM d, yyyy HH:mm")}
); } function Subtasks({ task }: { task: Task }) { const navigate = useNavigate(); const queryClient = useQueryClient(); const [newTitle, setNewTitle] = useState(""); const [pendingId, setPendingId] = useState(null); const refresh = () => { queryClient.invalidateQueries({ queryKey: ["task", task.id] }); queryClient.invalidateQueries({ queryKey: ["tasks"] }); }; const addMutation = useMutation({ mutationFn: (title: string) => api.post("/tasks", { title, parentId: task.id, domain: task.domainId }), onSuccess: () => { setNewTitle(""); toast.success("Subtask added"); refresh(); }, onError: (err) => toast.error(errorMessage(err)), }); const toggleMutation = useMutation({ mutationFn: ({ subId, completed }: { subId: string; completed: boolean }) => api.patch(`/tasks/${subId}`, { stateId: completed ? null : null }), onMutate: (vars) => setPendingId(vars.subId), onSettled: () => setPendingId(null), onSuccess: refresh, onError: (err) => toast.error(errorMessage(err)), }); const subtasks = task.subtasks || []; const submitNewSubtask = () => { const title = newTitle.trim(); if (!title || addMutation.isPending) return; addMutation.mutate(title); }; return (
setNewTitle(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); submitNewSubtask(); } }} placeholder="New subtask title…" className="h-9" />
{subtasks.length === 0 ? (

No subtasks yet.

) : (
{subtasks.map((sub) => (
toggleMutation.mutate({ subId: sub.id, completed: sub.status === "done", }) } aria-label={"Mark " + sub.title + " " + (sub.status === "done" ? "as not done" : "as done")} />
))}
)}
); } function Dependencies({ task }: { task: Task }) { const navigate = useNavigate(); const queryClient = useQueryClient(); const activeDomainId = useApiDomain(); const [targetEntityId, setTargetEntityId] = useState(""); const [linkType, setLinkType] = useState("blocks"); const { data: linksData, isLoading: linksLoading } = useApiQuery<{ items: import("@/lib/types").Link[] }>( ["links", "task", task.id], "/links?entityType=task&entityId=" + task.id ); const { data: tasksData, isLoading: tasksLoading } = useApiQuery>( ["tasks", activeDomainId, "dependency-picker"], "/tasks?limit=200" + (activeDomainId ? "&domain=" + activeDomainId : "") ); const refresh = () => { queryClient.invalidateQueries({ queryKey: ["links", "task", task.id] }); queryClient.invalidateQueries({ queryKey: ["task", task.id] }); queryClient.invalidateQueries({ queryKey: ["tasks"] }); }; const addLink = useMutation({ mutationFn: (vars: { sourceId: string; targetId: string; linkType: string }) => api.post("/links", { sourceType: "task", sourceId: vars.sourceId, targetType: "task", targetId: vars.targetId, linkType: vars.linkType, }), onSuccess: () => { setTargetEntityId(""); toast.success("Link added"); refresh(); }, onError: (err) => toast.error(errorMessage(err)), }); const removeLink = useMutation({ mutationFn: (linkId: string) => api.delete(`/links/${linkId}`), onSuccess: refresh, onError: (err) => toast.error(errorMessage(err)), }); const links = linksData?.items || []; const incomingLinks = links.filter((l) => l.targetId === task.id && l.sourceType === "task"); const outgoingLinks = links.filter((l) => l.sourceId === task.id && l.targetType === "task"); const allTaskIds = new Set((tasksData?.items || []).map((t) => t.id)); const linkedTaskIds = new Set([...incomingLinks.map((l) => l.sourceId), ...outgoingLinks.map((l) => l.targetId), task.id]); const availableTasks = (tasksData?.items || []).filter( (t) => t.id !== task.id && !linkedTaskIds.has(t.id) ); const depPlaceholder = tasksLoading ? "Loading tasks..." : availableTasks.length === 0 ? "No tasks to add" : "Add link..."; const taskTitleById = new Map((tasksData?.items || []).map((t) => [t.id, t.title])); const handleAddLink = (value: string) => { if (!value) return; addLink.mutate({ sourceId: task.id, targetId: value, linkType }); }; return (

Links to this task

{incomingLinks.length === 0 ? (

No incoming links.

) : (
{incomingLinks.map((link) => (
{link.linkType}
))}
)}

Links from this task

{outgoingLinks.length === 0 ? (

No outgoing links.

) : (
{outgoingLinks.map((link) => (
{link.linkType}
))}
)}
); } export const Route = createRoute({ getParentRoute: () => appRoute, path: "tasks/$id", component: TaskDetail, });