summaryrefslogtreecommitdiff
path: root/docs/superpowers/specs/2026-08-10-go-rewrite-design.md
blob: f59241287d1d8af9bb1ff02ae29f18b9c37e0aec (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
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.