Merge branch 'cw/improve-initiatives-ui' into cw-merge-1772872758746
This commit is contained in:
@@ -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 { describe, it, expect, vi } from 'vitest';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
import { router, publicProcedure, createCallerFactory } from '../trpc.js';
|
import { router, publicProcedure, createCallerFactory } from '../trpc.js';
|
||||||
import { initiativeProcedures } from './initiative.js';
|
import { initiativeProcedures } from './initiative.js';
|
||||||
import type { TRPCContext } from '../context.js';
|
import type { TRPCContext } from '../context.js';
|
||||||
import { createTestDatabase } from '../../db/repositories/drizzle/test-helpers.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
|
// Mock ensureProjectClone — prevents actual git cloning
|
||||||
@@ -61,6 +64,99 @@ async function setup() {
|
|||||||
// Tests
|
// 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', () => {
|
describe('updateInitiativeConfig — qualityReview', () => {
|
||||||
it('sets qualityReview to true', async () => {
|
it('sets qualityReview to true', async () => {
|
||||||
const { caller, initiative } = await setup();
|
const { caller, initiative } = await setup();
|
||||||
|
|||||||
@@ -11,6 +11,36 @@ import { buildRefinePrompt, buildConflictResolutionPrompt, buildConflictResoluti
|
|||||||
import type { PageForSerialization } from '../../agent/content-serializer.js';
|
import type { PageForSerialization } from '../../agent/content-serializer.js';
|
||||||
import { ensureProjectClone } from '../../git/project-clones.js';
|
import { ensureProjectClone } from '../../git/project-clones.js';
|
||||||
|
|
||||||
|
const ACTIVITY_STATE_PRIORITY: Record<string, number> = {
|
||||||
|
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<T extends { activity: { state: string }; updatedAt: string | Date; id: string }>(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) {
|
export function initiativeProcedures(publicProcedure: ProcedureBuilder) {
|
||||||
return {
|
return {
|
||||||
createInitiative: publicProcedure
|
createInitiative: publicProcedure
|
||||||
@@ -156,17 +186,18 @@ export function initiativeProcedures(publicProcedure: ProcedureBuilder) {
|
|||||||
|
|
||||||
if (ctx.phaseRepository) {
|
if (ctx.phaseRepository) {
|
||||||
const phaseRepo = 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);
|
const phases = await phaseRepo.findByInitiativeId(init.id);
|
||||||
return { ...init, ...addProjects(init), activity: deriveInitiativeActivity(init, phases, activeArchitectAgents) };
|
return { ...init, ...addProjects(init), activity: deriveInitiativeActivity(init, phases, activeArchitectAgents) };
|
||||||
}));
|
}));
|
||||||
|
return sortInitiatives(enriched);
|
||||||
}
|
}
|
||||||
|
|
||||||
return initiatives.map(init => ({
|
return sortInitiatives(initiatives.map(init => ({
|
||||||
...init,
|
...init,
|
||||||
...addProjects(init),
|
...addProjects(init),
|
||||||
activity: deriveInitiativeActivity(init, [], activeArchitectAgents),
|
activity: deriveInitiativeActivity(init, [], activeArchitectAgents),
|
||||||
}));
|
})));
|
||||||
}),
|
}),
|
||||||
|
|
||||||
getInitiative: publicProcedure
|
getInitiative: publicProcedure
|
||||||
|
|||||||
184
apps/web/src/components/InitiativeCard.test.tsx
Normal file
184
apps/web/src/components/InitiativeCard.test.tsx
Normal file
@@ -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) => (
|
||||||
|
<div data-testid="card" className={className} onClick={onClick}>{children}</div>
|
||||||
|
),
|
||||||
|
}))
|
||||||
|
|
||||||
|
import { InitiativeCard, type SerializedInitiative } from './InitiativeCard'
|
||||||
|
|
||||||
|
function makeInitiative(overrides: Partial<SerializedInitiative> = {}): 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(<InitiativeCard initiative={makeInitiative()} onClick={() => {}} />)
|
||||||
|
expect(document.querySelector('.mt-1\\.5')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('flex container has items-center class', () => {
|
||||||
|
render(<InitiativeCard initiative={makeInitiative()} onClick={() => {}} />)
|
||||||
|
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(<InitiativeCard initiative={initiative} onClick={() => {}} />)
|
||||||
|
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(<InitiativeCard initiative={initiative} onClick={() => {}} />)
|
||||||
|
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(<InitiativeCard initiative={initiative} onClick={() => {}} />)
|
||||||
|
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(<InitiativeCard initiative={initiative} onClick={() => {}} />)
|
||||||
|
// 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(<InitiativeCard initiative={makeInitiative()} onClick={() => {}} />)
|
||||||
|
const wrapper = document.querySelector('.opacity-0')
|
||||||
|
expect(wrapper).not.toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('dropdown wrapper has group-hover:opacity-100 class', () => {
|
||||||
|
render(<InitiativeCard initiative={makeInitiative()} onClick={() => {}} />)
|
||||||
|
const wrapper = document.querySelector('.opacity-0')
|
||||||
|
expect(wrapper).toHaveClass('group-hover:opacity-100')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('card has group class', () => {
|
||||||
|
render(<InitiativeCard initiative={makeInitiative()} onClick={() => {}} />)
|
||||||
|
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(<InitiativeCard initiative={initiative} onClick={() => {}} />)
|
||||||
|
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(<InitiativeCard initiative={initiative} onClick={() => {}} />)
|
||||||
|
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(<InitiativeCard initiative={initiative} onClick={() => {}} />)
|
||||||
|
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(<InitiativeCard initiative={initiative} onClick={() => {}} />)
|
||||||
|
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(<InitiativeCard initiative={initiative} onClick={() => {}} />)
|
||||||
|
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(<InitiativeCard initiative={initiative} onClick={() => {}} />)
|
||||||
|
expect(screen.queryByText('·')).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -10,7 +10,6 @@ import {
|
|||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { StatusDot, type StatusVariant } from "@/components/StatusDot";
|
import { StatusDot, type StatusVariant } from "@/components/StatusDot";
|
||||||
import { ProgressBar } from "@/components/ProgressBar";
|
|
||||||
import { trpc } from "@/lib/trpc";
|
import { trpc } from "@/lib/trpc";
|
||||||
|
|
||||||
/** Initiative shape as returned by tRPC (Date serialized to string over JSON) */
|
/** 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 }>;
|
projects?: Array<{ id: string; name: string }>;
|
||||||
activity: {
|
activity: {
|
||||||
state: string;
|
state: string;
|
||||||
activePhase?: { id: string; name: string };
|
activePhase?: { id: string; name: string } | null;
|
||||||
phasesTotal: number;
|
phasesTotal: number;
|
||||||
phasesCompleted: number;
|
phasesCompleted: number;
|
||||||
};
|
};
|
||||||
@@ -83,27 +82,56 @@ export function InitiativeCard({ initiative, onClick }: InitiativeCardProps) {
|
|||||||
|
|
||||||
const { activity } = initiative;
|
const { activity } = initiative;
|
||||||
const visual = activityVisual(activity.state);
|
const visual = activityVisual(activity.state);
|
||||||
|
const projects = initiative.projects ?? [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
interactive
|
interactive
|
||||||
className="p-4"
|
className="px-4 py-2 group"
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
>
|
>
|
||||||
{/* Row 1: Name + project pills + overflow menu */}
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
<div className="flex items-center justify-between gap-2">
|
<StatusDot
|
||||||
<div className="flex min-w-0 items-center gap-2">
|
status={activity.state}
|
||||||
<span className="shrink-0 text-base font-bold">
|
variant={visual.variant}
|
||||||
{initiative.name}
|
size="sm"
|
||||||
|
pulse={visual.pulse}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<span className="truncate min-w-0 flex-1 font-semibold text-sm">
|
||||||
|
{initiative.name}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{activity.activePhase && (
|
||||||
|
<>
|
||||||
|
<span className="text-muted-foreground text-sm shrink-0">·</span>
|
||||||
|
<span className="truncate text-sm text-muted-foreground">
|
||||||
|
{activity.activePhase.name}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activity.phasesTotal > 0 && (
|
||||||
|
<span className="ml-auto text-xs text-muted-foreground whitespace-nowrap shrink-0">
|
||||||
|
{activity.phasesCompleted} / {activity.phasesTotal}
|
||||||
</span>
|
</span>
|
||||||
{initiative.projects && initiative.projects.length > 0 &&
|
)}
|
||||||
initiative.projects.map((p) => (
|
|
||||||
<Badge key={p.id} variant="outline" size="xs" className="shrink-0 font-normal">
|
{projects.slice(0, 2).map((p) => (
|
||||||
{p.name}
|
<Badge key={p.id} variant="outline" size="xs" className="shrink-0">
|
||||||
</Badge>
|
{p.name}
|
||||||
))}
|
</Badge>
|
||||||
</div>
|
))}
|
||||||
<div onClick={(e) => e.stopPropagation()}>
|
{projects.length > 2 && (
|
||||||
|
<Badge variant="secondary" size="xs" className="shrink-0">
|
||||||
|
+{projects.length - 2}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="shrink-0 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button variant="ghost" size="icon" className="h-7 w-7">
|
<Button variant="ghost" size="icon" className="h-7 w-7">
|
||||||
@@ -123,35 +151,6 @@ export function InitiativeCard({ initiative, onClick }: InitiativeCardProps) {
|
|||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
69
apps/web/src/components/hq/HQWaitingForInputSection.tsx
Normal file
69
apps/web/src/components/hq/HQWaitingForInputSection.tsx
Normal file
@@ -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 (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h2 className="text-sm font-medium text-muted-foreground uppercase tracking-wide">
|
||||||
|
Waiting for Input
|
||||||
|
</h2>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{items.map((item) => {
|
||||||
|
const isTruncated = item.questionText.length > TRUNCATE_LENGTH
|
||||||
|
const displayText = isTruncated
|
||||||
|
? item.questionText.slice(0, TRUNCATE_LENGTH) + '…'
|
||||||
|
: item.questionText
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card key={item.agentId} className="p-4 flex items-center justify-between gap-4">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="font-semibold text-sm">
|
||||||
|
{item.agentName}
|
||||||
|
{item.initiativeId && item.initiativeName && (
|
||||||
|
<span className="font-normal text-muted-foreground"> · {item.initiativeName}</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1 cursor-default">
|
||||||
|
{displayText}
|
||||||
|
</p>
|
||||||
|
</TooltipTrigger>
|
||||||
|
{isTruncated && (
|
||||||
|
<TooltipContent forceMount>
|
||||||
|
{item.questionText}
|
||||||
|
</TooltipContent>
|
||||||
|
)}
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
waiting {formatRelativeTime(item.waitingSince)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => navigate({ to: '/inbox' })}
|
||||||
|
>
|
||||||
|
Answer
|
||||||
|
</Button>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
104
apps/web/src/routes/initiatives/index.test.tsx
Normal file
104
apps/web/src/routes/initiatives/index.test.tsx
Normal file
@@ -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: () => <div data-testid="initiative-list" />,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/components/CreateInitiativeDialog", () => ({
|
||||||
|
CreateInitiativeDialog: () => null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// ── Import after mocks ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
import { DashboardPage } from "@/routes/initiatives/index";
|
||||||
|
|
||||||
|
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function renderPage() {
|
||||||
|
return render(<DashboardPage />);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -21,11 +21,35 @@ const filterOptions: { value: StatusFilter; label: string }[] = [
|
|||||||
{ value: "archived", label: "Archived" },
|
{ value: "archived", label: "Archived" },
|
||||||
];
|
];
|
||||||
|
|
||||||
function DashboardPage() {
|
export function DashboardPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all");
|
const [statusFilter, setStatusFilter] = useState<StatusFilter>(() => {
|
||||||
|
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<string>("all");
|
const [projectFilter, setProjectFilter] = useState<string>("all");
|
||||||
const [createDialogOpen, setCreateDialogOpen] = useState(false);
|
const [createDialogOpen, setCreateDialogOpen] = useState(false);
|
||||||
|
|
||||||
|
const handleStatusFilterChange = (value: StatusFilter) => {
|
||||||
|
try {
|
||||||
|
sessionStorage.setItem("initiatives.statusFilter", value);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
setStatusFilter(value);
|
||||||
|
};
|
||||||
const projectsQuery = trpc.listProjects.useQuery();
|
const projectsQuery = trpc.listProjects.useQuery();
|
||||||
|
|
||||||
// Single SSE stream for live updates
|
// Single SSE stream for live updates
|
||||||
@@ -55,9 +79,10 @@ function DashboardPage() {
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
<select
|
<select
|
||||||
|
aria-label="Status"
|
||||||
value={statusFilter}
|
value={statusFilter}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setStatusFilter(e.target.value as StatusFilter)
|
handleStatusFilterChange(e.target.value as StatusFilter)
|
||||||
}
|
}
|
||||||
className="rounded-md border border-input bg-background px-3 py-1.5 text-sm ring-offset-background focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
|
className="rounded-md border border-input bg-background px-3 py-1.5 text-sm ring-offset-background focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
|
||||||
>
|
>
|
||||||
|
|||||||
Reference in New Issue
Block a user