Files
Codewalkers/packages/web/src/routes/initiatives/$id.tsx
2026-02-07 00:33:12 +01:00

201 lines
6.2 KiB
TypeScript

import { useState } from "react";
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { AlertCircle } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/Skeleton";
import { toast } from "sonner";
import { trpc } from "@/lib/trpc";
import { InitiativeHeader } from "@/components/InitiativeHeader";
import { ContentTab } from "@/components/editor/ContentTab";
import { ExecutionTab } from "@/components/ExecutionTab";
import { useSubscriptionWithErrorHandling } from "@/hooks";
export const Route = createFileRoute("/initiatives/$id")({
component: InitiativeDetailPage,
});
type Tab = "content" | "execution";
function InitiativeDetailPage() {
const { id } = Route.useParams();
const navigate = useNavigate();
const [activeTab, setActiveTab] = useState<Tab>("content");
// Live updates: keep subscriptions at page level so they work across both tabs
const utils = trpc.useUtils();
// Task updates subscription with robust error handling
useSubscriptionWithErrorHandling(
() => trpc.onTaskUpdate.useSubscription(undefined),
{
onData: () => {
void utils.listPhases.invalidate();
void utils.listTasks.invalidate();
void utils.listPlans.invalidate();
},
onError: (error) => {
toast.error("Live updates disconnected. Refresh to reconnect.", {
id: "sub-error",
duration: Infinity,
});
console.error('Task updates subscription error:', error);
},
onStarted: () => toast.dismiss("sub-error"),
autoReconnect: true,
maxReconnectAttempts: 5,
}
);
// Agent updates subscription with robust error handling
useSubscriptionWithErrorHandling(
() => trpc.onAgentUpdate.useSubscription(undefined),
{
onData: () => {
void utils.listAgents.invalidate();
},
onError: (error) => {
toast.error("Live updates disconnected. Refresh to reconnect.", {
id: "sub-error",
duration: Infinity,
});
console.error('Agent updates subscription error:', error);
},
onStarted: () => toast.dismiss("sub-error"),
autoReconnect: true,
maxReconnectAttempts: 5,
}
);
// Page updates subscription with robust error handling
useSubscriptionWithErrorHandling(
() => trpc.onPageUpdate.useSubscription(undefined),
{
onData: () => {
void utils.listPages.invalidate();
void utils.getPage.invalidate();
void utils.getRootPage.invalidate();
},
onError: (error) => {
toast.error("Live updates disconnected. Refresh to reconnect.", {
id: "sub-error",
duration: Infinity,
});
console.error('Page updates subscription error:', error);
},
onStarted: () => toast.dismiss("sub-error"),
autoReconnect: true,
maxReconnectAttempts: 5,
}
);
// tRPC queries
const initiativeQuery = trpc.getInitiative.useQuery({ id });
const phasesQuery = trpc.listPhases.useQuery(
{ initiativeId: id },
{ enabled: !!initiativeQuery.data },
);
// Loading state
if (initiativeQuery.isLoading) {
return (
<div className="space-y-6">
<div className="flex items-center gap-4">
<Skeleton className="h-4 w-4" />
<Skeleton className="h-7 w-64" />
<Skeleton className="h-5 w-20" />
</div>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[1fr_340px]">
<div className="space-y-1">
<Skeleton className="h-12 w-full rounded border" />
<Skeleton className="h-12 w-full rounded border" />
</div>
<div className="space-y-6">
<Skeleton className="h-24 w-full rounded" />
<Skeleton className="h-20 w-full rounded" />
</div>
</div>
</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;
const serializedInitiative = {
id: initiative.id,
name: initiative.name,
status: initiative.status,
};
const projects = (initiative as { projects?: Array<{ id: string; name: string; url: string }> }).projects;
const phases = phasesQuery.data ?? [];
return (
<div className="space-y-3">
{/* Header */}
<InitiativeHeader
initiative={serializedInitiative}
projects={projects}
onBack={() => navigate({ to: "/initiatives" })}
/>
{/* Tab bar */}
<div className="flex gap-1 border-b border-border">
<button
onClick={() => setActiveTab("content")}
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
activeTab === "content"
? "border-primary text-foreground"
: "border-transparent text-muted-foreground hover:text-foreground"
}`}
>
Content
</button>
<button
onClick={() => setActiveTab("execution")}
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
activeTab === "execution"
? "border-primary text-foreground"
: "border-transparent text-muted-foreground hover:text-foreground"
}`}
>
Execution
</button>
</div>
{/* Tab content */}
{activeTab === "content" && <ContentTab initiativeId={id} initiativeName={initiative.name} />}
{activeTab === "execution" && (
<ExecutionTab
initiativeId={id}
phases={phases}
phasesLoading={phasesQuery.isLoading}
phasesLoaded={phasesQuery.isSuccess}
/>
)}
</div>
);
}