fix(dashboard): timestamps are Asia/Bangkok everywhere; stale feed says so
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 <day>, 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.
This commit is contained in:
@@ -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()
|
||||
+59
-26
@@ -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 @@
|
||||
`<div class="risk-chips">${chips}</div>`;
|
||||
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) {
|
||||
|
||||
Reference in New Issue
Block a user