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} - - - )} -
); }