Commit Graph

679 Commits

Author SHA1 Message Date
Nora d44948cb90 docs(spec): SP32 update D10/testing for single-NPI per Remit 2026-07-02 16:51:18 -06:00
Nora 9e2dde6ddb docs(spec): SP32 simplify D4/D6 — single-NPI per Remit, no comma-aggregation 2026-07-02 16:51:12 -06:00
Nora 2d40fbbcbb docs(spec): SP32 rendering & service-provider NPI extraction 2026-07-02 16:43:14 -06:00
Nora a2ec65e5c6 merge: SP31 835 strict content-match auto-link into main 2026-07-02 16:25:48 -06:00
Nora 18fa119ff7 feat(sp31): expose keys_matched/candidate_count in Match dataclass and ActivityEvent payload, fix broken pre-existing test 2026-07-02 16:23:42 -06:00
Nora ab91449e62 feat(sp31): integrate content-keys fallback into reconcile.run() with auto_matched_835 event 2026-07-02 15:58:01 -06:00
Nora 9bade2429c fix(sp31): read NPI and charge from raw_json when ORM attribute missing 2026-07-02 15:27:32 -06:00
Nora 5a54961930 feat(sp31): add _score_fallback_candidates DB helper with ±30-day candidate pool 2026-07-02 15:17:26 -06:00
Nora b484ca36dc feat(sp31): add _content_keys_match pure helper (2-of-3 PCN/charge/NPI rule) 2026-07-02 15:04:48 -06:00
Nora 75a2800a9d refactor(sp31): rename Match strategy 'auto' to 'pcn-exact' for audit clarity 2026-07-02 14:59:49 -06:00
Nora 6624a0bafd docs(plan): SP31 835 strict content-match auto-link 2026-07-02 14:56:43 -06:00
Nora a12104fb0f docs(spec): SP31 835 strict content-match auto-link 2026-07-02 14:53:48 -06:00
Nora edee0a6259 merge: SP30 Dashboard Recent batches widget into main 2026-07-02 14:18:59 -06:00
Nora 97512ec4a7 feat(sp30): Dashboard Recent batches widget with billing outcome
- backend: GET /api/batches now returns acceptedCount/rejectedCount/
  pendingCount/billedTotal/topRejectionReason/hasProblem per item
  (one GROUP BY query + one ordered scan, no N+1)
- backend: 2 tests pin the 837p full-bucket case + the 835 zero case
- frontend: BatchSummary extended with 6 optional fields
  (backwards compat preserved)
- frontend: new RecentBatchesWidget renders one row per batch with
  status icon + billed total + accepted count + top rejection reason
- frontend: 837p rows show $ total + 'N/M accepted'; 835 rows show
  payment count (Remittance has no batch-level total_charge)
- frontend: full-width row between KPI tiles and Activity on the
  Dashboard; click navigates to /batches?batch=ID
- frontend: 3 component tests cover empty/clean/problem/835 branches
2026-07-02 14:18:55 -06:00
Nora bde3060e9e docs(spec,plan): SP30 Dashboard Recent batches widget 2026-07-02 14:08:30 -06:00
Nora 1d2572e624 merge: SP29 Inbox 999-rejected drill into main 2026-07-02 13:16:40 -06:00
Nora ffacfd8665 feat(sp29): inbox rejected-row 999 ack chips + per-row Resubmit
- backend: _ack_summary_for_claims helper attaches claim_acks payload
  to inbox rejected-lane rows (3 tests)
- frontend: InboxRow renders newest 3 AK2 chips + '+N more' overflow
  under the rejection reason, with '999 not linked' marker when an
  ack couldn't be linked to a claim
- frontend: per-row Resubmit button downloads a single corrected 837
  via api.serializeClaim837 (no bulk modal, no zip — one click, one
  .x12 file)
- frontend: 2 new tests for the chip rendering and the per-row
  download flow
2026-07-02 13:16:38 -06:00
Nora 9cc13e7940 docs(spec,plan): SP29 Inbox 999-rejected drill 2026-07-02 13:06:21 -06:00
Nora 37caf6af77 docs(plan): SP28 follow-up notes — Batch.kind fix + one-time backfill 2026-07-02 12:31:37 -06:00
Nora 02b879a204 merge: SP28 follow-up — fix Batch.kind '837' vs '837p' mismatch
Hotfix for the SP28 merge. The SP28 helpers (batch_envelope_index +
the two _batch_lookup closures in handle_ta1 / api.py) all filtered
Batch.kind == '837', but prod Batch rows are persisted with kind='837p'
(see api.py:443). Result on prod: claim_acks row count = 0, D10 Pass 1
(ST02 via batch.envelope.control_number) never fired, and the user's
specific claim t991102989o1c120d would not surface its 143 999 acks.

Fix: 4 production sites swapped to '837p'; 3 test files updated to
match the prod value. 25 SP28 tests + 20 handler tests pass post-fix.
Expected prod effect after re-deploy: 727 of 1,398 acks auto-link via
batch.envelope.control_number, plus the user's claim surfaces in
ClaimDrawer for /api/claims/t991102989o1c120d/acks.

Follow-up plan: backfill the existing 1,398 acks via a one-time script
that runs the SP28 helpers against the current DB (D10 join now
resolves the 991102989 ST02 from existing batches).
2026-07-02 12:22:36 -06:00
Nora fba594baf4 fix(sp28): align Batch.kind filter with prod ('837p' not '837')
The SP28 helpers (batch_envelope_index + lookup_claims_for_ack_set_response
+ two _batch_lookup closures in handle_ta1 + api.py) all filter
Batch.kind == "837", but prod Batch rows are persisted with
kind="837p" (lowercase p — see api.py:443 / store/records.py:58).
Result: the D10 batch-envelope index was empty on prod and zero 999 /
277CA / TA1 acks auto-linked to their claims via Pass 1 (ST02 via
batch.envelope.control_number). Pass 2 (PCN) was the only path firing,
which on this codebase matches 0 acks (Gainwell's 999 echoes the 837's
ST02, not its CLM01 — see spec §D10).

Fix: 4 production sites swapped to Batch.kind == "837p". 3 test
files updated to use the prod value (test_apply_claim_ack_links.py +
test_api_claim_acks.py + test_e2e_999_to_claim_drawer.py). All 25 SP28
tests + 20 handler tests pass. Expected prod effect: claim
t991102989o1c120d's 143 incoming 999 AK2 acks now auto-link via the
batch with envelope.control_number=991102989 (Pass 1), plus 727/1,398
total acks across all batches.
2026-07-02 12:22:29 -06:00
Nora 190f4b8c8d merge: SP28 ack-claim auto-link into main
Closes the operator gap where 999 / 277CA / TA1 acks were persisted but
never linked back to the claims they acknowledged. The original PCN-based
join matched 0 / 1,398 acks on prod (Gainwell's 999 echoes the source
837's ST02, not its CLM01); the D10 two-pass join via Batch.envelope.
control_number recovers 727 / 1,398 automatically.

Increment summary:
- New claim_acks join table (migration 0018) with partial unique dedup index
- Pure helpers (apply_claim_ack_links orchestrator + 999/277CA/TA1 paths)
  with the D10 two-pass lookup (Batch.envelope.control_number primary,
  Claim.patient_control_number fallback)
- CycloneStore facade: add_claim_ack, list_acks_for_claim,
  list_claims_for_ack, find_ack_orphans, remove_claim_ack, batch_envelope_index
- Handler integration: handle_999 / handle_277ca / handle_ta1
- 7 new API endpoints (read + stream + manual-match + unlink + orphan lane)
- Frontend: useClaimAcks / useAckClaims hooks, claimAcks live-tail slice,
  AcknowledgmentsPanel in ClaimDrawer, MatchedClaimPanel + manual-match
  dropdown in AckDrawer, Claims badge column on Acks page, Ack-orphans
  lane on Inbox page

Coverage: 727 / 1,398 acks auto-link; 671 remain real orphans (ST02=0001)
surfaced in the Inbox ack-orphans lane.

Auth posture: any logged-in user can run the manual-match endpoint
(D5/D9 deviation from admin-only remit-orphans — ack matches are
metadata-only, no Claim.state mutation).

Test baseline (no new failures):
- Backend: 25 new SP28 tests pass; full suite 1210 passed / 10 skipped /
  1 failed + 6 errors (pre-existing test_payer_summary +
  test_provider_extended_response pollution; all 7 pass in isolation)
- Frontend: 36 new SP28 tests pass; full suite 580 passed / 5 failed
  (pre-existing api.test.ts / tail-stream.test.ts / Inbox.test.tsx /
  InboxHeader.test.tsx baseline)
- Typecheck: 17 pre-existing errors remain; 0 in SP28-introduced files
  (refactor e10d388 fixed 3 pre-existing joinUrl import errors)

Files: 47 changed, +6,509/-23. Refactor e10d388 (joinUrl export) folded in
to keep SP28 merge coherent. 14 SP28 commits on branch preserved as audit
trail.
2026-07-02 12:12:31 -06:00
Nora 9d11ffcd8a docs(plan): SP28 deviations log — 4 backend + 5 frontend post-impl notes
Backend: helpers return dataclasses instead of inserting directly (preserves
publish-from-store contract); handlers commit work session before calling
cycl_store.add_claim_ack (SQLite single-writer); batch_envelope_index
accepts dict OR callable; link_manual returns ClaimAckLinkRow.

Frontend: TA1 row shape in Inbox lane; cross-page nav via useNavigate;
TA1 column header 'Batches' (not 'Claims'); ack-orphans lane position
between payer_rejected and candidates; fmt.usd instead of fmt.money
in MatchedClaimPanel.

Confirmed test baselines: backend 1210/10/6-err/1-fail = pre-SP28;
frontend 580 pass / 5 fail = pre-SP28. No new failures introduced.
46 files changed, +6,467/-22.
2026-07-02 12:05:45 -06:00
Nora 21a5485faa feat(sp28): api_routers/claim_acks (7 endpoints) + claim_acks surface on claims/acks
Phase 5 of SP28 (Ack↔Claim Auto-Link). Adds the public HTTP
surface for the claim_acks join table and wires it into the
existing claims + acks list endpoints.

New router (registered with matrix_gate auth, any-logged-in-user):

* GET    /api/claims/{id}/acks           — per-claim links
* GET    /api/claims/{id}/acks/stream    — NDJSON live-tail
* GET    /api/acks/{kind}/{id}/claims    — per-ack links
* GET    /api/acks/{kind}/{id}/claims/stream — NDJSON live-tail
* POST   /api/acks/{kind}/{id}/match-claim   — manual link (D5)
* DELETE /api/acks/{kind}/{id}/match-claim/{claim_id} — unlink
* GET    /api/inbox/ack-orphans          — orphan reconciliation

Manual match is any-logged-in-user (D5/D9), idempotent on the
(claim_id, ack_kind, ack_id) dedup key, rejects with 409 when the
claim is in a terminal state (REVERSED), and publishes
claim_ack_written / claim_ack_dropped on the bus so live-tail
subscribers refresh.

Existing list endpoints extended:
* /api/acks           — each item gains linked_claim_ids
* /api/ta1-acks       — each item gains linked_claim_ids
* /api/277ca-acks     — each item gains linked_claim_ids
* /api/claims/{id}    — body gains ack_links (compact form)

All three list extensions use a single batched SELECT against
claim_acks to avoid N+1.

Tests:
* tests/test_api_claim_acks.py — 8 tests covering all 7
  endpoints + the spec §6 named tests for the extended
  surfaces (claim_detail_includes_ack_links,
  acks_list_includes_linked_claim_ids,
  claim_acks_stream_emits_claim_ack_written). The stream test
  uses the established direct-endpoint-invocation pattern
  from test_api_stream_live.py so it gets true byte-streaming
  + clean async cancellation.
2026-07-02 11:55:15 -06:00
Nora 3fd2c445e7 fix(sp28): use fmt.usd instead of fmt.money in MatchedClaimPanel 2026-07-02 11:46:45 -06:00
Nora 9e16b8d9bd feat(sp28): wire apply_claim_ack_links into handle_999 / handle_277ca / handle_ta1
Phase 4 of SP28 (Ack↔Claim Auto-Link). Refactors the per-AK2
helpers in cyclone.claim_acks to return ClaimAckLinkRow dataclasses
instead of mutating the session directly — the orchestrator (the
999 / 277CA / TA1 handlers + the matching parse-* API endpoints)
now persists each row via cycl_store.add_claim_ack so the
publish-from-store contract owns the live-tail event emission.

* handle_999 / handle_277ca / handle_ta1 — build batch envelope
  index outside the work session (SQLite + concurrent sessions
  causes 'database is locked'), call apply_X to get
  ClaimAckLinkRow dataclasses, snapshot the rows before committing
  the work session, then call cycl_store.add_claim_ack per row
  in fresh sessions.
* /api/parse-999 / /api/parse-277ca / /api/parse-ta1 — mirror the
  handler chain with event_bus passed through so live-tail
  subscribers on the claim and ack sides see the new rows the
  moment they land. Adds a 'claim_ack_links_count' field to each
  ack response (spec §4).
* lookup_claims_for_ack_set_response — now accepts either a
  callable OR a plain dict as batch_envelope_index (the store
  returns a dict; tests pass callables).
* test_apply_claim_ack_links.py — 15 tests updated to assert on
  the dataclass shape and exercise the full helper→add_claim_ack
  cycle (so idempotency is verified at the store layer).
* test_e2e_999_to_claim_drawer.py — 2 new tests covering the
  D10 two-pass join end-to-end via FastAPI TestClient (Pass 1
  via ST02 + Pass 2 via PCN fallback).
2026-07-02 11:45:19 -06:00
Nora 1c367ddbe7 feat(sp28): Inbox ack orphans lane with Dismiss + Link to dropdown 2026-07-02 11:44:53 -06:00
Nora d8f3fb15f9 feat(sp28): Acks page Claims badge column with orphan/single/many variants 2026-07-02 11:40:50 -06:00
Nora 602dc352c5 feat(sp28): AckDrawer matched-claim panel + manual-match dropdown 2026-07-02 11:39:02 -06:00
Nora 490a1b9478 feat(sp28): ClaimDrawer acknowledgments panel + tests 2026-07-02 11:33:47 -06:00
Nora b094231995 feat(sp28): store.claim_acks facade + batch_envelope_index + to_ui_claim_ack serializer
The persistence layer for the SP28 join table lands in
cyclone/store/claim_acks.py:

- add_claim_ack — insert + publish 'claim_ack_written' on the bus
  (mirrors the publish-from-store pattern used by the ACKs paths)
- list_acks_for_claim / list_claims_for_ack — read helpers for the
  two list endpoints
- find_ack_orphans — Inbox ack-orphans lane source: every ack row
  whose ack_id has no ClaimAck row tied to it
- remove_claim_ack — unlink + publish 'claim_ack_dropped'
- batch_envelope_index — D10 in-memory map of
  {envelope.control_number: batch.id}, called once per ingest (cost
  is ~16 lookups today; trivially cheap)

Plus the to_ui_claim_ack serializer in store/ui.py mirroring
to_ui_ack shape — single source of truth for the wire format so the
live-tail event payload matches the list endpoint byte-for-byte.
Includes a single SELECT against claims for the claim_state field
(TA1 batch-level rows return 'n/a').

The CycloneStore facade in store/__init__.py re-exports all six
methods (add_claim_ack, list_acks_for_claim, list_claims_for_ack,
find_ack_orphans, remove_claim_ack, batch_envelope_index) plus
to_ui_claim_ack from the ui module.

Migration-version assertions in test_acks.py and test_db_migrate.py
bumped 17 → 18 to match the new migration head.

Steps 3.1/3.2/3.3/3.4 of the SP28 implementation plan.
2026-07-02 11:31:29 -06:00
Nora c54b2c1867 feat(sp28): claimAcks slice + useTailStream/useMergedTail dispatch 2026-07-02 11:31:17 -06:00
Nora a97f1d1350 feat(sp28): apply_claim_ack_links orchestrator + 999/277CA/TA1 helpers
The auto-linker closes the operator gap where every inbound 999 /
277CA / TA1 ack was persisted but never linked back to the claim it
acknowledges. Five pure helpers land in cyclone/claim_acks.py:

- lookup_claims_for_ack_set_response — D10 two-pass join. Primary is
  Batch.envelope.control_number (== source 837 ST02 for Gainwell
  batches); fallback is Claim.patient_control_number. Pass 1 wins,
  the two paths cannot both fire.
- apply_999_acceptances — walks parsed_999.set_responses, emits one
  ClaimAck per AK2 per matched claim (one-ack-to-many supported).
  Both accepted AND rejected AK2s link; per-AK2 granularity.
- apply_277ca_acks — same shape for parsed_277ca.claim_statuses.
  STC category code carried on the link row's
  set_accept_reject_code so the UI can render the lane inline
  without re-parsing raw_json.
- apply_ta1_envelope_link — envelope-level link. The link row has
  claim_id NULL + batch_id populated (the spec's batch-level TA1
  trace). Sender/receiver matching is delegated to a closure the
  caller supplies.
- link_manual — manually link an ack to a claim. Used by the new
  /api/acks/{kind}/{id}/match-claim endpoint. Idempotent.

All five helpers are pure (callers own the session); idempotent via
the partial unique index ux_claim_acks_dedup (helpers pre-check to
avoid IntegrityError log noise on re-ingest); flush-only (callers
commit).

14 of 15 named tests pass (the facade surface check belongs in
Phase 3 once store/claim_acks.py lands).

Steps 2.1/2.2/2.3/2.4/2.5 of the SP28 implementation plan.
2026-07-02 11:29:02 -06:00
Nora d2512945ca feat(sp28): useClaimAcks + useAckClaims hooks with api types 2026-07-02 11:26:25 -06:00
Nora 9e775c0150 feat(sp28): add 0018_claim_acks migration + ClaimAck ORM model
The claim_acks join table is the durable record of which inbound
999 / 277CA / TA1 ack acknowledged which claim (or, for TA1, which
originating 837 Batch). One row per AK2 set-response / ClaimStatus
/ envelope. The match granularity is per-AK2 so the operator can
answer 'which claims does this ack acknowledge?' with a single
SELECT and so the ClaimDrawer panel can show per-segment accept /
reject status without re-parsing raw_json.

Schema mirrors spec §3.1:
- claim_id NULLable + batch_id NULLable (TA1 envelope links land
  on batch_id; CHECK enforces at least one populated)
- unique partial index ux_claim_acks_dedup enforces idempotent
  re-ingest of the same 999 file
- set_control_number stores the value the upstream ack ACTUALLY
  CARRIED (== source 837 ST02 for Gainwell batches) for orphan
  traceability — the link survives even when the join had to fall
  back from ST02 to PCN matching

Mirrored on the ORM via Index(..., sqlite_where=text(...)) so
Base.metadata.create_all (the test-time safety net) emits the same
partial-unique constraint as the migration.

Step 1.1/1.2/1.3 of the SP28 implementation plan.
2026-07-02 11:24:49 -06:00
Nora e10d3886c2 refactor: export joinUrl from auth/api and reuse in lib/api
Single source of truth for the VITE_API_BASE_URL prefix logic. Both
auth/api and lib/api imported the same BASE_URL const; promote joinUrl
to a shared exported helper so future endpoints don't drift on the
trailing-slash normalization.
2026-07-02 11:18:22 -06:00
Nora ec87f98f02 docs(spec): SP28 D10 — two-pass join via Batch.envelope.control_number
The original SP28 spec assumed `Claim.patient_control_number == 999.set_control_number`.
Empirically that's wrong: Gainwell's 999 echoes the source 837's ST02, not its
CLM01, and TOC's billing software fills them differently. Measured against
prod on 2026-07-02: the PCN join matches 0 / 1,398 acks.

Fix: two-pass join. Primary is `Batch.envelope.control_number (== 837 ST02)
→ claims via batch_id`; fallback is `Claim.patient_control_number` (for
senders that fill CLM01 == ST02). Coverage after fix: 727 / 1,398 (52%);
the remaining 671 are real orphans (ST02=0001 placeholder, no matching 837
batch in our DB).

Spec adds D10 + a critical-correction paragraph at the top of §1. Plan
gains the `lookup_claims_for_ack_set_response` pure helper, the
`batch_envelope_index()` store method, and three new tests covering the
two-pass join + the false-positive guard + the one-ack-to-many case.
2026-07-02 11:16:53 -06:00
Nora f88a7c7c4f feat(acks): surface 999 rejections via sidebar badge + table sort
The 1,156 accepted acks were burying the 5 rejections — the operator
had to scroll the whole table to find them. Two changes:

1. Sidebar '999 ACKs' nav item gets a warning-toned badge showing
   the total rejected segment count from aggregates.rejected_count
   (mirrors the Reconciliation unmatched badge pattern). One number,
   visible from any page.

2. Acks page table now sorts rejected rows to the top, then
   newest-id-first. All 5 rejections fit on page 1 since they're a
   tiny fraction of the total — no pagination needed.

New files:
- src/hooks/useAckStats.ts (lightweight aggregates-only fetch)
- src/hooks/useAckStats.test.tsx (2 tests)
- src/components/Sidebar.test.tsx (3 tests)

Pre-existing baseline: 10 frontend failures (api.test.ts exportBatch837,
tail-stream.test.ts acks/ta1_acks targeting, Inbox/InboxHeader copy).
Unchanged.
2026-07-02 10:59:46 -06:00
Nora aca9667ff8 fix: enable CYCLONE_SCHEDULER_AUTOSTART in docker-compose
The SFTP poller was off by default in the production container —
operators had to POST /api/admin/scheduler/start manually after every
restart, which they were not doing. As a result the inbound 999 / 277CA
queue fell 7+ days behind in early July 2026 (scheduler.poll_count=0
in the live container since deploy).

Mirrors the existing CYCLONE_BACKUP_AUTOSTART=1 entry.

Note: there is a separate bug where scheduler._tick_impl's alphabetical
list_inbound hangs after ~30s on mft.gainwelltechnologies.com (TCP +
auth + first SFTP channel works, but the full alphabetic scan times
out). The pull-inbound CLI's date-filtered path works fine — SP to
follow.
2026-07-02 10:49:04 -06:00
Nora 999a762c86 docs(plan): SP28 ack-claim auto-link implementation plan 2026-07-02 10:32:17 -06:00
Nora 53a8c77cb8 docs(spec): SP28 ack-claim auto-link design 2026-07-02 10:30:52 -06:00
Nora de8bc2e153 merge: SP25 ack live-tail into main
Wires the live-tail triplet (useTailStream + useMergedTail +
<TailStatusPill>) into the Acks page so 999 and TA1 acknowledgments
appear the moment the SFTP poller (or a manual upload) lands them in
the database — no more 'no tracking is being updated'.

Backend changes
---------------
* store/acks.py: add_ack / add_ta1_ack / add_277ca_ack now publish
  ack_received / ta1_ack_received / two77ca_ack_received from inside
  the store after commit. Failures don't roll back the row.
* store/ui.py: new home for to_ui_ack / to_ui_ta1_ack / to_ui_two77ca_ack
  so the live-tail payload matches the list endpoint shape byte-for-byte.
* api_routers/acks.py + api_routers/ta1_acks.py: new /api/acks/stream
  and /api/ta1-acks/stream NDJSON endpoints following the live-tail
  wire format used by /api/claims/stream, /api/remittances/stream,
  and /api/activity/stream (snapshot + snapshot_end + live events +
  heartbeat + clean disconnect).
* api.py: parse-999 / parse-ta1 / parse-277ca endpoints thread
  request.app.state.event_bus into the store on write.
* handlers/handle_*.py: dropped the event_bus kwarg; the publish
  path is now store-only, not handler-driven.

Frontend changes
----------------
* lib/tail-stream.ts: TailResource widened to include acks + ta1_acks.
* store/tail-store.ts: acks + ta1Acks slices, addAck + addTa1Ack
  setters, matching reset cases. evictOldest is generic over
  (K extends string|number, V) so both keyed-by-id flavors share it.
* hooks/useTailStream.ts: dispatch routes acks / ta1_acks items into
  the matching store slices.
* hooks/useMergedTail.ts: generic constraint widened to
  T extends { id: string | number }, dedup normalizes via String(id)
  so numeric ids compare correctly.
* pages/Acks.tsx: opens both streams + renders two <TailStatusPill>
  components (one in the 999 hero, one in the TA1 section).

Verification
------------
* Backend: 1191 pass, 1 pre-existing test pollution failure (also
  fails on main, passes in isolation, unrelated to SP25).
* Frontend: 535 pass, 10 pre-existing failures in Inbox / Upload /
  Receipt / InboxHeader tests (all verified to fail the same way on
  main).
* TypeScript: zero new SP25 errors. Pre-existing Upload / Inbox
  errors match main.

Per the SP-N increment flow: no squash, no rebase. The merge commit
is the record of the increment landing.
2026-07-02 09:30:49 -06:00
Nora 1363d36046 fix(sp25): typecheck fixups
Three small follow-ups after the live-tail triplet wired up:

  * evictOldest is now generic over (K extends string|number, V) so
    the addAck / addTa1Ack call sites don't need a type assertion.
  * Acks.test.tsx no longer spreads importOriginal() — TypeScript
    was choking on the inferred 'unknown' from the mock factory.
    Use the same ApiError / mock shape that Claims.test.tsx uses.
  * tail-stream.test.ts guards the optional gen.return() with
    so strict mode TS2722 stops complaining about possibly-undefined.
2026-07-02 09:23:44 -06:00
Nora 146cb6d17d feat(sp25): wire live-tail triplet into Acks page
The 999 register opens /api/acks/stream via useTailStream("acks"),
merges the snapshot + tail through useMergedTail("acks", ...) so
new rows appear without a manual refresh, and surfaces the
connection state via <TailStatusPill> in the hero. The TA1 section
gets the same triplet against /api/ta1-acks/stream.

Both pills sit in the page header so the operator can see at a
glance whether the live-tail connection is healthy, without having
to open the drawer or refresh the page. A stalled/error stream
shows a Reconnect button inline.

The Acks test mock adds useTailStream at the module level so the
page renders without opening a real fetch. New test asserts both
TailStatusPills mount in the page tree.
2026-07-02 09:12:19 -06:00
Nora 191bc935c3 feat(sp25): extend useMergedTail to handle acks + ta1_acks slices
The Acks page needs two merge hooks (one per ack flavor) and both
shapes use numeric database ids rather than the string ids that
Claim/Remittance/Activity use. The generic constraint widens to
`T extends { id: string | number }` and the dedup Set normalizes
to String(id) so a base item with id=2 and a tail item with id=2
collide correctly.

Adds two switch cases that mirror the claims/remittances keyed-by-id
pattern: iterate the order array, look up the matching value in the
dict, drop undefined holes defensively.

Two tests assert the new slices order by their order arrays and dedup
against base items.
2026-07-02 09:09:53 -06:00
Nora 029623f3a5 feat(sp25): extend useTailStream dispatch for acks + ta1_acks
The Acks page mounts two streams (useTailStream("acks") and
useTailStream("ta1_acks")) — the dispatcher now routes their item
events into the matching store slices (addAck / addTa1Ack) the same
way the claims / remittances / activity cases already do.

Two new tests assert that an acks item lands in the acks slice (not
in claims) and a ta1_acks item lands in the ta1Acks slice (not in
acks) — i.e. the routing is exclusive, so a payload keyed by id 7
won't bleed across the two ack tables.
2026-07-02 09:08:46 -06:00
Nora 1648c81425 feat(sp25): extend useTailStore with acks + ta1_acks slices
The Acks page needs the same live-tail triplet as Claims/Remittances/
Activity. Both Ack and Ta1Ack have stable numeric ids from the
database row, so they get the keyed-by-id treatment (first write
wins, FIFO-capped at TAIL_CAP) — same shape as the claims and
remittances slices, not the append-only activity array.

Adds addAck, addTa1Ack, ackOrder, ta1AckOrder, and the matching
reset cases for the new 'acks' / 'ta1_acks' TailResource kinds.

evictOldest is now generic over T so the same helper covers the
two new keyed-by-id slices without duplication.
2026-07-02 09:07:26 -06:00
Nora f1bee546f6 feat(sp25): extend TailResource to include acks + ta1_acks
The Acks page needs the same live-tail triplet (useTailStream +
useMergedTail + TailStatusPill) that Claims/Remittances/Activity
already use. The first step is widening the TailResource union so
streamTail("acks") and streamTail("ta1_acks") are valid and the
URL falls out as /api/acks/stream and /api/ta1-acks/stream.

Two new tests assert the URL targets the new backend endpoints.
2026-07-02 09:01:53 -06:00
Nora 603ec8842a feat(sp25): /api/acks/stream + /api/ta1-acks/stream NDJSON endpoints
Adds two streaming endpoints that match the live-tail wire format
established by /api/claims/stream, /api/remittances/stream, and
/api/activity/stream:

  * /api/acks/stream      — subscribes to ack_received
  * /api/ta1-acks/stream  — subscribes to ta1_ack_received

Both yield a snapshot of existing rows (newest first, capped by the
`limit` query param), then a `snapshot_end` line, then forward
live events from the bus. They are registered before the
/{ack_id} path-param endpoints so the literal `stream` segment
isn't matched as an id.

Tests use the same direct-coroutine pattern as test_api_stream_live.py
because httpx.ASGITransport buffers the response body and never
delivers a disconnect message — iterating body_iterator directly
with body_iterator.aclose() simulates a client disconnect.
2026-07-02 09:00:54 -06:00
Nora 686c11f480 feat(sp25): drop event_bus kwarg from handlers; thread event_bus into parse endpoints 2026-07-02 08:52:20 -06:00