From 61aa0f9dd43e26b51638dcbcf90011512d73a00a Mon Sep 17 00:00:00 2001 From: Lukas May Date: Sat, 7 Mar 2026 00:21:53 +0100 Subject: [PATCH 1/4] 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 --- .../src/components/InitiativeCard.test.tsx | 184 ++++++++++++++++++ apps/web/src/components/InitiativeCard.tsx | 89 +++++---- 2 files changed, 228 insertions(+), 45 deletions(-) create mode 100644 apps/web/src/components/InitiativeCard.test.tsx 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} - - - )} -
); } From 7c35f262cfec0196b9cb68841756ca0e496c3730 Mon Sep 17 00:00:00 2001 From: Lukas May Date: Sat, 7 Mar 2026 00:33:26 +0100 Subject: [PATCH 2/4] feat: default statusFilter to active with sessionStorage persistence - Export DashboardPage for testability - Initialize statusFilter from sessionStorage (key: initiatives.statusFilter), falling back to "active" when absent or invalid - Write new filter value to sessionStorage on every change via handleStatusFilterChange, enabling same-session navigation persistence - Add aria-label="Status" to the status select for accessible querying - Add Vitest unit tests covering all 8 scenarios (default, read, write, fallback) Co-Authored-By: Claude Sonnet 4.6 --- .../web/src/routes/initiatives/index.test.tsx | 104 ++++++++++++++++++ apps/web/src/routes/initiatives/index.tsx | 31 +++++- 2 files changed, 132 insertions(+), 3 deletions(-) create mode 100644 apps/web/src/routes/initiatives/index.test.tsx diff --git a/apps/web/src/routes/initiatives/index.test.tsx b/apps/web/src/routes/initiatives/index.test.tsx new file mode 100644 index 0000000..8905cf5 --- /dev/null +++ b/apps/web/src/routes/initiatives/index.test.tsx @@ -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: () =>
, +})); + +vi.mock("@/components/CreateInitiativeDialog", () => ({ + CreateInitiativeDialog: () => null, +})); + +// ── Import after mocks ─────────────────────────────────────────────────────── + +import { DashboardPage } from "@/routes/initiatives/index"; + +// ── Helpers ────────────────────────────────────────────────────────────────── + +function renderPage() { + return render(); +} + +// ── 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"); + }); +}); diff --git a/apps/web/src/routes/initiatives/index.tsx b/apps/web/src/routes/initiatives/index.tsx index 140b7c3..0b6923f 100644 --- a/apps/web/src/routes/initiatives/index.tsx +++ b/apps/web/src/routes/initiatives/index.tsx @@ -21,11 +21,35 @@ const filterOptions: { value: StatusFilter; label: string }[] = [ { value: "archived", label: "Archived" }, ]; -function DashboardPage() { +export function DashboardPage() { const navigate = useNavigate(); - const [statusFilter, setStatusFilter] = useState("all"); + const [statusFilter, setStatusFilter] = useState(() => { + 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("all"); const [createDialogOpen, setCreateDialogOpen] = useState(false); + + const handleStatusFilterChange = (value: StatusFilter) => { + try { + sessionStorage.setItem("initiatives.statusFilter", value); + } catch { + // ignore + } + setStatusFilter(value); + }; const projectsQuery = trpc.listProjects.useQuery(); // Single SSE stream for live updates @@ -55,9 +79,10 @@ function DashboardPage() { ))}