feat(dashboard): console phase 4 - 2024 replay, simulation, replaces the old page
CI / Format & lint (push) Successful in 26s
CI / Test suite (push) Successful in 1m15s
Docs / Validate documentation (push) Successful in 12s

The console is now the dashboard at / (src/static/dashboard.html); the old
page and console.html are gone.

- Replay of the October 2024 flood (flood-2024-all.json): the page's clock,
  verdict, list, markers, flood zones and panel follow the replay; the panel
  charts the season on fixed axes with the 2024 model's 24 h peak. Hourly
  while the model alerts or P.1 is above 3.50 m, faster through quiet days;
  pause and drag. Rain, basin stations and the 5-minute refresh are off
  during the replay (today's data).
- ?demo_level= and ?demo_rise= simulate P.1. A violet REPLAY / amber
  SIMULATION bar, the top bar's KPI and the P.1 panel say so; the mode is
  drawn from state.mode alone, so a refresh or language switch cannot
  relabel either as live.
- History: rows without discharge_percent are ThaiWater's copy of the gauge
  (night hours RID publishes late), with a different rating (P.1: 26 against
  RID's 54 m3/s); their discharge is not drawn for gauges with RID discharge.
  The discharge strip has headroom so its label stays clear of the line.
- Swap: the old page's title, description, Open Graph and JSON-LD tags; no
  noindex; tests/test_dashboard.py = console tests + the old checks that
  still apply (one P.1 level, SEO files) + a replay/simulation labelling test.
- Docs: README screenshot (docs/img/dashboard.jpg, replacing two unused
  images), CLAUDE.md, CONTRIBUTING.md, FLOOD_FORECASTING.md (where the model
  shows), DASHBOARD_REDESIGN.md (phase 4 done, mode rules).

Deploy needs a restart: the server reads dashboard.html at start.
This commit is contained in:
grabowski committed 2026-10-20 13:47:15 +07:00
1 parent ffd3030352
commit cf10f2e4c8
15 files changed
+2419 -4599

No files matched your search

-130
View File
@@ -1,130 +0,0 @@
"""The console dashboard (src/static/console.html, docs/DASHBOARD_REDESIGN.md).
Served at /static/console.html while it is built; it replaces dashboard.html
once it covers every feature. The rules are the dashboard's: every fetched
path is routed, every string has EN and TH, no emoji in the interface.
"""
import re
from pathlib import Path
STATIC = Path(__file__).parents[1] / "src" / "static"
CONSOLE = STATIC / "console.html"
ROUTER = Path(__file__).parents[1] / "rust" / "collector" / "src" / "web" / "mod.rs"
def _html() -> str:
return CONSOLE.read_text(encoding="utf-8")
def _body(html: str) -> str:
return html[html.index("<body>") : html.index("<script src=")]
def _tables(html: str) -> dict:
start = html.index("const STRINGS = {")
end = html.index("// Thai unless the visitor's browser", start)
block = html[start:end]
tables = {}
for lang in ("en", "th"):
section = re.search(rf"\n {lang}: {{\n(.*?)\n }},\n", block, re.S)
assert section, f"{lang} table not found in STRINGS"
tables[lang] = set(re.findall(r"^\s{12}'([^']+)':", section.group(1), re.M))
return tables
def test_every_fetched_path_is_routed():
routes = set(re.findall(r'\.route\(\s*"([^"]+)"', ROUTER.read_text("utf-8")))
patterns = [
re.compile("^" + re.sub(r"\\\{[^}]+\\\}", "[^/]+", re.escape(r)) + "$")
for r in routes
]
fetched = set(re.findall(r"fetch\(\s*[`'\"](/[^`'\"?$]*)", _html()))
assert {"/stations", "/forecast", "/api/notifications"} <= fetched
missing = [
p
for p in fetched
if not p.startswith("/static/")
and not any(r.match(p.rstrip("/") or "/") for r in patterns)
and not any(r.startswith(p) for r in routes)
]
assert not missing, f"console fetches paths the server does not route: {missing}"
def test_static_files_it_loads_exist():
for path in re.findall(
r"/static/([\w./-]+\.(?:geojson|json|woff2|pmtiles))", _html()
):
assert (STATIC / path).is_file(), path
def test_translations_cover_both_languages():
tables = _tables(_html())
assert len(tables["en"]) > 100
assert not tables["en"] - tables["th"], sorted(tables["en"] - tables["th"])
assert not tables["th"] - tables["en"], sorted(tables["th"] - tables["en"])
def test_markup_keys_exist():
html = _html()
used = set(re.findall(r'data-i18n(?:-placeholder|-title|-aria)?="([^"]+)"', html))
assert not used - _tables(html)["en"], sorted(used - _tables(html)["en"])
def test_aria_labels_are_translated():
for match in re.finditer(r'<[^>]*\saria-label="[^"]+"[^>]*>', _body(_html())):
assert "data-i18n-aria" in match.group(0), match.group(0)[:120]
def test_no_emoji_in_the_interface():
emoji = re.compile("[\U0001f000-\U0001faff\u2600-\u27bf\u2b00-\u2bff\ufe0f]")
found = sorted(set(emoji.findall(_html())))
assert not found, f"emoji in console.html: {found}"
def test_thai_first_and_river_time():
html = _html()
assert "if (code.startsWith('th')) return 'th';" in html
assert "const TZ = 'Asia/Bangkok';" in html
assert "'+07:00'" in html # naive API timestamps are pinned to Bangkok
def test_fonts_are_self_hosted_with_their_licence():
html = _html()
assert "fonts.googleapis.com" not in html
for name in ("inter-latin.woff2", "noto-sans-thai-thai.woff2"):
assert f"/static/fonts/{name}" in html
assert (STATIC / "fonts" / "inter-OFL.txt").is_file()
assert (STATIC / "fonts" / "noto-sans-thai-OFL.txt").is_file()
def test_basemap_is_self_hosted():
html = _html()
assert "/static/basemap/ping-basin.pmtiles" in html
assert "protomaps-leaflet@5.1.0" in html and 'integrity="sha384-' in html
# the attribution OpenStreetMap's licence requires
assert "openstreetmap.org/copyright" in html
def test_basin_stations_have_a_fallback_feed():
html = _html()
assert "fetch('/api/hii/waterlevel/latest')" in html
assert "fetch('/sensors/thaiwater')" in html
def test_history_reaches_today():
"""/measurements/history returns the oldest rows first and stops at its
limit (50,000 by default): "All" must ask for more, or P.1 ends in 2024."""
assert "hours=${hours}&limit=100000" in _html()
dashboard = (STATIC / "dashboard.html").read_text(encoding="utf-8")
assert "&limit=100000" in dashboard
def test_skill_loads_only_when_opened():
html = _html()
assert "fetch('/api/forecast/skill?station_code=P.1&horizon=24')" in html
assert "sk.addEventListener('toggle'" in html
def test_preview_is_not_indexed():
assert '<meta name="robots" content="noindex">' in _html()
+149 -187
View File
@@ -1,47 +1,20 @@
"""The dashboard served at / (src/static/dashboard.html, docs/DASHBOARD_REDESIGN.md).
Rules: every fetched path is routed, every string has EN and TH (Thai first),
no emoji in the interface, and a replay or a simulation is never shown as
live data.
"""
import re
from pathlib import Path
DASHBOARD_PATH = Path(__file__).parents[1] / "src" / "static" / "dashboard.html"
ROUTER_PATH = (
Path(__file__).parents[1] / "rust" / "collector" / "src" / "web" / "mod.rs"
)
STATIC = Path(__file__).parents[1] / "src" / "static"
DASHBOARD = STATIC / "dashboard.html"
ROUTER = Path(__file__).parents[1] / "rust" / "collector" / "src" / "web" / "mod.rs"
def _rust_routes() -> set:
"""Paths the website serves (ping-collector's axum router)."""
source = ROUTER_PATH.read_text(encoding="utf-8")
return set(re.findall(r'\.route\(\s*"([^"]+)"', source))
def test_every_api_path_the_dashboard_fetches_is_served():
html = DASHBOARD_PATH.read_text(encoding="utf-8")
routes = _rust_routes()
patterns = [
re.compile("^" + re.sub(r"\\\{[^}]+\\\}", "[^/]+", re.escape(r)) + "$")
for r in routes
]
fetched = set(re.findall(r"fetch\(\s*[`'\"](/[^`'\"?$]*)", html))
assert fetched, "no fetch() calls found"
missing = [
path
for path in fetched
if not path.startswith("/static/")
and not any(p.match(path.rstrip("/") or "/") for p in patterns)
and not any(r.startswith(path) for r in routes)
]
assert not missing, f"dashboard fetches paths the server does not route: {missing}"
def test_dashboard_contains_live_map_and_flow_visualization():
html = DASHBOARD_PATH.read_text(encoding="utf-8")
assert 'id="station-map"' in html
assert 'id="river-flow"' in html
assert "fetch('/stations')" in html or 'fetch("/stations")' in html
assert (
"fetch('/measurements/latest" in html or 'fetch("/measurements/latest' in html
)
assert "leaflet" in html.lower()
def _html() -> str:
return DASHBOARD.read_text(encoding="utf-8")
def _body(html: str) -> str:
@@ -50,92 +23,15 @@ def _body(html: str) -> str:
return html[html.index("<body>") : html.index("<script src=")]
def test_dashboard_explains_flow_legend_and_refresh():
body = _body(DASHBOARD_PATH.read_text(encoding="utf-8"))
assert 'data-i18n="legend.flow"' in body
assert 'data-i18n="stat.updated"' in body
assert 'data-i18n="action.refresh"' in body
def _routes() -> set:
"""Paths the website serves (ping-collector's axum router)."""
return set(re.findall(r'\.route\(\s*"([^"]+)"', ROUTER.read_text("utf-8")))
def test_dashboard_uses_mapped_river_network_instead_of_station_connections():
html = DASHBOARD_PATH.read_text(encoding="utf-8")
river_network = DASHBOARD_PATH.with_name("ping-river-network.geojson")
assert river_network.exists()
assert "fetch('/static/ping-river-network.geojson')" in html
assert "mainBasin.map" not in html
def test_dashboard_loads_additional_thaiwater_sensors():
html = DASHBOARD_PATH.read_text(encoding="utf-8")
assert "fetch('/sensors/thaiwater')" in html
assert 'data-i18n="sensors.title"' in _body(html)
assert 'id="station-search"' in html
assert "applyStationSearch" in html
def test_dashboard_has_seo_and_indexing_files():
html = DASHBOARD_PATH.read_text(encoding="utf-8")
static_dir = DASHBOARD_PATH.parent
assert '<meta name="description"' in html
assert '<link rel="canonical" href="https://water.buildfor.life/">' in html
assert 'property="og:title"' in html
assert 'rel="icon"' in html
robots = (static_dir / "robots.txt").read_text(encoding="utf-8")
assert "Sitemap: https://water.buildfor.life/sitemap.xml" in robots
assert "<loc>https://water.buildfor.life/</loc>" in (
static_dir / "sitemap.xml"
).read_text(encoding="utf-8")
assert "Ping River Live Monitor" in (static_dir / "llms.txt").read_text(
encoding="utf-8"
)
assert {"/robots.txt", "/llms.txt", "/sitemap.xml"} <= _rust_routes()
def test_dashboard_shows_hii_rainfall_layer():
html = DASHBOARD_PATH.read_text(encoding="utf-8")
assert "fetch('/api/hii/rainfall/latest')" in html
assert "fetch('/api/hii/waterlevel/latest')" in html
assert "renderRainLayer" in html
assert "rain-toggle" in html
body = _body(html)
assert 'data-i18n="legend.rain"' in body
# TMD rain classes on the legend
assert 'data-i18n="legend.rain.heavy"' in body
assert 'data-i18n="legend.rain.extreme"' in body
def test_dashboard_loads_station_history_chart():
html = DASHBOARD_PATH.read_text(encoding="utf-8")
assert 'id="history-card"' in html
assert "/api/forecast/history/" in html
assert "'chart.model'" in html
assert "PostgreSQL" not in html
assert "/measurements/history/" in html
assert "history-chart" in html
# Date-range picker alongside the quick-range dropdown
assert 'id="history-start"' in html
assert 'id="history-end"' in html
assert 'data-i18n="range.7d"' in _body(html)
assert 'data-i18n="range.90d"' in _body(html)
def _extract_lang_tables(html: str) -> dict:
"""Pull the `en:` / `th:` key sets out of the STRINGS literal.
Parsing the JS with a regex is crude, but it is enough to catch the failure
that matters: a key added to one language and forgotten in the other, which
silently falls back to English for Thai readers.
"""
import re
def _tables(html: str) -> dict:
"""The `en:` / `th:` key sets of the STRINGS literal. Crude, but it
catches a key added to one language and forgotten in the other, which
silently falls back to English for Thai readers."""
start = html.index("const STRINGS = {")
end = html.index("// Thai unless the visitor's browser", start)
block = html[start:end]
@@ -147,86 +43,152 @@ def _extract_lang_tables(html: str) -> dict:
return tables
def test_dashboard_translations_cover_both_languages():
html = DASHBOARD_PATH.read_text(encoding="utf-8")
tables = _extract_lang_tables(html)
assert len(tables["en"]) > 100, "expected the full English string table"
missing_th = tables["en"] - tables["th"]
missing_en = tables["th"] - tables["en"]
assert not missing_th, f"keys missing a Thai translation: {sorted(missing_th)}"
assert (
not missing_en
), f"Thai-only keys with no English fallback: {sorted(missing_en)}"
def test_every_fetched_path_is_routed():
routes = _routes()
patterns = [
re.compile("^" + re.sub(r"\\\{[^}]+\\\}", "[^/]+", re.escape(r)) + "$")
for r in routes
]
fetched = set(re.findall(r"fetch\(\s*[`'\"](/[^`'\"?$]*)", _html()))
assert {"/stations", "/forecast", "/api/notifications"} <= fetched
missing = [
p
for p in fetched
if not p.startswith("/static/")
and not any(r.match(p.rstrip("/") or "/") for r in patterns)
and not any(r.startswith(p) for r in routes)
]
assert not missing, f"dashboard fetches paths the server does not route: {missing}"
def test_dashboard_i18n_markup_keys_exist():
"""Every data-i18n attribute must resolve to a real string key."""
import re
def test_static_files_it_loads_exist():
paths = re.findall(r"/static/([\w./-]+\.(?:geojson|json|woff2|pmtiles))", _html())
assert "ping-river-network.geojson" in paths # mapped rivers, not lines
assert "flood-2024-all.json" in paths
for path in paths:
assert (STATIC / path).is_file(), path
html = DASHBOARD_PATH.read_text(encoding="utf-8")
keys = _extract_lang_tables(html)["en"]
def test_translations_cover_both_languages():
tables = _tables(_html())
assert len(tables["en"]) > 100
assert not tables["en"] - tables["th"], sorted(tables["en"] - tables["th"])
assert not tables["th"] - tables["en"], sorted(tables["th"] - tables["en"])
def test_markup_keys_exist():
html = _html()
used = set(re.findall(r'data-i18n(?:-placeholder|-title|-aria)?="([^"]+)"', html))
unknown = used - keys
assert not unknown, f"markup references undefined string keys: {sorted(unknown)}"
assert not used - _tables(html)["en"], sorted(used - _tables(html)["en"])
def test_dashboard_is_mobile_portrait_safe():
html = DASHBOARD_PATH.read_text(encoding="utf-8")
# The header row overflowed a 412 px Android viewport by 64 px until it wrapped
assert "flex-wrap: wrap" in html
assert "overflow-x: hidden" in html
assert 'id="lang-toggle"' in html
assert "ping-monitor-lang" in html # remembered language choice
def test_dashboard_translates_user_visible_aria_labels():
def test_aria_labels_are_translated():
"""A Thai page must not hand screen-reader users English landmarks."""
import re
html = DASHBOARD_PATH.read_text(encoding="utf-8")
body = _body(html)
for match in re.finditer(r'<[^>]*\saria-label="[^"]+"[^>]*>', body):
tag = match.group(0)
if "data-i18n-aria" in tag or 'id="lang-toggle"' in tag:
continue # the toggle sets its own label per language in JS
raise AssertionError(f"aria-label without a translation key: {tag[:120]}")
for match in re.finditer(r'<[^>]*\saria-label="[^"]+"[^>]*>', _body(_html())):
assert "data-i18n-aria" in match.group(0), match.group(0)[:120]
def test_dashboard_keeps_simulation_and_replay_labels_on_language_switch():
"""Relabelling a pinned simulation as LIVE would present fake flood data as real."""
html = DASHBOARD_PATH.read_text(encoding="utf-8")
assert "setLiveIndicator(state.liveMode || 'live', state.liveLabelKey)" in html
assert "if (!state.replayTimer) setLiveIndicator('live');" not in html
def test_no_emoji_in_the_interface():
emoji = re.compile("[\U0001f000-\U0001faff\u2600-\u27bf\u2b00-\u2bff\ufe0f]")
found = sorted(set(emoji.findall(_html())))
assert not found, f"emoji in dashboard.html: {found}"
def test_dashboard_default_language_respects_browser_order():
"""navigator.languages = ['th-TH','en-US'] must resolve to Thai, not English."""
html = DASHBOARD_PATH.read_text(encoding="utf-8")
assert "langs.some" not in html # the old any-English-wins test
def test_thai_first_and_river_time():
"""navigator.languages = ['th-TH','en-US'] must resolve to Thai."""
html = _html()
assert "if (code.startsWith('th')) return 'th';" in html
assert "ping-monitor-lang" in html # remembered language choice
assert "const TZ = 'Asia/Bangkok';" in html
assert "'+07:00'" in html # naive API timestamps are pinned to Bangkok
def test_dashboard_shows_one_current_river_level():
"""The verdict banner and the P.1 outlook must not disagree about "now".
def test_phone_safe():
html = _html()
assert "overflow-x: hidden" in html
assert 'name="viewport"' in html
Production served 1.66 m in the banner and 1.52 m in the outlook at the
same moment: the banner used the latest measurement, the outlook used the
forecast payload's current_level from an older as_of.
def test_fonts_are_self_hosted_with_their_licence():
html = _html()
assert "fonts.googleapis.com" not in html
for name in ("inter-latin.woff2", "noto-sans-thai-thai.woff2"):
assert f"/static/fonts/{name}" in html
assert (STATIC / "fonts" / "inter-OFL.txt").is_file()
assert (STATIC / "fonts" / "noto-sans-thai-OFL.txt").is_file()
def test_basemap_is_self_hosted():
html = _html()
assert "/static/basemap/ping-basin.pmtiles" in html
assert "protomaps-leaflet@5.1.0" in html and 'integrity="sha384-' in html
# the attribution OpenStreetMap's licence requires
assert "openstreetmap.org/copyright" in html
def test_basin_stations_and_rain_have_their_feeds():
html = _html()
assert "fetch('/api/hii/waterlevel/latest')" in html
assert "fetch('/sensors/thaiwater')" in html # fallback feed
assert "fetch('/api/hii/rainfall/latest')" in html
def test_history_reaches_today():
"""/measurements/history returns the oldest rows first and stops at its
limit (50,000 by default): "All" must ask for more, or P.1 ends in 2024."""
html = _html()
assert "hours=${hours}&limit=100000" in html
assert "/api/forecast/history/" in html
def test_skill_loads_only_when_opened():
html = _html()
assert "fetch('/api/forecast/skill?station_code=P.1&horizon=24')" in html
assert "sk.addEventListener('toggle'" in html
def test_one_current_river_level():
"""The verdict and the P.1 outlook must not disagree about "now".
The old dashboard once showed 1.66 m in the banner and 1.52 m in the
outlook: the outlook used the forecast payload's current_level from an
older as_of. Every view reads the gauge's level from one station record.
"""
html = DASHBOARD_PATH.read_text(encoding="utf-8")
html = _html()
assert "current_level" not in html
assert "const p1 = state.byCode.get('P.1');" in html
# One arbitrated writer, freshest-wins
assert "function setP1Level(" in html
assert "state.p1NowAt" in html
# Neither feed may assign the level directly any more
assert "state.p1Now = Number(p1.water_level)" not in html
assert "state.p1Now = row.current_level" not in html
def test_replay_and_simulation_are_never_shown_as_live():
"""Relabelling a replay or a pinned simulation as live would present
2024 or invented flood data as today's."""
html = _html()
# the mode bar and the data-age KPI are drawn from state.mode alone, and
# redrawn on a language switch
assert "bar.hidden = state.mode === 'live';" in html
assert "if (newest && state.mode === 'live') {" in html
assert "renderStats();\n renderModebar();\n }" in html
# the 5-minute refresh would replace the replay frame with today's data
assert "if (state.mode !== 'replay') { load(); loadStats(); }" in html
# the demo hooks
assert "params.get('demo_level')" in html and "params.has('demo_rise')" in html
for key in ("mode.replay", "mode.sim", "sim.pinned", "sim.rising"):
assert f"'{key}':" in html
# The outlook renders the arbitrated level, and never a peak below it
assert "const shownNow = state.p1Now != null" in html
assert "Math.max(Number(row.predicted_max_level), shownNow)" in html
def test_seo_and_indexing_files():
html = _html()
assert '<meta name="description"' in html
assert '<link rel="canonical" href="https://water.buildfor.life/">' in html
assert 'property="og:title"' in html
assert "application/ld+json" in html
assert 'rel="icon"' in html
assert "noindex" not in html
robots = (STATIC / "robots.txt").read_text(encoding="utf-8")
assert "Sitemap: https://water.buildfor.life/sitemap.xml" in robots
sitemap = (STATIC / "sitemap.xml").read_text(encoding="utf-8")
assert "<loc>https://water.buildfor.life/</loc>" in sitemap
llms = (STATIC / "llms.txt").read_text(encoding="utf-8")
assert "Ping River Live Monitor" in llms
assert {"/robots.txt", "/llms.txt", "/sitemap.xml"} <= _routes()