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/config/config.go | 162 ++++++++++++++++++++++++++++++++++++---- internal/config/config_test.go | 123 ++++++++++++++++++++++++++++++ internal/openmeteo/openmeteo.go | 49 ++++++++++++ internal/render/render.go | 8 +- internal/render/render_test.go | 122 +++++++++++++++++++++++++++++- internal/render/table.go | 66 +++++++++++++++- 6 files changed, 509 insertions(+), 21 deletions(-) (limited to 'internal') diff --git a/internal/config/config.go b/internal/config/config.go index 51d8c27..b2b96cf 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -1,8 +1,8 @@ // 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. +// The format is one KEY=VALUE per line, '#' starts a comment, and values are +// never quoted. Parsing it here rather than pulling in a config library keeps +// the binary dependency-free. package config import ( @@ -37,6 +37,22 @@ var columnFields = map[string]string{ "visibility": "visibility", } +// Sources a custom column can draw from. They are separate Open-Meteo APIs with +// different field sets, so a column has to say which one it means. +var validSources = map[string]bool{"forecast": true, "air": true} + +// CustomColumn is a column defined in the config rather than built in, so a user +// can display a field prognosis never anticipated -- an allergen, soil +// temperature, anything the two APIs expose. +type CustomColumn struct { + Source string // "forecast" or "air" + Field string // the API field name, verbatim + Label string // header text; defaults to the column name + Width int // 0 means derive it from the label + Decimals int // digits after the point + Suffix string // appended to the value, e.g. "°" or "%" +} + var ( validIcons = map[string]bool{"nerd": true, "emoji": true, "none": true} validColors = map[string]bool{"auto": true, "always": true, "never": true} @@ -67,6 +83,16 @@ type Config struct { // key, because it describes one invocation rather than a preference. Minimal bool + // PollenExplicit records that the user named the species rather than asking + // for "all". A named species is shown even at zero -- you asked for it, so + // its absence is information -- while "all" shows only what is present, or + // the line would be six zeros of noise. + PollenExplicit bool + + // Custom holds columns declared in the config, keyed by the short name used + // in Columns. + Custom map[string]CustomColumn + // 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. @@ -79,7 +105,7 @@ func Default() Config { return Config{ Hours: 12, Units: "metric", - Columns: []string{"hour", "temp", "feels", "conditions", "mm", "rain"}, + Columns: []string{"hour", "temp", "feels", "conditions", "humidity", "mm", "rain"}, Icons: "nerd", Graph: true, GraphHeight: 5, @@ -87,6 +113,7 @@ func Default() Config { Pollen: append([]string(nil), AllSpecies...), Color: "auto", DisplayLang: "en", + Custom: map[string]CustomColumn{}, } } @@ -108,13 +135,32 @@ func ValidColumns() []string { return names } -// Fields returns the Open-Meteo hourly fields the selected columns need. +// Fields returns the forecast-API 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 { + return c.fieldsFor("forecast") +} + +// AirFields returns the air-quality-API hourly fields custom columns need. It is +// empty unless the config declares one, so the extra request is only made when +// something actually needs it. +func (c Config) AirFields() []string { + return c.fieldsFor("air") +} + +func (c Config) fieldsFor(source string) []string { seen := map[string]bool{} var out []string for _, col := range c.Columns { - f := columnFields[col] + var f string + if cc, ok := c.Custom[col]; ok { + if cc.Source != source { + continue + } + f = cc.Field + } else if source == "forecast" { + f = columnFields[col] + } if f == "" || seen[f] { continue } @@ -138,11 +184,24 @@ func (c Config) Has(column string) bool { // 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 name, cc := range c.Custom { + if _, clash := columnFields[name]; clash { + return fmt.Errorf("column.%s: %q is a built-in column; pick another name", name, name) + } + if cc.Source == "" || cc.Field == "" { + return fmt.Errorf("%q has label/width/decimals but no column.%s = source:field", + name, name) + } + } for _, col := range c.Columns { - if _, ok := columnFields[col]; !ok { - return fmt.Errorf("unknown column %q; valid: %s", - col, strings.Join(ValidColumns(), ", ")) + if _, ok := columnFields[col]; ok { + continue } + if _, ok := c.Custom[col]; ok { + continue + } + return fmt.Errorf("unknown column %q; valid: %s (or declare it: column.%s = air:FIELD)", + col, strings.Join(ValidColumns(), ", "), col) } if !validIcons[c.Icons] { return fmt.Errorf("unknown icons %q; valid: emoji, nerd, none", c.Icons) @@ -214,7 +273,64 @@ func Load(path string) (Config, error) { return cfg, sc.Err() } +// customKey splits "label.birch" into ("label", "birch"). +func customKey(key string) (attr, name string, ok bool) { + attr, name, ok = strings.Cut(key, ".") + if !ok || name == "" { + return "", "", false + } + switch attr { + case "column", "label", "width", "decimals", "suffix": + return attr, name, true + } + return "", "", false +} + +func (c *Config) setCustom(attr, name, value string) error { + if c.Custom == nil { + c.Custom = map[string]CustomColumn{} + } + cc := c.Custom[name] + switch attr { + case "column": + src, field, ok := strings.Cut(value, ":") + if !ok { + return fmt.Errorf("column.%s: expected source:field, got %q; sources: air, forecast", + name, value) + } + src, field = strings.TrimSpace(src), strings.TrimSpace(field) + if !validSources[src] { + return fmt.Errorf("column.%s: unknown source %q; valid: air, forecast", name, src) + } + if field == "" { + return fmt.Errorf("column.%s: no field given after %q:", name, src) + } + cc.Source, cc.Field = src, field + case "label": + cc.Label = value + case "suffix": + cc.Suffix = value + case "width": + n, err := strconv.Atoi(value) + if err != nil || n < 1 { + return fmt.Errorf("width.%s: %q is not a positive number", name, value) + } + cc.Width = n + case "decimals": + n, err := strconv.Atoi(value) + if err != nil || n < 0 || n > 6 { + return fmt.Errorf("decimals.%s: %q is not a number between 0 and 6", name, value) + } + cc.Decimals = n + } + c.Custom[name] = cc + return nil +} + func (c *Config) set(key, value string) error { + if attr, name, ok := customKey(key); ok { + return c.setCustom(attr, name, value) + } switch key { case "location": c.Location = value @@ -237,11 +353,11 @@ func (c *Config) set(key, value string) error { case "pollen": switch value { case "all": - c.Pollen = append([]string(nil), AllSpecies...) + c.Pollen, c.PollenExplicit = append([]string(nil), AllSpecies...), false case "none": - c.Pollen = nil + c.Pollen, c.PollenExplicit = nil, false default: - c.Pollen = splitList(value) + c.Pollen, c.PollenExplicit = splitList(value), true } case "hours": n, err := strconv.Atoi(value) @@ -298,8 +414,8 @@ 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. +# Place to query: a name, or "lat,lon". Required -- prognosis has no other way +# to know where you are, and will not guess. location=%s # Default span in hours. -n and -d override it. @@ -324,7 +440,11 @@ graph_height=%d # Official IMGW warnings for your powiat (Poland only). warnings=%t -# Pollen species to report, or "all" / "none". +# Which allergens to report, or whether to report any at all. +# none no pollen line +# all every species that has a reading +# birch,mugwort exactly these, always -- even at zero, because a species +# you name is one you react to pollen=%s # Restrict output to ASCII: no degree sign, no diacritics, no block drawing. @@ -334,6 +454,18 @@ ascii=%t # auto (colour when stdout is a terminal) | always | never color=%s +# Columns prognosis does not ship with. Declare a short name against a source +# and a field, then put the name in columns= above. "forecast" is the weather +# API, "air" the air-quality one that carries the allergens; an air column costs +# one extra request, made only when you declare one. See prognosis(1). +# +# column.birch = air:birch_pollen +# column.soil = forecast:soil_temperature_0cm +# label.birch = birch # header; defaults to the name +# width.birch = 6 # defaults to fit the label +# decimals.birch = 1 # digits after the point, default 0 +# suffix.soil = C # appended to the value + # 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. diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 06c0234..46a8635 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -156,3 +156,126 @@ func TestWriteDefaultRoundTrips(t *testing.T) { t.Fatalf("the file we generate must validate: %v", err) } } + +func TestCustomColumnDeclaration(t *testing.T) { + cfg, err := Load(write(t, `columns=hour,temp,birch +column.birch = air:birch_pollen +label.birch = brzoza +width.birch = 7 +decimals.birch = 2 +suffix.birch = g +`)) + if err != nil { + t.Fatal(err) + } + cc, ok := cfg.Custom["birch"] + if !ok { + t.Fatal("birch was not declared") + } + if cc.Source != "air" || cc.Field != "birch_pollen" { + t.Errorf("source/field = %q/%q", cc.Source, cc.Field) + } + if cc.Label != "brzoza" || cc.Width != 7 || cc.Decimals != 2 || cc.Suffix != "g" { + t.Errorf("attributes not parsed: %+v", cc) + } + if err := cfg.Validate(); err != nil { + t.Fatalf("a complete declaration must validate: %v", err) + } +} + +// Only the fields a selected column needs, split by which API serves them. +func TestCustomColumnsSplitFieldsByApi(t *testing.T) { + cfg, err := Load(write(t, `columns=hour,temp,birch,soil +column.birch = air:birch_pollen +column.soil = forecast:soil_temperature_0cm +`)) + if err != nil { + t.Fatal(err) + } + fc := strings.Join(cfg.Fields(), ",") + if !strings.Contains(fc, "soil_temperature_0cm") || !strings.Contains(fc, "temperature_2m") { + t.Errorf("forecast fields = %q", fc) + } + if strings.Contains(fc, "birch_pollen") { + t.Errorf("an air field must not be asked of the forecast API: %q", fc) + } + if air := strings.Join(cfg.AirFields(), ","); air != "birch_pollen" { + t.Errorf("air fields = %q, want birch_pollen", air) + } +} + +// No custom air column means no second request at all. +func TestNoAirFieldsWhenNoneDeclared(t *testing.T) { + if got := Default().AirFields(); len(got) != 0 { + t.Fatalf("AirFields() = %v, want empty", got) + } +} + +func TestCustomColumnErrors(t *testing.T) { + for name, body := range map[string]string{ + "no source": "column.x = birch_pollen\ncolumns=hour,x\n", + "unknown source": "column.x = weather:birch_pollen\ncolumns=hour,x\n", + "empty field": "column.x = air:\ncolumns=hour,x\n", + "bad width": "column.x = air:f\nwidth.x = wide\n", + "bad decimals": "column.x = air:f\ndecimals.x = 9\n", + } { + t.Run(name, func(t *testing.T) { + if _, err := Load(write(t, body)); err == nil { + t.Fatalf("expected an error for %q", body) + } + }) + } +} + +// Attributes without a declaration are a typo, not a silent no-op. +func TestAttributesWithoutDeclarationAreRejected(t *testing.T) { + cfg, err := Load(write(t, "label.birch = brzoza\n")) + if err != nil { + t.Fatal(err) + } + if err := cfg.Validate(); err == nil { + t.Fatal("label.birch without column.birch must be an error") + } +} + +// Shadowing a built-in would make which column you get depend on lookup order. +func TestCustomColumnCannotShadowABuiltIn(t *testing.T) { + cfg, err := Load(write(t, "column.temp = air:birch_pollen\n")) + if err != nil { + t.Fatal(err) + } + err = cfg.Validate() + if err == nil || !strings.Contains(err.Error(), "built-in") { + t.Fatalf("expected a built-in clash error, got %v", err) + } +} + +// An undeclared column name should say how to declare it. +func TestUnknownColumnSuggestsDeclaringIt(t *testing.T) { + cfg := Default() + cfg.Columns = []string{"hour", "birch"} + err := cfg.Validate() + if err == nil || !strings.Contains(err.Error(), "column.birch") { + t.Fatalf("error should show how to declare it, got %v", err) + } +} + +func TestPollenExplicitTracksWhoChose(t *testing.T) { + cases := map[string]bool{ + "pollen=grass,birch\n": true, + "pollen=all\n": false, + "pollen=none\n": false, + } + for body, want := range cases { + cfg, err := Load(write(t, body)) + if err != nil { + t.Fatal(err) + } + if cfg.PollenExplicit != want { + t.Errorf("%q gave PollenExplicit=%v, want %v", body, cfg.PollenExplicit, want) + } + } + if Default().PollenExplicit { + t.Error("the default is not an explicit choice") + } +} diff --git a/internal/openmeteo/openmeteo.go b/internal/openmeteo/openmeteo.go index b5220f4..b1596e0 100644 --- a/internal/openmeteo/openmeteo.go +++ b/internal/openmeteo/openmeteo.go @@ -383,3 +383,52 @@ func presentSlice(v any) []bool { } return out } + +// AirHourly fetches per-hour values for air-quality fields, keyed by the local +// timestamp the API reports ("2006-01-02T15:04") and then by field. +// +// Keyed by time rather than by index because this is a different endpoint from +// the forecast: nothing guarantees the two arrays start at the same hour, and +// merging by position would silently shift a column by an hour. +// +// A field the API withholds is absent rather than zero, so a column shows blank +// instead of a confident wrong number. +func AirHourly(lat, lon float64, hours int, fields []string) (map[string]map[string]float64, error) { + out := map[string]map[string]float64{} + if len(fields) == 0 { + return out, nil + } + days := hours/24 + 2 + if days > MaxAirDays { + days = MaxAirDays + } + var r struct { + 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"]) + for _, f := range fields { + vals := floatSlice(r.Hourly[f]) + have := presentSlice(r.Hourly[f]) + for i, t := range times { + if i >= len(vals) || (i < len(have) && !have[i]) { + continue + } + if out[t] == nil { + out[t] = map[string]float64{} + } + out[t][f] = vals[i] + } + } + return out, nil +} 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