aboutsummaryrefslogtreecommitdiff
path: root/docs
diff options
context:
space:
mode:
Diffstat (limited to 'docs')
-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.