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-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-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-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; }
|
.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, .side-card { background: var(--card); border: 1px solid var(--border); border-radius: 19px; box-shadow: var(--shadow); overflow: hidden; }
|
||||||
.map-card { position: relative; }
|
.map-card { position: relative; }
|
||||||
@@ -407,6 +409,7 @@
|
|||||||
'app.title': 'Ping River Live Monitor',
|
'app.title': 'Ping River Live Monitor',
|
||||||
'app.subtitle': 'Current water level and discharge across Northern Thailand',
|
'app.subtitle': 'Current water level and discharge across Northern Thailand',
|
||||||
'pill.live': 'LIVE DATA',
|
'pill.live': 'LIVE DATA',
|
||||||
|
'pill.stale': '⚠ STALE FEED',
|
||||||
'pill.replay': '⏪ 2024 REPLAY',
|
'pill.replay': '⏪ 2024 REPLAY',
|
||||||
'pill.sim': '⚠ SIMULATION',
|
'pill.sim': '⚠ SIMULATION',
|
||||||
'action.refresh': '↻ Refresh',
|
'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.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': 'Last updated',
|
||||||
'stat.updated.note': 'Loading latest readings',
|
'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',
|
'stat.updated.none': 'No timestamp available',
|
||||||
'map.title': 'Station flow map',
|
'map.title': 'Station flow map',
|
||||||
'map.subtitle': 'River width, colour & dash speed follow live discharge',
|
'map.subtitle': 'River width, colour & dash speed follow live discharge',
|
||||||
@@ -579,6 +584,7 @@
|
|||||||
'app.title': 'ติดตามระดับน้ำปิงแบบเรียลไทม์',
|
'app.title': 'ติดตามระดับน้ำปิงแบบเรียลไทม์',
|
||||||
'app.subtitle': 'ระดับน้ำและอัตราการไหลปัจจุบันทั่วภาคเหนือของประเทศไทย',
|
'app.subtitle': 'ระดับน้ำและอัตราการไหลปัจจุบันทั่วภาคเหนือของประเทศไทย',
|
||||||
'pill.live': 'ข้อมูลสด',
|
'pill.live': 'ข้อมูลสด',
|
||||||
|
'pill.stale': '⚠ ข้อมูลไม่อัปเดต',
|
||||||
'pill.replay': '⏪ ย้อนเหตุการณ์ 2567',
|
'pill.replay': '⏪ ย้อนเหตุการณ์ 2567',
|
||||||
'pill.sim': '⚠ การจำลอง',
|
'pill.sim': '⚠ การจำลอง',
|
||||||
'action.refresh': '↻ รีเฟรช',
|
'action.refresh': '↻ รีเฟรช',
|
||||||
@@ -611,7 +617,9 @@
|
|||||||
'stat.stress.tip': 'ระดับความเต็มของลำน้ำที่สถานีที่มีน้ำมากที่สุด — 100% หมายถึงน้ำถึงระดับตลิ่ง',
|
'stat.stress.tip': 'ระดับความเต็มของลำน้ำที่สถานีที่มีน้ำมากที่สุด — 100% หมายถึงน้ำถึงระดับตลิ่ง',
|
||||||
'stat.updated': 'อัปเดตล่าสุด',
|
'stat.updated': 'อัปเดตล่าสุด',
|
||||||
'stat.updated.note': 'กำลังโหลดข้อมูลล่าสุด',
|
'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': 'ไม่มีข้อมูลเวลา',
|
'stat.updated.none': 'ไม่มีข้อมูลเวลา',
|
||||||
'map.title': 'แผนที่การไหลของน้ำ',
|
'map.title': 'แผนที่การไหลของน้ำ',
|
||||||
'map.subtitle': 'ความกว้าง สี และความเร็วเส้นประของแม่น้ำแสดงอัตราการไหลจริง',
|
'map.subtitle': 'ความกว้าง สี และความเร็วเส้นประของแม่น้ำแสดงอัตราการไหลจริง',
|
||||||
@@ -841,6 +849,25 @@
|
|||||||
// what the replay label ("ต.ค. 2567") already says — pinning Gregorian here
|
// what the replay label ("ต.ค. 2567") already says — pinning Gregorian here
|
||||||
// put two different year systems on the same screen.
|
// put two different year systems on the same screen.
|
||||||
function loc() { return state.lang === 'th' ? 'th-TH' : 'en-GB'; }
|
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.
|
// Metre abbreviation: "ม." reads as a unit in Thai, "m" mid-sentence does not.
|
||||||
function metres(value, digits = 2) {
|
function metres(value, digits = 2) {
|
||||||
return `${Number(value).toFixed(digits)} ${t('unit.m')}`;
|
return `${Number(value).toFixed(digits)} ${t('unit.m')}`;
|
||||||
@@ -885,7 +912,7 @@
|
|||||||
const mm = r.rain_24h == null ? null : Number(r.rain_24h);
|
const mm = r.rain_24h == null ? null : Number(r.rain_24h);
|
||||||
const bin = rainBin(mm);
|
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 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], {
|
L.circleMarker([r.latitude, r.longitude], {
|
||||||
radius: bin.radius, color: '#ffffff', weight: 1.5,
|
radius: bin.radius, color: '#ffffff', weight: 1.5,
|
||||||
fillColor: bin.color, fillOpacity: bin.opacity ?? .85
|
fillColor: bin.color, fillOpacity: bin.opacity ?? .85
|
||||||
@@ -912,7 +939,7 @@
|
|||||||
const latest = new Map();
|
const latest = new Map();
|
||||||
measurements.forEach((item) => {
|
measurements.forEach((item) => {
|
||||||
const prior = latest.get(item.station_code);
|
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;
|
return latest;
|
||||||
}
|
}
|
||||||
@@ -945,7 +972,7 @@
|
|||||||
function buildPopup(station, measurement) {
|
function buildPopup(station, measurement) {
|
||||||
const flow = measurement ? measurement.discharge : null;
|
const flow = measurement ? measurement.discharge : null;
|
||||||
const level = measurement ? measurement.water_level : 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
|
// 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 primary = state.lang === 'th' ? station.thai_name : station.english_name;
|
||||||
const secondary = state.lang === 'th' ? station.english_name : station.thai_name;
|
const secondary = state.lang === 'th' ? station.english_name : station.thai_name;
|
||||||
@@ -1125,8 +1152,7 @@
|
|||||||
const downsample = (data) => {
|
const downsample = (data) => {
|
||||||
const buckets = {};
|
const buckets = {};
|
||||||
data.forEach((row) => {
|
data.forEach((row) => {
|
||||||
const date = new Date(row.timestamp);
|
const key = dayKey(row.timestamp);
|
||||||
const key = `${date.getUTCFullYear()}-${date.getUTCMonth()}-${date.getUTCDate()}`;
|
|
||||||
if (!buckets[key]) buckets[key] = { ts: row.timestamp, discharge: [], level: [] };
|
if (!buckets[key]) buckets[key] = { ts: row.timestamp, discharge: [], level: [] };
|
||||||
const b = buckets[key];
|
const b = buckets[key];
|
||||||
if (row.discharge != null) b.discharge.push(row.discharge);
|
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 fr = await fetch(`/api/forecast/history/${encodeURIComponent(stationCode)}?${query}&horizon=24`);
|
||||||
const forecastRows = fr.ok ? await fr.json() : [];
|
const forecastRows = fr.ok ? await fr.json() : [];
|
||||||
if (forecastRows.length) {
|
if (forecastRows.length) {
|
||||||
const keyOf = (value) => {
|
const keyOf = (value) => rows.length > 2000 ? dayKey(value) : hourKey(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 byKey = new Map();
|
const byKey = new Map();
|
||||||
forecastRows.forEach((r) => {
|
forecastRows.forEach((r) => {
|
||||||
if (r.predicted_max_level == null) return;
|
if (r.predicted_max_level == null) return;
|
||||||
@@ -1177,7 +1198,7 @@
|
|||||||
state.historyChart = new Chart($('history-chart'), {
|
state.historyChart = new Chart($('history-chart'), {
|
||||||
type: 'line',
|
type: 'line',
|
||||||
data: {
|
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: [
|
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.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 },
|
{ 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) {
|
function setP1Level(value, at, opts) {
|
||||||
const options = opts || {};
|
const options = opts || {};
|
||||||
if (value == null || Number.isNaN(Number(value))) return;
|
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;
|
const known = Number.isFinite(stamp) ? stamp : null;
|
||||||
if (!options.force && known != null && state.p1NowAt != null && known < state.p1NowAt) {
|
if (!options.force && known != null && state.p1NowAt != null && known < state.p1NowAt) {
|
||||||
return; // an older snapshot must not overwrite a newer one
|
return; // an older snapshot must not overwrite a newer one
|
||||||
@@ -1383,7 +1404,7 @@
|
|||||||
|
|
||||||
function renderSummary(stations, readings) {
|
function renderSummary(stations, readings) {
|
||||||
const current = stations.map((s) => readings.get(s.station_code)).filter(Boolean);
|
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;
|
const latest = timestamps.length ? new Date(Math.max(...timestamps.map((date) => date.getTime()))) : null;
|
||||||
$('station-count').textContent = `${current.length} / ${stations.length}`;
|
$('station-count').textContent = `${current.length} / ${stations.length}`;
|
||||||
if (state.hiiReportingCount != null) {
|
if (state.hiiReportingCount != null) {
|
||||||
@@ -1403,12 +1424,24 @@
|
|||||||
const worst = stressed.length ? stressed.reduce((max, item) => item.percent > max.percent ? item : max) : null;
|
const worst = stressed.length ? stressed.reduce((max, item) => item.percent > max.percent ? item : max) : null;
|
||||||
$('peak-flow').textContent = worst ? `${worst.percent.toFixed(0)}%` : '—';
|
$('peak-flow').textContent = worst ? `${worst.percent.toFixed(0)}%` : '—';
|
||||||
$('peak-station').textContent = worst ? t('stat.stress.capacity', worst.code) : t('stat.stress.nodata');
|
$('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) {
|
if (latest) {
|
||||||
const minutes = Math.max(0, Math.round((Date.now() - latest.getTime()) / 60000));
|
const minutes = Math.max(0, Math.round((Date.now() - latest.getTime()) / 60000));
|
||||||
$('data-age').textContent = t('stat.updated.ago',
|
const day = latest.toLocaleDateString(loc(), { timeZone: TZ, day: 'numeric', month: 'short' });
|
||||||
latest.toLocaleDateString(loc(), { day: 'numeric', month: 'short' }), minutes);
|
// RID publishes hourly and the scrape runs hourly, so anything past
|
||||||
} else $('data-age').textContent = t('stat.updated.none');
|
// ~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() {
|
async function loadDashboard() {
|
||||||
@@ -1655,13 +1688,13 @@
|
|||||||
// force: replay frames are 2024 timestamps, older than anything live
|
// force: replay frames are 2024 timestamps, older than anything live
|
||||||
if (p1Level != null) setP1Level(p1Level, null, { force: true });
|
if (p1Level != null) setP1Level(p1Level, null, { force: true });
|
||||||
restyleFloodZones();
|
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
|
// 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 p1Text = state.p1Now == null ? '—' : metres(state.p1Now);
|
||||||
const basinFlow = Math.round(totalFlow).toLocaleString(loc());
|
const basinFlow = Math.round(totalFlow).toLocaleString(loc());
|
||||||
$('p1-peak').textContent = t('replay.peak', when, p1Text, basinFlow);
|
$('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);
|
$('replay-clock-sub').textContent = t('replay.clock.sub', p1Text, basinFlow);
|
||||||
// model track: what the forecast system (trained pre-flood) said at this moment
|
// model track: what the forecast system (trained pre-flood) said at this moment
|
||||||
const model = data.model || {};
|
const model = data.model || {};
|
||||||
@@ -1850,9 +1883,9 @@
|
|||||||
`<div class="risk-chips">${chips}</div>`;
|
`<div class="risk-chips">${chips}</div>`;
|
||||||
grid.appendChild(cardEl);
|
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 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
|
const modelInfo = modelRow?.model_version
|
||||||
? t('forecast.status.model', modelRow.model_version, trainedAt ? t('forecast.status.trained', trainedAt) : '')
|
? t('forecast.status.model', modelRow.model_version, trainedAt ? t('forecast.status.trained', trainedAt) : '')
|
||||||
: '';
|
: '';
|
||||||
@@ -1888,7 +1921,7 @@
|
|||||||
const strip = $('db-stats');
|
const strip = $('db-stats');
|
||||||
try {
|
try {
|
||||||
state.lastStats = stats;
|
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
|
state.dbFirstDate = String(stats.first_timestamp).slice(0, 10); // feeds the All-time date range
|
||||||
$('db-total').textContent = Number(stats.total_measurements).toLocaleString(loc());
|
$('db-total').textContent = Number(stats.total_measurements).toLocaleString(loc());
|
||||||
if (stats.rid_measurements != null) {
|
if (stats.rid_measurements != null) {
|
||||||
|
|||||||
Reference in New Issue
Block a user