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

+324
View File
@@ -0,0 +1,324 @@
// Package coach decides which workouts the weather allows. The scoring is
// plain rules so the numbers are always right; the language model only
// explains the result and fills in the workout itself.
package coach
import (
"fmt"
"math"
"slices"
"time"
"git.b0b.be/bdeb/workout-suggester/internal/routes"
"git.b0b.be/bdeb/workout-suggester/internal/weather"
)
// Activity is something to do.
type Activity string
const (
Any Activity = ""
Run Activity = "run"
Ride Activity = "ride"
Walk Activity = "walk"
Indoor Activity = "indoor"
)
// Outdoor lists the activities we score against the weather.
var Outdoor = []Activity{Run, Ride, Walk}
// Label is a human name with an emoji.
func (a Activity) Label() string {
switch a {
case Run:
return "🏃 Run"
case Ride:
return "🚴 Ride"
case Walk:
return "🥾 Walk / hike"
case Indoor:
return "🏠 Indoor workout"
}
return "✨ Surprise me"
}
// Noun is the activity as a word for prompts.
func (a Activity) Noun() string {
switch a {
case Run:
return "running"
case Ride:
return "cycling"
case Walk:
return "walking/hiking"
case Indoor:
return "an indoor workout (HIIT, bodyweight or gym)"
}
return "anything"
}
// RouteKind is the kind of signposted route that suits the activity.
func (a Activity) RouteKind() routes.Kind {
if a == Ride {
return routes.Bike
}
return routes.Foot
}
// Intensity is how hard the user wants to go.
type Intensity string
const (
Easy Intensity = "easy"
Moderate Intensity = "moderate"
Hard Intensity = "hard"
)
// Request is what the user asked for.
type Request struct {
Activity Activity
Intensity Intensity
Minutes int
Note string
}
// Score is how good the weather is for an activity, 0–100, with the reasons
// points were taken off.
type Score struct {
Activity Activity
Value int
Reasons []string
}
// Verdict buckets a score.
func (s Score) Verdict() string {
switch {
case s.Value >= 75:
return "great"
case s.Value >= 50:
return "fine"
case s.Value >= 30:
return "meh"
}
return "no"
}
// Later is a noticeably better window later today.
type Later struct {
Start time.Time
Activity Activity
Score int
}
// Assessment is the full verdict for a request.
type Assessment struct {
Scores []Score // now, one per outdoor activity
Pick Activity
Later *Later
TargetKm float64 // 0 for indoor
Pace string
}
// IndoorBelow is the best outdoor score under which we send people inside.
const IndoorBelow = 40
// Assess scores every outdoor activity for the user's time window and picks one.
func Assess(f weather.Forecast, req Request) Assessment {
n := windowHours(f, req.Minutes)
now := f.Hours[:min(n, len(f.Hours))]
var a Assessment
for _, act := range Outdoor {
a.Scores = append(a.Scores, score(act, now))
}
a.Pick = pick(a.Scores, req)
a.Later = later(f, req, n, a.Scores)
a.TargetKm, a.Pace = target(a.Pick, req.Intensity, req.Minutes)
return a
}
// ScoreOf returns the score for one activity.
func (a Assessment) ScoreOf(act Activity) Score {
for _, s := range a.Scores {
if s.Activity == act {
return s
}
}
return Score{Activity: act}
}
func windowHours(f weather.Forecast, minutes int) int {
return max(1, int(math.Ceil(float64(f.Now.Time.Minute()+minutes)/60)))
}
func pick(scores []Score, req Request) Activity {
if req.Activity == Indoor {
return Indoor
}
byAct := map[Activity]int{}
for _, s := range scores {
byAct[s.Activity] = s.Value
}
if req.Activity != Any && byAct[req.Activity] >= 50 {
return req.Activity
}
best, bestV := Indoor, -1
for _, act := range Outdoor {
v := byAct[act]
if act == Walk { // a stroll is an easy workout
v += map[Intensity]int{Easy: 5, Moderate: -10, Hard: -20}[req.Intensity]
}
if v > bestV {
best, bestV = act, v
}
}
if byAct[best] < IndoorBelow {
return Indoor
}
return best
}
// later looks up to 12 hours ahead for a daylight window that is clearly
// better than now for the picked (or wanted) activity.
func later(f weather.Forecast, req Request, n int, nowScores []Score) *Later {
acts := Outdoor
if req.Activity != Any && req.Activity != Indoor {
acts = []Activity{req.Activity}
}
bestNow := 0
for _, s := range nowScores {
if slices.Contains(acts, s.Activity) {
bestNow = max(bestNow, s.Value)
}
}
if bestNow >= 75 {
return nil
}
for start := 1; start <= 12 && start+n <= len(f.Hours); start++ {
win := f.Hours[start : start+n]
if !win[0].IsDay {
continue
}
for _, act := range acts {
s := score(act, win)
if s.Value >= 60 && s.Value >= bestNow+20 {
return &Later{Start: win[0].Time, Activity: act, Score: s.Value}
}
}
}
return nil
}
// score applies simple, explainable rules to the worst hour in the window.
func score(act Activity, hours []weather.Hour) Score {
var (
maxPrecip, maxGust, maxWind float64
minFeels, maxFeels = math.Inf(1), math.Inf(-1)
maxProb int
thunder, ice, snow, fog bool
dark bool
)
for _, h := range hours {
maxPrecip = max(maxPrecip, h.Precip)
maxGust = max(maxGust, h.Gusts)
maxWind = max(maxWind, h.Wind)
minFeels = min(minFeels, h.Feels)
maxFeels = max(maxFeels, h.Feels)
maxProb = max(maxProb, h.PrecipProb)
thunder = thunder || h.Code >= 95
ice = ice || h.Code == 56 || h.Code == 57 || h.Code == 66 || h.Code == 67
snow = snow || (h.Code >= 71 && h.Code <= 77) || h.Code == 85 || h.Code == 86
fog = fog || h.Code == 45 || h.Code == 48
dark = dark || !h.IsDay
}
s := Score{Activity: act, Value: 100}
take := func(points int, why string) {
if points > 0 {
s.Value -= points
s.Reasons = append(s.Reasons, why)
}
}
// per activity: ride, run, walk
by := func(ride, run, walk int) int {
switch act {
case Ride:
return ride
case Run:
return run
}
return walk
}
if thunder {
take(100, "thunderstorms: stay off open ground")
}
switch {
case maxPrecip >= 2:
take(by(70, 65, 65), fmt.Sprintf("heavy rain (up to %.1f mm/h)", maxPrecip))
case maxPrecip >= 0.5:
take(by(40, 30, 30), fmt.Sprintf("rain (up to %.1f mm/h)", maxPrecip))
case maxPrecip >= 0.1:
take(by(15, 5, 10), "light drizzle")
case maxProb >= 60:
take(10, fmt.Sprintf("%d%% chance of rain", maxProb))
}
switch {
case maxGust >= 60:
take(by(70, 40, 35), fmt.Sprintf("storm gusts up to %.0f km/h", maxGust))
case maxGust >= 45:
take(by(40, 15, 10), fmt.Sprintf("strong gusts up to %.0f km/h", maxGust))
case maxGust >= 35:
take(by(20, 5, 0), fmt.Sprintf("gusty, up to %.0f km/h", maxGust))
}
if maxWind >= 25 {
take(by(15, 5, 0), fmt.Sprintf("steady %.0f km/h wind", maxWind))
}
switch {
case minFeels < -5:
take(by(50, 30, 35), fmt.Sprintf("bitter cold (feels like %.0f°C)", minFeels))
case minFeels < 2:
take(by(20, 5, 10), fmt.Sprintf("cold (feels like %.0f°C)", minFeels))
}
switch {
case maxFeels > 30:
take(by(15, 40, 20), fmt.Sprintf("hot (feels like %.0f°C)", maxFeels))
case maxFeels > 26:
take(by(5, 15, 5), fmt.Sprintf("warm (feels like %.0f°C)", maxFeels))
}
if ice {
take(by(60, 40, 30), "freezing rain: slippery")
} else if snow {
take(by(50, 20, 10), "snow")
}
if fog {
take(by(15, 5, 5), "fog: low visibility")
}
if dark {
take(by(10, 5, 10), "dark: bring lights and something reflective")
}
s.Value = max(0, s.Value)
return s
}
// target turns time and intensity into a distance at a sensible pace.
func target(act Activity, in Intensity, minutes int) (km float64, pace string) {
i := map[Intensity]int{Easy: 0, Moderate: 1, Hard: 2}[in]
h := float64(minutes) / 60
switch act {
case Run:
minPerKm := []float64{6.5, 5.75, 5.0}[i]
km = float64(minutes) / minPerKm
pace = fmt.Sprintf("about %d:%02d min/km", int(minPerKm), int(math.Round((minPerKm-math.Floor(minPerKm))*60)))
case Ride:
kmh := []float64{18, 24, 28}[i]
km = h * kmh
pace = fmt.Sprintf("about %.0f km/h", kmh)
case Walk:
kmh := []float64{4.5, 5, 5.5}[i]
km = h * kmh
pace = fmt.Sprintf("about %.1f km/h", kmh)
default:
return 0, ""
}
return math.Round(km*2) / 2, pace
}