feat(dashboard): the console, phase 1 of the redesign (preview at /static/console.html)
CI / Format & lint (push) Successful in 24s
CI / Test suite (push) Successful in 1m31s
Docs / Validate documentation (push) Successful in 10s

The user chose direction B of the redesign: a full-screen operations console.
It is built next to the current dashboard, served by ServeDir at
/static/console.html (noindex, links back to /), and replaces dashboard.html
once it covers every feature (docs/DASHBOARD_REDESIGN.md, phase 4).

Phase 1:
- top bar: the Chiang Mai verdict (P.1 level + 24 h forecast, the current
  dashboard's rule), gauges reporting, highest 24 h flood chance, data age
  ("overnight pause" instead of "stale" during RID's nightly gap, as in the
  notifier), EN/TH, light/dark (dark default), Get alerts
- gauge list: one status rule for list, map and panel (normal, watch within
  0.5 m or >= 25 % chance, warning, danger, no data), margin to the warning
  level, 72 h sparkline, 6 h change; filter; sort by river order, closest to
  the warning level, rising; arrow keys
- map: Ping mainstem and tributaries, status markers with code + level,
  layers Rain 24 h (on by default only with heavy rain) and Flood zones
  (auto-shown while P.1 is at a stage), one legend
- station panel: level, change, discharge (last RID value when the newest
  hour is level-only), warning/danger levels with margins, capacity, level
  scale with now and 24 h peak, 72 h chart with the forecast peaks, forecast
  table with the model and issue time, the P.1 inundation stages, data links
- alerts drawer with the ntfy topics; selection in the URL (#P.84); refresh
  every 5 minutes
- Inter and Noto Sans Thai self-hosted (src/static/fonts, 75 KB, OFL), no
  emoji, Thai first, Bangkok time

tests/test_console.py: routed fetch paths, EN/TH parity, translated aria
labels, no emoji, fonts and licences, noindex. dev_proxy.py serves
src/static with real content types (it sent HTML as octet-stream).
This commit is contained in:
grabowski committed 2026-10-19 16:31:09 +07:00
1 parent 0e30ddbcb6
commit 301a629b42
11 files changed
+1839 -3

No files matched your search

+4 -1
View File
@@ -32,7 +32,10 @@ plus the monthly `water-monitor-retrain.timer` (Python, `.venv`, uv-managed). Up
- Do not add Co-Authored-By trailers. - Do not add Co-Authored-By trailers.
- The dashboard is a single file, `src/static/dashboard.html`, EN + TH via the `t()` - The dashboard is a single file, `src/static/dashboard.html`, EN + TH via the `t()`
table: every user-visible string needs both languages. Every path it fetches must be table: every user-visible string needs both languages. Every path it fetches must be
a route in `rust/collector/src/web/mod.rs` (tests/test_dashboard.py checks). a route in `rust/collector/src/web/mod.rs` (tests/test_dashboard.py checks). Its
replacement, the console (`src/static/console.html`, preview at /static/console.html,
plan in docs/DASHBOARD_REDESIGN.md), follows the same rules plus no emoji
(tests/test_console.py); preview it with `uv run python scripts/dev_proxy.py`.
- Rust (`rust/`, see rust/README.md): `cargo fmt --all`, `cargo clippy --all-targets - Rust (`rust/`, see rust/README.md): `cargo fmt --all`, `cargo clippy --all-targets
-- -D warnings`, `cargo test`; minimum version 1.88 -- -D warnings`, `cargo test`; minimum version 1.88
(`CARGO_TARGET_DIR=target/msrv cargo +1.88 check --all-targets --locked`). Read (`CARGO_TARGET_DIR=target/msrv cargo +1.88 check --all-targets --locked`). Read
+1
View File
@@ -176,6 +176,7 @@ models/ trained bundles (gitignored) and evaluation results
- [docs/MATRIX_QUICK_START.md](docs/MATRIX_QUICK_START.md) — the same alerts in a Matrix room - [docs/MATRIX_QUICK_START.md](docs/MATRIX_QUICK_START.md) — the same alerts in a Matrix room
- [rust/README.md](rust/README.md) — the service: commands, configuration, develop, deploy - [rust/README.md](rust/README.md) — the service: commands, configuration, develop, deploy
- [docs/RUST_REWRITE.md](docs/RUST_REWRITE.md) — why and how it moved from Python to Rust - [docs/RUST_REWRITE.md](docs/RUST_REWRITE.md) — why and how it moved from Python to Rust
- [docs/DASHBOARD_REDESIGN.md](docs/DASHBOARD_REDESIGN.md) — the new console dashboard (preview at `/static/console.html`): layout, rules, phases
- [docs/references/NOTABLE_DOCUMENTS.md](docs/references/NOTABLE_DOCUMENTS.md) — official Thai government resources - [docs/references/NOTABLE_DOCUMENTS.md](docs/references/NOTABLE_DOCUMENTS.md) — official Thai government resources
- Public overview: [buildfor.life/docs/tooling/ping-river-monitor](https://buildfor.life/docs/tooling/ping-river-monitor/) - Public overview: [buildfor.life/docs/tooling/ping-river-monitor](https://buildfor.life/docs/tooling/ping-river-monitor/)
+151
View File
@@ -0,0 +1,151 @@
# Dashboard redesign: the console
Status: **phase 1 done** (2026-10-19). The new dashboard is built next to the current
one and replaces it in phase 4.
- **Preview:** <https://water.buildfor.life/static/console.html>, after `git pull` on the
server (static files are read per request, no restart). It is not indexed
(`noindex`) and links back to the current dashboard.
- **Locally:** `uv run python scripts/dev_proxy.py 8765`, then
<http://localhost:8765/static/console.html>. The API is proxied to production.
- **Rules:** the same as for `dashboard.html`, plus no emoji (`tests/test_console.py`):
- every path it fetches is a Rust route;
- every string exists in EN and TH, and Thai is the default;
- timestamps are Bangkok wall-clock time.
## Why: the current page
Observed on 2026-10-19 at 1440 px and on a 390 px phone.
- **No clear first answer.** "Is Chiang Mai flooding?" is a thin green banner above
four KPI cards of equal weight. Two of them answer nothing for a resident: "Most
stressed gauge 16%" and "Chiang Mai river flow 53.9" (no unit in the number). At the
bottom there are five more (2,700,706 datapoints, 3,002 days, …).
- **The colours answer the wrong question.** Markers and river lines are coloured by
discharge class (m³/s), not by distance to the warning level, and the station list
shows discharge.
- **The map is noisy.** OSM terrain with Thai labels, numbered markers and animated
dashes. The legend covers about a third of the map, and more than half of the first
phone screen.
- **The header is busy.** It has six buttons with emoji (↻ 🌙 🔔 ▶); on a phone they
wrap into three rows.
- **Internal detail on the main page.** The model-skill table lists seven model hashes;
the history card is an empty 360 px box until a station is clicked.
- **Inconsistent styling.** A gradient background, several card and pill styles, three
greens.
## Decision
Two directions were sketched on 2026-10-19 against live data (throwaway HTML, not kept):
- **A:** a public information page.
- **B:** an operations console.
The user chose **B**.
## Layout
One screen, no page scroll on a desktop:
| Area | Content |
|---|---|
| Top bar | brand · **Chiang Mai verdict** (P.1 level + 24 h forecast, always visible, click selects P.1) · gauges reporting · highest 24 h flood chance · data age · EN/ไทย · light/dark · Get alerts |
| Left | gauge list: status bar, code, name, margin to the warning level, 72 h sparkline, level and 6 h change; filter; sort by river order, closest to the warning level, rising; status legend |
| Centre | map: Ping mainstem and tributaries, gauges coloured by status with code + level labels, layers Rain 24 h and Flood zones, one legend |
| Right | selected gauge: level, 6 h change, discharge, warning/danger level with margins, capacity; level scale with now and 24 h peak; 72 h chart with the forecast peaks; forecast table (peak, chance of warning/danger at 6/12/24 h, model, issue time, disclaimer); for P.1 the seven inundation stages and the zones toggle; data links |
| Status bar | data credits, API, source code |
**Narrower screens:**
| Width | Layout |
|---|---|
| < 1280 px | The panel moves below list and map, in columns |
| < 860 px | One column: verdict, map, list, panel |
**Behaviour:**
- Selection is in the URL (`#P.84`).
- Arrow keys move through the list.
- Data refreshes every 5 minutes.
## Where every current element goes
| Current (`dashboard.html` id) | Console | Phase |
|---|---|---|
| `flood-verdict`, `verdict-*` | Verdict in the top bar | 1 |
| `station-count`, `last-updated`, `data-age`, `live-pill` | Top-bar KPIs (reporting, data age) | 1 |
| `river-flow`, `peak-station` (P.1 flow, most stressed gauge) | Discharge and capacity in the panel; "Closest to warning level" sort | 1 |
| `station-map`, `map-legend`, `legend-toggle`, `rain-toggle`, `zones-toggle` | Map, layer buttons, one legend | 1 |
| side list, `station-search` | Gauge list with filter and sorts | 1 |
| `forecast-card`, `p1-outlook`, `p1-peak`, `p1-stages`, `forecast-grid` | Panel: forecast table for every gauge, stages for P.1 | 1 |
| `alerts-panel`, `alerts-*` | Alerts drawer | 1 |
| `refresh-button` | Removed: refresh every 5 min, age in the top bar | 1 |
| `lang-toggle`, `theme-toggle` | EN / ไทย segmented control, icon button | 1 |
| `error`, `loading` | Map overlay with retry; "Loading" in the list | 1 |
| `thaiwater-sensors`, `sensors-*` | List tab + map layer | 2 |
| custom markers | Map layer | 2 |
| `history-card`, `history-*` | Panel history with ranges | 3 |
| `skill-panel`, `skill-*` | Panel section for P.1 | 3 |
| `db-stats`, `db-*` | Status-bar popover | 3 |
| `replay-2024`, `replay-*`, demo hooks | Replay/simulation mode with banner and mode pill | 4 |
## Design rules
- **Type:** Inter (Latin) and Noto Sans Thai, self-hosted in `src/static/fonts/` (75 KB, OFL). Numbers use tabular figures.
- **Colours:** CSS variables for light and dark, with dark the default; a saved choice wins (`ping-river-theme`, shared with the current dashboard).
- **Accent:** `#1d6fd6` light, `#4c9bf0` dark.
- **Status:** colours are used only for status, each with an `-ink` variant for text (WCAG AA on white).
- **One status rule** (`statusOf`) for the map, list and panel. It is display-only; the notifier keeps its own transitions.
| Status | Rule |
|---|---|
| Normal | none of the rules below |
| Watch | within 0.5 m of the warning level, or 24 h warning chance ≥ 25 % |
| Warning | level ≥ `threshold_warning` |
| Danger | level ≥ `threshold_danger` |
| No data | no reading for 24 h |
- **City verdict:** the current dashboard's rule — flooding from P.1 ≥ 3.70 m; "likely" at danger ≥ 50 % or warning ≥ 70 %; watch at warning ≥ 20 % or level ≥ 3.20 m.
- **Data age:** "feed stale" after 3 h, except during RID's overnight pause (21:00 to 09:00 with an evening reading). The pause shows as "overnight pause", the same rule as the notifier's `FEED_NIGHT_UNTIL_HOUR`.
- **Discharge:** comes with RID's report. When the newest hour is a level-only reading, the panel shows the last RID discharge with its time ("at 14:00").
- **Rain layer:** off by default unless any station has heavy rain (> 35 mm / 24 h); an explicit choice is remembered.
- **Flood zones:** shown automatically while P.1 is at or above a stage, unless hidden by the user.
## Phases
Each phase is one commit, checked with screenshots: 1440, 1100 and 390 px; light and dark; EN and TH.
1. **Done — Console shell with live data.**
- Layout, design tokens, fonts, EN/TH.
- Verdict and KPIs, gauge list with sparklines and sorts.
- Map with status markers, rain and flood-zone layers.
- Station panel (readings, scale, 72 h chart, forecast table, P.1 stages).
- Alerts drawer (ntfy topics); URL selection; 5-minute refresh.
- `tests/test_console.py`; `dev_proxy.py` serves `src/static` with proper content types.
2. **Map and basin.**
- **Basemap:** a self-hosted Protomaps extract of the Ping basin (PMTiles in `src/static/`, labels in Thai or English following the page language, light/dark themes). This replaces the dimmed OSM raster tiles, whose baked-in labels are the noisiest part of the screen.
- **Server:** check that ServeDir answers HTTP range requests (PMTiles needs them); add range support to `dev_proxy.py`.
- **ThaiWater/HII stations:**
- A tab in the list.
- A map layer: % of bank height, `/api/hii/waterlevel/latest`, falling back to `/sensors/thaiwater`.
- **Custom markers:** `custom-markers.json`, with their flood-zone popup.
3. **Station depth.**
- **History in the panel:** range 24 h / 7 d / 30 d / 90 d / all / custom dates, level and discharge, issued 24 h forecasts overlaid, flood bands; Chart.js or SVG.
- **P.1 forecast skill** ("Is the model getting better?") as a panel section, from `/api/forecast/skill`.
- **Data statistics:** a status-bar popover, from `/api/stats`.
4. **Modes and the swap.**
- **October 2024 replay:** banner, clock, markers and verdict follow the replay; zones auto-show.
- **Demo hooks:** `?demo_level=`, `?demo_rise=`. A mode pill (LIVE / REPLAY / SIMULATION) never relabels a replay or simulation as live.
- **The swap:** `console.html` becomes `dashboard.html`; the old file and its tests go. `tests/test_console.py` absorbs the remaining checks of `tests/test_dashboard.py` (one P.1 level, SEO tags; the robots `noindex` is removed).
- **Docs:** README screenshot and the CLAUDE.md rule.
5. **Polish.**
- Phone layout pass.
- Native-speaker review of the Thai strings.
- Keyboard and screen reader pass.
- Lighthouse ≥ 90 (performance, accessibility).
- No console errors.
## Data notes from building phase 1
- **Negative levels.** P.4A (−1.28 m) and P.67 (−0.59 m) report negative levels: gauge datums, not errors. The panel's scale starts at the lowest of 0 and the station's 72 h readings.
- **P.1 discharge disagrees between endpoints.** At 15:00 on 2026-10-19, `/measurements/latest` had level 1.30 m with no discharge, while `/measurements/history` had 26.0 m³/s with no capacity. RID's 14:00 report said 53.9 m³/s. The console shows the last RID discharge. Worth checking where the 26.0 comes from.
- **P.84 spike.** A one-hour spike at 13:00 the same day: 0.73 → 1.05 → 0.78 m.
+8 -2
View File
@@ -1,8 +1,12 @@
"""Serve the working-copy dashboard locally with API calls proxied to the """Serve the working-copy dashboard locally with API calls proxied to the
live server, so browser-side changes can be checked against real data live server, so browser-side changes can be checked against real data
before deploy. Usage: python scripts/dev_proxy.py [port]""" before deploy. Usage: python scripts/dev_proxy.py [port]
/ is src/static/dashboard.html, /static/... any file in src/static (e.g. the
console preview at /static/console.html), everything else the live server."""
import http.server import http.server
import mimetypes
import os import os
import sys import sys
import urllib.request import urllib.request
@@ -30,8 +34,10 @@ class Handler(http.server.BaseHTTPRequestHandler):
ctype = ( ctype = (
"application/json" "application/json"
if f.suffix in (".json", ".geojson") if f.suffix in (".json", ".geojson")
else "application/octet-stream" else mimetypes.guess_type(f.name)[0] or "application/octet-stream"
) )
if ctype.startswith("text/"):
ctype += "; charset=utf-8"
self._send(200, ctype, f.read_bytes()) self._send(200, ctype, f.read_bytes())
return return
try: try:
File diff suppressed because it is too large. Load diff
+13
View File
@@ -0,0 +1,13 @@
# Fonts
Self-hosted so the dashboard makes no request to Google. Both fonts are under the
SIL Open Font License 1.1 (`*-OFL.txt` next to them).
| File | Font | Subset |
|---|---|---|
| `inter-latin.woff2` | Inter (variable, weight 400–700), <https://github.com/rsms/inter> | Latin |
| `noto-sans-thai-thai.woff2` | Noto Sans Thai (variable, weight 400–700), <https://github.com/notofonts/thai> | Thai |
Both files are Google Fonts' woff2 subsets (`fonts.googleapis.com/css2?family=Inter:wght@400..700`
and `family=Noto+Sans+Thai:wght@400..700`, fetched with a woff2-capable user agent).
The `unicode-range` of each `@font-face` in `console.html` matches its subset.
+93
View File
@@ -0,0 +1,93 @@
Copyright 2020 The Inter Project Authors (https://github.com/rsms/inter)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.
+93
View File
@@ -0,0 +1,93 @@
Copyright 2022 The Noto Project Authors (https://github.com/notofonts/thai)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.
+100
View File
@@ -0,0 +1,100 @@
"""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))", _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_preview_is_not_indexed():
assert '<meta name="robots" content="noindex">' in _html()