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

@@ -0,0 +1,47 @@
/**
* Initiative Activity — derives current activity state from initiative + phases.
*/
import type { Initiative, Phase } from '../../db/schema.js';
import type { InitiativeActivity, InitiativeActivityState } from '@codewalk-district/shared';
export function deriveInitiativeActivity(initiative: Initiative, phases: Phase[]): InitiativeActivity {
const phasesTotal = phases.length;
const phasesCompleted = phases.filter(p => p.status === 'completed').length;
const base = { phasesTotal, phasesCompleted };
if (initiative.status === 'archived') {
return { ...base, state: 'archived' };
}
if (initiative.status === 'completed') {
return { ...base, state: 'complete' };
}
if (phasesTotal === 0) {
return { ...base, state: 'idle' };
}
// Priority-ordered state detection (first match wins)
const priorities: Array<{ status: Phase['status']; state: InitiativeActivityState }> = [
{ status: 'pending_review', state: 'pending_review' },
{ status: 'in_progress', state: 'executing' },
{ status: 'blocked', state: 'blocked' },
];
for (const { status, state } of priorities) {
const match = phases.find(p => p.status === status);
if (match) {
return { ...base, state, activePhase: { id: match.id, name: match.name } };
}
}
if (phasesCompleted === phasesTotal) {
return { ...base, state: 'complete' };
}
const approved = phases.find(p => p.status === 'approved');
if (approved) {
return { ...base, state: 'ready', activePhase: { id: approved.id, name: approved.name } };
}
return { ...base, state: 'planning' };
}

View File

@@ -6,6 +6,7 @@ import { TRPCError } from '@trpc/server';
import { z } from 'zod'; import { z } from 'zod';
import type { ProcedureBuilder } from '../trpc.js'; import type { ProcedureBuilder } from '../trpc.js';
import { requireInitiativeRepository, requireProjectRepository, requireTaskRepository } from './_helpers.js'; import { requireInitiativeRepository, requireProjectRepository, requireTaskRepository } from './_helpers.js';
import { deriveInitiativeActivity } from './initiative-activity.js';
export function initiativeProcedures(publicProcedure: ProcedureBuilder) { export function initiativeProcedures(publicProcedure: ProcedureBuilder) {
return { return {
@@ -63,10 +64,22 @@ export function initiativeProcedures(publicProcedure: ProcedureBuilder) {
}).optional()) }).optional())
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
const repo = requireInitiativeRepository(ctx); const repo = requireInitiativeRepository(ctx);
if (input?.status) { const initiatives = input?.status
return repo.findByStatus(input.status); ? await repo.findByStatus(input.status)
: await repo.findAll();
if (ctx.phaseRepository) {
const phaseRepo = ctx.phaseRepository;
return Promise.all(initiatives.map(async (init) => {
const phases = await phaseRepo.findByInitiativeId(init.id);
return { ...init, activity: deriveInitiativeActivity(init, phases) };
}));
} }
return repo.findAll();
return initiatives.map(init => ({
...init,
activity: deriveInitiativeActivity(init, []),
}));
}), }),
getInitiative: publicProcedure getInitiative: publicProcedure

View File

@@ -50,6 +50,7 @@ export function CreateInitiativeDialog({
mergeRequiresApproval: true, mergeRequiresApproval: true,
branch: null, branch: null,
projects: [], projects: [],
activity: { state: 'idle' as const, phasesTotal: 0, phasesCompleted: 0 },
}; };
utils.listInitiatives.setData(undefined, (old = []) => [tempInitiative, ...old]); utils.listInitiatives.setData(undefined, (old = []) => [tempInitiative, ...old]);
return { previousInitiatives }; 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 { Card } from "@/components/ui/card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
@@ -8,7 +8,7 @@ import {
DropdownMenuTrigger, DropdownMenuTrigger,
DropdownMenuSeparator, DropdownMenuSeparator,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { StatusBadge } from "@/components/StatusBadge"; import { StatusDot, type StatusVariant } from "@/components/StatusDot";
import { ProgressBar } from "@/components/ProgressBar"; import { ProgressBar } from "@/components/ProgressBar";
import { trpc } from "@/lib/trpc"; import { trpc } from "@/lib/trpc";
@@ -21,19 +21,33 @@ export interface SerializedInitiative {
branch: string | null; branch: string | null;
createdAt: string; createdAt: string;
updatedAt: 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 { interface InitiativeCardProps {
initiative: SerializedInitiative; initiative: SerializedInitiative;
onView: () => void; onClick: () => void;
onSpawnArchitect: (mode: "discuss" | "plan") => void;
} }
export function InitiativeCard({ export function InitiativeCard({ initiative, onClick }: InitiativeCardProps) {
initiative,
onView,
onSpawnArchitect,
}: InitiativeCardProps) {
const utils = trpc.useUtils(); const utils = trpc.useUtils();
const archiveMutation = trpc.updateInitiative.useMutation({ const archiveMutation = trpc.updateInitiative.useMutation({
onSuccess: () => utils.listInitiatives.invalidate(), onSuccess: () => utils.listInitiatives.invalidate(),
@@ -62,75 +76,23 @@ export function InitiativeCard({
deleteMutation.mutate({ id: initiative.id }); deleteMutation.mutate({ id: initiative.id });
} }
// Each card fetches its own phase stats (N+1 acceptable for v1 small counts) const { activity } = initiative;
const phasesQuery = trpc.listPhases.useQuery({ const visual = activityVisual(activity.state);
initiativeId: initiative.id,
});
const phases = phasesQuery.data ?? [];
const completedCount = phases.filter((p) => p.status === "completed").length;
const totalCount = phases.length;
return ( return (
<Card <Card
className="cursor-pointer p-4 transition-colors hover:bg-accent/50" 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"> {/* Row 1: Name + overflow menu */}
{/* Left: Initiative name */} <div className="flex items-center justify-between">
<div className="min-w-0 flex-shrink-0"> <span className="min-w-0 truncate text-base font-bold">
<span className="text-base font-bold">{initiative.name}</span> {initiative.name}
</div> </span>
<div onClick={(e) => e.stopPropagation()}>
{/* 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 */}
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="outline" size="sm"> <Button variant="ghost" size="icon" className="h-7 w-7">
<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">
<MoreHorizontal className="h-4 w-4" /> <MoreHorizontal className="h-4 w-4" />
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
@@ -147,6 +109,35 @@ export function InitiativeCard({
</DropdownMenu> </DropdownMenu>
</div> </div>
</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> </Card>
); );
} }

View File

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

View File

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

View File

@@ -26,8 +26,8 @@ function DashboardPage() {
// Single SSE stream for live updates // Single SSE stream for live updates
useLiveUpdates([ useLiveUpdates([
{ prefix: 'task:', invalidate: ['listInitiatives', 'listPhases'] }, { prefix: 'task:', invalidate: ['listInitiatives'] },
{ prefix: 'phase:', invalidate: ['listInitiatives', 'listPhases'] }, { prefix: 'phase:', invalidate: ['listInitiatives'] },
]); ]);
return ( return (
@@ -63,10 +63,6 @@ function DashboardPage() {
onViewInitiative={(id) => onViewInitiative={(id) =>
navigate({ to: "/initiatives/$id", params: { 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 */} {/* Create initiative dialog */}

View File

@@ -59,6 +59,7 @@ The initiative detail page has three tabs managed via local state (not URL param
### Core Components (`src/components/`) ### Core Components (`src/components/`)
| Component | Purpose | | Component | Purpose |
|-----------|---------| |-----------|---------|
| `InitiativeCard` | Initiative list card with activity indicator (dot + label + phase progress), overflow menu |
| `InitiativeHeader` | Initiative name, project badges, inline-editable execution mode & branch | | `InitiativeHeader` | Initiative name, project badges, inline-editable execution mode & branch |
| `InitiativeContent` | Content tab with page tree + editor | | `InitiativeContent` | Content tab with page tree + editor |
| `StatusDot` | Small colored dot using status tokens, with pulse animation | | `StatusDot` | Small colored dot using status tokens, with pulse animation |
@@ -172,4 +173,11 @@ Configured in `src/lib/trpc.ts`. Uses `@trpc/react-query` with TanStack Query fo
`packages/shared/` exports: `packages/shared/` exports:
- `sortByPriorityAndQueueTime()` — priority-based task sorting - `sortByPriorityAndQueueTime()` — priority-based task sorting
- `topologicalSort()` / `groupByPipelineColumn()` — phase DAG layout - `topologicalSort()` / `groupByPipelineColumn()` — phase DAG layout
- `InitiativeActivity` / `InitiativeActivityState` — server-computed activity state for initiative cards
- Shared type re-exports from `packages/shared/src/types.ts` (which re-exports from `apps/server/`) - Shared type re-exports from `packages/shared/src/types.ts` (which re-exports from `apps/server/`)
## Initiative Activity Indicator
`listInitiatives` returns an `activity` field on each initiative, computed server-side from phase statuses via `deriveInitiativeActivity()` in `apps/server/trpc/routers/initiative-activity.ts`. This eliminates per-card N+1 `listPhases` queries.
Activity states (priority order): `pending_review` > `executing` > `blocked` > `complete` > `ready` > `planning` > `idle` > `archived`. Each state maps to a `StatusVariant` + pulse animation in `InitiativeCard`'s `activityVisual()` function.

View File

@@ -85,7 +85,7 @@ Each procedure uses `require*Repository(ctx)` helpers that throw `TRPCError(INTE
| Procedure | Type | Description | | Procedure | Type | Description |
|-----------|------|-------------| |-----------|------|-------------|
| createInitiative | mutation | Create with optional branch/projectIds, auto-creates root page | | createInitiative | mutation | Create with optional branch/projectIds, auto-creates root page |
| listInitiatives | query | Filter by status | | listInitiatives | query | Filter by status; returns `activity` (state, activePhase, phase counts) computed from phases |
| getInitiative | query | With projects array | | getInitiative | query | With projects array |
| updateInitiative | mutation | Name, status | | updateInitiative | mutation | Name, status |
| deleteInitiative | mutation | Cascade delete initiative and all children | | deleteInitiative | mutation | Cascade delete initiative and all children |

View File

@@ -1,3 +1,3 @@
export type { AppRouter } from './trpc.js'; export type { AppRouter } from './trpc.js';
export type { Initiative, Phase, Task, Agent, Message, PendingQuestions, QuestionItem, SubscriptionEvent, Project, ChangeSet, ChangeSetEntry } from './types.js'; export type { Initiative, Phase, Task, Agent, Message, PendingQuestions, QuestionItem, SubscriptionEvent, Project, ChangeSet, ChangeSetEntry, InitiativeActivityState, InitiativeActivity } from './types.js';
export { sortByPriorityAndQueueTime, topologicalSortPhases, groupPhasesByDependencyLevel, type SortableItem, type PhaseForSort, type DependencyEdge, type PipelineColumn } from './utils.js'; export { sortByPriorityAndQueueTime, topologicalSortPhases, groupPhasesByDependencyLevel, type SortableItem, type PhaseForSort, type DependencyEdge, type PipelineColumn } from './utils.js';

View File

@@ -2,6 +2,23 @@ export type { Initiative, Phase, Task, Agent, Message, Page, Project, Account, C
export type { PendingQuestions, QuestionItem } from '../../../apps/server/agent/types.js'; export type { PendingQuestions, QuestionItem } from '../../../apps/server/agent/types.js';
export type ExecutionMode = 'yolo' | 'review_per_phase'; export type ExecutionMode = 'yolo' | 'review_per_phase';
export type InitiativeActivityState =
| 'idle' // Active but no phases
| 'planning' // All phases pending (no work started)
| 'ready' // Phases approved, waiting to execute
| 'executing' // At least one phase in_progress
| 'pending_review' // At least one phase pending_review
| 'blocked' // At least one phase blocked (none in_progress/pending_review)
| 'complete' // All phases completed
| 'archived'; // Initiative archived
export interface InitiativeActivity {
state: InitiativeActivityState;
activePhase?: { id: string; name: string };
phasesTotal: number;
phasesCompleted: number;
}
export type PhaseStatus = 'pending' | 'approved' | 'in_progress' | 'completed' | 'blocked' | 'pending_review'; export type PhaseStatus = 'pending' | 'approved' | 'in_progress' | 'completed' | 'blocked' | 'pending_review';
/** /**