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