feat: Add HQ section components for Headquarters page

Implements the five section components consumed by the HQ page:
- HQWaitingForInputSection, HQNeedsReviewSection, HQNeedsApprovalSection,
  HQBlockedSection, HQEmptyState with typed props from RouterOutputs.
- Shared types.ts defines the 5 item types from getHeadquartersDashboard.
- Adds RouterOutputs export to trpc.ts via inferRouterOutputs<AppRouter>.
- 22 unit tests verify rendering, navigation CTAs, truncation, and edge cases.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Lukas May
2026-03-06 15:53:15 +01:00
parent f244304c6f
commit 30d5f68f91
8 changed files with 635 additions and 0 deletions

View File

@@ -0,0 +1,51 @@
import { useNavigate } from '@tanstack/react-router'
import { Card } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { formatRelativeTime } from '@/lib/utils'
import type { BlockedPhaseItem } from './types'
interface Props {
items: BlockedPhaseItem[]
}
export function HQBlockedSection({ items }: Props) {
const navigate = useNavigate()
return (
<div className="space-y-3">
<h2 className="text-sm font-medium text-muted-foreground uppercase tracking-wide">
Blocked
</h2>
<div className="space-y-2">
{items.map((item) => (
<Card key={item.phaseId} className="p-4 flex items-center justify-between gap-4">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1 font-semibold text-sm">
<span>{item.initiativeName} {item.phaseName}</span>
<Badge variant="destructive" className="ml-2">Blocked</Badge>
</div>
{item.lastMessage && (
<p className="text-xs text-muted-foreground mt-1">{item.lastMessage}</p>
)}
<p className="text-xs text-muted-foreground">{formatRelativeTime(item.since)}</p>
</div>
<Button
variant="outline"
size="sm"
onClick={() =>
navigate({
to: '/initiatives/$id',
params: { id: item.initiativeId },
search: { tab: 'execution' },
})
}
>
View
</Button>
</Card>
))}
</div>
</div>
)
}

View File

@@ -0,0 +1,15 @@
import { Link } from '@tanstack/react-router'
export function HQEmptyState() {
return (
<div className="flex flex-col items-center justify-center py-24 text-center gap-3">
<p className="text-lg font-semibold">All clear.</p>
<p className="text-sm text-muted-foreground">
No initiatives need your attention right now.
</p>
<Link to="/initiatives" className="text-sm text-primary hover:underline">
Browse active work
</Link>
</div>
)
}

View File

@@ -0,0 +1,50 @@
import { useNavigate } from '@tanstack/react-router'
import { Card } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { formatRelativeTime } from '@/lib/utils'
import type { PlanningInitiativeItem } from './types'
interface Props {
items: PlanningInitiativeItem[]
}
export function HQNeedsApprovalSection({ items }: Props) {
const navigate = useNavigate()
return (
<div className="space-y-3">
<h2 className="text-sm font-medium text-muted-foreground uppercase tracking-wide">
Needs Approval to Continue
</h2>
<div className="space-y-2">
{items.map((item) => {
const s = item.pendingPhaseCount === 1 ? '' : 's'
return (
<Card key={item.initiativeId} className="p-4 flex items-center justify-between gap-4">
<div className="min-w-0 flex-1">
<p className="font-semibold text-sm">{item.initiativeName}</p>
<p className="text-sm text-muted-foreground">
Plan ready {item.pendingPhaseCount} phase{s} awaiting approval
</p>
<p className="text-xs text-muted-foreground">{formatRelativeTime(item.since)}</p>
</div>
<Button
variant="default"
size="sm"
onClick={() =>
navigate({
to: '/initiatives/$id',
params: { id: item.initiativeId },
search: { tab: 'plan' },
})
}
>
Review Plan
</Button>
</Card>
)
})}
</div>
</div>
)
}

View File

@@ -0,0 +1,68 @@
import { useNavigate } from '@tanstack/react-router'
import { Card } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { formatRelativeTime } from '@/lib/utils'
import type { PendingReviewInitiativeItem, PendingReviewPhaseItem } from './types'
interface Props {
initiatives: PendingReviewInitiativeItem[]
phases: PendingReviewPhaseItem[]
}
export function HQNeedsReviewSection({ initiatives, phases }: Props) {
const navigate = useNavigate()
return (
<div className="space-y-3">
<h2 className="text-sm font-medium text-muted-foreground uppercase tracking-wide">
Needs Review
</h2>
<div className="space-y-2">
{initiatives.map((item) => (
<Card key={item.initiativeId} className="p-4 flex items-center justify-between gap-4">
<div className="min-w-0 flex-1">
<p className="font-semibold text-sm">{item.initiativeName}</p>
<p className="text-sm text-muted-foreground">Content ready for review</p>
<p className="text-xs text-muted-foreground">{formatRelativeTime(item.since)}</p>
</div>
<Button
variant="outline"
size="sm"
onClick={() =>
navigate({
to: '/initiatives/$id',
params: { id: item.initiativeId },
search: { tab: 'review' },
})
}
>
Review
</Button>
</Card>
))}
{phases.map((item) => (
<Card key={item.phaseId} className="p-4 flex items-center justify-between gap-4">
<div className="min-w-0 flex-1">
<p className="font-semibold text-sm">{item.initiativeName} {item.phaseName}</p>
<p className="text-sm text-muted-foreground">Phase execution complete review diff</p>
<p className="text-xs text-muted-foreground">{formatRelativeTime(item.since)}</p>
</div>
<Button
variant="outline"
size="sm"
onClick={() =>
navigate({
to: '/initiatives/$id',
params: { id: item.initiativeId },
search: { tab: 'review' },
})
}
>
Review Diff
</Button>
</Card>
))}
</div>
</div>
)
}

View File

@@ -0,0 +1,376 @@
// @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'
const mockNavigate = vi.fn()
vi.mock('@tanstack/react-router', () => ({
useNavigate: () => mockNavigate,
Link: ({ to, children, className }: { to: string; children: React.ReactNode; className?: string }) => (
<a href={to} className={className}>{children}</a>
),
}))
// Mock formatRelativeTime to return a predictable string
vi.mock('@/lib/utils', () => ({
cn: (...classes: string[]) => classes.filter(Boolean).join(' '),
formatRelativeTime: () => '5 minutes ago',
}))
import { HQWaitingForInputSection } from './HQWaitingForInputSection'
import { HQNeedsReviewSection } from './HQNeedsReviewSection'
import { HQNeedsApprovalSection } from './HQNeedsApprovalSection'
import { HQBlockedSection } from './HQBlockedSection'
import { HQEmptyState } from './HQEmptyState'
const since = new Date(Date.now() - 5 * 60 * 1000).toISOString()
// ─── HQWaitingForInputSection ────────────────────────────────────────────────
describe('HQWaitingForInputSection', () => {
beforeEach(() => vi.clearAllMocks())
it('renders section heading "Waiting for Input"', () => {
render(<HQWaitingForInputSection items={[]} />)
expect(screen.getByText('Waiting for Input')).toBeInTheDocument()
})
it('renders agent name and truncated question text', () => {
const longQuestion = 'A'.repeat(150)
render(
<HQWaitingForInputSection
items={[
{
agentId: 'a1',
agentName: 'Agent Alpha',
initiativeId: null,
initiativeName: null,
questionText: longQuestion,
waitingSince: since,
},
]}
/>
)
expect(screen.getByText('Agent Alpha')).toBeInTheDocument()
// Truncated to 120 chars + ellipsis
const truncated = 'A'.repeat(120) + '…'
expect(screen.getByText(truncated)).toBeInTheDocument()
// Full text in tooltip content (forceMount renders it into DOM)
expect(screen.getAllByText(longQuestion).length).toBeGreaterThanOrEqual(1)
})
it('renders "waiting X" relative time', () => {
render(
<HQWaitingForInputSection
items={[
{
agentId: 'a1',
agentName: 'Agent Alpha',
initiativeId: null,
initiativeName: null,
questionText: 'What should I do?',
waitingSince: since,
},
]}
/>
)
expect(screen.getByText('waiting 5 minutes ago')).toBeInTheDocument()
})
it('clicking "Answer" calls navigate to /inbox', () => {
render(
<HQWaitingForInputSection
items={[
{
agentId: 'a1',
agentName: 'Agent Alpha',
initiativeId: null,
initiativeName: null,
questionText: 'Question?',
waitingSince: since,
},
]}
/>
)
fireEvent.click(screen.getByRole('button', { name: /answer/i }))
expect(mockNavigate).toHaveBeenCalledWith({ to: '/inbox' })
})
it('shows initiative name when non-null', () => {
render(
<HQWaitingForInputSection
items={[
{
agentId: 'a1',
agentName: 'Agent Alpha',
initiativeId: 'init-1',
initiativeName: 'My Initiative',
questionText: 'Question?',
waitingSince: since,
},
]}
/>
)
expect(screen.getByText(/My Initiative/)).toBeInTheDocument()
})
it('hides initiative name when null', () => {
render(
<HQWaitingForInputSection
items={[
{
agentId: 'a1',
agentName: 'Agent Alpha',
initiativeId: null,
initiativeName: null,
questionText: 'Question?',
waitingSince: since,
},
]}
/>
)
// No separator dot should appear since initiative is null
expect(screen.queryByText(/·/)).not.toBeInTheDocument()
})
})
// ─── HQNeedsReviewSection ────────────────────────────────────────────────────
describe('HQNeedsReviewSection', () => {
beforeEach(() => vi.clearAllMocks())
it('renders section heading "Needs Review"', () => {
render(<HQNeedsReviewSection initiatives={[]} phases={[]} />)
expect(screen.getByText('Needs Review')).toBeInTheDocument()
})
it('2a: shows initiative name, "Content ready for review", "Review" CTA navigates correctly', () => {
render(
<HQNeedsReviewSection
initiatives={[
{ initiativeId: 'init-1', initiativeName: 'Init One', since },
]}
phases={[]}
/>
)
expect(screen.getByText('Init One')).toBeInTheDocument()
expect(screen.getByText('Content ready for review')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: /^review$/i }))
expect(mockNavigate).toHaveBeenCalledWith({
to: '/initiatives/$id',
params: { id: 'init-1' },
search: { tab: 'review' },
})
})
it('2b: shows initiative phase, "Phase execution complete — review diff", "Review Diff" navigates correctly', () => {
render(
<HQNeedsReviewSection
initiatives={[]}
phases={[
{
phaseId: 'ph-1',
phaseName: 'Phase One',
initiativeId: 'init-1',
initiativeName: 'Init One',
since,
},
]}
/>
)
expect(screen.getByText('Init One Phase One')).toBeInTheDocument()
expect(screen.getByText('Phase execution complete — review diff')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: /review diff/i }))
expect(mockNavigate).toHaveBeenCalledWith({
to: '/initiatives/$id',
params: { id: 'init-1' },
search: { tab: 'review' },
})
})
it('when only initiatives provided, only 2a cards render', () => {
render(
<HQNeedsReviewSection
initiatives={[{ initiativeId: 'init-1', initiativeName: 'Init One', since }]}
phases={[]}
/>
)
expect(screen.getByText('Content ready for review')).toBeInTheDocument()
expect(screen.queryByText('Phase execution complete — review diff')).not.toBeInTheDocument()
})
it('when only phases provided, only 2b cards render', () => {
render(
<HQNeedsReviewSection
initiatives={[]}
phases={[
{
phaseId: 'ph-1',
phaseName: 'Phase One',
initiativeId: 'init-1',
initiativeName: 'Init One',
since,
},
]}
/>
)
expect(screen.getByText('Phase execution complete — review diff')).toBeInTheDocument()
expect(screen.queryByText('Content ready for review')).not.toBeInTheDocument()
})
})
// ─── HQNeedsApprovalSection ──────────────────────────────────────────────────
describe('HQNeedsApprovalSection', () => {
beforeEach(() => vi.clearAllMocks())
it('renders "Needs Approval to Continue" heading', () => {
render(<HQNeedsApprovalSection items={[]} />)
expect(screen.getByText('Needs Approval to Continue')).toBeInTheDocument()
})
it('shows singular phase count: "1 phase awaiting approval"', () => {
render(
<HQNeedsApprovalSection
items={[
{ initiativeId: 'init-1', initiativeName: 'Init One', pendingPhaseCount: 1, since },
]}
/>
)
expect(screen.getByText('Plan ready — 1 phase awaiting approval')).toBeInTheDocument()
})
it('shows plural phase count: "3 phases awaiting approval"', () => {
render(
<HQNeedsApprovalSection
items={[
{ initiativeId: 'init-1', initiativeName: 'Init One', pendingPhaseCount: 3, since },
]}
/>
)
expect(screen.getByText('Plan ready — 3 phases awaiting approval')).toBeInTheDocument()
})
it('"Review Plan" CTA navigates to /initiatives/$id?tab=plan', () => {
render(
<HQNeedsApprovalSection
items={[
{ initiativeId: 'init-1', initiativeName: 'Init One', pendingPhaseCount: 2, since },
]}
/>
)
fireEvent.click(screen.getByRole('button', { name: /review plan/i }))
expect(mockNavigate).toHaveBeenCalledWith({
to: '/initiatives/$id',
params: { id: 'init-1' },
search: { tab: 'plan' },
})
})
})
// ─── HQBlockedSection ────────────────────────────────────────────────────────
describe('HQBlockedSection', () => {
beforeEach(() => vi.clearAllMocks())
it('renders "Blocked" heading', () => {
render(<HQBlockedSection items={[]} />)
expect(screen.getByText('Blocked')).toBeInTheDocument()
})
it('shows initiative phase with "Blocked" badge', () => {
render(
<HQBlockedSection
items={[
{
phaseId: 'ph-1',
phaseName: 'Phase One',
initiativeId: 'init-1',
initiativeName: 'Init One',
lastMessage: null,
since,
},
]}
/>
)
expect(screen.getByText('Init One Phase One')).toBeInTheDocument()
// The "Blocked" badge - there will be one in the heading and one in the card
const badges = screen.getAllByText('Blocked')
expect(badges.length).toBeGreaterThanOrEqual(1)
})
it('shows lastMessage when non-null', () => {
render(
<HQBlockedSection
items={[
{
phaseId: 'ph-1',
phaseName: 'Phase One',
initiativeId: 'init-1',
initiativeName: 'Init One',
lastMessage: 'Something went wrong.',
since,
},
]}
/>
)
expect(screen.getByText('Something went wrong.')).toBeInTheDocument()
})
it('omits lastMessage when null', () => {
render(
<HQBlockedSection
items={[
{
phaseId: 'ph-1',
phaseName: 'Phase One',
initiativeId: 'init-1',
initiativeName: 'Init One',
lastMessage: null,
since,
},
]}
/>
)
expect(screen.queryByText('Something went wrong.')).not.toBeInTheDocument()
})
it('"View" CTA navigates to /initiatives/$id?tab=execution', () => {
render(
<HQBlockedSection
items={[
{
phaseId: 'ph-1',
phaseName: 'Phase One',
initiativeId: 'init-1',
initiativeName: 'Init One',
lastMessage: null,
since,
},
]}
/>
)
fireEvent.click(screen.getByRole('button', { name: /view/i }))
expect(mockNavigate).toHaveBeenCalledWith({
to: '/initiatives/$id',
params: { id: 'init-1' },
search: { tab: 'execution' },
})
})
})
// ─── HQEmptyState ────────────────────────────────────────────────────────────
describe('HQEmptyState', () => {
it('renders "All clear." text', () => {
render(<HQEmptyState />)
expect(screen.getByText('All clear.')).toBeInTheDocument()
})
it('renders "Browse active work" link pointing to /initiatives', () => {
render(<HQEmptyState />)
const link = screen.getByRole('link', { name: /browse active work/i })
expect(link).toBeInTheDocument()
expect(link).toHaveAttribute('href', '/initiatives')
})
})

View File

@@ -0,0 +1,65 @@
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[]
}
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 truncated =
item.questionText.slice(0, 120) +
(item.questionText.length > 120 ? '…' : '')
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-bold text-sm">
{item.agentName}
{item.initiativeName && (
<span className="font-normal text-muted-foreground"> · {item.initiativeName}</span>
)}
</p>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<p className="text-sm text-muted-foreground truncate">{truncated}</p>
</TooltipTrigger>
<TooltipContent forceMount>{item.questionText}</TooltipContent>
</Tooltip>
</TooltipProvider>
<p className="text-xs text-muted-foreground">
waiting {formatRelativeTime(item.waitingSince)}
</p>
</div>
<Button
variant="outline"
size="sm"
onClick={() => navigate({ to: '/inbox' })}
>
Answer
</Button>
</Card>
)
})}
</div>
</div>
)
}

View File

@@ -0,0 +1,8 @@
import type { RouterOutputs } from '@/lib/trpc'
type HQDashboard = RouterOutputs['getHeadquartersDashboard']
export type WaitingForInputItem = HQDashboard['waitingForInput'][number]
export type PendingReviewInitiativeItem = HQDashboard['pendingReviewInitiatives'][number]
export type PendingReviewPhaseItem = HQDashboard['pendingReviewPhases'][number]
export type PlanningInitiativeItem = HQDashboard['planningInitiatives'][number]
export type BlockedPhaseItem = HQDashboard['blockedPhases'][number]

View File

@@ -1,8 +1,10 @@
import { createTRPCReact } from '@trpc/react-query';
import { httpBatchLink, splitLink, httpSubscriptionLink } from '@trpc/client';
import type { AppRouter } from '@codewalk-district/shared';
import type { inferRouterOutputs } from '@trpc/server';
export const trpc = createTRPCReact<AppRouter>();
export type RouterOutputs = inferRouterOutputs<AppRouter>;
export function createTRPCClient() {
return trpc.createClient({