aboutsummaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
Diffstat (limited to 'internal')
-rw-r--r--internal/cache/cache.go251
-rw-r--r--internal/cache/cache_test.go282
-rw-r--r--internal/config/config.go360
-rw-r--r--internal/config/config_test.go158
-rw-r--r--internal/i18n/i18n.go196
-rw-r--r--internal/i18n/i18n_test.go88
-rw-r--r--internal/imgw/imgw.go156
-rw-r--r--internal/imgw/imgw_test.go190
-rw-r--r--internal/imgw/testdata/gugik_abroad.json1
-rw-r--r--internal/imgw/testdata/warnings.json1
-rw-r--r--internal/openmeteo/openmeteo.go385
-rw-r--r--internal/openmeteo/openmeteo_test.go364
-rw-r--r--internal/openmeteo/testdata/forecast.json1
-rw-r--r--internal/openmeteo/testdata/geocode_ambiguous.json1
-rw-r--r--internal/openmeteo/testdata/pollen.json1
-rw-r--r--internal/openmeteo/testdata/pollen_nulls.json1
-rw-r--r--internal/render/ascii.go67
-rw-r--r--internal/render/ascii_test.go55
-rw-r--r--internal/render/chart.go205
-rw-r--r--internal/render/color.go62
-rw-r--r--internal/render/icons.go84
-rw-r--r--internal/render/render.go283
-rw-r--r--internal/render/render_test.go287
-rw-r--r--internal/render/scale.go92
-rw-r--r--internal/render/scale_test.go57
-rw-r--r--internal/render/table.go240
-rw-r--r--internal/render/width.go110
-rw-r--r--internal/render/width_test.go112
28 files changed, 4090 insertions, 0 deletions
diff --git a/internal/cache/cache.go b/internal/cache/cache.go
new file mode 100644
index 0000000..892df4a
--- /dev/null
+++ b/internal/cache/cache.go
@@ -0,0 +1,251 @@
+// Package cache stores the lookups that never change: a place name's
+// coordinates, and a coordinate's TERYT powiat code.
+//
+// Forecasts are never cached -- they would be stale immediately.
+package cache
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "os"
+ "path/filepath"
+ "sort"
+)
+
+// Geo is a resolved location.
+type Geo struct {
+ Lat float64
+ Lon float64
+ Label string
+ Country string
+}
+
+// Geo is stored as a 4-element array, not an object, because the Python
+// implementation shares this file and reads [lat, lon, label, country]. The two
+// coexist until the Go version reaches parity, and a cache one of them cannot
+// read makes the other crash on its own data.
+func (g Geo) MarshalJSON() ([]byte, error) { return g.jsonLine() }
+
+// jsonLine renders the stored array with a space after each comma, which is how
+// it is written to disk. encoding/json compacts whatever a Marshaler returns, so
+// the file writer calls this directly; going through json.Marshal would strip
+// the spaces again. Element order is the contract described above, kept here so
+// it is stated once.
+func (g Geo) jsonLine() ([]byte, error) {
+ parts := make([][]byte, 0, 4)
+ for _, v := range []any{g.Lat, g.Lon, g.Label, g.Country} {
+ b, err := json.Marshal(v)
+ if err != nil {
+ return nil, err
+ }
+ parts = append(parts, b)
+ }
+ return append(append([]byte{'['}, bytes.Join(parts, []byte(", "))...), ']'), nil
+}
+
+func (g *Geo) UnmarshalJSON(b []byte) error {
+ var raw []any
+ if err := json.Unmarshal(b, &raw); err != nil {
+ return err
+ }
+ if len(raw) < 3 {
+ return fmt.Errorf("geo entry has %d fields, want at least 3", len(raw))
+ }
+ lat, ok1 := raw[0].(float64)
+ lon, ok2 := raw[1].(float64)
+ if !ok1 || !ok2 {
+ return fmt.Errorf("geo entry has non-numeric coordinates")
+ }
+ label, _ := raw[2].(string)
+ country := ""
+ if len(raw) > 3 {
+ country, _ = raw[3].(string)
+ }
+ *g = Geo{Lat: lat, Lon: lon, Label: label, Country: country}
+ return nil
+}
+
+type store struct {
+ Geo map[string]Geo `json:"geo"`
+ Teryt map[string]string `json:"teryt"`
+}
+
+// Cache is a JSON file holding both sections.
+type Cache struct{ path string }
+
+// New returns a cache backed by path. The file need not exist.
+func New(path string) *Cache { return &Cache{path: path} }
+
+// DefaultPath is ~/.cache/prognosis/cache.json.
+func DefaultPath() string {
+ if dir, err := os.UserCacheDir(); err == nil {
+ return filepath.Join(dir, "prognosis", "cache.json")
+ }
+ return filepath.Join(os.Getenv("HOME"), ".cache", "prognosis", "cache.json")
+}
+
+// load never fails the run: a cache it cannot read is treated as empty, because
+// losing a cache costs one extra request and failing the run costs the forecast.
+//
+// It salvages per entry rather than per file. Decoding the sections whole meant
+// one unreadable entry made the entire cache look empty, and the save that
+// followed then wrote that emptiness over every entry that was still good.
+//
+// The second result reports whether the file was usable. It is false only when
+// the document itself will not parse, which tells the writers to move it aside
+// before replacing it: the cache is disposable, but a file someone hand-edited
+// is the only copy of what they typed.
+func (c *Cache) load() (store, bool) {
+ s := store{Geo: map[string]Geo{}, Teryt: map[string]string{}}
+ data, err := os.ReadFile(c.path)
+ if err != nil {
+ return s, true // absent is not corrupt; a fresh cache may be written
+ }
+ if len(bytes.TrimSpace(data)) == 0 {
+ return s, true // an empty file is simply no cache yet
+ }
+ var raw struct {
+ Geo map[string]json.RawMessage `json:"geo"`
+ Teryt map[string]json.RawMessage `json:"teryt"`
+ }
+ if err := json.Unmarshal(data, &raw); err != nil {
+ return s, false
+ }
+ for k, v := range raw.Geo {
+ var g Geo
+ if err := json.Unmarshal(v, &g); err == nil {
+ s.Geo[k] = g
+ }
+ }
+ for k, v := range raw.Teryt {
+ var code string
+ if err := json.Unmarshal(v, &code); err == nil {
+ s.Teryt[k] = code
+ }
+ }
+ return s, true
+}
+
+// encode writes the store one entry per line, keys sorted so the file is stable
+// between runs. It is small, hand-edited, and read in a terminal, none of which
+// a single long line serves.
+func encode(s store) ([]byte, error) {
+ geo := make(map[string]json.RawMessage, len(s.Geo))
+ for k, g := range s.Geo {
+ v, err := g.jsonLine()
+ if err != nil {
+ return nil, err
+ }
+ geo[k] = v
+ }
+ teryt := make(map[string]json.RawMessage, len(s.Teryt))
+ for k, code := range s.Teryt {
+ v, err := json.Marshal(code)
+ if err != nil {
+ return nil, err
+ }
+ teryt[k] = v
+ }
+ var b bytes.Buffer
+ b.WriteString("{\n")
+ writeSection(&b, "geo", geo)
+ b.WriteString(",\n")
+ writeSection(&b, "teryt", teryt)
+ b.WriteString("\n}\n")
+ return b.Bytes(), nil
+}
+
+func writeSection(b *bytes.Buffer, name string, entries map[string]json.RawMessage) {
+ if len(entries) == 0 {
+ fmt.Fprintf(b, " %q: {}", name)
+ return
+ }
+ keys := make([]string, 0, len(entries))
+ for k := range entries {
+ keys = append(keys, k)
+ }
+ sort.Strings(keys)
+ fmt.Fprintf(b, " %q: {\n", name)
+ for i, k := range keys {
+ key, err := json.Marshal(k) // a place name may contain anything
+ if err != nil {
+ continue
+ }
+ fmt.Fprintf(b, " %s: %s", key, entries[k])
+ if i < len(keys)-1 {
+ b.WriteByte(',')
+ }
+ b.WriteByte('\n')
+ }
+ b.WriteString(" }")
+}
+
+// save writes atomically: a temporary file in the same directory, then a
+// rename. Two runs at once would otherwise interleave and leave truncated JSON
+// that the next run silently reads as an empty cache.
+func (c *Cache) save(s store) error {
+ if err := os.MkdirAll(filepath.Dir(c.path), 0o755); err != nil {
+ return err
+ }
+ tmp := fmt.Sprintf("%s.%d.tmp", c.path, os.Getpid())
+ data, err := encode(s)
+ if err != nil {
+ return err
+ }
+ if err := os.WriteFile(tmp, data, 0o644); err != nil {
+ return err
+ }
+ if err := os.Rename(tmp, c.path); err != nil {
+ os.Remove(tmp)
+ return err
+ }
+ return nil
+}
+
+// quarantine moves a cache that will not parse to <path>.bad, so replacing it
+// costs nothing that cannot be recovered. Losing the cache is cheap -- one extra
+// request -- but losing a hand-edit is not, and the two used to be the same act.
+func (c *Cache) quarantine() error {
+ return os.Rename(c.path, c.path+".bad")
+}
+
+// Geo returns a cached location.
+func (c *Cache) Geo(place string) (Geo, bool) {
+ s, _ := c.load()
+ g, ok := s.Geo[place]
+ return g, ok
+}
+
+// PutGeo records a location.
+func (c *Cache) PutGeo(place string, g Geo) error {
+ s, ok := c.load()
+ if !ok {
+ if err := c.quarantine(); err != nil {
+ return err
+ }
+ }
+ s.Geo[place] = g
+ return c.save(s)
+}
+
+// Teryt returns a cached powiat code. The empty string is a real answer meaning
+// "GUGiK knows this point is not in Poland"; the boolean distinguishes it from
+// never having asked.
+func (c *Cache) Teryt(key string) (string, bool) {
+ s, _ := c.load()
+ code, ok := s.Teryt[key]
+ return code, ok
+}
+
+// PutTeryt records a powiat code, or "" for a point outside Poland.
+func (c *Cache) PutTeryt(key, code string) error {
+ s, ok := c.load()
+ if !ok {
+ if err := c.quarantine(); err != nil {
+ return err
+ }
+ }
+ s.Teryt[key] = code
+ return c.save(s)
+}
diff --git a/internal/cache/cache_test.go b/internal/cache/cache_test.go
new file mode 100644
index 0000000..3283db2
--- /dev/null
+++ b/internal/cache/cache_test.go
@@ -0,0 +1,282 @@
+package cache
+
+import (
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "sync"
+ "testing"
+)
+
+func tmpCache(t *testing.T) *Cache {
+ t.Helper()
+ return New(filepath.Join(t.TempDir(), "sub", "cache.json"))
+}
+
+func TestGeoRoundTrip(t *testing.T) {
+ c := tmpCache(t)
+ if _, ok := c.Geo("Krakow"); ok {
+ t.Fatal("empty cache reported a hit")
+ }
+ want := Geo{Lat: 50.0617, Lon: 19.9373, Label: "Krakow, PL", Country: "PL"}
+ if err := c.PutGeo("Krakow", want); err != nil {
+ t.Fatal(err)
+ }
+ got, ok := c.Geo("Krakow")
+ if !ok || got != want {
+ t.Fatalf("got %+v (%v), want %+v", got, ok, want)
+ }
+}
+
+// "" is a real answer -- not in Poland -- and must be distinguishable from
+// never having asked, or every foreign location re-queries GUGiK forever.
+func TestTerytEmptyStringIsARealAnswer(t *testing.T) {
+ c := tmpCache(t)
+ if _, ok := c.Teryt("52.5200,13.4000"); ok {
+ t.Fatal("empty cache reported a hit")
+ }
+ if err := c.PutTeryt("52.5200,13.4000", ""); err != nil {
+ t.Fatal(err)
+ }
+ code, ok := c.Teryt("52.5200,13.4000")
+ if !ok {
+ t.Fatal("a cached empty code must report as present")
+ }
+ if code != "" {
+ t.Fatalf("code = %q, want empty", code)
+ }
+}
+
+func TestCorruptFileIsTreatedAsEmpty(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "cache.json")
+ if err := os.WriteFile(path, []byte("{not json"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ c := New(path)
+ if _, ok := c.Geo("anything"); ok {
+ t.Fatal("corrupt cache must read as empty, not error")
+ }
+ if err := c.PutGeo("x", Geo{Lat: 1}); err != nil {
+ t.Fatalf("must be able to overwrite a corrupt cache: %v", err)
+ }
+}
+
+func TestSaveLeavesNoTempFiles(t *testing.T) {
+ dir := t.TempDir()
+ c := New(filepath.Join(dir, "cache.json"))
+ if err := c.PutGeo("a", Geo{Lat: 1}); err != nil {
+ t.Fatal(err)
+ }
+ entries, _ := filepath.Glob(filepath.Join(dir, "*.tmp"))
+ if len(entries) != 0 {
+ t.Fatalf("temp files left behind: %v", entries)
+ }
+}
+
+// The whole point of the atomic write: concurrent writers must never leave a
+// file that fails to parse.
+func TestConcurrentWritesKeepValidJSON(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "cache.json")
+ var wg sync.WaitGroup
+ for i := 0; i < 16; i++ {
+ wg.Add(1)
+ go func(i int) {
+ defer wg.Done()
+ New(path).PutGeo("place", Geo{Lat: float64(i)})
+ }(i)
+ }
+ wg.Wait()
+
+ data, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var s store
+ if err := json.Unmarshal(data, &s); err != nil {
+ t.Fatalf("cache is not valid JSON after concurrent writes: %v\n%s", err, data)
+ }
+}
+
+// The Python implementation shares this file and stores geo entries as
+// [lat, lon, label, country]. If Go writes an object instead, Python crashes on
+// its own cache -- which is exactly what happened once.
+func TestGeoIsStoredAsAnArrayForPythonInterop(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "cache.json")
+ c := New(path)
+ if err := c.PutGeo("Krakow", Geo{Lat: 50.0617, Lon: 19.9373, Label: "Krakow, PL", Country: "PL"}); err != nil {
+ t.Fatal(err)
+ }
+ data, _ := os.ReadFile(path)
+ var probe struct {
+ Geo map[string][]any `json:"geo"`
+ }
+ if err := json.Unmarshal(data, &probe); err != nil {
+ t.Fatalf("geo must decode as arrays: %v\n%s", err, data)
+ }
+ entry := probe.Geo["Krakow"]
+ if len(entry) != 4 {
+ t.Fatalf("geo entry = %v, want 4 elements", entry)
+ }
+ if entry[2] != "Krakow, PL" {
+ t.Errorf("third element must be the label, got %v", entry[2])
+ }
+}
+
+// A cache written in the Python shape must load here unchanged.
+func TestReadsPythonWrittenCache(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "cache.json")
+ body := `{"geo":{"Krakow":[50.06170,19.93730,"Krakow, PL","PL"]},"teryt":{"50.0617,19.9373":"1815"}}`
+ if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ c := New(path)
+ g, ok := c.Geo("Krakow")
+ if !ok || g.Label != "Krakow, PL" || g.Country != "PL" {
+ t.Fatalf("got %+v (%v)", g, ok)
+ }
+ if code, ok := c.Teryt("50.0617,19.9373"); !ok || code != "1815" {
+ t.Fatalf("teryt = %q (%v)", code, ok)
+ }
+}
+
+// writeCache puts raw bytes where the cache expects its file.
+func writeCache(t *testing.T, c *Cache, body string) {
+ t.Helper()
+ if err := os.MkdirAll(filepath.Dir(c.path), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(c.path, []byte(body), 0o644); err != nil {
+ t.Fatal(err)
+ }
+}
+
+// One unreadable entry used to cost the whole file: load treated the parse
+// error as "empty cache", and the next save wrote that emptiness over
+// everything that was still fine.
+func TestOneMalformedEntryDoesNotDestroyTheOthers(t *testing.T) {
+ c := tmpCache(t)
+ writeCache(t, c, `{"geo":{"Gdansk":[54.35227,18.64912,"Gdansk, PL","PL"],"Broken":[1]},"teryt":{"50.0617,19.9373":"1815"}}`)
+
+ if err := c.PutGeo("Krakow", Geo{Lat: 50.0617, Lon: 19.9373, Label: "Krakow, PL", Country: "PL"}); err != nil {
+ t.Fatal(err)
+ }
+ if _, ok := c.Geo("Gdansk"); !ok {
+ t.Error("a good entry was destroyed by an unrelated malformed one")
+ }
+ if _, ok := c.Geo("Krakow"); !ok {
+ t.Error("the new entry was not stored")
+ }
+ if code, ok := c.Teryt("50.0617,19.9373"); !ok || code != "1815" {
+ t.Errorf("teryt section lost too: got %q, %v", code, ok)
+ }
+ if _, ok := c.Geo("Broken"); ok {
+ t.Error("the malformed entry should be dropped, not resurrected")
+ }
+}
+
+// A hand-edit that breaks the whole document must not cost the file. The cache
+// is disposable, but whatever was typed into it is not, so it moves aside
+// rather than being overwritten -- and the run still gets a working cache.
+func TestAnUnparseableFileIsMovedAsideNotDestroyed(t *testing.T) {
+ c := tmpCache(t)
+ const broken = `{"geo":{"Gdansk":[54.35227,`
+ writeCache(t, c, broken)
+
+ if err := c.PutGeo("Krakow", Geo{Lat: 50.0617, Lon: 19.9373}); err != nil {
+ t.Fatalf("the tool must keep working: %v", err)
+ }
+ kept, err := os.ReadFile(c.path + ".bad")
+ if err != nil {
+ t.Fatalf("the unparseable file was not preserved: %v", err)
+ }
+ if string(kept) != broken {
+ t.Errorf("preserved copy differs:\n got %s\nwant %s", kept, broken)
+ }
+ if _, ok := c.Geo("Krakow"); !ok {
+ t.Error("the fresh cache did not take the new entry")
+ }
+}
+
+func TestPutTerytAlsoMovesAnUnparseableFileAside(t *testing.T) {
+ c := tmpCache(t)
+ const broken = `{oops`
+ writeCache(t, c, broken)
+ if err := c.PutTeryt("50.0617,19.9373", "1815"); err != nil {
+ t.Fatalf("the tool must keep working: %v", err)
+ }
+ kept, err := os.ReadFile(c.path + ".bad")
+ if err != nil || string(kept) != broken {
+ t.Errorf("unparseable file not preserved: %q, %v", kept, err)
+ }
+ if code, ok := c.Teryt("50.0617,19.9373"); !ok || code != "1815" {
+ t.Errorf("fresh cache did not take the entry: %q, %v", code, ok)
+ }
+}
+
+// The file is read by a human at least as often as by the program.
+func TestSaveWritesOneEntryPerLine(t *testing.T) {
+ c := tmpCache(t)
+ if err := c.PutGeo("Krakow", Geo{Lat: 50.06170, Lon: 19.93730, Label: "Krakow, PL", Country: "PL"}); err != nil {
+ t.Fatal(err)
+ }
+ if err := c.PutGeo("Chiang Mai", Geo{Lat: 18.79038, Lon: 98.98468, Label: "Chiang Mai, TH", Country: "TH"}); err != nil {
+ t.Fatal(err)
+ }
+ if err := c.PutTeryt("50.0617,19.9373", "1815"); err != nil {
+ t.Fatal(err)
+ }
+ body, err := os.ReadFile(c.path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ want := `{
+ "geo": {
+ "Chiang Mai": [18.79038, 98.98468, "Chiang Mai, TH", "TH"],
+ "Krakow": [50.06170, 19.93730, "Krakow, PL", "PL"]
+ },
+ "teryt": {
+ "50.0617,19.9373": "1815"
+ }
+}
+`
+ if string(body) != want {
+ t.Errorf("got:\n%s\nwant:\n%s", body, want)
+ }
+}
+
+func TestSavedFileIsStillValidJSONForTheOtherImplementation(t *testing.T) {
+ c := tmpCache(t)
+ want := Geo{Lat: 50.06170, Lon: 19.93730, Label: "Krakow, PL", Country: "PL"}
+ if err := c.PutGeo("Krakow", want); err != nil {
+ t.Fatal(err)
+ }
+ body, err := os.ReadFile(c.path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var got struct {
+ Geo map[string][]any `json:"geo"`
+ }
+ if err := json.Unmarshal(body, &got); err != nil {
+ t.Fatalf("a stock JSON parser could not read it: %v", err)
+ }
+ row := got.Geo["Krakow"]
+ if len(row) != 4 {
+ t.Fatalf("got %d fields, want the 4-element shape Python reads: %v", len(row), row)
+ }
+ if row[2] != "Krakow, PL" {
+ t.Errorf("label field is %v, want the third element", row[2])
+ }
+}
+
+func TestEmptyCacheSavesReadableJSON(t *testing.T) {
+ c := tmpCache(t)
+ if err := c.PutTeryt("52.5200,13.4000", ""); err != nil {
+ t.Fatal(err)
+ }
+ body, _ := os.ReadFile(c.path)
+ if !json.Valid(body) {
+ t.Fatalf("invalid JSON with an empty geo section:\n%s", body)
+ }
+}
diff --git a/internal/config/config.go b/internal/config/config.go
new file mode 100644
index 0000000..51d8c27
--- /dev/null
+++ b/internal/config/config.go
@@ -0,0 +1,360 @@
+// Package config reads prognosis' KEY=VALUE configuration file.
+//
+// The format is deliberately the same shape as wego's ~/.wegorc: one KEY=VALUE
+// per line, '#' starts a comment, values are never quoted. Parsing it here
+// rather than pulling in a config library keeps the binary dependency-free.
+package config
+
+import (
+ "bufio"
+ "fmt"
+ "os"
+ "path/filepath"
+ "sort"
+ "strconv"
+ "strings"
+)
+
+// Columns available for the hourly table, in the order they are documented.
+// The value is the Open-Meteo hourly field the column needs, or "" when the
+// column is derived from data already fetched.
+var columnFields = map[string]string{
+ "hour": "",
+ "icon": "weather_code",
+ "temp": "temperature_2m",
+ "feels": "apparent_temperature",
+ "conditions": "weather_code",
+ "mm": "precipitation",
+ "rain": "precipitation_probability",
+ "wind": "wind_speed_10m",
+ "gusts": "wind_gusts_10m",
+ "dir": "wind_direction_10m",
+ "humidity": "relative_humidity_2m",
+ "dew": "dew_point_2m",
+ "uv": "uv_index",
+ "cloud": "cloud_cover",
+ "pressure": "pressure_msl",
+ "visibility": "visibility",
+}
+
+var (
+ validIcons = map[string]bool{"nerd": true, "emoji": true, "none": true}
+ validColors = map[string]bool{"auto": true, "always": true, "never": true}
+ validUnits = map[string]bool{"metric": true, "imperial": true, "si": true}
+ validLangs = map[string]bool{"en": true, "pl": true}
+)
+
+// AllSpecies is every pollen taxon Open-Meteo reports for Europe.
+var AllSpecies = []string{"grass", "birch", "alder", "mugwort", "ragweed", "olive"}
+
+// Config is the fully resolved settings for one run.
+type Config struct {
+ Location string
+ Hours int
+ Units string
+ Columns []string
+ Icons string
+ Graph bool
+ GraphHeight int
+ Warnings bool
+ Pollen []string
+ Color string
+ DisplayLang string
+
+ // Minimal strips everything that is not the forecast itself: sun times, the
+ // day summary and pollen. Set by -weather, for output meant to be piped to
+ // someone who did not ask for pollen counts. Flag only -- there is no config
+ // key, because it describes one invocation rather than a preference.
+ Minimal bool
+
+ // ASCII restricts output to ASCII so an SMS stays in GSM-7 (160 characters
+ // per segment) instead of UCS-2 (70). One degree sign costs more than half
+ // the message.
+ ASCII bool
+}
+
+// Default returns the built-in configuration, used when no file exists and as
+// the base every file and flag overrides.
+func Default() Config {
+ return Config{
+ Hours: 12,
+ Units: "metric",
+ Columns: []string{"hour", "temp", "feels", "conditions", "mm", "rain"},
+ Icons: "nerd",
+ Graph: true,
+ GraphHeight: 5,
+ Warnings: true,
+ Pollen: append([]string(nil), AllSpecies...),
+ Color: "auto",
+ DisplayLang: "en",
+ }
+}
+
+// Path is the default location of the config file.
+func Path() string {
+ if dir, err := os.UserConfigDir(); err == nil {
+ return filepath.Join(dir, "prognosis", "config")
+ }
+ return filepath.Join(os.Getenv("HOME"), ".config", "prognosis", "config")
+}
+
+// ValidColumns lists every column name, sorted, for error messages and docs.
+func ValidColumns() []string {
+ names := make([]string, 0, len(columnFields))
+ for k := range columnFields {
+ names = append(names, k)
+ }
+ sort.Strings(names)
+ return names
+}
+
+// Fields returns the Open-Meteo hourly fields the selected columns need.
+// Only what is displayed is requested, so a narrow table costs a small response.
+func (c Config) Fields() []string {
+ seen := map[string]bool{}
+ var out []string
+ for _, col := range c.Columns {
+ f := columnFields[col]
+ if f == "" || seen[f] {
+ continue
+ }
+ seen[f] = true
+ out = append(out, f)
+ }
+ sort.Strings(out)
+ return out
+}
+
+// Has reports whether a column is selected.
+func (c Config) Has(column string) bool {
+ for _, col := range c.Columns {
+ if col == column {
+ return true
+ }
+ }
+ return false
+}
+
+// Validate rejects unusable settings, naming the offending value and listing
+// what would have been accepted. A silently blank column is worse than an error.
+func (c Config) Validate() error {
+ for _, col := range c.Columns {
+ if _, ok := columnFields[col]; !ok {
+ return fmt.Errorf("unknown column %q; valid: %s",
+ col, strings.Join(ValidColumns(), ", "))
+ }
+ }
+ if !validIcons[c.Icons] {
+ return fmt.Errorf("unknown icons %q; valid: emoji, nerd, none", c.Icons)
+ }
+ if !validColors[c.Color] {
+ return fmt.Errorf("unknown color %q; valid: auto, always, never", c.Color)
+ }
+ if !validUnits[c.Units] {
+ return fmt.Errorf("unknown units %q; valid: metric, imperial, si", c.Units)
+ }
+ if !validLangs[c.DisplayLang] {
+ return fmt.Errorf("unknown display_lang %q; valid: en, pl", c.DisplayLang)
+ }
+ if c.Hours < 1 {
+ return fmt.Errorf("hours must be at least 1, got %d", c.Hours)
+ }
+ if c.GraphHeight < 2 {
+ return fmt.Errorf("graph_height must be at least 2, got %d", c.GraphHeight)
+ }
+ for _, s := range c.Pollen {
+ if !contains(AllSpecies, s) {
+ return fmt.Errorf("unknown pollen species %q; valid: %s, all, none",
+ s, strings.Join(AllSpecies, ", "))
+ }
+ }
+ return nil
+}
+
+func contains(list []string, want string) bool {
+ for _, s := range list {
+ if s == want {
+ return true
+ }
+ }
+ return false
+}
+
+// Load reads a config file over the defaults. A missing file is not an error:
+// the defaults stand, and the caller may write them out.
+func Load(path string) (Config, error) {
+ cfg := Default()
+ f, err := os.Open(path)
+ if err != nil {
+ if os.IsNotExist(err) {
+ return cfg, nil
+ }
+ return cfg, err
+ }
+ defer f.Close()
+
+ sc := bufio.NewScanner(f)
+ for line := 1; sc.Scan(); line++ {
+ text := strings.TrimSpace(sc.Text())
+ if text == "" || strings.HasPrefix(text, "#") {
+ continue
+ }
+ // Trailing comments are allowed so the generated file can annotate keys.
+ if i := strings.Index(text, "#"); i >= 0 {
+ text = strings.TrimSpace(text[:i])
+ }
+ key, value, ok := strings.Cut(text, "=")
+ if !ok {
+ return cfg, fmt.Errorf("%s:%d: expected KEY=VALUE, got %q", path, line, text)
+ }
+ if err := cfg.set(strings.TrimSpace(key), strings.TrimSpace(value)); err != nil {
+ return cfg, fmt.Errorf("%s:%d: %w", path, line, err)
+ }
+ }
+ return cfg, sc.Err()
+}
+
+func (c *Config) set(key, value string) error {
+ switch key {
+ case "location":
+ c.Location = value
+ case "units":
+ c.Units = value
+ case "icons":
+ c.Icons = value
+ case "color":
+ c.Color = value
+ case "display_lang":
+ c.DisplayLang = value
+ case "ascii":
+ b, err := parseBool(value)
+ if err != nil {
+ return fmt.Errorf("ascii: %w", err)
+ }
+ c.ASCII = b
+ case "columns":
+ c.Columns = splitList(value)
+ case "pollen":
+ switch value {
+ case "all":
+ c.Pollen = append([]string(nil), AllSpecies...)
+ case "none":
+ c.Pollen = nil
+ default:
+ c.Pollen = splitList(value)
+ }
+ case "hours":
+ n, err := strconv.Atoi(value)
+ if err != nil {
+ return fmt.Errorf("hours: %q is not a number", value)
+ }
+ c.Hours = n
+ case "graph_height":
+ n, err := strconv.Atoi(value)
+ if err != nil {
+ return fmt.Errorf("graph_height: %q is not a number", value)
+ }
+ c.GraphHeight = n
+ case "graph":
+ b, err := parseBool(value)
+ if err != nil {
+ return fmt.Errorf("graph: %w", err)
+ }
+ c.Graph = b
+ case "warnings":
+ b, err := parseBool(value)
+ if err != nil {
+ return fmt.Errorf("warnings: %w", err)
+ }
+ c.Warnings = b
+ default:
+ return fmt.Errorf("unknown key %q", key)
+ }
+ return nil
+}
+
+func parseBool(v string) (bool, error) {
+ switch strings.ToLower(v) {
+ case "true", "yes", "on", "1":
+ return true, nil
+ case "false", "no", "off", "0":
+ return false, nil
+ }
+ return false, fmt.Errorf("%q is not true or false", v)
+}
+
+func splitList(v string) []string {
+ var out []string
+ for _, part := range strings.Split(v, ",") {
+ if p := strings.TrimSpace(part); p != "" {
+ out = append(out, p)
+ }
+ }
+ return out
+}
+
+const template = `# prognosis configuration
+#
+# One KEY=VALUE per line. '#' starts a comment. Values are not quoted.
+# Command line flags override everything here.
+
+# Place to query. When empty, location= from ~/.wegorc is used, so prognosis
+# and wego never disagree about where you are.
+location=%s
+
+# Default span in hours. -n and -d override it.
+hours=%d
+
+# metric (C, km/h, mm) | imperial (F, mph, inch) | si (C, m/s, mm)
+units=%s
+
+# Columns, in order. Available:
+# %s
+columns=%s
+
+# Weather glyph set for the "icon" column: nerd | emoji | none.
+# nerd is single-width and monochrome, so it follows the terminal palette.
+# emoji are colour glyphs from a fallback font and are not all one cell wide.
+icons=%s
+
+# Temperature chart under the table.
+graph=%t
+graph_height=%d
+
+# Official IMGW warnings for your powiat (Poland only).
+warnings=%t
+
+# Pollen species to report, or "all" / "none".
+pollen=%s
+
+# Restrict output to ASCII: no degree sign, no diacritics, no block drawing.
+# For SMS, where one non-ASCII character cuts the segment from 160 to 70 chars.
+ascii=%t
+
+# auto (colour when stdout is a terminal) | always | never
+color=%s
+
+# Language for everything prognosis writes itself -- headers, condition names,
+# labels, dates, pollen species: en | pl. IMGW publishes its warning text in
+# Polish only, so that text stays Polish whatever this is set to.
+display_lang=%s
+`
+
+// WriteDefault writes a commented configuration file, creating parent
+// directories. The generated file documents every key, so the config is
+// discoverable without the README.
+func WriteDefault(path string, c Config) error {
+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
+ return err
+ }
+ pollen := "none"
+ if len(c.Pollen) > 0 {
+ pollen = strings.Join(c.Pollen, ",")
+ }
+ body := fmt.Sprintf(template,
+ c.Location, c.Hours, c.Units,
+ strings.Join(ValidColumns(), ", "),
+ strings.Join(c.Columns, ","),
+ c.Icons, c.Graph, c.GraphHeight, c.Warnings, pollen, c.ASCII, c.Color, c.DisplayLang)
+ return os.WriteFile(path, []byte(body), 0o644)
+}
diff --git a/internal/config/config_test.go b/internal/config/config_test.go
new file mode 100644
index 0000000..06c0234
--- /dev/null
+++ b/internal/config/config_test.go
@@ -0,0 +1,158 @@
+package config
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+func write(t *testing.T, body string) string {
+ t.Helper()
+ path := filepath.Join(t.TempDir(), "config")
+ if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ return path
+}
+
+func TestLoadMissingFileKeepsDefaults(t *testing.T) {
+ cfg, err := Load(filepath.Join(t.TempDir(), "absent"))
+ if err != nil {
+ t.Fatalf("missing file should not be an error: %v", err)
+ }
+ if cfg.Hours != 12 || cfg.Icons != "nerd" {
+ t.Fatalf("defaults not returned: %+v", cfg)
+ }
+}
+
+func TestLoadOverridesOnlyWhatIsSet(t *testing.T) {
+ cfg, err := Load(write(t, "hours=24\nicons=emoji\n"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if cfg.Hours != 24 {
+ t.Errorf("hours = %d, want 24", cfg.Hours)
+ }
+ if cfg.Icons != "emoji" {
+ t.Errorf("icons = %q, want emoji", cfg.Icons)
+ }
+ // Untouched keys must keep their defaults.
+ if cfg.GraphHeight != 5 || !cfg.Graph {
+ t.Errorf("unset keys lost their defaults: %+v", cfg)
+ }
+}
+
+func TestLoadCommentsAndBlanks(t *testing.T) {
+ cfg, err := Load(write(t, "\n# a comment\n\nhours=6 # trailing comment\n"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if cfg.Hours != 6 {
+ t.Fatalf("hours = %d, want 6 (trailing comment must be stripped)", cfg.Hours)
+ }
+}
+
+func TestLoadErrors(t *testing.T) {
+ for name, body := range map[string]string{
+ "no equals": "hours 12\n",
+ "unknown key": "colour=always\n",
+ "not a number": "hours=soon\n",
+ "not a bool": "graph=maybe\n",
+ } {
+ t.Run(name, func(t *testing.T) {
+ if _, err := Load(write(t, body)); err == nil {
+ t.Fatalf("expected an error for %q", body)
+ }
+ })
+ }
+}
+
+func TestPollenAllAndNone(t *testing.T) {
+ cfg, _ := Load(write(t, "pollen=all\n"))
+ if len(cfg.Pollen) != len(AllSpecies) {
+ t.Errorf("pollen=all gave %v", cfg.Pollen)
+ }
+ cfg, _ = Load(write(t, "pollen=none\n"))
+ if len(cfg.Pollen) != 0 {
+ t.Errorf("pollen=none gave %v", cfg.Pollen)
+ }
+}
+
+func TestValidateNamesTheOffender(t *testing.T) {
+ cfg := Default()
+ cfg.Columns = []string{"hour", "tempature"}
+ err := cfg.Validate()
+ if err == nil {
+ t.Fatal("expected an error for an unknown column")
+ }
+ if !strings.Contains(err.Error(), "tempature") {
+ t.Errorf("error must name the offending column, got: %v", err)
+ }
+ if !strings.Contains(err.Error(), "conditions") {
+ t.Errorf("error must list valid columns, got: %v", err)
+ }
+}
+
+func TestValidateRejectsBadEnums(t *testing.T) {
+ for name, mutate := range map[string]func(*Config){
+ "icons": func(c *Config) { c.Icons = "pictures" },
+ "color": func(c *Config) { c.Color = "sometimes" },
+ "units": func(c *Config) { c.Units = "furlongs" },
+ "hours": func(c *Config) { c.Hours = 0 },
+ "graph_height": func(c *Config) { c.GraphHeight = 1 },
+ "pollen": func(c *Config) { c.Pollen = []string{"oak"} },
+ } {
+ t.Run(name, func(t *testing.T) {
+ cfg := Default()
+ mutate(&cfg)
+ if err := cfg.Validate(); err == nil {
+ t.Fatalf("expected %s to be rejected", name)
+ }
+ })
+ }
+}
+
+func TestDefaultIsValid(t *testing.T) {
+ if err := Default().Validate(); err != nil {
+ t.Fatalf("the built-in default must be valid: %v", err)
+ }
+}
+
+func TestFieldsRequestsOnlySelectedColumns(t *testing.T) {
+ cfg := Default()
+ cfg.Columns = []string{"hour", "temp", "wind"}
+ got := strings.Join(cfg.Fields(), ",")
+ want := "temperature_2m,wind_speed_10m"
+ if got != want {
+ t.Fatalf("Fields() = %q, want %q", got, want)
+ }
+}
+
+func TestFieldsDeduplicates(t *testing.T) {
+ cfg := Default()
+ // icon and conditions both come from weather_code.
+ cfg.Columns = []string{"icon", "conditions"}
+ if got := cfg.Fields(); len(got) != 1 || got[0] != "weather_code" {
+ t.Fatalf("Fields() = %v, want one weather_code", got)
+ }
+}
+
+func TestWriteDefaultRoundTrips(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "sub", "config")
+ want := Default()
+ want.Location = "Krakow"
+ if err := WriteDefault(path, want); err != nil {
+ t.Fatal(err)
+ }
+ got, err := Load(path)
+ if err != nil {
+ t.Fatalf("the file we generate must parse: %v", err)
+ }
+ if got.Location != "Krakow" || got.Hours != want.Hours || got.Icons != want.Icons {
+ t.Fatalf("round trip changed values:\n got %+v\nwant %+v", got, want)
+ }
+ if err := got.Validate(); err != nil {
+ t.Fatalf("the file we generate must validate: %v", err)
+ }
+}
diff --git a/internal/i18n/i18n.go b/internal/i18n/i18n.go
new file mode 100644
index 0000000..f0526be
--- /dev/null
+++ b/internal/i18n/i18n.go
@@ -0,0 +1,196 @@
+// Package i18n holds the translations for everything prognosis writes itself:
+// column headers, condition names, section labels, weekday and month names, and
+// pollen species and bands.
+//
+// What is deliberately NOT translated: the body of an IMGW warning. IMGW
+// publishes those in Polish only, and rendering an invented English version of
+// an official warning would be worse than showing the original. In English mode
+// the surrounding labels are English and the warning text stays Polish.
+package i18n
+
+import (
+ "fmt"
+ "time"
+)
+
+// Lang is a supported display language.
+type Lang string
+
+const (
+ EN Lang = "en"
+ PL Lang = "pl"
+)
+
+// Catalog is the set of strings for one language.
+type Catalog struct {
+ lang Lang
+
+ // Table headers, keyed by column name.
+ headers map[string]string
+ // WMO weather code descriptions.
+ conditions map[int]string
+ // Section labels and short words.
+ words map[string]string
+ // Pollen taxa.
+ species map[string]string
+ // Qualitative pollen levels.
+ bands map[string]string
+
+ weekdays [7]string
+ months [12]string
+}
+
+var catalogs = map[Lang]*Catalog{EN: english(), PL: polish()}
+
+// For returns the catalogue for a language, falling back to English so a
+// mistyped language degrades to readable output rather than empty strings.
+func For(lang string) *Catalog {
+ if c, ok := catalogs[Lang(lang)]; ok {
+ return c
+ }
+ return catalogs[EN]
+}
+
+// Lang reports which language this catalogue is.
+func (c *Catalog) Lang() Lang { return c.lang }
+
+// Header is the column heading for a column name.
+func (c *Catalog) Header(column string) string {
+ if s, ok := c.headers[column]; ok {
+ return s
+ }
+ return column
+}
+
+// Condition describes a WMO weather code. Unknown codes are reported as such
+// rather than silently blank, so a new code in the API is visible.
+func (c *Catalog) Condition(code int) string {
+ if s, ok := c.conditions[code]; ok {
+ return s
+ }
+ return fmt.Sprintf("code %d", code)
+}
+
+// Word returns a label such as "day", "pollen" or "warnings".
+func (c *Catalog) Word(key string) string {
+ if s, ok := c.words[key]; ok {
+ return s
+ }
+ return key
+}
+
+// Species is the display name of a pollen taxon.
+func (c *Catalog) Species(name string) string {
+ if s, ok := c.species[name]; ok {
+ return s
+ }
+ return name
+}
+
+// Band is the display name of a qualitative pollen level.
+func (c *Catalog) Band(key string) string {
+ if s, ok := c.bands[key]; ok {
+ return s
+ }
+ return key
+}
+
+// Date formats a date as "Mon 10 Aug" in the catalogue's language. Go's time
+// package has no locale support, so the names come from the catalogue.
+func (c *Catalog) Date(t time.Time) string {
+ return fmt.Sprintf("%s %02d %s", c.weekdays[int(t.Weekday())], t.Day(), c.months[int(t.Month())-1])
+}
+
+func english() *Catalog {
+ return &Catalog{
+ lang: EN,
+ headers: map[string]string{
+ "hour": "hr", "icon": "", "temp": "temp", "feels": "feels",
+ "conditions": "conditions", "mm": "mm", "rain": "rain",
+ "wind": "wind", "gusts": "gusts", "dir": "dir",
+ "humidity": "hum", "dew": "dew", "uv": "uv",
+ "cloud": "cloud", "pressure": "hPa", "visibility": "vis",
+ },
+ conditions: map[int]string{
+ 0: "clear", 1: "mainly clear", 2: "part cloudy", 3: "overcast",
+ 45: "fog", 48: "rime fog",
+ 51: "lt drizzle", 53: "drizzle", 55: "hvy drizzle",
+ 56: "frz drizzle", 57: "frz drizzle",
+ 61: "lt rain", 63: "rain", 65: "hvy rain",
+ 66: "frz rain", 67: "frz rain",
+ 71: "lt snow", 73: "snow", 75: "hvy snow", 77: "snow grains",
+ 80: "lt showers", 81: "showers", 82: "hvy showers",
+ 85: "snow showers", 86: "hvy snow showers",
+ 95: "thunderstorm", 96: "storm + hail", 99: "storm + hail",
+ },
+ words: map[string]string{
+ "sun": "sun", "up": "up", "down": "down",
+ "day": "day", "dry": "dry", "pollen": "pollen",
+ "rainfall": "rain", "over": "over", "daylight": "daylight",
+ "of": "of", "max": "max", "level": "level",
+ "warnings": "warnings", "could_not_check": "could not check IMGW",
+ "poland_only": "IMGW covers Poland only",
+ "hours_available": "hours available, not",
+ "h_per_col": "h/col", "temp_row": "temp", "rain_row": "rain",
+ },
+ species: map[string]string{
+ "grass": "grass", "birch": "birch", "alder": "alder",
+ "mugwort": "mugwort", "ragweed": "ragweed", "olive": "olive",
+ },
+ bands: map[string]string{
+ "none": "none", "low": "low", "medium": "medium",
+ "high": "high", "very high": "very high",
+ },
+ weekdays: [7]string{"Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"},
+ months: [12]string{"Jan", "Feb", "Mar", "Apr", "May", "Jun",
+ "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"},
+ }
+}
+
+func polish() *Catalog {
+ return &Catalog{
+ lang: PL,
+ headers: map[string]string{
+ "hour": "godz", "icon": "", "temp": "temp", "feels": "odczuw",
+ "conditions": "warunki", "mm": "mm", "rain": "deszcz",
+ "wind": "wiatr", "gusts": "porywy", "dir": "kier",
+ "humidity": "wilg", "dew": "ros", "uv": "uv",
+ "cloud": "chmury", "pressure": "hPa", "visibility": "wid",
+ },
+ // Terminology follows IMGW's own vocabulary where it has one, so the
+ // table and a warning describe the same weather in the same words.
+ conditions: map[int]string{
+ 0: "bezchmurnie", 1: "gł. bezchmurnie", 2: "częśc. zachm.", 3: "zachmurzenie",
+ 45: "mgła", 48: "mgła osadz.",
+ 51: "słaba mżawka", 53: "mżawka", 55: "silna mżawka",
+ 56: "mżawka marzn.", 57: "mżawka marzn.",
+ 61: "słaby deszcz", 63: "deszcz", 65: "silny deszcz",
+ 66: "deszcz marzn.", 67: "deszcz marzn.",
+ 71: "słaby śnieg", 73: "śnieg", 75: "silny śnieg", 77: "ziarna śniegu",
+ 80: "słabe przelotne", 81: "przelotne", 82: "silne przelotne",
+ 85: "przelotny śnieg", 86: "silny przel. śnieg",
+ 95: "burza", 96: "burza z gradem", 99: "burza z gradem",
+ },
+ words: map[string]string{
+ "sun": "słońce", "up": "wsch", "down": "zach",
+ "day": "dzień", "dry": "sucho", "pollen": "pyłek",
+ "rainfall": "opad", "over": "przez", "daylight": "dnia",
+ "of": "z", "max": "maks", "level": "stopień",
+ "warnings": "ostrzeżenia", "could_not_check": "nie udało się sprawdzić IMGW",
+ "poland_only": "IMGW obejmuje tylko Polskę",
+ "hours_available": "godzin dostępnych, nie",
+ "h_per_col": "h/kol", "temp_row": "temp", "rain_row": "opad",
+ },
+ species: map[string]string{
+ "grass": "trawy", "birch": "brzoza", "alder": "olcha",
+ "mugwort": "bylica", "ragweed": "ambrozja", "olive": "oliwka",
+ },
+ bands: map[string]string{
+ "none": "brak", "low": "niskie", "medium": "średnie",
+ "high": "wysokie", "very high": "b. wysokie",
+ },
+ weekdays: [7]string{"nd", "pn", "wt", "śr", "cz", "pt", "sb"},
+ months: [12]string{"sty", "lut", "mar", "kwi", "maj", "cze",
+ "lip", "sie", "wrz", "paź", "lis", "gru"},
+ }
+}
diff --git a/internal/i18n/i18n_test.go b/internal/i18n/i18n_test.go
new file mode 100644
index 0000000..2a6a617
--- /dev/null
+++ b/internal/i18n/i18n_test.go
@@ -0,0 +1,88 @@
+package i18n
+
+import (
+ "testing"
+ "time"
+
+ "github.com/lukaszkasprzak/prognosis/internal/config"
+)
+
+func TestForFallsBackToEnglish(t *testing.T) {
+ if For("klingon").Lang() != EN {
+ t.Fatal("an unknown language must degrade to English, not to empty strings")
+ }
+ if For("pl").Lang() != PL {
+ t.Fatal("pl must resolve to the Polish catalogue")
+ }
+}
+
+// Every catalogue must cover every column, or a table in that language would
+// silently fall back to the internal column name.
+func TestEveryLanguageCoversEveryColumn(t *testing.T) {
+ for _, lang := range []Lang{EN, PL} {
+ c := For(string(lang))
+ for _, col := range config.ValidColumns() {
+ if _, ok := c.headers[col]; !ok {
+ t.Errorf("%s: no header for column %q", lang, col)
+ }
+ }
+ }
+}
+
+// The WMO codes must match across languages: a code described in English but
+// not Polish would print "code 71" to a Polish reader.
+func TestConditionCoverageMatchesAcrossLanguages(t *testing.T) {
+ en, pl := For("en"), For("pl")
+ for code := range en.conditions {
+ if _, ok := pl.conditions[code]; !ok {
+ t.Errorf("code %d described in English but not Polish", code)
+ }
+ }
+ for code := range pl.conditions {
+ if _, ok := en.conditions[code]; !ok {
+ t.Errorf("code %d described in Polish but not English", code)
+ }
+ }
+}
+
+func TestWordAndSpeciesCoverageMatches(t *testing.T) {
+ en, pl := For("en"), For("pl")
+ for k := range en.words {
+ if _, ok := pl.words[k]; !ok {
+ t.Errorf("word %q missing from Polish", k)
+ }
+ }
+ for _, s := range config.AllSpecies {
+ if _, ok := en.species[s]; !ok {
+ t.Errorf("species %q missing from English", s)
+ }
+ if _, ok := pl.species[s]; !ok {
+ t.Errorf("species %q missing from Polish", s)
+ }
+ }
+ for _, b := range []string{"none", "low", "medium", "high", "very high"} {
+ if en.Band(b) == b && b != "none" && b != "low" && b != "medium" && b != "high" && b != "very high" {
+ t.Errorf("band %q missing from English", b)
+ }
+ if pl.Band(b) == b {
+ t.Errorf("band %q not translated to Polish", b)
+ }
+ }
+}
+
+func TestUnknownCodeIsVisible(t *testing.T) {
+ got := For("en").Condition(4242)
+ if got != "code 4242" {
+ t.Fatalf("unknown codes must be visible, got %q", got)
+ }
+}
+
+func TestDate(t *testing.T) {
+ d := time.Date(2026, 8, 10, 13, 0, 0, 0, time.UTC) // a Monday
+ if got, want := For("en").Date(d), "Mon 10 Aug"; got != want {
+ t.Errorf("en date = %q, want %q", got, want)
+ }
+ if got, want := For("pl").Date(d), "pn 10 sie"; got != want {
+ t.Errorf("pl date = %q, want %q", got, want)
+ }
+}
diff --git a/internal/imgw/imgw.go b/internal/imgw/imgw.go
new file mode 100644
index 0000000..48ffe19
--- /dev/null
+++ b/internal/imgw/imgw.go
@@ -0,0 +1,156 @@
+// Package imgw fetches official Polish meteorological warnings and resolves a
+// point to the powiat code those warnings are tagged with.
+//
+// Both services are public and need no key.
+package imgw
+
+import (
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "strconv"
+ "time"
+)
+
+// Endpoints are variables so tests can serve recorded fixtures locally.
+var (
+ warningsURL = "https://danepubliczne.imgw.pl/api/data/warningsmeteo"
+ gugikURL = "https://services.gugik.gov.pl/uug/"
+)
+
+// now is the clock, replaceable in tests: whether a warning has expired depends
+// on it.
+var now = time.Now
+
+// Timeout bounds every request.
+var Timeout = 15 * time.Second
+
+// Status is the outcome of resolving a point to a powiat.
+type Status int
+
+const (
+ // StatusOK means the point resolved to a powiat code.
+ StatusOK Status = iota
+ // StatusOutside means GUGiK answered but knows no address there. It covers
+ // Poland only, so the point is abroad.
+ StatusOutside
+ // StatusError means the service could not be asked.
+ //
+ // This must never be conflated with StatusOutside: one means "no warnings
+ // apply here", the other "I do not know whether any apply".
+ StatusError
+)
+
+// Warning is one IMGW warning in force.
+type Warning struct {
+ Event string `json:"nazwa_zdarzenia"`
+ Level string `json:"stopien"`
+ Probability string `json:"prawdopodobienstwo"`
+ From string `json:"obowiazuje_od"`
+ To string `json:"obowiazuje_do"`
+ Text string `json:"tresc"`
+ Teryt []any `json:"teryt"`
+}
+
+func fetch(rawURL string, params url.Values, into any) error {
+ host := ""
+ if u, err := url.Parse(rawURL); err == nil {
+ host = u.Host
+ }
+ full := rawURL
+ if len(params) > 0 {
+ full += "?" + params.Encode()
+ }
+ req, err := http.NewRequest("GET", full, nil)
+ if err != nil {
+ return err
+ }
+ req.Header.Set("User-Agent", "prognosis/1.0")
+ resp, err := (&http.Client{Timeout: Timeout}).Do(req)
+ if err != nil {
+ return fmt.Errorf("cannot reach %s: %w", host, err)
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ return fmt.Errorf("%s returned HTTP %d", host, resp.StatusCode)
+ }
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return err
+ }
+ return json.Unmarshal(body, into)
+}
+
+type gugikResponse struct {
+ Results map[string]struct {
+ Teryt string `json:"teryt"`
+ } `json:"results"`
+}
+
+// Powiat resolves a point to its 4-digit TERYT powiat code via GUGiK.
+//
+// IMGW tags every warning with the powiat codes it covers, so this is what
+// makes "warnings for my area" mean this area rather than the whole country.
+func Powiat(lat, lon float64) (string, Status, error) {
+ var r gugikResponse
+ err := fetch(gugikURL, url.Values{
+ "request": {"GetAddressReverse"},
+ "location": {fmt.Sprintf("POINT(%.6f %.6f)", lon, lat)},
+ "srid": {"4326"},
+ // The default 100 m radius finds nothing in the mountains or deep
+ // countryside, which looks identical to being abroad and would suppress
+ // real warnings. GUGiK clamps this to its own 5 km maximum.
+ "radius": {"10000"},
+ }, &r)
+ if err != nil {
+ return "", StatusError, err
+ }
+ for _, entry := range r.Results {
+ if len(entry.Teryt) >= 4 {
+ return entry.Teryt[:4], StatusOK, nil
+ }
+ }
+ return "", StatusOutside, nil
+}
+
+// Warnings returns the warnings in force for a powiat.
+func Warnings(powiat string) ([]Warning, error) {
+ var all []Warning
+ if err := fetch(warningsURL, nil, &all); err != nil {
+ return nil, err
+ }
+ cut := now()
+ var live []Warning
+ for _, w := range all {
+ if !w.covers(powiat) {
+ continue
+ }
+ // An expired warning is dropped; one whose date will not parse is kept,
+ // because showing a stale warning beats hiding a live one.
+ if to, err := time.ParseInLocation("2006-01-02 15:04:05", w.To, time.Local); err == nil {
+ if to.Before(cut) {
+ continue
+ }
+ }
+ live = append(live, w)
+ }
+ return live, nil
+}
+
+func (w Warning) covers(powiat string) bool {
+ for _, a := range w.Teryt {
+ switch v := a.(type) {
+ case string:
+ if v == powiat {
+ return true
+ }
+ case float64:
+ if strconv.Itoa(int(v)) == powiat {
+ return true
+ }
+ }
+ }
+ return false
+}
diff --git a/internal/imgw/imgw_test.go b/internal/imgw/imgw_test.go
new file mode 100644
index 0000000..ffc678e
--- /dev/null
+++ b/internal/imgw/imgw_test.go
@@ -0,0 +1,190 @@
+package imgw
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+)
+
+func serve(t *testing.T, fixture string, gotQuery *string) *httptest.Server {
+ t.Helper()
+ body, err := os.ReadFile(filepath.Join("testdata", fixture))
+ if err != nil {
+ t.Fatal(err)
+ }
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if gotQuery != nil {
+ *gotQuery = r.URL.RawQuery
+ }
+ w.Write(body)
+ }))
+ t.Cleanup(srv.Close)
+ return srv
+}
+
+func serveText(t *testing.T, body string) *httptest.Server {
+ t.Helper()
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Write([]byte(body))
+ }))
+ t.Cleanup(srv.Close)
+ return srv
+}
+
+func pinClock(t *testing.T, at time.Time) {
+ t.Helper()
+ old := now
+ now = func() time.Time { return at }
+ t.Cleanup(func() { now = old })
+}
+
+// A point inside Poland resolves to the first four digits of its TERYT code.
+func TestPowiatTruncatesTerytToFourDigits(t *testing.T) {
+ srv := serve(t, "gugik_krakow.json", nil)
+ gugikURL = srv.URL
+ code, status, err := Powiat(50.0617, 19.9373)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if status != StatusOK {
+ t.Fatalf("status = %v, want StatusOK", status)
+ }
+ if code != "1815" {
+ t.Fatalf("code = %q, want 1815 (first four digits of 126101)", code)
+ }
+}
+
+// GUGiK answers a foreign point with HTTP 200 and no results. That is a real
+// answer -- "not in Poland" -- and must never be confused with a failure, which
+// is the difference between "no warnings apply" and "I do not know".
+func TestPowiatOutsidePolandIsAnAnswerNotAnError(t *testing.T) {
+ srv := serve(t, "gugik_abroad.json", nil)
+ gugikURL = srv.URL
+ code, status, err := Powiat(52.52, 13.40)
+ if err != nil {
+ t.Fatalf("a valid 'no results' response must not be an error: %v", err)
+ }
+ if status != StatusOutside {
+ t.Fatalf("status = %v, want StatusOutside", status)
+ }
+ if code != "" {
+ t.Fatalf("code = %q, want empty", code)
+ }
+}
+
+func TestPowiatUnreachableIsStatusError(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ http.Error(w, "boom", http.StatusBadGateway)
+ }))
+ srv.Close() // closed: the request cannot connect at all
+ gugikURL = srv.URL
+ _, status, err := Powiat(50, 21)
+ if status != StatusError {
+ t.Fatalf("status = %v, want StatusError", status)
+ }
+ if err == nil {
+ t.Fatal("expected an error describing the failure")
+ }
+}
+
+// The default 100 m radius finds nothing in the mountains, which is
+// indistinguishable from being abroad and would suppress real warnings.
+func TestPowiatAsksForAWideRadius(t *testing.T) {
+ var query string
+ srv := serve(t, "gugik_krakow.json", &query)
+ gugikURL = srv.URL
+ if _, _, err := Powiat(50, 21); err != nil {
+ t.Fatal(err)
+ }
+ if !strings.Contains(query, "radius=10000") {
+ t.Fatalf("query %q must ask for a wide radius", query)
+ }
+}
+
+func TestWarningsKeepOnlyThisPowiat(t *testing.T) {
+ pinClock(t, time.Date(2000, 1, 1, 0, 0, 0, 0, time.Local)) // nothing expired
+ body := `[
+ {"nazwa_zdarzenia":"Upal","stopien":"1","obowiazuje_do":"2030-01-01 00:00:00","teryt":["1815","1234"]},
+ {"nazwa_zdarzenia":"Burze","stopien":"2","obowiazuje_do":"2030-01-01 00:00:00","teryt":["2207"]}
+ ]`
+ srv := serveText(t, body)
+ warningsURL = srv.URL
+
+ live, err := Warnings("1815")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(live) != 1 || live[0].Event != "Upal" {
+ t.Fatalf("got %+v, want only the warning covering 1815", live)
+ }
+}
+
+func TestWarningsDropExpiredButKeepUnparseableDates(t *testing.T) {
+ pinClock(t, time.Date(2026, 8, 10, 12, 0, 0, 0, time.Local))
+ body := `[
+ {"nazwa_zdarzenia":"Wczorajsze","stopien":"1","obowiazuje_do":"2026-08-09 23:00:00","teryt":["1815"]},
+ {"nazwa_zdarzenia":"Trwajace","stopien":"1","obowiazuje_do":"2026-08-10 20:00:00","teryt":["1815"]},
+ {"nazwa_zdarzenia":"Bezdaty","stopien":"1","obowiazuje_do":"","teryt":["1815"]}
+ ]`
+ srv := serveText(t, body)
+ warningsURL = srv.URL
+
+ live, err := Warnings("1815")
+ if err != nil {
+ t.Fatal(err)
+ }
+ var names []string
+ for _, w := range live {
+ names = append(names, w.Event)
+ }
+ got := strings.Join(names, ",")
+ // Unparseable is kept: showing a stale warning beats hiding a live one.
+ if got != "Trwajace,Bezdaty" {
+ t.Fatalf("got %q, want \"Trwajace,Bezdaty\"", got)
+ }
+}
+
+// IMGW has been seen to encode TERYT codes as numbers as well as strings.
+func TestWarningsMatchNumericTerytCodes(t *testing.T) {
+ pinClock(t, time.Date(2000, 1, 1, 0, 0, 0, 0, time.Local))
+ srv := serveText(t, `[{"nazwa_zdarzenia":"X","obowiazuje_do":"2030-01-01 00:00:00","teryt":[1815]}]`)
+ warningsURL = srv.URL
+ live, err := Warnings("1815")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(live) != 1 {
+ t.Fatalf("a numeric teryt entry must still match, got %d warnings", len(live))
+ }
+}
+
+func TestWarningsUnreachableIsAnError(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
+ srv.Close()
+ warningsURL = srv.URL
+ if _, err := Warnings("1815"); err == nil {
+ t.Fatal("expected an error: the caller must be able to say 'could not check'")
+ }
+}
+
+// The recorded national feed must parse, and must not match a made-up powiat.
+func TestWarningsParseTheRecordedFeed(t *testing.T) {
+ pinClock(t, time.Date(2000, 1, 1, 0, 0, 0, 0, time.Local))
+ srv := serve(t, "warnings.json", nil)
+ warningsURL = srv.URL
+
+ if _, err := Warnings("1815"); err != nil {
+ t.Fatalf("the recorded feed must parse: %v", err)
+ }
+ none, err := Warnings("9999")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(none) != 0 {
+ t.Fatalf("powiat 9999 does not exist but matched %d warnings", len(none))
+ }
+}
diff --git a/internal/imgw/testdata/gugik_abroad.json b/internal/imgw/testdata/gugik_abroad.json
new file mode 100644
index 0000000..f6e58d8
--- /dev/null
+++ b/internal/imgw/testdata/gugik_abroad.json
@@ -0,0 +1 @@
+{"type":"address","max results limit":1,"radius":5000,"max polygon area":null,"returned objects":0,"results":null,"request time":0.00072391430536905923} \ No newline at end of file
diff --git a/internal/imgw/testdata/warnings.json b/internal/imgw/testdata/warnings.json
new file mode 100644
index 0000000..2f6b37f
--- /dev/null
+++ b/internal/imgw/testdata/warnings.json
@@ -0,0 +1 @@
+[{"id":"Wr20260810041817563","nazwa_zdarzenia":"Burze","stopien":"2","prawdopodobienstwo":"70","obowiazuje_do":"2026-08-11 00:00:00","obowiazuje_od":"2026-08-10 17:00:00","opublikowano":"2026-08-10 06:18:00","tresc":"Prognozowane s\u0105 miejscami burze, kt\u00f3rym b\u0119d\u0105 towarzyszy\u0107 opady deszczu do 25 mm oraz porywy wiatru do 85 km\/h, a punktowo mo\u017cliwe porywy do oko\u0142o 100 km\/h. Lokalnie grad.","komentarz":"Brak.","biuro":"Centralne Biuro Prognoz Meteorologicznych w Warszawie","teryt":["2209","2210","2216","2808","2810","1462","2012","2062","2063","1427","2004","2006","2007","2813","2861","0406","0408","0462","1413","1415","1419","2806","2807","2809","2811","0402","1411","2207","2802","2818","2819","2862","0412","0417","1420","1422","1437","1461","2812","2814","2815","2816","1402","0405","2801","2803","2804","2805","2817"]},{"id":"Wr20260810041839823","nazwa_zdarzenia":"Burze","stopien":"1","prawdopodobienstwo":"70","obowiazuje_do":"2026-08-10 22:00:00","obowiazuje_od":"2026-08-10 17:00:00","opublikowano":"2026-08-10 06:18:00","tresc":"Prognozowane s\u0105 lokalne burze, kt\u00f3rym b\u0119d\u0105 towarzyszy\u0107 opady deszczu do 25 mm oraz porywy wiatru do 85 km\/h. Mo\u017cliiwy grad.","komentarz":"Brak.","biuro":"Centralne Biuro Prognoz Meteorologicznych w Warszawie","teryt":["3028","0401","0404","0403","0407","0409","2205","2206","2213","2214","3023","3025","3026","3030","3012","3020","3064","3004","0410","0411","0413","0414","2261","2262","2264","3006","3003","3010","3021","3031","3009","2203","3027","0213","0461","0463","0464","2204","3007","3011","3013","3016","3061","3062","3063","2202","3001","0415","0416","0418","0419","3017","3018","3019","3022"]},{"id":"Wr20260810041856167","nazwa_zdarzenia":"Burze","stopien":"1","prawdopodobienstwo":"70","obowiazuje_do":"2026-08-11 00:00:00","obowiazuje_od":"2026-08-10 18:00:00","opublikowano":"2026-08-10 06:18:00","tresc":"Prognozowane s\u0105 lokalne burze, kt\u00f3rym b\u0119d\u0105 towarzyszy\u0107 opady deszczu do oko\u0142o 20 mm oraz porywy wiatru do 80 km\/h. Mo\u017cliwy grad.","komentarz":"Brak.","biuro":"Centralne Biuro Prognoz Meteorologicznych w Warszawie","teryt":["1003","1004","1011","1013","1014","1015","1418","1421","1424","1428","1465","1005","1061","1062","1063","1404","1432","1434","1435","1438","1001","1016","1019","1020","1021","1002","1006","1007","1008","1010","1405","1406","1408","1414"]},{"id":"Wr20260810041907317","nazwa_zdarzenia":"Burze","stopien":"1","prawdopodobienstwo":"70","obowiazuje_do":"2026-08-11 03:00:00","obowiazuje_od":"2026-08-10 19:00:00","opublikowano":"2026-08-10 06:19:00","tresc":"Prognozowane s\u0105 lokalne burze, kt\u00f3rym b\u0119d\u0105 towarzyszy\u0107 opady deszczu do oko\u0142o 20 mm oraz porywy wiatru do 80 km\/h. Mo\u017cliwy grad.","komentarz":"Brak.","biuro":"Centralne Biuro Prognoz Meteorologicznych w Warszawie","teryt":["1401","1407","2001","2005","1423","1426","1430","1436","0613","1433","1463","2014","2010","2013","2061","1412","1417","2009","2011","0661","1403","1410","1416","0601","0608","0611","0616","0614","0615","1425","1429","1464","2002","2003","2008"]},{"id":"Sk20260808093414110","nazwa_zdarzenia":"Upa\u0142","stopien":"2","prawdopodobienstwo":"80","obowiazuje_do":"2026-08-10 18:00:00","obowiazuje_od":"2026-08-09 13:00:00","opublikowano":"2026-08-08 11:34:00","tresc":"Prognozuje si\u0119 upa\u0142y. Temperatura maksymalna niedziel\u0119 09.08 od 29\u00b0C do 31\u00b0C, w poniedzia\u0142ek 10.08 od 30\u00b0C do 33\u00b0C. Temperatura minimalna w nocy od 17\u00b0C do 19\u00b0C.","komentarz":"Brak.","biuro":"Centralne Biuro Prognoz Meteorologicznych w Warszawie","teryt":["0812","3210","0211","0201","0802","0803","0804","0805","0807","0808","0809","0810","0811","0862","3212","0204","0264","0203","0801","0209","0216","0218","0220","0861","3206","0222","0223","0225","0262"]},{"id":"Sk20260809095307175","nazwa_zdarzenia":"Upa\u0142","stopien":"1","prawdopodobienstwo":"85","obowiazuje_do":"2026-08-10 20:00:00","obowiazuje_od":"2026-08-10 11:00:00","opublikowano":"2026-08-09 11:53:00","tresc":"Prognozuje si\u0119 upa\u0142. Temperatura maksymalna wyniesie od 30\u00b0C do 33\u00b0C.","komentarz":"Brak.","biuro":"Centralne Biuro Prognoz Meteorologicznych w Warszawie","teryt":["1808","1418","2607","2609","2610","2611","2612","0617","1002","1206","1409","1405","1410","1430","1438","1603","1818","2470","2608","2613","1412","2010","1602","1604","1605","1608","2468","2469","2471","2472","2473","2474","2475","0214","1425","1201","0609","1812","1864","2416","1062","0614","2406","1203","1204","1205","1207","1208","1209","1210","1212","1215","1216","1218","1219","1261","1262","1263","1601","1606","1607","1802","1803","1804","3020","3027","1005","1006","1007","0610","1805","1806","1807","1809","1810","1811","1813","1815","1816","1819","1820","1861","1862","1863","2401","2402","2403","2404","2405","2407","2408","2409","2410","2411","2412","2413","2414","2415","2417","2461","2462","1008","1009","1010","1011","1012","1013","1014","1015","3017","3018","0620","2478","2601","2606","1202","1814","1609","1610","1611","1661","2463","2464","2465","2466","2467","2661","1016","1017","1018","1019","1020","1021","1061","1063","1403","1406","2005","2013","3008","3009","0606","0607","1428","1429","1432","1433","1434","3007","1401","0616","0664","1213","1214","0202","0611","0618","0663","1001","1003","1004","2476","2477","2479","2602","2603","2604","2605","0224","0604","0605","0613","0615","0215","0612","3061","0208","0217","0602","0608","1407","1417","1421","1423","1426","1436","1463","1464","1465","2003"]},{"id":"Sk20260809095258478","nazwa_zdarzenia":"Upa\u0142","stopien":"1","prawdopodobienstwo":"85","obowiazuje_do":"2026-08-10 18:00:00","obowiazuje_od":"2026-08-10 11:00:00","opublikowano":"2026-08-09 11:52:00","tresc":"Prognozuje si\u0119 upa\u0142. Temperatura maksymalna wyniesie od 30\u00b0C do 32\u00b0C.","komentarz":"Brak.","biuro":"Centralne Biuro Prognoz Meteorologicznych w Warszawie","teryt":["0207","0210","0261","0265","3011","3014","3015","3016","1411","1424","3012","3024","3026","3063","0221","0401","1462","0206","3003","0212","0415","3021","3022","3025","3030","0405","0408","0409","0410","0411","0412","1427","1435","3005","3006","3010","0205","0226","1419","3001","3013","3023","3004","3028","3029","0219","0419","1404","1408","1414","1416","1420","3062","3064","0418","0461","0463","0464","1402","0403","0407","0213"]}] \ No newline at end of file
diff --git a/internal/openmeteo/openmeteo.go b/internal/openmeteo/openmeteo.go
new file mode 100644
index 0000000..b5220f4
--- /dev/null
+++ b/internal/openmeteo/openmeteo.go
@@ -0,0 +1,385 @@
+// Package openmeteo talks to Open-Meteo's forecast, air-quality and geocoding
+// APIs. None of them needs a key.
+package openmeteo
+
+import (
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "sort"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/lukaszkasprzak/prognosis/internal/cache"
+)
+
+// Endpoints are variables, not constants, so tests can point them at a local
+// server serving recorded fixtures instead of the live APIs.
+var (
+ forecastURL = "https://api.open-meteo.com/v1/forecast"
+ airURL = "https://air-quality-api.open-meteo.com/v1/air-quality"
+ geoURL = "https://geocoding-api.open-meteo.com/v1/search"
+)
+
+const (
+ // Both APIs reject anything larger; the air-quality one is the stricter.
+ MaxForecastDays = 16
+ MaxAirDays = 7
+)
+
+// now is the clock, replaceable in tests: the window these functions return
+// depends on the hour, so a real clock would make the tests pass or fail
+// depending on when they ran.
+var now = time.Now
+
+// Timeout bounds every request.
+var Timeout = 15 * time.Second
+
+// Row is one hour of forecast. Vals is keyed by Open-Meteo field name, so a
+// column added to the config needs no change here.
+type Row struct {
+ When time.Time
+ Vals map[string]float64
+ Code int
+}
+
+// Val returns a field, and whether it was present.
+func (r Row) Val(field string) (float64, bool) {
+ v, ok := r.Vals[field]
+ return v, ok
+}
+
+// Data is a forecast reduced to the requested window.
+type Data struct {
+ TZ string
+ Rows []Row
+ Sun map[string][2]string // date -> {sunrise, sunset} as HH:MM
+ Daily map[string]float64
+}
+
+// DailyFields are the once-a-day figures shown in the summary line.
+var DailyFields = []string{
+ "temperature_2m_max", "temperature_2m_min", "precipitation_sum",
+ "precipitation_hours", "daylight_duration", "sunshine_duration",
+}
+
+func get(rawURL string, params url.Values, into any) error {
+ host := ""
+ if u, err := url.Parse(rawURL); err == nil {
+ host = u.Host
+ }
+ client := &http.Client{Timeout: Timeout}
+ req, err := http.NewRequest("GET", rawURL+"?"+params.Encode(), nil)
+ if err != nil {
+ return err
+ }
+ req.Header.Set("User-Agent", "prognosis/1.0")
+ resp, err := client.Do(req)
+ if err != nil {
+ return fmt.Errorf("cannot reach %s: %w", host, err)
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ return fmt.Errorf("%s returned HTTP %d", host, resp.StatusCode)
+ }
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return fmt.Errorf("reading from %s: %w", host, err)
+ }
+ if err := json.Unmarshal(body, into); err != nil {
+ return fmt.Errorf("bad response from %s: %w", host, err)
+ }
+ return nil
+}
+
+type geoResponse struct {
+ Results []struct {
+ Name string `json:"name"`
+ Country string `json:"country_code"`
+ Admin1 string `json:"admin1"`
+ Latitude float64 `json:"latitude"`
+ Longitude float64 `json:"longitude"`
+ } `json:"results"`
+}
+
+// Candidate is one place the geocoder matched. Admin1 is the region, which is
+// usually the only thing telling two places of the same name apart.
+type Candidate struct {
+ Geo cache.Geo
+ Admin1 string
+}
+
+// Geocode resolves a place name to every candidate the geocoder returned, best
+// match first. Coordinates are kept for all of them: a caller that only knows
+// the names of the alternatives cannot offer a choice between them, it can only
+// guess.
+func Geocode(place string) ([]Candidate, error) {
+ var r geoResponse
+ err := get(geoURL, url.Values{
+ "name": {place},
+ "count": {"5"},
+ "format": {"json"},
+ }, &r)
+ if err != nil {
+ return nil, err
+ }
+ if len(r.Results) == 0 {
+ return nil, fmt.Errorf("no place called %q found by Open-Meteo's geocoder", place)
+ }
+ out := make([]Candidate, 0, len(r.Results))
+ for _, hit := range r.Results {
+ g := cache.Geo{Lat: hit.Latitude, Lon: hit.Longitude, Country: hit.Country}
+ g.Label = hit.Name
+ if hit.Country != "" {
+ g.Label += ", " + hit.Country
+ }
+ out = append(out, Candidate{Geo: g, Admin1: hit.Admin1})
+ }
+ return out, nil
+}
+
+type forecastResponse struct {
+ TZAbbrev string `json:"timezone_abbreviation"`
+ UTCOff int `json:"utc_offset_seconds"`
+ Hourly map[string]any `json:"hourly"`
+ Daily map[string]any `json:"daily"`
+ Error bool `json:"error"`
+ Reason string `json:"reason"`
+ _ map[string]float64 // keeps the shape obvious
+}
+
+// unitParams maps our units setting onto Open-Meteo's, so rounding is done by
+// the provider rather than by us.
+func unitParams(units string) url.Values {
+ v := url.Values{}
+ switch units {
+ case "imperial":
+ v.Set("temperature_unit", "fahrenheit")
+ v.Set("wind_speed_unit", "mph")
+ v.Set("precipitation_unit", "inch")
+ case "si":
+ v.Set("wind_speed_unit", "ms")
+ }
+ return v
+}
+
+// Forecast fetches `hours` hours from the current hour, in the location's own
+// timezone, requesting only the fields asked for.
+func Forecast(lat, lon float64, hours int, units string, fields []string) (*Data, error) {
+ need := append([]string{"weather_code"}, fields...)
+ sort.Strings(need)
+ need = dedupe(need)
+
+ days := hours/24 + 2 // we start partway through today
+ if days > MaxForecastDays {
+ days = MaxForecastDays
+ }
+ params := url.Values{
+ "latitude": {strconv.FormatFloat(lat, 'f', 4, 64)},
+ "longitude": {strconv.FormatFloat(lon, 'f', 4, 64)},
+ "hourly": {strings.Join(need, ",")},
+ "daily": {"sunrise,sunset," + strings.Join(DailyFields, ",")},
+ "forecast_days": {strconv.Itoa(days)},
+ "timezone": {"auto"},
+ }
+ for k, vs := range unitParams(units) {
+ params[k] = vs
+ }
+
+ var r forecastResponse
+ if err := get(forecastURL, params, &r); err != nil {
+ return nil, err
+ }
+ if r.Error {
+ return nil, fmt.Errorf("open-meteo: %s", r.Reason)
+ }
+
+ times := stringSlice(r.Hourly["time"])
+ if len(times) == 0 {
+ return nil, fmt.Errorf("Open-Meteo returned no hourly data")
+ }
+ start := WindowStart(times, r.UTCOff)
+
+ d := &Data{TZ: r.TZAbbrev, Sun: map[string][2]string{}, Daily: map[string]float64{}}
+ end := start + hours
+ if end > len(times) {
+ end = len(times)
+ }
+ series := map[string][]float64{}
+ for _, f := range need {
+ series[f] = floatSlice(r.Hourly[f])
+ }
+ for i := start; i < end; i++ {
+ when, err := time.Parse("2006-01-02T15:04", times[i])
+ if err != nil {
+ continue
+ }
+ row := Row{When: when, Vals: map[string]float64{}}
+ for f, vals := range series {
+ if i < len(vals) {
+ row.Vals[f] = vals[i]
+ }
+ }
+ if v, ok := row.Vals["weather_code"]; ok {
+ row.Code = int(v)
+ }
+ d.Rows = append(d.Rows, row)
+ }
+ if len(d.Rows) == 0 {
+ return nil, fmt.Errorf("no forecast hours left in the returned window")
+ }
+
+ dates := stringSlice(r.Daily["time"])
+ rises, sets := stringSlice(r.Daily["sunrise"]), stringSlice(r.Daily["sunset"])
+ for i, day := range dates {
+ if i < len(rises) && i < len(sets) && len(rises[i]) >= 16 && len(sets[i]) >= 16 {
+ d.Sun[day] = [2]string{rises[i][11:16], sets[i][11:16]}
+ }
+ }
+ for _, f := range DailyFields {
+ if vals := floatSlice(r.Daily[f]); len(vals) > 0 {
+ d.Daily[f] = vals[0]
+ }
+ }
+ return d, nil
+}
+
+// Pollen returns the peak per species over the window ahead.
+func Pollen(lat, lon float64, hours int, species []string) (map[string]float64, error) {
+ if len(species) == 0 {
+ return map[string]float64{}, nil
+ }
+ fields := make([]string, 0, len(species))
+ for _, s := range species {
+ fields = append(fields, s+"_pollen")
+ }
+ days := hours/24 + 2
+ if days > MaxAirDays {
+ days = MaxAirDays
+ }
+ var r struct {
+ UTCOff int `json:"utc_offset_seconds"`
+ Hourly map[string]any `json:"hourly"`
+ }
+ err := get(airURL, url.Values{
+ "latitude": {strconv.FormatFloat(lat, 'f', 4, 64)},
+ "longitude": {strconv.FormatFloat(lon, 'f', 4, 64)},
+ "hourly": {strings.Join(fields, ",")},
+ "forecast_days": {strconv.Itoa(days)},
+ "timezone": {"auto"},
+ }, &r)
+ if err != nil {
+ return nil, err
+ }
+
+ times := stringSlice(r.Hourly["time"])
+ start := WindowStart(times, r.UTCOff)
+ // Pollen peaks around midday, so a three-hour request would understate the
+ // day. Always look at least twelve hours ahead.
+ span := hours
+ if span < 12 {
+ span = 12
+ }
+ end := start + span
+ if end > len(times) {
+ end = len(times)
+ }
+
+ peaks := map[string]float64{}
+ for _, s := range species {
+ vals := floatSlice(r.Hourly[s+"_pollen"])
+ have := presentSlice(r.Hourly[s+"_pollen"])
+ found := false
+ best := 0.0
+ for i := start; i < end && i < len(vals); i++ {
+ if i < len(have) && !have[i] {
+ continue // null: no reading here
+ }
+ if !found || vals[i] > best {
+ best, found = vals[i], true
+ }
+ }
+ if found {
+ peaks[s] = best
+ }
+ }
+ return peaks, nil
+}
+
+// WindowStart is the index of the first timestamp at or after now, in the
+// location's timezone.
+//
+// Open-Meteo's hourly arrays begin at 00:00 local, so anything that slices from
+// the front reports the small hours of this morning rather than the hours
+// ahead. The offset comes from the response so this stays correct for a place
+// in another timezone.
+func WindowStart(times []string, utcOffsetSeconds int) int {
+ cut := now().UTC().Add(time.Duration(utcOffsetSeconds) * time.Second).Truncate(time.Hour)
+ for i, t := range times {
+ when, err := time.Parse("2006-01-02T15:04", t)
+ if err != nil {
+ continue
+ }
+ if !when.Before(cut) {
+ return i
+ }
+ }
+ return 0
+}
+
+func dedupe(in []string) []string {
+ seen := map[string]bool{}
+ out := in[:0]
+ for _, s := range in {
+ if !seen[s] {
+ seen[s] = true
+ out = append(out, s)
+ }
+ }
+ return out
+}
+
+func stringSlice(v any) []string {
+ raw, ok := v.([]any)
+ if !ok {
+ return nil
+ }
+ out := make([]string, 0, len(raw))
+ for _, x := range raw {
+ s, _ := x.(string)
+ out = append(out, s)
+ }
+ return out
+}
+
+func floatSlice(v any) []float64 {
+ raw, ok := v.([]any)
+ if !ok {
+ return nil
+ }
+ out := make([]float64, 0, len(raw))
+ for _, x := range raw {
+ f, _ := x.(float64)
+ out = append(out, f)
+ }
+ return out
+}
+
+// presentSlice reports, per index, whether the JSON value was non-null. Pollen
+// is null outside Europe, and treating that as 0.0 would report a confident
+// "grass 0.0 none" where the truth is "no data".
+func presentSlice(v any) []bool {
+ raw, ok := v.([]any)
+ if !ok {
+ return nil
+ }
+ out := make([]bool, 0, len(raw))
+ for _, x := range raw {
+ _, isNum := x.(float64)
+ out = append(out, isNum)
+ }
+ return out
+}
diff --git a/internal/openmeteo/openmeteo_test.go b/internal/openmeteo/openmeteo_test.go
new file mode 100644
index 0000000..758920c
--- /dev/null
+++ b/internal/openmeteo/openmeteo_test.go
@@ -0,0 +1,364 @@
+package openmeteo
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+)
+
+// serve returns a server replying with a recorded fixture, and records the
+// query it was asked for so tests can assert on the request as well as the
+// parsing.
+func serve(t *testing.T, fixture string, gotQuery *string) *httptest.Server {
+ t.Helper()
+ body, err := os.ReadFile(filepath.Join("testdata", fixture))
+ if err != nil {
+ t.Fatal(err)
+ }
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if gotQuery != nil {
+ *gotQuery = r.URL.RawQuery
+ }
+ w.Header().Set("Content-Type", "application/json")
+ w.Write(body)
+ }))
+ t.Cleanup(srv.Close)
+ return srv
+}
+
+// fixtureStart is the first hour in the recorded forecast, so tests can pin the
+// clock relative to real recorded data.
+func fixtureStart(t *testing.T) time.Time {
+ t.Helper()
+ body, err := os.ReadFile(filepath.Join("testdata", "forecast.json"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ var r struct {
+ Hourly struct {
+ Time []string `json:"time"`
+ } `json:"hourly"`
+ }
+ if err := json.Unmarshal(body, &r); err != nil {
+ t.Fatal(err)
+ }
+ when, err := time.Parse("2006-01-02T15:04", r.Hourly.Time[0])
+ if err != nil {
+ t.Fatal(err)
+ }
+ return when
+}
+
+func pinClock(t *testing.T, at time.Time) {
+ t.Helper()
+ old := now
+ now = func() time.Time { return at.UTC() }
+ t.Cleanup(func() { now = old })
+}
+
+// The hourly array begins at 00:00 local, so slicing from the front reports the
+// small hours of this morning rather than the hours ahead -- the bug that made
+// the Python version report the wrong pollen peak.
+//
+// It also pins the timezone contract: the clock is real UTC, and the offset in
+// the response converts it to the *location's* local time. That is what makes
+// "prognosis -l krakow" start at the right hour when run from another zone.
+func TestForecastWindowStartsAtTheCurrentLocalHourNotMidnight(t *testing.T) {
+ offset := fixtureOffset(t) // +02:00 for the recorded Polish forecast
+ if offset == 0 {
+ t.Skip("fixture has no UTC offset; the conversion cannot be exercised")
+ }
+ // 11:00 UTC is 13:00 in a +02:00 location.
+ utcNoon := fixtureStart(t).Add(13*time.Hour - time.Duration(offset)*time.Second)
+ pinClock(t, utcNoon)
+
+ srv := serve(t, "forecast.json", nil)
+ forecastURL = srv.URL
+ d, err := Forecast(50.0617, 19.9373, 6, "metric", []string{"temperature_2m"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got := d.Rows[0].When.Hour(); got != 13 {
+ t.Fatalf("first row is %02d:00, want 13:00 local (clock was %s UTC, offset %+ds)",
+ got, utcNoon.Format("15:04"), offset)
+ }
+ if len(d.Rows) != 6 {
+ t.Fatalf("got %d rows, want 6", len(d.Rows))
+ }
+ // Rows must be consecutive hours from there.
+ for i, r := range d.Rows {
+ if want := 13 + i; r.When.Hour() != want {
+ t.Fatalf("row %d is %02d:00, want %02d:00", i, r.When.Hour(), want)
+ }
+ }
+}
+
+// fixtureOffset is the recorded response's utc_offset_seconds.
+func fixtureOffset(t *testing.T) int {
+ t.Helper()
+ body, err := os.ReadFile(filepath.Join("testdata", "forecast.json"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ var r struct {
+ Offset int `json:"utc_offset_seconds"`
+ }
+ if err := json.Unmarshal(body, &r); err != nil {
+ t.Fatal(err)
+ }
+ return r.Offset
+}
+
+func TestForecastRequestsOnlySelectedFields(t *testing.T) {
+ pinClock(t, fixtureStart(t))
+ var query string
+ srv := serve(t, "forecast.json", &query)
+ forecastURL = srv.URL
+
+ if _, err := Forecast(50, 21, 3, "metric", []string{"temperature_2m", "wind_speed_10m"}); err != nil {
+ t.Fatal(err)
+ }
+ hourly := queryValue(t, query, "hourly")
+ for _, want := range []string{"temperature_2m", "wind_speed_10m", "weather_code"} {
+ if !strings.Contains(hourly, want) {
+ t.Errorf("hourly=%q is missing %q", hourly, want)
+ }
+ }
+ // A field nobody asked for costs response size for nothing.
+ for _, unwanted := range []string{"relative_humidity_2m", "pressure_msl", "uv_index"} {
+ if strings.Contains(hourly, unwanted) {
+ t.Errorf("hourly=%q requests %q, which was not selected", hourly, unwanted)
+ }
+ }
+}
+
+func TestForecastUnitsArePassedToTheProvider(t *testing.T) {
+ pinClock(t, fixtureStart(t))
+ cases := map[string]map[string]string{
+ "metric": {"temperature_unit": "", "wind_speed_unit": ""},
+ "imperial": {"temperature_unit": "fahrenheit", "wind_speed_unit": "mph"},
+ "si": {"wind_speed_unit": "ms"},
+ }
+ for units, want := range cases {
+ t.Run(units, func(t *testing.T) {
+ var query string
+ srv := serve(t, "forecast.json", &query)
+ forecastURL = srv.URL
+ if _, err := Forecast(50, 21, 3, units, []string{"temperature_2m"}); err != nil {
+ t.Fatal(err)
+ }
+ for k, v := range want {
+ if got := queryValue(t, query, k); got != v {
+ t.Errorf("%s=%q, want %q", k, got, v)
+ }
+ }
+ })
+ }
+}
+
+func TestForecastRequestsEnoughDaysAndRespectsTheCap(t *testing.T) {
+ pinClock(t, fixtureStart(t))
+ for hours, wantDays := range map[int]string{12: "2", 24: "3", 48: "4", 400: "16"} {
+ var query string
+ srv := serve(t, "forecast.json", &query)
+ forecastURL = srv.URL
+ if _, err := Forecast(50, 21, hours, "metric", []string{"temperature_2m"}); err != nil {
+ t.Fatal(err)
+ }
+ if got := queryValue(t, query, "forecast_days"); got != wantDays {
+ t.Errorf("%d hours asked for forecast_days=%s, want %s", hours, got, wantDays)
+ }
+ }
+}
+
+func TestForecastParsesDailyAndSun(t *testing.T) {
+ pinClock(t, fixtureStart(t))
+ srv := serve(t, "forecast.json", nil)
+ forecastURL = srv.URL
+ d, err := Forecast(50, 21, 3, "metric", []string{"temperature_2m"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(d.Sun) == 0 {
+ t.Error("no sunrise/sunset parsed")
+ }
+ for _, day := range d.Sun {
+ if len(day[0]) != 5 || len(day[1]) != 5 {
+ t.Errorf("sun times must be HH:MM, got %v", day)
+ }
+ }
+ for _, f := range DailyFields {
+ if _, ok := d.Daily[f]; !ok {
+ t.Errorf("daily field %q missing", f)
+ }
+ }
+}
+
+func TestForecastErrors(t *testing.T) {
+ pinClock(t, fixtureStart(t))
+ t.Run("http error", func(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ http.Error(w, "nope", http.StatusInternalServerError)
+ }))
+ defer srv.Close()
+ forecastURL = srv.URL
+ if _, err := Forecast(50, 21, 3, "metric", nil); err == nil {
+ t.Fatal("expected an error on HTTP 500")
+ }
+ })
+ t.Run("bad json", func(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Write([]byte("{not json"))
+ }))
+ defer srv.Close()
+ forecastURL = srv.URL
+ if _, err := Forecast(50, 21, 3, "metric", nil); err == nil {
+ t.Fatal("expected an error on malformed JSON")
+ }
+ })
+}
+
+// Pollen peaks around midday, so a 3-hour request must still look 12 hours
+// ahead or it understates the day for someone with an allergy.
+func TestPollenAlwaysLooksAtLeastTwelveHoursAhead(t *testing.T) {
+ start := fixtureStart(t)
+ pinClock(t, start.Add(6*time.Hour))
+ srv := serve(t, "pollen.json", nil)
+ airURL = srv.URL
+
+ short, err := Pollen(50, 21, 3, []string{"grass"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ long, err := Pollen(50, 21, 12, []string{"grass"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if short["grass"] != long["grass"] {
+ t.Fatalf("3-hour window gave %v but 12-hour gave %v; the short window must "+
+ "still cover 12 hours", short["grass"], long["grass"])
+ }
+}
+
+// Outside Europe the API returns nulls. Treating those as 0.0 reports a
+// confident "grass 0.0 none" where the truth is "no data".
+func TestPollenNullsAreAbsentNotZero(t *testing.T) {
+ pinClock(t, fixtureStart(t))
+ srv := serve(t, "pollen_nulls.json", nil)
+ airURL = srv.URL
+ peaks, err := Pollen(-54.8, -68.3, 12, []string{"grass"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if v, ok := peaks["grass"]; ok {
+ t.Fatalf("grass reported as %v, but the fixture has no readings there", v)
+ }
+}
+
+func TestPollenCapsDaysAtTheAirQualityLimit(t *testing.T) {
+ pinClock(t, fixtureStart(t))
+ var query string
+ srv := serve(t, "pollen.json", &query)
+ airURL = srv.URL
+ if _, err := Pollen(50, 21, 15*24, []string{"grass"}); err != nil {
+ t.Fatal(err)
+ }
+ if got := queryValue(t, query, "forecast_days"); got != "7" {
+ t.Fatalf("forecast_days=%s, want 7 (the air-quality API rejects more)", got)
+ }
+}
+
+func TestPollenWithNoSpeciesMakesNoRequest(t *testing.T) {
+ called := false
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ called = true
+ }))
+ defer srv.Close()
+ airURL = srv.URL
+ peaks, err := Pollen(50, 21, 12, nil)
+ if err != nil || len(peaks) != 0 {
+ t.Fatalf("got %v, %v", peaks, err)
+ }
+ if called {
+ t.Error("requested pollen despite no species being selected")
+ }
+}
+
+func TestGeocodeReportsAlternatives(t *testing.T) {
+ srv := serve(t, "geocode_ambiguous.json", nil)
+ geoURL = srv.URL
+ cands, err := Geocode("krakow")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(cands) < 2 {
+ t.Fatalf("got %d candidates, want several: the fixture is ambiguous", len(cands))
+ }
+ g := cands[0].Geo
+ if g.Country == "" || g.Label == "" {
+ t.Errorf("incomplete geo: %+v", g)
+ }
+ if !strings.Contains(g.Label, g.Country) {
+ t.Errorf("label %q should carry the country %q", g.Label, g.Country)
+ }
+}
+
+// Selecting a place other than the first one is impossible unless its
+// coordinates survive the call, which is exactly what the old API discarded.
+func TestGeocodeKeepsCoordinatesForEveryCandidate(t *testing.T) {
+ srv := serve(t, "geocode_ambiguous.json", nil)
+ geoURL = srv.URL
+ cands, err := Geocode("krakow")
+ if err != nil {
+ t.Fatal(err)
+ }
+ for i, c := range cands {
+ if c.Geo.Lat == 0 || c.Geo.Lon == 0 {
+ t.Errorf("candidate %d (%s) has no coordinates, so it cannot be picked", i+1, c.Geo.Label)
+ }
+ if c.Admin1 == "" {
+ t.Errorf("candidate %d (%s) has no region, so the list cannot tell duplicates apart", i+1, c.Geo.Label)
+ }
+ }
+}
+
+func TestGeocodeNoResultsIsAnError(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Write([]byte(`{"generationtime_ms":0.1}`))
+ }))
+ defer srv.Close()
+ geoURL = srv.URL
+ if _, err := Geocode("zzzznowhere"); err == nil {
+ t.Fatal("expected an error when nothing matched")
+ }
+}
+
+func TestWindowStartFallsBackToZeroWhenEverythingIsPast(t *testing.T) {
+ pinClock(t, time.Date(2030, 1, 1, 0, 0, 0, 0, time.UTC))
+ if got := WindowStart([]string{"2026-08-10T00:00", "2026-08-10T01:00"}, 0); got != 0 {
+ t.Fatalf("got %d, want 0", got)
+ }
+}
+
+func queryValue(t *testing.T, rawQuery, key string) string {
+ t.Helper()
+ for _, pair := range strings.Split(rawQuery, "&") {
+ k, v, _ := strings.Cut(pair, "=")
+ if k == key {
+ unescaped, err := urlUnescape(v)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return unescaped
+ }
+ }
+ return ""
+}
+
+func urlUnescape(s string) (string, error) { return url.QueryUnescape(s) }
diff --git a/internal/openmeteo/testdata/forecast.json b/internal/openmeteo/testdata/forecast.json
new file mode 100644
index 0000000..b8948f7
--- /dev/null
+++ b/internal/openmeteo/testdata/forecast.json
@@ -0,0 +1 @@
+{"latitude":50.061700,"longitude":19.937300,"generationtime_ms":1.4580488204956055,"utc_offset_seconds":7200,"timezone":"Europe/Warsaw","timezone_abbreviation":"GMT+2","elevation":236.0,"hourly_units":{"time":"iso8601","temperature_2m":"°C","apparent_temperature":"°C","precipitation":"mm","precipitation_probability":"%","weather_code":"wmo code"},"hourly":{"time":["2026-08-10T00:00","2026-08-10T01:00","2026-08-10T02:00","2026-08-10T03:00","2026-08-10T04:00","2026-08-10T05:00","2026-08-10T06:00","2026-08-10T07:00","2026-08-10T08:00","2026-08-10T09:00","2026-08-10T10:00","2026-08-10T11:00","2026-08-10T12:00","2026-08-10T13:00","2026-08-10T14:00","2026-08-10T15:00","2026-08-10T16:00","2026-08-10T17:00","2026-08-10T18:00","2026-08-10T19:00","2026-08-10T20:00","2026-08-10T21:00","2026-08-10T22:00","2026-08-10T23:00","2026-08-11T00:00","2026-08-11T01:00","2026-08-11T02:00","2026-08-11T03:00","2026-08-11T04:00","2026-08-11T05:00","2026-08-11T06:00","2026-08-11T07:00","2026-08-11T08:00","2026-08-11T09:00","2026-08-11T10:00","2026-08-11T11:00","2026-08-11T12:00","2026-08-11T13:00","2026-08-11T14:00","2026-08-11T15:00","2026-08-11T16:00","2026-08-11T17:00","2026-08-11T18:00","2026-08-11T19:00","2026-08-11T20:00","2026-08-11T21:00","2026-08-11T22:00","2026-08-11T23:00"],"temperature_2m":[15.0,14.4,13.4,13.0,12.9,12.1,11.9,14.4,19.2,22.4,24.6,26.2,27.5,28.7,29.3,29.4,29.3,28.9,28.4,27.0,24.8,23.6,22.9,22.6,22.3,22.5,22.4,21.9,21.6,20.3,18.9,19.8,20.9,22.4,22.0,22.1,23.1,22.7,21.5,22.2,21.9,21.8,21.7,21.3,19.5,18.3,16.8,15.6],"apparent_temperature":[13.8,13.3,12.5,11.8,11.5,11.2,11.0,14.0,18.3,21.6,24.1,26.2,27.6,29.1,29.5,28.2,28.3,28.7,28.9,28.2,25.5,24.1,22.2,21.8,21.3,20.4,20.0,19.5,19.7,18.7,18.3,19.4,21.3,23.1,21.6,22.0,23.3,21.8,20.4,20.8,19.8,19.7,20.2,19.6,18.3,16.3,15.1,13.7],"precipitation":[0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00],"precipitation_probability":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,3,4,4,3,2,1,0,0,2,6,8,8,6,4,3,1,0,0,0,0,0,0,0,0,0,0,0,0,0],"weather_code":[1,3,2,0,0,0,0,1,0,2,2,3,3,0,3,3,3,3,3,0,0,3,1,0,0,0,0,0,0,0,0,0,3,3,3,3,2,3,3,3,3,3,0,0,0,0,0,0]},"daily_units":{"time":"iso8601","sunrise":"iso8601","sunset":"iso8601","temperature_2m_max":"°C","temperature_2m_min":"°C","precipitation_sum":"mm","precipitation_hours":"h","daylight_duration":"s","sunshine_duration":"s"},"daily":{"time":["2026-08-10","2026-08-11"],"sunrise":["2026-08-10T05:15","2026-08-11T05:16"],"sunset":["2026-08-10T20:01","2026-08-11T19:59"],"temperature_2m_max":[29.4,23.1],"temperature_2m_min":[11.9,15.6],"precipitation_sum":[0.00,0.00],"precipitation_hours":[0.0,0.0],"daylight_duration":[53154.79,52962.40],"sunshine_duration":[43387.38,856.60]}} \ No newline at end of file
diff --git a/internal/openmeteo/testdata/geocode_ambiguous.json b/internal/openmeteo/testdata/geocode_ambiguous.json
new file mode 100644
index 0000000..62f9cda
--- /dev/null
+++ b/internal/openmeteo/testdata/geocode_ambiguous.json
@@ -0,0 +1 @@
+{"results":[{"id":3094802,"name":"Krakow","latitude":50.06143,"longitude":19.93658,"elevation":219.0,"feature_code":"PPLA","country_code":"PL","admin1_id":858786,"admin2_id":6690154,"admin3_id":7531791,"timezone":"Europe/Warsaw","population":804237,"country_id":798544,"country":"Poland","admin1":"Lesser Poland","admin2":"Kraków","admin3":"Kraków"},{"id":5258888,"name":"Krakow","latitude":44.76166,"longitude":-88.25149,"elevation":237.0,"feature_code":"PPL","country_code":"US","admin1_id":5279468,"admin2_id":5265518,"admin3_id":5248361,"timezone":"America/Chicago","population":354,"postcodes":["54137"],"country_id":6252001,"country":"United States","admin1":"Wisconsin","admin2":"Oconto","admin3":"Town of Chase"},{"id":2884850,"name":"Krakow am See","latitude":53.65142,"longitude":12.26748,"elevation":48.0,"feature_code":"PPLA4","country_code":"DE","admin1_id":2872567,"admin3_id":8648342,"admin4_id":6550687,"timezone":"Europe/Berlin","population":3450,"country_id":2921044,"country":"Germany","admin1":"Mecklenburg-Vorpommern","admin3":"Landkreis Rostock","admin4":"Krakow am See"},{"id":2884852,"name":"Krakow","latitude":54.12372,"longitude":12.78276,"elevation":18.0,"feature_code":"PPL","country_code":"DE","admin1_id":2872567,"admin3_id":2843324,"admin4_id":6548111,"timezone":"Europe/Berlin","country_id":2921044,"country":"Germany","admin1":"Mecklenburg-Vorpommern","admin3":"Landkreis Vorpommern-Rügen","admin4":"Drechow"},{"id":3094801,"name":"Krąków","latitude":51.72921,"longitude":18.51595,"elevation":136.0,"feature_code":"PPL","country_code":"PL","admin1_id":3337493,"admin2_id":7531009,"admin3_id":7533290,"timezone":"Europe/Warsaw","country_id":798544,"country":"Poland","admin1":"Łódź Voivodeship","admin2":"Sieradz County","admin3":"Warta"}],"generationtime_ms":0.44548512} \ No newline at end of file
diff --git a/internal/openmeteo/testdata/pollen.json b/internal/openmeteo/testdata/pollen.json
new file mode 100644
index 0000000..eba1a73
--- /dev/null
+++ b/internal/openmeteo/testdata/pollen.json
@@ -0,0 +1 @@
+{"latitude":50.0,"longitude":21.8,"generationtime_ms":0.19991397857666016,"utc_offset_seconds":7200,"timezone":"Europe/Warsaw","timezone_abbreviation":"GMT+2","elevation":236.0,"hourly_units":{"time":"iso8601","grass_pollen":"grains/m³","birch_pollen":"grains/m³"},"hourly":{"time":["2026-08-10T00:00","2026-08-10T01:00","2026-08-10T02:00","2026-08-10T03:00","2026-08-10T04:00","2026-08-10T05:00","2026-08-10T06:00","2026-08-10T07:00","2026-08-10T08:00","2026-08-10T09:00","2026-08-10T10:00","2026-08-10T11:00","2026-08-10T12:00","2026-08-10T13:00","2026-08-10T14:00","2026-08-10T15:00","2026-08-10T16:00","2026-08-10T17:00","2026-08-10T18:00","2026-08-10T19:00","2026-08-10T20:00","2026-08-10T21:00","2026-08-10T22:00","2026-08-10T23:00","2026-08-11T00:00","2026-08-11T01:00","2026-08-11T02:00","2026-08-11T03:00","2026-08-11T04:00","2026-08-11T05:00","2026-08-11T06:00","2026-08-11T07:00","2026-08-11T08:00","2026-08-11T09:00","2026-08-11T10:00","2026-08-11T11:00","2026-08-11T12:00","2026-08-11T13:00","2026-08-11T14:00","2026-08-11T15:00","2026-08-11T16:00","2026-08-11T17:00","2026-08-11T18:00","2026-08-11T19:00","2026-08-11T20:00","2026-08-11T21:00","2026-08-11T22:00","2026-08-11T23:00"],"grass_pollen":[7.2,6.7,5.4,2.6,1.7,2.6,2.6,6.2,6.3,6.5,6.9,6.6,5.5,4.7,4.5,4.7,5.0,4.9,5.5,6.0,10.6,6.4,6.3,6.5,6.4,5.4,5.4,6.0,5.4,4.9,4.8,4.6,4.6,3.8,3.4,3.5,3.5,3.8,3.9,3.8,3.7,4.4,4.2,4.9,5.8,5.8,6.6,8.0],"birch_pollen":[0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0]}} \ No newline at end of file
diff --git a/internal/openmeteo/testdata/pollen_nulls.json b/internal/openmeteo/testdata/pollen_nulls.json
new file mode 100644
index 0000000..46effa0
--- /dev/null
+++ b/internal/openmeteo/testdata/pollen_nulls.json
@@ -0,0 +1 @@
+{"latitude":-54.8,"longitude":-68.299995,"generationtime_ms":0.09846687316894531,"utc_offset_seconds":-10800,"timezone":"America/Argentina/Ushuaia","timezone_abbreviation":"GMT-3","elevation":52.0,"hourly_units":{"time":"iso8601","grass_pollen":"grains/m³"},"hourly":{"time":["2026-08-10T00:00","2026-08-10T01:00","2026-08-10T02:00","2026-08-10T03:00","2026-08-10T04:00","2026-08-10T05:00","2026-08-10T06:00","2026-08-10T07:00","2026-08-10T08:00","2026-08-10T09:00","2026-08-10T10:00","2026-08-10T11:00","2026-08-10T12:00","2026-08-10T13:00","2026-08-10T14:00","2026-08-10T15:00","2026-08-10T16:00","2026-08-10T17:00","2026-08-10T18:00","2026-08-10T19:00","2026-08-10T20:00","2026-08-10T21:00","2026-08-10T22:00","2026-08-10T23:00"],"grass_pollen":[null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null]}} \ No newline at end of file
diff --git a/internal/render/ascii.go b/internal/render/ascii.go
new file mode 100644
index 0000000..4203bc9
--- /dev/null
+++ b/internal/render/ascii.go
@@ -0,0 +1,67 @@
+package render
+
+import "strings"
+
+// asciiMap is deliberately one rune to one ASCII character.
+//
+// Substitution happens after the table has been laid out, so anything that
+// changed the number of characters would shear every column to its right. That
+// constraint is why the wind arrows become single letters rather than the
+// two-letter compass points that would read better.
+var asciiMap = map[rune]string{
+ // Polish diacritics, so a Polish forecast survives GSM-7.
+ 'ą': "a", 'ć': "c", 'ę': "e", 'ł': "l", 'ń': "n",
+ 'ó': "o", 'ś': "s", 'ź': "z", 'ż': "z",
+ 'Ą': "A", 'Ć': "C", 'Ę': "E", 'Ł': "L", 'Ń': "N",
+ 'Ó': "O", 'Ś': "S", 'Ź': "Z", 'Ż': "Z",
+
+ // Wind arrows. The arrow points the way the wind blows, so v is a northerly.
+ '↑': "^", '↓': "v", '←': "<", '→': ">",
+ '↖': "\\", '↗': "/", '↙': "/", '↘': "\\",
+
+ // Chart: blocks and the axis rule.
+ '█': "#", '▇': "#", '▆': "=", '▅': "=",
+ '▄': "_", '▃': ":", '▂': ".", '▁': ".",
+ '│': "|",
+}
+
+// ASCII rewrites output to pure ASCII, one character for one character.
+//
+// The point is SMS: a single non-ASCII character forces the whole message from
+// GSM-7 into UCS-2, which cuts a segment from 160 characters to 70. A degree
+// sign alone therefore more than doubles the cost of sending a forecast.
+//
+// The degree sign becomes the unit letter, which is both ASCII and clearer to
+// someone reading it cold: "29C" rather than "29".
+func ASCII(s, units string) string {
+ degree := "C"
+ if units == "imperial" {
+ degree = "F"
+ }
+ runes := []rune(s)
+ var b strings.Builder
+ b.Grow(len(s))
+ for i, r := range runes {
+ switch {
+ case r == '°':
+ // Prose from IMGW already writes "30°C"; appending the unit again
+ // would give "30CC". Only a bare degree sign gains the letter.
+ if i+1 < len(runes) && (runes[i+1] == 'C' || runes[i+1] == 'F') {
+ continue
+ }
+ b.WriteString(degree)
+ case r < 128:
+ b.WriteRune(r)
+ default:
+ if sub, ok := asciiMap[r]; ok {
+ b.WriteString(sub)
+ } else {
+ // Anything unmapped -- a place name in another script, a weather
+ // code description we have not transliterated -- becomes '?'
+ // rather than silently vanishing and misaligning the row.
+ b.WriteString("?")
+ }
+ }
+ }
+ return b.String()
+}
diff --git a/internal/render/ascii_test.go b/internal/render/ascii_test.go
new file mode 100644
index 0000000..7e6a805
--- /dev/null
+++ b/internal/render/ascii_test.go
@@ -0,0 +1,55 @@
+package render
+
+import (
+ "strings"
+ "testing"
+)
+
+func TestASCIIIsPureASCII(t *testing.T) {
+ in := " ! Upał stopień 1\n 14 29° (28) zachmurzenie ↗\n 30°│███▄▄▁"
+ got := ASCII(in, "metric")
+ for _, r := range got {
+ if r > 127 {
+ t.Fatalf("non-ASCII %q survived in %q", r, got)
+ }
+ }
+}
+
+// Substitution runs after layout, so it must not change the character count --
+// otherwise every column to the right of a degree sign shears.
+func TestASCIIPreservesLength(t *testing.T) {
+ for _, in := range []string{
+ " 14 29° (28) zachmurzenie",
+ " 30°│███▄▄▁▂",
+ " godz temp odczuw warunki",
+ " słońce 12h03m z 14h45m dnia",
+ } {
+ if got := ASCII(in, "metric"); len([]rune(got)) != len([]rune(in)) {
+ t.Errorf("length changed: %q (%d) -> %q (%d)",
+ in, len([]rune(in)), got, len([]rune(got)))
+ }
+ }
+}
+
+// IMGW prose already writes "30°C"; the unit letter must not be doubled.
+func TestASCIIDoesNotDoubleTheUnitInProse(t *testing.T) {
+ got := ASCII("temperatura od 30°C do 33°C", "metric")
+ if strings.Contains(got, "CC") {
+ t.Fatalf("doubled unit: %q", got)
+ }
+ if got != "temperatura od 30C do 33C" {
+ t.Fatalf("got %q", got)
+ }
+}
+
+func TestASCIIUsesFahrenheitLetterInImperial(t *testing.T) {
+ if got := ASCII("85°", "imperial"); got != "85F" {
+ t.Fatalf("got %q, want 85F", got)
+ }
+}
+
+func TestASCIIMarksUnmappedRunesRatherThanDroppingThem(t *testing.T) {
+ if got := ASCII("東京", "metric"); got != "??" {
+ t.Fatalf("got %q, want ??", got)
+ }
+}
diff --git a/internal/render/chart.go b/internal/render/chart.go
new file mode 100644
index 0000000..cc20ca9
--- /dev/null
+++ b/internal/render/chart.go
@@ -0,0 +1,205 @@
+package render
+
+import (
+ "fmt"
+ "math"
+ "strings"
+
+ "github.com/lukaszkasprzak/prognosis/internal/openmeteo"
+)
+
+const blocks = "▁▂▃▄▅▆▇█"
+
+// column is one drawn chart column. Keeping level, colour, rain and hour in one
+// struct means they cannot drift apart, which four parallel slices would allow.
+type column struct {
+ level int
+ style string
+ rain float64
+ hour string
+}
+
+// chart draws the temperature over several rows with a labelled axis.
+//
+// A one-row sparkline gives only 8 levels, so a single cold hour flattens the
+// rest of the week into the top two blocks. Drawing over height rows with
+// half-block cells gives height*2 levels, enough to see the daily rise and fall.
+// Long spans are downsampled so the chart fits the terminal: a week is 168
+// hourly points and would otherwise wrap into mush.
+func (x ctx) chart(v View) []string {
+ height := x.cfg.GraphHeight
+ const gutter = 5 // "NN°" label plus the axis rule
+ cols := x.width - gutter - 1
+ if cols < 8 {
+ cols = 8
+ }
+
+ groups := buckets(min(len(v.Rows), cols), len(v.Rows))
+ temps := make([]float64, len(groups))
+ rains := make([]float64, len(groups))
+ for i, g := range groups {
+ sum, maxRain := 0.0, 0.0
+ for _, r := range v.Rows[g[0]:g[1]] {
+ t, _ := r.Val("temperature_2m")
+ sum += t
+ if mm, ok := r.Val("precipitation"); ok && mm > maxRain {
+ maxRain = mm
+ }
+ }
+ temps[i] = sum / float64(g[1]-g[0])
+ rains[i] = maxRain
+ }
+
+ lo, hi := temps[0], temps[0]
+ for _, t := range temps {
+ lo, hi = math.Min(lo, t), math.Max(hi, t)
+ }
+ span := hi - lo
+ if span == 0 {
+ span = 1
+ }
+ steps := height * 2
+
+ // A 12-hour chart would occupy 12 of 80 columns; widen each point to use the
+ // terminal rather than leaving the curve cramped in the corner.
+ scale := cols / len(groups)
+ if scale < 1 {
+ scale = 1
+ }
+ var drawn []column
+ for i, g := range groups {
+ lvl := int(math.Round((temps[i] - lo) / span * float64(steps)))
+ if lvl < 1 {
+ lvl = 1 // always one half-cell, so the coldest column still shows
+ }
+ for k := 0; k < scale; k++ {
+ drawn = append(drawn, column{
+ level: lvl,
+ style: TempStyle(x.celsius(temps[i])),
+ rain: rains[i],
+ hour: v.Rows[g[0]].When.Format("15"),
+ })
+ }
+ }
+
+ out := []string{""}
+ for r := 0; r < height; r++ {
+ full := (height - r) * 2
+ value := lo + (hi-lo)*float64(height-1-r)/float64(height-1)
+ label := " "
+ switch {
+ case r == 0:
+ label = x.c(TempStyle(x.celsius(hi)), PadLeft(fmt.Sprintf("%d°", Deg(hi)), 4))
+ case r == height-1:
+ label = x.c(TempStyle(x.celsius(lo)), PadLeft(fmt.Sprintf("%d°", Deg(lo)), 4))
+ case height >= 5 && r == height/2:
+ label = x.c(TempStyle(x.celsius(value)), PadLeft(fmt.Sprintf("%d°", Deg(value)), 4))
+ }
+ cells := make([]Cell, 0, len(drawn))
+ for _, d := range drawn {
+ switch {
+ case d.level >= full:
+ cells = append(cells, Cell{Style: d.style, Text: "█"})
+ case d.level == full-1:
+ cells = append(cells, Cell{Style: d.style, Text: "▄"})
+ default:
+ cells = append(cells, Cell{Text: " "})
+ }
+ }
+ out = append(out, label+x.c(Dim, "│")+Paint(cells, x.c))
+ }
+
+ note := fmt.Sprintf("%d-%d°", Deg(lo), Deg(hi))
+ if len(groups) < len(v.Rows) {
+ note += fmt.Sprintf(" %.0f%s", float64(len(v.Rows))/float64(len(groups)), x.cat.Word("h_per_col"))
+ }
+
+ // A flat row of empty blocks says nothing; only draw rain if there is any.
+ maxRain := 0.0
+ for _, d := range drawn {
+ maxRain = math.Max(maxRain, d.rain)
+ }
+ if maxRain > 0 {
+ series := make([]float64, len(drawn))
+ for i, d := range drawn {
+ series[i] = d.rain
+ }
+ out = append(out, x.c(Dim, PadLeft(x.cat.Word("rain_row"), 4)+"│")+
+ x.c(Cyan, spark(series))+
+ x.c(Dim, fmt.Sprintf(" %s %.1fmm", x.cat.Word("max"), maxRain)))
+ }
+
+ every := scale * maxInt(1, ceilDiv(len(groups), 8)) // at most 8 labels
+ hours := make([]string, len(drawn))
+ for i, d := range drawn {
+ hours[i] = d.hour
+ }
+ out = append(out, x.c(Dim, strings.Repeat(" ", gutter)+axis(hours, every)))
+ out = append(out, x.c(Dim, strings.Repeat(" ", gutter)+note))
+ return out
+}
+
+// axis places hour labels under the chart, one character per column so they
+// line up. A label that would run off the end is skipped rather than printed as
+// a half label.
+func axis(hours []string, every int) string {
+ line := []rune(strings.Repeat(" ", len(hours)))
+ for i := 0; i < len(hours); i += every {
+ label := []rune(hours[i])
+ if i+len(label) > len(line) {
+ continue
+ }
+ copy(line[i:], label)
+ }
+ return string(line)
+}
+
+// buckets splits total rows into count contiguous groups.
+func buckets(count, total int) [][2]int {
+ out := make([][2]int, count)
+ for i := range out {
+ lo := i * total / count
+ hi := (i + 1) * total / count
+ if hi <= lo {
+ hi = lo + 1
+ }
+ out[i] = [2]int{lo, hi}
+ }
+ return out
+}
+
+// spark renders one block character per value, scaled to the series' own range.
+func spark(values []float64) string {
+ lo, hi := values[0], values[0]
+ for _, v := range values {
+ lo, hi = math.Min(lo, v), math.Max(hi, v)
+ }
+ if hi-lo < 1e-9 { // flat: sit on the baseline rather than divide by zero
+ return strings.Repeat(string([]rune(blocks)[0]), len(values))
+ }
+ runes := []rune(blocks)
+ step := (hi - lo) / float64(len(runes)-1)
+ var b strings.Builder
+ for _, v := range values {
+ b.WriteRune(runes[int(math.Round((v-lo)/step))])
+ }
+ return b.String()
+}
+
+func min(a, b int) int {
+ if a < b {
+ return a
+ }
+ return b
+}
+
+func maxInt(a, b int) int {
+ if a > b {
+ return a
+ }
+ return b
+}
+
+func ceilDiv(a, b int) int { return (a + b - 1) / b }
+
+var _ = openmeteo.Row{}
diff --git a/internal/render/color.go b/internal/render/color.go
new file mode 100644
index 0000000..cf2af89
--- /dev/null
+++ b/internal/render/color.go
@@ -0,0 +1,62 @@
+package render
+
+import "strings"
+
+// Colour uses ANSI slots 0-15 only -- codes 30-37 and 90-97, plus the
+// attributes 1 (bold), 2 (dim) and 4 (underline). Never 256-colour indices:
+// this runs on terminals whose palette remaps the low slots (the phone's is
+// entirely green), where a hardcoded 38;5;196 would be the one off-palette
+// thing on screen.
+const (
+ Reset = "0"
+ Bold = "1"
+ Dim = "2"
+ Underline = "4"
+
+ Blue = "34"
+ Cyan = "36"
+ Green = "32"
+ Yellow = "33"
+ Red = "31"
+ BrightBlue = "94"
+ BrightRed = "91"
+ BrightWhite = "97"
+)
+
+// Styler returns a function that wraps text in an ANSI code, or returns it
+// unchanged when colour is off.
+func Styler(colour bool) func(code, text string) string {
+ if !colour {
+ return func(_, text string) string { return text }
+ }
+ return func(code, text string) string {
+ if code == "" {
+ return text
+ }
+ return "\033[" + code + "m" + text + "\033[0m"
+ }
+}
+
+// Cell is one character of chart output with the style it should carry.
+type Cell struct {
+ Style string
+ Text string
+}
+
+// Paint joins cells, emitting one escape sequence per run of identical style
+// rather than one per character. A per-character version makes the chart around
+// ten times larger for output that looks exactly the same.
+func Paint(cells []Cell, c func(string, string) string) string {
+ var b strings.Builder
+ for i := 0; i < len(cells); {
+ j := i
+ var run strings.Builder
+ for j < len(cells) && cells[j].Style == cells[i].Style {
+ run.WriteString(cells[j].Text)
+ j++
+ }
+ b.WriteString(c(cells[i].Style, run.String()))
+ i = j
+ }
+ return b.String()
+}
diff --git a/internal/render/icons.go b/internal/render/icons.go
new file mode 100644
index 0000000..59ae3d3
--- /dev/null
+++ b/internal/render/icons.go
@@ -0,0 +1,84 @@
+package render
+
+// Weather glyphs per icon set, keyed by WMO code group.
+//
+// The "nerd" codepoints are from the Nerd Fonts Weather range (U+E300-U+E3E3),
+// which JetBrains Mono Nerd Font and MesloLGS NF both carry. They are single
+// cell and monochrome, so they take the terminal's foreground colour and do not
+// break a remapped palette.
+//
+// The "emoji" set is colour and comes from a fallback font. Its glyphs are not
+// all one cell wide -- see width.go -- which is why every pad goes through
+// DisplayWidth.
+var iconSets = map[string]map[string]string{
+ "nerd": {
+ "clear": "",
+ "partly": "",
+ "cloudy": "",
+ "fog": "",
+ "drizzle": "",
+ "rain": "",
+ "snow": "",
+ "storm": "",
+ },
+ "emoji": {
+ "clear": "☀",
+ "partly": "⛅",
+ "cloudy": "☁",
+ "fog": "\U0001F32B",
+ "drizzle": "\U0001F326",
+ "rain": "\U0001F327",
+ "snow": "\U0001F328",
+ "storm": "⛈",
+ },
+}
+
+// iconGroup maps a WMO weather code onto a glyph group.
+func iconGroup(code int) string {
+ switch {
+ case code == 0 || code == 1:
+ return "clear"
+ case code == 2:
+ return "partly"
+ case code == 3:
+ return "cloudy"
+ case code == 45 || code == 48:
+ return "fog"
+ case code >= 51 && code <= 57:
+ return "drizzle"
+ case code >= 61 && code <= 67, code >= 80 && code <= 82:
+ return "rain"
+ case code >= 71 && code <= 77, code == 85 || code == 86:
+ return "snow"
+ case code >= 95:
+ return "storm"
+ }
+ return "cloudy"
+}
+
+// Icon returns the glyph for a weather code in the named set. An unknown set,
+// or "none", yields an empty string so the column simply renders blank.
+func Icon(set string, code int) string {
+ glyphs, ok := iconSets[set]
+ if !ok {
+ return ""
+ }
+ return glyphs[iconGroup(code)]
+}
+
+// IconWidth is the display width the icon column should reserve for a set.
+// The emoji set contains wide glyphs, so its column is two cells even for the
+// entries that happen to be one.
+func IconWidth(set string) int {
+ glyphs, ok := iconSets[set]
+ if !ok {
+ return 0
+ }
+ w := 1
+ for _, g := range glyphs {
+ if gw := DisplayWidth(g); gw > w {
+ w = gw
+ }
+ }
+ return w
+}
diff --git a/internal/render/render.go b/internal/render/render.go
new file mode 100644
index 0000000..4417763
--- /dev/null
+++ b/internal/render/render.go
@@ -0,0 +1,283 @@
+// Package render turns fetched weather into terminal output. It is pure: no
+// network, no clock beyond what it is given, so every rule below is testable.
+package render
+
+import (
+ "fmt"
+ "math"
+ "strings"
+
+ "github.com/lukaszkasprzak/prognosis/internal/config"
+ "github.com/lukaszkasprzak/prognosis/internal/i18n"
+ "github.com/lukaszkasprzak/prognosis/internal/imgw"
+ "github.com/lukaszkasprzak/prognosis/internal/openmeteo"
+)
+
+// RainInterestPct is the chance of rain below which the mm and rain columns are
+// hidden: on a dry day they are a block of zeroes that pushes real content
+// sideways.
+const RainInterestPct = 20
+
+// View is everything one run needs to render.
+type View struct {
+ Label string
+ 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
+}
+
+type ctx struct {
+ cfg config.Config
+ cat *i18n.Catalog
+ c func(string, string) string
+ width int
+ wet bool
+}
+
+// celsius converts a displayed temperature back to Celsius.
+//
+// IMGW's thresholds are defined in Celsius, so they must be compared in
+// Celsius: a warning threshold does not move because the display was switched
+// to Fahrenheit. Without this, 85F (a mild 29C) renders in the "IMGW would warn
+// about this" red.
+func (x ctx) celsius(v float64) float64 {
+ if x.cfg.Units == "imperial" {
+ return (v - 32) * 5 / 9
+ }
+ return v
+}
+
+// Render produces the whole output.
+func Render(v View, cfg config.Config, width int, colour bool) string {
+ cx := ctx{
+ cfg: cfg,
+ cat: i18n.For(cfg.DisplayLang),
+ c: Styler(colour),
+ width: width,
+ wet: isWet(v.Rows),
+ }
+ var out []string
+ out = append(out, cx.warnings(v)...)
+ out = append(out, cx.header(v)...)
+ out = append(out, cx.table(v)...)
+ if cfg.Graph {
+ out = append(out, cx.chart(v)...)
+ }
+ text := strings.Join(out, "\n")
+ // Applied last, one character for one, so the layout above is unaffected.
+ if cfg.ASCII {
+ text = ASCII(text, cfg.Units)
+ }
+ return text
+}
+
+func isWet(rows []openmeteo.Row) bool {
+ for _, r := range rows {
+ if mm, ok := r.Val("precipitation"); ok && mm > 0 {
+ return true
+ }
+ if p, ok := r.Val("precipitation_probability"); ok && p >= RainInterestPct {
+ return true
+ }
+ }
+ return false
+}
+
+// warnings renders IMGW warnings, or an explicit line saying why none are shown.
+//
+// WarnFailed must never look the same as "none in force": silence would be read
+// as all-clear.
+func (x ctx) warnings(v View) []string {
+ var out []string
+ switch {
+ case x.WarnDisabled():
+ return nil
+ case v.WarnFailed:
+ out = append(out, x.c(Dim, " "+x.cat.Word("warnings")+": "+x.cat.Word("could_not_check")))
+ case v.WarnNote != "":
+ out = append(out, x.c(Dim, " "+x.cat.Word("warnings")+": "+v.WarnNote))
+ }
+ for _, w := range v.Warnings {
+ style := Yellow
+ switch w.Level {
+ case "2":
+ style = Red
+ case "3":
+ style = Bold + ";" + Red
+ }
+ head := fmt.Sprintf(" ! %s %s %s %s -> %s (%s%%)",
+ w.Event, x.cat.Word("level"), w.Level,
+ clip(w.From), clip(w.To), w.Probability)
+ out = append(out, x.c(style, head))
+ for _, line := range wrap(w.Text, x.width) {
+ out = append(out, x.c(Dim, " "+line))
+ }
+ }
+ if len(out) > 0 {
+ out = append(out, "")
+ }
+ return out
+}
+
+// WarnDisabled reports whether warnings were switched off in config.
+func (x ctx) WarnDisabled() bool { return !x.cfg.Warnings }
+
+// clip shortens "2026-08-10 11:00:00" to "08-10 11:00".
+func clip(s string) string {
+ if len(s) >= 16 {
+ return s[5:16]
+ }
+ return s
+}
+
+func (x ctx) header(v View) []string {
+ var out []string
+ first := v.Rows[0].When
+ title := v.Label + " " + x.cat.Date(first)
+ if x.cfg.Minimal {
+ // Just the place and the date: no sun times, no summary, no pollen.
+ return append(out, x.c(Bold, title))
+ }
+ sun, hasSun := v.Sun[first.Format("2006-01-02")]
+ suffix := ""
+ if hasSun {
+ suffix = fmt.Sprintf(" %s %s %s %s",
+ sun[0], x.cat.Word("up"), sun[1], x.cat.Word("down"))
+ }
+ tz := ""
+ if v.TZ != "" {
+ tz = " " + v.TZ
+ }
+ // Keep it to one line where it fits; a phone is narrow enough that it often
+ // does not, and a wrapped title reads worse than two deliberate lines.
+ if hasSun && DisplayWidth(title)+DisplayWidth(suffix)+DisplayWidth(tz) <= x.width {
+ out = append(out, x.c(Bold, title)+x.c(Dim, suffix+tz))
+ } else {
+ out = append(out, x.c(Bold, title)+x.c(Dim, tz))
+ if hasSun {
+ out = append(out, x.c(Dim, fmt.Sprintf(" %s %s %s %s %s",
+ x.cat.Word("sun"), sun[0], x.cat.Word("up"), sun[1], x.cat.Word("down"))))
+ }
+ }
+
+ if len(v.Daily) > 0 {
+ var bits []string
+ lo, okLo := v.Daily["temperature_2m_min"]
+ hi, okHi := v.Daily["temperature_2m_max"]
+ if okLo && okHi {
+ bits = append(bits, fmt.Sprintf("%d-%d°", Deg(lo), Deg(hi)))
+ }
+ if mm, ok := v.Daily["precipitation_sum"]; ok {
+ if mm == 0 {
+ bits = append(bits, x.cat.Word("dry"))
+ } else {
+ bits = append(bits, fmt.Sprintf("%s %.1fmm %s %.0fh",
+ x.cat.Word("rainfall"), mm, x.cat.Word("over"),
+ v.Daily["precipitation_hours"]))
+ }
+ }
+ if sun, ok := v.Daily["sunshine_duration"]; ok {
+ if day, ok2 := v.Daily["daylight_duration"]; ok2 {
+ bits = append(bits, fmt.Sprintf("%s %s %s %s %s",
+ x.cat.Word("sun"), hm(sun), x.cat.Word("of"), hm(day),
+ x.cat.Word("daylight")))
+ }
+ }
+ label := " " + Pad(x.cat.Word("day"), 6) + " "
+ // Joined with wide separators when it fits; only a line too long for the
+ // terminal is re-wrapped, and then on single spaces.
+ joined := strings.Join(bits, " ")
+ lines := []string{joined}
+ if DisplayWidth(label)+DisplayWidth(joined) > x.width {
+ lines = wrap(joined, x.width-DisplayWidth(label))
+ }
+ for i, line := range lines {
+ prefix := label
+ if i > 0 {
+ prefix = strings.Repeat(" ", DisplayWidth(label))
+ }
+ out = append(out, x.c(Dim, prefix+line))
+ }
+ }
+
+ if len(v.Pollen) > 0 {
+ var bits []string
+ for _, s := range sortedByValue(v.Pollen) {
+ band := PollenBand(s, v.Pollen[s])
+ // Skip taxa that are simply absent, but never hide grass: it is the
+ // one someone may be allergic to and its absence is information.
+ if band == "none" && s != "grass" {
+ continue
+ }
+ text := fmt.Sprintf("%s %.1f", x.cat.Species(s), v.Pollen[s])
+ if band != "" {
+ text += " " + x.cat.Band(band)
+ }
+ bits = append(bits, text)
+ }
+ if len(bits) > 0 {
+ if len(bits) > 4 {
+ bits = bits[:4]
+ }
+ out = append(out, x.c(Dim, " "+Pad(x.cat.Word("pollen"), 6)+" ")+strings.Join(bits, " "))
+ }
+ }
+ return out
+}
+
+func hm(seconds float64) string {
+ s := int(seconds)
+ return fmt.Sprintf("%dh%02dm", s/3600, (s%3600)/60)
+}
+
+func sortedByValue(m map[string]float64) []string {
+ keys := make([]string, 0, len(m))
+ for k := range m {
+ keys = append(keys, k)
+ }
+ for i := 1; i < len(keys); i++ {
+ for j := i; j > 0 && m[keys[j]] > m[keys[j-1]]; j-- {
+ keys[j], keys[j-1] = keys[j-1], keys[j]
+ }
+ }
+ return keys
+}
+
+func wrap(text string, width int) []string {
+ if width < 8 {
+ width = 8
+ }
+ var lines []string
+ var line string
+ for _, word := range strings.Fields(text) {
+ switch {
+ case line == "":
+ line = word
+ case DisplayWidth(line)+1+DisplayWidth(word) <= width:
+ line += " " + word
+ default:
+ lines = append(lines, line)
+ line = word
+ }
+ }
+ if line != "" {
+ lines = append(lines, line)
+ }
+ return lines
+}
+
+func round1(v float64) string { return fmt.Sprintf("%.1f", v) }
+
+func compass(deg float64) string {
+ dirs := []string{"↓", "↙", "←", "↖", "↑", "↗", "→", "↘"}
+ i := int(math.Mod(math.Round(deg/45), 8))
+ if i < 0 {
+ i += 8
+ }
+ return dirs[i]
+}
diff --git a/internal/render/render_test.go b/internal/render/render_test.go
new file mode 100644
index 0000000..484a45b
--- /dev/null
+++ b/internal/render/render_test.go
@@ -0,0 +1,287 @@
+package render
+
+import (
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/lukaszkasprzak/prognosis/internal/config"
+ "github.com/lukaszkasprzak/prognosis/internal/imgw"
+ "github.com/lukaszkasprzak/prognosis/internal/openmeteo"
+)
+
+// row builds one hour. mm and pop default to dry.
+func row(hour int, temp float64, code int, mm, pop float64) openmeteo.Row {
+ return openmeteo.Row{
+ When: time.Date(2026, 8, 10, hour, 0, 0, 0, time.UTC),
+ Code: code,
+ Vals: map[string]float64{
+ "temperature_2m": temp,
+ "apparent_temperature": temp,
+ "precipitation": mm,
+ "precipitation_probability": pop,
+ "weather_code": float64(code),
+ },
+ }
+}
+
+func testConfig() config.Config {
+ c := config.Default()
+ c.Columns = []string{"hour", "temp", "feels", "conditions", "mm", "rain"}
+ c.Graph = false
+ c.Icons = "none"
+ c.DisplayLang = "en"
+ return c
+}
+
+func view(rows ...openmeteo.Row) View {
+ return View{Label: "Test, PL", Rows: rows, Sun: map[string][2]string{}}
+}
+
+// On a dry day the mm and rain columns are a block of zeroes pushing the real
+// content sideways.
+func TestDryWindowHidesTheRainColumns(t *testing.T) {
+ dry := Render(view(row(12, 25, 3, 0, 0), row(13, 26, 3, 0, 5)), testConfig(), 80, false)
+ if strings.Contains(dry, "mm") || strings.Contains(dry, "rain") {
+ t.Errorf("dry window still shows rain columns:\n%s", dry)
+ }
+
+ wet := Render(view(row(12, 25, 3, 0, 0), row(13, 26, 61, 0.4, 80)), testConfig(), 80, false)
+ if !strings.Contains(wet, "mm") || !strings.Contains(wet, "rain") {
+ t.Errorf("wet window must show rain columns:\n%s", wet)
+ }
+}
+
+// Probability alone is enough: rain that has not started yet still matters.
+func TestProbabilityAloneBringsBackTheRainColumns(t *testing.T) {
+ v := view(row(12, 25, 3, 0, RainInterestPct), row(13, 26, 3, 0, RainInterestPct))
+ if out := Render(v, testConfig(), 80, false); !strings.Contains(out, "rain") {
+ t.Errorf("%d%% chance must show the columns:\n%s", RainInterestPct, out)
+ }
+ below := view(row(12, 25, 3, 0, RainInterestPct-1), row(13, 26, 3, 0, 0))
+ if out := Render(below, testConfig(), 80, false); strings.Contains(out, "rain") {
+ t.Errorf("below the threshold the columns must stay hidden:\n%s", out)
+ }
+}
+
+// An unbroken column of "overcast" hides the hour it stops being overcast,
+// which is the only interesting part.
+func TestConditionsPrintOnlyWhenTheyChange(t *testing.T) {
+ v := view(
+ row(12, 25, 3, 0, 0), // overcast
+ row(13, 25, 3, 0, 0), // still overcast: blank
+ row(14, 25, 0, 0, 0), // clear: printed
+ row(15, 25, 0, 0, 0), // still clear: blank
+ )
+ out := Render(v, testConfig(), 80, false)
+ if n := strings.Count(out, "overcast"); n != 1 {
+ t.Errorf("overcast appears %d times, want 1:\n%s", n, out)
+ }
+ if n := strings.Count(out, "clear"); n != 1 {
+ t.Errorf("clear appears %d times, want 1:\n%s", n, out)
+ }
+}
+
+// Across a day boundary the conditions are repeated once, so a reader starting
+// at the new day is not looking at a blank column.
+func TestConditionsRepeatAfterADaySeparator(t *testing.T) {
+ next := row(0, 20, 3, 0, 0)
+ next.When = time.Date(2026, 8, 11, 0, 0, 0, 0, time.UTC)
+ v := view(row(23, 22, 3, 0, 0), next)
+ v.Sun["2026-08-11"] = [2]string{"05:16", "19:59"}
+
+ out := Render(v, testConfig(), 80, false)
+ if n := strings.Count(out, "overcast"); n != 2 {
+ t.Errorf("conditions must repeat once per day, appeared %d times:\n%s", n, out)
+ }
+ if !strings.Contains(out, "Tue 11 Aug") {
+ t.Errorf("missing the day separator:\n%s", out)
+ }
+}
+
+func TestFeelsLikeOnlyWhenItDiffers(t *testing.T) {
+ same := row(12, 25, 3, 0, 0)
+ diff := row(13, 25, 3, 0, 0)
+ diff.Vals["apparent_temperature"] = 28
+
+ out := Render(view(same, diff), testConfig(), 80, false)
+ if strings.Count(out, "(") != 1 {
+ t.Errorf("feels-like must appear once, only where it differs:\n%s", out)
+ }
+ if !strings.Contains(out, "(28)") {
+ t.Errorf("expected (28):\n%s", out)
+ }
+}
+
+// The four warning states must stay distinguishable: silence read as all-clear
+// is the failure that matters.
+func TestWarningStatesAreDistinct(t *testing.T) {
+ cfg := testConfig()
+ base := view(row(12, 25, 3, 0, 0))
+
+ inForce := base
+ inForce.Warnings = []imgw.Warning{{
+ Event: "Upal", Level: "1", Probability: "85",
+ From: "2026-08-10 11:00:00", To: "2026-08-10 20:00:00",
+ Text: "Prognozuje sie upal.",
+ }}
+ out := Render(inForce, cfg, 80, false)
+ if !strings.Contains(out, "Upal") || !strings.Contains(out, "level 1") {
+ t.Errorf("a live warning must be shown:\n%s", out)
+ }
+
+ if out := Render(base, cfg, 80, false); strings.Contains(out, "warnings:") {
+ t.Errorf("checked-and-none must print nothing about warnings:\n%s", out)
+ }
+
+ failed := base
+ failed.WarnFailed = true
+ if out := Render(failed, cfg, 80, false); !strings.Contains(out, "could not check") {
+ t.Errorf("a failed check must say so, not stay silent:\n%s", out)
+ }
+
+ abroad := base
+ abroad.WarnNote = "IMGW covers Poland only"
+ if out := Render(abroad, cfg, 80, false); !strings.Contains(out, "Poland only") {
+ t.Errorf("an abroad location must explain itself:\n%s", out)
+ }
+}
+
+func TestWarningsDisabledSuppressesEvenAFailure(t *testing.T) {
+ cfg := testConfig()
+ cfg.Warnings = false
+ v := view(row(12, 25, 3, 0, 0))
+ v.WarnFailed = true
+ if out := Render(v, cfg, 80, false); strings.Contains(out, "could not check") {
+ t.Errorf("warnings=false must suppress the notice too:\n%s", out)
+ }
+}
+
+// -weather strips everything that is not the forecast.
+func TestMinimalStripsTheExtras(t *testing.T) {
+ cfg := testConfig()
+ cfg.Minimal = true
+ v := view(row(12, 25, 3, 0, 0))
+ v.Sun["2026-08-10"] = [2]string{"05:15", "20:01"}
+ v.Daily = map[string]float64{"temperature_2m_min": 12, "temperature_2m_max": 30}
+ v.Pollen = map[string]float64{"grass": 10}
+
+ out := Render(v, cfg, 80, false)
+ for _, unwanted := range []string{"sun", "up", "day", "pollen", "grass"} {
+ if strings.Contains(out, unwanted) {
+ t.Errorf("minimal output still contains %q:\n%s", unwanted, out)
+ }
+ }
+ if !strings.Contains(out, "Test, PL") || !strings.Contains(out, "25°") {
+ t.Errorf("minimal output must still carry place and forecast:\n%s", out)
+ }
+}
+
+func chartOf(t *testing.T, hours int, width int) []string {
+ t.Helper()
+ cfg := testConfig()
+ cfg.Graph = true
+ var rows []openmeteo.Row
+ for i := 0; i < hours; i++ {
+ when := time.Date(2026, 8, 10, 0, 0, 0, 0, time.UTC).Add(time.Duration(i) * time.Hour)
+ r := row(0, 20+float64(i%10), 3, 0, 0)
+ r.When = when
+ rows = append(rows, r)
+ }
+ out := Render(view(rows...), cfg, width, false)
+ return strings.Split(out, "\n")
+}
+
+// A 12-hour chart drawn one column per hour would occupy 12 of 80 columns.
+func TestChartWidensShortSpansToFillTheTerminal(t *testing.T) {
+ lines := chartOf(t, 12, 80)
+ var widest int
+ for _, l := range lines {
+ if strings.Contains(l, "│") {
+ if w := DisplayWidth(l); w > widest {
+ widest = w
+ }
+ }
+ }
+ if widest < 40 {
+ t.Fatalf("chart is only %d columns wide for a 12-hour span; it should widen", widest)
+ }
+}
+
+// A week is 168 points and would wrap into mush; columns must cover several
+// hours and the label must say so.
+func TestChartDownsamplesLongSpansAndSaysSo(t *testing.T) {
+ out := strings.Join(chartOf(t, 168, 80), "\n")
+ if !strings.Contains(out, "h/col") {
+ t.Fatalf("a downsampled chart must disclose the ratio:\n%s", out)
+ }
+ for _, l := range strings.Split(out, "\n") {
+ if w := DisplayWidth(l); w > 80 {
+ t.Fatalf("chart line is %d columns wide, wider than the terminal:\n%s", w, l)
+ }
+ }
+}
+
+func TestChartNeverExceedsTheTerminalWidth(t *testing.T) {
+ for _, width := range []int{32, 53, 80, 96} {
+ for _, hours := range []int{1, 6, 24, 72} {
+ lines := chartOf(t, hours, width)
+ // Only the chart: the table has fixed column widths and is measured
+ // separately, below.
+ for i, l := range lines {
+ isChart := strings.Contains(l, "│") ||
+ (i >= len(lines)-2 && strings.TrimSpace(l) != "")
+ if !isChart {
+ continue
+ }
+ if w := DisplayWidth(l); w > width {
+ t.Errorf("width=%d hours=%d: chart line is %d columns:\n%s", width, hours, w, l)
+ }
+ }
+ }
+ }
+}
+
+// The table has fixed column widths, so unlike the chart it does not shrink to
+// fit. This pins the width the default column set needs: the phone is 53
+// columns, so there is headroom, but a narrower terminal will wrap and there is
+// no code preventing it. Narrow the columns instead -- see -columns.
+func TestTableMinimumWidthIsKnown(t *testing.T) {
+ cfg := testConfig()
+ out := Render(view(row(12, 25, 3, 0, 0)), cfg, 80, false)
+ widest := 0
+ for _, l := range strings.Split(out, "\n") {
+ if w := DisplayWidth(l); w > widest {
+ widest = w
+ }
+ }
+ const documented = 34
+ if widest != documented {
+ t.Fatalf("the default table now needs %d columns, not the documented %d; "+
+ "update the README if this is intended", widest, documented)
+ }
+ // A narrower column set must actually be narrower, or -columns is no remedy.
+ cfg.Columns = []string{"hour", "temp", "conditions"}
+ narrow := 0
+ for _, l := range strings.Split(Render(view(row(12, 25, 3, 0, 0)), cfg, 80, false), "\n") {
+ if w := DisplayWidth(l); w > narrow {
+ narrow = w
+ }
+ }
+ if narrow >= documented {
+ t.Errorf("narrow column set is %d columns, no better than %d", narrow, documented)
+ }
+}
+
+// A label that would run off the end is skipped, never printed as half a label.
+func TestChartAxisLabelsAreWholeOrAbsent(t *testing.T) {
+ for _, hours := range []int{1, 2, 3, 5, 12, 24} {
+ lines := chartOf(t, hours, 40)
+ axis := lines[len(lines)-2] // axis sits above the range note
+ for _, field := range strings.Fields(axis) {
+ if len(field) != 2 {
+ t.Errorf("hours=%d: axis has a partial label %q in %q", hours, field, axis)
+ }
+ }
+ }
+}
diff --git a/internal/render/scale.go b/internal/render/scale.go
new file mode 100644
index 0000000..a2eb2dc
--- /dev/null
+++ b/internal/render/scale.go
@@ -0,0 +1,92 @@
+package render
+
+import "math"
+
+// Temperature bands. The two ends are IMGW's own warning criteria, so a red
+// temperature means the met office would issue a warning about it rather than
+// that it looked hot to whoever wrote this:
+//
+// Tmin <= -15 silny mroz, stopien 1 -> bright blue
+// Tmax >= 30 upal, stopien 1 -> red
+// Tmax > 35 the higher heat level -> bright red
+//
+// The splits between (0, 10, 20) are round numbers, not thresholds from any
+// source; they only subdivide the range nobody warns about.
+var tempBands = []struct {
+ below float64
+ code string
+}{
+ {0, Blue}, {10, Cyan}, {20, Green}, {30, Yellow},
+}
+
+// Deg rounds to whole degrees. Rounding through int conversion avoids "-0",
+// which is arithmetically fine and visually wrong.
+func Deg(t float64) int {
+ return int(math.Round(t))
+}
+
+// TempStyle is the ANSI code for a temperature in Celsius.
+//
+// The warning edges are written out rather than folded into tempBands so they
+// match IMGW's criteria exactly, inclusive and exclusive included.
+//
+// The value is rounded first, so the colour always matches the number printed
+// beside it: otherwise -14.6 prints as "-15" in a different colour than a true
+// -15 and looks like a rendering bug.
+func TempStyle(celsius float64) string {
+ t := float64(Deg(celsius))
+ switch {
+ case t <= -15:
+ return BrightBlue
+ case t > 35:
+ return BrightRed
+ case t >= 30:
+ return Red
+ }
+ for _, b := range tempBands {
+ if t < b.below {
+ return b.code
+ }
+ }
+ return Yellow
+}
+
+// Pollen bands in grains/m3, from Polish clinical sources. Each entry is an
+// upper bound (exclusive) and a band key; a nil bound means "everything above".
+//
+// grass -- alergen.info.pl symptom table: 20 = first nasal symptoms in 25%
+// of sufferers, 50 = symptoms in all tested, 65 = intensified in
+// over 75%, 120 = dyspnoea after 30 minutes of exposure.
+// birch -- mp.pl: 80 provokes symptoms in over 95% of allergics.
+// mugwort -- mp.pl: over 70 counts as high, intensified symptoms.
+//
+// Birch and mugwort have a single published anchor each, so they get a two-way
+// split rather than four bands: their "low" is weaker evidence than grass's.
+// Alder, olive and ragweed have no Polish threshold that could be sourced and
+// are deliberately left unbanded rather than banded on a guess.
+var pollenBands = map[string][]struct {
+ below float64
+ band string
+}{
+ "grass": {{20, "low"}, {50, "medium"}, {65, "high"}, {math.Inf(1), "very high"}},
+ "birch": {{80, "low"}, {math.Inf(1), "high"}},
+ "mugwort": {{70, "low"}, {math.Inf(1), "high"}},
+}
+
+// PollenBand is the qualitative level for a count, or "" where no threshold
+// exists for that species.
+func PollenBand(species string, value float64) string {
+ if value < 1 {
+ return "none"
+ }
+ bands, ok := pollenBands[species]
+ if !ok {
+ return ""
+ }
+ for _, b := range bands {
+ if value < b.below {
+ return b.band
+ }
+ }
+ return ""
+}
diff --git a/internal/render/scale_test.go b/internal/render/scale_test.go
new file mode 100644
index 0000000..9754bdc
--- /dev/null
+++ b/internal/render/scale_test.go
@@ -0,0 +1,57 @@
+package render
+
+import (
+ "testing"
+
+ "github.com/lukaszkasprzak/prognosis/internal/config"
+)
+
+// IMGW's criteria are in Celsius. Switching the display to Fahrenheit must not
+// move the temperature at which the met office is said to warn.
+func TestThresholdsAreComparedInCelsiusWhateverTheDisplayUnits(t *testing.T) {
+ metric := ctx{cfg: config.Config{Units: "metric"}}
+ imperial := ctx{cfg: config.Config{Units: "imperial"}}
+
+ cases := []struct {
+ celsius float64
+ fahrenheit float64
+ want string
+ why string
+ }{
+ {29, 84.2, Yellow, "below the upal threshold"},
+ {30, 86, Red, "upal stopien 1 is Tmax >= 30C"},
+ {36, 96.8, BrightRed, "the higher heat level is Tmax > 35C"},
+ {-15, 5, BrightBlue, "silny mroz stopien 1 is Tmin <= -15C"},
+ {-14, 6.8, Blue, "just above the frost threshold"},
+ }
+ for _, c := range cases {
+ if got := TempStyle(metric.celsius(c.celsius)); got != c.want {
+ t.Errorf("%.0fC -> %s, want %s (%s)", c.celsius, got, c.want, c.why)
+ }
+ if got := TempStyle(imperial.celsius(c.fahrenheit)); got != c.want {
+ t.Errorf("%.1fF (=%.0fC) -> %s, want %s (%s)",
+ c.fahrenheit, c.celsius, got, c.want, c.why)
+ }
+ }
+}
+
+func TestPollenBandEdges(t *testing.T) {
+ cases := []struct {
+ species string
+ value float64
+ want string
+ }{
+ {"grass", 0.5, "none"}, {"grass", 19, "low"}, {"grass", 20, "medium"},
+ {"grass", 49, "medium"}, {"grass", 50, "high"}, {"grass", 64, "high"},
+ {"grass", 65, "very high"}, {"grass", 200, "very high"},
+ {"birch", 79, "low"}, {"birch", 80, "high"},
+ {"mugwort", 69, "low"}, {"mugwort", 70, "high"},
+ {"ragweed", 50, ""}, // no Polish threshold sourced: deliberately unbanded
+ {"alder", 500, ""},
+ }
+ for _, c := range cases {
+ if got := PollenBand(c.species, c.value); got != c.want {
+ t.Errorf("PollenBand(%q, %v) = %q, want %q", c.species, c.value, got, c.want)
+ }
+ }
+}
diff --git a/internal/render/table.go b/internal/render/table.go
new file mode 100644
index 0000000..3ade296
--- /dev/null
+++ b/internal/render/table.go
@@ -0,0 +1,240 @@
+package render
+
+import (
+ "fmt"
+ "strings"
+
+ "github.com/lukaszkasprzak/prognosis/internal/openmeteo"
+)
+
+// cell is one rendered table cell: text, its colour, and how it is aligned.
+type cell struct {
+ text string
+ style string
+ left bool
+}
+
+// colWidth is the reserved display width per column.
+func (x ctx) colWidth(name string) int {
+ switch name {
+ case "hour":
+ // Three, not two: the Python leaves a double space after the hour.
+ return 3
+ case "icon":
+ return IconWidth(x.cfg.Icons)
+ case "temp":
+ return 5
+ case "feels":
+ return 6
+ case "conditions":
+ return 16
+ case "mm", "rain", "wind", "gusts", "humidity", "dew", "uv", "cloud", "visibility":
+ return 5
+ case "dir":
+ return 3
+ case "pressure":
+ return 6
+ }
+ return 6
+}
+
+func (x ctx) leftAligned(name string) bool {
+ switch name {
+ case "hour", "icon", "temp", "feels", "conditions":
+ return true
+ }
+ return false
+}
+
+// visible drops columns that have nothing to say: the icon column when icons
+// are off, and the rain pair on a dry window.
+func (x ctx) visible() []string {
+ var out []string
+ for _, name := range x.cfg.Columns {
+ if name == "icon" && x.cfg.Icons == "none" {
+ continue
+ }
+ if (name == "mm" || name == "rain") && !x.wet {
+ continue
+ }
+ out = append(out, name)
+ }
+ return out
+}
+
+// row assembles one line from cells, padding each to its column width BEFORE
+// colouring it. Escape sequences carry no display width, so padding a coloured
+// string misaligns every column to its right -- invisible when piped, obvious
+// in a terminal.
+func (x ctx) row(cells map[string]cell) string {
+ var b strings.Builder
+ for _, name := range x.visible() {
+ c := cells[name]
+ w := x.colWidth(name)
+ padded := PadLeft(c.text, w)
+ if x.leftAligned(name) {
+ padded = Pad(c.text, w)
+ }
+ b.WriteString(" ")
+ b.WriteString(x.c(c.style, padded))
+ }
+ return strings.TrimRight(b.String(), " ")
+}
+
+func (x ctx) table(v View) []string {
+ out := []string{""}
+
+ headers := map[string]cell{}
+ for _, name := range x.visible() {
+ headers[name] = cell{text: x.cat.Header(name), style: ""}
+ }
+ out = append(out, x.c(Underline, x.rowPlain(headers)))
+
+ day := v.Rows[0].When.Format("2006-01-02")
+ prevCode := -1
+ for i, r := range v.Rows {
+ if d := r.When.Format("2006-01-02"); d != day {
+ day = d
+ sep := " -- " + x.cat.Date(r.When) + " --"
+ if sun, ok := v.Sun[d]; ok {
+ sep += fmt.Sprintf(" %s %s / %s", x.cat.Word("sun"), sun[0], sun[1])
+ }
+ out = append(out, x.c(Dim, sep))
+ prevCode = -1 // repeat the conditions once per day for context
+ }
+ out = append(out, x.row(x.cells(r, i == 0, &prevCode)))
+ }
+ return out
+}
+
+// rowPlain is the header row: padded like the data but never coloured per cell,
+// so the underline runs unbroken across it.
+func (x ctx) rowPlain(cells map[string]cell) string {
+ var b strings.Builder
+ for _, name := range x.visible() {
+ w := x.colWidth(name)
+ text := cells[name].text
+ padded := PadLeft(text, w)
+ if x.leftAligned(name) {
+ padded = Pad(text, w)
+ }
+ b.WriteString(" ")
+ b.WriteString(padded)
+ }
+ return b.String()
+}
+
+func (x ctx) cells(r openmeteo.Row, isNow bool, prevCode *int) map[string]cell {
+ out := map[string]cell{}
+ temp, hasTemp := r.Val("temperature_2m")
+
+ for _, name := range x.visible() {
+ switch name {
+ case "hour":
+ style := Reset
+ if isNow {
+ style = Bold
+ }
+ out[name] = cell{text: r.When.Format("15"), style: style}
+
+ case "icon":
+ out[name] = cell{text: Icon(x.cfg.Icons, r.Code)}
+
+ case "temp":
+ style := TempStyle(x.celsius(temp))
+ // The current hour keeps its emphasis on top of the heat colour.
+ if isNow {
+ style = Bold + ";" + style
+ }
+ out[name] = cell{text: fmt.Sprintf("%d°", Deg(temp)), style: style}
+
+ case "feels":
+ text := ""
+ if feels, ok := r.Val("apparent_temperature"); ok && hasTemp {
+ // Only shown when it differs; otherwise it is a column of noise.
+ if abs(feels-temp) >= 1 {
+ text = fmt.Sprintf("(%d)", Deg(feels))
+ }
+ }
+ out[name] = cell{text: text, style: Dim}
+
+ case "conditions":
+ text := ""
+ // Only when they change: an unbroken column of "overcast" hides the
+ // hour it stops being overcast, which is the only interesting part.
+ if r.Code != *prevCode {
+ text = Truncate(x.cat.Condition(r.Code), x.colWidth(name))
+ }
+ out[name] = cell{text: text}
+
+ case "mm":
+ mm, _ := r.Val("precipitation")
+ style := Dim
+ if mm > 0 {
+ style = Cyan
+ }
+ out[name] = cell{text: round1(mm), style: style}
+
+ case "rain":
+ p, _ := r.Val("precipitation_probability")
+ style := Dim
+ if p >= 50 {
+ style = Yellow
+ }
+ out[name] = cell{text: fmt.Sprintf("%d%%", int(p)), style: style}
+
+ case "wind":
+ v, _ := r.Val("wind_speed_10m")
+ out[name] = cell{text: fmt.Sprintf("%d", int(v))}
+
+ case "gusts":
+ v, _ := r.Val("wind_gusts_10m")
+ style := ""
+ if v >= 60 {
+ style = Yellow
+ }
+ out[name] = cell{text: fmt.Sprintf("%d", int(v)), style: style}
+
+ case "dir":
+ v, _ := r.Val("wind_direction_10m")
+ out[name] = cell{text: compass(v)}
+
+ case "humidity":
+ v, _ := r.Val("relative_humidity_2m")
+ out[name] = cell{text: fmt.Sprintf("%d%%", int(v)), style: Dim}
+
+ case "dew":
+ v, _ := r.Val("dew_point_2m")
+ out[name] = cell{text: fmt.Sprintf("%d°", Deg(v)), style: Dim}
+
+ case "uv":
+ v, _ := r.Val("uv_index")
+ style := Dim
+ if v >= 6 {
+ style = Yellow
+ }
+ out[name] = cell{text: round1(v), style: style}
+
+ case "cloud":
+ v, _ := r.Val("cloud_cover")
+ out[name] = cell{text: fmt.Sprintf("%d%%", int(v)), style: Dim}
+
+ case "pressure":
+ v, _ := r.Val("pressure_msl")
+ out[name] = cell{text: fmt.Sprintf("%d", int(v)), style: Dim}
+
+ case "visibility":
+ v, _ := r.Val("visibility")
+ out[name] = cell{text: fmt.Sprintf("%.0fkm", v/1000), style: Dim}
+ }
+ }
+ *prevCode = r.Code
+ return out
+}
+
+func abs(f float64) float64 {
+ if f < 0 {
+ return -f
+ }
+ return f
+}
diff --git a/internal/render/width.go b/internal/render/width.go
new file mode 100644
index 0000000..c5676d5
--- /dev/null
+++ b/internal/render/width.go
@@ -0,0 +1,110 @@
+package render
+
+import (
+ "strings"
+ "unicode"
+)
+
+// wideRanges are the code point ranges this program can emit that occupy two
+// terminal cells. Only the sets we actually produce are covered -- our own icon
+// glyphs, plus the CJK blocks a place name could contain -- rather than the
+// whole Unicode width table, which would be a dependency or a large generated
+// file for no gain.
+var wideRanges = [][2]rune{
+ {0x1100, 0x115F}, // Hangul Jamo
+ {0x2329, 0x232A},
+ {0x2E80, 0x303E}, // CJK radicals, Kangxi
+ {0x3041, 0x33FF}, // kana, CJK compatibility
+ {0x3400, 0x4DBF}, // CJK extension A
+ {0x4E00, 0x9FFF}, // CJK unified
+ {0xA000, 0xA4CF}, // Yi
+ {0xAC00, 0xD7A3}, // Hangul syllables
+ {0xF900, 0xFAFF}, // CJK compatibility ideographs
+ {0xFE30, 0xFE6F}, // CJK compatibility forms
+ {0xFF00, 0xFF60}, // fullwidth forms
+ {0xFFE0, 0xFFE6},
+ {0x1F300, 0x1F64F}, // emoji: weather, faces
+ {0x1F680, 0x1F6FF}, // emoji: transport and symbols
+ {0x1F900, 0x1F9FF}, // supplemental symbols
+ {0x26C4, 0x26C8}, // snowman, thundercloud
+ {0x2614, 0x2615}, // umbrella with rain, hot beverage
+}
+
+// ambiguousWide lists the individual code points we emit whose East-Asian width
+// is Ambiguous but which terminals in this estate render as two cells.
+var ambiguousWide = map[rune]bool{
+ 0x26C5: true, // sun behind cloud
+ 0x26C8: true, // thunder cloud and rain
+}
+
+func runeWidth(r rune) int {
+ switch {
+ case r == 0xFE0F:
+ // Variation Selector-16 requests emoji presentation. It has no width of
+ // its own; its effect is already counted on the base rune.
+ return 0
+ case r == 0xFE0E:
+ return 0
+ case unicode.Is(unicode.Mn, r) || unicode.Is(unicode.Me, r) || unicode.Is(unicode.Cf, r):
+ return 0 // combining and formatting marks occupy no cell
+ case r == '‍':
+ return 0 // zero-width joiner
+ case r < 0x20:
+ return 0
+ case ambiguousWide[r]:
+ return 2
+ }
+ for _, rng := range wideRanges {
+ if r >= rng[0] && r <= rng[1] {
+ return 2
+ }
+ }
+ return 1
+}
+
+// DisplayWidth is the number of terminal cells a string occupies.
+//
+// Neither len() nor a rune count will do: an emoji may be two cells, a
+// variation selector is zero, and combining marks are zero. Padding with the
+// wrong number shears every column to the right of it -- and only in a real
+// terminal, never when the output is piped, which is what makes it easy to miss.
+func DisplayWidth(s string) int {
+ w := 0
+ for _, r := range s {
+ w += runeWidth(r)
+ }
+ return w
+}
+
+// Pad returns s padded with spaces to at least w display cells (left aligned).
+func Pad(s string, w int) string {
+ if n := w - DisplayWidth(s); n > 0 {
+ return s + strings.Repeat(" ", n)
+ }
+ return s
+}
+
+// PadLeft returns s padded with spaces to at least w display cells (right aligned).
+func PadLeft(s string, w int) string {
+ if n := w - DisplayWidth(s); n > 0 {
+ return strings.Repeat(" ", n) + s
+ }
+ return s
+}
+
+// Truncate cuts s to at most w display cells, never splitting a rune.
+func Truncate(s string, w int) string {
+ if DisplayWidth(s) <= w {
+ return s
+ }
+ out, used := make([]rune, 0, len(s)), 0
+ for _, r := range s {
+ rw := runeWidth(r)
+ if used+rw > w {
+ break
+ }
+ out = append(out, r)
+ used += rw
+ }
+ return string(out)
+}
diff --git a/internal/render/width_test.go b/internal/render/width_test.go
new file mode 100644
index 0000000..f20038e
--- /dev/null
+++ b/internal/render/width_test.go
@@ -0,0 +1,112 @@
+package render
+
+import (
+ "strings"
+ "testing"
+)
+
+func TestDisplayWidth(t *testing.T) {
+ cases := []struct {
+ name string
+ in string
+ want int
+ }{
+ {"ascii", "temp", 4},
+ {"empty", "", 0},
+ {"degree sign is one cell", "28°", 3},
+ {"polish diacritics are one cell each", "słońce", 6},
+ {"emoji with variation selector counts once", "☀️", 1},
+ {"bare BMP symbol", "☀", 1},
+ {"sun behind cloud is wide", "⛅", 2},
+ {"rain cloud is wide", "\U0001F327", 2},
+ {"nerd font glyph is one cell", "", 1},
+ {"block drawing is one cell", "█", 1},
+ {"combining acute adds nothing", "é", 1},
+ }
+ for _, c := range cases {
+ t.Run(c.name, func(t *testing.T) {
+ if got := DisplayWidth(c.in); got != c.want {
+ t.Errorf("DisplayWidth(%q) = %d, want %d", c.in, got, c.want)
+ }
+ })
+ }
+}
+
+// The invariant that actually matters: whatever goes in a column, the padded
+// result occupies exactly the requested number of cells. If this fails, every
+// column to the right shears -- but only in a real terminal.
+func TestPadReachesExactWidthForEveryIconSet(t *testing.T) {
+ samples := []string{
+ "clear", "", "28°", "słońce",
+ "☀️", "⛅", "\U0001F327", "⛈", // emoji set
+ "", "", "", // nerd set
+ }
+ for _, s := range samples {
+ for _, w := range []int{1, 4, 8, 16} {
+ got := Pad(s, w)
+ if DisplayWidth(s) <= w && DisplayWidth(got) != w {
+ t.Errorf("Pad(%q, %d) has width %d, want %d", s, w, DisplayWidth(got), w)
+ }
+ if !strings.HasPrefix(got, s) {
+ t.Errorf("Pad(%q, %d) = %q, must not alter the content", s, w, got)
+ }
+ }
+ }
+}
+
+func TestPadLeft(t *testing.T) {
+ if got := PadLeft("5", 3); got != " 5" {
+ t.Fatalf("PadLeft = %q, want %q", got, " 5")
+ }
+ if got := PadLeft("⛅", 4); DisplayWidth(got) != 4 {
+ t.Fatalf("PadLeft of a wide glyph has width %d, want 4", DisplayWidth(got))
+ }
+}
+
+func TestPadDoesNotShrink(t *testing.T) {
+ if got := Pad("conditions", 4); got != "conditions" {
+ t.Fatalf("Pad must never truncate: got %q", got)
+ }
+}
+
+func TestTruncateNeverSplitsARune(t *testing.T) {
+ if got := Truncate("słońce", 3); got != "sło" {
+ t.Errorf("Truncate = %q, want %q", got, "sło")
+ }
+ // A wide glyph that does not fit is dropped whole, not halved.
+ if got := Truncate("a⛅", 2); got != "a" {
+ t.Errorf("Truncate = %q, want %q", got, "a")
+ }
+}
+
+func TestPaintGroupsRuns(t *testing.T) {
+ c := Styler(true)
+ cells := []Cell{
+ {Style: "31", Text: "a"}, {Style: "31", Text: "b"}, {Style: "31", Text: "c"},
+ {Style: "33", Text: "d"},
+ }
+ got := Paint(cells, c)
+ if n := strings.Count(got, "\033["); n != 4 { // 2 opens + 2 resets
+ t.Fatalf("expected one escape pair per run, got %d escapes in %q", n, got)
+ }
+ if strings.Count(got, "\033[31m") != 1 {
+ t.Errorf("the three red cells must share one escape: %q", got)
+ }
+}
+
+func TestPaintWithoutColourIsPlain(t *testing.T) {
+ c := Styler(false)
+ got := Paint([]Cell{{Style: "31", Text: "a"}, {Style: "33", Text: "b"}}, c)
+ if got != "ab" {
+ t.Fatalf("colour off must yield plain text, got %q", got)
+ }
+}
+
+// Colour must never reach for a 256-colour index.
+func TestNoExtendedColourCodes(t *testing.T) {
+ for _, code := range []string{Blue, Cyan, Green, Yellow, Red, BrightBlue, BrightRed, BrightWhite} {
+ if strings.Contains(code, "38;5;") || strings.Contains(code, "48;5;") {
+ t.Errorf("%q is a 256-colour index; only slots 0-15 are allowed", code)
+ }
+ }
+}