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

+160
View File
@@ -0,0 +1,160 @@
package views
import (
"bytes"
"encoding/json"
"fmt"
"math"
"regexp"
"slices"
"strings"
"github.com/a-h/templ"
"github.com/yuin/goldmark"
"github.com/yuin/goldmark/extension"
"github.com/yuin/goldmark/renderer/html"
"git.b0b.be/bdeb/workout-suggester/internal/coach"
"git.b0b.be/bdeb/workout-suggester/internal/routes"
"git.b0b.be/bdeb/workout-suggester/internal/weather"
)
// md renders CommonMark + GFM. Raw HTML in model output is escaped
// (goldmark's default), so a reply can't inject markup into the page.
// Hard wraps: small models separate sections with single newlines.
var md = goldmark.New(goldmark.WithExtensions(extension.GFM), goldmark.WithRendererOptions(html.WithHardWraps()))
// sectionStart finds "**Label:**" lines. Models often start them right
// after a list, which Markdown would fold into the last bullet.
var sectionStart = regexp.MustCompile(`\n(\*\*[^*\n]+:?\*\*)`)
// Markdown renders model output as HTML, falling back to plain text.
func Markdown(src string) templ.Component {
var buf bytes.Buffer
src = sectionStart.ReplaceAllString(src, "\n\n$1")
if err := md.Convert([]byte(src), &buf); err != nil {
return Token(src)
}
return templ.Raw(buf.String())
}
func healthTrigger(h HealthInfo) string {
if h.Checking {
return "load"
}
return "every 30s"
}
func minutesLabel(m int) string {
if m >= 60 && m%60 == 0 {
return fmt.Sprintf("%dh", m/60)
}
if m > 60 {
return fmt.Sprintf("%dh%02d", m/60, m%60)
}
return fmt.Sprintf("%d min", m)
}
func placeName(p string) string {
if p == "" {
return "📍 your location"
}
return p
}
// gustsLabel marks gusts that were estimated from wind speed.
func gustsLabel(f weather.Forecast) string {
if f.GustsEstimated {
return "≈"
}
return ""
}
func nextHours(f weather.Forecast, n int) []weather.Hour {
return f.Hours[:min(n, len(f.Hours))]
}
func reasons(s coach.Score) string {
if len(s.Reasons) == 0 {
return "good conditions"
}
return strings.Join(s.Reasons, "; ")
}
// pickedReasons explains the pick's score, or why everything outside lost.
func pickedReasons(p coach.Plan) string {
a := p.Assessment
if a.Pick == coach.Indoor {
var all []string
for _, s := range a.Scores {
for _, r := range s.Reasons {
if !slices.Contains(all, r) {
all = append(all, r)
}
}
}
return strings.Join(all, "; ")
}
if s := a.ScoreOf(a.Pick); len(s.Reasons) > 0 {
return strings.Join(s.Reasons, "; ")
}
return ""
}
// loopDetails is the one-line summary of a generated loop.
func loopDetails(p coach.Plan, r *routes.Route) string {
parts := []string{fmt.Sprintf("%d m climb", r.AscentM)}
if r.PavedPct >= 0 {
parts = append(parts, fmt.Sprintf("%d%% paved", r.PavedPct))
}
if r.IntoWind(p.Forecast.Now.WindDirection) {
parts = append(parts, "into the wind first, tailwind home")
}
return strings.Join(parts, " · ")
}
func junctionKind(j *coach.JunctionInfo) string {
if j.Kind == routes.Bike {
return "cycling"
}
return "walking"
}
// mapData is the JSON the map script draws: you, the routes, the junction.
func mapData(p coach.Plan) string {
type route struct {
Name string `json:"name"`
Ways [][][2]float64 `json:"ways"`
Join [2]float64 `json:"join"`
}
out := struct {
You [2]float64 `json:"you"`
Routes []route `json:"routes"`
}{You: [2]float64{p.At.Lat, p.At.Lon}}
for _, s := range p.Routes {
r := route{Name: s.Route.Name, Join: [2]float64{s.JoinAt.Lat, s.JoinAt.Lon}}
for _, w := range s.Route.Ways {
pts := make([][2]float64, len(w))
for i, q := range w {
pts[i] = [2]float64{round5(q.Lat), round5(q.Lon)}
}
r.Ways = append(r.Ways, pts)
}
out.Routes = append(out.Routes, r)
}
b, _ := json.Marshal(out)
return string(b)
}
func round5(v float64) float64 { return math.Round(v*1e5) / 1e5 }
// directionsURL opens OpenStreetMap's own (OSRM) directions from the user to
// where the route is closest.
func directionsURL(p coach.Plan, s routes.Suggestion) string {
engine := "fossgis_osrm_foot"
if s.Route.Kind == routes.Bike {
engine = "fossgis_osrm_bike"
}
return fmt.Sprintf("https://www.openstreetmap.org/directions?engine=%s&route=%.5f%%2C%.5f%%3B%.5f%%2C%.5f",
engine, p.At.Lat, p.At.Lon, s.JoinAt.Lat, s.JoinAt.Lon)
}