diff --git a/apps/web/src/components/hq/HQBlockedSection.tsx b/apps/web/src/components/hq/HQBlockedSection.tsx new file mode 100644 index 0000000..8f61a13 --- /dev/null +++ b/apps/web/src/components/hq/HQBlockedSection.tsx @@ -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 ( +
+

+ Blocked +

+
+ {items.map((item) => ( + +
+
+ {item.initiativeName} › {item.phaseName} + Blocked +
+ {item.lastMessage && ( +

{item.lastMessage}

+ )} +

{formatRelativeTime(item.since)}

+
+ +
+ ))} +
+
+ ) +} diff --git a/apps/web/src/components/hq/HQEmptyState.tsx b/apps/web/src/components/hq/HQEmptyState.tsx new file mode 100644 index 0000000..d65bac2 --- /dev/null +++ b/apps/web/src/components/hq/HQEmptyState.tsx @@ -0,0 +1,15 @@ +import { Link } from '@tanstack/react-router' + +export function HQEmptyState() { + return ( +
+

All clear.

+

+ No initiatives need your attention right now. +

+ + Browse active work + +
+ ) +} diff --git a/apps/web/src/components/hq/HQNeedsApprovalSection.tsx b/apps/web/src/components/hq/HQNeedsApprovalSection.tsx new file mode 100644 index 0000000..396fa5f --- /dev/null +++ b/apps/web/src/components/hq/HQNeedsApprovalSection.tsx @@ -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 ( +
+

+ Needs Approval to Continue +

+
+ {items.map((item) => { + const s = item.pendingPhaseCount === 1 ? '' : 's' + return ( + +
+

{item.initiativeName}

+

+ Plan ready — {item.pendingPhaseCount} phase{s} awaiting approval +

+

{formatRelativeTime(item.since)}

+
+ +
+ ) + })} +
+
+ ) +} diff --git a/apps/web/src/components/hq/HQNeedsReviewSection.tsx b/apps/web/src/components/hq/HQNeedsReviewSection.tsx new file mode 100644 index 0000000..8ac3970 --- /dev/null +++ b/apps/web/src/components/hq/HQNeedsReviewSection.tsx @@ -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 ( +
+

+ Needs Review +

+
+ {initiatives.map((item) => ( + +
+

{item.initiativeName}

+

Content ready for review

+

{formatRelativeTime(item.since)}

+
+ +
+ ))} + {phases.map((item) => ( + +
+

{item.initiativeName} › {item.phaseName}

+

Phase execution complete — review diff

+

{formatRelativeTime(item.since)}

+
+ +
+ ))} +
+
+ ) +} diff --git a/apps/web/src/components/hq/HQSections.test.tsx b/apps/web/src/components/hq/HQSections.test.tsx new file mode 100644 index 0000000..dab6734 --- /dev/null +++ b/apps/web/src/components/hq/HQSections.test.tsx @@ -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 }) => ( + {children} + ), +})) + +// 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() + expect(screen.getByText('Waiting for Input')).toBeInTheDocument() + }) + + it('renders agent name and truncated question text', () => { + const longQuestion = 'A'.repeat(150) + render( + + ) + 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( + + ) + expect(screen.getByText('waiting 5 minutes ago')).toBeInTheDocument() + }) + + it('clicking "Answer" calls navigate to /inbox', () => { + render( + + ) + fireEvent.click(screen.getByRole('button', { name: /answer/i })) + expect(mockNavigate).toHaveBeenCalledWith({ to: '/inbox' }) + }) + + it('shows initiative name when non-null', () => { + render( + + ) + expect(screen.getByText(/My Initiative/)).toBeInTheDocument() + }) + + it('hides initiative name when null', () => { + render( + + ) + // 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() + expect(screen.getByText('Needs Review')).toBeInTheDocument() + }) + + it('2a: shows initiative name, "Content ready for review", "Review" CTA navigates correctly', () => { + render( + + ) + 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( + + ) + 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( + + ) + 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( + + ) + 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() + expect(screen.getByText('Needs Approval to Continue')).toBeInTheDocument() + }) + + it('shows singular phase count: "1 phase awaiting approval"', () => { + render( + + ) + expect(screen.getByText('Plan ready — 1 phase awaiting approval')).toBeInTheDocument() + }) + + it('shows plural phase count: "3 phases awaiting approval"', () => { + render( + + ) + expect(screen.getByText('Plan ready — 3 phases awaiting approval')).toBeInTheDocument() + }) + + it('"Review Plan" CTA navigates to /initiatives/$id?tab=plan', () => { + render( + + ) + 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() + expect(screen.getByText('Blocked')).toBeInTheDocument() + }) + + it('shows initiative › phase with "Blocked" badge', () => { + render( + + ) + 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( + + ) + expect(screen.getByText('Something went wrong.')).toBeInTheDocument() + }) + + it('omits lastMessage when null', () => { + render( + + ) + expect(screen.queryByText('Something went wrong.')).not.toBeInTheDocument() + }) + + it('"View" CTA navigates to /initiatives/$id?tab=execution', () => { + render( + + ) + 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() + expect(screen.getByText('All clear.')).toBeInTheDocument() + }) + + it('renders "Browse active work" link pointing to /initiatives', () => { + render() + const link = screen.getByRole('link', { name: /browse active work/i }) + expect(link).toBeInTheDocument() + expect(link).toHaveAttribute('href', '/initiatives') + }) +}) diff --git a/apps/web/src/components/hq/HQWaitingForInputSection.tsx b/apps/web/src/components/hq/HQWaitingForInputSection.tsx new file mode 100644 index 0000000..6a23e33 --- /dev/null +++ b/apps/web/src/components/hq/HQWaitingForInputSection.tsx @@ -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 ( +
+

+ Waiting for Input +

+
+ {items.map((item) => { + const truncated = + item.questionText.slice(0, 120) + + (item.questionText.length > 120 ? '…' : '') + + return ( + +
+

+ {item.agentName} + {item.initiativeName && ( + · {item.initiativeName} + )} +

+ + + +

{truncated}

+
+ {item.questionText} +
+
+

+ waiting {formatRelativeTime(item.waitingSince)} +

+
+ +
+ ) + })} +
+
+ ) +} diff --git a/apps/web/src/components/hq/types.ts b/apps/web/src/components/hq/types.ts new file mode 100644 index 0000000..d59a26c --- /dev/null +++ b/apps/web/src/components/hq/types.ts @@ -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] diff --git a/apps/web/src/lib/trpc.ts b/apps/web/src/lib/trpc.ts index d0a6fd5..ec1d9a5 100644 --- a/apps/web/src/lib/trpc.ts +++ b/apps/web/src/lib/trpc.ts @@ -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(); +export type RouterOutputs = inferRouterOutputs; export function createTRPCClient() { return trpc.createClient({