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
}
+144
View File
@@ -0,0 +1,144 @@
package coach
import (
"strings"
"testing"
"time"
"git.b0b.be/bdeb/workout-suggester/internal/weather"
)
// forecast builds a 24-hour forecast where every hour looks like h, then
// lets the test tweak individual hours.
func forecast(h weather.Hour, tweak func(i int, h *weather.Hour)) weather.Forecast {
start := time.Date(2026, 10, 11, 14, 0, 0, 0, time.UTC)
f := weather.Forecast{Sunset: start.Add(5 * time.Hour)}
for i := range 24 {
x := h
x.Time = start.Add(time.Duration(i) * time.Hour)
x.IsDay = i < 5
if tweak != nil {
tweak(i, &x)
}
f.Hours = append(f.Hours, x)
}
f.Now = f.Hours[0]
return f
}
var nice = weather.Hour{Temp: 15, Feels: 14, Wind: 10, Gusts: 20, Code: 1}
func TestAssess(t *testing.T) {
tests := []struct {
name string
f weather.Forecast
req Request
pick Activity
check func(t *testing.T, a Assessment)
}{
{
name: "nice weather, anything goes",
f: forecast(nice, nil),
req: Request{Intensity: Moderate, Minutes: 60},
pick: Run,
},
{
name: "wanted activity is honoured when fine",
f: forecast(nice, nil),
req: Request{Activity: Ride, Intensity: Hard, Minutes: 90},
pick: Ride,
check: func(t *testing.T, a Assessment) {
if a.TargetKm != 42 {
t.Errorf("90 min hard ride target = %v km, want 42", a.TargetKm)
}
},
},
{
name: "windy: running beats cycling",
f: forecast(weather.Hour{Temp: 12, Feels: 9, Wind: 30, Gusts: 50, Code: 3}, nil),
req: Request{Intensity: Moderate, Minutes: 45},
pick: Run,
check: func(t *testing.T, a Assessment) {
if ride, run := a.ScoreOf(Ride).Value, a.ScoreOf(Run).Value; ride >= run {
t.Errorf("ride %d should score below run %d in strong gusts", ride, run)
}
},
},
{
name: "storm sends you inside",
f: forecast(weather.Hour{Temp: 8, Feels: 3, Wind: 40, Gusts: 70, Precip: 3, PrecipProb: 95, Code: 63}, nil),
req: Request{Activity: Run, Intensity: Moderate, Minutes: 60},
pick: Indoor,
},
{
name: "thunder is a hard no",
f: forecast(weather.Hour{Temp: 22, Feels: 22, Code: 95}, nil),
req: Request{Intensity: Easy, Minutes: 30},
pick: Indoor,
},
{
name: "rain now, dry later: suggests the later window",
f: forecast(nice, func(i int, h *weather.Hour) {
if i < 2 {
h.Precip, h.PrecipProb, h.Code = 3, 90, 63
}
}),
req: Request{Activity: Ride, Intensity: Moderate, Minutes: 60},
pick: Indoor,
check: func(t *testing.T, a Assessment) {
if a.Later == nil || a.Later.Start.Hour() != 16 || a.Later.Activity != Ride {
t.Errorf("later = %+v, want a ride at 16:00", a.Later)
}
},
},
{
name: "hard intensity doesn't pick a stroll",
f: forecast(weather.Hour{Temp: 15, Feels: 14, Wind: 10, Gusts: 20, Precip: 0.6, Code: 61}, nil),
req: Request{Intensity: Hard, Minutes: 60},
pick: Run,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
a := Assess(tt.f, tt.req)
if a.Pick != tt.pick {
t.Errorf("pick = %s, want %s (scores %+v)", a.Pick, tt.pick, a.Scores)
}
if tt.check != nil {
tt.check(t, a)
}
})
}
}
func TestDarkIsMentioned(t *testing.T) {
f := forecast(nice, nil)
f.Hours = f.Hours[4:] // 18:00, the last daylight hour, then night
f.Now = f.Hours[0]
s := Assess(f, Request{Intensity: Easy, Minutes: 90}).ScoreOf(Run)
if !strings.Contains(strings.Join(s.Reasons, ","), "dark") {
t.Errorf("reasons %v should mention the dark", s.Reasons)
}
}
func TestTargetPace(t *testing.T) {
km, pace := target(Run, Moderate, 60)
if km != 10.5 || pace != "about 5:45 min/km" {
t.Errorf("got %v %q", km, pace)
}
if km, _ := target(Indoor, Hard, 60); km != 0 {
t.Errorf("indoor target = %v, want 0", km)
}
}
func TestFactsCarryTheNumbers(t *testing.T) {
f := forecast(nice, nil)
p := Plan{Place: "Gent", Forecast: f, Request: Request{Intensity: Moderate, Minutes: 60, Note: "sore knee"},
Assessment: Assess(f, Request{Intensity: Moderate, Minutes: 60}), RoutesLoaded: true}
got := p.SystemPrompt()
for _, want := range []string{"Gent", "15°C", "sore knee", "The app's pick: running", "about 10.5 km", "Routes: none found nearby"} {
if !strings.Contains(got, want) {
t.Errorf("system prompt lacks %q:\n%s", want, got)
}
}
}
+141
View File
@@ -0,0 +1,141 @@
package coach
import (
"fmt"
"strings"
"git.b0b.be/bdeb/workout-suggester/internal/routes"
"git.b0b.be/bdeb/workout-suggester/internal/weather"
)
// Plan is everything the model needs to know, gathered before it speaks.
type Plan struct {
Place string
At routes.Point // exact; only used on this machine
Forecast weather.Forecast
Request Request
Assessment Assessment
RoutesLoaded bool
Routes []routes.Suggestion
Junction *JunctionInfo
RoutesErr string
}
// JunctionInfo is the nearest node-network junction to start from.
type JunctionInfo struct {
Ref string
Kind routes.Kind
Km float64
}
// FirstQuestion is the hidden user turn that asks for the plan.
const FirstQuestion = "What should I do right now?"
// FollowUpHint is added to follow-up questions sent to the model.
const FollowUpHint = "Answer in a few short lines, not the full plan format. Only change what I asked."
const persona = `You are the coach inside "workout-suggester", an app that gets people off their screen and outside.
The app has already checked the weather and scored each activity with fixed rules. Trust the FACTS below and never invent other numbers, temperatures, times or route names.
Be warm, direct and brief: under 180 words, Markdown, no tables, no preamble.`
const firstReplyFormat = `Answer the first question in exactly this shape:
**<emoji> <the pick, e.g. "Go for a run">**: one sentence on why, citing the weather.
**When:** now, or the better window from the FACTS if there is one.
**The session:** 3–5 bullets: warm-up, a main set that matches the intensity and the minutes, cool-down. Hard = intervals or tempo; easy = conversational pace.
**Route:** recommend the first listed route by its name and say how to use it (from the door, laps, out and back); mention its climb if it has one and fits the user's note. If no routes are listed, suggest an easy out-and-back from the door.
**Wear:** one line for this weather.
**Plan B:** one line.
If the pick is an indoor workout, give a concrete HIIT, bodyweight or gym session for the minutes and intensity under **The session**, skip **Route** and **Wear**, and say under **When** whether it gets nicer outside later.`
// SystemPrompt is the persona plus the facts and the answer format.
func (p Plan) SystemPrompt() string {
return persona + "\n\n" + p.Facts() + "\n\n" + firstReplyFormat
}
// Facts is a compact, model-friendly summary of the plan.
func (p Plan) Facts() string {
var b strings.Builder
f, a, r := p.Forecast, p.Assessment, p.Request
w := func(format string, args ...any) { fmt.Fprintf(&b, format+"\n", args...) }
desc, _ := weather.Describe(f.Now.Code)
w("FACTS")
where := p.Place
if where == "" {
where = "the user's location"
}
w("- Place: %s. Local time: %s. Sunset: %s.", where, f.Now.Time.Format("Mon 15:04"), f.Sunset.Format("15:04"))
gusts := fmt.Sprintf("gusts %.0f km/h", f.Now.Gusts)
if f.GustsEstimated {
gusts = fmt.Sprintf("gusts around %.0f km/h (estimated)", f.Now.Gusts)
}
chance := ""
if f.Now.PrecipProb >= 0 {
chance = fmt.Sprintf(", rain chance %d%%", f.Now.PrecipProb)
}
w("- Weather now: %.0f°C (feels like %.0f°C), %s, wind %.0f km/h from the %s, %s, rain %.1f mm%s.",
f.Now.Temp, f.Now.Feels, desc, f.Now.Wind, weather.Compass(f.Now.WindDirection), gusts, f.Now.Precip, chance)
w("- User wants: %s, %s intensity, %d minutes.", r.Activity.Noun(), r.Intensity, r.Minutes)
if r.Note != "" {
w("- User note: %q", r.Note)
}
w("- Scores for the next %d minutes (0-100, rules-based):", r.Minutes)
for _, s := range a.Scores {
why := "good conditions"
if len(s.Reasons) > 0 {
why = strings.Join(s.Reasons, "; ")
}
w(" - %s: %d (%s): %s", s.Activity.Noun(), s.Value, s.Verdict(), why)
}
w("- The app's pick: %s.", a.Pick.Noun())
if r.Activity != Any && r.Activity != a.Pick {
w("- The user asked for %s but the weather makes it a poor choice right now; explain kindly.", r.Activity.Noun())
}
if a.Later != nil {
w("- Better window: %s scores %d from %s.", a.Later.Activity.Noun(), a.Later.Score, a.Later.Start.Format("15:04"))
} else {
w("- Better window later today: none, now is as good as it gets.")
}
if a.TargetKm > 0 {
w("- Target: about %.1f km at %s.", a.TargetKm, a.Pace)
}
if a.Pick != Indoor {
switch {
case len(p.Routes) > 0:
w("- Routes, best fit first:")
for i, s := range p.Routes {
r := s.Route
if r.Generated {
extra := fmt.Sprintf(", %d m of climbing", r.AscentM)
if r.PavedPct >= 0 {
extra += fmt.Sprintf(", %d%% paved", r.PavedPct)
}
if r.IntoWind(f.Now.WindDirection) {
extra += ", heads out into the wind so the way home has a tailwind"
}
w(" %d. %q: a loop from the user's door, %.1f km%s.", i+1, r.Name, r.LengthKm, extra)
continue
}
loop := ""
if r.Loop {
loop = ", loop"
}
w(" %d. %q: signposted route, %.1f km%s, %.1f km from the user; suggested: %s (≈%.1f km).",
i+1, r.Name, r.LengthKm, loop, s.FromYouKm, s.Describe(), s.TotalKm)
}
case p.RoutesErr != "":
w("- Routes: the map service couldn't be reached, so no routes are listed.")
default:
w("- Routes: none found nearby.")
}
if j := p.Junction; j != nil {
kind := "walking"
if j.Kind == routes.Bike {
kind = "cycling"
}
w("- Nearest %s junction (knooppunt): number %s, %.1f km away.", kind, j.Ref, j.Km)
}
}
return strings.TrimRight(b.String(), "\n")
}