Skip to content

feat(weather): long-range outlook view and daily digest - #131

Merged
prorochestvo merged 22 commits into
alphafrom
feat/127-long-range-weather-outlook
Aug 23, 2026
Merged

feat(weather): long-range outlook view and daily digest#131
prorochestvo merged 22 commits into
alphafrom
feat/127-long-range-weather-outlook

Conversation

@prorochestvo

@prorochestvo prorochestvo commented Aug 21, 2026

Copy link
Copy Markdown
Owner

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:

  1. 16 days now, on the v1/forecast endpoint Beacon already calls, 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 mm, a snow day is snowfall_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).
  3. Notify as well as view, delivered as a once-a-day digest rather than per-flip alerts.
  4. Refresh once a day.

What changed

Storage. A new weather_forecast_days table, keyed on
(location_id, provider, forecast_date) and upserted in place. It is deliberately not
weather_observations: the collector sweeps that table by captured_at at 48 hours on
every 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 *_archive twin would have nothing to hold.
Retention is keyed on forecast_date and keeps one day of slack, because UTC offsets run
from −12 to +14.

Collection. 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 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. Forecast and its
decoder are byte-for-byte unchanged — the diff on that file is additions only.

WeatherForecastAgent runs beside WeatherAgent and gates on the UTC calendar day, not
on 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_outlook kind, opt-in like heat and frost rather than
forced. 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_state column and queues a message only when
it 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/current gains a days array per city, with the
threshold 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:

  • A day leaving the window read as a change, and as a clearing. A signature spans the
    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.
  • The range decode trusted the upstream array length. Bounded only by the 1 MiB response
    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 is
    written in one BEGIN IMMEDIATE, which takes the WAL write lock at BEGIN. The decode now
    truncates 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 is
    now an error rather than a silent success that left captured_at unmoved, holding the
    daily gate open so the location refetched on every tick behind a fetched=1 failed=0 log.

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 release
flips the channel symlink before running migrations (#134); and CLAUDE.md is over its own
20k 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 NewService and every caller for one assertion.

Notes

  • Privacy: weather_forecast_days holds public meteorological numbers keyed on a
    location and has no user column at all. notify_state is system-managed dedup state of
    the same nature as the existing alert_latched and last_notified_at. Neither is
    identity-adjacent; no policy change was needed.
  • 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
    added after it.
  • The full -race suite does not build on the development machine: it is an 8 GB Pi
    with no swap, and modernc.org/sqlite/lib under race instrumentation is OOM-killed by the
    kernel. Every package that does build under -race is green, and the whole suite plus
    go vet is green without it. CI is the gate for the race build.
  • Plan: plans/017-long-range-weather-outlook.md.

Refs #127

prorochestvo and others added 22 commits August 21, 2026 13:24
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
@prorochestvo
prorochestvo merged commit ab7703f into alpha Aug 23, 2026
1 check passed
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
@prorochestvo
prorochestvo deleted the feat/127-long-range-weather-outlook branch August 30, 2026 16:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant