summaryrefslogtreecommitdiff
path: root/docs/superpowers
diff options
context:
space:
mode:
authorLukasz Kasprzak <lukas@labunix.xyz>2026-08-13 13:04:10 +0200
committerLukasz Kasprzak <lukas@labunix.xyz>2026-08-13 13:04:10 +0200
commite14a7db4ffe3c4e0f15f6b37a980501a8d74d26b (patch)
tree670ef0897839871a64d3a3bb2e17e242e7d6c385 /docs/superpowers
downloadprognosis-e14a7db4ffe3c4e0f15f6b37a980501a8d74d26b.tar.gz
prognosis-e14a7db4ffe3c4e0f15f6b37a980501a8d74d26b.zip
Initial commit: prognosis, the Go implementation
An hour-by-hour forecast for the terminal, with official IMGW warnings for Polish locations. Replaces the Python version, whose cache file format it keeps so the two can coexist until this reaches parity. Open-Meteo provides the forecast, geocoding and pollen; GUGiK turns coordinates into a TERYT powiat code; IMGW supplies the warnings, filtered to that powiat rather than the whole country. Only the two lookups that never change are cached. Forecasts never are. Silence is never allowed to read as all-clear: "no warnings in force" and "the check failed" are reported as distinct states. Place names are resolved without guessing. A name matching several places is refused with a numbered list carrying each candidate's region and coordinates, and -pick N chooses one and remembers it. A stray positional beside -l is an error, so an unquoted "Wiry, PL" cannot silently resolve to somewhere else. The cache is written one entry per line with sorted keys, and treated as disposable but not worthless: an entry that will not parse is skipped and the rest kept, and a file that will not parse at all is moved to cache.json.bad rather than overwritten. No third-party dependencies. `make ci` is the gate: gofmt clean, vet, tests.
Diffstat (limited to 'docs/superpowers')
-rw-r--r--docs/superpowers/plans/2026-08-10-go-rewrite.md133
-rw-r--r--docs/superpowers/specs/2026-08-10-go-rewrite-design.md279
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.