feat: rewrite InitiativeCard to single-row compact layout with tests

Replaces the two-row card layout with a single horizontal flex row that
fits within 48-56px height. Adds project badge overflow (max 2 + chip),
hover-reveal menu (opacity-0/group-hover), phase counter (X / Y),
active phase name with separator, and StatusDot pulse behavior.

Removes ProgressBar, status label text, and second row entirely.
Adds Vitest unit tests covering all 6 test groups from the spec.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Lukas May
2026-03-07 00:21:53 +01:00
parent 79a0bd0a74
commit 61aa0f9dd4
2 changed files with 228 additions and 45 deletions

View 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()
})
})