From 92e1106b91f836339a957312a1ea4449c55114c7 Mon Sep 17 00:00:00 2001 From: Lukasz Kasprzak Date: Tue, 25 Aug 2026 15:42:55 +0200 Subject: Stand alone, document, and let the config show anything prognosis no longer reads ~/.wegorc. Falling back to another program's configuration made it useless without wego installed, and hid the fact that it has no way to know where you are: location= is now required in its own config, and the error says so and shows how to set it. Custom columns. Any field the two Open-Meteo APIs expose can be displayed by declaring it -- column.birch = air:birch_pollen -- and then naming it in columns=. The source is explicit because forecast and air-quality are separate services with separate fields; an air column costs one extra request, made only when one is declared. Air values merge onto forecast hours by timestamp rather than array index, since nothing guarantees the two endpoints start at the same hour and merging by position would shift a column by an hour unnoticed. A value the API withholds renders blank, not zero: for an allergen those are different claims. Pollen selection no longer privileges grass. A species named in pollen= is shown even at zero, because you named it for a reason; pollen=all shows only what is present, or the line is six zeroes. Grass had been special-cased, which forced it on someone allergic to birch while hiding theirs. Temperature colours are compared in Celsius whatever the display units. In imperial, 85F -- a mild 29C -- was rendering in the red that means IMGW would issue a heat warning. A man page, checked by make lint and installed by make install. The Makefile gains PREFIX/DESTDIR for packaging, a version stamped into the binary, a release target that refuses to tag a dirty tree or a version with no changelog entry, cross-compilation for six platforms, and a pre-push hook. Also: humidity in the default columns, -weather for output meant for someone else, -ascii so an SMS stays in GSM-7 rather than dropping to 70-character UCS-2 segments, and -pollen and -version. --- internal/render/render.go | 8 ++- internal/render/render_test.go | 122 ++++++++++++++++++++++++++++++++++++++++- internal/render/table.go | 66 +++++++++++++++++++++- 3 files changed, 190 insertions(+), 6 deletions(-) (limited to 'internal/render') diff --git a/internal/render/render.go b/internal/render/render.go index 4417763..7df9b66 100644 --- a/internal/render/render.go +++ b/internal/render/render.go @@ -209,9 +209,11 @@ func (x ctx) header(v View) []string { 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" { + // A species the user named is always shown, even at zero: they named + // it because they react to it, and "none today" is what they wanted + // to know. With pollen=all nobody chose, so absent taxa are dropped + // rather than printing a line of zeroes. + if band == "none" && !x.cfg.PollenExplicit { continue } text := fmt.Sprintf("%s %.1f", x.cat.Species(s), v.Pollen[s]) diff --git a/internal/render/render_test.go b/internal/render/render_test.go index 484a45b..d06fc7b 100644 --- a/internal/render/render_test.go +++ b/internal/render/render_test.go @@ -27,7 +27,7 @@ func row(hour int, temp float64, code int, mm, pop float64) openmeteo.Row { func testConfig() config.Config { c := config.Default() - c.Columns = []string{"hour", "temp", "feels", "conditions", "mm", "rain"} + c.Columns = []string{"hour", "temp", "feels", "conditions", "humidity", "mm", "rain"} c.Graph = false c.Icons = "none" c.DisplayLang = "en" @@ -255,7 +255,7 @@ func TestTableMinimumWidthIsKnown(t *testing.T) { widest = w } } - const documented = 34 + const documented = 40 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) @@ -285,3 +285,121 @@ func TestChartAxisLabelsAreWholeOrAbsent(t *testing.T) { } } } + +func customCfg() config.Config { + c := testConfig() + c.Columns = []string{"hour", "temp", "birch", "soil"} + c.Custom = map[string]config.CustomColumn{ + "birch": {Source: "air", Field: "birch_pollen", Label: "brzoza", Decimals: 1}, + "soil": {Source: "forecast", Field: "soil_temperature_0cm", Suffix: "°", Decimals: 0}, + } + return c +} + +func TestCustomColumnsRender(t *testing.T) { + r := row(12, 25, 3, 0, 0) + r.Vals[CustomKey("birch")] = 12.34 + r.Vals["soil_temperature_0cm"] = 21.6 + + out := Render(view(r), customCfg(), 80, false) + if !strings.Contains(out, "brzoza") { + t.Errorf("the declared label must be the header:\n%s", out) + } + if !strings.Contains(out, "12.3") { + t.Errorf("decimals=1 should give 12.3:\n%s", out) + } + if !strings.Contains(out, "22°") { + t.Errorf("decimals=0 with a suffix should give 22°:\n%s", out) + } +} + +// "No data" and "zero" are different claims, and for an allergen the difference +// matters. +func TestCustomColumnBlankWhenTheApiGaveNothing(t *testing.T) { + r := row(12, 25, 3, 0, 0) // no custom values set at all + out := Render(view(r), customCfg(), 80, false) + if strings.Contains(out, "0.0") { + t.Errorf("a missing value must render blank, not as zero:\n%s", out) + } + if !strings.Contains(out, "brzoza") { + t.Errorf("the column should still be present:\n%s", out) + } +} + +// A custom column named after a built-in API field must not read that field's +// value; the prefix is what keeps them apart. +func TestCustomKeyDoesNotCollideWithApiFields(t *testing.T) { + if CustomKey("temperature_2m") == "temperature_2m" { + t.Fatal("custom values must be stored under a distinct key") + } + r := row(12, 25, 3, 0, 0) // temperature_2m = 25 + cfg := testConfig() + cfg.Columns = []string{"hour", "mine"} + cfg.Custom = map[string]config.CustomColumn{ + "mine": {Source: "air", Field: "temperature_2m", Decimals: 0}, + } + if out := Render(view(r), cfg, 80, false); strings.Contains(out, "25") { + t.Errorf("the custom column picked up the built-in field's value:\n%s", out) + } +} + +func TestCustomColumnWidthFromLabelWhenUnset(t *testing.T) { + cfg := testConfig() + cfg.Columns = []string{"hour", "verylongname"} + cfg.Custom = map[string]config.CustomColumn{ + "verylongname": {Source: "air", Field: "f", Label: "verylongname"}, + } + r := row(12, 25, 3, 0, 0) + r.Vals[CustomKey("verylongname")] = 1 + out := Render(view(r), cfg, 80, false) + for _, l := range strings.Split(out, "\n") { + if strings.Contains(l, "verylongname") && DisplayWidth(l) < 12 { + t.Errorf("header was truncated: %q", l) + } + } +} + +// A species the user named is shown even at zero: they named it because they +// react to it, and "none today" is the answer they wanted. +func TestNamedPollenSpeciesShownEvenAtZero(t *testing.T) { + cfg := testConfig() + cfg.Pollen = []string{"birch"} + cfg.PollenExplicit = true + v := view(row(12, 25, 3, 0, 0)) + v.Pollen = map[string]float64{"birch": 0} + + out := Render(v, cfg, 80, false) + if !strings.Contains(out, "birch") { + t.Errorf("a named species must appear even at zero:\n%s", out) + } +} + +// With pollen=all nobody chose, so a line of six zeroes is noise. +func TestPollenAllHidesAbsentSpecies(t *testing.T) { + cfg := testConfig() + cfg.Pollen = []string{"grass", "birch"} + cfg.PollenExplicit = false + v := view(row(12, 25, 3, 0, 0)) + v.Pollen = map[string]float64{"grass": 12, "birch": 0} + + out := Render(v, cfg, 80, false) + if !strings.Contains(out, "grass") { + t.Errorf("a present species must be shown:\n%s", out) + } + if strings.Contains(out, "birch") { + t.Errorf("an absent species must be dropped when nobody named it:\n%s", out) + } +} + +// No species is privileged. Grass used to be special-cased, which forced it on +// someone allergic to birch while hiding theirs. +func TestNoSpeciesIsPrivileged(t *testing.T) { + cfg := testConfig() + cfg.PollenExplicit = false + v := view(row(12, 25, 3, 0, 0)) + v.Pollen = map[string]float64{"grass": 0, "birch": 0} + + if out := Render(v, cfg, 80, false); strings.Contains(out, "grass") { + t.Errorf("grass at zero must be dropped like any other species:\n%s", out) + } +} diff --git a/internal/render/table.go b/internal/render/table.go index 3ade296..3219e9d 100644 --- a/internal/render/table.go +++ b/internal/render/table.go @@ -4,6 +4,7 @@ import ( "fmt" "strings" + "github.com/lukaszkasprzak/prognosis/internal/config" "github.com/lukaszkasprzak/prognosis/internal/openmeteo" ) @@ -14,8 +15,34 @@ type cell struct { left bool } +// custom returns the config's definition of a column, if it has one. +func (x ctx) custom(name string) (config.CustomColumn, bool) { + cc, ok := x.cfg.Custom[name] + return cc, ok +} + +// customLabel is the header for a custom column: what the user asked for, or +// the column's own name. +func customLabel(name string, cc config.CustomColumn) string { + if cc.Label != "" { + return cc.Label + } + return name +} + // colWidth is the reserved display width per column. func (x ctx) colWidth(name string) int { + if cc, ok := x.custom(name); ok { + if cc.Width > 0 { + return cc.Width + } + // Wide enough for the header, and for a value of a few digits. + w := DisplayWidth(customLabel(name, cc)) + if w < 5 { + w = 5 + } + return w + } switch name { case "hour": // Three, not two: the Python leaves a double space after the hour. @@ -39,6 +66,9 @@ func (x ctx) colWidth(name string) int { } func (x ctx) leftAligned(name string) bool { + if _, ok := x.custom(name); ok { + return false // custom columns are numeric + } switch name { case "hour", "icon", "temp", "feels", "conditions": return true @@ -86,7 +116,12 @@ func (x ctx) table(v View) []string { headers := map[string]cell{} for _, name := range x.visible() { - headers[name] = cell{text: x.cat.Header(name), style: ""} + text := x.cat.Header(name) + if cc, ok := x.custom(name); ok { + // A user-declared label is not ours to translate. + text = customLabel(name, cc) + } + headers[name] = cell{text: text, style: ""} } out = append(out, x.c(Underline, x.rowPlain(headers))) @@ -129,6 +164,10 @@ func (x ctx) cells(r openmeteo.Row, isNow bool, prevCode *int) map[string]cell { temp, hasTemp := r.Val("temperature_2m") for _, name := range x.visible() { + if cc, ok := x.custom(name); ok { + out[name] = x.customCell(r, name, cc) + continue + } switch name { case "hour": style := Reset @@ -238,3 +277,28 @@ func abs(f float64) float64 { } return f } + +// CustomKey is where a custom column's value lives in a Row. +// +// Prefixed so a column called "temp" or "visibility" can never shadow the API +// field of the same name that a built-in column reads. +func CustomKey(name string) string { return "x:" + name } + +// customCell formats one user-declared column. +// +// A value the API did not supply renders blank rather than as zero: for an +// allergen or a soil reading, "no data" and "none" are different claims. +func (x ctx) customCell(r openmeteo.Row, name string, cc config.CustomColumn) cell { + key := cc.Field + if cc.Source == "air" { + key = CustomKey(name) + } + v, ok := r.Val(key) + if !ok { + return cell{text: "", style: Dim} + } + return cell{ + text: fmt.Sprintf("%.*f%s", cc.Decimals, v, cc.Suffix), + style: Dim, + } +} -- cgit v1.3