Skip to content

Native forecast pages — combined stack (reconcile + date picker + data layer + forecast page)#1129

Draft
busbyk wants to merge 48 commits into
native-product-pagesfrom
issue-03-native-forecast-page
Draft

Native forecast pages — combined stack (reconcile + date picker + data layer + forecast page)#1129
busbyk wants to merge 48 commits into
native-product-pagesfrom
issue-03-native-forecast-page

Conversation

@busbyk

@busbyk busbyk commented Jun 30, 2026

Copy link
Copy Markdown
Collaborator

Combined review PR for the native forecast page work. Folds in the previously-stacked PRs so the interdependent changes are reviewed together:

Base: native-product-pages (the long-lived integration branch, kept current with main). Green on pnpm tsc / lint / test (619) / drift:check.

Note: the two Payload migration snapshots (src/migrations/*.json, ~50k lines) dominate the diff — skip them in review.

Issue: .scratch/native-product-pages/issues/03-native-forecast-page.md · Build plan: .scratch/native-product-pages/handoffs/03-native-forecast-page-handoff.md

Done

  • ISR / unfreeze — all-zones route moved off bare force-static to revalidate=300; single-zone lowered 1800→300. Forecast is never frozen at build time.
  • Bottom Line above danger — leads the view, matching the widget.
  • Avalanche Problems (N) heading + Problem #i: {NAME} cards with the four labeled columns (Problem Type / Aspect-Elevation / Likelihood / Size) and the example photo inline-right with its caption.
  • Danger section parity — today/tomorrow side-by-side date-headed columns (stack ≤375px), per-band "N - Name" labels with icons, Elevation Band Descriptions disclosure, and the danger-scale legend (1–5 strip + expandable definitions + explainer link).
  • Validity-date banner — archived on dated views, expired on live (absolute-instant comparison); withdrawn deferred (no v2 model flag).
  • Sanitization already wired everywhere; all-zones grid already complete.
  • Media lightbox pinch-zoom — pinch/scroll/click-to-zoom on forecast photos; carousel drag disabled so panning a zoomed photo doesn't trigger swipe (arrow keys / prev-next still navigate).

Remaining (next pass — carry live-data validation)

  • Weather Summary inline — Weather product schema/model/fetcher/v2 source (verbatim port from ../avy), WeatherTable/WeatherTableV1 shape-detected components, mount between discussion and media (live page only). Gate: validate /v2/public/product/{id} for both table formats.
  • Revalidate-on-view freshnessfetchForecastConditional (no-store, If-None-Match, tolerate 304) + fix the next.tags spread bug + tag fetchForecast; getForecastConditional with server-derived fingerprint ETag; api/[center]/forecast-freshness route handler; ForecastFreshness.client (live-only) → router.refresh(); no-skip invariant. Gate: validate live ETag/304 behavior.

Notes / decisions

  • Bottom Line ordering = above danger by default (matches widget); revertable block-swap pending NWAC/Aaron confirmation.
  • Glossary tooltips deferred to issue 06 (weather-table help is static, not glossary).
  • Migrations recreated per docs/coding-guide.md after merging main: the two Settings-flag migrations are combined into 20260701_224854_native_products_flags with a main-inclusive snapshot.

busbyk and others added 30 commits April 1, 2026 09:57
Port forecast/warning Zod schemas from AvyApp to web package with real API
fixtures from NWAC, SAC, and SNFAC. Covers forecasts, summaries (off-season),
null warnings, string-typed size transforms, and all media type variants.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Adds fetchForecast(), fetchWarning(), and resolveZoneFromSlug() to the
NAC service. All functions apply the DVAC->NWAC center alias and use
5-minute ISR revalidation. Includes unit tests for zone resolution.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Port dangerName, dangerColor, dangerTextColor, dangerIconUrl from avy app.
Copy danger and problem icon PNGs to public/images/. Document cross-repo
color discrepancies in docs/nac-data-display.md for future alignment.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Adds a per-center checkbox under a new "Features" tab in Settings,
defaulting to false. Includes a utility to read the flag by tenant slug,
seed data, migration, and regenerated types.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…onents

Pixel-perfect SVG triangle ported from AvyApp with verbatim path data.
Elevation band rows display label, colored bar, icon, and danger name.
DangerRating composes both into today + tomorrow outlook sections.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…display

Pixel-perfect port of avy/components/DangerRose.tsx converted from react-native-svg
to standard web SVG. 24-sector rose (8 aspects x 3 elevations) with cardinal direction
labels. Active sectors highlighted based on AvalancheProblemLocation array prop.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Port SeverityNumberLine from AvyApp to web SVG. Two exported
components: LikelihoodSlider (single-value) and SizeSlider
(min/max range). Server component, no client JS.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…ussion components

Server components for forecast page text/HTML sections. Includes shared
HTML sanitization utility using isomorphic-dompurify with restrictive
allowlist. WarningBanner uses details/summary for progressive enhancement.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Composite card displaying problem icon/name, locator rose, likelihood
and size sliders, sanitized discussion HTML, and media thumbnail.
Uses local problem icon assets mapped from AvalancheProblemName enum.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Client components using shadcn Dialog + Carousel (Embla) for full-screen
media viewing with image, YouTube, and fallback support.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Class-based error boundary that catches render errors in forecast sections
and displays a styled fallback message. Wrapping of individual sections
happens in page composition.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Assembles all forecast components into a server-rendered page with
per-section error boundaries. Route checks useNativeForecasts feature
flag to toggle between native rendering and legacy widget.
generateMetadata enhanced with real zone name and bottom line in
native mode.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
AllZonesForecast fetches all zone forecasts + warnings in parallel.
ZoneForecastCard renders compact cards reusing WarningBanner, ForecastHeader,
DangerRating, and BottomLine. Route checks useNativeForecasts flag to toggle
between native grid and legacy widget.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…tton

- Remove tenant slug prefix from zone links (middleware already rewrites)
- Parse HTML in elevation band labels and lightbox captions via sanitizeHtml
- Add /images/ to middleware exclusion so danger/problem icons load
- Add closeClassName prop to Dialog for visible lightbox close button
- Formatting fixes from prettier

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Brings the completed native avalanche-forecast implementation forward from
origin/replace-widgets (14 additive commits; retired beads epic monorepo-9ha)
behind the per-tenant `useNativeForecasts` Settings flag. Issue 01 of the
native-product-pages PRD; gates issues 02-05 and 08.

Base: branched off origin/tooling-domain-context (matches current main; the
fallow dead-code suggestions are deferred to a later reconciliation pass).

Hand-merged conflicts (4):
- services/nac/nac.ts: union of main's getMapLayer/getForecastZoneDanger and
  RW's fetchForecast/fetchWarning; deduped DVAC mapping via normalizeCenterSlug.
- [zone]/page.tsx: native/widget branch gated on getUseNativeForecasts; kept
  main's revalidate=1800 + dynamic OG image and enriched og:description with the
  forecaster's bottom_line when native is on.
- all-zones page.tsx: native/widget branch; kept main's simplified NACWidget.
- migrations/index.ts: kept main's migrations, dropped RW's stale entry.

Migration: regenerated the Settings flag migration against current schema
(27.8k-line snapshot vs RW's stale 22.4k that predated 6 migrations landed since
the fork). Safe additive `ALTER TABLE settings ADD use_native_forecasts`.

Sanitization: the native forecast components sanitize HTML server-side. RW used
isomorphic-dompurify, but main had dropped it (its only consumer was a client
component on plain dompurify), and DOMPurify/jsdom does not survive Next's server
bundle. Switched sanitizeHtml to sanitize-html (pure JS, no jsdom) so it works in
both the server components and the one client consumer.

Verified: pnpm tsc / lint / test (528) / drift green; migrate + seed from scratch
succeed; native single-zone + all-zones pages server-render with live NAC data
(HTTP 200, no jsdom error), flag-off centers still serve the widget, flag defaults
off and toggles per tenant.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…clickable cards, detail header

Iterating on the reconciled native forecast page:
- ForecastHeader: render as a plain block instead of its own Card (it was a
  card-in-card inside the all-zones zone card); relabel "Published" -> "Issued".
- Format issued/expires in the avalanche center's timezone (NAC metadata
  `timezone` field) via formatDateTime, instead of the server's UTC.
- All-zones ZoneForecastCard: make the whole card a single click target
  (stretched link to the zone detail page; warning banner kept interactive).
- NativeForecastPage: add a zone-name <h1> header with an "Avalanche Forecast" /
  "Seasonal Summary" subtitle (the off-season product is product_type=summary).

Note: NWAC currently returns a `summary` product (daily forecasts ended ~Apr 20,
expires Oct 31) - there is no daily forecast in the off-season, so the page shows
the seasonal statement (hazard_discussion) without danger ratings or problems.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The reconciliation commit used --no-verify (to bypass the additive-ALTER
migration-safety warning), which also skipped lint-staged's prettier pass.
Apply that formatting so pnpm-lock.yaml and the new migration's JSON snapshot
match the repo's prettier-formatted convention and keep the branch diff clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The AFP product_type for off-season products is `summary`, but that is not a
user-facing label — NWAC titles the content itself (e.g. "2026 Spring Statement"
in the hazard_discussion). So only show the "Avalanche Forecast" subtitle for
actual `forecast` products; summary products carry their own heading in the
discussion. Avoids imposing terminology the AFP doesn't use.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Forecast HTML comes from the NAC API, so any absolute (or protocol-relative)
link in it points off the AvyWeb tenant site. sanitizeHtml now adds
target="_blank" rel="noopener noreferrer" to those via sanitize-html's
transformTags, leaving relative links in the same tab. Adds a server test
covering external/relative link handling and tag stripping.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The native detail and all-zones pages used `mx-auto max-w-4xl px-4 sm:px-6`, which
is narrower with different padding than the rest of the site. The breadcrumbs, the
widget path in these same routes, and all content pages use the Tailwind
`container` class. Switch both native wrappers to `container space-y-6 py-6` so the
content width lines up with the header/breadcrumb and the legacy widget.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds a danger-colored calendar date picker to the native single-zone
forecast page so readers can browse any past forecast/statement for the
zone. Default (no date) shows the current product; picking a date renders
that historical product at a shareable, server-rendered dated URL
(/forecasts/avalanche/[zone]/[date]). Implements issue 09.

Data layer
- Product list schema + archiveDates: noon valid-date rule (center tz),
  dedupe same-date products to the latest publish, carry danger_rating.
- fetchProductArchive narrows server-side via date_start/date_end (the
  params avy uses; type/zone_id are ignored), fetches uncached + trims to
  a small slice, then caches via unstable_cache (the full archive is ~13MB
  for NWAC, over Next's 2MB fetch-cache limit).
- fetchProductById for the historical product (immutable, long cache);
  kept view-only, not issue 02's adapter.
- GET /api/[center]/forecast-archive?zone&from&to for lazy month loading.

Pages
- New on-demand dated route [zone]/[date] (immutable ISR, noindex).
- Extracted shared NativeForecastView; live + dated pages compose it.
- Live page anchors the picker window on the current product's date (not
  today) so off-season the calendar opens populated on that month.

Picker (ForecastDatePicker.client)
- shadcn Calendar in a popover; days colored by danger_rating via the
  shared dangerColor scale, no-product days disabled, current/selected day
  outlined; calendar lazy-loads older months' colors on demand.
- Days, prev/next arrows, and "Current forecast" are real Next <Link>s so
  the global nextjs-toploader handles navigation feedback (its bar only
  starts on anchor clicks, not router.push).

Verified live at desktop + 375px; tsc/lint/test (541) pass.
ADR 017 records the native forecast-glossary design: a national shared
GlossaryTerms collection, client-side marking fed by a cache-tagged
/api/glossary endpoint (decoupled from the forecast page cache), no
per-center gate, and a definition popover with a "Learn more" link.

Adds the "Forecast Glossary" cluster (Glossary Term, Glossary tooltip)
to DOMAIN_CONTEXT.md and indexes 017 (plus back-filled 015/016) in the
decisions README. Re-acknowledges the CLAUDE.md->README drift binding.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Issue 02 of native-product-pages: the data layer every native page consumes.

- Normalized model (services/nac/model/forecast.ts): API-shape-agnostic types
  the components depend on. Owns the top-level product types and reuses the leaf
  domain enums/types from the v2 wire schema. "No active warning" is plain null,
  not v2's null-object, so consumers stop special-casing the miss representation.
- Source adapter (services/nac/sources/): ForecastSource/WarningSource interfaces
  with a v2 implementation that maps the legacy response into the model. Pure,
  unit-tested v2 mappers. Components/pages fetch through the adapter, never a
  raw fetcher.
- Control 2 (data source): per-product v2/v3 selection via code/env config,
  uniform across tenants, defaulting to v2, with a per-center v3 canary allowlist.
- Control 1 (rollout): Settings.useNativeForecasts becomes a per-product
  nativeProducts:{forecast,warning} group; getNativeProductFlag(center,product)
  reader. Migration backfills both flags from the old value; seed updated.

tsc, lint, test (incl. v2->model mapper tests), and drift all green. Migration
verified on a dev.db copy (backfill preserves data before the column drop);
--no-verify only bypasses the ALTER/DROP review gate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A concise, human-facing README covering the normalized model, the per-product
source adapter, the model-vs-wire dependency direction, and the two controls
(rollout vs data source), plus how to extend the layer.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Relocate the data-layer architecture overview from src/services/nac/README.md
to docs/nac-data-layer.md (repo convention: docs live in docs/). Repo-root the
code paths and cross-link with nac-data-display.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
One physical line per paragraph/bullet (no manual hard-wrapping).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Bare force-static froze the all-zones grid (per-zone danger + bottom line)
at build time, which is unsafe for a daily forecast. Move the all-zones route
to ISR (revalidate=300) and lower the single-zone route from 1800 to 300 as a
short freshness backstop ahead of the revalidate-on-view path. The dated
archive route keeps its long immutable backstop.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Match the afp danger section: today and tomorrow render as side-by-side
date-headed columns (each with its own triangle), per-band ratings use the
"{level} - {Name}" format (e.g. "4 - High"), and the section adds an
Elevation Band Descriptions disclosure plus the 1-5 danger-scale legend with
expandable travel-advice/likelihood/size definitions and a link to the full
NAC danger-scale explainer. Columns stack at <=375px. The compact all-zones
card passes no dates, so it keeps the stacked Today/Tomorrow layout.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Title each card "Problem #{rank}: {name}" and lay out the four labeled
columns the widget shows (Problem Type icon + name, Aspect/Elevation rose,
Likelihood, Size). Render the example photo (medium size) floated inline to
the right of the discussion with its sanitized italic caption, instead of a
full-width uncaptioned thumbnail below.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…dates)

Lead with the Bottom Line above the danger rating to match the legacy widget,
head the avalanche problems with the "Avalanche Problems (N)" count, and feed
the published time + timezone into the danger section so today/tomorrow render
under their real valid dates.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
On narrow screens the [triangle][label][rating bar] row was wider than the
viewport, pushing the colored rating bars off the right edge. On mobile, hide
the (redundant) danger triangle and stack each band's elevation label above its
full-width rating bar, so the rating always fits; from sm up, the triangle
returns and the label sits beside the bar. min-w-0 lets the columns shrink
instead of overflowing. The two day columns now go side-by-side at lg (two full
danger displays need the width) rather than at 376px.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…tomorrow)

Rework the danger section to the legacy widget's look: today renders gray rows
with white elevation-name pills over a color-coded triangle, the rating, and the
danger diamond icon; tomorrow is a compact outlook (gray rows, rating + icon, no
triangle), and today is the wider column, so the current day reads as the more
important one. Side by side from lg, stacked below. The compact all-zones card
renders both days compactly. Verified against the afp reference at mobile and
desktop widths with CLS 0.00. Removes the now-unused DangerElevationBand.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The danger diamond icons (3-5 are wider than tall due to the cloud) were forced
into square boxes, squishing them - most visibly next to The Bottom Line. Render
each at its real aspect (fixed height, auto width) using intrinsic per-level
dimensions, which also reserve the space so nothing shifts on load. Give the
compact outlook rows the same min-height as the detailed today rows so the two
day columns line up.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…itions

Replace the Elevation Band Descriptions disclosure with a new-tab link to the
NWAC elevation-bands explainer, and add a shared ExternalLink component (text +
trailing new-tab icon) used for it and the Danger Scale 'Learn more' link, so
reference links out to avalanche.org / nwac.us always read as leaving the site.
Restyle the danger-scale definitions from a bordered card details/summary to a
subtle chevron toggle with the table inline.

The elevation-bands URL is NWAC-specific for now (should come from per-center
config when other centers ship native).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Use the legacy afp ForecastBanner language: 'This is an archived product. View
the most recent forecast.' (with a link back to the live zone forecast) on dated
views, and 'This product is expired.' on an expired live product, with matching
history / alert icons. Omits afp's 'all archived forecasts' link (native has no
all-archives page yet; the date picker on the page covers per-zone history).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
From the afp-vs-native language audit, match the legacy wording:
- Add the scope disclaimer under every product word-for-word ('This information
  is provided by the U.S.D.A. Forest Service ... does not apply to ski areas and
  highways where avalanche mitigation is conducted'), using the center's type
  (USFS -> 'U.S.D.A. Forest Service', else the center name).
- Warning banner: label warnings/watches/special bulletins '... in Effect',
  rename Special -> 'Special Avalanche Bulletin', and surface the Issued/Expires
  validity window (needs the center timezone).
- Danger: show the legacy No Rating advice when today has no rating.
- Weather section heading 'Weather Summary' -> 'Mountain Weather'.
- Product subtitle 'Backcountry Avalanche Forecast' / 'General Avalanche
  Information' (summary), matching afp product titles.
- Restore the two dropped clauses in the Ridgeline Wind Speed weather-table help.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… too

The dated forecast already references the weather product that was current when it
was issued, so fetch that (immutable, by id) and render it — previously the inline
weather was live-page-only, which meant it never showed off-season (when the only
real forecasts are archived ones). Validated against a live SAC archived forecast:
the columns/rows WeatherTable renders correctly.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
# Conflicts:
#	drift.lock
#	src/migrations/index.ts
Introduce ZoomablePhoto (react-zoom-pan-pinch) for still images in
MediaLightbox, reusing the Gallery block's zoom/pan config but rendering a
plain <img> from the external forecast URL. Disable carousel drag so panning a
zoomed photo doesn't trigger swipe-to-navigate; arrow keys and the prev/next
buttons still navigate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@busbyk busbyk changed the title Issue 03: native forecast page parity + freshness (WIP) Native forecast pages — combined stack (reconcile + date picker + data layer + forecast page) Jul 1, 2026
Per docs/coding-guide.md, recreate the branch's migrations after merging main so
the .json snapshot reflects main's schema. Combines the two Settings-flag
migrations (add use_native_forecasts, then rename to native_products_forecast/
_warning) into a single 20260701_224854_native_products_flags that adds the two
native_products columns, with a snapshot including main's tables. Production
never had use_native_forecasts, so the intermediate add/rename/drop is dropped.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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