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

+18
View File
@@ -0,0 +1,18 @@
# Where your OpenAI-compatible model server lives (oMLX, llama.cpp, Ollama, LM Studio, ...)
LLM_BASE_URL=http://127.0.0.1:8000/v1
# Leave empty to use the first model the server lists
LLM_MODEL=gemma-4-E2B-it-qat-4bit
# Only if your server requires one
LLM_API_KEY=
# Prefill the town field
# DEFAULT_PLACE=Gent
# ADDR=127.0.0.1:3000
# LLM_TIMEOUT=3m
# One or more Overpass endpoints, tried in order
# OVERPASS_URL=https://overpass-api.de/api/interpreter
# BRouter plans loops from your door; self-host it or set "-" to turn loops off
# BROUTER_URL=https://brouter.de/brouter
# Open-Meteo is open source; point these at your own instance if you like
# OPEN_METEO_URL=https://api.open-meteo.com/v1/forecast
# OPEN_METEO_GEOCODE_URL=https://geocoding-api.open-meteo.com/v1/search
+6
View File
@@ -0,0 +1,6 @@
bin/
.env
*.local
dist/
dev/
.DS_Store
+25
View File
@@ -0,0 +1,25 @@
VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
LDFLAGS := -s -w -X main.version=$(VERSION)
PLATFORMS := linux/amd64 linux/arm64 darwin/arm64 darwin/amd64 windows/amd64 windows/arm64
.PHONY: generate build run test dist
generate: ## regenerate Templ components
go tool templ generate
build: generate ## build ./bin/workout-suggester for this machine
go build -trimpath -ldflags "$(LDFLAGS)" -o bin/workout-suggester ./cmd/workout-suggester
run: build ## run in the foreground (reads ./.env)
./bin/workout-suggester --debug
test: ## unit + handler tests (no model or network needed)
go test -race ./...
dist: generate ## cross-compile for every platform into ./dist
@for p in $(PLATFORMS); do \
os=$${p%/*}; arch=$${p#*/}; ext=$$( [ $$os = windows ] && echo .exe ); \
echo " $$os/$$arch"; \
CGO_ENABLED=0 GOOS=$$os GOARCH=$$arch go build -trimpath -ldflags "$(LDFLAGS)" \
-o dist/workout-suggester-$$os-$$arch$$ext ./cmd/workout-suggester || exit 1; \
done
+74
View File
@@ -0,0 +1,74 @@
# workout-suggester
Tell it where you are, how hard you want to go and for how long. It checks the weather and tells you whether to run, ride or walk, or to stay in and do HIIT when it's grim. It also gives you a signposted OpenStreetMap route that fits the distance, as a GPX file for your watch.
The advice comes from **Gemma 4 E2B running on your own machine**. It's a **Go + Templ + HTMX** app in one ~9 MB binary.
<p>
<img src="docs/screenshot.png" width="62%" alt="Desktop: weather, activity scores, the coach's plan and a route map">
<img src="docs/screenshot-mobile.png" width="30%" alt="The same on a phone">
</p>
## How it works
1. **Weather:** [Open-Meteo](https://open-meteo.com/) (open source, no key) gives current conditions and the next 24 hours. If it's overloaded, the app retries once and then falls back to [MET Norway](https://api.met.no/). MET Norway has no gusts or rain chance outside the Nordics, so gusts are estimated (shown as ≈), the rain chance shows as unknown, and sunrise/sunset are computed locally. If both fail, it uses the last forecast it had, up to 3 h old. Place search falls back from Open-Meteo to [Nominatim](https://nominatim.org/).
2. **Scoring (plain Go rules, not the model):** each activity starts at 100 and loses points for rain, gusts, cold, heat, ice, fog and darkness. The amount depends on the activity, e.g. 50 km/h gusts cost a ride 40 points and a run only 15. Below 40 everywhere, you're sent inside. The app also looks up to 12 hours ahead for a clearly better daylight window ("better at 17:00").
3. **Routes:**
- **Loops from your door:** [BRouter](https://github.com/abrensch/brouter), an open-source OpenStreetMap router, generates round trips of your target length in three directions. It uses a foot profile for runs and walks and a bike profile for rides. The first loop heads out into the wind so the way home has a tailwind. Each loop shows its climb and how much of it is paved.
- **Signposted routes nearby:** the [Overpass API](https://overpass-api.de/) finds named cycling and walking routes (Belgian *fietsroutes*, *wandelroutes*) and the nearest *knooppunt* (node-network junction). They're matched to your distance as loops, laps or out-and-back. The way to the start counts towards your distance, and long-distance trails like the Streek-GR are left out.
4. **Coach:** all of the above goes into the system prompt as a short sheet of facts. Gemma writes the plan: why, when, the session (warm-up, main set, cool-down), route, what to wear and a plan B. You can ask follow-ups ("only 30 min", "make it harder").
The split is deliberate: small models get numbers wrong, so code decides the facts and the model explains them and builds the workout.
## Why open
- **The model runs on your machine.** Your location, your notes ("sore knee") and your questions never reach an AI company. There's no API key and nothing to pay per token.
- **Only rough coordinates leave the machine.** Weather lookups use about 1 km precision, signposted-route lookups a ~2 km grid, and loop planning ~100 m. Distances to routes are computed locally with your exact position.
- **Every part can be swapped or self-hosted.** Point `LLM_BASE_URL` at oMLX, llama.cpp, Ollama or LM Studio and use any model. Open-Meteo, Overpass and BRouter are open source too, so `OPEN_METEO_URL`, `OVERPASS_URL` and `BROUTER_URL` can point at your own instances.
- **Routes keep working without a connection.** Route data is cached on disk for a week. GPX export means your phone can stay in your pocket.
- **OpenStreetMap knows the local routes,** down to the knooppunten network and park loops. No closed fitness API gives you that for free.
## Run it
You need an OpenAI-compatible model server. On a Mac:
```sh
omlx serve # or: llama-server -hf unsloth/gemma-4-E2B-it-GGUF:Q4_K_M --no-mmproj
cp .env.example .env # set LLM_BASE_URL / LLM_MODEL (and LLM_API_KEY if your server wants one)
make run # → http://127.0.0.1:3000
```
Tap **📍 Here** (the browser asks for your location) or type a town.
| Setting | Default |
|---|---|
| `LLM_BASE_URL` | `http://127.0.0.1:8000/v1` (oMLX) |
| `LLM_MODEL` | first model the server lists |
| `LLM_API_KEY` | none |
| `DEFAULT_PLACE` | none (prefills the town field) |
| `ADDR` | `127.0.0.1:3000` |
| `BROUTER_URL` | `https://brouter.de/brouter` (self-hostable; `-` turns loops off) |
| `OVERPASS_URL` | `https://overpass-api.de/api/interpreter` (space-separated list, tried in order) |
| `OPEN_METEO_URL`, `OPEN_METEO_GEOCODE_URL` | the public Open-Meteo API |
`gemma-4-E2B-it-qat-4bit` answers in about 10 s on an M-series Mac. `E4B` follows the format more closely.
## Develop
```sh
make test # unit + handler tests, no network or model needed
uv run scripts/e2e.py --url http://127.0.0.1:3000 [--mobile] [--dark] # browser test + screenshots
make dist # cross-compile for Linux, macOS, Windows
```
```
cmd/workout-suggester entry point
internal/weather Open-Meteo + MET Norway fallback, local sunrise maths (rounded coords, 15 min cache)
internal/coach scoring rules, distance targets, the prompt
internal/routes BRouter loops, Overpass client (retries, disk cache), route matching, GPX
internal/session per-browser plan + chat
internal/llm minimal OpenAI-compatible streaming client
internal/web handlers, Templ views, HTMX + SSE, Leaflet map
```
Weather data by [Open-Meteo](https://open-meteo.com/) and [MET Norway](https://api.met.no/) (both CC BY 4.0). Route data © [OpenStreetMap](https://www.openstreetmap.org/copyright) contributors (ODbL). Loops by [BRouter](https://brouter.de/).
+75
View File
@@ -0,0 +1,75 @@
// Command workout-suggester checks the weather where you are and has a local,
// open-weight model tell you what workout to do outside (or inside, if it's
// truly grim), with signposted OpenStreetMap routes to match.
package main
import (
"context"
"errors"
"flag"
"fmt"
"log/slog"
"os"
"os/signal"
"path/filepath"
"syscall"
"git.b0b.be/bdeb/workout-suggester/internal/config"
"git.b0b.be/bdeb/workout-suggester/internal/web"
)
var version = "dev" // set with -ldflags "-X main.version=..."
func main() {
if err := run(os.Args[1:]); err != nil {
fmt.Fprintln(os.Stderr, "workout-suggester:", err)
os.Exit(1)
}
}
func run(args []string) error {
fs := flag.NewFlagSet("workout-suggester", flag.ContinueOnError)
envFile := fs.String("env-file", "", "load settings from this KEY=VALUE file (default: ./.env if present)")
debug := fs.Bool("debug", false, "verbose logging")
showVersion := fs.Bool("version", false, "print the version and exit")
if err := fs.Parse(args); err != nil {
if errors.Is(err, flag.ErrHelp) {
return nil
}
return err
}
if *showVersion {
fmt.Println(version)
return nil
}
if *envFile == "" {
if _, err := os.Stat(".env"); err == nil {
*envFile = ".env"
}
}
if *envFile != "" {
if err := config.LoadEnvFile(*envFile); err != nil {
return fmt.Errorf("env file: %w", err)
}
}
cfg, err := config.Load()
if err != nil {
return err
}
level := slog.LevelInfo
if *debug {
level = slog.LevelDebug
}
log := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: level}))
cacheDir := ""
if dir, err := os.UserCacheDir(); err == nil {
cacheDir = filepath.Join(dir, "workout-suggester", "routes")
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
return web.New(cfg, cacheDir, log).Run(ctx)
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 988 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

+27
View File
@@ -0,0 +1,27 @@
module git.b0b.be/bdeb/workout-suggester
go 1.27.1
require (
github.com/a-h/templ v0.3.1070
github.com/yuin/goldmark v1.8.6
)
require (
github.com/a-h/parse v0.0.0-20250122154542-74294addb73e // indirect
github.com/andybalholm/brotli v1.2.6 // indirect
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
github.com/cli/browser v1.3.0 // indirect
github.com/fatih/color v1.19.0 // indirect
github.com/fsnotify/fsnotify v1.10.1 // indirect
github.com/mattn/go-colorable v0.1.15 // indirect
github.com/mattn/go-isatty v0.0.24 // indirect
github.com/natefinch/atomic v1.0.1 // indirect
golang.org/x/mod v0.41.0 // indirect
golang.org/x/net v0.59.0 // indirect
golang.org/x/sync v0.23.0 // indirect
golang.org/x/sys v0.48.0 // indirect
golang.org/x/tools v0.51.0 // indirect
)
tool github.com/a-h/templ/cmd/templ
+44
View File
@@ -0,0 +1,44 @@
github.com/a-h/parse v0.0.0-20250122154542-74294addb73e h1:HjVbSQHy+dnlS6C3XajZ69NYAb5jbGNfHanvm1+iYlo=
github.com/a-h/parse v0.0.0-20250122154542-74294addb73e/go.mod h1:3mnrkvGpurZ4ZrTDbYU84xhwXW2TjTKShSwjRi2ihfQ=
github.com/a-h/templ v0.3.1070 h1:3iCOXHXqFXa99OQzgkUOLJbNzv/QjFGLwKZkBAg+ADA=
github.com/a-h/templ v0.3.1070/go.mod h1:iIqB3g2RHBe/WlnPwa+Bvhh1TKXHWLRnBD9NWHOLm78=
github.com/andybalholm/brotli v1.2.6 h1:ftYnfj6usCp+UGV5kSJ3+chpMQgU+gJf/AxsUQ52REI=
github.com/andybalholm/brotli v1.2.6/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
github.com/cli/browser v1.3.0 h1:LejqCrpWr+1pRqmEPDGnTZOjsMe7sehifLynZJuqJpo=
github.com/cli/browser v1.3.0/go.mod h1:HH8s+fOAxjhQoBUAsKuPCbqUuxZDhQ2/aD+SzsEfBTk=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w=
github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE=
github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY=
github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
github.com/natefinch/atomic v1.0.1 h1:ZPYKxkqQOx3KZ+RsbnP/YsgvxWQPGxjC0oBt2AhwV0A=
github.com/natefinch/atomic v1.0.1/go.mod h1:N/D/ELrljoqDyT3rZrsUmtsuzvHkeB/wWjHV22AZRbM=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
github.com/yuin/goldmark v1.8.6 h1:d0VcaP1sx9GkFVkoW+KtggpGi2KZ965i14b0+bDQST4=
github.com/yuin/goldmark v1.8.6/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c=
golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o=
golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues=
golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg=
golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/tools v0.51.0 h1:k4Xc/1Om9jwkBJBo4NVLMSARBoWtK10mx+W5BnXCeAI=
golang.org/x/tools v0.51.0/go.mod h1:9eEncMayCV6zRMGhR5eZEC2iBx98qWcF1HZ9Z7wJOoA=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+324
View File
@@ -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
}
+144
View File
@@ -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)
}
}
}
+141
View File
@@ -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")
}
+88
View File
@@ -0,0 +1,88 @@
// Package config loads workout-suggester settings from the environment and
// an optional .env file.
package config
import (
"bufio"
"fmt"
"os"
"strings"
"time"
)
// Config holds all runtime settings.
type Config struct {
Addr string // ADDR: where the web UI listens
BaseURL string // LLM_BASE_URL: OpenAI-compatible API root, e.g. http://127.0.0.1:8000/v1
APIKey string // LLM_API_KEY: optional bearer token for the model server
Model string // LLM_MODEL: model id; empty means "first model the server lists"
Timeout time.Duration // LLM_TIMEOUT: upper bound for a single reply
OverpassURL string // OVERPASS_URL: OpenStreetMap Overpass API endpoint
BRouterURL string // BROUTER_URL: BRouter endpoint for loops from your door; "-" disables
DefaultPlace string // DEFAULT_PLACE: prefilled town in the form
ForecastURL string // OPEN_METEO_URL: forecast endpoint (Open-Meteo is self-hostable)
GeocodeURL string // OPEN_METEO_GEOCODE_URL: place search endpoint
}
// Load reads the configuration from environment variables, applying defaults.
func Load() (Config, error) {
c := Config{
Addr: env("ADDR", "127.0.0.1:3000"),
BaseURL: strings.TrimRight(env("LLM_BASE_URL", "http://127.0.0.1:8000/v1"), "/"),
APIKey: os.Getenv("LLM_API_KEY"),
Model: os.Getenv("LLM_MODEL"),
Timeout: 3 * time.Minute,
OverpassURL: env("OVERPASS_URL", "https://overpass-api.de/api/interpreter"),
BRouterURL: env("BROUTER_URL", "https://brouter.de/brouter"),
DefaultPlace: os.Getenv("DEFAULT_PLACE"),
ForecastURL: env("OPEN_METEO_URL", "https://api.open-meteo.com/v1/forecast"),
GeocodeURL: env("OPEN_METEO_GEOCODE_URL", "https://geocoding-api.open-meteo.com/v1/search"),
}
if v := os.Getenv("LLM_TIMEOUT"); v != "" {
d, err := time.ParseDuration(v)
if err != nil {
return c, fmt.Errorf("LLM_TIMEOUT: %w", err)
}
c.Timeout = d
}
return c, nil
}
func env(key, fallback string) string {
if v, ok := os.LookupEnv(key); ok && v != "" {
return v
}
return fallback
}
// LoadEnvFile sets variables from a KEY=VALUE file. Variables that are
// already set in the environment win, so the real environment can override
// the file. Blank lines and lines starting with # are ignored; values may be
// wrapped in single or double quotes.
func LoadEnvFile(path string) error {
f, err := os.Open(path)
if err != nil {
return err
}
defer f.Close()
sc := bufio.NewScanner(f)
for n := 1; sc.Scan(); n++ {
line := strings.TrimSpace(sc.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
key, val, ok := strings.Cut(strings.TrimPrefix(line, "export "), "=")
if !ok {
return fmt.Errorf("%s:%d: expected KEY=VALUE", path, n)
}
key, val = strings.TrimSpace(key), strings.TrimSpace(val)
if len(val) >= 2 && (val[0] == '"' || val[0] == '\'') && val[len(val)-1] == val[0] {
val = val[1 : len(val)-1]
}
if _, set := os.LookupEnv(key); !set {
os.Setenv(key, val)
}
}
return sc.Err()
}
+175
View File
@@ -0,0 +1,175 @@
// Package llm is a minimal client for OpenAI-compatible chat APIs, as served
// by llama.cpp, oMLX, Ollama, LM Studio, Lemonade, vLLM and friends.
package llm
import (
"bufio"
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strings"
)
// Message is one chat turn.
type Message struct {
Role string `json:"role"` // "system", "user" or "assistant"
Content string `json:"content"`
}
// Client talks to a single OpenAI-compatible endpoint.
type Client struct {
BaseURL string // e.g. http://127.0.0.1:8000/v1
APIKey string
Model string // empty: use the first model the server lists
HTTP *http.Client
}
func (c *Client) httpClient() *http.Client {
if c.HTTP != nil {
return c.HTTP
}
return http.DefaultClient
}
func (c *Client) newRequest(ctx context.Context, method, path string, body io.Reader) (*http.Request, error) {
req, err := http.NewRequestWithContext(ctx, method, c.BaseURL+path, body)
if err != nil {
return nil, err
}
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
if c.APIKey != "" {
req.Header.Set("Authorization", "Bearer "+c.APIKey)
}
return req, nil
}
// Models lists the model ids the server offers. It doubles as a health check.
func (c *Client) Models(ctx context.Context) ([]string, error) {
req, err := c.newRequest(ctx, http.MethodGet, "/models", nil)
if err != nil {
return nil, err
}
resp, err := c.httpClient().Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if err := checkStatus(resp); err != nil {
return nil, err
}
var out struct {
Data []struct {
ID string `json:"id"`
} `json:"data"`
}
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return nil, fmt.Errorf("decode models: %w", err)
}
ids := make([]string, len(out.Data))
for i, m := range out.Data {
ids[i] = m.ID
}
return ids, nil
}
// ResolveModel returns the configured model, or the first one the server
// lists when none is configured.
func (c *Client) ResolveModel(ctx context.Context) (string, error) {
if c.Model != "" {
return c.Model, nil
}
ids, err := c.Models(ctx)
if err != nil {
return "", err
}
if len(ids) == 0 {
return "", errors.New("model server lists no models; set LLM_MODEL")
}
return ids[0], nil
}
// Stream sends the conversation and calls onDelta for every chunk of the
// reply as it arrives. It returns when the reply is complete, the context is
// cancelled, or onDelta returns an error.
func (c *Client) Stream(ctx context.Context, msgs []Message, onDelta func(string) error) error {
model, err := c.ResolveModel(ctx)
if err != nil {
return err
}
body, err := json.Marshal(map[string]any{
"model": model,
"messages": msgs,
"stream": true,
})
if err != nil {
return err
}
req, err := c.newRequest(ctx, http.MethodPost, "/chat/completions", bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Accept", "text/event-stream")
resp, err := c.httpClient().Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if err := checkStatus(resp); err != nil {
return err
}
sc := bufio.NewScanner(resp.Body)
sc.Buffer(make([]byte, 64*1024), 1024*1024)
for sc.Scan() {
data, ok := strings.CutPrefix(sc.Text(), "data:")
if !ok {
continue // blank separators, comments, "event:" lines
}
data = strings.TrimSpace(data)
if data == "[DONE]" {
return nil
}
var chunk struct {
Choices []struct {
Delta struct {
Content string `json:"content"`
} `json:"delta"`
} `json:"choices"`
Error *struct {
Message string `json:"message"`
} `json:"error"`
}
if err := json.Unmarshal([]byte(data), &chunk); err != nil {
return fmt.Errorf("decode stream chunk: %w", err)
}
if chunk.Error != nil {
return fmt.Errorf("model server: %s", chunk.Error.Message)
}
for _, ch := range chunk.Choices {
if ch.Delta.Content == "" {
continue
}
if err := onDelta(ch.Delta.Content); err != nil {
return err
}
}
}
if err := sc.Err(); err != nil {
return err
}
return nil // stream ended without [DONE]; treat what we got as the reply
}
func checkStatus(resp *http.Response) error {
if resp.StatusCode < 300 {
return nil
}
msg, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
return fmt.Errorf("model server returned %s: %s", resp.Status, bytes.TrimSpace(msg))
}
+107
View File
@@ -0,0 +1,107 @@
package llm
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// fakeServer mimics an OpenAI-compatible server that streams the given chunks.
func fakeServer(t *testing.T, chunks []string) *httptest.Server {
t.Helper()
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/v1/models":
fmt.Fprint(w, `{"data":[{"id":"tiny-model"},{"id":"other"}]}`)
case "/v1/chat/completions":
if got := r.Header.Get("Authorization"); got != "Bearer secret" {
http.Error(w, `{"error":"bad key"}`, http.StatusUnauthorized)
return
}
var req struct {
Model string `json:"model"`
Stream bool `json:"stream"`
Msgs []Message `json:"messages"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
t.Errorf("decode request: %v", err)
}
if req.Model != "tiny-model" || !req.Stream || len(req.Msgs) != 1 {
t.Errorf("unexpected request: %+v", req)
}
w.Header().Set("Content-Type", "text/event-stream")
fmt.Fprint(w, ": keep-alive comment\n\n")
fmt.Fprint(w, `data: {"choices":[{"delta":{"role":"assistant"}}]}`+"\n\n")
for _, c := range chunks {
b, _ := json.Marshal(c)
fmt.Fprintf(w, `data: {"choices":[{"delta":{"content":%s}}]}`+"\n\n", b)
}
fmt.Fprint(w, "data: [DONE]\n\n")
default:
http.NotFound(w, r)
}
}))
}
func TestStream(t *testing.T) {
srv := fakeServer(t, []string{"Hel", "lo", "\n", "world"})
defer srv.Close()
c := &Client{BaseURL: srv.URL + "/v1", APIKey: "secret"} // no model: auto-pick first
var got strings.Builder
err := c.Stream(context.Background(), []Message{{Role: "user", Content: "hi"}}, func(d string) error {
got.WriteString(d)
return nil
})
if err != nil {
t.Fatal(err)
}
if got.String() != "Hello\nworld" {
t.Fatalf("got %q", got.String())
}
}
func TestStreamHTTPError(t *testing.T) {
srv := fakeServer(t, nil)
defer srv.Close()
c := &Client{BaseURL: srv.URL + "/v1", APIKey: "wrong", Model: "tiny-model"}
err := c.Stream(context.Background(), []Message{{Role: "user", Content: "hi"}}, func(string) error { return nil })
if err == nil || !strings.Contains(err.Error(), "401") {
t.Fatalf("want 401 error, got %v", err)
}
}
func TestStreamCallbackErrorStops(t *testing.T) {
srv := fakeServer(t, []string{"a", "b", "c"})
defer srv.Close()
c := &Client{BaseURL: srv.URL + "/v1", APIKey: "secret"}
stop := fmt.Errorf("stop")
n := 0
err := c.Stream(context.Background(), []Message{{Role: "user", Content: "hi"}}, func(string) error {
n++
return stop
})
if err != stop || n != 1 {
t.Fatalf("err=%v n=%d", err, n)
}
}
func TestResolveModel(t *testing.T) {
srv := fakeServer(t, nil)
defer srv.Close()
c := &Client{BaseURL: srv.URL + "/v1"}
if m, err := c.ResolveModel(context.Background()); err != nil || m != "tiny-model" {
t.Fatalf("auto: %q %v", m, err)
}
c.Model = "pinned"
if m, _ := c.ResolveModel(context.Background()); m != "pinned" {
t.Fatalf("pinned: %q", m)
}
}
+233
View File
@@ -0,0 +1,233 @@
package routes
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"math"
"net/http"
"net/url"
"slices"
"strconv"
"strings"
"sync"
"time"
)
// BRouter's round trips come out about this many times longer than the
// roundTripDistance asked for (it's closer to a radius than a length).
const brouterStretch = 5.1
var brouterProfile = map[Kind]string{Foot: "hiking-mountain", Bike: "trekking"}
// Loops asks BRouter for round trips of about targetKm that start and end
// at p, heading out in three directions. The first heads out into the wind
// (windFrom, in degrees) so the way home is the easy part. The start point
// is rounded to ~100 m before it leaves the machine.
func (c *Client) Loops(ctx context.Context, kind Kind, p Point, targetKm float64, windFrom int) ([]Suggestion, error) {
if c.BRouterURL == "" || c.BRouterURL == "-" || targetKm <= 0 {
return nil, nil
}
p = Point{math.Round(p.Lat*1000) / 1000, math.Round(p.Lon*1000) / 1000}
key := fmt.Sprintf("%s_%.3f_%.3f_%.1f_%d", kind, p.Lat, p.Lon, targetKm, windFrom/30)
c.mu.Lock()
c.initLocked()
if hit, ok := c.loops[key]; ok && time.Since(hit.at) < cacheFor {
c.mu.Unlock()
return hit.s, nil
}
c.mu.Unlock()
type result struct {
r *Route
err error
}
results := make([]result, 3)
var wg sync.WaitGroup
for i := range results {
wg.Go(func() {
dir := (windFrom + i*120) % 360
r, err := c.roundTrip(ctx, kind, p, targetKm, dir)
results[i] = result{r, err}
})
}
wg.Wait()
var out []Suggestion
var errs []error
for _, res := range results {
if res.err != nil {
errs = append(errs, res.err)
continue
}
out = append(out, Suggestion{Route: res.r, Plan: "loop", Laps: 1, TotalKm: res.r.LengthKm, JoinAt: p})
}
if len(out) == 0 {
return nil, fmt.Errorf("brouter: %w", errors.Join(errs...))
}
// Closest to the target first; into-the-wind wins ties within 10%.
slices.SortStableFunc(out, func(a, b Suggestion) int {
da, db := math.Abs(a.TotalKm-targetKm), math.Abs(b.TotalKm-targetKm)
if math.Abs(da-db) < 0.1*targetKm {
return 0
}
if da < db {
return -1
}
return 1
})
c.mu.Lock()
c.loops[key] = cachedLoops{out, time.Now()}
for _, s := range out {
c.byID[s.Route.ID] = s.Route
}
c.mu.Unlock()
return out, nil
}
type cachedLoops struct {
s []Suggestion
at time.Time
}
func (c *Client) roundTrip(ctx context.Context, kind Kind, p Point, targetKm float64, dir int) (*Route, error) {
q := url.Values{
"lonlats": {fmt.Sprintf("%.3f,%.3f", p.Lon, p.Lat)},
"profile": {brouterProfile[kind]},
"alternativeidx": {"0"},
"format": {"geojson"},
"engineMode": {"4"}, // round trip
"roundTripDistance": {strconv.Itoa(int(targetKm * 1000 / brouterStretch))},
"direction": {strconv.Itoa(dir)},
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.BRouterURL+"?"+q.Encode(), nil)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", userAgent)
hc := c.HTTP
if hc == nil {
hc = http.DefaultClient
}
resp, err := hc.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
msg, _ := io.ReadAll(io.LimitReader(resp.Body, 256))
return nil, fmt.Errorf("%s: %s", resp.Status, strings.TrimSpace(string(msg)))
}
r, err := parseBRouter(resp.Body)
if err != nil {
return nil, err
}
r.Kind, r.Heading = kind, dir
r.ID = c.nextLoopID()
r.Name = fmt.Sprintf("%.1f km loop heading %s", r.LengthKm, compass(dir))
return r, nil
}
// nextLoopID hands out negative ids so generated loops never collide with
// OpenStreetMap relation ids.
func (c *Client) nextLoopID() int64 {
c.mu.Lock()
defer c.mu.Unlock()
c.loopSeq--
return c.loopSeq
}
func parseBRouter(r io.Reader) (*Route, error) {
var out struct {
Features []struct {
Properties struct {
Length string `json:"track-length"`
Ascent string `json:"filtered ascend"`
Messages [][]string `json:"messages"`
} `json:"properties"`
Geometry struct {
Coordinates [][]float64 `json:"coordinates"` // lon, lat, elevation
} `json:"geometry"`
} `json:"features"`
}
if err := json.NewDecoder(r).Decode(&out); err != nil {
return nil, fmt.Errorf("decode brouter: %w", err)
}
if len(out.Features) == 0 || len(out.Features[0].Geometry.Coordinates) < 2 {
return nil, errors.New("brouter: empty route")
}
f := out.Features[0]
m, _ := strconv.Atoi(f.Properties.Length)
ascent, _ := strconv.Atoi(f.Properties.Ascent)
pts := make([]Point, len(f.Geometry.Coordinates))
for i, c := range f.Geometry.Coordinates {
pts[i] = Point{c[1], c[0]}
}
return &Route{
LengthKm: math.Round(float64(m)/100) / 10,
Loop: true,
Generated: true,
AscentM: ascent,
PavedPct: pavedPct(f.Properties.Messages),
Ways: [][]Point{simplify(pts, 0.015)},
}, nil
}
var (
pavedSurfaces = []string{"asphalt", "paved", "concrete", "concrete:plates", "concrete:lanes", "paving_stones", "sett", "metal", "wood"}
unpavedHighways = []string{"track", "path", "bridleway"}
)
// pavedPct estimates how much of the route is paved from BRouter's per-
// segment way tags: the surface tag when there is one, else the road type.
func pavedPct(msgs [][]string) int {
if len(msgs) < 2 {
return -1
}
dist, tags := slices.Index(msgs[0], "Distance"), slices.Index(msgs[0], "WayTags")
if dist < 0 || tags < 0 {
return -1
}
var paved, total float64
for _, row := range msgs[1:] {
if len(row) <= max(dist, tags) {
continue
}
d, _ := strconv.ParseFloat(row[dist], 64)
kv := map[string]string{}
for _, f := range strings.Fields(row[tags]) {
k, v, _ := strings.Cut(f, "=")
kv[k] = v
}
total += d
if s, ok := kv["surface"]; ok {
if slices.Contains(pavedSurfaces, s) {
paved += d
}
} else if !slices.Contains(unpavedHighways, kv["highway"]) {
paved += d
}
}
if total == 0 {
return -1
}
return int(math.Round(100 * paved / total))
}
func compass(deg int) string {
dirs := []string{"north", "north-east", "east", "south-east", "south", "south-west", "west", "north-west"}
return dirs[((deg%360+360)%360+22)/45%8]
}
// IntoWind reports whether a loop heads out roughly into the wind, so the
// way home has a tailwind.
func (r *Route) IntoWind(windFrom int) bool {
if !r.Generated {
return false
}
d := ((r.Heading-windFrom)%360 + 360) % 360
return d <= 45 || d >= 315
}
+367
View File
@@ -0,0 +1,367 @@
// Package routes finds signposted cycling, walking and running routes near a
// point using OpenStreetMap data from the Overpass API, and picks the ones
// that fit a target distance.
package routes
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"math"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"sync"
"time"
)
// Kind is what a route is signposted for.
type Kind string
const (
Bike Kind = "bike"
Foot Kind = "foot"
)
// Point is a WGS84 coordinate.
type Point struct {
Lat float64 `json:"lat"`
Lon float64 `json:"lon"`
}
// Route is a named OSM route relation with its geometry.
type Route struct {
ID int64 `json:"id"`
Name string `json:"name"`
Kind Kind `json:"kind"`
LengthKm float64 `json:"length_km"`
Loop bool `json:"loop"`
Ways [][]Point `json:"ways"` // unordered pieces, as OSM stores them
// Set for loops generated by BRouter rather than signposted in OSM.
Generated bool `json:"generated,omitempty"`
Heading int `json:"heading,omitempty"` // direction it sets off in, degrees
AscentM int `json:"ascent_m,omitempty"` // total climb
PavedPct int `json:"paved_pct,omitempty"` // -1 when unknown
}
// Junction is a numbered node of the Belgian/Dutch node networks
// (fietsknooppunt / wandelknooppunt).
type Junction struct {
Ref string `json:"ref"`
Kind Kind `json:"kind"`
Point Point `json:"point"`
}
// Area holds everything found around one spot.
type Area struct {
Routes []Route `json:"routes"`
Junctions []Junction `json:"junctions"`
Fetched time.Time `json:"fetched"`
}
// Client queries Overpass for signposted routes, keeping results in memory
// and on disk so an area you've loaded once keeps working without a
// connection, and BRouter for loops generated from your door.
type Client struct {
URLs []string // Overpass endpoints, tried in order
BRouterURL string // e.g. https://brouter.de/brouter; empty or "-" disables loops
HTTP *http.Client
CacheDir string // empty: no disk cache
mu sync.Mutex
areas map[string]*Area
byID map[int64]*Route
locks map[string]*sync.Mutex
loops map[string]cachedLoops
loopSeq int64
}
func (c *Client) initLocked() {
if c.byID == nil {
c.areas, c.byID, c.locks, c.loops = map[string]*Area{}, map[int64]*Route{}, map[string]*sync.Mutex{}, map[string]cachedLoops{}
}
}
const (
bikeRadius = 15000 // m
footRadius = 8000 // m
junctionRadius = 3000 // m
cacheFor = 7 * 24 * time.Hour
userAgent = "workout-suggester/0.1 (+https://git.b0b.be/bdeb/workout-suggester)"
)
// cell snaps a coordinate to a ~2 km grid so nearby requests share a cache
// entry and the exact location never leaves the machine.
func cell(lat, lon float64) (float64, float64, string) {
lat, lon = math.Round(lat*50)/50, math.Round(lon*50)/50
return lat, lon, fmt.Sprintf("%.2f_%.2f", lat, lon)
}
// Near returns the routes and junctions around a point.
func (c *Client) Near(ctx context.Context, lat, lon float64) (*Area, error) {
clat, clon, key := cell(lat, lon)
// One fetch per cell at a time; concurrent callers wait for it.
c.mu.Lock()
c.initLocked()
l, ok := c.locks[key]
if !ok {
l = &sync.Mutex{}
c.locks[key] = l
}
c.mu.Unlock()
l.Lock()
defer l.Unlock()
c.mu.Lock()
a := c.areas[key]
c.mu.Unlock()
if a == nil {
a = c.readDisk(key)
}
if a != nil && time.Since(a.Fetched) < cacheFor {
c.remember(key, a)
return a, nil
}
fresh, err := c.fetch(ctx, clat, clon)
if err != nil {
if a != nil { // stale beats nothing
c.remember(key, a)
return a, nil
}
return nil, err
}
c.remember(key, fresh)
c.writeDisk(key, fresh)
return fresh, nil
}
// Route looks up a route seen in an earlier Near call.
func (c *Client) Route(id int64) (*Route, bool) {
c.mu.Lock()
defer c.mu.Unlock()
c.initLocked()
r, ok := c.byID[id]
return r, ok
}
func (c *Client) remember(key string, a *Area) {
c.mu.Lock()
defer c.mu.Unlock()
c.areas[key] = a
for i := range a.Routes {
c.byID[a.Routes[i].ID] = &a.Routes[i]
}
}
func (c *Client) readDisk(key string) *Area {
if c.CacheDir == "" {
return nil
}
b, err := os.ReadFile(filepath.Join(c.CacheDir, key+".json"))
if err != nil {
return nil
}
var a Area
if json.Unmarshal(b, &a) != nil {
return nil
}
return &a
}
func (c *Client) writeDisk(key string, a *Area) {
if c.CacheDir == "" {
return
}
b, err := json.Marshal(a)
if err != nil || os.MkdirAll(c.CacheDir, 0o755) != nil {
return
}
tmp := filepath.Join(c.CacheDir, key+".json.tmp")
if os.WriteFile(tmp, b, 0o644) == nil {
os.Rename(tmp, filepath.Join(c.CacheDir, key+".json"))
}
}
func query(lat, lon float64) string {
at := func(r int) string { return fmt.Sprintf("(around:%d,%.4f,%.4f)", r, lat, lon) }
return `[out:json][timeout:25];(` +
`relation["type"="route"]["route"="bicycle"]["network"~"^(lcn|rcn)$"]["network:type"!="node_network"]` + at(bikeRadius) + `;` +
`relation["type"="route"]["route"~"^(hiking|foot|walking|running)$"]["network"~"^(lwn|rwn)$"]["network:type"!="node_network"]` + at(footRadius) + `;` +
`);out geom;(` +
`node["rcn_ref"]` + at(junctionRadius) + `;` +
`node["rwn_ref"]` + at(junctionRadius) + `;` +
`);out;`
}
// fetch tries each Overpass endpoint, retrying busy servers a couple of times.
func (c *Client) fetch(ctx context.Context, lat, lon float64) (*Area, error) {
hc := c.HTTP
if hc == nil {
hc = http.DefaultClient
}
body := url.Values{"data": {query(lat, lon)}}.Encode()
var errs []error
for attempt := range 3 {
for _, u := range c.URLs {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, u, strings.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Accept", "application/json") // overpass-api.de answers 406 without it
req.Header.Set("User-Agent", userAgent)
resp, err := hc.Do(req)
if err != nil {
if ctx.Err() != nil {
return nil, ctx.Err()
}
errs = append(errs, err)
continue
}
if resp.StatusCode != http.StatusOK {
resp.Body.Close()
errs = append(errs, fmt.Errorf("%s: %s", u, resp.Status))
continue
}
a, err := parse(resp.Body)
resp.Body.Close()
if err != nil {
errs = append(errs, err)
continue
}
return a, nil
}
select { // busy servers (429/504) usually recover within seconds
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(time.Duration(attempt+1) * 2 * time.Second):
}
}
return nil, fmt.Errorf("overpass: %w", errors.Join(errs...))
}
func parse(r io.Reader) (*Area, error) {
var out struct {
Elements []struct {
Type string `json:"type"`
ID int64 `json:"id"`
Lat float64 `json:"lat"`
Lon float64 `json:"lon"`
Tags map[string]string `json:"tags"`
Members []struct {
Type string `json:"type"`
Role string `json:"role"`
Geometry []Point `json:"geometry"`
} `json:"members"`
} `json:"elements"`
Remark string `json:"remark"`
}
if err := json.NewDecoder(r).Decode(&out); err != nil {
return nil, fmt.Errorf("decode overpass: %w", err)
}
if strings.Contains(out.Remark, "runtime error") {
return nil, fmt.Errorf("overpass: %s", out.Remark)
}
a := &Area{Fetched: time.Now()}
for _, e := range out.Elements {
t := e.Tags
switch e.Type {
case "node":
if ref := t["rcn_ref"]; ref != "" {
a.Junctions = append(a.Junctions, Junction{Ref: ref, Kind: Bike, Point: Point{e.Lat, e.Lon}})
}
if ref := t["rwn_ref"]; ref != "" {
a.Junctions = append(a.Junctions, Junction{Ref: ref, Kind: Foot, Point: Point{e.Lat, e.Lon}})
}
case "relation":
name := t["name"]
if name == "" {
name = t["ref"]
}
if name == "" {
continue
}
r := Route{ID: e.ID, Name: name, Kind: Foot, Loop: t["roundtrip"] == "yes"}
if t["route"] == "bicycle" {
r.Kind = Bike
}
for _, m := range e.Members {
if m.Type != "way" || len(m.Geometry) < 2 {
continue
}
r.LengthKm += pathKm(m.Geometry)
r.Ways = append(r.Ways, simplify(m.Geometry, 0.015))
}
if r.LengthKm < 0.5 {
continue
}
r.LengthKm = math.Round(r.LengthKm*10) / 10
a.Routes = append(a.Routes, r)
}
}
return a, nil
}
// Km is the great-circle distance between two points.
func Km(a, b Point) float64 {
const R = 6371.0
la1, la2 := a.Lat*math.Pi/180, b.Lat*math.Pi/180
dla, dlo := la2-la1, (b.Lon-a.Lon)*math.Pi/180
h := math.Sin(dla/2)*math.Sin(dla/2) + math.Cos(la1)*math.Cos(la2)*math.Sin(dlo/2)*math.Sin(dlo/2)
return 2 * R * math.Asin(math.Sqrt(h))
}
func pathKm(pts []Point) float64 {
var d float64
for i := 1; i < len(pts); i++ {
d += Km(pts[i-1], pts[i])
}
return d
}
// simplify drops points closer than minKm to the last kept one, which is
// plenty for a map or a GPX track and keeps the page small.
func simplify(pts []Point, minKm float64) []Point {
out := []Point{pts[0]}
for i := 1; i < len(pts)-1; i++ {
if Km(out[len(out)-1], pts[i]) >= minKm {
out = append(out, pts[i])
}
}
return append(out, pts[len(pts)-1])
}
// Closest returns the point of the route nearest to p and its distance.
func (r *Route) Closest(p Point) (Point, float64) {
best, bestKm := Point{}, math.Inf(1)
for _, w := range r.Ways {
for _, q := range w {
if d := Km(p, q); d < bestKm {
best, bestKm = q, d
}
}
}
return best, bestKm
}
// NearestJunction finds the closest node-network junction of a kind.
func (a *Area) NearestJunction(kind Kind, p Point) (Junction, float64, bool) {
var best Junction
bestKm := math.Inf(1)
for _, j := range a.Junctions {
if j.Kind != kind {
continue
}
if d := Km(p, j.Point); d < bestKm {
best, bestKm = j, d
}
}
return best, bestKm, !math.IsInf(bestKm, 1)
}
+216
View File
@@ -0,0 +1,216 @@
package routes
import (
"bytes"
"context"
"encoding/xml"
"fmt"
"net/http"
"net/http/httptest"
"slices"
"strings"
"sync"
"sync/atomic"
"testing"
)
// line returns a straight path of about km kilometres going east from p.
func line(p Point, km float64) []Point {
const kmPerDegLon = 70.0 // at ~51°N
return []Point{p, {p.Lat, p.Lon + km/kmPerDegLon/2}, {p.Lat, p.Lon + km/kmPerDegLon}}
}
const overpassJSON = `{"elements":[
{"type":"relation","id":1,"tags":{"route":"foot","name":"Park loop","roundtrip":"yes"},
"members":[{"type":"way","geometry":[{"lat":51.05,"lon":3.70},{"lat":51.05,"lon":3.7357}]}]},
{"type":"relation","id":2,"tags":{"route":"bicycle","name":"River ride"},
"members":[{"type":"way","geometry":[{"lat":51.05,"lon":3.70},{"lat":51.05,"lon":3.9857}]},{"type":"node"}]},
{"type":"relation","id":3,"tags":{"route":"foot"},"members":[]},
{"type":"node","id":9,"lat":51.051,"lon":3.701,"tags":{"rcn_ref":"56"}}
]}`
func TestParse(t *testing.T) {
a, err := parse(strings.NewReader(overpassJSON))
if err != nil {
t.Fatal(err)
}
if len(a.Routes) != 2 {
t.Fatalf("got %d routes, want 2 (unnamed one skipped): %+v", len(a.Routes), a.Routes)
}
park, ride := a.Routes[0], a.Routes[1]
if park.Kind != Foot || !park.Loop || park.LengthKm < 2.4 || park.LengthKm > 2.6 {
t.Errorf("park = %+v", park)
}
if ride.Kind != Bike || ride.Loop || ride.LengthKm < 19.5 || ride.LengthKm > 20.5 {
t.Errorf("ride = %+v", ride)
}
j, km, ok := a.NearestJunction(Bike, Point{51.05, 3.70})
if !ok || j.Ref != "56" || km > 0.2 {
t.Errorf("junction = %+v %.2f %v", j, km, ok)
}
if _, _, ok := a.NearestJunction(Foot, Point{51.05, 3.70}); ok {
t.Error("found a walking junction that isn't there")
}
}
func TestSuggest(t *testing.T) {
home := Point{51.05, 3.70}
a := &Area{Routes: []Route{
{ID: 1, Name: "short loop", Kind: Foot, Loop: true, LengthKm: 5, Ways: [][]Point{line(home, 5)}},
{ID: 2, Name: "perfect loop far away", Kind: Foot, Loop: true, LengthKm: 10, Ways: [][]Point{line(Point{51.2, 3.7}, 10)}},
{ID: 3, Name: "long path", Kind: Foot, LengthKm: 30, Ways: [][]Point{line(home, 30)}},
{ID: 4, Name: "bike loop", Kind: Bike, Loop: true, LengthKm: 10, Ways: [][]Point{line(home, 10)}},
}}
got := Suggest(a, Foot, 10, home, 3)
if len(got) != 2 {
t.Fatalf("got %d suggestions, want 2 (one too far, one for bikes): %+v", len(got), got)
}
if got[0].Route.ID != 1 || got[0].Plan != "laps" || got[0].Laps != 2 || got[0].TotalKm != 10 {
t.Errorf("first = %+v, want 2 laps of the short loop", got[0])
}
if got[1].Plan != "section" || got[1].TotalKm != 10 {
t.Errorf("second = %+v, want a 10 km section", got[1])
}
if s := got[0].Describe(); s != "2 laps of the 5.0 km loop" {
t.Errorf("describe = %q", s)
}
}
func TestNearRetriesAndCaches(t *testing.T) {
var calls atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Accept") != "application/json" {
http.Error(w, "406", http.StatusNotAcceptable)
return
}
if calls.Add(1) == 1 {
http.Error(w, "busy", http.StatusGatewayTimeout)
return
}
fmt.Fprint(w, overpassJSON)
}))
defer srv.Close()
dir := t.TempDir()
c := &Client{URLs: []string{srv.URL}, CacheDir: dir}
a, err := c.Near(context.Background(), 51.05, 3.70)
if err != nil || len(a.Routes) != 2 {
t.Fatalf("Near = %v, %v", a, err)
}
if _, ok := c.Route(2); !ok {
t.Error("route 2 not remembered for GPX")
}
// Same ~2 km cell: no new request. A fresh client reads the disk cache.
c.Near(context.Background(), 51.051, 3.701)
(&Client{URLs: []string{srv.URL}, CacheDir: dir}).Near(context.Background(), 51.05, 3.70)
if n := calls.Load(); n != 2 {
t.Errorf("overpass called %d times, want 2 (one busy, one ok)", n)
}
}
func TestWriteGPX(t *testing.T) {
var buf bytes.Buffer
r := &Route{Name: "Park & loop", Ways: [][]Point{{{51, 3}, {51.1, 3.1}}, {{51.2, 3.2}, {51.3, 3.3}}}}
if err := WriteGPX(&buf, r); err != nil {
t.Fatal(err)
}
var g struct {
Name string `xml:"trk>name"`
Segs []struct {
Pts []struct{} `xml:"trkpt"`
} `xml:"trk>trkseg"`
}
if err := xml.Unmarshal(buf.Bytes(), &g); err != nil {
t.Fatal(err, buf.String())
}
if g.Name != "Park & loop" || len(g.Segs) != 2 || len(g.Segs[1].Pts) != 2 {
t.Errorf("gpx = %+v\n%s", g, buf.String())
}
}
func TestLoops(t *testing.T) {
var dirs []string
var mu sync.Mutex
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
mu.Lock()
dirs = append(dirs, q.Get("direction"))
mu.Unlock()
if q.Get("engineMode") != "4" || q.Get("profile") != "hiking-mountain" || q.Get("lonlats") != "4.700,50.880" {
http.Error(w, "bad query "+r.URL.RawQuery, http.StatusBadRequest)
return
}
if q.Get("roundTripDistance") != "1960" { // 10 km / 5.1
http.Error(w, "bad distance "+q.Get("roundTripDistance"), http.StatusBadRequest)
return
}
if q.Get("direction") == "330" {
http.Error(w, "no route", http.StatusInternalServerError) // one direction failing is fine
return
}
http.ServeFile(w, r, "testdata/brouter.json")
}))
defer srv.Close()
c := &Client{BRouterURL: srv.URL}
// The exact spot is rounded to ~100 m before it's sent.
got, err := c.Loops(context.Background(), Foot, Point{50.88012, 4.70044}, 10, 90)
if err != nil {
t.Fatal(err)
}
slices.Sort(dirs)
if !slices.Equal(dirs, []string{"210", "330", "90"}) {
t.Errorf("directions asked = %v, want into the wind (90) and ±120°", dirs)
}
if len(got) != 2 {
t.Fatalf("got %d loops, want 2", len(got))
}
r := got[0].Route
if !r.Generated || !r.Loop || r.LengthKm != 5.9 || r.AscentM != 17 || r.PavedPct < 0 || r.PavedPct > 100 || r.ID >= 0 {
t.Errorf("loop = %+v", r)
}
if !strings.HasPrefix(r.Name, "5.9 km loop heading ") {
t.Errorf("name = %q", r.Name)
}
if _, ok := c.Route(r.ID); !ok {
t.Error("loop not remembered for GPX")
}
n := len(dirs)
c.Loops(context.Background(), Foot, Point{50.88012, 4.70044}, 10, 90)
if len(dirs) != n {
t.Error("second identical request should hit the cache")
}
}
func TestPavedPct(t *testing.T) {
msgs := [][]string{
{"Longitude", "Latitude", "Elevation", "Distance", "WayTags"},
{"0", "0", "0", "300", "highway=residential"},
{"0", "0", "0", "100", "highway=track surface=asphalt"},
{"0", "0", "0", "400", "highway=path"},
{"0", "0", "0", "200", "highway=footway surface=gravel"},
}
if got := pavedPct(msgs); got != 40 {
t.Errorf("paved = %d%%, want 40%%", got)
}
}
func TestIntoWind(t *testing.T) {
r := &Route{Generated: true, Heading: 270}
if !r.IntoWind(250) || r.IntoWind(90) || (&Route{Heading: 270}).IntoWind(270) {
t.Error("IntoWind wrong")
}
}
func TestSuggestSkipsLongTrails(t *testing.T) {
home := Point{51.05, 3.70}
a := &Area{Routes: []Route{
{ID: 1, Name: "Streek-GR", Kind: Foot, LengthKm: 130, Ways: [][]Point{line(home, 130)}},
{ID: 3, Name: "Streek-GR loop", Kind: Foot, Loop: true, LengthKm: 130, Ways: [][]Point{line(home, 130)}},
{ID: 2, Name: "Park loop", Kind: Foot, Loop: true, LengthKm: 8, Ways: [][]Point{line(Point{51.06, 3.70}, 8)}},
}}
got := Suggest(a, Foot, 10, home, 3)
if len(got) != 1 || got[0].Route.ID != 2 { // loop or not, 130 km is not a 10 km workout
t.Errorf("got %+v, want only the park loop", got)
}
}
+135
View File
@@ -0,0 +1,135 @@
package routes
import (
"encoding/xml"
"fmt"
"io"
"math"
"slices"
)
// Suggestion is a way to use a route for a workout of a given distance.
type Suggestion struct {
Route *Route
Plan string // "loop", "laps", "out-and-back" or "section"
Laps int // for "laps"
TotalKm float64 // what the user will actually cover on the route
FromYouKm float64 // to the nearest point of the route
JoinAt Point
}
// Describe says in a few words how to use the route.
func (s Suggestion) Describe() string {
switch s.Plan {
case "laps":
return fmt.Sprintf("%d laps of the %.1f km loop", s.Laps, s.Route.LengthKm)
case "out-and-back":
return fmt.Sprintf("out and back, %.1f km each way", s.Route.LengthKm)
case "section":
return fmt.Sprintf("a %.0f km stretch (out and back) of this %.0f km route", s.TotalKm, s.Route.LengthKm)
}
if s.Route.Generated {
return "from your door and back"
}
if s.Route.Loop {
return fmt.Sprintf("the full %.1f km loop", s.Route.LengthKm)
}
return fmt.Sprintf("the full %.1f km route", s.Route.LengthKm)
}
// maxApproachKm is how far we'd send someone to reach a route's start.
var maxApproachKm = map[Kind]float64{Bike: 6, Foot: 3}
// Suggest picks up to n routes of a kind that fit targetKm, starting from p.
func Suggest(a *Area, kind Kind, targetKm float64, p Point, n int) []Suggestion {
type scored struct {
Suggestion
cost float64
}
var all []scored
for i := range a.Routes {
r := &a.Routes[i]
if r.Kind != kind {
continue
}
if r.LengthKm > 3*targetKm {
continue // long-distance trails (Streek-GR & co) aren't a workout route
}
join, from := r.Closest(p)
if from > maxApproachKm[kind] {
continue
}
s := Suggestion{Route: r, Plan: "loop", Laps: 1, TotalKm: r.LengthKm, FromYouKm: from, JoinAt: join}
switch {
case r.Loop && r.LengthKm < 0.75*targetKm:
s.Laps = min(4, max(1, int(math.Round(targetKm/r.LengthKm))))
if s.Laps > 1 {
s.Plan = "laps"
}
s.TotalKm = r.LengthKm * float64(s.Laps)
case !r.Loop && 2*r.LengthKm <= 1.3*targetKm:
s.Plan, s.TotalKm = "out-and-back", 2*r.LengthKm
case r.LengthKm > 1.3*targetKm:
s.Plan, s.TotalKm = "section", max(targetKm/2, targetKm-2*from)
}
// Getting to the start and back is part of the workout.
cost := math.Abs(s.TotalKm+2*from-targetKm)/targetKm + 0.03*from
if !r.Loop {
cost += 0.3 // loops bring you home
}
if s.Plan == "section" {
cost += 0.15
}
if s.Laps > 2 {
cost += 0.1 // laps get dull
}
all = append(all, scored{s, cost})
}
slices.SortFunc(all, func(x, y scored) int {
switch {
case x.cost < y.cost:
return -1
case x.cost > y.cost:
return 1
}
return 0
})
var out []Suggestion
for _, s := range all[:min(n, len(all))] {
out = append(out, s.Suggestion)
}
return out
}
// WriteGPX writes a route as a GPX track, one segment per OSM way, ready for
// a watch, bike computer or OsmAnd.
func WriteGPX(w io.Writer, r *Route) error {
type trkpt struct {
Lat float64 `xml:"lat,attr"`
Lon float64 `xml:"lon,attr"`
}
type trkseg struct {
Points []trkpt `xml:"trkpt"`
}
type gpx struct {
XMLName xml.Name `xml:"gpx"`
Version string `xml:"version,attr"`
Creator string `xml:"creator,attr"`
Xmlns string `xml:"xmlns,attr"`
Name string `xml:"trk>name"`
Segs []trkseg `xml:"trk>trkseg"`
}
g := gpx{Version: "1.1", Creator: "workout-suggester (data © OpenStreetMap contributors, ODbL)",
Xmlns: "http://www.topografix.com/GPX/1/1", Name: r.Name}
for _, way := range r.Ways {
var seg trkseg
for _, p := range way {
seg.Points = append(seg.Points, trkpt{p.Lat, p.Lon})
}
g.Segs = append(g.Segs, seg)
}
io.WriteString(w, xml.Header)
enc := xml.NewEncoder(w)
enc.Indent("", " ")
return enc.Encode(g)
}
+453
View File
@@ -0,0 +1,453 @@
{
"type": "FeatureCollection",
"features": [
{
"type": "Feature",
"properties": {
"creator": "BRouter-1.7.10",
"name": "brouter_hiking-mountain_0",
"track-length": "5872",
"filtered ascend": "17",
"plain-ascend": "0",
"total-time": "4214",
"total-energy": "64086",
"cost": "10944",
"messages": [
["Longitude", "Latitude", "Elevation", "Distance", "CostPerKm", "ElevCost", "TurnCost", "NodeCost", "InitialCost", "WayTags", "NodeTags", "Time", "Energy"],
["4700029", "50879984", "23", "3", "1260", "0", "0", "0", "0", "highway=footway surface=paving_stones", "", "2", "34"],
["4700045", "50880001", "22", "2", "1200", "0", "0", "0", "0", "highway=pedestrian surface=paving_stones bicycle=yes", "", "3", "52"],
["4700077", "50880030", "22", "4", "1260", "0", "0", "0", "0", "highway=footway surface=asphalt bicycle=yes", "highway=crossing crossing=uncontrolled bicycle=yes", "6", "89"],
["4700117", "50880063", "22", "5", "1260", "0", "0", "0", "0", "highway=footway surface=asphalt bicycle=yes", "", "10", "135"],
["4700293", "50880210", "22", "20", "1200", "0", "0", "0", "0", "highway=pedestrian surface=sett bicycle=yes", "", "23", "203"],
["4700423", "50880308", "21", "14", "1200", "0", "0", "0", "0", "highway=pedestrian surface=paving_stones bicycle=yes", "", "33", "233"],
["4700964", "50880166", "22", "48", "1000", "0", "0", "0", "0", "highway=pedestrian surface=sett bicycle=yes route_hiking_nwn=yes route_hiking_rwn=yes", "", "67", "617"],
["4700995", "50880188", "22", "3", "1260", "0", "0", "0", "0", "reversedirection=yes highway=footway surface=sett", "", "70", "646"],
["4701350", "50880466", "22", "40", "1260", "0", "0", "0", "0", "reversedirection=yes highway=footway surface=paving_stones", "", "98", "890"],
["4701426", "50880513", "22", "7", "1260", "0", "0", "0", "0", "reversedirection=yes highway=footway surface=sett", "highway=crossing crossing=unmarked", "103", "937"],
["4701462", "50880534", "22", "3", "1260", "0", "0", "0", "0", "reversedirection=yes highway=footway surface=sett", "", "105", "958"],
["4701820", "50880705", "21", "31", "1260", "0", "0", "0", "0", "highway=footway surface=compacted access=no foot=yes", "", "126", "1050"],
["4701864", "50880726", "21", "4", "1260", "0", "0", "0", "0", "reversedirection=yes highway=footway surface=sett", "highway=crossing crossing=unmarked", "129", "1060"],
["4701911", "50880749", "21", "4", "1260", "0", "0", "0", "0", "reversedirection=yes highway=footway surface=sett", "", "132", "1070"],
["4702081", "50880815", "21", "14", "1260", "0", "0", "0", "0", "reversedirection=yes highway=footway surface=paving_stones", "", "142", "1087"],
["4702141", "50880842", "21", "5", "1260", "0", "0", "0", "0", "reversedirection=yes highway=footway surface=asphalt", "crossing=unknown", "145", "1096"],
["4702201", "50880864", "21", "5", "1260", "0", "0", "0", "0", "reversedirection=yes highway=footway surface=asphalt", "", "149", "1108"],
["4703896", "50881681", "20", "159", "1260", "0", "0", "0", "0", "reversedirection=yes highway=footway surface=paving_stones", "", "260", "1725"],
["4703929", "50881704", "20", "3", "1260", "0", "0", "0", "0", "reversedirection=yes highway=footway surface=asphalt", "highway=crossing", "262", "1732"],
["4703985", "50881728", "20", "5", "1260", "0", "0", "0", "0", "reversedirection=yes highway=footway surface=asphalt", "", "265", "1745"],
["4704773", "50882093", "20", "70", "1260", "0", "0", "0", "0", "highway=footway surface=paving_stones", "", "315", "2066"],
["4704838", "50882125", "20", "6", "1260", "0", "0", "0", "0", "highway=footway surface=asphalt", "crossing=unknown", "319", "2102"],
["4704885", "50882149", "20", "4", "1260", "0", "0", "0", "0", "highway=footway surface=asphalt", "", "322", "2128"],
["4704219", "50882691", "20", "76", "1260", "0", "0", "0", "0", "reversedirection=yes highway=footway surface=paving_stones", "", "375", "2676"],
["4704763", "50883960", "19", "147", "1260", "0", "0", "0", "0", "reversedirection=yes highway=footway surface=concrete", "", "480", "3854"],
["4705107", "50884096", "19", "29", "1260", "0", "0", "0", "0", "reversedirection=yes highway=footway surface=paving_stones", "", "500", "3913"],
["4705080", "50884119", "19", "3", "1260", "0", "0", "0", "0", "reversedirection=yes highway=footway surface=asphalt", "highway=crossing crossing=unmarked", "502", "3923"],
["4705033", "50884156", "19", "5", "1260", "0", "0", "0", "0", "reversedirection=yes highway=footway surface=asphalt", "", "506", "3940"],
["4705152", "50884205", "18", "10", "1260", "0", "0", "0", "0", "highway=footway", "", "513", "3957"],
["4705390", "50884781", "18", "91", "1260", "0", "0", "0", "0", "reversedirection=yes highway=footway", "", "576", "4195"],
["4705499", "50884835", "18", "9", "1260", "0", "0", "0", "0", "highway=footway surface=asphalt", "", "582", "4231"],
["4705441", "50884869", "18", "6", "1100", "0", "0", "0", "0", "highway=cycleway surface=paving_stones foot=designated bicycle=designated route_hiking_nwn=yes route_hiking_rwn=yes", "", "587", "4257"],
["4705465", "50884885", "18", "2", "1100", "0", "0", "0", "0", "highway=cycleway surface=concrete foot=yes route_hiking_nwn=yes route_hiking_rwn=yes", "", "588", "4266"],
["4705560", "50884949", "18", "10", "1100", "0", "0", "0", "0", "reversedirection=yes highway=cycleway surface=asphalt foot=designated bicycle=designated route_hiking_nwn=yes route_hiking_rwn=yes", "", "595", "4293"],
["4705824", "50886096", "19", "129", "1260", "0", "0", "0", "0", "reversedirection=yes highway=footway surface=asphalt foot=yes", "crossing=unknown", "688", "5661"],
["4705976", "50886872", "18", "87", "1260", "0", "0", "0", "0", "reversedirection=yes highway=footway surface=asphalt foot=yes", "", "749", "6310"],
["4706015", "50886870", "18", "3", "1260", "0", "0", "0", "0", "reversedirection=yes highway=footway surface=asphalt", "highway=crossing", "751", "6326"],
["4706055", "50886868", "18", "3", "1260", "0", "0", "0", "0", "reversedirection=yes highway=footway surface=asphalt", "barrier=kerb kerb=flush", "754", "6342"],
["4706588", "50887183", "18", "54", "1260", "0", "0", "0", "0", "highway=footway surface=paving_stones", "", "792", "6729"],
["4706710", "50887317", "19", "17", "1260", "0", "0", "0", "0", "reversedirection=yes highway=footway", "highway=crossing estimated_crossing_class=0 crossing=traffic_signals", "804", "6940"],
["4707067", "50887727", "20", "52", "1260", "0", "0", "0", "0", "reversedirection=yes highway=footway", "", "843", "7844"],
["4707080", "50887764", "20", "4", "1260", "0", "0", "0", "0", "reversedirection=yes highway=footway surface=asphalt", "highway=crossing estimated_crossing_class=0 crossing=traffic_signals bicycle=yes", "845", "7912"],
["4707124", "50887747", "20", "4", "16800", "0", "0", "0", "0", "reversedirection=yes highway=primary_link surface=asphalt foot=use_sidepath bicycle=use_sidepath", "highway=traffic_signals traffic_signals:direction=forward", "848", "7978"],
["4708324", "50887251", "21", "101", "16800", "0", "0", "0", "0", "reversedirection=yes highway=primary_link surface=asphalt foot=use_sidepath bicycle=use_sidepath", "", "923", "9656"],
["4707124", "50887747", "20", "101", "16800", "0", "0", "0", "0", "highway=primary_link surface=asphalt foot=use_sidepath bicycle=use_sidepath", "highway=traffic_signals traffic_signals:direction=forward", "994", "10312"],
["4707080", "50887764", "20", "4", "16800", "0", "0", "0", "0", "highway=primary_link surface=asphalt foot=use_sidepath bicycle=use_sidepath", "highway=crossing estimated_crossing_class=0 crossing=traffic_signals bicycle=yes", "997", "10343"],
["4707067", "50887727", "20", "4", "1260", "0", "0", "0", "0", "highway=footway surface=asphalt", "", "1000", "10374"],
["4706710", "50887317", "19", "52", "1260", "0", "0", "0", "0", "highway=footway", "highway=crossing estimated_crossing_class=0 crossing=traffic_signals", "1036", "10496"],
["4706588", "50887183", "18", "17", "1260", "0", "0", "0", "0", "highway=footway", "", "1048", "10515"],
["4711122", "50884603", "21", "441", "1260", "0", "0", "0", "0", "highway=footway surface=paving_stones", "", "1366", "15770"],
["4711170", "50884572", "21", "5", "1260", "0", "0", "0", "0", "highway=footway surface=asphalt", "highway=crossing crossing=uncontrolled", "1370", "15819"],
["4711194", "50884557", "21", "2", "1260", "0", "0", "0", "0", "highway=footway surface=asphalt", "barrier=kerb kerb=flush", "1371", "15839"],
["4713706", "50882320", "24", "306", "1260", "0", "0", "0", "0", "highway=footway surface=paving_stones", "", "1591", "19160"],
["4713716", "50882311", "24", "1", "1260", "0", "0", "0", "0", "reversedirection=yes highway=footway surface=paving_stones", "", "1592", "19170"],
["4714413", "50881554", "28", "98", "1260", "0", "0", "0", "0", "highway=footway surface=paving_stones", "", "1668", "22011"],
["4714572", "50881225", "29", "39", "1200", "0", "0", "0", "0", "highway=pedestrian surface=paving_stones", "", "1698", "23027"],
["4714622", "50881234", "29", "4", "1260", "0", "0", "0", "0", "highway=footway surface=asphalt", "crossing=unknown", "1701", "23119"],
["4714653", "50881159", "29", "9", "1320", "0", "0", "0", "0", "highway=residential surface=concrete bicycle=yes sidewalk=both route_bicycle_lcn=yes", "", "1707", "23317"],
["4714681", "50881090", "29", "8", "1320", "0", "0", "0", "0", "highway=residential surface=concrete bicycle=yes route_bicycle_ncn=yes route_bicycle_rcn=yes route_bicycle_lcn=yes", "crossing=unknown", "1713", "23484"],
["4714797", "50880817", "29", "31", "1320", "0", "0", "0", "0", "highway=residential surface=concrete bicycle=yes route_bicycle_ncn=yes route_bicycle_rcn=yes route_bicycle_lcn=yes", "", "1736", "24071"],
["4714876", "50880590", "29", "26", "1320", "0", "0", "0", "0", "reversedirection=yes highway=residential surface=concrete bicycle=yes route_bicycle_lcn=yes", "", "1756", "24593"],
["4715160", "50879831", "30", "87", "1320", "0", "0", "0", "0", "highway=residential surface=asphalt bicycle=use_sidepath", "", "1820", "26149"],
["4715101", "50879883", "30", "7", "1440", "0", "0", "0", "0", "reversedirection=yes highway=service", "highway=give_way direction=forward", "1825", "26274"],
["4714381", "50881499", "26", "187", "1440", "0", "0", "0", "0", "reversedirection=yes highway=service", "", "1965", "30005"],
["4714614", "50881537", "26", "17", "1440", "0", "0", "0", "0", "reversedirection=yes highway=service surface=concrete", "", "1977", "30222"],
["4713938", "50882450", "24", "114", "1440", "0", "0", "0", "0", "highway=service", "", "2057", "30932"],
["4713540", "50882794", "24", "47", "1440", "0", "0", "0", "0", "highway=service surface=asphalt", "", "2090", "31017"],
["4713938", "50882450", "24", "47", "1440", "0", "0", "0", "0", "reversedirection=yes highway=service surface=asphalt", "", "2124", "31453"],
["4714614", "50881537", "27", "114", "1440", "0", "0", "0", "0", "reversedirection=yes highway=service", "", "2205", "32510"],
["4714381", "50881499", "27", "17", "1440", "0", "0", "0", "0", "highway=service surface=concrete", "", "2217", "32667"],
["4715101", "50879883", "30", "187", "1440", "0", "0", "0", "0", "highway=service", "highway=give_way direction=forward", "2357", "36467"],
["4715160", "50879831", "30", "7", "1440", "0", "0", "0", "0", "highway=service", "", "2362", "36570"],
["4714907", "50880454", "29", "72", "1320", "0", "0", "0", "0", "reversedirection=yes highway=residential surface=asphalt bicycle=use_sidepath", "", "2417", "38328"],
["4714636", "50880436", "30", "19", "1320", "0", "0", "0", "0", "reversedirection=yes highway=cycleway surface=paving_stones route_bicycle_lcn=yes", "", "2431", "38835"],
["4712836", "50879070", "34", "198", "1320", "0", "0", "0", "0", "reversedirection=yes highway=residential surface=asphalt foot=yes sidewalk=both", "", "2578", "42607"],
["4712905", "50879038", "34", "6", "1320", "0", "0", "0", "0", "highway=residential surface=asphalt", "highway=give_way direction=backward", "2583", "42716"],
["4714481", "50878358", "34", "134", "1320", "0", "0", "0", "0", "highway=residential surface=asphalt", "highway=crossing crossing=uncontrolled traffic_calming=table", "2680", "44525"],
["4714517", "50878296", "35", "10", "1320", "0", "0", "0", "0", "highway=residential surface=asphalt", "highway=crossing crossing=unmarked kerb=flush", "2688", "44730"],
["4713125", "50877237", "36", "153", "1320", "0", "0", "0", "0", "highway=residential surface=asphalt", "", "2802", "47900"],
["4713457", "50877115", "36", "29", "1440", "0", "0", "0", "0", "reversedirection=yes highway=service access=permissive", "", "2823", "48359"],
["4713125", "50877237", "36", "29", "1440", "0", "0", "0", "0", "highway=service access=permissive", "", "2845", "48838"],
["4712507", "50876767", "36", "68", "1320", "0", "0", "0", "0", "highway=residential surface=asphalt", "highway=give_way direction=forward", "2895", "49693"],
["4712479", "50876746", "37", "3", "1320", "0", "0", "0", "0", "highway=residential surface=asphalt", "", "2897", "49741"],
["4712501", "50876618", "37", "14", "1320", "0", "0", "0", "0", "reversedirection=yes highway=residential surface=paving_stones junction=roundabout", "", "2907", "49956"],
["4712445", "50876614", "37", "4", "1260", "0", "0", "0", "0", "highway=footway surface=paving_stones", "barrier=bollard bicycle=yes foot=yes motor_vehicle=no", "2910", "50016"],
["4712448", "50876594", "37", "2", "1200", "0", "0", "0", "0", "highway=pedestrian surface=paving_stones bicycle=yes", "barrier=bollard bicycle=yes foot=yes motor_vehicle=no", "2911", "50045"],
["4712345", "50876516", "37", "16", "1200", "0", "0", "0", "0", "highway=pedestrian surface=paving_stones bicycle=yes", "barrier=bollard bicycle=yes foot=yes motor_vehicle=no", "2923", "50289"],
["4712316", "50876514", "37", "2", "1200", "0", "0", "0", "0", "highway=pedestrian surface=paving_stones bicycle=yes", "barrier=bollard bicycle=yes foot=yes motor_vehicle=no", "2925", "50316"],
["4712323", "50876470", "37", "5", "1260", "0", "0", "0", "0", "reversedirection=yes highway=footway surface=paving_stones", "", "2928", "50394"],
["4712121", "50876456", "37", "14", "1320", "0", "0", "0", "0", "reversedirection=yes highway=residential surface=paving_stones junction=roundabout", "", "2938", "50576"],
["4712096", "50876437", "37", "3", "1320", "0", "0", "0", "0", "highway=residential surface=asphalt sidewalk=both", "highway=give_way direction=backward", "2941", "50614"],
["4711050", "50875639", "37", "116", "1320", "0", "0", "0", "0", "highway=residential surface=asphalt sidewalk=both", "highway=crossing crossing=uncontrolled kerb=lowered", "3024", "51921"],
["4711033", "50875627", "37", "2", "1320", "0", "0", "0", "0", "highway=residential surface=asphalt sidewalk=both", "highway=give_way direction=forward", "3026", "51941"],
["4710922", "50875545", "37", "12", "1320", "0", "0", "0", "0", "highway=residential surface=asphalt sidewalk=both", "highway=crossing crossing=uncontrolled kerb=lowered", "3034", "52064"],
["4710881", "50875514", "37", "4", "1320", "0", "0", "0", "0", "highway=residential surface=asphalt sidewalk=both", "highway=give_way direction=backward", "3037", "52105"],
["4710354", "50875078", "37", "61", "1320", "0", "0", "0", "0", "highway=residential surface=asphalt sidewalk=both", "", "3081", "52710"],
["4710315", "50875054", "37", "4", "1320", "0", "0", "0", "0", "highway=residential surface=paving_stones sidewalk=both", "highway=crossing crossing=uncontrolled kerb=lowered", "3084", "52749"],
["4710276", "50875029", "37", "4", "1320", "0", "0", "0", "0", "highway=residential surface=paving_stones sidewalk=both", "highway=stop direction=forward", "3087", "52788"],
["4710191", "50874972", "37", "8", "1320", "0", "0", "0", "0", "highway=residential surface=paving_stones sidewalk=both", "highway=stop direction=backward", "3092", "52866"],
["4710148", "50874938", "37", "5", "1320", "0", "0", "0", "0", "highway=residential surface=paving_stones sidewalk=both", "highway=crossing crossing=uncontrolled kerb=lowered", "3096", "52914"],
["4710115", "50874913", "37", "4", "1320", "0", "0", "0", "0", "highway=residential surface=paving_stones sidewalk=both", "", "3099", "52953"],
["4709794", "50874663", "38", "36", "1320", "0", "0", "0", "0", "highway=residential surface=asphalt", "", "3125", "53524"],
["4709764", "50874640", "38", "3", "1320", "0", "0", "0", "0", "highway=residential surface=asphalt sidewalk=both", "barrier=bollard bicycle=yes foot=yes motor_vehicle=no", "3127", "53572"],
["4709735", "50874620", "38", "3", "1320", "0", "0", "0", "0", "reversedirection=yes highway=residential surface=asphalt sidewalk=both", "", "3130", "53619"],
["4708064", "50872679", "38", "253", "1320", "0", "0", "0", "0", "highway=residential surface=asphalt sidewalk=both", "", "3313", "56842"],
["4708332", "50872658", "37", "19", "1440", "0", "0", "0", "0", "reversedirection=yes highway=service", "", "3326", "56977"],
["4708064", "50872679", "38", "19", "1440", "0", "0", "0", "0", "highway=service", "", "3340", "57267"],
["4708625", "50874109", "38", "184", "1320", "0", "0", "0", "0", "reversedirection=yes highway=residential surface=asphalt sidewalk=both", "traffic_calming=choker", "3471", "58852"],
["4708058", "50874404", "37", "51", "1320", "0", "0", "0", "0", "reversedirection=yes highway=residential surface=asphalt sidewalk=both", "traffic_calming=choker", "3507", "59141"],
["4707935", "50874469", "38", "11", "1320", "0", "0", "0", "0", "reversedirection=yes highway=residential surface=asphalt sidewalk=both", "highway=crossing", "3515", "59262"],
["4707611", "50874644", "38", "30", "1320", "0", "0", "0", "0", "reversedirection=yes highway=residential surface=asphalt sidewalk=both", "traffic_calming=choker", "3537", "59585"],
["4707340", "50874790", "38", "25", "1320", "0", "0", "0", "0", "reversedirection=yes highway=residential surface=asphalt sidewalk=both", "highway=give_way direction=backward", "3555", "59845"],
["4707325", "50874798", "38", "1", "1320", "0", "0", "0", "0", "reversedirection=yes highway=residential surface=asphalt sidewalk=both", "highway=crossing crossing=unmarked", "3556", "59855"],
["4707260", "50874830", "38", "6", "1320", "0", "0", "0", "0", "reversedirection=yes highway=residential surface=asphalt sidewalk=both", "", "3560", "59916"],
["4707818", "50875257", "38", "62", "1320", "0", "0", "0", "0", "highway=residential surface=asphalt sidewalk=both", "", "3604", "60533"],
["4708336", "50875628", "38", "55", "1320", "0", "0", "0", "0", "highway=residential surface=asphalt foot=yes sidewalk=both", "highway=stop direction=forward", "3644", "61052"],
["4708350", "50875638", "38", "1", "1320", "0", "0", "0", "0", "highway=residential surface=asphalt foot=yes sidewalk=both", "highway=crossing crossing=uncontrolled kerb=lowered", "3644", "61062"],
["4708412", "50875685", "38", "7", "1320", "0", "0", "0", "0", "highway=residential surface=asphalt foot=yes sidewalk=both", "", "3649", "61129"],
["4708322", "50875717", "37", "7", "1440", "0", "0", "0", "0", "reversedirection=yes highway=tertiary surface=asphalt sidewalk=both", "highway=crossing crossing=uncontrolled kerb=lowered", "3654", "61182"],
["4706094", "50876714", "38", "193", "1440", "0", "0", "0", "0", "reversedirection=yes highway=tertiary surface=asphalt sidewalk=both", "highway=crossing crossing=unmarked kerb=lowered", "3793", "63402"],
["4706058", "50876727", "38", "3", "1440", "0", "0", "0", "0", "reversedirection=yes highway=tertiary surface=asphalt sidewalk=both", "", "3796", "63438"],
["4705972", "50876755", "38", "7", "1320", "0", "0", "0", "0", "reversedirection=yes highway=living_street surface=asphalt bicycle=yes sidewalk=both", "", "3801", "63506"],
["4705423", "50876932", "38", "43", "1320", "0", "0", "0", "0", "reversedirection=yes highway=living_street surface=paving_stones bicycle=yes sidewalk=both", "", "3831", "63776"],
["4705400", "50876939", "38", "2", "1320", "0", "0", "0", "0", "reversedirection=yes highway=residential surface=asphalt", "highway=crossing crossing=unmarked kerb=flush", "3832", "63789"],
["4704857", "50877116", "36", "43", "1320", "0", "0", "0", "0", "reversedirection=yes highway=residential surface=asphalt", "", "3862", "63789"],
["4704683", "50877183", "36", "14", "1320", "0", "0", "0", "0", "reversedirection=yes highway=residential surface=asphalt sidewalk=both", "highway=crossing crossing=unmarked", "3871", "63789"],
["4704644", "50877203", "36", "3", "1320", "0", "0", "0", "0", "reversedirection=yes highway=residential surface=asphalt sidewalk=both", "", "3873", "63789"],
["4703626", "50877883", "32", "105", "1200", "0", "0", "0", "0", "reversedirection=yes highway=pedestrian surface=sett bicycle=yes", "", "3946", "64086"],
["4702846", "50878849", "29", "126", "1200", "0", "0", "0", "0", "reversedirection=yes highway=pedestrian surface=sett access=delivery foot=yes bicycle=yes", "barrier=bollard", "4030", "64086"],
["4702447", "50879023", "27", "34", "1400", "0", "0", "0", "0", "reversedirection=yes highway=pedestrian surface=paving_stones access=delivery foot=yes bicycle=yes", "", "4052", "64086"],
["4702453", "50879159", "26", "15", "1200", "0", "0", "0", "0", "reversedirection=yes highway=pedestrian access=delivery foot=yes bicycle=yes", "", "4062", "64086"],
["4702176", "50879240", "28", "22", "1320", "0", "0", "0", "0", "highway=residential surface=asphalt foot=yes bicycle=yes route_bicycle_ncn=yes route_bicycle_rcn=yes", "highway=crossing", "4076", "64086"],
["4702137", "50879274", "28", "5", "1320", "0", "0", "0", "0", "highway=residential surface=asphalt foot=yes bicycle=yes sidewalk=both route_bicycle_ncn=yes route_bicycle_rcn=yes", "", "4080", "64086"],
["4701863", "50879520", "27", "33", "1100", "0", "0", "0", "0", "highway=residential surface=asphalt foot=yes bicycle=yes sidewalk=both route_bicycle_lcn=yes route_hiking_nwn=yes route_hiking_rwn=yes", "nodeaccessgranted=yes highway=crossing", "4102", "64086"],
["4701074", "50879938", "24", "73", "1300", "0", "0", "0", "0", "highway=residential surface=asphalt foot=yes bicycle=yes sidewalk=both route_bicycle_lcn=yes route_hiking_nwn=yes route_hiking_rwn=yes", "nodeaccessgranted=yes highway=crossing crossing=uncontrolled", "4150", "64086"],
["4700933", "50880001", "23", "12", "1300", "0", "0", "0", "0", "highway=residential surface=asphalt foot=yes bicycle=yes sidewalk=both route_bicycle_lcn=yes route_hiking_nwn=yes route_hiking_rwn=yes", "", "4157", "64086"],
["4700731", "50879935", "23", "16", "1520", "0", "0", "0", "0", "highway=residential surface=asphalt foot=yes bicycle=yes sidewalk=both route_bicycle_lcn=yes", "", "4168", "64086"],
["4700609", "50879880", "23", "11", "1520", "0", "0", "0", "0", "highway=residential surface=asphalt bicycle=yes route_bicycle_lcn=yes", "crossing=unknown", "4175", "64086"],
["4700567", "50879908", "23", "4", "1460", "0", "0", "0", "0", "reversedirection=yes highway=footway surface=asphalt", "", "4178", "64086"],
["4700436", "50879922", "23", "12", "1400", "0", "0", "0", "0", "highway=pedestrian surface=paving_stones", "", "4185", "64086"],
["4700399", "50879888", "23", "5", "1460", "0", "0", "0", "0", "reversedirection=yes highway=footway surface=asphalt", "highway=crossing crossing=unmarked", "4189", "64086"],
["4700077", "50880030", "22", "28", "1431", "0", "0", "0", "0", "highway=residential surface=asphalt bicycle=yes route_bicycle_ncn=yes route_bicycle_rcn=yes route_bicycle_lcn=yes", "highway=crossing crossing=uncontrolled bicycle=yes", "4207", "64086"],
["4700045", "50880001", "22", "4", "1260", "0", "0", "0", "0", "reversedirection=yes highway=footway surface=asphalt bicycle=yes", "", "4210", "64086"],
["4700029", "50879984", "23", "2", "1200", "0", "0", "0", "0", "reversedirection=yes highway=pedestrian surface=paving_stones bicycle=yes", "", "4212", "64086"],
["4699997", "50879997", "22", "3", "1260", "0", "0", "0", "0", "reversedirection=yes highway=footway surface=paving_stones", "", "4214", "64086"]
],
"times": [0,2.163,3.592,6.45,10.023,10.731,23.989,33.714,36.5,39.264,42.056,67.852,70.001,70.711,74.936,98.233,103.186,105.31,122.058,126.223,126.918,129.7,132.485,142.174,145.645,149.121,153.297,155.388,216.549,217.964,230.002,256.888,260.361,262.446,265.926,268.017,315.035,319.27,322.095,341.906,375.975,422.96,434.367,456.478,466.468,480.519,500.66,502.752,506.243,509.709,513.179,520.135,540.402,541.103,576.523,577.222,578.621,582.822,587.027,588.43,595.394,599.581,657.224,665.125,677.329,688.09,735.792,749.828,751.937,754.048,757.568,761.759,768.057,789.447,792.325,794.483,804.63,816.298,843.016,845.964,848.908,923.235,994.634,997.475,1000.317,1025.363,1036.474,1046.156,1048.236,1054.488,1057.969,1188.031,1216.246,1217.69,1219.854,1224.861,1270.5,1294.282,1366.788,1370.37,1371.803,1373.236,1373.959,1394.884,1442.953,1591.905,1592.62,1595.505,1647.111,1651.672,1668.393,1669.148,1698.194,1701.212,1707.979,1713.969,1736.995,1740.685,1747.368,1756.393,1767.421,1771.745,1820.746,1825.924,1829.647,1871.099,1965.407,1977.726,2021.74,2057.81,2090.421,2124.015,2161.896,2205.496,2217.646,2312.819,2353.622,2357.313,2362.428,2412.539,2417.085,2423.174,2431.638,2509.766,2578.798,2583.241,2669.58,2680.645,2685.133,2688.118,2691.843,2775.31,2782.71,2802.574,2806.969,2817.116,2820.704,2823.859,2827.488,2831.152,2841.471,2845.88,2863.9,2884.197,2895.129,2897.33,2907.583,2910.506,2911.966,2913.439,2917.814,2922.222,2923.677,2925.131,2928.797,2938.952,2941.126,2959.208,2964.983,2990.204,3021.826,3024.697,3026.132,3029.003,3034.743,3036.895,3037.612,3050.519,3055.536,3081.326,3084.191,3087.055,3090.635,3092.782,3096.362,3099.225,3116.077,3121.231,3125.641,3127.843,3130.043,3197.625,3232.684,3242.78,3252.782,3262.867,3313.393,3326.857,3340.766,3389.922,3399.826,3409.824,3419.743,3454.78,3465.504,3471.938,3484.501,3502.874,3507.871,3515.79,3537.358,3555.307,3556.024,3560.329,3574.671,3604.764,3611.155,3644.101,3644.817,3649.828,3654.796,3670.425,3726.304,3745.056,3748.627,3780.958,3789.644,3793.981,3796.149,3801.162,3803.31,3820.933,3831.533,3832.948,3862.153,3868.914,3871.629,3872.309,3873.669,3887.58,3914.583,3946.206,3950.333,3988.384,4008.297,4016.443,4030.473,4035.778,4052.43,4057.557,4062.002,4067.258,4074.098,4076.843,4080.28,4102.608,4113.217,4121.783,4125.044,4150.016,4157.703,4168.1,4175.395,4178.04,4183.308,4185.978,4189.325,4207.966,4210.658,4212.018,4214.043]
},
"geometry": {
"type": "LineString",
"coordinates": [
[4.699997, 50.879997, 22.75],
[4.700029, 50.879984, 23.0],
[4.700045, 50.880001, 22.75],
[4.700077, 50.880030, 22.75],
[4.700117, 50.880063, 22.75],
[4.700126, 50.880071, 22.5],
[4.700293, 50.880210, 22.0],
[4.700423, 50.880308, 21.75],
[4.700458, 50.880337, 21.75],
[4.700497, 50.880366, 21.5],
[4.700547, 50.880349, 21.75],
[4.700964, 50.880166, 22.5],
[4.700995, 50.880188, 22.5],
[4.701006, 50.880197, 22.25],
[4.701045, 50.880243, 22.0],
[4.701350, 50.880466, 22.0],
[4.701426, 50.880513, 22.0],
[4.701462, 50.880534, 22.0],
[4.701734, 50.880664, 21.5],
[4.701806, 50.880698, 21.25],
[4.701820, 50.880705, 21.25],
[4.701864, 50.880726, 21.25],
[4.701911, 50.880749, 21.25],
[4.702081, 50.880815, 21.0],
[4.702141, 50.880842, 21.0],
[4.702201, 50.880864, 21.0],
[4.702262, 50.880829, 21.0],
[4.702299, 50.880840, 21.0],
[4.703275, 50.881325, 21.0],
[4.703293, 50.881333, 21.0],
[4.703486, 50.881429, 21.0],
[4.703923, 50.881642, 20.0],
[4.703896, 50.881681, 20.0],
[4.703929, 50.881704, 20.0],
[4.703985, 50.881728, 20.0],
[4.704023, 50.881729, 20.0],
[4.704773, 50.882093, 20.0],
[4.704838, 50.882125, 20.0],
[4.704885, 50.882149, 20.0],
[4.704640, 50.882349, 20.0],
[4.704219, 50.882691, 20.0],
[4.704474, 50.883260, 20.0],
[4.704538, 50.883397, 20.0],
[4.704650, 50.883664, 20.0],
[4.704707, 50.883786, 20.0],
[4.704763, 50.883960, 19.5],
[4.705107, 50.884096, 19.0],
[4.705080, 50.884119, 19.0],
[4.705033, 50.884156, 19.0],
[4.705090, 50.884178, 18.75],
[4.705152, 50.884205, 18.75],
[4.705069, 50.884275, 18.75],
[4.704819, 50.884487, 18.75],
[4.704823, 50.884496, 18.75],
[4.705390, 50.884781, 18.25],
[4.705406, 50.884788, 18.25],
[4.705432, 50.884800, 18.25],
[4.705499, 50.884835, 18.25],
[4.705441, 50.884869, 18.25],
[4.705465, 50.884885, 18.25],
[4.705560, 50.884949, 18.0],
[4.705589, 50.884999, 18.0],
[4.705744, 50.885709, 19.0],
[4.705767, 50.885811, 19.0],
[4.705800, 50.885964, 19.0],
[4.705824, 50.886096, 19.0],
[4.705941, 50.886695, 18.75],
[4.705976, 50.886872, 18.25],
[4.706015, 50.886870, 18.25],
[4.706055, 50.886868, 18.25],
[4.706128, 50.886863, 18.25],
[4.706211, 50.886883, 18.0],
[4.706328, 50.886925, 18.0],
[4.706555, 50.887151, 18.5],
[4.706588, 50.887183, 18.75],
[4.706610, 50.887205, 18.75],
[4.706710, 50.887317, 19.0],
[4.706819, 50.887439, 19.25],
[4.707067, 50.887727, 20.0],
[4.707080, 50.887764, 20.0],
[4.707124, 50.887747, 20.0],
[4.708324, 50.887251, 21.0],
[4.707124, 50.887747, 20.0],
[4.707080, 50.887764, 20.0],
[4.707067, 50.887727, 20.0],
[4.706819, 50.887439, 19.25],
[4.706710, 50.887317, 19.0],
[4.706610, 50.887205, 18.75],
[4.706588, 50.887183, 18.75],
[4.706719, 50.887194, 18.75],
[4.706785, 50.887176, 18.75],
[4.708918, 50.886307, 20.5],
[4.709376, 50.886112, 20.75],
[4.709399, 50.886102, 20.75],
[4.709430, 50.886089, 20.75],
[4.709508, 50.886055, 20.5],
[4.710023, 50.885591, 21.0],
[4.710294, 50.885347, 21.0],
[4.711122, 50.884603, 21.0],
[4.711170, 50.884572, 21.0],
[4.711194, 50.884557, 21.0],
[4.711216, 50.884540, 21.0],
[4.711223, 50.884534, 21.25],
[4.711459, 50.884324, 21.25],
[4.711997, 50.883841, 21.75],
[4.713706, 50.882320, 24.5],
[4.713716, 50.882311, 24.5],
[4.713749, 50.882281, 24.75],
[4.714302, 50.881798, 28.0],
[4.714327, 50.881744, 28.0],
[4.714413, 50.881554, 28.25],
[4.714431, 50.881557, 28.25],
[4.714572, 50.881225, 29.0],
[4.714622, 50.881234, 29.0],
[4.714653, 50.881159, 29.0],
[4.714681, 50.881090, 29.0],
[4.714797, 50.880817, 29.0],
[4.714814, 50.880777, 29.0],
[4.714846, 50.880695, 29.25],
[4.714876, 50.880590, 29.75],
[4.714907, 50.880454, 29.25],
[4.714921, 50.880405, 28.75],
[4.715160, 50.879831, 30.0],
[4.715101, 50.879883, 30.25],
[4.715059, 50.879920, 30.5],
[4.714850, 50.880390, 29.25],
[4.714381, 50.881499, 26.5],
[4.714614, 50.881537, 26.25],
[4.714384, 50.882068, 25.0],
[4.713938, 50.882450, 24.0],
[4.713540, 50.882794, 24.0],
[4.713938, 50.882450, 24.75],
[4.714384, 50.882068, 26.0],
[4.714614, 50.881537, 27.25],
[4.714381, 50.881499, 27.75],
[4.714850, 50.880390, 30.5],
[4.715059, 50.879920, 30.5],
[4.715101, 50.879883, 30.25],
[4.715160, 50.879831, 30.0],
[4.714921, 50.880405, 28.75],
[4.714907, 50.880454, 29.25],
[4.714793, 50.880442, 29.5],
[4.714636, 50.880436, 30.0],
[4.713651, 50.879700, 32.0],
[4.712836, 50.879070, 34.25],
[4.712905, 50.879038, 34.25],
[4.714304, 50.878435, 34.0],
[4.714481, 50.878358, 34.75],
[4.714557, 50.878326, 35.25],
[4.714517, 50.878296, 35.25],
[4.714471, 50.878260, 35.25],
[4.713463, 50.877494, 36.5],
[4.713369, 50.877427, 36.75],
[4.713125, 50.877237, 36.75],
[4.713185, 50.877205, 36.75],
[4.713340, 50.877128, 36.5],
[4.713403, 50.877117, 36.25],
[4.713457, 50.877115, 36.0],
[4.713403, 50.877117, 36.25],
[4.713340, 50.877128, 36.5],
[4.713185, 50.877205, 36.75],
[4.713125, 50.877237, 36.75],
[4.712896, 50.877063, 36.25],
[4.712645, 50.876872, 36.5],
[4.712507, 50.876767, 36.75],
[4.712479, 50.876746, 37.0],
[4.712501, 50.876618, 37.0],
[4.712445, 50.876614, 37.0],
[4.712448, 50.876594, 37.0],
[4.712451, 50.876576, 37.25],
[4.712371, 50.876570, 37.0],
[4.712379, 50.876518, 37.25],
[4.712345, 50.876516, 37.0],
[4.712316, 50.876514, 37.0],
[4.712323, 50.876470, 37.25],
[4.712121, 50.876456, 37.0],
[4.712096, 50.876437, 37.0],
[4.711873, 50.876267, 37.0],
[4.711800, 50.876211, 37.0],
[4.711483, 50.875969, 37.0],
[4.711086, 50.875667, 37.0],
[4.711050, 50.875639, 37.0],
[4.711033, 50.875627, 37.0],
[4.711000, 50.875602, 37.0],
[4.710922, 50.875545, 37.0],
[4.710893, 50.875523, 37.0],
[4.710881, 50.875514, 37.0],
[4.710719, 50.875385, 37.0],
[4.710670, 50.875332, 37.0],
[4.710354, 50.875078, 37.0],
[4.710315, 50.875054, 37.0],
[4.710276, 50.875029, 37.0],
[4.710221, 50.874995, 37.0],
[4.710191, 50.874972, 37.0],
[4.710148, 50.874938, 37.0],
[4.710115, 50.874913, 37.0],
[4.709913, 50.874756, 37.75],
[4.709846, 50.874704, 38.0],
[4.709794, 50.874663, 38.0],
[4.709764, 50.874640, 38.0],
[4.709735, 50.874620, 38.0],
[4.708889, 50.873974, 38.0],
[4.708457, 50.873625, 37.75],
[4.708350, 50.873523, 38.0],
[4.708263, 50.873409, 37.75],
[4.708197, 50.873294, 38.0],
[4.708064, 50.872679, 38.75],
[4.708332, 50.872658, 37.5],
[4.708064, 50.872679, 38.75],
[4.708197, 50.873294, 38.0],
[4.708263, 50.873409, 37.75],
[4.708350, 50.873523, 38.0],
[4.708457, 50.873625, 37.75],
[4.708889, 50.873974, 38.0],
[4.708719, 50.874060, 38.0],
[4.708625, 50.874109, 38.0],
[4.708423, 50.874214, 37.25],
[4.708132, 50.874365, 37.5],
[4.708058, 50.874404, 37.75],
[4.707935, 50.874469, 38.0],
[4.707611, 50.874644, 38.0],
[4.707340, 50.874790, 38.0],
[4.707325, 50.874798, 38.0],
[4.707260, 50.874830, 38.0],
[4.707439, 50.874966, 38.0],
[4.707818, 50.875257, 38.0],
[4.707898, 50.875319, 37.75],
[4.708336, 50.875628, 38.0],
[4.708350, 50.875638, 38.0],
[4.708412, 50.875685, 38.0],
[4.708322, 50.875717, 37.75],
[4.708046, 50.875813, 37.75],
[4.707083, 50.876156, 38.0],
[4.706788, 50.876294, 38.25],
[4.706735, 50.876321, 38.0],
[4.706291, 50.876602, 39.0],
[4.706168, 50.876681, 38.75],
[4.706094, 50.876714, 38.75],
[4.706058, 50.876727, 38.75],
[4.705972, 50.876755, 38.5],
[4.705931, 50.876768, 38.5],
[4.705614, 50.876870, 38.0],
[4.705423, 50.876932, 38.0],
[4.705400, 50.876939, 38.0],
[4.704857, 50.877116, 36.5],
[4.704725, 50.877159, 36.0],
[4.704683, 50.877183, 36.0],
[4.704666, 50.877191, 36.0],
[4.704644, 50.877203, 36.0],
[4.704412, 50.877323, 35.0],
[4.704041, 50.877569, 33.75],
[4.703626, 50.877883, 32.5],
[4.703643, 50.877935, 32.25],
[4.703401, 50.878420, 30.75],
[4.703191, 50.878656, 29.75],
[4.703075, 50.878735, 30.0],
[4.702846, 50.878849, 29.25],
[4.702758, 50.878898, 28.75],
[4.702447, 50.879023, 27.25],
[4.702440, 50.879099, 26.75],
[4.702453, 50.879159, 26.25],
[4.702343, 50.879173, 27.0],
[4.702217, 50.879215, 28.0],
[4.702176, 50.879240, 28.0],
[4.702137, 50.879274, 28.0],
[4.701863, 50.879520, 27.25],
[4.701712, 50.879630, 26.25],
[4.701582, 50.879708, 25.75],
[4.701521, 50.879736, 25.25],
[4.701074, 50.879938, 24.0],
[4.700933, 50.880001, 23.25],
[4.700731, 50.879935, 23.25],
[4.700609, 50.879880, 23.5],
[4.700567, 50.879908, 23.25],
[4.700470, 50.879953, 23.0],
[4.700436, 50.879922, 23.25],
[4.700399, 50.879888, 23.25],
[4.700077, 50.880030, 22.75],
[4.700045, 50.880001, 22.75],
[4.700029, 50.879984, 23.0],
[4.699997, 50.879997, 22.75]
]
}
}
]
}
+216
View File
@@ -0,0 +1,216 @@
// Package session keeps one plan and its follow-up conversation per browser,
// in memory.
package session
import (
"crypto/rand"
"encoding/hex"
"sync"
"time"
"git.b0b.be/bdeb/workout-suggester/internal/coach"
"git.b0b.be/bdeb/workout-suggester/internal/llm"
)
// State tracks an assistant reply through its lifecycle.
type State int
const (
Pending State = iota // created, nobody is generating it yet
Streaming // a stream handler owns it
Done // finished (successfully or with Err set)
)
// Message is a chat turn.
type Message struct {
ID string
Role string // "user" or "assistant"
Content string
State State
Err string
Hidden bool // the app's own question, not shown in the UI
}
// Conversation is one plan plus the chat about it.
type Conversation struct {
mu sync.Mutex
plan coach.Plan
messages []*Message
lastUsed time.Time
}
// Store maps session ids to conversations.
type Store struct {
mu sync.Mutex
convs map[string]*Conversation
}
// NewStore returns an empty store.
func NewStore() *Store {
return &Store{convs: make(map[string]*Conversation)}
}
// NewID returns a random hex id, suitable for sessions and messages.
func NewID() string {
b := make([]byte, 16)
rand.Read(b)
return hex.EncodeToString(b)
}
// Get returns the conversation for a session, or nil if there is none.
func (s *Store) Get(session string) *Conversation {
s.mu.Lock()
defer s.mu.Unlock()
c := s.convs[session]
if c != nil {
c.mu.Lock()
c.lastUsed = time.Now()
c.mu.Unlock()
}
return c
}
// Start replaces the session's conversation with a fresh plan and returns
// the pending first reply.
func (s *Store) Start(session string, p coach.Plan) (*Conversation, Message) {
c := &Conversation{plan: p, lastUsed: time.Now()}
q := &Message{ID: NewID(), Role: "user", Content: coach.FirstQuestion, State: Done, Hidden: true}
r := &Message{ID: NewID(), Role: "assistant", State: Pending}
c.messages = []*Message{q, r}
s.mu.Lock()
s.convs[session] = c
s.mu.Unlock()
return c, *r
}
// Prune drops conversations idle for longer than maxIdle.
func (s *Store) Prune(maxIdle time.Duration) {
cutoff := time.Now().Add(-maxIdle)
s.mu.Lock()
defer s.mu.Unlock()
for id, c := range s.convs {
c.mu.Lock()
idle := c.lastUsed.Before(cutoff)
c.mu.Unlock()
if idle {
delete(s.convs, id)
}
}
}
// Plan returns the conversation's plan.
func (c *Conversation) Plan() coach.Plan {
c.mu.Lock()
defer c.mu.Unlock()
return c.plan
}
// UpdatePlan changes the plan, e.g. once routes have been looked up.
func (c *Conversation) UpdatePlan(f func(*coach.Plan)) {
c.mu.Lock()
defer c.mu.Unlock()
f(&c.plan)
}
// Messages returns a snapshot of the visible conversation.
func (c *Conversation) Messages() []Message {
c.mu.Lock()
defer c.mu.Unlock()
var out []Message
for _, m := range c.messages {
if !m.Hidden {
out = append(out, *m)
}
}
return out
}
// Ask appends a user message plus a pending assistant reply and returns both.
func (c *Conversation) Ask(text string) (user, reply Message) {
c.mu.Lock()
defer c.mu.Unlock()
u := &Message{ID: NewID(), Role: "user", Content: text, State: Done}
r := &Message{ID: NewID(), Role: "assistant", State: Pending}
c.messages = append(c.messages, u, r)
return *u, *r
}
// maxHistory bounds how many earlier turns go back to the model; small
// models lose the thread (and speed) with long contexts.
const maxHistory = 12
// Claim marks a pending reply as streaming and returns the prompt to send:
// the plan's system prompt plus the finished messages before the reply. ok
// is false if the reply does not exist or someone else already claimed it;
// msg then holds its current state (if it exists).
func (c *Conversation) Claim(id string) (prompt []llm.Message, msg Message, ok bool) {
c.mu.Lock()
defer c.mu.Unlock()
idx := c.index(id)
if idx < 0 {
return nil, Message{}, false
}
m := c.messages[idx]
if m.State != Pending {
return nil, *m, false
}
m.State = Streaming
var history []llm.Message
for _, h := range c.messages[:idx] {
if h.State == Done && h.Err == "" && h.Content != "" {
history = append(history, llm.Message{Role: h.Role, Content: h.Content})
}
}
if len(history) > maxHistory { // keep the opening question and plan
history = append(history[:2:2], history[len(history)-maxHistory+2:]...)
}
// Small models tend to repeat the whole plan template; nudge follow-ups.
if n := len(history); n > 2 && history[n-1].Role == "user" {
history[n-1].Content += "\n\n(" + coach.FollowUpHint + ")"
}
prompt = []llm.Message{{Role: "system", Content: c.plan.SystemPrompt()}}
return append(prompt, history...), *m, true
}
// IsFirstReply reports whether id is the plan's opening reply.
func (c *Conversation) IsFirstReply(id string) bool {
c.mu.Lock()
defer c.mu.Unlock()
return len(c.messages) > 1 && c.messages[1].ID == id
}
// Append adds streamed text to a reply.
func (c *Conversation) Append(id, delta string) {
c.mu.Lock()
defer c.mu.Unlock()
if i := c.index(id); i >= 0 {
c.messages[i].Content += delta
}
}
// Finish marks a reply as done, recording err if generation failed, and
// returns its final state.
func (c *Conversation) Finish(id string, err error) Message {
c.mu.Lock()
defer c.mu.Unlock()
i := c.index(id)
if i < 0 {
return Message{ID: id, Role: "assistant", State: Done}
}
m := c.messages[i]
m.State = Done
if err != nil {
m.Err = err.Error()
}
return *m
}
func (c *Conversation) index(id string) int {
for i, m := range c.messages {
if m.ID == id {
return i
}
}
return -1
}
+153
View File
@@ -0,0 +1,153 @@
package weather
import (
"context"
"errors"
"fmt"
"math"
"net/url"
"strings"
"time"
)
// metNo fetches the MET Norway (yr.no) forecast, a free fallback with
// global coverage. Outside the Nordics it has no gusts or rain chance, so
// gusts are estimated from wind speed and the rain chance is left unknown.
func (c *Client) metNo(ctx context.Context, lat, lon float64) (Forecast, error) {
q := url.Values{"lat": {fmt.Sprintf("%.2f", lat)}, "lon": {fmt.Sprintf("%.2f", lon)}}
var raw struct {
Properties struct {
Timeseries []struct {
Time time.Time `json:"time"`
Data struct {
Instant struct {
Details map[string]float64 `json:"details"`
} `json:"instant"`
Next1 *struct {
Summary struct {
Symbol string `json:"symbol_code"`
} `json:"summary"`
Details map[string]float64 `json:"details"`
} `json:"next_1_hours"`
} `json:"data"`
} `json:"timeseries"`
} `json:"properties"`
}
u := or(c.MetNoURL, "https://api.met.no/weatherapi/locationforecast/2.0/complete") + "?" + q.Encode()
if err := c.getJSON(ctx, u, &raw); err != nil {
return Forecast{}, err
}
now := time.Now()
if c.now != nil {
now = c.now()
}
f := Forecast{Source: "MET Norway"}
f.Sunrise, f.Sunset = sunTimes(now, lat, lon)
if f.Sunset.Before(now) {
f.Sunrise, f.Sunset = sunTimes(now.Add(24*time.Hour), lat, lon)
}
for _, t := range raw.Properties.Timeseries {
if t.Time.Before(now.Truncate(time.Hour)) || t.Data.Next1 == nil {
continue
}
if len(f.Hours) == 24 {
break
}
d, n := t.Data.Instant.Details, t.Data.Next1.Details
h := Hour{
Time: t.Time.Local(),
Temp: d["air_temperature"],
Feels: d["air_temperature"],
Precip: n["precipitation_amount"],
PrecipProb: -1,
Code: symbolCode(t.Data.Next1.Summary.Symbol),
Wind: d["wind_speed"] * 3.6,
WindDirection: int(math.Round(d["wind_from_direction"])),
IsDay: isDay(t.Time, lat, lon),
}
if v, ok := d["apparent_air_temperature"]; ok {
h.Feels = v
}
if v, ok := n["probability_of_precipitation"]; ok {
h.PrecipProb = int(math.Round(v))
}
if v, ok := d["wind_speed_of_gust"]; ok {
h.Gusts = v * 3.6
} else {
h.Gusts = h.Wind * 1.5 // typical gust factor over open land
f.GustsEstimated = true
}
f.Hours = append(f.Hours, h)
}
if len(f.Hours) == 0 {
return Forecast{}, errors.New("met norway: no hourly forecast")
}
f.Now = f.Hours[0]
f.Now.Time = now.Truncate(time.Minute)
return f, nil
}
// symbolCode maps a MET Norway symbol ("lightrainshowers_day") to the
// closest WMO weather code.
func symbolCode(sym string) int {
base, _, _ := strings.Cut(sym, "_")
switch {
case strings.Contains(base, "thunder"):
return 95
case strings.Contains(base, "sleet"):
if strings.HasPrefix(base, "light") {
return 66
}
return 67
case strings.HasSuffix(base, "snowshowers"):
if strings.HasPrefix(base, "heavy") {
return 86
}
return 85
case strings.HasSuffix(base, "rainshowers"):
switch {
case strings.HasPrefix(base, "light"):
return 80
case strings.HasPrefix(base, "heavy"):
return 82
}
return 81
}
if code, ok := map[string]int{
"clearsky": 0, "fair": 1, "partlycloudy": 2, "cloudy": 3, "fog": 45,
"lightrain": 61, "rain": 63, "heavyrain": 65,
"lightsnow": 71, "snow": 73, "heavysnow": 75,
}[base]; ok {
return code
}
return 3 // unknown: say overcast rather than promise sunshine
}
func isDay(t time.Time, lat, lon float64) bool {
rise, set := sunTimes(t, lat, lon)
return !t.Before(rise) && t.Before(set)
}
// sunTimes computes sunrise and sunset for t's UTC date with the standard
// sunrise equation (good to a minute or two), in local time.
func sunTimes(t time.Time, lat, lon float64) (rise, set time.Time) {
const rad = math.Pi / 180
y, m, d := t.UTC().Date()
noon := float64(time.Date(y, m, d, 12, 0, 0, 0, time.UTC).Unix())/86400 + 2440587.5 // Julian date
n := math.Round(noon - 2451545.0) // days since J2000
jStar := n - lon/360
M := math.Mod(357.5291+0.98560028*jStar, 360)
C := 1.9148*math.Sin(M*rad) + 0.02*math.Sin(2*M*rad) + 0.0003*math.Sin(3*M*rad)
lambda := math.Mod(M+C+180+102.9372, 360)
transit := 2451545.0 + jStar + 0.0053*math.Sin(M*rad) - 0.0069*math.Sin(2*lambda*rad)
sinDec := math.Sin(lambda*rad) * math.Sin(23.4397*rad)
cosDec := math.Cos(math.Asin(sinDec))
cosW := (math.Sin(-0.833*rad) - math.Sin(lat*rad)*sinDec) / (math.Cos(lat*rad) * cosDec)
cosW = math.Max(-1, math.Min(1, cosW)) // polar day/night: clamp
w := math.Acos(cosW) / rad
toTime := func(j float64) time.Time {
return time.Unix(int64(math.Round((j-2440587.5)*86400)), 0).Local()
}
return toTime(transit - w/360), toTime(transit + w/360)
}
+1
View File
@@ -0,0 +1 @@
{"latitude":51.052,"longitude":3.712,"generationtime_ms":0.3993511199951172,"utc_offset_seconds":7200,"timezone":"Europe/Brussels","timezone_abbreviation":"GMT+2","elevation":9.0,"current_units":{"time":"iso8601","interval":"seconds","temperature_2m":"°C","apparent_temperature":"°C","precipitation":"mm","weather_code":"wmo code","wind_speed_10m":"km/h","wind_gusts_10m":"km/h","wind_direction_10m":"°","is_day":""},"current":{"time":"2026-10-11T16:30","interval":900,"temperature_2m":14.7,"apparent_temperature":11.7,"precipitation":0.00,"weather_code":0,"wind_speed_10m":12.2,"wind_gusts_10m":27.7,"wind_direction_10m":276,"is_day":1},"hourly_units":{"time":"iso8601","temperature_2m":"°C","apparent_temperature":"°C","precipitation":"mm","weather_code":"wmo code","wind_speed_10m":"km/h","wind_gusts_10m":"km/h","wind_direction_10m":"°","is_day":"","precipitation_probability":"%"},"hourly":{"time":["2026-10-11T16:00","2026-10-11T17:00","2026-10-11T18:00","2026-10-11T19:00","2026-10-11T20:00","2026-10-11T21:00","2026-10-11T22:00","2026-10-11T23:00","2026-10-12T00:00","2026-10-12T01:00","2026-10-12T02:00","2026-10-12T03:00","2026-10-12T04:00","2026-10-12T05:00","2026-10-12T06:00","2026-10-12T07:00","2026-10-12T08:00","2026-10-12T09:00","2026-10-12T10:00","2026-10-12T11:00","2026-10-12T12:00","2026-10-12T13:00","2026-10-12T14:00","2026-10-12T15:00"],"temperature_2m":[14.7,14.6,14.1,13.0,11.4,10.6,10.7,10.7,10.2,10.2,9.4,8.8,9.2,10.0,10.2,10.2,10.6,11.0,12.0,13.1,15.0,16.5,17.3,17.9],"apparent_temperature":[11.7,11.6,11.4,11.0,9.4,8.5,8.9,8.2,7.5,7.7,7.6,7.2,7.7,8.8,9.0,8.7,9.2,9.6,10.7,11.6,13.0,14.0,14.7,15.2],"precipitation":[0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.10,0.10,0.00,0.00,0.00,0.00,0.00,0.00],"weather_code":[0,0,0,0,0,0,3,0,0,0,0,0,3,3,3,3,51,51,2,3,0,2,2,1],"wind_speed_10m":[12.6,11.9,10.4,6.8,6.5,6.5,4.7,8.3,9.0,9.7,8.6,7.2,7.6,6.8,6.1,8.3,8.6,8.6,8.6,9.4,12.2,14.0,14.0,14.4],"wind_gusts_10m":[28.8,26.3,24.1,19.8,9.4,7.6,7.6,11.5,11.9,16.6,14.8,12.6,12.6,13.0,11.5,14.4,15.8,15.8,16.2,18.7,25.2,28.8,29.5,30.6],"wind_direction_10m":[274,277,276,249,206,177,166,179,186,182,189,169,184,175,139,142,141,141,152,171,184,184,190,187],"is_day":[1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,1,1],"precipitation_probability":[12,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]},"daily_units":{"time":"iso8601","sunrise":"iso8601","sunset":"iso8601"},"daily":{"time":["2026-10-11","2026-10-12"],"sunrise":["2026-10-11T08:01","2026-10-12T08:03"],"sunset":["2026-10-11T19:01","2026-10-12T18:59"]}}
+1
View File
@@ -0,0 +1 @@
{"results":[{"id":2797656,"name":"Ghent","latitude":51.05,"longitude":3.71667,"elevation":10.0,"feature_code":"PPL","country_code":"BE","admin1_id":3337388,"admin2_id":2789733,"admin3_id":2797655,"admin4_id":2797657,"timezone":"Europe/Brussels","population":265086,"postcodes":["9000"],"country_id":2802361,"country":"Belgium","admin1":"Flanders","admin2":"East Flanders","admin3":"Arrondissement of Ghent","admin4":"Gent"}],"generationtime_ms":0.7789135}
+1
View File
@@ -0,0 +1 @@
{"type":"Feature","geometry":{"type":"Point","coordinates":[3.72,51.05,13]},"properties":{"meta":{"updated_at":"2026-10-11T13:19:22Z","units":{"air_pressure_at_sea_level":"hPa","air_temperature":"celsius","air_temperature_max":"celsius","air_temperature_min":"celsius","apparent_air_temperature":"celsius","cloud_area_fraction":"%","cloud_area_fraction_high":"%","cloud_area_fraction_low":"%","cloud_area_fraction_medium":"%","dew_point_temperature":"celsius","fog_area_fraction":"%","precipitation_amount":"mm","relative_humidity":"%","ultraviolet_index_clear_sky":"1","wind_from_direction":"degrees","wind_speed":"m/s"}},"timeseries":[{"time":"2026-10-11T16:00:00Z","data":{"instant":{"details":{"air_pressure_at_sea_level":1014.1,"air_temperature":13.1,"apparent_air_temperature":12.5,"cloud_area_fraction":19.5,"cloud_area_fraction_high":0.0,"cloud_area_fraction_low":19.5,"cloud_area_fraction_medium":0.0,"dew_point_temperature":6.2,"fog_area_fraction":0.0,"relative_humidity":62.8,"ultraviolet_index_clear_sky":0.1,"wind_from_direction":283.8,"wind_speed":2.3}},"next_12_hours":{"summary":{"symbol_code":"fair_night"},"details":{}},"next_1_hours":{"summary":{"symbol_code":"fair_day"},"details":{"precipitation_amount":0.0}},"next_6_hours":{"summary":{"symbol_code":"clearsky_night"},"details":{"air_temperature_max":11.1,"air_temperature_min":8.0,"precipitation_amount":0.0}}}},{"time":"2026-10-11T17:00:00Z","data":{"instant":{"details":{"air_pressure_at_sea_level":1014.6,"air_temperature":11.1,"apparent_air_temperature":10.8,"cloud_area_fraction":6.2,"cloud_area_fraction_high":0.0,"cloud_area_fraction_low":6.2,"cloud_area_fraction_medium":0.0,"dew_point_temperature":6.6,"fog_area_fraction":0.0,"relative_humidity":74.1,"ultraviolet_index_clear_sky":0.0,"wind_from_direction":263.2,"wind_speed":1.6}},"next_12_hours":{"summary":{"symbol_code":"fair_night"},"details":{}},"next_1_hours":{"summary":{"symbol_code":"clearsky_night"},"details":{"precipitation_amount":0.0}},"next_6_hours":{"summary":{"symbol_code":"clearsky_night"},"details":{"air_temperature_max":10.3,"air_temperature_min":7.8,"precipitation_amount":0.0}}}},{"time":"2026-10-11T18:00:00Z","data":{"instant":{"details":{"air_pressure_at_sea_level":1015.2,"air_temperature":10.3,"apparent_air_temperature":9.7,"cloud_area_fraction":2.3,"cloud_area_fraction_high":0.0,"cloud_area_fraction_low":2.3,"cloud_area_fraction_medium":0.0,"dew_point_temperature":6.5,"fog_area_fraction":0.0,"relative_humidity":78.3,"ultraviolet_index_clear_sky":0.0,"wind_from_direction":247.4,"wind_speed":1.7}},"next_12_hours":{"summary":{"symbol_code":"fair_night"},"details":{}},"next_1_hours":{"summary":{"symbol_code":"clearsky_night"},"details":{"precipitation_amount":0.0}},"next_6_hours":{"summary":{"symbol_code":"clearsky_night"},"details":{"air_temperature_max":9.5,"air_temperature_min":7.6,"precipitation_amount":0.0}}}},{"time":"2026-10-11T19:00:00Z","data":{"instant":{"details":{"air_pressure_at_sea_level":1015.6,"air_temperature":9.5,"apparent_air_temperature":8.8,"cloud_area_fraction":0.0,"cloud_area_fraction_high":0.0,"cloud_area_fraction_low":0.0,"cloud_area_fraction_medium":0.0,"dew_point_temperature":6.2,"fog_area_fraction":0.0,"relative_humidity":80.3,"ultraviolet_index_clear_sky":0.0,"wind_from_direction":231.5,"wind_speed":1.8}},"next_12_hours":{"summary":{"symbol_code":"fair_night"},"details":{}},"next_1_hours":{"summary":{"symbol_code":"clearsky_night"},"details":{"precipitation_amount":0.0}},"next_6_hours":{"summary":{"symbol_code":"clearsky_night"},"details":{"air_temperature_max":9.0,"air_temperature_min":7.4,"precipitation_amount":0.0}}}},{"time":"2026-10-11T20:00:00Z","data":{"instant":{"details":{"air_pressure_at_sea_level":1015.9,"air_temperature":9.0,"apparent_air_temperature":8.1,"cloud_area_fraction":4.7,"cloud_area_fraction_high":0.0,"cloud_area_fraction_low":4.7,"cloud_area_fraction_medium":0.0,"dew_point_temperature":5.9,"fog_area_fraction":0.0,"relative_humidity":81.3,"ultraviolet_index_clear_sky":0.0,"wind_from_direction":218.5,"wind_speed":1.9}},"next_12_hours":{"summary":{"symbol_code":"fair_night"},"details":{}},"next_1_hours":{"summary":{"symbol_code":"clearsky_night"},"details":{"precipitation_amount":0.0}},"next_6_hours":{"summary":{"symbol_code":"clearsky_night"},"details":{"air_temperature_max":8.3,"air_temperature_min":7.1,"precipitation_amount":0.0}}}},{"time":"2026-10-11T21:00:00Z","data":{"instant":{"details":{"air_pressure_at_sea_level":1016.4,"air_temperature":8.3,"apparent_air_temperature":7.4,"cloud_area_fraction":0.8,"cloud_area_fraction_high":0.0,"cloud_area_fraction_low":0.8,"cloud_area_fraction_medium":0.0,"dew_point_temperature":5.6,"fog_area_fraction":0.0,"relative_humidity":83.0,"ultraviolet_index_clear_sky":0.0,"wind_from_direction":204.9,"wind_speed":1.8}},"next_12_hours":{"summary":{"symbol_code":"partlycloudy_night"},"details":{}},"next_1_hours":{"summary":{"symbol_code":"clearsky_night"},"details":{"precipitation_amount":0.0}},"next_6_hoLine truncated
+1
View File
@@ -0,0 +1 @@
[{"place_id":101749274,"licence":"Data © OpenStreetMap contributors, ODbL 1.0. http://osm.org/copyright","osm_type":"relation","osm_id":897671,"lat":"51.0538286","lon":"3.7250121","category":"boundary","type":"administrative","place_rank":14,"importance":0.6930824855023677,"addresstype":"city","name":"Ghent","display_name":"Ghent, Gent, East Flanders, Flanders, Belgium","boundingbox":["50.9795422","51.1888910","3.5797616","3.8493413"]}]
+370
View File
@@ -0,0 +1,370 @@
// Package weather fetches forecasts and place names from Open-Meteo
// (open source, no API key), with MET Norway and Nominatim as fallbacks.
// Coordinates are rounded to ~1 km before they leave the machine.
package weather
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"math"
"net/http"
"net/url"
"strconv"
"strings"
"sync"
"time"
)
// Hour is the weather for one hour of the forecast.
type Hour struct {
Time time.Time
Temp float64 // °C
Feels float64 // apparent temperature, °C
PrecipProb int // %, or -1 when the source doesn't say
Precip float64 // mm
Code int // WMO weather code
Wind float64 // km/h
Gusts float64 // km/h
WindDirection int // degrees, where the wind comes from
IsDay bool
}
// Forecast is the current weather plus the next 24 hours.
type Forecast struct {
Now Hour
Hours []Hour // starting at the current hour
Sunrise time.Time
Sunset time.Time
Source string // who made the forecast, shown in the UI
GustsEstimated bool // the source had no gusts; they're derived from wind speed
}
// Place is a geocoded location.
type Place struct {
Name string
Lat float64
Lon float64
}
// Client fetches forecasts from Open-Meteo, falling back to MET Norway when
// Open-Meteo is down, and caches them for 15 minutes.
type Client struct {
ForecastURL string // default https://api.open-meteo.com/v1/forecast
GeocodeURL string // default https://geocoding-api.open-meteo.com/v1/search
MetNoURL string // default https://api.met.no/weatherapi/locationforecast/2.0/complete; "-" disables
NominatimURL string // default https://nominatim.openstreetmap.org/search; "-" disables
HTTP *http.Client
now func() time.Time // for tests; nil means time.Now
mu sync.Mutex
cache map[string]cached
}
type cached struct {
f Forecast
at time.Time
}
const (
cacheFor = 15 * time.Minute
staleFor = 3 * time.Hour // when every source is down, an older forecast beats none
userAgent = "workout-suggester/0.1 (+https://git.b0b.be/bdeb/workout-suggester)"
)
func (c *Client) http() *http.Client {
if c.HTTP != nil {
return c.HTTP
}
return http.DefaultClient
}
func or(v, fallback string) string {
if v == "" {
return fallback
}
return v
}
// Round keeps two decimals (~1 km), which is plenty for weather.
func Round(v float64) float64 { return math.Round(v*100) / 100 }
// getJSON fetches u, retrying once when the server is busy (429/5xx).
func (c *Client) getJSON(ctx context.Context, u string, out any) error {
var err error
for attempt := range 2 {
if attempt > 0 {
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(time.Second):
}
}
var retry bool
if retry, err = c.getOnce(ctx, u, out); !retry {
return err
}
}
return err
}
func (c *Client) getOnce(ctx context.Context, u string, out any) (retry bool, err error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
if err != nil {
return false, err
}
req.Header.Set("User-Agent", userAgent) // MET Norway and Nominatim require one
req.Header.Set("Accept", "application/json")
resp, err := c.http().Do(req)
if err != nil {
return ctx.Err() == nil, err
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
msg, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
busy := resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode >= 500
return busy, fmt.Errorf("%s returned %s: %s", req.URL.Host, resp.Status, bytes.TrimSpace(msg))
}
return false, json.NewDecoder(resp.Body).Decode(out)
}
// Geocode finds a town or city by name, via Open-Meteo or else Nominatim.
func (c *Client) Geocode(ctx context.Context, name string) (Place, error) {
q := url.Values{"name": {name}, "count": {"1"}, "language": {"en"}, "format": {"json"}}
var out struct {
Results []struct {
Name string `json:"name"`
Country string `json:"country"`
Lat float64 `json:"latitude"`
Lon float64 `json:"longitude"`
} `json:"results"`
}
err := c.getJSON(ctx, or(c.GeocodeURL, "https://geocoding-api.open-meteo.com/v1/search")+"?"+q.Encode(), &out)
if err == nil {
if len(out.Results) == 0 {
return Place{}, fmt.Errorf("couldn't find a place called %q", name)
}
r := out.Results[0]
n := r.Name
if r.Country != "" {
n += ", " + r.Country
}
return Place{Name: n, Lat: r.Lat, Lon: r.Lon}, nil
}
if c.NominatimURL == "-" {
return Place{}, err
}
p, err2 := c.nominatim(ctx, name)
if err2 != nil {
return Place{}, errors.Join(err, err2)
}
return p, nil
}
func (c *Client) nominatim(ctx context.Context, name string) (Place, error) {
q := url.Values{"q": {name}, "format": {"jsonv2"}, "limit": {"1"}, "accept-language": {"en"}}
var out []struct {
Name string `json:"name"`
Display string `json:"display_name"`
Lat string `json:"lat"`
Lon string `json:"lon"`
}
if err := c.getJSON(ctx, or(c.NominatimURL, "https://nominatim.openstreetmap.org/search")+"?"+q.Encode(), &out); err != nil {
return Place{}, err
}
if len(out) == 0 {
return Place{}, fmt.Errorf("couldn't find a place called %q", name)
}
lat, err1 := strconv.ParseFloat(out[0].Lat, 64)
lon, err2 := strconv.ParseFloat(out[0].Lon, 64)
if err1 != nil || err2 != nil {
return Place{}, errors.New("nominatim: bad coordinates")
}
n := out[0].Name
if parts := strings.Split(out[0].Display, ", "); len(parts) > 1 {
n += ", " + parts[len(parts)-1] // country
}
return Place{Name: n, Lat: lat, Lon: lon}, nil
}
// Forecast returns current weather and the next 24 hours.
func (c *Client) Forecast(ctx context.Context, lat, lon float64) (Forecast, error) {
lat, lon = Round(lat), Round(lon)
key := fmt.Sprintf("%.2f,%.2f", lat, lon)
c.mu.Lock()
hit, ok := c.cache[key]
c.mu.Unlock()
if ok && time.Since(hit.at) < cacheFor {
return hit.f, nil
}
f, err := c.openMeteo(ctx, lat, lon)
if err != nil && c.MetNoURL != "-" {
var err2 error
if f, err2 = c.metNo(ctx, lat, lon); err2 != nil {
err = errors.Join(err, err2)
} else {
err = nil
}
}
if err != nil {
if ok && time.Since(hit.at) < staleFor {
hit.f.Source += fmt.Sprintf(", from %s", hit.at.Format("15:04"))
return hit.f, nil
}
return Forecast{}, err
}
c.mu.Lock()
if c.cache == nil {
c.cache = make(map[string]cached)
}
c.cache[key] = cached{f, time.Now()}
c.mu.Unlock()
return f, nil
}
func (c *Client) openMeteo(ctx context.Context, lat, lon float64) (Forecast, error) {
vars := "temperature_2m,apparent_temperature,precipitation,weather_code,wind_speed_10m,wind_gusts_10m,wind_direction_10m,is_day"
q := url.Values{
"latitude": {strconv.FormatFloat(lat, 'f', 2, 64)},
"longitude": {strconv.FormatFloat(lon, 'f', 2, 64)},
"current": {vars},
"hourly": {vars + ",precipitation_probability"},
"daily": {"sunrise,sunset"},
"forecast_hours": {"24"},
"forecast_days": {"2"},
"timezone": {"auto"},
}
var raw apiResponse
if err := c.getJSON(ctx, or(c.ForecastURL, "https://api.open-meteo.com/v1/forecast")+"?"+q.Encode(), &raw); err != nil {
return Forecast{}, err
}
return raw.forecast()
}
type apiResponse struct {
UTCOffset int `json:"utc_offset_seconds"`
Current struct {
Time string `json:"time"`
Temp float64 `json:"temperature_2m"`
Feels float64 `json:"apparent_temperature"`
Precip float64 `json:"precipitation"`
Code int `json:"weather_code"`
Wind float64 `json:"wind_speed_10m"`
Gusts float64 `json:"wind_gusts_10m"`
Dir int `json:"wind_direction_10m"`
IsDay int `json:"is_day"`
} `json:"current"`
Hourly struct {
Time []string `json:"time"`
Temp []float64 `json:"temperature_2m"`
Feels []float64 `json:"apparent_temperature"`
Precip []float64 `json:"precipitation"`
PrecipProb []int `json:"precipitation_probability"`
Code []int `json:"weather_code"`
Wind []float64 `json:"wind_speed_10m"`
Gusts []float64 `json:"wind_gusts_10m"`
Dir []int `json:"wind_direction_10m"`
IsDay []int `json:"is_day"`
} `json:"hourly"`
Daily struct {
Sunrise []string `json:"sunrise"`
Sunset []string `json:"sunset"`
} `json:"daily"`
}
func (r apiResponse) forecast() (Forecast, error) {
loc := time.FixedZone("local", r.UTCOffset)
parse := func(s string) time.Time {
t, _ := time.ParseInLocation("2006-01-02T15:04", s, loc)
return t
}
h := r.Hourly
n := len(h.Time)
if n == 0 || len(h.Temp) != n || len(h.Feels) != n || len(h.Precip) != n || len(h.PrecipProb) != n ||
len(h.Code) != n || len(h.Wind) != n || len(h.Gusts) != n || len(h.Dir) != n || len(h.IsDay) != n {
return Forecast{}, errors.New("open-meteo: incomplete hourly forecast")
}
f := Forecast{Source: "Open-Meteo", Now: Hour{
Time: parse(r.Current.Time), Temp: r.Current.Temp, Feels: r.Current.Feels,
Precip: r.Current.Precip, Code: r.Current.Code, Wind: r.Current.Wind,
Gusts: r.Current.Gusts, WindDirection: r.Current.Dir, IsDay: r.Current.IsDay == 1,
}}
for i := range n {
f.Hours = append(f.Hours, Hour{
Time: parse(h.Time[i]), Temp: h.Temp[i], Feels: h.Feels[i], PrecipProb: h.PrecipProb[i],
Precip: h.Precip[i], Code: h.Code[i], Wind: h.Wind[i], Gusts: h.Gusts[i],
WindDirection: h.Dir[i], IsDay: h.IsDay[i] == 1,
})
}
// The current hour's rain chance is the best guess for "now".
f.Now.PrecipProb = f.Hours[0].PrecipProb
// Today's sunrise/sunset, or tomorrow's once today's sun has set.
for i := range r.Daily.Sunset {
f.Sunrise, f.Sunset = parse(r.Daily.Sunrise[i]), parse(r.Daily.Sunset[i])
if f.Sunset.After(f.Now.Time) {
break
}
}
return f, nil
}
// Describe turns a WMO weather code into words and an emoji.
func Describe(code int) (string, string) {
switch {
case code == 0:
return "clear sky", "☀️"
case code == 1:
return "mostly clear", "🌤️"
case code == 2:
return "partly cloudy", "⛅"
case code == 3:
return "overcast", "☁️"
case code == 45 || code == 48:
return "fog", "🌫️"
case code >= 51 && code <= 55:
return "drizzle", "🌦️"
case code == 56 || code == 57:
return "freezing drizzle", "🧊"
case code >= 61 && code <= 65:
return "rain", "🌧️"
case code == 66 || code == 67:
return "freezing rain", "🧊"
case code >= 71 && code <= 77:
return "snow", "🌨️"
case code >= 80 && code <= 82:
return "rain showers", "🌦️"
case code == 85 || code == 86:
return "snow showers", "🌨️"
case code >= 95:
return "thunderstorm", "⛈️"
}
return "unknown", "🌡️"
}
// Icon is Describe's emoji, with a moon for clear nights.
func (h Hour) Icon() string {
_, icon := Describe(h.Code)
if !h.IsDay && h.Code <= 1 {
return "🌙"
}
return icon
}
// Chance is the rain chance as text, "–" when unknown.
func (h Hour) Chance() string {
if h.PrecipProb < 0 {
return "–"
}
return fmt.Sprintf("%d%%", h.PrecipProb)
}
// Compass turns a wind direction in degrees into N, NE, E, ...
func Compass(deg int) string {
dirs := []string{"N", "NE", "E", "SE", "S", "SW", "W", "NW"}
return dirs[((deg%360+360)%360+22)/45%8]
}
+164
View File
@@ -0,0 +1,164 @@
package weather
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"time"
)
// testdata holds real Open-Meteo responses for Ghent.
func fakeOpenMeteo(t *testing.T, calls *atomic.Int32) *httptest.Server {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls.Add(1)
switch r.URL.Path {
case "/v1/forecast":
if lat := r.URL.Query().Get("latitude"); lat != "51.05" {
t.Errorf("latitude sent = %q, want it rounded to 51.05", lat)
}
http.ServeFile(w, r, "testdata/forecast.json")
case "/v1/search":
http.ServeFile(w, r, "testdata/geocode.json")
}
}))
t.Cleanup(srv.Close)
return srv
}
func TestForecast(t *testing.T) {
var calls atomic.Int32
srv := fakeOpenMeteo(t, &calls)
c := &Client{ForecastURL: srv.URL + "/v1/forecast"}
f, err := c.Forecast(context.Background(), 51.0512345, 3.7212345)
if err != nil {
t.Fatal(err)
}
if len(f.Hours) != 24 || f.Now.Time.IsZero() || f.Sunset.Before(f.Now.Time) {
t.Errorf("forecast = now %v, %d hours, sunset %v", f.Now.Time, len(f.Hours), f.Sunset)
}
if _, off := f.Now.Time.Zone(); off != 2*3600 {
t.Errorf("zone offset = %d, want Brussels summer time", off)
}
c.Forecast(context.Background(), 51.05, 3.72)
if calls.Load() != 1 {
t.Errorf("second call within 15 min should hit the cache, made %d requests", calls.Load())
}
}
func TestGeocode(t *testing.T) {
var calls atomic.Int32
srv := fakeOpenMeteo(t, &calls)
p, err := (&Client{GeocodeURL: srv.URL + "/v1/search"}).Geocode(context.Background(), "Gent")
if err != nil || p.Name != "Ghent, Belgium" || p.Lat < 51 || p.Lat > 51.1 {
t.Errorf("geocode = %+v, %v", p, err)
}
}
func TestCompassAndIcons(t *testing.T) {
for deg, want := range map[int]string{0: "N", 44: "NE", 276: "W", 350: "N", -90: "W"} {
if got := Compass(deg); got != want {
t.Errorf("Compass(%d) = %s, want %s", deg, got, want)
}
}
if (Hour{Code: 0, IsDay: false}).Icon() != "🌙" || (Hour{Code: 63, IsDay: false}).Icon() != "🌧️" {
t.Error("night icons wrong")
}
}
// busy answers 503 like an overloaded Open-Meteo, and serves the fallbacks.
func busy(t *testing.T, openMeteoCalls *atomic.Int32) *httptest.Server {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/v1/forecast", "/v1/search":
openMeteoCalls.Add(1)
http.Error(w, `{"reason":"The service is overloaded","error":true}`, http.StatusServiceUnavailable)
case "/metno":
if r.Header.Get("User-Agent") == "" {
http.Error(w, "403", http.StatusForbidden)
return
}
http.ServeFile(w, r, "testdata/metno.json")
case "/nominatim":
http.ServeFile(w, r, "testdata/nominatim.json")
}
}))
t.Cleanup(srv.Close)
return srv
}
func TestForecastFallsBackToMetNo(t *testing.T) {
var calls atomic.Int32
srv := busy(t, &calls)
c := &Client{ForecastURL: srv.URL + "/v1/forecast", MetNoURL: srv.URL + "/metno",
now: func() time.Time { return time.Date(2026, 10, 11, 17, 10, 0, 0, time.UTC) }}
f, err := c.Forecast(context.Background(), 51.05, 3.72)
if err != nil {
t.Fatal(err)
}
if calls.Load() != 2 {
t.Errorf("open-meteo tried %d times, want 2 (one retry)", calls.Load())
}
if f.Source != "MET Norway" || !f.GustsEstimated || len(f.Hours) != 24 {
t.Errorf("source %q, estimated gusts %v, %d hours", f.Source, f.GustsEstimated, len(f.Hours))
}
h := f.Hours[0]
if h.PrecipProb != -1 || h.Chance() != "–" || h.Gusts <= h.Wind || h.Temp == 0 {
t.Errorf("first hour = %+v", h)
}
if !f.Hours[0].Time.Equal(time.Date(2026, 10, 11, 17, 0, 0, 0, time.UTC)) {
t.Errorf("first hour at %v, want 17:00 UTC", f.Hours[0].Time)
}
if !h.IsDay || f.Hours[1].IsDay { // sunset in Ghent is 19:01 CEST
t.Errorf("19:00 CEST should be day, 20:00 night: %v %v", h.IsDay, f.Hours[1].IsDay)
}
}
func TestStaleForecastBeatsNothing(t *testing.T) {
var calls atomic.Int32
srv := busy(t, &calls)
c := &Client{ForecastURL: srv.URL + "/v1/forecast", MetNoURL: "-"}
if _, err := c.Forecast(context.Background(), 51.05, 3.72); err == nil {
t.Fatal("expected an error with every source down and no cache")
}
c.cache = map[string]cached{"51.05,3.72": {Forecast{Source: "Open-Meteo"}, time.Now().Add(-time.Hour)}}
f, err := c.Forecast(context.Background(), 51.05, 3.72)
if err != nil || !strings.HasPrefix(f.Source, "Open-Meteo, from ") {
t.Errorf("stale forecast = %q, %v", f.Source, err)
}
}
func TestGeocodeFallsBackToNominatim(t *testing.T) {
var calls atomic.Int32
srv := busy(t, &calls)
p, err := (&Client{GeocodeURL: srv.URL + "/v1/search", NominatimURL: srv.URL + "/nominatim"}).Geocode(context.Background(), "Gent")
if err != nil || !strings.HasSuffix(p.Name, ", Belgium") || p.Lat < 51 || p.Lat > 51.1 {
t.Errorf("geocode = %+v, %v", p, err)
}
}
func TestSunTimesMatchOpenMeteo(t *testing.T) {
// Open-Meteo says 08:01 / 19:01 CEST for Ghent on 11 Oct 2026.
cest := time.FixedZone("CEST", 2*3600)
rise, set := sunTimes(time.Date(2026, 10, 11, 12, 0, 0, 0, time.UTC), 51.05, 3.72)
for _, c := range []struct {
got, want time.Time
}{{rise, time.Date(2026, 10, 11, 8, 1, 0, 0, cest)}, {set, time.Date(2026, 10, 11, 19, 1, 0, 0, cest)}} {
if d := c.got.Sub(c.want); d < -3*time.Minute || d > 3*time.Minute {
t.Errorf("got %v, want %v", c.got.In(cest), c.want)
}
}
}
func TestSymbolCode(t *testing.T) {
for sym, want := range map[string]int{
"clearsky_night": 0, "partlycloudy_day": 2, "heavyrainshowers_day": 82, "lightrainshowers_night": 80,
"rain": 63, "lightsleet": 66, "heavyrainandthunder": 95, "snowshowers_day": 85, "somethingnew": 3,
} {
if got := symbolCode(sym); got != want {
t.Errorf("symbolCode(%q) = %d, want %d", sym, got, want)
}
}
}
+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
+68
View File
@@ -0,0 +1,68 @@
# /// script
# requires-python = ">=3.11"
# dependencies = ["playwright"]
# ///
"""Browser end-to-end test: taps "📍 Here" (with a faked GPS fix), waits for
the coach's streamed advice and the route map, asks a follow-up, and saves
screenshots.
uv run scripts/e2e.py [--url http://127.0.0.1:3000] [--dark] [--mobile]
"""
import argparse, sys
from playwright.sync_api import sync_playwright, expect
p = argparse.ArgumentParser()
p.add_argument("--url", default="http://127.0.0.1:3000")
p.add_argument("--channel", default="msedge", help="installed browser: chrome, msedge, ...")
p.add_argument("--out", default="docs")
p.add_argument("--dark", action="store_true")
p.add_argument("--mobile", action="store_true")
p.add_argument("--lat", type=float, default=51.0543)
p.add_argument("--lon", type=float, default=3.7174)
args = p.parse_args()
name = "screenshot" + ("-mobile" if args.mobile else "") + ("-dark" if args.dark else "")
viewport = {"width": 400, "height": 860} if args.mobile else {"width": 1000, "height": 900}
with sync_playwright() as pw:
browser = pw.chromium.launch(channel=args.channel, headless=True)
ctx = browser.new_context(
viewport=viewport,
color_scheme="dark" if args.dark else "light",
geolocation={"latitude": args.lat, "longitude": args.lon},
permissions=["geolocation"],
device_scale_factor=2,
)
page = ctx.new_page()
errors = []
page.on("pageerror", lambda e: errors.append(str(e)))
page.goto(args.url)
page.evaluate("localStorage.clear()")
page.reload()
expect(page.locator("#health .dot.ok")).to_be_visible(timeout=10_000)
page.get_by_text("🔥 Hard").click()
page.get_by_text("📍 Here").click() # locates and submits
expect(page.locator(".weather")).to_be_visible(timeout=20_000)
expect(page.locator(".score.picked")).to_be_visible()
expect(page.locator("#thread .markdown")).to_be_visible(timeout=180_000)
if page.locator(".score.indoor.picked").count() == 0:
expect(page.locator(".routes .map.leaflet-container")).to_be_visible(timeout=60_000)
page.wait_for_timeout(1500) # tiles
page.screenshot(path=f"{args.out}/{name}.png", full_page=True)
page.get_by_text("⏱️ Only 30 min").click()
expect(page.locator("#thread .markdown")).to_have_count(2, timeout=180_000)
replies = page.locator("#thread .markdown").all_inner_texts()
print("--- plan ---\n" + replies[0] + "\n--- follow-up ---\n" + replies[1])
page.reload() # the plan survives a reload
expect(page.locator("#thread .markdown")).to_have_count(2)
browser.close()
if errors:
print("page errors:", errors, file=sys.stderr)
sys.exit(1)
print(f"ok: {args.out}/{name}.png")