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