commit d7342d6096711aad0bc6be50eaac9944855c7758 Author: bdeb1337 Date: Sun Oct 11 19:29:58 2026 +0200 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. diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..da28eda --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b236934 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +bin/ +.env +*.local +dist/ +dev/ +.DS_Store diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..d2c287e --- /dev/null +++ b/Makefile @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..93fed18 --- /dev/null +++ b/README.md @@ -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. + +

+ Desktop: weather, activity scores, the coach's plan and a route map + The same on a phone +

+ +## 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/). diff --git a/cmd/workout-suggester/main.go b/cmd/workout-suggester/main.go new file mode 100644 index 0000000..2d39bfd --- /dev/null +++ b/cmd/workout-suggester/main.go @@ -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) +} diff --git a/docs/screenshot-mobile.png b/docs/screenshot-mobile.png new file mode 100644 index 0000000..b528514 Binary files /dev/null and b/docs/screenshot-mobile.png differ diff --git a/docs/screenshot.png b/docs/screenshot.png new file mode 100644 index 0000000..f62c189 Binary files /dev/null and b/docs/screenshot.png differ diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..d502898 --- /dev/null +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..c2450e4 --- /dev/null +++ b/go.sum @@ -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= diff --git a/internal/coach/coach.go b/internal/coach/coach.go new file mode 100644 index 0000000..bed9211 --- /dev/null +++ b/internal/coach/coach.go @@ -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 +} diff --git a/internal/coach/coach_test.go b/internal/coach/coach_test.go new file mode 100644 index 0000000..8d5d205 --- /dev/null +++ b/internal/coach/coach_test.go @@ -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) + } + } +} diff --git a/internal/coach/prompt.go b/internal/coach/prompt.go new file mode 100644 index 0000000..5819e40 --- /dev/null +++ b/internal/coach/prompt.go @@ -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: +** **: 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") +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..8466d7b --- /dev/null +++ b/internal/config/config.go @@ -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() +} diff --git a/internal/llm/client.go b/internal/llm/client.go new file mode 100644 index 0000000..3bcdbd1 --- /dev/null +++ b/internal/llm/client.go @@ -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)) +} diff --git a/internal/llm/client_test.go b/internal/llm/client_test.go new file mode 100644 index 0000000..b61fad4 --- /dev/null +++ b/internal/llm/client_test.go @@ -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) + } +} diff --git a/internal/routes/loops.go b/internal/routes/loops.go new file mode 100644 index 0000000..851f679 --- /dev/null +++ b/internal/routes/loops.go @@ -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 +} diff --git a/internal/routes/routes.go b/internal/routes/routes.go new file mode 100644 index 0000000..70bad08 --- /dev/null +++ b/internal/routes/routes.go @@ -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) +} diff --git a/internal/routes/routes_test.go b/internal/routes/routes_test.go new file mode 100644 index 0000000..f702553 --- /dev/null +++ b/internal/routes/routes_test.go @@ -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) + } +} diff --git a/internal/routes/suggest.go b/internal/routes/suggest.go new file mode 100644 index 0000000..83d6e94 --- /dev/null +++ b/internal/routes/suggest.go @@ -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) +} diff --git a/internal/routes/testdata/brouter.json b/internal/routes/testdata/brouter.json new file mode 100644 index 0000000..fcd9562 --- /dev/null +++ b/internal/routes/testdata/brouter.json @@ -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] + ] + } + } + ] +} diff --git a/internal/session/store.go b/internal/session/store.go new file mode 100644 index 0000000..7f5d0fe --- /dev/null +++ b/internal/session/store.go @@ -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 +} diff --git a/internal/weather/metno.go b/internal/weather/metno.go new file mode 100644 index 0000000..6ad2c5d --- /dev/null +++ b/internal/weather/metno.go @@ -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) +} diff --git a/internal/weather/testdata/forecast.json b/internal/weather/testdata/forecast.json new file mode 100644 index 0000000..3f8d6d1 --- /dev/null +++ b/internal/weather/testdata/forecast.json @@ -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"]}} \ No newline at end of file diff --git a/internal/weather/testdata/geocode.json b/internal/weather/testdata/geocode.json new file mode 100644 index 0000000..5d684e7 --- /dev/null +++ b/internal/weather/testdata/geocode.json @@ -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} \ No newline at end of file diff --git a/internal/weather/testdata/metno.json b/internal/weather/testdata/metno.json new file mode 100644 index 0000000..d1047a0 --- /dev/null +++ b/internal/weather/testdata/metno.json @@ -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_hours":{"summary":{"symbol_code":"fair_night"},"details":{"air_temperature_max":8.0,"air_temperature_min":7.0,"precipitation_amount":0.0}}}},{"time":"2026-10-11T22:00:00Z","data":{"instant":{"details":{"air_pressure_at_sea_level":1016.8,"air_temperature":8.0,"apparent_air_temperature":6.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":5.4,"fog_area_fraction":0.0,"relative_humidity":83.2,"ultraviolet_index_clear_sky":0.0,"wind_from_direction":197.7,"wind_speed":2.0}},"next_12_hours":{"summary":{"symbol_code":"partlycloudy_day"},"details":{}},"next_1_hours":{"summary":{"symbol_code":"clearsky_night"},"details":{"precipitation_amount":0.0}},"next_6_hours":{"summary":{"symbol_code":"fair_night"},"details":{"air_temperature_max":7.8,"air_temperature_min":7.0,"precipitation_amount":0.0}}}},{"time":"2026-10-11T23:00:00Z","data":{"instant":{"details":{"air_pressure_at_sea_level":1017.0,"air_temperature":7.8,"apparent_air_temperature":6.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.2,"fog_area_fraction":0.0,"relative_humidity":82.9,"ultraviolet_index_clear_sky":0.0,"wind_from_direction":198.2,"wind_speed":2.3}},"next_12_hours":{"summary":{"symbol_code":"partlycloudy_day"},"details":{}},"next_1_hours":{"summary":{"symbol_code":"clearsky_night"},"details":{"precipitation_amount":0.0}},"next_6_hours":{"summary":{"symbol_code":"partlycloudy_night"},"details":{"air_temperature_max":8.6,"air_temperature_min":7.0,"precipitation_amount":0.0}}}},{"time":"2026-10-12T00:00:00Z","data":{"instant":{"details":{"air_pressure_at_sea_level":1017.3,"air_temperature":7.6,"apparent_air_temperature":6.2,"cloud_area_fraction":3.1,"cloud_area_fraction_high":0.0,"cloud_area_fraction_low":3.1,"cloud_area_fraction_medium":0.0,"dew_point_temperature":5.0,"fog_area_fraction":0.0,"relative_humidity":82.7,"ultraviolet_index_clear_sky":0.0,"wind_from_direction":188.2,"wind_speed":2.2}},"next_12_hours":{"summary":{"symbol_code":"partlycloudy_day"},"details":{}},"next_1_hours":{"summary":{"symbol_code":"clearsky_night"},"details":{"precipitation_amount":0.0}},"next_6_hours":{"summary":{"symbol_code":"partlycloudy_night"},"details":{"air_temperature_max":9.0,"air_temperature_min":7.0,"precipitation_amount":0.0}}}},{"time":"2026-10-12T01:00:00Z","data":{"instant":{"details":{"air_pressure_at_sea_level":1017.3,"air_temperature":7.4,"apparent_air_temperature":5.8,"cloud_area_fraction":3.1,"cloud_area_fraction_high":0.0,"cloud_area_fraction_low":3.1,"cloud_area_fraction_medium":0.0,"dew_point_temperature":4.9,"fog_area_fraction":0.0,"relative_humidity":83.3,"ultraviolet_index_clear_sky":0.0,"wind_from_direction":168.2,"wind_speed":2.3}},"next_12_hours":{"summary":{"symbol_code":"partlycloudy_day"},"details":{}},"next_1_hours":{"summary":{"symbol_code":"clearsky_night"},"details":{"precipitation_amount":0.0}},"next_6_hours":{"summary":{"symbol_code":"partlycloudy_night"},"details":{"air_temperature_max":9.0,"air_temperature_min":7.0,"precipitation_amount":0.0}}}},{"time":"2026-10-12T02:00:00Z","data":{"instant":{"details":{"air_pressure_at_sea_level":1017.4,"air_temperature":7.1,"apparent_air_temperature":5.6,"cloud_area_fraction":12.5,"cloud_area_fraction_high":1.6,"cloud_area_fraction_low":10.2,"cloud_area_fraction_medium":0.0,"dew_point_temperature":4.8,"fog_area_fraction":0.0,"relative_humidity":84.2,"ultraviolet_index_clear_sky":0.0,"wind_from_direction":166.9,"wind_speed":2.3}},"next_12_hours":{"summary":{"symbol_code":"partlycloudy_day"},"details":{}},"next_1_hours":{"summary":{"symbol_code":"clearsky_night"},"details":{"precipitation_amount":0.0}},"next_6_hours":{"summary":{"symbol_code":"partlycloudy_night"},"details":{"air_temperature_max":10.2,"air_temperature_min":7.0,"precipitation_amount":0.0}}}},{"time":"2026-10-12T03:00:00Z","data":{"instant":{"details":{"air_pressure_at_sea_level":1017.5,"air_temperature":7.0,"apparent_air_temperature":5.3,"cloud_area_fraction":91.4,"cloud_area_fraction_high":31.2,"cloud_area_fraction_low":87.5,"cloud_area_fraction_medium":0.0,"dew_point_temperature":4.8,"fog_area_fraction":0.0,"relative_humidity":84.8,"ultraviolet_index_clear_sky":0.0,"wind_from_direction":161.4,"wind_speed":2.5}},"next_12_hours":{"summary":{"symbol_code":"partlycloudy_day"},"details":{}},"next_1_hours":{"summary":{"symbol_code":"cloudy"},"details":{"precipitation_amount":0.0}},"next_6_hours":{"summary":{"symbol_code":"partlycloudy_night"},"details":{"air_temperature_max":11.8,"air_temperature_min":7.7,"precipitation_amount":0.0}}}},{"time":"2026-10-12T04:00:00Z","data":{"instant":{"details":{"air_pressure_at_sea_level":1017.5,"air_temperature":7.7,"apparent_air_temperature":6.1,"cloud_area_fraction":99.2,"cloud_area_fraction_high":42.2,"cloud_area_fraction_low":98.4,"cloud_area_fraction_medium":0.0,"dew_point_temperature":5.5,"fog_area_fraction":0.0,"relative_humidity":85.3,"ultraviolet_index_clear_sky":0.0,"wind_from_direction":161.2,"wind_speed":2.5}},"next_12_hours":{"summary":{"symbol_code":"partlycloudy_day"},"details":{}},"next_1_hours":{"summary":{"symbol_code":"cloudy"},"details":{"precipitation_amount":0.0}},"next_6_hours":{"summary":{"symbol_code":"partlycloudy_day"},"details":{"air_temperature_max":13.5,"air_temperature_min":8.6,"precipitation_amount":0.0}}}},{"time":"2026-10-12T05:00:00Z","data":{"instant":{"details":{"air_pressure_at_sea_level":1017.7,"air_temperature":8.6,"apparent_air_temperature":7.0,"cloud_area_fraction":100.0,"cloud_area_fraction_high":48.4,"cloud_area_fraction_low":100.0,"cloud_area_fraction_medium":0.0,"dew_point_temperature":6.8,"fog_area_fraction":0.0,"relative_humidity":88.6,"ultraviolet_index_clear_sky":0.0,"wind_from_direction":161.0,"wind_speed":2.6}},"next_12_hours":{"summary":{"symbol_code":"partlycloudy_day"},"details":{}},"next_1_hours":{"summary":{"symbol_code":"cloudy"},"details":{"precipitation_amount":0.0}},"next_6_hours":{"summary":{"symbol_code":"partlycloudy_day"},"details":{"air_temperature_max":14.9,"air_temperature_min":9.0,"precipitation_amount":0.0}}}},{"time":"2026-10-12T06:00:00Z","data":{"instant":{"details":{"air_pressure_at_sea_level":1018.1,"air_temperature":9.0,"apparent_air_temperature":7.3,"cloud_area_fraction":57.0,"cloud_area_fraction_high":41.4,"cloud_area_fraction_low":32.8,"cloud_area_fraction_medium":0.8,"dew_point_temperature":7.9,"fog_area_fraction":0.0,"relative_humidity":93.2,"ultraviolet_index_clear_sky":0.0,"wind_from_direction":152.1,"wind_speed":2.9}},"next_12_hours":{"summary":{"symbol_code":"lightrainshowers_day"},"details":{}},"next_1_hours":{"summary":{"symbol_code":"partlycloudy_day"},"details":{"precipitation_amount":0.0}},"next_6_hours":{"summary":{"symbol_code":"partlycloudy_day"},"details":{"air_temperature_max":15.8,"air_temperature_min":9.0,"precipitation_amount":0.0}}}},{"time":"2026-10-12T07:00:00Z","data":{"instant":{"details":{"air_pressure_at_sea_level":1018.5,"air_temperature":9.0,"apparent_air_temperature":7.2,"cloud_area_fraction":6.2,"cloud_area_fraction_high":3.9,"cloud_area_fraction_low":1.6,"cloud_area_fraction_medium":0.8,"dew_point_temperature":8.3,"fog_area_fraction":0.0,"relative_humidity":95.5,"ultraviolet_index_clear_sky":0.2,"wind_from_direction":152.7,"wind_speed":3.2}},"next_12_hours":{"summary":{"symbol_code":"lightrainshowers_day"},"details":{}},"next_1_hours":{"summary":{"symbol_code":"clearsky_day"},"details":{"precipitation_amount":0.0}},"next_6_hours":{"summary":{"symbol_code":"partlycloudy_day"},"details":{"air_temperature_max":16.4,"air_temperature_min":10.2,"precipitation_amount":0.0}}}},{"time":"2026-10-12T08:00:00Z","data":{"instant":{"details":{"air_pressure_at_sea_level":1019.0,"air_temperature":10.2,"apparent_air_temperature":8.9,"cloud_area_fraction":79.7,"cloud_area_fraction_high":71.9,"cloud_area_fraction_low":27.3,"cloud_area_fraction_medium":0.0,"dew_point_temperature":9.0,"fog_area_fraction":0.0,"relative_humidity":92.5,"ultraviolet_index_clear_sky":0.7,"wind_from_direction":154.3,"wind_speed":2.8}},"next_12_hours":{"summary":{"symbol_code":"lightrainshowers_day"},"details":{}},"next_1_hours":{"summary":{"symbol_code":"partlycloudy_day"},"details":{"precipitation_amount":0.0}},"next_6_hours":{"summary":{"symbol_code":"partlycloudy_day"},"details":{"air_temperature_max":16.7,"air_temperature_min":11.8,"precipitation_amount":0.0}}}},{"time":"2026-10-12T09:00:00Z","data":{"instant":{"details":{"air_pressure_at_sea_level":1019.3,"air_temperature":11.8,"apparent_air_temperature":10.7,"cloud_area_fraction":75.8,"cloud_area_fraction_high":74.2,"cloud_area_fraction_low":7.8,"cloud_area_fraction_medium":0.0,"dew_point_temperature":10.0,"fog_area_fraction":0.0,"relative_humidity":87.5,"ultraviolet_index_clear_sky":1.4,"wind_from_direction":152.3,"wind_speed":3.0}},"next_12_hours":{"summary":{"symbol_code":"lightrainshowers_day"},"details":{}},"next_1_hours":{"summary":{"symbol_code":"partlycloudy_day"},"details":{"precipitation_amount":0.0}},"next_6_hours":{"summary":{"symbol_code":"partlycloudy_day"},"details":{"air_temperature_max":16.7,"air_temperature_min":13.5,"precipitation_amount":0.0}}}},{"time":"2026-10-12T10:00:00Z","data":{"instant":{"details":{"air_pressure_at_sea_level":1019.3,"air_temperature":13.5,"apparent_air_temperature":12.4,"cloud_area_fraction":45.3,"cloud_area_fraction_high":43.0,"cloud_area_fraction_low":3.9,"cloud_area_fraction_medium":0.0,"dew_point_temperature":10.6,"fog_area_fraction":0.0,"relative_humidity":82.3,"ultraviolet_index_clear_sky":2.1,"wind_from_direction":160.9,"wind_speed":3.6}},"next_12_hours":{"summary":{"symbol_code":"lightrainshowers_day"},"details":{}},"next_1_hours":{"summary":{"symbol_code":"partlycloudy_day"},"details":{"precipitation_amount":0.0}},"next_6_hours":{"summary":{"symbol_code":"partlycloudy_day"},"details":{"air_temperature_max":16.7,"air_temperature_min":14.9,"precipitation_amount":0.0}}}},{"time":"2026-10-12T11:00:00Z","data":{"instant":{"details":{"air_pressure_at_sea_level":1019.5,"air_temperature":14.9,"apparent_air_temperature":13.9,"cloud_area_fraction":46.1,"cloud_area_fraction_high":46.1,"cloud_area_fraction_low":0.0,"cloud_area_fraction_medium":0.0,"dew_point_temperature":10.4,"fog_area_fraction":0.0,"relative_humidity":74.8,"ultraviolet_index_clear_sky":2.6,"wind_from_direction":169.2,"wind_speed":4.2}},"next_12_hours":{"summary":{"symbol_code":"lightrainshowers_day"},"details":{}},"next_1_hours":{"summary":{"symbol_code":"partlycloudy_day"},"details":{"precipitation_amount":0.0}},"next_6_hours":{"summary":{"symbol_code":"partlycloudy_day"},"details":{"air_temperature_max":16.7,"air_temperature_min":15.4,"precipitation_amount":0.1}}}},{"time":"2026-10-12T12:00:00Z","data":{"instant":{"details":{"air_pressure_at_sea_level":1019.2,"air_temperature":15.8,"apparent_air_temperature":15.8,"cloud_area_fraction":11.7,"cloud_area_fraction_high":11.7,"cloud_area_fraction_low":0.0,"cloud_area_fraction_medium":0.0,"dew_point_temperature":10.2,"fog_area_fraction":0.0,"relative_humidity":69.0,"ultraviolet_index_clear_sky":2.6,"wind_from_direction":170.8,"wind_speed":4.5}},"next_12_hours":{"summary":{"symbol_code":"lightrain"},"details":{}},"next_1_hours":{"summary":{"symbol_code":"clearsky_day"},"details":{"precipitation_amount":0.0}},"next_6_hours":{"summary":{"symbol_code":"rainshowers_day"},"details":{"air_temperature_max":16.7,"air_temperature_min":14.4,"precipitation_amount":1.0}}}},{"time":"2026-10-12T13:00:00Z","data":{"instant":{"details":{"air_pressure_at_sea_level":1019.0,"air_temperature":16.4,"apparent_air_temperature":16.4,"cloud_area_fraction":28.9,"cloud_area_fraction_high":26.6,"cloud_area_fraction_low":0.8,"cloud_area_fraction_medium":2.3,"dew_point_temperature":10.2,"fog_area_fraction":0.0,"relative_humidity":66.2,"ultraviolet_index_clear_sky":2.1,"wind_from_direction":171.3,"wind_speed":4.6}},"next_12_hours":{"summary":{"symbol_code":"lightrain"},"details":{}},"next_1_hours":{"summary":{"symbol_code":"fair_day"},"details":{"precipitation_amount":0.0}},"next_6_hours":{"summary":{"symbol_code":"rainshowers_day"},"details":{"air_temperature_max":16.7,"air_temperature_min":14.2,"precipitation_amount":1.1}}}},{"time":"2026-10-12T14:00:00Z","data":{"instant":{"details":{"air_pressure_at_sea_level":1018.8,"air_temperature":16.7,"apparent_air_temperature":16.7,"cloud_area_fraction":25.0,"cloud_area_fraction_high":19.5,"cloud_area_fraction_low":0.0,"cloud_area_fraction_medium":6.2,"dew_point_temperature":10.1,"fog_area_fraction":0.0,"relative_humidity":64.4,"ultraviolet_index_clear_sky":1.3,"wind_from_direction":167.3,"wind_speed":4.7}},"next_12_hours":{"summary":{"symbol_code":"lightrain"},"details":{}},"next_1_hours":{"summary":{"symbol_code":"fair_day"},"details":{"precipitation_amount":0.0}},"next_6_hours":{"summary":{"symbol_code":"rain"},"details":{"air_temperature_max":16.7,"air_temperature_min":14.2,"precipitation_amount":1.1}}}},{"time":"2026-10-12T15:00:00Z","data":{"instant":{"details":{"air_pressure_at_sea_level":1018.7,"air_temperature":16.7,"apparent_air_temperature":16.7,"cloud_area_fraction":83.6,"cloud_area_fraction_high":32.0,"cloud_area_fraction_low":28.1,"cloud_area_fraction_medium":75.8,"dew_point_temperature":10.1,"fog_area_fraction":0.0,"relative_humidity":64.4,"ultraviolet_index_clear_sky":0.6,"wind_from_direction":166.7,"wind_speed":4.6}},"next_12_hours":{"summary":{"symbol_code":"lightrain"},"details":{}},"next_1_hours":{"summary":{"symbol_code":"partlycloudy_day"},"details":{"precipitation_amount":0.0}},"next_6_hours":{"summary":{"symbol_code":"rain"},"details":{"air_temperature_max":16.0,"air_temperature_min":14.2,"precipitation_amount":1.1}}}},{"time":"2026-10-12T16:00:00Z","data":{"instant":{"details":{"air_pressure_at_sea_level":1018.7,"air_temperature":16.0,"apparent_air_temperature":16.0,"cloud_area_fraction":100.0,"cloud_area_fraction_high":82.0,"cloud_area_fraction_low":36.7,"cloud_area_fraction_medium":100.0,"dew_point_temperature":10.4,"fog_area_fraction":0.0,"relative_humidity":69.1,"ultraviolet_index_clear_sky":0.2,"wind_from_direction":160.9,"wind_speed":3.9}},"next_12_hours":{"summary":{"symbol_code":"lightrain"},"details":{}},"next_1_hours":{"summary":{"symbol_code":"cloudy"},"details":{"precipitation_amount":0.0}},"next_6_hours":{"summary":{"symbol_code":"rain"},"details":{"air_temperature_max":15.4,"air_temperature_min":14.2,"precipitation_amount":1.2}}}},{"time":"2026-10-12T17:00:00Z","data":{"instant":{"details":{"air_pressure_at_sea_level":1018.9,"air_temperature":15.4,"apparent_air_temperature":15.4,"cloud_area_fraction":100.0,"cloud_area_fraction_high":64.8,"cloud_area_fraction_low":57.0,"cloud_area_fraction_medium":100.0,"dew_point_temperature":10.7,"fog_area_fraction":0.0,"relative_humidity":73.7,"ultraviolet_index_clear_sky":0.0,"wind_from_direction":162.2,"wind_speed":3.9}},"next_12_hours":{"summary":{"symbol_code":"lightrain"},"details":{}},"next_1_hours":{"summary":{"symbol_code":"rain"},"details":{"precipitation_amount":0.9}},"next_6_hours":{"summary":{"symbol_code":"rain"},"details":{"air_temperature_max":14.8,"air_temperature_min":14.2,"precipitation_amount":1.3}}}},{"time":"2026-10-12T18:00:00Z","data":{"instant":{"details":{"air_pressure_at_sea_level":1019.1,"air_temperature":14.4,"apparent_air_temperature":13.3,"cloud_area_fraction":100.0,"cloud_area_fraction_high":80.5,"cloud_area_fraction_low":6.2,"cloud_area_fraction_medium":100.0,"dew_point_temperature":11.4,"fog_area_fraction":0.0,"relative_humidity":82.5,"ultraviolet_index_clear_sky":0.0,"wind_from_direction":157.1,"wind_speed":4.4}},"next_12_hours":{"summary":{"symbol_code":"cloudy"},"details":{}},"next_1_hours":{"summary":{"symbol_code":"cloudy"},"details":{"precipitation_amount":0.0}},"next_6_hours":{"summary":{"symbol_code":"cloudy"},"details":{"air_temperature_max":14.8,"air_temperature_min":14.2,"precipitation_amount":0.4}}}},{"time":"2026-10-12T19:00:00Z","data":{"instant":{"details":{"air_pressure_at_sea_level":1019.1,"air_temperature":14.2,"apparent_air_temperature":13.0,"cloud_area_fraction":100.0,"cloud_area_fraction_high":76.6,"cloud_area_fraction_low":100.0,"cloud_area_fraction_medium":100.0,"dew_point_temperature":10.9,"fog_area_fraction":0.0,"relative_humidity":80.4,"ultraviolet_index_clear_sky":0.0,"wind_from_direction":168.9,"wind_speed":4.4}},"next_12_hours":{"summary":{"symbol_code":"cloudy"},"details":{}},"next_1_hours":{"summary":{"symbol_code":"cloudy"},"details":{"precipitation_amount":0.0}},"next_6_hours":{"summary":{"symbol_code":"cloudy"},"details":{"air_temperature_max":14.8,"air_temperature_min":14.4,"precipitation_amount":0.3}}}},{"time":"2026-10-12T20:00:00Z","data":{"instant":{"details":{"air_pressure_at_sea_level":1019.1,"air_temperature":14.5,"apparent_air_temperature":13.4,"cloud_area_fraction":100.0,"cloud_area_fraction_high":14.1,"cloud_area_fraction_low":100.0,"cloud_area_fraction_medium":9.4,"dew_point_temperature":10.4,"fog_area_fraction":0.0,"relative_humidity":76.3,"ultraviolet_index_clear_sky":0.0,"wind_from_direction":168.3,"wind_speed":4.3}},"next_12_hours":{"summary":{"symbol_code":"cloudy"},"details":{}},"next_1_hours":{"summary":{"symbol_code":"cloudy"},"details":{"precipitation_amount":0.0}},"next_6_hours":{"summary":{"symbol_code":"cloudy"},"details":{"air_temperature_max":14.8,"air_temperature_min":14.3,"precipitation_amount":0.3}}}},{"time":"2026-10-12T21:00:00Z","data":{"instant":{"details":{"air_pressure_at_sea_level":1019.3,"air_temperature":14.8,"apparent_air_temperature":13.8,"cloud_area_fraction":100.0,"cloud_area_fraction_high":3.1,"cloud_area_fraction_low":100.0,"cloud_area_fraction_medium":100.0,"dew_point_temperature":10.0,"fog_area_fraction":0.0,"relative_humidity":73.0,"ultraviolet_index_clear_sky":0.0,"wind_from_direction":176.6,"wind_speed":4.3}},"next_12_hours":{"summary":{"symbol_code":"cloudy"},"details":{}},"next_1_hours":{"summary":{"symbol_code":"lightrain"},"details":{"precipitation_amount":0.1}},"next_6_hours":{"summary":{"symbol_code":"cloudy"},"details":{"air_temperature_max":14.8,"air_temperature_min":14.2,"precipitation_amount":0.3}}}},{"time":"2026-10-12T22:00:00Z","data":{"instant":{"details":{"air_pressure_at_sea_level":1019.4,"air_temperature":14.6,"apparent_air_temperature":13.5,"cloud_area_fraction":100.0,"cloud_area_fraction_high":0.8,"cloud_area_fraction_low":100.0,"cloud_area_fraction_medium":100.0,"dew_point_temperature":10.3,"fog_area_fraction":0.0,"relative_humidity":75.3,"ultraviolet_index_clear_sky":0.0,"wind_from_direction":182.4,"wind_speed":4.3}},"next_12_hours":{"summary":{"symbol_code":"cloudy"},"details":{}},"next_1_hours":{"summary":{"symbol_code":"lightrain"},"details":{"precipitation_amount":0.2}},"next_6_hours":{"summary":{"symbol_code":"cloudy"},"details":{"air_temperature_max":14.8,"air_temperature_min":14.2,"precipitation_amount":0.2}}}},{"time":"2026-10-12T23:00:00Z","data":{"instant":{"details":{"air_pressure_at_sea_level":1019.3,"air_temperature":14.4,"apparent_air_temperature":13.2,"cloud_area_fraction":100.0,"cloud_area_fraction_high":0.0,"cloud_area_fraction_low":100.0,"cloud_area_fraction_medium":6.2,"dew_point_temperature":10.6,"fog_area_fraction":0.0,"relative_humidity":78.0,"ultraviolet_index_clear_sky":0.0,"wind_from_direction":186.7,"wind_speed":4.4}},"next_12_hours":{"summary":{"symbol_code":"cloudy"},"details":{}},"next_1_hours":{"summary":{"symbol_code":"cloudy"},"details":{"precipitation_amount":0.0}},"next_6_hours":{"summary":{"symbol_code":"cloudy"},"details":{"air_temperature_max":15.0,"air_temperature_min":14.2,"precipitation_amount":0.0}}}},{"time":"2026-10-13T00:00:00Z","data":{"instant":{"details":{"air_pressure_at_sea_level":1019.2,"air_temperature":14.7,"apparent_air_temperature":13.6,"cloud_area_fraction":100.0,"cloud_area_fraction_high":0.0,"cloud_area_fraction_low":100.0,"cloud_area_fraction_medium":34.4,"dew_point_temperature":10.7,"fog_area_fraction":0.0,"relative_humidity":77.3,"ultraviolet_index_clear_sky":0.0,"wind_from_direction":190.4,"wind_speed":4.4}},"next_12_hours":{"summary":{"symbol_code":"cloudy"},"details":{}},"next_1_hours":{"summary":{"symbol_code":"cloudy"},"details":{"precipitation_amount":0.0}},"next_6_hours":{"summary":{"symbol_code":"cloudy"},"details":{"air_temperature_max":15.2,"air_temperature_min":14.2,"precipitation_amount":0.0}}}},{"time":"2026-10-13T01:00:00Z","data":{"instant":{"details":{"air_pressure_at_sea_level":1019.1,"air_temperature":14.8,"apparent_air_temperature":13.8,"cloud_area_fraction":94.5,"cloud_area_fraction_high":0.0,"cloud_area_fraction_low":93.7,"cloud_area_fraction_medium":6.2,"dew_point_temperature":11.3,"fog_area_fraction":0.0,"relative_humidity":79.9,"ultraviolet_index_clear_sky":0.0,"wind_from_direction":194.6,"wind_speed":4.4}},"next_12_hours":{"summary":{"symbol_code":"cloudy"},"details":{}},"next_1_hours":{"summary":{"symbol_code":"cloudy"},"details":{"precipitation_amount":0.0}},"next_6_hours":{"summary":{"symbol_code":"cloudy"},"details":{"air_temperature_max":15.6,"air_temperature_min":14.2,"precipitation_amount":0.0}}}},{"time":"2026-10-13T02:00:00Z","data":{"instant":{"details":{"air_pressure_at_sea_level":1018.9,"air_temperature":14.3,"apparent_air_temperature":13.3,"cloud_area_fraction":40.6,"cloud_area_fraction_high":0.0,"cloud_area_fraction_low":39.8,"cloud_area_fraction_medium":1.6,"dew_point_temperature":11.8,"fog_area_fraction":0.0,"relative_humidity":85.0,"ultraviolet_index_clear_sky":0.0,"wind_from_direction":194.0,"wind_speed":4.1}},"next_12_hours":{"summary":{"symbol_code":"cloudy"},"details":{}},"next_1_hours":{"summary":{"symbol_code":"partlycloudy_night"},"details":{"precipitation_amount":0.0}},"next_6_hours":{"summary":{"symbol_code":"cloudy"},"details":{"air_temperature_max":16.0,"air_temperature_min":14.2,"precipitation_amount":0.0}}}},{"time":"2026-10-13T03:00:00Z","data":{"instant":{"details":{"air_pressure_at_sea_level":1018.7,"air_temperature":14.2,"apparent_air_temperature":13.2,"cloud_area_fraction":100.0,"cloud_area_fraction_high":15.6,"cloud_area_fraction_low":100.0,"cloud_area_fraction_medium":1.6,"dew_point_temperature":12.1,"fog_area_fraction":0.0,"relative_humidity":87.7,"ultraviolet_index_clear_sky":0.0,"wind_from_direction":194.1,"wind_speed":4.0}},"next_12_hours":{"summary":{"symbol_code":"lightrain"},"details":{}},"next_1_hours":{"summary":{"symbol_code":"cloudy"},"details":{"precipitation_amount":0.0}},"next_6_hours":{"summary":{"symbol_code":"cloudy"},"details":{"air_temperature_max":16.7,"air_temperature_min":14.7,"precipitation_amount":0.0}}}},{"time":"2026-10-13T04:00:00Z","data":{"instant":{"details":{"air_pressure_at_sea_level":1018.5,"air_temperature":14.7,"apparent_air_temperature":13.7,"cloud_area_fraction":100.0,"cloud_area_fraction_high":25.0,"cloud_area_fraction_low":100.0,"cloud_area_fraction_medium":0.8,"dew_point_temperature":12.6,"fog_area_fraction":0.0,"relative_humidity":88.2,"ultraviolet_index_clear_sky":0.0,"wind_from_direction":192.4,"wind_speed":4.0}},"next_12_hours":{"summary":{"symbol_code":"lightrain"},"details":{}},"next_1_hours":{"summary":{"symbol_code":"cloudy"},"details":{"precipitation_amount":0.0}},"next_6_hours":{"summary":{"symbol_code":"cloudy"},"details":{"air_temperature_max":17.5,"air_temperature_min":15.0,"precipitation_amount":0.1}}}},{"time":"2026-10-13T05:00:00Z","data":{"instant":{"details":{"air_pressure_at_sea_level":1018.5,"air_temperature":15.0,"apparent_air_temperature":14.0,"cloud_area_fraction":100.0,"cloud_area_fraction_high":39.8,"cloud_area_fraction_low":100.0,"cloud_area_fraction_medium":1.6,"dew_point_temperature":13.1,"fog_area_fraction":0.0,"relative_humidity":89.4,"ultraviolet_index_clear_sky":0.0,"wind_from_direction":190.4,"wind_speed":4.3}},"next_12_hours":{"summary":{"symbol_code":"lightrainshowers_day"},"details":{}},"next_1_hours":{"summary":{"symbol_code":"cloudy"},"details":{"precipitation_amount":0.0}},"next_6_hours":{"summary":{"symbol_code":"cloudy"},"details":{"air_temperature_max":18.0,"air_temperature_min":15.2,"precipitation_amount":0.2}}}},{"time":"2026-10-13T06:00:00Z","data":{"instant":{"details":{"air_pressure_at_sea_level":1018.8,"air_temperature":15.2,"apparent_air_temperature":15.2,"cloud_area_fraction":100.0,"cloud_area_fraction_high":57.8,"cloud_area_fraction_low":100.0,"cloud_area_fraction_medium":0.0,"dew_point_temperature":13.6,"fog_area_fraction":0.0,"relative_humidity":90.3,"ultraviolet_index_clear_sky":0.0,"wind_from_direction":196.8,"wind_speed":4.3}},"next_12_hours":{"summary":{"symbol_code":"lightrainshowers_day"},"details":{}},"next_1_hours":{"summary":{"symbol_code":"cloudy"},"details":{"precipitation_amount":0.0}},"next_6_hours":{"summary":{"symbol_code":"cloudy"},"details":{"air_temperature_max":18.8,"air_temperature_min":15.6,"precipitation_amount":0.2}}}},{"time":"2026-10-13T07:00:00Z","data":{"instant":{"details":{"air_pressure_at_sea_level":1019.5,"air_temperature":15.6,"apparent_air_temperature":15.6,"cloud_area_fraction":100.0,"cloud_area_fraction_high":80.5,"cloud_area_fraction_low":100.0,"cloud_area_fraction_medium":0.8,"dew_point_temperature":14.0,"fog_area_fraction":0.0,"relative_humidity":90.8,"ultraviolet_index_clear_sky":0.2,"wind_from_direction":201.4,"wind_speed":4.2}},"next_12_hours":{"summary":{"symbol_code":"lightrainshowers_day"},"details":{}},"next_1_hours":{"summary":{"symbol_code":"cloudy"},"details":{"precipitation_amount":0.0}},"next_6_hours":{"summary":{"symbol_code":"cloudy"},"details":{"air_temperature_max":19.9,"air_temperature_min":16.0,"precipitation_amount":0.2}}}},{"time":"2026-10-13T08:00:00Z","data":{"instant":{"details":{"air_pressure_at_sea_level":1019.8,"air_temperature":16.0,"apparent_air_temperature":16.0,"cloud_area_fraction":100.0,"cloud_area_fraction_high":78.1,"cloud_area_fraction_low":100.0,"cloud_area_fraction_medium":0.0,"dew_point_temperature":14.6,"fog_area_fraction":0.0,"relative_humidity":90.7,"ultraviolet_index_clear_sky":0.7,"wind_from_direction":202.1,"wind_speed":4.1}},"next_12_hours":{"summary":{"symbol_code":"partlycloudy_day"},"details":{}},"next_1_hours":{"summary":{"symbol_code":"cloudy"},"details":{"precipitation_amount":0.0}},"next_6_hours":{"summary":{"symbol_code":"cloudy"},"details":{"air_temperature_max":20.3,"air_temperature_min":16.7,"precipitation_amount":0.3}}}},{"time":"2026-10-13T09:00:00Z","data":{"instant":{"details":{"air_pressure_at_sea_level":1020.1,"air_temperature":16.7,"apparent_air_temperature":16.7,"cloud_area_fraction":100.0,"cloud_area_fraction_high":60.2,"cloud_area_fraction_low":100.0,"cloud_area_fraction_medium":0.0,"dew_point_temperature":15.2,"fog_area_fraction":0.0,"relative_humidity":90.1,"ultraviolet_index_clear_sky":1.5,"wind_from_direction":201.4,"wind_speed":4.3}},"next_12_hours":{"summary":{"symbol_code":"lightrainshowers_day"},"details":{}},"next_1_hours":{"summary":{"symbol_code":"cloudy"},"details":{"precipitation_amount":0.0}},"next_6_hours":{"summary":{"symbol_code":"lightrainshowers_day"},"details":{"air_temperature_max":20.3,"air_temperature_min":17.5,"precipitation_amount":0.6}}}},{"time":"2026-10-13T10:00:00Z","data":{"instant":{"details":{"air_pressure_at_sea_level":1020.2,"air_temperature":17.5,"apparent_air_temperature":17.5,"cloud_area_fraction":100.0,"cloud_area_fraction_high":38.3,"cloud_area_fraction_low":100.0,"cloud_area_fraction_medium":15.6,"dew_point_temperature":15.7,"fog_area_fraction":0.0,"relative_humidity":88.8,"ultraviolet_index_clear_sky":2.2,"wind_from_direction":202.4,"wind_speed":4.8}},"next_12_hours":{"summary":{"symbol_code":"lightrainshowers_day"},"details":{}},"next_1_hours":{"summary":{"symbol_code":"cloudy"},"details":{"precipitation_amount":0.0}},"next_6_hours":{"summary":{"symbol_code":"lightrainshowers_day"},"details":{"air_temperature_max":20.3,"air_temperature_min":18.0,"precipitation_amount":0.6}}}},{"time":"2026-10-13T11:00:00Z","data":{"instant":{"details":{"air_pressure_at_sea_level":1020.4,"air_temperature":18.0,"apparent_air_temperature":18.0,"cloud_area_fraction":100.0,"cloud_area_fraction_high":50.8,"cloud_area_fraction_low":100.0,"cloud_area_fraction_medium":9.4,"dew_point_temperature":16.0,"fog_area_fraction":0.0,"relative_humidity":88.0,"ultraviolet_index_clear_sky":2.6,"wind_from_direction":211.3,"wind_speed":4.8}},"next_12_hours":{"summary":{"symbol_code":"lightrainshowers_day"},"details":{}},"next_1_hours":{"summary":{"symbol_code":"cloudy"},"details":{"precipitation_amount":0.0}},"next_6_hours":{"summary":{"symbol_code":"lightrainshowers_day"},"details":{"air_temperature_max":20.3,"air_temperature_min":17.8,"precipitation_amount":0.6}}}},{"time":"2026-10-13T12:00:00Z","data":{"instant":{"details":{"air_pressure_at_sea_level":1020.5,"air_temperature":18.8,"apparent_air_temperature":18.8,"cloud_area_fraction":98.4,"cloud_area_fraction_high":61.7,"cloud_area_fraction_low":97.7,"cloud_area_fraction_medium":0.0,"dew_point_temperature":16.2,"fog_area_fraction":0.0,"relative_humidity":85.2,"ultraviolet_index_clear_sky":2.5,"wind_from_direction":217.6,"wind_speed":5.7}},"next_12_hours":{"summary":{"symbol_code":"lightrainshowers_day"},"details":{}},"next_1_hours":{"summary":{"symbol_code":"cloudy"},"details":{"precipitation_amount":0.0}},"next_6_hours":{"summary":{"symbol_code":"lightrainshowers_day"},"details":{"air_temperature_max":20.3,"air_temperature_min":17.2,"precipitation_amount":0.6}}}},{"time":"2026-10-13T13:00:00Z","data":{"instant":{"details":{"air_pressure_at_sea_level":1020.2,"air_temperature":19.9,"apparent_air_temperature":19.9,"cloud_area_fraction":77.3,"cloud_area_fraction_high":50.0,"cloud_area_fraction_low":54.7,"cloud_area_fraction_medium":0.0,"dew_point_temperature":16.3,"fog_area_fraction":0.0,"relative_humidity":80.2,"ultraviolet_index_clear_sky":2.0,"wind_from_direction":227.1,"wind_speed":6.2}},"next_12_hours":{"summary":{"symbol_code":"lightrainshowers_day"},"details":{}},"next_1_hours":{"summary":{"symbol_code":"partlycloudy_day"},"details":{"precipitation_amount":0.0}},"next_6_hours":{"summary":{"symbol_code":"lightrainshowers_day"},"details":{"air_temperature_max":20.3,"air_temperature_min":16.5,"precipitation_amount":0.6}}}},{"time":"2026-10-13T14:00:00Z","data":{"instant":{"details":{"air_pressure_at_sea_level":1020.4,"air_temperature":20.3,"apparent_air_temperature":20.3,"cloud_area_fraction":100.0,"cloud_area_fraction_high":29.7,"cloud_area_fraction_low":100.0,"cloud_area_fraction_medium":58.6,"dew_point_temperature":15.9,"fog_area_fraction":0.0,"relative_humidity":75.8,"ultraviolet_index_clear_sky":1.2,"wind_from_direction":246.7,"wind_speed":6.7}},"next_12_hours":{"summary":{"symbol_code":"fair_day"},"details":{}},"next_1_hours":{"summary":{"symbol_code":"rain"},"details":{"precipitation_amount":0.4}},"next_6_hours":{"summary":{"symbol_code":"fair_day"},"details":{"air_temperature_max":19.0,"air_temperature_min":15.8,"precipitation_amount":0.5}}}},{"time":"2026-10-13T15:00:00Z","data":{"instant":{"details":{"air_pressure_at_sea_level":1020.8,"air_temperature":19.0,"apparent_air_temperature":19.0,"cloud_area_fraction":20.3,"cloud_area_fraction_high":8.6,"cloud_area_fraction_low":11.7,"cloud_area_fraction_medium":1.6,"dew_point_temperature":16.3,"fog_area_fraction":0.0,"relative_humidity":84.2,"ultraviolet_index_clear_sky":0.6,"wind_from_direction":243.3,"wind_speed":5.0}},"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":"fair_night"},"details":{"air_temperature_max":18.9,"air_temperature_min":15.1,"precipitation_amount":0.1}}}},{"time":"2026-10-13T16:00:00Z","data":{"instant":{"details":{"air_pressure_at_sea_level":1021.4,"air_temperature":18.9,"apparent_air_temperature":18.9,"cloud_area_fraction":88.3,"cloud_area_fraction_high":2.3,"cloud_area_fraction_low":85.2,"cloud_area_fraction_medium":14.1,"dew_point_temperature":16.3,"fog_area_fraction":0.0,"relative_humidity":84.9,"ultraviolet_index_clear_sky":0.1,"wind_from_direction":244.9,"wind_speed":5.3}},"next_12_hours":{"summary":{"symbol_code":"clearsky_night"},"details":{}},"next_1_hours":{"summary":{"symbol_code":"cloudy"},"details":{"precipitation_amount":0.0}},"next_6_hours":{"summary":{"symbol_code":"fair_night"},"details":{"air_temperature_max":17.8,"air_temperature_min":14.5,"precipitation_amount":0.1}}}},{"time":"2026-10-13T17:00:00Z","data":{"instant":{"details":{"air_pressure_at_sea_level":1022.2,"air_temperature":17.8,"apparent_air_temperature":17.8,"cloud_area_fraction":43.7,"cloud_area_fraction_high":31.2,"cloud_area_fraction_low":17.2,"cloud_area_fraction_medium":3.1,"dew_point_temperature":16.2,"fog_area_fraction":0.0,"relative_humidity":90.0,"ultraviolet_index_clear_sky":0.0,"wind_from_direction":247.7,"wind_speed":4.6}},"next_12_hours":{"summary":{"symbol_code":"clearsky_night"},"details":{}},"next_1_hours":{"summary":{"symbol_code":"partlycloudy_night"},"details":{"precipitation_amount":0.0}},"next_6_hours":{"summary":{"symbol_code":"clearsky_night"},"details":{"air_temperature_max":17.2,"air_temperature_min":13.9,"precipitation_amount":0.0}}}},{"time":"2026-10-13T18:00:00Z","data":{"instant":{"details":{"air_pressure_at_sea_level":1023.0,"air_temperature":17.2,"apparent_air_temperature":17.2,"cloud_area_fraction":57.8,"cloud_area_fraction_high":56.2,"cloud_area_fraction_low":4.7,"cloud_area_fraction_medium":0.0,"dew_point_temperature":16.0,"fog_area_fraction":0.0,"relative_humidity":92.6,"ultraviolet_index_clear_sky":0.0,"wind_from_direction":250.6,"wind_speed":4.2}},"next_12_hours":{"summary":{"symbol_code":"clearsky_night"},"details":{}},"next_1_hours":{"summary":{"symbol_code":"partlycloudy_night"},"details":{"precipitation_amount":0.0}},"next_6_hours":{"summary":{"symbol_code":"clearsky_night"},"details":{"air_temperature_max":16.5,"air_temperature_min":13.3,"precipitation_amount":0.0}}}},{"time":"2026-10-13T19:00:00Z","data":{"instant":{"details":{"air_pressure_at_sea_level":1023.9,"air_temperature":16.5,"apparent_air_temperature":16.5,"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":15.6,"fog_area_fraction":0.0,"relative_humidity":93.9,"ultraviolet_index_clear_sky":0.0,"wind_from_direction":252.6,"wind_speed":3.9}},"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":15.8,"air_temperature_min":12.8,"precipitation_amount":0.0}}}},{"time":"2026-10-13T20:00:00Z","data":{"instant":{"details":{"air_pressure_at_sea_level":1024.6,"air_temperature":15.8,"apparent_air_temperature":15.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":15.0,"fog_area_fraction":0.0,"relative_humidity":94.6,"ultraviolet_index_clear_sky":0.0,"wind_from_direction":251.3,"wind_speed":3.6}},"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":15.1,"air_temperature_min":12.2,"precipitation_amount":0.0}}}},{"time":"2026-10-13T21:00:00Z","data":{"instant":{"details":{"air_pressure_at_sea_level":1025.4,"air_temperature":15.1,"apparent_air_temperature":15.1,"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":14.4,"fog_area_fraction":0.0,"relative_humidity":95.9,"ultraviolet_index_clear_sky":0.0,"wind_from_direction":254.5,"wind_speed":3.3}},"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":14.5,"air_temperature_min":11.7,"precipitation_amount":0.0}}}},{"time":"2026-10-13T22:00:00Z","data":{"instant":{"details":{"air_pressure_at_sea_level":1026.0,"air_temperature":14.5,"apparent_air_temperature":13.8,"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":13.7,"fog_area_fraction":0.0,"relative_humidity":95.4,"ultraviolet_index_clear_sky":0.0,"wind_from_direction":260.6,"wind_speed":3.1}},"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":13.9,"air_temperature_min":11.1,"precipitation_amount":0.0}}}},{"time":"2026-10-13T23:00:00Z","data":{"instant":{"details":{"air_pressure_at_sea_level":1026.5,"air_temperature":13.9,"apparent_air_temperature":13.2,"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":12.9,"fog_area_fraction":0.0,"relative_humidity":94.2,"ultraviolet_index_clear_sky":0.0,"wind_from_direction":264.1,"wind_speed":3.0}},"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":13.3,"air_temperature_min":10.6,"precipitation_amount":0.0}}}},{"time":"2026-10-14T00:00:00Z","data":{"instant":{"details":{"air_pressure_at_sea_level":1027.0,"air_temperature":13.3,"apparent_air_temperature":12.6,"cloud_area_fraction":3.1,"cloud_area_fraction_high":0.0,"cloud_area_fraction_low":3.1,"cloud_area_fraction_medium":0.0,"dew_point_temperature":12.2,"fog_area_fraction":0.0,"relative_humidity":93.4,"ultraviolet_index_clear_sky":0.0,"wind_from_direction":269.7,"wind_speed":2.8}},"next_12_hours":{"summary":{"symbol_code":"fair_day"},"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":12.8,"air_temperature_min":10.2,"precipitation_amount":0.0}}}},{"time":"2026-10-14T01:00:00Z","data":{"instant":{"details":{"air_pressure_at_sea_level":1027.4,"air_temperature":12.8,"apparent_air_temperature":12.2,"cloud_area_fraction":4.7,"cloud_area_fraction_high":1.6,"cloud_area_fraction_low":3.1,"cloud_area_fraction_medium":0.0,"dew_point_temperature":11.8,"fog_area_fraction":0.0,"relative_humidity":93.7,"ultraviolet_index_clear_sky":0.0,"wind_from_direction":269.7,"wind_speed":2.3}},"next_1_hours":{"summary":{"symbol_code":"clearsky_night"},"details":{"precipitation_amount":0.0}}}},{"time":"2026-10-14T02:00:00Z","data":{"instant":{"details":{"air_pressure_at_sea_level":1027.7,"air_temperature":12.2,"apparent_air_temperature":11.9,"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":11.4,"fog_area_fraction":0.0,"relative_humidity":94.1,"ultraviolet_index_clear_sky":0.0,"wind_from_direction":263.3,"wind_speed":1.9}},"next_1_hours":{"summary":{"symbol_code":"clearsky_night"},"details":{"precipitation_amount":0.0}}}},{"time":"2026-10-14T03:00:00Z","data":{"instant":{"details":{"air_pressure_at_sea_level":1028.1,"air_temperature":11.7,"apparent_air_temperature":11.3,"cloud_area_fraction":0.8,"cloud_area_fraction_high":0.8,"cloud_area_fraction_low":0.0,"cloud_area_fraction_medium":0.0,"dew_point_temperature":10.8,"fog_area_fraction":0.0,"relative_humidity":94.0,"ultraviolet_index_clear_sky":0.0,"wind_from_direction":257.4,"wind_speed":1.8}},"next_1_hours":{"summary":{"symbol_code":"clearsky_night"},"details":{"precipitation_amount":0.0}}}},{"time":"2026-10-14T04:00:00Z","data":{"instant":{"details":{"air_pressure_at_sea_level":1028.3,"air_temperature":11.1,"apparent_air_temperature":10.6,"cloud_area_fraction":13.3,"cloud_area_fraction_high":13.3,"cloud_area_fraction_low":0.0,"cloud_area_fraction_medium":0.0,"dew_point_temperature":10.3,"fog_area_fraction":0.0,"relative_humidity":94.5,"ultraviolet_index_clear_sky":0.0,"wind_from_direction":242.7,"wind_speed":1.8}},"next_1_hours":{"summary":{"symbol_code":"fair_night"},"details":{"precipitation_amount":0.0}}}},{"time":"2026-10-14T05:00:00Z","data":{"instant":{"details":{"air_pressure_at_sea_level":1028.8,"air_temperature":10.6,"apparent_air_temperature":9.9,"cloud_area_fraction":6.2,"cloud_area_fraction_high":6.2,"cloud_area_fraction_low":0.0,"cloud_area_fraction_medium":0.0,"dew_point_temperature":10.0,"fog_area_fraction":0.0,"relative_humidity":95.8,"ultraviolet_index_clear_sky":0.0,"wind_from_direction":238.9,"wind_speed":1.9}},"next_1_hours":{"summary":{"symbol_code":"clearsky_night"},"details":{"precipitation_amount":0.0}}}},{"time":"2026-10-14T06:00:00Z","data":{"instant":{"details":{"air_pressure_at_sea_level":1029.4,"air_temperature":10.2,"apparent_air_temperature":9.4,"cloud_area_fraction":8.6,"cloud_area_fraction_high":8.6,"cloud_area_fraction_low":0.0,"cloud_area_fraction_medium":0.0,"dew_point_temperature":9.7,"fog_area_fraction":0.0,"relative_humidity":96.9,"ultraviolet_index_clear_sky":0.0,"wind_from_direction":235.7,"wind_speed":1.9}},"next_12_hours":{"summary":{"symbol_code":"partlycloudy_day"},"details":{}},"next_6_hours":{"summary":{"symbol_code":"fair_day"},"details":{"air_temperature_max":18.2,"air_temperature_min":10.3,"precipitation_amount":0.0}}}},{"time":"2026-10-14T12:00:00Z","data":{"instant":{"details":{"air_pressure_at_sea_level":1029.8,"air_temperature":18.2,"apparent_air_temperature":18.2,"cloud_area_fraction":37.5,"cloud_area_fraction_high":12.5,"cloud_area_fraction_low":28.1,"cloud_area_fraction_medium":0.0,"dew_point_temperature":12.4,"relative_humidity":69.1,"wind_from_direction":214.0,"wind_speed":2.6}},"next_12_hours":{"summary":{"symbol_code":"partlycloudy_day"},"details":{}},"next_6_hours":{"summary":{"symbol_code":"fair_day"},"details":{"air_temperature_max":18.4,"air_temperature_min":14.7,"precipitation_amount":0.0}}}},{"time":"2026-10-14T18:00:00Z","data":{"instant":{"details":{"air_pressure_at_sea_level":1028.2,"air_temperature":14.7,"apparent_air_temperature":14.3,"cloud_area_fraction":99.2,"cloud_area_fraction_high":99.2,"cloud_area_fraction_low":0.0,"cloud_area_fraction_medium":0.0,"dew_point_temperature":12.3,"relative_humidity":86.3,"wind_from_direction":171.2,"wind_speed":2.6}},"next_12_hours":{"summary":{"symbol_code":"partlycloudy_night"},"details":{}},"next_6_hours":{"summary":{"symbol_code":"cloudy"},"details":{"air_temperature_max":14.7,"air_temperature_min":12.8,"precipitation_amount":0.0}}}},{"time":"2026-10-15T00:00:00Z","data":{"instant":{"details":{"air_pressure_at_sea_level":1027.1,"air_temperature":12.8,"apparent_air_temperature":11.9,"cloud_area_fraction":39.1,"cloud_area_fraction_high":39.1,"cloud_area_fraction_low":0.0,"cloud_area_fraction_medium":0.0,"dew_point_temperature":11.4,"relative_humidity":91.1,"wind_from_direction":189.5,"wind_speed":3.0}},"next_12_hours":{"summary":{"symbol_code":"partlycloudy_day"},"details":{}},"next_6_hours":{"summary":{"symbol_code":"partlycloudy_night"},"details":{"air_temperature_max":13.1,"air_temperature_min":12.5,"precipitation_amount":0.0}}}},{"time":"2026-10-15T06:00:00Z","data":{"instant":{"details":{"air_pressure_at_sea_level":1025.3,"air_temperature":12.5,"apparent_air_temperature":11.4,"cloud_area_fraction":76.6,"cloud_area_fraction_high":75.8,"cloud_area_fraction_low":0.8,"cloud_area_fraction_medium":2.3,"dew_point_temperature":11.8,"relative_humidity":95.6,"wind_from_direction":193.3,"wind_speed":3.2}},"next_12_hours":{"summary":{"symbol_code":"fair_day"},"details":{}},"next_6_hours":{"summary":{"symbol_code":"partlycloudy_day"},"details":{"air_temperature_max":19.8,"air_temperature_min":12.4,"precipitation_amount":0.0}}}},{"time":"2026-10-15T12:00:00Z","data":{"instant":{"details":{"air_pressure_at_sea_level":1023.7,"air_temperature":19.8,"apparent_air_temperature":19.8,"cloud_area_fraction":1.6,"cloud_area_fraction_high":0.8,"cloud_area_fraction_low":0.8,"cloud_area_fraction_medium":0.0,"dew_point_temperature":14.9,"relative_humidity":73.4,"wind_from_direction":189.7,"wind_speed":3.8}},"next_12_hours":{"summary":{"symbol_code":"clearsky_day"},"details":{}},"next_6_hours":{"summary":{"symbol_code":"clearsky_day"},"details":{"air_temperature_max":20.6,"air_temperature_min":17.0,"precipitation_amount":0.0}}}},{"time":"2026-10-15T18:00:00Z","data":{"instant":{"details":{"air_pressure_at_sea_level":1021.2,"air_temperature":17.0,"apparent_air_temperature":17.0,"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":15.0,"relative_humidity":86.8,"wind_from_direction":171.2,"wind_speed":2.7}},"next_12_hours":{"summary":{"symbol_code":"fair_night"},"details":{}},"next_6_hours":{"summary":{"symbol_code":"clearsky_night"},"details":{"air_temperature_max":17.0,"air_temperature_min":15.0,"precipitation_amount":0.0}}}},{"time":"2026-10-16T00:00:00Z","data":{"instant":{"details":{"air_pressure_at_sea_level":1019.7,"air_temperature":15.0,"apparent_air_temperature":14.3,"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":13.7,"relative_humidity":92.6,"wind_from_direction":189.3,"wind_speed":3.4}},"next_12_hours":{"summary":{"symbol_code":"lightrainshowers_day"},"details":{}},"next_6_hours":{"summary":{"symbol_code":"clearsky_night"},"details":{"air_temperature_max":15.0,"air_temperature_min":14.6,"precipitation_amount":0.0}}}},{"time":"2026-10-16T06:00:00Z","data":{"instant":{"details":{"air_pressure_at_sea_level":1017.8,"air_temperature":14.9,"apparent_air_temperature":14.2,"cloud_area_fraction":100.0,"cloud_area_fraction_high":0.0,"cloud_area_fraction_low":100.0,"cloud_area_fraction_medium":0.0,"dew_point_temperature":14.2,"relative_humidity":96.2,"wind_from_direction":207.1,"wind_speed":3.5}},"next_12_hours":{"summary":{"symbol_code":"rainshowers_day"},"details":{}},"next_6_hours":{"summary":{"symbol_code":"rain"},"details":{"air_temperature_max":17.0,"air_temperature_min":14.9,"precipitation_amount":1.9}}}},{"time":"2026-10-16T12:00:00Z","data":{"instant":{"details":{"air_pressure_at_sea_level":1019.3,"air_temperature":15.0,"apparent_air_temperature":13.8,"cloud_area_fraction":100.0,"cloud_area_fraction_high":60.9,"cloud_area_fraction_low":100.0,"cloud_area_fraction_medium":100.0,"dew_point_temperature":14.2,"relative_humidity":95.4,"wind_from_direction":324.1,"wind_speed":5.1}},"next_12_hours":{"summary":{"symbol_code":"lightrainshowers_day"},"details":{}},"next_6_hours":{"summary":{"symbol_code":"rain"},"details":{"air_temperature_max":15.0,"air_temperature_min":11.9,"precipitation_amount":1.7}}}},{"time":"2026-10-16T18:00:00Z","data":{"instant":{"details":{"air_pressure_at_sea_level":1023.8,"air_temperature":11.9,"apparent_air_temperature":10.4,"cloud_area_fraction":11.7,"cloud_area_fraction_high":0.0,"cloud_area_fraction_low":7.8,"cloud_area_fraction_medium":5.5,"dew_point_temperature":8.0,"relative_humidity":76.9,"wind_from_direction":330.5,"wind_speed":3.9}},"next_12_hours":{"summary":{"symbol_code":"fair_night"},"details":{}},"next_6_hours":{"summary":{"symbol_code":"clearsky_night"},"details":{"air_temperature_max":11.9,"air_temperature_min":10.0,"precipitation_amount":0.0}}}},{"time":"2026-10-17T00:00:00Z","data":{"instant":{"details":{"air_pressure_at_sea_level":1026.4,"air_temperature":10.0,"apparent_air_temperature":9.2,"cloud_area_fraction":14.8,"cloud_area_fraction_high":0.0,"cloud_area_fraction_low":10.2,"cloud_area_fraction_medium":6.2,"dew_point_temperature":6.5,"relative_humidity":79.7,"wind_from_direction":317.7,"wind_speed":2.1}},"next_12_hours":{"summary":{"symbol_code":"fair_day"},"details":{}},"next_6_hours":{"summary":{"symbol_code":"fair_night"},"details":{"air_temperature_max":10.3,"air_temperature_min":8.9,"precipitation_amount":0.2}}}},{"time":"2026-10-17T06:00:00Z","data":{"instant":{"details":{"air_pressure_at_sea_level":1028.8,"air_temperature":9.1,"apparent_air_temperature":7.5,"cloud_area_fraction":40.6,"cloud_area_fraction_high":0.0,"cloud_area_fraction_low":18.7,"cloud_area_fraction_medium":28.1,"dew_point_temperature":7.4,"relative_humidity":89.6,"wind_from_direction":336.9,"wind_speed":3.0}},"next_12_hours":{"summary":{"symbol_code":"partlycloudy_day"},"details":{}},"next_6_hours":{"summary":{"symbol_code":"partlycloudy_day"},"details":{"air_temperature_max":13.9,"air_temperature_min":9.1,"precipitation_amount":0.2}}}},{"time":"2026-10-17T12:00:00Z","data":{"instant":{"details":{"air_pressure_at_sea_level":1030.7,"air_temperature":13.9,"apparent_air_temperature":12.7,"cloud_area_fraction":18.7,"cloud_area_fraction_high":0.0,"cloud_area_fraction_low":15.6,"cloud_area_fraction_medium":10.2,"dew_point_temperature":7.0,"relative_humidity":63.4,"wind_from_direction":6.3,"wind_speed":4.3}},"next_12_hours":{"summary":{"symbol_code":"partlycloudy_day"},"details":{}},"next_6_hours":{"summary":{"symbol_code":"fair_day"},"details":{"air_temperature_max":14.3,"air_temperature_min":11.0,"precipitation_amount":0.2}}}},{"time":"2026-10-17T18:00:00Z","data":{"instant":{"details":{"air_pressure_at_sea_level":1032.1,"air_temperature":11.0,"apparent_air_temperature":9.4,"cloud_area_fraction":96.9,"cloud_area_fraction_high":0.0,"cloud_area_fraction_low":96.9,"cloud_area_fraction_medium":0.0,"dew_point_temperature":5.7,"relative_humidity":69.5,"wind_from_direction":10.5,"wind_speed":3.7}},"next_12_hours":{"summary":{"symbol_code":"fair_night"},"details":{}},"next_6_hours":{"summary":{"symbol_code":"cloudy"},"details":{"air_temperature_max":11.3,"air_temperature_min":8.9,"precipitation_amount":0.0}}}},{"time":"2026-10-18T00:00:00Z","data":{"instant":{"details":{"air_pressure_at_sea_level":1032.8,"air_temperature":8.9,"apparent_air_temperature":7.7,"cloud_area_fraction":14.1,"cloud_area_fraction_high":0.0,"cloud_area_fraction_low":10.2,"cloud_area_fraction_medium":10.2,"dew_point_temperature":6.2,"relative_humidity":83.3,"wind_from_direction":6.0,"wind_speed":2.3}},"next_12_hours":{"summary":{"symbol_code":"fair_day"},"details":{}},"next_6_hours":{"summary":{"symbol_code":"fair_night"},"details":{"air_temperature_max":9.0,"air_temperature_min":8.1,"precipitation_amount":0.0}}}},{"time":"2026-10-18T06:00:00Z","data":{"instant":{"details":{"air_pressure_at_sea_level":1032.7,"air_temperature":8.1,"apparent_air_temperature":6.1,"cloud_area_fraction":9.4,"cloud_area_fraction_high":0.0,"cloud_area_fraction_low":1.6,"cloud_area_fraction_medium":8.6,"dew_point_temperature":5.4,"relative_humidity":82.4,"wind_from_direction":24.0,"wind_speed":3.2}},"next_12_hours":{"summary":{"symbol_code":"fair_day"},"details":{}},"next_6_hours":{"summary":{"symbol_code":"clearsky_day"},"details":{"air_temperature_max":12.8,"air_temperature_min":8.0,"precipitation_amount":0.0}}}},{"time":"2026-10-18T12:00:00Z","data":{"instant":{"details":{"air_pressure_at_sea_level":1033.2,"air_temperature":12.8,"apparent_air_temperature":11.0,"cloud_area_fraction":24.2,"cloud_area_fraction_high":0.0,"cloud_area_fraction_low":24.2,"cloud_area_fraction_medium":0.0,"dew_point_temperature":4.5,"relative_humidity":56.7,"wind_from_direction":49.6,"wind_speed":5.3}},"next_12_hours":{"summary":{"symbol_code":"clearsky_day"},"details":{}},"next_6_hours":{"summary":{"symbol_code":"fair_day"},"details":{"air_temperature_max":12.9,"air_temperature_min":9.9,"precipitation_amount":0.0}}}},{"time":"2026-10-18T18:00:00Z","data":{"instant":{"details":{"air_pressure_at_sea_level":1033.2,"air_temperature":9.9,"apparent_air_temperature":7.7,"cloud_area_fraction":3.1,"cloud_area_fraction_high":0.0,"cloud_area_fraction_low":3.1,"cloud_area_fraction_medium":0.0,"dew_point_temperature":4.6,"relative_humidity":69.8,"wind_from_direction":50.9,"wind_speed":4.5}},"next_12_hours":{"summary":{"symbol_code":"fair_night"},"details":{}},"next_6_hours":{"summary":{"symbol_code":"clearsky_night"},"details":{"air_temperature_max":9.9,"air_temperature_min":8.2,"precipitation_amount":0.0}}}},{"time":"2026-10-19T00:00:00Z","data":{"instant":{"details":{"air_pressure_at_sea_level":1035.1,"air_temperature":8.2,"apparent_air_temperature":5.4,"cloud_area_fraction":3.9,"cloud_area_fraction_high":0.8,"cloud_area_fraction_low":3.1,"cloud_area_fraction_medium":0.0,"dew_point_temperature":5.7,"relative_humidity":83.7,"wind_from_direction":48.4,"wind_speed":4.8}},"next_12_hours":{"summary":{"symbol_code":"fair_day"},"details":{}},"next_6_hours":{"summary":{"symbol_code":"clearsky_night"},"details":{"air_temperature_max":8.2,"air_temperature_min":6.3,"precipitation_amount":0.0}}}},{"time":"2026-10-19T06:00:00Z","data":{"instant":{"details":{"air_pressure_at_sea_level":1036.6,"air_temperature":6.3,"apparent_air_temperature":3.3,"cloud_area_fraction":44.5,"cloud_area_fraction_high":2.3,"cloud_area_fraction_low":43.7,"cloud_area_fraction_medium":0.0,"dew_point_temperature":5.3,"relative_humidity":92.6,"wind_from_direction":51.9,"wind_speed":4.2}},"next_12_hours":{"summary":{"symbol_code":"fair_day"},"details":{}},"next_6_hours":{"summary":{"symbol_code":"partlycloudy_day"},"details":{"air_temperature_max":11.2,"air_temperature_min":6.3,"precipitation_amount":0.0}}}},{"time":"2026-10-19T12:00:00Z","data":{"instant":{"details":{"air_pressure_at_sea_level":1037.3,"air_temperature":11.2,"apparent_air_temperature":9.6,"cloud_area_fraction":14.1,"cloud_area_fraction_high":3.1,"cloud_area_fraction_low":10.9,"cloud_area_fraction_medium":0.0,"dew_point_temperature":6.5,"relative_humidity":73.1,"wind_from_direction":58.0,"wind_speed":3.7}},"next_12_hours":{"summary":{"symbol_code":"fair_day"},"details":{}},"next_6_hours":{"summary":{"symbol_code":"fair_day"},"details":{"air_temperature_max":12.2,"air_temperature_min":9.0,"precipitation_amount":0.0}}}},{"time":"2026-10-19T18:00:00Z","data":{"instant":{"details":{"air_pressure_at_sea_level":1035.9,"air_temperature":9.0,"apparent_air_temperature":6.9,"cloud_area_fraction":12.5,"cloud_area_fraction_high":12.5,"cloud_area_fraction_low":0.0,"cloud_area_fraction_medium":0.0,"dew_point_temperature":3.5,"relative_humidity":68.6,"wind_from_direction":52.1,"wind_speed":3.8}},"next_12_hours":{"summary":{"symbol_code":"partlycloudy_night"},"details":{}},"next_6_hours":{"summary":{"symbol_code":"clearsky_night"},"details":{"air_temperature_max":9.0,"air_temperature_min":6.3,"precipitation_amount":0.0}}}},{"time":"2026-10-20T00:00:00Z","data":{"instant":{"details":{"air_pressure_at_sea_level":1035.5,"air_temperature":6.3,"apparent_air_temperature":4.4,"cloud_area_fraction":96.1,"cloud_area_fraction_high":96.1,"cloud_area_fraction_low":0.0,"cloud_area_fraction_medium":0.0,"dew_point_temperature":4.5,"relative_humidity":87.9,"wind_from_direction":50.0,"wind_speed":2.6}},"next_12_hours":{"summary":{"symbol_code":"cloudy"},"details":{}},"next_6_hours":{"summary":{"symbol_code":"cloudy"},"details":{"air_temperature_max":6.3,"air_temperature_min":5.4,"precipitation_amount":0.0}}}},{"time":"2026-10-20T06:00:00Z","data":{"instant":{"details":{"air_pressure_at_sea_level":1033.9,"air_temperature":5.5,"apparent_air_temperature":4.4,"cloud_area_fraction":100.0,"cloud_area_fraction_high":100.0,"cloud_area_fraction_low":14.1,"cloud_area_fraction_medium":0.0,"dew_point_temperature":4.6,"relative_humidity":94.2,"wind_from_direction":31.7,"wind_speed":1.6}},"next_12_hours":{"summary":{"symbol_code":"partlycloudy_day"},"details":{}},"next_6_hours":{"summary":{"symbol_code":"cloudy"},"details":{"air_temperature_max":13.4,"air_temperature_min":5.5,"precipitation_amount":0.0}}}},{"time":"2026-10-20T12:00:00Z","data":{"instant":{"details":{"air_pressure_at_sea_level":1032.1,"air_temperature":13.4,"apparent_air_temperature":13.0,"cloud_area_fraction":75.8,"cloud_area_fraction_high":34.4,"cloud_area_fraction_low":63.3,"cloud_area_fraction_medium":0.0,"dew_point_temperature":9.3,"relative_humidity":76.0,"wind_from_direction":19.1,"wind_speed":2.1}},"next_12_hours":{"summary":{"symbol_code":"partlycloudy_day"},"details":{}},"next_6_hours":{"summary":{"symbol_code":"partlycloudy_day"},"details":{"air_temperature_max":14.5,"air_temperature_min":11.4,"precipitation_amount":0.0}}}},{"time":"2026-10-20T18:00:00Z","data":{"instant":{"details":{"air_pressure_at_sea_level":1030.2,"air_temperature":11.4,"apparent_air_temperature":10.3,"cloud_area_fraction":75.0,"cloud_area_fraction_high":61.7,"cloud_area_fraction_low":35.9,"cloud_area_fraction_medium":0.0,"dew_point_temperature":9.7,"relative_humidity":88.3,"wind_from_direction":39.9,"wind_speed":2.8}},"next_6_hours":{"summary":{"symbol_code":"partlycloudy_night"},"details":{"air_temperature_max":11.4,"air_temperature_min":7.9,"precipitation_amount":0.0}}}},{"time":"2026-10-21T00:00:00Z","data":{"instant":{"details":{"air_pressure_at_sea_level":1030.2,"air_temperature":7.9,"apparent_air_temperature":6.0,"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":6.6,"relative_humidity":91.2,"wind_from_direction":66.3,"wind_speed":3.0}}}}]}} \ No newline at end of file diff --git a/internal/weather/testdata/nominatim.json b/internal/weather/testdata/nominatim.json new file mode 100644 index 0000000..f8371bf --- /dev/null +++ b/internal/weather/testdata/nominatim.json @@ -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"]}] \ No newline at end of file diff --git a/internal/weather/weather.go b/internal/weather/weather.go new file mode 100644 index 0000000..1d875af --- /dev/null +++ b/internal/weather/weather.go @@ -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] +} diff --git a/internal/weather/weather_test.go b/internal/weather/weather_test.go new file mode 100644 index 0000000..e411e9d --- /dev/null +++ b/internal/weather/weather_test.go @@ -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) + } + } +} diff --git a/internal/web/server.go b/internal/web/server.go new file mode 100644 index 0000000..4d57a86 --- /dev/null +++ b/internal/web/server.go @@ -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() +} diff --git a/internal/web/server_test.go b/internal/web/server_test.go new file mode 100644 index 0000000..95a838c --- /dev/null +++ b/internal/web/server_test.go @@ -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** ", ""} { + 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, "Go for a run") { + t.Errorf("stream = %s", stream) + } + if strings.Contains(stream, " + + + + + +
+

🌿 workout-suggester

+ @Health(HealthInfo{Checking: true}) +
+ @Form(d.DefaultPlace) +
+ if d.Plan != nil { + @PlanView(*d.Plan, d.Messages) + } +
+ + + +} + +templ Form(defaultPlace string) { +
+
+ + + + +
+
+ Up for + @radio("activity", "", coach.Any.Label(), true) + @radio("activity", "run", coach.Run.Label(), false) + @radio("activity", "ride", coach.Ride.Label(), false) + @radio("activity", "walk", coach.Walk.Label(), false) +
+
+ How hard + @radio("intensity", "easy", "😌 Easy", false) + @radio("intensity", "moderate", "🙂 Moderate", true) + @radio("intensity", "hard", "🔥 Hard", false) +
+
+ How long + for _, m := range []int{30, 45, 60, 90, 120} { + @radio("minutes", fmt.Sprint(m), minutesLabel(m), m == 60) + } +
+ + +
+} + +templ radio(name, value, label string, checked bool) { + +} + +templ Error(msg string) { +
⚠️ { msg }
+} + +// PlanView is the weather, the scores, the coach's advice, routes and the +// follow-up chat. +templ PlanView(p coach.Plan, msgs []session.Message) { + @WeatherCard(p) + @Scores(p) +
+
+ for _, m := range msgs { + if m.Role == "assistant" && m.State != session.Done { + @StreamingReply(m) + } else { + @MessageView(m) + } + } +
+
+ + + + +
+
+ + +
+
+ if p.Assessment.Pick != coach.Indoor { + if p.RoutesLoaded { + @RoutesCard(p) + } else { +
+

🗺️ Routes nearby

+

Planning loops from your door and looking up signposted routes…

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

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

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

Why: { why }

+ } +} + +templ RoutesCard(p coach.Plan) { +
+

🗺️ Routes

+ if len(p.Routes) == 0 { +

+ if p.RoutesErr != "" { + The route services are busy, so no routes this time. Try again in a minute. + } else { + No { p.Assessment.Pick.Noun() } routes found close by. An out-and-back from your door works too. + } +

+ } else { +
+
    + for i, s := range p.Routes { +
  1. +
    + if s.Route.Generated { + from your door + } else { + signposted + } + { s.Route.Name } +
    +
    + if s.Route.Generated { + { loopDetails(p, s.Route) } + } else { + { s.Describe() } ≈ { fmt.Sprintf("%.1f km", s.TotalKm) } · { fmt.Sprintf("%.1f km", s.FromYouKm) } from you + } +
    + +
  2. + } +
+ } + if j := p.Junction; j != nil { +

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

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

⚠️ { m.Err }

+ } + } else { +
{ m.Content }
+ } +
+} + +// Exchange is a follow-up question plus its streaming reply. +templ Exchange(user, reply session.Message) { + @MessageView(user) + @StreamingReply(reply) +} + +// StreamingReply opens an SSE connection. "token" events append text as it +// arrives; the final "done" event swaps in the rendered Markdown. +templ StreamingReply(m session.Message) { +
+
+
+} + +templ Token(text string) { + { text } +} + +// HealthInfo describes the model server status shown in the header. +type HealthInfo struct { + Checking bool + OK bool + Model string + Err string +} + +templ Health(h HealthInfo) { +
+ if h.Checking { + connecting… + } else if h.OK { + { h.Model } + } else { + model offline + } +
+} diff --git a/internal/web/views/views_templ.go b/internal/web/views/views_templ.go new file mode 100644 index 0000000..ccf36af --- /dev/null +++ b/internal/web/views/views_templ.go @@ -0,0 +1,1507 @@ +// Code generated by templ - DO NOT EDIT. + +// templ: version: v0.3.1070 +package views + +//lint:file-ignore SA4006 This context is only used if a nested component is present. + +import "github.com/a-h/templ" +import templruntime "github.com/a-h/templ/runtime" + +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 +} + +func Page(d PageData) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var1 := templ.GetChildren(ctx) + if templ_7745c5c3_Var1 == nil { + templ_7745c5c3_Var1 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "workout-suggester

🌿 workout-suggester

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = Health(HealthInfo{Checking: true}).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = Form(d.DefaultPlace).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if d.Plan != nil { + templ_7745c5c3_Err = PlanView(*d.Plan, d.Messages).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +func Form(defaultPlace string) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var2 := templ.GetChildren(ctx) + if templ_7745c5c3_Var2 == nil { + templ_7745c5c3_Var2 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "
Up for") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = radio("activity", "", coach.Any.Label(), true).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = radio("activity", "run", coach.Run.Label(), false).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = radio("activity", "ride", coach.Ride.Label(), false).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = radio("activity", "walk", coach.Walk.Label(), false).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "
How hard") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = radio("intensity", "easy", "😌 Easy", false).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = radio("intensity", "moderate", "🙂 Moderate", true).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = radio("intensity", "hard", "🔥 Hard", false).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "
How long ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + for _, m := range []int{30, 45, 60, 90, 120} { + templ_7745c5c3_Err = radio("minutes", fmt.Sprint(m), minutesLabel(m), m == 60).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +func radio(name, value, label string, checked bool) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var4 := templ.GetChildren(ctx) + if templ_7745c5c3_Var4 == nil { + templ_7745c5c3_Var4 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +func Error(msg string) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var8 := templ.GetChildren(ctx) + if templ_7745c5c3_Var8 == nil { + templ_7745c5c3_Var8 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "
⚠️ ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var9 string + templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(msg) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/views.templ`, Line: 92, Col: 37} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +// PlanView is the weather, the scores, the coach's advice, routes and the +// follow-up chat. +func PlanView(p coach.Plan, msgs []session.Message) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var10 := templ.GetChildren(ctx) + if templ_7745c5c3_Var10 == nil { + templ_7745c5c3_Var10 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = WeatherCard(p).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = Scores(p).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + for _, m := range msgs { + if m.Role == "assistant" && m.State != session.Done { + templ_7745c5c3_Err = StreamingReply(m).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = MessageView(m).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if p.Assessment.Pick != coach.Indoor { + if p.RoutesLoaded { + templ_7745c5c3_Err = RoutesCard(p).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "

🗺️ Routes nearby

Planning loops from your door and looking up signposted routes…

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + } + return nil + }) +} + +func WeatherCard(p coach.Plan) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var11 := templ.GetChildren(ctx) + if templ_7745c5c3_Var11 == nil { + templ_7745c5c3_Var11 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + f := p.Forecast + desc, _ := weather.Describe(f.Now.Code) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var12 string + templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(f.Now.Icon()) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/views.templ`, Line: 138, Col: 36} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var13 string + templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%.0f°", f.Now.Temp)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/views.templ`, Line: 139, Col: 57} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var14 string + templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(desc) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/views.templ`, Line: 141, Col: 23} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, ", feels like ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var15 string + templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%.0f°", f.Now.Feels)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/views.templ`, Line: 141, Col: 83} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var16 string + templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(placeName(p.Place)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/views.templ`, Line: 142, Col: 43} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, " · ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var17 string + templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(f.Now.Time.Format("Mon 15:04")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/views.templ`, Line: 142, Col: 81} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, " · via ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var18 string + templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(f.Source) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/views.templ`, Line: 142, Col: 101} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "
  • 💨 ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var19 string + templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%.0f km/h %s", f.Now.Wind, weather.Compass(f.Now.WindDirection))) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/views.templ`, Line: 146, Col: 91} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, ", gusts ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var20 string + templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(gustsLabel(f)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/views.templ`, Line: 146, Col: 116} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var21 string + templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%.0f", f.Now.Gusts)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/views.templ`, Line: 146, Col: 152} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "
  • 🌧️ ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var22 string + templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%.1f mm", f.Now.Precip)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/views.templ`, Line: 147, Col: 53} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, " · ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var23 string + templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinStringErrs(f.Now.Chance()) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/views.templ`, Line: 147, Col: 75} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, " chance
  • 🌇 sunset ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var24 string + templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(f.Sunset.Format("15:04")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/views.templ`, Line: 148, Col: 45} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "
    ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + for _, h := range nextHours(f, 8) { + var templ_7745c5c3_Var25 = []any{templ.KV("night", !h.IsDay)} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var25...) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "
  1. ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var27 string + templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinStringErrs(h.Time.Format("15h")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/views.templ`, Line: 153, Col: 47} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var28 string + templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinStringErrs(h.Icon()) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/views.templ`, Line: 154, Col: 21} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var28)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var29 string + templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%.0f°", h.Temp)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/views.templ`, Line: 155, Col: 42} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var30 string + templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinStringErrs(h.Chance()) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/views.templ`, Line: 156, Col: 36} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var31 string + templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%.0f", h.Gusts)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/views.templ`, Line: 157, Col: 55} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "
  2. ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +func Scores(p coach.Plan) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var32 := templ.GetChildren(ctx) + if templ_7745c5c3_Var32 == nil { + templ_7745c5c3_Var32 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + a := p.Assessment + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + for _, s := range a.Scores { + var templ_7745c5c3_Var33 = []any{"score", s.Verdict(), templ.KV("picked", s.Activity == a.Pick)} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var33...) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var36 string + templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.JoinStringErrs(s.Activity.Label()) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/views.templ`, Line: 169, Col: 43} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var36)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var37 string + templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprint(s.Value)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/views.templ`, Line: 170, Col: 44} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var37)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var38 string + templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.JoinStringErrs(s.Verdict()) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/views.templ`, Line: 171, Col: 38} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var38)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + var templ_7745c5c3_Var39 = []any{"score", "indoor", templ.KV("picked", a.Pick == coach.Indoor)} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var39...) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var41 string + templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.JoinStringErrs(coach.Indoor.Label()) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/views.templ`, Line: 175, Col: 44} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var41)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "
🏋️
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if a.Pick == coach.Indoor { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "today") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "plan B") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if a.Later != nil { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "

⏰ Better at ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var42 string + templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.JoinStringErrs(a.Later.Start.Format("15:04")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/views.templ`, Line: 187, Col: 72} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var42)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, ": ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var43 string + templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.JoinStringErrs(a.Later.Activity.Noun()) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/views.templ`, Line: 187, Col: 110} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var43)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, " scores ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var44 string + templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprint(a.Later.Score)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/views.templ`, Line: 187, Col: 147} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var44)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, ".

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + if why := pickedReasons(p); why != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "

Why: ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var45 string + templ_7745c5c3_Var45, templ_7745c5c3_Err = templ.JoinStringErrs(why) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/views.templ`, Line: 190, Col: 35} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var45)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + return nil + }) +} + +func RoutesCard(p coach.Plan) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var46 := templ.GetChildren(ctx) + if templ_7745c5c3_Var46 == nil { + templ_7745c5c3_Var46 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "

🗺️ Routes

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if len(p.Routes) == 0 { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if p.RoutesErr != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "The route services are busy, so no routes this time. Try again in a minute.") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "No ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var47 string + templ_7745c5c3_Var47, templ_7745c5c3_Err = templ.JoinStringErrs(p.Assessment.Pick.Noun()) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/views.templ`, Line: 202, Col: 34} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var47)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, " routes found close by. An out-and-back from your door works too.") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "
    ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + for i, s := range p.Routes { + var templ_7745c5c3_Var49 = []any{templ.KV("active", i == 0)} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var49...) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "
  1. ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if s.Route.Generated { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "from your door ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "signposted ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + var templ_7745c5c3_Var52 string + templ_7745c5c3_Var52, templ_7745c5c3_Err = templ.JoinStringErrs(s.Route.Name) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/views.templ`, Line: 216, Col: 21} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var52)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "
    ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if s.Route.Generated { + var templ_7745c5c3_Var53 string + templ_7745c5c3_Var53, templ_7745c5c3_Err = templ.JoinStringErrs(loopDetails(p, s.Route)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/views.templ`, Line: 220, Col: 33} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var53)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + var templ_7745c5c3_Var54 string + templ_7745c5c3_Var54, templ_7745c5c3_Err = templ.JoinStringErrs(s.Describe()) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/views.templ`, Line: 222, Col: 22} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var54)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, " ≈ ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var55 string + templ_7745c5c3_Var55, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%.1f km", s.TotalKm)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/views.templ`, Line: 222, Col: 64} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var55)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, " · ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var56 string + templ_7745c5c3_Var56, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%.1f km", s.FromYouKm)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/views.templ`, Line: 222, Col: 107} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var56)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, " from you") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, "
    ⬇ GPX ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if !s.Route.Generated { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "🧭 To the start OSM") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, "
  2. ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + if j := p.Junction; j != nil { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 85, "

📍 Nearest ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var60 string + templ_7745c5c3_Var60, templ_7745c5c3_Err = templ.JoinStringErrs(junctionKind(j)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/views.templ`, Line: 237, Col: 53} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var60)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 86, " junction: ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var61 string + templ_7745c5c3_Var61, templ_7745c5c3_Err = templ.JoinStringErrs(j.Ref) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/views.templ`, Line: 237, Col: 92} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var61)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 87, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var62 string + templ_7745c5c3_Var62, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%.1f km", j.Km)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/views.templ`, Line: 237, Col: 132} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var62)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 88, " away

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 89, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +// MessageView renders a finished chat message. +func MessageView(m session.Message) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var63 := templ.GetChildren(ctx) + if templ_7745c5c3_Var63 == nil { + templ_7745c5c3_Var63 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + var templ_7745c5c3_Var64 = []any{"msg", m.Role} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var64...) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 90, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if m.Role == "assistant" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 93, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = Markdown(m.Content).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 94, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if m.Err != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 95, "

⚠️ ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var67 string + templ_7745c5c3_Var67, templ_7745c5c3_Err = templ.JoinStringErrs(m.Err) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/views.templ`, Line: 250, Col: 35} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var67)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 96, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 97, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var68 string + templ_7745c5c3_Var68, templ_7745c5c3_Err = templ.JoinStringErrs(m.Content) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/views.templ`, Line: 253, Col: 33} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var68)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 98, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 99, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +// Exchange is a follow-up question plus its streaming reply. +func Exchange(user, reply session.Message) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var69 := templ.GetChildren(ctx) + if templ_7745c5c3_Var69 == nil { + templ_7745c5c3_Var69 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = MessageView(user).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = StreamingReply(reply).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +// StreamingReply opens an SSE connection. "token" events append text as it +// arrives; the final "done" event swaps in the rendered Markdown. +func StreamingReply(m session.Message) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var70 := templ.GetChildren(ctx) + if templ_7745c5c3_Var70 == nil { + templ_7745c5c3_Var70 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 100, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +func Token(text string) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var73 := templ.GetChildren(ctx) + if templ_7745c5c3_Var73 == nil { + templ_7745c5c3_Var73 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 103, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var74 string + templ_7745c5c3_Var74, templ_7745c5c3_Err = templ.JoinStringErrs(text) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/views.templ`, Line: 281, Col: 13} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var74)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 104, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +// HealthInfo describes the model server status shown in the header. +type HealthInfo struct { + Checking bool + OK bool + Model string + Err string +} + +func Health(h HealthInfo) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var75 := templ.GetChildren(ctx) + if templ_7745c5c3_Var75 == nil { + templ_7745c5c3_Var75 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 105, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if h.Checking { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 107, " connecting…") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else if h.OK { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 108, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var77 string + templ_7745c5c3_Var77, templ_7745c5c3_Err = templ.JoinStringErrs(h.Model) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/views.templ`, Line: 297, Col: 41} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var77)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 109, " model offline") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 111, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +var _ = templruntime.GeneratedTemplate diff --git a/scripts/e2e.py b/scripts/e2e.py new file mode 100644 index 0000000..dd8f8dc --- /dev/null +++ b/scripts/e2e.py @@ -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")