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:
2026-09-11 23:00:39 +02:00
parent ce08312c0f
commit 6f4a86edbb
2 changed files with 110 additions and 26 deletions
+59 -26
View File
@@ -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) {