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:
42 files changed
+7219
No files matched your search
@@ -0,0 +1,324 @@
|
||||
// Package coach decides which workouts the weather allows. The scoring is
|
||||
// plain rules so the numbers are always right; the language model only
|
||||
// explains the result and fills in the workout itself.
|
||||
package coach
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
"git.b0b.be/bdeb/workout-suggester/internal/routes"
|
||||
"git.b0b.be/bdeb/workout-suggester/internal/weather"
|
||||
)
|
||||
|
||||
// Activity is something to do.
|
||||
type Activity string
|
||||
|
||||
const (
|
||||
Any Activity = ""
|
||||
Run Activity = "run"
|
||||
Ride Activity = "ride"
|
||||
Walk Activity = "walk"
|
||||
Indoor Activity = "indoor"
|
||||
)
|
||||
|
||||
// Outdoor lists the activities we score against the weather.
|
||||
var Outdoor = []Activity{Run, Ride, Walk}
|
||||
|
||||
// Label is a human name with an emoji.
|
||||
func (a Activity) Label() string {
|
||||
switch a {
|
||||
case Run:
|
||||
return "🏃 Run"
|
||||
case Ride:
|
||||
return "🚴 Ride"
|
||||
case Walk:
|
||||
return "🥾 Walk / hike"
|
||||
case Indoor:
|
||||
return "🏠 Indoor workout"
|
||||
}
|
||||
return "✨ Surprise me"
|
||||
}
|
||||
|
||||
// Noun is the activity as a word for prompts.
|
||||
func (a Activity) Noun() string {
|
||||
switch a {
|
||||
case Run:
|
||||
return "running"
|
||||
case Ride:
|
||||
return "cycling"
|
||||
case Walk:
|
||||
return "walking/hiking"
|
||||
case Indoor:
|
||||
return "an indoor workout (HIIT, bodyweight or gym)"
|
||||
}
|
||||
return "anything"
|
||||
}
|
||||
|
||||
// RouteKind is the kind of signposted route that suits the activity.
|
||||
func (a Activity) RouteKind() routes.Kind {
|
||||
if a == Ride {
|
||||
return routes.Bike
|
||||
}
|
||||
return routes.Foot
|
||||
}
|
||||
|
||||
// Intensity is how hard the user wants to go.
|
||||
type Intensity string
|
||||
|
||||
const (
|
||||
Easy Intensity = "easy"
|
||||
Moderate Intensity = "moderate"
|
||||
Hard Intensity = "hard"
|
||||
)
|
||||
|
||||
// Request is what the user asked for.
|
||||
type Request struct {
|
||||
Activity Activity
|
||||
Intensity Intensity
|
||||
Minutes int
|
||||
Note string
|
||||
}
|
||||
|
||||
// Score is how good the weather is for an activity, 0–100, with the reasons
|
||||
// points were taken off.
|
||||
type Score struct {
|
||||
Activity Activity
|
||||
Value int
|
||||
Reasons []string
|
||||
}
|
||||
|
||||
// Verdict buckets a score.
|
||||
func (s Score) Verdict() string {
|
||||
switch {
|
||||
case s.Value >= 75:
|
||||
return "great"
|
||||
case s.Value >= 50:
|
||||
return "fine"
|
||||
case s.Value >= 30:
|
||||
return "meh"
|
||||
}
|
||||
return "no"
|
||||
}
|
||||
|
||||
// Later is a noticeably better window later today.
|
||||
type Later struct {
|
||||
Start time.Time
|
||||
Activity Activity
|
||||
Score int
|
||||
}
|
||||
|
||||
// Assessment is the full verdict for a request.
|
||||
type Assessment struct {
|
||||
Scores []Score // now, one per outdoor activity
|
||||
Pick Activity
|
||||
Later *Later
|
||||
TargetKm float64 // 0 for indoor
|
||||
Pace string
|
||||
}
|
||||
|
||||
// IndoorBelow is the best outdoor score under which we send people inside.
|
||||
const IndoorBelow = 40
|
||||
|
||||
// Assess scores every outdoor activity for the user's time window and picks one.
|
||||
func Assess(f weather.Forecast, req Request) Assessment {
|
||||
n := windowHours(f, req.Minutes)
|
||||
now := f.Hours[:min(n, len(f.Hours))]
|
||||
var a Assessment
|
||||
for _, act := range Outdoor {
|
||||
a.Scores = append(a.Scores, score(act, now))
|
||||
}
|
||||
a.Pick = pick(a.Scores, req)
|
||||
a.Later = later(f, req, n, a.Scores)
|
||||
a.TargetKm, a.Pace = target(a.Pick, req.Intensity, req.Minutes)
|
||||
return a
|
||||
}
|
||||
|
||||
// ScoreOf returns the score for one activity.
|
||||
func (a Assessment) ScoreOf(act Activity) Score {
|
||||
for _, s := range a.Scores {
|
||||
if s.Activity == act {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return Score{Activity: act}
|
||||
}
|
||||
|
||||
func windowHours(f weather.Forecast, minutes int) int {
|
||||
return max(1, int(math.Ceil(float64(f.Now.Time.Minute()+minutes)/60)))
|
||||
}
|
||||
|
||||
func pick(scores []Score, req Request) Activity {
|
||||
if req.Activity == Indoor {
|
||||
return Indoor
|
||||
}
|
||||
byAct := map[Activity]int{}
|
||||
for _, s := range scores {
|
||||
byAct[s.Activity] = s.Value
|
||||
}
|
||||
if req.Activity != Any && byAct[req.Activity] >= 50 {
|
||||
return req.Activity
|
||||
}
|
||||
best, bestV := Indoor, -1
|
||||
for _, act := range Outdoor {
|
||||
v := byAct[act]
|
||||
if act == Walk { // a stroll is an easy workout
|
||||
v += map[Intensity]int{Easy: 5, Moderate: -10, Hard: -20}[req.Intensity]
|
||||
}
|
||||
if v > bestV {
|
||||
best, bestV = act, v
|
||||
}
|
||||
}
|
||||
if byAct[best] < IndoorBelow {
|
||||
return Indoor
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
// later looks up to 12 hours ahead for a daylight window that is clearly
|
||||
// better than now for the picked (or wanted) activity.
|
||||
func later(f weather.Forecast, req Request, n int, nowScores []Score) *Later {
|
||||
acts := Outdoor
|
||||
if req.Activity != Any && req.Activity != Indoor {
|
||||
acts = []Activity{req.Activity}
|
||||
}
|
||||
bestNow := 0
|
||||
for _, s := range nowScores {
|
||||
if slices.Contains(acts, s.Activity) {
|
||||
bestNow = max(bestNow, s.Value)
|
||||
}
|
||||
}
|
||||
if bestNow >= 75 {
|
||||
return nil
|
||||
}
|
||||
for start := 1; start <= 12 && start+n <= len(f.Hours); start++ {
|
||||
win := f.Hours[start : start+n]
|
||||
if !win[0].IsDay {
|
||||
continue
|
||||
}
|
||||
for _, act := range acts {
|
||||
s := score(act, win)
|
||||
if s.Value >= 60 && s.Value >= bestNow+20 {
|
||||
return &Later{Start: win[0].Time, Activity: act, Score: s.Value}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// score applies simple, explainable rules to the worst hour in the window.
|
||||
func score(act Activity, hours []weather.Hour) Score {
|
||||
var (
|
||||
maxPrecip, maxGust, maxWind float64
|
||||
minFeels, maxFeels = math.Inf(1), math.Inf(-1)
|
||||
maxProb int
|
||||
thunder, ice, snow, fog bool
|
||||
dark bool
|
||||
)
|
||||
for _, h := range hours {
|
||||
maxPrecip = max(maxPrecip, h.Precip)
|
||||
maxGust = max(maxGust, h.Gusts)
|
||||
maxWind = max(maxWind, h.Wind)
|
||||
minFeels = min(minFeels, h.Feels)
|
||||
maxFeels = max(maxFeels, h.Feels)
|
||||
maxProb = max(maxProb, h.PrecipProb)
|
||||
thunder = thunder || h.Code >= 95
|
||||
ice = ice || h.Code == 56 || h.Code == 57 || h.Code == 66 || h.Code == 67
|
||||
snow = snow || (h.Code >= 71 && h.Code <= 77) || h.Code == 85 || h.Code == 86
|
||||
fog = fog || h.Code == 45 || h.Code == 48
|
||||
dark = dark || !h.IsDay
|
||||
}
|
||||
|
||||
s := Score{Activity: act, Value: 100}
|
||||
take := func(points int, why string) {
|
||||
if points > 0 {
|
||||
s.Value -= points
|
||||
s.Reasons = append(s.Reasons, why)
|
||||
}
|
||||
}
|
||||
// per activity: ride, run, walk
|
||||
by := func(ride, run, walk int) int {
|
||||
switch act {
|
||||
case Ride:
|
||||
return ride
|
||||
case Run:
|
||||
return run
|
||||
}
|
||||
return walk
|
||||
}
|
||||
|
||||
if thunder {
|
||||
take(100, "thunderstorms: stay off open ground")
|
||||
}
|
||||
switch {
|
||||
case maxPrecip >= 2:
|
||||
take(by(70, 65, 65), fmt.Sprintf("heavy rain (up to %.1f mm/h)", maxPrecip))
|
||||
case maxPrecip >= 0.5:
|
||||
take(by(40, 30, 30), fmt.Sprintf("rain (up to %.1f mm/h)", maxPrecip))
|
||||
case maxPrecip >= 0.1:
|
||||
take(by(15, 5, 10), "light drizzle")
|
||||
case maxProb >= 60:
|
||||
take(10, fmt.Sprintf("%d%% chance of rain", maxProb))
|
||||
}
|
||||
switch {
|
||||
case maxGust >= 60:
|
||||
take(by(70, 40, 35), fmt.Sprintf("storm gusts up to %.0f km/h", maxGust))
|
||||
case maxGust >= 45:
|
||||
take(by(40, 15, 10), fmt.Sprintf("strong gusts up to %.0f km/h", maxGust))
|
||||
case maxGust >= 35:
|
||||
take(by(20, 5, 0), fmt.Sprintf("gusty, up to %.0f km/h", maxGust))
|
||||
}
|
||||
if maxWind >= 25 {
|
||||
take(by(15, 5, 0), fmt.Sprintf("steady %.0f km/h wind", maxWind))
|
||||
}
|
||||
switch {
|
||||
case minFeels < -5:
|
||||
take(by(50, 30, 35), fmt.Sprintf("bitter cold (feels like %.0f°C)", minFeels))
|
||||
case minFeels < 2:
|
||||
take(by(20, 5, 10), fmt.Sprintf("cold (feels like %.0f°C)", minFeels))
|
||||
}
|
||||
switch {
|
||||
case maxFeels > 30:
|
||||
take(by(15, 40, 20), fmt.Sprintf("hot (feels like %.0f°C)", maxFeels))
|
||||
case maxFeels > 26:
|
||||
take(by(5, 15, 5), fmt.Sprintf("warm (feels like %.0f°C)", maxFeels))
|
||||
}
|
||||
if ice {
|
||||
take(by(60, 40, 30), "freezing rain: slippery")
|
||||
} else if snow {
|
||||
take(by(50, 20, 10), "snow")
|
||||
}
|
||||
if fog {
|
||||
take(by(15, 5, 5), "fog: low visibility")
|
||||
}
|
||||
if dark {
|
||||
take(by(10, 5, 10), "dark: bring lights and something reflective")
|
||||
}
|
||||
s.Value = max(0, s.Value)
|
||||
return s
|
||||
}
|
||||
|
||||
// target turns time and intensity into a distance at a sensible pace.
|
||||
func target(act Activity, in Intensity, minutes int) (km float64, pace string) {
|
||||
i := map[Intensity]int{Easy: 0, Moderate: 1, Hard: 2}[in]
|
||||
h := float64(minutes) / 60
|
||||
switch act {
|
||||
case Run:
|
||||
minPerKm := []float64{6.5, 5.75, 5.0}[i]
|
||||
km = float64(minutes) / minPerKm
|
||||
pace = fmt.Sprintf("about %d:%02d min/km", int(minPerKm), int(math.Round((minPerKm-math.Floor(minPerKm))*60)))
|
||||
case Ride:
|
||||
kmh := []float64{18, 24, 28}[i]
|
||||
km = h * kmh
|
||||
pace = fmt.Sprintf("about %.0f km/h", kmh)
|
||||
case Walk:
|
||||
kmh := []float64{4.5, 5, 5.5}[i]
|
||||
km = h * kmh
|
||||
pace = fmt.Sprintf("about %.1f km/h", kmh)
|
||||
default:
|
||||
return 0, ""
|
||||
}
|
||||
return math.Round(km*2) / 2, pace
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
package coach
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.b0b.be/bdeb/workout-suggester/internal/weather"
|
||||
)
|
||||
|
||||
// forecast builds a 24-hour forecast where every hour looks like h, then
|
||||
// lets the test tweak individual hours.
|
||||
func forecast(h weather.Hour, tweak func(i int, h *weather.Hour)) weather.Forecast {
|
||||
start := time.Date(2026, 10, 11, 14, 0, 0, 0, time.UTC)
|
||||
f := weather.Forecast{Sunset: start.Add(5 * time.Hour)}
|
||||
for i := range 24 {
|
||||
x := h
|
||||
x.Time = start.Add(time.Duration(i) * time.Hour)
|
||||
x.IsDay = i < 5
|
||||
if tweak != nil {
|
||||
tweak(i, &x)
|
||||
}
|
||||
f.Hours = append(f.Hours, x)
|
||||
}
|
||||
f.Now = f.Hours[0]
|
||||
return f
|
||||
}
|
||||
|
||||
var nice = weather.Hour{Temp: 15, Feels: 14, Wind: 10, Gusts: 20, Code: 1}
|
||||
|
||||
func TestAssess(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
f weather.Forecast
|
||||
req Request
|
||||
pick Activity
|
||||
check func(t *testing.T, a Assessment)
|
||||
}{
|
||||
{
|
||||
name: "nice weather, anything goes",
|
||||
f: forecast(nice, nil),
|
||||
req: Request{Intensity: Moderate, Minutes: 60},
|
||||
pick: Run,
|
||||
},
|
||||
{
|
||||
name: "wanted activity is honoured when fine",
|
||||
f: forecast(nice, nil),
|
||||
req: Request{Activity: Ride, Intensity: Hard, Minutes: 90},
|
||||
pick: Ride,
|
||||
check: func(t *testing.T, a Assessment) {
|
||||
if a.TargetKm != 42 {
|
||||
t.Errorf("90 min hard ride target = %v km, want 42", a.TargetKm)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "windy: running beats cycling",
|
||||
f: forecast(weather.Hour{Temp: 12, Feels: 9, Wind: 30, Gusts: 50, Code: 3}, nil),
|
||||
req: Request{Intensity: Moderate, Minutes: 45},
|
||||
pick: Run,
|
||||
check: func(t *testing.T, a Assessment) {
|
||||
if ride, run := a.ScoreOf(Ride).Value, a.ScoreOf(Run).Value; ride >= run {
|
||||
t.Errorf("ride %d should score below run %d in strong gusts", ride, run)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "storm sends you inside",
|
||||
f: forecast(weather.Hour{Temp: 8, Feels: 3, Wind: 40, Gusts: 70, Precip: 3, PrecipProb: 95, Code: 63}, nil),
|
||||
req: Request{Activity: Run, Intensity: Moderate, Minutes: 60},
|
||||
pick: Indoor,
|
||||
},
|
||||
{
|
||||
name: "thunder is a hard no",
|
||||
f: forecast(weather.Hour{Temp: 22, Feels: 22, Code: 95}, nil),
|
||||
req: Request{Intensity: Easy, Minutes: 30},
|
||||
pick: Indoor,
|
||||
},
|
||||
{
|
||||
name: "rain now, dry later: suggests the later window",
|
||||
f: forecast(nice, func(i int, h *weather.Hour) {
|
||||
if i < 2 {
|
||||
h.Precip, h.PrecipProb, h.Code = 3, 90, 63
|
||||
}
|
||||
}),
|
||||
req: Request{Activity: Ride, Intensity: Moderate, Minutes: 60},
|
||||
pick: Indoor,
|
||||
check: func(t *testing.T, a Assessment) {
|
||||
if a.Later == nil || a.Later.Start.Hour() != 16 || a.Later.Activity != Ride {
|
||||
t.Errorf("later = %+v, want a ride at 16:00", a.Later)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "hard intensity doesn't pick a stroll",
|
||||
f: forecast(weather.Hour{Temp: 15, Feels: 14, Wind: 10, Gusts: 20, Precip: 0.6, Code: 61}, nil),
|
||||
req: Request{Intensity: Hard, Minutes: 60},
|
||||
pick: Run,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
a := Assess(tt.f, tt.req)
|
||||
if a.Pick != tt.pick {
|
||||
t.Errorf("pick = %s, want %s (scores %+v)", a.Pick, tt.pick, a.Scores)
|
||||
}
|
||||
if tt.check != nil {
|
||||
tt.check(t, a)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDarkIsMentioned(t *testing.T) {
|
||||
f := forecast(nice, nil)
|
||||
f.Hours = f.Hours[4:] // 18:00, the last daylight hour, then night
|
||||
f.Now = f.Hours[0]
|
||||
s := Assess(f, Request{Intensity: Easy, Minutes: 90}).ScoreOf(Run)
|
||||
if !strings.Contains(strings.Join(s.Reasons, ","), "dark") {
|
||||
t.Errorf("reasons %v should mention the dark", s.Reasons)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTargetPace(t *testing.T) {
|
||||
km, pace := target(Run, Moderate, 60)
|
||||
if km != 10.5 || pace != "about 5:45 min/km" {
|
||||
t.Errorf("got %v %q", km, pace)
|
||||
}
|
||||
if km, _ := target(Indoor, Hard, 60); km != 0 {
|
||||
t.Errorf("indoor target = %v, want 0", km)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFactsCarryTheNumbers(t *testing.T) {
|
||||
f := forecast(nice, nil)
|
||||
p := Plan{Place: "Gent", Forecast: f, Request: Request{Intensity: Moderate, Minutes: 60, Note: "sore knee"},
|
||||
Assessment: Assess(f, Request{Intensity: Moderate, Minutes: 60}), RoutesLoaded: true}
|
||||
got := p.SystemPrompt()
|
||||
for _, want := range []string{"Gent", "15°C", "sore knee", "The app's pick: running", "about 10.5 km", "Routes: none found nearby"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("system prompt lacks %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
package coach
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"git.b0b.be/bdeb/workout-suggester/internal/routes"
|
||||
"git.b0b.be/bdeb/workout-suggester/internal/weather"
|
||||
)
|
||||
|
||||
// Plan is everything the model needs to know, gathered before it speaks.
|
||||
type Plan struct {
|
||||
Place string
|
||||
At routes.Point // exact; only used on this machine
|
||||
Forecast weather.Forecast
|
||||
Request Request
|
||||
Assessment Assessment
|
||||
RoutesLoaded bool
|
||||
Routes []routes.Suggestion
|
||||
Junction *JunctionInfo
|
||||
RoutesErr string
|
||||
}
|
||||
|
||||
// JunctionInfo is the nearest node-network junction to start from.
|
||||
type JunctionInfo struct {
|
||||
Ref string
|
||||
Kind routes.Kind
|
||||
Km float64
|
||||
}
|
||||
|
||||
// FirstQuestion is the hidden user turn that asks for the plan.
|
||||
const FirstQuestion = "What should I do right now?"
|
||||
|
||||
// FollowUpHint is added to follow-up questions sent to the model.
|
||||
const FollowUpHint = "Answer in a few short lines, not the full plan format. Only change what I asked."
|
||||
|
||||
const persona = `You are the coach inside "workout-suggester", an app that gets people off their screen and outside.
|
||||
The app has already checked the weather and scored each activity with fixed rules. Trust the FACTS below and never invent other numbers, temperatures, times or route names.
|
||||
Be warm, direct and brief: under 180 words, Markdown, no tables, no preamble.`
|
||||
|
||||
const firstReplyFormat = `Answer the first question in exactly this shape:
|
||||
**<emoji> <the pick, e.g. "Go for a run">**: one sentence on why, citing the weather.
|
||||
**When:** now, or the better window from the FACTS if there is one.
|
||||
**The session:** 3–5 bullets: warm-up, a main set that matches the intensity and the minutes, cool-down. Hard = intervals or tempo; easy = conversational pace.
|
||||
**Route:** recommend the first listed route by its name and say how to use it (from the door, laps, out and back); mention its climb if it has one and fits the user's note. If no routes are listed, suggest an easy out-and-back from the door.
|
||||
**Wear:** one line for this weather.
|
||||
**Plan B:** one line.
|
||||
|
||||
If the pick is an indoor workout, give a concrete HIIT, bodyweight or gym session for the minutes and intensity under **The session**, skip **Route** and **Wear**, and say under **When** whether it gets nicer outside later.`
|
||||
|
||||
// SystemPrompt is the persona plus the facts and the answer format.
|
||||
func (p Plan) SystemPrompt() string {
|
||||
return persona + "\n\n" + p.Facts() + "\n\n" + firstReplyFormat
|
||||
}
|
||||
|
||||
// Facts is a compact, model-friendly summary of the plan.
|
||||
func (p Plan) Facts() string {
|
||||
var b strings.Builder
|
||||
f, a, r := p.Forecast, p.Assessment, p.Request
|
||||
w := func(format string, args ...any) { fmt.Fprintf(&b, format+"\n", args...) }
|
||||
desc, _ := weather.Describe(f.Now.Code)
|
||||
|
||||
w("FACTS")
|
||||
where := p.Place
|
||||
if where == "" {
|
||||
where = "the user's location"
|
||||
}
|
||||
w("- Place: %s. Local time: %s. Sunset: %s.", where, f.Now.Time.Format("Mon 15:04"), f.Sunset.Format("15:04"))
|
||||
gusts := fmt.Sprintf("gusts %.0f km/h", f.Now.Gusts)
|
||||
if f.GustsEstimated {
|
||||
gusts = fmt.Sprintf("gusts around %.0f km/h (estimated)", f.Now.Gusts)
|
||||
}
|
||||
chance := ""
|
||||
if f.Now.PrecipProb >= 0 {
|
||||
chance = fmt.Sprintf(", rain chance %d%%", f.Now.PrecipProb)
|
||||
}
|
||||
w("- Weather now: %.0f°C (feels like %.0f°C), %s, wind %.0f km/h from the %s, %s, rain %.1f mm%s.",
|
||||
f.Now.Temp, f.Now.Feels, desc, f.Now.Wind, weather.Compass(f.Now.WindDirection), gusts, f.Now.Precip, chance)
|
||||
w("- User wants: %s, %s intensity, %d minutes.", r.Activity.Noun(), r.Intensity, r.Minutes)
|
||||
if r.Note != "" {
|
||||
w("- User note: %q", r.Note)
|
||||
}
|
||||
w("- Scores for the next %d minutes (0-100, rules-based):", r.Minutes)
|
||||
for _, s := range a.Scores {
|
||||
why := "good conditions"
|
||||
if len(s.Reasons) > 0 {
|
||||
why = strings.Join(s.Reasons, "; ")
|
||||
}
|
||||
w(" - %s: %d (%s): %s", s.Activity.Noun(), s.Value, s.Verdict(), why)
|
||||
}
|
||||
w("- The app's pick: %s.", a.Pick.Noun())
|
||||
if r.Activity != Any && r.Activity != a.Pick {
|
||||
w("- The user asked for %s but the weather makes it a poor choice right now; explain kindly.", r.Activity.Noun())
|
||||
}
|
||||
if a.Later != nil {
|
||||
w("- Better window: %s scores %d from %s.", a.Later.Activity.Noun(), a.Later.Score, a.Later.Start.Format("15:04"))
|
||||
} else {
|
||||
w("- Better window later today: none, now is as good as it gets.")
|
||||
}
|
||||
if a.TargetKm > 0 {
|
||||
w("- Target: about %.1f km at %s.", a.TargetKm, a.Pace)
|
||||
}
|
||||
if a.Pick != Indoor {
|
||||
switch {
|
||||
case len(p.Routes) > 0:
|
||||
w("- Routes, best fit first:")
|
||||
for i, s := range p.Routes {
|
||||
r := s.Route
|
||||
if r.Generated {
|
||||
extra := fmt.Sprintf(", %d m of climbing", r.AscentM)
|
||||
if r.PavedPct >= 0 {
|
||||
extra += fmt.Sprintf(", %d%% paved", r.PavedPct)
|
||||
}
|
||||
if r.IntoWind(f.Now.WindDirection) {
|
||||
extra += ", heads out into the wind so the way home has a tailwind"
|
||||
}
|
||||
w(" %d. %q: a loop from the user's door, %.1f km%s.", i+1, r.Name, r.LengthKm, extra)
|
||||
continue
|
||||
}
|
||||
loop := ""
|
||||
if r.Loop {
|
||||
loop = ", loop"
|
||||
}
|
||||
w(" %d. %q: signposted route, %.1f km%s, %.1f km from the user; suggested: %s (≈%.1f km).",
|
||||
i+1, r.Name, r.LengthKm, loop, s.FromYouKm, s.Describe(), s.TotalKm)
|
||||
}
|
||||
case p.RoutesErr != "":
|
||||
w("- Routes: the map service couldn't be reached, so no routes are listed.")
|
||||
default:
|
||||
w("- Routes: none found nearby.")
|
||||
}
|
||||
if j := p.Junction; j != nil {
|
||||
kind := "walking"
|
||||
if j.Kind == routes.Bike {
|
||||
kind = "cycling"
|
||||
}
|
||||
w("- Nearest %s junction (knooppunt): number %s, %.1f km away.", kind, j.Ref, j.Km)
|
||||
}
|
||||
}
|
||||
return strings.TrimRight(b.String(), "\n")
|
||||
}
|
||||
Reference in new issue
Block a user