From 2ec4ddb2fd68b553f04b4e20892849877e31a505 Mon Sep 17 00:00:00 2001 From: Lukas May Date: Fri, 6 Mar 2026 16:08:35 +0100 Subject: [PATCH] feat: Wire up hq.tsx with query, live updates, loading/error states, and section rendering Replaces the placeholder body in apps/web/src/routes/hq.tsx with the full Headquarters page. The component fetches from getHeadquartersDashboard, subscribes to live SSE invalidations via useLiveUpdates, and renders the four action sections or an empty state. Includes skeleton loading and error-with-retry states. Also adds apps/web/src/components/ui/skeleton.tsx (standard shadcn component not yet in the project). Co-Authored-By: Claude Sonnet 4.6 --- apps/web/src/components/ui/skeleton.tsx | 12 ++ apps/web/src/routes/hq.test.tsx | 156 ++++++++++++++++++++++++ apps/web/src/routes/hq.tsx | 96 ++++++++++++++- 3 files changed, 262 insertions(+), 2 deletions(-) create mode 100644 apps/web/src/components/ui/skeleton.tsx create mode 100644 apps/web/src/routes/hq.test.tsx diff --git a/apps/web/src/components/ui/skeleton.tsx b/apps/web/src/components/ui/skeleton.tsx new file mode 100644 index 0000000..2b4045c --- /dev/null +++ b/apps/web/src/components/ui/skeleton.tsx @@ -0,0 +1,12 @@ +import { cn } from "@/lib/utils" + +function Skeleton({ className, ...props }: React.HTMLAttributes) { + return ( +
+ ) +} + +export { Skeleton } diff --git a/apps/web/src/routes/hq.test.tsx b/apps/web/src/routes/hq.test.tsx new file mode 100644 index 0000000..818fc63 --- /dev/null +++ b/apps/web/src/routes/hq.test.tsx @@ -0,0 +1,156 @@ +// @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 mockUseQuery = vi.hoisted(() => vi.fn()) +vi.mock('@/lib/trpc', () => ({ + trpc: { + getHeadquartersDashboard: { useQuery: mockUseQuery }, + }, +})) + +vi.mock('@/hooks', () => ({ + useLiveUpdates: vi.fn(), + LiveUpdateRule: undefined, +})) + +vi.mock('@/components/hq/HQWaitingForInputSection', () => ({ + HQWaitingForInputSection: ({ items }: any) =>
{items.length}
, +})) + +vi.mock('@/components/hq/HQNeedsReviewSection', () => ({ + HQNeedsReviewSection: ({ initiatives, phases }: any) => ( +
{initiatives.length},{phases.length}
+ ), +})) + +vi.mock('@/components/hq/HQNeedsApprovalSection', () => ({ + HQNeedsApprovalSection: ({ items }: any) =>
{items.length}
, +})) + +vi.mock('@/components/hq/HQBlockedSection', () => ({ + HQBlockedSection: ({ items }: any) =>
{items.length}
, +})) + +vi.mock('@/components/hq/HQEmptyState', () => ({ + HQEmptyState: () =>
All clear
, +})) + +// Import after mocks are set up +import { HeadquartersPage } from './hq' + +const emptyData = { + waitingForInput: [], + pendingReviewInitiatives: [], + pendingReviewPhases: [], + planningInitiatives: [], + blockedPhases: [], +} + +describe('HeadquartersPage', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('renders skeleton loading state', () => { + mockUseQuery.mockReturnValue({ isLoading: true, isError: false, data: undefined }) + render() + + // Should show heading + expect(screen.getByText('Headquarters')).toBeInTheDocument() + // Should show skeleton elements (there are 3) + const skeletons = document.querySelectorAll('[class*="skeleton"], [class*="h-16"]') + expect(skeletons.length).toBeGreaterThan(0) + // No section components + expect(screen.queryByTestId('waiting')).not.toBeInTheDocument() + expect(screen.queryByTestId('needs-review')).not.toBeInTheDocument() + expect(screen.queryByTestId('needs-approval')).not.toBeInTheDocument() + expect(screen.queryByTestId('blocked')).not.toBeInTheDocument() + expect(screen.queryByTestId('empty-state')).not.toBeInTheDocument() + }) + + it('renders error state with retry button', () => { + const mockRefetch = vi.fn() + mockUseQuery.mockReturnValue({ isLoading: false, isError: true, data: undefined, refetch: mockRefetch }) + render() + + expect(screen.getByText('Failed to load headquarters data.')).toBeInTheDocument() + const retryButton = screen.getByRole('button', { name: /retry/i }) + expect(retryButton).toBeInTheDocument() + + fireEvent.click(retryButton) + expect(mockRefetch).toHaveBeenCalledOnce() + }) + + it('renders empty state when all arrays are empty', () => { + mockUseQuery.mockReturnValue({ isLoading: false, isError: false, data: emptyData }) + render() + + expect(screen.getByTestId('empty-state')).toBeInTheDocument() + expect(screen.queryByTestId('waiting')).not.toBeInTheDocument() + expect(screen.queryByTestId('needs-review')).not.toBeInTheDocument() + expect(screen.queryByTestId('needs-approval')).not.toBeInTheDocument() + expect(screen.queryByTestId('blocked')).not.toBeInTheDocument() + }) + + it('renders WaitingForInput section when items exist', () => { + mockUseQuery.mockReturnValue({ + isLoading: false, + isError: false, + data: { ...emptyData, waitingForInput: [{ id: '1' }] }, + }) + render() + + expect(screen.getByTestId('waiting')).toBeInTheDocument() + expect(screen.queryByTestId('needs-review')).not.toBeInTheDocument() + expect(screen.queryByTestId('needs-approval')).not.toBeInTheDocument() + expect(screen.queryByTestId('blocked')).not.toBeInTheDocument() + expect(screen.queryByTestId('empty-state')).not.toBeInTheDocument() + }) + + it('renders all four sections when all arrays have items', () => { + mockUseQuery.mockReturnValue({ + isLoading: false, + isError: false, + data: { + waitingForInput: [{ id: '1' }], + pendingReviewInitiatives: [{ id: '2' }], + pendingReviewPhases: [{ id: '3' }], + planningInitiatives: [{ id: '4' }], + blockedPhases: [{ id: '5' }], + }, + }) + render() + + expect(screen.getByTestId('waiting')).toBeInTheDocument() + expect(screen.getByTestId('needs-review')).toBeInTheDocument() + expect(screen.getByTestId('needs-approval')).toBeInTheDocument() + expect(screen.getByTestId('blocked')).toBeInTheDocument() + expect(screen.queryByTestId('empty-state')).not.toBeInTheDocument() + }) + + it('renders NeedsReview section when only pendingReviewInitiatives has items', () => { + mockUseQuery.mockReturnValue({ + isLoading: false, + isError: false, + data: { ...emptyData, pendingReviewInitiatives: [{ id: '1' }] }, + }) + render() + + expect(screen.getByTestId('needs-review')).toBeInTheDocument() + expect(screen.queryByTestId('empty-state')).not.toBeInTheDocument() + }) + + it('renders NeedsReview section when only pendingReviewPhases has items', () => { + mockUseQuery.mockReturnValue({ + isLoading: false, + isError: false, + data: { ...emptyData, pendingReviewPhases: [{ id: '1' }] }, + }) + render() + + expect(screen.getByTestId('needs-review')).toBeInTheDocument() + expect(screen.queryByTestId('empty-state')).not.toBeInTheDocument() + }) +}) diff --git a/apps/web/src/routes/hq.tsx b/apps/web/src/routes/hq.tsx index 919f2dd..dea4865 100644 --- a/apps/web/src/routes/hq.tsx +++ b/apps/web/src/routes/hq.tsx @@ -1,11 +1,81 @@ import { createFileRoute } from "@tanstack/react-router"; import { motion } from "motion/react"; +import { trpc } from "@/lib/trpc"; +import { useLiveUpdates, type LiveUpdateRule } from "@/hooks"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Button } from "@/components/ui/button"; +import { HQWaitingForInputSection } from "@/components/hq/HQWaitingForInputSection"; +import { HQNeedsReviewSection } from "@/components/hq/HQNeedsReviewSection"; +import { HQNeedsApprovalSection } from "@/components/hq/HQNeedsApprovalSection"; +import { HQBlockedSection } from "@/components/hq/HQBlockedSection"; +import { HQEmptyState } from "@/components/hq/HQEmptyState"; export const Route = createFileRoute("/hq")({ component: HeadquartersPage, }); -function HeadquartersPage() { +const HQ_LIVE_UPDATE_RULES: LiveUpdateRule[] = [ + { prefix: "initiative:", invalidate: ["getHeadquartersDashboard"] }, + { prefix: "phase:", invalidate: ["getHeadquartersDashboard"] }, + { prefix: "agent:", invalidate: ["getHeadquartersDashboard"] }, +]; + +export function HeadquartersPage() { + useLiveUpdates(HQ_LIVE_UPDATE_RULES); + const query = trpc.getHeadquartersDashboard.useQuery(); + + if (query.isLoading) { + return ( + +
+

Headquarters

+

+ Items waiting for your attention. +

+
+
+ {[1, 2, 3].map((i) => ( + + ))} +
+
+ ); + } + + if (query.isError) { + return ( + +
+

+ Failed to load headquarters data. +

+ +
+
+ ); + } + + const data = query.data!; + + const hasAny = + data.waitingForInput.length > 0 || + data.pendingReviewInitiatives.length > 0 || + data.pendingReviewPhases.length > 0 || + data.planningInitiatives.length > 0 || + data.blockedPhases.length > 0; + return (
- {/* Sections implemented in next phase */} + + {!hasAny ? ( + + ) : ( +
+ {data.waitingForInput.length > 0 && ( + + )} + {(data.pendingReviewInitiatives.length > 0 || + data.pendingReviewPhases.length > 0) && ( + + )} + {data.planningInitiatives.length > 0 && ( + + )} + {data.blockedPhases.length > 0 && ( + + )} +
+ )} ); }