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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,16 @@ NAC_HOST=
# AFP wordpress API base url. Defaults to https://forecasts.avalanche.org. Not required.
AFP_HOST=

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

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

# PostHog API key
NEXT_PUBLIC_POSTHOG_KEY=

Expand Down
94 changes: 94 additions & 0 deletions __tests__/server/nacSourceMappers.server.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { ProductType } from '@/services/nac/model/forecast'
import { mapV2ForecastResult, mapV2Warning } from '@/services/nac/sources/v2/mappers'
import { forecastResultSchema, 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'

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: '<p>Dangerous avalanche conditions.</p>',
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: '<p>Conditions deteriorating.</p>',
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)
})
})
2 changes: 2 additions & 0 deletions docs/nac-data-display.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

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:
Expand Down
44 changes: 44 additions & 0 deletions docs/nac-data-layer.md
Original file line number Diff line number Diff line change
@@ -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 `<Product>Source` interface + a source impl with a mapper, and (if it has a native page) a `nativeProducts.<product>` rollout flag.
6 changes: 3 additions & 3 deletions drift.lock
Original file line number Diff line number Diff line change
Expand Up @@ -333,7 +333,7 @@ sig = "7c11051944f82644"
[[bindings]]
doc = "docs/decisions/016-per-tenant-globals-as-unique-tenant-collections.md"
target = "src/collections/Settings/index.ts"
sig = "ce23ea8c0093841c"
sig = "53f3bd23fcacb254"

[[bindings]]
doc = "docs/decisions/016-per-tenant-globals-as-unique-tenant-collections.md"
Expand Down Expand Up @@ -443,7 +443,7 @@ sig = "f3230eac010baa8c"
[[bindings]]
doc = "docs/migration-safety.md"
target = "src/migrations/index.ts"
sig = "7c70ed390657b65a"
sig = "e2b657763c6d6a16"

[[bindings]]
doc = "docs/migration-safety.md"
Expand Down Expand Up @@ -568,7 +568,7 @@ sig = "4fc2ad0bd4509765"
[[bindings]]
doc = "docs/testing.md"
target = ".env.example"
sig = "1f83179d9438d2e7"
sig = "5441b852491a9857"

[[bindings]]
doc = "docs/testing.md"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,15 @@ import {
validDateForProduct,
} from '@/services/nac/archiveDates'
import {
fetchForecast,
fetchProductArchive,
fetchProductById,
getAvalancheCenterMetadata,
getAvalancheCenterPlatforms,
} from '@/services/nac/nac'
import { resolveZoneFromSlug } from '@/services/nac/resolveZone'
import { getForecastSource } from '@/services/nac/sources'
import { formatZoneName } from '@/utilities/formatZoneName'
import { getUseNativeForecasts } from '@/utilities/getUseNativeForecasts'
import { getNativeProductFlag } from '@/utilities/getNativeProductFlag'
import { format, parseISO } from 'date-fns'
import { notFound } from 'next/navigation'

Expand Down Expand Up @@ -52,7 +52,7 @@ export default async function Page({ params }: Args) {
}

// The dated history view is native-only; the legacy widget keeps its own archive.
const useNative = await getUseNativeForecasts(center)
const useNative = await getNativeProductFlag(center, 'forecast')
if (!useNative) {
notFound()
}
Expand All @@ -79,7 +79,7 @@ export default async function Page({ params }: Args) {
// The current/live product is fetched only to anchor the picker's "return to current" path.
const [forecastResult, currentProduct] = await Promise.all([
fetchProductById(productId),
fetchForecast(center, resolvedZone.zone.id),
getForecastSource(center).getForecast(center, resolvedZone.zone.id),
])

if (!forecastResult) {
Expand Down
12 changes: 6 additions & 6 deletions src/app/(frontend)/[center]/forecasts/avalanche/[zone]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,16 @@ 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 {
fetchForecast,
getActiveForecastZones,
getAvalancheCenterPlatforms,
getForecastZoneDanger,
} from '@/services/nac/nac'
import { resolveZoneFromSlug } from '@/services/nac/resolveZone'
import { ProductType } from '@/services/nac/types/forecastSchemas'
import { getForecastSource } from '@/services/nac/sources'
import { formatZoneName } from '@/utilities/formatZoneName'
import { getUseNativeForecasts } from '@/utilities/getUseNativeForecasts'
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
Expand Down Expand Up @@ -64,7 +64,7 @@ export default async function Page({ params }: Args) {
notFound()
}

const useNative = await getUseNativeForecasts(center)
const useNative = await getNativeProductFlag(center, 'forecast')

if (useNative) {
return <NativeForecastPage centerSlug={center} zoneSlug={zone} />
Expand Down Expand Up @@ -102,11 +102,11 @@ export async function generateMetadata(
const danger = await getForecastZoneDanger(center, zone).catch(() => null)
let description = danger?.travel_advice ?? undefined

const useNative = await getUseNativeForecasts(center)
const useNative = await getNativeProductFlag(center, 'forecast')
if (useNative) {
const resolved = await resolveZoneFromSlug(center, zone)
if (resolved) {
const forecast = await fetchForecast(center, resolved.zone.id)
const forecast = await getForecastSource(center).getForecast(center, resolved.zone.id)
if (forecast && forecast.product_type === ProductType.Forecast && forecast.bottom_line) {
description = forecast.bottom_line
}
Expand Down
4 changes: 2 additions & 2 deletions src/app/(frontend)/[center]/forecasts/avalanche/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ 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 { getUseNativeForecasts } from '@/utilities/getUseNativeForecasts'
import { getNativeProductFlag } from '@/utilities/getNativeProductFlag'
import { notFound } from 'next/navigation'
import { ZoneLinkHijacker } from './ZoneLinkHijacker.client'

Expand Down Expand Up @@ -43,7 +43,7 @@ export default async function Page({ params }: Args) {
notFound()
}

const useNative = await getUseNativeForecasts(center)
const useNative = await getNativeProductFlag(center, 'forecast')

if (useNative) {
return <AllZonesForecast centerSlug={center} />
Expand Down
27 changes: 23 additions & 4 deletions src/collections/Settings/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -217,13 +217,32 @@ const brandAssetsFields: Field[] = [

const featuresFields: Field[] = [
{
name: 'useNativeForecasts',
type: 'checkbox',
defaultValue: false,
name: 'nativeProducts',
type: 'group',
label: 'Native product pages',
admin: {
description:
'When enabled, the forecast page renders natively using Next.js server components instead of the embedded widget. This can be toggled per center for incremental rollout.',
'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).',
},
},
],
},
]

Expand Down
16 changes: 7 additions & 9 deletions src/components/forecast/AllZonesForecast.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,8 @@
* All-zones forecast grid: fetches and renders compact forecast cards
* for every active zone in a center, in parallel.
*/
import {
fetchForecast,
fetchWarning,
getActiveForecastZones,
getAvalancheCenterMetadata,
} from '@/services/nac/nac'
import { getActiveForecastZones, getAvalancheCenterMetadata } from '@/services/nac/nac'
import { getForecastSource, getWarningSource } from '@/services/nac/sources'

import { ForecastErrorBoundary } from './ForecastErrorBoundary'
import { ZoneForecastCard } from './ZoneForecastCard'
Expand All @@ -31,12 +27,14 @@ export async function AllZonesForecast({ centerSlug }: AllZonesForecastProps) {
)
}

// Fetch all forecasts and warnings in parallel
// 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([
fetchForecast(centerSlug, zone.id),
fetchWarning(centerSlug, zone.id),
forecastSource.getForecast(centerSlug, zone.id),
warningSource.getWarning(centerSlug, zone.id),
])
return { slug, zone, forecast, warning }
}),
Expand Down
2 changes: 1 addition & 1 deletion src/components/forecast/AvalancheProblemCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import {
AvalancheProblemName,
MediaType,
type AvalancheProblem,
} from '@/services/nac/types/forecastSchemas'
} from '@/services/nac/model/forecast'

import { LocatorRose } from './LocatorRose'
import { LikelihoodSlider, SizeSlider } from './ProblemSlider'
Expand Down
2 changes: 1 addition & 1 deletion src/components/forecast/BottomLine.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
*/
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { dangerIconUrl } from '@/services/nac/dangerScale'
import type { DangerLevel } from '@/services/nac/types/forecastSchemas'
import type { DangerLevel } from '@/services/nac/model/forecast'

import { sanitizeHtml } from './sanitizeHtml'

Expand Down
2 changes: 1 addition & 1 deletion src/components/forecast/DangerElevationBand.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import Image from 'next/image'

import { dangerColor, dangerIconUrl, dangerName, dangerTextColor } from '@/services/nac/dangerScale'
import { DangerLevel } from '@/services/nac/types/forecastSchemas'
import { DangerLevel } from '@/services/nac/model/forecast'

import { sanitizeHtml } from './sanitizeHtml'

Expand Down
2 changes: 1 addition & 1 deletion src/components/forecast/DangerRating.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
* plus a tomorrow outlook section.
*/
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { type AvalancheDangerForecast, ForecastPeriod } from '@/services/nac/types/forecastSchemas'
import { type AvalancheDangerForecast, ForecastPeriod } from '@/services/nac/model/forecast'
import type { ElevationBandNames } from '@/services/nac/types/schemas'

import { DangerElevationBand } from './DangerElevationBand'
Expand Down
Loading
Loading