From 96386e1c3d9735db2a0067fc750c18394024fa48 Mon Sep 17 00:00:00 2001 From: Lukas May Date: Tue, 3 Mar 2026 12:49:07 +0100 Subject: [PATCH] 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. --- .../trpc/routers/initiative-activity.ts | 47 +++++++ apps/server/trpc/routers/initiative.ts | 19 ++- .../src/components/CreateInitiativeDialog.tsx | 1 + apps/web/src/components/InitiativeCard.tsx | 133 ++++++++---------- apps/web/src/components/InitiativeList.tsx | 21 ++- apps/web/src/components/StatusDot.tsx | 5 +- apps/web/src/routes/initiatives/index.tsx | 8 +- docs/frontend.md | 8 ++ docs/server-api.md | 2 +- packages/shared/src/index.ts | 2 +- packages/shared/src/types.ts | 17 +++ 11 files changed, 167 insertions(+), 96 deletions(-) create mode 100644 apps/server/trpc/routers/initiative-activity.ts diff --git a/apps/server/trpc/routers/initiative-activity.ts b/apps/server/trpc/routers/initiative-activity.ts new file mode 100644 index 0000000..bfed6b7 --- /dev/null +++ b/apps/server/trpc/routers/initiative-activity.ts @@ -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' }; +} diff --git a/apps/server/trpc/routers/initiative.ts b/apps/server/trpc/routers/initiative.ts index bfc6f7c..5c9ed1e 100644 --- a/apps/server/trpc/routers/initiative.ts +++ b/apps/server/trpc/routers/initiative.ts @@ -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 diff --git a/apps/web/src/components/CreateInitiativeDialog.tsx b/apps/web/src/components/CreateInitiativeDialog.tsx index 079d195..021c7ab 100644 --- a/apps/web/src/components/CreateInitiativeDialog.tsx +++ b/apps/web/src/components/CreateInitiativeDialog.tsx @@ -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 }; diff --git a/apps/web/src/components/InitiativeCard.tsx b/apps/web/src/components/InitiativeCard.tsx index 6736f74..5674f78 100644 --- a/apps/web/src/components/InitiativeCard.tsx +++ b/apps/web/src/components/InitiativeCard.tsx @@ -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 ( -
- {/* Left: Initiative name */} -
- {initiative.name} -
- - {/* Middle: Status + Progress + Phase count */} -
- - - - {completedCount}/{totalCount} phases - -
- - {/* Right: Action buttons */} -
e.stopPropagation()} - > - - - {/* Spawn Architect Dropdown */} + {/* Row 1: Name + overflow menu */} +
+ + {initiative.name} + +
e.stopPropagation()}> - - - - onSpawnArchitect("discuss")} - > - Discuss - - onSpawnArchitect("plan")} - > - Plan - - - - - {/* More Actions Menu */} - - - @@ -147,6 +109,35 @@ export function InitiativeCard({
+ + {/* Row 2: Activity dot + label + active phase + progress */} +
+ + {visual.label} + {activity.activePhase && ( + + {activity.activePhase.name} + + )} + {activity.phasesTotal > 0 && ( + <> + + + {activity.phasesCompleted}/{activity.phasesTotal} + + + )} +
); } diff --git a/apps/web/src/components/InitiativeList.tsx b/apps/web/src/components/InitiativeList.tsx index 0b688da..f70c71e 100644 --- a/apps/web/src/components/InitiativeList.tsx +++ b/apps/web/src/components/InitiativeList.tsx @@ -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({
{Array.from({ length: 3 }).map((_, i) => ( -
+
-
- - - -
+ +
+
+ + +
))} @@ -91,8 +87,7 @@ export function InitiativeList({ onViewInitiative(initiative.id)} - onSpawnArchitect={(mode) => onSpawnArchitect(initiative.id, mode)} + onClick={() => onViewInitiative(initiative.id)} /> ))}
diff --git a/apps/web/src/components/StatusDot.tsx b/apps/web/src/components/StatusDot.tsx index a7f26d3..e706160 100644 --- a/apps/web/src/components/StatusDot.tsx +++ b/apps/web/src/components/StatusDot.tsx @@ -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(); diff --git a/apps/web/src/routes/initiatives/index.tsx b/apps/web/src/routes/initiatives/index.tsx index 368cb03..52388fb 100644 --- a/apps/web/src/routes/initiatives/index.tsx +++ b/apps/web/src/routes/initiatives/index.tsx @@ -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 */} diff --git a/docs/frontend.md b/docs/frontend.md index 7b320a7..eb9d9c7 100644 --- a/docs/frontend.md +++ b/docs/frontend.md @@ -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. diff --git a/docs/server-api.md b/docs/server-api.md index 78ad60d..fabb816 100644 --- a/docs/server-api.md +++ b/docs/server-api.md @@ -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 | diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index b3ff90a..2d253f9 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -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'; diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index 8e58a3f..edbf246 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -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'; /**