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