aboutsummaryrefslogtreecommitdiff
path: root/docs/superpowers/specs
diff options
context:
space:
mode:
Diffstat (limited to 'docs/superpowers/specs')
-rw-r--r--docs/superpowers/specs/2026-08-10-go-rewrite-design.md279
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.