feat: Replace initiative card N+1 queries with server-computed activity indicator

listInitiatives now returns an activity object (state, activePhase, phase
counts) derived server-side from phases, eliminating per-card listPhases
queries. Initiative cards show a StatusDot with pulse animation + label
instead of a static StatusBadge. Removed redundant View and Spawn Architect
buttons from cards. Added variant override prop to StatusDot.
This commit is contained in:
Lukas May
2026-03-03 12:49:07 +01:00
parent b74b59b906
commit 96386e1c3d
11 changed files with 167 additions and 96 deletions

View File

@@ -50,6 +50,7 @@ export function CreateInitiativeDialog({
mergeRequiresApproval: true,
branch: null,
projects: [],
activity: { state: 'idle' as const, phasesTotal: 0, phasesCompleted: 0 },
};
utils.listInitiatives.setData(undefined, (old = []) => [tempInitiative, ...old]);
return { previousInitiatives };

View File

@@ -1,4 +1,4 @@
import { MoreHorizontal, Eye, Bot } from "lucide-react";
import { MoreHorizontal } from "lucide-react";
import { Card } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import {
@@ -8,7 +8,7 @@ import {
DropdownMenuTrigger,
DropdownMenuSeparator,
} from "@/components/ui/dropdown-menu";
import { StatusBadge } from "@/components/StatusBadge";
import { StatusDot, type StatusVariant } from "@/components/StatusDot";
import { ProgressBar } from "@/components/ProgressBar";
import { trpc } from "@/lib/trpc";
@@ -21,19 +21,33 @@ export interface SerializedInitiative {
branch: string | null;
createdAt: string;
updatedAt: string;
activity: {
state: string;
activePhase?: { id: string; name: string };
phasesTotal: number;
phasesCompleted: number;
};
}
function activityVisual(state: string): { label: string; variant: StatusVariant; pulse: boolean } {
switch (state) {
case "executing": return { label: "Executing", variant: "active", pulse: true };
case "pending_review": return { label: "Pending Review", variant: "warning", pulse: true };
case "ready": return { label: "Ready", variant: "active", pulse: false };
case "blocked": return { label: "Blocked", variant: "error", pulse: false };
case "complete": return { label: "Complete", variant: "success", pulse: false };
case "planning": return { label: "Planning", variant: "neutral", pulse: false };
case "archived": return { label: "Archived", variant: "neutral", pulse: false };
default: return { label: "Idle", variant: "neutral", pulse: false };
}
}
interface InitiativeCardProps {
initiative: SerializedInitiative;
onView: () => void;
onSpawnArchitect: (mode: "discuss" | "plan") => void;
onClick: () => void;
}
export function InitiativeCard({
initiative,
onView,
onSpawnArchitect,
}: InitiativeCardProps) {
export function InitiativeCard({ initiative, onClick }: InitiativeCardProps) {
const utils = trpc.useUtils();
const archiveMutation = trpc.updateInitiative.useMutation({
onSuccess: () => utils.listInitiatives.invalidate(),
@@ -62,75 +76,23 @@ export function InitiativeCard({
deleteMutation.mutate({ id: initiative.id });
}
// Each card fetches its own phase stats (N+1 acceptable for v1 small counts)
const phasesQuery = trpc.listPhases.useQuery({
initiativeId: initiative.id,
});
const phases = phasesQuery.data ?? [];
const completedCount = phases.filter((p) => p.status === "completed").length;
const totalCount = phases.length;
const { activity } = initiative;
const visual = activityVisual(activity.state);
return (
<Card
className="cursor-pointer p-4 transition-colors hover:bg-accent/50"
onClick={onView}
onClick={onClick}
>
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
{/* Left: Initiative name */}
<div className="min-w-0 flex-shrink-0">
<span className="text-base font-bold">{initiative.name}</span>
</div>
{/* Middle: Status + Progress + Phase count */}
<div className="flex flex-1 items-center gap-4">
<StatusBadge status={initiative.status} />
<ProgressBar
completed={completedCount}
total={totalCount}
className="w-32"
/>
<span className="hidden text-sm text-muted-foreground md:inline">
{completedCount}/{totalCount} phases
</span>
</div>
{/* Right: Action buttons */}
<div
className="flex items-center gap-2"
onClick={(e) => e.stopPropagation()}
>
<Button variant="outline" size="sm" onClick={onView}>
<Eye className="mr-1 h-4 w-4" />
View
</Button>
{/* Spawn Architect Dropdown */}
{/* Row 1: Name + overflow menu */}
<div className="flex items-center justify-between">
<span className="min-w-0 truncate text-base font-bold">
{initiative.name}
</span>
<div onClick={(e) => e.stopPropagation()}>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm">
<Bot className="mr-1 h-4 w-4" />
Spawn Architect
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => onSpawnArchitect("discuss")}
>
Discuss
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => onSpawnArchitect("plan")}
>
Plan
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{/* More Actions Menu */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<Button variant="ghost" size="icon" className="h-7 w-7">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
@@ -147,6 +109,35 @@ export function InitiativeCard({
</DropdownMenu>
</div>
</div>
{/* Row 2: Activity dot + label + active phase + progress */}
<div className="mt-1.5 flex items-center gap-3">
<StatusDot
status={activity.state}
variant={visual.variant}
size="sm"
pulse={visual.pulse}
label={visual.label}
/>
<span className="text-sm font-medium">{visual.label}</span>
{activity.activePhase && (
<span className="truncate text-sm text-muted-foreground">
{activity.activePhase.name}
</span>
)}
{activity.phasesTotal > 0 && (
<>
<ProgressBar
completed={activity.phasesCompleted}
total={activity.phasesTotal}
className="ml-auto w-24"
/>
<span className="hidden text-xs text-muted-foreground md:inline">
{activity.phasesCompleted}/{activity.phasesTotal}
</span>
</>
)}
</div>
</Card>
);
}

View File

@@ -9,17 +9,12 @@ interface InitiativeListProps {
statusFilter?: "all" | "active" | "completed" | "archived";
onCreateNew: () => void;
onViewInitiative: (id: string) => void;
onSpawnArchitect: (
initiativeId: string,
mode: "discuss" | "plan",
) => void;
}
export function InitiativeList({
statusFilter = "all",
onCreateNew,
onViewInitiative,
onSpawnArchitect,
}: InitiativeListProps) {
const initiativesQuery = trpc.listInitiatives.useQuery(
statusFilter === "all" ? undefined : { status: statusFilter },
@@ -31,13 +26,14 @@ export function InitiativeList({
<div className="space-y-3">
{Array.from({ length: 3 }).map((_, i) => (
<Card key={i} className="p-4">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div className="flex items-center justify-between">
<Skeleton className="h-5 w-48" />
<div className="flex flex-1 items-center gap-4">
<Skeleton className="h-5 w-16" />
<Skeleton className="h-2 w-32" />
<Skeleton className="h-4 w-24" />
</div>
<Skeleton className="h-7 w-7 rounded" />
</div>
<div className="mt-1.5 flex items-center gap-3">
<Skeleton className="h-2 w-2 rounded-full" />
<Skeleton className="h-4 w-20" />
<Skeleton className="ml-auto h-2 w-24" />
</div>
</Card>
))}
@@ -91,8 +87,7 @@ export function InitiativeList({
<InitiativeCard
key={initiative.id}
initiative={initiative}
onView={() => onViewInitiative(initiative.id)}
onSpawnArchitect={(mode) => onSpawnArchitect(initiative.id, mode)}
onClick={() => onViewInitiative(initiative.id)}
/>
))}
</div>

View File

@@ -60,6 +60,8 @@ export function mapEntityStatus(rawStatus: string): StatusVariant {
interface StatusDotProps {
status: string;
/** Override the auto-mapped variant with an explicit one. */
variant?: StatusVariant;
size?: "sm" | "md" | "lg";
pulse?: boolean;
label?: string;
@@ -68,6 +70,7 @@ interface StatusDotProps {
export function StatusDot({
status,
variant: variantOverride,
size = "md",
pulse = false,
label,
@@ -79,7 +82,7 @@ export function StatusDot({
lg: "h-4 w-4",
};
const variant = mapEntityStatus(status);
const variant = variantOverride ?? mapEntityStatus(status);
const color = dotColors[variant];
const displayLabel = label ?? status.replace(/_/g, " ").toLowerCase();

View File

@@ -26,8 +26,8 @@ function DashboardPage() {
// Single SSE stream for live updates
useLiveUpdates([
{ prefix: 'task:', invalidate: ['listInitiatives', 'listPhases'] },
{ prefix: 'phase:', invalidate: ['listInitiatives', 'listPhases'] },
{ prefix: 'task:', invalidate: ['listInitiatives'] },
{ prefix: 'phase:', invalidate: ['listInitiatives'] },
]);
return (
@@ -63,10 +63,6 @@ function DashboardPage() {
onViewInitiative={(id) =>
navigate({ to: "/initiatives/$id", params: { id } })
}
onSpawnArchitect={(_initiativeId, _mode) => {
// Architect spawning is self-contained within SpawnArchitectDropdown
// This callback is available for future toast notifications
}}
/>
{/* Create initiative dialog */}