- 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
34 KiB
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:
- Start the backend (
python -m cyclone serve). - Start the Vite dev server (
npm run dev). - Open
http://localhost:5173/upload, drop in a.txtfile, watch the claims stream in. - Navigate to
/claims,/remittances,/providers,/activityand see the data they just parsed. - The whole stack is bound to
127.0.0.1; no auth, no internet exposure.
2. Goals
- Persist parsed data for the session. A new
InMemoryStoreon 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. - Expose GET endpoints that match the UI's resource model.
GET /api/claims,/api/remittances,/api/providers,/api/activityplus/api/batchesand/api/batches/{id}. The list endpoints accept filter / sort / pagination query params and support both JSON and NDJSON streaming. - Wire the existing 4 UI pages to the live API via
@tanstack/react-queryv5, with loading skeletons, error states, and automatic invalidation after a successful parse. Fall back to the in-memory sample store whenVITE_API_BASE_URLis empty (the existingdataadapter pattern stays). - Replace the lost reference notes with 4 fresh, short notes under
docs/reference/(~50–80 lines each) covering 837P, 835, X12 naming, and CO Medicaid specifics. Also rewrite the rootREADME.md. - Tighten the local-only deploy posture.
python -m cyclone servebinds127.0.0.1:8000(not0.0.0.0); CORS allowlist stayshttp://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.Lockaround the store's batch list (single-process, but FastAPI may run request handlers in a threadpool).pytest+fastapi.testclient.TestClientfor new tests.
Frontend additions:
@tanstack/react-queryv5 added topackage.jsondependencies.QueryClientProviderinsrc/main.tsx.useQuery/useMutationhooks undersrc/hooks/.SkeletonUI primitive added undersrc/components/ui/(re-usescnhelper).- 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:
- User drops a file in
/upload, clicks Parse. - Frontend
useMutationPOSTs to/api/parse-837(or 835) withonProgress(NDJSON) orAccept: application/jsonfor small files. - Backend's existing parse route appends the resulting
ParseResulttoInMemoryStoreon success. Failures are not stored. useMutation.onSuccesscallsqueryClient.invalidateQueries(['batches'], ['claims'], ['remittances'], ['activity'])(and 837/835-specific keys).- Any open page reactively re-fetches via
useQueryand re-renders with the new data.
Data flow on browse:
- Page mount →
useQueryfires the GET with the page's filter / sort / pagination params. - First render: skeleton. Success: data. Error: error block with retry.
- Background:
refetchOnWindowFocus: true(default in v5) so navigating back updates the view.
6. Backend changes
6.1 New module backend/src/cyclone/store.py
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.passedandfrequency_code == "1"→submittedvalidation.passedand anyvalidation.warnings→pending!validation.passedand no error mentionsR050_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) →
receivedwith adenialReasonpopulated from the CAS segments - 21, 22 (reversal of previous payment) →
reconciled(out-of-scope: full reversal handling; the data is inservice_payments[*].adjustments) - all other valid codes →
received(default) - any code outside
cfg.allowed_status_codes→ still stored, mapped toreceived, and surfaced as a validation warning
6.3 Existing parse routes — wire to the store
POST /api/parse-837— on success, build aBatchRecord(kind="837p", result=result)and callstore.add(...)before returning the response. OnCycloneParseErroror any unhandled exception, do not add.POST /api/parse-835— same pattern withkind="835".- Both routes also stamp
parsed_atand generateid = uuid.uuid4().hex.
6.4 Uvicorn invocation
cyclone/__main__.py's serve branch:
sys.argv = [sys.argv[0], "cyclone.api:app", "--host", "127.0.0.1", "--port", "8000"]
--host 127.0.0.1(not0.0.0.0).--port 8000(configurable viaCYCLONE_PORTenv, default8000).--reloadonly whenCYCLONE_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:
"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>):
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
// 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.
// 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 3–5 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.
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?").
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.
// 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.
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.
// 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/STheaders,SE/GE/IEAfooters. - 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_segmentsbut not modeled): too many to enumerate; see the parser source for the full list. - CO Medicaid specifics:
REF*G1prior-auth, no patient loop,CLM05shape12:B:1etc.
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:
BPR1081-1725341 (TXIX) or 84-0644739 (BHA),N1047912900843,N1*PRnameCO_TXIXorCO_BHA.
8.3 docs/reference/x12naming.md (~50 lines)
- Segments: 2–3 letter codes (
CLM,NM1,BPR). - Elements:
*-separated, 1-indexed. - Composite elements:
:-separated sub-elements, indexed01,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".
- 837P:
- 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 serveandnpm run dev. Openhttp://localhost:5173. - Test —
cd backend && .venv/bin/pytestandnpm 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 2–4 with one-line descriptions and a note that they're not in this build.
- License — no license file yet; add a
LICENSEfile 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.resultdoesn't conform (e.g. we tried to store a result from a code path that returnsNone), the model validator fails loudly at insertion time. - GET route errors:
404for missingbatch_id;400for invalid query params (FastAPI's default validation handles this);500only 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:
useQueryexposesisError+error. Each page renders an error block with a retry button that callsrefetch(). 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-ndjsonon/api/claimsreturns parseable lines.test_api_parse_persists.py(2 tests) — parse-837 followed byGET /api/claimsreturns 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:listClaimsbuilds the right URL with params,listBatcheshits/api/batches,getBatchreturnsParseResult835whenkind === "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
dataadapter stays during the transition. We delete it in a follow-up once no one calls it. - The new
InMemoryStoreis 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_PORTandCYCLONE_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
pytestpasses with the new store + GET tests; total ≥ 121 + 23 = 144.npm run buildstill passes.python -m cyclone servebinds to127.0.0.1:8000and refuses connections from non-loopback.- Upload page streams a 2-claim CO fixture; navigating to
/claimsshows the same 2 claims. - Filtering by
status=submittedon/api/claimsreturns the right subset; UI shows the same. GET /api/claimswithAccept: application/x-ndjsonreturns 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 rootREADME.mdis rewritten. - The frontend still works without a backend (
VITE_API_BASE_URLempty) — pages fall back to the zustand sample store via the existingdataadapter.
13.1 Visual / aesthetic acceptance checks
- Skeleton uses the scanline-shimmer pattern, not a generic
animate-pulseon a flat gray block. - Empty state on every refactored page uses the
EmptyStateprimitive 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
ErrorStateprimitive with a destructive-tinted hairline border, anAlertCircleicon, the error message, and aRetrybutton — 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|pendingandreceived|posted|reconciled) use the existing palette and existingClaimStatusBadge/RemitStatusBadgecomponents — no new colors, no new components. - Pagination footer on list pages uses the new
Paginationprimitive with‹ 1 2 3 … N ›and thereturned / total / has_moresummary; active page echoes the sidebarnav-active1px 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.