diff --git a/packages/web/src/routes/initiatives/$id.tsx b/packages/web/src/routes/initiatives/$id.tsx index 29af1fa..f2b3392 100644 --- a/packages/web/src/routes/initiatives/$id.tsx +++ b/packages/web/src/routes/initiatives/$id.tsx @@ -1,16 +1,432 @@ -import { createFileRoute } from '@tanstack/react-router' +import { useState, useCallback } from "react"; +import { createFileRoute, useNavigate } from "@tanstack/react-router"; +import { AlertCircle } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { trpc } from "@/lib/trpc"; +import { InitiativeHeader } from "@/components/InitiativeHeader"; +import { ProgressPanel } from "@/components/ProgressPanel"; +import { PhaseAccordion } from "@/components/PhaseAccordion"; +import { DecisionList } from "@/components/DecisionList"; +import { TaskDetailModal } from "@/components/TaskDetailModal"; +import type { SerializedTask } from "@/components/TaskRow"; -export const Route = createFileRoute('/initiatives/$id')({ +export const Route = createFileRoute("/initiatives/$id")({ component: InitiativeDetailPage, -}) +}); + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/** Aggregated task counts reported upward from PhaseWithTasks */ +interface TaskCounts { + complete: number; + total: number; +} + +/** Flat task entry with metadata needed for the modal */ +interface FlatTaskEntry { + task: SerializedTask; + phaseName: string; + agentName: string | null; + blockedBy: Array<{ name: string; status: string }>; + dependents: Array<{ name: string; status: string }>; +} + +// --------------------------------------------------------------------------- +// PhaseWithTasks — solves the "hooks inside loops" problem +// --------------------------------------------------------------------------- + +interface PhaseWithTasksProps { + phase: { + id: string; + initiativeId: string; + number: number; + name: string; + description: string | null; + status: string; + createdAt: string; + updatedAt: string; + }; + defaultExpanded: boolean; + onTaskClick: (taskId: string) => void; + onTaskCounts: (phaseId: string, counts: TaskCounts) => void; + registerTasks: (phaseId: string, entries: FlatTaskEntry[]) => void; +} + +function PhaseWithTasks({ + phase, + defaultExpanded, + onTaskClick, + onTaskCounts, + registerTasks, +}: PhaseWithTasksProps) { + // Fetch all plans for this phase + const plansQuery = trpc.listPlans.useQuery({ phaseId: phase.id }); + + // Fetch phase dependencies + const depsQuery = trpc.getPhaseDependencies.useQuery({ phaseId: phase.id }); + + const plans = plansQuery.data ?? []; + const planIds = plans.map((p) => p.id); + + return ( + + ); +} + +// Inner component that fetches tasks for each plan — needs stable hook count +// Since planIds array changes, we fetch tasks per plan inside yet another child +interface PhaseWithTasksInnerProps { + phase: PhaseWithTasksProps["phase"]; + planIds: string[]; + plansLoaded: boolean; + phaseDependencyIds: string[]; + defaultExpanded: boolean; + onTaskClick: (taskId: string) => void; + onTaskCounts: (phaseId: string, counts: TaskCounts) => void; + registerTasks: (phaseId: string, entries: FlatTaskEntry[]) => void; +} + +function PhaseWithTasksInner({ + phase, + planIds, + plansLoaded, + phaseDependencyIds: _phaseDependencyIds, + defaultExpanded, + onTaskClick, + onTaskCounts, + registerTasks, +}: PhaseWithTasksInnerProps) { + // We can't call useQuery in a loop, so we render PlanTasksFetcher per plan + // and aggregate the results + const [planTasks, setPlanTasks] = useState>( + {}, + ); + + const handlePlanTasks = useCallback( + (planId: string, tasks: SerializedTask[]) => { + setPlanTasks((prev) => { + // Skip if unchanged (same reference) + if (prev[planId] === tasks) return prev; + const next = { ...prev, [planId]: tasks }; + + // Aggregate all tasks across plans + const allTasks = Object.values(next).flat(); + const complete = allTasks.filter( + (t) => t.status === "completed", + ).length; + + // Report counts up + onTaskCounts(phase.id, { complete, total: allTasks.length }); + + // Register flat entries for the modal lookup + const entries: FlatTaskEntry[] = allTasks.map((task) => ({ + task, + phaseName: `Phase ${phase.number}: ${phase.name}`, + agentName: null, // No agent info from task data alone + blockedBy: [], // Simplified: no dependency lookup per task in v1 + dependents: [], + })); + registerTasks(phase.id, entries); + + return next; + }); + }, + [phase.id, phase.number, phase.name, onTaskCounts, registerTasks], + ); + + // Build task entries for PhaseAccordion + const allTasks = planIds.flatMap((pid) => planTasks[pid] ?? []); + const taskEntries = allTasks.map((task) => ({ + task, + agentName: null as string | null, + blockedBy: [] as Array<{ name: string; status: string }>, + })); + + // Phase-level dependencies (empty for now — would need to resolve IDs to names) + const phaseDeps: Array<{ name: string; status: string }> = []; + + return ( + <> + {/* Hidden fetchers — one per plan */} + {plansLoaded && + planIds.map((planId) => ( + + ))} + + + + ); +} + +// --------------------------------------------------------------------------- +// PlanTasksFetcher — fetches tasks for a single plan (stable hook count) +// --------------------------------------------------------------------------- + +interface PlanTasksFetcherProps { + planId: string; + onTasks: (planId: string, tasks: SerializedTask[]) => void; +} + +function PlanTasksFetcher({ planId, onTasks }: PlanTasksFetcherProps) { + const tasksQuery = trpc.listTasks.useQuery({ planId }); + + // Report tasks upward when loaded + if (tasksQuery.isSuccess && tasksQuery.data) { + // Cast to SerializedTask — tRPC serializes Date to string over JSON + const tasks = tasksQuery.data as unknown as SerializedTask[]; + onTasks(planId, tasks); + } + + return null; // Render nothing — this is a data-fetching component +} + +// --------------------------------------------------------------------------- +// Main Page Component +// --------------------------------------------------------------------------- function InitiativeDetailPage() { - const { id } = Route.useParams() + const { id } = Route.useParams(); + const navigate = useNavigate(); + + // State + const [selectedTaskId, setSelectedTaskId] = useState(null); + const [taskCountsByPhase, setTaskCountsByPhase] = useState< + Record + >({}); + const [tasksByPhase, setTasksByPhase] = useState< + Record + >({}); + + // tRPC queries + const initiativeQuery = trpc.getInitiative.useQuery({ id }); + const phasesQuery = trpc.listPhases.useQuery( + { initiativeId: id }, + { enabled: !!initiativeQuery.data }, + ); + + // tRPC mutations + const queueTaskMutation = trpc.queueTask.useMutation(); + const queuePhaseMutation = trpc.queuePhase.useMutation(); + + // Callbacks for PhaseWithTasks + const handleTaskCounts = useCallback( + (phaseId: string, counts: TaskCounts) => { + setTaskCountsByPhase((prev) => { + if ( + prev[phaseId]?.complete === counts.complete && + prev[phaseId]?.total === counts.total + ) { + return prev; + } + return { ...prev, [phaseId]: counts }; + }); + }, + [], + ); + + const handleRegisterTasks = useCallback( + (phaseId: string, entries: FlatTaskEntry[]) => { + setTasksByPhase((prev) => { + if (prev[phaseId] === entries) return prev; + return { ...prev, [phaseId]: entries }; + }); + }, + [], + ); + + // Derived data + const phases = phasesQuery.data ?? []; + const phasesComplete = phases.filter( + (p) => p.status === "completed", + ).length; + + const allTaskCounts = Object.values(taskCountsByPhase); + const tasksComplete = allTaskCounts.reduce((s, c) => s + c.complete, 0); + const tasksTotal = allTaskCounts.reduce((s, c) => s + c.total, 0); + + // Find selected task across all phases + const allFlatTasks = Object.values(tasksByPhase).flat(); + const selectedEntry = selectedTaskId + ? allFlatTasks.find((e) => e.task.id === selectedTaskId) ?? null + : null; + + // Determine which phase should be expanded by default (first non-completed) + const firstIncompletePhaseIndex = phases.findIndex( + (p) => p.status !== "completed", + ); + + // Queue all pending phases + const handleQueueAll = useCallback(() => { + const pendingPhases = phases.filter((p) => p.status === "pending"); + for (const phase of pendingPhases) { + queuePhaseMutation.mutate({ phaseId: phase.id }); + } + }, [phases, queuePhaseMutation]); + + // Queue a single task + const handleQueueTask = useCallback( + (taskId: string) => { + queueTaskMutation.mutate({ taskId }); + setSelectedTaskId(null); + }, + [queueTaskMutation], + ); + + // Loading state + if (initiativeQuery.isLoading) { + return ( +
+ Loading initiative... +
+ ); + } + + // Error state + if (initiativeQuery.isError) { + return ( +
+ +

+ {initiativeQuery.error.message.includes("not found") + ? "Initiative not found" + : `Failed to load initiative: ${initiativeQuery.error.message}`} +

+ +
+ ); + } + + const initiative = initiativeQuery.data; + if (!initiative) return null; + + // tRPC serializes Date to string over JSON — cast to wire format + const serializedInitiative = { + id: initiative.id, + name: initiative.name, + status: initiative.status, + createdAt: String(initiative.createdAt), + updatedAt: String(initiative.updatedAt), + }; + + const hasPendingPhases = phases.some((p) => p.status === "pending"); + return ( -
-

Initiative Detail

-

Initiative ID: {id}

-

Content coming in Phase 18

+
+ {/* Header */} + navigate({ to: "/initiatives" })} + /> + + {/* Two-column layout */} +
+ {/* Left column: Phases */} +
+ {/* Section header */} +
+

Phases

+ +
+ + {/* Phase loading */} + {phasesQuery.isLoading && ( +
+ Loading phases... +
+ )} + + {/* Phases list */} + {phasesQuery.isSuccess && phases.length === 0 && ( +
+ No phases yet +
+ )} + + {phasesQuery.isSuccess && + phases.map((phase, idx) => { + // tRPC serializes Date to string over JSON — cast to wire format + const serializedPhase = { + id: phase.id, + initiativeId: phase.initiativeId, + number: phase.number, + name: phase.name, + description: phase.description, + status: phase.status, + createdAt: String(phase.createdAt), + updatedAt: String(phase.updatedAt), + }; + + return ( + + ); + })} +
+ + {/* Right column: Progress + Decisions */} +
+ + + +
+
+ + {/* Task Detail Modal */} + setSelectedTaskId(null)} + onQueueTask={handleQueueTask} + onStopTask={() => setSelectedTaskId(null)} + />
- ) + ); }