From 0edef0bc71d3f80313095de23355c42a571d5dad Mon Sep 17 00:00:00 2001 From: ak2k <19240940+ak2k@users.noreply.github.com> Date: Fri, 5 Jun 2026 19:30:11 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20richer=20scan=20detail=20=E2=80=94=20ma?= =?UTF-8?q?il=20phase,=20machine,=20and=20delivery=20ETA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each scan event rendered only the raw IV-MTR operation code (e.g. "919 @ NEW YORK NY"), discarding the human-readable fields USPS already sends. Surface them: each scan now leads with the mail phase (e.g. "Phase 3c - Destination Sequenced Carrier Sortation"), with the op code, processing machine, and facility location as a secondary detail line. The piece-level expected-delivery date is forwarded as an ETA banner. The numeric op-code -> prose table is deliberately not vendored — it's a 170+ entry USPS spreadsheet updated weekly, and `mailPhase` is the authoritative inline label. Extract the live+stored merge into `_merge_scan_sources` (pure, unit- tested) so track_ws stays small and the dedup/field-mapping is covered directly. --- mailwatch/routes.py | 112 ++++++++++++++++++------------ mailwatch/static/style.css | 7 ++ mailwatch/templates/tracking.html | 54 ++++++++++---- tests/test_routes.py | 93 +++++++++++++++++++++++-- 4 files changed, 204 insertions(+), 62 deletions(-) diff --git a/mailwatch/routes.py b/mailwatch/routes.py index 731c05b..500d1c1 100644 --- a/mailwatch/routes.py +++ b/mailwatch/routes.py @@ -41,7 +41,7 @@ from mailwatch.avery import AVERY, DEFAULT_AVERY, DISPLAY_NAMES as _AVERY_LABELS from mailwatch.config import Settings from mailwatch.layouts import DEFAULT_ENVELOPE, DISPLAY_NAMES as _ENVELOPE_LABELS, ENVELOPES -from mailwatch.models import AddressRequest, PushFeedPayload +from mailwatch.models import AddressRequest, PushFeedPayload, TrackingResponse from mailwatch.usps_api import IVMTRClient, NewApiClient # Practical ceiling on any single session field so the cookie stays under @@ -958,62 +958,82 @@ async def track_ws(websocket: WebSocket) -> None: stored_imb = await _db_call(lock, db.get_imb_by_serial, conn, parsed.serial) imb_key = stored_imb or fallback_key - merged: list[dict[str, Any]] = [] - try: live = await ivmtr.get_tracking(imb_key) except Exception as exc: # noqa: BLE001 — live-pull failure falls back to stored events logger.info("IV-MTR live pull failed: %s", exc) live = None - seen: set[str] = set() - if live is not None and live.data is not None: - for scan in live.data.scans: - key = f"{scan.scanDatetime.isoformat()}|{scan.scanEventCode}" - if key in seen: - continue - seen.add(key) - merged.append( - { - "timestamp": scan.scanDatetime.isoformat(), - "event": scan.scanEventCode, - "location": _format_location( - scan.scanFacilityCity, - scan.scanFacilityState, - scan.scanFacilityZip, - ), - "source": "live", - } - ) - stored = await _db_call(lock, db.get_scan_events, conn, imb_key) - for row_data in stored: - payload = row_data.get("event") or {} - scan_dt = row_data.get("scan_datetime") or payload.get("scanDatetime") - event_code = payload.get("scanEventCode") or "SCAN" - key = f"{scan_dt}|{event_code}" - if key in seen: - continue - seen.add(key) - merged.append( - { - "timestamp": scan_dt, - "event": event_code, - "location": _format_location( - payload.get("scanFacilityCity"), - payload.get("scanFacilityState"), - payload.get("scanFacilityZip"), - ), - "source": "stored", - } - ) - - merged.sort(key=lambda s: s.get("timestamp") or "", reverse=True) - await websocket.send_json({"scans": merged}) + merged, eta = _merge_scan_sources(live, stored) + await websocket.send_json({"scans": merged, "eta": eta}) except WebSocketDisconnect: return +def _merge_scan_sources( + live: TrackingResponse | None, + stored: list[dict[str, Any]], +) -> tuple[list[dict[str, Any]], str | None]: + """Merge live IV-MTR scans with webhook/poll-stored events, newest first. + + De-dupes on ``(timestamp, operation code)``; a live scan wins over a stored + one for the same key. Each merged scan carries the human-readable mail + phase (e.g. "Destination Sequenced Carrier Sortation"), the processing + machine, and the facility location alongside the raw code. Also returns the + piece-level expected-delivery ETA from the live pull, or ``None``. + """ + merged: list[dict[str, Any]] = [] + seen: set[str] = set() + eta: str | None = None + + if live is not None and live.data is not None: + eta = live.data.expected_delivery_date + for scan in live.data.scans: + key = f"{scan.scanDatetime.isoformat()}|{scan.scanEventCode}" + if key in seen: + continue + seen.add(key) + merged.append( + { + "timestamp": scan.scanDatetime.isoformat(), + "event": scan.scanEventCode, + "phase": scan.mailPhase, + "machine": scan.machineName, + "location": _format_location( + scan.scanFacilityCity, scan.scanFacilityState, scan.scanFacilityZip + ), + "source": "live", + } + ) + + for row_data in stored: + payload = row_data.get("event") or {} + scan_dt = row_data.get("scan_datetime") or payload.get("scanDatetime") + event_code = payload.get("scanEventCode") or "SCAN" + key = f"{scan_dt}|{event_code}" + if key in seen: + continue + seen.add(key) + merged.append( + { + "timestamp": scan_dt, + "event": event_code, + "phase": payload.get("mailPhase"), + "machine": payload.get("machineName"), + "location": _format_location( + payload.get("scanFacilityCity"), + payload.get("scanFacilityState"), + payload.get("scanFacilityZip"), + ), + "source": "stored", + } + ) + + merged.sort(key=lambda s: s.get("timestamp") or "", reverse=True) + return merged, eta + + def _format_location(city: str | None, state: str | None, zip_code: str | None) -> str | None: """Join city/state/ZIP with single spaces; return None if all empty.""" parts = [p for p in (city, state, zip_code) if p] diff --git a/mailwatch/static/style.css b/mailwatch/static/style.css index cf94a49..1e8b36d 100644 --- a/mailwatch/static/style.css +++ b/mailwatch/static/style.css @@ -175,13 +175,20 @@ button.primary:hover, .button-link.primary:hover { .recent-status-in_transit { color: var(--accent); border-color: var(--accent); } .recent-status-awaiting { color: var(--muted); } +.eta { + padding: 0.5rem 0.75rem; border: 1px solid var(--ok); border-radius: var(--radius); + background: var(--surface); color: var(--ok); font-weight: 500; font-size: 0.95rem; +} + .scans { list-style: none; padding: 0; margin: 0; } .scan { padding: 0.6rem 0.75rem; border: 1px solid var(--border); border-radius: var(--radius); margin-bottom: 0.5rem; background: var(--surface); } +.scan-head { display: flex; flex-wrap: wrap; align-items: baseline; gap: 0.2rem 0.5rem; } .scan-time { color: var(--muted); font-variant-numeric: tabular-nums; } .scan-event { font-weight: 500; } +.scan-detail { margin-top: 0.2rem; color: var(--muted); font-size: 0.9rem; } .scan-location { color: var(--muted); } @media print { diff --git a/mailwatch/templates/tracking.html b/mailwatch/templates/tracking.html index 95e81da..57cf40d 100644 --- a/mailwatch/templates/tracking.html +++ b/mailwatch/templates/tracking.html @@ -68,6 +68,7 @@

Recently generated

Status

Idle. Submit the form above to connect.

+

Scan events

@@ -98,6 +99,7 @@

Scan events

var serialInput = document.getElementById('serial'); var zipInput = document.getElementById('zip'); var statusEl = document.getElementById('status'); + var etaEl = document.getElementById('eta'); var scansEl = document.getElementById('scans'); var emptyNotice = document.getElementById('empty-notice'); @@ -127,6 +129,12 @@

Scan events

var li = document.createElement('li'); li.className = 'scan'; + // Header: timestamp + the human-readable mail phase USPS reports + // (e.g. "Phase 3c - Destination Sequenced Carrier Sortation"), + // falling back to the raw operation code when no phase is present. + var head = document.createElement('div'); + head.className = 'scan-head'; + var when = document.createElement('time'); when.className = 'scan-time'; if (scan.timestamp) { @@ -136,25 +144,42 @@

Scan events

var what = document.createElement('span'); what.className = 'scan-event'; - what.textContent = scan.event || scan.description || 'Scan'; + what.textContent = scan.phase || scan.event || scan.description || 'Scan'; - var where = document.createElement('span'); - where.className = 'scan-location'; - if (scan.location) { - where.textContent = scan.location; - } + head.appendChild(when); + head.appendChild(document.createTextNode(' — ')); + head.appendChild(what); + li.appendChild(head); - li.appendChild(when); - li.appendChild(document.createTextNode(' — ')); - li.appendChild(what); - if (scan.location) { - li.appendChild(document.createTextNode(' @ ')); - li.appendChild(where); + // Detail: operation code (only when the phase was the headline, so we + // don't repeat it), processing machine, and facility location. + var bits = []; + if (scan.phase && scan.event) { bits.push('op ' + scan.event); } + if (scan.machine) { bits.push(scan.machine); } + if (scan.location) { bits.push(scan.location); } + if (bits.length) { + var detail = document.createElement('div'); + detail.className = 'scan-detail'; + detail.textContent = bits.join(' · '); + li.appendChild(detail); } scansEl.insertBefore(li, scansEl.firstChild); } + function showEta(eta) { + if (!eta) { return; } + var text = String(eta); + var d = new Date(eta); + if (!isNaN(d.getTime())) { + text = d.toLocaleDateString(undefined, { + weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' + }); + } + etaEl.textContent = 'Expected delivery: ' + text; + etaEl.hidden = false; + } + function connect(serial, zip) { if (ws) { try { ws.close(); } catch (e) { /* ignore */ } @@ -163,6 +188,8 @@

Scan events

scansEl.innerHTML = ''; receivedAny = false; emptyNotice.hidden = false; + etaEl.hidden = true; + etaEl.textContent = ''; var proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; var url = proto + '//' + window.location.host + '/track-ws'; @@ -192,6 +219,9 @@

Scan events

setStatus('Error: ' + data.error, 'status-error'); return; } + if (data.eta) { + showEta(data.eta); + } if (Array.isArray(data.scans)) { data.scans.forEach(prependScan); if (receivedAny) { diff --git a/tests/test_routes.py b/tests/test_routes.py index 7666e4f..105ab69 100644 --- a/tests/test_routes.py +++ b/tests/test_routes.py @@ -64,6 +64,7 @@ def fake_tracking() -> TrackingResponse: return TrackingResponse( data=TrackingData( imb="9" * 31, + expected_delivery_date="2026-04-23", scans=[ TrackingScan( imb="9" * 31, @@ -72,6 +73,8 @@ def fake_tracking() -> TrackingResponse: scanFacilityCity="WASHINGTON", scanFacilityState="DC", scanFacilityZip="20018", + machineName="DBCS-051", + mailPhase="Phase 3c - Destination Sequenced Carrier Sortation", ) ], ) @@ -725,8 +728,14 @@ def test_track_ws_returns_merged_scans(client: TestClient) -> None: assert "scans" in msg # Live fake returned 1 scan. assert len(msg["scans"]) >= 1 - assert msg["scans"][0]["event"] == "SD" - assert msg["scans"][0]["location"].startswith("WASHINGTON") + scan = msg["scans"][0] + assert scan["event"] == "SD" + assert scan["location"].startswith("WASHINGTON") + # Richer per-scan detail surfaced from the IV-MTR payload. + assert scan["phase"] == "Phase 3c - Destination Sequenced Carrier Sortation" + assert scan["machine"] == "DBCS-051" + # Piece-level expected-delivery ETA is forwarded to the client. + assert msg["eta"] == "2026-04-23" def test_track_ws_rejects_invalid_payload(client: TestClient) -> None: @@ -749,8 +758,8 @@ def test_track_ws_survives_live_pull_failure(client: TestClient) -> None: with client.websocket_connect("/track-ws") as ws: ws.send_text(json.dumps({"serial": 1, "receipt_zip": "20500"})) msg = ws.receive_json() - # Empty live results, no stored events -> empty list. - assert msg == {"scans": []} + # Empty live results, no stored events -> empty list, no ETA. + assert msg == {"scans": [], "eta": None} def test_generate_with_mailer_id_starting_with_9(tmp_path: Path) -> None: @@ -903,3 +912,79 @@ def test_usps_feed_rejects_non_allowlisted_ip(tmp_path: Path) -> None: with TestClient(app) as c: resp = c.post("/usps_feed", json=_sample_push()) assert resp.status_code == 403 + + +# --------------------------------------------------------------------------- # +# _merge_scan_sources (live + stored merge) # +# --------------------------------------------------------------------------- # + + +def test_merge_scan_sources_live_wins_and_maps_fields() -> None: + """Live + stored merge: dedup by (ts, code), live wins, rich fields mapped.""" + from mailwatch.routes import _merge_scan_sources + + live = TrackingResponse( + data=TrackingData( + imb="9" * 31, + expected_delivery_date="2026-04-23", + scans=[ + TrackingScan( + scanDatetime="2026-04-21T09:00:00Z", # type: ignore[arg-type] + scanEventCode="SD", + scanFacilityCity="WASHINGTON", + scanFacilityState="DC", + scanFacilityZip="20018", + machineName="DBCS-051", + mailPhase="Phase 3c - Destination Sequenced Carrier Sortation", + ) + ], + ) + ) + stored = [ + # Same (timestamp, code) as the live scan → dropped in favor of live. + { + "event": {"scanEventCode": "SD", "scanDatetime": "2026-04-21T09:00:00+00:00"}, + "scan_datetime": "2026-04-21T09:00:00+00:00", + }, + # Distinct earlier stored-only event. + { + "event": { + "scanEventCode": "AC", + "scanFacilityCity": "BOSTON", + "scanFacilityState": "MA", + "scanFacilityZip": "02110", + "mailPhase": "Phase 0 - Origin Processing", + "machineName": "AFCS200-005", + }, + "scan_datetime": "2026-04-20T08:00:00+00:00", + }, + ] + + merged, eta = _merge_scan_sources(live, stored) + + assert eta == "2026-04-23" + # Newest first; the duplicate SD collapses to a single (live) entry. + assert [s["event"] for s in merged] == ["SD", "AC"] + sd = merged[0] + assert sd["source"] == "live" + assert sd["phase"] == "Phase 3c - Destination Sequenced Carrier Sortation" + assert sd["machine"] == "DBCS-051" + assert sd["location"].startswith("WASHINGTON") + ac = merged[1] + assert ac["source"] == "stored" + assert ac["phase"] == "Phase 0 - Origin Processing" + assert ac["machine"] == "AFCS200-005" + + +def test_merge_scan_sources_stored_only_when_no_live() -> None: + """With no live data, stored events still surface; ETA is None.""" + from mailwatch.routes import _merge_scan_sources + + stored = [{"event": {"scanEventCode": "AC"}, "scan_datetime": "2026-04-20T08:00:00+00:00"}] + merged, eta = _merge_scan_sources(None, stored) + + assert eta is None + assert len(merged) == 1 + assert merged[0]["event"] == "AC" + assert merged[0]["phase"] is None + assert merged[0]["machine"] is None