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.
161 lines
4.2 KiB
Go
161 lines
4.2 KiB
Go
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)
|
|
}
|