diff --git a/.superpowers/skills/cyclone-frontend-page/SKILL.md b/.superpowers/skills/cyclone-frontend-page/SKILL.md new file mode 100644 index 0000000..3e0d785 --- /dev/null +++ b/.superpowers/skills/cyclone-frontend-page/SKILL.md @@ -0,0 +1,138 @@ +--- +name: cyclone-frontend-page +description: "Cyclone React page conventions (TanStack Query, use hook, drawer, URL state, .test.tsx sibling, Layout / PageHeader / Sidebar). Use when: adding a new page, refactoring an existing one, or wiring a drawer into a page." +--- + +# cyclone-frontend-page + +Cyclone pages live at `src/pages/.tsx`: a `use` hook in `src/hooks/use.ts` does the fetching (and optionally the live-tail subscription), `` + `` + `` (`src/components/`) provide the app shell, and any right-side detail (claim, remittance) lives in `src/components//` whose open/close state is mirrored to the URL via `useDrawerUrlState`. This skill codifies the conventions so additions stay consistent with the eleven pages already shipped. + +As of this writing: **11 pages** under `src/pages/` (9 of 11 with a `*.test.tsx` sibling — Dashboard, Upload, and BatchDiff are not yet covered; be the first when you refactor them), **~30 hooks** under `src/hooks/`, and **2 drawer modules** at `src/components/{ClaimDrawer,RemitDrawer}/`. The next increment is **SP22**. + +## When to use + +- **Adding a new page.** Mounting a new screen in `src/pages/` — you need the Layout + PageHeader + table shape, the `use` hook split, and the route registration point in `src/App.tsx`. +- **Refactoring an existing page.** Splitting a 600-line page, swapping a manual `fetch` for a hook, or moving in-component state into the URL — load this skill to confirm the destination shape. +- **Wiring a drawer.** Adding a new right-side detail drawer (e.g. `BatchDrawer`, `ActivityDrawer`) — you need `useDrawerUrlState` for URL-driven open/close, the `src/components//` folder layout, and the deep-link contract so `?claim=…` / `?remit=…` round-trips. +- **Sharing state via URL.** Persisting filter / page / drawer state across reloads — confirm the `useDrawerUrlState` / `useRemitDrawerUrlState` hook pair is the right tool before reaching for `useState` + history. + +## Conventions + +1. **Page shape.** Every page in `src/pages/.tsx` exports a `function ()` (most pages use a named export — see `src/pages/Claims.tsx:51`, `Remittances.tsx:62`, `Dashboard.tsx:68`; `src/pages/Inbox.tsx:40` is the lone default export). The app shell is provided by the `` route wrapper in `src/App.tsx:30`. Each page sets a `` (`src/components/PageHeader.tsx:18`) and renders a table, list, or KPI grid. Sidebar nav is mounted once by `` at `src/components/Sidebar.tsx`. +2. **Data hook.** Each page pairs with a `use` hook in `src/hooks/use.ts` (e.g. `Claims` ↔ `useClaims`, `Remittances` ↔ `useRemittances`, `Acks` ↔ `useAcks`). The hook returns `{ data, isLoading, isError, error, refetch }` from TanStack Query's `useQuery` (see `src/hooks/useClaims.ts:24-31`, `useRemittances.ts:21-25`). Pages never call `fetch` or `@/lib/api` directly — the hook is the boundary so the page is testable with `vi.mock("@/lib/api", ...)`. +3. **Live tail.** Pages with live data compose three hooks in order: the `use` initial fetch, `useTailStream(resource)` (`src/hooks/useTailStream.ts:80` — opens the NDJSON stream, drives the backoff/stall state machine), and `useMergedTail(resource, baseItems, filterFn?)` (`src/hooks/useMergedTail.ts:25` — merges snapshot + tail, dedup'd by id). The full wiring lives at `src/pages/Claims.tsx:85-89` and `Remittances.tsx:81-83`. The streaming subscription belongs on the page, not in the data hook — hoisting it couples the lifecycle to whoever mounts `use`. +4. **Drawer.** Right-side detail drawers live in `src/components//` (currently `ClaimDrawer/`, `RemitDrawer/`) with a barrel `index.ts` (`src/components/ClaimDrawer/index.ts:1-14`). The drawer is wired to URL state via `useDrawerUrlState()` for `?claim=…` (`src/hooks/useDrawerUrlState.ts`) or `useRemitDrawerUrlState()` for `?remit=…` (`src/hooks/useRemitDrawerUrlState.ts`). Open state is driven by the URL, not a `useState` flag, so deep-links round-trip. +5. **Tests.** Every page gets a `src/pages/.test.tsx` sibling (e.g. `Claims.test.tsx`, `Remittances.test.tsx`, `Batches.test.tsx`). Every hook gets a `src/hooks/use.test.ts` sibling (e.g. `useClaims.test.ts`, `useRemittances.test.ts`). The shared setup — `// @vitest-environment happy-dom`, `IS_REACT_ACT_ENVIRONMENT = true`, `QueryClient` provider, `vi.mock("@/lib/api", ...)` — is documented in `cyclone-tests`; mirror `src/pages/Claims.test.tsx:1-30` for the canonical page-test shape. +6. **UI primitives.** Use Radix-backed components from `src/components/ui/` (`button.tsx`, `dialog.tsx`, `table.tsx`, `select.tsx`, `pagination.tsx`, `empty-state.tsx`, `error-state.tsx`, `filter-chips.tsx`, `skeleton.tsx`, `input.tsx`, `label.tsx`, `card.tsx`, `badge.tsx`, `skip-link.tsx`, `claim-state-badge.tsx`). Don't pull in a new UI library without discussion — every primitive here is already consumed by at least one shipped page. +7. **Routing.** Pages register their route in `src/App.tsx` as a `> />} />` inside the `` element wrapper (`src/App.tsx:30-44`). Currently every page is a static import; switch to `React.lazy(() => import(...))` only if a page grows heavy (large parse/EDI libs, chart code) and the import cost shows up in the bundle report. + +## Patterns + +### `Claims.tsx`-style page (Layout + PageHeader + table + drawer) + +Canonical page shape — composes the data hook, the tail triplet, and +`useDrawerUrlState` for the `?claim=…` deep-link. See +`src/pages/Claims.tsx:51-200` for the full file. + +```tsx +import { useMemo, useState } from "react"; +import { Table, TableBody, TableRow, /* … */ } from "@/components/ui/table"; +import { PageHeader } from "@/components/PageHeader"; +import { ClaimDrawer } from "@/components/ClaimDrawer"; +import { useClaims } from "@/hooks/useClaims"; +import { useDrawerUrlState } from "@/hooks/useDrawerUrlState"; +import { useTailStream } from "@/hooks/useTailStream"; +import { useMergedTail } from "@/hooks/useMergedTail"; +import { TailStatusPill } from "@/components/TailStatusPill"; + +export function Claims() { + const [status, setStatus] = useState(null); + const params = useMemo(() => ({ status, limit: 25, offset: 0 }), [status]); + + const { data } = useClaims(params); + const { status: tailStatus, lastEventAt, forceReconnect } = useTailStream("claims"); + const items = useMergedTail("claims", data?.items ?? [], (c) => !status || c.status === status); + const { claimId, openClaim, closeClaim } = useDrawerUrlState(); + + return ( + <> + } + /> + + + {items.map((c) => ( + openClaim(c.id)}>{/* …cells… */} + ))} + +
+ + + ); +} +``` + +### `use` data hook (TanStack Query) + +Pattern from `src/hooks/useClaims.ts:24-67` and `useRemittances.ts:21-65`. +Returns a stable shape so the page treats all data hooks uniformly. +The tail subscription lives on the page (see Convention 3), not here. + +```ts +import { useQuery } from "@tanstack/react-query"; +import { api, type ListClaimsParams, type PaginatedResponse } from "@/lib/api"; +import type { Claim } from "@/types"; + +export function useClaims(params: ListClaimsParams) { + return useQuery>({ + queryKey: ["claims", params], + queryFn: () => api.listClaims(params), + enabled: api.isConfigured, + // …in-memory fallback when !api.isConfigured… + }); +} +``` + +### Drawer component with `useDrawerUrlState` + +Pattern from `src/components/ClaimDrawer/ClaimDrawer.tsx:1-50` + +`src/hooks/useDrawerUrlState.ts`. The drawer takes `claimId` as a +prop (driven by the URL), renders nothing when `null`, and uses +`useDrawerKeyboard` for j/k navigation + Escape to close. The page +that mounts it controls the URL — `openClaim("abc")` sets `?claim=abc`, +removing the param closes the drawer, and reload preserves the state. + +```tsx +import { Dialog, DialogContent } from "@/components/ui/dialog"; +import { useClaimDetail } from "@/hooks/useClaimDetail"; +import { useDrawerKeyboard } from "@/hooks/useDrawerKeyboard"; + +export function ClaimDrawer({ claimId, claims, onClose, onNavigate, onToggleHelp }) { + const open = claimId !== null; + const { data, isLoading, isError, error } = useClaimDetail(claimId); + useDrawerKeyboard({ open, claims, onClose, onNavigate, onToggleHelp }); + if (!open) return null; + return ( + !o && onClose()}> + {/* header + body panels from useClaimDetail… */} + + ); +} +``` + +## Anti-patterns + +- **Don't `fetch` from inside a page component.** All API access goes through the `use` hook in `src/hooks/use.ts`. Pages that reach for `fetch(...)` directly can't be tested with `vi.mock("@/lib/api", ...)` and split the data lifecycle across files. The hook returns `{ data, isLoading, isError, error, refetch }` so the page is a pure renderer. +- **Don't open a drawer via local component state.** A `const [open, setOpen] = useState(false)` for a drawer breaks deep-links and reload-restore. Use `useDrawerUrlState()` (claim) or `useRemitDrawerUrlState()` (remit) so the URL is the single source of truth. +- **Don't put domain logic in JSX.** Conditional renderings, table sorting, and KPI math all belong in the `use` hook or a pure helper under `src/lib/` (e.g. `src/lib/format.ts` for currency / date formatting). JSX is for layout; mixing in `items.filter(...).sort(...)` inline is hard to test and hides behavior from the hook. +- **Don't call `useTailStream` from inside a `use` hook.** The streaming subscription belongs on the page (see `src/pages/Claims.tsx:85-89`). Hoisting it into `useClaims` couples the open/close lifecycle of the page to whoever mounts the hook, and breaks the one-resource-one-page ownership that the backoff/stall state machine assumes. +- **Don't import a new UI library to render a button, modal, or table.** Radix-backed primitives in `src/components/ui/` already cover every widget the shipped pages use. Reach for a new library only after the primitive gap is real and the proposal is in a spec / PR. + +## Related skills + +- **`cyclone-tail`** — load for any live-data page (Claims, Remittances, ActivityLog). Documents the `useTailStream` + `useMergedTail` triplet, the `` wiring, and the 30s stall threshold (`STALL_TIMEOUT_MS = 30_000` at `useTailStream.ts:53`). +- **`cyclone-api-router`** — load when a page is calling a new HTTP endpoint. Documents `api_routers/.py` vs. inline `api.py` registration and the response / error-envelope shapes. +- **`cyclone-tests`** — load when adding the `*.test.tsx` sibling. Documents the `// @vitest-environment happy-dom` setup, `IS_REACT_ACT_ENVIRONMENT = true`, the `@testing-library/react` vs. `createRoot`+`Probe` rendering styles, and the `vi.mock("@/lib/api")` convention. +- **`cyclone-edi`** — load when a page renders parsed 837P / 835 / 999 / 270 / 271 / 277CA / TA1 content (ServiceLinesTable, CAS panels, ValidationPanel). Documents the parser modules, the R-coded validator rules, and the CAS / CARC / NPI / EIN helpers.