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) { workout-suggester

🌿 workout-suggester

@Health(HealthInfo{Checking: true})
@Form(d.DefaultPlace)
if d.Plan != nil { @PlanView(*d.Plan, d.Messages) }
} templ Form(defaultPlace string) {
Up for @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)
How hard @radio("intensity", "easy", "😌 Easy", false) @radio("intensity", "moderate", "🙂 Moderate", true) @radio("intensity", "hard", "🔥 Hard", false)
How long for _, m := range []int{30, 45, 60, 90, 120} { @radio("minutes", fmt.Sprint(m), minutesLabel(m), m == 60) }
} templ radio(name, value, label string, checked bool) { } templ Error(msg string) {
⚠️ { msg }
} // 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)
for _, m := range msgs { if m.Role == "assistant" && m.State != session.Done { @StreamingReply(m) } else { @MessageView(m) } }
if p.Assessment.Pick != coach.Indoor { if p.RoutesLoaded { @RoutesCard(p) } else {

🗺️ Routes nearby

Planning loops from your door and looking up signposted routes…

} } } templ WeatherCard(p coach.Plan) { {{ f := p.Forecast }} {{ desc, _ := weather.Describe(f.Now.Code) }}
{ f.Now.Icon() } { fmt.Sprintf("%.0f°", f.Now.Temp) }
{ desc }, feels like { fmt.Sprintf("%.0f°", f.Now.Feels) }
{ placeName(p.Place) } · { f.Now.Time.Format("Mon 15:04") } · via { f.Source }
    for _, h := range nextHours(f, 8) {
  1. { h.Time.Format("15h") } { h.Icon() } { fmt.Sprintf("%.0f°", h.Temp) } { h.Chance() } { fmt.Sprintf("%.0f", h.Gusts) }
  2. }
} templ Scores(p coach.Plan) { {{ a := p.Assessment }}
for _, s := range a.Scores {
{ s.Activity.Label() }
{ fmt.Sprint(s.Value) }
{ s.Verdict() }
}
{ coach.Indoor.Label() }
🏋️
if a.Pick == coach.Indoor { today } else { plan B }
if a.Later != nil {

⏰ Better at { a.Later.Start.Format("15:04") }: { a.Later.Activity.Noun() } scores { fmt.Sprint(a.Later.Score) }.

} if why := pickedReasons(p); why != "" {

Why: { why }

} } templ RoutesCard(p coach.Plan) {

🗺️ Routes

if len(p.Routes) == 0 {

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

} else {
    for i, s := range p.Routes {
  1. if s.Route.Generated { from your door } else { signposted } { s.Route.Name }
    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 }
  2. }
} if j := p.Junction; j != nil {

📍 Nearest { junctionKind(j) } junction: { j.Ref } { fmt.Sprintf("%.1f km", j.Km) } away

}
} // MessageView renders a finished chat message. templ MessageView(m session.Message) {
if m.Role == "assistant" {
@Markdown(m.Content)
if m.Err != "" {

⚠️ { m.Err }

} } else {
{ m.Content }
}
} // 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) {
} templ Token(text string) { { text } } // 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) {
if h.Checking { connecting… } else if h.OK { { h.Model } } else { model offline }
}