diff options
Diffstat (limited to 'docs')
| -rw-r--r-- | docs/superpowers/plans/2026-08-10-go-rewrite.md | 133 | ||||
| -rw-r--r-- | docs/superpowers/specs/2026-08-10-go-rewrite-design.md | 279 |
2 files changed, 412 insertions, 0 deletions
diff --git a/docs/superpowers/plans/2026-08-10-go-rewrite.md b/docs/superpowers/plans/2026-08-10-go-rewrite.md new file mode 100644 index 0000000..1286edc --- /dev/null +++ b/docs/superpowers/plans/2026-08-10-go-rewrite.md @@ -0,0 +1,133 @@ +# prognosis Go rewrite — implementation plan + +> **For agentic workers:** implement task-by-task. Steps use checkbox (`- [ ]`) syntax. + +**Goal:** Replace the Python `prognosis` with a configurable Go implementation +that keeps every behaviour in the spec's 16-item parity contract. + +**Architecture:** `cmd/prognosis` wires flags + config into three API clients +(`openmeteo`, `imgw`) behind a `cache`, then hands a view model to `render`. +Rendering is pure: it takes data and a config, returns lines, touches no network. + +**Tech stack:** Go 1.24, standard library only. No third-party modules. + +## Global constraints + +- Module `github.com/lukaszkasprzak/prognosis`, Go 1.24, **stdlib only**. +- Colour: ANSI slots 0–15 only (codes 30–37, 90–97) plus attributes 1/2/4. +- Every column pad goes through `render.Pad`, never `len()` or rune count. +- IMGW thresholds compared in °C regardless of display units. +- Licence header not required per-file; `LICENSE` is GPLv3 at the repo root. +- Python `bin/prognosis-py` stays in the repo, off PATH, until parity is signed off. + +--- + +### Task 1: Scaffold + config package + +**Files:** +- Create: `go.mod`, `internal/config/config.go`, `internal/config/config_test.go` +- Modify: `Makefile` (add Go targets) + +**Interfaces produced:** +- `config.Config` struct with fields `Location, Units, Icons, Color string; + Hours, GraphHeight int; Graph, Warnings bool; Columns, Pollen []string` +- `config.Default() Config` +- `config.Load(path string) (Config, error)` — KEY=VALUE, `#` comments +- `config.Validate() error` — unknown column/icon/colour names +- `config.WriteDefault(path string) error` — commented defaults +- `config.ValidColumns() []string` + +- [ ] Write table tests: parse, precedence, unknown column error naming offender, unknown icons value, malformed line, comment/blank handling +- [ ] Run `go test ./internal/config/` — expect failure +- [ ] Implement +- [ ] Run tests — expect pass + +### Task 2: cache package + +**Files:** `internal/cache/cache.go`, `internal/cache/cache_test.go` + +**Interfaces produced:** +- `cache.Get(section, key string) (string, bool)` +- `cache.Put(section, key, value string) error` +- `cache.GetGeo(place string) (Geo, bool)` / `cache.PutGeo(place string, g Geo)` +- `cache.Geo{Lat, Lon float64; Label, Country string}` + +- [ ] Tests: round-trip, missing key, corrupt file self-heals to empty, atomic write leaves no `.tmp`, concurrent writers leave valid JSON +- [ ] Implement with temp file + `os.Rename` + +### Task 3: render width + colour primitives + +**Files:** `internal/render/width.go`, `internal/render/color.go`, plus tests + +**Interfaces produced:** +- `render.DisplayWidth(s string) int` +- `render.Pad(s string, w int) string` / `render.PadLeft(s string, w int) string` +- `render.Styler(colour bool) func(code, text string) string` +- `render.Paint(cells []render.Cell, c func(string, string) string) string` +- `render.Cell{Style, Text string}` + +- [ ] Tests: VS16 pair counts 1, `⛅` counts 2, combining mark counts 0, ASCII counts len, Nerd glyph counts 1; `Pad` reaches the requested display width for all three icon sets; `Paint` groups runs +- [ ] Implement + +### Task 4: openmeteo client + +**Files:** `internal/openmeteo/openmeteo.go`, `_test.go`, `testdata/*.json` + +**Interfaces produced:** +- `openmeteo.Geocode(place string) (cache.Geo, []string, error)` — second value is alternatives for the ambiguity note +- `openmeteo.Forecast(lat, lon float64, hours int, units string, fields []string) (*openmeteo.Data, error)` +- `openmeteo.Pollen(lat, lon float64, hours int, species []string) (map[string]float64, error)` +- `openmeteo.Data{TZ string; Rows []Row; Sun map[string][2]string; Daily map[string]float64}` +- `openmeteo.Row{When time.Time; Vals map[string]float64; Code int}` + +- [ ] Record fixtures once from the live API into `testdata/` +- [ ] Tests against fixtures: window starts at the current hour not 00:00, requested fields only, units mapping, pollen forward window ≥12h +- [ ] Implement + +### Task 5: imgw + gugik + +**Files:** `internal/imgw/imgw.go`, `internal/imgw/gugik.go`, tests, fixtures + +**Interfaces produced:** +- `imgw.Powiat(lat, lon float64) (code string, status imgw.Status, err error)` +- `imgw.Status` = `StatusOK | StatusOutside | StatusError` +- `imgw.Warnings(powiat string) ([]imgw.Warning, error)` +- `imgw.Warning{Event, Level, From, To, Probability, Text string}` + +- [ ] Tests: TERYT truncated to 4 digits; 0-result response ⇒ `StatusOutside`; transport failure ⇒ `StatusError`; expired warning dropped; unparseable date kept; radius parameter present +- [ ] Implement + +### Task 6: render table + chart + +**Files:** `internal/render/table.go`, `internal/render/chart.go`, `internal/render/render.go`, tests + +**Interfaces produced:** +- `render.View{Label, TZ string; Rows []openmeteo.Row; Sun map[string][2]string; Daily map[string]float64; Pollen map[string]float64; Warnings []imgw.Warning; WarnNote string; WarnFailed bool}` +- `render.Render(v View, cfg config.Config, width int, colour bool) string` +- `render.TempStyle(c float64) string` +- `render.PollenBand(species string, v float64) string` + +- [ ] Tests: temp band edges −15/30/35 exactly; rounded-value colouring (−14.6 ⇒ same as −15); dry window hides mm/rain; conditions repeat suppressed and reset per day; chart widen/downsample and `Nh/col`; axis labels skipped not truncated; all four warning states +- [ ] Implement + +### Task 7: cmd wiring + +**Files:** `cmd/prognosis/main.go` + +- [ ] Flags mirroring the spec's mapping table; flags > config > defaults +- [ ] Exit codes 0/1/2; notes to stderr +- [ ] Write default config on first run + +### Task 8: parity harness + +**Files:** `scripts/parity.sh`, Makefile target `parity` + +- [ ] Uses a bash/zsh **array** for arguments, never an unquoted string (zsh does not word-split; a string silently degrades every case into a usage error that compares equal) +- [ ] Runs both binaries back to back, compares layout: line count, column start positions, sections present, stderr and exit code exactly; tolerates numeric drift +- [ ] Covers the spec's matrix including `COLUMNS=53` and all three icon sets + +### Task 9: build, cross-compile, install + +- [ ] `make ci` clean: fmt, vet, test +- [ ] `make cross` produces `linux/amd64`, `linux/arm64`, `android/arm64` +- [ ] Verify the android/arm64 binary runs on the phone 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. |
