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 <noreply@anthropic.com>
This commit is contained in:
Lukas May
2026-03-06 16:08:35 +01:00
parent f244304c6f
commit 2ec4ddb2fd
3 changed files with 262 additions and 2 deletions

View File

@@ -0,0 +1,12 @@
import { cn } from "@/lib/utils"
function Skeleton({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn("animate-pulse rounded-md bg-muted", className)}
{...props}
/>
)
}
export { Skeleton }

View File

@@ -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) => <div data-testid="waiting">{items.length}</div>,
}))
vi.mock('@/components/hq/HQNeedsReviewSection', () => ({
HQNeedsReviewSection: ({ initiatives, phases }: any) => (
<div data-testid="needs-review">{initiatives.length},{phases.length}</div>
),
}))
vi.mock('@/components/hq/HQNeedsApprovalSection', () => ({
HQNeedsApprovalSection: ({ items }: any) => <div data-testid="needs-approval">{items.length}</div>,
}))
vi.mock('@/components/hq/HQBlockedSection', () => ({
HQBlockedSection: ({ items }: any) => <div data-testid="blocked">{items.length}</div>,
}))
vi.mock('@/components/hq/HQEmptyState', () => ({
HQEmptyState: () => <div data-testid="empty-state">All clear</div>,
}))
// 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(<HeadquartersPage />)
// 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(<HeadquartersPage />)
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(<HeadquartersPage />)
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(<HeadquartersPage />)
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(<HeadquartersPage />)
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(<HeadquartersPage />)
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(<HeadquartersPage />)
expect(screen.getByTestId('needs-review')).toBeInTheDocument()
expect(screen.queryByTestId('empty-state')).not.toBeInTheDocument()
})
})

View File

@@ -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 (
<motion.div
className="mx-auto max-w-4xl space-y-6"
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
>
<div>
<h1 className="text-xl font-semibold">Headquarters</h1>
<p className="text-sm text-muted-foreground">
Items waiting for your attention.
</p>
</div>
<div className="space-y-4">
{[1, 2, 3].map((i) => (
<Skeleton key={i} className="h-16 w-full rounded-lg" />
))}
</div>
</motion.div>
);
}
if (query.isError) {
return (
<motion.div
className="mx-auto max-w-4xl space-y-6"
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
>
<div className="flex flex-col items-center justify-center py-24 gap-3 text-center">
<p className="text-sm text-muted-foreground">
Failed to load headquarters data.
</p>
<Button variant="outline" size="sm" onClick={() => query.refetch()}>
Retry
</Button>
</div>
</motion.div>
);
}
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 (
<motion.div
className="mx-auto max-w-4xl space-y-6"
@@ -19,7 +89,29 @@ function HeadquartersPage() {
Items waiting for your attention.
</p>
</div>
{/* Sections implemented in next phase */}
{!hasAny ? (
<HQEmptyState />
) : (
<div className="space-y-8">
{data.waitingForInput.length > 0 && (
<HQWaitingForInputSection items={data.waitingForInput} />
)}
{(data.pendingReviewInitiatives.length > 0 ||
data.pendingReviewPhases.length > 0) && (
<HQNeedsReviewSection
initiatives={data.pendingReviewInitiatives}
phases={data.pendingReviewPhases}
/>
)}
{data.planningInitiatives.length > 0 && (
<HQNeedsApprovalSection items={data.planningInitiatives} />
)}
{data.blockedPhases.length > 0 && (
<HQBlockedSection items={data.blockedPhases} />
)}
</div>
)}
</motion.div>
);
}