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)
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package views
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMarkdownSections(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
src := "**Go for a run**: nice.\n**When:** now\n**The session:**\n- warm up\n- run\n**Route:** the loop"
|
||||
Markdown(src).Render(context.Background(), &buf)
|
||||
got := buf.String()
|
||||
if strings.Contains(got, "run\n<strong>Route:") || !strings.Contains(got, "</ul>\n<p><strong>Route:</strong>") {
|
||||
t.Errorf("Route folded into the list:\n%s", got)
|
||||
}
|
||||
if !strings.Contains(got, "<p><strong>Go for a run</strong>: nice.</p>") {
|
||||
t.Errorf("first line mangled:\n%s", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
package views
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"git.b0b.be/bdeb/workout-suggester/internal/coach"
|
||||
"git.b0b.be/bdeb/workout-suggester/internal/session"
|
||||
"git.b0b.be/bdeb/workout-suggester/internal/weather"
|
||||
)
|
||||
|
||||
// PageData is everything the full page needs.
|
||||
type PageData struct {
|
||||
DefaultPlace string
|
||||
Plan *coach.Plan // nil before the first plan
|
||||
Messages []session.Message
|
||||
}
|
||||
|
||||
templ Page(d PageData) {
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||
<title>workout-suggester</title>
|
||||
<meta name="description" content="Checks the weather and tells you what workout to do outside, with a local AI model."/>
|
||||
<link rel="stylesheet" href="/static/leaflet.css"/>
|
||||
<link rel="stylesheet" href="/static/app.css"/>
|
||||
<link rel="icon" href="/static/favicon.svg" type="image/svg+xml"/>
|
||||
<script src="/static/htmx.min.js" defer></script>
|
||||
<script src="/static/sse.min.js" defer></script>
|
||||
<script src="/static/leaflet.js" defer></script>
|
||||
<script src="/static/app.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>🌿 workout-suggester</h1>
|
||||
@Health(HealthInfo{Checking: true})
|
||||
</header>
|
||||
@Form(d.DefaultPlace)
|
||||
<main id="plan" aria-live="polite">
|
||||
if d.Plan != nil {
|
||||
@PlanView(*d.Plan, d.Messages)
|
||||
}
|
||||
</main>
|
||||
<footer>
|
||||
Advice by a local open-weight model · weather <a href="https://open-meteo.com/">Open-Meteo</a> / <a href="https://api.met.no/">MET Norway</a> · routes © <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors, loops by <a href="https://brouter.de/">BRouter</a>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
}
|
||||
|
||||
templ Form(defaultPlace string) {
|
||||
<form id="ask" class="card" hx-post="/plan" hx-target="#plan" hx-swap="innerHTML" hx-disabled-elt="find button[type=submit]">
|
||||
<div class="where">
|
||||
<input type="text" name="place" placeholder="Town or city" value={ defaultPlace } aria-label="Town or city" autocomplete="address-level2"/>
|
||||
<button type="button" id="locate" class="ghost" title="Use my location">📍 Here</button>
|
||||
<input type="hidden" name="lat"/>
|
||||
<input type="hidden" name="lon"/>
|
||||
</div>
|
||||
<fieldset>
|
||||
<legend>Up for</legend>
|
||||
@radio("activity", "", coach.Any.Label(), true)
|
||||
@radio("activity", "run", coach.Run.Label(), false)
|
||||
@radio("activity", "ride", coach.Ride.Label(), false)
|
||||
@radio("activity", "walk", coach.Walk.Label(), false)
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<legend>How hard</legend>
|
||||
@radio("intensity", "easy", "😌 Easy", false)
|
||||
@radio("intensity", "moderate", "🙂 Moderate", true)
|
||||
@radio("intensity", "hard", "🔥 Hard", false)
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<legend>How long</legend>
|
||||
for _, m := range []int{30, 45, 60, 90, 120} {
|
||||
@radio("minutes", fmt.Sprint(m), minutesLabel(m), m == 60)
|
||||
}
|
||||
</fieldset>
|
||||
<input type="text" name="note" maxlength="200" placeholder="Anything else? e.g. sore knee, want hills, have a dog" aria-label="Anything else"/>
|
||||
<button type="submit" class="go">Get me outside</button>
|
||||
</form>
|
||||
}
|
||||
|
||||
templ radio(name, value, label string, checked bool) {
|
||||
<label class="pill">
|
||||
<input type="radio" name={ name } value={ value } checked?={ checked }/>
|
||||
<span>{ label }</span>
|
||||
</label>
|
||||
}
|
||||
|
||||
templ Error(msg string) {
|
||||
<div class="card error">⚠️ { msg }</div>
|
||||
}
|
||||
|
||||
// PlanView is the weather, the scores, the coach's advice, routes and the
|
||||
// follow-up chat.
|
||||
templ PlanView(p coach.Plan, msgs []session.Message) {
|
||||
@WeatherCard(p)
|
||||
@Scores(p)
|
||||
<section class="card coach">
|
||||
<div id="thread">
|
||||
for _, m := range msgs {
|
||||
if m.Role == "assistant" && m.State != session.Done {
|
||||
@StreamingReply(m)
|
||||
} else {
|
||||
@MessageView(m)
|
||||
}
|
||||
}
|
||||
</div>
|
||||
<div class="chips">
|
||||
<button type="button" class="ghost" data-ask="Make it harder">💪 Harder</button>
|
||||
<button type="button" class="ghost" data-ask="Make it easier">😮💨 Easier</button>
|
||||
<button type="button" class="ghost" data-ask="I only have 30 minutes">⏱️ Only 30 min</button>
|
||||
<button type="button" class="ghost" data-ask="Suggest something different">🔀 Something else</button>
|
||||
</div>
|
||||
<form id="composer" hx-post="/ask" hx-target="#thread" hx-swap="beforeend" hx-disabled-elt="find button">
|
||||
<textarea name="message" rows="1" maxlength="1000" placeholder="Ask the coach…" required></textarea>
|
||||
<button type="submit">Send</button>
|
||||
</form>
|
||||
</section>
|
||||
if p.Assessment.Pick != coach.Indoor {
|
||||
if p.RoutesLoaded {
|
||||
@RoutesCard(p)
|
||||
} else {
|
||||
<section class="card routes" hx-get="/routes" hx-trigger="load" hx-swap="outerHTML">
|
||||
<h2>🗺️ Routes nearby</h2>
|
||||
<p class="muted">Planning loops from your door and looking up signposted routes…</p>
|
||||
</section>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
templ WeatherCard(p coach.Plan) {
|
||||
{{ f := p.Forecast }}
|
||||
{{ desc, _ := weather.Describe(f.Now.Code) }}
|
||||
<section class="card weather">
|
||||
<div class="now">
|
||||
<span class="icon">{ f.Now.Icon() }</span>
|
||||
<span class="temp">{ fmt.Sprintf("%.0f°", f.Now.Temp) }</span>
|
||||
<div>
|
||||
<div><strong>{ desc }</strong>, feels like { fmt.Sprintf("%.0f°", f.Now.Feels) }</div>
|
||||
<div class="muted">{ placeName(p.Place) } · { f.Now.Time.Format("Mon 15:04") } · via { f.Source }</div>
|
||||
</div>
|
||||
</div>
|
||||
<ul class="facts">
|
||||
<li>💨 { fmt.Sprintf("%.0f km/h %s", f.Now.Wind, weather.Compass(f.Now.WindDirection)) }, gusts { gustsLabel(f) }{ fmt.Sprintf("%.0f", f.Now.Gusts) }</li>
|
||||
<li>🌧️ { fmt.Sprintf("%.1f mm", f.Now.Precip) } · { f.Now.Chance() } chance</li>
|
||||
<li>🌇 sunset { f.Sunset.Format("15:04") }</li>
|
||||
</ul>
|
||||
<ol class="hours">
|
||||
for _, h := range nextHours(f, 8) {
|
||||
<li class={ templ.KV("night", !h.IsDay) }>
|
||||
<span class="muted">{ h.Time.Format("15h") }</span>
|
||||
<span>{ h.Icon() }</span>
|
||||
<span>{ fmt.Sprintf("%.0f°", h.Temp) }</span>
|
||||
<span class="rain">{ h.Chance() }</span>
|
||||
<span class="muted">{ fmt.Sprintf("%.0f", h.Gusts) }</span>
|
||||
</li>
|
||||
}
|
||||
</ol>
|
||||
</section>
|
||||
}
|
||||
|
||||
templ Scores(p coach.Plan) {
|
||||
{{ a := p.Assessment }}
|
||||
<section class="scores">
|
||||
for _, s := range a.Scores {
|
||||
<div class={ "score", s.Verdict(), templ.KV("picked", s.Activity == a.Pick) } title={ reasons(s) }>
|
||||
<div class="label">{ s.Activity.Label() }</div>
|
||||
<div class="value">{ fmt.Sprint(s.Value) }</div>
|
||||
<div class="verdict">{ s.Verdict() }</div>
|
||||
</div>
|
||||
}
|
||||
<div class={ "score", "indoor", templ.KV("picked", a.Pick == coach.Indoor) }>
|
||||
<div class="label">{ coach.Indoor.Label() }</div>
|
||||
<div class="value">🏋️</div>
|
||||
<div class="verdict">
|
||||
if a.Pick == coach.Indoor {
|
||||
today
|
||||
} else {
|
||||
plan B
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
if a.Later != nil {
|
||||
<p class="later">⏰ Better at <strong>{ a.Later.Start.Format("15:04") }</strong>: { a.Later.Activity.Noun() } scores { fmt.Sprint(a.Later.Score) }.</p>
|
||||
}
|
||||
if why := pickedReasons(p); why != "" {
|
||||
<p class="later muted">Why: { why }</p>
|
||||
}
|
||||
}
|
||||
|
||||
templ RoutesCard(p coach.Plan) {
|
||||
<section class="card routes">
|
||||
<h2>🗺️ Routes</h2>
|
||||
if len(p.Routes) == 0 {
|
||||
<p class="muted">
|
||||
if p.RoutesErr != "" {
|
||||
The route services are busy, so no routes this time. Try again in a minute.
|
||||
} else {
|
||||
No { p.Assessment.Pick.Noun() } routes found close by. An out-and-back from your door works too.
|
||||
}
|
||||
</p>
|
||||
} else {
|
||||
<div class="map" data-map={ mapData(p) }></div>
|
||||
<ol class="route-list">
|
||||
for i, s := range p.Routes {
|
||||
<li data-route={ fmt.Sprint(i) } class={ templ.KV("active", i == 0) }>
|
||||
<div class="name">
|
||||
if s.Route.Generated {
|
||||
<span class="badge">from your door</span>
|
||||
} else {
|
||||
<span class="badge signposted">signposted</span>
|
||||
}
|
||||
{ s.Route.Name }
|
||||
</div>
|
||||
<div class="muted">
|
||||
if s.Route.Generated {
|
||||
{ loopDetails(p, s.Route) }
|
||||
} else {
|
||||
{ s.Describe() } ≈ { fmt.Sprintf("%.1f km", s.TotalKm) } · { fmt.Sprintf("%.1f km", s.FromYouKm) } from you
|
||||
}
|
||||
</div>
|
||||
<div class="links">
|
||||
<a href={ templ.SafeURL(fmt.Sprintf("/gpx/%d", s.Route.ID)) } download>⬇ GPX</a>
|
||||
if !s.Route.Generated {
|
||||
<a href={ templ.SafeURL(directionsURL(p, s)) } target="_blank" rel="noopener">🧭 To the start</a>
|
||||
<a href={ templ.SafeURL(fmt.Sprintf("https://www.openstreetmap.org/relation/%d", s.Route.ID)) } target="_blank" rel="noopener">OSM</a>
|
||||
}
|
||||
</div>
|
||||
</li>
|
||||
}
|
||||
</ol>
|
||||
}
|
||||
if j := p.Junction; j != nil {
|
||||
<p class="junction">📍 Nearest { junctionKind(j) } junction: <span class="node">{ j.Ref }</span> { fmt.Sprintf("%.1f km", j.Km) } away</p>
|
||||
}
|
||||
</section>
|
||||
}
|
||||
|
||||
// MessageView renders a finished chat message.
|
||||
templ MessageView(m session.Message) {
|
||||
<div class={ "msg", m.Role } id={ "msg-" + m.ID }>
|
||||
if m.Role == "assistant" {
|
||||
<div class="markdown">
|
||||
@Markdown(m.Content)
|
||||
</div>
|
||||
if m.Err != "" {
|
||||
<p class="error">⚠️ { m.Err }</p>
|
||||
}
|
||||
} else {
|
||||
<div class="plain">{ m.Content }</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
// Exchange is a follow-up question plus its streaming reply.
|
||||
templ Exchange(user, reply session.Message) {
|
||||
@MessageView(user)
|
||||
@StreamingReply(reply)
|
||||
}
|
||||
|
||||
// StreamingReply opens an SSE connection. "token" events append text as it
|
||||
// arrives; the final "done" event swaps in the rendered Markdown.
|
||||
templ StreamingReply(m session.Message) {
|
||||
<div
|
||||
class="msg assistant"
|
||||
id={ "msg-" + m.ID }
|
||||
hx-ext="sse"
|
||||
sse-connect={ "/stream/" + m.ID }
|
||||
sse-swap="done"
|
||||
hx-swap="outerHTML"
|
||||
sse-close="done"
|
||||
>
|
||||
<div class="plain streaming" sse-swap="token" hx-swap="beforeend"></div>
|
||||
</div>
|
||||
}
|
||||
|
||||
templ Token(text string) {
|
||||
<span>{ text }</span>
|
||||
}
|
||||
|
||||
// HealthInfo describes the model server status shown in the header.
|
||||
type HealthInfo struct {
|
||||
Checking bool
|
||||
OK bool
|
||||
Model string
|
||||
Err string
|
||||
}
|
||||
|
||||
templ Health(h HealthInfo) {
|
||||
<div id="health" class="health" hx-get="/health" hx-trigger={ healthTrigger(h) } hx-swap="outerHTML">
|
||||
if h.Checking {
|
||||
<span class="dot"></span> connecting…
|
||||
} else if h.OK {
|
||||
<span class="dot ok"></span> { h.Model }
|
||||
} else {
|
||||
<span class="dot bad"></span> <span title={ h.Err }>model offline</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user