diff --git a/.env.example b/.env.example index 3cfeec488..fe1d9ed17 100644 --- a/.env.example +++ b/.env.example @@ -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= diff --git a/DOMAIN_CONTEXT.md b/DOMAIN_CONTEXT.md index c8eb22519..33391fa57 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__/client/components/AvalancheProblemCard.client.test.tsx b/__tests__/client/components/AvalancheProblemCard.client.test.tsx new file mode 100644 index 000000000..d3971a001 --- /dev/null +++ b/__tests__/client/components/AvalancheProblemCard.client.test.tsx @@ -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: '

Watch for storm slabs on north-facing terrain.

', + 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() + expect(screen.getByText('Storm Slab')).toBeInTheDocument() + }) + + it('renders the problem icon with correct src', () => { + render() + const icon = document.querySelector('img[src="/images/problem-icons/StormSlab.png"]') + expect(icon).toBeInTheDocument() + }) + + it('renders sanitized discussion HTML', () => { + render() + 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() + expect(container.querySelector('.prose')).not.toBeInTheDocument() + }) + + it('renders likelihood and size labels', () => { + render() + 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() + 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() + expect(screen.getByText('Problem #2: Storm Slab')).toBeInTheDocument() + }) + + it('renders the four labeled problem columns', () => { + render() + 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() + const images = document.querySelectorAll('img') + // Only the problem icon, no media thumbnail + expect(images).toHaveLength(1) + }) +}) diff --git a/__tests__/client/components/ValidityBanner.client.test.tsx b/__tests__/client/components/ValidityBanner.client.test.tsx new file mode 100644 index 000000000..06aff3567 --- /dev/null +++ b/__tests__/client/components/ValidityBanner.client.test.tsx @@ -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( + , + ) + 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( + , + ) + expect(container.textContent).toMatch(/this product is expired/i) + }) + + it('renders nothing for a current, unexpired live forecast', () => { + const { container } = render( + , + ) + expect(container).toBeEmptyDOMElement() + }) +}) diff --git a/__tests__/client/components/WeatherSummary.client.test.tsx b/__tests__/client/components/WeatherSummary.client.test.tsx new file mode 100644 index 000000000..8aedfaa1a --- /dev/null +++ b/__tests__/client/components/WeatherSummary.client.test.tsx @@ -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( + , + ) + 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() + 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( + , + ) + expect(container).toBeEmptyDOMElement() + }) +}) diff --git a/__tests__/server/archiveDates.server.test.ts b/__tests__/server/archiveDates.server.test.ts new file mode 100644 index 000000000..fc8ef39c1 --- /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/__tests__/server/dangerScale.server.test.ts b/__tests__/server/dangerScale.server.test.ts new file mode 100644 index 000000000..8dbda4391 --- /dev/null +++ b/__tests__/server/dangerScale.server.test.ts @@ -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) + }) +}) diff --git a/__tests__/server/fixtures/inline-weather.json b/__tests__/server/fixtures/inline-weather.json new file mode 100644 index 000000000..00b4c3fe9 --- /dev/null +++ b/__tests__/server/fixtures/inline-weather.json @@ -0,0 +1,59 @@ +{ + "id": 200001, + "product_type": "weather", + "status": "published", + "author": "Test Forecaster", + "published_time": "2026-01-15T14:00:00+00:00", + "created_at": "2026-01-15T13:45:00+00:00", + "updated_at": "2026-01-15T14:00:00+00:00", + "announcement": null, + "danger_level_text": null, + "weather_discussion": "

A cold front arrives this afternoon.

", + "avalanche_center": { + "id": "TEST", + "name": "Test Avalanche Center", + "url": "https://example.com", + "city": "Testville", + "state": "WA" + }, + "forecast_zone": [ + { + "id": 900, + "name": "Test Zone", + "url": "https://example.com/zone", + "state": "WA", + "zone_id": "9" + } + ], + "weather_data": [ + { + "zone_id": "9", + "zone_name": "Test Zone", + "periods": [ + "Today", + "Tonight", + "Tomorrow" + ], + "data": [ + { "field": "5000 ft Temperatures", "unit": "°F", "values": ["28", "22", "30"] }, + { "field": "Ridgeline Wind Speed", "unit": "", "values": ["Moderate", "Strong", "Light"] }, + { + "field": "Snowfall", + "unit": "in", + "values": [ + [ + { "label": "12hr", "value": "3" }, + { "label": "24hr", "value": "6" } + ], + [ + { "label": "12hr", "value": "2" }, + { "label": "24hr", "value": "5" } + ], + "0" + ] + }, + { "field": "Snow Water Equivalent", "unit": "in", "values": ["0.4", "0.3", "0.0"] } + ] + } + ] +} diff --git a/__tests__/server/fixtures/nwac-forecast-active.json b/__tests__/server/fixtures/nwac-forecast-active.json new file mode 100644 index 000000000..d86924c04 --- /dev/null +++ b/__tests__/server/fixtures/nwac-forecast-active.json @@ -0,0 +1,175 @@ +{ + "id": 184213, + "published_time": "2026-04-01T01:30:00+00:00", + "expires_time": "2026-04-02T01:30:00+00:00", + "created_at": "2026-03-31T23:49:34+00:00", + "updated_at": "2026-04-01T01:05:00+00:00", + "author": "Lee Lazzara", + "product_type": "forecast", + "bottom_line": "

Avalanche danger will rise through the day as a storm moves in and temperatures warm up. Shift to simpler, lower-angle terrain during stormy periods or if you see signs of instability like shooting cracks, rollerballs, or fresh avalanches.<\/p>", + "hazard_discussion": "

West North has been the outlier forecast zone over the last few days, with more snow and avalanche activity over the weekend and into Monday than anyplace else in the Cascades. Precipitation amounts haven't been especially noteworthy, nor have avalanches been very large, but the 4-10 inches of new snow just haven't bonded well to the crust layer just below it. This weak interface produced numerous loose snow avalanches and some slabs as well. The snowpack beneath recent snow is a mix of crusts with rounded grains and soft melt forms—a product of repeated swings between cold, snowy weather and warmer, rainy periods this winter.<\/p>\n

The most important factor heading into the next storm is the current snow surface. Light-density surface snow has faceted, with weak snow lingering even on sunnier aspects where you’d typically expect to only find melt-freeze crusts. A few days of faceting alone isn’t a classic setup for a persistent weak layer, but it is enough to increase sensitivity. Expect fresh slabs to be more reactive than usual, even with relatively moderate amounts of new snow.<\/p>", + "weather_discussion": null, + "announcement": null, + "status": "published", + "media": [ + { + "id": 1, + "url": { + "large": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/IMG_5066_69cad8ec14366-large.jpeg", + "medium": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/IMG_5066_69cad8ec14366-medium.jpeg", + "original": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/IMG_5066_69cad8ec14366.jpeg", + "thumbnail": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/IMG_5066_69cad8ec14366-thumbnail.jpeg" + }, + "type": "image", + "title": null, + "caption": "(3\/30\/2026)

Small soft slabs and sluffs  in very steep areas with a human trigger. They ran on the crust beneath the  6 inches or so of snow that fell in the previous days. <\/p> Photo: Jess Trimble ", + "favorite": true + }, + { + "id": 2, + "url": { + "large": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/62F8E33D-42B0-4CEC-A90C-5CCC48325860_69cb1d1339a7c-large.heic", + "medium": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/62F8E33D-42B0-4CEC-A90C-5CCC48325860_69cb1d1339a7c-medium.heic", + "original": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/62F8E33D-42B0-4CEC-A90C-5CCC48325860_69cb1d1339a7c.heic", + "thumbnail": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/62F8E33D-42B0-4CEC-A90C-5CCC48325860_69cb1d1339a7c-thumbnail.heic" + }, + "type": "image", + "title": "Public Observation: Swift Creek\/Annette", + "caption": "

(3\/30\/2026)<\/p>\n

A human-triggered slab on the northeast side of Huntoon Point at 5100 feet.<\/p>\n

Photo: Conner Niemann<\/p>", + "favorite": false + }, + { + "id": 3, + "url": { + "large": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/Pit%20Mar%2029_69c9d0df0eaf8-large.jpg", + "medium": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/Pit%20Mar%2029_69c9d0df0eaf8-medium.jpg", + "original": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/Pit%20Mar%2029_69c9d0df0eaf8.jpg", + "thumbnail": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/Pit%20Mar%2029_69c9d0df0eaf8-thumbnail.jpg" + }, + "type": "image", + "title": null, + "caption": "(3\/29\/2026) Photo: Lee Lazzara", + "favorite": false + }, + { + "id": 4, + "url": { + "large": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/Shuksan%20Mar%2026_69c5b9f0d8853-large.JPG", + "medium": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/Shuksan%20Mar%2026_69c5b9f0d8853-medium.JPG", + "original": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/Shuksan%20Mar%2026_69c5b9f0d8853.JPG", + "thumbnail": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/Shuksan%20Mar%2026_69c5b9f0d8853-thumbnail.JPG" + }, + "type": "image", + "title": null, + "caption": "(3\/26\/2026)

A natural D3 slab avalanche on the North Face of Shuksan. With the crown slightly filled in, this likely released on March 25th during a period of heavy snowfall and strong winds. Northerly aspect at roughly 7500 feet.<\/p>\n

(HS-N-D3-U)<\/p> Photo: Lee Lazzara", + "favorite": false + } + ], + "weather_data": null, + "json_data": null, + "avalanche_center": { + "id": "NWAC", + "name": "Northwest Avalanche Center", + "url": "https:\/\/www.nwac.us\/", + "city": "Seattle", + "state": "WA" + }, + "forecast_avalanche_problems": [ + { + "id": 2986878, + "forecast_id": 184213, + "avalanche_problem_id": 2, + "rank": 1, + "likelihood": "likely", + "discussion": "

The incoming storm isn’t arriving cleanly—expect variable snowfall rates, fluctuating snow levels, and shifting winds. Avalanche danger could rise quickly in the afternoon or evening, especially during heavier snowfall, but shallow, reactive slabs may exist from the start. Conditions will vary widely across the zone, with more precipitation near the Baker Backcountry and less along the Canadian border and in North Cascades National Park.<\/p>\n

You are more likely to trigger a slab on steep slopes that are loading up with wind-drifted snow. But with weak, faceted snow or crusts at the current snow surface, be ready to see shallow slabs in all terrain, wind-loaded or not. Track conditions through the day. As snowfall, wind, and poor visibility increase, step back from bigger, steeper, and more complex terrain.<\/p>", + "media": { + "url": { + "large": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/Dec%2016%20small%20crown_6760c96a23f73-large.jpg", + "medium": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/Dec%2016%20small%20crown_6760c96a23f73-medium.jpg", + "original": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/Dec%2016%20small%20crown_6760c96a23f73.jpg", + "thumbnail": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/Dec%2016%20small%20crown_6760c96a23f73-thumbnail.jpg" + }, + "type": "image", + "title": null, + "caption": "

(12\/16\/2024)<\/p>\n

You could find the slab in wind-sheltered terrain on Wednesday. Weak snow surfaces mean you might see some reactivity even with modest amounts of new snow.<\/p>\n

Photo: Gregg Oliveri<\/p>" + }, + "location": [ + "north upper", + "northeast upper", + "east upper", + "southeast upper", + "south upper", + "southwest upper", + "west upper", + "northwest upper", + "north middle", + "northeast middle", + "east middle", + "southeast middle", + "south middle", + "southwest middle", + "west middle", + "northwest middle" + ], + "size": ["1", "2"], + "name": "Storm Slab", + "problem_description": "Storm Slab avalanches are the release of a cohesive layer (a slab) of new snow that breaks within new snow or on the old snow surface. Storm-slabs typically last between a few hours and few days (following snowfall). Storm-slabs that form over a persistent weak layer (surface hoar, depth hoar, or near-surface facets) may be termed Persistent Slabs or may develop into Persistent Slabs.", + "icon": "http:\/\/api.avalanche.org\/img\/avalanche_problems\/StormSlab.png" + }, + { + "id": 2986879, + "forecast_id": 184213, + "avalanche_problem_id": 6, + "rank": 2, + "likelihood": "possible", + "discussion": "

Wet loose avalanches should be on your radar at low and middle elevations as rising snow levels spit rain onto a bit of new snow. While all should be small, they can still pose a real hazard in rugged, marginally snow-covered terrain or where open creeks exist in the runout. Avoid travel on or directly beneath steep slopes where you see fresh rollerballs, pinwheels, or small wet avalanches.<\/p>", + "media": { + "url": { + "large": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/Pond%20Jan%2020%202024_65ac6bff2dbf4-large.jpg", + "medium": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/Pond%20Jan%2020%202024_65ac6bff2dbf4-medium.jpg", + "original": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/Pond%20Jan%2020%202024_65ac6bff2dbf4.jpg", + "thumbnail": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/Pond%20Jan%2020%202024_65ac6bff2dbf4-thumbnail.jpg" + }, + "type": "image", + "title": null, + "caption": "

(1\/20\/2024)<\/p>\n

Rain on new snow is a near-guarantee of snow sliding down the hill.<\/p>\n

Photo: Julz Holder - Grounding Truth<\/p>" + }, + "location": [ + "north middle", + "northeast middle", + "east middle", + "southeast middle", + "south middle", + "southwest middle", + "west middle", + "northwest middle", + "north lower", + "northeast lower", + "east lower", + "southeast lower", + "south lower", + "southwest lower", + "west lower", + "northwest lower" + ], + "size": ["1", "1.5"], + "name": "Wet Loose", + "problem_description": "Wet Loose avalanches are the release of wet unconsolidated snow or slush. These avalanches typically occur within layers of wet snow near the surface of the snowpack, but they may quickly gouge into lower snowpack layers. Like Loose Dry Avalanches, they start at a point and entrain snow as they move downhill, forming a fan-shaped avalanche. Other names for loose-wet avalanches include point-release avalanches or sluffs. Loose Wet avalanches can trigger slab avalanches that break into deeper snow layers.", + "icon": "http:\/\/api.avalanche.org\/img\/avalanche_problems\/WetLoose.png" + } + ], + "danger": [ + { "lower": 2, "upper": 3, "middle": 2, "valid_day": "current" }, + { "lower": 1, "upper": 2, "middle": 2, "valid_day": "tomorrow" } + ], + "forecast_zone": [ + { + "id": 1646, + "name": "West Slopes North", + "url": "http:\/\/www.nwac.us\/avalanche-forecast\/#\/west-slopes-north", + "state": "WA", + "zone_id": "4", + "config": null + } + ] +} diff --git a/__tests__/server/fixtures/nwac-forecast.json b/__tests__/server/fixtures/nwac-forecast.json new file mode 100644 index 000000000..f034860b6 --- /dev/null +++ b/__tests__/server/fixtures/nwac-forecast.json @@ -0,0 +1,36 @@ +{ + "id": 184066, + "published_time": "2026-03-30T01:30:00+00:00", + "expires_time": "2026-04-20T01:30:00+00:00", + "created_at": "2026-03-29T23:12:44+00:00", + "updated_at": "2026-03-29T23:35:19+00:00", + "author": "Sam Clairmont", + "product_type": "summary", + "bottom_line": null, + "hazard_discussion": "

Avalanche forecasts have ended for the 2025-26 season in the Olympic Mountains. Daily afternoon Mountain Weather Forecasts<\/a> will continue through early May. <\/strong><\/p>\n

Longer days and the potential for more sunshine make the mountains an inviting place in the spring. However, avalanche accidents and fatalities can occur on snow-covered mountains in April<\/a>. Make sure you have the experience and skills to evaluate avalanche terrain, snowpack conditions, and weather observations to decide when, where, and how to travel in the mountains.<\/p>\n

During winter-like storms: <\/strong><\/p>\n

    \n
  • Winter-like slab avalanches can occur during and immediately following storms. <\/li>\n
  • Avoid slopes greater than 35 degrees when you find more than 6-8” of new snow, during times of heavy snowfall, and when you see wind drifting snow.<\/li>\n
  • New snow changes quickly as it warms up. Danger may increase rapidly when sunny weather or warmer temperatures follow a storm, and result in natural avalanches.<\/li>\n
  • Limit your exposure to areas where avalanches can run and stop during these big swings in the weather. <\/li>\n<\/ul>\n

    During periods of cold clear nights and warm sunny days:<\/strong><\/p>\n

      \n
    • Avalanche danger is often lowest in the morning.<\/li>\n
    • Plan your day to travel and return early<\/li>\n
    • Wet snow avalanches may occur with daytime warming and sunshine.<\/li>\n
    • If you see recent avalanches or new rollerballs avoid similar slopes. <\/li>\n
    • Very large and destructive wet slab avalanches may fail on significant crusts or weak layers.<\/li>\n<\/ul>\n

      Other springtime hazards: <\/strong><\/p>\n

        \n
      • Exposed ground, including dirt, rocks, tree limbs, etc. can become submerged by fresh snowfall, creating typical “early season” travel hazards in April, particularly on southerly aspects and near exposed ridges.<\/li>\n
      • Glide cracks can form on steep smooth slopes as water reaches the ground. These slopes can release without warning at any time of the day or night.<\/li>\n
      • Creeks are opening and snow bridges are weakening. Fatalities involving open creek holes or collapsed snow bridges can occur.<\/li>\n
      • Cornices will sag and can fail during periods of warmer weather. Falling cornices can entrain significant amounts of snow or trigger other avalanches. <\/li>\n
      • Choose routes that help limit the amount of time you spend on, near, and under any of these springtime hazards. <\/li>\n<\/ul>\n

        Even though avalanche forecasting has ended for the season, NWAC will continue to track snow and weather conditions. Large spring storms and\/or widespread avalanche cycles may cause us to issue special avalanche bulletins, avalanche warnings, or other spring statements to keep you informed.<\/p>\n

        You can help your backcountry community by continuing to submit observations on our public site<\/a>. <\/p>\n

        Have a fun and safe spring playing in the mountains!<\/p>\n

        Contact NWAC
        <\/strong>You can reach us at the forecast desk daily from 8 AM to 4 PM:
        Email: 
        forecasters@nwac.us<\/a> 
        Phone: 206-526-6164 (Office voicemail)
        Mail: Northwest Avalanche Center
                   7600 Sandpoint Way NE
                   Seattle, Washington 98115<\/p>", + "weather_discussion": null, + "announcement": null, + "status": "published", + "media": [], + "weather_data": null, + "json_data": null, + "avalanche_center": { + "id": "NWAC", + "name": "Northwest Avalanche Center", + "url": "https:\/\/www.nwac.us\/", + "city": "Seattle", + "state": "WA" + }, + "forecast_avalanche_problems": [], + "danger": [], + "forecast_zone": [ + { + "id": 1645, + "name": "Olympics", + "url": "http:\/\/www.nwac.us\/avalanche-forecast\/#\/olympics", + "state": "WA", + "zone_id": "1", + "config": null + } + ] +} diff --git a/__tests__/server/fixtures/nwac-warning.json b/__tests__/server/fixtures/nwac-warning.json new file mode 100644 index 000000000..fc2f62a5b --- /dev/null +++ b/__tests__/server/fixtures/nwac-warning.json @@ -0,0 +1,7 @@ +{ + "avalanche_center": null, + "published_time": null, + "expires_time": null, + "created_at": null, + "updated_at": null +} diff --git a/__tests__/server/fixtures/sac-forecast.json b/__tests__/server/fixtures/sac-forecast.json new file mode 100644 index 000000000..c90808ce7 --- /dev/null +++ b/__tests__/server/fixtures/sac-forecast.json @@ -0,0 +1,246 @@ +{ + "id": 184250, + "published_time": "2026-04-01T13:44:00+00:00", + "expires_time": "2026-04-02T11:00:00+00:00", + "created_at": "2026-04-01T12:52:14+00:00", + "updated_at": "2026-04-01T13:44:13+00:00", + "author": "Brandon Schwartz", + "product_type": "forecast", + "bottom_line": "

        Backcountry travelers could easily trigger large avalanches today, especially in locations with 6 or more inches of new snow. Consider avoiding avalanche terrain in areas where clues to unstable snow are present, such as slopes filling in with wind drifted snow, snow surface cracking, or recent avalanches.<\/p>", + "hazard_discussion": "", + "weather_discussion": null, + "announcement": null, + "status": "published", + "media": [ + { + "id": 1, + "url": { + "large": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/barely-covered-rocks_69cc181752af2-large.jpg", + "medium": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/barely-covered-rocks_69cc181752af2-medium.jpg", + "original": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/barely-covered-rocks_69cc181752af2.jpg", + "thumbnail": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/barely-covered-rocks_69cc181752af2-thumbnail.jpg" + }, + "type": "image", + "title": null, + "caption": "(3\/31\/2026)

        About an inch of new snow on the summit of Andesite Peak. Many rocks and other obstacles remain exposed. Now they just have a dusting of new snow. <\/p>\n

        Andesite Peak, Donner Summit are, CA<\/p> Photo: Sierra Avalanche Center", + "favorite": false + }, + { + "id": 2, + "url": { + "large": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/PXL_20260328_171835661_69c82b932c2ad.MP_69c82b932c2ad-large.jpg", + "medium": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/PXL_20260328_171835661_69c82b932c2ad.MP_69c82b932c2ad-medium.jpg", + "original": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/PXL_20260328_171835661_69c82b932c2ad.MP_69c82b932c2ad.jpg", + "thumbnail": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/PXL_20260328_171835661_69c82b932c2ad.MP_69c82b932c2ad-thumbnail.jpg" + }, + "type": "image", + "title": null, + "caption": "(3\/28\/2026)

        More exposed rocks and other obstacles continue to melt out each day.<\/p>\n

        Summit of Andesite Peak.<\/p> Photo: Sierra Avalanche Center", + "favorite": false + }, + { + "id": 3, + "url": { + "large": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/PXL_20260329_203809986_69c9a825deb43.MP_69c9a825deb43-large.jpg", + "medium": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/PXL_20260329_203809986_69c9a825deb43.MP_69c9a825deb43-medium.jpg", + "original": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/PXL_20260329_203809986_69c9a825deb43.MP_69c9a825deb43.jpg", + "thumbnail": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/PXL_20260329_203809986_69c9a825deb43.MP_69c9a825deb43-thumbnail.jpg" + }, + "type": "image", + "title": null, + "caption": "(3\/29\/2026)

        N-NE-E aspects like the Hourglass on Tamarack Peak still have decent snow coverage while S aspects like those on Mt. Rose are mostly dirt. <\/p> Photo: Sierra Avalanche Center", + "favorite": false + }, + { + "id": 4, + "url": { + "large": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/IMG_1021_69c8a4f29153c-large.jpeg", + "medium": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/IMG_1021_69c8a4f29153c-medium.jpeg", + "original": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/IMG_1021_69c8a4f29153c.jpeg", + "thumbnail": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/IMG_1021_69c8a4f29153c-thumbnail.jpeg" + }, + "type": "image", + "title": "Looking west", + "caption": "(3\/28\/2026)

        Looking west towards Round Top. <\/p> Photo: Mark Speicher", + "favorite": false + }, + { + "id": 5, + "url": { + "large": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/IMG_1024_69c8a4f3c0539-large.jpeg", + "medium": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/IMG_1024_69c8a4f3c0539-medium.jpeg", + "original": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/IMG_1024_69c8a4f3c0539.jpeg", + "thumbnail": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/IMG_1024_69c8a4f3c0539-thumbnail.jpeg" + }, + "type": "image", + "title": "Red Lake Peak", + "caption": "(3\/28\/2026)

        Red Lake Peak in the distance.  South faces have little to no snow left. <\/p> Photo: Mark Speicher", + "favorite": false + }, + { + "id": 6, + "url": { + "large": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/IMG_0989_69c8a4efa5b7b-large.jpeg", + "medium": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/IMG_0989_69c8a4efa5b7b-medium.jpeg", + "original": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/IMG_0989_69c8a4efa5b7b.jpeg", + "thumbnail": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/IMG_0989_69c8a4efa5b7b-thumbnail.jpeg" + }, + "type": "image", + "title": "Forestdale Rd. ", + "caption": "(3\/28\/2026)

        Forestdale Rd is 60% covered with snow to the bridge. Trucks have not yet driven down the road.  It is still managable for a snowmobile. <\/p> Photo: Mark Speicher", + "favorite": false + }, + { + "id": 7, + "url": { + "large": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/PXL_20260328_170213668_69c82b94abaaa-large.jpg", + "medium": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/PXL_20260328_170213668_69c82b94abaaa-medium.jpg", + "original": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/PXL_20260328_170213668_69c82b94abaaa.jpg", + "thumbnail": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/PXL_20260328_170213668_69c82b94abaaa-thumbnail.jpg" + }, + "type": "image", + "title": null, + "caption": "(3\/28\/2026)

        Snowmelt continues to accelerate on E aspects as well leaving patchy snow coverage and lots of exposed debris.<\/p>\n

        Andesite Peak<\/p> Photo: Sierra Avalanche Center", + "favorite": false + }, + { + "id": 8, + "url": { + "large": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/Obs%20Photos%281%29_69c6fc5d4d10d-large.jpg", + "medium": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/Obs%20Photos%281%29_69c6fc5d4d10d-medium.jpg", + "original": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/Obs%20Photos%281%29_69c6fc5d4d10d.jpg", + "thumbnail": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/Obs%20Photos%281%29_69c6fc5d4d10d-thumbnail.jpg" + }, + "type": "image", + "title": null, + "caption": "(3\/27\/2026)

        More rocks and other obstacles are starting to melt out on northerly aspects, but they still have decent coverage, especially above 9000 ft. Much less snow and much more bare ground exists on the sunnier south through west aspects. <\/p> Photo: Sierra Avalanche Center", + "favorite": false + }, + { + "id": 9, + "url": { + "large": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/PXL_20260327_165133946_69c6fc5fd1e76.MP_69c6fc5fd1e76-large.jpg", + "medium": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/PXL_20260327_165133946_69c6fc5fd1e76.MP_69c6fc5fd1e76-medium.jpg", + "original": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/PXL_20260327_165133946_69c6fc5fd1e76.MP_69c6fc5fd1e76.jpg", + "thumbnail": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/PXL_20260327_165133946_69c6fc5fd1e76.MP_69c6fc5fd1e76-thumbnail.jpg" + }, + "type": "image", + "title": null, + "caption": "(3\/27\/2026)

        What was on the snow travel on March 25 is now dirt in some places on the approach to Mt. Houghton.<\/p> Photo: Sierra Avalanche Center", + "favorite": false + }, + { + "id": 10, + "url": { + "large": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/IMG_4793_69c44618761ae-large.jpeg", + "medium": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/IMG_4793_69c44618761ae-medium.jpeg", + "original": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/IMG_4793_69c44618761ae.jpeg", + "thumbnail": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/IMG_4793_69c44618761ae-thumbnail.jpeg" + }, + "type": "image", + "title": null, + "caption": "(3\/25\/2026)

        Walking the Rubicon summer trail from 6900' to 7600' on most all dirt trail.<\/p>\n

        Rubicon Peak, West Shore.<\/p> Photo: Sierra Avalanche Center", + "favorite": false + }, + { + "id": 11, + "url": { + "large": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/IMG_4806_69c5a98a07f65-large.jpeg", + "medium": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/IMG_4806_69c5a98a07f65-medium.jpeg", + "original": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/IMG_4806_69c5a98a07f65.jpeg", + "thumbnail": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/IMG_4806_69c5a98a07f65-thumbnail.jpeg" + }, + "type": "image", + "title": null, + "caption": "(3\/26\/2026)

        NE\/E face of Elephants Back.<\/p>\n

        Carson Pass area.<\/p> Photo: Sierra Avalanche Center", + "favorite": false + }, + { + "id": 12, + "url": { + "large": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/PXL_20260327_173243667_69c6fc60061d7.MP_69c6fc60061d7-large.jpg", + "medium": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/PXL_20260327_173243667_69c6fc60061d7.MP_69c6fc60061d7-medium.jpg", + "original": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/PXL_20260327_173243667_69c6fc60061d7.MP_69c6fc60061d7.jpg", + "thumbnail": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/PXL_20260327_173243667_69c6fc60061d7.MP_69c6fc60061d7-thumbnail.jpg" + }, + "type": "image", + "title": null, + "caption": "(3\/27\/2026)

        Mount Houghton's SE gullies have lost significant snow since March 19th. <\/p> Photo: Sierra Avalanche Center", + "favorite": false + } + ], + "weather_data": { "weather_product_id": 184245 }, + "json_data": null, + "avalanche_center": { + "id": "SAC", + "name": "Sierra Avalanche Center", + "url": "https:\/\/www.sierraavalanchecenter.org\/", + "city": "Truckee", + "state": "CA" + }, + "forecast_avalanche_problems": [ + { + "id": 2988958, + "forecast_id": 184250, + "avalanche_problem_id": 2, + "rank": 1, + "likelihood": "likely", + "discussion": "

        Avalanche hazard is expected to vary significantly today from one location to another around the forecast area due to a very wide range in new snow accumulation both over the past 24 hours and during the day today. Consider avoiding avalanche terrain today, especially in locations with 6 or more inches of new snow, where slopes are filling in with wind drifted snow, where you encounter snow surface cracking, or see signs of recent avalanches. A firm crust at the old\/new snow interface may act as an efficient bed surface and allow for some long running avalanches today.<\/p>\n

        As new snow continues to accumulate today, expect to encounter areas of unstable snow in both wind exposed and wind protected terrain. Wind speeds are forecast to reach gale force\/extreme speeds this afternoon. This increase in wind speeds is expected to increase the amount of wind loading that occurs in near treeline and below treeline areas as the day progresses. There is also the chance of some dry or wet loose snow avalanches today, where new snow slides on firm crust at the old\/new snow interface.<\/p>\n

        Before this storm, significant snowmelt occurred across the forecast area. Large areas of bare ground were present on all aspects below 7,500' to 8,000' as well as on southerly aspects at all elevations. Today's avalanche concerns are focused on locations where new snow has fallen on top of old snow. Where new snow has fallen on bare ground, avalanche concerns are minimal. In all areas, numerous hazards such as rocks, stumps, logs, etc are barely hidden under the recent new snow. Anticipate conditions similar to early season, where it is very easy to hit hard objects under the snow surface as you travel.<\/p>", + "media": { + "url": { + "large": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/IMG_6111_698d36bd970cc-large.jpeg", + "medium": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/IMG_6111_698d36bd970cc-medium.jpeg", + "original": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/IMG_6111_698d36bd970cc.jpeg", + "thumbnail": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/IMG_6111_698d36bd970cc-thumbnail.jpeg" + }, + "type": "image", + "title": null, + "caption": "

        Snow surface cracking like this indicates that you have found unstable snow. Where this is the case, consider opting for slopes with a slope angle under 30 degrees until you have navigated away from avalanche terrain.<\/p>\n

        This photo was taken earlier this year near Blue Lakes, CA.<\/p>\n

        Photo: Jason Bilek<\/p>" + }, + "location": [ + "northwest middle", + "southeast middle", + "southeast upper", + "northwest upper", + "north upper", + "north middle", + "northeast middle", + "northeast upper", + "east upper", + "east middle", + "south upper", + "southwest upper", + "west upper", + "south middle", + "southwest middle", + "west middle", + "north lower", + "northeast lower", + "east lower", + "southeast lower", + "south lower", + "southwest lower", + "west lower", + "northwest lower" + ], + "size": ["1", "2"], + "name": "Storm Slab", + "problem_description": "Storm Slab avalanches are the release of a cohesive layer (a slab) of new snow that breaks within new snow or on the old snow surface. Storm-slabs typically last between a few hours and few days (following snowfall). Storm-slabs that form over a persistent weak layer (surface hoar, depth hoar, or near-surface facets) may be termed Persistent Slabs or may develop into Persistent Slabs.", + "icon": "http:\/\/api.avalanche.org\/img\/avalanche_problems\/StormSlab.png" + } + ], + "danger": [ + { "lower": 3, "upper": 3, "middle": 3, "valid_day": "current" }, + { "lower": 3, "upper": 3, "middle": 3, "valid_day": "tomorrow" } + ], + "forecast_zone": [ + { + "id": 2458, + "name": "Central Sierra Nevada", + "url": "https:\/\/www.sierraavalanchecenter.org\/forecasts\/avalanche\/central-sierra-nevada#\/central-sierra-nevada\/", + "state": "CA", + "zone_id": "1", + "config": null + } + ] +} diff --git a/__tests__/server/fixtures/sac-warning.json b/__tests__/server/fixtures/sac-warning.json new file mode 100644 index 000000000..fc2f62a5b --- /dev/null +++ b/__tests__/server/fixtures/sac-warning.json @@ -0,0 +1,7 @@ +{ + "avalanche_center": null, + "published_time": null, + "expires_time": null, + "created_at": null, + "updated_at": null +} diff --git a/__tests__/server/fixtures/sac-weather.json b/__tests__/server/fixtures/sac-weather.json new file mode 100644 index 000000000..045cc3845 --- /dev/null +++ b/__tests__/server/fixtures/sac-weather.json @@ -0,0 +1,228 @@ +{ + "announcement": null, + "author": "Steve Reynaud", + "avalanche_center": { + "city": "Truckee", + "id": "SAC", + "name": "Sierra Avalanche Center", + "state": "CA", + "url": "https://www.sierraavalanchecenter.org/" + }, + "bottom_line": null, + "created_at": "2024-04-02T12:17:45+00:00", + "danger": [], + "expires_time": null, + "forecast_avalanche_problems": [], + "forecast_zone": [ + { + "config": null, + "id": 1605, + "name": "Central Sierra Nevada", + "state": "CA", + "url": "https://www.sierraavalanchecenter.org/forecasts/#/central-sierra-nevada", + "zone_id": "1" + } + ], + "hazard_discussion": null, + "id": 135333, + "media": null, + "product_type": "weather", + "published_time": "2024-04-02T13:45:00+00:00", + "status": "published", + "updated_at": "2024-04-02T13:45:59+00:00", + "weather_data": [ + { + "columns": [ + [ + { + "heading": "Conditions in the last 24hrs", + "subheading": "", + "width": 25 + }, + { + "heading": "Weather Forecast for Today", + "subheading": "Produced in partnership with the Reno NWS", + "width": 25 + }, + { + "heading": "Weather Forecast for Tonight", + "subheading": "Produced in partnership with the Reno NWS", + "width": 25 + }, + { + "heading": "Weather Forecast for Tomorrow", + "subheading": "Produced in partnership with the Reno NWS", + "width": 25 + } + ] + ], + "data": [ + [ + { + "value": "Sunny and clear." + }, + { + "value": "Sunny. Snow levels 7500 feet increasing to 8500 feet in the afternoon. Chance of precipitation is 0%." + }, + { + "value": "Clear. Snow levels 9000 feet. Chance of precipitation is 0%." + }, + { + "value": "Sunny then becoming partly cloudy. Snow levels 8500 feet. Chance of precipitation is 5%." + } + ], + [ + { + "value": "@ 5am: 30 to 41° F | Max: 36 to 47° F" + }, + { + "value": "50 to 56. ° F" + }, + { + "value": "29 to 35. ° F" + }, + { + "value": "47 to 53. ° F" + } + ], + [ + { + "value": "@ 5am: 28 to 40° F | Max: 32 to 46° F" + }, + { + "value": "45 to 51. ° F" + }, + { + "value": "26 to 31. ° F" + }, + { + "value": "41 to 47. ° F" + } + ], + [ + { + "value": "Light winds." + }, + { + "value": "Light winds." + }, + { + "value": "Light winds." + }, + { + "value": "Southwest 15 to 25 mph. Gusts up to 50 mph in the afternoon." + } + ], + [ + { + "value": "NE winds 20 to 40mph with gusts to 58mph." + }, + { + "value": "Northeast winds 15 to 20 mph with gusts up to 35 mph in the morning becoming light." + }, + { + "value": "Light winds becoming southwest around 15 mph with gusts to 25 mph after midnight." + }, + { + "value": "Southwest around 15 to 20 mph with gusts to 35 mph increasing to 20 to 30 mph with gusts to 55 mph in the afternoon." + } + ], + [ + { + "value": "0 inches" + }, + { + "value": "No accumulation. | SWE = None." + }, + { + "value": "No accumulation. | SWE = None." + }, + { + "value": "No accumulation. | SWE = Less than 0.10 inch." + } + ], + [ + { + "value": "0 inches" + }, + { + "value": "No accumulation. | SWE = None." + }, + { + "value": "No accumulation. | SWE = None." + }, + { + "value": "No accumulation. | SWE = Less than 0.10 inch." + } + ], + [ + { + "value": "78 to 119 inches" + } + ] + ], + "meta": "Loaded from JSON file, the NWS published this forecast on Tue, Apr 02, 2024 at 01:58 AM.", + "rows": [ + { + "field": "textarea", + "heading": "Weather", + "help": null, + "options": null, + "unit": null + }, + { + "field": "input", + "heading": "Temperatures 7000 to 8000 ft.", + "help": null, + "options": null, + "unit": null + }, + { + "field": "input", + "heading": "Temperatures 8000 to 9000 ft.", + "help": null, + "options": null, + "unit": null + }, + { + "field": "textarea", + "heading": "Mid Slope Winds", + "help": null, + "options": null, + "unit": null + }, + { + "field": "textarea", + "heading": "Ridgetop Winds", + "help": null, + "options": null, + "unit": null + }, + { + "field": "textarea", + "heading": "Snowfall 7000 to 8000 ft.", + "help": null, + "options": null, + "unit": null + }, + { + "field": "textarea", + "heading": "Snowfall 8000 to 9000 ft.", + "help": null, + "options": null, + "unit": null + }, + { + "field": "input", + "heading": "Total Snow Depth between 8200 and 8700 ft.", + "help": null, + "options": null, + "unit": null + } + ], + "zone_id": "1", + "zone_name": "Central Sierra Nevada" + } + ], + "weather_discussion": "

        High pressure will continue with warm, dry, and sunny weather for today.  High temperatures will be in the mid 40's to mid 50's above 7000'.  Remote sensors are showing overnight temperatures near to above freezing at stations above 7000'.  Moderate to strong NE winds are expected to decrease to light by midday.  Wednesday will remain dry and warm with increasing SW winds as a storm approaches on Thursday.  Unsettled winter weather will continue through the weekend.

        " +} diff --git a/__tests__/server/fixtures/snfac-forecast.json b/__tests__/server/fixtures/snfac-forecast.json new file mode 100644 index 000000000..0b1e4a08c --- /dev/null +++ b/__tests__/server/fixtures/snfac-forecast.json @@ -0,0 +1,142 @@ +{ + "id": 184224, + "published_time": "2026-04-01T12:56:00+00:00", + "expires_time": "2026-04-02T10:00:00+00:00", + "created_at": "2026-04-01T10:54:07+00:00", + "updated_at": "2026-04-01T13:36:05+00:00", + "author": "Zachary Miller", + "product_type": "forecast", + "bottom_line": "

        Watch for and avoid fresh wind drifts and slabs. You will find more, and larger, drifts and slabs as you climb from middle to upper elevations. At lower elevations, steer clear of steep slopes if you are sinking into a wet, mushy snowpack.<\/p>\n

        We will issue our last daily Avalanche Forecast of the season this Sunday, April 5th. We will issue General Avalanche Information updates through late April or early May.  <\/em><\/p>", + "hazard_discussion": "

        Just when we thought winter had punched its timecard and clocked out - April Fools - it's back! With the return of snowfall, it's worth turning our brains back into winter mode and out of the simplified spring-diurnal mindset. Keep your head on a swivel for changing conditions if you're out enjoying the cold new snow, especially in areas where you find more than 6\" of fresh snow. Spring storms are notoriously spotty in character, with large amounts of variability in the timing and quantity of snowfall across the landscape. Additionally, convective storms could produce graupel or other snow crystal types that form sensitive mid-storm interfaces. These interfaces make great failure layers for even thin wind slabs that quickly form above them.<\/p>\n

        Conveniently, the recent warm weather has solidified much of the snowpack below this new snowfall. On steep slopes where new snow is accumulating on firm, hard ice or wind-hammered surfaces, loose snow avalanches (sluffs)<\/a> could release naturally or be easy to trigger and run faster and further than expected. At lower elevations near or below the rain-snow line, wet avalanches are still in the mix, and avoiding steep slopes where you find unsupportable wet snow is a great way to play it safe. Given the variety of conditions you may experience in the mountains today, be ready to pull the plug and switch to a more conservative plan B if you see obvious signs of instability or feel the hairs on the back of your neck stand up.<\/p>", + "weather_discussion": null, + "announcement": null, + "status": "published", + "media": [ + { + "id": 1, + "url": { + "large": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/IMG_4074_69cc69b3bdd4e-large.jpeg", + "medium": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/IMG_4074_69cc69b3bdd4e-medium.jpeg", + "original": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/IMG_4074_69cc69b3bdd4e.jpeg", + "thumbnail": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/IMG_4074_69cc69b3bdd4e-thumbnail.jpeg" + }, + "type": "image", + "title": "Public Observation: Bench Hut", + "caption": "(3\/31\/2026)

        Wet loose off the Defibrillator couloir (north facing) observed on 3\/31\/2026 in the Sawtooth Range.<\/p> Photo: Jon Preuss", + "favorite": false + }, + { + "id": 2, + "url": { + "large": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/260326_PoleCk_001_69c5da4df1f15-large.jpeg", + "medium": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/260326_PoleCk_001_69c5da4df1f15-medium.jpeg", + "original": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/260326_PoleCk_001_69c5da4df1f15.jpeg", + "thumbnail": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/260326_PoleCk_001_69c5da4df1f15-thumbnail.jpeg" + }, + "type": "image", + "title": "Pairie Creek wet slab avalanche", + "caption": "

        (3\/26\/2026) Wet slab and wet loose avalanches. E-SE @ 9,300-9,800'. Prairie Ck. <\/p>\n

        Photo: Sawtooth Avalanche Center<\/p>", + "favorite": false + }, + { + "id": 3, + "url": { + "large": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/AAE23B67-7543-4662-83C7-00A55C80157E_69c1f02a1784d-large.jpg", + "medium": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/AAE23B67-7543-4662-83C7-00A55C80157E_69c1f02a1784d-medium.jpg", + "original": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/AAE23B67-7543-4662-83C7-00A55C80157E_69c1f02a1784d.jpg", + "thumbnail": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/AAE23B67-7543-4662-83C7-00A55C80157E_69c1f02a1784d-thumbnail.jpg" + }, + "type": "image", + "title": "Public Observation: Marshall Lakes Basin", + "caption": "

        (3\/23\/2026) Wet Loose avalanche observed in Marshall Lakes Basin in the Northern Sawtooth Range.<\/p>\n

        Photo: Bernard, Rich, Seymore, Armstomg, Anderson<\/p>", + "favorite": false + }, + { + "id": 4, + "url": { + "large": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/IMG_2728_69c1c99ae8dc1-large.jpeg", + "medium": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/IMG_2728_69c1c99ae8dc1-medium.jpeg", + "original": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/IMG_2728_69c1c99ae8dc1.jpeg", + "thumbnail": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/IMG_2728_69c1c99ae8dc1-thumbnail.jpeg" + }, + "type": "image", + "title": "Public Observation: Bench Lakes", + "caption": "

        (3\/22\/2026) Large recent wet loose avalanches on Williams Peak's S face.<\/p>", + "favorite": false + }, + { + "id": 5, + "url": { + "large": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/2603221_BaldyOB_Gatorade_watermovement_labeled_69bf6e52487c9-large.jpg", + "medium": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/2603221_BaldyOB_Gatorade_watermovement_labeled_69bf6e52487c9-medium.jpg", + "original": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/2603221_BaldyOB_Gatorade_watermovement_labeled_69bf6e52487c9.jpg", + "thumbnail": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/2603221_BaldyOB_Gatorade_watermovement_labeled_69bf6e52487c9-thumbnail.jpg" + }, + "type": "image", + "title": null, + "caption": "

        (3\/21\/2026) This photo was taken a couple of minutes after pouring purple Gatorade onto the snow surface. The layers where water collects, or pools, stand out in the photo, especially the 2 crusts immediately above the faceted snow at\/near the ground. <\/p>\n

        Photo: Sawtooth Avalanche Center<\/p>", + "favorite": false + }, + { + "id": 6, + "url": { + "large": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/P1170364_69bb84f0659d6-large.jpeg", + "medium": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/P1170364_69bb84f0659d6-medium.jpeg", + "original": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/P1170364_69bb84f0659d6.jpeg", + "thumbnail": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/P1170364_69bb84f0659d6-thumbnail.jpeg" + }, + "type": "image", + "title": "Peak 9367", + "caption": "

        (3\/18\/2026) Wet loose avalanche ~8500' on a west aspect in the Smiley Creek drainage on Peak 9367.<\/p>\n

        Photo: Sawtooth Avalanche Center<\/p>", + "favorite": false + } + ], + "weather_data": { "weather_product_id": 184222 }, + "json_data": null, + "avalanche_center": { + "id": "SNFAC", + "name": "Sawtooth Avalanche Center", + "url": "https:\/\/www.sawtoothavalanche.com\/", + "city": "Ketchum", + "state": "ID" + }, + "forecast_avalanche_problems": [ + { + "id": 2988903, + "forecast_id": 184224, + "avalanche_problem_id": 3, + "rank": 1, + "likelihood": "possible", + "discussion": "

        You could find wind slabs up to 2 feet thick in exposed terrain. Wind slabs are largest and most widespread at upper elevations.<\/p>\n

        Recent cornice growth, pillows, and dunes<\/a> are all clues that wind was at work moving snow. Look for shooting cracks<\/a> as evidence of fresh wind slabs and a nudge to move to softer snow on more sheltered slopes. These slabs commonly form beneath ridgelines, on the sides of cross-loaded gullies, and lower in cirques below large cliff bands, chutes, and couloirs.<\/p>", + "media": { "url": "", "type": "", "caption": "" }, + "location": [ + "north upper", + "northeast upper", + "east upper", + "southeast upper", + "south upper", + "southwest upper", + "west upper", + "northwest upper" + ], + "size": ["1", "2"], + "name": "Wind Slab", + "problem_description": "Wind Slab avalanches are the release of a cohesive layer of snow (a slab) formed by the wind. Wind typically transports snow from the upwind sides of terrain features and deposits snow on the downwind side. Wind slabs are often smooth and rounded and sometimes sound hollow, and can range from soft to hard. Wind slabs that form over a persistent weak layer (surface hoar, depth hoar, or near-surface facets) may be termed Persistent Slabs or may develop into Persistent Slabs.", + "icon": "http:\/\/api.avalanche.org\/img\/avalanche_problems\/WindSlab.png" + } + ], + "danger": [ + { "lower": 2, "upper": 2, "middle": 2, "valid_day": "current" }, + { "lower": 2, "upper": 3, "middle": 2, "valid_day": "tomorrow" } + ], + "forecast_zone": [ + { + "id": 2906, + "name": "Sawtooth & Western Smoky Mtns", + "url": "https:\/\/www.sawtoothavalanche.com\/forecasts\/avalanche\/sawtooth-&-western-smoky-mtns", + "state": "ID", + "zone_id": "2", + "config": null + } + ] +} diff --git a/__tests__/server/fixtures/snfac-warning.json b/__tests__/server/fixtures/snfac-warning.json new file mode 100644 index 000000000..fc2f62a5b --- /dev/null +++ b/__tests__/server/fixtures/snfac-warning.json @@ -0,0 +1,7 @@ +{ + "avalanche_center": null, + "published_time": null, + "expires_time": null, + "created_at": null, + "updated_at": null +} diff --git a/__tests__/server/forecastFingerprint.server.test.ts b/__tests__/server/forecastFingerprint.server.test.ts new file mode 100644 index 000000000..0c5724713 --- /dev/null +++ b/__tests__/server/forecastFingerprint.server.test.ts @@ -0,0 +1,23 @@ +import { forecastFingerprint } from '@/services/nac/forecastFingerprint' +import { mapV2ForecastResult } from '@/services/nac/sources/v2/mappers' +import { forecastResultSchema } from '@/services/nac/types/forecastSchemas' +import nwacForecastActive from './fixtures/nwac-forecast-active.json' + +describe('forecastFingerprint', () => { + const base = mapV2ForecastResult(forecastResultSchema.parse(nwacForecastActive)) + + it('is stable for identical content', () => { + const again = mapV2ForecastResult(forecastResultSchema.parse(nwacForecastActive)) + expect(forecastFingerprint(base)).toBe(forecastFingerprint(again)) + }) + + it('changes when the bottom line changes (a correction)', () => { + const corrected = { ...base, bottom_line: `${base.bottom_line ?? ''} (corrected)` } + expect(forecastFingerprint(corrected)).not.toBe(forecastFingerprint(base)) + }) + + it('changes when the product is re-issued (updated_at bumped)', () => { + const reissued = { ...base, updated_at: '2099-01-01T00:00:00+00:00' } + expect(forecastFingerprint(reissued)).not.toBe(forecastFingerprint(base)) + }) +}) diff --git a/__tests__/server/forecastFreshnessRoute.server.test.ts b/__tests__/server/forecastFreshnessRoute.server.test.ts new file mode 100644 index 000000000..d5cd5f869 --- /dev/null +++ b/__tests__/server/forecastFreshnessRoute.server.test.ts @@ -0,0 +1,111 @@ +import { forecastFingerprint } from '@/services/nac/forecastFingerprint' +import { mapV2ForecastResult } from '@/services/nac/sources/v2/mappers' +import { forecastResultSchema } from '@/services/nac/types/forecastSchemas' +import { NextRequest } from 'next/server' +import nwacForecastActive from './fixtures/nwac-forecast-active.json' + +const mockRevalidateTag = jest.fn() +jest.mock('next/cache', () => ({ revalidateTag: (tag: string) => mockRevalidateTag(tag) })) + +const mockResolveZone = jest.fn() +jest.mock('../../src/services/nac/resolveZone', () => ({ + resolveZoneFromSlug: (...args: unknown[]) => mockResolveZone(...args), +})) + +const mockGetForecastFresh = jest.fn() +const mockGetForecast = jest.fn() +jest.mock('../../src/services/nac/sources', () => ({ + getForecastSource: () => ({ + getForecastFresh: (...a: unknown[]) => mockGetForecastFresh(...a), + getForecast: (...a: unknown[]) => mockGetForecast(...a), + }), +})) + +// Avoid loading the real nac module (and payload) just for the cache-tag helpers. +jest.mock('../../src/services/nac/nac', () => ({ + forecastCacheTag: (centerId: string, zoneId: number) => + `forecast:${centerId === 'dvac' ? 'nwac' : centerId}:${zoneId}`, + weatherCacheTag: (id: number) => `weather:${id}`, +})) + +// Import the handler after the mocks are registered (jest hoists the mocks above imports). +import { GET } from '@/app/api/[center]/forecast-freshness/route' + +const forecast = mapV2ForecastResult(forecastResultSchema.parse(nwacForecastActive)) +const etag = forecastFingerprint(forecast) +const params = Promise.resolve({ center: 'nwac' }) + +function req(headers: Record = {}, zone: string | null = 'west-slopes-north') { + const url = zone + ? `http://localhost/api/nwac/forecast-freshness?zone=${zone}` + : `http://localhost/api/nwac/forecast-freshness` + return new NextRequest(url, { headers }) +} + +beforeEach(() => { + mockRevalidateTag.mockClear() + mockResolveZone.mockReset() + mockGetForecastFresh.mockReset() + mockGetForecast.mockReset() + mockResolveZone.mockResolvedValue({ slug: 'west-slopes-north', zone: { id: 123, name: 'X' } }) +}) + +describe('forecast-freshness route', () => { + it('returns 304 without revalidating when the forecast is unchanged', async () => { + mockGetForecastFresh.mockResolvedValue(forecast) + mockGetForecast.mockResolvedValue(forecast) + const res = await GET(req({ 'If-None-Match': etag }), { params }) + expect(res.status).toBe(304) + expect(mockRevalidateTag).not.toHaveBeenCalled() + }) + + it('revalidates the forecast tag and returns 200 when fresh differs from the cached product', async () => { + mockGetForecastFresh.mockResolvedValue({ ...forecast, bottom_line: 'CORRECTED' }) + mockGetForecast.mockResolvedValue(forecast) + const res = await GET(req({ 'If-None-Match': etag }), { params }) + expect(res.status).toBe(200) + expect(mockRevalidateTag).toHaveBeenCalledWith('forecast:nwac:123') + }) + + it('also revalidates the weather tag when the changed forecast points to a weather product', async () => { + mockGetForecastFresh.mockResolvedValue({ + ...forecast, + bottom_line: 'CORRECTED', + weather_data: { weather_product_id: 555 }, + }) + mockGetForecast.mockResolvedValue(forecast) + await GET(req({ 'If-None-Match': etag }), { params }) + expect(mockRevalidateTag).toHaveBeenCalledWith('forecast:nwac:123') + expect(mockRevalidateTag).toHaveBeenCalledWith('weather:555') + }) + + it('does NOT purge or blank on a failed/absent fresh fetch (returns 304)', async () => { + // A transient upstream error surfaces as null — must not evict the last-known-good forecast. + mockGetForecastFresh.mockResolvedValue(null) + const res = await GET(req({ 'If-None-Match': etag }), { params }) + expect(res.status).toBe(304) + expect(mockRevalidateTag).not.toHaveBeenCalled() + expect(mockGetForecast).not.toHaveBeenCalled() + }) + + it('does NOT revalidate for a bogus/absent client etag when the cache is current (no purge abuse)', async () => { + mockGetForecastFresh.mockResolvedValue(forecast) + mockGetForecast.mockResolvedValue(forecast) + const res = await GET(req(), { params }) // no If-None-Match + expect(mockRevalidateTag).not.toHaveBeenCalled() + expect(res.status).toBe(200) // still tells this (fresh-less) client to refresh, but no purge + }) + + it('always runs the fresh check (no-skip invariant)', async () => { + mockGetForecastFresh.mockResolvedValue(forecast) + mockGetForecast.mockResolvedValue(forecast) + await GET(req({ 'If-None-Match': etag }), { params }) + expect(mockGetForecastFresh).toHaveBeenCalled() + }) + + it('400s when the zone param is missing', async () => { + const res = await GET(req({}, null), { params }) + expect(res.status).toBe(400) + expect(mockGetForecastFresh).not.toHaveBeenCalled() + }) +}) diff --git a/__tests__/server/forecastSchemas.server.test.ts b/__tests__/server/forecastSchemas.server.test.ts new file mode 100644 index 000000000..56872b725 --- /dev/null +++ b/__tests__/server/forecastSchemas.server.test.ts @@ -0,0 +1,166 @@ +import { + DangerLevel, + ForecastPeriod, + forecastResultSchema, + mediaItemSchema, + MediaType, + ProductType, + warningResultSchema, +} from '@/services/nac/types/forecastSchemas' +import nwacForecastActive from './fixtures/nwac-forecast-active.json' +import nwacForecastSummary from './fixtures/nwac-forecast.json' +import nwacWarning from './fixtures/nwac-warning.json' +import sacForecast from './fixtures/sac-forecast.json' +import sacWarning from './fixtures/sac-warning.json' +import snfacForecast from './fixtures/snfac-forecast.json' +import snfacWarning from './fixtures/snfac-warning.json' + +describe('forecastResultSchema', () => { + it('parses an active NWAC forecast with string-typed sizes', () => { + const result = forecastResultSchema.parse(nwacForecastActive) + expect(result.product_type).toBe(ProductType.Forecast) + if (result.product_type === ProductType.Forecast) { + expect(result.forecast_avalanche_problems.length).toBeGreaterThan(0) + // NWAC returns string sizes — verify they are transformed to numbers + for (const problem of result.forecast_avalanche_problems) { + for (const size of problem.size) { + expect(typeof size).toBe('number') + } + } + expect(result.danger.length).toBeGreaterThan(0) + expect(result.danger[0].valid_day).toBe(ForecastPeriod.Current) + } + }) + + it('parses an SAC forecast with string-typed sizes', () => { + const result = forecastResultSchema.parse(sacForecast) + expect(result.product_type).toBe(ProductType.Forecast) + if (result.product_type === ProductType.Forecast) { + expect(result.forecast_avalanche_problems.length).toBeGreaterThan(0) + for (const problem of result.forecast_avalanche_problems) { + for (const size of problem.size) { + expect(typeof size).toBe('number') + } + } + } + }) + + it('parses an SNFAC forecast', () => { + const result = forecastResultSchema.parse(snfacForecast) + expect(result.product_type).toBe(ProductType.Forecast) + if (result.product_type === ProductType.Forecast) { + expect(result.forecast_avalanche_problems.length).toBeGreaterThan(0) + expect(result.avalanche_center.id).toBe('SNFAC') + } + }) + + it('parses an off-season NWAC summary product', () => { + const result = forecastResultSchema.parse(nwacForecastSummary) + expect(result.product_type).toBe(ProductType.Summary) + // Summary products don't have danger or problems in their type + expect(result.avalanche_center.id).toBe('NWAC') + }) + + it('preserves null danger levels as DangerLevel.None', () => { + const result = forecastResultSchema.parse(nwacForecastActive) + if (result.product_type === ProductType.Forecast) { + for (const danger of result.danger) { + expect(danger.lower).toBeGreaterThanOrEqual(DangerLevel.None) + expect(danger.middle).toBeGreaterThanOrEqual(DangerLevel.None) + expect(danger.upper).toBeGreaterThanOrEqual(DangerLevel.None) + } + } + }) + + it('parses forecast media items', () => { + const result = forecastResultSchema.parse(sacForecast) + if (result.product_type === ProductType.Forecast && result.media) { + expect(result.media.length).toBeGreaterThan(0) + for (const item of result.media) { + expect(item.type).not.toBe(MediaType.Unknown) + } + } + }) +}) + +describe('warningResultSchema', () => { + it('parses a null NWAC warning (no active warning)', () => { + const result = warningResultSchema.parse(nwacWarning) + expect(result.avalanche_center).toBeNull() + expect(result.published_time).toBeNull() + }) + + it('parses a null SAC warning', () => { + const result = warningResultSchema.parse(sacWarning) + expect(result.avalanche_center).toBeNull() + }) + + it('parses a null SNFAC warning', () => { + const result = warningResultSchema.parse(snfacWarning) + expect(result.avalanche_center).toBeNull() + }) +}) + +describe('mediaItemSchema', () => { + it('parses an image media item', () => { + const item = { + id: 123, + type: '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: 'Test caption', + } + const result = mediaItemSchema.parse(item) + expect(result.type).toBe(MediaType.Image) + }) + + it('parses a video media item with external link', () => { + const item = { + id: 456, + type: 'video', + url: { + external_link: 'https://youtube.com/watch?v=abc', + external_type: 'video', + }, + caption: 'Video caption', + } + const result = mediaItemSchema.parse(item) + expect(result.type).toBe(MediaType.Video) + }) + + it('parses a PDF media item', () => { + const item = { + type: 'pdf', + url: { + original: 'https://example.com/doc.pdf', + }, + } + const result = mediaItemSchema.parse(item) + expect(result.type).toBe(MediaType.PDF) + }) + + it('falls back to unknown for unrecognized media types', () => { + const item = { + type: 'hologram', + url: 'https://example.com/holo', + } + const result = mediaItemSchema.parse(item) + expect(result.type).toBe(MediaType.Unknown) + }) + + it('parses an empty media item', () => { + const item = { type: '', url: '', caption: '' } + const result = mediaItemSchema.parse(item) + expect(result.type).toBe(MediaType.None) + }) + + it('parses a null media item', () => { + const item = { type: null, url: null, caption: '', title: '' } + const result = mediaItemSchema.parse(item) + expect(result.type).toBeNull() + }) +}) diff --git a/__tests__/server/nacSourceMappers.server.test.ts b/__tests__/server/nacSourceMappers.server.test.ts new file mode 100644 index 000000000..08b7e7f47 --- /dev/null +++ b/__tests__/server/nacSourceMappers.server.test.ts @@ -0,0 +1,134 @@ +import { ProductType } from '@/services/nac/model/forecast' +import { mapV2ForecastResult, mapV2Warning, mapV2Weather } from '@/services/nac/sources/v2/mappers' +import { + forecastResultSchema, + warningResultSchema, + weatherSchema, +} from '@/services/nac/types/forecastSchemas' +import inlineWeather from './fixtures/inline-weather.json' +import nwacForecastActive from './fixtures/nwac-forecast-active.json' +import nwacForecastSummary from './fixtures/nwac-forecast.json' +import nwacWarning from './fixtures/nwac-warning.json' +import sacWeather from './fixtures/sac-weather.json' + +describe('mapV2ForecastResult', () => { + it('maps a v2 active forecast into the model, preserving danger and problems', () => { + const wire = forecastResultSchema.parse(nwacForecastActive) + const model = mapV2ForecastResult(wire) + + expect(model.product_type).toBe(ProductType.Forecast) + if (model.product_type === ProductType.Forecast) { + expect(model.danger.length).toBeGreaterThan(0) + expect(model.forecast_avalanche_problems.length).toBeGreaterThan(0) + expect(model.avalanche_center.id).toBe('NWAC') + } + // For v2 the model is shape-identical to the parsed wire response. + expect(model).toEqual(wire) + }) + + it('maps a v2 off-season summary into the model (no danger/problems)', () => { + const wire = forecastResultSchema.parse(nwacForecastSummary) + const model = mapV2ForecastResult(wire) + + expect(model.product_type).toBe(ProductType.Summary) + expect(model.avalanche_center.id).toBe('NWAC') + expect(model).not.toHaveProperty('danger') + expect(model).not.toHaveProperty('forecast_avalanche_problems') + expect(model).toEqual(wire) + }) +}) + +describe('mapV2Warning', () => { + it('collapses the v2 null-object (no active warning) to null', () => { + const wire = warningResultSchema.parse(nwacWarning) + expect(mapV2Warning(wire)).toBeNull() + }) + + it('maps an active v2 warning into a warning product', () => { + const wire = warningResultSchema.parse({ + id: 42, + product_type: 'warning', + published_time: '2026-01-01T00:00:00+00:00', + expires_time: '2026-01-02T00:00:00+00:00', + created_at: '2026-01-01T00:00:00+00:00', + updated_at: null, + reason: 'Heavy new snow and wind loading', + affected_area: 'All zones above 4000 ft', + bottom_line: 'Avalanche Warning in effect', + hazard_discussion: '

        Dangerous avalanche conditions.

        ', + avalanche_center: { + id: 'NWAC', + name: 'Northwest Avalanche Center', + url: 'https://nwac.us', + city: 'Seattle', + state: 'WA', + }, + }) + + const model = mapV2Warning(wire) + expect(model).not.toBeNull() + expect(model?.product_type).toBe(ProductType.Warning) + expect(model?.bottom_line).toBe('Avalanche Warning in effect') + expect(model?.affected_area).toBe('All zones above 4000 ft') + expect(model?.avalanche_center.id).toBe('NWAC') + }) + + it('maps an active v2 watch into a watch product', () => { + const wire = warningResultSchema.parse({ + id: 7, + product_type: 'watch', + published_time: '2026-01-01T00:00:00+00:00', + expires_time: '2026-01-02T00:00:00+00:00', + created_at: '2026-01-01T00:00:00+00:00', + updated_at: null, + reason: 'Incoming storm', + affected_area: 'West slopes', + bottom_line: 'Avalanche Watch', + hazard_discussion: '

        Conditions deteriorating.

        ', + avalanche_center: { + id: 'NWAC', + name: 'Northwest Avalanche Center', + url: 'https://nwac.us', + city: 'Seattle', + state: 'WA', + }, + }) + + const model = mapV2Warning(wire) + expect(model?.product_type).toBe(ProductType.Watch) + }) +}) + +describe('mapV2Weather', () => { + it('parses and maps a columns/rows weather product (SAC)', () => { + const wire = weatherSchema.parse(sacWeather) + const model = mapV2Weather(wire) + + expect(model.product_type).toBe(ProductType.Weather) + expect(model.avalanche_center.id).toBe('SAC') + expect(model.weather_data).toHaveLength(1) + const table = model.weather_data[0] + // Columns/rows format: has `columns`/`rows`, no `periods`. + expect('periods' in table).toBe(false) + expect('columns' in table).toBe(true) + // For v2 the model is shape-identical to the parsed wire response. + expect(model).toEqual(wire) + }) + + it('parses and maps an inline/periods weather product', () => { + const wire = weatherSchema.parse(inlineWeather) + const model = mapV2Weather(wire) + + expect(model.weather_data).toHaveLength(1) + const table = model.weather_data[0] + // Inline format: detected by a `periods` key. + expect('periods' in table).toBe(true) + if ('periods' in table) { + expect(table.periods).toHaveLength(3) + // A split-cell value (Snowfall) is a nested array of {label,value}. + const snowfall = table.data.find((row) => row.field === 'Snowfall') + expect(Array.isArray(snowfall?.values[0])).toBe(true) + } + expect(model).toEqual(wire) + }) +}) diff --git a/__tests__/server/resolveZone.server.test.ts b/__tests__/server/resolveZone.server.test.ts new file mode 100644 index 000000000..39b5360af --- /dev/null +++ b/__tests__/server/resolveZone.server.test.ts @@ -0,0 +1,96 @@ +import type { ActiveForecastZoneWithSlug } from '../../src/services/nac/nac' +import { AvalancheForecastZoneStatus } from '../../src/services/nac/types/schemas' + +const mockGetActiveForecastZones = jest.fn() + +jest.mock('../../src/services/nac/nac', () => ({ + getActiveForecastZones: (...args: unknown[]) => mockGetActiveForecastZones(...args), +})) + +// eslint-disable-next-line @typescript-eslint/no-require-imports +const { resolveZoneFromSlug } = require('../../src/services/nac/resolveZone') + +const mockZoneConfig = { + elevation_band_names: { + lower: 'Below Treeline', + middle: 'Near Treeline', + upper: 'Above Treeline', + }, +} + +const mockZones: ActiveForecastZoneWithSlug[] = [ + { + slug: 'west-slopes-north', + zone: { + status: AvalancheForecastZoneStatus.Active, + id: 1001, + name: 'West Slopes North', + url: '/forecasts/west-slopes-north', + rank: 1, + zone_id: 'WSN', + config: mockZoneConfig, + }, + }, + { + slug: 'east-slopes-north', + zone: { + status: AvalancheForecastZoneStatus.Active, + id: 1002, + name: 'East Slopes North', + url: '/forecasts/east-slopes-north', + rank: 2, + zone_id: 'ESN', + config: mockZoneConfig, + }, + }, +] + +beforeEach(() => { + mockGetActiveForecastZones.mockReset() + mockGetActiveForecastZones.mockResolvedValue(mockZones) +}) + +describe('resolveZoneFromSlug', () => { + it('returns the correct zone for a valid slug', async () => { + const result = await resolveZoneFromSlug('nwac', 'west-slopes-north') + + expect(result).not.toBeNull() + expect(result?.slug).toBe('west-slopes-north') + expect(result?.zone.id).toBe(1001) + expect(result?.zone.name).toBe('West Slopes North') + }) + + it('returns null for an unknown slug', async () => { + const result = await resolveZoneFromSlug('nwac', 'nonexistent-zone') + + expect(result).toBeNull() + }) + + it('applies DVAC alias — passes nwac to getActiveForecastZones', async () => { + await resolveZoneFromSlug('dvac', 'west-slopes-north') + + expect(mockGetActiveForecastZones).toHaveBeenCalledWith('nwac') + }) + + it('DVAC alias resolves the correct zone', async () => { + const result = await resolveZoneFromSlug('dvac', 'east-slopes-north') + + expect(result).not.toBeNull() + expect(result?.slug).toBe('east-slopes-north') + expect(result?.zone.id).toBe(1002) + }) + + it('passes through non-dvac center slugs unchanged', async () => { + await resolveZoneFromSlug('sac', 'some-zone') + + expect(mockGetActiveForecastZones).toHaveBeenCalledWith('sac') + }) + + it('returns null when no zones exist for the center', async () => { + mockGetActiveForecastZones.mockResolvedValue([]) + + const result = await resolveZoneFromSlug('nwac', 'west-slopes-north') + + expect(result).toBeNull() + }) +}) diff --git a/__tests__/server/sanitizeHtml.server.test.ts b/__tests__/server/sanitizeHtml.server.test.ts new file mode 100644 index 000000000..cf7d7bac2 --- /dev/null +++ b/__tests__/server/sanitizeHtml.server.test.ts @@ -0,0 +1,27 @@ +import { sanitizeHtml } from '@/components/forecast/sanitizeHtml' + +describe('sanitizeHtml', () => { + it('opens external (other-domain) links in a new tab with a safe rel', () => { + const out = sanitizeHtml('

        See NWAC

        ') + expect(out).toContain('href="https://nwac.us/foo"') + expect(out).toContain('target="_blank"') + expect(out).toContain('rel="noopener noreferrer"') + }) + + it('treats protocol-relative URLs as external', () => { + const out = sanitizeHtml('x') + expect(out).toContain('target="_blank"') + }) + + it('leaves relative links in the same tab (no target)', () => { + const out = sanitizeHtml('zone') + expect(out).not.toContain('target="_blank"') + }) + + it('strips disallowed tags but keeps their text content', () => { + const out = sanitizeHtml('

        Safe text

        ') + expect(out).not.toContain('text
        ') + expect(out).toContain('Safe') + }) +}) diff --git a/docs/decisions/017-forecast-glossary.md b/docs/decisions/017-forecast-glossary.md new file mode 100644 index 000000000..b90fa9b76 --- /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 1a00b63ae..bc4d5d4ce 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/docs/nac-data-display.md b/docs/nac-data-display.md new file mode 100644 index 000000000..da27e6c76 --- /dev/null +++ b/docs/nac-data-display.md @@ -0,0 +1,57 @@ +# Displaying NAC Data in Native Pages + +This document captures conventions and known discrepancies for rendering NAC API data (forecasts, warnings, danger ratings) in native Next.js pages rather than the embedded Vue widget. + +For how that data is fetched, normalized, and configured (the source adapter, normalized model, and the rollout/data-source controls), see [`nac-data-layer.md`](./nac-data-layer.md). + +## Danger Scale Colors + +The canonical danger colors are consistent across the avy app, afp-public-widgets, and our native implementation: + +| Level | Name | Hex | Source agreement | +|-------|------|-----|------------------| +| 5 | Extreme | `#231f20` | All sources agree | +| 4 | High | `#ed1c24` | All sources agree | +| 3 | Considerable | `#f7941e` | All sources agree | +| 2 | Moderate | `#fff200` | All sources agree | +| 1 | Low | `#50b848` | All sources agree | + +### "No Rating" / Level 0 — Discrepancy + +Level 0 (None) uses different colors in different contexts within afp-public-widgets: + +| Context | Hex | Description | +|---------|-----|-------------| +| `DangerElevation.vue` | `#939598` | Gray, used in elevation band graphics | +| `forecasts.js` constant | `#909092` | Slightly different gray | +| SCSS `$no-rating` | `#6ea4db` | Blue, used in maps and charts | +| `.danger-bg-0` utility | `#ccc` | Light gray, used in generic backgrounds | + +Our native implementation uses `#939598` (matching the elevation display component) for `None` and `#6ea4db` for `GeneralInformation` (-1). This aligns with the avy app's `colorFor()` function. + +**TODO:** Align all implementations on a single "no rating" color per context (elevation graphics vs. maps vs. generic backgrounds), or document the intentional differences. + +### Text Color on Danger Backgrounds + +The afp-public-widgets only apply white text to **Extreme** (level 5). All other levels use dark text, including High (red). Our native implementation follows this convention: + +- Extreme (`#231f20` background) → white text +- All other levels → dark text (`#1a1a1a`) + +The avy app does not have explicit text color logic — it relies on the `color` npm package for contrast calculations, which may produce different results than the widget CSS. + +## Danger Icons + +Self-hosted PNGs at `/images/danger-icons/{0-5}.png`, copied from `avy/assets/danger-icons/`. These are the standard NAC danger scale icons used across all implementations. + +## Problem Type Icons + +Self-hosted PNGs at `/images/problem-icons/{ProblemName}.png`, copied from `avy/assets/problem-icons/`. Available types: CorniceFall, DeepPersistentSlab, DryLoose, Glide, PersistentSlab, StormSlab, WetLoose, WetSlab, WindSlab. + +## Reference: Source Implementations + +When investigating display behavior, these are the authoritative sources: + +- **afp-public-widgets** — The Vue 3 widget we're replacing. Colors in `src/constants/forecasts.js` and `src/css/_variables.scss`. Components in `src/components/forecasts/`. +- **avy** (React Native app) — The mobile app. Danger colors in `components/AvalancheDangerTriangle.tsx` (`colorFor`). Names in `components/helpers/dangerText.ts`. +- **web native** — Our implementation. Utilities in `src/services/nac/dangerScale.ts`. diff --git a/docs/nac-data-layer.md b/docs/nac-data-layer.md new file mode 100644 index 000000000..310331124 --- /dev/null +++ b/docs/nac-data-layer.md @@ -0,0 +1,44 @@ +# NAC data layer + +Code: `src/services/nac/`. For rendering conventions once the data is in hand (danger colors, known display discrepancies), see [`nac-data-display.md`](./nac-data-display.md). + +Native product pages (forecast, warning, …) read NAC/AFP product data through this layer. Pages never touch a raw API response — they consume a **normalized model** produced by a **per-product source adapter**. That seam is what lets us: + +- swap a product's backend (legacy **v2** → NAC **v3**) with no change to presentation, +- recombine products into new layouts (a _view_ composes several products), and +- roll a product out to native per center, with instant rollback. + +## The pieces + +All paths are under `src/services/nac/`. + +| Path | Role | +| -------------------------- | ----------------------------------------------------------------------------------------------------------------------------- | +| `model/forecast.ts` | The **normalized model** — API-shape-agnostic types the components depend on. | +| `sources/` | The **adapter**: `ForecastSource`/`WarningSource` interfaces, a `v2/` implementation, and `config.ts` (which backend to use). | +| `types/forecastSchemas.ts` | The **v2 wire schema** (zod) + shared leaf domain types. Validation/parsing only. | + +``` +page / component ──▶ getForecastSource(center).getForecast(...) // sources/index.ts + │ + ▼ + forecastSourceV2 ──▶ fetchForecast (nac.ts, hits v2, zod-parses) + │ │ + ▼ ▼ + mapV2ForecastResult(wire) ─▶ NormalizedForecast // model +``` + +**Model vs wire.** The model **owns the top-level product types** (`Forecast`, `Warning`, …) and **reuses the leaf types** (a danger level, an avalanche problem) from the wire schema. The mapper is where backend quirks get absorbed — e.g. v2 returns a null-object when there's no active warning, and the model collapses that to plain `null`, so no consumer special-cases it. For v2 the shapes already line up, so the mappers are mostly structural; a future v3 mapper targets the same model and is the _only_ place v3's deviations live. + +**Dependency direction is one-way:** components → model → (leaf re-exports) wire. The model never imports a source; sources import the model. Don't make the model depend on a backend. + +## Two controls (don't conflate them) + +- **Rollout — native vs widget.** Per-tenant × per-product, in the Payload `Settings` collection (`nativeProducts: { forecast, warning }`), read via `getNativeProductFlag`. Center-admin-facing; this is _which renderer_ a center sees. +- **Data source — v2 vs v3.** Code/env config in `sources/config.ts`, uniform across tenants (with a per-center v3 canary allowlist). **Not** a Setting — an admin can't point a live page at an unverified backend, and the test matrix stays small. This is _where the data comes from_. + +## Extending it + +- **Flip a center to the native forecast:** toggle `nativeProducts.forecast` in its Settings. +- **Move a product to v3:** implement `sources/v3/`, wire it into `getForecastSource` / `getWarningSource`, then set `NAC_FORECAST_SOURCE=v3` (or add the center to the canary allowlist). No component changes. +- **Add a new product (observation, map-layer, …):** define its model type, add a `Source` interface + a source impl with a mapper, and (if it has a native page) a `nativeProducts.` rollout flag. diff --git a/drift.lock b/drift.lock index ceb615f25..10b29f0fe 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" @@ -223,7 +223,7 @@ sig = "eef97067a5b42a38" [[bindings]] doc = "docs/decisions/007-dynamic-tenants-middleware.md" target = "src/middleware.ts" -sig = "ff301eba57dc04e2" +sig = "fd722c55fdb3239d" [[bindings]] doc = "docs/decisions/007-dynamic-tenants-middleware.md" @@ -233,7 +233,7 @@ sig = "2fcb46e02fa2f388" [[bindings]] doc = "docs/decisions/008-edge-config-tenant-lookup.md" target = "src/middleware.ts" -sig = "ff301eba57dc04e2" +sig = "fd722c55fdb3239d" [[bindings]] doc = "docs/decisions/008-edge-config-tenant-lookup.md" @@ -283,7 +283,7 @@ sig = "0ce7dd3d375aa0fe" [[bindings]] doc = "docs/decisions/013-hardcoded-tenant-lookup.md" target = "src/middleware.ts" -sig = "ff301eba57dc04e2" +sig = "fd722c55fdb3239d" [[bindings]] doc = "docs/decisions/013-hardcoded-tenant-lookup.md" @@ -328,7 +328,7 @@ sig = "7c11051944f82644" [[bindings]] doc = "docs/decisions/016-per-tenant-globals-as-unique-tenant-collections.md" target = "src/collections/Settings/index.ts" -sig = "3e50277f603c25bb" +sig = "53f3bd23fcacb254" [[bindings]] doc = "docs/decisions/016-per-tenant-globals-as-unique-tenant-collections.md" @@ -443,7 +443,7 @@ sig = "f3230eac010baa8c" [[bindings]] doc = "docs/migration-safety.md" target = "src/migrations/index.ts" -sig = "cb69e6610b10d518" +sig = "ca4bd66e7929ccde" [[bindings]] doc = "docs/migration-safety.md" @@ -568,7 +568,7 @@ sig = "4fc2ad0bd4509765" [[bindings]] doc = "docs/testing.md" target = ".env.example" -sig = "1f83179d9438d2e7" +sig = "5441b852491a9857" [[bindings]] doc = "docs/testing.md" @@ -598,4 +598,4 @@ sig = "b1d9736649cf9b6a" [[bindings]] doc = "docs/troubleshooting.md" target = "src/middleware.ts" -sig = "ff301eba57dc04e2" +sig = "fd722c55fdb3239d" diff --git a/package.json b/package.json index 26cae469b..5764a2924 100644 --- a/package.json +++ b/package.json @@ -120,6 +120,7 @@ "react-intersection-observer": "^10.0.0", "react-zoom-pan-pinch": "^4.0.3", "require-in-the-middle": "^7.5.2", + "sanitize-html": "^2.17.5", "sharp": "0.34.5", "tailwind-merge": "^3.4.0", "tailwindcss-animate": "^1.0.7", @@ -143,6 +144,7 @@ "@types/node": "22.19.17", "@types/react": "19.2.14", "@types/react-dom": "19.2.3", + "@types/sanitize-html": "^2.16.1", "autoprefixer": "^10.4.20", "dotenv": "^17.2.0", "eslint": "^9.26.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fa183aa4b..1f088812a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -213,6 +213,9 @@ importers: require-in-the-middle: specifier: ^7.5.2 version: 7.5.2 + sanitize-html: + specifier: ^2.17.5 + version: 2.17.5 sharp: specifier: 0.34.5 version: 0.34.5 @@ -277,6 +280,9 @@ importers: '@types/react-dom': specifier: 19.2.3 version: 19.2.3(@types/react@19.2.14) + '@types/sanitize-html': + specifier: ^2.16.1 + version: 2.16.1 autoprefixer: specifier: ^10.4.20 version: 10.4.21(postcss@8.5.3) @@ -5962,6 +5968,12 @@ packages: integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==, } + '@types/sanitize-html@2.16.1': + resolution: + { + integrity: sha512-n9wjs8bCOTyN/ynwD8s/nTcTreIHB1vf31vhLMGqUPNHaweKC4/fAl4Dj+hUlCTKYgm4P3k83fmiFfzkZ6sgMA==, + } + '@types/set-cookie-parser@2.4.10': resolution: { @@ -7641,6 +7653,12 @@ packages: integrity: sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==, } + dayjs@1.11.21: + resolution: + { + integrity: sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==, + } + debounce-fn@6.0.0: resolution: { @@ -9270,6 +9288,12 @@ packages: } engines: { node: '>=14' } + htmlparser2@10.1.0: + resolution: + { + integrity: sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==, + } + htmlparser2@8.0.2: resolution: { @@ -9644,6 +9668,13 @@ packages: } engines: { node: '>=0.12.0' } + is-plain-object@5.0.0: + resolution: + { + integrity: sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==, + } + engines: { node: '>=0.10.0' } + is-potential-custom-element-name@1.0.1: resolution: { @@ -10239,6 +10270,12 @@ packages: } engines: { node: '>=0.10' } + launder@1.7.1: + resolution: + { + integrity: sha512-mU6WRz5EusL9ZZuiZ5SO4Y6C0P9PAUR9iwdb6bzj4KDihm28DiHFw+/yk9DBH4f+Pv1wuzQ4e2jV3oQ7mkIqvw==, + } + leac@0.6.0: resolution: { @@ -11338,6 +11375,12 @@ packages: } engines: { node: '>=0.10.0' } + parse-srcset@1.0.2: + resolution: + { + integrity: sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==, + } + parse5@7.3.0: resolution: { @@ -12294,6 +12337,12 @@ packages: integrity: sha512-y/52Mcy7aw3gRm7IrcGDFx/bCk4AhRh2eI9luHOQM86nZsqwiRkkq2GekHXBBD+SmPidc8i2PqtYZl+pWJ8Oeg==, } + sanitize-html@2.17.5: + resolution: + { + integrity: sha512-ZmU1joGRrvoyctKIiuwUxqR6moLoU2Wk+2bMccN6f7UwhAmwYDvWziqPxRDDN2Qip62NqnIrVrT9akbL6Wretg==, + } + sass@1.77.4: resolution: { @@ -17701,6 +17750,10 @@ snapshots: dependencies: csstype: 3.2.3 + '@types/sanitize-html@2.16.1': + dependencies: + htmlparser2: 10.1.0 + '@types/set-cookie-parser@2.4.10': dependencies: '@types/node': 22.19.17 @@ -18659,6 +18712,8 @@ snapshots: dateformat@4.6.3: {} + dayjs@1.11.21: {} + debounce-fn@6.0.0: dependencies: mimic-function: 5.0.1 @@ -19789,6 +19844,13 @@ snapshots: htmlparser2: 8.0.2 selderee: 0.11.0 + htmlparser2@10.1.0: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + domutils: 3.2.2 + entities: 7.0.1 + htmlparser2@8.0.2: dependencies: domelementtype: 2.3.0 @@ -19992,6 +20054,8 @@ snapshots: is-number@7.0.0: {} + is-plain-object@5.0.0: {} + is-potential-custom-element-name@1.0.1: {} is-promise@4.0.0: {} @@ -20545,6 +20609,10 @@ snapshots: dependencies: language-subtag-registry: 0.3.23 + launder@1.7.1: + dependencies: + dayjs: 1.11.21 + leac@0.6.0: {} leven@3.1.0: {} @@ -21325,6 +21393,8 @@ snapshots: parse-passwd@1.0.0: {} + parse-srcset@1.0.2: {} + parse5@7.3.0: dependencies: entities: 6.0.1 @@ -21993,6 +22063,16 @@ snapshots: dependencies: truncate-utf8-bytes: 1.0.2 + sanitize-html@2.17.5: + dependencies: + deepmerge: 4.3.1 + escape-string-regexp: 4.0.0 + htmlparser2: 10.1.0 + is-plain-object: 5.0.0 + launder: 1.7.1 + parse-srcset: 1.0.2 + postcss: 8.5.3 + sass@1.77.4: dependencies: chokidar: 3.6.0 diff --git a/public/images/danger-icons/0.png b/public/images/danger-icons/0.png new file mode 100644 index 000000000..e6761a536 Binary files /dev/null and b/public/images/danger-icons/0.png differ diff --git a/public/images/danger-icons/1.png b/public/images/danger-icons/1.png new file mode 100644 index 000000000..b80906bf4 Binary files /dev/null and b/public/images/danger-icons/1.png differ diff --git a/public/images/danger-icons/2.png b/public/images/danger-icons/2.png new file mode 100644 index 000000000..d56a7cd73 Binary files /dev/null and b/public/images/danger-icons/2.png differ diff --git a/public/images/danger-icons/3.png b/public/images/danger-icons/3.png new file mode 100644 index 000000000..6ec8b08a5 Binary files /dev/null and b/public/images/danger-icons/3.png differ diff --git a/public/images/danger-icons/4.png b/public/images/danger-icons/4.png new file mode 100644 index 000000000..f651e59e3 Binary files /dev/null and b/public/images/danger-icons/4.png differ diff --git a/public/images/danger-icons/5.png b/public/images/danger-icons/5.png new file mode 100644 index 000000000..f651e59e3 Binary files /dev/null and b/public/images/danger-icons/5.png differ diff --git a/public/images/problem-icons/CorniceFall.png b/public/images/problem-icons/CorniceFall.png new file mode 100644 index 000000000..affa4d078 Binary files /dev/null and b/public/images/problem-icons/CorniceFall.png differ diff --git a/public/images/problem-icons/DeepPersistentSlab.png b/public/images/problem-icons/DeepPersistentSlab.png new file mode 100644 index 000000000..f07a23225 Binary files /dev/null and b/public/images/problem-icons/DeepPersistentSlab.png differ diff --git a/public/images/problem-icons/DryLoose.png b/public/images/problem-icons/DryLoose.png new file mode 100644 index 000000000..c512b2dd1 Binary files /dev/null and b/public/images/problem-icons/DryLoose.png differ diff --git a/public/images/problem-icons/Glide.png b/public/images/problem-icons/Glide.png new file mode 100644 index 000000000..41f3252b4 Binary files /dev/null and b/public/images/problem-icons/Glide.png differ diff --git a/public/images/problem-icons/PersistentSlab.png b/public/images/problem-icons/PersistentSlab.png new file mode 100644 index 000000000..9cda9e7ca Binary files /dev/null and b/public/images/problem-icons/PersistentSlab.png differ diff --git a/public/images/problem-icons/StormSlab.png b/public/images/problem-icons/StormSlab.png new file mode 100644 index 000000000..a809a0ca8 Binary files /dev/null and b/public/images/problem-icons/StormSlab.png differ diff --git a/public/images/problem-icons/WetLoose.png b/public/images/problem-icons/WetLoose.png new file mode 100644 index 000000000..b25ff1ad7 Binary files /dev/null and b/public/images/problem-icons/WetLoose.png differ diff --git a/public/images/problem-icons/WetSlab.png b/public/images/problem-icons/WetSlab.png new file mode 100644 index 000000000..d494a1dd3 Binary files /dev/null and b/public/images/problem-icons/WetSlab.png differ diff --git a/public/images/problem-icons/WindSlab.png b/public/images/problem-icons/WindSlab.png new file mode 100644 index 000000000..cb20d7909 Binary files /dev/null and b/public/images/problem-icons/WindSlab.png differ 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 000000000..4d35d01c3 --- /dev/null +++ b/src/app/(frontend)/[center]/forecasts/avalanche/[zone]/[date]/page.tsx @@ -0,0 +1,138 @@ +import type { Metadata } from 'next/types' + +import { NativeForecastView } from '@/components/forecast/NativeForecastView' +import { + buildZoneArchiveDates, + findProductIdForDate, + initialArchiveWindow, + validDateForProduct, +} from '@/services/nac/archiveDates' +import { + fetchProductArchive, + fetchProductById, + getAvalancheCenterMetadata, + getAvalancheCenterPlatforms, +} from '@/services/nac/nac' +import { resolveZoneFromSlug } from '@/services/nac/resolveZone' +import { getForecastSource, getWeatherSource } from '@/services/nac/sources' +import { formatZoneName } from '@/utilities/formatZoneName' +import { getNativeProductFlag } from '@/utilities/getNativeProductFlag' +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 getNativeProductFlag(center, 'forecast') + 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), + getForecastSource(center).getForecast(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 + + // The archived forecast references the mountain-weather product that was current when it was + // issued; fetch that (immutable, by id) so historical views show the matching weather. + const weatherProductId = forecastResult.weather_data?.weather_product_id ?? null + const weather = weatherProductId + ? await getWeatherSource(center).getWeather(weatherProductId) + : 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/(frontend)/[center]/forecasts/avalanche/[zone]/page.tsx b/src/app/(frontend)/[center]/forecasts/avalanche/[zone]/page.tsx index fc527375b..6f2d15737 100644 --- a/src/app/(frontend)/[center]/forecasts/avalanche/[zone]/page.tsx +++ b/src/app/(frontend)/[center]/forecasts/avalanche/[zone]/page.tsx @@ -5,17 +5,23 @@ import { getPayload } from 'payload' import { NACWidget } from '@/components/NACWidget' import { WidgetRouterHandler } from '@/components/NACWidget/WidgetRouterHandler.client' +import { NativeForecastPage } from '@/components/forecast/NativeForecastPage' +import { ProductType } from '@/services/nac/model/forecast' import { getActiveForecastZones, getAvalancheCenterPlatforms, getForecastZoneDanger, } from '@/services/nac/nac' +import { resolveZoneFromSlug } from '@/services/nac/resolveZone' +import { getForecastSource } from '@/services/nac/sources' import { formatZoneName } from '@/utilities/formatZoneName' +import { getNativeProductFlag } from '@/utilities/getNativeProductFlag' import { notFound } from 'next/navigation' -// ISR rather than fully static so the og:description travel advice in shared link previews -// stays current with the daily forecast. The og:image itself is always live (dynamic route). -export const revalidate = 1800 +// Short ISR backstop (5 min) so a forecast is never frozen at build time and the og:description +// travel advice in shared link previews stays current with the daily forecast. The per-view +// revalidate-on-view path (ForecastFreshness) catches corrections/retractions faster than this. +export const revalidate = 300 export const dynamicParams = false export async function generateStaticParams() { @@ -59,6 +65,12 @@ export default async function Page({ params }: Args) { notFound() } + const useNative = await getNativeProductFlag(center, 'forecast') + + if (useNative) { + return + } + return ( <> @@ -84,15 +96,27 @@ export async function generateMetadata( const parentOg = parentMeta.openGraph const zoneName = formatZoneName(zone) + const title = `${zoneName} - Avalanche Forecast | ${parentTitle}` + // Description: the forecaster's bottom line when native mode is on (richer), otherwise the + // map-layer travel advice. The og:image is always the live dynamic OG route. const danger = await getForecastZoneDanger(center, zone).catch(() => null) - const travelAdvice = danger?.travel_advice ?? null - - const title = `${zoneName} - Avalanche Forecast | ${parentTitle}` + let description = danger?.travel_advice ?? undefined + + const useNative = await getNativeProductFlag(center, 'forecast') + if (useNative) { + const resolved = await resolveZoneFromSlug(center, zone) + if (resolved) { + const forecast = await getForecastSource(center).getForecast(center, resolved.zone.id) + if (forecast && forecast.product_type === ProductType.Forecast && forecast.bottom_line) { + description = forecast.bottom_line + } + } + } return { title, - ...(travelAdvice ? { description: travelAdvice } : {}), + ...(description ? { description } : {}), alternates: { canonical: `/forecasts/avalanche/${zone}`, }, @@ -100,7 +124,7 @@ export async function generateMetadata( ...parentOg, title, url: `/forecasts/avalanche/${zone}`, - ...(travelAdvice ? { description: travelAdvice } : {}), + ...(description ? { description } : {}), images: [ { url: `/api/${center}/og?route=forecasts/avalanche/${zone}`, diff --git a/src/app/(frontend)/[center]/forecasts/avalanche/page.tsx b/src/app/(frontend)/[center]/forecasts/avalanche/page.tsx index d1244e513..dfe834daa 100644 --- a/src/app/(frontend)/[center]/forecasts/avalanche/page.tsx +++ b/src/app/(frontend)/[center]/forecasts/avalanche/page.tsx @@ -5,11 +5,15 @@ import { getPayload } from 'payload' import { NACWidget } from '@/components/NACWidget' import { WidgetRouterHandler } from '@/components/NACWidget/WidgetRouterHandler.client' +import { AllZonesForecast } from '@/components/forecast/AllZonesForecast' import { getAvalancheCenterPlatforms } from '@/services/nac/nac' +import { getNativeProductFlag } from '@/utilities/getNativeProductFlag' import { notFound } from 'next/navigation' import { ZoneLinkHijacker } from './ZoneLinkHijacker.client' -export const dynamic = 'force-static' +// Short ISR backstop (5 min) instead of force-static: bare force-static freezes the all-zones +// grid (per-zone danger + bottom line) at build time, which is unsafe for a daily forecast. +export const revalidate = 300 export async function generateStaticParams() { const payload = await getPayload({ config: configPromise }) @@ -41,6 +45,12 @@ export default async function Page({ params }: Args) { notFound() } + const useNative = await getNativeProductFlag(center, 'forecast') + + if (useNative) { + return + } + return ( <> 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 000000000..ac68978a5 --- /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/app/api/[center]/forecast-freshness/route.ts b/src/app/api/[center]/forecast-freshness/route.ts new file mode 100644 index 000000000..4665e0314 --- /dev/null +++ b/src/app/api/[center]/forecast-freshness/route.ts @@ -0,0 +1,78 @@ +import { forecastFingerprint } from '@/services/nac/forecastFingerprint' +import { forecastCacheTag, weatherCacheTag } from '@/services/nac/nac' +import { resolveZoneFromSlug } from '@/services/nac/resolveZone' +import { getForecastSource } from '@/services/nac/sources' +import { revalidateTag } from 'next/cache' +import { NextRequest, NextResponse } from 'next/server' + +const NO_STORE = { 'Cache-Control': 'no-store' } + +// Reads request headers, so it must never be cached. +export const dynamic = 'force-dynamic' + +/** + * Revalidate-on-view freshness check (safety-critical). On mount the client sends the fingerprint + * (`If-None-Match`) of the forecast its ISR page currently shows. This fetches the CURRENT forecast + * fresh (short-cached upstream) and decides two things independently: + * + * 1. Purge the SHARED cache? Only when the fresh product genuinely differs from what the cache is + * serving — a server-side comparison, NOT the caller-controlled header, so an unauthenticated + * client can't force repeated purges (which would defeat the cache and amplify upstream load). + * On a real change we revalidate the forecast tag (which also invalidates the page's route + * cache) and the weather product's tag, so a refresh renders fresh forecast + weather together. + * 2. Refresh THIS viewer? When the fresh fingerprint differs from the one they rendered (their + * If-None-Match) → 200 so their router.refresh() re-renders; otherwise 304. + * + * A failed or absent fresh fetch (upstream error, parse failure, or genuinely no product) returns + * 304 and never purges — so a transient upstream blip can't blank the last-known-good forecast; the + * ISR window remains the backstop, and a genuine withdrawal is caught by that window. + * + * Invariant: the fresh check always runs on every view; validity/expiry never skips it — a + * correction can be published while a forecast is still inside its validity window. + */ +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ center: string }> }, +) { + const { center } = await params + const zoneSlug = request.nextUrl.searchParams.get('zone') + + if (!zoneSlug) { + return NextResponse.json( + { error: 'Missing zone parameter' }, + { status: 400, headers: NO_STORE }, + ) + } + + const zone = await resolveZoneFromSlug(center, zoneSlug) + if (!zone) { + return NextResponse.json({ error: 'Zone not found' }, { status: 404, headers: NO_STORE }) + } + + const source = getForecastSource(center) + const fresh = await source.getForecastFresh(center, zone.zone.id) + + // No fresh product (upstream error, parse failure, or genuinely none published): do NOT purge the + // cache — leave the last-known-good forecast in place and let the ISR window back it up. + if (!fresh) { + return new NextResponse(null, { status: 304, headers: NO_STORE }) + } + const freshEtag = forecastFingerprint(fresh) + + // Purge decision — server-authoritative: compare the fresh product to what the shared cache is + // actually serving (not the caller's header), so freshness spam can't evict the cache. + const cached = await source.getForecast(center, zone.zone.id) + const cacheIsStale = !cached || forecastFingerprint(cached) !== freshEtag + if (cacheIsStale) { + revalidateTag(forecastCacheTag(center, zone.zone.id)) + const weatherProductId = fresh.weather_data?.weather_product_id + if (weatherProductId) revalidateTag(weatherCacheTag(weatherProductId)) + } + + // Refresh decision — is what THIS viewer rendered stale relative to the fresh product? + const ifNoneMatch = request.headers.get('if-none-match') + if (ifNoneMatch !== null && ifNoneMatch === freshEtag) { + return new NextResponse(null, { status: 304, headers: NO_STORE }) + } + return NextResponse.json({ changed: true, etag: freshEtag }, { status: 200, headers: NO_STORE }) +} diff --git a/src/collections/Settings/index.ts b/src/collections/Settings/index.ts index 035f6a7ad..42dca6f22 100644 --- a/src/collections/Settings/index.ts +++ b/src/collections/Settings/index.ts @@ -215,6 +215,37 @@ const brandAssetsFields: Field[] = [ }, ] +const featuresFields: Field[] = [ + { + name: 'nativeProducts', + type: 'group', + label: 'Native product pages', + admin: { + description: + 'When enabled, these products render natively as Next.js pages on this site’s design system instead of the embedded NAC widget. Toggle per product for incremental rollout with instant rollback.', + }, + fields: [ + { + name: 'forecast', + type: 'checkbox', + defaultValue: false, + admin: { + description: 'Render the avalanche forecast page natively.', + }, + }, + { + name: 'warning', + type: 'checkbox', + defaultValue: false, + admin: { + description: + 'Render warning/watch/special bulletins natively (currently shown as a banner on the native forecast page).', + }, + }, + ], + }, +] + const socialMediaFields: Field[] = [ { name: 'socialMedia', @@ -325,6 +356,11 @@ export const Settings: CollectionConfig = { 'Images used throughout the website including in the header, footer, browser tabs, and link previews.', fields: brandAssetsFields, }, + { + label: 'Features', + description: 'Feature flags to control which features are enabled for this center.', + fields: featuresFields, + }, { label: 'Social Media', description: diff --git a/src/components/forecast/AllZonesForecast.tsx b/src/components/forecast/AllZonesForecast.tsx new file mode 100644 index 000000000..d3980bcad --- /dev/null +++ b/src/components/forecast/AllZonesForecast.tsx @@ -0,0 +1,62 @@ +/** + * All-zones forecast grid: fetches and renders compact forecast cards + * for every active zone in a center, in parallel. + */ +import { getActiveForecastZones, getAvalancheCenterMetadata } from '@/services/nac/nac' +import { getForecastSource, getWarningSource } from '@/services/nac/sources' + +import { ForecastErrorBoundary } from './ForecastErrorBoundary' +import { ZoneForecastCard } from './ZoneForecastCard' + +interface AllZonesForecastProps { + centerSlug: string +} + +export async function AllZonesForecast({ centerSlug }: AllZonesForecastProps) { + // Metadata gives us the center timezone for rendering issued/expires times. + const [zones, metadata] = await Promise.all([ + getActiveForecastZones(centerSlug), + getAvalancheCenterMetadata(centerSlug), + ]) + + if (zones.length === 0) { + return ( +
        + No active forecast zones found. +
        + ) + } + + // Fetch all forecasts and warnings in parallel, through the per-product source adapter. + const forecastSource = getForecastSource(centerSlug) + const warningSource = getWarningSource(centerSlug) + const results = await Promise.all( + zones.map(async ({ slug, zone }) => { + const [forecast, warning] = await Promise.all([ + forecastSource.getForecast(centerSlug, zone.id), + warningSource.getWarning(centerSlug, zone.id), + ]) + return { slug, zone, forecast, warning } + }), + ) + + return ( +
        + {results.map(({ slug, zone, forecast, warning }) => ( + + + + ))} +
        + ) +} diff --git a/src/components/forecast/AvalancheProblemCard.tsx b/src/components/forecast/AvalancheProblemCard.tsx new file mode 100644 index 000000000..10209b9c0 --- /dev/null +++ b/src/components/forecast/AvalancheProblemCard.tsx @@ -0,0 +1,121 @@ +/** + * Avalanche problem card, matching the legacy afp widget: titled "Problem #{rank}: {name}", + * with four labeled columns (Problem Type icon + name, Aspect/Elevation rose, Likelihood, + * Size), then the discussion with the example photo floated inline to its right. + */ +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { + AvalancheProblemName, + MediaType, + type AvalancheProblem, +} from '@/services/nac/model/forecast' + +import { LocatorRose } from './LocatorRose' +import { LikelihoodSlider, SizeSlider } from './ProblemSlider' +import { sanitizeHtml } from './sanitizeHtml' + +/** Maps problem names to local icon filenames at /images/problem-icons/{name}.png */ +const problemIconFile: Record = { + [AvalancheProblemName.DryLoose]: 'DryLoose', + [AvalancheProblemName.StormSlab]: 'StormSlab', + [AvalancheProblemName.WindSlab]: 'WindSlab', + [AvalancheProblemName.PersistentSlab]: 'PersistentSlab', + [AvalancheProblemName.DeepPersistentSlab]: 'DeepPersistentSlab', + [AvalancheProblemName.WetLoose]: 'WetLoose', + [AvalancheProblemName.WetSlab]: 'WetSlab', + [AvalancheProblemName.CorniceFall]: 'CorniceFall', + [AvalancheProblemName.Glide]: 'Glide', + [AvalancheProblemName.GlideAvalanches]: 'Glide', +} + +function problemIconUrl(name: AvalancheProblemName): string { + return `/images/problem-icons/${problemIconFile[name]}.png` +} + +/** + * The example photo (src + optional HTML caption) for a problem's media item, or null if not + * displayable. Uses the medium image size to fill the inline figure; handles image and photo. + */ +function problemPhoto( + media: AvalancheProblem['media'], +): { src: string; caption: string | null } | null { + if (media.type === MediaType.Image) { + return { src: media.url.medium, caption: media.caption } + } + if (media.type === MediaType.Photo) { + return typeof media.url === 'string' ? { src: media.url, caption: media.caption } : null + } + return null +} + +interface AvalancheProblemCardProps { + problem: AvalancheProblem +} + +export function AvalancheProblemCard({ problem }: AvalancheProblemCardProps) { + const photo = problemPhoto(problem.media) + + return ( + + + + Problem #{problem.rank}: {problem.name} + + + + {/* Four labeled columns, matching the widget: Problem Type, Aspect/Elevation, + Likelihood, Size. Two-up at small widths, four-up from lg. */} +
        +
        +
        Problem Type
        + {/* eslint-disable-next-line @next/next/no-img-element */} + + {problem.name} +
        +
        +
        Aspect/Elevation
        + +
        +
        +
        Likelihood
        + +
        +
        +
        Size
        + +
        +
        + + {/* Discussion with the example photo floated inline to the right (wraps on md+); + overflow-hidden contains the float within the card. */} + {(photo || problem.discussion) && ( +
        + {photo && ( +
        + {/* eslint-disable-next-line @next/next/no-img-element */} + + {photo.caption && ( +
        + )} +
        + )} + {problem.discussion && ( +
        + )} +
        + )} + + + ) +} diff --git a/src/components/forecast/BottomLine.tsx b/src/components/forecast/BottomLine.tsx new file mode 100644 index 000000000..c121a6f25 --- /dev/null +++ b/src/components/forecast/BottomLine.tsx @@ -0,0 +1,43 @@ +/** + * Bottom line summary: highest danger icon + sanitized HTML. + */ +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { dangerIconSize, dangerIconUrl } from '@/services/nac/dangerScale' +import type { DangerLevel } from '@/services/nac/model/forecast' + +import { sanitizeHtml } from './sanitizeHtml' + +interface BottomLineProps { + html: string + dangerLevel: DangerLevel +} + +export function BottomLine({ html, dangerLevel }: BottomLineProps) { + const iconSize = dangerIconSize(dangerLevel) + return ( + + + + {/* Fixed height, auto width preserves the diamond's aspect ratio (icons 3–5 are wider); + width/height attributes reserve the space so the title doesn't shift on load. */} + {/* eslint-disable-next-line @next/next/no-img-element */} + + The Bottom Line + + + +
        + + + ) +} diff --git a/src/components/forecast/DangerRating.tsx b/src/components/forecast/DangerRating.tsx new file mode 100644 index 000000000..86802a844 --- /dev/null +++ b/src/components/forecast/DangerRating.tsx @@ -0,0 +1,199 @@ +/** + * Avalanche danger section, matching the legacy afp widget. Today gets the detailed treatment — + * gray rows with white elevation-name pills over a color-coded triangle, the "{level} - {Name}" + * rating, and the danger diamond icon; tomorrow is a compact outlook (gray rows, rating + icon), + * so the current day reads as the more important one. Below sit the Elevation Band Descriptions + * disclosure and the danger-scale legend. The compact all-zones card passes no dates and renders + * both days compactly without the legend. + */ +import Image from 'next/image' + +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { validDateHeading } from '@/services/nac/archiveDates' +import { + ELEVATION_BANDS_URL, + NO_RATING_ADVICE, + dangerIconSize, + dangerIconUrl, + dangerLevelLabel, + dangerName, +} from '@/services/nac/dangerScale' +import { + type AvalancheDangerForecast, + DangerLevel, + ForecastPeriod, +} from '@/services/nac/model/forecast' +import type { ElevationBandNames } from '@/services/nac/types/schemas' +import { cn } from '@/utilities/ui' + +import { DangerScale } from './DangerScale' +import { DangerTriangle } from './DangerTriangle' +import { ExternalLink } from './ExternalLink' +import { sanitizeHtml } from './sanitizeHtml' + +interface DangerRatingProps { + danger: AvalancheDangerForecast[] + elevationBandNames: ElevationBandNames + /** Published time of the product — when set, day columns are headed by real valid dates. */ + publishedTime?: string + /** Center timezone for the noon valid-date rule. */ + timezone?: string | null +} + +export function DangerRating({ + danger, + elevationBandNames, + publishedTime, + timezone, +}: DangerRatingProps) { + const today = danger.find((d) => d.valid_day === ForecastPeriod.Current) + const tomorrow = danger.find((d) => d.valid_day === ForecastPeriod.Tomorrow) + + // No Rating everywhere today → show the legacy explanation pointing to the summary. + const todayNoRating = + today != null && + today.upper === DangerLevel.None && + today.middle === DangerLevel.None && + today.lower === DangerLevel.None + + // The full dated view passes a published time; the compact all-zones card does not. + const dated = publishedTime != null + const todayHeading = dated ? (validDateHeading(publishedTime, timezone, 0) ?? 'Today') : 'Today' + const tomorrowHeading = dated + ? (validDateHeading(publishedTime, timezone, 1) ?? 'Tomorrow') + : 'Tomorrow' + + return ( + + + Avalanche Danger + + + {/* Today is wider/detailed; tomorrow is a compact outlook. Side by side from lg. */} +
        + {today && ( +
        + +
        + )} + {tomorrow && ( +
        + +
        + )} +
        + + {dated && todayNoRating && ( +

        {NO_RATING_ADVICE}

        + )} + + {dated && ( + + Elevation Band Descriptions + + )} + {dated && } +
        +
        + ) +} + +interface DangerDayProps { + heading: string + forecast: AvalancheDangerForecast + elevationBandNames: ElevationBandNames + variant: 'detailed' | 'compact' +} + +function DangerDay({ heading, forecast, elevationBandNames, variant }: DangerDayProps) { + const bands: { label: string; level: DangerLevel }[] = [ + { label: elevationBandNames.upper, level: forecast.upper }, + { label: elevationBandNames.middle, level: forecast.middle }, + { label: elevationBandNames.lower, level: forecast.lower }, + ] + + if (variant === 'compact') { + return ( +
        +

        {heading}

        +
        + {bands.map((band, i) => { + const size = dangerIconSize(band.level) + return ( +
        + {/* Muted single-line band label (br collapsed) keeps the outlook compact. */} + + {dangerLevelLabel(band.level)} + {dangerName(band.level)} +
        + ) + })} +
        +
        + ) + } + + return ( +
        +

        {heading}

        + {/* Three stacked layers: gray row backgrounds, the color-coded triangle, then the content + (white elevation pills over the triangle + rating + diamond icon). */} +
        +
        + {bands.map((_, i) => ( +
        + ))} +
        + +
        + {bands.map((band, i) => { + const size = dangerIconSize(band.level) + return ( +
        + {/* Elevation labels may contain HTML (e.g. "Upper Elevations
        7500-5500ft") */} + + + {dangerLevelLabel(band.level)} + + {dangerName(band.level)} +
        + ) + })} +
        +
        +
        + ) +} diff --git a/src/components/forecast/DangerScale.tsx b/src/components/forecast/DangerScale.tsx new file mode 100644 index 000000000..255a38291 --- /dev/null +++ b/src/components/forecast/DangerScale.tsx @@ -0,0 +1,105 @@ +/** + * North American Public Avalanche Danger Scale legend, matching the legacy widget: a 1–5 color + * strip ("{level} - {Name}") with an expandable table of travel advice / likelihood / size and + * distribution per level, plus a link out to the full danger-scale explainer. + */ +import { ChevronRight } from 'lucide-react' +import Image from 'next/image' + +import { + DANGER_SCALE_URL, + dangerColor, + dangerIconSize, + dangerIconUrl, + dangerName, + dangerScaleRows, +} from '@/services/nac/dangerScale' + +import { ExternalLink } from './ExternalLink' +import { sanitizeHtml } from './sanitizeHtml' + +export function DangerScale() { + return ( +
        +
        +

        Danger Scale

        + Learn more +
        + + {/* Color strip: levels 1–5, each capped by its danger color. */} +
        + {dangerScaleRows.map((row) => ( +
        + {row.level} - {row.rating} +
        + ))} +
        + + {/* Expandable definitions — a subtle inline toggle rather than a bordered card. */} +
        + + +
        + + + + + {dangerScaleRows.map((row) => ( + + ))} + + + + + + {dangerScaleRows.map((row) => ( + + + + {dangerScaleRows.map((row) => ( + + ))} + + + + {dangerScaleRows.map((row) => ( + + ))} + + +
        + {dangerName(row.level)} +
        + {row.level} - {row.rating} +
        +
        Travel Advice + ))} +
        Likelihood of Avalanches + {row.likelihood} +
        Avalanche Size and Distribution + {row.sizeDist} +
        +
        +
        +
        + ) +} diff --git a/src/components/forecast/DangerTriangle.tsx b/src/components/forecast/DangerTriangle.tsx new file mode 100644 index 000000000..5021b7ef9 --- /dev/null +++ b/src/components/forecast/DangerTriangle.tsx @@ -0,0 +1,40 @@ +/** + * Danger triangle SVG — 3-trapezoid pyramid colored by danger level per elevation band. + * Pixel-perfect port from avy/components/AvalancheDangerTriangle.tsx. + * SVG path data copied verbatim from the AvyApp source. + */ +import { dangerColor } from '@/services/nac/dangerScale' +import { DangerLevel } from '@/services/nac/model/forecast' + +interface DangerTriangleProps { + upper: DangerLevel + middle: DangerLevel + lower: DangerLevel + className?: string +} + +export function DangerTriangle({ upper, middle, lower, className }: DangerTriangleProps) { + return ( + + + + + + ) +} diff --git a/src/components/forecast/ExternalLink.tsx b/src/components/forecast/ExternalLink.tsx new file mode 100644 index 000000000..aa2767d97 --- /dev/null +++ b/src/components/forecast/ExternalLink.tsx @@ -0,0 +1,31 @@ +/** + * A text link that opens in a new tab, with a trailing new-tab icon — used for reference/explainer + * links out to avalanche.org, nwac.us, etc. so it's always clear the link leaves the site. + */ +import { ExternalLink as ExternalLinkIcon } from 'lucide-react' +import type { ReactNode } from 'react' + +import { cn } from '@/utilities/ui' + +interface ExternalLinkProps { + href: string + children: ReactNode + className?: string +} + +export function ExternalLink({ href, children, className }: ExternalLinkProps) { + return ( + + {children} + + ) +} diff --git a/src/components/forecast/ForecastDatePicker.client.tsx b/src/components/forecast/ForecastDatePicker.client.tsx new file mode 100644 index 000000000..7040eeb81 --- /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/ForecastDisclaimer.tsx b/src/components/forecast/ForecastDisclaimer.tsx new file mode 100644 index 000000000..7a80d2b46 --- /dev/null +++ b/src/components/forecast/ForecastDisclaimer.tsx @@ -0,0 +1,26 @@ +/** + * Forecast scope disclaimer, shown under every product — matches the legacy afp Disclaimer.vue + * word-for-word. The provider is "U.S.D.A. Forest Service" for USFS centers, otherwise the + * center's name (afp reads `centerInfo.type == "usfs"` from the v2 avalanche-center response, + * which we capture as `AvalancheCenterType`). + */ +import { AvalancheCenterType } from '@/services/nac/types/schemas' + +interface ForecastDisclaimerProps { + centerType: AvalancheCenterType + centerName: string +} + +export function ForecastDisclaimer({ centerType, centerName }: ForecastDisclaimerProps) { + const provider = centerType === AvalancheCenterType.USFS ? 'U.S.D.A. Forest Service' : centerName + + return ( +

        + This information is provided by the {provider} and describes general backcountry avalanche + hazard and conditions.{' '} + {/* afp breaks the second sentence onto its own line on desktop, inline on mobile. */} +
        + It does not apply to ski areas and highways where avalanche mitigation is conducted. +

        + ) +} diff --git a/src/components/forecast/ForecastDiscussion.tsx b/src/components/forecast/ForecastDiscussion.tsx new file mode 100644 index 000000000..83d3fc296 --- /dev/null +++ b/src/components/forecast/ForecastDiscussion.tsx @@ -0,0 +1,26 @@ +/** + * Forecast discussion: sanitized HTML rendering. + */ +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' + +import { sanitizeHtml } from './sanitizeHtml' + +interface ForecastDiscussionProps { + html: string +} + +export function ForecastDiscussion({ html }: ForecastDiscussionProps) { + return ( + + + Forecast Discussion + + +
        + + + ) +} diff --git a/src/components/forecast/ForecastErrorBoundary.tsx b/src/components/forecast/ForecastErrorBoundary.tsx new file mode 100644 index 000000000..1fb495d8c --- /dev/null +++ b/src/components/forecast/ForecastErrorBoundary.tsx @@ -0,0 +1,47 @@ +'use client' + +import { Component, type ErrorInfo, type ReactNode } from 'react' + +interface ForecastErrorBoundaryProps { + fallbackMessage: string + children: ReactNode +} + +interface ForecastErrorBoundaryState { + hasError: boolean +} + +/** + * Section-level error boundary for forecast components. + * When a child throws during rendering, displays a styled fallback message + * instead of crashing the entire page. + */ +export class ForecastErrorBoundary extends Component< + ForecastErrorBoundaryProps, + ForecastErrorBoundaryState +> { + constructor(props: ForecastErrorBoundaryProps) { + super(props) + this.state = { hasError: false } + } + + static getDerivedStateFromError(): ForecastErrorBoundaryState { + return { hasError: true } + } + + componentDidCatch(error: Error, errorInfo: ErrorInfo): void { + console.error(`[ForecastErrorBoundary] ${this.props.fallbackMessage}:`, error, errorInfo) + } + + render() { + if (this.state.hasError) { + return ( +
        + {this.props.fallbackMessage} +
        + ) + } + + return this.props.children + } +} diff --git a/src/components/forecast/ForecastFreshness.client.tsx b/src/components/forecast/ForecastFreshness.client.tsx new file mode 100644 index 000000000..4e156bede --- /dev/null +++ b/src/components/forecast/ForecastFreshness.client.tsx @@ -0,0 +1,44 @@ +'use client' + +/** + * Revalidate-on-view: on mount, asks the freshness endpoint whether the forecast this page shows + * has been corrected/retracted since it was rendered (the page is ISR, so it can be up to its + * revalidate window stale). On a change it refreshes the route so the viewer sees the current + * forecast without a manual reload. Renders nothing. + * + * Safety invariant: the check always runs on mount; nothing (expiry, validity) gates it. + */ +import { useRouter } from 'next/navigation' +import { useEffect } from 'react' + +interface ForecastFreshnessProps { + center: string + zoneSlug: string + /** Fingerprint of the forecast this page was rendered with, sent as If-None-Match. */ + initialEtag: string +} + +export function ForecastFreshness({ center, zoneSlug, initialEtag }: ForecastFreshnessProps) { + const router = useRouter() + + useEffect(() => { + const controller = new AbortController() + + fetch(`/api/${center}/forecast-freshness?zone=${encodeURIComponent(zoneSlug)}`, { + headers: { 'If-None-Match': initialEtag }, + signal: controller.signal, + }) + .then((res) => { + // 200 → the forecast changed since render; re-render with fresh data. 304 → unchanged. + if (res.status === 200) router.refresh() + }) + .catch(() => { + // Network hiccup (or an aborted in-flight check on unmount): leave the page as-is; the + // short ISR window still backstops freshness. + }) + + return () => controller.abort() + }, [center, zoneSlug, initialEtag, router]) + + return null +} diff --git a/src/components/forecast/ForecastHeader.tsx b/src/components/forecast/ForecastHeader.tsx new file mode 100644 index 000000000..e79877265 --- /dev/null +++ b/src/components/forecast/ForecastHeader.tsx @@ -0,0 +1,49 @@ +/** + * Forecast metadata: issued time, expiration time, and author name. + * + * Rendered as a plain block (no Card) so it composes inside the all-zones zone + * card without nesting a card in a card. Times are formatted in the avalanche + * center's timezone (from the NAC metadata) because the page is server-rendered + * and has no client locale to fall back on. + */ +import type { Forecast, Summary } from '@/services/nac/model/forecast' +import { formatDateTime } from '@/utilities/formatDateTime' + +interface ForecastHeaderProps { + forecast: Pick + timezone: string | null | undefined +} + +const DATE_FORMAT = "EEEE, MMMM d, yyyy 'at' h:mm a zzz" + +function formatInZone(iso: string | null, timezone: string | null | undefined): string | null { + if (!iso) return null + const date = new Date(iso) + if (isNaN(date.getTime())) return null + return formatDateTime(iso, timezone, DATE_FORMAT) +} + +export function ForecastHeader({ forecast, timezone }: ForecastHeaderProps) { + const issued = formatInZone(forecast.published_time, timezone) + const expires = formatInZone(forecast.expires_time, timezone) + + return ( +
        + {forecast.author && ( +

        + Author: {forecast.author} +

        + )} + {issued && ( +

        + Issued: {issued} +

        + )} + {expires && ( +

        + Expires: {expires} +

        + )} +
        + ) +} diff --git a/src/components/forecast/ForecastMediaThumbnails.tsx b/src/components/forecast/ForecastMediaThumbnails.tsx new file mode 100644 index 000000000..96f1feb86 --- /dev/null +++ b/src/components/forecast/ForecastMediaThumbnails.tsx @@ -0,0 +1,74 @@ +'use client' + +import { useState } from 'react' + +import { type MediaItem, MediaType } from '@/services/nac/model/forecast' + +import { getThumbnailUrl, MediaLightbox } from './MediaLightbox' + +interface ForecastMediaThumbnailsProps { + media: MediaItem[] +} + +/** Filter to media items that have something to display in the lightbox */ +function displayableMedia(items: MediaItem[]): MediaItem[] { + return items.filter((item): item is MediaItem & { type: MediaType } => { + const t = item.type + return ( + t === MediaType.Image || + t === MediaType.Video || + t === MediaType.Photo || + t === MediaType.External || + t === MediaType.PDF + ) + }) +} + +export function ForecastMediaThumbnails({ media }: ForecastMediaThumbnailsProps) { + const [lightboxOpen, setLightboxOpen] = useState(false) + const [lightboxIndex, setLightboxIndex] = useState(0) + + const items = displayableMedia(media) + if (items.length === 0) return null + + return ( + <> +
        + {items.map((item, idx) => { + const thumbUrl = getThumbnailUrl(item) + return ( + + ) + })} +
        + + + + ) +} diff --git a/src/components/forecast/LocatorRose.tsx b/src/components/forecast/LocatorRose.tsx new file mode 100644 index 000000000..033d7a65c --- /dev/null +++ b/src/components/forecast/LocatorRose.tsx @@ -0,0 +1,119 @@ +/** + * Locator rose SVG — 24-sector rose (8 aspects × 3 elevations) showing which + * aspects and elevations an avalanche problem affects. + * Pixel-perfect port from avy/components/DangerRose.tsx. + * SVG path data copied verbatim from the AvyApp source. + */ +'use client' + +import { AvalancheProblemLocation } from '@/services/nac/model/forecast' + +interface LocatorRoseProps { + locations: AvalancheProblemLocation[] + className?: string +} + +/** + * SVG path data for each of the 24 sectors. Keys match AvalancheProblemLocation enum values. + * Paths copied verbatim from avy/components/DangerRose.tsx. + */ +const sectorPaths: Record = { + [AvalancheProblemLocation.NorthUpper]: 'M529.716,527l68.371,-166.7l-138.1,0l69.729,166.7Z', + [AvalancheProblemLocation.NorthMiddle]: + 'M666.581,193.63l-277.081,0l69.27,166.67l138.541,0l69.27,-166.67Z', + [AvalancheProblemLocation.NorthLower]: + 'M734.1,26.997l-414.2,0l69.943,166.67l275.865,0l68.392,-166.67Z', + [AvalancheProblemLocation.NortheastUpper]: + 'M529.716,527l166.22,-69.529l-97.651,-97.652l-68.569,167.181Z', + [AvalancheProblemLocation.NortheastMiddle]: + 'M862.222,388.05l-195.925,-195.926l-68.873,166.835l97.963,97.963l166.835,-68.872Z', + [AvalancheProblemLocation.NortheastLower]: + 'M1027.79,317.966l-292.884,-292.884l-68.396,167.311l195.066,195.066l166.214,-69.493Z', + [AvalancheProblemLocation.EastUpper]: 'M527.716,528.339l166.7,68.371l0,-138.1l-166.7,69.729Z', + [AvalancheProblemLocation.EastMiddle]: + 'M861.086,665.204l0,-277.081l-166.67,69.27l0,138.541l166.67,69.27Z', + [AvalancheProblemLocation.EastLower]: + 'M1027.72,732.723l0,-414.2l-166.67,69.943l0,275.865l166.67,68.392Z', + [AvalancheProblemLocation.SoutheastUpper]: + 'M527.716,528.339l69.529,166.22l97.652,-97.651l-167.181,-68.569Z', + [AvalancheProblemLocation.SoutheastMiddle]: + 'M666.666,860.845l195.926,-195.925l-166.835,-68.872l-97.963,97.962l68.872,166.835Z', + [AvalancheProblemLocation.SoutheastLower]: + 'M736.75,1026.42l292.884,-292.884l-167.311,-68.396l-195.066,195.066l69.493,166.214Z', + [AvalancheProblemLocation.SouthUpper]: 'M527.918,528.416l-68.371,166.7l138.1,0l-69.729,-166.7Z', + [AvalancheProblemLocation.SouthMiddle]: + 'M391.053,861.786l277.081,0l-69.27,-166.67l-138.541,0l-69.27,166.67Z', + [AvalancheProblemLocation.SouthLower]: + 'M323.534,1028.42l414.2,0l-69.943,-166.67l-275.865,0l-68.392,166.67Z', + [AvalancheProblemLocation.SouthwestUpper]: + 'M527.918,528.416l-166.22,69.529l97.651,97.652l68.569,-167.181Z', + [AvalancheProblemLocation.SouthwestMiddle]: + 'M195.412,667.366l195.926,195.926l68.872,-166.835l-97.963,-97.963l-166.835,68.872Z', + [AvalancheProblemLocation.SouthwestLower]: + 'M29.841,737.451l292.884,292.883l68.396,-167.31l-195.066,-195.067l-166.214,69.494Z', + [AvalancheProblemLocation.WestUpper]: 'M528.918,527.077l-166.7,-68.371l0,138.1l166.7,-69.729Z', + [AvalancheProblemLocation.WestMiddle]: + 'M195.548,390.212l0,277.081l166.67,-69.27l0,-138.541l-166.67,-69.27Z', + [AvalancheProblemLocation.WestLower]: + 'M28.915,322.693l0,414.2l166.67,-69.943l0,-275.865l-166.67,-68.392Z', + [AvalancheProblemLocation.NorthwestUpper]: + 'M529.918,527.077l-69.529,-166.22l-97.652,97.651l167.181,68.569Z', + [AvalancheProblemLocation.NorthwestMiddle]: + 'M390.968,194.571l-195.926,195.926l166.835,68.872l97.963,-97.963l-68.872,-166.835Z', + [AvalancheProblemLocation.NorthwestLower]: + 'M318.884,27l-292.884,292.884l167.311,68.396l195.066,-195.066l-69.493,-166.214Z', +} + +const ACTIVE_FILL = 'rgb(200, 202, 206)' +const INACTIVE_FILL = 'transparent' +const STROKE_COLOR = 'rgb(81, 85, 88)' + +/** Cardinal/intercardinal labels positioned around the rose */ +const directionLabels = [ + { label: 'N', x: 528, y: -30 }, + { label: 'NE', x: 1070, y: 280 }, + { label: 'E', x: 1070, y: 540 }, + { label: 'SE', x: 1070, y: 800 }, + { label: 'S', x: 528, y: 1090 }, + { label: 'SW', x: -20, y: 800 }, + { label: 'W', x: -20, y: 540 }, + { label: 'NW', x: -20, y: 280 }, +] as const + +export function LocatorRose({ locations, className }: LocatorRoseProps) { + const locationSet = new Set(locations) + + return ( + + {Object.values(AvalancheProblemLocation).map((location) => ( + + ))} + {directionLabels.map(({ label, x, y }) => ( + + {label} + + ))} + + ) +} diff --git a/src/components/forecast/MediaLightbox.tsx b/src/components/forecast/MediaLightbox.tsx new file mode 100644 index 000000000..9c17616d6 --- /dev/null +++ b/src/components/forecast/MediaLightbox.tsx @@ -0,0 +1,201 @@ +'use client' + +import { useCallback, useEffect, useState } from 'react' + +import { + Carousel, + CarouselContent, + CarouselItem, + CarouselNext, + CarouselPrevious, + type CarouselApi, +} from '@/components/ui/carousel' +import { Dialog, DialogContent, DialogDescription, DialogTitle } from '@/components/ui/dialog' +import { MediaType, type MediaItem } from '@/services/nac/model/forecast' + +import { ZoomablePhoto } from './ZoomablePhoto' +import { sanitizeHtml } from './sanitizeHtml' + +interface MediaLightboxProps { + media: MediaItem[] + initialIndex: number + open: boolean + onOpenChange: (open: boolean) => void +} + +function getThumbnailUrl(item: MediaItem): string | null { + if (item.type === MediaType.Image) return item.url.thumbnail + if (item.type === MediaType.Photo) return typeof item.url === 'string' ? item.url : null + if (item.type === MediaType.Video) { + if (typeof item.url === 'object' && 'thumbnail' in item.url) return item.url.thumbnail + return null + } + return null +} + +function getFullUrl(item: MediaItem): string | null { + if (item.type === MediaType.Image) return item.url.original + if (item.type === MediaType.Photo) return typeof item.url === 'string' ? item.url : null + return null +} + +/** Extract YouTube video_id from a Video media item */ +function getYouTubeVideoId(item: MediaItem): string | null { + if (item.type !== MediaType.Video) return null + if (typeof item.url === 'object' && 'video_id' in item.url) return item.url.video_id + return null +} + +function getCaption(item: MediaItem): string | null { + if ('caption' in item && item.caption) return item.caption + return null +} + +export function MediaLightbox({ media, initialIndex, open, onOpenChange }: MediaLightboxProps) { + const [api, setApi] = useState() + const [current, setCurrent] = useState(initialIndex) + + useEffect(() => { + if (!api) return + // Jump to the initial index when the lightbox opens or initialIndex changes + api.scrollTo(initialIndex, true) + setCurrent(initialIndex) + }, [api, initialIndex]) + + useEffect(() => { + if (!api) return + const onSelect = () => setCurrent(api.selectedScrollSnap()) + api.on('select', onSelect) + return () => { + api.off('select', onSelect) + } + }, [api]) + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === 'ArrowLeft') { + e.preventDefault() + api?.scrollPrev() + } else if (e.key === 'ArrowRight') { + e.preventDefault() + api?.scrollNext() + } + }, + [api], + ) + + if (media.length === 0) return null + + return ( + + + Media viewer + + Viewing {current + 1} of {media.length} media items. Use arrow keys to navigate. + + +
        + 1, watchDrag: false }} + > + + {media.map((item, idx) => ( + +
        + +
        +
        + ))} +
        + + {media.length > 1 && ( + <> + + + + )} +
        + + {/* Caption and counter */} +
        + {/* Captions may contain HTML tags (e.g.

        ,  ) */} + {getCaption(media[current]) && ( +

        + )} + {media.length > 1 && ( +

        + {current + 1} / {media.length} +

        + )} +
        +
        + +
        + ) +} + +function MediaSlide({ item }: { item: MediaItem }) { + const videoId = getYouTubeVideoId(item) + if (videoId) { + return ( +
        +