Files
workout-suggester/internal/web/views/views.templ
T
bdeb1337 d7342d6096 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.
2026-10-11 19:29:58 +02:00

303 lines
9.9 KiB
Templ

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>
}