Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
48 commits
Select commit Hold shift + click to select a range
9cb380e
add Zod schemas for NAC forecast and warning API responses
busbyk Apr 1, 2026
a9bef23
add data fetching functions and zone resolution for native forecast page
busbyk Apr 1, 2026
d46f06f
add danger scale utilities, icon assets, and NAC display docs
busbyk Apr 1, 2026
ac5f524
add useNativeForecasts feature flag to Settings collection
busbyk Apr 1, 2026
4d48793
add DangerTriangle, DangerElevationBand, and DangerRating server comp…
busbyk Apr 1, 2026
7149817
add LocatorRose SVG component for avalanche problem aspect/elevation …
busbyk Apr 2, 2026
ca4d703
add ProblemSlider component (likelihood + size variants)
busbyk Apr 2, 2026
04dca6f
feat: add ForecastHeader, BottomLine, WarningBanner, and ForecastDisc…
busbyk Apr 4, 2026
689404e
feat: add AvalancheProblemCard component
busbyk Apr 6, 2026
88ec636
feat: add media lightbox and thumbnail grid for forecast media
busbyk Apr 6, 2026
95fccbe
feat: add ForecastErrorBoundary for per-section error isolation
busbyk Apr 6, 2026
65be3e4
feat: add NativeForecastPage composition and wire into single-zone route
busbyk Apr 6, 2026
3ba6637
feat: add all-zones grid page with parallel forecast fetching
busbyk Apr 6, 2026
81547a3
fix: zone links, HTML rendering, static images, and lightbox close bu…
busbyk Apr 6, 2026
5e31627
Reconcile native forecast page from replace-widgets
busbyk Jun 17, 2026
ab6f06a
Polish native forecast UI: flat zone card, Issued + center-tz dates, …
busbyk Jun 17, 2026
4480d5f
chore: prettier-format reconciliation lockfile and migration snapshot
busbyk Jun 17, 2026
3e2704d
Drop invented "Seasonal Summary" label from forecast detail header
busbyk Jun 17, 2026
f4ef61a
Open external forecast links in a new tab
busbyk Jun 17, 2026
36921ea
Use the site container class on native forecast pages
busbyk Jun 17, 2026
53ba5c8
Add forecast date picker (historical browse / season replay)
busbyk Jun 22, 2026
8d1e7b1
docs: add forecast glossary ADR + domain terms
busbyk Jun 22, 2026
34d7631
Add per-product source adapter, normalized model, and the two controls
busbyk Jun 23, 2026
999054b
Document the NAC data-layer model architecture
busbyk Jun 23, 2026
918083d
Move NAC data-layer doc into docs/
busbyk Jun 23, 2026
95eb922
Unwrap prose in the NAC data-layer doc
busbyk Jun 23, 2026
fd9429c
fix(forecast): move forecast routes to ISR revalidate=300
busbyk Jun 30, 2026
2da4d45
feat(forecast): danger-section parity with the legacy widget
busbyk Jun 30, 2026
39f2659
feat(forecast): avalanche problem card parity with the legacy widget
busbyk Jun 30, 2026
f925e61
feat(forecast): compose forecast view parity (bottom line, problems, …
busbyk Jun 30, 2026
e8e4562
feat(forecast): add validity-date banner to the forecast view
busbyk Jun 30, 2026
0d7dd35
feat(nac): add Weather product schema, model, fetcher and v2 source
busbyk Jul 1, 2026
54d1963
feat(forecast): render inline Weather Summary on the live forecast page
busbyk Jul 1, 2026
ac6b52c
fix(nac): treat the v2 null-object weather response as no product
busbyk Jul 1, 2026
e328c04
feat(forecast): add revalidate-on-view freshness (safety-critical)
busbyk Jul 1, 2026
eca5349
fix(forecast): harden revalidate-on-view freshness (review findings)
busbyk Jul 1, 2026
edef720
fix(forecast): stop danger elevation bands overflowing on mobile
busbyk Jul 1, 2026
b56548f
feat(forecast): match the afp danger design (detailed today, compact …
busbyk Jul 1, 2026
4046c02
fix(forecast): correct danger icon aspect ratio and align danger rows
busbyk Jul 1, 2026
f8cb664
feat(forecast): external-link affordance + cleaner danger-scale defin…
busbyk Jul 1, 2026
01edba9
style(forecast): external links underline on hover, not by default
busbyk Jul 1, 2026
8122b78
feat(forecast): match afp validity banner wording word-for-word
busbyk Jul 1, 2026
55d26c8
feat(forecast): match afp user-facing language (audit fixes)
busbyk Jul 1, 2026
fd11b22
feat(forecast): render mountain weather on archived (dated) forecasts…
busbyk Jul 1, 2026
5eeb874
Merge branch 'native-product-pages' into issue-03-native-forecast-page
busbyk Jul 1, 2026
560ce87
Add pinch-zoom to forecast media lightbox
busbyk Jul 1, 2026
d97f292
Merge branch 'native-product-pages' into issue-03-native-forecast-page
busbyk Jul 1, 2026
ab9dc03
Recreate feature migrations as one main-inclusive migration
busbyk Jul 1, 2026
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 .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,16 @@ NAC_HOST=
# AFP wordpress API base url. Defaults to https://forecasts.avalanche.org. Not required.
AFP_HOST=

# Per-product data source for the native pages (Control 2). v2 | v3, uniform across tenants,
# defaults to v2. Flip a product to v3 once products-api v3 is confirmed deployed. Not required.
NAC_FORECAST_SOURCE=
NAC_WARNING_SOURCE=

# Per-center v3 canary allowlist: comma-separated center slugs that use v3 regardless of the
# default above (e.g. "nwac,sac"). Not required.
NAC_FORECAST_V3_CANARY_CENTERS=
NAC_WARNING_V3_CANARY_CENTERS=

# PostHog API key
NEXT_PUBLIC_POSTHOG_KEY=

Expand Down
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
101 changes: 101 additions & 0 deletions __tests__/client/components/AvalancheProblemCard.client.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { AvalancheProblemCard } from '@/components/forecast/AvalancheProblemCard'
import {
AvalancheProblemLikelihood,
AvalancheProblemLocation,
AvalancheProblemName,
AvalancheProblemType,
MediaType,
type AvalancheProblem,
} from '@/services/nac/types/forecastSchemas'
import '@testing-library/jest-dom'
import { render, screen } from '@testing-library/react'

const baseProblem: AvalancheProblem = {
id: 1,
forecast_id: 100,
rank: 1,
avalanche_problem_id: AvalancheProblemType.StormSlab,
name: AvalancheProblemName.StormSlab,
likelihood: AvalancheProblemLikelihood.Likely,
location: [
AvalancheProblemLocation.NorthUpper,
AvalancheProblemLocation.NorthMiddle,
AvalancheProblemLocation.NortheastUpper,
],
size: [1, 2],
discussion: '<p>Watch for storm slabs on north-facing terrain.</p>',
problem_description: 'Storm Slab description',
icon: 'http://api.avalanche.org/img/avalanche_problems/StormSlab.png',
media: { type: MediaType.Unknown },
}

describe('AvalancheProblemCard', () => {
it('renders the problem name', () => {
render(<AvalancheProblemCard problem={baseProblem} />)
expect(screen.getByText('Storm Slab')).toBeInTheDocument()
})

it('renders the problem icon with correct src', () => {
render(<AvalancheProblemCard problem={baseProblem} />)
const icon = document.querySelector('img[src="/images/problem-icons/StormSlab.png"]')
expect(icon).toBeInTheDocument()
})

it('renders sanitized discussion HTML', () => {
render(<AvalancheProblemCard problem={baseProblem} />)
expect(screen.getByText('Watch for storm slabs on north-facing terrain.')).toBeInTheDocument()
})

it('does not render discussion when null', () => {
const problem = { ...baseProblem, discussion: null }
const { container } = render(<AvalancheProblemCard problem={problem} />)
expect(container.querySelector('.prose')).not.toBeInTheDocument()
})

it('renders likelihood and size labels', () => {
render(<AvalancheProblemCard problem={baseProblem} />)
expect(screen.getByText('Likelihood')).toBeInTheDocument()
expect(screen.getByText('Size')).toBeInTheDocument()
})

it('renders the example photo (medium size) with its caption for image type', () => {
const problem: AvalancheProblem = {
...baseProblem,
media: {
type: MediaType.Image,
url: {
large: 'https://example.com/large.jpg',
medium: 'https://example.com/medium.jpg',
original: 'https://example.com/original.jpg',
thumbnail: 'https://example.com/thumb.jpg',
},
caption: 'Storm slab crown',
title: null,
},
}
render(<AvalancheProblemCard problem={problem} />)
const img = document.querySelector('img[src="https://example.com/medium.jpg"]')
expect(img).toBeInTheDocument()
expect(screen.getByText('Storm slab crown')).toBeInTheDocument()
})

it('renders the numbered problem heading', () => {
render(<AvalancheProblemCard problem={{ ...baseProblem, rank: 2 }} />)
expect(screen.getByText('Problem #2: Storm Slab')).toBeInTheDocument()
})

it('renders the four labeled problem columns', () => {
render(<AvalancheProblemCard problem={baseProblem} />)
expect(screen.getByText('Problem Type')).toBeInTheDocument()
expect(screen.getByText('Aspect/Elevation')).toBeInTheDocument()
expect(screen.getByText('Likelihood')).toBeInTheDocument()
expect(screen.getByText('Size')).toBeInTheDocument()
})

it('does not render a thumbnail for unknown media type', () => {
render(<AvalancheProblemCard problem={baseProblem} />)
const images = document.querySelectorAll('img')
// Only the problem icon, no media thumbnail
expect(images).toHaveLength(1)
})
})
44 changes: 44 additions & 0 deletions __tests__/client/components/ValidityBanner.client.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { ValidityBanner } from '@/components/forecast/ValidityBanner'
import '@testing-library/jest-dom'
import { render, screen } from '@testing-library/react'

const BASE = '/forecasts/avalanche/west-slopes-north'

describe('ValidityBanner', () => {
it('shows the archived-product notice with a link to the most recent forecast', () => {
const { container } = render(
<ValidityBanner
forecast={{ expires_time: null }}
selectedDate="2026-01-09"
basePath={BASE}
/>,
)
expect(container.textContent).toMatch(/this is an archived product/i)
expect(screen.getByRole('link', { name: /most recent forecast/i })).toHaveAttribute(
'href',
BASE,
)
})

it('warns when a live forecast has expired', () => {
const { container } = render(
<ValidityBanner
forecast={{ expires_time: '2020-01-02T00:00:00+00:00' }}
selectedDate={null}
basePath={BASE}
/>,
)
expect(container.textContent).toMatch(/this product is expired/i)
})

it('renders nothing for a current, unexpired live forecast', () => {
const { container } = render(
<ValidityBanner
forecast={{ expires_time: '2099-01-02T00:00:00+00:00' }}
selectedDate={null}
basePath={BASE}
/>,
)
expect(container).toBeEmptyDOMElement()
})
})
43 changes: 43 additions & 0 deletions __tests__/client/components/WeatherSummary.client.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { WeatherSummary } from '@/components/forecast/WeatherSummary'
import { mapV2Weather } from '@/services/nac/sources/v2/mappers'
import { weatherSchema } from '@/services/nac/types/forecastSchemas'
import '@testing-library/jest-dom'
import { render, screen } from '@testing-library/react'
import inlineWeather from '../../server/fixtures/inline-weather.json'
import sacWeather from '../../server/fixtures/sac-weather.json'

const sac = mapV2Weather(weatherSchema.parse(sacWeather))
const inline = mapV2Weather(weatherSchema.parse(inlineWeather))

describe('WeatherSummary', () => {
it('renders a columns/rows weather table', () => {
render(
<WeatherSummary
weather={sac}
zoneName="Central Sierra Nevada"
timezone="America/Los_Angeles"
/>,
)
expect(screen.getByText('Mountain Weather')).toBeInTheDocument()
// Zone title cell + a row heading from the columns/rows table.
expect(screen.getByText('Central Sierra Nevada')).toBeInTheDocument()
expect(screen.getByText('Ridgetop Winds')).toBeInTheDocument()
})

it('renders an inline/periods weather table with split-cell values', () => {
render(<WeatherSummary weather={inline} zoneName="Test Zone" timezone="America/Los_Angeles" />)
expect(screen.getByText('Snowfall')).toBeInTheDocument()
// Period headers are inline HTML, sanitized and rendered.
expect(screen.getByText('Today')).toBeInTheDocument()
// Split-cell labels ("12hr:") appear for the Snowfall row.
expect(screen.getAllByText(/12hr/).length).toBeGreaterThan(0)
})

it('renders nothing when there is no table and no discussion', () => {
const empty = { ...inline, weather_data: [], weather_discussion: null }
const { container } = render(
<WeatherSummary weather={empty} zoneName="Test Zone" timezone="America/Los_Angeles" />,
)
expect(container).toBeEmptyDOMElement()
})
})
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()
})
})
58 changes: 58 additions & 0 deletions __tests__/server/dangerScale.server.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { dangerColor, dangerIconUrl, dangerName, dangerTextColor } from '@/services/nac/dangerScale'
import { DangerLevel } from '@/services/nac/types/forecastSchemas'

describe('dangerName', () => {
it.each([
[DangerLevel.Extreme, 'Extreme'],
[DangerLevel.High, 'High'],
[DangerLevel.Considerable, 'Considerable'],
[DangerLevel.Moderate, 'Moderate'],
[DangerLevel.Low, 'Low'],
[DangerLevel.None, 'No Rating'],
[DangerLevel.GeneralInformation, 'No Rating'],
])('returns "%s" for level %i', (level, expected) => {
expect(dangerName(level)).toBe(expected)
})
})

describe('dangerColor', () => {
it.each([
[DangerLevel.Extreme, '#231f20'],
[DangerLevel.High, '#ed1c24'],
[DangerLevel.Considerable, '#f7941e'],
[DangerLevel.Moderate, '#fff200'],
[DangerLevel.Low, '#50b848'],
[DangerLevel.None, '#939598'],
[DangerLevel.GeneralInformation, '#6ea4db'],
])('returns correct hex for level %i', (level, expected) => {
expect(dangerColor(level)).toBe(expected)
})
})

describe('dangerTextColor', () => {
it.each([
[DangerLevel.Extreme, '#ffffff'],
[DangerLevel.High, '#1a1a1a'],
[DangerLevel.Considerable, '#1a1a1a'],
[DangerLevel.Moderate, '#1a1a1a'],
[DangerLevel.Low, '#1a1a1a'],
[DangerLevel.None, '#1a1a1a'],
[DangerLevel.GeneralInformation, '#1a1a1a'],
])('returns appropriate contrast color for level %i', (level, expected) => {
expect(dangerTextColor(level)).toBe(expected)
})
})

describe('dangerIconUrl', () => {
it.each([
[DangerLevel.Extreme, '/images/danger-icons/5.png'],
[DangerLevel.High, '/images/danger-icons/4.png'],
[DangerLevel.Considerable, '/images/danger-icons/3.png'],
[DangerLevel.Moderate, '/images/danger-icons/2.png'],
[DangerLevel.Low, '/images/danger-icons/1.png'],
[DangerLevel.None, '/images/danger-icons/0.png'],
[DangerLevel.GeneralInformation, '/images/danger-icons/0.png'],
])('returns correct path for level %i', (level, expected) => {
expect(dangerIconUrl(level)).toBe(expected)
})
})
Loading
Loading