Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
112 changes: 66 additions & 46 deletions mailwatch/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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]
Expand Down
7 changes: 7 additions & 0 deletions mailwatch/static/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
54 changes: 42 additions & 12 deletions mailwatch/templates/tracking.html
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ <h2>Recently generated</h2>
<section class="stack">
<h2>Status</h2>
<p id="status" class="status status-idle">Idle. Submit the form above to connect.</p>
<p id="eta" class="eta" hidden></p>

<h2>Scan events</h2>
<p id="empty-notice" class="muted">
Expand Down Expand Up @@ -98,6 +99,7 @@ <h2>Scan events</h2>
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');

Expand Down Expand Up @@ -127,6 +129,12 @@ <h2>Scan events</h2>
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) {
Expand All @@ -136,25 +144,42 @@ <h2>Scan events</h2>

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 */ }
Expand All @@ -163,6 +188,8 @@ <h2>Scan events</h2>
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';
Expand Down Expand Up @@ -192,6 +219,9 @@ <h2>Scan events</h2>
setStatus('Error: ' + data.error, 'status-error');
return;
}
if (data.eta) {
showEta(data.eta);
}
if (Array.isArray(data.scans)) {
data.scans.forEach(prependScan);
if (receivedAny) {
Expand Down
93 changes: 89 additions & 4 deletions tests/test_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -72,6 +73,8 @@ def fake_tracking() -> TrackingResponse:
scanFacilityCity="WASHINGTON",
scanFacilityState="DC",
scanFacilityZip="20018",
machineName="DBCS-051",
mailPhase="Phase 3c - Destination Sequenced Carrier Sortation",
)
],
)
Expand Down Expand Up @@ -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:
Expand All @@ -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:
Expand Down Expand Up @@ -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