diff --git a/apps/server/trpc/routers/initiative.test.ts b/apps/server/trpc/routers/initiative.test.ts index 13c24b2..85c3d74 100644 --- a/apps/server/trpc/routers/initiative.test.ts +++ b/apps/server/trpc/routers/initiative.test.ts @@ -1,15 +1,18 @@ /** - * Integration tests for initiative tRPC router — qualityReview field. + * Integration tests for initiative tRPC router — qualityReview field and listInitiatives sort. * - * Verifies that updateInitiativeConfig accepts and persists qualityReview. + * Verifies that updateInitiativeConfig accepts and persists qualityReview, + * and that listInitiatives returns initiatives in priority-sorted order. */ import { describe, it, expect, vi } from 'vitest'; +import { eq } from 'drizzle-orm'; import { router, publicProcedure, createCallerFactory } from '../trpc.js'; import { initiativeProcedures } from './initiative.js'; import type { TRPCContext } from '../context.js'; import { createTestDatabase } from '../../db/repositories/drizzle/test-helpers.js'; -import { DrizzleInitiativeRepository } from '../../db/repositories/drizzle/index.js'; +import { DrizzleInitiativeRepository, DrizzlePhaseRepository } from '../../db/repositories/drizzle/index.js'; +import { initiatives as initiativesTable } from '../../db/schema.js'; // ============================================================================= // Mock ensureProjectClone — prevents actual git cloning @@ -61,6 +64,99 @@ async function setup() { // Tests // ============================================================================= +// ============================================================================= +// listInitiatives sort order tests +// ============================================================================= + +describe('listInitiatives — sort order', () => { + async function setupWithPhaseRepo() { + const db = createTestDatabase(); + const initiativeRepo = new DrizzleInitiativeRepository(db); + const phaseRepo = new DrizzlePhaseRepository(db); + const ctx: TRPCContext = { + eventBus: createMockEventBus(), + serverStartedAt: null, + processCount: 0, + initiativeRepository: initiativeRepo, + phaseRepository: phaseRepo, + }; + const caller = createCaller(ctx); + return { caller, initiativeRepo, phaseRepo, db }; + } + + async function setupNoPhaseRepo() { + const db = createTestDatabase(); + const initiativeRepo = new DrizzleInitiativeRepository(db); + const ctx: TRPCContext = { + eventBus: createMockEventBus(), + serverStartedAt: null, + processCount: 0, + initiativeRepository: initiativeRepo, + }; + const caller = createCaller(ctx); + return { caller, initiativeRepo, db }; + } + + it('executing appears before idle/planning (state priority 0 < 1)', async () => { + const { caller, initiativeRepo, phaseRepo } = await setupWithPhaseRepo(); + const executingInit = await initiativeRepo.create({ name: 'Executing' }); + const planningInit = await initiativeRepo.create({ name: 'Planning' }); + await phaseRepo.create({ initiativeId: executingInit.id, name: 'Phase 1', status: 'in_progress' }); + // planningInit has no phases → derives 'idle' (priority 1 via fallback) + // executingInit has in_progress phase → derives 'executing' (priority 0) + const result = await caller.listInitiatives({}); + expect(result[0].id).toBe(executingInit.id); + }); + + it('ready appears before blocked (priority 1 < 2)', async () => { + const { caller, initiativeRepo, phaseRepo } = await setupWithPhaseRepo(); + const blockedInit = await initiativeRepo.create({ name: 'Blocked' }); + const readyInit = await initiativeRepo.create({ name: 'Ready' }); + await phaseRepo.create({ initiativeId: blockedInit.id, name: 'Blocked Phase', status: 'blocked' }); + await phaseRepo.create({ initiativeId: readyInit.id, name: 'Ready Phase', status: 'approved' }); + const result = await caller.listInitiatives({}); + expect(result[0].id).toBe(readyInit.id); + }); + + it('same state — most recently updated comes first', async () => { + const { caller, initiativeRepo, db } = await setupNoPhaseRepo(); + const older = await initiativeRepo.create({ name: 'Older' }); + const newer = await initiativeRepo.create({ name: 'Newer' }); + // Set different updatedAt values via raw DB (SQLite stores timestamps at second precision) + const olderDate = new Date('2024-01-01T00:00:00.000Z'); + const newerDate = new Date('2024-01-02T00:00:00.000Z'); + await db.update(initiativesTable).set({ updatedAt: olderDate }).where(eq(initiativesTable.id, older.id)); + await db.update(initiativesTable).set({ updatedAt: newerDate }).where(eq(initiativesTable.id, newer.id)); + const result = await caller.listInitiatives({}); + expect(result[0].id).toBe(newer.id); + }); + + it('same state and updatedAt — lexicographically smaller id appears first', async () => { + const { caller, initiativeRepo, db } = await setupNoPhaseRepo(); + const init1 = await initiativeRepo.create({ name: 'Init A' }); + const init2 = await initiativeRepo.create({ name: 'Init B' }); + const fixedDate = new Date('2024-01-01T00:00:00.000Z'); + await db.update(initiativesTable).set({ updatedAt: fixedDate }).where(eq(initiativesTable.id, init1.id)); + await db.update(initiativesTable).set({ updatedAt: fixedDate }).where(eq(initiativesTable.id, init2.id)); + const result = await caller.listInitiatives({}); + const expectedFirst = init1.id < init2.id ? init1.id : init2.id; + expect(result[0].id).toBe(expectedFirst); + }); + + it('empty list returns []', async () => { + const { caller } = await setupNoPhaseRepo(); + const result = await caller.listInitiatives({}); + expect(result).toEqual([]); + }); + + it('single item returns array of length 1', async () => { + const { caller, initiativeRepo } = await setupNoPhaseRepo(); + await initiativeRepo.create({ name: 'Only' }); + const result = await caller.listInitiatives({}); + expect(result).toHaveLength(1); + }); +}); + describe('updateInitiativeConfig — qualityReview', () => { it('sets qualityReview to true', async () => { const { caller, initiative } = await setup(); diff --git a/apps/server/trpc/routers/initiative.ts b/apps/server/trpc/routers/initiative.ts index 1696565..b362823 100644 --- a/apps/server/trpc/routers/initiative.ts +++ b/apps/server/trpc/routers/initiative.ts @@ -11,6 +11,36 @@ import { buildRefinePrompt, buildConflictResolutionPrompt, buildConflictResoluti import type { PageForSerialization } from '../../agent/content-serializer.js'; import { ensureProjectClone } from '../../git/project-clones.js'; +const ACTIVITY_STATE_PRIORITY: Record = { + executing: 0, + pending_review: 0, + discussing: 0, + detailing: 0, + refining: 0, + resolving_conflict: 0, + ready: 1, + planning: 1, + blocked: 2, + complete: 3, + archived: 4, +}; + +function activityPriority(state: string): number { + return ACTIVITY_STATE_PRIORITY[state] ?? 1; +} + +function sortInitiatives(enriched: T[]): T[] { + return enriched.sort((a, b) => { + const pa = activityPriority(a.activity.state); + const pb = activityPriority(b.activity.state); + if (pa !== pb) return pa - pb; + const ta = new Date(a.updatedAt).getTime(); + const tb = new Date(b.updatedAt).getTime(); + if (tb !== ta) return tb - ta; + return a.id < b.id ? -1 : a.id > b.id ? 1 : 0; + }); +} + export function initiativeProcedures(publicProcedure: ProcedureBuilder) { return { createInitiative: publicProcedure @@ -156,17 +186,18 @@ export function initiativeProcedures(publicProcedure: ProcedureBuilder) { if (ctx.phaseRepository) { const phaseRepo = ctx.phaseRepository; - return Promise.all(initiatives.map(async (init) => { + const enriched = await Promise.all(initiatives.map(async (init) => { const phases = await phaseRepo.findByInitiativeId(init.id); return { ...init, ...addProjects(init), activity: deriveInitiativeActivity(init, phases, activeArchitectAgents) }; })); + return sortInitiatives(enriched); } - return initiatives.map(init => ({ + return sortInitiatives(initiatives.map(init => ({ ...init, ...addProjects(init), activity: deriveInitiativeActivity(init, [], activeArchitectAgents), - })); + }))); }), getInitiative: publicProcedure diff --git a/apps/web/src/components/InitiativeCard.test.tsx b/apps/web/src/components/InitiativeCard.test.tsx new file mode 100644 index 0000000..7e49b4d --- /dev/null +++ b/apps/web/src/components/InitiativeCard.test.tsx @@ -0,0 +1,184 @@ +// @vitest-environment happy-dom +import '@testing-library/jest-dom/vitest' +import { render, screen } from '@testing-library/react' +import { vi, describe, it, expect } from 'vitest' + +vi.mock('@/lib/trpc', () => ({ + trpc: { + useUtils: () => ({ listInitiatives: { invalidate: vi.fn() } }), + updateInitiative: { useMutation: vi.fn(() => ({ mutate: vi.fn() })) }, + deleteInitiative: { useMutation: vi.fn(() => ({ mutate: vi.fn() })) }, + }, +})) + +vi.mock('@/components/ui/card', () => ({ + Card: ({ children, className, onClick }: any) => ( +
{children}
+ ), +})) + +import { InitiativeCard, type SerializedInitiative } from './InitiativeCard' + +function makeInitiative(overrides: Partial = {}): SerializedInitiative { + return { + id: 'init-1', + name: 'Test Initiative', + status: 'active', + branch: null, + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:00:00Z', + projects: [], + ...overrides, + activity: { + state: 'planning', + activePhase: null, + phasesTotal: 0, + phasesCompleted: 0, + ...(overrides.activity ?? {}), + }, + } +} + +describe('InitiativeCard — single-row structure', () => { + it('renders no second-row div (no mt-1.5 class in DOM)', () => { + render( {}} />) + expect(document.querySelector('.mt-1\\.5')).toBeNull() + }) + + it('flex container has items-center class', () => { + render( {}} />) + const flexContainer = document.querySelector('.flex.items-center') + expect(flexContainer).not.toBeNull() + }) +}) + +describe('InitiativeCard — project badge overflow', () => { + it('1 project → 1 badge with project name, no +N chip', () => { + const initiative = makeInitiative({ + projects: [{ id: 'p1', name: 'Project Alpha' }], + }) + render( {}} />) + expect(screen.getByText('Project Alpha')).toBeInTheDocument() + expect(screen.queryByText(/^\+\d+$/)).toBeNull() + }) + + it('3 projects → first 2 names present, third absent, +1 chip', () => { + const initiative = makeInitiative({ + projects: [ + { id: 'p1', name: 'Alpha' }, + { id: 'p2', name: 'Beta' }, + { id: 'p3', name: 'Gamma' }, + ], + }) + render( {}} />) + expect(screen.getByText('Alpha')).toBeInTheDocument() + expect(screen.getByText('Beta')).toBeInTheDocument() + expect(screen.queryByText('Gamma')).toBeNull() + expect(screen.getByText('+1')).toBeInTheDocument() + }) + + it('5 projects → first 2 names present, +3 chip', () => { + const initiative = makeInitiative({ + projects: [ + { id: 'p1', name: 'Alpha' }, + { id: 'p2', name: 'Beta' }, + { id: 'p3', name: 'Gamma' }, + { id: 'p4', name: 'Delta' }, + { id: 'p5', name: 'Epsilon' }, + ], + }) + render( {}} />) + expect(screen.getByText('Alpha')).toBeInTheDocument() + expect(screen.getByText('Beta')).toBeInTheDocument() + expect(screen.getByText('+3')).toBeInTheDocument() + }) + + it('0 projects → no badge elements rendered', () => { + const initiative = makeInitiative({ projects: [] }) + render( {}} />) + // No outline or secondary badges for projects + expect(document.querySelectorAll('[class*="rounded-full"][class*="border"]').length).toBe(0) + }) +}) + +describe('InitiativeCard — hover-reveal menu', () => { + it('dropdown wrapper has opacity-0 class', () => { + render( {}} />) + const wrapper = document.querySelector('.opacity-0') + expect(wrapper).not.toBeNull() + }) + + it('dropdown wrapper has group-hover:opacity-100 class', () => { + render( {}} />) + const wrapper = document.querySelector('.opacity-0') + expect(wrapper).toHaveClass('group-hover:opacity-100') + }) + + it('card has group class', () => { + render( {}} />) + const card = screen.getByTestId('card') + expect(card).toHaveClass('group') + }) +}) + +describe('InitiativeCard — phase counter', () => { + it('shows "2 / 5" when phasesCompleted=2 and phasesTotal=5', () => { + const initiative = makeInitiative({ + activity: { state: 'planning', activePhase: null, phasesTotal: 5, phasesCompleted: 2 }, + }) + render( {}} />) + expect(screen.getByText('2 / 5')).toBeInTheDocument() + }) + + it('counter absent when phasesTotal=0', () => { + const initiative = makeInitiative({ + activity: { state: 'planning', activePhase: null, phasesTotal: 0, phasesCompleted: 0 }, + }) + render( {}} />) + expect(screen.queryByText(/\d+ \/ \d+/)).toBeNull() + }) +}) + +describe('InitiativeCard — activity dot pulse', () => { + it('state="executing" → StatusDot has animate-status-pulse class', () => { + const initiative = makeInitiative({ + activity: { state: 'executing', activePhase: null, phasesTotal: 0, phasesCompleted: 0 }, + }) + render( {}} />) + const dot = document.querySelector('[role="status"]') + expect(dot).toHaveClass('animate-status-pulse') + }) + + it('state="planning" → StatusDot does not have animate-status-pulse class', () => { + const initiative = makeInitiative({ + activity: { state: 'planning', activePhase: null, phasesTotal: 0, phasesCompleted: 0 }, + }) + render( {}} />) + const dot = document.querySelector('[role="status"]') + expect(dot).not.toHaveClass('animate-status-pulse') + }) +}) + +describe('InitiativeCard — active phase name', () => { + it('activePhase present → phase name and separator visible', () => { + const initiative = makeInitiative({ + activity: { + state: 'planning', + activePhase: { id: 'p1', name: 'Phase Alpha' }, + phasesTotal: 0, + phasesCompleted: 0, + }, + }) + render( {}} />) + expect(screen.getByText('Phase Alpha')).toBeInTheDocument() + expect(screen.getByText('·')).toBeInTheDocument() + }) + + it('activePhase=null → no separator or phase name rendered', () => { + const initiative = makeInitiative({ + activity: { state: 'planning', activePhase: null, phasesTotal: 0, phasesCompleted: 0 }, + }) + render( {}} />) + expect(screen.queryByText('·')).toBeNull() + }) +}) diff --git a/apps/web/src/components/InitiativeCard.tsx b/apps/web/src/components/InitiativeCard.tsx index 5cf86ce..73415a9 100644 --- a/apps/web/src/components/InitiativeCard.tsx +++ b/apps/web/src/components/InitiativeCard.tsx @@ -10,7 +10,6 @@ import { DropdownMenuSeparator, } from "@/components/ui/dropdown-menu"; import { StatusDot, type StatusVariant } from "@/components/StatusDot"; -import { ProgressBar } from "@/components/ProgressBar"; import { trpc } from "@/lib/trpc"; /** Initiative shape as returned by tRPC (Date serialized to string over JSON) */ @@ -24,7 +23,7 @@ export interface SerializedInitiative { projects?: Array<{ id: string; name: string }>; activity: { state: string; - activePhase?: { id: string; name: string }; + activePhase?: { id: string; name: string } | null; phasesTotal: number; phasesCompleted: number; }; @@ -83,27 +82,56 @@ export function InitiativeCard({ initiative, onClick }: InitiativeCardProps) { const { activity } = initiative; const visual = activityVisual(activity.state); + const projects = initiative.projects ?? []; return ( - {/* Row 1: Name + project pills + overflow menu */} -
-
- - {initiative.name} +
+ + + + {initiative.name} + + + {activity.activePhase && ( + <> + · + + {activity.activePhase.name} + + + )} + + {activity.phasesTotal > 0 && ( + + {activity.phasesCompleted} / {activity.phasesTotal} - {initiative.projects && initiative.projects.length > 0 && - initiative.projects.map((p) => ( - - {p.name} - - ))} -
-
e.stopPropagation()}> + )} + + {projects.slice(0, 2).map((p) => ( + + {p.name} + + ))} + {projects.length > 2 && ( + + +{projects.length - 2} + + )} + +
e.stopPropagation()} + >
- - {/* 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/hq/HQWaitingForInputSection.tsx b/apps/web/src/components/hq/HQWaitingForInputSection.tsx new file mode 100644 index 0000000..9add7e8 --- /dev/null +++ b/apps/web/src/components/hq/HQWaitingForInputSection.tsx @@ -0,0 +1,69 @@ +import { useNavigate } from '@tanstack/react-router' +import { Card } from '@/components/ui/card' +import { Button } from '@/components/ui/button' +import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from '@/components/ui/tooltip' +import { formatRelativeTime } from '@/lib/utils' +import type { WaitingForInputItem } from './types' + +interface Props { + items: WaitingForInputItem[] +} + +const TRUNCATE_LENGTH = 120 + +export function HQWaitingForInputSection({ items }: Props) { + const navigate = useNavigate() + + return ( +
+

+ Waiting for Input +

+
+ {items.map((item) => { + const isTruncated = item.questionText.length > TRUNCATE_LENGTH + const displayText = isTruncated + ? item.questionText.slice(0, TRUNCATE_LENGTH) + '…' + : item.questionText + + return ( + +
+

+ {item.agentName} + {item.initiativeId && item.initiativeName && ( + · {item.initiativeName} + )} +

+ + + +

+ {displayText} +

+
+ {isTruncated && ( + + {item.questionText} + + )} +
+
+

+ waiting {formatRelativeTime(item.waitingSince)} +

+
+ +
+ ) + })} +
+
+ ) +} diff --git a/apps/web/src/routes/initiatives/index.test.tsx b/apps/web/src/routes/initiatives/index.test.tsx new file mode 100644 index 0000000..8905cf5 --- /dev/null +++ b/apps/web/src/routes/initiatives/index.test.tsx @@ -0,0 +1,104 @@ +// @vitest-environment happy-dom +import "@testing-library/jest-dom/vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; +import { vi, describe, it, expect, beforeEach } from "vitest"; + +// ── Mocks ──────────────────────────────────────────────────────────────────── + +vi.mock("@tanstack/react-router", () => ({ + createFileRoute: () => () => ({}), + useNavigate: () => vi.fn(), +})); + +vi.mock("@/lib/trpc", () => ({ + trpc: { + listProjects: { useQuery: () => ({ data: [] }) }, + useUtils: () => ({}), + }, +})); + +vi.mock("@/hooks", () => ({ + useLiveUpdates: vi.fn(), + INITIATIVE_LIST_RULES: {}, +})); + +vi.mock("@/components/InitiativeList", () => ({ + InitiativeList: () =>
, +})); + +vi.mock("@/components/CreateInitiativeDialog", () => ({ + CreateInitiativeDialog: () => null, +})); + +// ── Import after mocks ─────────────────────────────────────────────────────── + +import { DashboardPage } from "@/routes/initiatives/index"; + +// ── Helpers ────────────────────────────────────────────────────────────────── + +function renderPage() { + return render(); +} + +// ── Tests ──────────────────────────────────────────────────────────────────── + +describe("DashboardPage — statusFilter default and sessionStorage", () => { + beforeEach(() => { + sessionStorage.clear(); + vi.clearAllMocks(); + }); + + it("defaults to 'active' when sessionStorage is empty", () => { + renderPage(); + const select = screen.getByRole("combobox", { name: /status/i }) as HTMLSelectElement; + expect(select.value).toBe("active"); + }); + + it("displays the 'Active' option as selected on first render", () => { + renderPage(); + const select = screen.getByDisplayValue("Active") as HTMLSelectElement; + expect(select.value).toBe("active"); + }); + + it("reads 'completed' from sessionStorage as initial value", () => { + sessionStorage.setItem("initiatives.statusFilter", "completed"); + renderPage(); + const select = screen.getByDisplayValue("Completed") as HTMLSelectElement; + expect(select.value).toBe("completed"); + }); + + it("reads 'archived' from sessionStorage as initial value", () => { + sessionStorage.setItem("initiatives.statusFilter", "archived"); + renderPage(); + const select = screen.getByDisplayValue("Archived") as HTMLSelectElement; + expect(select.value).toBe("archived"); + }); + + it("falls back to 'active' when sessionStorage contains an invalid value", () => { + sessionStorage.setItem("initiatives.statusFilter", "bogus"); + renderPage(); + const select = screen.getByDisplayValue("Active") as HTMLSelectElement; + expect(select.value).toBe("active"); + }); + + it("writes the new value to sessionStorage when the filter changes", () => { + renderPage(); + const select = screen.getByDisplayValue("Active"); + fireEvent.change(select, { target: { value: "all" } }); + expect(sessionStorage.getItem("initiatives.statusFilter")).toBe("all"); + }); + + it("updates the select's displayed value after the filter changes", () => { + renderPage(); + const select = screen.getByDisplayValue("Active"); + fireEvent.change(select, { target: { value: "completed" } }); + expect((select as HTMLSelectElement).value).toBe("completed"); + }); + + it("writes 'archived' to sessionStorage when filter changes to archived", () => { + renderPage(); + const select = screen.getByDisplayValue("Active"); + fireEvent.change(select, { target: { value: "archived" } }); + expect(sessionStorage.getItem("initiatives.statusFilter")).toBe("archived"); + }); +}); diff --git a/apps/web/src/routes/initiatives/index.tsx b/apps/web/src/routes/initiatives/index.tsx index 140b7c3..0b6923f 100644 --- a/apps/web/src/routes/initiatives/index.tsx +++ b/apps/web/src/routes/initiatives/index.tsx @@ -21,11 +21,35 @@ const filterOptions: { value: StatusFilter; label: string }[] = [ { value: "archived", label: "Archived" }, ]; -function DashboardPage() { +export function DashboardPage() { const navigate = useNavigate(); - const [statusFilter, setStatusFilter] = useState("all"); + const [statusFilter, setStatusFilter] = useState(() => { + try { + const stored = sessionStorage.getItem("initiatives.statusFilter"); + if ( + stored === "all" || + stored === "active" || + stored === "completed" || + stored === "archived" + ) { + return stored; + } + } catch { + // sessionStorage unavailable (SSR, private-browsing restriction, etc.) + } + return "active"; + }); const [projectFilter, setProjectFilter] = useState("all"); const [createDialogOpen, setCreateDialogOpen] = useState(false); + + const handleStatusFilterChange = (value: StatusFilter) => { + try { + sessionStorage.setItem("initiatives.statusFilter", value); + } catch { + // ignore + } + setStatusFilter(value); + }; const projectsQuery = trpc.listProjects.useQuery(); // Single SSE stream for live updates @@ -55,9 +79,10 @@ function DashboardPage() { ))}