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:
47
apps/server/trpc/routers/initiative-activity.ts
Normal file
47
apps/server/trpc/routers/initiative-activity.ts
Normal 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' };
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { TRPCError } from '@trpc/server';
|
||||
import { z } from 'zod';
|
||||
import type { ProcedureBuilder } from '../trpc.js';
|
||||
import { requireInitiativeRepository, requireProjectRepository, requireTaskRepository } from './_helpers.js';
|
||||
import { deriveInitiativeActivity } from './initiative-activity.js';
|
||||
|
||||
export function initiativeProcedures(publicProcedure: ProcedureBuilder) {
|
||||
return {
|
||||
@@ -63,10 +64,22 @@ export function initiativeProcedures(publicProcedure: ProcedureBuilder) {
|
||||
}).optional())
|
||||
.query(async ({ ctx, input }) => {
|
||||
const repo = requireInitiativeRepository(ctx);
|
||||
if (input?.status) {
|
||||
return repo.findByStatus(input.status);
|
||||
const initiatives = 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
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -59,6 +59,7 @@ The initiative detail page has three tabs managed via local state (not URL param
|
||||
### Core Components (`src/components/`)
|
||||
| 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 |
|
||||
| `InitiativeContent` | Content tab with page tree + editor |
|
||||
| `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:
|
||||
- `sortByPriorityAndQueueTime()` — priority-based task sorting
|
||||
- `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/`)
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -85,7 +85,7 @@ Each procedure uses `require*Repository(ctx)` helpers that throw `TRPCError(INTE
|
||||
| Procedure | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| 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 |
|
||||
| updateInitiative | mutation | Name, status |
|
||||
| deleteInitiative | mutation | Cascade delete initiative and all children |
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
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';
|
||||
|
||||
@@ -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 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';
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user