Brings the SP5 live-tail implementation into main:
Backend
* EventBus (cyclone.pubsub): per-kind fan-out with drop-oldest overflow
* FastAPI lifespan initializes the bus + db once per process
* store.add() publishes claim_written / remittance_written /
activity_recorded events on every batch write
* GET /api/{claims,remittances,activity}/stream: NDJSON snapshot +
live subscription + 15s idle heartbeat
* EventBus.unsubscribe() lets the tail loop release its queue on
client disconnect (no queue leak per open stream)
Frontend
* src/lib/tail-stream.ts: streamTail() async-generator over fetch
* src/store/tail-store.ts: zustand with FIFO cap 10k per slice
* src/hooks/useTailStream.ts: connecting/live/reconnecting/stalled/error/closed
state machine with 1→2→4→8→16→30s backoff
* src/hooks/useMergedTail.ts: base + tail merge with filter
* src/components/TailStatusPill.tsx: badge + Reconnect button
* Claims, Remittances, ActivityLog pages wired to the tail
Tests
* 437 backend tests pass (was 418 before SP5)
* 154 frontend tests pass (was 124)
* npm run typecheck clean
* end-to-end smoke: open /api/claims/stream, POST 837, see new claims
arrive in real time without refresh
# Conflicts:
# src/pages/ActivityLog.tsx
# src/pages/Remittances.test.tsx
# src/pages/Remittances.tsx
The Reconciliation page crashed with 'c.chargeAmount.toFixed is undefined' because
the TS types declared fields the backend never emitted (chargeAmount vs billedAmount,
patientControlNumber vs patientName, statusCode vs status, totalPaid vs paidAmount).
- UnmatchedClaim: chargeAmount -> billedAmount, patientControlNumber -> patientName,
payerId/serviceDate now optional (backend omits them)
- UnmatchedRemittance: statusCode -> status, totalPaid -> paidAmount,
totalCharge/serviceDate/isReversal now optional
- Reconciliation page + test fixtures updated to use the wire shape
- Tests: 124/124 pass
- RawSegmentsPanel: summary now reads 'Show N raw segments' for
non-empty (with singular pluralization). The previous identical
ternary branches read as a bug.
- PartiesGrid: replace the verbose intersection cast on AddressBlock
with a direct ClaimDetailAddress cast (the union's other arm is
Record<string, never>, ruled out by isEmptyAddress above).
ClaimDrawerError.tsx already wraps its error surface with role=alert
(line 36). Apply the same pattern to the ValidationPanel errors
sub-section so assistive tech announces blocking validation failures
when the drawer opens, matching the established sibling pattern.
Warnings stay decorative (no role change) — they're advisories, not
blocks.
The parametrized variant test only exercised 4 of the 6 mapped states
(submitted/denied/paid/pending). Add accepted (-> default) and matched
(-> success) so the entire STATE_VARIANT map is verified, not just the
branches whose tokens happened to be easy to reach.
- npm run typecheck failed on the 0-arg vi.fn<() => void>() because
mock.calls[0] became an empty tuple, so calls[0][2] (the URL arg)
was typed as undefined.
- Constrain the mock to the real (state, unused, url?) signature so
mock.calls[0] is a 3-tuple and [2] is string-or-URL-or-null. Drops
the as-string cast at every callsite.
- readClaimId: URLSearchParams.get returns '' for ?claim=, not null.
Normalize to null so the contract matches the JSDoc and consumers
can treat empty as absent.
- Tests: regression for ?claim= → null; open/close preserves other
query params (?foo=bar survives open/close unchanged).
- Tighten pushStateMock/replaceStateMock typing to vi.fn<() => void>()
per code-review nit (drops the verbose ReturnType<typeof vi.fn>).