From 6f4a86edbb6f7bf8545f45dab457a287db77763d Mon Sep 17 00:00:00 2001 From: grabowski Date: Fri, 11 Sep 2026 23:00:39 +0200 Subject: [PATCH] fix(dashboard): timestamps are Asia/Bangkok everywhere; stale feed says so MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The API emits naive ICT timestamps ("2026-09-12T02:00:00"). The page fed them to new Date(), which applies the BROWSER's zone: a viewer in Europe parsed a 02:00 ICT reading as 02:00 CEST, five hours in the future, so "Last updated" clamped to "0 min ago" forever; a viewer in the Americas saw thousands of minutes. parseTs() now pins +07:00 on naive strings and every display formats with timeZone: Asia/Bangkok, so the site shows river time regardless of where it is opened. Daily/hourly chart buckets key on the Bangkok calendar day instead of UTC getters. The tile also gets a real stale state: past 3 h (RID is hourly) it turns red, reads "Feed stale · last reading , N h ago", and the header pill switches from LIVE DATA to STALE FEED. Age shows hours past 2 h. scripts/dev_proxy.py serves the working-copy dashboard with API calls proxied to water.buildfor.life so browser-side changes can be checked against live data (and any browser timezone) before deploying. --- scripts/dev_proxy.py | 51 +++++++++++++++++++++++ src/static/dashboard.html | 85 +++++++++++++++++++++++++++------------ 2 files changed, 110 insertions(+), 26 deletions(-) create mode 100644 scripts/dev_proxy.py diff --git a/scripts/dev_proxy.py b/scripts/dev_proxy.py new file mode 100644 index 0000000..6407816 --- /dev/null +++ b/scripts/dev_proxy.py @@ -0,0 +1,51 @@ +"""Serve the working-copy dashboard locally with API calls proxied to the +live server, so browser-side changes can be checked against real data +before deploy. Usage: python scripts/dev_proxy.py [port]""" + +import http.server +import sys +import urllib.request +from pathlib import Path + +UPSTREAM = "https://water.buildfor.life" +STATIC = Path(__file__).resolve().parents[1] / "src" / "static" + + +class Handler(http.server.BaseHTTPRequestHandler): + def do_GET(self): + if self.path == "/" or self.path.startswith("/?"): + body = (STATIC / "dashboard.html").read_bytes() + self._send(200, "text/html; charset=utf-8", body) + return + if self.path.startswith("/static/"): + f = STATIC / self.path[len("/static/"):].split("?")[0] + if f.is_file(): + ctype = "application/json" if f.suffix in (".json", ".geojson") else "application/octet-stream" + self._send(200, ctype, f.read_bytes()) + return + try: + req = urllib.request.Request( + UPSTREAM + self.path, + headers={"User-Agent": "Mozilla/5.0 (dev_proxy; +https://buildfor.life)", "Accept": "application/json"}, + ) + with urllib.request.urlopen(req, timeout=60) as r: + self._send(r.status, r.headers.get("Content-Type", "application/json"), r.read()) + except urllib.error.HTTPError as e: + self._send(e.code, "application/json", e.read()) + + def _send(self, code, ctype, body): + self.send_response(code) + self.send_header("Content-Type", ctype) + self.send_header("Content-Length", str(len(body))) + self.send_header("Cache-Control", "no-store") + self.end_headers() + self.wfile.write(body) + + def log_message(self, *a): + pass + + +if __name__ == "__main__": + port = int(sys.argv[1]) if len(sys.argv) > 1 else 8765 + print(f"http://localhost:{port}/ (API -> {UPSTREAM})") + http.server.ThreadingHTTPServer(("127.0.0.1", port), Handler).serve_forever() diff --git a/src/static/dashboard.html b/src/static/dashboard.html index a7472b6..c5f4ae8 100644 --- a/src/static/dashboard.html +++ b/src/static/dashboard.html @@ -97,6 +97,8 @@ .stat-label { color: var(--muted); text-transform: uppercase; letter-spacing: .09em; font-size: .68rem; font-weight: 800; } .stat-value { margin-top: 9px; font-size: 1.65rem; font-weight: 800; letter-spacing: -.04em; white-space: nowrap; } .stat-note { color: var(--muted); margin-top: 3px; font-size: .77rem; } + .stat.stale { border-color: var(--red); background: rgba(204,75,55,.08); } + .stat.stale .stat-value, .stat.stale .stat-note { color: var(--red); } .workspace { display: grid; grid-template-columns: minmax(0, 1fr) 330px; gap: 14px; min-height: 640px; } .map-card, .side-card { background: var(--card); border: 1px solid var(--border); border-radius: 19px; box-shadow: var(--shadow); overflow: hidden; } .map-card { position: relative; } @@ -407,6 +409,7 @@ 'app.title': 'Ping River Live Monitor', 'app.subtitle': 'Current water level and discharge across Northern Thailand', 'pill.live': 'LIVE DATA', + 'pill.stale': '⚠ STALE FEED', 'pill.replay': '⏪ 2024 REPLAY', 'pill.sim': '⚠ SIMULATION', 'action.refresh': '↻ Refresh', @@ -439,7 +442,9 @@ 'stat.stress.tip': 'How full the river channel is at the busiest gauge — 100% means water reaches the top of the bank', 'stat.updated': 'Last updated', 'stat.updated.note': 'Loading latest readings', - 'stat.updated.ago': (date, mins) => `${date} · ${mins} min ago`, + 'stat.updated.ago': (date, mins) => `${date} · ${mins} min ago (ICT)`, + 'stat.updated.agoh': (date, hours) => `${date} · ${hours} h ago (ICT)`, + 'stat.updated.stale': (date, hours) => `⚠ Feed stale · last reading ${date}, ${hours} h ago`, 'stat.updated.none': 'No timestamp available', 'map.title': 'Station flow map', 'map.subtitle': 'River width, colour & dash speed follow live discharge', @@ -579,6 +584,7 @@ 'app.title': 'ติดตามระดับน้ำปิงแบบเรียลไทม์', 'app.subtitle': 'ระดับน้ำและอัตราการไหลปัจจุบันทั่วภาคเหนือของประเทศไทย', 'pill.live': 'ข้อมูลสด', + 'pill.stale': '⚠ ข้อมูลไม่อัปเดต', 'pill.replay': '⏪ ย้อนเหตุการณ์ 2567', 'pill.sim': '⚠ การจำลอง', 'action.refresh': '↻ รีเฟรช', @@ -611,7 +617,9 @@ 'stat.stress.tip': 'ระดับความเต็มของลำน้ำที่สถานีที่มีน้ำมากที่สุด — 100% หมายถึงน้ำถึงระดับตลิ่ง', 'stat.updated': 'อัปเดตล่าสุด', 'stat.updated.note': 'กำลังโหลดข้อมูลล่าสุด', - 'stat.updated.ago': (date, mins) => `${date} · ${mins} นาทีที่แล้ว`, + 'stat.updated.ago': (date, mins) => `${date} · ${mins} นาทีที่แล้ว (เวลาไทย)`, + 'stat.updated.agoh': (date, hours) => `${date} · ${hours} ชั่วโมงที่แล้ว (เวลาไทย)`, + 'stat.updated.stale': (date, hours) => `⚠ ข้อมูลไม่อัปเดต · ค่าล่าสุด ${date}, ${hours} ชั่วโมงที่แล้ว`, 'stat.updated.none': 'ไม่มีข้อมูลเวลา', 'map.title': 'แผนที่การไหลของน้ำ', 'map.subtitle': 'ความกว้าง สี และความเร็วเส้นประของแม่น้ำแสดงอัตราการไหลจริง', @@ -841,6 +849,25 @@ // what the replay label ("ต.ค. 2567") already says — pinning Gregorian here // put two different year systems on the same screen. function loc() { return state.lang === 'th' ? 'th-TH' : 'en-GB'; } + // Every timestamp the API emits is Asia/Bangkok wall-clock WITHOUT an + // offset ("2026-09-12T02:00:00"). new Date() on such a string uses the + // browser's own zone, so a viewer in Europe read a 02:00 ICT reading as + // five hours in the future and saw "0 min ago" forever. Pin the offset + // here and always format with timeZone: TZ so the site shows river time + // no matter where it is opened. + const TZ = 'Asia/Bangkok'; + const STALE_AFTER_MIN = 180; + function parseTs(value) { + if (value == null || value === '') return null; + if (value instanceof Date) return value; + const text = String(value).trim(); + const naive = /^\d{4}-\d\d-\d\d[T ]\d\d:\d\d(:\d\d(\.\d+)?)?$/.test(text); + const date = new Date(naive ? text.replace(' ', 'T') + '+07:00' : text); + return Number.isNaN(date.getTime()) ? null : date; + } + // Bucket keys in river-local time (a Bangkok calendar day, not a UTC one) + const dayKey = (value) => parseTs(value).toLocaleDateString('en-CA', { timeZone: TZ }); + const hourKey = (value) => `${dayKey(value)}-${parseTs(value).toLocaleTimeString('en-GB', { timeZone: TZ, hour: '2-digit' })}`; // Metre abbreviation: "ม." reads as a unit in Thai, "m" mid-sentence does not. function metres(value, digits = 2) { return `${Number(value).toFixed(digits)} ${t('unit.m')}`; @@ -885,7 +912,7 @@ const mm = r.rain_24h == null ? null : Number(r.rain_24h); const bin = rainBin(mm); const name = (state.lang === 'th' ? r.name_th || r.name_en : r.name_en || r.name_th) || r.oldcode || `Station ${r.station_id}`; - const time = r.timestamp ? new Date(r.timestamp).toLocaleString(loc(), { dateStyle: 'medium', timeStyle: 'short' }) : t('popup.noreading'); + const time = r.timestamp ? parseTs(r.timestamp).toLocaleString(loc(), { timeZone: TZ, dateStyle: 'medium', timeStyle: 'short' }) : t('popup.noreading'); L.circleMarker([r.latitude, r.longitude], { radius: bin.radius, color: '#ffffff', weight: 1.5, fillColor: bin.color, fillOpacity: bin.opacity ?? .85 @@ -912,7 +939,7 @@ const latest = new Map(); measurements.forEach((item) => { const prior = latest.get(item.station_code); - if (!prior || new Date(item.timestamp) > new Date(prior.timestamp)) latest.set(item.station_code, item); + if (!prior || parseTs(item.timestamp) > parseTs(prior.timestamp)) latest.set(item.station_code, item); }); return latest; } @@ -945,7 +972,7 @@ function buildPopup(station, measurement) { const flow = measurement ? measurement.discharge : null; const level = measurement ? measurement.water_level : null; - const time = measurement ? new Date(measurement.timestamp).toLocaleString(loc(), { dateStyle: 'medium', timeStyle: 'short' }) : t('popup.noreading'); + const time = measurement ? parseTs(measurement.timestamp).toLocaleString(loc(), { timeZone: TZ, dateStyle: 'medium', timeStyle: 'short' }) : t('popup.noreading'); // Station names are bilingual in the data: lead with the reader's language const primary = state.lang === 'th' ? station.thai_name : station.english_name; const secondary = state.lang === 'th' ? station.english_name : station.thai_name; @@ -1125,8 +1152,7 @@ const downsample = (data) => { const buckets = {}; data.forEach((row) => { - const date = new Date(row.timestamp); - const key = `${date.getUTCFullYear()}-${date.getUTCMonth()}-${date.getUTCDate()}`; + const key = dayKey(row.timestamp); if (!buckets[key]) buckets[key] = { ts: row.timestamp, discharge: [], level: [] }; const b = buckets[key]; if (row.discharge != null) b.discharge.push(row.discharge); @@ -1147,12 +1173,7 @@ const fr = await fetch(`/api/forecast/history/${encodeURIComponent(stationCode)}?${query}&horizon=24`); const forecastRows = fr.ok ? await fr.json() : []; if (forecastRows.length) { - const keyOf = (value) => { - const d = new Date(value); - return rows.length > 2000 - ? `${d.getUTCFullYear()}-${d.getUTCMonth()}-${d.getUTCDate()}` - : `${d.getUTCFullYear()}-${d.getUTCMonth()}-${d.getUTCDate()}-${d.getUTCHours()}`; - }; + const keyOf = (value) => rows.length > 2000 ? dayKey(value) : hourKey(value); const byKey = new Map(); forecastRows.forEach((r) => { if (r.predicted_max_level == null) return; @@ -1177,7 +1198,7 @@ state.historyChart = new Chart($('history-chart'), { type: 'line', data: { - labels: sampled.map((row) => new Date(row.timestamp).toLocaleString(loc(), { timeZone: 'Asia/Bangkok', month: 'short', day: 'numeric', year: '2-digit', hour: '2-digit' })), + labels: sampled.map((row) => parseTs(row.timestamp).toLocaleString(loc(), { timeZone: TZ, month: 'short', day: 'numeric', year: '2-digit', hour: '2-digit' })), datasets: [ { label: t('chart.discharge'), data: sampled.map((row) => row.discharge), borderColor: '#087da5', backgroundColor: 'rgba(8,125,165,.12)', yAxisID: 'flow', pointRadius: 0, tension: .25 }, { label: t('chart.level'), data: sampled.map((row) => row.water_level), borderColor: '#d99018', yAxisID: 'level', pointRadius: 0, tension: .25 }, @@ -1343,7 +1364,7 @@ function setP1Level(value, at, opts) { const options = opts || {}; if (value == null || Number.isNaN(Number(value))) return; - const stamp = at ? new Date(at).getTime() : null; + const stamp = at ? (parseTs(at)?.getTime() ?? NaN) : null; const known = Number.isFinite(stamp) ? stamp : null; if (!options.force && known != null && state.p1NowAt != null && known < state.p1NowAt) { return; // an older snapshot must not overwrite a newer one @@ -1383,7 +1404,7 @@ function renderSummary(stations, readings) { const current = stations.map((s) => readings.get(s.station_code)).filter(Boolean); - const timestamps = current.map((m) => new Date(m.timestamp)).filter((date) => !Number.isNaN(date.getTime())); + const timestamps = current.map((m) => parseTs(m.timestamp)).filter(Boolean); const latest = timestamps.length ? new Date(Math.max(...timestamps.map((date) => date.getTime()))) : null; $('station-count').textContent = `${current.length} / ${stations.length}`; if (state.hiiReportingCount != null) { @@ -1403,12 +1424,24 @@ const worst = stressed.length ? stressed.reduce((max, item) => item.percent > max.percent ? item : max) : null; $('peak-flow').textContent = worst ? `${worst.percent.toFixed(0)}%` : '—'; $('peak-station').textContent = worst ? t('stat.stress.capacity', worst.code) : t('stat.stress.nodata'); - $('last-updated').textContent = latest ? latest.toLocaleTimeString(loc(), { hour: '2-digit', minute: '2-digit' }) : '—'; + $('last-updated').textContent = latest ? latest.toLocaleTimeString(loc(), { timeZone: TZ, hour: '2-digit', minute: '2-digit' }) : '—'; + const tile = $('last-updated').closest('.stat'); if (latest) { const minutes = Math.max(0, Math.round((Date.now() - latest.getTime()) / 60000)); - $('data-age').textContent = t('stat.updated.ago', - latest.toLocaleDateString(loc(), { day: 'numeric', month: 'short' }), minutes); - } else $('data-age').textContent = t('stat.updated.none'); + const day = latest.toLocaleDateString(loc(), { timeZone: TZ, day: 'numeric', month: 'short' }); + // RID publishes hourly and the scrape runs hourly, so anything past + // ~3 h means the feed or the collector has stopped: say so loudly + // instead of letting "6000 min ago" pass as a number. + const stale = minutes >= STALE_AFTER_MIN; + $('data-age').textContent = stale + ? t('stat.updated.stale', day, Math.round(minutes / 60)) + : minutes >= 120 ? t('stat.updated.agoh', day, Math.round(minutes / 60)) + : t('stat.updated.ago', day, minutes); + tile.classList.toggle('stale', stale); + if (!state.replayTimer && (state.liveMode === 'live' || state.liveMode === 'stale')) { + setLiveIndicator(stale ? 'stale' : 'live', stale ? 'pill.stale' : 'pill.live'); + } + } else { $('data-age').textContent = t('stat.updated.none'); tile.classList.remove('stale'); } } async function loadDashboard() { @@ -1655,13 +1688,13 @@ // force: replay frames are 2024 timestamps, older than anything live if (p1Level != null) setP1Level(p1Level, null, { force: true }); restyleFloodZones(); - const ts = new Date(data.timestamps[frame]); + const ts = parseTs(data.timestamps[frame]); // minute included: a lone "17" reads as a year in Thai output - const when = ts.toLocaleString(loc(), { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' }); + const when = ts.toLocaleString(loc(), { timeZone: TZ, day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' }); const p1Text = state.p1Now == null ? '—' : metres(state.p1Now); const basinFlow = Math.round(totalFlow).toLocaleString(loc()); $('p1-peak').textContent = t('replay.peak', when, p1Text, basinFlow); - $('replay-clock-time').textContent = ts.toLocaleString(loc(), { day: 'numeric', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit' }); + $('replay-clock-time').textContent = ts.toLocaleString(loc(), { timeZone: TZ, day: 'numeric', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit' }); $('replay-clock-sub').textContent = t('replay.clock.sub', p1Text, basinFlow); // model track: what the forecast system (trained pre-flood) said at this moment const model = data.model || {}; @@ -1850,9 +1883,9 @@ `
${chips}
`; grid.appendChild(cardEl); }); - const asOf = rows[0].as_of ? new Date(rows[0].as_of).toLocaleString(loc(), { dateStyle: 'medium', timeStyle: 'short' }) : null; + const asOf = rows[0].as_of ? parseTs(rows[0].as_of).toLocaleString(loc(), { timeZone: TZ, dateStyle: 'medium', timeStyle: 'short' }) : null; const modelRow = rows.find((r) => r.source === 'model'); - const trainedAt = modelRow?.trained_at ? new Date(modelRow.trained_at).toLocaleDateString(loc(), { day: 'numeric', month: 'short' }) : null; + const trainedAt = modelRow?.trained_at ? parseTs(modelRow.trained_at).toLocaleDateString(loc(), { timeZone: TZ, day: 'numeric', month: 'short' }) : null; const modelInfo = modelRow?.model_version ? t('forecast.status.model', modelRow.model_version, trainedAt ? t('forecast.status.trained', trainedAt) : '') : ''; @@ -1888,7 +1921,7 @@ const strip = $('db-stats'); try { state.lastStats = stats; - const fmtDate = (value) => new Date(value).toLocaleDateString(loc(), { day: 'numeric', month: 'short', year: 'numeric' }); + const fmtDate = (value) => parseTs(value).toLocaleDateString(loc(), { timeZone: TZ, day: 'numeric', month: 'short', year: 'numeric' }); state.dbFirstDate = String(stats.first_timestamp).slice(0, 10); // feeds the All-time date range $('db-total').textContent = Number(stats.total_measurements).toLocaleString(loc()); if (stats.rid_measurements != null) {