aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--CHANGELOG.md51
-rw-r--r--Makefile89
-rw-r--r--README.md50
-rw-r--r--cmd/prognosis/args_test.go2
-rw-r--r--cmd/prognosis/main.go94
-rw-r--r--internal/config/config.go162
-rw-r--r--internal/config/config_test.go123
-rw-r--r--internal/openmeteo/openmeteo.go49
-rw-r--r--internal/render/render.go8
-rw-r--r--internal/render/render_test.go122
-rw-r--r--internal/render/table.go66
-rw-r--r--man/prognosis.1276
-rwxr-xr-xscripts/hooks/pre-push7
13 files changed, 1011 insertions, 88 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..c797f5e
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,51 @@
+# Changelog
+
+Terse, newest first. Versions are git tags.
+
+## 0.1.1 — 2026-08-25
+
+### Added
+- Man page, `man/prognosis.1`, installed by `make install`.
+- **Custom columns.** Any field the Open-Meteo forecast or air-quality APIs
+ expose can be displayed by declaring it in the config:
+ `column.birch = air:birch_pollen`, then `columns=hour,temp,birch`. Optional
+ `label.`, `width.`, `decimals.` and `suffix.` keys. An air-quality column
+ costs one extra request, made only when one is declared.
+- `humidity` column added to the default column set.
+- `-version`, and the version is stamped into the binary at build time.
+- `-weather` (forecast only), `-no-warnings`, `-ascii` (GSM-7 safe output for
+ SMS).
+- `make lint` checks the man page renders without groff warnings; `make ci`
+ now includes it. `make release`, `make install-hooks`, and cross-compilation
+ for six platforms.
+
+### Changed
+- **Pollen selection no longer privileges grass.** A species named in `pollen=`
+ is shown even at zero; `pollen=all` shows only what is present. Previously
+ grass was special-cased and always displayed, which forced it on someone
+ allergic to birch while hiding theirs. `-pollen` sets it for one run.
+- **`~/.wegorc` is no longer read.** prognosis is standalone: set `location=`
+ in its own config, or pass `-l`. Previously it fell back to wego's config,
+ which made it useless without wego installed.
+- `make install` installs the man page too, and honours `PREFIX` and `DESTDIR`
+ for packaging.
+- Temperature colours are compared in Celsius whatever the display units, so
+ `units=imperial` no longer reports 85F as the "IMGW would warn" red.
+
+### Fixed
+- Pollen values the API withholds are absent rather than zero, so a location
+ outside Europe no longer reports a confident `grass 0.0 none`.
+- Flags after a positional argument are parsed rather than swallowed into the
+ place name.
+
+## 0.1.0 — 2026-08-14
+
+Initial release. Go implementation replacing an earlier Python one.
+
+- Hourly table, temperature chart, day summary, sun times, pollen with bands
+ sourced from Polish clinical thresholds.
+- Official IMGW warnings filtered to your powiat via GUGiK, with the failure
+ case ("could not check") kept distinct from "none in force".
+- Temperature colours anchored to IMGW's own warning criteria.
+- Configurable columns, `en`/`pl` display languages, three icon sets.
+- No API key, no third-party Go modules.
diff --git a/Makefile b/Makefile
index 40c3ad2..2acc62f 100644
--- a/Makefile
+++ b/Makefile
@@ -1,31 +1,45 @@
.POSIX:
-DESTDIR=$(HOME)
-PREFIX=/.local
-INSTALL_PATH=$(DESTDIR)$(PREFIX)/bin
-BIN=prognosis
-DIST=dist
-.PHONY: help build install uninstall test vet fmt ci cross clean
+# Standard GNU-ish install variables, so a packager can redirect everything:
+# make install PREFIX=/usr DESTDIR=/tmp/pkg
+DESTDIR ?=
+PREFIX ?= $(HOME)/.local
+BINDIR := $(DESTDIR)$(PREFIX)/bin
+MANDIR := $(DESTDIR)$(PREFIX)/share/man/man1
-help: ## show this help
- @grep -E '^[a-z-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[1m%-16s\033[0m %s\n", $$1, $$2}'
+BIN := prognosis
+DIST := dist
+MODULE := github.com/lukaszkasprzak/prognosis
+
+# Version comes from git when there is a tag, "dev" otherwise, and is stamped
+# into the binary so -version reports something meaningful.
+VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
+GOFLAGS := -trimpath
+LDFLAGS := -s -w -X main.version=$(VERSION)
+
+PLATFORMS := linux/amd64 linux/arm64 android/arm64 darwin/amd64 darwin/arm64 freebsd/amd64
-build: ## build the Go binary into ./$(BIN)
- go build -trimpath -ldflags "-s -w" -o $(BIN) ./cmd/prognosis
+.PHONY: help build install uninstall test vet fmt lint ci cross release install-hooks clean
-install: build ## build and install the Go binary
- mkdir -p $(INSTALL_PATH)
- # rm first: the target may be a symlink into this repo (see `link`), and cp
- # follows symlinks -- it would write the binary over bin/prognosis itself.
- rm -f $(INSTALL_PATH)/$(BIN)
- cp $(BIN) $(INSTALL_PATH)/$(BIN)
- chmod 755 $(INSTALL_PATH)/$(BIN)
- @echo "Installed. Config: $$($(INSTALL_PATH)/$(BIN) -config)"
+help: ## show this help
+ @grep -E '^[a-z-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | \
+ awk 'BEGIN {FS = ":.*?## "}; {printf " \033[1m%-14s\033[0m %s\n", $$1, $$2}'
+ @echo
+ @echo " version: $(VERSION) prefix: $(PREFIX)"
+build: ## build ./$(BIN)
+ go build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BIN) ./cmd/prognosis
+install: build ## install the binary and man page
+ mkdir -p $(BINDIR) $(MANDIR)
+ # rm first: the target may be a symlink, and cp follows symlinks.
+ rm -f $(BINDIR)/$(BIN)
+ install -m 755 $(BIN) $(BINDIR)/$(BIN)
+ install -m 644 man/prognosis.1 $(MANDIR)/prognosis.1
+ @echo "installed $(BINDIR)/$(BIN) and $(MANDIR)/prognosis.1"
-uninstall: ## remove the installed binary
- rm -f $(INSTALL_PATH)/$(BIN)
+uninstall: ## remove the installed binary and man page
+ rm -f $(BINDIR)/$(BIN) $(MANDIR)/prognosis.1
test: ## run the tests
go test ./...
@@ -36,21 +50,42 @@ vet: ## go vet
fmt: ## gofmt the tree
gofmt -w .
-ci: ## pre-push gate: gofmt clean, vet, tests, no third-party deps
+lint: ## check the man page renders without warnings
+ @out=$$(man --warnings -l man/prognosis.1 2>&1 >/dev/null); \
+ test -z "$$out" || { echo "man page warnings:"; echo "$$out"; exit 1; }
+ @echo "man page ok"
+
+ci: ## the gate: gofmt, vet, tests, man page, and no third-party deps
@test -z "$$(gofmt -l .)" || { echo "gofmt needed:"; gofmt -l .; exit 1; }
go vet ./...
go test ./...
- @deps=$$(go list -deps ./... | grep -E '^[a-z0-9-]+\.[a-z]+/' | grep -v '^github.com/lukaszkasprzak/prognosis' || true); \
+ @$(MAKE) --no-print-directory lint
+ @deps=$$(go list -deps ./... | grep -E '^[a-z0-9-]+\.[a-z]+/' | grep -v '^$(MODULE)' || true); \
test -z "$$deps" || { echo "third-party dependencies crept in:"; echo "$$deps"; exit 1; }
@echo "ci ok"
-cross: ## cross-compile into $(DIST)/ -- android/arm64 is the phone
- mkdir -p $(DIST)
- CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags "-s -w" -o $(DIST)/$(BIN)-linux-amd64 ./cmd/prognosis
- CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -trimpath -ldflags "-s -w" -o $(DIST)/$(BIN)-linux-arm64 ./cmd/prognosis
- CGO_ENABLED=0 GOOS=android GOARCH=arm64 go build -trimpath -ldflags "-s -w" -o $(DIST)/$(BIN)-android-arm64 ./cmd/prognosis
+cross: ## cross-compile every platform into $(DIST)/
+ @mkdir -p $(DIST)
+ @for p in $(PLATFORMS); do \
+ os=$${p%/*}; arch=$${p#*/}; \
+ echo " $$os/$$arch"; \
+ CGO_ENABLED=0 GOOS=$$os GOARCH=$$arch \
+ go build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(DIST)/$(BIN)-$$os-$$arch ./cmd/prognosis || exit 1; \
+ done
@ls -la $(DIST)
+release: ## tag a release: make release VERSION=0.2.0 (add its CHANGELOG entry first)
+ @test "$(VERSION)" != "dev" || { echo "give a version: make release VERSION=0.2.0"; exit 1; }
+ @grep -q "^## $(VERSION)" CHANGELOG.md || \
+ { echo "CHANGELOG.md has no '## $(VERSION)' entry; write it first"; exit 1; }
+ @test -z "$$(git status --porcelain)" || { echo "working tree is dirty"; exit 1; }
+ @$(MAKE) --no-print-directory ci
+ git tag -a v$(VERSION) -m "prognosis v$(VERSION)"
+ @echo "tagged v$(VERSION); push it with: git push --tags"
+
+install-hooks: ## install the pre-push hook that runs make ci
+ install -m 755 scripts/hooks/pre-push .git/hooks/pre-push
+ @echo "installed .git/hooks/pre-push"
clean: ## remove build artifacts
rm -rf $(BIN) $(DIST)
diff --git a/README.md b/README.md
index 17ad747..0e5cb46 100644
--- a/README.md
+++ b/README.md
@@ -38,7 +38,11 @@ Krakow, PL Mon 10 Aug 05:15 up 20:01 down GMT+2
make build # build ./prognosis
make install # build and install into ~/.local/bin
make cross # dist/ binaries for linux-amd64, linux-arm64, android-arm64
- make ci # gofmt, vet, tests, and the no-dependencies check
+ make ci # gofmt, vet, tests, man page, no-dependencies check
+ make install-hooks # pre-push hook that runs make ci
+
+Packagers: `make install PREFIX=/usr DESTDIR=/tmp/pkg` installs the binary and
+`man/prognosis.1`.
Needs `~/.local/bin` on `PATH`. For the phone, copy `dist/prognosis-android-arm64`
across — no interpreter, no shebang, nothing to install.
@@ -62,6 +66,43 @@ blank column.
glyphs are single-width and monochrome, so they follow the terminal palette;
emoji are colour glyphs from a fallback font and are not all one cell wide.
+### Pollen
+
+`pollen=` decides whether the allergen line appears at all and which species it
+carries:
+
+ pollen=none # no pollen line
+ pollen=all # every species that has a reading
+ pollen=birch,mugwort # exactly these, always — even at zero
+
+A species you name is shown even when it reads zero: you named it because you
+react to it, so "none today" is the answer you wanted. With `all` nobody chose,
+so absent species are dropped rather than printing a line of zeroes. `-pollen`
+does the same for one run.
+
+### Custom columns
+
+Any field the two Open-Meteo APIs expose can be displayed, whether or not
+prognosis ships with it. Declare a short name, then use it:
+
+ columns=hour,temp,birch,soil,conditions
+
+ column.birch = air:birch_pollen
+ column.soil = forecast:soil_temperature_0cm
+
+ label.birch = birch
+ decimals.birch = 1
+ suffix.soil = °
+
+`forecast` is the weather API, `air` the air-quality one that carries the
+allergens — they are separate services with separate fields, which is why the
+source is explicit. An `air` column costs one extra request, made only when one
+is declared.
+
+`label.`, `width.`, `decimals.` and `suffix.` are optional. A custom name may
+not shadow a built-in column, and a value the API does not supply renders blank
+rather than as zero: for an allergen, "no data" and "none" are different claims.
+
`display_lang=` is `en` or `pl`, covering everything prognosis writes itself —
headers, condition names, labels, dates, pollen species and bands. **IMGW
publishes its warning text in Polish only**, so that text stays Polish in either
@@ -78,8 +119,9 @@ than the original.
prognosis --no-graph # table only
prognosis --no-color # plain text
-Location comes from `location=` in `~/.wegorc`, so wego and prognosis never
-disagree about where you are. `-l` overrides it for one run.
+Location comes from `location=` in the config file; `-l` overrides it for one
+run. prognosis is standalone — it reads no other program's configuration and
+will not guess where you are.
Quote a name that contains a comma or a space. `-l Wiry, PL` is two arguments
once the shell has finished with it, and prognosis refuses it rather than
@@ -176,7 +218,7 @@ as bare numbers rather than banded on a guess.
- **Open-Meteo hourly arrays start at 00:00 local.** Slicing from the front
reports this morning, not the hours ahead. See `openmeteo.WindowStart`.
- **The table does not shrink to fit.** Column widths are fixed; the default set
- needs 34 columns. `TestTableMinimumWidthIsKnown` pins that figure. Use
+ needs 40 columns. `TestTableMinimumWidthIsKnown` pins that figure. Use
`columns=` for a narrow terminal.
- **GUGiK's default search radius is 100 m**, which finds nothing in the
mountains or deep countryside — indistinguishable from being abroad. The
diff --git a/cmd/prognosis/args_test.go b/cmd/prognosis/args_test.go
index 4a85a2d..32a4208 100644
--- a/cmd/prognosis/args_test.go
+++ b/cmd/prognosis/args_test.go
@@ -41,7 +41,7 @@ func TestPositionalsJoinIntoThePlace(t *testing.T) {
func TestNoPlaceAnywhereIsNotAnError(t *testing.T) {
got, err := placeFromArgs("", nil)
if err != nil {
- t.Fatalf("unexpected error %v: the config and ~/.wegorc are consulted next", err)
+ t.Fatalf("unexpected error %v: location= in the config is consulted next", err)
}
if got != "" {
t.Errorf("got %q, want empty", got)
diff --git a/cmd/prognosis/main.go b/cmd/prognosis/main.go
index 6e29c8e..3075563 100644
--- a/cmd/prognosis/main.go
+++ b/cmd/prognosis/main.go
@@ -3,7 +3,6 @@
package main
import (
- "bufio"
"errors"
"flag"
"fmt"
@@ -19,7 +18,9 @@ import (
"github.com/lukaszkasprzak/prognosis/internal/render"
)
-const wegorc = ".wegorc"
+// version is stamped at build time: -ldflags "-X main.version=$(git describe)".
+// "dev" means someone built it straight from a working tree.
+var version = "dev"
func main() { os.Exit(run()) }
@@ -28,12 +29,13 @@ func run() int {
configureResolver()
var (
- location = flag.String("l", "", "place to query (default: config, then ~/.wegorc)")
+ location = flag.String("l", "", "place to query (default: location= in the config file)")
hours = flag.Int("n", 0, "hours ahead to show")
days = flag.Int("d", 0, "days ahead to show, 24h each")
columns = flag.String("columns", "", "comma-separated columns to display")
icons = flag.String("icons", "", "icon set: nerd, emoji or none")
lang = flag.String("lang", "", "display language: en or pl")
+ pollen = flag.String("pollen", "", "pollen species to show: a list, or all / none")
noGraph = flag.Bool("no-graph", false, "table only, no chart")
weather = flag.Bool("weather", false, "forecast only: no sun times, summary, pollen or chart")
noWarn = flag.Bool("no-warnings", false, "omit IMGW warnings")
@@ -41,6 +43,7 @@ func run() int {
noColor = flag.Bool("no-color", false, "plain output")
pick = flag.Int("pick", 0, "choose the Nth place when the name is ambiguous")
showCfg = flag.Bool("config", false, "print the config file path and exit")
+ showVer = flag.Bool("version", false, "print the version and exit")
)
flag.Usage = usage
// Go's flag package stops at the first non-flag argument, so
@@ -59,6 +62,11 @@ func run() int {
rest = flag.Args()[1:]
}
+ if *showVer {
+ fmt.Printf("prognosis %s\n", version)
+ return 0
+ }
+
cfgPath := config.Path()
if *showCfg {
fmt.Println(cfgPath)
@@ -87,6 +95,16 @@ func run() int {
if *lang != "" {
cfg.DisplayLang = *lang
}
+ if *pollen != "" {
+ switch *pollen {
+ case "all":
+ cfg.Pollen, cfg.PollenExplicit = append([]string(nil), config.AllSpecies...), false
+ case "none":
+ cfg.Pollen, cfg.PollenExplicit = nil, false
+ default:
+ cfg.Pollen, cfg.PollenExplicit = splitList(*pollen), true
+ }
+ }
if *noGraph {
cfg.Graph = false
}
@@ -147,10 +165,9 @@ func run() int {
cfg.Location = place
}
if cfg.Location == "" {
- cfg.Location = locationFromWegorc()
- }
- if cfg.Location == "" {
- fmt.Fprintf(os.Stderr, "prognosis: no location set in %s and none in ~/%s\n", cfgPath, wegorc)
+ fmt.Fprintf(os.Stderr,
+ "prognosis: no location set. Put one in %s:\n\n location=Krakow\n\n"+
+ "or pass it for one run: prognosis -l Krakow\n", cfgPath)
return 2
}
@@ -180,6 +197,17 @@ func run() int {
fmt.Fprintf(os.Stderr, "note: %d %s %d\n", len(data.Rows), cat.Word("hours_available"), cfg.Hours)
}
+ // Custom air-quality columns need a second request, made only when the
+ // config actually declares one.
+ if fields := cfg.AirFields(); len(fields) > 0 {
+ hourly, err := openmeteo.AirHourly(geo.Lat, geo.Lon, cfg.Hours, fields)
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "note: air-quality data unavailable: %v\n", err)
+ } else {
+ mergeCustom(data.Rows, hourly, cfg)
+ }
+ }
+
view := render.View{
Label: geo.Label, TZ: data.TZ, Rows: data.Rows,
Sun: data.Sun, Daily: data.Daily,
@@ -350,33 +378,6 @@ func parseCoords(s string) (float64, float64, bool) {
return a, b, true
}
-// locationFromWegorc reads location= from wego's config, so the two tools never
-// disagree about where you are.
-func locationFromWegorc() string {
- home, err := os.UserHomeDir()
- if err != nil {
- return ""
- }
- f, err := os.Open(home + "/" + wegorc)
- if err != nil {
- return ""
- }
- defer f.Close()
- sc := bufio.NewScanner(f)
- for sc.Scan() {
- line := strings.TrimSpace(sc.Text())
- if line == "" || strings.HasPrefix(line, "#") {
- continue
- }
- if k, v, ok := strings.Cut(line, "="); ok && strings.TrimSpace(k) == "location" {
- if v = strings.TrimSpace(v); v != "" {
- return v
- }
- }
- }
- return ""
-}
-
func shouldColour(mode string) bool {
switch mode {
case "never":
@@ -424,18 +425,41 @@ func usage() {
usage: prognosis [flags] [place]
flags:
- -l PLACE place to query (default: config, then ~/.wegorc)
+ -l PLACE place to query (default: location= in the config file)
-pick N choose the Nth place when the name matches several
-n N hours ahead to show
-d N days ahead to show, 24h each (max %d)
-columns LIST comma-separated columns; valid: %s
-icons SET nerd, emoji or none
-lang LANG en or pl
+ -pollen LIST pollen species to show: a list, or all / none
-no-graph table only
-weather forecast only: no sun times, summary, pollen or chart
-no-warnings omit IMGW warnings
-ascii ASCII only, so an SMS stays in GSM-7 (160 chars, not 70)
-no-color plain output
-config print the config file path and exit
+ -version print the version and exit
`, openmeteo.MaxForecastDays-1, strings.Join(config.ValidColumns(), ", "))
}
+
+// mergeCustom copies air-quality values onto the matching forecast hour.
+//
+// Custom values are stored under the column's own name, prefixed, so they can
+// never collide with an API field name already in the row.
+func mergeCustom(rows []openmeteo.Row, hourly map[string]map[string]float64, cfg config.Config) {
+ for i, r := range rows {
+ byField, ok := hourly[r.When.Format("2006-01-02T15:04")]
+ if !ok {
+ continue
+ }
+ for name, cc := range cfg.Custom {
+ if cc.Source != "air" {
+ continue
+ }
+ if v, ok := byField[cc.Field]; ok {
+ rows[i].Vals[render.CustomKey(name)] = v
+ }
+ }
+ }
+}
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,
+ }
+}
diff --git a/man/prognosis.1 b/man/prognosis.1
new file mode 100644
index 0000000..8c28b60
--- /dev/null
+++ b/man/prognosis.1
@@ -0,0 +1,276 @@
+.TH PROGNOSIS 1 "2026-08-21" "prognosis" "User Commands"
+.SH NAME
+prognosis \- hour-by-hour terminal forecast with official Polish warnings
+.SH SYNOPSIS
+.B prognosis
+.RI [ options ]
+.RI [ place ]
+.SH DESCRIPTION
+.B prognosis
+prints an hourly weather table, a temperature chart, and \(em for locations in
+Poland \(em the meteorological warnings IMGW has issued for that powiat.
+.PP
+It needs no API key and has no third-party dependencies. Which columns appear,
+in what order, and in which language is set by the configuration file; columns
+the program does not ship with can be declared there too, so any field the
+underlying APIs expose can be displayed. See
+.B CUSTOM COLUMNS .
+.PP
+Output is pipe-safe: colour is switched off when standard output is not a
+terminal, notes and diagnostics go to standard error, and
+.B \-ascii
+restricts output to ASCII for onward transmission by SMS.
+.SH OPTIONS
+.TP
+.BI \-l " PLACE"
+Place to query: a name, or
+.IR lat , lon .
+Overrides
+.B location
+in the configuration file for one run.
+.TP
+.BI \-n " N"
+Show
+.I N
+hours ahead.
+.TP
+.BI \-d " N"
+Show
+.I N
+days ahead, of 24 hours each. Mutually exclusive with
+.BR \-n .
+.TP
+.BI \-pick " N"
+Choose the
+.IR N th
+candidate for an ambiguous place name, and remember it. Re-resolves rather than
+reading the cache, which is how a wrongly cached name is corrected.
+.TP
+.BI \-columns " LIST"
+Comma-separated columns to display, overriding the configuration file.
+.TP
+.BI \-icons " SET"
+Glyph set for the
+.B icon
+column:
+.BR nerd ,
+.BR emoji ,
+or
+.BR none .
+.TP
+.BI \-pollen " LIST"
+Allergens to show for one run: a comma-separated list, or
+.B all
+or
+.BR none .
+.TP
+.BI \-lang " LANG"
+Display language:
+.B en
+or
+.BR pl .
+.TP
+.B \-weather
+Forecast only: omit sun times, the day summary, pollen and the chart.
+.TP
+.B \-no\-warnings
+Omit IMGW warnings.
+.TP
+.B \-no\-graph
+Table only, no chart.
+.TP
+.B \-no\-color
+Plain output, no escape sequences.
+.TP
+.B \-ascii
+Restrict output to ASCII. Intended for SMS, where a single non-ASCII character
+forces the message from GSM\-7 (160 characters per segment) into UCS\-2 (70).
+The degree sign becomes the unit letter and Polish diacritics are transliterated.
+.TP
+.B \-config
+Print the path of the configuration file and exit.
+.TP
+.B \-version
+Print the version and exit.
+.SH CONFIGURATION
+The configuration file is
+.I $XDG_CONFIG_HOME/prognosis/config
+(by default
+.IR ~/.config/prognosis/config ).
+It is written with commented defaults on first run. The format is one
+.I KEY=VALUE
+per line;
+.B #
+begins a comment; values are not quoted.
+.PP
+Command line flags override the file, and the file overrides the built-in
+defaults.
+.TP
+.B location
+Place to query. Required: prognosis has no other way to know where you are and
+will not guess.
+.TP
+.B hours
+Default span in hours.
+.TP
+.B units
+.BR metric ", " imperial " or " si .
+Passed to the provider, so rounding is theirs. Warning thresholds are always
+compared in Celsius, whatever the display units.
+.TP
+.B columns
+Columns to display, in order.
+.TP
+.B icons
+.BR nerd ", " emoji " or " none .
+.TP
+.B graph ", " graph_height
+Whether to draw the temperature chart, and over how many rows.
+.TP
+.B warnings
+Whether to check IMGW for warnings.
+.TP
+.B pollen
+Which allergens to report, or whether to report any:
+.B none
+omits the line entirely,
+.B all
+shows every species that has a reading, and a comma-separated list shows exactly
+those species \(em even at zero, since a species you named is one you react to
+and "none today" is the answer you wanted. With
+.B all
+nobody chose, so absent species are dropped rather than printing a line of
+zeroes. Species:
+.BR grass ", " birch ", " alder ", " mugwort ", " ragweed ", " olive .
+.TP
+.B color
+.BR auto ", " always " or " never .
+.TP
+.B display_lang
+.BR en " or " pl .
+Covers everything prognosis writes itself. IMGW publishes its warning text in
+Polish only, so that text remains Polish in either language.
+.TP
+.B ascii
+Restrict output to ASCII, as
+.BR \-ascii .
+.SH COLUMNS
+Built-in columns:
+.BR hour ", " icon ", " temp ", " feels ", " conditions ", " mm ", " rain ", "
+.BR wind ", " gusts ", " dir ", " humidity ", " dew ", " uv ", " cloud ", "
+.BR pressure ", " visibility .
+.PP
+Only the fields the selected columns need are requested, so a narrow table
+costs a smaller response. An unknown column name is an error at startup naming
+the offender, never a silently blank column.
+.PP
+.B mm
+and
+.B rain
+are hidden automatically when the window is dry and no hour reaches a 20%
+chance of precipitation, and the day summary says
+.I dry
+instead.
+.SH CUSTOM COLUMNS
+Any field the two Open-Meteo APIs expose can be displayed, whether or not
+prognosis knows about it. Declare a short name, then use it in
+.BR columns .
+.PP
+.in +4n
+.EX
+columns=hour,temp,birch,soil,conditions
+
+column.birch = air:birch_pollen
+column.soil = forecast:soil_temperature_0cm
+
+label.soil = soil
+suffix.soil = \(de
+decimals.soil = 0
+label.birch = birch
+decimals.birch = 1
+.EE
+.in
+.PP
+.B column.\fINAME\fB = \fISOURCE\fB:\fIFIELD\fR
+is the declaration.
+.I SOURCE
+is
+.B forecast
+(the weather API) or
+.B air
+(the air-quality API, which carries the allergens); they are separate services
+with separate field sets, which is why the source must be given. An
+.B air
+column costs one extra request, made only when such a column is declared.
+.PP
+The remaining keys are optional:
+.B label.\fINAME\fR
+sets the header (default: the name),
+.B width.\fINAME\fR
+the column width (default: derived from the label),
+.B decimals.\fINAME\fR
+the digits after the point (default: 0), and
+.B suffix.\fINAME\fR
+a string appended to each value.
+.PP
+A custom name may not shadow a built-in column. A value the API does not supply
+renders blank rather than as zero: for an allergen, "no data" and "none" are
+different claims.
+.SH WARNINGS
+IMGW publishes every warning in Poland, each tagged with the TERYT codes of the
+powiats it covers. The coordinates are resolved to that code through GUGiK, so
+warnings are filtered to your area rather than the whole country.
+.PP
+Four states are kept deliberately distinct, because silence must never be
+mistaken for an all-clear:
+.TP
+warnings printed
+In force for your powiat.
+.TP
+nothing printed
+Checked; none in force.
+.TP
+.I warnings: could not check IMGW
+The check itself failed.
+.TP
+.I warnings: IMGW covers Poland only
+The location is outside Poland.
+.SH FILES
+.TP
+.I ~/.config/prognosis/config
+Configuration.
+.TP
+.I ~/.cache/prognosis/cache.json
+Cached geocoding and TERYT lookups, one entry per line. Disposable: an entry
+that will not parse is skipped and the rest kept; a file that will not parse at
+all is moved aside to
+.I cache.json.bad
+rather than overwritten.
+.SH EXIT STATUS
+.TP
+.B 0
+Success.
+.TP
+.B 1
+The forecast could not be fetched.
+.TP
+.B 2
+Usage error: bad flags, an invalid configuration, no location set, or an
+ambiguous place name.
+.SH EXAMPLES
+.TP
+.B prognosis
+The next twelve hours where you live.
+.TP
+.B prognosis \-l krakow \-d 3
+Three days for another place.
+.TP
+.B prognosis \-weather \-ascii \-no\-warnings \-n 6
+A short, ASCII-only forecast suitable for sending by SMS.
+.SH SEE ALSO
+.BR wego (1)
+.PP
+Data from Open-Meteo (https://open-meteo.com/), GUGiK
+(https://services.gugik.gov.pl/) and IMGW (https://danepubliczne.imgw.pl/).
+.SH AUTHOR
+Lukasz Kasprzak.
diff --git a/scripts/hooks/pre-push b/scripts/hooks/pre-push
new file mode 100755
index 0000000..83b2043
--- /dev/null
+++ b/scripts/hooks/pre-push
@@ -0,0 +1,7 @@
+#!/bin/sh
+# Refuse to push anything that would not survive `make ci`.
+#
+# git never clones hooks, so on a fresh checkout this must be installed with
+# `make install-hooks`.
+set -e
+exec make ci