feat: workout-suggester, weather-aware workouts from a local Gemma

Go + Templ + HTMX app that checks the weather (Open-Meteo, MET Norway
fallback), scores run/ride/walk with plain rules, and has a local
open-weight model (Gemma 4 E2B via any OpenAI-compatible server) write
the plan. Routes are loops from your door (BRouter) plus signposted
OpenStreetMap routes (Overpass), with GPX export.
This commit is contained in:
bdeb1337 committed 2026-10-11 19:29:58 +02:00
commit d7342d6096
42 files changed
+7219

No files matched your search

+370
View File
@@ -0,0 +1,370 @@
// Package weather fetches forecasts and place names from Open-Meteo
// (open source, no API key), with MET Norway and Nominatim as fallbacks.
// Coordinates are rounded to ~1 km before they leave the machine.
package weather
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"math"
"net/http"
"net/url"
"strconv"
"strings"
"sync"
"time"
)
// Hour is the weather for one hour of the forecast.
type Hour struct {
Time time.Time
Temp float64 // °C
Feels float64 // apparent temperature, °C
PrecipProb int // %, or -1 when the source doesn't say
Precip float64 // mm
Code int // WMO weather code
Wind float64 // km/h
Gusts float64 // km/h
WindDirection int // degrees, where the wind comes from
IsDay bool
}
// Forecast is the current weather plus the next 24 hours.
type Forecast struct {
Now Hour
Hours []Hour // starting at the current hour
Sunrise time.Time
Sunset time.Time
Source string // who made the forecast, shown in the UI
GustsEstimated bool // the source had no gusts; they're derived from wind speed
}
// Place is a geocoded location.
type Place struct {
Name string
Lat float64
Lon float64
}
// Client fetches forecasts from Open-Meteo, falling back to MET Norway when
// Open-Meteo is down, and caches them for 15 minutes.
type Client struct {
ForecastURL string // default https://api.open-meteo.com/v1/forecast
GeocodeURL string // default https://geocoding-api.open-meteo.com/v1/search
MetNoURL string // default https://api.met.no/weatherapi/locationforecast/2.0/complete; "-" disables
NominatimURL string // default https://nominatim.openstreetmap.org/search; "-" disables
HTTP *http.Client
now func() time.Time // for tests; nil means time.Now
mu sync.Mutex
cache map[string]cached
}
type cached struct {
f Forecast
at time.Time
}
const (
cacheFor = 15 * time.Minute
staleFor = 3 * time.Hour // when every source is down, an older forecast beats none
userAgent = "workout-suggester/0.1 (+https://git.b0b.be/bdeb/workout-suggester)"
)
func (c *Client) http() *http.Client {
if c.HTTP != nil {
return c.HTTP
}
return http.DefaultClient
}
func or(v, fallback string) string {
if v == "" {
return fallback
}
return v
}
// Round keeps two decimals (~1 km), which is plenty for weather.
func Round(v float64) float64 { return math.Round(v*100) / 100 }
// getJSON fetches u, retrying once when the server is busy (429/5xx).
func (c *Client) getJSON(ctx context.Context, u string, out any) error {
var err error
for attempt := range 2 {
if attempt > 0 {
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(time.Second):
}
}
var retry bool
if retry, err = c.getOnce(ctx, u, out); !retry {
return err
}
}
return err
}
func (c *Client) getOnce(ctx context.Context, u string, out any) (retry bool, err error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
if err != nil {
return false, err
}
req.Header.Set("User-Agent", userAgent) // MET Norway and Nominatim require one
req.Header.Set("Accept", "application/json")
resp, err := c.http().Do(req)
if err != nil {
return ctx.Err() == nil, err
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
msg, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
busy := resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode >= 500
return busy, fmt.Errorf("%s returned %s: %s", req.URL.Host, resp.Status, bytes.TrimSpace(msg))
}
return false, json.NewDecoder(resp.Body).Decode(out)
}
// Geocode finds a town or city by name, via Open-Meteo or else Nominatim.
func (c *Client) Geocode(ctx context.Context, name string) (Place, error) {
q := url.Values{"name": {name}, "count": {"1"}, "language": {"en"}, "format": {"json"}}
var out struct {
Results []struct {
Name string `json:"name"`
Country string `json:"country"`
Lat float64 `json:"latitude"`
Lon float64 `json:"longitude"`
} `json:"results"`
}
err := c.getJSON(ctx, or(c.GeocodeURL, "https://geocoding-api.open-meteo.com/v1/search")+"?"+q.Encode(), &out)
if err == nil {
if len(out.Results) == 0 {
return Place{}, fmt.Errorf("couldn't find a place called %q", name)
}
r := out.Results[0]
n := r.Name
if r.Country != "" {
n += ", " + r.Country
}
return Place{Name: n, Lat: r.Lat, Lon: r.Lon}, nil
}
if c.NominatimURL == "-" {
return Place{}, err
}
p, err2 := c.nominatim(ctx, name)
if err2 != nil {
return Place{}, errors.Join(err, err2)
}
return p, nil
}
func (c *Client) nominatim(ctx context.Context, name string) (Place, error) {
q := url.Values{"q": {name}, "format": {"jsonv2"}, "limit": {"1"}, "accept-language": {"en"}}
var out []struct {
Name string `json:"name"`
Display string `json:"display_name"`
Lat string `json:"lat"`
Lon string `json:"lon"`
}
if err := c.getJSON(ctx, or(c.NominatimURL, "https://nominatim.openstreetmap.org/search")+"?"+q.Encode(), &out); err != nil {
return Place{}, err
}
if len(out) == 0 {
return Place{}, fmt.Errorf("couldn't find a place called %q", name)
}
lat, err1 := strconv.ParseFloat(out[0].Lat, 64)
lon, err2 := strconv.ParseFloat(out[0].Lon, 64)
if err1 != nil || err2 != nil {
return Place{}, errors.New("nominatim: bad coordinates")
}
n := out[0].Name
if parts := strings.Split(out[0].Display, ", "); len(parts) > 1 {
n += ", " + parts[len(parts)-1] // country
}
return Place{Name: n, Lat: lat, Lon: lon}, nil
}
// Forecast returns current weather and the next 24 hours.
func (c *Client) Forecast(ctx context.Context, lat, lon float64) (Forecast, error) {
lat, lon = Round(lat), Round(lon)
key := fmt.Sprintf("%.2f,%.2f", lat, lon)
c.mu.Lock()
hit, ok := c.cache[key]
c.mu.Unlock()
if ok && time.Since(hit.at) < cacheFor {
return hit.f, nil
}
f, err := c.openMeteo(ctx, lat, lon)
if err != nil && c.MetNoURL != "-" {
var err2 error
if f, err2 = c.metNo(ctx, lat, lon); err2 != nil {
err = errors.Join(err, err2)
} else {
err = nil
}
}
if err != nil {
if ok && time.Since(hit.at) < staleFor {
hit.f.Source += fmt.Sprintf(", from %s", hit.at.Format("15:04"))
return hit.f, nil
}
return Forecast{}, err
}
c.mu.Lock()
if c.cache == nil {
c.cache = make(map[string]cached)
}
c.cache[key] = cached{f, time.Now()}
c.mu.Unlock()
return f, nil
}
func (c *Client) openMeteo(ctx context.Context, lat, lon float64) (Forecast, error) {
vars := "temperature_2m,apparent_temperature,precipitation,weather_code,wind_speed_10m,wind_gusts_10m,wind_direction_10m,is_day"
q := url.Values{
"latitude": {strconv.FormatFloat(lat, 'f', 2, 64)},
"longitude": {strconv.FormatFloat(lon, 'f', 2, 64)},
"current": {vars},
"hourly": {vars + ",precipitation_probability"},
"daily": {"sunrise,sunset"},
"forecast_hours": {"24"},
"forecast_days": {"2"},
"timezone": {"auto"},
}
var raw apiResponse
if err := c.getJSON(ctx, or(c.ForecastURL, "https://api.open-meteo.com/v1/forecast")+"?"+q.Encode(), &raw); err != nil {
return Forecast{}, err
}
return raw.forecast()
}
type apiResponse struct {
UTCOffset int `json:"utc_offset_seconds"`
Current struct {
Time string `json:"time"`
Temp float64 `json:"temperature_2m"`
Feels float64 `json:"apparent_temperature"`
Precip float64 `json:"precipitation"`
Code int `json:"weather_code"`
Wind float64 `json:"wind_speed_10m"`
Gusts float64 `json:"wind_gusts_10m"`
Dir int `json:"wind_direction_10m"`
IsDay int `json:"is_day"`
} `json:"current"`
Hourly struct {
Time []string `json:"time"`
Temp []float64 `json:"temperature_2m"`
Feels []float64 `json:"apparent_temperature"`
Precip []float64 `json:"precipitation"`
PrecipProb []int `json:"precipitation_probability"`
Code []int `json:"weather_code"`
Wind []float64 `json:"wind_speed_10m"`
Gusts []float64 `json:"wind_gusts_10m"`
Dir []int `json:"wind_direction_10m"`
IsDay []int `json:"is_day"`
} `json:"hourly"`
Daily struct {
Sunrise []string `json:"sunrise"`
Sunset []string `json:"sunset"`
} `json:"daily"`
}
func (r apiResponse) forecast() (Forecast, error) {
loc := time.FixedZone("local", r.UTCOffset)
parse := func(s string) time.Time {
t, _ := time.ParseInLocation("2006-01-02T15:04", s, loc)
return t
}
h := r.Hourly
n := len(h.Time)
if n == 0 || len(h.Temp) != n || len(h.Feels) != n || len(h.Precip) != n || len(h.PrecipProb) != n ||
len(h.Code) != n || len(h.Wind) != n || len(h.Gusts) != n || len(h.Dir) != n || len(h.IsDay) != n {
return Forecast{}, errors.New("open-meteo: incomplete hourly forecast")
}
f := Forecast{Source: "Open-Meteo", Now: Hour{
Time: parse(r.Current.Time), Temp: r.Current.Temp, Feels: r.Current.Feels,
Precip: r.Current.Precip, Code: r.Current.Code, Wind: r.Current.Wind,
Gusts: r.Current.Gusts, WindDirection: r.Current.Dir, IsDay: r.Current.IsDay == 1,
}}
for i := range n {
f.Hours = append(f.Hours, Hour{
Time: parse(h.Time[i]), Temp: h.Temp[i], Feels: h.Feels[i], PrecipProb: h.PrecipProb[i],
Precip: h.Precip[i], Code: h.Code[i], Wind: h.Wind[i], Gusts: h.Gusts[i],
WindDirection: h.Dir[i], IsDay: h.IsDay[i] == 1,
})
}
// The current hour's rain chance is the best guess for "now".
f.Now.PrecipProb = f.Hours[0].PrecipProb
// Today's sunrise/sunset, or tomorrow's once today's sun has set.
for i := range r.Daily.Sunset {
f.Sunrise, f.Sunset = parse(r.Daily.Sunrise[i]), parse(r.Daily.Sunset[i])
if f.Sunset.After(f.Now.Time) {
break
}
}
return f, nil
}
// Describe turns a WMO weather code into words and an emoji.
func Describe(code int) (string, string) {
switch {
case code == 0:
return "clear sky", "☀️"
case code == 1:
return "mostly clear", "🌤️"
case code == 2:
return "partly cloudy", "⛅"
case code == 3:
return "overcast", "☁️"
case code == 45 || code == 48:
return "fog", "🌫️"
case code >= 51 && code <= 55:
return "drizzle", "🌦️"
case code == 56 || code == 57:
return "freezing drizzle", "🧊"
case code >= 61 && code <= 65:
return "rain", "🌧️"
case code == 66 || code == 67:
return "freezing rain", "🧊"
case code >= 71 && code <= 77:
return "snow", "🌨️"
case code >= 80 && code <= 82:
return "rain showers", "🌦️"
case code == 85 || code == 86:
return "snow showers", "🌨️"
case code >= 95:
return "thunderstorm", "⛈️"
}
return "unknown", "🌡️"
}
// Icon is Describe's emoji, with a moon for clear nights.
func (h Hour) Icon() string {
_, icon := Describe(h.Code)
if !h.IsDay && h.Code <= 1 {
return "🌙"
}
return icon
}
// Chance is the rain chance as text, "–" when unknown.
func (h Hour) Chance() string {
if h.PrecipProb < 0 {
return "–"
}
return fmt.Sprintf("%d%%", h.PrecipProb)
}
// Compass turns a wind direction in degrees into N, NE, E, ...
func Compass(deg int) string {
dirs := []string{"N", "NE", "E", "SE", "S", "SW", "W", "NW"}
return dirs[((deg%360+360)%360+22)/45%8]
}