feat(18-04): wire Initiative Detail page with data fetching and all components

Replace placeholder with full initiative detail page: tRPC data fetching
(getInitiative, listPhases, listPlans, listTasks), PhaseWithTasks helper
component pattern for stable hooks, two-column layout with phases on left
and progress/decisions on right, TaskDetailModal with selectedTask state,
queue actions for phases and tasks, loading/error states.
This commit is contained in:
Lukas May
2026-02-04 21:37:41 +01:00
parent 630c36af5a
commit 1e26bfadbd

View File

@@ -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 (
<PhaseWithTasksInner
phase={phase}
planIds={planIds}
plansLoaded={plansQuery.isSuccess}
phaseDependencyIds={depsQuery.data?.dependencies ?? []}
defaultExpanded={defaultExpanded}
onTaskClick={onTaskClick}
onTaskCounts={onTaskCounts}
registerTasks={registerTasks}
/>
);
}
// 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<Record<string, SerializedTask[]>>(
{},
);
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
key={planId}
planId={planId}
onTasks={handlePlanTasks}
/>
))}
<PhaseAccordion
phase={phase}
tasks={taskEntries}
defaultExpanded={defaultExpanded}
phaseDependencies={phaseDeps}
onTaskClick={onTaskClick}
/>
</>
);
}
// ---------------------------------------------------------------------------
// 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<string | null>(null);
const [taskCountsByPhase, setTaskCountsByPhase] = useState<
Record<string, TaskCounts>
>({});
const [tasksByPhase, setTasksByPhase] = useState<
Record<string, FlatTaskEntry[]>
>({});
// 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 (
<div>
<h1 className="text-2xl font-bold">Initiative Detail</h1>
<p className="text-muted-foreground mt-2">Initiative ID: {id}</p>
<p className="text-muted-foreground mt-1">Content coming in Phase 18</p>
<div className="flex items-center justify-center py-12 text-muted-foreground">
Loading initiative...
</div>
)
);
}
// Error state
if (initiativeQuery.isError) {
return (
<div className="flex flex-col items-center justify-center gap-4 py-12">
<AlertCircle className="h-8 w-8 text-destructive" />
<p className="text-sm text-destructive">
{initiativeQuery.error.message.includes("not found")
? "Initiative not found"
: `Failed to load initiative: ${initiativeQuery.error.message}`}
</p>
<Button
variant="outline"
size="sm"
onClick={() => navigate({ to: "/initiatives" })}
>
Back to Dashboard
</Button>
</div>
);
}
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 (
<div className="space-y-6">
{/* Header */}
<InitiativeHeader
initiative={serializedInitiative}
onBack={() => navigate({ to: "/initiatives" })}
/>
{/* Two-column layout */}
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[1fr_340px]">
{/* Left column: Phases */}
<div className="space-y-0">
{/* Section header */}
<div className="flex items-center justify-between border-b border-border pb-3">
<h2 className="text-lg font-semibold">Phases</h2>
<Button
variant="outline"
size="sm"
disabled={!hasPendingPhases}
onClick={handleQueueAll}
>
Queue All
</Button>
</div>
{/* Phase loading */}
{phasesQuery.isLoading && (
<div className="py-8 text-center text-muted-foreground">
Loading phases...
</div>
)}
{/* Phases list */}
{phasesQuery.isSuccess && phases.length === 0 && (
<div className="py-8 text-center text-muted-foreground">
No phases yet
</div>
)}
{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 (
<PhaseWithTasks
key={phase.id}
phase={serializedPhase}
defaultExpanded={idx === firstIncompletePhaseIndex}
onTaskClick={setSelectedTaskId}
onTaskCounts={handleTaskCounts}
registerTasks={handleRegisterTasks}
/>
);
})}
</div>
{/* Right column: Progress + Decisions */}
<div className="space-y-6">
<ProgressPanel
phasesComplete={phasesComplete}
phasesTotal={phases.length}
tasksComplete={tasksComplete}
tasksTotal={tasksTotal}
/>
<DecisionList decisions={[]} />
</div>
</div>
{/* Task Detail Modal */}
<TaskDetailModal
task={selectedEntry?.task ?? null}
phaseName={selectedEntry?.phaseName ?? ""}
agentName={selectedEntry?.agentName ?? null}
dependencies={selectedEntry?.blockedBy ?? []}
dependents={selectedEntry?.dependents ?? []}
onClose={() => setSelectedTaskId(null)}
onQueueTask={handleQueueTask}
onStopTask={() => setSelectedTaskId(null)}
/>
</div>
);
}