diff --git a/.claude/skills/beacon-collection/SKILL.md b/.claude/skills/beacon-collection/SKILL.md index ab9e335..0f0a5c8 100644 --- a/.claude/skills/beacon-collection/SKILL.md +++ b/.claude/skills/beacon-collection/SKILL.md @@ -1,6 +1,6 @@ --- name: beacon-collection -description: How Beacon's collector reaches upstreams and what it does with the results — per-source proxy opt-in and why direct is the default, batched sources sharing one fetch (the 20 Yahoo rows), the Open-Meteo weather provider with its retry policy and alert edge semantics, and the source-health alerting that reports a source gone silent. Load before touching cmd/collector, internal/tools/rateextractor, internal/application/collection, internal/infrastructure/weather, notification.SourceHealthAgent, any rate_sources row or seed migration, anything involving BEACON_PROXY_URL or options.use_proxy, weather alert kinds, or the rain/thaw/heat/frost latches. +description: How Beacon's collector reaches upstreams and what it does with the results — per-source proxy opt-in and why direct is the default, batched sources sharing one fetch (the 20 Yahoo rows), the Open-Meteo weather provider with its retry policy and alert edge semantics, the 16-day long-range forecast on its own daily gate and the content-gated outlook digest, and the source-health alerting that reports a source gone silent. Load before touching cmd/collector, internal/tools/rateextractor, internal/application/collection, internal/infrastructure/weather, notification.SourceHealthAgent, any rate_sources row or seed migration, anything involving BEACON_PROXY_URL or options.use_proxy, weather alert kinds, the rain/thaw/heat/frost latches, collection.WeatherForecastAgent, OpenMeteo.Forecast or ForecastRange, or the forecast_outlook notify kind and its notify_state signature. --- # Beacon collection @@ -120,6 +120,86 @@ waiting still fits inside `weatherGeoTimeout`, the 5 s deadline the Mini App cit puts on the *same* client. Raising the budget means moving that deadline in the same change, and `TestRetryScheduleFitsTheTightestCaller` is what says so out loud. +## The long-range forecast + +`WeatherForecastAgent` (collector, its own runner beside `WeatherAgent`) stores 16 daily +rows per subscribed location in `weather_forecast_days`. Everything about it is deliberately +separate from the current-conditions path. + +- **A separate HTTP request, not a wider one.** `OpenMeteo.ForecastRange` issues its own + call; `Forecast` and `decodeOpenMeteoForecast` are untouched. `Forecast` decodes daily + index `[0]`, and that index *is* today for the morning summary and for `alert_heat`, + `alert_frost`, `alert_thunderstorm` and `alert_thaw` — all four read `obs.TempMax` / + `TempMin` / `WeatherCode`. Widening the request or the decode risks shifting what those + five things mean with nothing to report it. The second request costs about one weighted + API call per location per day against a budget of 10,000. It still routes through + `OpenMeteo.get`, so it inherits the retry policy unchanged. +- **The decode is bounded, and the bound is not the clock.** + `decodeOpenMeteoForecastRange` truncates at `domain.WeatherOutlookHorizonDays` and drops + any date past a window measured from the response's *own* first date — plus an absolute + one-year ceiling, the only guard against a permanent row, since retention deletes the past + and nothing prunes the far future. Two invariants rest on this and neither is re-checked + downstream: the table has no archive tier because it is bounded at locations × 16, and a + whole fetch goes into one `BEGIN IMMEDIATE`, so an oversized response holds the WAL write + lock against the notifier and the web server for the length of the insert. The window is + anchored to the response rather than to `time.Now()` because forecast dates are city-local + while the clock is UTC — and on a host with no battery-backed RTC, a boot before time + synchronisation would otherwise filter a good response down to nothing. A `daily[]` that + yields no storable row is an **error**, never an empty success: reported as success it + leaves `captured_at` unmoved, so the daily gate never closes and the location is re-fetched + every tick behind a log line reading `fetched=1 failed=0`. This is the path a later + ensemble source swap would inherit. +- **The gate is a UTC calendar day, not 24 elapsed hours.** Against an hourly cron, "at + least 24 h since the last capture" drifts an hour later every day and eventually lands + after the subscriber's notify hour, so the digest would read a forecast a day older than + it needed to be. A calendar day pins the fetch to the first tick after midnight UTC. +- **Retention keeps one day of slack.** `RemoveForecastDaysBefore` runs on every tick + whatever the fetches did, with a cutoff of yesterday UTC: offsets run from −12 to +14, so + one extra day is what makes "past" unambiguous for every subscriber. +- **The units are not interchangeable.** Open-Meteo reports `rain_sum` in **millimetres** + and `snowfall_sum` in **centimetres**. A rain day is ≥ 1 mm, a snow day ≥ 1 cm; the two + thresholds are numerically equal and dimensionally different, so a single shared constant + is a bug. +- **The bar is not `> 0`.** Models smear small amounts across most days of a long-range run, + so at any trace above zero nearly every day of a 16-day window comes back wet. + +## The outlook digest is content-gated, not latched + +`forecast_outlook` is the one notify kind outside the latch model entirely: it is absent +from `alertKinds`, `UsesForecastDateCap` is false for it, and it never reaches +`EvaluateLatched`. A day two weeks out changes its mind several times before it arrives, so +a latch per condition would either send every flip or, with a dead band wide enough to stop +that, say nothing at all. + +Instead `WeatherCheckAgent.runOutlookPhase` compares `domain.WeatherOutlook.Signature()` +against the stored `weather_user_cities.notify_state` *reduced to the days still ahead*, and +queues a message only when the two differ. Four properties are load-bearing: + +- **The stored signature is pruned to today's window before it is compared or rendered** + (`domain.PruneWeatherOutlookSignature`); the freshly built one is stored unpruned. A + signature spans days strictly after the baseline and the baseline advances every morning, + so the day that becomes today leaves the new signature on its own, with nothing in the + forecast having changed. Compared raw, that reads as a change: the gate sends, and the diff + reports the arriving day as *cleared* — telling a reader on the morning it rains that the + rain day cleared. It also fires one message per roll-off day, which in a wet week is the + daily-whatever-happens digest the content gate exists to prevent. A second content-gated + kind modelled on this one inherits the trap. +- **The cursor advances on every evaluation that had data**, not only on a send. That is + what bounds the digest at one message per city per local day *regardless of how often the + collector refreshes the forecast underneath it* — a guarantee the fetch cadence must not + be able to take away by changing. +- **An empty signature means "never evaluated"** and is distinct from an evaluated outlook + with nothing in it, which encodes as the version prefix alone (`o1:`). The distinction is + what keeps a first digest from opening with "nothing to report". +- **A day is notable if it brings rain, snow, or a change in the freezing regime** relative + to the last classified day before it. Reporting every cold day would fill a Kazakh winter + digest with the fact that February is cold; what the reader needs is the day the regime + turns. + +The `o1:` prefix on the signature exists so a future change to the encoding re-notifies +every subscriber exactly once rather than diffing two encodings that do not mean the same +thing. Bump it when the encoding changes meaning. + ## Weather alert edge semantics Every alert kind is edge-triggered through the per-row `alert_latched` boolean, and diff --git a/.claude/skills/beacon-http-api/SKILL.md b/.claude/skills/beacon-http-api/SKILL.md index e992d4b..b5af184 100644 --- a/.claude/skills/beacon-http-api/SKILL.md +++ b/.claude/skills/beacon-http-api/SKILL.md @@ -1,6 +1,6 @@ --- name: beacon-http-api -description: Beacon's HTTP surface and browser client — endpoint contracts that are not obvious from the router code (chart period whitelist, weather city create validation, the forced alert rows and their 409, liveness vs readiness), the content-hashed WASM asset URLs and the nginx location ordering they depend on, and the Mini App's 2x2 screen navigation. Load before adding or changing anything under internal/gateway, cmd/web, cmd/wasm, cmd/web/static, configs/nginx.*, or any /api/v1/me or /api/v1/public route. +description: Beacon's HTTP surface and browser client — endpoint contracts that are not obvious from the router code (chart period whitelist, weather city create validation, the forced alert rows and their 409, the multi-week days array on /weather/current, liveness vs readiness), the content-hashed WASM asset URLs and the nginx location ordering they depend on, and the Mini App's 2x2 screen navigation. Load before adding or changing anything under internal/gateway, cmd/web, cmd/wasm, cmd/web/static, configs/nginx.*, dto.WeatherCurrentItem or WeatherForecastDayItem, the forecast_outlook subscription kind, or any /api/v1/me or /api/v1/public route. --- # Beacon HTTP API and Mini App @@ -69,6 +69,33 @@ deployment that must publish the port passes `--bind 0.0.0.0` explicitly. Binding loopback is not what stops a *co-hosted* vhost reaching Beacon — that neighbour proxies over loopback too. Only the port or the neighbour's upstream settles that. +## The multi-week outlook rides on `/current` + +`GET /api/v1/me/weather/current` carries a `days` array per city: one entry per city-local +calendar day from today, ascending, at most `domain.WeatherOutlookHorizonDays` (16) of them. +There is no separate forecast endpoint and no second screen — the Mini App's navigation is a +2×2 matrix (below) and a fifth cell would break it, and one round trip beats two on a phone. + +- **`days` is omitted, never sent as `[]`.** A location whose first long-range fetch has not + completed renders exactly as it did before the field existed. +- **It is independent of `has_data`.** The reading and the outlook are collected on + different cadences (hourly against once a day), so a city can hold either without the + other; the field is attached before the `has_data` gate for that reason. +- **The verdicts are the server's, not the client's.** `rain` and `snow` are booleans + resolved against the day thresholds (≥ 1 mm of rain, ≥ 1 cm of snowfall — different + units), `zero_state` is one of `above` / `crossing` / `below` / `""`, and `label` is the + date pre-formatted as `Sun 23 Aug`. All three are computed server-side so every client + draws the same badge from the same rule and the WASM bundle still needs no tzdata. +- **`""` for `zero_state` means the day carried no usable pair of temperature bounds.** It + is not a fourth category and must not be rendered as one. + +The `forecast_outlook` subscription that turns this into a Telegram digest is **opt-in**, +unlike the two forced kinds below: it is listed in the manage screen's kind dropdown like +heat and frost. Its numeric input is the **hour picker**, not a threshold — it is timed +rather than thresholded, exactly like `morning_summary`. The 16-day *view*, by contrast, +appears for every tracked city whether or not the digest is subscribed, because collection +is driven by the distinct subscribed locations and is kind-agnostic. + ## Forced weather subscriptions `alert_thaw` and `rain_alert` are **forced, system-managed rows**. Creating any city diff --git a/.claude/skills/beacon-storage/SKILL.md b/.claude/skills/beacon-storage/SKILL.md index f46c48d..eab5750 100644 --- a/.claude/skills/beacon-storage/SKILL.md +++ b/.claude/skills/beacon-storage/SKILL.md @@ -1,6 +1,6 @@ --- name: beacon-storage -description: Beacon's SQLite storage rules beyond the basics — the hot/archive tiering of rate_values and execution_history (why one file, why reads UNION both tiers and writes touch only hot, roll-over, retention, VACUUM), the migrator contract and the immutable migration filename convention, columns that look droppable but are not, and how to read production data out of a gzipped snapshot. Load before writing or reviewing any query in internal/repository or internal/infrastructure/sqlitedb, adding or altering a migration under ./migrations, touching collection.MaintenanceAgent, sqlitedb.Migrator, Transaction/ReadOnlyTransaction, RetainRateSource, rate_source_health or weather_observations, or inspecting the production database. +description: Beacon's SQLite storage rules beyond the basics — the hot/archive tiering of rate_values and execution_history (why one file, why reads UNION both tiers and writes touch only hot, roll-over, retention, VACUUM), the migrator contract and the immutable migration filename convention, columns that look droppable but are not, why weather_forecast_days is bounded rather than tiered, why historical migration tests must not seed through a repository, and how to read production data out of a gzipped snapshot. Load before writing or reviewing any query in internal/repository or internal/infrastructure/sqlitedb, adding or altering a migration under ./migrations, touching collection.MaintenanceAgent, sqlitedb.Migrator, Transaction/ReadOnlyTransaction, RetainRateSource, rate_source_health, weather_observations, weather_forecast_days or RetainWeatherForecastDays, writing a test against stubSQLiteDBThrough, or inspecting the production database. --- # Beacon storage @@ -90,6 +90,37 @@ through `const` declarations (e.g. `rateSourceTableName`, `rateSourceNameFieldNa schema rename surfaces at compile time and via `grep`, never via a runtime "no such column" error. +### `weather_forecast_days` is bounded, not tiered + +The long-range forecast table is a **bounded working set**, `locations × 16` rows, upserted +in place on the natural key `(location_id, provider, forecast_date)`. The tiering rule above +governs append-only telemetry and does not apply here: there is nothing an `*_archive` twin +could hold, no roll-over, and no reason for a read to union two branches. Do not "fix" that. + +Three things about it that are decisions rather than omissions: + +- **A whole fetch is one transaction.** `RetainWeatherForecastDays` writes all sixteen rows + under one `BEGIN`: a day's forecast is a single observation of the future, and the write + lock is taken at `BEGIN` (`_txlock=immediate`), so sixteen transactions would take and + release it sixteen times per location against three processes sharing the file. +- **Retention is keyed on `forecast_date`, never on `captured_at`.** Rows are superseded + while the day is still ahead and dropped once it is behind. A `captured_at` sweep — which + is what `weather_observations` uses — would delete a still-future day the moment its + location stopped being refreshed. +- **No foreign key to `weather_user_cities`.** A location whose last subscriber leaves stops + being refreshed and ages out within the horizon; cascading would tie the lifetime of + public meteorological data to one user's subscription row. + +### Historical migration tests must not go through a repository + +`weatherusercity_backfill_test.go` and `weatherusercity_backfillrain_test.go` exercise +migrations 021 and 026 against a snapshot of the schema **as it was when those migrations +were written** (`stubSQLiteDBThrough`), because both reference columns that later migrations +drop. Seeding or reading such a snapshot through `WeatherUserCityRepository` fails on every +column added afterwards: its SQL always names the current schema. Use +`seedHistoricalWeatherUserCity` / `obtainHistoricalWeatherUserCities` in `main_test.go`, +whose column list (`weatherUserCityEraColumns`) is frozen to that era on purpose. + ### Two columns a migration must not "clean up" - **`weather_observations.provider`** now only ever holds `'open-meteo'`, so it reads as diff --git a/CLAUDE.md b/CLAUDE.md index 55ee865..48bdadc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -13,8 +13,8 @@ for its subject — this file only keeps the tripwire. | Skill | Load before touching | |---|---| -| `beacon-collection` | `cmd/collector`, `rateextractor`, `application/collection`, `infrastructure/weather`, `SourceHealthAgent`, `rate_sources` rows, `BEACON_PROXY_URL` / `options.use_proxy`, weather alert kinds | -| `beacon-storage` | any `internal/repository` query, any `./migrations/*.sql`, `MaintenanceAgent`, `sqlitedb.Migrator`, reading the production database | +| `beacon-collection` | `cmd/collector`, `rateextractor`, `application/collection`, `infrastructure/weather`, `SourceHealthAgent`, `rate_sources` rows, `BEACON_PROXY_URL` / `options.use_proxy`, weather alert kinds, `ForecastRange`, `forecast_outlook` | +| `beacon-storage` | any `internal/repository` query, any `./migrations/*.sql`, `MaintenanceAgent`, `sqlitedb.Migrator`, `weather_forecast_days`, reading the production database | | `beacon-http-api` | `internal/gateway`, `cmd/web`, `cmd/wasm`, `cmd/web/static`, `configs/nginx.*`, any `/api/v1/me` or `/api/v1/public` route | | `beacon-forecasting` | `internal/tools/rateforecaster`, `internal/tools/rateanomaly` (load with `knowledge:forecasting`) | | `beacon-data-privacy` | any new column on a user-scoped table, anything captured from a Telegram update, any new log field | @@ -83,6 +83,10 @@ proxied: `BEACON_PROXY_URL` says a proxy exists, `rate_sources.options.use_proxy source wants it. No source is opted in today, and the default is a measured decision (issue #16) — do not reverse it casually. Chromedp and weather stay direct regardless. +**Never widen `OpenMeteo.Forecast`'s `daily` block.** Its index `[0]` *is* today for the +morning summary and all four daily-metric latches. The multi-week fetch is a separate call +(`ForecastRange`, its own table, its own daily cadence) for exactly that reason. + > `cmd/doctor` is the operator-only umbrella for LLM rule (re)generation and source auditing (`rulegen` single/`--all`, `audit --all`/`--source`). Usage, exit codes, and env vars: `cmd/doctor/README.md` + godoc. ### Layer Responsibilities @@ -179,6 +183,11 @@ rewrites those rows wholesale (`cmd/doctor rulegen` does exactly that), so a col there is destroyed by an unrelated config write — which is why the source-health latch lives in its own `rate_source_health` table. +**Long-range forecast rows belong in `weather_forecast_days`, never in +`weather_observations`**: the collector sweeps that table by `captured_at` at 48 h on every +tick, so a row describing a day two weeks out is gone a day and a half after it is written, +without an error anywhere. + **`rate_values` and `execution_history` are tiered.** Each has an `*_archive` twin in the same file: reads must span both via `UNION ALL`, writes touch hot only. Getting this wrong returns partial history without erroring. Schema lives at `./migrations/*.sql` and applied diff --git a/cmd/collector/main.go b/cmd/collector/main.go index 021cbb3..0978f19 100644 --- a/cmd/collector/main.go +++ b/cmd/collector/main.go @@ -94,6 +94,10 @@ func main() { if err != nil { log.Fatalf("repositories: %s", err.Error()) } + weatherForecastRepo, err := repository.NewWeatherForecastDayRepository(db) + if err != nil { + log.Fatalf("repositories: %s", err.Error()) + } metaRepo, err := repository.NewServiceMetaRepository(db) if err != nil { log.Fatalf("repositories: %s", err.Error()) @@ -112,7 +116,7 @@ func main() { runners, err := buildRunners( sourceRepo, historyRepo, rateValueRepo, - weatherCityRepo, weatherObsRepo, + weatherCityRepo, weatherObsRepo, weatherForecastRepo, l.WriterAs(internal.LogLevelWarning), ) if err != nil { @@ -262,6 +266,7 @@ func buildRunners( value *repository.RateValueRepository, weatherCity *repository.WeatherUserCityRepository, weatherObs *repository.WeatherObservationRepository, + weatherForecast *repository.WeatherForecastDayRepository, logger io.Writer, ) ([]runner, error) { // Passing the proxy URL does not route anything through it: the extractor builds a @@ -280,17 +285,21 @@ func buildRunners( return nil, errors.Join(err, loginjector.NewTraceError()) } - weatherAgent, err := wireWeather(weatherCity, weatherObs, logger) + weatherAgents, err := wireWeather(weatherCity, weatherObs, weatherForecast, logger) if err != nil { return nil, errors.Join(err, loginjector.NewTraceError()) } - return []runner{collectionRateAgent, weatherAgent}, nil + return append([]runner{collectionRateAgent}, weatherAgents...), nil } -// wireWeather constructs the Open-Meteo weather collection agent. Open-Meteo is -// hardcoded and always on — there is no per-provider config table and no "inactive" -// state, so this always returns a non-nil runner. An agent construction failure is fatal +// wireWeather constructs the two Open-Meteo weather collection agents: current conditions +// on an hourly throttle, and the multi-week daily forecast on a once-per-UTC-day gate. They +// share one provider instance and are separate runners because their cadences and their +// tables are different; see the type comments in the collection package. +// +// Open-Meteo is hardcoded and always on — there is no per-provider config table and no +// "inactive" state, so this always returns runners. An agent construction failure is fatal // and returned as an error. // // Direct, like the rate sources, which also makes this consistent with cmd/web: the @@ -299,8 +308,9 @@ func buildRunners( func wireWeather( weatherCity *repository.WeatherUserCityRepository, weatherObs *repository.WeatherObservationRepository, + weatherForecast *repository.WeatherForecastDayRepository, logger io.Writer, -) (runner, error) { +) ([]runner, error) { openMeteoProvider, err := weatherinfra.NewOpenMeteo("", logger) if err != nil { return nil, errors.Join(fmt.Errorf("weather: open-meteo provider: %w", err), loginjector.NewTraceError()) @@ -314,5 +324,14 @@ func wireWeather( if err != nil { return nil, errors.Join(fmt.Errorf("weather: agent: %w", err), loginjector.NewTraceError()) } - return weatherAgent, nil + + forecastAgent, err := collection.NewWeatherForecastAgent( + openMeteoProvider, weatherCity, weatherForecast, + logger, + ) + if err != nil { + return nil, errors.Join(fmt.Errorf("weather: forecast agent: %w", err), loginjector.NewTraceError()) + } + + return []runner{weatherAgent, forecastAgent}, nil } diff --git a/cmd/collector/main_test.go b/cmd/collector/main_test.go index 6908f2e..bd6a57b 100644 --- a/cmd/collector/main_test.go +++ b/cmd/collector/main_test.go @@ -21,20 +21,25 @@ import ( func TestWireWeather(t *testing.T) { t.Parallel() - t.Run("always builds a runner", func(t *testing.T) { + t.Run("always builds both runners", func(t *testing.T) { t.Parallel() // Repos are constructed with a nil db and never Run in this test — they only need - // to be non-nil so NewWeatherAgent's required-arg check passes and wireWeather - // returns the assembled runner. Open-Meteo is hardcoded always-on: there is no - // "inactive" state to test. + // to be non-nil so the agents' required-arg checks pass and wireWeather returns the + // assembled runners. Open-Meteo is hardcoded always-on: there is no "inactive" + // state to test. cityRepo, err := repository.NewWeatherUserCityRepository(nil) require.NoError(t, err) obsRepo, err := repository.NewWeatherObservationRepository(nil) require.NoError(t, err) + forecastRepo, err := repository.NewWeatherForecastDayRepository(nil) + require.NoError(t, err) - agent, err := wireWeather(cityRepo, obsRepo, nil) + agents, err := wireWeather(cityRepo, obsRepo, forecastRepo, nil) require.NoError(t, err) - assert.NotNil(t, agent, "Open-Meteo is hardcoded always-on and must always produce a weather runner") + require.Len(t, agents, 2, "current conditions and the long-range forecast are separate runners") + for i, agent := range agents { + assert.NotNil(t, agent, "Open-Meteo is hardcoded always-on and must always produce runner %d", i) + } }) t.Run("weather collection is direct", func(t *testing.T) { @@ -47,10 +52,12 @@ func TestWireWeather(t *testing.T) { require.NoError(t, err) obsRepo, err := repository.NewWeatherObservationRepository(nil) require.NoError(t, err) + forecastRepo, err := repository.NewWeatherForecastDayRepository(nil) + require.NoError(t, err) - agent, err := wireWeather(cityRepo, obsRepo, nil) + agents, err := wireWeather(cityRepo, obsRepo, forecastRepo, nil) require.NoError(t, err) - assert.NotNil(t, agent) + assert.NotEmpty(t, agents) }) } @@ -70,10 +77,12 @@ func TestWeatherIgnoresProxyEnv(t *testing.T) { require.NoError(t, err) obsRepo, err := repository.NewWeatherObservationRepository(nil) require.NoError(t, err) + forecastRepo, err := repository.NewWeatherForecastDayRepository(nil) + require.NoError(t, err) - agent, err := wireWeather(cityRepo, obsRepo, nil) + agents, err := wireWeather(cityRepo, obsRepo, forecastRepo, nil) require.NoError(t, err, "a proxy setting in the environment must be inert for weather") - assert.NotNil(t, agent) + assert.NotEmpty(t, agents) } // TestProxyEnvAloneDoesNotRouteCollection pins the deploy-time half of the two-level diff --git a/cmd/notifier/main.go b/cmd/notifier/main.go index 7c5c600..9e85109 100644 --- a/cmd/notifier/main.go +++ b/cmd/notifier/main.go @@ -102,6 +102,10 @@ func main() { if err != nil { log.Fatalf("repositories: %s", err.Error()) } + weatherForecastRepo, err := repository.NewWeatherForecastDayRepository(db) + if err != nil { + log.Fatalf("repositories: %s", err.Error()) + } historyRepo, err := repository.NewExecutionHistoryRepository(db) if err != nil { log.Fatalf("repositories: %s", err.Error()) @@ -133,6 +137,7 @@ func main() { weatherCheckAgent, err := notification.NewWeatherCheckAgent( weatherCityRepo, weatherObsRepo, + weatherForecastRepo, eventRepo, l.WriterAs(internal.LogLevelWarning), ) diff --git a/cmd/wasm/application/me_weather_cities.go b/cmd/wasm/application/me_weather_cities.go index 0cf727b..a34f076 100644 --- a/cmd/wasm/application/me_weather_cities.go +++ b/cmd/wasm/application/me_weather_cities.go @@ -266,11 +266,12 @@ func (p *MeWeatherCitiesPage) SavePendingAlert(ctx context.Context) error { NotifyKind: p.state.AlertFormKind, } - // morning_summary reuses the numeric form input as a 0–23 local hour, not a - // threshold. Blank → omit NotifyHour so the server applies its default (07:00). - // A non-numeric hour is rejected here rather than POSTed as garbage; the 0–23 - // range itself is validated server-side (single source of truth). - if p.state.AlertFormKind == "morning_summary" { + // morning_summary and forecast_outlook reuse the numeric form input as a 0–23 + // local hour, not a threshold: both are timed rather than thresholded. Blank → + // omit NotifyHour so the server applies its default (07:00). A non-numeric hour is + // rejected here rather than POSTed as garbage; the 0–23 range itself is validated + // server-side (single source of truth). + if p.state.AlertFormKind == "morning_summary" || p.state.AlertFormKind == "forecast_outlook" { if hourStr := strings.TrimSpace(p.state.AlertFormValue); hourStr != "" { hour, err := strconv.Atoi(hourStr) if err != nil { diff --git a/cmd/wasm/ui/me_weather_cities.go b/cmd/wasm/ui/me_weather_cities.go index fa46727..5b63ae8 100644 --- a/cmd/wasm/ui/me_weather_cities.go +++ b/cmd/wasm/ui/me_weather_cities.go @@ -283,6 +283,8 @@ func alertKindLabel(kind, conditionValue string, notifyHour int) string { return "Thaw alert" case "rain_alert": return fmt.Sprintf("Rain alert ≥ %s%% within 6h", conditionValue) + case "forecast_outlook": + return fmt.Sprintf("Outlook digest · %02d:00", notifyHour) default: // morning_summary or empty return fmt.Sprintf("Morning summary · %02d:00", notifyHour) } @@ -309,6 +311,7 @@ func renderWeatherAlertForm(state application.WeatherCitiesState) string { {"alert_frost", "Frost alert (°C)"}, {"alert_thunderstorm", "Thunderstorm alert"}, {"rain_alert", "Rain alert (%)"}, + {"forecast_outlook", "Outlook digest (daily)"}, } for _, k := range kinds { selected := "" @@ -320,13 +323,14 @@ func renderWeatherAlertForm(state application.WeatherCitiesState) string { b.WriteString(``) // Numeric input; its meaning depends on the selected kind: - // - morning_summary: a local hour 0–23 (blank → server default 07:00); + // - morning_summary and forecast_outlook: a local hour 0–23 (blank → server + // default 07:00), since both are timed rather than thresholded; // - heat/frost/rain: the numeric threshold; // - thunderstorm/thaw: no numeric input at all. switch state.AlertFormKind { case "alert_thunderstorm", "alert_thaw": // No numeric input. - case "morning_summary": + case "morning_summary", "forecast_outlook": fmt.Fprintf(&b, ``, diff --git a/cmd/wasm/ui/me_weather_current.go b/cmd/wasm/ui/me_weather_current.go index 9964b03..b81a58a 100644 --- a/cmd/wasm/ui/me_weather_current.go +++ b/cmd/wasm/ui/me_weather_current.go @@ -6,6 +6,7 @@ import ( "github.com/seilbekskindirov/beacon/cmd/wasm/application" "github.com/seilbekskindirov/beacon/cmd/wasm/dom" + "github.com/seilbekskindirov/beacon/internal/domain" "github.com/seilbekskindirov/beacon/internal/dto" ) @@ -60,6 +61,71 @@ func renderWeatherCurrentTopbar() string { `` } +// renderWeatherForecastStrip emits the multi-week outlook as a horizontally scrollable row +// of one chip per day. It answers the three questions the screen exists for at a glance: +// rain or not, snow or not, and where the day sits against freezing. +// +// A dry day still gets a chip. The reader has to be able to tell a dry day from a wet one, +// and a strip that only showed the wet days would leave them counting gaps. +// +// Every server string goes through dom.Escape; numeric fields render only when their +// pointer is non-nil, so a day the provider had no answer for shows its date and nothing +// invented. An empty outlook emits nothing at all. +func renderWeatherForecastStrip(days []dto.WeatherForecastDayItem) string { + if len(days) == 0 { + return "" + } + + var b strings.Builder + b.WriteString(`
`) + for _, day := range days { + fmt.Fprintf(&b, `
`, dom.Escape(zeroStateClass(day.ZeroState))) + fmt.Fprintf(&b, `
%s
`, dom.Escape(day.Label)) + + b.WriteString(`
`) + switch { + case day.Rain && day.Snow: + b.WriteString(`🌧❄`) + case day.Rain: + b.WriteString(`🌧`) + case day.Snow: + b.WriteString(`❄`) + default: + // A non-breaking space keeps the dry chips the same height as the wet ones, + // so the strip does not jump row to row. + b.WriteString(` `) + } + b.WriteString(`
`) + + fmt.Fprintf(&b, `
%s
`, domain.ParseWeatherZeroState(day.ZeroState).Symbol()) + + if day.TempMax != nil && day.TempMin != nil { + fmt.Fprintf(&b, `
%.0f° / %.0f°
`, *day.TempMax, *day.TempMin) + } + + if day.Rain && day.RainSum != nil { + fmt.Fprintf(&b, `
%.1f mm
`, *day.RainSum) + } else if day.Snow && day.SnowfallSum != nil { + fmt.Fprintf(&b, `
%.1f cm
`, *day.SnowfallSum) + } + + b.WriteString(`
`) + } + b.WriteString(`
`) + return b.String() +} + +// zeroStateClass maps the zero_state token to the chip modifier class, going through the +// domain enum so the classes and the glyphs cannot drift apart the way two hand-written +// tables already had. An unrecognised value falls back to "unknown": Label only ever returns +// a token this build knows, so a class name can never be injected through it. +func zeroStateClass(zeroState string) string { + if label := domain.ParseWeatherZeroState(zeroState).Label(); label != "" { + return label + } + return "unknown" +} + // renderWeatherCurrentCard emits one city weather card. When HasData is false, // a "data not yet available" placeholder is shown instead of the numeric fields. // All string fields from the server are escaped; emoji fields pass through unchanged @@ -73,6 +139,10 @@ func renderWeatherCurrentCard(item dto.WeatherCurrentItem) string { if !item.HasData { b.WriteString(`

Data not yet available.

`) + // The outlook and the current reading are collected on different cadences, so a + // city can hold one without the other. Returning here without the strip would + // hide a whole screen of forecast over an unrelated absence. + b.WriteString(renderWeatherForecastStrip(item.Days)) b.WriteString(``) return b.String() } @@ -134,6 +204,8 @@ func renderWeatherCurrentCard(item dto.WeatherCurrentItem) string { fmt.Fprintf(&b, `
Updated: %s
`, dom.Escape(item.CapturedAt)) } + b.WriteString(renderWeatherForecastStrip(item.Days)) + b.WriteString(``) return b.String() } diff --git a/cmd/wasm/ui/me_weather_current_test.go b/cmd/wasm/ui/me_weather_current_test.go index 08e4ca7..b8f71db 100644 --- a/cmd/wasm/ui/me_weather_current_test.go +++ b/cmd/wasm/ui/me_weather_current_test.go @@ -1,6 +1,7 @@ package ui_test import ( + "strings" "testing" "github.com/stretchr/testify/assert" @@ -172,3 +173,89 @@ func TestRenderMeWeatherCurrent(t *testing.T) { assert.NotContains(t, html, "weather-current-feels") }) } + +func TestRenderMeWeatherCurrentForecastStrip(t *testing.T) { + t.Parallel() + + render := func(days []dto.WeatherForecastDayItem, hasData bool) string { + return ui.RenderMeWeatherCurrent(application.WeatherCurrentState{Items: []dto.WeatherCurrentItem{{ + LocationID: "1234", + DisplayName: "Astana", + Timezone: "Asia/Almaty", + HasData: hasData, + TempCurrent: ptrOf(21.5), + Days: days, + }}}) + } + + t.Run("renders one chip per day with its badges and temperatures", func(t *testing.T) { + t.Parallel() + html := render([]dto.WeatherForecastDayItem{ + {Date: "2026-08-21", Label: "Fri 21 Aug", TempMax: ptrOf(21.5), TempMin: ptrOf(11.2), ZeroState: "above"}, + {Date: "2026-08-22", Label: "Sat 22 Aug", TempMax: ptrOf(2.0), TempMin: ptrOf(-3.0), Rain: true, RainSum: ptrOf(4.2), ZeroState: "crossing"}, + {Date: "2026-08-23", Label: "Sun 23 Aug", TempMax: ptrOf(-2.0), TempMin: ptrOf(-9.0), Snow: true, SnowfallSum: ptrOf(3.5), ZeroState: "below"}, + }, true) + + require.Contains(t, html, "weather-forecast-strip") + assert.Equal(t, 3, strings.Count(html, `class="weather-forecast-day`)) + assert.Contains(t, html, "Fri 21 Aug") + assert.Contains(t, html, "weather-forecast-above") + assert.Contains(t, html, "weather-forecast-crossing") + assert.Contains(t, html, "weather-forecast-below") + assert.Contains(t, html, "4.2 mm") + assert.Contains(t, html, "3.5 cm") + assert.Contains(t, html, "▲") + assert.Contains(t, html, "↕") + assert.Contains(t, html, "▼") + }) + + t.Run("a dry day still gets a chip so wet days are countable", func(t *testing.T) { + t.Parallel() + html := render([]dto.WeatherForecastDayItem{ + {Date: "2026-08-21", Label: "Fri 21 Aug", TempMax: ptrOf(21.5), TempMin: ptrOf(11.2), ZeroState: "above"}, + }, true) + + assert.Contains(t, html, "Fri 21 Aug") + assert.Contains(t, html, "22° / 11°", "the chip rounds to whole degrees") + assert.NotContains(t, html, "mm") + }) + + t.Run("no outlook emits no strip at all", func(t *testing.T) { + t.Parallel() + html := render(nil, true) + assert.NotContains(t, html, "weather-forecast-strip") + }) + + t.Run("the strip survives a city with no reading yet", func(t *testing.T) { + t.Parallel() + html := render([]dto.WeatherForecastDayItem{ + {Date: "2026-08-22", Label: "Sat 22 Aug", Rain: true, RainSum: ptrOf(4.2), ZeroState: "above"}, + }, false) + + assert.Contains(t, html, "weather-current-nodata") + assert.Contains(t, html, "weather-forecast-strip") + assert.Contains(t, html, "Sat 22 Aug") + }) + + t.Run("a day with no bounds shows neither a temperature nor a guessed zero state", func(t *testing.T) { + t.Parallel() + html := render([]dto.WeatherForecastDayItem{ + {Date: "2026-08-22", Label: "Sat 22 Aug"}, + }, true) + + assert.Contains(t, html, "weather-forecast-unknown") + assert.Contains(t, html, "—") + assert.NotContains(t, html, "weather-forecast-temp") + }) + + t.Run("server-supplied strings are escaped", func(t *testing.T) { + t.Parallel() + html := render([]dto.WeatherForecastDayItem{ + {Date: "x", Label: ``, ZeroState: `" onload="x`}, + }, true) + + assert.NotContains(t, html, "` + wet := domain.WeatherForecastDay{ForecastDate: "2026-08-23", TempMax: fptr(22.0), TempMin: fptr(14.6), RainSum: fptr(1.3)} + + msg, err := RenderForecastOutlook(hostile, outlookOf(wet), "") + require.NoError(t, err) + assert.NotContains(t, msg, "