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.
303 lines
9.9 KiB
Templ
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>
|
|
}
|