feat(weather): long-range outlook view and daily digest - #131
Merged
Conversation
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016yN2eof4KJTh8oAwnoE8rg
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016yN2eof4KJTh8oAwnoE8rg
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016yN2eof4KJTh8oAwnoE8rg
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016yN2eof4KJTh8oAwnoE8rg
GET /api/v1/me/weather/current gains a days array per city: one entry per city-local day from today, with the rain and snow verdicts, the freezing classification and the date label all resolved server-side, so every client draws the same badges from the same rule and the WASM bundle still needs no tzdata. No new route and no second round trip. The outlook rides on the endpoint the weather screen already calls, which keeps the Mini App's 2x2 navigation intact. The field is omitted rather than sent empty, so a client that has never seen it renders exactly as before, and it is attached independently of has_data because the reading and the outlook are collected on different cadences. Refs: #127 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016yN2eof4KJTh8oAwnoE8rg
Each city card gains a horizontally scrollable row of day chips: rain and snow badges, the freezing indicator, and the day's range. Dry days keep their chip, because a strip that showed only wet days would leave the reader counting gaps. The freezing state is carried by the glyph as well as the border colour, so the distinction survives a monochrome or colour-blind reading, and an unrecognised state token falls back to a fixed class rather than becoming a class name of its own. The manage screen gains the outlook digest as an addable kind. It is timed rather than thresholded, so it reuses the hour picker instead of the threshold input. Refs: #127 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016yN2eof4KJTh8oAwnoE8rg
Two tripwires go in CLAUDE.md because their violation is silent: a long-range row written into weather_observations is swept at 48 hours with no error, and widening OpenMeteo.Forecast's daily block shifts what daily[0] means for the morning summary and four alert latches. The depth goes into the three skills, and each description gains the symbols that should pull it in — a skill that does not load is knowledge that was not written. Refs: #127 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016yN2eof4KJTh8oAwnoE8rg
A location whose collection has been down long enough for retention to drain the front of its window ends up with rows that are all in the past. NewWeatherOutlook then reports nothing notable, which is indistinguishable from a genuine all-clear, so the digest announced that everything had cleared and claimed to cover the next zero days. Skip it the way a missing forecast is skipped: no message, no cursor, so the digest resumes when collection does. Refs: #127 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016yN2eof4KJTh8oAwnoE8rg
Six findings from the gate, all in code added for the outlook: gocritic read an arrow in three trailing comments as commented-out code, govet caught an err shadow in ObtainLatestForecastCapture whose outer value is read afterwards, and testifylint wanted ErrorIs over a hand-rolled errors.Is assertion and objected to a signature compared against a second call to the same builder. Refs: #127 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016yN2eof4KJTh8oAwnoE8rg
scripts/lint-checks.sh requires every test stub to carry a compile-time interface assertion, since a stub that silently stops satisfying the contract it stands in for is an absence no linter can see. The two stubs added for ForecastLoader were missing theirs. Refs: #127 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016yN2eof4KJTh8oAwnoE8rg
An outlook signature spans the days after the baseline, and the baseline advances every morning. The day that becomes today therefore left the signature on its own, with nothing in the forecast having changed: the content gate read the difference as a change and sent, and the diff reported the arriving day as cleared. A city forecast 4.2 mm of rain was told on the morning it rained that the day had cleared. Compare against the stored signature reduced to the days still ahead. The stored value still advances in the quiet branch, so the following morning prunes from a fresh base. Refs: #127 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01U6jNuzK5PZrhDWBxB3t2gY
The range decoder emitted one row per element of daily.time, capped only by the 1 MiB response limit — roughly fifteen thousand days. Two invariants rested on a bound nothing enforced. The table has no archive tier because it is a bounded working set of locations by sixteen, and retention deletes only the past, so a row dated years out would be permanent and would sit in the read window forever. And a whole fetch is written under one BEGIN IMMEDIATE, which takes the WAL write lock at BEGIN, so an oversized response would hold it against the notifier and the web server for the length of the insert. Truncate at the horizon and drop any date past it, with a day of slack for a city-local date ahead of UTC. A daily block that yields no storable day is now an error rather than an empty success. Reported as a success it left captured_at unmoved while counting the fetch, so the daily gate never closed and the location was re-fetched on every tick behind a log line reading fetched=1 failed=0. Refs: #127 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01U6jNuzK5PZrhDWBxB3t2gY
The digest phase issued one forecast read per due subscription, so the read count grew with subscribers rather than with locations: two users watching the same city, due on the same tick, each opened a transaction for byte-identical rows. The alert phase forty lines above already solves this for observations and the new phase did not reuse the shape. Cache per (location, baseline). The baseline is part of the key because it bounds the query, and two cities sharing a location can sit in different timezones. Refs: #127 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01U6jNuzK5PZrhDWBxB3t2gY
The Telegram digest and the Mini App chip each carried their own copy of the YYYY-MM-DD to "Sun 23 Aug" conversion, identical down to the pass-through on a parse failure. They label the same day for two audiences, so changing the layout string in one would have them disagree with nothing to catch it. Refs: #127 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01U6jNuzK5PZrhDWBxB3t2gY
The three states were mapped to their glyphs twice, in the domain and again in the browser client, and the two copies had already drifted: a day with no usable temperature bounds rendered as a question mark in the Telegram digest and as an em dash in the Mini App. Parse the wire token back into the enum in the client and ask it for the symbol, so one table answers for both surfaces. The em dash wins — the day carries a gap in the data, not a question put to the reader. Refs: #127 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01U6jNuzK5PZrhDWBxB3t2gY
The outlook baseline took its own time.Now inside the per-city loop, so two cities in one response could straddle a midnight and be handed baselines a calendar day apart. Refs: #127 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01U6jNuzK5PZrhDWBxB3t2gY
The forecast read failed the whole response on any error, while the observation read in the same loop tolerates ErrNotFound and carries the city with a nil reading. The plan's acceptance criterion asks for the latter on both. Latent today, since the repository returns an empty slice rather than ErrNotFound for a location with nothing stored. The day it mirrors the observation contract instead, every user with one un-collected city would lose the whole weather screen to it. Refs: #127 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01U6jNuzK5PZrhDWBxB3t2gY
The skipped-date case asserted the two surviving dates and no measurement, so a decoder indexing the parallel arrays by output position rather than by input position — the misalignment the skip creates the opportunity for — satisfied every assertion in it. Refs: #127 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01U6jNuzK5PZrhDWBxB3t2gY
The horizon bound compared city-local forecast dates against a date derived from the UTC clock. Two frames, which is why it needed a slack day and still misread the clock itself: on a host with no battery-backed RTC, a boot before time synchronisation converges would filter a perfectly good response down to nothing and surface it as a decoder error. Measure the window from the response's own first date instead. Same frame, exact, no clock. The absolute ceiling stays, widened to a year, because it guards the one thing a relative bound cannot — a row dated far out is permanent, since retention deletes the past and nothing prunes the future. Refs: #127 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01U6jNuzK5PZrhDWBxB3t2gY
The glyph moved onto the domain enum and the chip modifier class stayed behind as a second table keyed on the same three wire tokens. A fourth state would have been added to the enum and its glyph, and rendered inside an unknown-state chip. Refs: #127 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01U6jNuzK5PZrhDWBxB3t2gY
Symbol and ParseWeatherZeroState were only reachable through the Mini App strip renderer, so the em dash for a day with no temperature bounds was pinned in cmd/wasm and nowhere near the enum — while the Telegram digest renders the same glyph through the same method with no guard at all. Refs: #127 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01U6jNuzK5PZrhDWBxB3t2gY
The skill described the content gate as a plain comparison against notify_state, which stopped being true when the stored signature began being pruned to today's window. Left as it was, someone modelling a second content-gated kind on the documented shape would reship the false "cleared" it was written to prevent. The decode bounds get the same treatment: what they guard, why the window is anchored to the response rather than to the clock, and why an unstorable daily block is an error. That is the path a later ensemble source swap inherits. Refs: #127 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01U6jNuzK5PZrhDWBxB3t2gY
This was referenced Aug 23, 2026
prorochestvo
added a commit
that referenced
this pull request
Aug 23, 2026
All eight tasks shipped in ab7703f (#131). Reviewed on three lenses and re-reviewed over the fixes, with no P0 or P1 on either pass; the ten fixes that came out of it each carry a test verified to fail when the fix is reverted, bar one recorded as unguarded on the issue. One acceptance criterion did not close: CLAUDE.md was to stay under 20k characters and stands at 21133. It was already at 20526 before this work, which added 607 for the forecast tripwire. Compressing the file is its own concern — #135. Refs: #127 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01U6jNuzK5PZrhDWBxB3t2gY
This was referenced Aug 23, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Implements #127: a weather view that answers three questions per day — rain or not, snow or
not, above or below 0 °C — over a 16-day horizon, plus a notification channel for the same
information.
The four decisions this was blocked on
Settled by the owner before any code was written:
v1/forecastendpoint Beacon already calls, with the 35-dayensemble left as a later source swap behind the same table rather than a second parallel
feature.
rain_sum ≥ 1 mm, a snow day issnowfall_sum ≥ 1 cm(Open-Meteo reports rain in millimetres and snowfall in centimetres — verified against a
live response), and the freezing axis is three-state: above (
min > 0), crossing(
min ≤ 0 < max), below (max ≤ 0).What changed
Storage. A new
weather_forecast_daystable, keyed on(location_id, provider, forecast_date)and upserted in place. It is deliberately notweather_observations: the collector sweeps that table bycaptured_atat 48 hours onevery tick, so a row describing a day two weeks out would be deleted a day and a half after
it was written, with no error anywhere. It is not tiered either — the rows are a bounded
working set of
locations × 16, so an*_archivetwin would have nothing to hold.Retention is keyed on
forecast_dateand keeps one day of slack, because UTC offsets runfrom −12 to +14.
Collection.
OpenMeteo.ForecastRangeissues its own request rather than wideningForecast.Forecastdecodes daily index[0], and that index is today for the morningsummary and for all four daily-metric alert latches; changing what it asks for would put
their meaning at risk of a shift that nothing would report. The second request costs roughly
one weighted API call per location per day against a budget of 10,000.
Forecastand itsdecoder are byte-for-byte unchanged — the diff on that file is additions only.
WeatherForecastAgentruns besideWeatherAgentand gates on the UTC calendar day, noton 24 elapsed hours: against an hourly cron the elapsed-time form drifts an hour later every
day and eventually lands after the subscriber's notify hour.
Notification. A new
forecast_outlookkind, opt-in like heat and frost rather thanforced. It is the one kind outside the latch model: 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 notifier compares
the outlook's signature against a new
notify_statecolumn and queues a message only whenit differs, marking the days that moved and naming the ones that cleared. The cursor
advances on every evaluation that had data, which bounds the digest at one message per city
per local day regardless of how often the collector refreshes underneath it.
A day is reported when it brings 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.
API and UI.
GET /api/v1/me/weather/currentgains adaysarray per city, with thethreshold verdicts, the freezing classification and the date label resolved server-side. No
new route and no second round trip: the outlook rides on the endpoint the weather screen
already calls, which keeps the Mini App's 2×2 navigation intact. The field is omitted rather
than sent empty, so a client that has never seen it renders exactly as before. The screen
gains a horizontally scrollable strip of day chips; the freezing state is carried by a glyph
as well as a border colour, so it survives a monochrome reading.
Review
Reviewed on three lenses in parallel — correctness and tests, security and operations,
performance and architecture — then re-reviewed solo over the fixes. No finding was P0 or P1
on either pass. Ten fixes were applied on top; each carries a test verified to fail when the
fix is reverted, except where noted.
The two that mattered:
days after the baseline and the baseline advances every morning, so the day that became
today left the signature on its own with nothing in the forecast having moved. The gate
read that as a change and sent; the diff called the arriving day cleared. A city forecast
4.2 mm of rain was told, on the morning it rained, that the day had cleared — and got one
such message per roll-off day, which in a wet week is the daily-whatever-happens digest the
content gate exists to prevent. The stored signature is now pruned to today's window before
it is compared or rendered, while the new one is stored unpruned. A mechanical walk over 20
consecutive mornings against a frozen dataset produces zero spurious clearings, zero
swallowed changes, and one send per genuinely new day.
cap — roughly 15,000 days — while two invariants rested on a bound nothing enforced: the
table has no archive tier because it is bounded at
locations × 16, and a whole fetch iswritten in one
BEGIN IMMEDIATE, which takes the WAL write lock atBEGIN. The decode nowtruncates at the horizon and drops any date past a window measured from the response's own
first date (the clock is the wrong frame — forecast dates are city-local, and a host whose
time has not synchronised would filter a good response to nothing), with an absolute
one-year ceiling for the one thing a relative bound cannot catch: retention deletes the
past, so a far-future row would be permanent. A
daily[]that yields nothing storable isnow an error rather than a silent success that left
captured_atunmoved, holding thedaily gate open so the location refetched on every tick behind a
fetched=1 failed=0log.The rest: the digest read the forecast once per subscription instead of once per location;
the date formatter and the zero-state glyph table each existed twice, the glyph copies
disagreeing on what to show for a day with no temperature bounds; the weather view took a
fresh clock inside its per-city loop, so two cities could straddle a midnight; the forecast
read failed the whole response where the observation read beside it tolerates
ErrNotFound;and the decoder's skipped-date test asserted dates but no measurement, so an output-position
indexing bug passed it.
Deferred deliberately, each with an issue: the fetch gate throttles successes only, so a
provider outage costs far more than one call per location per day (#132); Open-Meteo
transport errors carry coordinates into the log through
*url.Error(#133); the releaseflips the channel symlink before running migrations (#134); and
CLAUDE.mdis over its own20k budget (#135). Accepted without change: the digest signature encodes flags rather than
amounts, and a signature-version bump re-notifies the quiet cohort once — both are the
plan's explicit design. One fix ships without a regression guard and is called out as such:
hoisting the clock out of the weather view's loop would need a clock seam on the service to
test, which means changing
NewServiceand every caller for one assertion.Notes
weather_forecast_daysholds public meteorological numbers keyed on alocation and has no user column at all.
notify_stateis system-managed dedup state ofthe same nature as the existing
alert_latchedandlast_notified_at. Neither isidentity-adjacent; no policy change was needed.
WeatherUserCityRepository. Its SQL names the columns of the current schema, so a testpinning a frozen snapshot broke on the new column and would have broken on every column
added after it.
-racesuite does not build on the development machine: it is an 8 GB Piwith no swap, and
modernc.org/sqlite/libunder race instrumentation is OOM-killed by thekernel. Every package that does build under
-raceis green, and the whole suite plusgo vetis green without it. CI is the gate for the race build.plans/017-long-range-weather-outlook.md.Refs #127