aboutsummaryrefslogtreecommitdiff
path: root/internal/render/render.go
diff options
context:
space:
mode:
authorLukasz Kasprzak <lukas@labunix.xyz>2026-08-13 13:04:10 +0200
committerLukasz Kasprzak <lukas@labunix.xyz>2026-08-13 13:04:10 +0200
commite14a7db4ffe3c4e0f15f6b37a980501a8d74d26b (patch)
tree670ef0897839871a64d3a3bb2e17e242e7d6c385 /internal/render/render.go
downloadprognosis-e14a7db4ffe3c4e0f15f6b37a980501a8d74d26b.tar.gz
prognosis-e14a7db4ffe3c4e0f15f6b37a980501a8d74d26b.zip
Initial commit: prognosis, the Go implementation
An hour-by-hour forecast for the terminal, with official IMGW warnings for Polish locations. Replaces the Python version, whose cache file format it keeps so the two can coexist until this reaches parity. Open-Meteo provides the forecast, geocoding and pollen; GUGiK turns coordinates into a TERYT powiat code; IMGW supplies the warnings, filtered to that powiat rather than the whole country. Only the two lookups that never change are cached. Forecasts never are. Silence is never allowed to read as all-clear: "no warnings in force" and "the check failed" are reported as distinct states. Place names are resolved without guessing. A name matching several places is refused with a numbered list carrying each candidate's region and coordinates, and -pick N chooses one and remembers it. A stray positional beside -l is an error, so an unquoted "Wiry, PL" cannot silently resolve to somewhere else. The cache is written one entry per line with sorted keys, and treated as disposable but not worthless: an entry that will not parse is skipped and the rest kept, and a file that will not parse at all is moved to cache.json.bad rather than overwritten. No third-party dependencies. `make ci` is the gate: gofmt clean, vet, tests.
Diffstat (limited to 'internal/render/render.go')
-rw-r--r--internal/render/render.go283
1 files changed, 283 insertions, 0 deletions
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]
+}