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:
@@ -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 };
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
Reference in New Issue
Block a user