From 6274073dda3e85b33266570ade3d1ad59165c556 Mon Sep 17 00:00:00 2001 From: prorochestvo Date: Fri, 21 Aug 2026 13:24:19 +0500 Subject: [PATCH 01/22] docs(plans): plan the long-range weather outlook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue #127 asks for a rain/snow/0 °C view over several weeks plus a warning channel. The owner settled the four open decisions: 16 days on the existing endpoint now with the 35-day ensemble left as a later source swap, a 1 mm rain and 1 cm snow bar, a three-state zero axis, a once-per-day refresh, and notifications for far days delivered as a content-gated daily digest rather than per-flip alerts. Refs: #127 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016yN2eof4KJTh8oAwnoE8rg --- plans/017-long-range-weather-outlook.md | 324 ++++++++++++++++++++++++ 1 file changed, 324 insertions(+) create mode 100644 plans/017-long-range-weather-outlook.md diff --git a/plans/017-long-range-weather-outlook.md b/plans/017-long-range-weather-outlook.md new file mode 100644 index 0000000..182afad --- /dev/null +++ b/plans/017-long-range-weather-outlook.md @@ -0,0 +1,324 @@ +# Task Breakdown + +## Overview + +Issue #127 asks for a weather view that answers three questions per day — rain or not, snow +or not, above or below 0 °C — over a horizon of several weeks, plus a warning channel for +the same information. Reconnaissance (posted on the issue) established that Open-Meteo's +`v1/forecast` endpoint Beacon already calls returns all four required daily variables for +**16 days**, that the 35-day ensemble is the only honest route past that, and that the +seasonal APIs cannot serve this at all (no daily precipitation aggregate). + +The owner settled the four open decisions on 2026-08-21: + +1. **16 days now**, with the 35-day ensemble left as a later source swap behind the same + table rather than a second parallel feature. +2. **Thresholds**: a rain day is `rain_sum ≥ 1.0 mm`, a snow day is `snowfall_sum ≥ 1.0 cm` + (Open-Meteo returns rain in mm and snowfall in cm — verified live), and the 0 °C axis is + **three-state**, not binary: above (`min > 0`), crossing (`min ≤ 0 < max`), below + (`max ≤ 0`). +3. **Notify as well as view.** Far-day forecasts flip before the day arrives, so the + notification is a **once-per-day digest gated on content change**, not a per-flip alert. +4. **Refresh once a day.** + +This plan delivers: a new `weather_forecast_days` table with its own retention, a separate +Open-Meteo request that does not touch the existing decoder, a collector agent on a +daily gate, a `forecast_outlook` notification kind that sends only when the outlook actually +changes, a 16-day strip on the existing weather view screen, and the documentation each of +those needs. + +## Assumptions + +- **Open-Meteo returns 16 daily rows with the needed variables.** Verified live on + 2026-08-21 for Astana: `forecast_days=16` with + `daily=temperature_2m_max,temperature_2m_min,rain_sum,snowfall_sum,precipitation_sum,precipitation_probability_max,weather_code` + returned 16 dates (`2026-08-21` → `2026-09-05`) and `daily_units` of + `{"rain_sum":"mm","snowfall_sum":"cm","temperature_2m_max":"°C"}`. **Snowfall is + centimetres, rain is millimetres** — the two thresholds are not the same unit and must not + be collapsed into one constant. +- **`daily[0]` semantics are preserved by construction, not by care.** The long-range fetch + is a **new provider method issuing its own HTTP request** (`ForecastRange`); neither + `OpenMeteo.Forecast` nor `decodeOpenMeteoForecast` is modified. The morning summary and the + four daily-metric latches keep reading exactly the bytes they read today. The cost is one + extra request per location per day, which the free tier does not notice (16 days × 7 + variables ≈ 1 weighted call; the budget is 10,000/day). +- **The 48 h sweep cannot reach the new rows.** `RemoveWeatherObservationsOlderThan` + (`cmd/collector/main.go:158`) deletes from `weather_observations` by `captured_at`. The new + table is a different table with its own `forecast_date`-keyed retention. +- **The new table is not tiered.** `beacon-storage`'s hot/archive rule governs append-only + telemetry (`rate_values`, `execution_history`). `weather_forecast_days` is a bounded + working set — `locations × 16` rows, upserted in place, with past days deleted — so it has + no archive twin and no `UNION ALL` read. Stating this here so the rule is not + cargo-culted onto it in review. +- **`rate_sources`' "no runtime state on a config table" rule does not apply**, because + nothing rewrites `weather_user_cities` rows wholesale: the subscription upsert path + deliberately preserves `last_notified_at` and `alert_latched` (see `beacon-http-api`, + "Subscription upserts rewrite settings, never cursors"). The new `notify_state` column + joins those two under the same rule. +- **Privacy classification.** `weather_forecast_days` is keyed on `location_id` and holds + public meteorological numbers — no user column at all. `weather_user_cities.notify_state` + holds a derived signature of that public forecast, and is system-managed dedup state of + exactly the same nature as the existing `alert_latched` and `last_notified_at`. Neither is + on the `beacon-data-privacy` off-limits list and neither is identity-adjacent; no policy + change is needed. +- **The outlook kind is opt-in, not forced.** `alert_thaw` and `rain_alert` are forced onto + every tracked city; `forecast_outlook` is added by the user like heat and frost. The + 16-day **view**, by contrast, appears for every tracked city, because collection is driven + by `ObtainDistinctWeatherLocations` and is kind-agnostic. Making the digest forced later is + a one-line change in `Service.ensureForcedKinds`. +- **The Mini App keeps its 2×2 navigation.** No fifth screen: the strip renders inside the + existing `RenderMeWeatherCurrent` city card, and the days ride on the existing + `GET /api/v1/me/weather/current` response so the screen keeps its single round trip. + +## Tasks + +### Task 1: `weather_forecast_days` table and repository + +- Description: add `migrations/202608.033.weather_forecast_days.table_initiate.sql` and + `internal/repository/weatherforecastday.go`. Columns: `id`, `location_id`, `provider`, + `forecast_date`, `captured_at`, `temp_max`, `temp_min`, `rain_sum`, `snowfall_sum`, + `precip_sum`, `precip_prob_max`, `weather_code`. `UNIQUE (location_id, provider, + forecast_date)` plus an index on `(location_id, provider, forecast_date)` for the range + read. Repository methods: `RetainWeatherForecastDays` (batch `INSERT … ON CONFLICT DO + UPDATE` inside one write transaction), `ObtainForecastDays(ctx, locationID, provider, + fromDate string, limit int)`, `ObtainLatestForecastCapture(ctx, locationID, provider)`, and + `RemoveForecastDaysBefore(ctx, date string)`. Column and table names go through `const` + declarations like every other repository file. +- Acceptance Criteria: + - Re-running the same day's fetch updates rows in place; the row count for one location + stays at 16 after repeated writes. + - Writes use `r.db.Transaction`, reads use `r.db.ReadOnlyTransaction`; a whole day's 16 + rows are written in **one** transaction, not sixteen. + - `ObtainLatestForecastCapture` returns `internal.ErrNotFound` when the location has never + been fetched. + - `CheckUP` exists and matches the pattern of the sibling repositories. +- Pitfalls & edge cases: the migration number must be the next free global counter (033 — + 032 is the last applied); an applied filename is immutable. Do not add a foreign key to + `weather_user_cities` — a location outliving its last subscriber must age out by date, not + cascade. +- Complexity: Medium + +### Task 2: Domain — forecast day, thresholds, and the outlook + +- Description: add `internal/domain/weatherforecast.go` with `WeatherForecastDay`, the two + threshold constants (`WeatherRainDayThresholdMM = 1.0`, `WeatherSnowDayThresholdCM = 1.0`), + a `WeatherZeroState` enum (`Unknown` / `Above` / `Crossing` / `Below`), the predicates + `IsRainDay`, `IsSnowDay`, `ZeroState`, and a `WeatherOutlook` built from an ordered day + slice that exposes `NotableDays()` and `Signature()`. + - **Notable** means: a rain day, **or** a snow day, **or** a day whose zero-state differs + from the calendar-previous day's. The third clause is what keeps a Kazakh winter from + reporting "below zero" on all 15 days: what a reader needs is the day the regime + *changes*, not the fact that February is cold. + - The outlook is built over `[today … today+15]` but reports only days **after** today — + today is present solely as the zero-state baseline for day 1, and today is already + covered by the morning summary and the existing alerts. + - `Signature()` is a deterministic, compact, versioned string: + `o1::;:` where flags are `R`/`S` (in that fixed order) followed + by one of `+ ~ - ?`. The `o1:` prefix means a future format change re-notifies once + instead of diffing two incomparable encodings. An outlook with no notable days has + signature `"o1:"` — non-empty, and therefore distinguishable from `""`, which means + "never evaluated". +- Acceptance Criteria: + - A day with `rain_sum = 0.9` is not a rain day; `1.0` is. A day with `snowfall_sum = 0.9` + is not a snow day; `1.0` is. + - `ZeroState` returns `Unknown` when either bound is nil, and never guesses. + - Signature is stable across two calls on equal input and changes when any notable day's + flags change. + - Table-driven tests cover: all-clear, rain-only, snow-only, a zero-crossing run + (above → crossing → below → crossing → above yields exactly the transition days), and + nil temperature bounds. +- Pitfalls & edge cases: the two thresholds carry different units — a shared constant is a + bug. Days must be sorted by date before the previous-day comparison; the repository returns + them ordered, but the domain must not depend on that silently. +- Complexity: Medium + +### Task 3: `OpenMeteo.ForecastRange` + +- Description: add `ForecastRange(ctx, lat, lng float64) ([]domain.WeatherForecastDay, + error)` to `internal/infrastructure/weather/openmeteo.go`, requesting + `daily=temperature_2m_max,temperature_2m_min,rain_sum,snowfall_sum,precipitation_sum,precipitation_probability_max,weather_code` + with `timezone=auto` and `forecast_days=openMeteoForecastRangeDays` (16), decoded by a new + `decodeOpenMeteoForecastRange`. It routes through the existing `o.get`, so it inherits the + 5-attempt retry policy, the 429 exclusion and the backoff cap unchanged. +- Acceptance Criteria: + - `Forecast`, `decodeOpenMeteoForecast` and the existing `forecast_days=2` request are + **byte-for-byte unmodified**; `git diff` on this task shows only additions to the file. + - The decoder tolerates short or ragged arrays: a `daily` block whose optional arrays are + shorter than `time` yields nil pointers for the missing entries rather than panicking. + - Existing `openmeteo_test.go` cases pass untouched; new cases cover a full 16-day + payload, an empty `daily` block, and a truncated variable array. +- Pitfalls & edge cases: `timezone=auto` makes `daily.time[i]` a **city-local** calendar + date; store the string verbatim and never re-derive it from a UTC instant. +- Complexity: Medium + +### Task 4: `WeatherForecastAgent` on a once-per-day gate + +- Description: add `internal/application/collection/weatherforecastagent.go` — a one-shot + agent iterating `ObtainDistinctWeatherLocations`, skipping any location whose newest stored + `captured_at` falls on the **current UTC calendar day**, fetching `ForecastRange` for the + rest and upserting the 16 rows. Per-location failure isolation and the detached + `context.Background()` write, both for the same reasons documented on `WeatherAgent`. Run + ends with an unconditional `RemoveForecastDaysBefore(yesterdayUTC)` and a proof-of-execution + line: `weather forecast: fetched=… skipped=… failed=… total=…`. Wire it into + `buildRunners` in `cmd/collector/main.go`. +- Acceptance Criteria: + - A second `Run` in the same UTC day fetches nothing and logs `skipped` for every location. + - The first `Run` of a new UTC day fetches every location. + - One location failing its fetch does not prevent the others from being stored, and the + joined error names the failing location. + - Retention deletes `forecast_date < (UTC today − 1 day)` — one day of slack so no city's + still-current local day is ever deleted out from under it, whatever its UTC offset. +- Pitfalls & edge cases: the gate is a **calendar-day** comparison, not `now.Sub(last) ≥ + 24h`; the latter drifts an hour per day against an hourly cron and eventually lands after + the user's notify hour. Do not fold this into `WeatherAgent` — its hourly throttle and this + daily one answer different questions, and merging them puts the current-conditions fetch at + risk for no gain. +- Complexity: Medium + +### Task 5: The `forecast_outlook` notification kind + +- Description: + - `migrations/202608.034.weather_user_cities.add_notify_state.sql`: + `ADD COLUMN notify_state TEXT NOT NULL DEFAULT ''`, plus + `SetWeatherNotifyState(ctx, id, state)` on the city repository and the field on + `domain.WeatherUserCity`. + - `domain.WeatherNotifyForecastOutlook = "forecast_outlook"`: accepted by `Validate` with + an empty `ConditionValue`, **not** an alert kind (absent from + `weathercheckagent.go`'s `alertKinds` slice, `UsesForecastDateCap` returns false, no + `EvaluateLatched` path). + - A third phase in `WeatherCheckAgent.Run`: load cities of this kind, gate on + `IsMorningDue` (reusing `NotifyHour` and `last_notified_at`), read the location's forecast + days, build the outlook, compare `Signature()` with the stored `notify_state`, and queue a + rendered digest as a `domain.RateUserEvent` only when they differ. On a successful + enqueue, write `notify_state` and advance `last_notified_at`; when the signature is + unchanged, advance `last_notified_at` alone. + - `RenderForecastOutlook(city, outlook, prevSignature)` in `weatherrender.go`, matching the + existing Telegram HTML style: escaped city name, one line per notable day + (`Fri 23 Aug — 🌧 1.3 mm · ▲ +22.4 / ▼ +13.6`), a `🆕` marker on days that are new or + changed against `prevSignature` (suppressed when `prevSignature` is empty, so a first + digest is not a wall of markers), and a trailing `cleared:` line naming days that dropped + out. +- Acceptance Criteria: + - At most **one** digest per city per local day, whatever the tick rate: `last_notified_at` + advances on every evaluation that had data, not only on a send. + - An unchanged outlook sends nothing. + - A city whose `notify_state` is `""` and whose outlook is empty stores the signature and + sends nothing — a first contact reporting "nothing to report" is noise. + - An outlook that goes from notable to empty **does** send ("no rain, snow or freezing + transitions in the next 15 days"), because that is a real change. + - No forecast rows for the location yet → skip **without** advancing, so the first digest + fires once collection catches up (same rule the morning-summary phase already follows). + - A failed enqueue leaves both `notify_state` and `last_notified_at` untouched. + - Existing morning-summary and alert-phase tests still pass unmodified. +- Pitfalls & edge cases: the window and the "today" baseline are computed in the **city's** + timezone, not UTC; a `LoadLocation` failure is logged and skipped exactly as the + morning-summary phase does. The digest must not be added to `alertKinds` — that loop + evaluates against a single `WeatherObservation` and would mis-handle this kind silently. +- Complexity: Hard + +### Task 6: DTO, service and handler — days on the weather view + +- Description: add `dto.WeatherForecastDayItem` (`date`, `label`, `temp_max`, `temp_min`, + `rain_sum`, `snowfall_sum`, `rain`, `snow`, `zero_state`, `weather_code`, + `condition_emoji`) and hang `Days []WeatherForecastDayItem \`json:"days,omitempty"\`` off + `WeatherCurrentItem`. `weather.Service.ObtainMeCurrent` loads the forecast days per + distinct location alongside the observation it already loads; the handler maps them, with + `label` (`"Fri 23 Aug"`) and `zero_state` (`"above"` / `"crossing"` / `"below"` / + `""`) resolved **server-side**, matching how `SunriseLocal` is already handled so the WASM + client needs no tzdata. +- Acceptance Criteria: + - A city with no stored forecast returns `has_data` as today and simply omits `days`; + the client renders the card exactly as it does now. + - The window starts at the city's local today and returns at most 16 entries. + - A location that errors on the forecast read fails the whole list only for a + non-`ErrNotFound` error, matching the existing observation-loading contract. + - No new route: the endpoint stays `GET /api/v1/me/weather/current` under `MePrefix`, so + the authenticated mount and the 404-not-403 ownership rule are inherited unchanged. +- Pitfalls & edge cases: `days` must be `omitempty` — an always-present empty array would + change the wire shape for every existing client state. +- Complexity: Medium + +### Task 7: The 16-day strip in the Mini App + +- Description: render the strip inside `renderWeatherCurrentCard` + (`cmd/wasm/ui/me_weather_current.go`) as a horizontally scrollable row of day chips — + weekday/date label, a 🌧 and/or ❄ badge, the zero indicator (`▲` above, `↕` crossing, `▼` + below), and max/min. Add the matching CSS to the inline stylesheet in + `cmd/web/static/index.html`, next to the existing `.weather-current-*` block and using the + same `--tg-theme-*` variables. Add `forecast_outlook` to the alert-kind dropdown, the + label map and the no-threshold branch in `cmd/wasm/ui/me_weather_cities.go`. +- Acceptance Criteria: + - Every server string passes through `dom.Escape`; numeric fields render only when their + pointer is non-nil. + - A day with neither rain nor snow still renders its chip with the temperature and zero + indicator — the acceptance criteria require the reader to distinguish precipitation days + from dry ones, which needs both to be visible. + - The strip scrolls within the card without widening the page; the section rail and the + manage gear keep their current geometry. + - Adding a `forecast_outlook` subscription from the manage screen hides the threshold + input. +- Pitfalls & edge cases: the weather screen replaces `#app` innerHTML on every redraw, so any + new interactive control must be delegated from `#app`, never bound to a chip node. +- Complexity: Medium + +### Task 8: Documentation + +- Description: one line in `CLAUDE.md` for the tripwire that does not announce itself — the + daily-sweep/tiering distinction of the new table — and the depth into the skills: + `beacon-collection` gains the daily forecast gate and the separate-request rationale, + `beacon-storage` gains `weather_forecast_days` and its retention, `beacon-http-api` gains + the `days` extension and the opt-in-vs-forced distinction for the new kind. Update the + `dto` godoc listing the notify kinds (three places name them today). +- Acceptance Criteria: + - `CLAUDE.md` stays under 20k chars (`wc -c` before and after, not an estimate). + - Every kind list in the codebase that enumerates notify kinds mentions the new one: + `internal/dto/weather.go` godoc, `routes.go` where relevant, the WASM dropdown. +- Complexity: Easy + +## Execution Order + +1. Task 1 — table and repository (everything else stores or reads through it) +2. Task 2 — domain types and classification (no dependencies; parallel with 1 in principle) +3. Task 3 — `ForecastRange` (needs Task 2's type) +4. Task 4 — collector agent (needs 1 and 3) +5. Task 5 — notification kind and digest (needs 1, 2 and rows from 4) +6. Task 6 — DTO, service, handler (needs 1 and 2) +7. Task 7 — Mini App strip (needs 6) +8. Task 8 — documentation (last, once the shapes are settled) + +## Risks + +- **The 16-day tail is not trustworthy and the UI must not pretend otherwise.** Beyond + roughly day 10 a deterministic run is barely better than climatology. This plan ships a + badge for all 16 days because the acceptance criteria ask for one; the honest mitigation is + the ensemble swap behind the same table, and the day-11+ chips should be visually muted + when that lands. Flagged, not solved. +- **Digest fatigue.** One message per city per day *when something changed* is bounded, but a + volatile week can still mean seven messages. If that grates, the cheapest dial is to + restrict notable days to a shorter sub-window (say the next 7) while the view keeps all 16. +- **An extra daily request per location** on a keyless, IP-limited API. At today's scale + (a handful of locations) this is noise against a 10,000/day budget; at a hundred locations + it is still noise. It only matters if the ensemble swap multiplies it. +- **`make lint` may not run on this machine** (8 GB Pi, no swap, golangci-lint OOM-killed + twice at 1.44 GB). Check `free -m` first; fall back to an `s_*` tag, which runs the gate + against no host. + +## Trade-offs + +- **A second HTTP request instead of widening the existing one.** Widening `Forecast` to 16 + days and decoding the whole array would save a request and put the day-0 semantics of the + morning summary and four latches at risk of a silent change. The extra request costs + approximately nothing and removes the risk entirely rather than guarding against it. +- **Digest over per-day alerts.** A per-day alert needs per-(city, date, kind) latch state + and still floods when a distant day oscillates. The digest needs one column and is bounded + at one message per city per day by construction. What it gives up is immediacy: a change at + 14:00 is reported the next morning. +- **Three-state zero axis instead of the binary the issue proposed.** "Above/below" is what + the issue asked for; "crossing" is the state that actually matters underfoot, and it is + free to compute. The existing `alert_thaw` keeps its own binary `TempMax`-keyed convention + for today — the two coexist because they answer different questions, and the plan does not + touch that evaluator. +- **No new screen.** The strip rides on the existing weather view, keeping the 2×2 navigation + and one round trip, at the cost of a taller card. +- **No archive tier for the new table.** It is bounded and upserted, so tiering would add a + union read and a roll-over step to protect against growth that cannot happen. From 77e0d0e25694ca228d36a285115f65c62c664b4a Mon Sep 17 00:00:00 2001 From: prorochestvo Date: Fri, 21 Aug 2026 13:37:40 +0500 Subject: [PATCH 02/22] feat(weather): store the long-range daily forecast Adds weather_forecast_days with its own retention and the domain types that classify a day: a rain day at 1 mm, a snow day at 1 cm (Open-Meteo reports rain in millimetres and snowfall in centimetres), and a three-state position against freezing. The table is separate from weather_observations because that one is swept every collector tick by captured_at at 48 hours, which would delete a day two weeks out long before it arrived. It is not tiered either: the rows are a bounded working set upserted in place on (location, provider, forecast_date), so there is nothing an archive twin could hold. An outlook reports only the days worth telling a user about: rain, snow, or a change in the freezing regime. Reporting every cold day would fill a winter digest with the fact that February is cold. Refs: #127 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016yN2eof4KJTh8oAwnoE8rg --- internal/domain/identity/identity.go | 2 + internal/domain/identity/identity_test.go | 1 + internal/domain/weatherforecast.go | 308 ++++++++++++++++++ internal/domain/weatherforecast_test.go | 273 ++++++++++++++++ internal/repository/weatherforecastday.go | 286 ++++++++++++++++ .../repository/weatherforecastday_test.go | 236 ++++++++++++++ ...3.weather_forecast_days.table_initiate.sql | 39 +++ 7 files changed, 1145 insertions(+) create mode 100644 internal/domain/weatherforecast.go create mode 100644 internal/domain/weatherforecast_test.go create mode 100644 internal/repository/weatherforecastday.go create mode 100644 internal/repository/weatherforecastday_test.go create mode 100644 migrations/202608.033.weather_forecast_days.table_initiate.sql diff --git a/internal/domain/identity/identity.go b/internal/domain/identity/identity.go index 02a17e0..d20c2f8 100644 --- a/internal/domain/identity/identity.go +++ b/internal/domain/identity/identity.go @@ -36,6 +36,8 @@ const ( KindWeatherUserCity Kind = "WUC" // KindWeatherObservation is the Kind for weather_observations entities. KindWeatherObservation Kind = "WOB" + // KindWeatherForecastDay is the Kind for weather_forecast_days entities. + KindWeatherForecastDay Kind = "WFD" ) // New returns a new unique string identifier for the given Kind, in the format: diff --git a/internal/domain/identity/identity_test.go b/internal/domain/identity/identity_test.go index ef9ce36..c927392 100644 --- a/internal/domain/identity/identity_test.go +++ b/internal/domain/identity/identity_test.go @@ -21,6 +21,7 @@ func TestNew(t *testing.T) { identity.KindExecutionHistory, identity.KindWeatherUserCity, identity.KindWeatherObservation, + identity.KindWeatherForecastDay, } t.Run("prefix matches kind", func(t *testing.T) { diff --git a/internal/domain/weatherforecast.go b/internal/domain/weatherforecast.go new file mode 100644 index 0000000..a8dd1a7 --- /dev/null +++ b/internal/domain/weatherforecast.go @@ -0,0 +1,308 @@ +package domain + +import ( + "slices" + "strings" + "time" +) + +const ( + // WeatherRainDayThresholdMM is the daily rain total, in millimetres, at which a day + // counts as a rain day. Open-Meteo reports rain_sum in millimetres. The bar sits at + // 1 mm rather than at any trace above zero because models smear small amounts across + // most days of a long-range run: at rain_sum > 0 almost every day of a 16-day window + // comes back wet, which tells the reader nothing. + WeatherRainDayThresholdMM = 1.0 + + // WeatherSnowDayThresholdCM is the daily snowfall total, in CENTIMETRES, at which a + // day counts as a snow day. Open-Meteo reports snowfall_sum in centimetres while + // rain_sum is in millimetres, so the two bars are numerically equal and dimensionally + // different — they must never be collapsed into one constant. + WeatherSnowDayThresholdCM = 1.0 + + // WeatherOutlookHorizonDays is the number of daily rows one long-range fetch covers, + // counting the current local day. 16 is the documented maximum of the Open-Meteo + // forecast endpoint (forecast_days), and every variable this feature needs exists over + // the whole of it. + WeatherOutlookHorizonDays = 16 +) + +// WeatherForecastDay is a single day of a long-range forecast for one location, as stored +// in weather_forecast_days. ForecastDate is the city-local calendar day (YYYY-MM-DD) the +// provider labelled the row with; it is stored verbatim and never re-derived from an +// instant. Every measurement is a pointer for the same reason WeatherObservation's are: a +// provider that omits a value must not read back as a zero, since zero is real data here. +type WeatherForecastDay struct { + ID string + LocationID string + Provider string // ProviderOpenMeteo — a literal data token, never translated + ForecastDate string // YYYY-MM-DD in the city-local timezone + CapturedAt time.Time + + TempMax *float64 // °C + TempMin *float64 // °C + RainSum *float64 // millimetres + SnowfallSum *float64 // centimetres + PrecipSum *float64 // millimetres, rain and snow-water combined + PrecipProbMax *int // percent, 0–100 + WeatherCode *int // raw WMO integer; resolve via WMOWeatherCode at render time +} + +// IsRainDay reports whether the day's rain total reaches WeatherRainDayThresholdMM. +// A nil RainSum is not a rain day: an absent measurement is not evidence of rain. +func (d WeatherForecastDay) IsRainDay() bool { + return d.RainSum != nil && *d.RainSum >= WeatherRainDayThresholdMM +} + +// IsSnowDay reports whether the day's snowfall total reaches WeatherSnowDayThresholdCM. +// A nil SnowfallSum is not a snow day, for the same reason as IsRainDay. +func (d WeatherForecastDay) IsSnowDay() bool { + return d.SnowfallSum != nil && *d.SnowfallSum >= WeatherSnowDayThresholdCM +} + +// ZeroState classifies the day against the freezing point. It reports +// WeatherZeroStateUnknown unless both bounds are present — a day with only one of them +// cannot be placed, and guessing would put a wrong badge on the screen with no way for the +// reader to tell. +func (d WeatherForecastDay) ZeroState() WeatherZeroState { + if d.TempMin == nil || d.TempMax == nil { + return WeatherZeroStateUnknown + } + switch { + case *d.TempMin > 0: + return WeatherZeroStateAbove + case *d.TempMax <= 0: + return WeatherZeroStateBelow + default: + return WeatherZeroStateCrossing + } +} + +// WeatherZeroState is a forecast day's position relative to the freezing point. It is +// three-valued rather than the "above or below" a thermometer reading suggests, because a +// day that starts below zero and ends above it is neither, and is the one that puts ice on +// the ground. +type WeatherZeroState uint8 + +const ( + // WeatherZeroStateUnknown means the day carries no usable pair of temperature bounds. + WeatherZeroStateUnknown WeatherZeroState = iota + // WeatherZeroStateAbove means the day never reached freezing (TempMin > 0 °C). + WeatherZeroStateAbove + // WeatherZeroStateCrossing means the day spans the freezing point (TempMin ≤ 0 < TempMax). + WeatherZeroStateCrossing + // WeatherZeroStateBelow means the day never rose above freezing (TempMax ≤ 0 °C). + WeatherZeroStateBelow +) + +// Label returns the wire token for the state, as carried by the JSON API. The empty string +// stands for WeatherZeroStateUnknown so an absent classification is omitted rather than +// rendered as a fourth category. +func (s WeatherZeroState) Label() string { + switch s { + case WeatherZeroStateAbove: + return "above" + case WeatherZeroStateCrossing: + return "crossing" + case WeatherZeroStateBelow: + return "below" + case WeatherZeroStateUnknown: + return "" + default: + return "" + } +} + +// Symbol returns the single-character indicator used in rendered output. +func (s WeatherZeroState) Symbol() string { + switch s { + case WeatherZeroStateAbove: + return "▲" + case WeatherZeroStateCrossing: + return "↕" + case WeatherZeroStateBelow: + return "▼" + case WeatherZeroStateUnknown: + return "?" + default: + return "?" + } +} + +// signatureToken returns the state's one-byte encoding inside an outlook signature. It is +// deliberately not Symbol: a signature is compared byte-for-byte across releases, so it +// uses ASCII that no font substitution or normalisation can touch. +func (s WeatherZeroState) signatureToken() string { + switch s { + case WeatherZeroStateAbove: + return "+" + case WeatherZeroStateCrossing: + return "~" + case WeatherZeroStateBelow: + return "-" + case WeatherZeroStateUnknown: + return "?" + default: + return "?" + } +} + +// WeatherOutlook is one location's long-range forecast, reduced to the days worth telling +// a user about. Construct it with NewWeatherOutlook. +type WeatherOutlook struct { + days []WeatherForecastDay + baseline string +} + +// NewWeatherOutlook builds an outlook over days for a reader standing on the local +// calendar day baseline (YYYY-MM-DD). days may arrive in any order and may hold dates +// outside the window; the constructor sorts a copy ascending and drops anything before +// baseline. +// +// The baseline day itself is kept but never reported. It is the anchor the first reported +// day's zero-state transition is measured against, and today is already covered by the +// morning summary and the same-day alerts — repeating it in a multi-week outlook would be +// the one line of the message the reader already knows. +func NewWeatherOutlook(days []WeatherForecastDay, baseline string) WeatherOutlook { + window := make([]WeatherForecastDay, 0, len(days)) + for _, d := range days { + if d.ForecastDate >= baseline { + window = append(window, d) + } + } + // Lexicographic order is chronological for YYYY-MM-DD, and the transition scan below + // depends on it. The repository already returns them ordered; sorting here means the + // invariant belongs to the type rather than to the caller that happened to satisfy it. + slices.SortFunc(window, func(a, b WeatherForecastDay) int { + return strings.Compare(a.ForecastDate, b.ForecastDate) + }) + return WeatherOutlook{days: window, baseline: baseline} +} + +// Days returns the whole window ascending by date, baseline day included. This is the +// view's input; NotableDays is the notification's. +func (o WeatherOutlook) Days() []WeatherForecastDay { + return o.days +} + +// NotableDays returns the days after the baseline that are worth reporting: a rain day, a +// snow day, or a day whose zero-state differs from the last classified day before it. +// +// The third clause is what keeps a continental winter from reporting "below zero" on every +// one of fifteen days. What a reader needs from the temperature axis is the day the regime +// changes; that it is cold in February is not news. A day with unknown bounds neither +// reports a transition nor resets the comparison, so a single gap between two below-zero +// days does not manufacture one. +func (o WeatherOutlook) NotableDays() []WeatherForecastDay { + notable := make([]WeatherForecastDay, 0, len(o.days)) + prev := WeatherZeroStateUnknown + for _, d := range o.days { + state := d.ZeroState() + transition := state != WeatherZeroStateUnknown && prev != WeatherZeroStateUnknown && state != prev + if state != WeatherZeroStateUnknown { + prev = state + } + if d.ForecastDate <= o.baseline { + continue + } + if d.IsRainDay() || d.IsSnowDay() || transition { + notable = append(notable, d) + } + } + return notable +} + +// Signature returns a compact, deterministic encoding of the notable days, used as the +// content gate for the daily outlook digest: the digest is sent when this differs from +// what was last sent and stays silent when it does not. +// +// The weatherOutlookSignatureVersion prefix means a future change to the encoding +// re-notifies every subscriber exactly once instead of diffing two encodings that do not +// mean the same thing. An outlook with nothing notable in it encodes as the prefix alone — +// non-empty, and therefore distinguishable from the empty string, which means "never +// evaluated". +func (o WeatherOutlook) Signature() string { + var b strings.Builder + b.WriteString(weatherOutlookSignatureVersion) + b.WriteByte(':') + for i, d := range o.NotableDays() { + if i > 0 { + b.WriteByte(';') + } + b.WriteString(d.ForecastDate) + b.WriteByte(':') + if d.IsRainDay() { + b.WriteByte('R') + } + if d.IsSnowDay() { + b.WriteByte('S') + } + b.WriteString(d.ZeroState().signatureToken()) + } + return b.String() +} + +// WeatherOutlookChange is how one outlook signature differs from an earlier one. +// Changed holds the forecast dates that are newly notable or notable for a different +// reason; Cleared holds the dates that were notable before and no longer are, ascending. +type WeatherOutlookChange struct { + Changed map[string]bool + Cleared []string +} + +// CompareWeatherOutlookSignatures reports how next differs from prev. +// +// An empty prev, or one written by a different signature version, yields no Changed and no +// Cleared entries: there is nothing meaningful to diff against, and marking every line of +// a first digest as new would make the marker useless on the message where it matters +// least. +func CompareWeatherOutlookSignatures(prev, next string) WeatherOutlookChange { + change := WeatherOutlookChange{Changed: map[string]bool{}} + before, ok := parseWeatherOutlookSignature(prev) + if !ok { + return change + } + after, ok := parseWeatherOutlookSignature(next) + if !ok { + return change + } + for date, flags := range after { + if before[date] != flags { + change.Changed[date] = true + } + } + for date := range before { + if _, still := after[date]; !still { + change.Cleared = append(change.Cleared, date) + } + } + slices.Sort(change.Cleared) + return change +} + +// weatherOutlookSignatureVersion prefixes every outlook signature. Bump it whenever the +// encoding below changes meaning: the mismatch is what forces one clean re-notification +// instead of a silently wrong diff. +const weatherOutlookSignatureVersion = "o1" + +// parseWeatherOutlookSignature splits a signature into date → flags. ok is false for an +// empty signature or one carrying a different version prefix, which the caller must treat +// as "no comparable previous state" rather than as an empty outlook. +func parseWeatherOutlookSignature(signature string) (map[string]string, bool) { + body, ok := strings.CutPrefix(signature, weatherOutlookSignatureVersion+":") + if !ok { + return nil, false + } + entries := map[string]string{} + if body == "" { + return entries, true + } + for _, part := range strings.Split(body, ";") { + date, flags, found := strings.Cut(part, ":") + if !found { + continue + } + entries[date] = flags + } + return entries, true +} diff --git a/internal/domain/weatherforecast_test.go b/internal/domain/weatherforecast_test.go new file mode 100644 index 0000000..9117d94 --- /dev/null +++ b/internal/domain/weatherforecast_test.go @@ -0,0 +1,273 @@ +package domain + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestWeatherForecastDay_IsRainDay(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + rain *float64 + want bool + }{ + {name: "nil is not a rain day", rain: nil, want: false}, + {name: "zero is not a rain day", rain: f(0), want: false}, + {name: "just under the bar is drizzle, not rain", rain: f(0.9), want: false}, + {name: "exactly at the bar counts", rain: f(1.0), want: true}, + {name: "well over the bar counts", rain: f(12.4), want: true}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + t.Parallel() + d := WeatherForecastDay{RainSum: c.rain} + assert.Equal(t, c.want, d.IsRainDay()) + }) + } +} + +func TestWeatherForecastDay_IsSnowDay(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + snow *float64 + want bool + }{ + {name: "nil is not a snow day", snow: nil, want: false}, + {name: "zero is not a snow day", snow: f(0), want: false}, + {name: "just under the bar is a dusting", snow: f(0.9), want: false}, + {name: "exactly at the bar counts", snow: f(1.0), want: true}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + t.Parallel() + d := WeatherForecastDay{SnowfallSum: c.snow} + assert.Equal(t, c.want, d.IsSnowDay()) + }) + } +} + +func TestWeatherForecastDay_ZeroState(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + min *float64 + max *float64 + want WeatherZeroState + wantLbl string + }{ + {name: "both nil is unknown", min: nil, max: nil, want: WeatherZeroStateUnknown, wantLbl: ""}, + {name: "min alone is unknown", min: f(-3), max: nil, want: WeatherZeroStateUnknown, wantLbl: ""}, + {name: "max alone is unknown", min: nil, max: f(3), want: WeatherZeroStateUnknown, wantLbl: ""}, + {name: "warm day is above", min: f(11.2), max: f(21.5), want: WeatherZeroStateAbove, wantLbl: "above"}, + {name: "frozen day is below", min: f(-14), max: f(-2), want: WeatherZeroStateBelow, wantLbl: "below"}, + {name: "max exactly zero is still below", min: f(-8), max: f(0), want: WeatherZeroStateBelow, wantLbl: "below"}, + {name: "min exactly zero crosses", min: f(0), max: f(5), want: WeatherZeroStateCrossing, wantLbl: "crossing"}, + {name: "straddling zero crosses", min: f(-4.5), max: f(2.1), want: WeatherZeroStateCrossing, wantLbl: "crossing"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + t.Parallel() + d := WeatherForecastDay{TempMin: c.min, TempMax: c.max} + assert.Equal(t, c.want, d.ZeroState()) + assert.Equal(t, c.wantLbl, d.ZeroState().Label()) + }) + } +} + +func TestWeatherOutlook_NotableDays(t *testing.T) { + t.Parallel() + + t.Run("an all-clear window reports nothing", func(t *testing.T) { + t.Parallel() + o := NewWeatherOutlook([]WeatherForecastDay{ + warmDay("2026-08-21"), warmDay("2026-08-22"), warmDay("2026-08-23"), + }, "2026-08-21") + assert.Empty(t, o.NotableDays()) + }) + + t.Run("the baseline day is never reported however wet it is", func(t *testing.T) { + t.Parallel() + today := warmDay("2026-08-21") + today.RainSum = f(9) + o := NewWeatherOutlook([]WeatherForecastDay{today, warmDay("2026-08-22")}, "2026-08-21") + assert.Empty(t, o.NotableDays()) + }) + + t.Run("rain and snow days are reported", func(t *testing.T) { + t.Parallel() + wet := warmDay("2026-08-23") + wet.RainSum = f(1.3) + snowy := warmDay("2026-08-25") + snowy.SnowfallSum = f(4.0) + o := NewWeatherOutlook([]WeatherForecastDay{ + warmDay("2026-08-21"), warmDay("2026-08-22"), wet, warmDay("2026-08-24"), snowy, + }, "2026-08-21") + got := o.NotableDays() + require.Len(t, got, 2) + assert.Equal(t, "2026-08-23", got[0].ForecastDate) + assert.Equal(t, "2026-08-25", got[1].ForecastDate) + }) + + t.Run("only the zero-state transitions are reported, not every cold day", func(t *testing.T) { + t.Parallel() + o := NewWeatherOutlook([]WeatherForecastDay{ + tempDay("2026-11-01", 2, 8), // baseline: above + tempDay("2026-11-02", 1, 6), // above, no change + tempDay("2026-11-03", -2, 4), // crossing <- transition + tempDay("2026-11-04", -3, 3), // crossing, no change + tempDay("2026-11-05", -9, -1), // below <- transition + tempDay("2026-11-06", -12, -4), // below, no change + tempDay("2026-11-07", -11, -3), // below, no change + tempDay("2026-11-08", -2, 5), // crossing <- transition + }, "2026-11-01") + got := o.NotableDays() + require.Len(t, got, 3) + assert.Equal(t, "2026-11-03", got[0].ForecastDate) + assert.Equal(t, "2026-11-05", got[1].ForecastDate) + assert.Equal(t, "2026-11-08", got[2].ForecastDate) + }) + + t.Run("a day with no bounds does not report a transition of its own", func(t *testing.T) { + t.Parallel() + gap := WeatherForecastDay{ForecastDate: "2026-11-03"} + o := NewWeatherOutlook([]WeatherForecastDay{ + tempDay("2026-11-01", -9, -1), // baseline: below + tempDay("2026-11-02", -8, -2), // below + gap, // unknown + tempDay("2026-11-04", -7, -3), // below again: still no transition + }, "2026-11-01") + assert.Empty(t, o.NotableDays()) + }) + + t.Run("a day with no bounds does not reset the comparison either", func(t *testing.T) { + t.Parallel() + gap := WeatherForecastDay{ForecastDate: "2026-11-03"} + o := NewWeatherOutlook([]WeatherForecastDay{ + tempDay("2026-11-01", -9, -1), // baseline: below + tempDay("2026-11-02", -8, -2), // below + gap, // unknown: the last classified state stays -1 below + tempDay("2026-11-04", -2, 6), // crossing, measured against the day before the gap + }, "2026-11-01") + got := o.NotableDays() + require.Len(t, got, 1) + assert.Equal(t, "2026-11-04", got[0].ForecastDate) + }) + + t.Run("days out of order and before the baseline are windowed away", func(t *testing.T) { + t.Parallel() + wet := warmDay("2026-08-23") + wet.RainSum = f(2) + stale := warmDay("2026-08-19") + stale.RainSum = f(5) + o := NewWeatherOutlook([]WeatherForecastDay{ + wet, stale, warmDay("2026-08-22"), warmDay("2026-08-21"), + }, "2026-08-21") + require.Len(t, o.Days(), 3) + assert.Equal(t, "2026-08-21", o.Days()[0].ForecastDate) + got := o.NotableDays() + require.Len(t, got, 1) + assert.Equal(t, "2026-08-23", got[0].ForecastDate) + }) +} + +func TestWeatherOutlook_Signature(t *testing.T) { + t.Parallel() + + t.Run("an empty outlook is the version prefix alone, never the empty string", func(t *testing.T) { + t.Parallel() + o := NewWeatherOutlook([]WeatherForecastDay{warmDay("2026-08-21")}, "2026-08-21") + assert.Equal(t, "o1:", o.Signature()) + assert.NotEmpty(t, o.Signature()) + }) + + t.Run("equal outlooks produce equal signatures", func(t *testing.T) { + t.Parallel() + build := func() WeatherOutlook { + wet := tempDay("2026-08-23", 14.6, 22.0) + wet.RainSum = f(1.3) + return NewWeatherOutlook([]WeatherForecastDay{tempDay("2026-08-21", 11.2, 21.5), wet}, "2026-08-21") + } + assert.Equal(t, build().Signature(), build().Signature()) + assert.Equal(t, "o1:2026-08-23:R+", build().Signature()) + }) + + t.Run("a changed flag changes the signature", func(t *testing.T) { + t.Parallel() + wet := tempDay("2026-08-23", 14.6, 22.0) + wet.RainSum = f(1.3) + before := NewWeatherOutlook([]WeatherForecastDay{tempDay("2026-08-21", 11.2, 21.5), wet}, "2026-08-21") + + wet.SnowfallSum = f(2) + after := NewWeatherOutlook([]WeatherForecastDay{tempDay("2026-08-21", 11.2, 21.5), wet}, "2026-08-21") + + assert.NotEqual(t, before.Signature(), after.Signature()) + assert.Equal(t, "o1:2026-08-23:RS+", after.Signature()) + }) +} + +func TestCompareWeatherOutlookSignatures(t *testing.T) { + t.Parallel() + + t.Run("no previous state marks nothing", func(t *testing.T) { + t.Parallel() + got := CompareWeatherOutlookSignatures("", "o1:2026-08-23:R+;2026-08-25:S-") + assert.Empty(t, got.Changed) + assert.Empty(t, got.Cleared) + }) + + t.Run("a different signature version marks nothing", func(t *testing.T) { + t.Parallel() + got := CompareWeatherOutlookSignatures("o0:2026-08-23:R+", "o1:2026-08-23:R+") + assert.Empty(t, got.Changed) + assert.Empty(t, got.Cleared) + }) + + t.Run("new and changed days are marked, unchanged ones are not", func(t *testing.T) { + t.Parallel() + got := CompareWeatherOutlookSignatures( + "o1:2026-08-23:R+;2026-08-25:R+", + "o1:2026-08-23:R+;2026-08-25:RS+;2026-08-27:S-", + ) + assert.False(t, got.Changed["2026-08-23"]) + assert.True(t, got.Changed["2026-08-25"]) + assert.True(t, got.Changed["2026-08-27"]) + assert.Empty(t, got.Cleared) + }) + + t.Run("days that stopped being notable are reported cleared, ascending", func(t *testing.T) { + t.Parallel() + got := CompareWeatherOutlookSignatures( + "o1:2026-08-23:R+;2026-08-25:R+;2026-08-27:S-", + "o1:2026-08-25:R+", + ) + assert.Empty(t, got.Changed) + assert.Equal(t, []string{"2026-08-23", "2026-08-27"}, got.Cleared) + }) + + t.Run("an outlook emptying out clears everything it held", func(t *testing.T) { + t.Parallel() + got := CompareWeatherOutlookSignatures("o1:2026-08-23:R+", "o1:") + assert.Empty(t, got.Changed) + assert.Equal(t, []string{"2026-08-23"}, got.Cleared) + }) +} + +// f returns a pointer to v, so table cases can express "absent" as nil. +func f(v float64) *float64 { return &v } + +// tempDay returns a day carrying only a temperature range. +func tempDay(date string, minTemp, maxTemp float64) WeatherForecastDay { + return WeatherForecastDay{ForecastDate: date, TempMin: f(minTemp), TempMax: f(maxTemp)} +} + +// warmDay returns a dry day comfortably above freezing. +func warmDay(date string) WeatherForecastDay { + return tempDay(date, 11.2, 21.5) +} diff --git a/internal/repository/weatherforecastday.go b/internal/repository/weatherforecastday.go new file mode 100644 index 0000000..f5b915d --- /dev/null +++ b/internal/repository/weatherforecastday.go @@ -0,0 +1,286 @@ +package repository + +import ( + "context" + "database/sql" + "errors" + "fmt" + "time" + + "github.com/prorochestvo/loginjector" + "github.com/seilbekskindirov/beacon/internal" + "github.com/seilbekskindirov/beacon/internal/domain" + "github.com/seilbekskindirov/beacon/internal/domain/identity" +) + +// WeatherForecastDayRepository persists and retrieves domain.WeatherForecastDay records — +// the long-range daily forecast behind the multi-week outlook view and digest. +type WeatherForecastDayRepository struct { + db db +} + +// NewWeatherForecastDayRepository returns a repository for the weather_forecast_days table. +func NewWeatherForecastDayRepository(db db) (*WeatherForecastDayRepository, error) { + return &WeatherForecastDayRepository{db: db}, nil +} + +// CheckUP verifies that the repository can read from the weather_forecast_days table. +func (r *WeatherForecastDayRepository) CheckUP(ctx context.Context) error { + tx, err := r.db.ReadOnlyTransaction(ctx) + if err != nil { + return errors.Join(err, loginjector.NewTraceError()) + } + defer printRollbackError(tx) + + query := "SELECT COUNT(*) FROM " + weatherForecastDayTableName + ";" + var count int64 + if err := tx.QueryRowContext(ctx, query).Scan(&count); err != nil { + return errors.Join(err, fmt.Errorf("SQL: %s", query), loginjector.NewTraceError()) + } + if count < 0 { + return errors.Join(errors.New("unexpected result"), loginjector.NewTraceError()) + } + return nil +} + +// Name returns the name of the underlying database table. +func (r *WeatherForecastDayRepository) Name() string { return weatherForecastDayTableName } + +// ObtainForecastDays returns the stored forecast for locationID and provider from fromDate +// (inclusive, YYYY-MM-DD) onwards, ascending by date and capped at limit rows. +// +// A forecast supersedes itself daily and is upserted in place, so this returns one row per +// day and never a history of revisions. An empty slice is a location whose first long-range +// fetch has not completed yet, not a failure. +func (r *WeatherForecastDayRepository) ObtainForecastDays(ctx context.Context, locationID, provider, fromDate string, limit int) ([]domain.WeatherForecastDay, error) { + if limit <= 0 { + limit = domain.WeatherOutlookHorizonDays + } + + tx, err := r.db.ReadOnlyTransaction(ctx) + if err != nil { + return nil, errors.Join(err, loginjector.NewTraceError()) + } + defer printRollbackError(tx) + + query := weatherForecastDaySQLSelect + + " WHERE " + weatherForecastDayLocationIDFieldName + " = ?" + + " AND " + weatherForecastDayProviderFieldName + " = ?" + + " AND " + weatherForecastDayForecastDateFieldName + " >= ?" + + " ORDER BY " + weatherForecastDayForecastDateFieldName + " ASC" + + " LIMIT ?;" + + rows, err := tx.QueryContext(ctx, query, locationID, provider, fromDate, limit) + if err != nil { + return nil, errors.Join(err, fmt.Errorf("SQL: %s", query), loginjector.NewTraceError()) + } + defer func() { err = errors.Join(err, rows.Close()) }() + + items := make([]domain.WeatherForecastDay, 0, limit) + for rows.Next() { + item, scanErr := weatherForecastDayScan(rows) + if scanErr != nil { + return nil, scanErr + } + items = append(items, item) + } + if iterErr := rows.Err(); iterErr != nil { + return nil, errors.Join(iterErr, loginjector.NewTraceError()) + } + return items, nil +} + +// ObtainLatestForecastCapture returns the newest captured_at stored for (locationID, +// provider) — the collector's throttle gate. Returns internal.ErrNotFound when the location +// has never been fetched, which the caller must read as "due", not as an error. +func (r *WeatherForecastDayRepository) ObtainLatestForecastCapture(ctx context.Context, locationID, provider string) (time.Time, error) { + tx, err := r.db.ReadOnlyTransaction(ctx) + if err != nil { + return time.Time{}, errors.Join(err, loginjector.NewTraceError()) + } + defer printRollbackError(tx) + + query := "SELECT MAX(" + weatherForecastDayCapturedAtFieldName + ")" + + " FROM " + weatherForecastDayTableName + + " WHERE " + weatherForecastDayLocationIDFieldName + " = ?" + + " AND " + weatherForecastDayProviderFieldName + " = ?;" + + // MAX over an empty set is one row holding NULL, not zero rows, so the miss arrives as + // a nil string rather than as sql.ErrNoRows. + var capturedAt *string + if err := tx.QueryRowContext(ctx, query, locationID, provider).Scan(&capturedAt); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return time.Time{}, internal.ErrNotFound + } + return time.Time{}, errors.Join(err, fmt.Errorf("SQL: %s", query), loginjector.NewTraceError()) + } + if capturedAt == nil || *capturedAt == "" { + return time.Time{}, internal.ErrNotFound + } + + at, err := time.Parse(time.RFC3339, *capturedAt) + if err != nil { + return time.Time{}, errors.Join(err, loginjector.NewTraceError()) + } + return at.UTC(), nil +} + +// RemoveForecastDaysBefore deletes every stored day whose forecast_date is earlier than +// date (YYYY-MM-DD). This is the table's whole retention policy: rows are superseded in +// place while the day is still ahead, and dropped once it is behind. +func (r *WeatherForecastDayRepository) RemoveForecastDaysBefore(ctx context.Context, date string) error { + tx, err := r.db.Transaction(ctx) + if err != nil { + return errors.Join(err, loginjector.NewTraceError()) + } + defer printRollbackError(tx) + + cmd := "DELETE FROM " + weatherForecastDayTableName + + " WHERE " + weatherForecastDayForecastDateFieldName + " < ?;" + if _, err := tx.ExecContext(ctx, cmd, date); err != nil { + return errors.Join(err, fmt.Errorf("SQL: %s", cmd), loginjector.NewTraceError()) + } + + if err := tx.Commit(); err != nil { + return errors.Join(err, loginjector.NewTraceError()) + } + return nil +} + +// RetainWeatherForecastDays upserts a whole fetch on the natural key (location_id, +// provider, forecast_date), minting an ID for any record that lacks one. +// +// All rows go in ONE transaction, for two reasons. A day's forecast is a single observation +// of the future and half of it is not a usable answer; and the SQLite write lock is taken +// at BEGIN here (_txlock=immediate), so sixteen separate transactions would take and release +// it sixteen times per location against a collector, notifier and web server sharing the +// file. +func (r *WeatherForecastDayRepository) RetainWeatherForecastDays(ctx context.Context, records []domain.WeatherForecastDay) error { + if len(records) == 0 { + return nil + } + + tx, err := r.db.Transaction(ctx) + if err != nil { + return errors.Join(err, loginjector.NewTraceError()) + } + defer printRollbackError(tx) + + for i := range records { + record := &records[i] + if record.ID == "" { + record.ID = identity.New(identity.KindWeatherForecastDay) + } + if _, err := tx.ExecContext(ctx, weatherForecastDaySQLUpsert, + record.ID, + record.LocationID, + record.Provider, + record.ForecastDate, + record.CapturedAt.UTC().Format(time.RFC3339), + record.TempMax, + record.TempMin, + record.RainSum, + record.SnowfallSum, + record.PrecipSum, + record.PrecipProbMax, + record.WeatherCode, + ); err != nil { + return errors.Join(err, fmt.Errorf("SQL: %s", weatherForecastDaySQLUpsert), loginjector.NewTraceError()) + } + } + + if err := tx.Commit(); err != nil { + return errors.Join(err, loginjector.NewTraceError()) + } + return nil +} + +const ( + weatherForecastDayTableName = "weather_forecast_days" + weatherForecastDayIDFieldName = "id" + weatherForecastDayLocationIDFieldName = "location_id" + weatherForecastDayProviderFieldName = "provider" + weatherForecastDayForecastDateFieldName = "forecast_date" + weatherForecastDayCapturedAtFieldName = "captured_at" + weatherForecastDayTempMaxFieldName = "temp_max" + weatherForecastDayTempMinFieldName = "temp_min" + weatherForecastDayRainSumFieldName = "rain_sum" + weatherForecastDaySnowfallSumFieldName = "snowfall_sum" + weatherForecastDayPrecipSumFieldName = "precip_sum" + weatherForecastDayPrecipProbMaxFieldName = "precip_prob_max" + weatherForecastDayWeatherCodeFieldName = "weather_code" + + weatherForecastDaySQLSelect = "SELECT " + + weatherForecastDayIDFieldName + ", " + + weatherForecastDayLocationIDFieldName + ", " + + weatherForecastDayProviderFieldName + ", " + + weatherForecastDayForecastDateFieldName + ", " + + weatherForecastDayCapturedAtFieldName + ", " + + weatherForecastDayTempMaxFieldName + ", " + + weatherForecastDayTempMinFieldName + ", " + + weatherForecastDayRainSumFieldName + ", " + + weatherForecastDaySnowfallSumFieldName + ", " + + weatherForecastDayPrecipSumFieldName + ", " + + weatherForecastDayPrecipProbMaxFieldName + ", " + + weatherForecastDayWeatherCodeFieldName + + " FROM " + weatherForecastDayTableName + + // weatherForecastDaySQLUpsert rewrites every measurement of an existing day in place. + // id is absent from the SET clause on purpose: the row keeps the identifier it was + // created with, so nothing that referenced it is invalidated by a refresh. + weatherForecastDaySQLUpsert = "INSERT INTO " + weatherForecastDayTableName + " (" + + weatherForecastDayIDFieldName + ", " + + weatherForecastDayLocationIDFieldName + ", " + + weatherForecastDayProviderFieldName + ", " + + weatherForecastDayForecastDateFieldName + ", " + + weatherForecastDayCapturedAtFieldName + ", " + + weatherForecastDayTempMaxFieldName + ", " + + weatherForecastDayTempMinFieldName + ", " + + weatherForecastDayRainSumFieldName + ", " + + weatherForecastDaySnowfallSumFieldName + ", " + + weatherForecastDayPrecipSumFieldName + ", " + + weatherForecastDayPrecipProbMaxFieldName + ", " + + weatherForecastDayWeatherCodeFieldName + + ") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) " + + "ON CONFLICT(" + + weatherForecastDayLocationIDFieldName + ", " + + weatherForecastDayProviderFieldName + ", " + + weatherForecastDayForecastDateFieldName + + ") DO UPDATE SET " + + weatherForecastDayCapturedAtFieldName + " = excluded." + weatherForecastDayCapturedAtFieldName + ", " + + weatherForecastDayTempMaxFieldName + " = excluded." + weatherForecastDayTempMaxFieldName + ", " + + weatherForecastDayTempMinFieldName + " = excluded." + weatherForecastDayTempMinFieldName + ", " + + weatherForecastDayRainSumFieldName + " = excluded." + weatherForecastDayRainSumFieldName + ", " + + weatherForecastDaySnowfallSumFieldName + " = excluded." + weatherForecastDaySnowfallSumFieldName + ", " + + weatherForecastDayPrecipSumFieldName + " = excluded." + weatherForecastDayPrecipSumFieldName + ", " + + weatherForecastDayPrecipProbMaxFieldName + " = excluded." + weatherForecastDayPrecipProbMaxFieldName + ", " + + weatherForecastDayWeatherCodeFieldName + " = excluded." + weatherForecastDayWeatherCodeFieldName + ";" +) + +func weatherForecastDayScan(s weatherUserCityScanner) (domain.WeatherForecastDay, error) { + var item domain.WeatherForecastDay + var capturedAt string + + if err := s.Scan( + &item.ID, + &item.LocationID, + &item.Provider, + &item.ForecastDate, + &capturedAt, + &item.TempMax, + &item.TempMin, + &item.RainSum, + &item.SnowfallSum, + &item.PrecipSum, + &item.PrecipProbMax, + &item.WeatherCode, + ); err != nil { + return domain.WeatherForecastDay{}, err + } + + var err error + if item.CapturedAt, err = time.Parse(time.RFC3339, capturedAt); err != nil { + return domain.WeatherForecastDay{}, errors.Join(err, loginjector.NewTraceError()) + } + return item, nil +} diff --git a/internal/repository/weatherforecastday_test.go b/internal/repository/weatherforecastday_test.go new file mode 100644 index 0000000..64520f4 --- /dev/null +++ b/internal/repository/weatherforecastday_test.go @@ -0,0 +1,236 @@ +package repository + +import ( + "errors" + "testing" + "time" + + "github.com/seilbekskindirov/beacon/internal" + "github.com/seilbekskindirov/beacon/internal/domain" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestWeatherForecastDayRepository_RetainWeatherForecastDays(t *testing.T) { + t.Parallel() + + t.Run("inserts a fetch and round-trips every measurement", func(t *testing.T) { + t.Parallel() + repo := stubForecastDayRepo(t) + + captured := time.Now().UTC().Truncate(time.Second) + days := []domain.WeatherForecastDay{ + forecastDay("loc1", "2026-08-21", captured, 21.5, 11.2, 0, 0), + forecastDay("loc1", "2026-08-22", captured, 24.3, 12.6, 1.3, 0), + } + require.NoError(t, repo.RetainWeatherForecastDays(t.Context(), days)) + require.NotEmpty(t, days[0].ID) + require.NotEmpty(t, days[1].ID) + + got, err := repo.ObtainForecastDays(t.Context(), "loc1", domain.ProviderOpenMeteo, "2026-08-21", 0) + require.NoError(t, err) + require.Len(t, got, 2) + assert.Equal(t, "2026-08-21", got[0].ForecastDate) + assert.Equal(t, "2026-08-22", got[1].ForecastDate) + require.NotNil(t, got[1].TempMax) + assert.InDelta(t, 24.3, *got[1].TempMax, 1e-6) + require.NotNil(t, got[1].RainSum) + assert.InDelta(t, 1.3, *got[1].RainSum, 1e-6) + assert.Equal(t, captured.Format(time.RFC3339), got[0].CapturedAt.Format(time.RFC3339)) + }) + + t.Run("stores NULL for absent measurements rather than zero", func(t *testing.T) { + t.Parallel() + repo := stubForecastDayRepo(t) + + require.NoError(t, repo.RetainWeatherForecastDays(t.Context(), []domain.WeatherForecastDay{{ + LocationID: "loc1", + Provider: domain.ProviderOpenMeteo, + ForecastDate: "2026-08-21", + CapturedAt: time.Now().UTC(), + }})) + + got, err := repo.ObtainForecastDays(t.Context(), "loc1", domain.ProviderOpenMeteo, "2026-08-21", 0) + require.NoError(t, err) + require.Len(t, got, 1) + assert.Nil(t, got[0].TempMax) + assert.Nil(t, got[0].RainSum) + assert.Nil(t, got[0].SnowfallSum) + assert.Nil(t, got[0].PrecipProbMax) + assert.Nil(t, got[0].WeatherCode) + }) + + t.Run("a second fetch of the same day updates in place and keeps the row count", func(t *testing.T) { + t.Parallel() + repo := stubForecastDayRepo(t) + + first := time.Now().UTC().Add(-24 * time.Hour).Truncate(time.Second) + days := []domain.WeatherForecastDay{forecastDay("loc1", "2026-08-25", first, 20, 10, 0, 0)} + require.NoError(t, repo.RetainWeatherForecastDays(t.Context(), days)) + originalID := days[0].ID + + second := time.Now().UTC().Truncate(time.Second) + revised := []domain.WeatherForecastDay{forecastDay("loc1", "2026-08-25", second, 18, 6, 4.2, 1.5)} + require.NoError(t, repo.RetainWeatherForecastDays(t.Context(), revised)) + + got, err := repo.ObtainForecastDays(t.Context(), "loc1", domain.ProviderOpenMeteo, "2026-08-25", 0) + require.NoError(t, err) + require.Len(t, got, 1, "an upserted day must not accumulate revisions") + assert.Equal(t, originalID, got[0].ID, "the row keeps the identifier it was created with") + require.NotNil(t, got[0].RainSum) + assert.InDelta(t, 4.2, *got[0].RainSum, 1e-6) + require.NotNil(t, got[0].SnowfallSum) + assert.InDelta(t, 1.5, *got[0].SnowfallSum, 1e-6) + assert.Equal(t, second.Format(time.RFC3339), got[0].CapturedAt.Format(time.RFC3339)) + }) + + t.Run("an empty fetch is a no-op, not an error", func(t *testing.T) { + t.Parallel() + repo := stubForecastDayRepo(t) + require.NoError(t, repo.RetainWeatherForecastDays(t.Context(), nil)) + }) +} + +func TestWeatherForecastDayRepository_ObtainForecastDays(t *testing.T) { + t.Parallel() + + t.Run("windows from the requested date and orders ascending", func(t *testing.T) { + t.Parallel() + repo := stubForecastDayRepo(t) + + captured := time.Now().UTC() + require.NoError(t, repo.RetainWeatherForecastDays(t.Context(), []domain.WeatherForecastDay{ + forecastDay("loc1", "2026-08-23", captured, 20, 10, 0, 0), + forecastDay("loc1", "2026-08-21", captured, 20, 10, 0, 0), + forecastDay("loc1", "2026-08-22", captured, 20, 10, 0, 0), + })) + + got, err := repo.ObtainForecastDays(t.Context(), "loc1", domain.ProviderOpenMeteo, "2026-08-22", 0) + require.NoError(t, err) + require.Len(t, got, 2) + assert.Equal(t, "2026-08-22", got[0].ForecastDate) + assert.Equal(t, "2026-08-23", got[1].ForecastDate) + }) + + t.Run("does not leak another location or another provider", func(t *testing.T) { + t.Parallel() + repo := stubForecastDayRepo(t) + + captured := time.Now().UTC() + other := forecastDay("loc2", "2026-08-21", captured, 20, 10, 0, 0) + foreign := forecastDay("loc1", "2026-08-21", captured, 20, 10, 0, 0) + foreign.Provider = "some-other-provider" + require.NoError(t, repo.RetainWeatherForecastDays(t.Context(), []domain.WeatherForecastDay{ + forecastDay("loc1", "2026-08-21", captured, 20, 10, 0, 0), other, foreign, + })) + + got, err := repo.ObtainForecastDays(t.Context(), "loc1", domain.ProviderOpenMeteo, "2026-08-21", 0) + require.NoError(t, err) + require.Len(t, got, 1) + assert.Equal(t, "loc1", got[0].LocationID) + assert.Equal(t, domain.ProviderOpenMeteo, got[0].Provider) + }) + + t.Run("honours the limit", func(t *testing.T) { + t.Parallel() + repo := stubForecastDayRepo(t) + + captured := time.Now().UTC() + stored := []domain.WeatherForecastDay{ + forecastDay("loc1", "2026-08-21", captured, 20, 10, 0, 0), + forecastDay("loc1", "2026-08-22", captured, 20, 10, 0, 0), + forecastDay("loc1", "2026-08-23", captured, 20, 10, 0, 0), + } + require.NoError(t, repo.RetainWeatherForecastDays(t.Context(), stored)) + + got, err := repo.ObtainForecastDays(t.Context(), "loc1", domain.ProviderOpenMeteo, "2026-08-21", 2) + require.NoError(t, err) + require.Len(t, got, 2) + }) + + t.Run("a location with no fetch yet returns an empty slice, not an error", func(t *testing.T) { + t.Parallel() + repo := stubForecastDayRepo(t) + + got, err := repo.ObtainForecastDays(t.Context(), "nobody", domain.ProviderOpenMeteo, "2026-08-21", 0) + require.NoError(t, err) + assert.Empty(t, got) + }) +} + +func TestWeatherForecastDayRepository_ObtainLatestForecastCapture(t *testing.T) { + t.Parallel() + + t.Run("returns the newest captured_at across the stored window", func(t *testing.T) { + t.Parallel() + repo := stubForecastDayRepo(t) + + older := time.Now().UTC().Add(-48 * time.Hour).Truncate(time.Second) + newer := time.Now().UTC().Truncate(time.Second) + require.NoError(t, repo.RetainWeatherForecastDays(t.Context(), []domain.WeatherForecastDay{ + forecastDay("loc1", "2026-08-21", older, 20, 10, 0, 0), + forecastDay("loc1", "2026-08-22", newer, 20, 10, 0, 0), + })) + + got, err := repo.ObtainLatestForecastCapture(t.Context(), "loc1", domain.ProviderOpenMeteo) + require.NoError(t, err) + assert.Equal(t, newer.Format(time.RFC3339), got.Format(time.RFC3339)) + }) + + t.Run("a location never fetched is ErrNotFound, not a zero time", func(t *testing.T) { + t.Parallel() + repo := stubForecastDayRepo(t) + + _, err := repo.ObtainLatestForecastCapture(t.Context(), "nobody", domain.ProviderOpenMeteo) + require.Error(t, err) + assert.True(t, errors.Is(err, internal.ErrNotFound)) + }) +} + +func TestWeatherForecastDayRepository_RemoveForecastDaysBefore(t *testing.T) { + t.Parallel() + + t.Run("drops past days and keeps the cutoff day itself", func(t *testing.T) { + t.Parallel() + repo := stubForecastDayRepo(t) + + captured := time.Now().UTC() + require.NoError(t, repo.RetainWeatherForecastDays(t.Context(), []domain.WeatherForecastDay{ + forecastDay("loc1", "2026-08-19", captured, 20, 10, 0, 0), + forecastDay("loc1", "2026-08-20", captured, 20, 10, 0, 0), + forecastDay("loc1", "2026-08-21", captured, 20, 10, 0, 0), + })) + + require.NoError(t, repo.RemoveForecastDaysBefore(t.Context(), "2026-08-20")) + + got, err := repo.ObtainForecastDays(t.Context(), "loc1", domain.ProviderOpenMeteo, "2026-01-01", 0) + require.NoError(t, err) + require.Len(t, got, 2) + assert.Equal(t, "2026-08-20", got[0].ForecastDate) + assert.Equal(t, "2026-08-21", got[1].ForecastDate) + }) +} + +// forecastDay builds one Open-Meteo forecast row. Rain and snow are passed as plain +// float64 and stored as pointers so a case can express a real zero. +func forecastDay(locationID, date string, captured time.Time, maxTemp, minTemp, rain, snow float64) domain.WeatherForecastDay { + return domain.WeatherForecastDay{ + LocationID: locationID, + Provider: domain.ProviderOpenMeteo, + ForecastDate: date, + CapturedAt: captured, + TempMax: &maxTemp, + TempMin: &minTemp, + RainSum: &rain, + SnowfallSum: &snow, + } +} + +// stubForecastDayRepo returns a repository over a fresh migrated in-memory database. +func stubForecastDayRepo(t *testing.T) *WeatherForecastDayRepository { + t.Helper() + repo, err := NewWeatherForecastDayRepository(stubSQLiteDB(t)) + require.NoError(t, err) + return repo +} diff --git a/migrations/202608.033.weather_forecast_days.table_initiate.sql b/migrations/202608.033.weather_forecast_days.table_initiate.sql new file mode 100644 index 0000000..be61ce3 --- /dev/null +++ b/migrations/202608.033.weather_forecast_days.table_initiate.sql @@ -0,0 +1,39 @@ +-- Long-range daily forecast: one row per (location, provider, forecast day). +-- +-- Deliberately NOT weather_observations. That table is swept on every collector tick by +-- RemoveWeatherObservationsOlderThan(48h), keyed on captured_at, so a row describing a day +-- two weeks out would be deleted a day and a half after it was written and the view could +-- never show a day more than two out. Retention here is keyed on forecast_date instead: a +-- day is kept until it is in the past. +-- +-- Deliberately NOT tiered either. rate_values and execution_history are append-only +-- telemetry and each carry an *_archive twin; this is a bounded working set of +-- locations x 16 rows upserted in place on the natural key below, so there is nothing an +-- archive tier could hold and no reason for a read to union two branches. +-- +-- No foreign key to weather_user_cities. A location whose last subscriber leaves simply +-- stops being refreshed and ages out within the horizon; cascading instead would tie the +-- lifetime of public meteorological data to one user's subscription row. +-- +-- Units follow the provider and are not interchangeable: rain_sum and precip_sum are +-- millimetres, snowfall_sum is CENTIMETRES. +CREATE TABLE IF NOT EXISTS weather_forecast_days ( + id TEXT NOT NULL PRIMARY KEY, + location_id TEXT NOT NULL, + provider TEXT NOT NULL, + forecast_date TEXT NOT NULL, + captured_at TEXT NOT NULL, + temp_max REAL, + temp_min REAL, + rain_sum REAL, + snowfall_sum REAL, + precip_sum REAL, + precip_prob_max INTEGER, + weather_code INTEGER, + UNIQUE (location_id, provider, forecast_date) +); + +-- The UNIQUE constraint above already indexes (location_id, provider, forecast_date), which +-- is what the per-city window read rides. This one serves the retention delete, which spans +-- every location at once and would otherwise scan the table. +CREATE INDEX IF NOT EXISTS idx_weather_forecast_days_date ON weather_forecast_days (forecast_date); From b5d168fdfe356ba6ab8ee4f71265105afccda18a Mon Sep 17 00:00:00 2001 From: prorochestvo Date: Fri, 21 Aug 2026 13:37:40 +0500 Subject: [PATCH 03/22] feat(weather): fetch the 16-day forecast once a day OpenMeteo.ForecastRange issues its own request rather than widening Forecast. Forecast decodes daily index [0], and that index is today for the morning summary and all four daily-metric alert latches; changing what it asks for would put their meaning at risk of a shift nothing would report. A second request costs about one weighted API call per location per day against a budget of 10,000. WeatherForecastAgent gates on the UTC calendar day rather than on elapsed hours: against an hourly cron, a 24-hour window drifts an hour later every day and eventually lands after the subscriber's notify hour. Retention runs whatever the fetches did, keeping one day of slack so no city's still-current local day is dropped early. Refs: #127 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016yN2eof4KJTh8oAwnoE8rg --- cmd/collector/main.go | 35 ++- cmd/collector/main_test.go | 29 +- .../collection/weatherforecastagent.go | 139 ++++++++++ .../collection/weatherforecastagent_test.go | 249 ++++++++++++++++++ internal/infrastructure/weather/openmeteo.go | 108 ++++++++ .../infrastructure/weather/openmeteo_test.go | 131 +++++++++ .../testdata/forecast_range_astana.json | 165 ++++++++++++ 7 files changed, 838 insertions(+), 18 deletions(-) create mode 100644 internal/application/collection/weatherforecastagent.go create mode 100644 internal/application/collection/weatherforecastagent_test.go create mode 100644 internal/infrastructure/weather/testdata/forecast_range_astana.json 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/internal/application/collection/weatherforecastagent.go b/internal/application/collection/weatherforecastagent.go new file mode 100644 index 0000000..43377c6 --- /dev/null +++ b/internal/application/collection/weatherforecastagent.go @@ -0,0 +1,139 @@ +package collection + +import ( + "context" + "errors" + "fmt" + "io" + "time" + + "github.com/prorochestvo/loginjector" + "github.com/seilbekskindirov/beacon/internal/domain" +) + +// WeatherForecastAgent collects the multi-week daily forecast from Open-Meteo for every +// distinct subscribed location and stores it in weather_forecast_days. Each Run invocation +// is one-shot, called once per cron tick from cmd/collector, and fetches each location at +// most once per UTC calendar day. +// +// It is deliberately not part of WeatherAgent. That agent answers "what is it doing right +// now", on an hourly throttle, and feeds the morning summary and the same-day alerts; this +// one answers "what will the next two weeks look like", where the upstream model is +// re-issued a few times a day and an hourly fetch would return unchanged bytes. Folding the +// two together would put the current-conditions path at risk for no gain. +type WeatherForecastAgent struct { + provider weatherRangeProvider + cityRepo weatherCollectionCityRepo + dayRepo weatherForecastDayRepo + logger io.Writer +} + +// NewWeatherForecastAgent constructs a WeatherForecastAgent. provider, cityRepo and dayRepo +// are all required; a nil logger discards output. +func NewWeatherForecastAgent( + provider weatherRangeProvider, + cityRepo weatherCollectionCityRepo, + dayRepo weatherForecastDayRepo, + logger io.Writer, +) (*WeatherForecastAgent, error) { + if provider == nil || cityRepo == nil || dayRepo == nil { + return nil, errors.New("weather forecast agent: provider, cityRepo, and dayRepo are all required") + } + if logger == nil { + logger = io.Discard + } + return &WeatherForecastAgent{ + provider: provider, + cityRepo: cityRepo, + dayRepo: dayRepo, + logger: logger, + }, nil +} + +// Run fetches a fresh long-range forecast for every location that has not been fetched yet +// today and upserts it. One failing location never aborts the rest; the joined error names +// each one. Retention runs afterwards whatever happened above, since it is the only thing +// bounding the table and a fetch failure is no reason to keep yesterday's rows. +func (a *WeatherForecastAgent) Run(ctx context.Context) error { + locations, err := a.cityRepo.ObtainDistinctWeatherLocations(ctx) + if err != nil { + return errors.Join(err, loginjector.NewTraceError()) + } + + now := time.Now().UTC() + var errs []error + var fetched, skipped, failed int + total := len(locations) + + for _, loc := range locations { + if !a.isDue(ctx, loc.LocationID, now) { + skipped++ + continue + } + + days, fetchErr := a.provider.ForecastRange(ctx, loc.Latitude, loc.Longitude) + if fetchErr != nil { + failed++ + fmt.Fprintf(a.logger, "weather forecast: location %s: fetch error: %v\n", loc.LocationID, fetchErr) + errs = append(errs, fmt.Errorf("location %s: forecast range: %w", loc.LocationID, fetchErr)) + continue + } + for i := range days { + days[i].LocationID = loc.LocationID + } + + // Persist under context.Background() so a SIGTERM does not discard a forecast that + // was already fetched and paid for; the same reasoning as WeatherAgent's. + //nolint:contextcheck // the detached context is the point; see the comment above + if retainErr := a.dayRepo.RetainWeatherForecastDays(context.Background(), days); retainErr != nil { + failed++ + fmt.Fprintf(a.logger, "weather forecast: location %s: retain error: %v\n", loc.LocationID, retainErr) + errs = append(errs, fmt.Errorf("location %s: retain forecast: %w", loc.LocationID, retainErr)) + continue + } + fetched++ + } + + fmt.Fprintf(a.logger, "weather forecast: fetched=%d skipped=%d failed=%d total=%d\n", fetched, skipped, failed, total) + + // A day is kept until it is behind every subscriber, not merely behind UTC. Offsets run + // from -12 to +14, so one whole day of slack is what makes "past" unambiguous; the cost + // of the slack is one extra row per location. + //nolint:contextcheck // detached for the same reason the write above is + if pruneErr := a.dayRepo.RemoveForecastDaysBefore(context.Background(), now.AddDate(0, 0, -1).Format(time.DateOnly)); pruneErr != nil { + fmt.Fprintf(a.logger, "weather forecast: retention: %v\n", pruneErr) + errs = append(errs, fmt.Errorf("forecast retention: %w", pruneErr)) + } + + return errors.Join(errs...) +} + +// isDue reports whether the location still needs its fetch for the current UTC day. +// +// The gate is a calendar-day comparison rather than "at least 24 h since the last capture". +// Against an hourly cron the elapsed-time form 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 +// and stays there. +// +// A read failure counts as due. ErrNotFound means the location has never been fetched, and +// anything else must not be allowed to skip a location permanently. +func (a *WeatherForecastAgent) isDue(ctx context.Context, locationID string, now time.Time) bool { + last, err := a.dayRepo.ObtainLatestForecastCapture(ctx, locationID, domain.ProviderOpenMeteo) + if err != nil { + return true + } + return last.UTC().Format(time.DateOnly) != now.Format(time.DateOnly) +} + +// weatherRangeProvider fetches a multi-week daily forecast for the given coordinates. +type weatherRangeProvider interface { + ForecastRange(ctx context.Context, lat, lng float64) ([]domain.WeatherForecastDay, error) +} + +// weatherForecastDayRepo is the narrow forecast-table surface the collector needs. +type weatherForecastDayRepo interface { + ObtainLatestForecastCapture(ctx context.Context, locationID, provider string) (time.Time, error) + RemoveForecastDaysBefore(ctx context.Context, date string) error + RetainWeatherForecastDays(ctx context.Context, records []domain.WeatherForecastDay) error +} diff --git a/internal/application/collection/weatherforecastagent_test.go b/internal/application/collection/weatherforecastagent_test.go new file mode 100644 index 0000000..fdc6626 --- /dev/null +++ b/internal/application/collection/weatherforecastagent_test.go @@ -0,0 +1,249 @@ +package collection + +import ( + "context" + "errors" + "io" + "strings" + "testing" + "time" + + "github.com/seilbekskindirov/beacon/internal" + "github.com/seilbekskindirov/beacon/internal/domain" + "github.com/seilbekskindirov/beacon/internal/repository" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +var _ weatherRangeProvider = (*mockWeatherRangeProvider)(nil) +var _ weatherForecastDayRepo = (*mockWeatherForecastDayRepo)(nil) + +// Compile-time assertion that the concrete repository satisfies the narrow interface. +var _ weatherForecastDayRepo = &repository.WeatherForecastDayRepository{} + +func TestNewWeatherForecastAgent(t *testing.T) { + t.Parallel() + + t.Run("valid construction", func(t *testing.T) { + t.Parallel() + a, err := NewWeatherForecastAgent(&mockWeatherRangeProvider{}, &mockWeatherCityRepo{}, &mockWeatherForecastDayRepo{}, io.Discard) + require.NoError(t, err) + require.NotNil(t, a) + }) + + t.Run("nil provider returns error", func(t *testing.T) { + t.Parallel() + _, err := NewWeatherForecastAgent(nil, &mockWeatherCityRepo{}, &mockWeatherForecastDayRepo{}, io.Discard) + require.Error(t, err) + }) + + t.Run("nil cityRepo returns error", func(t *testing.T) { + t.Parallel() + _, err := NewWeatherForecastAgent(&mockWeatherRangeProvider{}, nil, &mockWeatherForecastDayRepo{}, io.Discard) + require.Error(t, err) + }) + + t.Run("nil dayRepo returns error", func(t *testing.T) { + t.Parallel() + _, err := NewWeatherForecastAgent(&mockWeatherRangeProvider{}, &mockWeatherCityRepo{}, nil, io.Discard) + require.Error(t, err) + }) +} + +func TestWeatherForecastAgent_Run(t *testing.T) { + t.Parallel() + + t.Run("no locations means no fetch", func(t *testing.T) { + t.Parallel() + provider := &mockWeatherRangeProvider{} + a := newForecastAgent(t, provider, &mockWeatherCityRepo{}, &mockWeatherForecastDayRepo{}) + require.NoError(t, a.Run(t.Context())) + assert.Zero(t, provider.calls) + }) + + t.Run("a location never fetched is fetched and stored under its location key", func(t *testing.T) { + t.Parallel() + provider := &mockWeatherRangeProvider{days: []domain.WeatherForecastDay{ + {ForecastDate: "2026-08-21"}, {ForecastDate: "2026-08-22"}, + }} + dayRepo := &mockWeatherForecastDayRepo{captureErr: internal.ErrNotFound} + a := newForecastAgent(t, provider, &mockWeatherCityRepo{locations: locations("loc1")}, dayRepo) + + require.NoError(t, a.Run(t.Context())) + assert.Equal(t, 1, provider.calls) + require.Len(t, dayRepo.retained, 2) + assert.Equal(t, "loc1", dayRepo.retained[0].LocationID) + assert.Equal(t, "loc1", dayRepo.retained[1].LocationID) + }) + + t.Run("a location already fetched today is skipped", func(t *testing.T) { + t.Parallel() + provider := &mockWeatherRangeProvider{days: []domain.WeatherForecastDay{{ForecastDate: "2026-08-21"}}} + dayRepo := &mockWeatherForecastDayRepo{capture: time.Now().UTC()} + a := newForecastAgent(t, provider, &mockWeatherCityRepo{locations: locations("loc1")}, dayRepo) + + require.NoError(t, a.Run(t.Context())) + assert.Zero(t, provider.calls, "one fetch per UTC day is the whole point of the gate") + assert.Empty(t, dayRepo.retained) + }) + + t.Run("a location last fetched on an earlier UTC day is due again", func(t *testing.T) { + t.Parallel() + provider := &mockWeatherRangeProvider{days: []domain.WeatherForecastDay{{ForecastDate: "2026-08-21"}}} + dayRepo := &mockWeatherForecastDayRepo{capture: time.Now().UTC().AddDate(0, 0, -1)} + a := newForecastAgent(t, provider, &mockWeatherCityRepo{locations: locations("loc1")}, dayRepo) + + require.NoError(t, a.Run(t.Context())) + assert.Equal(t, 1, provider.calls) + }) + + t.Run("a capture read failure counts as due rather than skipping the location forever", func(t *testing.T) { + t.Parallel() + provider := &mockWeatherRangeProvider{days: []domain.WeatherForecastDay{{ForecastDate: "2026-08-21"}}} + dayRepo := &mockWeatherForecastDayRepo{captureErr: errors.New("database is busy")} + a := newForecastAgent(t, provider, &mockWeatherCityRepo{locations: locations("loc1")}, dayRepo) + + require.NoError(t, a.Run(t.Context())) + assert.Equal(t, 1, provider.calls) + }) + + t.Run("one failing location does not stop the others", func(t *testing.T) { + t.Parallel() + provider := &mockWeatherRangeProvider{ + days: []domain.WeatherForecastDay{{ForecastDate: "2026-08-21"}}, + failOnLat: 2, + } + dayRepo := &mockWeatherForecastDayRepo{captureErr: internal.ErrNotFound} + a := newForecastAgent(t, provider, &mockWeatherCityRepo{locations: locations("loc1", "loc2", "loc3")}, dayRepo) + + err := a.Run(t.Context()) + require.Error(t, err) + assert.Contains(t, err.Error(), "loc2", "the joined error must name the location that failed") + require.Len(t, dayRepo.retained, 2, "the other two locations must still be stored") + }) + + t.Run("a retain failure is reported and does not stop the run", func(t *testing.T) { + t.Parallel() + provider := &mockWeatherRangeProvider{days: []domain.WeatherForecastDay{{ForecastDate: "2026-08-21"}}} + dayRepo := &mockWeatherForecastDayRepo{captureErr: internal.ErrNotFound, retainErr: errors.New("disk full")} + a := newForecastAgent(t, provider, &mockWeatherCityRepo{locations: locations("loc1", "loc2")}, dayRepo) + + err := a.Run(t.Context()) + require.Error(t, err) + assert.Equal(t, 2, provider.calls) + }) + + t.Run("retention runs with a day of slack, even when every fetch was skipped", func(t *testing.T) { + t.Parallel() + dayRepo := &mockWeatherForecastDayRepo{capture: time.Now().UTC()} + a := newForecastAgent(t, &mockWeatherRangeProvider{}, &mockWeatherCityRepo{locations: locations("loc1")}, dayRepo) + + require.NoError(t, a.Run(t.Context())) + require.Len(t, dayRepo.prunedBefore, 1) + want := time.Now().UTC().AddDate(0, 0, -1).Format(time.DateOnly) + assert.Equal(t, want, dayRepo.prunedBefore[0], "yesterday is kept so no city's local today is deleted early") + }) + + t.Run("a retention failure is reported, not swallowed", func(t *testing.T) { + t.Parallel() + dayRepo := &mockWeatherForecastDayRepo{capture: time.Now().UTC(), pruneErr: errors.New("locked")} + a := newForecastAgent(t, &mockWeatherRangeProvider{}, &mockWeatherCityRepo{locations: locations("loc1")}, dayRepo) + + err := a.Run(t.Context()) + require.Error(t, err) + assert.Contains(t, err.Error(), "retention") + }) + + t.Run("a city-repository failure aborts before any fetch", func(t *testing.T) { + t.Parallel() + provider := &mockWeatherRangeProvider{} + a := newForecastAgent(t, provider, &mockWeatherCityRepo{err: errors.New("no database")}, &mockWeatherForecastDayRepo{}) + + require.Error(t, a.Run(t.Context())) + assert.Zero(t, provider.calls) + }) + + t.Run("logs a proof-of-execution line", func(t *testing.T) { + t.Parallel() + var log strings.Builder + dayRepo := &mockWeatherForecastDayRepo{captureErr: internal.ErrNotFound} + a, err := NewWeatherForecastAgent( + &mockWeatherRangeProvider{days: []domain.WeatherForecastDay{{ForecastDate: "2026-08-21"}}}, + &mockWeatherCityRepo{locations: locations("loc1")}, + dayRepo, + &log, + ) + require.NoError(t, err) + require.NoError(t, a.Run(t.Context())) + assert.Contains(t, log.String(), "weather forecast: fetched=1 skipped=0 failed=0 total=1") + }) +} + +// locations builds the distinct-location rows the collector iterates, one per id, each with +// distinct coordinates so a provider stub can fail a chosen one. +func locations(ids ...string) []domain.WeatherUserCity { + out := make([]domain.WeatherUserCity, 0, len(ids)) + for i, id := range ids { + out = append(out, domain.WeatherUserCity{ + LocationID: id, + Latitude: float64(i + 1), + Longitude: float64(i + 1), + }) + } + return out +} + +// newForecastAgent constructs an agent with a discarding logger. +func newForecastAgent(t *testing.T, provider weatherRangeProvider, cityRepo weatherCollectionCityRepo, dayRepo weatherForecastDayRepo) *WeatherForecastAgent { + t.Helper() + a, err := NewWeatherForecastAgent(provider, cityRepo, dayRepo, io.Discard) + require.NoError(t, err) + return a +} + +// mockWeatherRangeProvider simulates the long-range Open-Meteo endpoint. failOnLat names the +// latitude whose fetch fails, so a test can single out one location of several. +type mockWeatherRangeProvider struct { + days []domain.WeatherForecastDay + calls int + failOnLat float64 +} + +func (m *mockWeatherRangeProvider) ForecastRange(_ context.Context, lat, _ float64) ([]domain.WeatherForecastDay, error) { + m.calls++ + if m.failOnLat != 0 && lat == m.failOnLat { + return nil, errors.New("upstream refused") + } + // A copy per call: the agent stamps LocationID onto the returned slice, and two + // locations sharing one backing array would each overwrite the other's key. + out := make([]domain.WeatherForecastDay, len(m.days)) + copy(out, m.days) + return out, nil +} + +// mockWeatherForecastDayRepo simulates the forecast-day repository for the collector. +type mockWeatherForecastDayRepo struct { + capture time.Time + captureErr error + retained []domain.WeatherForecastDay + retainErr error + prunedBefore []string + pruneErr error +} + +func (m *mockWeatherForecastDayRepo) ObtainLatestForecastCapture(_ context.Context, _, _ string) (time.Time, error) { + return m.capture, m.captureErr +} + +func (m *mockWeatherForecastDayRepo) RemoveForecastDaysBefore(_ context.Context, date string) error { + m.prunedBefore = append(m.prunedBefore, date) + return m.pruneErr +} + +func (m *mockWeatherForecastDayRepo) RetainWeatherForecastDays(_ context.Context, records []domain.WeatherForecastDay) error { + if m.retainErr != nil { + return m.retainErr + } + m.retained = append(m.retained, records...) + return nil +} diff --git a/internal/infrastructure/weather/openmeteo.go b/internal/infrastructure/weather/openmeteo.go index 387a1eb..0b541f6 100644 --- a/internal/infrastructure/weather/openmeteo.go +++ b/internal/infrastructure/weather/openmeteo.go @@ -184,6 +184,44 @@ func (o *OpenMeteo) Forecast(ctx context.Context, lat, lng float64) (*domain.Wea return decodeOpenMeteoForecast(body, lat, lng) } +// ForecastRange fetches the multi-week daily forecast for the given coordinates: one +// domain.WeatherForecastDay per city-local calendar day, starting with today, over +// domain.WeatherOutlookHorizonDays days. +// +// It is a second request rather than a widening of Forecast, on purpose. Forecast decodes +// daily index [0], and that index IS today for the morning summary and for all four +// daily-metric alert latches; changing what it asks for or how it decodes would put their +// meaning at risk of a shift that nothing would report. A separate request costs roughly one +// weighted API call per location per day against a budget of 10,000, which is the cheaper +// side of that trade. +// +// The returned days carry no LocationID — the caller owns the location key — and no ID; the +// repository mints one. +func (o *OpenMeteo) ForecastRange(ctx context.Context, lat, lng float64) ([]domain.WeatherForecastDay, error) { + u, err := url.Parse(openMeteoForecastBase) + if err != nil { + return nil, errors.Join(err, loginjector.NewTraceError()) + } + q := u.Query() + q.Set("latitude", fmt.Sprintf("%f", lat)) + q.Set("longitude", fmt.Sprintf("%f", lng)) + // rain_sum and snowfall_sum are requested alongside precipitation_sum rather than + // derived from it: the rain-or-snow distinction is the whole question this fetch + // answers, and a combined total cannot be split back apart. Their units differ — + // rain in millimetres, snowfall in centimetres. + q.Set("daily", "temperature_2m_max,temperature_2m_min,rain_sum,snowfall_sum,precipitation_sum,precipitation_probability_max,weather_code") + q.Set("timezone", "auto") + q.Set("forecast_days", strconv.Itoa(domain.WeatherOutlookHorizonDays)) + u.RawQuery = q.Encode() + + body, err := o.get(ctx, u.String()) + if err != nil { + return nil, err + } + + return decodeOpenMeteoForecastRange(body) +} + // get fetches rawURL, re-sending the request when the failure looks transient. // // Open-Meteo intermittently answers 5xx — 59% of forecast fetches met one over five days @@ -510,6 +548,67 @@ func decodeOpenMeteoForecast(body []byte, lat, lng float64) (*domain.WeatherObse return obs, nil } +// decodeOpenMeteoForecastRange is the pure-decode step for ForecastRange, extracted so +// tests can exercise it without a live HTTP server. +// +// Every measurement array is read by index against daily.time and every element is a +// pointer. Open-Meteo returns the arrays parallel and writes JSON null where it has no +// value, so a short array or a null must yield a nil measurement — decoding into []float64 +// would turn both into a very believable 0.0, and "0 mm of rain" on a day the model has no +// answer for is exactly the kind of wrong that reads as data. +func decodeOpenMeteoForecastRange(body []byte) ([]domain.WeatherForecastDay, error) { + var resp struct { + Daily struct { + Time []string `json:"time"` + Temperature2mMax []*float64 `json:"temperature_2m_max"` + Temperature2mMin []*float64 `json:"temperature_2m_min"` + RainSum []*float64 `json:"rain_sum"` + SnowfallSum []*float64 `json:"snowfall_sum"` + PrecipitationSum []*float64 `json:"precipitation_sum"` + PrecipitationProbMax []*int `json:"precipitation_probability_max"` + WeatherCode []*int `json:"weather_code"` + } `json:"daily"` + } + if err := json.Unmarshal(body, &resp); err != nil { + return nil, errors.Join( + fmt.Errorf("open-meteo forecast range: decode response: %w", err), + loginjector.NewTraceError(), + ) + } + + if len(resp.Daily.Time) == 0 { + return nil, errors.Join( + errors.New("open-meteo forecast range: daily[] array is empty"), + loginjector.NewTraceError(), + ) + } + + // One capture instant for the whole fetch: the sixteen rows are a single observation of + // the future, and the collector's daily gate compares against exactly this value. + capturedAt := time.Now().UTC() + + days := make([]domain.WeatherForecastDay, 0, len(resp.Daily.Time)) + for i, date := range resp.Daily.Time { + if date == "" { + continue // a day with no calendar date has no natural key to be stored under + } + days = append(days, domain.WeatherForecastDay{ + Provider: domain.ProviderOpenMeteo, + ForecastDate: date, + CapturedAt: capturedAt, + TempMax: valueAt(resp.Daily.Temperature2mMax, i), + TempMin: valueAt(resp.Daily.Temperature2mMin, i), + RainSum: valueAt(resp.Daily.RainSum, i), + SnowfallSum: valueAt(resp.Daily.SnowfallSum, i), + PrecipSum: valueAt(resp.Daily.PrecipitationSum, i), + PrecipProbMax: valueAt(resp.Daily.PrecipitationProbMax, i), + WeatherCode: valueAt(resp.Daily.WeatherCode, i), + }) + } + + return days, nil +} + func isRetryable(err error) bool { var r retryableError return errors.As(err, &r) @@ -562,5 +661,14 @@ func sleepWithContext(ctx context.Context, d time.Duration) error { } } +// valueAt returns the i-th element of a parallel Open-Meteo measurement array, or nil when +// the array is shorter than daily.time or holds a null at that index. +func valueAt[T any](values []*T, i int) *T { + if i >= len(values) { + return nil + } + return values[i] +} + func float64Ptr(v float64) *float64 { return &v } func intPtr(v int) *int { return &v } diff --git a/internal/infrastructure/weather/openmeteo_test.go b/internal/infrastructure/weather/openmeteo_test.go index 1dd7058..7980235 100644 --- a/internal/infrastructure/weather/openmeteo_test.go +++ b/internal/infrastructure/weather/openmeteo_test.go @@ -662,3 +662,134 @@ func loggedDuration(t *testing.T, text, pattern string) time.Duration { require.NoError(t, err, "%q is not a duration", m[1]) return d } + +func TestOpenMeteo_ForecastRange(t *testing.T) { + t.Parallel() + + t.Run("decodes a full 16-day window from a real fixture", func(t *testing.T) { + t.Parallel() + fixture := loadFixture(t, "forecast_range_astana.json") + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/v1/forecast", r.URL.Path) + assert.Equal(t, "auto", r.URL.Query().Get("timezone")) + assert.Equal(t, "16", r.URL.Query().Get("forecast_days")) + daily := r.URL.Query().Get("daily") + assert.Contains(t, daily, "rain_sum", "the rain/snow split is the question this fetch answers") + assert.Contains(t, daily, "snowfall_sum") + w.WriteHeader(http.StatusOK) + _, _ = w.Write(fixture) + })) + t.Cleanup(srv.Close) + + om := newTestOpenMeteo(t, srv.URL, srv.URL) + days, err := om.ForecastRange(t.Context(), 51.1801, 71.446) + require.NoError(t, err) + require.Len(t, days, 16) + + assert.Equal(t, "open-meteo", days[0].Provider) + assert.Equal(t, "2026-08-21", days[0].ForecastDate) + assert.Equal(t, "2026-09-05", days[15].ForecastDate) + assert.Empty(t, days[0].LocationID, "the caller owns the location key") + assert.Empty(t, days[0].ID, "the repository mints the identifier") + assert.False(t, days[0].CapturedAt.IsZero()) + + require.NotNil(t, days[2].RainSum) + assert.InDelta(t, 1.3, *days[2].RainSum, 1e-6) + require.NotNil(t, days[2].SnowfallSum) + assert.InDelta(t, 0.0, *days[2].SnowfallSum, 1e-6) + require.NotNil(t, days[2].TempMax) + assert.InDelta(t, 22.0, *days[2].TempMax, 1e-6) + require.NotNil(t, days[2].PrecipProbMax) + assert.Equal(t, 61, *days[2].PrecipProbMax) + require.NotNil(t, days[2].WeatherCode) + assert.Equal(t, 53, *days[2].WeatherCode) + }) + + t.Run("every day of the fixture shares one capture instant", func(t *testing.T) { + t.Parallel() + days, err := decodeOpenMeteoForecastRange(loadFixture(t, "forecast_range_astana.json")) + require.NoError(t, err) + require.NotEmpty(t, days) + for _, d := range days { + assert.True(t, d.CapturedAt.Equal(days[0].CapturedAt), "day %s carries a different capture instant", d.ForecastDate) + } + }) + + t.Run("an empty daily block is an error, not an empty forecast", func(t *testing.T) { + t.Parallel() + _, err := decodeOpenMeteoForecastRange([]byte(`{"daily":{"time":[]}}`)) + require.Error(t, err) + assert.Contains(t, err.Error(), "daily[] array is empty") + }) + + t.Run("malformed JSON is an error", func(t *testing.T) { + t.Parallel() + _, err := decodeOpenMeteoForecastRange([]byte(`{"daily":`)) + require.Error(t, err) + assert.Contains(t, err.Error(), "decode response") + }) + + t.Run("a short measurement array yields nil, never a zero", func(t *testing.T) { + t.Parallel() + days, err := decodeOpenMeteoForecastRange([]byte(`{"daily":{ + "time":["2026-08-21","2026-08-22","2026-08-23"], + "temperature_2m_max":[21.5], + "rain_sum":[0.0,1.3] + }}`)) + require.NoError(t, err) + require.Len(t, days, 3) + require.NotNil(t, days[0].TempMax) + assert.InDelta(t, 21.5, *days[0].TempMax, 1e-6) + assert.Nil(t, days[1].TempMax, "a short array must not read back as 0 °C") + assert.Nil(t, days[2].TempMax) + require.NotNil(t, days[1].RainSum) + assert.InDelta(t, 1.3, *days[1].RainSum, 1e-6) + assert.Nil(t, days[2].RainSum, "a short array must not read back as 0 mm of rain") + assert.Nil(t, days[0].TempMin, "an absent array must not read back as 0 °C") + }) + + t.Run("a JSON null yields nil, never a zero", func(t *testing.T) { + t.Parallel() + days, err := decodeOpenMeteoForecastRange([]byte(`{"daily":{ + "time":["2026-08-21","2026-08-22"], + "rain_sum":[null,2.5], + "snowfall_sum":[1.5,null], + "precipitation_probability_max":[null,40], + "weather_code":[null,71] + }}`)) + require.NoError(t, err) + require.Len(t, days, 2) + assert.Nil(t, days[0].RainSum) + assert.Nil(t, days[1].SnowfallSum) + assert.Nil(t, days[0].PrecipProbMax) + assert.Nil(t, days[0].WeatherCode) + require.NotNil(t, days[1].RainSum) + assert.InDelta(t, 2.5, *days[1].RainSum, 1e-6) + require.NotNil(t, days[1].WeatherCode) + assert.Equal(t, 71, *days[1].WeatherCode) + }) + + t.Run("a day with no calendar date is skipped", func(t *testing.T) { + t.Parallel() + days, err := decodeOpenMeteoForecastRange([]byte(`{"daily":{ + "time":["2026-08-21","","2026-08-23"], + "rain_sum":[0.0,0.0,3.0] + }}`)) + require.NoError(t, err) + require.Len(t, days, 2) + assert.Equal(t, "2026-08-21", days[0].ForecastDate) + assert.Equal(t, "2026-08-23", days[1].ForecastDate) + }) + + t.Run("an upstream failure propagates", func(t *testing.T) { + t.Parallel() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusBadRequest) + })) + t.Cleanup(srv.Close) + + om := newTestOpenMeteo(t, srv.URL, srv.URL) + _, err := om.ForecastRange(t.Context(), 51.1801, 71.446) + require.Error(t, err) + }) +} diff --git a/internal/infrastructure/weather/testdata/forecast_range_astana.json b/internal/infrastructure/weather/testdata/forecast_range_astana.json new file mode 100644 index 0000000..b0934ab --- /dev/null +++ b/internal/infrastructure/weather/testdata/forecast_range_astana.json @@ -0,0 +1,165 @@ +{ + "latitude": 51.142353, + "longitude": 71.41831, + "generationtime_ms": 2.072930335998535, + "utc_offset_seconds": 18000, + "timezone": "Asia/Almaty", + "timezone_abbreviation": "GMT+5", + "elevation": 357.0, + "daily_units": { + "time": "iso8601", + "temperature_2m_max": "°C", + "temperature_2m_min": "°C", + "rain_sum": "mm", + "snowfall_sum": "cm", + "precipitation_sum": "mm", + "precipitation_probability_max": "%", + "weather_code": "wmo code" + }, + "daily": { + "time": [ + "2026-08-21", + "2026-08-22", + "2026-08-23", + "2026-08-24", + "2026-08-25", + "2026-08-26", + "2026-08-27", + "2026-08-28", + "2026-08-29", + "2026-08-30", + "2026-08-31", + "2026-09-01", + "2026-09-02", + "2026-09-03", + "2026-09-04", + "2026-09-05" + ], + "temperature_2m_max": [ + 21.5, + 24.3, + 22.0, + 21.3, + 22.4, + 24.1, + 26.8, + 27.9, + 23.4, + 18.5, + 20.0, + 18.6, + 17.3, + 19.5, + 17.0, + 18.2 + ], + "temperature_2m_min": [ + 11.2, + 12.6, + 14.6, + 12.4, + 13.6, + 13.4, + 14.5, + 17.9, + 15.4, + 11.9, + 9.1, + 10.2, + 6.6, + 7.7, + 10.2, + 8.2 + ], + "rain_sum": [ + 0.0, + 0.0, + 1.3, + 1.3, + 0.0, + 0.0, + 0.0, + 0.0, + 0.6, + 1.8, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + "snowfall_sum": [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + "precipitation_sum": [ + 0.0, + 0.0, + 2.3, + 2.4, + 0.9, + 0.0, + 0.0, + 0.0, + 2.4, + 2.4, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + "precipitation_probability_max": [ + 0, + 4, + 61, + 45, + 20, + 14, + 0, + 10, + 8, + 12, + 16, + 18, + 7, + 8, + 7, + 10 + ], + "weather_code": [ + 2, + 3, + 53, + 55, + 51, + 0, + 3, + 3, + 51, + 51, + 0, + 1, + 0, + 3, + 3, + 3 + ] + } +} From 675d6403e9d65d83f3f4ef75bcb1d82b4c15c1c6 Mon Sep 17 00:00:00 2001 From: prorochestvo Date: Fri, 21 Aug 2026 13:48:14 +0500 Subject: [PATCH 04/22] feat(weather): send a daily digest of the multi-week outlook Adds the forecast_outlook notify kind: once per local day, at the hour the subscription already carries, the notifier compares the outlook it would send against the one stored in the new notify_state column and queues a message only when they differ. Days that are new or changed carry a marker; days that stopped being notable are listed as cleared. Content-gating rather than a latch is what makes far days notifiable at all. 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. The cursor advances on every evaluation that had data, so the bound of one message per city per day does not depend on how often the collector refreshes. The historical backfill-migration tests no longer seed through WeatherUserCityRepository. Its SQL names the columns of the current schema, so a test pinning a frozen snapshot broke on the new column and would have broken on every column after it. Refs: #127 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016yN2eof4KJTh8oAwnoE8rg --- cmd/notifier/main.go | 5 + .../notification/weathercheckagent.go | 141 +++++- .../notification/weathercheckagent_test.go | 405 +++++++++++++++--- .../application/notification/weatherrender.go | 70 +++ .../notification/weatherrender_test.go | 101 +++++ internal/domain/weatherforecast.go | 13 + internal/domain/weatheruser.go | 37 +- internal/repository/main_test.go | 107 +++++ internal/repository/weatherusercity.go | 38 +- .../weatherusercity_backfill_test.go | 28 +- .../weatherusercity_backfillrain_test.go | 28 +- ...weatherusercity_dropgismeteocityid_test.go | 2 +- internal/repository/weatherusercity_test.go | 86 ++++ ...4.weather_user_cities.add_notify_state.sql | 16 + 14 files changed, 966 insertions(+), 111 deletions(-) create mode 100644 migrations/202608.034.weather_user_cities.add_notify_state.sql 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/internal/application/notification/weathercheckagent.go b/internal/application/notification/weathercheckagent.go index 0d05278..0631475 100644 --- a/internal/application/notification/weathercheckagent.go +++ b/internal/application/notification/weathercheckagent.go @@ -33,30 +33,34 @@ import ( // It reuses the existing FX notification queue (rate_user_events) with an empty // SourceName → NULL so there is no FK dependency on rate_sources. type WeatherCheckAgent struct { - cityRepo weatherCheckCityRepository - obsRepo weatherCheckObsRepository - eventRepo rateCheckEventRepository // reuse the same narrow interface as RateCheckAgent - logger io.Writer + cityRepo weatherCheckCityRepository + obsRepo weatherCheckObsRepository + forecastRepo weatherCheckForecastRepository + eventRepo rateCheckEventRepository // reuse the same narrow interface as RateCheckAgent + logger io.Writer } -// NewWeatherCheckAgent constructs a WeatherCheckAgent. All arguments are required. +// NewWeatherCheckAgent constructs a WeatherCheckAgent. All repository arguments are +// required; a nil logger discards output. func NewWeatherCheckAgent( cityRepo weatherCheckCityRepository, obsRepo weatherCheckObsRepository, + forecastRepo weatherCheckForecastRepository, eventRepo rateCheckEventRepository, logger io.Writer, ) (*WeatherCheckAgent, error) { - if cityRepo == nil || obsRepo == nil || eventRepo == nil { - return nil, errors.New("weather check agent: cityRepo, obsRepo, and eventRepo are all required") + if cityRepo == nil || obsRepo == nil || forecastRepo == nil || eventRepo == nil { + return nil, errors.New("weather check agent: cityRepo, obsRepo, forecastRepo, and eventRepo are all required") } if logger == nil { logger = io.Discard } return &WeatherCheckAgent{ - cityRepo: cityRepo, - obsRepo: obsRepo, - eventRepo: eventRepo, - logger: logger, + cityRepo: cityRepo, + obsRepo: obsRepo, + forecastRepo: forecastRepo, + eventRepo: eventRepo, + logger: logger, }, nil } @@ -260,12 +264,116 @@ func (a *WeatherCheckAgent) Run(ctx context.Context) error { } } + outlookQueued, outlookAttempted, outlookQuiet, outlookErrs := a.runOutlookPhase(ctx, now) + errs = append(errs, outlookErrs...) + // Proof-of-execution marker matching RateCheckAgent's pattern. - fmt.Fprintf(a.logger, "weather check: queued %d/%d events (alerts: %d/%d suppressed: %d)\n", - totalQueued, totalAttempted, alertQueued, alertAttempted, alertSuppressed) + fmt.Fprintf(a.logger, "weather check: queued %d/%d events (alerts: %d/%d suppressed: %d) (outlook: %d/%d quiet: %d)\n", + totalQueued, totalAttempted, alertQueued, alertAttempted, alertSuppressed, + outlookQueued, outlookAttempted, outlookQuiet) return errors.Join(errs...) } +// runOutlookPhase delivers the multi-week forecast digest for every forecast_outlook +// subscription that is due in its own local day, and returns what it did for the run's log +// line plus every error it survived. +// +// The digest is content-gated, not edge-triggered. 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 the phase compares +// the outlook's signature with the one stored on the row and sends only when they differ. +// +// The per-day 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 property the fetch cadence should not be +// able to take away by changing. +func (a *WeatherCheckAgent) runOutlookPhase(ctx context.Context, now time.Time) (queued, attempted, quiet int, errs []error) { + cities, err := a.cityRepo.ObtainDueWeatherUserCities(ctx, domain.WeatherNotifyForecastOutlook) + if err != nil { + return 0, 0, 0, []error{errors.Join( + fmt.Errorf("weather outlook: load due cities: %w", err), + loginjector.NewTraceError(), + )} + } + + for _, city := range cities { + due, tzErr := city.IsMorningDue(now) + if tzErr != nil { + fmt.Fprintf(a.logger, "weather outlook: city %s: timezone error: %v\n", city.ID, tzErr) + continue + } + if !due { + continue + } + + baseline, dateErr := city.LocalDate(now) + if dateErr != nil { + fmt.Fprintf(a.logger, "weather outlook: city %s: local date: %v\n", city.ID, dateErr) + continue + } + + days, loadErr := a.forecastRepo.ObtainForecastDays(ctx, city.LocationID, domain.ProviderOpenMeteo, baseline, domain.WeatherOutlookHorizonDays) + if loadErr != nil { + errs = append(errs, fmt.Errorf("weather outlook city=%s: load forecast: %w", city.ID, loadErr)) + continue + } + if len(days) == 0 { + // Nothing collected for this location yet. Do NOT advance the cursor, so the + // first digest fires once the collector has stored a forecast — the same rule + // the morning-summary phase follows for a missing observation. + fmt.Fprintf(a.logger, "weather outlook: city %s location %s: no forecast yet, skipping\n", city.ID, city.LocationID) + continue + } + + outlook := domain.NewWeatherOutlook(days, baseline) + signature := outlook.Signature() + + // Two quiet cases. The outlook is unchanged since the last digest; or this is the + // first evaluation and there is nothing to report, where an opening message saying + // "nothing" would be the worst possible introduction to a notification channel. + if signature == city.NotifyState || (city.NotifyState == "" && len(outlook.NotableDays()) == 0) { + if city.NotifyState != signature { + if setErr := a.cityRepo.SetWeatherNotifyState(ctx, city.ID, signature); setErr != nil { + errs = append(errs, fmt.Errorf("weather outlook city=%s: persist state: %w", city.ID, setErr)) + } + } + if advErr := a.cityRepo.AdvanceLastNotifiedAt(ctx, city.ID, now); advErr != nil { + errs = append(errs, fmt.Errorf("weather outlook city=%s: advance last_notified_at: %w", city.ID, advErr)) + } + quiet++ + continue + } + + msg, renderErr := RenderForecastOutlook(city, outlook, city.NotifyState) + if renderErr != nil { + errs = append(errs, fmt.Errorf("weather outlook city=%s: render: %w", city.ID, renderErr)) + continue + } + + ev := &domain.RateUserEvent{ + UserType: domain.UserTypeTelegram, + UserID: city.UserID, + Message: msg, + // SourceName empty → stored as NULL; same transport as the morning summary. + } + attempted++ + if retainErr := a.eventRepo.RetainRateUserEvent(ctx, ev); retainErr != nil { + errs = append(errs, fmt.Errorf("weather outlook city=%s: queue event: %w", city.ID, retainErr)) + continue // neither the state nor the cursor moves; the next tick retries + } + queued++ + + if setErr := a.cityRepo.SetWeatherNotifyState(ctx, city.ID, signature); setErr != nil { + errs = append(errs, fmt.Errorf("weather outlook city=%s: persist state: %w", city.ID, setErr)) + } + if advErr := a.cityRepo.AdvanceLastNotifiedAt(ctx, city.ID, now); advErr != nil { + errs = append(errs, fmt.Errorf("weather outlook city=%s: advance last_notified_at: %w", city.ID, advErr)) + } + } + + return queued, attempted, quiet, errs +} + // loadCachedObservation returns the latest Open-Meteo observation for locationID, // using obsCache to avoid redundant DB reads within a single Run call. When the // observation is absent (ErrNotFound) the result is recorded in obsNotFound and @@ -303,9 +411,16 @@ type weatherCheckCityRepository interface { ObtainDueWeatherUserCities(ctx context.Context, notifyKind domain.WeatherNotifyKind) ([]domain.WeatherUserCity, error) AdvanceLastNotifiedAt(ctx context.Context, id string, when time.Time) error SetWeatherAlertLatched(ctx context.Context, id string, latched bool) error + SetWeatherNotifyState(ctx context.Context, id, state string) error MarkWeatherAlertFired(ctx context.Context, id string, firedForDate time.Time) error } +// weatherCheckForecastRepository is the narrow long-range-forecast surface the check agent +// needs for the outlook digest. +type weatherCheckForecastRepository interface { + ObtainForecastDays(ctx context.Context, locationID, provider, fromDate string, limit int) ([]domain.WeatherForecastDay, error) +} + // weatherCheckObsRepository is the narrow observation-repository surface the check agent needs. type weatherCheckObsRepository interface { ObtainLatestObservation(ctx context.Context, locationID, provider string) (*domain.WeatherObservation, error) diff --git a/internal/application/notification/weathercheckagent_test.go b/internal/application/notification/weathercheckagent_test.go index 3c39391..9a71483 100644 --- a/internal/application/notification/weathercheckagent_test.go +++ b/internal/application/notification/weathercheckagent_test.go @@ -17,10 +17,12 @@ import ( var _ weatherCheckCityRepository = (*mockWeatherCheckCityRepo)(nil) var _ weatherCheckObsRepository = (*mockWeatherCheckObsRepo)(nil) +var _ weatherCheckForecastRepository = (*mockWeatherCheckForecastRepo)(nil) // Compile-time assertions that the concrete repository types satisfy the interfaces. var _ weatherCheckCityRepository = &repository.WeatherUserCityRepository{} var _ weatherCheckObsRepository = &repository.WeatherObservationRepository{} +var _ weatherCheckForecastRepository = &repository.WeatherForecastDayRepository{} func TestNewWeatherCheckAgent(t *testing.T) { t.Parallel() @@ -30,6 +32,7 @@ func TestNewWeatherCheckAgent(t *testing.T) { a, err := NewWeatherCheckAgent( &mockWeatherCheckCityRepo{}, &mockWeatherCheckObsRepo{}, + &mockWeatherCheckForecastRepo{}, &mockCheckEventRepository{}, io.Discard, ) @@ -39,19 +42,25 @@ func TestNewWeatherCheckAgent(t *testing.T) { t.Run("nil cityRepo returns error", func(t *testing.T) { t.Parallel() - _, err := NewWeatherCheckAgent(nil, &mockWeatherCheckObsRepo{}, &mockCheckEventRepository{}, io.Discard) + _, err := NewWeatherCheckAgent(nil, &mockWeatherCheckObsRepo{}, &mockWeatherCheckForecastRepo{}, &mockCheckEventRepository{}, io.Discard) require.Error(t, err) }) t.Run("nil obsRepo returns error", func(t *testing.T) { t.Parallel() - _, err := NewWeatherCheckAgent(&mockWeatherCheckCityRepo{}, nil, &mockCheckEventRepository{}, io.Discard) + _, err := NewWeatherCheckAgent(&mockWeatherCheckCityRepo{}, nil, &mockWeatherCheckForecastRepo{}, &mockCheckEventRepository{}, io.Discard) + require.Error(t, err) + }) + + t.Run("nil forecastRepo returns error", func(t *testing.T) { + t.Parallel() + _, err := NewWeatherCheckAgent(&mockWeatherCheckCityRepo{}, &mockWeatherCheckObsRepo{}, nil, &mockCheckEventRepository{}, io.Discard) require.Error(t, err) }) t.Run("nil eventRepo returns error", func(t *testing.T) { t.Parallel() - _, err := NewWeatherCheckAgent(&mockWeatherCheckCityRepo{}, &mockWeatherCheckObsRepo{}, nil, io.Discard) + _, err := NewWeatherCheckAgent(&mockWeatherCheckCityRepo{}, &mockWeatherCheckObsRepo{}, &mockWeatherCheckForecastRepo{}, nil, io.Discard) require.Error(t, err) }) } @@ -113,10 +122,11 @@ func TestWeatherCheckAgent_Run(t *testing.T) { eventRepo := &mockCheckEventRepository{} a := &WeatherCheckAgent{ - cityRepo: cityRepo, - obsRepo: obsRepo, - eventRepo: eventRepo, - logger: io.Discard, + cityRepo: cityRepo, + obsRepo: obsRepo, + forecastRepo: &mockWeatherCheckForecastRepo{}, + eventRepo: eventRepo, + logger: io.Discard, } require.NoError(t, a.Run(t.Context())) @@ -141,10 +151,11 @@ func TestWeatherCheckAgent_Run(t *testing.T) { eventRepo := &mockCheckEventRepository{} a := &WeatherCheckAgent{ - cityRepo: cityRepo, - obsRepo: obsRepo, - eventRepo: eventRepo, - logger: io.Discard, + cityRepo: cityRepo, + obsRepo: obsRepo, + forecastRepo: &mockWeatherCheckForecastRepo{}, + eventRepo: eventRepo, + logger: io.Discard, } require.NoError(t, a.Run(t.Context())) require.Empty(t, eventRepo.retained) @@ -159,10 +170,11 @@ func TestWeatherCheckAgent_Run(t *testing.T) { eventRepo := &mockCheckEventRepository{} a := &WeatherCheckAgent{ - cityRepo: cityRepo, - obsRepo: obsRepo, - eventRepo: eventRepo, - logger: io.Discard, + cityRepo: cityRepo, + obsRepo: obsRepo, + forecastRepo: &mockWeatherCheckForecastRepo{}, + eventRepo: eventRepo, + logger: io.Discard, } require.NoError(t, a.Run(t.Context())) require.Empty(t, eventRepo.retained, "no event must be queued when no observation exists") @@ -187,10 +199,11 @@ func TestWeatherCheckAgent_Run(t *testing.T) { var logBuf strings.Builder a := &WeatherCheckAgent{ - cityRepo: cityRepo, - obsRepo: obsRepo, - eventRepo: eventRepo, - logger: &logBuf, + cityRepo: cityRepo, + obsRepo: obsRepo, + forecastRepo: &mockWeatherCheckForecastRepo{}, + eventRepo: eventRepo, + logger: &logBuf, } // Must NOT return an error; bad-tz city is skipped with a log line. require.NoError(t, a.Run(t.Context())) @@ -208,10 +221,11 @@ func TestWeatherCheckAgent_Run(t *testing.T) { eventRepo := &mockCheckEventRepository{err: errors.New("db write fail")} a := &WeatherCheckAgent{ - cityRepo: cityRepo, - obsRepo: obsRepo, - eventRepo: eventRepo, - logger: io.Discard, + cityRepo: cityRepo, + obsRepo: obsRepo, + forecastRepo: &mockWeatherCheckForecastRepo{}, + eventRepo: eventRepo, + logger: io.Discard, } err := a.Run(t.Context()) require.Error(t, err) @@ -221,10 +235,11 @@ func TestWeatherCheckAgent_Run(t *testing.T) { t.Run("city repo error is returned immediately", func(t *testing.T) { t.Parallel() a := &WeatherCheckAgent{ - cityRepo: &mockWeatherCheckCityRepo{err: errors.New("db down")}, - obsRepo: &mockWeatherCheckObsRepo{}, - eventRepo: &mockCheckEventRepository{}, - logger: io.Discard, + cityRepo: &mockWeatherCheckCityRepo{err: errors.New("db down")}, + obsRepo: &mockWeatherCheckObsRepo{}, + forecastRepo: &mockWeatherCheckForecastRepo{}, + eventRepo: &mockCheckEventRepository{}, + logger: io.Discard, } require.Error(t, a.Run(t.Context())) }) @@ -241,10 +256,11 @@ func TestWeatherCheckAgent_Run(t *testing.T) { cityRepo := &mockWeatherCheckCityRepo{cities: []domain.WeatherUserCity{city1, city2}} a := &WeatherCheckAgent{ - cityRepo: cityRepo, - obsRepo: obsRepo, - eventRepo: eventRepo, - logger: io.Discard, + cityRepo: cityRepo, + obsRepo: obsRepo, + forecastRepo: &mockWeatherCheckForecastRepo{}, + eventRepo: eventRepo, + logger: io.Discard, } err := a.Run(t.Context()) require.Error(t, err, "joined error must contain the failing location") @@ -288,7 +304,7 @@ func TestWeatherCheckAgent_Run(t *testing.T) { }} eventRepo := &mockCheckEventRepository{} - a := &WeatherCheckAgent{cityRepo: cityRepo, obsRepo: obsRepo, eventRepo: eventRepo, logger: io.Discard} + a := &WeatherCheckAgent{cityRepo: cityRepo, obsRepo: obsRepo, forecastRepo: &mockWeatherCheckForecastRepo{}, eventRepo: eventRepo, logger: io.Discard} require.NoError(t, a.Run(t.Context())) require.Len(t, eventRepo.retained, 1, "one heat alert event must be queued") assert.Contains(t, eventRepo.retained[0].Message, "Heat alert") @@ -330,7 +346,7 @@ func TestWeatherCheckAgent_Run(t *testing.T) { }} eventRepo := &mockCheckEventRepository{} - a := &WeatherCheckAgent{cityRepo: cityRepo, obsRepo: obsRepo, eventRepo: eventRepo, logger: io.Discard} + a := &WeatherCheckAgent{cityRepo: cityRepo, obsRepo: obsRepo, forecastRepo: &mockWeatherCheckForecastRepo{}, eventRepo: eventRepo, logger: io.Discard} require.NoError(t, a.Run(t.Context())) require.Empty(t, eventRepo.retained, "a latched, still-met row must not re-fire") require.Empty(t, cityRepo.fired) @@ -366,7 +382,7 @@ func TestWeatherCheckAgent_Run(t *testing.T) { }} eventRepo := &mockCheckEventRepository{} - a := &WeatherCheckAgent{cityRepo: cityRepo, obsRepo: obsRepo, eventRepo: eventRepo, logger: io.Discard} + a := &WeatherCheckAgent{cityRepo: cityRepo, obsRepo: obsRepo, forecastRepo: &mockWeatherCheckForecastRepo{}, eventRepo: eventRepo, logger: io.Discard} require.NoError(t, a.Run(t.Context())) require.Empty(t, eventRepo.retained, "no event when condition is not met") require.Empty(t, cityRepo.fired) @@ -394,7 +410,7 @@ func TestWeatherCheckAgent_Run(t *testing.T) { obsRepo := &mockWeatherCheckObsRepo{globalErr: internal.ErrNotFound} eventRepo := &mockCheckEventRepository{} - a := &WeatherCheckAgent{cityRepo: cityRepo, obsRepo: obsRepo, eventRepo: eventRepo, logger: io.Discard} + a := &WeatherCheckAgent{cityRepo: cityRepo, obsRepo: obsRepo, forecastRepo: &mockWeatherCheckForecastRepo{}, eventRepo: eventRepo, logger: io.Discard} require.NoError(t, a.Run(t.Context())) require.Empty(t, eventRepo.retained, "no event when observation is absent") require.Empty(t, cityRepo.fired, "must not persist when observation absent") @@ -463,7 +479,7 @@ func TestWeatherCheckAgent_Run(t *testing.T) { }} eventRepo := &mockCheckEventRepository{} - a := &WeatherCheckAgent{cityRepo: cityRepo, obsRepo: obsRepo, eventRepo: eventRepo, logger: io.Discard} + a := &WeatherCheckAgent{cityRepo: cityRepo, obsRepo: obsRepo, forecastRepo: &mockWeatherCheckForecastRepo{}, eventRepo: eventRepo, logger: io.Discard} require.NoError(t, a.Run(t.Context())) require.Len(t, eventRepo.retained, 1) assert.Contains(t, eventRepo.retained[0].Message, "Thunderstorm alert") @@ -497,7 +513,7 @@ func TestWeatherCheckAgent_Run(t *testing.T) { }} eventRepo := &mockCheckEventRepository{} - a := &WeatherCheckAgent{cityRepo: cityRepo, obsRepo: obsRepo, eventRepo: eventRepo, logger: io.Discard} + a := &WeatherCheckAgent{cityRepo: cityRepo, obsRepo: obsRepo, forecastRepo: &mockWeatherCheckForecastRepo{}, eventRepo: eventRepo, logger: io.Discard} require.NoError(t, a.Run(t.Context())) require.Len(t, eventRepo.retained, 1, "one rain alert event must be queued") assert.Contains(t, eventRepo.retained[0].Message, "Rain alert") @@ -532,7 +548,7 @@ func TestWeatherCheckAgent_Run(t *testing.T) { }} eventRepo := &mockCheckEventRepository{} - a := &WeatherCheckAgent{cityRepo: cityRepo, obsRepo: obsRepo, eventRepo: eventRepo, logger: io.Discard} + a := &WeatherCheckAgent{cityRepo: cityRepo, obsRepo: obsRepo, forecastRepo: &mockWeatherCheckForecastRepo{}, eventRepo: eventRepo, logger: io.Discard} require.NoError(t, a.Run(t.Context())) require.Empty(t, eventRepo.retained, "a latched, still-met rain row must not re-fire") require.Empty(t, cityRepo.fired) @@ -563,7 +579,7 @@ func TestWeatherCheckAgent_Run(t *testing.T) { }} eventRepo := &mockCheckEventRepository{} - a := &WeatherCheckAgent{cityRepo: cityRepo, obsRepo: obsRepo, eventRepo: eventRepo, logger: io.Discard} + a := &WeatherCheckAgent{cityRepo: cityRepo, obsRepo: obsRepo, forecastRepo: &mockWeatherCheckForecastRepo{}, eventRepo: eventRepo, logger: io.Discard} require.NoError(t, a.Run(t.Context())) require.Empty(t, eventRepo.retained, "no event when probability below threshold") require.Empty(t, cityRepo.fired) @@ -592,7 +608,7 @@ func TestWeatherCheckAgent_Run(t *testing.T) { }} eventRepo := &mockCheckEventRepository{} - a := &WeatherCheckAgent{cityRepo: cityRepo, obsRepo: obsRepo, eventRepo: eventRepo, logger: io.Discard} + a := &WeatherCheckAgent{cityRepo: cityRepo, obsRepo: obsRepo, forecastRepo: &mockWeatherCheckForecastRepo{}, eventRepo: eventRepo, logger: io.Discard} require.NoError(t, a.Run(t.Context())) require.Empty(t, eventRepo.retained, "no event when hourly data is absent") require.Empty(t, cityRepo.fired) @@ -625,7 +641,7 @@ func TestWeatherCheckAgent_Run(t *testing.T) { }} eventRepo := &mockCheckEventRepository{} - a := &WeatherCheckAgent{cityRepo: cityRepo, obsRepo: obsRepo, eventRepo: eventRepo, logger: io.Discard} + a := &WeatherCheckAgent{cityRepo: cityRepo, obsRepo: obsRepo, forecastRepo: &mockWeatherCheckForecastRepo{}, eventRepo: eventRepo, logger: io.Discard} require.NoError(t, a.Run(t.Context())) require.Len(t, eventRepo.retained, 1, "one thaw alert event must be queued") assert.Contains(t, eventRepo.retained[0].Message, "Thaw alert") @@ -659,7 +675,7 @@ func TestWeatherCheckAgent_Run(t *testing.T) { }} eventRepo := &mockCheckEventRepository{} - a := &WeatherCheckAgent{cityRepo: cityRepo, obsRepo: obsRepo, eventRepo: eventRepo, logger: io.Discard} + a := &WeatherCheckAgent{cityRepo: cityRepo, obsRepo: obsRepo, forecastRepo: &mockWeatherCheckForecastRepo{}, eventRepo: eventRepo, logger: io.Discard} require.NoError(t, a.Run(t.Context())) require.Empty(t, eventRepo.retained, "a latched, still-met thaw row must not re-fire") require.Empty(t, cityRepo.fired) @@ -694,7 +710,7 @@ func TestWeatherCheckAgent_Run(t *testing.T) { }} eventRepo := &mockCheckEventRepository{} - a := &WeatherCheckAgent{cityRepo: cityRepo, obsRepo: obsRepo, eventRepo: eventRepo, logger: io.Discard} + a := &WeatherCheckAgent{cityRepo: cityRepo, obsRepo: obsRepo, forecastRepo: &mockWeatherCheckForecastRepo{}, eventRepo: eventRepo, logger: io.Discard} err := a.Run(t.Context()) require.Error(t, err, "evaluator error for bad city must surface in the returned error") assert.Contains(t, err.Error(), "evaluate", "error must reference the evaluate step") @@ -778,7 +794,7 @@ func TestWeatherCheckAgent_Run(t *testing.T) { }} obsRepo := &mockWeatherCheckObsRepo{obsByProvider: map[string]*domain.WeatherObservation{domain.ProviderOpenMeteo: obs}} eventRepo := &mockCheckEventRepository{} - a := &WeatherCheckAgent{cityRepo: cityRepo, obsRepo: obsRepo, eventRepo: eventRepo, logger: io.Discard} + a := &WeatherCheckAgent{cityRepo: cityRepo, obsRepo: obsRepo, forecastRepo: &mockWeatherCheckForecastRepo{}, eventRepo: eventRepo, logger: io.Discard} require.NoError(t, a.Run(t.Context())) return eventRepo, cityRepo } @@ -837,7 +853,7 @@ func TestWeatherCheckAgent_Run(t *testing.T) { }} obsRepo := &mockWeatherCheckObsRepo{obsByProvider: map[string]*domain.WeatherObservation{domain.ProviderOpenMeteo: obs}} eventRepo := &mockCheckEventRepository{} - a := &WeatherCheckAgent{cityRepo: cityRepo, obsRepo: obsRepo, eventRepo: eventRepo, logger: io.Discard} + a := &WeatherCheckAgent{cityRepo: cityRepo, obsRepo: obsRepo, forecastRepo: &mockWeatherCheckForecastRepo{}, eventRepo: eventRepo, logger: io.Discard} require.NoError(t, a.Run(t.Context())) return eventRepo, cityRepo } @@ -879,7 +895,7 @@ func TestWeatherCheckAgent_Run(t *testing.T) { obsRepo := &mockWeatherCheckObsRepo{obsByProvider: map[string]*domain.WeatherObservation{domain.ProviderOpenMeteo: obs}} eventRepo := &mockCheckEventRepository{} - a := &WeatherCheckAgent{cityRepo: cityRepo, obsRepo: obsRepo, eventRepo: eventRepo, logger: io.Discard} + a := &WeatherCheckAgent{cityRepo: cityRepo, obsRepo: obsRepo, forecastRepo: &mockWeatherCheckForecastRepo{}, eventRepo: eventRepo, logger: io.Discard} require.NoError(t, a.Run(t.Context())) require.Empty(t, eventRepo.retained, "a re-arm must not notify") require.Empty(t, cityRepo.fired) @@ -903,7 +919,7 @@ func TestWeatherCheckAgent_Run(t *testing.T) { obsRepo := &mockWeatherCheckObsRepo{obsByProvider: map[string]*domain.WeatherObservation{domain.ProviderOpenMeteo: obs}} eventRepo := &mockCheckEventRepository{} - a := &WeatherCheckAgent{cityRepo: cityRepo, obsRepo: obsRepo, eventRepo: eventRepo, logger: io.Discard} + a := &WeatherCheckAgent{cityRepo: cityRepo, obsRepo: obsRepo, forecastRepo: &mockWeatherCheckForecastRepo{}, eventRepo: eventRepo, logger: io.Discard} require.NoError(t, a.Run(t.Context())) require.Empty(t, eventRepo.retained) require.Empty(t, cityRepo.fired) @@ -928,7 +944,7 @@ func TestWeatherCheckAgent_Run(t *testing.T) { obsRepo := &mockWeatherCheckObsRepo{obsByProvider: map[string]*domain.WeatherObservation{domain.ProviderOpenMeteo: obs}} eventRepo := &mockCheckEventRepository{err: errors.New("queue write fail")} - a := &WeatherCheckAgent{cityRepo: cityRepo, obsRepo: obsRepo, eventRepo: eventRepo, logger: io.Discard} + a := &WeatherCheckAgent{cityRepo: cityRepo, obsRepo: obsRepo, forecastRepo: &mockWeatherCheckForecastRepo{}, eventRepo: eventRepo, logger: io.Discard} err := a.Run(t.Context()) require.Error(t, err) require.Empty(t, cityRepo.fired, "a queue failure must not mark the alert fired") @@ -954,7 +970,7 @@ func TestWeatherCheckAgent_Run(t *testing.T) { eventRepo := &mockCheckEventRepository{} var logBuf strings.Builder - a := &WeatherCheckAgent{cityRepo: cityRepo, obsRepo: obsRepo, eventRepo: eventRepo, logger: &logBuf} + a := &WeatherCheckAgent{cityRepo: cityRepo, obsRepo: obsRepo, forecastRepo: &mockWeatherCheckForecastRepo{}, eventRepo: eventRepo, logger: &logBuf} require.NoError(t, a.Run(t.Context())) require.Len(t, eventRepo.retained, 1, "an unparseable forecast_date must never drop the alert") require.Empty(t, cityRepo.fired, "the fire cursor cannot be recorded without a valid forecast_date") @@ -995,6 +1011,8 @@ type mockWeatherCheckCityRepo struct { advanced []string // IDs passed to AdvanceLastNotifiedAt latched []latchCall // SetWeatherAlertLatched calls, in call order fired []firedCall // MarkWeatherAlertFired calls, in call order + states []stateCall // SetWeatherNotifyState calls, in call order + stateErr error // when set, every SetWeatherNotifyState call fails } func (m *mockWeatherCheckCityRepo) ObtainDueWeatherUserCities(_ context.Context, kind domain.WeatherNotifyKind) ([]domain.WeatherUserCity, error) { @@ -1028,6 +1046,14 @@ func (m *mockWeatherCheckCityRepo) SetWeatherAlertLatched(_ context.Context, id return nil } +func (m *mockWeatherCheckCityRepo) SetWeatherNotifyState(_ context.Context, id, state string) error { + if m.stateErr != nil { + return m.stateErr + } + m.states = append(m.states, stateCall{id: id, state: state}) + return nil +} + func (m *mockWeatherCheckCityRepo) MarkWeatherAlertFired(_ context.Context, id string, firedForDate time.Time) error { m.fired = append(m.fired, firedCall{id: id, firedForDate: firedForDate}) return nil @@ -1134,7 +1160,7 @@ func TestWeatherCheckAgent_MorningFailureDoesNotSuppressAlerts(t *testing.T) { t.Parallel() cityRepo, obsRepo, eventRepo := newStormFixture() - a := &WeatherCheckAgent{cityRepo: cityRepo, obsRepo: obsRepo, eventRepo: eventRepo, logger: io.Discard} + a := &WeatherCheckAgent{cityRepo: cityRepo, obsRepo: obsRepo, forecastRepo: &mockWeatherCheckForecastRepo{}, eventRepo: eventRepo, logger: io.Discard} err := a.Run(t.Context()) require.Error(t, err, "the morning failure must still be reported") @@ -1151,7 +1177,7 @@ func TestWeatherCheckAgent_MorningFailureDoesNotSuppressAlerts(t *testing.T) { frostErr := errors.New("frost read interrupted") cityRepo.errByKind[domain.WeatherNotifyAlertFrost] = frostErr - a := &WeatherCheckAgent{cityRepo: cityRepo, obsRepo: obsRepo, eventRepo: eventRepo, logger: io.Discard} + a := &WeatherCheckAgent{cityRepo: cityRepo, obsRepo: obsRepo, forecastRepo: &mockWeatherCheckForecastRepo{}, eventRepo: eventRepo, logger: io.Discard} err := a.Run(t.Context()) require.Error(t, err) @@ -1197,7 +1223,7 @@ func TestWeatherCheckAgent_RainBothEdges(t *testing.T) { }} eventRepo := &mockCheckEventRepository{} - a := &WeatherCheckAgent{cityRepo: cityRepo, obsRepo: obsRepo, eventRepo: eventRepo, logger: io.Discard} + a := &WeatherCheckAgent{cityRepo: cityRepo, obsRepo: obsRepo, forecastRepo: &mockWeatherCheckForecastRepo{}, eventRepo: eventRepo, logger: io.Discard} require.NoError(t, a.Run(t.Context())) require.Len(t, eventRepo.retained, 1, "leaving the rain condition must notify, not just re-arm silently") @@ -1229,7 +1255,7 @@ func TestWeatherCheckAgent_RainBothEdges(t *testing.T) { }} eventRepo := &mockCheckEventRepository{} - a := &WeatherCheckAgent{cityRepo: cityRepo, obsRepo: obsRepo, eventRepo: eventRepo, logger: io.Discard} + a := &WeatherCheckAgent{cityRepo: cityRepo, obsRepo: obsRepo, forecastRepo: &mockWeatherCheckForecastRepo{}, eventRepo: eventRepo, logger: io.Discard} require.NoError(t, a.Run(t.Context())) require.Len(t, eventRepo.retained, 1, @@ -1250,7 +1276,7 @@ func TestWeatherCheckAgent_RainBothEdges(t *testing.T) { }} eventRepo := &mockCheckEventRepository{} - a := &WeatherCheckAgent{cityRepo: cityRepo, obsRepo: obsRepo, eventRepo: eventRepo, logger: io.Discard} + a := &WeatherCheckAgent{cityRepo: cityRepo, obsRepo: obsRepo, forecastRepo: &mockWeatherCheckForecastRepo{}, eventRepo: eventRepo, logger: io.Discard} require.NoError(t, a.Run(t.Context())) assert.Empty(t, eventRepo.retained, "the hysteresis band must not produce a message") @@ -1271,7 +1297,7 @@ func TestWeatherCheckAgent_RainBothEdges(t *testing.T) { }} eventRepo := &mockCheckEventRepository{err: errors.New("queue down")} - a := &WeatherCheckAgent{cityRepo: cityRepo, obsRepo: obsRepo, eventRepo: eventRepo, logger: io.Discard} + a := &WeatherCheckAgent{cityRepo: cityRepo, obsRepo: obsRepo, forecastRepo: &mockWeatherCheckForecastRepo{}, eventRepo: eventRepo, logger: io.Discard} err := a.Run(t.Context()) require.Error(t, err) @@ -1305,7 +1331,7 @@ func TestWeatherCheckAgent_RainBothEdges(t *testing.T) { }} eventRepo := &mockCheckEventRepository{} - a := &WeatherCheckAgent{cityRepo: cityRepo, obsRepo: obsRepo, eventRepo: eventRepo, logger: io.Discard} + a := &WeatherCheckAgent{cityRepo: cityRepo, obsRepo: obsRepo, forecastRepo: &mockWeatherCheckForecastRepo{}, eventRepo: eventRepo, logger: io.Discard} require.NoError(t, a.Run(t.Context())) assert.Empty(t, eventRepo.retained, "the forecast_date cap must still hold for heat") @@ -1313,3 +1339,270 @@ func TestWeatherCheckAgent_RainBothEdges(t *testing.T) { assert.True(t, cityRepo.latched[0].latched) }) } + +// stateCall records one SetWeatherNotifyState invocation. +type stateCall struct { + id string + state string +} + +// mockWeatherCheckForecastRepo simulates the long-range forecast repository. days is keyed +// by location_id; a location absent from the map has nothing stored, which is the normal +// state of a city whose first long-range fetch has not completed. +type mockWeatherCheckForecastRepo struct { + days map[string][]domain.WeatherForecastDay + err error +} + +func (m *mockWeatherCheckForecastRepo) ObtainForecastDays(_ context.Context, locationID, _, fromDate string, limit int) ([]domain.WeatherForecastDay, error) { + if m.err != nil { + return nil, m.err + } + stored := m.days[locationID] + out := make([]domain.WeatherForecastDay, 0, len(stored)) + for _, d := range stored { + if d.ForecastDate >= fromDate && len(out) < limit { + out = append(out, d) + } + } + return out, nil +} + +func TestWeatherCheckAgent_OutlookPhase(t *testing.T) { + t.Parallel() + + // outlookCity returns a forecast_outlook subscription that IsMorningDue always + // evaluates true for: UTC, NotifyHour 0, never notified. + outlookCity := func(id, locationID, state string) domain.WeatherUserCity { + return domain.WeatherUserCity{ + ID: id, + UserType: domain.UserTypeTelegram, + UserID: "user1", + LocationID: locationID, + DisplayName: "Astana", + Timezone: "UTC", + NotifyKind: domain.WeatherNotifyForecastOutlook, + NotifyHour: 0, + NotifyState: state, + } + } + + // window returns a stored forecast anchored on today: a dry baseline plus the given + // offsets-from-today, each carrying the rain in millimetres named for it. + window := func(rainByOffset map[int]float64) []domain.WeatherForecastDay { + base := time.Now().UTC() + days := make([]domain.WeatherForecastDay, 0, domain.WeatherOutlookHorizonDays) + for offset := range domain.WeatherOutlookHorizonDays { + maxTemp, minTemp := 21.5, 11.2 + day := domain.WeatherForecastDay{ + Provider: domain.ProviderOpenMeteo, + ForecastDate: base.AddDate(0, 0, offset).Format(time.DateOnly), + CapturedAt: base, + TempMax: &maxTemp, + TempMin: &minTemp, + } + if rain, ok := rainByOffset[offset]; ok { + r := rain + day.RainSum = &r + } + days = append(days, day) + } + return days + } + + agentFor := func(cityRepo *mockWeatherCheckCityRepo, forecastRepo *mockWeatherCheckForecastRepo, eventRepo *mockCheckEventRepository) *WeatherCheckAgent { + return &WeatherCheckAgent{ + cityRepo: cityRepo, + obsRepo: &mockWeatherCheckObsRepo{}, + forecastRepo: forecastRepo, + eventRepo: eventRepo, + logger: io.Discard, + } + } + + outlookOnly := func(cities ...domain.WeatherUserCity) *mockWeatherCheckCityRepo { + return &mockWeatherCheckCityRepo{citiesByKind: map[domain.WeatherNotifyKind][]domain.WeatherUserCity{ + domain.WeatherNotifyForecastOutlook: cities, + }} + } + + t.Run("a first digest with something to report is queued and both cursors move", func(t *testing.T) { + t.Parallel() + cityRepo := outlookOnly(outlookCity("o1", "loc1", "")) + forecastRepo := &mockWeatherCheckForecastRepo{days: map[string][]domain.WeatherForecastDay{ + "loc1": window(map[int]float64{3: 4.2}), + }} + eventRepo := &mockCheckEventRepository{} + + require.NoError(t, agentFor(cityRepo, forecastRepo, eventRepo).Run(t.Context())) + + require.Len(t, eventRepo.retained, 1) + assert.Contains(t, eventRepo.retained[0].Message, "Outlook — Astana") + assert.Contains(t, eventRepo.retained[0].Message, "4.2 mm") + assert.NotContains(t, eventRepo.retained[0].Message, "🆕", "nothing is marked new when there is no previous digest") + require.Len(t, cityRepo.states, 1) + assert.Equal(t, "o1", cityRepo.states[0].id) + assert.NotEmpty(t, cityRepo.states[0].state) + assert.Equal(t, []string{"o1"}, cityRepo.advanced) + }) + + t.Run("a first evaluation with nothing to report stays silent but records the state", func(t *testing.T) { + t.Parallel() + cityRepo := outlookOnly(outlookCity("o1", "loc1", "")) + forecastRepo := &mockWeatherCheckForecastRepo{days: map[string][]domain.WeatherForecastDay{ + "loc1": window(nil), + }} + eventRepo := &mockCheckEventRepository{} + + require.NoError(t, agentFor(cityRepo, forecastRepo, eventRepo).Run(t.Context())) + + assert.Empty(t, eventRepo.retained, "an opening message that says nothing is noise") + require.Len(t, cityRepo.states, 1) + assert.Equal(t, "o1:", cityRepo.states[0].state, "an evaluated-but-empty outlook is not the same as never evaluated") + assert.Equal(t, []string{"o1"}, cityRepo.advanced) + }) + + t.Run("an unchanged outlook sends nothing and still advances the cursor", func(t *testing.T) { + t.Parallel() + days := window(map[int]float64{3: 4.2}) + unchanged := domain.NewWeatherOutlook(days, time.Now().UTC().Format(time.DateOnly)).Signature() + + cityRepo := outlookOnly(outlookCity("o1", "loc1", unchanged)) + forecastRepo := &mockWeatherCheckForecastRepo{days: map[string][]domain.WeatherForecastDay{"loc1": days}} + eventRepo := &mockCheckEventRepository{} + + require.NoError(t, agentFor(cityRepo, forecastRepo, eventRepo).Run(t.Context())) + + assert.Empty(t, eventRepo.retained) + assert.Empty(t, cityRepo.states, "an unchanged signature needs no write") + assert.Equal(t, []string{"o1"}, cityRepo.advanced, "the cursor still moves, so the digest stays once a day") + }) + + t.Run("a changed outlook marks what moved", func(t *testing.T) { + t.Parallel() + before := domain.NewWeatherOutlook(window(map[int]float64{3: 4.2}), time.Now().UTC().Format(time.DateOnly)).Signature() + + cityRepo := outlookOnly(outlookCity("o1", "loc1", before)) + forecastRepo := &mockWeatherCheckForecastRepo{days: map[string][]domain.WeatherForecastDay{ + "loc1": window(map[int]float64{3: 4.2, 7: 2.0}), + }} + eventRepo := &mockCheckEventRepository{} + + require.NoError(t, agentFor(cityRepo, forecastRepo, eventRepo).Run(t.Context())) + + require.Len(t, eventRepo.retained, 1) + assert.Contains(t, eventRepo.retained[0].Message, "🆕", "the day that appeared must be marked") + assert.Equal(t, 1, strings.Count(eventRepo.retained[0].Message, "🆕"), "the day that did not change must not be") + require.Len(t, cityRepo.states, 1) + assert.NotEqual(t, before, cityRepo.states[0].state) + }) + + t.Run("an outlook that empties out reports what cleared", func(t *testing.T) { + t.Parallel() + before := domain.NewWeatherOutlook(window(map[int]float64{3: 4.2}), time.Now().UTC().Format(time.DateOnly)).Signature() + + cityRepo := outlookOnly(outlookCity("o1", "loc1", before)) + forecastRepo := &mockWeatherCheckForecastRepo{days: map[string][]domain.WeatherForecastDay{"loc1": window(nil)}} + eventRepo := &mockCheckEventRepository{} + + require.NoError(t, agentFor(cityRepo, forecastRepo, eventRepo).Run(t.Context())) + + require.Len(t, eventRepo.retained, 1, "an outlook clearing is a change worth reporting") + assert.Contains(t, eventRepo.retained[0].Message, "No rain, snow or freezing change") + require.Len(t, cityRepo.states, 1) + assert.Equal(t, "o1:", cityRepo.states[0].state) + }) + + t.Run("a location with no forecast yet is skipped without advancing", func(t *testing.T) { + t.Parallel() + cityRepo := outlookOnly(outlookCity("o1", "loc1", "")) + eventRepo := &mockCheckEventRepository{} + + require.NoError(t, agentFor(cityRepo, &mockWeatherCheckForecastRepo{}, eventRepo).Run(t.Context())) + + assert.Empty(t, eventRepo.retained) + assert.Empty(t, cityRepo.states) + assert.Empty(t, cityRepo.advanced, "the first digest must still fire once collection catches up") + }) + + t.Run("a city already notified today is not evaluated again", func(t *testing.T) { + t.Parallel() + city := outlookCity("o1", "loc1", "") + city.LastNotifiedAt = time.Now().UTC() + + cityRepo := outlookOnly(city) + forecastRepo := &mockWeatherCheckForecastRepo{days: map[string][]domain.WeatherForecastDay{ + "loc1": window(map[int]float64{3: 4.2}), + }} + eventRepo := &mockCheckEventRepository{} + + require.NoError(t, agentFor(cityRepo, forecastRepo, eventRepo).Run(t.Context())) + + assert.Empty(t, eventRepo.retained, "one digest per city per local day, whatever the tick rate") + assert.Empty(t, cityRepo.advanced) + }) + + t.Run("a queue failure leaves both cursors untouched so the next tick retries", func(t *testing.T) { + t.Parallel() + cityRepo := outlookOnly(outlookCity("o1", "loc1", "")) + forecastRepo := &mockWeatherCheckForecastRepo{days: map[string][]domain.WeatherForecastDay{ + "loc1": window(map[int]float64{3: 4.2}), + }} + eventRepo := &mockCheckEventRepository{err: errors.New("pool is down")} + + err := agentFor(cityRepo, forecastRepo, eventRepo).Run(t.Context()) + + require.Error(t, err) + assert.Empty(t, cityRepo.states) + assert.Empty(t, cityRepo.advanced) + }) + + t.Run("a forecast read failure is reported and does not advance", func(t *testing.T) { + t.Parallel() + cityRepo := outlookOnly(outlookCity("o1", "loc1", "")) + forecastRepo := &mockWeatherCheckForecastRepo{err: errors.New("database is locked")} + eventRepo := &mockCheckEventRepository{} + + err := agentFor(cityRepo, forecastRepo, eventRepo).Run(t.Context()) + + require.Error(t, err) + assert.Contains(t, err.Error(), "load forecast") + assert.Empty(t, cityRepo.advanced) + }) + + t.Run("an unloadable timezone skips the city without failing the run", func(t *testing.T) { + t.Parallel() + city := outlookCity("o1", "loc1", "") + city.Timezone = "Mars/Olympus_Mons" + + cityRepo := outlookOnly(city) + forecastRepo := &mockWeatherCheckForecastRepo{days: map[string][]domain.WeatherForecastDay{ + "loc1": window(map[int]float64{3: 4.2}), + }} + eventRepo := &mockCheckEventRepository{} + + require.NoError(t, agentFor(cityRepo, forecastRepo, eventRepo).Run(t.Context())) + assert.Empty(t, eventRepo.retained) + }) + + t.Run("the outlook phase runs even when the morning-summary read fails", func(t *testing.T) { + t.Parallel() + cityRepo := &mockWeatherCheckCityRepo{ + citiesByKind: map[domain.WeatherNotifyKind][]domain.WeatherUserCity{ + domain.WeatherNotifyForecastOutlook: {outlookCity("o1", "loc1", "")}, + }, + errByKind: map[domain.WeatherNotifyKind]error{ + domain.WeatherNotifyMorningSummary: errors.New("morning read failed"), + }, + } + forecastRepo := &mockWeatherCheckForecastRepo{days: map[string][]domain.WeatherForecastDay{ + "loc1": window(map[int]float64{3: 4.2}), + }} + eventRepo := &mockCheckEventRepository{} + + err := agentFor(cityRepo, forecastRepo, eventRepo).Run(t.Context()) + + require.Error(t, err, "the morning failure must still be reported") + require.Len(t, eventRepo.retained, 1, "one phase failing must not silence another") + }) +} diff --git a/internal/application/notification/weatherrender.go b/internal/application/notification/weatherrender.go index 548ead8..cfda368 100644 --- a/internal/application/notification/weatherrender.go +++ b/internal/application/notification/weatherrender.go @@ -174,3 +174,73 @@ func formatWeatherTemp(v float64) string { // with the U+2212 MINUS SIGN for visual alignment with the FX table style. return fmt.Sprintf("%s%.1f°C", minusSign, -v) } + +// RenderForecastOutlook produces the Telegram HTML multi-week outlook digest for city. +// +// It lists only the days the outlook considers notable — rain, snow, or a change in the +// freezing regime — because a line per day over two weeks is a wall of text whose signal is +// the three lines that differ. Days whose entry is new or has changed since prevSignature +// carry a marker; when prevSignature names no comparable previous state, nothing is marked, +// since marking every line of a first digest tells the reader nothing. +// +// Returns an error only for a city of the wrong kind, which is a wiring bug rather than a +// message. A forecast date that will not parse degrades to the raw date rather than failing +// the digest: the reader loses a weekday name, not the warning. +func RenderForecastOutlook(city domain.WeatherUserCity, outlook domain.WeatherOutlook, prevSignature string) (string, error) { + if city.NotifyKind != domain.WeatherNotifyForecastOutlook { + return "", fmt.Errorf("RenderForecastOutlook: city %s is %q, not %q", city.ID, city.NotifyKind, domain.WeatherNotifyForecastOutlook) + } + + cityName := html.EscapeString(city.DisplayName) + notable := outlook.NotableDays() + + var sb strings.Builder + fmt.Fprintf(&sb, "🗓 Outlook — %s\n", cityName) + + if len(notable) == 0 { + fmt.Fprintf(&sb, "No rain, snow or freezing change in the next %d days.", outlook.AheadDays()) + return sb.String(), nil + } + + change := domain.CompareWeatherOutlookSignatures(prevSignature, outlook.Signature()) + + fmt.Fprintf(&sb, "Next %d days\n", outlook.AheadDays()) + for _, day := range notable { + sb.WriteByte('\n') + if change.Changed[day.ForecastDate] { + sb.WriteString("🆕 ") + } + fmt.Fprintf(&sb, "%s", html.EscapeString(formatForecastDate(day.ForecastDate))) + if day.IsRainDay() && day.RainSum != nil { + fmt.Fprintf(&sb, " 🌧 %.1f mm", *day.RainSum) + } + if day.IsSnowDay() && day.SnowfallSum != nil { + fmt.Fprintf(&sb, " ❄ %.1f cm", *day.SnowfallSum) + } + fmt.Fprintf(&sb, " %s", day.ZeroState().Symbol()) + if day.TempMax != nil && day.TempMin != nil { + fmt.Fprintf(&sb, " %s / %s", formatWeatherTemp(*day.TempMax), formatWeatherTemp(*day.TempMin)) + } + } + + if len(change.Cleared) > 0 { + labels := make([]string, 0, len(change.Cleared)) + for _, date := range change.Cleared { + labels = append(labels, html.EscapeString(formatForecastDate(date))) + } + fmt.Fprintf(&sb, "\n\nCleared: %s", strings.Join(labels, ", ")) + } + + return sb.String(), nil +} + +// formatForecastDate turns a YYYY-MM-DD forecast date into "Sun 23 Aug". The date is already +// city-local, so it is parsed as a bare calendar day and never converted; an unparseable one +// is returned verbatim rather than dropped. +func formatForecastDate(forecastDate string) string { + t, err := time.Parse(time.DateOnly, forecastDate) + if err != nil { + return forecastDate + } + return t.Format("Mon 2 Jan") +} diff --git a/internal/application/notification/weatherrender_test.go b/internal/application/notification/weatherrender_test.go index de922be..043c9c3 100644 --- a/internal/application/notification/weatherrender_test.go +++ b/internal/application/notification/weatherrender_test.go @@ -1,6 +1,7 @@ package notification import ( + "strings" "testing" "time" _ "time/tzdata" // embedded IANA tzdata so LoadLocation works without system tzdata @@ -390,3 +391,103 @@ func TestRenderWeatherAlertEdges(t *testing.T) { } }) } + +func TestRenderForecastOutlook(t *testing.T) { + t.Parallel() + + outlookOf := func(days ...domain.WeatherForecastDay) domain.WeatherOutlook { + baseline := domain.WeatherForecastDay{ForecastDate: "2026-08-21", TempMax: fptr(21.5), TempMin: fptr(11.2)} + return domain.NewWeatherOutlook(append([]domain.WeatherForecastDay{baseline}, days...), "2026-08-21") + } + + city := domain.WeatherUserCity{ + ID: "o1", + DisplayName: "Astana", + Timezone: "UTC", + NotifyKind: domain.WeatherNotifyForecastOutlook, + } + + t.Run("renders a notable day with its date, amount and temperatures", func(t *testing.T) { + t.Parallel() + wet := domain.WeatherForecastDay{ForecastDate: "2026-08-23", TempMax: fptr(22.0), TempMin: fptr(14.6), RainSum: fptr(1.3)} + msg, err := RenderForecastOutlook(city, outlookOf(wet), "") + require.NoError(t, err) + + assert.Contains(t, msg, "Outlook — Astana") + assert.Contains(t, msg, "Sun 23 Aug") + assert.Contains(t, msg, "🌧 1.3 mm") + assert.Contains(t, msg, "+22.0°C") + assert.Contains(t, msg, "▲") + }) + + t.Run("renders snow in centimetres and a freezing day below zero", func(t *testing.T) { + t.Parallel() + snowy := domain.WeatherForecastDay{ForecastDate: "2026-08-25", TempMax: fptr(-2.0), TempMin: fptr(-9.0), SnowfallSum: fptr(4.0)} + msg, err := RenderForecastOutlook(city, outlookOf(snowy), "") + require.NoError(t, err) + + assert.Contains(t, msg, "❄ 4.0 cm") + assert.Contains(t, msg, "▼") + assert.Contains(t, msg, "−9.0°C", "negative temperatures use the Unicode minus sign") + }) + + t.Run("an empty outlook says so instead of listing nothing", func(t *testing.T) { + t.Parallel() + msg, err := RenderForecastOutlook(city, outlookOf(), "o1:2026-08-23:R+") + require.NoError(t, err) + assert.Contains(t, msg, "No rain, snow or freezing change") + assert.NotContains(t, msg, "🌧") + }) + + t.Run("only the days that changed are marked", func(t *testing.T) { + t.Parallel() + kept := domain.WeatherForecastDay{ForecastDate: "2026-08-23", TempMax: fptr(22.0), TempMin: fptr(14.6), RainSum: fptr(1.3)} + added := domain.WeatherForecastDay{ForecastDate: "2026-08-27", TempMax: fptr(19.0), TempMin: fptr(9.0), RainSum: fptr(2.0)} + msg, err := RenderForecastOutlook(city, outlookOf(kept, added), "o1:2026-08-23:R+") + require.NoError(t, err) + + assert.Equal(t, 1, strings.Count(msg, "🆕")) + assert.Contains(t, msg, "🆕 Thu 27 Aug") + }) + + t.Run("days that stopped being notable are listed as cleared", func(t *testing.T) { + t.Parallel() + kept := domain.WeatherForecastDay{ForecastDate: "2026-08-23", TempMax: fptr(22.0), TempMin: fptr(14.6), RainSum: fptr(1.3)} + msg, err := RenderForecastOutlook(city, outlookOf(kept), "o1:2026-08-23:R+;2026-08-27:R+") + require.NoError(t, err) + + assert.Contains(t, msg, "Cleared: Thu 27 Aug") + }) + + t.Run("the city name is HTML-escaped", func(t *testing.T) { + t.Parallel() + hostile := city + hostile.DisplayName = `Astana ` + 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, "`, ZeroState: `" onload="x`}, + }, true) + + assert.NotContains(t, html, "