feat: Thai localisation and portrait-phone layout for the dashboard

Thai is the default unless the browser prefers English, chosen by first
match in navigator.languages order and remembered in localStorage. A
STRINGS table carries both languages (interpolated strings as functions),
applyTranslations() drives static markup via data-i18n attributes, and
setLang() rebuilds everything the JS renders — including map layers, so
popups render in the current language and sensor markers are replaced
rather than stacked. Thai dates use the Buddhist era, matching the
replay label; station names lead with the reader's language.

Portrait phones: the header overflowed a 412 px Android viewport by
64 px, so the page scrolled sideways and the Refresh button sat off
screen. The header now wraps into two deliberate rows (DOM order matches
visual order, so focus order is unaffected), the map description box is
hidden on phones, map height is capped by viewport — including a
height-gated rule for landscape phones, whose 850-960 px widths never
matched the width breakpoints — and the forecast grid goes single
column. Verified in a real browser at 412x915, 360x800, 915x412 and
1440x900: zero horizontal overflow, no desktop change.

Review-swarm fixes: a language switch no longer relabels a pinned
SIMULATION or the 2024 replay as LIVE DATA (it kept the mode from
state); Thai wording corrected where it asserted a rising trend the code
never checks, labelled every gauge 'critical', or used a malformed
compound; aria-labels, the Leaflet load failure and the flood-stage
chips are translated; the language toggle states its action instead of
an aria-pressed value that contradicted its label; and Thai font
families sit after the Latin stack so they cannot restyle English text.
This commit is contained in:
2026-08-13 23:10:34 +07:00
parent 5dc5850df6
commit b02e815d72
2 changed files with 823 additions and 187 deletions
+722 -174
View File
File diff suppressed because it is too large Load Diff
+101 -13
View File
@@ -14,12 +14,18 @@ def test_dashboard_contains_live_map_and_flow_visualization():
assert "leaflet" in html.lower()
def test_dashboard_explains_flow_legend_and_refresh():
html = DASHBOARD_PATH.read_text(encoding="utf-8")
def _body(html: str) -> str:
"""Markup only. The STRINGS table repeats every English phrase, so a
whole-file search would pass even if an element were deleted."""
return html[html.index("<body>"):html.index("<script src=")]
assert "Flow status" in html
assert "Last updated" in html
assert "Refresh" in html
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 test_dashboard_uses_mapped_river_network_instead_of_station_connections():
@@ -35,7 +41,7 @@ def test_dashboard_loads_additional_thaiwater_sensors():
html = DASHBOARD_PATH.read_text(encoding="utf-8")
assert "fetch('/sensors/thaiwater')" in html
assert "Additional basin stations" in html
assert 'data-i18n="sensors.title"' in _body(html)
assert 'id="station-search"' in html
assert "applyStationSearch" in html
@@ -67,23 +73,105 @@ def test_dashboard_shows_hii_rainfall_layer():
assert "fetch('/api/hii/waterlevel/latest')" in html
assert "renderRainLayer" in html
assert "rain-toggle" in html
assert "Rainfall · 24 h" in html
body = _body(html)
assert 'data-i18n="legend.rain"' in body
# TMD rain classes on the legend
assert "Heavy 3590" in html
assert "Extreme &gt; 150" in html
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 "Station history" in html
assert 'id="history-card"' in html
assert "/api/forecast/history/" in html
assert "Model 24 h peak (as issued)" 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 "Last 7 days" in html
assert "Last 90 days" 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
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_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_dashboard_i18n_markup_keys_exist():
"""Every data-i18n attribute must resolve to a real string key."""
import re
html = DASHBOARD_PATH.read_text(encoding="utf-8")
keys = _extract_lang_tables(html)["en"]
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)}"
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():
"""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]}")
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_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
assert "if (code.startsWith('th')) return 'th';" in html