diff options
Diffstat (limited to 'docs/superpowers/specs/2026-08-10-go-rewrite-design.md')
| -rw-r--r-- | docs/superpowers/specs/2026-08-10-go-rewrite-design.md | 279 |
1 files changed, 279 insertions, 0 deletions
diff --git a/docs/superpowers/specs/2026-08-10-go-rewrite-design.md b/docs/superpowers/specs/2026-08-10-go-rewrite-design.md new file mode 100644 index 0000000..f592412 --- /dev/null +++ b/docs/superpowers/specs/2026-08-10-go-rewrite-design.md @@ -0,0 +1,279 @@ +# prognosis in Go — design + +**Date:** 2026-08-10 +**Status:** implemented and in service on both machines. The Python +implementation was retired on 2026-08-10 once the Go packages had offline tests +of their own; a copy is at ~/.local/share/backups/2026-08-10-prognosis-py/. +**Supersedes:** the Python implementation, retired 2026-08-10. + +## Why + +Two reasons, neither of them "Go is nicer". + +1. **Deployment.** The phone currently runs a copied Python file and depends on + Termux's `python3` plus `termux-exec` rewriting a `#!/usr/bin/env` shebang + that cannot resolve on Android, which has no `/usr/bin`. A static + `android/arm64` binary removes that whole chain. +2. **Configurability.** The display is hardcoded. Choosing columns, toggling the + chart and picking pollen species wants a config file, and the Python version + has no structure for it. + +A rewrite of something that already works carries one real risk: the dozen small +behaviours that took a day to find are easy to drop silently. The parity harness +below exists to make that impossible, and is built early rather than last. + +## Layout + +Follows `bread-calc`, the house convention for a Go tool. + +``` +prognosis/ +├── cmd/prognosis/main.go flag parsing, wiring, exit codes +├── internal/ +│ ├── config/ KEY=VALUE parser, defaults, column set +│ ├── openmeteo/ forecast, air-quality, geocoding clients +│ ├── imgw/ warnings + GUGiK TERYT lookup +│ ├── cache/ geo/teryt cache, atomic write +│ └── render/ table, chart, colour +├── testdata/ recorded JSON fixtures, no network in tests +├── bin/prognosis-py the Python implementation, until parity +├── go.mod github.com/lukaszkasprzak/prognosis +├── Makefile build install test vet fmt ci cross clean +├── LICENSE GPLv3 +└── README.md +``` + +## Config + +`~/.config/prognosis/config`, `KEY=VALUE`, `#` comments, parsed by our own code. +No dependency. Written with commented defaults on first run, so the file +documents itself — the same trick wego's `ingo` uses, without the library. + +``` +# ~/.config/prognosis/config + +location=Krakow # falls back to location= in ~/.wegorc when unset +hours=12 # default span; -n / -d override +units=metric # metric | imperial | si + +columns=hour,icon,temp,feels,conditions,rain + +icons=nerd # nerd | emoji | none +graph=true +graph_height=5 +warnings=true +pollen=all # species to show; a list, "all" or "none" +color=auto # auto | always | never +display_lang=en # en | pl +``` + +`display_lang` covers everything prognosis writes itself: column headers, +condition names, section labels, weekday and month names, pollen species and +bands. **IMGW publishes its warning text in Polish only**, so that text stays +Polish in either language; translating an official warning would mean inventing +its wording. + +Precedence: **flags > config file > built-in defaults**. Location additionally +falls back to `~/.wegorc` so the two weather tools never disagree about where +you are. + +`units` is passed to Open-Meteo as `temperature_unit` / `wind_speed_unit` / +`precipitation_unit` rather than converted locally, so rounding matches the +provider: `metric` = °C, km/h, mm; `imperial` = °F, mph, inch; `si` = °C, m/s, +mm. The IMGW temperature thresholds are defined in °C and are compared against +the Celsius value regardless of display units — a warning threshold does not +move because you changed how numbers are printed. + +Flag/config mapping, so both spellings exist for every knob: + +| flag | config key | +|---|---| +| `-l`, `--location` | `location` | +| `-n`, `--hours` / `-d`, `--days` | `hours` (days × 24) | +| `--no-graph` | `graph=false` | +| `--no-color` | `color=never` | +| `--columns` | `columns` | +| `--icons` | `icons` | +| `--lang` | `display_lang` | + +`color=auto` means colour when stdout is a terminal and `TERM` is not `dumb`, +which is the current behaviour; `always` forces it on for piping into a pager. + +### Columns + +An ordered, named set. Unknown names are a startup error naming the offender and +listing what is valid — never a silently blank column. + +| name | source field | notes | +|---|---|---| +| `hour` | derived | current hour emphasised | +| `icon` | `weather_code` | glyph for the conditions, see below | +| `temp` | `temperature_2m` | coloured by IMGW bands | +| `feels` | `apparent_temperature` | shown only when it differs by ≥1° | +| `conditions` | `weather_code` | shown only when it changes | +| `mm` | `precipitation` | | +| `rain` | `precipitation_probability` | | +| `wind` | `wind_speed_10m` | | +| `gusts` | `wind_gusts_10m` | | +| `dir` | `wind_direction_10m` | rendered as an arrow | +| `humidity` | `relative_humidity_2m` | | +| `dew` | `dew_point_2m` | | +| `uv` | `uv_index` | | +| `cloud` | `cloud_cover` | | +| `pressure` | `pressure_msl` | | +| `visibility` | `visibility` | metres → km | + +Only the fields actually selected are requested from the API, so a narrow column +set costs a smaller response. + +### Icons + +The `icon` column renders the weather code as a glyph. `icons=` picks the set: + +- `emoji` — ☀ ⛅ ☁ 🌧 ⛈ 🌨 🌫. Colour glyphs, drawn by a fallback font. +- `nerd` — Nerd Font weather glyphs. Single-width, monochrome, so they take the + terminal's foreground colour like any other text. +- `none` — the column renders empty (kept so `columns=` need not change). + +**The right default differs per machine, which is why this is config.** + +The first version of this spec defaulted to `nerd` on the grounds that both +machines have Nerd Fonts installed. Testing disproved that: *installed* is not +*reachable*. + +- **t480 (st):** the primary font is Terminus, which has no private-use glyphs, + so icons go through Xft's fallback path — which + `~/.config/fontconfig/conf.d/60-st-terminus-fallback.conf` deliberately steers + to DejaVu Sans Mono, to stop fallback glyphs being sheared to the Terminus + cell. DejaVu Sans Mono contains U+2601 (emoji cloud) but neither U+26C5 (emoji + sun-behind-cloud) nor the Nerd range at U+E3xx. So on st, **`nerd` draws + nothing and `emoji` is only partially covered**; `icons=none` is the honest + setting there unless the fallback is extended. +- **pixel (Termux):** the *primary* font is MesloLGS NF, so the Nerd range is + covered directly with no fallback involved. `nerd` is right there, and it is + also the only set that respects the deliberately monochrome green palette — a + colour emoji would be the one non-green thing on screen. + +The default stays `nerd`: it is correct on the machine where a glyph column +earns its place, and it fails blank rather than wrong. Per-machine config is the +mechanism for the difference. + +Making `nerd` work on st would mean adding the Nerd range to the fallback — an +additive fontconfig file beside the existing one, or st's `font2[]` — which +touches a carefully tuned working setup and is deliberately out of scope here. + +**Width must be measured, not counted.** Weather emoji do not share a width: +`⛅` is East-Asian Wide (2 cells), `⛈` is Ambiguous, `☀ ☁ ❄` are Neutral, and +`☀️` is two runes because of variation selector U+FE0F, which pushes most +terminals to double-width. Neither `len()` nor `utf8.RuneCountInString` gives the +display width. The renderer needs a `displayWidth(string) int` that accounts for +combining marks, variation selectors and East-Asian width, and **every column +pad must go through it**. This is the emoji-shaped version of the +pad-before-colour rule: get it wrong and the whole table shears, but only in a +real terminal, never when piped. + +The existing rule that rain columns vanish on a dry window becomes conditional +on them being selected at all: if `mm`/`rain` are in `columns` and the window is +dry (no precipitation and no hour at or above 20% probability), they are hidden, +and the day line says `dry`. + +## Parity contract + +Every item below is behaviour the Python version has, each of which cost +something to discover. The Go version must reproduce all of them, and the parity +harness must cover each one. + +1. **Four warning states, kept distinct.** Warnings shown; nothing shown + (checked, none in force); `could not check IMGW` (the check failed); + `IMGW covers Poland only` (location abroad). Silence must never be mistaken + for all-clear. +2. **Powiat filtering.** GUGiK reverse-geocode → 6-digit TERYT → first 4 digits → + match against each warning's `teryt` array. Warnings whose `obowiazuje_do` has + passed are dropped; an unparseable date is kept rather than dropped. +3. **GUGiK radius.** Request a wide radius (the service clamps to its own 5 km + maximum). The 100 m default finds nothing in the mountains, which is + indistinguishable from being abroad and would suppress real warnings. +4. **Temperature colours from IMGW criteria**, with exact edges: + `Tmin ≤ -15` bright blue, `Tmax ≥ 30` red, `Tmax > 35` bright red. Note `≤` + and `>` — both edges were wrong in a first attempt. Intermediate splits at + 0/10/20 are round numbers and are documented as such. +5. **Colour computed from the rounded value**, so a reading of −14.6 that prints + as `-15°` gets the same colour as a true −15°. +6. **ANSI slots 0–15 only.** No 256-colour indices: the phone's palette remaps + the low slots to shades of green and a hardcoded index would be the one + non-green thing on screen. +7. **Pad cells to width before colouring.** Escape sequences have no visible + width; padding a coloured string misaligns the column. Invisible when piped, + obvious in a terminal. +8. **Colour runs are grouped**, one escape per colour change rather than per + character. +9. **Display width is measured, not counted** -- East-Asian width, combining + marks and variation selectors -- and every pad goes through it. Emoji are not + all one cell wide; a rune count shears the table in a real terminal while + looking correct when piped. +10. **Pollen bands** with per-species evidence strength: grass four bands + (20/50/65/120), birch and mugwort a two-way split on a single anchor (80, 70), + alder/olive/ragweed unbanded. Grass is never hidden even at zero; other + species are hidden when absent. +11. **Pollen window looks forward** from the current hour, at least 12 hours, on + arrays that begin at 00:00 local. +12. **Chart** over `graph_height` rows using half-block cells; columns widen to + fill narrow spans and downsample on long ones, with `Nh/col` shown when they + do; axis labels are skipped rather than truncated when they would overrun. +13. **Terminal width** read at runtime, honouring `COLUMNS`; header folds sun + times onto one line only when it fits. +14. **Atomic cache writes** (temp file + rename), so concurrent runs cannot leave + truncated JSON. +15. **Pipe-safe**: colour off when stdout is not a terminal; notes and failures + on stderr; `note: N hours available, not M` when the API returns fewer hours + than requested. +16. **Exit codes**: 0 success, 1 fetch failure, 2 usage error. + +## Testing + +Unit tests, table-driven, no network: + +- config parsing: precedence, unknown keys, unknown column names, malformed lines +- column selection: order preserved, dry-window hiding, only-selected-fields requested +- thresholds: temperature band edges (−15, 30, 35 exactly), pollen bands per species +- chart: scaling, widening, downsampling, axis label placement and skipping +- render: pad-before-colour width invariants, colour-run grouping, ANSI slots used +- width: display width of emoji (wide, ambiguous, VS16 pairs), Nerd Font glyphs + and plain ASCII, and that every column stays aligned across all three icon sets + +HTTP clients are tested against recorded JSON in `testdata/`, captured from the +live APIs once. This also documents the response shapes. + +## Parity harness + +`make parity` runs both implementations over a fixed argument matrix and diffs +stdout, stderr and exit code: + +``` +--no-color / -n 1 / -n 30 / -d 1 / -d 2 / -d 5 / --no-graph +-l Bergen -n 6 (wet window: rain columns appear) +-l Ushuaia -n 4 (cold bands, negative-zero formatting) +52.52,13.40 -n 3 (abroad by coordinates) +49.23,19.98 -n 3 (remote Polish point: GUGiK radius) +-d 16 / -n 0 / -d 0 / -n 5 -d 2 (usage errors and exit codes) +COLUMNS=53 (narrow rendering, header fold, wrap) +--icons=emoji / --icons=nerd / --icons=none (column alignment per set) +``` + +Live data changes between runs, so both binaries are invoked back to back and +the harness compares structure: line count, column positions, which sections are +present, and the stderr/exit code exactly. Numeric drift between two calls +seconds apart is tolerated; layout differences are not. + +**A warning learned the hard way:** the shell here is zsh, which does **not** +word-split unquoted variables. A harness looping over argument strings must use +`${=args}` or an array, or every case silently degrades into an argparse error +and the comparison passes while testing nothing. + +## Out of scope + +- Air quality (PM2.5/PM10/AQI) — the endpoint is already called for pollen, so + it is cheap to add later, but it is not in this rewrite. +- Cron/mail integration. Output is already pipe-safe; no code needed. +- Retiring the Python implementation. That happens after parity, as a separate + decision. |
