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:
42 files changed
+7219
No files matched your search
@@ -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)
|
||||
}
|
||||
Reference in new issue
Block a user