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,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>
|
||||
}
|
||||
Reference in new issue
Block a user