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