Skip to content

Commit 8c11f0d

Browse files
committed
feat: add staleness indicators for sources and vehicles
Show an orange warning icon next to sources that haven't received data in 30+ minutes, with a tooltip showing how long ago data was last seen. Hide vehicles from the realtime map if they haven't reported in 2 hours (down from 24h). The /sources endpoint now includes a last_updated timestamp derived from the in-memory snapshot.
1 parent 9c9cde9 commit 8c11f0d

4 files changed

Lines changed: 113 additions & 4 deletions

File tree

CHANGELOG.md

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,16 @@
1-
<!-- changelog-id: 7 -->
1+
<!-- changelog-id: 8 -->
22
# Changelog
33

4+
## 2026-02-24 — Stale Data Indicators
5+
The St. John's data source is currently experiencing technical difficulties on
6+
the city's end. To make situations like this visible, the map now shows an
7+
orange warning icon next to any source that hasn't received fresh data in a
8+
while. Hovering over it tells you how long it's been. Vehicles that haven't
9+
reported a position recently are also hidden from the realtime view so the map
10+
only shows what's actually moving.
11+
12+
[View changes](https://github.com/jackharrhy/where-the-plow/compare/e32ec39...HEAD)
13+
414
## 2026-02-23 — Ko-fi Support Button
515
A "Support me on Ko-fi" button now appears in the sidebar, if you'd like to
616
help keep the servers running.

src/where_the_plow/routes.py

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -140,22 +140,46 @@ def _rows_to_feature_collection(rows: list[dict], limit: int) -> FeatureCollecti
140140
)
141141

142142

143+
def _source_last_updated(snapshots: dict[str, dict], source_name: str) -> str | None:
144+
"""Derive the most recent vehicle timestamp from a source's cached snapshot."""
145+
fc = snapshots.get(source_name)
146+
if not fc:
147+
return None
148+
features = fc.get("features", [])
149+
if not features:
150+
return None
151+
latest = max(
152+
(
153+
f["properties"]["timestamp"]
154+
for f in features
155+
if f.get("properties", {}).get("timestamp")
156+
),
157+
default=None,
158+
)
159+
return latest
160+
161+
143162
@router.get(
144163
"/sources",
145164
summary="Available data sources",
146-
description="Returns metadata about each configured plow data source.",
165+
description="Returns metadata about each configured plow data source, "
166+
"including the timestamp of the most recent vehicle position per source.",
147167
tags=["sources"],
148168
)
149-
def get_sources():
169+
def get_sources(request: Request):
150170
from where_the_plow.config import SOURCES
151171

172+
store = getattr(request.app.state, "store", {})
173+
snapshots = store.get("realtime", {})
174+
152175
return {
153176
name: {
154177
"display_name": src.display_name,
155178
"center": list(src.center),
156179
"zoom": src.zoom,
157180
"enabled": src.enabled,
158181
"min_coverage_zoom": src.min_coverage_zoom,
182+
"last_updated": _source_last_updated(snapshots, name),
159183
}
160184
for name, src in SOURCES.items()
161185
if src.enabled

src/where_the_plow/static/app.js

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -816,6 +816,8 @@ plowMap.on("moveend", () => {
816816
/* ── Utilities ─────────────────────────────────────── */
817817

818818
const ONE_DAY_MS = 24 * 60 * 60 * 1000;
819+
const VEHICLE_STALE_MS = 2 * 60 * 60 * 1000; // hide vehicles not seen in 2 hours
820+
const SOURCE_STALE_MS = 30 * 60 * 1000; // warn if source has no data in 30 minutes
819821

820822
const VEHICLE_COLORS = {
821823
"SA PLOW TRUCK": "#2563eb",
@@ -863,6 +865,17 @@ function formatTimestamp(ts) {
863865
});
864866
}
865867

868+
function formatDurationAgo(ms) {
869+
const minutes = Math.floor(ms / 60000);
870+
if (minutes < 60) return `${minutes}m ago`;
871+
const hours = Math.floor(minutes / 60);
872+
const remainMin = minutes % 60;
873+
if (hours < 24) return remainMin > 0 ? `${hours}h ${remainMin}m ago` : `${hours}h ago`;
874+
const days = Math.floor(hours / 24);
875+
const remainHrs = hours % 24;
876+
return remainHrs > 0 ? `${days}d ${remainHrs}h ago` : `${days}d ago`;
877+
}
878+
866879
/** Get padded viewport bounds for coverage culling. */
867880
function getPaddedBounds(map, padding) {
868881
const b = map.getBounds();
@@ -917,7 +930,7 @@ function updateVehicleCount(data) {
917930
}
918931

919932
function filterRecentFeatures(data) {
920-
const cutoff = Date.now() - ONE_DAY_MS;
933+
const cutoff = Date.now() - VEHICLE_STALE_MS;
921934
return {
922935
...data,
923936
features: data.features.filter(
@@ -1162,13 +1175,25 @@ class PlowApp {
11621175

11631176
const text = document.createTextNode(src.display_name);
11641177
label.appendChild(text);
1178+
1179+
// Staleness warning indicator
1180+
const staleIcon = document.createElement("span");
1181+
staleIcon.className = "source-stale-icon";
1182+
staleIcon.dataset.source = key;
1183+
staleIcon.textContent = "!";
1184+
staleIcon.style.display = "none";
1185+
label.appendChild(staleIcon);
1186+
11651187
label.appendChild(cb);
11661188

11671189
row.appendChild(zoomBtn);
11681190
row.appendChild(label);
11691191
container.appendChild(row);
11701192
}
11711193

1194+
// Initial staleness check
1195+
this.updateSourceStaleness();
1196+
11721197
// Also add a "Types" section title to the vehicle legend (idempotent)
11731198
const vehicleLegend = document.getElementById("legend-vehicles");
11741199
if (!vehicleLegend.querySelector(".legend-section-title")) {
@@ -1179,6 +1204,25 @@ class PlowApp {
11791204
}
11801205
}
11811206

1207+
updateSourceStaleness() {
1208+
const now = Date.now();
1209+
for (const [key, src] of Object.entries(this.sources)) {
1210+
const icon = document.querySelector(`.source-stale-icon[data-source="${key}"]`);
1211+
if (!icon) continue;
1212+
1213+
const lastUpdated = src.last_updated ? new Date(src.last_updated).getTime() : 0;
1214+
const age = now - lastUpdated;
1215+
1216+
if (!src.last_updated || age > SOURCE_STALE_MS) {
1217+
const agoText = src.last_updated ? formatDurationAgo(age) : "never";
1218+
icon.title = `No data received (last: ${agoText})`;
1219+
icon.style.display = "";
1220+
} else {
1221+
icon.style.display = "none";
1222+
}
1223+
}
1224+
}
1225+
11821226
getSourceDisplayName(sourceKey) {
11831227
const src = this.sources[sourceKey];
11841228
return src ? src.display_name : sourceKey;
@@ -1686,6 +1730,7 @@ class PlowApp {
16861730

16871731
startAutoRefresh() {
16881732
if (this.refreshInterval) return;
1733+
this._sourceRefreshCounter = 0;
16891734
this.refreshInterval = setInterval(async () => {
16901735
if (this.mode !== "realtime") return;
16911736
try {
@@ -1700,6 +1745,19 @@ class PlowApp {
17001745
} catch (err) {
17011746
console.error("Failed to refresh vehicles:", err);
17021747
}
1748+
1749+
// Re-fetch sources every ~60s (10 ticks * 6s) to update staleness info
1750+
this._sourceRefreshCounter = (this._sourceRefreshCounter || 0) + 1;
1751+
if (this._sourceRefreshCounter >= 10) {
1752+
this._sourceRefreshCounter = 0;
1753+
try {
1754+
const resp = await fetch("/sources");
1755+
if (resp.ok) {
1756+
this.sources = await resp.json();
1757+
}
1758+
} catch (_) { /* ignore */ }
1759+
}
1760+
this.updateSourceStaleness();
17031761
}, 6000);
17041762
}
17051763

src/where_the_plow/static/style.css

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -512,6 +512,23 @@ body {
512512
display: none;
513513
}
514514

515+
.source-stale-icon {
516+
display: inline-flex;
517+
align-items: center;
518+
justify-content: center;
519+
width: 15px;
520+
height: 15px;
521+
border-radius: 50%;
522+
background: #f59e0b;
523+
color: #000;
524+
font-size: 10px;
525+
font-weight: 700;
526+
line-height: 1;
527+
flex-shrink: 0;
528+
margin-left: 4px;
529+
cursor: help;
530+
}
531+
515532
.legend-section-title {
516533
font-size: 0.65rem;
517534
text-transform: uppercase;

0 commit comments

Comments
 (0)