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:
bdeb1337 committed 2026-10-11 19:29:58 +02:00
commit d7342d6096
42 files changed
+7219

No files matched your search

+406
View File
@@ -0,0 +1,406 @@
// Package web serves the app: Templ pages, HTMX fragments and an SSE
// endpoint that streams the coach's replies token by token.
package web
import (
"bytes"
"context"
"embed"
"errors"
"fmt"
"log/slog"
"net/http"
"strconv"
"strings"
"sync"
"time"
"github.com/a-h/templ"
"git.b0b.be/bdeb/workout-suggester/internal/coach"
"git.b0b.be/bdeb/workout-suggester/internal/config"
"git.b0b.be/bdeb/workout-suggester/internal/llm"
"git.b0b.be/bdeb/workout-suggester/internal/routes"
"git.b0b.be/bdeb/workout-suggester/internal/session"
"git.b0b.be/bdeb/workout-suggester/internal/weather"
"git.b0b.be/bdeb/workout-suggester/internal/web/views"
)
//go:embed static
var staticFS embed.FS
const (
sessionCookie = "ws_sid"
maxFormLen = 8 << 10
routesTimeout = 30 * time.Second
)
// Server is the web app.
type Server struct {
cfg config.Config
llm *llm.Client
weather *weather.Client
routes *routes.Client
store *session.Store
log *slog.Logger
}
// New wires a server from config. cacheDir keeps OpenStreetMap lookups
// between runs; empty disables that.
func New(cfg config.Config, cacheDir string, log *slog.Logger) *Server {
hc := &http.Client{Timeout: 45 * time.Second}
return &Server{
cfg: cfg,
llm: &llm.Client{
BaseURL: cfg.BaseURL,
APIKey: cfg.APIKey,
Model: cfg.Model,
HTTP: &http.Client{}, // per-request deadlines come from contexts
},
weather: &weather.Client{ForecastURL: cfg.ForecastURL, GeocodeURL: cfg.GeocodeURL, HTTP: hc},
routes: &routes.Client{URLs: strings.Fields(cfg.OverpassURL), BRouterURL: cfg.BRouterURL, HTTP: hc, CacheDir: cacheDir},
store: session.NewStore(),
log: log,
}
}
// Handler returns the app's routes.
func (s *Server) Handler() http.Handler {
mux := http.NewServeMux()
mux.Handle("GET /static/", http.FileServerFS(staticFS))
mux.HandleFunc("GET /{$}", s.index)
mux.HandleFunc("GET /health", s.health)
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) { w.Write([]byte("ok")) })
mux.HandleFunc("POST /plan", s.plan)
mux.HandleFunc("GET /routes", s.routesCard)
mux.HandleFunc("GET /gpx/{id}", s.gpx)
mux.HandleFunc("POST /ask", s.ask)
mux.HandleFunc("GET /stream/{id}", s.stream)
return s.withSession(mux)
}
// Run serves until ctx is cancelled, then shuts down gracefully.
func (s *Server) Run(ctx context.Context) error {
srv := &http.Server{
Addr: s.cfg.Addr,
Handler: s.Handler(),
ReadHeaderTimeout: 10 * time.Second,
// No WriteTimeout: replies stream for as long as the model talks.
// LLM_TIMEOUT bounds each reply instead.
}
go func() {
t := time.NewTicker(10 * time.Minute)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
s.store.Prune(12 * time.Hour)
}
}
}()
errc := make(chan error, 1)
go func() { errc <- srv.ListenAndServe() }()
s.log.Info("workout-suggester listening", "url", "http://"+s.cfg.Addr, "llm", s.cfg.BaseURL, "model", s.cfg.Model)
select {
case err := <-errc:
return err
case <-ctx.Done():
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
return srv.Shutdown(shutdownCtx)
}
}
type ctxKey struct{}
// withSession gives every browser a random session cookie.
func (s *Server) withSession(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
sid := ""
if c, err := r.Cookie(sessionCookie); err == nil && len(c.Value) == 32 {
sid = c.Value
} else {
sid = session.NewID()
http.SetCookie(w, &http.Cookie{
Name: sessionCookie,
Value: sid,
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
})
}
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), ctxKey{}, sid)))
})
}
func sessionID(r *http.Request) string {
sid, _ := r.Context().Value(ctxKey{}).(string)
return sid
}
func (s *Server) render(w http.ResponseWriter, r *http.Request, c templ.Component) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := c.Render(r.Context(), w); err != nil {
s.log.Error("render", "err", err)
}
}
func (s *Server) index(w http.ResponseWriter, r *http.Request) {
d := views.PageData{DefaultPlace: s.cfg.DefaultPlace}
if conv := s.store.Get(sessionID(r)); conv != nil {
p := conv.Plan()
d.Plan, d.Messages = &p, conv.Messages()
}
s.render(w, r, views.Page(d))
}
func (s *Server) health(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
defer cancel()
model, err := s.llm.ResolveModel(ctx)
if err == nil && s.cfg.Model != "" {
_, err = s.llm.Models(ctx) // configured model: still check the server is up
}
h := views.HealthInfo{OK: err == nil, Model: model}
if err != nil {
h.Err = err.Error()
}
s.render(w, r, views.Health(h))
}
// parseRequest reads the form into a coach request, with safe defaults.
func parseRequest(r *http.Request) coach.Request {
req := coach.Request{
Activity: coach.Activity(r.FormValue("activity")),
Intensity: coach.Intensity(r.FormValue("intensity")),
Note: strings.TrimSpace(r.FormValue("note")),
}
switch req.Activity {
case coach.Run, coach.Ride, coach.Walk, coach.Indoor:
default:
req.Activity = coach.Any
}
switch req.Intensity {
case coach.Easy, coach.Moderate, coach.Hard:
default:
req.Intensity = coach.Moderate
}
req.Minutes, _ = strconv.Atoi(r.FormValue("minutes"))
req.Minutes = min(240, max(15, req.Minutes))
if len(req.Note) > 200 {
req.Note = req.Note[:200]
}
return req
}
// plan checks the weather, scores the options and starts the coach's reply.
func (s *Server) plan(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxFormLen)
if err := r.ParseForm(); err != nil {
s.render(w, r, views.Error("That form was too big."))
return
}
req := parseRequest(r)
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
defer cancel()
var p coach.Plan
lat, errLat := strconv.ParseFloat(r.FormValue("lat"), 64)
lon, errLon := strconv.ParseFloat(r.FormValue("lon"), 64)
switch place := strings.TrimSpace(r.FormValue("place")); {
case errLat == nil && errLon == nil && lat >= -90 && lat <= 90 && lon >= -180 && lon <= 180:
p.At = routes.Point{Lat: lat, Lon: lon}
case place != "":
pl, err := s.weather.Geocode(ctx, place)
if err != nil {
s.log.Warn("geocode", "err", err)
s.render(w, r, views.Error("Couldn't find that place. Check the spelling, or tap 📍 Here."))
return
}
p.Place, p.At = pl.Name, routes.Point{Lat: pl.Lat, Lon: pl.Lon}
default:
s.render(w, r, views.Error("Where are you? Type a town or tap 📍 Here."))
return
}
f, err := s.weather.Forecast(ctx, p.At.Lat, p.At.Lon)
if err != nil {
s.log.Warn("forecast", "err", err)
s.render(w, r, views.Error("Couldn't get the weather right now. Are you online?"))
return
}
p.Forecast, p.Request, p.Assessment = f, req, coach.Assess(f, req)
conv, _ := s.store.Start(sessionID(r), p)
s.render(w, r, views.PlanView(conv.Plan(), conv.Messages()))
}
// loadRoutes looks up routes for the plan's pick once per plan. Concurrent
// callers (the routes card and the first reply) share one Overpass request.
func (s *Server) loadRoutes(ctx context.Context, conv *session.Conversation) coach.Plan {
p := conv.Plan()
if p.RoutesLoaded || p.Assessment.Pick == coach.Indoor {
return p
}
ctx, cancel := context.WithTimeout(ctx, routesTimeout)
defer cancel()
// Loops from the door (BRouter) and signposted routes (Overpass) at once.
kind := p.Assessment.Pick.RouteKind()
var (
loops []routes.Suggestion
loopsErr error
wg sync.WaitGroup
)
wg.Go(func() {
loops, loopsErr = s.routes.Loops(ctx, kind, p.At, p.Assessment.TargetKm, p.Forecast.Now.WindDirection)
})
area, err := s.routes.Near(ctx, p.At.Lat, p.At.Lon)
wg.Wait()
if loopsErr != nil {
s.log.Warn("loops", "err", loopsErr)
}
if err != nil {
s.log.Warn("routes", "err", err)
}
conv.UpdatePlan(func(p *coach.Plan) {
if p.RoutesLoaded {
return
}
p.RoutesLoaded = true
p.Routes = loops[:min(2, len(loops))]
if err == nil {
p.Routes = append(p.Routes, routes.Suggest(area, kind, p.Assessment.TargetKm, p.At, 4-len(p.Routes))...)
if j, km, ok := area.NearestJunction(kind, p.At); ok && km < 2 {
p.Junction = &coach.JunctionInfo{Ref: j.Ref, Kind: kind, Km: km}
}
}
if len(p.Routes) == 0 && err != nil {
p.RoutesErr = err.Error()
}
})
return conv.Plan()
}
func (s *Server) routesCard(w http.ResponseWriter, r *http.Request) {
conv := s.store.Get(sessionID(r))
if conv == nil {
s.render(w, r, views.Error("This plan expired. Ask again."))
return
}
s.render(w, r, views.RoutesCard(s.loadRoutes(r.Context(), conv)))
}
func (s *Server) gpx(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
route, ok := s.routes.Route(id)
if err != nil || !ok {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "application/gpx+xml")
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="osm-%d.gpx"`, id))
routes.WriteGPX(w, route)
}
func (s *Server) ask(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxFormLen)
text := strings.TrimSpace(r.FormValue("message"))
conv := s.store.Get(sessionID(r))
switch {
case conv == nil:
s.render(w, r, views.Error("This plan expired. Ask again from the top."))
return
case text == "" || len(text) > 1000:
http.Error(w, "message is empty or too long", http.StatusBadRequest)
return
}
user, reply := conv.Ask(text)
s.render(w, r, views.Exchange(user, reply))
}
// stream generates a reply and pushes it to the browser as server-sent
// events. EventSource reconnects automatically, so a reply that is already
// claimed or finished is answered with its current state instead of being
// generated twice.
func (s *Server) stream(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
conv := s.store.Get(sessionID(r))
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("X-Accel-Buffering", "no") // don't let reverse proxies buffer
sse := &sseWriter{w: w, rc: http.NewResponseController(w)}
lost := session.Message{ID: id, Role: "assistant", State: session.Done,
Err: "This reply was lost, probably because the server restarted. Please ask again."}
if conv == nil {
sse.component(r.Context(), "done", views.MessageView(lost))
return
}
// The first reply should know the routes, so wait for them (cached
// after the first lookup, and shared with the routes card).
if conv.IsFirstReply(id) {
s.loadRoutes(r.Context(), conv)
}
prompt, msg, ok := conv.Claim(id)
if !ok {
if msg.ID == "" {
msg = lost
}
sse.component(r.Context(), "done", views.MessageView(msg))
return
}
ctx, cancel := context.WithTimeout(r.Context(), s.cfg.Timeout)
defer cancel()
start := time.Now()
err := s.llm.Stream(ctx, prompt, func(delta string) error {
conv.Append(id, delta)
return sse.component(ctx, "token", views.Token(delta))
})
if r.Context().Err() != nil { // browser went away; keep what we have
conv.Finish(id, errors.New("interrupted"))
return
}
switch {
case errors.Is(err, context.DeadlineExceeded):
err = fmt.Errorf("reply took longer than %s and was cut off", s.cfg.Timeout)
case err != nil:
s.log.Warn("generation failed", "err", err)
err = errors.New("the local model didn't answer. Is your model server running?")
default:
s.log.Debug("reply done", "id", id, "took", time.Since(start))
}
sse.component(r.Context(), "done", views.MessageView(conv.Finish(id, err)))
}
type sseWriter struct {
w http.ResponseWriter
rc *http.ResponseController
}
// component sends one rendered Templ component as a named SSE event.
func (s *sseWriter) component(ctx context.Context, event string, c templ.Component) error {
var buf bytes.Buffer
if err := c.Render(ctx, &buf); err != nil {
return err
}
var b strings.Builder
b.WriteString("event: " + event + "\n")
for _, line := range strings.Split(buf.String(), "\n") {
b.WriteString("data: " + line + "\n")
}
b.WriteString("\n")
if _, err := s.w.Write([]byte(b.String())); err != nil {
return err
}
return s.rc.Flush()
}
+169
View File
@@ -0,0 +1,169 @@
package web
import (
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"net/http/cookiejar"
"net/http/httptest"
"net/url"
"regexp"
"strings"
"testing"
"time"
"git.b0b.be/bdeb/workout-suggester/internal/config"
)
// fakeUpstreams serves Open-Meteo (real Ghent fixtures), Overpass and an LLM
// that echoes the start of its system prompt, recording what it was sent.
func fakeUpstreams(t *testing.T, prompts *[]string) *httptest.Server {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/v1/forecast":
http.ServeFile(w, r, "../weather/testdata/forecast.json")
case "/v1/search":
http.ServeFile(w, r, "../weather/testdata/geocode.json")
case "/overpass":
fmt.Fprint(w, `{"elements":[{"type":"relation","id":7,"tags":{"route":"foot","name":"Bourgoyenpad","roundtrip":"yes"},
"members":[{"type":"way","geometry":[{"lat":51.05,"lon":3.70},{"lat":51.05,"lon":3.775}]}]}]}`)
case "/brouter":
http.ServeFile(w, r, "../routes/testdata/brouter.json")
case "/llm/models":
fmt.Fprint(w, `{"data":[{"id":"fake-gemma"}]}`)
case "/llm/chat/completions":
var req struct {
Messages []struct{ Role, Content string }
}
json.NewDecoder(r.Body).Decode(&req)
*prompts = append(*prompts, req.Messages[0].Content+"\n---\n"+req.Messages[len(req.Messages)-1].Content)
w.Header().Set("Content-Type", "text/event-stream")
for _, part := range []string{"**Go for a run** ", "<script>x</script>"} {
b, _ := json.Marshal(part)
fmt.Fprintf(w, "data: {\"choices\":[{\"delta\":{\"content\":%s}}]}\n\n", b)
}
fmt.Fprint(w, "data: [DONE]\n\n")
default:
http.NotFound(w, r)
}
}))
t.Cleanup(srv.Close)
return srv
}
func newTestApp(t *testing.T, prompts *[]string) (*httptest.Server, *http.Client) {
t.Helper()
up := fakeUpstreams(t, prompts)
cfg := config.Config{
BaseURL: up.URL + "/llm",
Timeout: 10 * time.Second,
OverpassURL: up.URL + "/overpass",
BRouterURL: up.URL + "/brouter",
ForecastURL: up.URL + "/v1/forecast",
GeocodeURL: up.URL + "/v1/search",
}
app := httptest.NewServer(New(cfg, "", slog.New(slog.NewTextHandler(io.Discard, nil))).Handler())
t.Cleanup(app.Close)
jar, _ := cookiejar.New(nil)
return app, &http.Client{Jar: jar}
}
var streamURL = regexp.MustCompile(`sse-connect="(/stream/[0-9a-f]+)"`)
func do(t *testing.T, c *http.Client, method, u string, form url.Values) string {
t.Helper()
var resp *http.Response
var err error
if method == http.MethodPost {
resp, err = c.PostForm(u, form)
} else {
resp, err = c.Get(u)
}
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
return string(b)
}
func TestPlanFlow(t *testing.T) {
var prompts []string
app, c := newTestApp(t, &prompts)
page := do(t, c, "GET", app.URL+"/", nil)
if !strings.Contains(page, "Get me outside") {
t.Fatal("index lacks the form")
}
plan := do(t, c, "POST", app.URL+"/plan", url.Values{
"place": {"Gent"}, "activity": {"run"}, "intensity": {"easy"}, "minutes": {"30"}, "note": {"bad knee"},
})
for _, want := range []string{"Ghent, Belgium", "15°", "great", `hx-get="/routes"`} {
if !strings.Contains(plan, want) {
t.Errorf("plan lacks %q", want)
}
}
m := streamURL.FindStringSubmatch(plan)
if m == nil {
t.Fatal("plan has no streaming reply")
}
// The first reply waits for routes, so the model sees them.
stream := do(t, c, "GET", app.URL+m[1], nil)
if !strings.Contains(stream, "event: done") || !strings.Contains(stream, "<strong>Go for a run</strong>") {
t.Errorf("stream = %s", stream)
}
if strings.Contains(stream, "<script>") {
t.Error("model output was not escaped")
}
if len(prompts) != 1 {
t.Fatalf("llm called %d times", len(prompts))
}
for _, want := range []string{"Ghent, Belgium", `"bad knee"`, `"Bourgoyenpad": signposted`, "a loop from the user's door", "m of climbing", "The app's pick: running", "What should I do right now?"} {
if !strings.Contains(prompts[0], want) {
t.Errorf("prompt lacks %q:\n%s", want, prompts[0])
}
}
// Reconnecting to a finished reply doesn't generate it again.
do(t, c, "GET", app.URL+m[1], nil)
if len(prompts) != 1 {
t.Error("reply was generated twice")
}
routes := do(t, c, "GET", app.URL+"/routes", nil)
if !strings.Contains(routes, "Bourgoyenpad") || !strings.Contains(routes, "/gpx/7") || !strings.Contains(routes, "from your door") || !strings.Contains(routes, "/gpx/-1") {
t.Errorf("routes card = %s", routes)
}
if gpx := do(t, c, "GET", app.URL+"/gpx/7", nil); !strings.Contains(gpx, "<trkpt") {
t.Errorf("gpx = %s", gpx)
}
// Follow-ups keep the plan and get the brevity hint.
ask := do(t, c, "POST", app.URL+"/ask", url.Values{"message": {"make it harder"}})
m = streamURL.FindStringSubmatch(ask)
if m == nil || !strings.Contains(ask, "make it harder") {
t.Fatalf("ask = %s", ask)
}
do(t, c, "GET", app.URL+m[1], nil)
if len(prompts) != 2 || !strings.Contains(prompts[1], "make it harder\n\n(Answer in a few short lines") {
t.Errorf("follow-up prompt = %q", prompts[len(prompts)-1])
}
// Reloading the page shows the plan and the chat again.
page = do(t, c, "GET", app.URL+"/", nil)
if !strings.Contains(page, "make it harder") || !strings.Contains(page, "Ghent, Belgium") {
t.Error("reload lost the plan")
}
}
func TestPlanNeedsAPlace(t *testing.T) {
var prompts []string
app, c := newTestApp(t, &prompts)
if got := do(t, c, "POST", app.URL+"/plan", url.Values{"minutes": {"60"}}); !strings.Contains(got, "Where are you?") {
t.Errorf("got %s", got)
}
}
+223
View File
@@ -0,0 +1,223 @@
:root {
--bg: #f4f6f1;
--surface: #ffffff;
--text: #1c211c;
--muted: #667066;
--border: #dfe5db;
--accent: #2f8f4e;
--accent-soft: #e3f2e7;
--accent-text: #ffffff;
--great: #2f8f4e;
--fine: #7a9a2e;
--meh: #c98a12;
--no: #c2412d;
--bad: #c2412d;
--radius: 14px;
color-scheme: light dark;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #111511;
--surface: #1a1f1a;
--text: #e8ece6;
--muted: #93a093;
--border: #2b332b;
--accent: #4cb46d;
--accent-soft: #1f3324;
--accent-text: #0c120d;
--great: #4cb46d;
--fine: #a3c14a;
--meh: #e0a93a;
--no: #e06a55;
--bad: #e06a55;
}
}
* { box-sizing: border-box; }
body {
max-width: 760px;
margin: 0 auto;
padding: 0 16px 32px;
background: var(--bg);
color: var(--text);
font: 16px/1.5 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
}
a { color: var(--accent); }
.muted { color: var(--muted); }
header { display: flex; align-items: center; gap: 12px; padding: 16px 0 12px; }
header h1 { font-size: 1.15rem; margin: 0; flex: 1; white-space: nowrap; }
.health { display: flex; align-items: center; gap: 6px; font-size: .8rem; color: var(--muted); }
.dot { width: 8px; height: 8px; border-radius: 50%; background: var(--muted); }
.dot.ok { background: var(--great); }
.dot.bad { background: var(--bad); }
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 14px 16px;
margin-bottom: 12px;
}
.card h2 { font-size: 1rem; margin: 0 0 10px; }
.card.error { border-color: var(--bad); color: var(--bad); }
/* the form */
#ask { display: grid; gap: 12px; }
.where { display: flex; gap: 8px; }
input[type=text], textarea {
width: 100%;
min-width: 0;
padding: 10px 12px;
border: 1px solid var(--border);
border-radius: 10px;
background: var(--bg);
color: var(--text);
font: inherit;
}
input[type=text]:focus, textarea:focus { outline: 2px solid var(--accent); outline-offset: -1px; }
fieldset { border: 0; padding: 0; margin: 0; display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }
legend { font-size: .8rem; color: var(--muted); padding: 0 0 4px; width: 100%; }
.pill input { position: absolute; opacity: 0; pointer-events: none; }
.pill span {
display: inline-block;
padding: 6px 12px;
border: 1px solid var(--border);
border-radius: 999px;
cursor: pointer;
font-size: .92rem;
user-select: none;
}
.pill input:checked + span { background: var(--accent-soft); border-color: var(--accent); font-weight: 600; }
.pill input:focus-visible + span { outline: 2px solid var(--accent); }
button {
padding: 10px 18px;
border: 0;
border-radius: 10px;
background: var(--accent);
color: var(--accent-text);
font: inherit;
font-weight: 600;
cursor: pointer;
white-space: nowrap;
}
button:disabled { opacity: .55; cursor: progress; }
button.ghost {
background: transparent;
color: var(--text);
border: 1px solid var(--border);
font-weight: 500;
}
button.go { font-size: 1.05rem; padding: 12px; }
.htmx-request button.go::after { content: " …"; }
/* weather */
.weather .now { display: flex; align-items: center; gap: 12px; }
.weather .icon { font-size: 2.4rem; line-height: 1; }
.weather .temp { font-size: 2.4rem; font-weight: 700; letter-spacing: -1px; }
.facts { display: flex; flex-wrap: wrap; gap: 4px 16px; list-style: none; padding: 0; margin: 10px 0; font-size: .9rem; }
.hours {
display: grid;
grid-auto-flow: column;
grid-auto-columns: minmax(52px, 1fr);
gap: 4px;
list-style: none;
padding: 0;
margin: 0;
overflow-x: auto;
font-size: .82rem;
text-align: center;
}
.hours li { display: grid; gap: 1px; padding: 6px 2px; border-radius: 8px; background: var(--bg); }
.hours li.night { opacity: .7; }
.hours .rain { color: #3b6cf6; }
/* scores */
.scores { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; margin-bottom: 8px; }
.score {
background: var(--surface);
border: 1px solid var(--border);
border-top: 4px solid var(--muted);
border-radius: 12px;
padding: 8px;
text-align: center;
cursor: help;
}
.score .label { font-size: .78rem; color: var(--muted); }
.score .value { font-size: 1.5rem; font-weight: 700; }
.score .verdict { font-size: .78rem; font-weight: 600; text-transform: uppercase; letter-spacing: .5px; }
.score.great { border-top-color: var(--great); } .score.great .verdict { color: var(--great); }
.score.fine { border-top-color: var(--fine); } .score.fine .verdict { color: var(--fine); }
.score.meh { border-top-color: var(--meh); } .score.meh .verdict { color: var(--meh); }
.score.no { border-top-color: var(--no); } .score.no .verdict { color: var(--no); }
.score.indoor { cursor: default; }
.score.picked { outline: 2px solid var(--accent); background: var(--accent-soft); }
.later { margin: 0 0 12px; font-size: .92rem; }
/* coach */
#thread { display: grid; gap: 12px; }
.msg.user { justify-self: end; background: var(--accent-soft); padding: 6px 12px; border-radius: 12px 12px 4px 12px; max-width: 85%; }
.plain { white-space: pre-wrap; }
.markdown > :first-child { margin-top: 0; }
.markdown > :last-child { margin-bottom: 0; }
.markdown p, .markdown ul { margin: 0 0 8px; }
.markdown ul { padding-left: 20px; }
.error { color: var(--bad); font-size: .9rem; margin: 6px 0 0; }
.streaming:empty::after {
content: "● ● ●";
letter-spacing: 2px;
color: var(--muted);
animation: pulse 1.2s ease-in-out infinite;
}
@keyframes pulse { 50% { opacity: .3; } }
.chips { display: flex; flex-wrap: wrap; gap: 6px; margin: 14px 0 8px; }
.chips button { padding: 5px 10px; font-size: .85rem; }
#composer { display: flex; gap: 8px; }
#composer textarea { resize: none; max-height: 160px; }
/* routes */
.map { height: 300px; border-radius: 10px; margin-bottom: 10px; z-index: 0; }
.route-list { list-style: none; padding: 0; margin: 0; display: grid; gap: 6px; }
.route-list li { padding: 8px 10px; border: 1px solid var(--border); border-radius: 10px; cursor: pointer; font-size: .9rem; }
.route-list li.active { border-color: var(--accent); background: var(--accent-soft); }
.route-list .name { font-weight: 600; }
.badge {
display: inline-block;
margin-right: 6px;
padding: 0 7px;
border-radius: 999px;
background: var(--accent);
color: var(--accent-text);
font-size: .72rem;
font-weight: 600;
vertical-align: 1px;
}
.badge.signposted { background: #1f6f3a; color: #fff; }
.route-list .links { display: flex; gap: 14px; margin-top: 4px; }
.junction { margin: 10px 0 0; font-size: .9rem; }
.node {
display: inline-grid;
place-items: center;
min-width: 26px;
height: 26px;
padding: 0 4px;
border-radius: 50%;
background: #1f6f3a;
color: #fff;
font-weight: 700;
font-size: .8rem;
}
footer { margin-top: 24px; font-size: .78rem; color: var(--muted); text-align: center; }
@media (max-width: 520px) {
.scores { grid-template-columns: repeat(2, 1fr); }
.weather .temp, .weather .icon { font-size: 2rem; }
header h1 { font-size: 1rem; }
.health { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
}
+134
View File
@@ -0,0 +1,134 @@
// Small touches on top of HTMX: "📍 Here", remembering the form, the route
// map, quick follow-up chips and Enter-to-send.
(() => {
const ask = document.getElementById("ask");
const place = ask.elements.place;
const lat = ask.elements.lat;
const lon = ask.elements.lon;
const locate = document.getElementById("locate");
// Remember the last choices in this browser only.
const KEY = "workout-suggester:form";
const save = () => {
const d = Object.fromEntries(new FormData(ask));
try { localStorage.setItem(KEY, JSON.stringify(d)); } catch {}
};
try {
const d = JSON.parse(localStorage.getItem(KEY) || "{}");
for (const [k, v] of Object.entries(d)) {
const el = ask.elements[k];
if (!el) continue;
if (el instanceof RadioNodeList) {
for (const r of el) r.checked = r.value === v;
} else if (v) {
el.value = v;
}
}
if (lat.value) place.placeholder = "📍 your location";
} catch {}
ask.addEventListener("change", save);
ask.addEventListener("htmx:beforeRequest", save);
// Typing a place overrides the GPS fix.
place.addEventListener("input", () => {
lat.value = lon.value = "";
place.placeholder = "Town or city";
});
locate.addEventListener("click", () => {
if (!navigator.geolocation) return alert("This browser can't share its location. Type a town instead.");
locate.disabled = true;
locate.textContent = "📍 …";
navigator.geolocation.getCurrentPosition(
(pos) => {
lat.value = pos.coords.latitude.toFixed(5);
lon.value = pos.coords.longitude.toFixed(5);
place.value = "";
place.placeholder = "📍 your location";
locate.disabled = false;
locate.textContent = "📍 Here";
save();
ask.requestSubmit();
},
(err) => {
locate.disabled = false;
locate.textContent = "📍 Here";
alert("Couldn't get your location (" + err.message + "). Type a town instead.");
},
{ enableHighAccuracy: false, timeout: 10000, maximumAge: 600000 }
);
});
// Draw route maps that arrive in HTMX swaps.
const COLORS = ["#2f8f4e", "#3b6cf6", "#c2410c"];
const drawMaps = (root) => {
if (!window.L) return;
for (const el of root.querySelectorAll(".map[data-map]:not(.leaflet-container)")) {
const d = JSON.parse(el.dataset.map);
// The view must exist before vector layers are added, or Leaflet throws.
const map = L.map(el, { scrollWheelZoom: false }).setView(d.you, 13);
L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", {
maxZoom: 18,
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
}).addTo(map);
const layers = d.routes.map((r, i) =>
L.featureGroup([
L.polyline(r.ways, { color: COLORS[i % COLORS.length], weight: i === 0 ? 5 : 3, opacity: i === 0 ? 0.9 : 0.5 }),
L.circleMarker(r.join, { radius: 5, color: COLORS[i % COLORS.length], fillOpacity: 1 }).bindTooltip("Start here"),
]).bindTooltip(r.name, { sticky: true })
);
layers.forEach((l) => l.addTo(map));
L.circleMarker(d.you, { radius: 7, color: "#fff", weight: 2, fillColor: "#111", fillOpacity: 1 })
.bindTooltip("You").addTo(map);
const focus = (i) => {
layers.forEach((l, j) => l.eachLayer((x) => x.setStyle && x.setStyle({ weight: j === i ? 5 : 3, opacity: j === i ? 0.9 : 0.4 })));
layers[i].bringToFront();
map.fitBounds(layers[i].getBounds().extend(d.you), { padding: [20, 20] });
};
focus(0);
const list = el.parentElement.querySelectorAll("[data-route]");
list.forEach((li) =>
li.addEventListener("click", (e) => {
if (e.target.closest("a")) return;
list.forEach((x) => x.classList.toggle("active", x === li));
focus(+li.dataset.route);
})
);
}
};
// Follow-up chat: Enter to send, quick chips, auto-grow.
document.body.addEventListener("keydown", (e) => {
const t = e.target;
if (t.matches("#composer textarea") && e.key === "Enter" && !e.shiftKey && !e.isComposing) {
e.preventDefault();
if (t.value.trim()) t.form.requestSubmit();
}
});
document.body.addEventListener("input", (e) => {
if (e.target.matches("#composer textarea")) {
e.target.style.height = "auto";
e.target.style.height = e.target.scrollHeight + "px";
}
});
document.body.addEventListener("click", (e) => {
const chip = e.target.closest("[data-ask]");
if (!chip) return;
const form = document.getElementById("composer");
form.elements.message.value = chip.dataset.ask;
form.requestSubmit();
});
document.body.addEventListener("htmx:afterRequest", (e) => {
if (e.target.id === "composer" && e.detail.successful) {
e.target.reset();
e.target.elements.message.style.height = "auto";
}
});
// outerHTML swaps replace the event target, so just look everywhere.
document.body.addEventListener("htmx:afterSettle", (e) => {
drawMaps(document);
if (e.target.id === "plan") e.target.scrollIntoView({ behavior: "smooth", block: "start" });
});
addEventListener("load", () => drawMaps(document));
})();
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="7" fill="#2f8f4e"/><path d="M7 25c2-6 1-10 0-14 3 3 4 7 4 14M14 25c0-7 1-12 3-17 1 6 1 11 0 17M20 25c1-5 3-8 6-10-1 4-2 7-3 10" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round"/></svg>

After

Width:  |  Height:  |  Size: 295 B

+1
View File
@@ -0,0 +1 @@
var htmx=function(){"use strict";const Y={onLoad:null,process:null,on:null,off:null,trigger:null,ajax:null,find:null,findAll:null,closest:null,values:function(e,t){const n=pn(e,t||"post");return n.values},remove:null,addClass:null,removeClass:null,toggleClass:null,takeClass:null,swap:null,defineExtension:null,removeExtension:null,logAll:null,logNone:null,logger:null,config:{historyEnabled:true,historyCacheSize:10,refreshOnHistoryMiss:false,defaultSwapStyle:"innerHTML",defaultSwapDelay:0,defaultSettleDelay:20,includeIndicatorStyles:true,indicatorClass:"htmx-indicator",requestClass:"htmx-request",addedClass:"htmx-added",settlingClass:"htmx-settling",swappingClass:"htmx-swapping",allowEval:true,allowScriptTags:true,inlineScriptNonce:"",inlineStyleNonce:"",attributesToSettle:["class","style","width","height"],withCredentials:false,timeout:0,wsReconnectDelay:"full-jitter",wsBinaryType:"blob",disableSelector:"[hx-disable], [data-hx-disable]",scrollBehavior:"instant",defaultFocusScroll:false,getCacheBusterParam:false,globalViewTransitions:false,methodsThatUseUrlParams:["get","delete"],selfRequestsOnly:true,ignoreTitle:false,scrollIntoViewOnBoost:true,triggerSpecsCache:null,disableInheritance:false,responseHandling:[{code:"204",swap:false},{code:"[23]..",swap:true},{code:"[45]..",swap:false,error:true}],allowNestedOobSwaps:true,historyRestoreAsHxRequest:true,reportValidityOfForms:false},parseInterval:null,location:location,_:null,version:"2.0.11"};Y.onLoad=V;Y.process=Bt;Y.on=ye;Y.off=xe;Y.trigger=ae;Y.ajax=In;Y.find=a;Y.findAll=x;Y.closest=g;Y.remove=z;Y.addClass=w;Y.removeClass=S;Y.toggleClass=G;Y.takeClass=W;Y.swap=C;Y.defineExtension=zn;Y.removeExtension=Jn;Y.logAll=$;Y.logNone=_;Y.parseInterval=d;Y._=e;const n={addTriggerHandler:Et,bodyContains:ie,canAccessLocalStorage:U,findThisElement:we,filterValues:xn,swap:C,hasAttribute:s,getAttributeValue:f,getClosestAttributeValue:te,getClosestMatch:A,getExpressionVars:qn,getHeaders:yn,getInputValues:pn,getInternalData:re,getSwapSpecification:vn,getTriggerSpecs:lt,getTarget:Se,makeFragment:D,mergeObjects:se,makeSettleInfo:En,oobSwap:Te,querySelectorExt:ce,settleImmediately:Qt,shouldCancel:dt,triggerEvent:ae,triggerErrorEvent:ue,withExtensions:Vt};const he=["get","post","put","delete","patch"];const R=he.map(function(e){return"[hx-"+e+"], [data-hx-"+e+"]"}).join(", ");function d(e){if(e==undefined){return undefined}let t=NaN;if(e.slice(-2)=="ms"){t=parseFloat(e.slice(0,-2))}else if(e.slice(-1)=="s"){t=parseFloat(e.slice(0,-1))*1e3}else if(e.slice(-1)=="m"){t=parseFloat(e.slice(0,-1))*1e3*60}else{t=parseFloat(e)}return isNaN(t)?undefined:t}function Q(e,t){return e instanceof Element&&e.getAttribute(t)}function s(e,t){return!!e.hasAttribute&&(e.hasAttribute(t)||e.hasAttribute("data-"+t))}function f(e,t){return Q(e,t)||Q(e,"data-"+t)}function u(e){const t=e.parentElement;if(!t&&e.parentNode instanceof ShadowRoot)return e.parentNode;return t}function ee(){return document}function q(e,t){return e.getRootNode?e.getRootNode({composed:t}):ee()}function A(e,t){while(e&&!t(e)){e=u(e)}return e||null}function o(e,t,n){const r=f(t,n);const o=f(t,"hx-disinherit");var i=f(t,"hx-inherit");if(e!==t){if(Y.config.disableInheritance){if(i&&(i==="*"||i.split(" ").indexOf(n)>=0)){return r}else{return null}}if(o&&(o==="*"||o.split(" ").indexOf(n)>=0)){return"unset"}}return r}function te(t,n){let r=null;A(t,function(e){return!!(r=o(t,le(e),n))});if(r!=="unset"){return r}}function h(e,t){return e instanceof Element&&e.matches(t)}function N(e){const t=/<([a-z][^\/\0>\x20\t\r\n\f]*)/i;const n=t.exec(e);if(n){return n[1].toLowerCase()}else{return""}}function I(e){if("parseHTMLUnsafe"in Document){return Document.parseHTMLUnsafe(e)}const t=new DOMParser;return t.parseFromString(e,"text/html")}function L(e,t){while(t.childNodes.length>0){e.append(t.childNodes[0])}}function r(e){const t=ee().createElement("script");oe(e.attributes,function(e){t.setAttribute(e.name,e.value)});t.textContent=e.textContent;t.async=false;if(Y.config.inlineScriptNonce){t.nonce=Y.config.inlineScriptNonce}return t}function i(e){return e.matches("script")&&(e.type==="text/javascript"||e.type==="module"||e.type==="")}function k(e){if(!Y.config.allowScriptTags){e.querySelectorAll("script").forEach(e=>e.remove());return}Array.from(e.querySelectorAll("script")).forEach(e=>{if(i(e)){const t=r(e);const n=e.parentNode;try{n.insertBefore(t,e)}catch(e){T(e)}finally{e.remove()}}})}function D(e){e=e.replace(/<hx-([a-z]+)((?:\s[^>]*)?)>/gi,'<template hx type="$1"$2>').replace(/<\/hx-[a-z]+>/gi,"</template>");const t=e.replace(/<head(\s[^>]*)?>[\s\S]*?<\/head>/i,"");const n=N(t);let r;if(n==="html"){r=new DocumentFragment;const i=I(e);L(r,i.body);r.title=i.title}else if(n==="body"){r=new DocumentFragment;const i=I(t);L(r,i.body);r.title=i.title}else{const i=I('<body><template class="internal-htmx-wrapper">'+t+"</template></body>");r=i.querySelector("template").content;r.title=i.title;var o=r.querySelector("title");if(o&&o.parentNode===r){oLine truncated
+661
View File
@@ -0,0 +1,661 @@
/* required styles */
.leaflet-pane,
.leaflet-tile,
.leaflet-marker-icon,
.leaflet-marker-shadow,
.leaflet-tile-container,
.leaflet-pane > svg,
.leaflet-pane > canvas,
.leaflet-zoom-box,
.leaflet-image-layer,
.leaflet-layer {
position: absolute;
left: 0;
top: 0;
}
.leaflet-container {
overflow: hidden;
}
.leaflet-tile,
.leaflet-marker-icon,
.leaflet-marker-shadow {
-webkit-user-select: none;
-moz-user-select: none;
user-select: none;
-webkit-user-drag: none;
}
/* Prevents IE11 from highlighting tiles in blue */
.leaflet-tile::selection {
background: transparent;
}
/* Safari renders non-retina tile on retina better with this, but Chrome is worse */
.leaflet-safari .leaflet-tile {
image-rendering: -webkit-optimize-contrast;
}
/* hack that prevents hw layers "stretching" when loading new tiles */
.leaflet-safari .leaflet-tile-container {
width: 1600px;
height: 1600px;
-webkit-transform-origin: 0 0;
}
.leaflet-marker-icon,
.leaflet-marker-shadow {
display: block;
}
/* .leaflet-container svg: reset svg max-width decleration shipped in Joomla! (joomla.org) 3.x */
/* .leaflet-container img: map is broken in FF if you have max-width: 100% on tiles */
.leaflet-container .leaflet-overlay-pane svg {
max-width: none !important;
max-height: none !important;
}
.leaflet-container .leaflet-marker-pane img,
.leaflet-container .leaflet-shadow-pane img,
.leaflet-container .leaflet-tile-pane img,
.leaflet-container img.leaflet-image-layer,
.leaflet-container .leaflet-tile {
max-width: none !important;
max-height: none !important;
width: auto;
padding: 0;
}
.leaflet-container img.leaflet-tile {
/* See: https://bugs.chromium.org/p/chromium/issues/detail?id=600120 */
mix-blend-mode: plus-lighter;
}
.leaflet-container.leaflet-touch-zoom {
-ms-touch-action: pan-x pan-y;
touch-action: pan-x pan-y;
}
.leaflet-container.leaflet-touch-drag {
-ms-touch-action: pinch-zoom;
/* Fallback for FF which doesn't support pinch-zoom */
touch-action: none;
touch-action: pinch-zoom;
}
.leaflet-container.leaflet-touch-drag.leaflet-touch-zoom {
-ms-touch-action: none;
touch-action: none;
}
.leaflet-container {
-webkit-tap-highlight-color: transparent;
}
.leaflet-container a {
-webkit-tap-highlight-color: rgba(51, 181, 229, 0.4);
}
.leaflet-tile {
filter: inherit;
visibility: hidden;
}
.leaflet-tile-loaded {
visibility: inherit;
}
.leaflet-zoom-box {
width: 0;
height: 0;
-moz-box-sizing: border-box;
box-sizing: border-box;
z-index: 800;
}
/* workaround for https://bugzilla.mozilla.org/show_bug.cgi?id=888319 */
.leaflet-overlay-pane svg {
-moz-user-select: none;
}
.leaflet-pane { z-index: 400; }
.leaflet-tile-pane { z-index: 200; }
.leaflet-overlay-pane { z-index: 400; }
.leaflet-shadow-pane { z-index: 500; }
.leaflet-marker-pane { z-index: 600; }
.leaflet-tooltip-pane { z-index: 650; }
.leaflet-popup-pane { z-index: 700; }
.leaflet-map-pane canvas { z-index: 100; }
.leaflet-map-pane svg { z-index: 200; }
.leaflet-vml-shape {
width: 1px;
height: 1px;
}
.lvml {
behavior: url(#default#VML);
display: inline-block;
position: absolute;
}
/* control positioning */
.leaflet-control {
position: relative;
z-index: 800;
pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
pointer-events: auto;
}
.leaflet-top,
.leaflet-bottom {
position: absolute;
z-index: 1000;
pointer-events: none;
}
.leaflet-top {
top: 0;
}
.leaflet-right {
right: 0;
}
.leaflet-bottom {
bottom: 0;
}
.leaflet-left {
left: 0;
}
.leaflet-control {
float: left;
clear: both;
}
.leaflet-right .leaflet-control {
float: right;
}
.leaflet-top .leaflet-control {
margin-top: 10px;
}
.leaflet-bottom .leaflet-control {
margin-bottom: 10px;
}
.leaflet-left .leaflet-control {
margin-left: 10px;
}
.leaflet-right .leaflet-control {
margin-right: 10px;
}
/* zoom and fade animations */
.leaflet-fade-anim .leaflet-popup {
opacity: 0;
-webkit-transition: opacity 0.2s linear;
-moz-transition: opacity 0.2s linear;
transition: opacity 0.2s linear;
}
.leaflet-fade-anim .leaflet-map-pane .leaflet-popup {
opacity: 1;
}
.leaflet-zoom-animated {
-webkit-transform-origin: 0 0;
-ms-transform-origin: 0 0;
transform-origin: 0 0;
}
svg.leaflet-zoom-animated {
will-change: transform;
}
.leaflet-zoom-anim .leaflet-zoom-animated {
-webkit-transition: -webkit-transform 0.25s cubic-bezier(0,0,0.25,1);
-moz-transition: -moz-transform 0.25s cubic-bezier(0,0,0.25,1);
transition: transform 0.25s cubic-bezier(0,0,0.25,1);
}
.leaflet-zoom-anim .leaflet-tile,
.leaflet-pan-anim .leaflet-tile {
-webkit-transition: none;
-moz-transition: none;
transition: none;
}
.leaflet-zoom-anim .leaflet-zoom-hide {
visibility: hidden;
}
/* cursors */
.leaflet-interactive {
cursor: pointer;
}
.leaflet-grab {
cursor: -webkit-grab;
cursor: -moz-grab;
cursor: grab;
}
.leaflet-crosshair,
.leaflet-crosshair .leaflet-interactive {
cursor: crosshair;
}
.leaflet-popup-pane,
.leaflet-control {
cursor: auto;
}
.leaflet-dragging .leaflet-grab,
.leaflet-dragging .leaflet-grab .leaflet-interactive,
.leaflet-dragging .leaflet-marker-draggable {
cursor: move;
cursor: -webkit-grabbing;
cursor: -moz-grabbing;
cursor: grabbing;
}
/* marker & overlays interactivity */
.leaflet-marker-icon,
.leaflet-marker-shadow,
.leaflet-image-layer,
.leaflet-pane > svg path,
.leaflet-tile-container {
pointer-events: none;
}
.leaflet-marker-icon.leaflet-interactive,
.leaflet-image-layer.leaflet-interactive,
.leaflet-pane > svg path.leaflet-interactive,
svg.leaflet-image-layer.leaflet-interactive path {
pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
pointer-events: auto;
}
/* visual tweaks */
.leaflet-container {
background: #ddd;
outline-offset: 1px;
}
.leaflet-container a {
color: #0078A8;
}
.leaflet-zoom-box {
border: 2px dotted #38f;
background: rgba(255,255,255,0.5);
}
/* general typography */
.leaflet-container {
font-family: "Helvetica Neue", Arial, Helvetica, sans-serif;
font-size: 12px;
font-size: 0.75rem;
line-height: 1.5;
}
/* general toolbar styles */
.leaflet-bar {
box-shadow: 0 1px 5px rgba(0,0,0,0.65);
border-radius: 4px;
}
.leaflet-bar a {
background-color: #fff;
border-bottom: 1px solid #ccc;
width: 26px;
height: 26px;
line-height: 26px;
display: block;
text-align: center;
text-decoration: none;
color: black;
}
.leaflet-bar a,
.leaflet-control-layers-toggle {
background-position: 50% 50%;
background-repeat: no-repeat;
display: block;
}
.leaflet-bar a:hover,
.leaflet-bar a:focus {
background-color: #f4f4f4;
}
.leaflet-bar a:first-child {
border-top-left-radius: 4px;
border-top-right-radius: 4px;
}
.leaflet-bar a:last-child {
border-bottom-left-radius: 4px;
border-bottom-right-radius: 4px;
border-bottom: none;
}
.leaflet-bar a.leaflet-disabled {
cursor: default;
background-color: #f4f4f4;
color: #bbb;
}
.leaflet-touch .leaflet-bar a {
width: 30px;
height: 30px;
line-height: 30px;
}
.leaflet-touch .leaflet-bar a:first-child {
border-top-left-radius: 2px;
border-top-right-radius: 2px;
}
.leaflet-touch .leaflet-bar a:last-child {
border-bottom-left-radius: 2px;
border-bottom-right-radius: 2px;
}
/* zoom control */
.leaflet-control-zoom-in,
.leaflet-control-zoom-out {
font: bold 18px 'Lucida Console', Monaco, monospace;
text-indent: 1px;
}
.leaflet-touch .leaflet-control-zoom-in, .leaflet-touch .leaflet-control-zoom-out {
font-size: 22px;
}
/* layers control */
.leaflet-control-layers {
box-shadow: 0 1px 5px rgba(0,0,0,0.4);
background: #fff;
border-radius: 5px;
}
.leaflet-control-layers-toggle {
background-image: url(images/layers.png);
width: 36px;
height: 36px;
}
.leaflet-retina .leaflet-control-layers-toggle {
background-image: url(images/layers-2x.png);
background-size: 26px 26px;
}
.leaflet-touch .leaflet-control-layers-toggle {
width: 44px;
height: 44px;
}
.leaflet-control-layers .leaflet-control-layers-list,
.leaflet-control-layers-expanded .leaflet-control-layers-toggle {
display: none;
}
.leaflet-control-layers-expanded .leaflet-control-layers-list {
display: block;
position: relative;
}
.leaflet-control-layers-expanded {
padding: 6px 10px 6px 6px;
color: #333;
background: #fff;
}
.leaflet-control-layers-scrollbar {
overflow-y: scroll;
overflow-x: hidden;
padding-right: 5px;
}
.leaflet-control-layers-selector {
margin-top: 2px;
position: relative;
top: 1px;
}
.leaflet-control-layers label {
display: block;
font-size: 13px;
font-size: 1.08333em;
}
.leaflet-control-layers-separator {
height: 0;
border-top: 1px solid #ddd;
margin: 5px -10px 5px -6px;
}
/* Default icon URLs */
.leaflet-default-icon-path { /* used only in path-guessing heuristic, see L.Icon.Default */
background-image: url(images/marker-icon.png);
}
/* attribution and scale controls */
.leaflet-container .leaflet-control-attribution {
background: #fff;
background: rgba(255, 255, 255, 0.8);
margin: 0;
}
.leaflet-control-attribution,
.leaflet-control-scale-line {
padding: 0 5px;
color: #333;
line-height: 1.4;
}
.leaflet-control-attribution a {
text-decoration: none;
}
.leaflet-control-attribution a:hover,
.leaflet-control-attribution a:focus {
text-decoration: underline;
}
.leaflet-attribution-flag {
display: inline !important;
vertical-align: baseline !important;
width: 1em;
height: 0.6669em;
}
.leaflet-left .leaflet-control-scale {
margin-left: 5px;
}
.leaflet-bottom .leaflet-control-scale {
margin-bottom: 5px;
}
.leaflet-control-scale-line {
border: 2px solid #777;
border-top: none;
line-height: 1.1;
padding: 2px 5px 1px;
white-space: nowrap;
-moz-box-sizing: border-box;
box-sizing: border-box;
background: rgba(255, 255, 255, 0.8);
text-shadow: 1px 1px #fff;
}
.leaflet-control-scale-line:not(:first-child) {
border-top: 2px solid #777;
border-bottom: none;
margin-top: -2px;
}
.leaflet-control-scale-line:not(:first-child):not(:last-child) {
border-bottom: 2px solid #777;
}
.leaflet-touch .leaflet-control-attribution,
.leaflet-touch .leaflet-control-layers,
.leaflet-touch .leaflet-bar {
box-shadow: none;
}
.leaflet-touch .leaflet-control-layers,
.leaflet-touch .leaflet-bar {
border: 2px solid rgba(0,0,0,0.2);
background-clip: padding-box;
}
/* popup */
.leaflet-popup {
position: absolute;
text-align: center;
margin-bottom: 20px;
}
.leaflet-popup-content-wrapper {
padding: 1px;
text-align: left;
border-radius: 12px;
}
.leaflet-popup-content {
margin: 13px 24px 13px 20px;
line-height: 1.3;
font-size: 13px;
font-size: 1.08333em;
min-height: 1px;
}
.leaflet-popup-content p {
margin: 17px 0;
margin: 1.3em 0;
}
.leaflet-popup-tip-container {
width: 40px;
height: 20px;
position: absolute;
left: 50%;
margin-top: -1px;
margin-left: -20px;
overflow: hidden;
pointer-events: none;
}
.leaflet-popup-tip {
width: 17px;
height: 17px;
padding: 1px;
margin: -10px auto 0;
pointer-events: auto;
-webkit-transform: rotate(45deg);
-moz-transform: rotate(45deg);
-ms-transform: rotate(45deg);
transform: rotate(45deg);
}
.leaflet-popup-content-wrapper,
.leaflet-popup-tip {
background: white;
color: #333;
box-shadow: 0 3px 14px rgba(0,0,0,0.4);
}
.leaflet-container a.leaflet-popup-close-button {
position: absolute;
top: 0;
right: 0;
border: none;
text-align: center;
width: 24px;
height: 24px;
font: 16px/24px Tahoma, Verdana, sans-serif;
color: #757575;
text-decoration: none;
background: transparent;
}
.leaflet-container a.leaflet-popup-close-button:hover,
.leaflet-container a.leaflet-popup-close-button:focus {
color: #585858;
}
.leaflet-popup-scrolled {
overflow: auto;
}
.leaflet-oldie .leaflet-popup-content-wrapper {
-ms-zoom: 1;
}
.leaflet-oldie .leaflet-popup-tip {
width: 24px;
margin: 0 auto;
-ms-filter: "progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678)";
filter: progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678);
}
.leaflet-oldie .leaflet-control-zoom,
.leaflet-oldie .leaflet-control-layers,
.leaflet-oldie .leaflet-popup-content-wrapper,
.leaflet-oldie .leaflet-popup-tip {
border: 1px solid #999;
}
/* div icon */
.leaflet-div-icon {
background: #fff;
border: 1px solid #666;
}
/* Tooltip */
/* Base styles for the element that has a tooltip */
.leaflet-tooltip {
position: absolute;
padding: 6px;
background-color: #fff;
border: 1px solid #fff;
border-radius: 3px;
color: #222;
white-space: nowrap;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
pointer-events: none;
box-shadow: 0 1px 3px rgba(0,0,0,0.4);
}
.leaflet-tooltip.leaflet-interactive {
cursor: pointer;
pointer-events: auto;
}
.leaflet-tooltip-top:before,
.leaflet-tooltip-bottom:before,
.leaflet-tooltip-left:before,
.leaflet-tooltip-right:before {
position: absolute;
pointer-events: none;
border: 6px solid transparent;
background: transparent;
content: "";
}
/* Directions */
.leaflet-tooltip-bottom {
margin-top: 6px;
}
.leaflet-tooltip-top {
margin-top: -6px;
}
.leaflet-tooltip-bottom:before,
.leaflet-tooltip-top:before {
left: 50%;
margin-left: -6px;
}
.leaflet-tooltip-top:before {
bottom: 0;
margin-bottom: -12px;
border-top-color: #fff;
}
.leaflet-tooltip-bottom:before {
top: 0;
margin-top: -12px;
margin-left: -6px;
border-bottom-color: #fff;
}
.leaflet-tooltip-left {
margin-left: -6px;
}
.leaflet-tooltip-right {
margin-left: 6px;
}
.leaflet-tooltip-left:before,
.leaflet-tooltip-right:before {
top: 50%;
margin-top: -6px;
}
.leaflet-tooltip-left:before {
right: 0;
margin-right: -12px;
border-left-color: #fff;
}
.leaflet-tooltip-right:before {
left: 0;
margin-left: -12px;
border-right-color: #fff;
}
/* Printing */
@media print {
/* Prevent printers from removing background-images of controls. */
.leaflet-control {
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
}
+6
View File
@@ -0,0 +1,6 @@
/* @preserve
* Leaflet 1.9.4, a JS library for interactive maps. https://leafletjs.com
* (c) 2010-2023 Vladimir Agafonkin, (c) 2010-2011 CloudMade
*/
!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?e(exports):"function"==typeof define&&define.amd?define(["exports"],e):e((t="undefined"!=typeof globalThis?globalThis:t||self).leaflet={})}(this,function(t){"use strict";function l(t){for(var e,i,n=1,o=arguments.length;n<o;n++)for(e in i=arguments[n])t[e]=i[e];return t}var R=Object.create||function(t){return N.prototype=t,new N};function N(){}function a(t,e){var i,n=Array.prototype.slice;return t.bind?t.bind.apply(t,n.call(arguments,1)):(i=n.call(arguments,2),function(){return t.apply(e,i.length?i.concat(n.call(arguments)):arguments)})}var D=0;function h(t){return"_leaflet_id"in t||(t._leaflet_id=++D),t._leaflet_id}function j(t,e,i){var n,o,s=function(){n=!1,o&&(r.apply(i,o),o=!1)},r=function(){n?o=arguments:(t.apply(i,arguments),setTimeout(s,e),n=!0)};return r}function H(t,e,i){var n=e[1],e=e[0],o=n-e;return t===n&&i?t:((t-e)%o+o)%o+e}function u(){return!1}function i(t,e){return!1===e?t:(e=Math.pow(10,void 0===e?6:e),Math.round(t*e)/e)}function W(t){return t.trim?t.trim():t.replace(/^\s+|\s+$/g,"")}function F(t){return W(t).split(/\s+/)}function c(t,e){for(var i in Object.prototype.hasOwnProperty.call(t,"options")||(t.options=t.options?R(t.options):{}),e)t.options[i]=e[i];return t.options}function U(t,e,i){var n,o=[];for(n in t)o.push(encodeURIComponent(i?n.toUpperCase():n)+"="+encodeURIComponent(t[n]));return(e&&-1!==e.indexOf("?")?"&":"?")+o.join("&")}var V=/\{ *([\w_ -]+) *\}/g;function q(t,i){return t.replace(V,function(t,e){e=i[e];if(void 0===e)throw new Error("No value provided for variable "+t);return e="function"==typeof e?e(i):e})}var d=Array.isArray||function(t){return"[object Array]"===Object.prototype.toString.call(t)};function G(t,e){for(var i=0;i<t.length;i++)if(t[i]===e)return i;return-1}var K="data:image/gif;base64,R0lGODlhAQABAAD/ACwAAAAAAQABAAACADs=";function Y(t){return window["webkit"+t]||window["moz"+t]||window["ms"+t]}var X=0;function J(t){var e=+new Date,i=Math.max(0,16-(e-X));return X=e+i,window.setTimeout(t,i)}var $=window.requestAnimationFrame||Y("RequestAnimationFrame")||J,Q=window.cancelAnimationFrame||Y("CancelAnimationFrame")||Y("CancelRequestAnimationFrame")||function(t){window.clearTimeout(t)};function x(t,e,i){if(!i||$!==J)return $.call(window,a(t,e));t.call(e)}function r(t){t&&Q.call(window,t)}var tt={__proto__:null,extend:l,create:R,bind:a,get lastId(){return D},stamp:h,throttle:j,wrapNum:H,falseFn:u,formatNum:i,trim:W,splitWords:F,setOptions:c,getParamString:U,template:q,isArray:d,indexOf:G,emptyImageUrl:K,requestFn:$,cancelFn:Q,requestAnimFrame:x,cancelAnimFrame:r};function et(){}et.extend=function(t){function e(){c(this),this.initialize&&this.initialize.apply(this,arguments),this.callInitHooks()}var i,n=e.__super__=this.prototype,o=R(n);for(i in(o.constructor=e).prototype=o,this)Object.prototype.hasOwnProperty.call(this,i)&&"prototype"!==i&&"__super__"!==i&&(e[i]=this[i]);if(t.statics&&l(e,t.statics),t.includes){var s=t.includes;if("undefined"!=typeof L&&L&&L.Mixin){s=d(s)?s:[s];for(var r=0;r<s.length;r++)s[r]===L.Mixin.Events&&console.warn("Deprecated include of L.Mixin.Events: this property will be removed in future releases, please inherit from L.Evented instead.",(new Error).stack)}l.apply(null,[o].concat(t.includes))}return l(o,t),delete o.statics,delete o.includes,o.options&&(o.options=n.options?R(n.options):{},l(o.options,t.options)),o._initHooks=[],o.callInitHooks=function(){if(!this._initHooksCalled){n.callInitHooks&&n.callInitHooks.call(this),this._initHooksCalled=!0;for(var t=0,e=o._initHooks.length;t<e;t++)o._initHooks[t].call(this)}},e},et.include=function(t){var e=this.prototype.options;return l(this.prototype,t),t.options&&(this.prototype.options=e,this.mergeOptions(t.options)),this},et.mergeOptions=function(t){return l(this.prototype.options,t),this},et.addInitHook=function(t){var e=Array.prototype.slice.call(arguments,1),i="function"==typeof t?t:function(){this[t].apply(this,e)};return this.prototype._initHooks=this.prototype._initHooks||[],this.prototype._initHooks.push(i),this};var e={on:function(t,e,i){if("object"==typeof t)for(var n in t)this._on(n,t[n],e);else for(var o=0,s=(t=F(t)).length;o<s;o++)this._on(t[o],e,i);return this},off:function(t,e,i){if(arguments.length)if("object"==typeof t)for(var n in t)this._off(n,t[n],e);else{t=F(t);for(var o=1===arguments.length,s=0,r=t.length;s<r;s++)o?this._off(t[s]):this._off(t[s],e,i)}else delete this._events;return this},_on:function(t,e,i,n){"function"!=typeof e?console.warn("wrong listener type: "+typeof e):!1===this._listens(t,e,i)&&(e={fn:e,ctx:i=i===this?void 0:i},n&&(e.once=!0),this._events=this._events||{},this._events[t]=this._events[t]||[],this._events[t].push(e))},_off:function(t,e,i){var n,o,s;if(this._events&&(n=this._events[t]))if(1===arguments.length){if(this._firingCount)for(o=0,s=n.length;o<s;o++)n[o].fn=u;delete this._events[t]}else"function"!=typeof e?console.warn("wrong listener type: "+typeof e):!1!==(e=this._listens(Line truncated
//# sourceMappingURL=leaflet.js.map
+1
View File
@@ -0,0 +1 @@
(function(){var g;htmx.defineExtension("sse",{init:function(e){g=e;if(htmx.createEventSource==undefined){htmx.createEventSource=t}},getSelectors:function(){return["[sse-connect]","[data-sse-connect]","[sse-swap]","[data-sse-swap]"]},onEvent:function(e,t){var r=t.target||t.detail.elt;switch(e){case"htmx:beforeCleanupElement":var n=g.getInternalData(r);var s=n.sseEventSource;if(s){g.triggerEvent(r,"htmx:sseClose",{source:s,type:"nodeReplaced"});n.sseEventSource.close()}return;case"htmx:afterProcessNode":i(r)}}});function t(e){return new EventSource(e,{withCredentials:true})}function a(n){if(g.getAttributeValue(n,"sse-swap")){var s=g.getClosestMatch(n,v);if(s==null){return null}var e=g.getInternalData(s);var a=e.sseEventSource;var t=g.getAttributeValue(n,"sse-swap");var r=t.split(",");for(var i=0;i<r.length;i++){const u=r[i].trim();const c=function(e){if(l(s)){return}if(!g.bodyContains(n)){a.removeEventListener(u,c);return}if(!g.triggerEvent(n,"htmx:sseBeforeMessage",e)){return}f(n,e.data);g.triggerEvent(n,"htmx:sseMessage",e)};g.getInternalData(n).sseEventListener=c;a.addEventListener(u,c)}}if(g.getAttributeValue(n,"hx-trigger")){var s=g.getClosestMatch(n,v);if(s==null){return null}var e=g.getInternalData(s);var a=e.sseEventSource;var o=g.getTriggerSpecs(n);o.forEach(function(t){if(t.trigger.slice(0,4)!=="sse:"){return}var r=function(e){if(l(s)){return}if(!g.bodyContains(n)){a.removeEventListener(t.trigger.slice(4),r)}htmx.trigger(n,t.trigger,e);htmx.trigger(n,"htmx:sseMessage",e)};g.getInternalData(n).sseEventListener=r;a.addEventListener(t.trigger.slice(4),r)})}}function i(e,t){if(e==null){return null}if(g.getAttributeValue(e,"sse-connect")){var r=g.getAttributeValue(e,"sse-connect");if(r==null){return}n(e,r,t)}a(e)}function n(r,e,n){var s=htmx.createEventSource(e);s.onerror=function(e){g.triggerErrorEvent(r,"htmx:sseError",{error:e,source:s});if(l(r)){return}if(s.readyState===EventSource.CLOSED){n=n||0;n=Math.max(Math.min(n*2,128),1);var t=n*500;window.setTimeout(function(){i(r,n)},t)}};s.onopen=function(e){g.triggerEvent(r,"htmx:sseOpen",{source:s});if(n&&n>0){const t=r.querySelectorAll("[sse-swap], [data-sse-swap], [hx-trigger], [data-hx-trigger]");for(let e=0;e<t.length;e++){a(t[e])}n=0}};g.getInternalData(r).sseEventSource=s;var t=g.getAttributeValue(r,"sse-close");if(t){s.addEventListener(t,function(){g.triggerEvent(r,"htmx:sseClose",{source:s,type:"message"});s.close()})}}function l(e){if(!g.bodyContains(e)){var t=g.getInternalData(e).sseEventSource;if(t!=undefined){g.triggerEvent(e,"htmx:sseClose",{source:t,type:"nodeMissing"});t.close();return true}}return false}function f(t,r){g.withExtensions(t,function(e){r=e.transformResponse(r,null,t)});var e=g.getSwapSpecification(t);var n=g.getTarget(t);g.swap(n,r,e,{contextElement:t})}function v(e){return g.getInternalData(e).sseEventSource!=null}})();
+160
View File
@@ -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)
}
+21
View File
@@ -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)
}
}
+302
View File
@@ -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