diff --git a/DOMAIN_CONTEXT.md b/DOMAIN_CONTEXT.md index c8eb2251..33391fa5 100644 --- a/DOMAIN_CONTEXT.md +++ b/DOMAIN_CONTEXT.md @@ -98,6 +98,16 @@ A curated, named series or collection of Events with its own page (slugged), e.g **EventTag**: A lightweight, slugged filter label for browsing Events. Categorizes for filtering; not a destination page. +## Forecast Glossary + +This cluster is **national/shared** — standard avalanche vocabulary, not center-specific, so it lives outside per-Tenant isolation (like the Avalanche Education cluster). + +**Glossary Term**: +A standard avalanche-vocabulary entry — the word(s) it matches, a short plain-language definition, and an avalanche.org encyclopedia link. National/shared (one set across all Avalanche Centers), not tenant-scoped. Surfaced as a tooltip when its word appears in forecast prose. Replaces the legacy widget's hardcoded term list (sourced from the avalanche.org encyclopedia, which has no API). + +**Glossary tooltip**: +The rendered hover affordance on a forecast page — a Glossary Term's word marked up in the forecast prose, showing its definition and linking to avalanche.org. Surfaced on every native forecast whenever the national Glossary Terms set is non-empty (no per-Tenant toggle — the vocabulary is universal). Applied client-side over the server-rendered prose, so it never enters the forecast page cache. + ## Content & Site Configuration **Tenant-scoped global**: diff --git a/__tests__/server/archiveDates.server.test.ts b/__tests__/server/archiveDates.server.test.ts new file mode 100644 index 00000000..fc8ef39c --- /dev/null +++ b/__tests__/server/archiveDates.server.test.ts @@ -0,0 +1,112 @@ +import { + buildZoneArchiveDates, + findProductIdForDate, + validDateForProduct, + type ArchiveProductSummary, +} from '@/services/nac/archiveDates' + +const TZ = 'America/Los_Angeles' + +function item( + partial: Partial & Pick, +): ArchiveProductSummary { + return { + product_type: 'forecast', + published_time: '2026-01-10T02:30:00+00:00', + danger_rating: 2, + forecast_zone: [{ id: 1646 }], + ...partial, + } +} + +describe('validDateForProduct', () => { + it('treats an evening-published forecast as the next local day (noon cutover)', () => { + // 2026-04-19T01:30Z = 2026-04-18 18:30 PDT → valid for 2026-04-19 + expect(validDateForProduct('2026-04-19T01:30:00+00:00', TZ)).toBe('2026-04-19') + }) + + it('keeps a morning-published product on the same local day', () => { + // 2026-04-19T16:00Z = 2026-04-19 09:00 PDT → valid for 2026-04-19 + expect(validDateForProduct('2026-04-19T16:00:00+00:00', TZ)).toBe('2026-04-19') + }) + + it('uses the center timezone, not the server timezone, for the cutover', () => { + // 2026-01-10T02:30Z = 2026-01-09 18:30 PST → valid for 2026-01-10 + expect(validDateForProduct('2026-01-10T02:30:00+00:00', TZ)).toBe('2026-01-10') + }) + + it('returns null for an unparseable timestamp', () => { + expect(validDateForProduct('not-a-date', TZ)).toBeNull() + }) +}) + +describe('buildZoneArchiveDates', () => { + it('keeps only renderable products that cover the zone', () => { + const items: ArchiveProductSummary[] = [ + item({ id: 1, published_time: '2026-04-19T01:30:00+00:00' }), + item({ id: 2, product_type: 'synopsis', published_time: '2026-04-18T01:30:00+00:00' }), + item({ + id: 3, + published_time: '2026-04-17T01:30:00+00:00', + forecast_zone: [{ id: 9999 }], + }), + item({ + id: 4, + product_type: 'summary', + published_time: '2026-04-16T01:30:00+00:00', + forecast_zone: [{ id: 1645 }, { id: 1646 }], + }), + ] + + const dates = buildZoneArchiveDates(items, 1646, TZ) + + // synopsis (id 2) and the other-zone product (id 3) are excluded. + expect(dates.map((d) => d.productId)).toEqual([1, 4]) + expect(dates.find((d) => d.productId === 4)?.productType).toBe('summary') + }) + + it('collapses same-date products to the most recently published', () => { + const items: ArchiveProductSummary[] = [ + item({ id: 10, published_time: '2026-02-01T02:00:00+00:00', danger_rating: 1 }), // 2026-01-31 18:00 PST → 2026-02-01 + item({ id: 11, published_time: '2026-02-01T05:00:00+00:00', danger_rating: 3 }), // 2026-01-31 21:00 PST → 2026-02-01 (later) + ] + + const dates = buildZoneArchiveDates(items, 1646, TZ) + + expect(dates).toHaveLength(1) + // The later publication wins, and its danger rating is carried for coloring. + expect(dates[0]).toEqual({ + date: '2026-02-01', + productId: 11, + productType: 'forecast', + dangerRating: 3, + }) + }) + + it('sorts newest date first', () => { + const items: ArchiveProductSummary[] = [ + item({ id: 20, published_time: '2026-01-05T02:30:00+00:00' }), + item({ id: 21, published_time: '2026-01-08T02:30:00+00:00' }), + item({ id: 22, published_time: '2026-01-06T02:30:00+00:00' }), + ] + + const dates = buildZoneArchiveDates(items, 1646, TZ) + + expect(dates.map((d) => d.date)).toEqual(['2026-01-08', '2026-01-06', '2026-01-05']) + }) +}) + +describe('findProductIdForDate', () => { + const dates = [ + { date: '2026-01-08', productId: 21, productType: 'forecast', dangerRating: 2 }, + { date: '2026-01-06', productId: 22, productType: 'forecast', dangerRating: 3 }, + ] + + it('resolves a known date to its product id', () => { + expect(findProductIdForDate(dates, '2026-01-06')).toBe(22) + }) + + it('returns null for a date with no product', () => { + expect(findProductIdForDate(dates, '2026-01-07')).toBeNull() + }) +}) diff --git a/docs/decisions/017-forecast-glossary.md b/docs/decisions/017-forecast-glossary.md new file mode 100644 index 00000000..b90fa9b7 --- /dev/null +++ b/docs/decisions/017-forecast-glossary.md @@ -0,0 +1,43 @@ +# Forecast glossary: national collection, client-side marking decoupled from the page cache + +Date: 2026-06-22 + +Status: accepted + +## Context + +The legacy `afp-public-widgets` forecast widget surfaces a glossary: standard avalanche terms appearing in forecast prose get an underline, a hover tooltip with a plain-language definition, and a link to the matching avalanche.org encyclopedia page. We are rebuilding this on the native forecast page. + +Two facts shaped the design: + +- **There is no avalanche.org glossary API.** The 82 terms (the words/aliases to match, the definition text, and the encyclopedia URL) are hardcoded in the widget's `glossaryTerms.js`. avalanche.org is only the _link target_; the definitions are static copy. +- **Native forecast prose is server-rendered and heavily cached.** The live zone page is SSG + ISR (`revalidate=1800`); the dated archive pages are rendered once and cached `revalidate=2592000` (≈immutable). Forecaster HTML (bottom line, discussion, problem discussions) is sanitized server-side (`sanitize-html`) and injected via `dangerouslySetInnerHTML`. + +We wanted the terms to be editable by NAC staff (not a code deploy), and we did **not** want editing a term to ripple across the forecast page cache. + +## Decision + +- **Carrier: a national, shared `GlossaryTerms` Payload collection** (outside per-Tenant isolation, like the Avalanche Education cluster). Fields: `term` (canonical, required), `aliases[]` (plurals/tenses/synonyms), `definition` (required), `link` (optional avalanche.org URL). Public read; super-admin (global role) write. Chosen over a code constant because the terms are a managed editorial surface, and over a per-tenant collection because the vocabulary is universal — per-center copies would only duplicate. + +- **No per-center gate.** Glossary tooltips render on every native forecast whenever the national set is non-empty. (The widget had a per-center `widget_config.forecast.glossary` flag; we drop it — the vocabulary is universal and a center gains nothing by hiding it.) + +- **Marking is client-side, fed by a dedicated endpoint with its own cache tag.** The forecast page is server-rendered _without_ glossary markup. A client island fetches the term list from `GET /api/glossary` (cached server-side, tagged `glossary`) and marks the already-rendered prose in the browser. Editing a Glossary Term fires `revalidateTag('glossary')`, which purges **only** that endpoint's response — **no forecast page is ever revalidated for a glossary change.** + + - The term list must be fetched by the client island from its own endpoint, **not** passed as a prop from the forecast server component — a prop would be serialized into the cached RSC payload and silently re-couple glossary edits to the page cache. + +- **Scan / match semantics.** Scan the bottom line, forecast discussion, and each problem discussion; descend into `p`, `li`, and `figcaption` only; never mark inside existing links, headings, tables (they carry their own field-info tooltips), figures, or images. Matching is case-insensitive, whole-word, longest-match-first (so "Avalanche Path" wins over "Avalanche"), and marks every occurrence (widget parity). + +- **Interaction.** A marked term is a focusable control that opens a popover containing the definition and a "Learn more on avalanche.org →" link. Hover/focus on desktop, tap on mobile — tapping shows the definition and never navigates away; leaving the forecast is an explicit second action. The mark pass is layout-neutral (decoration/color only, no reflow) and the affordance fades in, so the page paints identically with or without the glossary. + +## Consequences + +- A glossary edit is cheap and contained: one tag purge, zero forecast-page invalidation. Archive pages (≈immutable) always show the _current_ glossary for free, since marking happens at view time in the client. +- Cost: a small client island (matcher + popover lib + the term list, a few KB) and a brief, layout-neutral fade-in of the affordance after hydration. No-JS readers see fully readable prose without tooltips — acceptable, as the glossary is an enhancement. +- The marking decision deviates from "everything else is server-rendered." The driver is cache decoupling, not performance (SSR cost is negligible and amortized by ISR). This is the surprising part a future reader should understand: client-side here is _on purpose_, to keep a frequently-editable national collection out of the forecast page cache. + +## Alternatives considered + +- **Code constant (port `glossaryTerms.js`)** — simplest, no infra, but not editable without a deploy. Rejected: we want NAC staff to manage terms in the CMS. +- **Server-side marking** (mark during render, bake anchors into the HTML) — gives SSR/no-JS links, but couples a cross-tenant collection to the entire forecast page cache: a one-word edit would invalidate every tenant's live + archive forecast pages. The SSR/SEO upside is marginal (the prose text is server-rendered regardless; the only deferred part is outbound links to avalanche.org). Rejected for the coupling. +- **Per-tenant `GlossaryTerms` collection** — rejected: the vocabulary is universal; per-center sets would mostly duplicate and drift. +- **Term list passed as a prop from the server component** — rejected: bakes the list into the cached RSC payload, re-coupling edits to the page cache. diff --git a/docs/decisions/README.md b/docs/decisions/README.md index 1a00b63a..bc4d5d4c 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -23,6 +23,9 @@ Each record loosely follows the [ADR format](https://adr.github.io/) (Context, D | [012](012-non-rbac-access-patterns.md) | Moving from pure RBAC to a hybrid permission model | 2025-11-02 | accepted | | [013](013-hardcoded-tenant-lookup.md) | Hardcoded Tenant Lookup | 2026-01-22 | accepted | | [014](014-built-in-pages-drive-navigation.md) | Built-in pages drive navigation (at provision-time) | 2026-05-04 | accepted | +| [015](015-national-course-catalog.md) | National course catalog | 2026-06-16 | accepted | +| [016](016-per-tenant-globals-as-unique-tenant-collections.md) | Per-tenant globals as unique tenant collections | 2026-06-16 | accepted | +| [017](017-forecast-glossary.md) | Forecast glossary (national collection, client-side marking) | 2026-06-22 | accepted | > **Note:** there are two ADRs numbered `007` ([dynamic tenant middleware](007-dynamic-tenants-middleware.md) and [persistent environments](007-persistent-envs-and-file-storage.md)). The number was reused by accident; both are kept as-is to preserve their stable filenames and any existing links. New ADRs should continue from `015`. diff --git a/drift.lock b/drift.lock index 7d0b4904..1033afe9 100644 --- a/drift.lock +++ b/drift.lock @@ -48,7 +48,7 @@ sig = "670dda646337e58d" [[bindings]] doc = "CLAUDE.md" target = "docs/decisions/README.md" -sig = "247601da82e8c82c" +sig = "dd907c0a43e1fd56" [[bindings]] doc = "CLAUDE.md" diff --git a/src/app/(frontend)/[center]/forecasts/avalanche/[zone]/[date]/page.tsx b/src/app/(frontend)/[center]/forecasts/avalanche/[zone]/[date]/page.tsx new file mode 100644 index 00000000..76ca120d --- /dev/null +++ b/src/app/(frontend)/[center]/forecasts/avalanche/[zone]/[date]/page.tsx @@ -0,0 +1,129 @@ +import type { Metadata } from 'next/types' + +import { NativeForecastView } from '@/components/forecast/NativeForecastView' +import { + buildZoneArchiveDates, + findProductIdForDate, + initialArchiveWindow, + validDateForProduct, +} from '@/services/nac/archiveDates' +import { + fetchForecast, + fetchProductArchive, + fetchProductById, + getAvalancheCenterMetadata, + getAvalancheCenterPlatforms, +} from '@/services/nac/nac' +import { resolveZoneFromSlug } from '@/services/nac/resolveZone' +import { formatZoneName } from '@/utilities/formatZoneName' +import { getUseNativeForecasts } from '@/utilities/getUseNativeForecasts' +import { format, parseISO } from 'date-fns' +import { notFound } from 'next/navigation' + +// Historical products are immutable: render on demand and cache for a long time. This route +// deliberately does NOT run the live revalidate-on-view freshness path — only the current +// forecast page needs that. The long revalidate is a backstop, not a staleness check. +export const revalidate = 2592000 // 30 days +export const dynamicParams = true + +// Matches a YYYY-MM-DD valid date. +const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/ + +type Args = { + params: Promise +} + +type PathArgs = { + center: string + zone: string + date: string +} + +export default async function Page({ params }: Args) { + const { center, zone, date } = await params + + if (!DATE_PATTERN.test(date)) { + notFound() + } + + const avalancheCenterPlatforms = await getAvalancheCenterPlatforms(center) + if (!avalancheCenterPlatforms.forecasts) { + notFound() + } + + // The dated history view is native-only; the legacy widget keeps its own archive. + const useNative = await getUseNativeForecasts(center) + if (!useNative) { + notFound() + } + + const [resolvedZone, metadata] = await Promise.all([ + resolveZoneFromSlug(center, zone), + getAvalancheCenterMetadata(center), + ]) + + if (!resolvedZone) { + notFound() + } + + // The picker window is anchored on the viewed date; older months lazy-load client-side. + const window = initialArchiveWindow(date) + const archive = await fetchProductArchive(center, window) + const initialDates = buildZoneArchiveDates(archive, resolvedZone.zone.id, metadata.timezone) + const productId = findProductIdForDate(initialDates, date) + + if (productId === null) { + notFound() + } + + // The current/live product is fetched only to anchor the picker's "return to current" path. + const [forecastResult, currentProduct] = await Promise.all([ + fetchProductById(productId), + fetchForecast(center, resolvedZone.zone.id), + ]) + + if (!forecastResult) { + return ( +
+ Unable to load this forecast. Please try again later. +
+ ) + } + + const currentDate = currentProduct + ? validDateForProduct(currentProduct.published_time, metadata.timezone) + : null + + return ( + + ) +} + +export async function generateMetadata({ params }: Args): Promise { + const { zone, date } = await params + + const zoneName = formatZoneName(zone) + const dateLabel = DATE_PATTERN.test(date) ? format(parseISO(date), 'MMMM d, yyyy') : date + const title = `${zoneName} - Avalanche Forecast for ${dateLabel}` + + return { + title, + alternates: { + canonical: `/forecasts/avalanche/${zone}/${date}`, + }, + // Thousands of immutable archive pages shouldn't compete with the live page in search. + robots: { index: false, follow: true }, + } +} diff --git a/src/app/api/[center]/forecast-archive/route.ts b/src/app/api/[center]/forecast-archive/route.ts new file mode 100644 index 00000000..ac68978a --- /dev/null +++ b/src/app/api/[center]/forecast-archive/route.ts @@ -0,0 +1,55 @@ +import { buildZoneArchiveDates } from '@/services/nac/archiveDates' +import { fetchProductArchive, getAvalancheCenterMetadata } from '@/services/nac/nac' +import { resolveZoneFromSlug } from '@/services/nac/resolveZone' +import { NextRequest, NextResponse } from 'next/server' + +// Matches a YYYY-MM-DD date. +const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/ + +/** + * Lazy-load source for the forecast date picker's calendar. Returns the zone's published + * forecast dates (with product id + danger rating) within a `from`..`to` window, so the + * client calendar can color additional months on demand without the page ever shipping the + * full archive. The underlying fetch narrows server-side via `date_start`/`date_end`. + */ +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ center: string }> }, +) { + const { center } = await params + const searchParams = request.nextUrl.searchParams + const zoneSlug = searchParams.get('zone') + const from = searchParams.get('from') + const to = searchParams.get('to') + + if ( + !zoneSlug || + !from || + !to || + !DATE_PATTERN.test(from) || + !DATE_PATTERN.test(to) || + from > to + ) { + return NextResponse.json({ error: 'Invalid zone/from/to parameters' }, { status: 400 }) + } + + const [zone, metadata] = await Promise.all([ + resolveZoneFromSlug(center, zoneSlug), + getAvalancheCenterMetadata(center), + ]) + + if (!zone) { + return NextResponse.json({ error: 'Zone not found' }, { status: 404 }) + } + + const archive = await fetchProductArchive(center, { from, to }) + const dates = buildZoneArchiveDates(archive, zone.zone.id, metadata.timezone) + + return NextResponse.json( + { dates }, + { + // Archive windows are effectively immutable; cache hard at the edge. + headers: { 'Cache-Control': 'public, s-maxage=1800, stale-while-revalidate=86400' }, + }, + ) +} diff --git a/src/components/forecast/ForecastDatePicker.client.tsx b/src/components/forecast/ForecastDatePicker.client.tsx new file mode 100644 index 00000000..7040eeb8 --- /dev/null +++ b/src/components/forecast/ForecastDatePicker.client.tsx @@ -0,0 +1,281 @@ +'use client' + +/** + * Date navigation for the native forecast page: a calendar whose days are colored by that + * day's avalanche danger rating (matching the legacy widget), plus prev/next stepping. Built + * for season-replay browsing. The server seeds an initial month window; the calendar + * lazy-loads older months' danger colors on demand from `/api/{center}/forecast-archive` so + * the page never ships the full ~9.6k-product archive. + * + * Each day (and the arrows) is a real Next `` to the dated route, so navigation uses the + * app's global `nextjs-toploader` progress bar — the bar only starts on anchor clicks, not on + * programmatic `router.push`. The dated route resolves the date to a product id server-side. + */ +import { addMonths, endOfMonth, format, parseISO, startOfMonth } from 'date-fns' +import { CalendarIcon, ChevronLeft, ChevronRight, Loader2, MapPin } from 'lucide-react' +import Link from 'next/link' +import { createContext, useContext, useMemo, useState, type ComponentProps } from 'react' +import type { DayButton } from 'react-day-picker' + +import { Button } from '@/components/ui/button' +import { Calendar } from '@/components/ui/calendar' +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' +import { dangerColor, dangerLevelFromRating, dangerTextColor } from '@/services/nac/dangerScale' +import { cn } from '@/utilities/ui' + +export interface ForecastArchiveDate { + /** `YYYY-MM-DD` valid date. */ + date: string + /** Overall danger rating (0-5; -1 = general info) for coloring the day. */ + dangerRating: number +} + +interface ForecastDatePickerProps { + center: string + zoneSlug: string + zoneName: string + /** Tenant-relative zone base path, e.g. `/forecasts/avalanche/west-slopes-north`. */ + basePath: string + /** The shown date (`YYYY-MM-DD`), or null when showing the current/live forecast. */ + selectedDate: string | null + /** Valid date of the current/live product; its link points to the live page. */ + currentDate: string | null + /** Dates (with danger) for the server-rendered initial window. */ + initialDates: ForecastArchiveDate[] + /** The `from`/`to` (YYYY-MM-DD) window covered by initialDates. */ + initialRange: { from: string; to: string } +} + +// The legacy widget's calendar starts at the 2018-19 season. +const ARCHIVE_START = new Date(2018, 8, 1) + +const dayKey = (date: Date) => format(date, 'yyyy-MM-dd') +const monthKey = (date: Date) => format(date, 'yyyy-MM') + +function monthsBetween(from: string, to: string): string[] { + const months: string[] = [] + let cursor = startOfMonth(parseISO(from)) + const end = startOfMonth(parseISO(to)) + while (cursor <= end) { + months.push(monthKey(cursor)) + cursor = addMonths(cursor, 1) + } + return months +} + +/** + * Context feeding the custom day renderer, so `DayLink` can stay a stable module-level + * component (no remount per render) while reading the live ratings map and link targets. + */ +const DayLinkContext = createContext<{ + ratings: Map + hrefFor: (date: string) => string + shownDate: string | null +}>({ ratings: new Map(), hrefFor: () => '#', shownDate: null }) + +const DAY_CELL = + 'flex aspect-square h-full w-full min-w-[--cell-size] items-center justify-center rounded-md text-sm' + +/** Renders a calendar day as a danger-colored Link (or a muted, non-interactive cell if no product). */ +function DayLink({ day, className }: ComponentProps) { + const { ratings, hrefFor, shownDate } = useContext(DayLinkContext) + const key = dayKey(day.date) + const rating = ratings.get(key) + const dayNumber = day.date.getDate() + + if (rating === undefined) { + return ( + + {dayNumber} + + ) + } + + const level = dangerLevelFromRating(rating) + const isChosen = key === shownDate + + return ( + + {dayNumber} + + ) +} + +export function ForecastDatePicker({ + center, + zoneSlug, + zoneName, + basePath, + selectedDate, + currentDate, + initialDates, + initialRange, +}: ForecastDatePickerProps) { + // date (YYYY-MM-DD) → danger rating, accumulated as the user pages into new months. + const [ratings, setRatings] = useState>( + () => new Map(initialDates.map((d) => [d.date, d.dangerRating])), + ) + const [loadedMonths, setLoadedMonths] = useState>( + () => new Set(monthsBetween(initialRange.from, initialRange.to)), + ) + const [loading, setLoading] = useState(false) + const [open, setOpen] = useState(false) + + // The date currently shown (live page falls back to the current product's date). + const shownDate = selectedDate ?? currentDate + const [month, setMonth] = useState(() => + shownDate ? startOfMonth(parseISO(shownDate)) : startOfMonth(new Date()), + ) + + // Selecting the current product's date returns to the live page; any other date is dated. + const hrefFor = (date: string) => + currentDate && date === currentDate ? basePath : `${basePath}/${date}` + + const loadMonth = async (target: Date) => { + const mk = monthKey(target) + if (loadedMonths.has(mk)) return + setLoading(true) + try { + const from = format(startOfMonth(target), 'yyyy-MM-dd') + const to = format(endOfMonth(target), 'yyyy-MM-dd') + const res = await fetch( + `/api/${center}/forecast-archive?zone=${zoneSlug}&from=${from}&to=${to}`, + ) + if (!res.ok) return + const body: { dates?: ForecastArchiveDate[] } = await res.json() + const fetched = body.dates ?? [] + setRatings((prev) => { + const next = new Map(prev) + for (const d of fetched) next.set(d.date, d.dangerRating) + return next + }) + setLoadedMonths((prev) => new Set(prev).add(mk)) + } catch { + // Leave the month unloaded so it can be retried; days stay muted meanwhile. + } finally { + setLoading(false) + } + } + + const handleMonthChange = (next: Date) => { + setMonth(next) + void loadMonth(next) + } + + // Prev/next step to the adjacent loaded date that has a product (the calendar handles + // larger jumps and lazy-loads colors). Disabled when there's no loaded neighbor that way. + const sortedDates = useMemo(() => Array.from(ratings.keys()).sort(), [ratings]) + const olderDate = shownDate ? [...sortedDates].reverse().find((d) => d < shownDate) : undefined + const newerDate = shownDate ? sortedDates.find((d) => d > shownDate) : undefined + const atCurrent = currentDate !== null && shownDate === currentDate + const olderHref = olderDate ? hrefFor(olderDate) : undefined + const newerHref = atCurrent ? undefined : newerDate ? hrefFor(newerDate) : undefined + + const triggerLabel = selectedDate + ? format(parseISO(selectedDate), 'MMM d, yyyy') + : 'Current forecast' + + return ( +
+
+ + + + + + + +
+ + {zoneName} +
+
+ + {/* mode="single" makes react-day-picker render an interactive DayButton per day + (our DayLink); without a mode it renders plain, non-interactive text. */} + + + {loading && ( +
+ +
+ )} +
+ {selectedDate && currentDate && ( +
+ +
+ )} +
+
+ + +
+
+ ) +} + +/** An arrow as a Link (so the global top-loader fires), or a disabled button at the edge. */ +function ArrowLink({ + href, + label, + side, +}: { + href: string | undefined + label: string + side: 'left' | 'right' +}) { + const rounded = side === 'left' ? 'rounded-r-none border-r-0' : 'rounded-l-none border-l-0' + const Icon = side === 'left' ? ChevronLeft : ChevronRight + + if (!href) { + return ( + + ) + } + + return ( + + ) +} diff --git a/src/components/forecast/NativeForecastPage.tsx b/src/components/forecast/NativeForecastPage.tsx index 9303a3eb..1d9ec7fd 100644 --- a/src/components/forecast/NativeForecastPage.tsx +++ b/src/components/forecast/NativeForecastPage.tsx @@ -1,49 +1,30 @@ /** - * Native forecast page composition: assembles all forecast components - * into a single server-rendered page. + * Native forecast page (current/live product): fetches the zone's current forecast, + * any active warning, and the product archive (for the date picker), then hands off + * to the shared NativeForecastView. The dated history route renders the same view with + * a product fetched by id. */ -import { fetchForecast, fetchWarning, getAvalancheCenterMetadata } from '@/services/nac/nac' -import { resolveZoneFromSlug } from '@/services/nac/resolveZone' import { - DangerLevel, - ForecastPeriod, - ProductType, - type Forecast, - type WarningResult, -} from '@/services/nac/types/forecastSchemas' + buildZoneArchiveDates, + initialArchiveWindow, + validDateForProduct, +} from '@/services/nac/archiveDates' +import { + fetchForecast, + fetchProductArchive, + fetchWarning, + getAvalancheCenterMetadata, +} from '@/services/nac/nac' +import { resolveZoneFromSlug } from '@/services/nac/resolveZone' +import { type WarningResult } from '@/services/nac/types/forecastSchemas' -import { AvalancheProblemCard } from './AvalancheProblemCard' -import { BottomLine } from './BottomLine' -import { DangerRating } from './DangerRating' -import { ForecastDiscussion } from './ForecastDiscussion' -import { ForecastErrorBoundary } from './ForecastErrorBoundary' -import { ForecastHeader } from './ForecastHeader' -import { ForecastMediaThumbnails } from './ForecastMediaThumbnails' -import { WarningBanner } from './WarningBanner' +import { NativeForecastView } from './NativeForecastView' interface NativeForecastPageProps { centerSlug: string zoneSlug: string } -/** All danger levels ordered low to high for safe lookup by numeric value. */ -const dangerLevels: DangerLevel[] = [ - DangerLevel.None, - DangerLevel.Low, - DangerLevel.Moderate, - DangerLevel.Considerable, - DangerLevel.High, - DangerLevel.Extreme, -] - -/** Extract the highest danger level from a forecast's current-day danger array. */ -function highestDangerLevel(forecast: Forecast): DangerLevel { - const today = forecast.danger.find((d) => d.valid_day === ForecastPeriod.Current) - if (!today) return DangerLevel.None - const max = Math.max(today.upper, today.middle, today.lower) - return dangerLevels[max] ?? DangerLevel.None -} - /** Narrow a WarningResult to a Warning/Watch/Special or null. */ function toWarningProduct(result: WarningResult | null) { if (!result) return null @@ -53,7 +34,7 @@ function toWarningProduct(result: WarningResult | null) { } export async function NativeForecastPage({ centerSlug, zoneSlug }: NativeForecastPageProps) { - // Metadata gives us the center timezone for rendering issued/expires times. + // Metadata gives us the center timezone for rendering issued/expires times and dates. const [zone, metadata] = await Promise.all([ resolveZoneFromSlug(centerSlug, zoneSlug), getAvalancheCenterMetadata(centerSlug), @@ -76,73 +57,26 @@ export async function NativeForecastPage({ centerSlug, zoneSlug }: NativeForecas ) } - const warning = toWarningProduct(warningResult) - const isForecast = forecastResult.product_type === ProductType.Forecast + // Anchor the picker window on the current product's date, not "today": off-season the + // latest forecast can be months old (e.g. an April summary), and the calendar opens on + // that month — so that's the window we must pre-load, or it renders empty until paged. + const currentDate = validDateForProduct(forecastResult.published_time, metadata.timezone) + const window = initialArchiveWindow(currentDate) + const archive = await fetchProductArchive(centerSlug, window) + const initialDates = buildZoneArchiveDates(archive, zone.zone.id, metadata.timezone) return ( -
- {/* Page header: zone name. The "Avalanche Forecast" subtitle is only shown for - forecast products; summary products carry their own title in the discussion - (e.g. NWAC's "Spring Statement"), so we don't impose a label. */} -
-

{zone.zone.name}

- {isForecast &&

Avalanche Forecast

} -
- - {/* Warning banner */} - - - - - {/* Header: author, issued, expires */} - - - - - {/* Danger rating — only for full forecasts (not summary products) */} - {isForecast && ( - - - - )} - - {/* Bottom line */} - {forecastResult.bottom_line && ( - - - - )} - - {/* Avalanche problems — only for full forecasts */} - {isForecast && - forecastResult.forecast_avalanche_problems.map((problem) => ( - - - - ))} - - {/* Forecast discussion */} - {forecastResult.hazard_discussion && ( - - - - )} - - {/* Forecast-level media */} - {forecastResult.media && forecastResult.media.length > 0 && ( - - - - )} -
+ ) } diff --git a/src/components/forecast/NativeForecastView.tsx b/src/components/forecast/NativeForecastView.tsx new file mode 100644 index 00000000..d74f5655 --- /dev/null +++ b/src/components/forecast/NativeForecastView.tsx @@ -0,0 +1,163 @@ +/** + * Presentational composition for a single forecast/summary product. Shared by the + * live forecast page (current product) and the dated history route (a product fetched + * by id), so both render identically. Pure presentation: it receives already-fetched + * data and renders — no data fetching here. + */ +import type { ZoneArchiveDate } from '@/services/nac/archiveDates' +import type { ActiveForecastZoneWithSlug } from '@/services/nac/nac' +import { + DangerLevel, + ForecastPeriod, + ProductType, + type Forecast, + type ForecastResult, + type Special, + type Warning, + type Watch, +} from '@/services/nac/types/forecastSchemas' + +import { AvalancheProblemCard } from './AvalancheProblemCard' +import { BottomLine } from './BottomLine' +import { DangerRating } from './DangerRating' +import { ForecastDatePicker } from './ForecastDatePicker.client' +import { ForecastDiscussion } from './ForecastDiscussion' +import { ForecastErrorBoundary } from './ForecastErrorBoundary' +import { ForecastHeader } from './ForecastHeader' +import { ForecastMediaThumbnails } from './ForecastMediaThumbnails' +import { WarningBanner } from './WarningBanner' + +type WarningProduct = Warning | Watch | Special + +interface NativeForecastViewProps { + center: string + zone: ActiveForecastZoneWithSlug + timezone: string | null | undefined + forecastResult: ForecastResult + /** Active warning banner — live view only; null for historical/dated views. */ + warning: WarningProduct | null + /** Dates (with danger) for the picker's server-rendered initial month window. */ + initialDates: ZoneArchiveDate[] + /** The `from`/`to` (YYYY-MM-DD) window covered by initialDates. */ + initialRange: { from: string; to: string } + /** Valid date of the current/live product, so the picker can return to the live page. */ + currentDate: string | null + /** The shown date (`YYYY-MM-DD`), or null when showing the current/live forecast. */ + selectedDate: string | null + /** Tenant-relative zone base path, e.g. `/forecasts/avalanche/west-slopes-north`. */ + basePath: string +} + +/** All danger levels ordered low to high for safe lookup by numeric value. */ +const dangerLevels: DangerLevel[] = [ + DangerLevel.None, + DangerLevel.Low, + DangerLevel.Moderate, + DangerLevel.Considerable, + DangerLevel.High, + DangerLevel.Extreme, +] + +/** Extract the highest danger level from a forecast's current-day danger array. */ +function highestDangerLevel(forecast: Forecast): DangerLevel { + const today = forecast.danger.find((d) => d.valid_day === ForecastPeriod.Current) + if (!today) return DangerLevel.None + const max = Math.max(today.upper, today.middle, today.lower) + return dangerLevels[max] ?? DangerLevel.None +} + +export function NativeForecastView({ + center, + zone, + timezone, + forecastResult, + warning, + initialDates, + initialRange, + currentDate, + selectedDate, + basePath, +}: NativeForecastViewProps) { + const isForecast = forecastResult.product_type === ProductType.Forecast + + return ( +
+ {/* Page header: zone name. The "Avalanche Forecast" subtitle is only shown for + forecast products; summary products carry their own title in the discussion + (e.g. NWAC's "Spring Statement"), so we don't impose a label. */} +
+

{zone.zone.name}

+ {isForecast &&

Avalanche Forecast

} +
+ + {/* Date picker — browse this zone's published forecast history, colored by danger. */} + + ({ date: d.date, dangerRating: d.dangerRating }))} + initialRange={initialRange} + /> + + + {/* Warning banner */} + + + + + {/* Header: author, issued, expires */} + + + + + {/* Danger rating — only for full forecasts (not summary products) */} + {isForecast && ( + + + + )} + + {/* Bottom line */} + {forecastResult.bottom_line && ( + + + + )} + + {/* Avalanche problems — only for full forecasts */} + {isForecast && + forecastResult.forecast_avalanche_problems.map((problem) => ( + + + + ))} + + {/* Forecast discussion */} + {forecastResult.hazard_discussion && ( + + + + )} + + {/* Forecast-level media */} + {forecastResult.media && forecastResult.media.length > 0 && ( + + + + )} +
+ ) +} diff --git a/src/services/nac/archiveDates.ts b/src/services/nac/archiveDates.ts new file mode 100644 index 00000000..8275db65 --- /dev/null +++ b/src/services/nac/archiveDates.ts @@ -0,0 +1,122 @@ +/** + * Pure logic for turning a center's product archive into a single zone's list of + * browsable forecast dates. Kept free of network/IO so it is unit-testable. + * + * Date model: a product's *valid date* is the calendar day the product applies to, + * in the center's timezone, using the legacy "noon cutover" rule — products + * published at or after 12:00 local are treated as the next day's product (NWAC + * publishes the evening before). This matches the date the legacy widget displays + * and highlights, so shareable dated URLs line up with what readers expect. + */ +import { TZDate } from '@date-fns/tz' +import { addDays } from 'date-fns/addDays' +import { endOfMonth } from 'date-fns/endOfMonth' +import { format } from 'date-fns/format' +import { parseISO } from 'date-fns/parseISO' +import { startOfMonth } from 'date-fns/startOfMonth' +import { subMonths } from 'date-fns/subMonths' + +/** Product types that the native forecast view can render (others, e.g. synopsis, are skipped). */ +const RENDERABLE_PRODUCT_TYPES = new Set(['forecast', 'summary']) + +/** + * The minimal slice of an archive product this module needs. The full archive is ~13MB for + * NWAC — too large for Next's 2MB data cache — so callers cache only these fields (~1MB). + */ +export interface ArchiveProductSummary { + id: number + product_type: string + published_time: string + /** Overall danger rating (0-5; -1 = general info). Used to color the picker. */ + danger_rating: number + forecast_zone: { id: number }[] +} + +export interface ZoneArchiveDate { + /** Valid date as `YYYY-MM-DD` (center timezone, noon-cutover applied). */ + date: string + /** The product id to fetch for this date. */ + productId: number + /** Product type, so the picker can mark non-daily products (e.g. summary). */ + productType: string + /** Overall danger rating (0-5; -1 = general info) for coloring the calendar day. */ + dangerRating: number +} + +/** + * The calendar day a product applies to, in the center's timezone, as `YYYY-MM-DD`. + * Returns null for an unparseable timestamp. + */ +export function validDateForProduct( + publishedTime: string, + timezone: string | null | undefined, +): string | null { + const date = timezone ? new TZDate(publishedTime, timezone) : new Date(publishedTime) + if (isNaN(date.getTime())) return null + // Published at/after local noon → the product is for the following day. + const valid = date.getHours() >= 12 ? addDays(date, 1) : date + return format(valid, 'yyyy-MM-dd') +} + +/** + * Build the ordered (newest-first) list of browsable dates for a single zone from the + * full center archive. Filters to renderable products that cover the zone, collapses + * each valid date to its most-recently-published product (corrections/re-issues land on + * the same date), and sorts newest-first. + */ +export function buildZoneArchiveDates( + items: ArchiveProductSummary[], + zoneId: number, + timezone: string | null | undefined, +): ZoneArchiveDate[] { + const byDate = new Map< + string, + { productId: number; productType: string; publishedTime: string; dangerRating: number } + >() + + for (const item of items) { + if (!RENDERABLE_PRODUCT_TYPES.has(item.product_type)) continue + if (!item.forecast_zone.some((zone) => zone.id === zoneId)) continue + + const date = validDateForProduct(item.published_time, timezone) + if (!date) continue + + const existing = byDate.get(date) + // ISO-8601 timestamps compare correctly as strings; keep the latest publication. + if (!existing || item.published_time > existing.publishedTime) { + byDate.set(date, { + productId: item.id, + productType: item.product_type, + publishedTime: item.published_time, + dangerRating: item.danger_rating, + }) + } + } + + return Array.from(byDate.entries()) + .map(([date, value]) => ({ + date, + productId: value.productId, + productType: value.productType, + dangerRating: value.dangerRating, + })) + .sort((a, b) => b.date.localeCompare(a.date)) +} + +/** Resolve a `YYYY-MM-DD` date to its product id within a prebuilt zone date list. */ +export function findProductIdForDate(archiveDates: ZoneArchiveDate[], date: string): number | null { + return archiveDates.find((entry) => entry.date === date)?.productId ?? null +} + +/** + * The date window (`date_start`/`date_end`) the page renders up front for the picker: the + * anchor's month plus the prior month, so the calendar opens populated and one page-back is + * instant. Older months are lazy-loaded client-side. Anchor is the shown date, or today. + */ +export function initialArchiveWindow(anchor: string | null): { from: string; to: string } { + const date = anchor ? parseISO(anchor) : new Date() + return { + from: format(startOfMonth(subMonths(date, 1)), 'yyyy-MM-dd'), + to: format(endOfMonth(date), 'yyyy-MM-dd'), + } +} diff --git a/src/services/nac/dangerScale.ts b/src/services/nac/dangerScale.ts index 19080012..4dda6d2e 100644 --- a/src/services/nac/dangerScale.ts +++ b/src/services/nac/dangerScale.ts @@ -5,6 +5,30 @@ */ import { DangerLevel } from './types/forecastSchemas' +/** + * Coerce a raw numeric `danger_rating` (from the product list endpoint, -1..5) to a + * DangerLevel. Out-of-range values fall back to None so callers can color safely. + */ +export function dangerLevelFromRating(rating: number): DangerLevel { + switch (rating) { + case DangerLevel.GeneralInformation: + return DangerLevel.GeneralInformation + case DangerLevel.Low: + return DangerLevel.Low + case DangerLevel.Moderate: + return DangerLevel.Moderate + case DangerLevel.Considerable: + return DangerLevel.Considerable + case DangerLevel.High: + return DangerLevel.High + case DangerLevel.Extreme: + return DangerLevel.Extreme + case DangerLevel.None: + default: + return DangerLevel.None + } +} + /** Human-readable name for a danger level. */ export function dangerName(level: DangerLevel): string { switch (level) { diff --git a/src/services/nac/nac.ts b/src/services/nac/nac.ts index f46615e5..7ea74307 100644 --- a/src/services/nac/nac.ts +++ b/src/services/nac/nac.ts @@ -1,13 +1,16 @@ import { normalizePath } from '@/utilities/path' import config from '@payload-config' +import { unstable_cache } from 'next/cache' import { getPayload } from 'payload' import * as qs from 'qs-esm' +import type { ArchiveProductSummary } from './archiveDates' import { forecastResultSchema, warningResultSchema, type ForecastResult, type WarningResult, } from './types/forecastSchemas' +import { productListSchema } from './types/productListSchemas' import { allAvalancheCenterCapabilitiesSchema, avalancheCenterSchema, @@ -34,6 +37,10 @@ export class NACError extends Error { type Options = { tags?: string[] cachedTime?: number | false + // Skip Next's fetch-level data cache entirely (cache: 'no-store'). Use for responses too + // large for the 2MB data cache (e.g. the full product archive), which are cached one layer + // up via unstable_cache after being trimmed down. + noStore?: boolean } export async function nacFetch(path: string, options: Options = {}) { @@ -42,10 +49,14 @@ export async function nacFetch(path: string, options: Options = {}) { try { const res = await fetch(url, { - next: { - revalidate: options?.cachedTime ?? 24 * 60 * 60 * 1000, // hold on to this cached data for a day (in milliseconds) - ...(options?.tags && options.tags.length > 0 ? options.tags : []), - }, + ...(options.noStore + ? { cache: 'no-store' } + : { + next: { + revalidate: options?.cachedTime ?? 24 * 60 * 60 * 1000, // hold on to this cached data for a day (in milliseconds) + ...(options?.tags && options.tags.length > 0 ? options.tags : []), + }, + }), }) if (!res.ok) { @@ -310,4 +321,93 @@ export async function fetchWarning( } } +/** A `date_start`/`date_end` window (YYYY-MM-DD) used to narrow the archive list server-side. */ +export type ArchiveDateRange = { from: string; to: string } + +/** + * Fetch + trim the center's product archive (the list endpoint). The endpoint narrows + * server-side by date range via `date_start`/`date_end` (the avy app relies on this; the + * `type`/`zone_id` params are ignored). The *unfiltered* archive is ~13MB for NWAC — too + * large for Next's 2MB fetch-data cache — so we always fetch uncached and immediately trim + * to the small slice the date picker needs (cached one layer up via unstable_cache). + */ +async function fetchArchiveSummaries( + centerSlugUpper: string, + range?: ArchiveDateRange, +): Promise { + const params = new URLSearchParams({ avalanche_center_id: centerSlugUpper }) + if (range) { + params.set('date_start', range.from) + params.set('date_end', range.to) + } + + const data = await nacFetch(`/v2/public/products?${params.toString()}`, { noStore: true }) + + const parsed = productListSchema.safeParse(data) + if (!parsed.success) { + const payload = await getPayload({ config }) + payload.logger.error({ err: parsed.error }, 'Failed to parse product archive response') + return [] + } + + return parsed.data.map((item) => ({ + id: item.id, + product_type: item.product_type, + published_time: item.published_time, + danger_rating: item.danger_rating ?? 0, + forecast_zone: item.forecast_zone.map((zone) => ({ id: zone.id })), + })) +} + +/** + * The center's product archive for a date window (default: the whole archive), trimmed and + * cached server-side so a given window is fetched at most once per 30-minute window rather + * than per view. Callers filter the result to a single zone with `buildZoneArchiveDates`. + * Returns [] on failure so the page degrades gracefully (no date list) rather than crashing. + */ +export async function fetchProductArchive( + centerSlug: string, + range?: ArchiveDateRange, +): Promise { + const centerSlugToUse = normalizeCenterSlug(centerSlug.toLowerCase()).toUpperCase() + + const getCached = unstable_cache( + () => fetchArchiveSummaries(centerSlugToUse, range), + ['nac-product-archive', centerSlugToUse, range?.from ?? 'all', range?.to ?? 'all'], + { revalidate: 30 * 60 }, + ) + + try { + return await getCached() + } catch { + return [] + } +} + +/** + * Fetch a single historical product by id. Historical products are immutable, so this is + * cached for a long time and deliberately does NOT use the live revalidate-on-view freshness + * path — only the current-forecast view needs that. Returns the forecast/summary product, or + * null when the id is missing or the response doesn't parse (e.g. a non-renderable type). + */ +export async function fetchProductById(id: number): Promise { + try { + const data = await nacFetch(`/v2/public/product/${id}`, { + // Immutable: hold for 30 days. Dated views are cached, never freshness-checked. + cachedTime: 30 * 24 * 60 * 60, + }) + + const parsed = forecastResultSchema.safeParse(data) + if (!parsed.success) { + const payload = await getPayload({ config }) + payload.logger.error({ err: parsed.error }, 'Failed to parse product-by-id response') + return null + } + + return parsed.data + } catch { + return null + } +} + export { resolveZoneFromSlug } from './resolveZone' diff --git a/src/services/nac/types/productListSchemas.ts b/src/services/nac/types/productListSchemas.ts new file mode 100644 index 00000000..8c4bbd35 --- /dev/null +++ b/src/services/nac/types/productListSchemas.ts @@ -0,0 +1,35 @@ +/** + * Zod schema for the NAC v2 product *list* endpoint + * (`GET /v2/public/products?avalanche_center_id={CENTER}`). + * + * This is the lightweight archive index — one entry per published product. It is + * intentionally narrower than the full by-id product schema in `forecastSchemas`: + * we only keep the fields needed to build a zone's date list (id, type, published + * time, and which zones the product covers). The endpoint ignores all narrowing + * params and always returns the center's full archive (~9.6k items for NWAC), so + * unknown fields are tolerated and only the columns we use are validated. + */ +import { z } from 'zod' + +export const productListItemSchema = z + .object({ + id: z.number(), + product_type: z.string(), + published_time: z.string(), + // Top-level overall danger rating (0-5; -1 = general info). Used to color the date picker. + danger_rating: z.number().nullable().optional(), + forecast_zone: z.array( + z + .object({ + id: z.number(), + name: z.string(), + }) + // Tolerate the other zone fields (url, zone_id, config) without validating them. + .passthrough(), + ), + }) + // Tolerate the many archive columns we don't consume (danger, bottom_line, ...). + .passthrough() +export type ProductListItem = z.infer + +export const productListSchema = z.array(productListItemSchema)