From 36db92022e4e0e69fcb89efc0c650a13833e183b Mon Sep 17 00:00:00 2001 From: Emi Griffith Date: Sat, 11 Jul 2026 07:58:42 -0700 Subject: [PATCH] Weekly view: center the daily-graded window on the target day (#28) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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. --- app.py | 39 ++++++++++++++++++++++++++++----------- 1 file changed, 28 insertions(+), 11 deletions(-) diff --git a/app.py b/app.py index d61614e..d7dd6a6 100644 --- a/app.py +++ b/app.py @@ -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))