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 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