Commit Graph

34 Commits

Author SHA1 Message Date
Nora 59c3275adf feat(sp27): server-aggregate Dashboard KPIs so 100-row sample doesn't lie
The Dashboard was hardcoded to useClaims({ limit: 100 }) and reduce
KPIs client-side. With 60k+ claims in production, every tile
(Billed $940K, Received $59K, Denial rate, Pending AR, monthly
sparkline, top providers, recent denials) was computed from a
0.16% sample of the dataset — silently wrong numbers on the
operator's primary view.

Fix: add GET /api/dashboard/kpis that aggregates server-side in
one read over the entire claim population. The new useDashboardKpis
hook consumes it and polls every 60s. Dashboard.tsx drops
useClaims({limit:100}) + useProviders() + the client-side
buildMonthly reduce.

Backend:
- store.py: dashboard_kpis() — one Claim query (selectinload on
  batch to avoid N+1) + one bulk Remittance lookup, Python reduce
  over the full population. Zero-filled response for empty DB.
- api.py: GET /api/dashboard/kpis behind matrix_gate, query-param
  clamps (1..24 months, 0..50 top_n_*).

Frontend:
- api.ts: DashboardKpis types + getDashboardKpis() wrapper.
- useDashboardKpis.ts: TanStack Query hook, 60s refetchInterval,
  bypass to data:undefined when not configured.
- Dashboard.tsx: switched to useDashboardKpis, extracted
  ZERO_TOTALS constant, dropped the buildMonthly helper.

Tests:
- backend/tests/test_dashboard_kpis.py: 12 tests covering empty DB,
  matched-remit math, pending-state semantics, monthly binning,
  top-providers/top-denials sort + cap, orphan-claim defensive
  guard, HTTP wiring + param validation.
- src/hooks/useDashboardKpis.test.ts: 3 tests for the hook
  contract (configured path, unconfigured fallback, param
  passthrough).
- src/pages/Dashboard.test.tsx: wrapped renders in
  QueryClientProvider + stubbed useAuth + isConfigured=false. This
  fixes 3 pre-existing Dashboard test failures (the page never had
  a QueryClient set up because useClaims/useProviders were the
  first useQuery hooks in the page).

Reviewer fixes (same commit):
1. topDenials sort placed empty submissionDate claims first under
   reverse-lex. Drop them at append time.
2. r.batch lazy-load → N+1 on 60k rows. selectinload(Claim.batch).
3. pending_states rebuilt per call as a mutable set — moved to
   module-level _DASHBOARD_PENDING_STATES frozenset.
4. Module-level ProviderORM import inconsistent with the
   "local import inside the function" pattern — moved inline.
2026-06-29 12:51:28 -06:00
Nora 4c05c6527b hotfix(acks): paginate /api/acks and surface server-side aggregates
The Acks page silently capped at 100 of 1056 rows in the operator's
DB: the eyebrow read `${items.length} on file` (not the server's
`total`), the KPI strip summed from the 100 visible items, and the
endpoint accepted no `offset` — so the user had no signal that 956
more acks existed. Same class of bug on the Activity page (cap=200,
no 'X of Y' hint).

Backend
- /api/acks (acks.py): add `offset`, bump `le=1000`→`le=5000`,
  slice `rows[offset:offset+limit]`, return server-side
  `aggregates` (accepted/rejected/received summed over the full
  row set, not the page) so the KPI strip reflects every persisted
  999 instead of just the visible 50.
- /api/activity list + stream (api.py): bump `le=500`→`le=5000` so
  the page can ask for a denser snapshot.
- /api/277ca-acks (api.py): bump `le=1000`→`le=5000` for
  consistency.
- /api/ta1-acks: left at `le=1000` — TA1s aren't shipped today and
  the structural fix (offset + aggregates) wasn't applied, so a
  larger cap would just make the same latent silent-failure easier
  to hit. (TODO: fold in the same shape when Gainwell starts
  shipping TA1s.)

Frontend
- listAcks (api.ts): accept `offset`, surface `aggregates`,
  adapt wire `*_count` keys to the in-page
  `accepted`/`rejected`/`received` shape so the page can use
  `data.aggregates` as a drop-in for the page-local fallback
  accumulator.
- useAcks (hooks): pass `offset` through; return type carries
  `aggregates`.
- Acks.tsx: add `page` state (PAGE_SIZE=50), use `data.total`
  for eyebrow + watermark (not `items.length`), use
  `data.aggregates` for the KPI strip (with in-page fallback
  accumulator on first paint), render `<Pagination>` when
  `totalCount > PAGE_SIZE`. Footer row reads "N rows on file"
  instead of "N rows".
- ActivityLog.tsx: bump `limit: 200`→`limit: 500`, eyebrow reads
  "Activity · showing N most recent" to make the bounded-window
  semantics honest (the endpoint doesn't expose a true total — it
  reports events matching the current kind/since filter, capped at
  the request limit).

Tests
- test_acks.py: 4 new tests pin the fix:
  1. `offset` walks the full set; `has_more` flips at the
     boundary.
  2. `aggregates` reflects the full row set, not the page (the
     silent-failure pin) — and stays stable across page slices.
  3. `limit` cap of 5000 is enforced (422 above it).
  4. `offset` past the end returns an empty page with stable
     aggregates (a stale UI page state across a row count change
     must not 500 or zero the KPIs).

Live smoke-verified: /api/acks?limit=2&offset=0 vs ?offset=2 return
the expected row slices, aggregates stable at 15/10/15 for 5 seeded
rows, /api/acks?limit=10000 rejected with 422.

Triage note: the TA1 section (`Ta1AcksSection`, lines 609-616 of
Acks.tsx) has the same latent silent-failure pattern (page-sums
KPIs, no offset on /api/ta1-acks). Left untouched because the
empty-state copy says Gainwell doesn't ship TA1s today and the
larger structural fix belongs in a follow-up.
2026-06-29 11:19:10 -06:00
Nora 6507a8c874 fix(acks): accept IK5 from Gainwell, trust set-level codes over bogus AK9, surface TA1 in UI
Two related fixes land together because the UI was reporting
"1 accepted 1 rejected" for every 999 even though every inbound file
Gainwell ships has IK5=A.

  1. Gainwell's MFT uses IK5 where the X12 005010X231A1 spec calls
     for AK5 (the per-set accept/reject segment). The parser only
     recognized AK5, so set_responses[0].set_accept_reject.code
     defaulted to 'R' and the count summary showed all rejections.
     _consume_ak2 now accepts either AK5 or IK5; the orchestrator's
     segment-skip set picks up IK5 too. A new fixture
     (minimal_999_ik5_gainwell.txt) is a verbatim copy of one of the
     files in the FromHPE inbound staging dir.

  2. _ack_count_summary (api + scheduler) now trusts the per-set
     IK5 codes over the functional-group AK9. Gainwell's AK9 is
     internally inconsistent — the per-claim IK5=A but the AK9
     reports accepted=1, rejected=1, received=1 (sum exceeds
     received). Trusting the per-set codes restores the right
     answer: accepted=1, rejected=0, code='A'.

  3. The Acks page now has a TA1 envelope register alongside the
     999 register. TA1s are the lower-level sibling of the 999
     (one row per inbound ISA/IEA). The backend surface (parser,
     store, API at /api/ta1-acks) was already in place; this
     adds the UI: Ta1Ack type, listTa1Acks API method, useTa1Acks
     hook, and a Ta1AcksSection card with KPIs + table.

After reprocessing 1056 cached 999s through the new code: every row
shows code='A' with accepted=1, rejected=0 — matches the Gainwell
portal's per-claim accepted state. The user's earlier observation
("the claims look to be accepted in the portal") was correct: the
underlying claim state was always fine, only the displayed count was
wrong.

  - backend/src/cyclone/parsers/parse_999.py | 21 ++-
  - backend/src/cyclone/api.py               | 11 +-
  - backend/src/cyclone/scheduler.py         | 13 +-
  - backend/tests/test_parse_999.py          | 32 ++++
  - backend/tests/fixtures/minimal_999_ik5_gainwell.txt
  - src/types/index.ts                       | 32 ++++
  - src/lib/api.ts                           | 62 +++++-
  - src/hooks/useTa1Acks.ts                  | 26 +++ (new)
  - src/pages/Acks.tsx                       | 209 +++++++++++++++++++-
2026-06-25 00:26:13 -06:00
Tyler 9bca4b608a feat(release): v0.2.0 — batch 837 export, ClaimCard, theme tokens
Backend:

- New POST /api/batches/{id}/export-837: regenerate X12 837 files
  for a list of claim_ids into a ZIP using HCPF file naming standards,
  with a unique interchange/group control number per export. Wire
  the clearhouse Loop 1000A (NM1*41 + PER) and per-payer receiver
  (NM1*40) blocks so the serializer no longer falls back to
  CYCLONE / RECEIVER placeholders.
- /api/parse-837 and /api/parse-835 now surface the server-side
  batch_id in both JSON and NDJSON response shapes so the frontend
  can hit batch-scoped endpoints without an extra listBatches
  round-trip.
- Filename helpers and the 837 serializer updated to match the new
  HCPF envelope; tests cover batch export, parse batch_id, and the
  serializer's control-number uniqueness guarantee.

Frontend:
- New shared components: ClaimCard, ClaimCard837, DominantKpiCard,
  EditorialNote, ExportBar, TickerTape, and a charts/ set
  (BarChart, HBarChart, SegmentedBar, AgingBars).
- New useBatchExport hook driving ExportBar's download flow against
  the new endpoint.
- ClaimDrawer, Lane, and Layout migrated from raw CSS-variable
  colors to Tailwind theme tokens (bg-card, text-foreground,
  border/60, etc.) for consistency with the rest of the instrument
  chrome; the active tab indicator gains a subtle accent glow.
- Upload, Inbox, Batches, BatchDiff, Reconciliation, and Acks pages
  reworked to compose the new shared components and consume the new
  batch-scoped API surface (notably ExportBar wired into Batches).

Tooling / Docs:
- Add audit-uiux.mjs and a docs/goodclaim.x12 sample fixture.
- Update ClaimDrawer testids and add coverage for the new
  components and the useBatchExport hook.

Rolls up into the v0.2.0 release tag.
2026-06-22 11:01:58 -06:00
Tyler 33fa899217 feat(drill): AckDrawer with header + segment status list 2026-06-21 18:10:43 -06:00
Tyler 6fdbceefc2 feat(drill): useAckDrawerUrlState — ?ack= URL sync 2026-06-21 18:10:43 -06:00
Tyler 1db6b8841c fix(drill): ProviderDrawer quality pass — error branching, demo fallback, flex layout
Quality-fix iteration on 078c9ad. Resolves the 4 Important issues
flagged in the code review while preserving everything that's working.

Issue 1 — ProviderDrawer silently swallowed error states (infinite
skeleton on any failure). Now mirrors ClaimDrawer/RemitDrawer:
  - Destructure { data, isLoading, isError, error, refetch }
  - Compute errorKind = ApiError(404) → not_found, else → network
  - New ProviderDrawerError.tsx renders the right copy + retry/close
  - Body branches on errorKind → loading → data

Issue 2 — useProviderDetail did not match the useClaimDetail contract:
  - 404 retry guard (no retries on 404, <=3 on everything else)
  - Explicit typed return shape { data, isLoading, isError, error,
    refetch }
  - npi === null short-circuit after the useQuery call

Issue 3 — Provider drilldown was non-functional in demo mode. Added
the useSyncExternalStore fallback to useAppStore.providers (same
pattern as useProviders), gated by api.isConfigured. Unknown NPI in
demo mode surfaces via the error branch instead of an infinite
skeleton.

Issue 4 — Test was shape-only (1 case). Rewrote with vi.hoisted +
vi.fn() hook mock and 7 cases covering null/loading/404/network/
close/success/hook-call-shape.

Issue 5 — Replaced h-[calc(100%-64px)] with a flex layout
(flex h-full flex-col on DialogContent, flex h-full flex-col
overflow-y-auto on inner wrappers). Mirrors RemitDrawer's pattern;
no more coupling to DrillDrawerHeader's padding/font sizes.

Issue 7 — Removed the dead npi === null ? null : (...) wrapper
inside DialogContent; Dialog open={npi !== null} already gates it.

Issue 8 — Trailing newlines added across all touched + created files.

Self-review: tsc clean on changed files; drawer test suite 211/211
passing; full test suite 369/372 (3 pre-existing Inbox/Reconciliation
failures unchanged). ProviderDrawer now 7/7.
2026-06-21 16:41:15 -06:00
Tyler 0678e25de7 feat(drill): ProviderDrawer with Overview tab + DrillDrawerHeader shell 2026-06-21 16:41:15 -06:00
Tyler 5b3b8619e6 feat(drill): useProviderDrawerUrlState — provider drawer URL state (mirrors remit hook) 2026-06-21 16:41:15 -06:00
Tyler 50dc0b2fb3 feat(drill): PayerPeekContent + usePayerSummary + api.getPayerSummary 2026-06-21 14:28:49 -06:00
Tyler 5c9365ec33 feat(sp14): 5-lane Inbox UI with Payer-Rejected acknowledge action
Closes the gap between the SP10 backend (5 lanes) and the SP6
frontend (4 lanes). The Payer-Rejected lane (277CA STC A4/A6/A7)
is now rendered alongside Rejected/Candidates/Unmatched/Done,
with an Acknowledge bulk action that drops claims from the
working surface without erasing the original 277CA rejection
event (audit log stays intact, SP11).

Backend:
* Migration 0010: add payer_rejected_acknowledged_at +
  payer_rejected_acknowledged_actor columns + partial index.
* db.py: surface the two new columns on the Claim model.
* inbox_lanes.py: filter acknowledged claims out of the
  payer_rejected lane; expose the new fields on the row payload
  for forward-compat (e.g. a future 'Recently acknowledged' view).
* api.py:
  - POST /api/inbox/payer-rejected/acknowledge
    Bulk-acknowledge. Idempotent. Returns transitioned /
    already_acked / not_found / not_rejected counts so the UI
    can show '3 of 5 were already acknowledged' on a noop bulk.
    Writes a 'claim.payer_rejected_acknowledged' event to the
    SP11 hash-chained audit log.
  - GET /api/inbox/export.csv: accept 'payer_rejected' lane.
* test_acks.py: bump user_version assertion to 10.
* test_lane_filter_acknowledged.py: 4 tests for the lane filter
  and forward-compat row payload.
* test_payer_rejected_acknowledge.py: 6 tests for the endpoint
  (happy path, idempotency, no-op on non-rejected, missing
  ids, 400 on empty, audit-log wiring + chain integrity).

Frontend:
* lib/inbox-api.ts: add payer_rejected to InboxLanes, add
  acknowledgePayerRejected(), update exportInboxCsvUrl union.
* hooks/useInboxLanes.ts: add payer_rejected to initial state.
* hooks/useInboxLanes.test.ts: add payer_rejected to mocks.
* components/inbox/BulkBar.tsx: add 'payer_rejected' lane with
  Acknowledge action (no Resubmit, no Dismiss — payer-rejected
  is not eligible for either).
* components/inbox/BulkBar.test.tsx: add payer_rejected test.
* pages/Inbox.tsx: render the 5th lane, hook up onAcknowledge,
  include payer_rejected in the needEyes count.
* pages/Inbox.test.tsx: 3 new tests (5-lane render, need-eyes
  count, acknowledge action hits the right endpoint).
* components/inbox/InboxHeader.tsx: doc comment now explains
  why payer_rejected rolls up into need-eyes.

Pre-existing typecheck warnings in BulkBar.test.tsx / InboxRow
.test.tsx / Lane.tsx / download.test.ts are unchanged from
main — not touched here.

Test counts: backend 724 -> 734 (+10). Frontend 350 -> 354 (+4).
2026-06-21 00:13:47 -06:00
Tyler 0b8f253186 feat(sp7): wire LineReconciliationTab into ClaimDrawer 2026-06-20 19:51:04 -06:00
Tyler fb8eba9bba feat(sp6): useInboxLanes refetches on SP5 tail events 2026-06-20 18:46:08 -06:00
Tyler b607e4c459 feat(sp6): Inbox page + useInboxLanes hook 2026-06-20 18:41:44 -06:00
Tyler f4a7cd99b3 merge: ui/batch-diff 2026-06-20 17:30:30 -06:00
Tyler 5baa0290f0 merge: ui/remit-drawer 2026-06-20 17:30:09 -06:00
Tyler 19fb675007 merge: ui/global-search 2026-06-20 17:29:45 -06:00
Tyler 1359b0753a merge: SP5 live-tail — pub/sub + 3 stream endpoints + frontend tail integration
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
2026-06-20 17:28:58 -06:00
Tyler a79668192d feat(frontend): global Cmd-K search across claims/remits/activity 2026-06-20 17:25:40 -06:00
Tyler 7b394fff1a feat(batch-diff): side-by-side claim diff between two batches 2026-06-20 17:17:09 -06:00
Tyler 25d47a5621 feat(frontend): RemitDrawer component with CAS adjustments + parties + claim payments 2026-06-20 17:16:23 -06:00
Tyler e56a1eb503 feat(frontend): useMergedTail merges base + tail with filter 2026-06-20 17:05:24 -06:00
Tyler 0e766ce654 feat(frontend): useTailStream lifecycle hook with backoff + stall detection 2026-06-20 17:03:36 -06:00
Tyler 8f419cdeba feat(frontend): j/k row nav + ? cheatsheet on Acks and Remittances 2026-06-20 16:57:22 -06:00
Tyler 24d13faca7 fix(frontend): type pushState mocks with real History.pushState signature
- 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.
2026-06-20 10:55:03 -06:00
Tyler e7469d39ec fix(frontend): normalize empty ?claim= to null; preserve sibling params
- 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>).
2026-06-20 10:52:42 -06:00
Tyler 234cd8b28b feat(frontend): useDrawerUrlState hook for ?claim= sync 2026-06-20 10:39:53 -06:00
Tyler c737bd742d feat(frontend): useDrawerKeyboard hook for j/k/esc/? 2026-06-20 10:23:40 -06:00
Tyler 1dfd4b58c0 feat(frontend): useClaimDetail hook with null-id short-circuit 2026-06-20 10:12:10 -06:00
Tyler fb2a98fc7a feat(parsers+api+ui+db): 999 ACK transaction set end-to-end (SP3 P3) 2026-06-20 08:03:37 -06:00
Tyler e8c251f9ca feat(frontend): useReconciliation hook with react-query invalidation cascade 2026-06-19 23:58:00 -06:00
Tyler 87cd51e1fc fix(frontend): replay row-flash on every refetch via dataUpdatedAt key
The previous static key={c.id} let React reuse the same <TableRow>
element across refetches, so animate-row-flash only played on
initial mount and on the first render of a brand-new claim_id.
Updated rows whose claim_id was unchanged never re-flashed.

Re-keying on `${c.id}-${dataUpdatedAt}` re-mounts the row on
every refetch (initial load, filter change, parse invalidation),
so the 1.2s accent-tint flash replays each time.

Also: README's 'Frontend unit tests (when added)' is stale — npm
test now exists and runs 3 passing tests.

hooks: add dataUpdatedAt: 0 to the !isConfigured fallback return
so the pages type-check.
2026-06-19 20:00:05 -06:00
Tyler d7bd061ee0 feat(frontend): add 6 query/mutation hooks (batches, claims, remits, providers, activity, parse) 2026-06-19 19:41:42 -06:00
Tyler 3d3bdfb07c feat(frontend): wire Vite app to FastAPI parser with NDJSON streaming + Upload page 2026-06-19 17:31:59 -06:00