Weekly view: center the daily-graded window on the target day (#28)

* Pin the °F/°C toggle to the header's top-right on all widths

The unit toggle sat inside .brand after a flex-basis:auto title block, so on
narrow (phone) widths the long tagline claimed the first row and bumped the
toggle onto its own line mid-header. Give the title block flex:1 1 0 with
min-width:0 so it contributes ~nothing to the wrap decision and shrinks
instead — keeping the toggle on the logo's row, top-right, with the tagline
wrapping beneath and the view tabs on their own row below.

* Weekly view: center the daily-graded window on the target day

Reframe the "Daily, graded" chart + table on the weekly view around the
selected target day instead of ending at it:

- Show two weeks of history before the target, the target itself, then up
  to 7 days after it. Days after the target are real observations when they
  are already in the past and the forward forecast when they run into the
  future.
- Mark the target day with a solid orange line on the chart (and an accent
  edge on its table column); draw the dashed "forecast →" divider only where
  the window actually crosses into the future and it is separated from the
  target marker.

Backend: _build_grade now grades a [target-days, target+after] window built
from both the archive and the recent+forecast bundle (the bundle wins per
date; the archive fills older gaps), so any past target renders its
surrounding week. api/grade gains an `after` param (default 7) and the cache
key includes it; the /cell prefetch bundle builds the matching slice.

Frontend: the chart consumes the server-built window directly — the separate
forecast fetch and gap-merge are gone. The target is looked up by date for
the comparison cards (the newest row is now a forecast day), and the graded
table opens centered on the target column.

* Weekly view: extend the after-target window to 14 days

Bump the daily-graded window's after-target span from 7 to 14 days. A target
far enough in the past now shows 14 observed days after it; a recent target
shows its observed days plus the 1-7 available forecast days, and the forecast
naturally caps at ~7 days out. The after cap stays at 14.
This commit is contained in:
Emi Griffith 2026-07-11 07:58:42 -07:00 committed by GitHub
parent d62c3cd61d
commit 36db92022e

39
app.py
View file

@ -164,14 +164,28 @@ def _json_response(body: bytes, etag: str | None = None) -> Response:
# /cell bundle, and the offline migrate script. HTTP semantics (audit runs, cache
# lookups, etags) stay in the endpoints; `run` is the audit run or None.
def _build_grade(cell, target, days, history, recent, cache_meta, place, run=None) -> dict:
"""/grade payload: the last `days` observed days graded against their own
±7-day windows, plus the target day's climatology summary."""
def _build_grade(cell, target, days, history, recent, cache_meta, place, run=None, after=14) -> dict:
"""/grade payload: a window of days centered on the target — `days` of history
before it, the target itself, then up to `after` days after it plus the
target day's climatology summary. Days after the target are observed when they
are already in the past and forecast when they run into the future, so the
weekly view can frame two weeks of history around the orange target marker and
trail off into up to 14 days of observations / forecast after it. The forecast
only reaches ~7 days out, so a recent target naturally yields some observed days
plus 1-7 forecast days, while an older target fills the whole 14 with real obs."""
run = run or _NullRun()
with run.phase("grading"):
# Only grade observed days up to and including the target date (last `days`).
recent = recent[recent["date"] <= target].sort_values("date").tail(days)
graded = _grade_rows(history, recent)
lo = target - pd.Timedelta(days=days)
hi = target + pd.Timedelta(days=after)
# Build the window from both sources. The recent+forecast bundle covers the
# last few weeks plus the forward forecast (the only source for future days);
# the archive reaches decades back for targets older than that bundle. Prefer
# the bundle row for any given date, filling the rest from the archive.
rwin = recent[(recent["date"] >= lo) & (recent["date"] <= hi)]
hwin = history[(history["date"] >= lo) & (history["date"] <= hi)]
hwin = hwin[~hwin["date"].isin(set(rwin["date"]))]
window = pd.concat([rwin, hwin]).sort_values("date")
graded = _grade_rows(history, window)
_attach_dry_streaks(graded, history, recent)
graded.reverse() # newest first for display
climo = grading.climatology(history, int(target.dayofyear))
@ -331,7 +345,10 @@ def api_grade(
lat: float = Query(..., ge=-90, le=90),
lon: float = Query(..., ge=-180, le=180),
date: str | None = Query(None, description="target date YYYY-MM-DD (default today)"),
days: int = Query(14, ge=1, le=60, description="how many recent days to grade"),
days: int = Query(14, ge=1, le=60, description="days of history to grade before the target"),
after: int = Query(14, ge=0, le=14,
description="days to grade after the target (observed, or forecast when future; "
"the forecast reaches ~7 days out so future days cap there)"),
):
if not grid.in_north_america(lat, lon):
raise HTTPException(
@ -365,7 +382,7 @@ def api_grade(
if history.empty:
raise HTTPException(status_code=404, detail="No historical data for this cell.")
key = f"{target.date().isoformat()}:{days}"
key = f"{target.date().isoformat()}:{days}:{after}"
token = f"{PAYLOAD_VER}:{_hist_end(history)}:{climate.recent_stamp(cell['id'])}"
etag = _etag_for("grade", cell["id"], key, token)
if _not_modified(request, etag):
@ -378,7 +395,7 @@ def api_grade(
with run.phase("reverse_geocode"):
place = climate.reverse_geocode(cell["center_lat"], cell["center_lon"])
payload = _build_grade(cell, target, days, history, recent, cache_meta, place, run)
payload = _build_grade(cell, target, days, history, recent, cache_meta, place, run, after=after)
return _json_response(store.put_payload("grade", cell["id"], key, token, payload), etag)
@ -647,8 +664,8 @@ def api_cell(
else:
rf_token = f"{PAYLOAD_VER}:{hist_end}:{climate.recent_stamp(cid)}"
slices["grade"] = slice_for(
"grade", f"{today.date().isoformat()}:14", rf_token,
lambda: _build_grade(cell, today, 14, history, recent, cache_meta, place, run))
"grade", f"{today.date().isoformat()}:14:14", rf_token,
lambda: _build_grade(cell, today, 14, history, recent, cache_meta, place, run, after=14))
slices["forecast"] = slice_for(
"forecast", f"{today.date().isoformat()}:7", rf_token,
lambda: _build_forecast(cell, 7, history, recent, today, place, run))