Files
cyclone/docs/superpowers/specs/2026-06-19-cyclone-production-readiness-design.md
Tyler 078de28cc4 docs: add live-data UX section + aesthetic acceptance checks
- Replace 7.6 (skeleton) with hairline + scanline-shimmer design that
  matches the existing precision-instrument voice (Cabinet Grotesk + Geist
  Mono, true-black + electric-blue, hairline chrome, radial light + grain)
- Add 7.7 Live-data UX patterns: skeleton variants, EmptyState primitive,
  ErrorState primitive, Layout-level refetch indicator, Active filter chips,
  newly-streamed row highlight, status color alignment, pagination footer
- Add 13.1 visual / aesthetic acceptance checks
- Voice decision: stay the course — additive only, no new accent / typeface /
  layout primitive / light theme
2026-06-19 18:36:39 -06:00

577 lines
34 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Cyclone Production-Readiness (local-only) — Design
**Date:** 2026-06-19
**Status:** Approved (pending user review of this doc)
**Scope:** First sub-project of the four-part Cyclone roadmap. Local-only deployment (no auth, `127.0.0.1`-bound). Adds a backend in-memory batch store, GET endpoints, react-query wiring on the frontend, fresh reference notes, and a new root README. Out of scope: DB persistence, reconciliation, additional X12 transaction types, additional 837P/835 validation rules.
---
## 1. Overview
The Cyclone EDI suite already has working 837P and 835 parsers, a FastAPI surface for parsing uploads, and a React frontend with an Upload page. What it does not have is a way to *browse* the parsed data — once a file is uploaded, the user sees it in the Upload page, but a refresh or page navigation loses the result, and the existing Dashboard / Claims / Remittances / Providers / Activity pages still read static sample data.
This sub-project closes that loop. We add a process-local in-memory store on the backend, expose GET endpoints that match the resource shape the existing UI already expects, wire react-query into the frontend so pages fetch live data and auto-refresh after a parse, and replace the lost reference notes with a clean new docs tree.
After this, a user can:
1. Start the backend (`python -m cyclone serve`).
2. Start the Vite dev server (`npm run dev`).
3. Open `http://localhost:5173/upload`, drop in a `.txt` file, watch the claims stream in.
4. Navigate to `/claims`, `/remittances`, `/providers`, `/activity` and see the data they just parsed.
5. The whole stack is bound to `127.0.0.1`; no auth, no internet exposure.
## 2. Goals
1. **Persist parsed data for the session.** A new `InMemoryStore` on the backend keeps every successful parse and serves it to GET requests. Lost on backend restart — that is acceptable for a local-only tool and avoids a DB in this sub-project.
2. **Expose GET endpoints that match the UI's resource model.** `GET /api/claims`, `/api/remittances`, `/api/providers`, `/api/activity` plus `/api/batches` and `/api/batches/{id}`. The list endpoints accept filter / sort / pagination query params and support both JSON and NDJSON streaming.
3. **Wire the existing 4 UI pages to the live API** via `@tanstack/react-query` v5, with loading skeletons, error states, and automatic invalidation after a successful parse. Fall back to the in-memory sample store when `VITE_API_BASE_URL` is empty (the existing `data` adapter pattern stays).
4. **Replace the lost reference notes** with 4 fresh, short notes under `docs/reference/` (~5080 lines each) covering 837P, 835, X12 naming, and CO Medicaid specifics. Also rewrite the root `README.md`.
5. **Tighten the local-only deploy posture.** `python -m cyclone serve` binds `127.0.0.1:8000` (not `0.0.0.0`); CORS allowlist stays `http://localhost:5173`. No auth, no API key. The README documents the exact command.
## 3. Non-goals (this sub-project)
- **Database persistence.** SQLite/Postgres is sub-project 2.
- **837P ↔ 835 reconciliation.** Sub-project 2.
- **More 837P/835 validation rules** (REF*G1 enforcement, BHT06, CAS deep-parsing). Sub-project 3.
- **999 ACK, 270/271, or other X12 transaction types.** Sub-project 3.
- **Auth of any kind.** Explicitly out — local-only, single-user, `127.0.0.1`-bound.
- **Structured logging, health-check enhancements, 12-factor env config, Dockerfile.** Deferred.
- **Dev tooling polish** (pre-commit, Makefile, CONTRIBUTING.md, .editorconfig). Deferred.
## 4. Stack
**Backend additions:**
- New module `cyclone.store` (`InMemoryStore`, `BatchRecord`, mappers).
- FastAPI (already in use). New routes added to `cyclone.api`.
- `threading.Lock` around the store's batch list (single-process, but FastAPI may run request handlers in a threadpool).
- `pytest` + `fastapi.testclient.TestClient` for new tests.
**Frontend additions:**
- `@tanstack/react-query` v5 added to `package.json` dependencies.
- `QueryClientProvider` in `src/main.tsx`.
- `useQuery` / `useMutation` hooks under `src/hooks/`.
- `Skeleton` UI primitive added under `src/components/ui/` (re-uses `cn` helper).
- No build-tool changes; the existing Vite + TS + Tailwind stack stays.
**Docs:**
- Plain Markdown under `docs/reference/`. No doc generator, no linter, no CI check.
## 5. Architecture
```
┌──────────────────────┐ POST /api/parse-{837,835} ┌─────────────────────────┐
│ Vite/React UI │ ────────────────────────────▶ │ FastAPI backend │
│ │ GET /api/{batches,claims, │ (uvicorn 127.0.0.1) │
│ ┌────────────────┐ │ remittances,providers, │ ┌────────────────────┐ │
│ │ react-query v5 │ │ activity,batches/{id}} │ │ InMemoryStore │ │
│ │ QueryClient │ │ ◀──────────────────────────── │ │ - batches: list │ │
│ │ useQuery / │ │ (JSON or NDJSON stream) │ │ - lock │ │
│ │ useMutation │ │ │ │ - mapper funcs │ │
│ └────────────────┘ │ │ └────────────────────┘ │
│ ┌────────────────┐ │ │ ┌────────────────────┐ │
│ │ zustand store │ │ │ │ parse-837/835 │ │
│ │ (sample data) │ │ │ │ (already shipped) │ │
│ └────────────────┘ │ │ └────────────────────┘ │
└──────────────────────┘ └─────────────────────────┘
│ CORS allow
│ http://localhost:5173
│ only
```
**Data flow on parse:**
1. User drops a file in `/upload`, clicks Parse.
2. Frontend `useMutation` POSTs to `/api/parse-837` (or 835) with `onProgress` (NDJSON) or `Accept: application/json` for small files.
3. Backend's existing parse route appends the resulting `ParseResult` to `InMemoryStore` on success. Failures are *not* stored.
4. `useMutation.onSuccess` calls `queryClient.invalidateQueries(['batches'], ['claims'], ['remittances'], ['activity'])` (and 837/835-specific keys).
5. Any open page reactively re-fetches via `useQuery` and re-renders with the new data.
**Data flow on browse:**
1. Page mount → `useQuery` fires the GET with the page's filter / sort / pagination params.
2. First render: skeleton. Success: data. Error: error block with retry.
3. Background: `refetchOnWindowFocus: true` (default in v5) so navigating back updates the view.
## 6. Backend changes
### 6.1 New module `backend/src/cyclone/store.py`
```python
class BatchRecord(BaseModel):
model_config = ConfigDict(extra="ignore")
id: str # uuid4 hex
kind: Literal["837p", "835"]
input_filename: str
parsed_at: datetime # tz-aware UTC
result: ParseResult | ParseResult835 # union discriminated by .kind
class InMemoryStore:
def __init__(self) -> None:
self._batches: list[BatchRecord] = []
self._lock = threading.Lock()
def add(self, record: BatchRecord) -> None: ...
def list(self, *, limit: int = 100) -> list[BatchRecord]: ...
def get(self, batch_id: str) -> BatchRecord | None: ...
def iter_claims(self, *, batch_id: str | None = None, ...) -> Iterable[Claim]: ...
def iter_remittances(self, *, batch_id: str | None = None, ...) -> Iterable[Remittance]: ...
def distinct_providers(self) -> list[Provider]: ...
def recent_activity(self, *, limit: int = 200) -> list[Activity]: ...
store = InMemoryStore() # module-level singleton
```
**Mappers** (`to_ui_claim`, `to_ui_remittance`, `to_ui_provider`, `to_activity_event`) live in the same file. They translate the rich backend models (Pydantic, raw segments) to the simpler UI types the GET endpoints return.
**Filter / sort API:** each `iter_*` method takes the relevant filter kwargs and applies them in Python. There is no index — the in-memory list is small (single-session, dozens of batches at most). Sorting uses `sorted(..., key=..., reverse=order == "desc")`.
**Threading:** every public method acquires `self._lock` (RLock, since `list` may call `get` internally) and releases it.
### 6.2 New routes in `backend/src/cyclone/api.py`
| Method | Path | Behavior |
|---|---|---|
| GET | `/api/batches` | `store.list(limit=100)`, mapped to a `BatchSummary`-shaped response. Newest first. |
| GET | `/api/batches/{id}` | `store.get(id)`. 404 if missing. Returns the full `ParseResult` / `ParseResult835`. |
| GET | `/api/claims` | `store.iter_claims(...)` mapped to UI `Claim[]`. Supports `batch_id`, `status`, `payer`, `provider_npi`, `date_from`, `date_to`, `sort`, `order`, `limit` (≤ 1000), `offset`. |
| GET | `/api/remittances` | `store.iter_remittances(...)` mapped to UI `Remittance[]`. Supports `batch_id`, `payer`, `claim_id`, `date_from`, `date_to`, `sort`, `order`, `limit`, `offset`. |
| GET | `/api/providers` | `store.distinct_providers()`. Supports `npi`, `state`, `limit`, `offset`. |
| GET | `/api/activity` | `store.recent_activity(limit=200)`. Supports `kind`, `since`, `limit` (≤ 500). |
| GET | `/api/health` | (unchanged) |
**Streaming:** every list endpoint above accepts `Accept: application/x-ndjson`. When set, the response is a `StreamingResponse` with one JSON object per line: `{type: "item", data: {...}}` for each result, then a final `{type: "summary", data: {total: N, returned: M, has_more: bool}}`. Default JSON response wraps the same data in a `{items: [...], total: N, returned: M, has_more: bool}` envelope so the frontend can paginate uniformly.
**Status mapping (837P):** each parsed `ClaimOutput` has a `validation.passed` boolean. The UI's `Claim.status` is one of `draft|submitted|accepted|denied|paid|pending`. The mapper does:
- `validation.passed` and `frequency_code == "1"``submitted`
- `validation.passed` and any `validation.warnings``pending`
- `!validation.passed` and no error mentions `R050_diagnosis_present``denied`
- otherwise → `draft` (rare; means validation broke somewhere)
**Status mapping (835):** 835 `CLP02` status codes map to `Remittance.status` (`received|posted|reconciled`):
- 1, 2, 19, 20 (paid, primary/secondary) → `received`
- 4 (denied) → `received` with a `denialReason` populated from the CAS segments
- 21, 22 (reversal of previous payment) → `reconciled` (out-of-scope: full reversal handling; the data is in `service_payments[*].adjustments`)
- all other valid codes → `received` (default)
- any code outside `cfg.allowed_status_codes` → still stored, mapped to `received`, and surfaced as a validation warning
### 6.3 Existing parse routes — wire to the store
- `POST /api/parse-837` — on success, build a `BatchRecord(kind="837p", result=result)` and call `store.add(...)` *before* returning the response. On `CycloneParseError` or any unhandled exception, do *not* add.
- `POST /api/parse-835` — same pattern with `kind="835"`.
- Both routes also stamp `parsed_at` and generate `id = uuid.uuid4().hex`.
### 6.4 Uvicorn invocation
`cyclone/__main__.py`'s `serve` branch:
```python
sys.argv = [sys.argv[0], "cyclone.api:app", "--host", "127.0.0.1", "--port", "8000"]
```
- `--host 127.0.0.1` (not `0.0.0.0`).
- `--port 8000` (configurable via `CYCLONE_PORT` env, default `8000`).
- `--reload` only when `CYCLONE_RELOAD=1` (dev convenience).
CORS in `api.py` stays as-is: `allow_origins=["http://localhost:5173"]`.
## 7. Frontend changes
### 7.1 Dependencies
Add to `package.json`:
```json
"dependencies": {
...
"@tanstack/react-query": "^5.59.0"
}
```
### 7.2 `src/main.tsx`
Wrap the existing `<App />` in `<QueryClientProvider client={queryClient}>` where `queryClient` is a module-level `new QueryClient({ defaultOptions: { queries: { staleTime: 30_000, refetchOnWindowFocus: true } } })`.
### 7.3 `src/lib/api.ts` — add GET methods
Mirror the existing `request<T>` helper. New methods (all return `Promise<T>`):
```ts
api.listBatches(): Promise<BatchSummary[]>
api.getBatch(id: string): Promise<ParseResult837 | ParseResult835>
api.listClaims(params: ListClaimsParams): Promise<PaginatedResponse<Claim>>
api.listRemittances(params: ListRemittancesParams): Promise<PaginatedResponse<Remittance>>
api.listProviders(params?: ListProvidersParams): Promise<PaginatedResponse<Provider>>
api.listActivity(params?: ListActivityParams): Promise<PaginatedResponse<Activity>>
```
NDJSON streaming is *not* required for the initial frontend wiring (the existing Upload page already streams from the POST); GET is fine as JSON. The streaming option on GET is for future large-batch scenarios and is a backend-side capability only.
### 7.4 `src/hooks/` — new query / mutation hooks
```ts
// src/hooks/useBatches.ts
export function useBatches() {
return useQuery({ queryKey: ['batches'], queryFn: () => api.listBatches() });
}
// src/hooks/useClaims.ts
export function useClaims(params: ListClaimsParams) {
return useQuery({ queryKey: ['claims', params], queryFn: () => api.listClaims(params) });
}
// ... useRemittances, useProviders, useActivity similarly ...
// src/hooks/useParse.ts
export function useParse(kind: '837p' | '835') {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ file, onProgress }) =>
kind === '837p' ? api.parse837(file, { onProgress }) : api.parse835(file, { onProgress }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['batches'] });
queryClient.invalidateQueries({ queryKey: ['claims'] });
queryClient.invalidateQueries({ queryKey: ['remittances'] });
queryClient.invalidateQueries({ queryKey: ['providers'] });
queryClient.invalidateQueries({ queryKey: ['activity'] });
},
});
}
```
### 7.5 Page refactors
| Page | Current | New |
|---|---|---|
| `src/pages/Claims.tsx` | `useAppStore((s) => s.claims)` | `useClaims({ status, search })`; falls back to store data when `!api.isConfigured` (handled in the hook) |
| `src/pages/Remittances.tsx` | store-backed | `useRemittances({})` |
| `src/pages/Providers.tsx` | store-backed | `useProviders({})` |
| `src/pages/ActivityLog.tsx` | store-backed | `useActivity({ since: now - 7d })`, `refetchInterval: 30_000` |
| `src/pages/Upload.tsx` | uses `useMutation`-less `addParsedBatch` | replace mutation with `useParse(kind)`; keep `addParsedBatch` for the "recent batches" list, but the live-data pages will refresh from the server |
Each page gains: `<Skeleton />` rows during `isLoading`, an error block with "Retry" button on `isError`, and a small footer showing `{returned} of {total}` when `has_more` is true.
**Fallback when `VITE_API_BASE_URL` is empty:** the existing `data` adapter in `src/lib/api.ts` continues to provide a path that reads from the in-memory zustand store. Each new hook checks `api.isConfigured` and either runs the react-query path (default) or returns a synchronous result from the store via `useSyncExternalStore`. Page components don't need to know which path is active. The `data` adapter is removed in a follow-up sub-project once no caller depends on it.
### 7.6 UI primitive: `src/components/ui/skeleton.tsx`
A reusable row-shaped skeleton block. Voice: hairline + a slow horizontal scanline gradient (not the generic "pulse the whole block gray" pattern). Renders three bars (id / name / amount) sized to match the typography of the row it stands in for. ~25 lines, no new dep.
```tsx
// Sketch — not the final implementation
export function Skeleton({ className, variant = "row" }: Props) {
return (
<div
className={cn(
"relative overflow-hidden rounded-md bg-muted/30",
// hairline border at 0.4 opacity to echo the rest of the chrome
"ring-1 ring-inset ring-border/40",
className
)}
>
<div
className="absolute inset-0 -translate-x-full animate-[shimmer_1.6s_infinite]"
style={{
background:
"linear-gradient(90deg, transparent 0%, hsl(var(--muted-foreground) / 0.08) 50%, transparent 100%)",
}}
/>
{variant === "row" ? (
<div className="grid grid-cols-12 gap-3 p-4">
<div className="col-span-2 h-3 bg-muted/60 rounded" />
<div className="col-span-3 h-3 bg-muted/60 rounded" />
<div className="col-span-2 h-3 bg-muted/60 rounded" />
<div className="col-span-2 h-3 bg-muted/60 rounded" />
<div className="col-span-2 h-3 bg-muted/60 rounded ml-auto" />
<div className="col-span-1 h-3 bg-muted/60 rounded ml-auto" />
</div>
) : null}
</div>
);
}
```
The `shimmer` keyframe is added to `tailwind.config.js` next to the existing `fade-in` keyframes. No external library.
### 7.7 Live-data UX patterns
The aesthetic is already committed (Cabinet Grotesk + Geist Mono, true-black ground, electric-blue accent, hairline chrome, radial top-right light, fine grain). This sub-project does not introduce a new accent color, a new typeface, a light theme, or a new layout primitive. The additions below are *only* the live-data UX patterns needed to make the page refactors feel as precise as the data they carry.
#### 7.7.1 Loading — three skeleton variants
Pages render 35 `Skeleton` rows (or cards, or KPI tiles) on `isLoading` so the page doesn't pop. Skeleton uses the row-shaped variant (above) inside table surfaces, the `card` variant inside the Provider grid, and the existing `.surface` + `display` text treatment for the page header (which renders immediately, not behind a skeleton).
#### 7.7.2 Empty state
Replace the existing generic "No claims yet." text with a consistent empty state primitive: a hairline-circle icon (1.5px stroke), an all-caps eyebrow (`text-[10.5px] font-semibold uppercase tracking-[0.18em] text-muted-foreground`), and a one-line message. The eyebrow uses the *page subject* so it reads as an instrument label: e.g., "Claims · inbox idle" / "Remittances · awaiting first 835" / "Activity · log idle" / "Providers · directory empty". No apologetic language, no illustrations.
```tsx
function EmptyState({ eyebrow, message }: { eyebrow: string; message: string }) {
return (
<div className="flex flex-col items-center justify-center gap-2.5 py-14">
<div className="h-10 w-10 rounded-full ring-1 ring-inset ring-border/60 flex items-center justify-center text-muted-foreground">
<CircleDashed className="h-4 w-4" strokeWidth={1.5} />
</div>
<div className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
{eyebrow}
</div>
<div className="text-sm text-muted-foreground/80">{message}</div>
</div>
);
}
```
`CircleDashed` from `lucide-react`.
#### 7.7.3 Error state
A bordered block at the top of the page content (not a full-page takeover — the user should still be able to navigate away). `border-destructive/30` on a `.surface` background; a destructive-tinted `AlertCircle` icon, the error message in `text-sm`, and a `Retry` button on the right. A sonner toast surfaces the same error at the bottom-right (existing Toaster config keeps this). Network errors get a more verbose message ("Can't reach the backend at `http://127.0.0.1:8000`. Is the FastAPI server running?").
```tsx
function ErrorState({ error, onRetry }: { error: Error; onRetry: () => void }) {
return (
<div className="surface rounded-xl border border-destructive/30 p-4 flex items-center gap-3">
<AlertCircle className="h-4 w-4 text-destructive shrink-0" strokeWidth={1.75} />
<div className="flex-1 min-w-0">
<div className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-destructive/80">
Fetch failed
</div>
<div className="text-sm text-foreground/90 truncate">{error.message}</div>
</div>
<Button variant="outline" size="sm" onClick={onRetry}>
Retry
</Button>
</div>
);
}
```
#### 7.7.4 Refetch indicator (Layout-level)
A 1px hairline at the very top of `<main>` that animates in (left → right, accent color, 400ms) whenever *any* query is fetching in the background (e.g., after `invalidateQueries` post-parse). Uses `useIsFetching()` from `@tanstack/react-query`. Implemented as a fixed-position element inside `Layout.tsx`, not page-level — so every page benefits without per-page plumbing.
```tsx
// In Layout.tsx
const isFetching = useIsFetching();
return (
<div className="relative min-h-screen z-10">
{isFetching > 0 ? (
<div className="fixed top-0 left-0 right-0 z-50 h-px overflow-hidden pointer-events-none">
<div
className="h-full w-1/3 bg-accent animate-[scan_1.2s_ease-in-out_infinite]"
style={{ boxShadow: "0 0 8px hsl(var(--accent) / 0.5)" }}
/>
</div>
) : null}
<Sidebar />
<main className="md:pl-60"></main>
</div>
);
```
The `scan` keyframe is added to `tailwind.config.js`. The element is hairline-thin so it never competes with the content; the only motion on the page during a background refetch is this 1px sliver.
#### 7.7.5 Active filter chips (Claims page)
The existing `<Select>` controls stay (they set the values). Above the table, a row of read-only pills makes the *active* filter set scannable at a glance. Each pill: `border-border/60` background, `text-xs`, a small "×" on the right that clears the filter. A "Clear all" link on the right of the chip row clears every active filter. The chips sit on the same `surface` card as the Selects, separated by a 1px hairline.
```tsx
function FilterChips({
active,
onRemove,
onClearAll,
}: {
active: { key: string; label: string }[];
onRemove: (key: string) => void;
onClearAll: () => void;
}) {
if (active.length === 0) return null;
return (
<div className="flex flex-wrap items-center gap-2 mt-4 pt-4 border-t border-border/40">
<span className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
Active
</span>
{active.map((c) => (
<span
key={c.key}
className="inline-flex items-center gap-1.5 rounded-md border border-border/60 bg-muted/30 pl-2.5 pr-1 py-0.5 text-xs"
>
<span className="text-muted-foreground">{c.key}:</span>
<span className="font-medium">{c.label}</span>
<button
type="button"
onClick={() => onRemove(c.key)}
className="ml-0.5 h-4 w-4 rounded-sm hover:bg-muted flex items-center justify-center"
aria-label={`Remove ${c.key} filter`}
>
<X className="h-3 w-3" strokeWidth={1.75} />
</button>
</span>
))}
{active.length > 1 ? (
<button
type="button"
onClick={onClearAll}
className="ml-auto text-[11px] text-muted-foreground hover:text-foreground transition-colors"
>
Clear all
</button>
) : null}
</div>
);
}
```
#### 7.7.6 Newly-streamed row highlight (Tables)
When react-query invalidates after a successful parse, the affected tables briefly highlight the *new* rows with a 2-second accent-tinted background fade. Implementation: each row that arrived in the most recent parse carries a `data-newer-than={parsedAt}` attribute; CSS animates a one-shot `bg-accent/10` to `bg-transparent` over 2s. This is the visual confirmation that "the parse made it into the list" without being noisy.
```tsx
// in TableRow wrapper
<tr
data-newer-than={isNewRow ? parsedAt : undefined}
className="data-[newer-than]:animate-[row-flash_2s_ease-out]"
/>
```
The `row-flash` keyframe: `from { background-color: hsl(var(--accent) / 0.1); } to { background-color: transparent; }`. Lives in `tailwind.config.js`.
#### 7.7.7 Status color alignment (no new colors)
The new mapper output is `draft|submitted|accepted|denied|paid|pending` for 837P claims and `received|posted|reconciled` for 835 remittances — exactly what the existing `ClaimStatusBadge` and `RemitStatusBadge` already key on. The palette is **unchanged**: success (green) for `accepted`/`paid`/`reconciled`, accent (blue) for `submitted`/`received`, warning (amber) for `pending`/`posted`, destructive (red) for `denied`, muted for `draft`. No new tokens, no new semantics.
#### 7.7.8 Pagination footer (replaces the placeholder)
For list endpoints with `total > limit`, the existing page footer grows a paginator: `` `1` `2` `3``N` ``, plus the `returned` / `total` / `has_more` summary on the right. Style: `text-xs` with `num` on the numbers, the active page gets `bg-muted/60` + a left-aligned 1px accent line (echoing the sidebar `nav-active` treatment). The component is a small new primitive `src/components/ui/pagination.tsx` (~40 lines).
## 8. Docs
### 8.1 `docs/reference/837p.md` (~80 lines)
Sections:
- File extension: `.txt`. Encoding: ASCII or UTF-8. Delimiters: `*` element, `:` component, `~` segment, `^` repetition.
- Envelope: `ISA/GS/ST` headers, `SE/GE/IEA` footers.
- Loops: 2000A (billing provider), 2000B (subscriber), 2300 (claim), 2400 (service line).
- Segments Cyclone parses: NM1, N3/N4, REF, CLM, HI, LX, SV1, DTP, BHT.
- Segments Cyclone skips (preserved in `raw_segments` but not modeled): too many to enumerate; see the parser source for the full list.
- CO Medicaid specifics: `REF*G1` prior-auth, no patient loop, `CLM05` shape `12:B:1` etc.
### 8.2 `docs/reference/835.md` (~80 lines)
- Envelope: same as 837P.
- Header: `BPR` (financial), `TRN` (trace), `DTM` (dates).
- Loops: 1000A (payer), 1000B (payee), 2100 (CLP claim payment), 2110 (SVC service payment).
- Critical balancing rules: `BPR02 == sum(CLP04)`, `CLP04 == sum(SVC03)`.
- CO Medicaid specifics: `BPR10` 81-1725341 (TXIX) or 84-0644739 (BHA), `N104` 7912900843, `N1*PR` name `CO_TXIX` or `CO_BHA`.
### 8.3 `docs/reference/x12naming.md` (~50 lines)
- Segments: 23 letter codes (`CLM`, `NM1`, `BPR`).
- Elements: `*`-separated, 1-indexed.
- Composite elements: `:`-separated sub-elements, indexed `01`, `02`, ...
- Loops: 4-digit IDs, hierarchical (2000 contains 2300 contains 2400).
- Common qualifiers we care about: `B` (POS qualifier), `ABK` (ICD-10 principal diagnosis), `MC` (Medicaid claim filing).
### 8.4 `docs/reference/co-medicaid.md` (~80 lines)
- Trading partner ID (TPID) — sender / receiver roles in 837P and 835.
- Payer IDs:
- 837P: `NM1*PR N104 = "SKCO0"` (COHCPF).
- 835: `BPR10 = "81-1725341"` (TXIX) or `"84-0644739"` (BHA); `N1*PR N104 = "7912900843"`.
- Allowed frequency codes (837P `CLM05-3`): `{1, 7, 8}`.
- Allowed status codes (835 `CLP02`): `{1, 2, 3, 4, 19, 20, 21, 22, 23, 25}`.
- POS codes: full CMS set (89 codes; canonical list in `cyclone.parsers.payer.CMS_PLACE_OF_SERVICE_CODES`).
### 8.5 `README.md` (root, rewrite)
Sections:
- **What is Cyclone?** — 1 paragraph. EDI claim parser + browser UI.
- **Install** — `git clone`, `cd backend && python -m venv .venv && .venv/bin/pip install -e '.[dev]'`, `cd .. && npm install`.
- **Dev** — two terminals: `cd backend && .venv/bin/python -m cyclone serve` and `npm run dev`. Open `http://localhost:5173`.
- **Test** — `cd backend && .venv/bin/pytest` and `npm run build`.
- **Project layout** — `backend/` (Python API + parsers), `src/` (React UI), `docs/reference/` (companion notes), `docs/prodfiles/` (sample EDI files).
- **Roadmap** — short list of sub-projects 24 with one-line descriptions and a note that they're not in this build.
- **License** — no license file yet; add a `LICENSE` file when one is decided. The project is internal-use until then.
## 9. Error handling
- **Backend store errors:** none expected (in-memory, no I/O). If a `BatchRecord.result` doesn't conform (e.g. we tried to store a result from a code path that returns `None`), the model validator fails loudly at insertion time.
- **GET route errors:** `404` for missing `batch_id`; `400` for invalid query params (FastAPI's default validation handles this); `500` only for unexpected bugs.
- **NDJSON streaming:** on a serialization error mid-stream, the response is already partially written; we log the error and end the stream. The client sees a truncated response and the final summary line is missing. The frontend falls back to "show what we have" and shows a toast.
- **Frontend:** `useQuery` exposes `isError` + `error`. Each page renders an error block with a retry button that calls `refetch()`. Network errors show a sonner toast on top of the inline error.
- **Parse failure:** the parse route already returns 400 / 422 with a clear message. The Upload page shows a destructive toast. The store is unchanged (failed parses are not persisted).
## 10. Testing
### 10.1 Backend (`backend/tests/`)
- `test_store.py` (~8 tests) — add/list/get; concurrent adds under a thread; mapper functions; filter / sort / paginate; distinct providers; activity derivation.
- `test_api_gets.py` (~12 tests) — one happy-path + one filter test per new GET endpoint (6 endpoints × 2 = 12); plus one 404 test for `/api/batches/{id}`.
- `test_api_streaming.py` (1 test) — `Accept: application/x-ndjson` on `/api/claims` returns parseable lines.
- `test_api_parse_persists.py` (2 tests) — parse-837 followed by `GET /api/claims` returns the claim; failed parse does *not* create a batch.
Total new backend tests: **~23**.
### 10.2 Frontend (`src/`)
- `src/lib/api.test.ts` — extend with 3 tests: `listClaims` builds the right URL with params, `listBatches` hits `/api/batches`, `getBatch` returns `ParseResult835` when `kind === "835"`.
- No DOM / component tests in this sub-project. The page refactors are small and the smoke test below is the integration check.
### 10.3 End-to-end smoke test (manual, documented in the plan)
```
# Terminal 1
cd backend && .venv/bin/python -m cyclone serve
# Terminal 2
npm run dev
# In a third terminal
cd backend && .venv/bin/python -m cyclone.cli parse-837 tests/fixtures/co_medicaid_837p.txt --output-dir /tmp/co-837
# (this populates a real production-like batch; we don't need the JSON files for the test, just the upload flow)
# In a browser: open http://localhost:5173/upload, drop tests/fixtures/co_medicaid_837p.txt
# Verify: claims stream in, summary toast appears, navigate to /claims and see them
```
## 11. Migration / rollout
- No DB, so no migration.
- The frontend's existing `data` adapter stays during the transition. We delete it in a follow-up once no one calls it.
- The new `InMemoryStore` is empty on first run. Existing parse-837/parse-835 callers see no behavior change (they still get the parse result; we just *also* add to the store now).
- A uvicorn restart wipes the store. That is by design and called out in the README.
## 12. Out of scope (explicitly)
- DB persistence, Alembic migrations, transactional safety.
- 837P ↔ 835 reconciliation (matching payouts to original claims).
- 999 ACK, 270/271, or other X12 transaction types.
- More 837P validation rules beyond what already ships.
- 835 CAS deep-parsing with reason-code explanations.
- Auth (any kind: API key, JWT, session).
- Rate limiting, request size limits beyond FastAPI defaults.
- Structured logging, log levels via env, JSON logs.
- Health-check enhancements (uptime, parser state, last-batch timestamp).
- 12-factor env config beyond `CYCLONE_PORT` and `CYCLONE_RELOAD`.
- Docker / docker-compose / any deploy artifact.
- Pre-commit hooks, Makefile, .editorconfig, CONTRIBUTING.md, lint config.
- Component tests for the React pages.
- E2E browser tests (Playwright/Cypress).
## 13. Acceptance checklist
- [ ] `pytest` passes with the new store + GET tests; total ≥ 121 + 23 = 144.
- [ ] `npm run build` still passes.
- [ ] `python -m cyclone serve` binds to `127.0.0.1:8000` and refuses connections from non-loopback.
- [ ] Upload page streams a 2-claim CO fixture; navigating to `/claims` shows the same 2 claims.
- [ ] Filtering by `status=submitted` on `/api/claims` returns the right subset; UI shows the same.
- [ ] `GET /api/claims` with `Accept: application/x-ndjson` returns valid NDJSON.
- [ ] Restarting the backend wipes the in-memory store; the UI shows an empty state on first refresh.
- [ ] All 4 reference notes exist under `docs/reference/` and the root `README.md` is rewritten.
- [ ] The frontend still works without a backend (`VITE_API_BASE_URL` empty) — pages fall back to the zustand sample store via the existing `data` adapter.
### 13.1 Visual / aesthetic acceptance checks
- [ ] **Skeleton** uses the scanline-shimmer pattern, not a generic `animate-pulse` on a flat gray block.
- [ ] **Empty state** on every refactored page uses the `EmptyState` primitive with an all-caps instrument-label eyebrow (e.g., "Claims · inbox idle"), not a plain text line.
- [ ] **Error state** on every refactored page uses the `ErrorState` primitive with a destructive-tinted hairline border, an `AlertCircle` icon, the error message, and a `Retry` button — not a full-page takeover.
- [ ] **Refetch indicator** at the top of `<main>` appears (1px hairline, accent color, scanning left-to-right) when *any* query is fetching in the background and disappears when the fetch completes.
- [ ] **Active filter chips** appear on the Claims page above the table whenever a filter is set, and the `×` button on each chip clears that single filter.
- [ ] **Newly-streamed row highlight** flashes the new rows in any list for ~2 seconds after a successful parse (accent-tinted background fade to transparent).
- [ ] **Status badges** for the new mapper output (`draft|submitted|accepted|denied|paid|pending` and `received|posted|reconciled`) use the *existing* palette and existing `ClaimStatusBadge` / `RemitStatusBadge` components — no new colors, no new components.
- [ ] **Pagination footer** on list pages uses the new `Pagination` primitive with ` 1 2 3 … N ` and the `returned / total / has_more` summary; active page echoes the sidebar `nav-active` 1px accent line.
- [ ] **No new accent color, no new typeface, no new layout primitive, no light theme** — the page refactors and the new UX patterns sit inside the existing aesthetic voice.