Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions DOMAIN_CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**:
Expand Down
112 changes: 112 additions & 0 deletions __tests__/server/archiveDates.server.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import {
buildZoneArchiveDates,
findProductIdForDate,
validDateForProduct,
type ArchiveProductSummary,
} from '@/services/nac/archiveDates'

const TZ = 'America/Los_Angeles'

function item(
partial: Partial<ArchiveProductSummary> & Pick<ArchiveProductSummary, 'id'>,
): 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()
})
})
43 changes: 43 additions & 0 deletions docs/decisions/017-forecast-glossary.md
Original file line number Diff line number Diff line change
@@ -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.
3 changes: 3 additions & 0 deletions docs/decisions/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

Expand Down
2 changes: 1 addition & 1 deletion drift.lock
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ sig = "670dda646337e58d"
[[bindings]]
doc = "CLAUDE.md"
target = "docs/decisions/README.md"
sig = "247601da82e8c82c"
sig = "dd907c0a43e1fd56"

[[bindings]]
doc = "CLAUDE.md"
Expand Down
129 changes: 129 additions & 0 deletions src/app/(frontend)/[center]/forecasts/avalanche/[zone]/[date]/page.tsx
Original file line number Diff line number Diff line change
@@ -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<PathArgs>
}

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 (
<div className="container py-8 text-center text-muted-foreground">
Unable to load this forecast. Please try again later.
</div>
)
}

const currentDate = currentProduct
? validDateForProduct(currentProduct.published_time, metadata.timezone)
: null

return (
<NativeForecastView
center={center}
zone={resolvedZone}
timezone={metadata.timezone}
forecastResult={forecastResult}
// Historical view: the warning banner reflects current alerts, not point-in-time ones.
warning={null}
initialDates={initialDates}
initialRange={window}
currentDate={currentDate}
selectedDate={date}
basePath={`/forecasts/avalanche/${zone}`}
/>
)
}

export async function generateMetadata({ params }: Args): Promise<Metadata> {
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 },
}
}
55 changes: 55 additions & 0 deletions src/app/api/[center]/forecast-archive/route.ts
Original file line number Diff line number Diff line change
@@ -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' },
},
)
}
Loading
Loading