diff --git a/.env.example b/.env.example index 3cfeec488..9a245ba77 100644 --- a/.env.example +++ b/.env.example @@ -77,3 +77,6 @@ NON_PROD_SYNC_PASSWORD= # When enabled: disables schema push and enforces foreign keys to match Turso production # Used automatically by pnpm test:migration - not needed for manual setting # ENABLE_LOCAL_MIGRATIONS=true + +# SnowObs weather API token for /weather/stations pages (public token also served on nwac.us; override in prod) +SNOWOBS_TOKEN= diff --git a/.fallowrc.json b/.fallowrc.json index 5cc8d079a..b8f2b48e7 100644 --- a/.fallowrc.json +++ b/.fallowrc.json @@ -21,6 +21,9 @@ "tailwindcss-animate" ], "ignoreExportsUsedInFile": true, + "duplicates": { + "ignore": ["**/weather/stations/**"] + }, "ignoreExports": [ { "file": "src/components/ui/**", diff --git a/__tests__/server/snowobsTransform.server.test.ts b/__tests__/server/snowobsTransform.server.test.ts new file mode 100644 index 000000000..6567410e4 --- /dev/null +++ b/__tests__/server/snowobsTransform.server.test.ts @@ -0,0 +1,128 @@ +import { buildStationTable, computePrecipCumsum } from '../../src/services/snowobs/transform' +import type { SnowObsTimeseriesResponse } from '../../src/services/snowobs/types/schemas' + +// Two stations with deliberately misaligned timestamps to exercise the full +// outer join, null passthrough, and cumulative-precip computation. +const T0 = '2026-07-07T00:00:00Z' // 07/06 17:00 PDT +const T1 = '2026-07-07T01:00:00Z' // 07/06 18:00 PDT +const T2 = '2026-07-07T02:00:00Z' // 07/06 19:00 PDT +const T3 = '2026-07-07T03:00:00Z' // 07/06 20:00 PDT + +const response: SnowObsTimeseriesResponse = { + UNITS: { air_temp: 'fahrenheit', precip_accum_one_hour: 'inches' }, + VARIABLES: [ + { variable: 'air_temp', long_name: 'Air Temperature' }, + { variable: 'precip_accum_one_hour', long_name: 'Precipitation' }, + ], + STATION: [ + { + id: '4', + stid: '4', + name: 'Hurricane Ridge', + latitude: null, + longitude: null, + elevation: 5250, + observations: { + date_time: [T0, T1, T2], + air_temp: [60, null, 62], + precip_accum_one_hour: [0.1, null, 0.2], + }, + }, + { + id: '5', + stid: '5', + name: 'Upper', + latitude: null, + longitude: null, + elevation: 4200, + observations: { + date_time: [T1, T2, T3], + air_temp: [40, 41, 42], + }, + }, + ], +} + +const columnConfig: [string, string][] = [ + ['4', 'air_temp'], + ['4', 'precip_accum_one_hour'], + ['5', 'air_temp'], +] + +describe('computePrecipCumsum', () => { + it('runs a cumulative total, passing nulls through without advancing the sum', () => { + expect(computePrecipCumsum([0.1, 0.2, null, 0.3])).toEqual([0.1, 0.3, null, 0.6]) + }) + + it('rounds to two decimals to avoid float drift', () => { + expect(computePrecipCumsum([0.1, 0.2])).toEqual([0.1, 0.3]) + }) + + it('handles an all-null series', () => { + expect(computePrecipCumsum([null, null])).toEqual([null, null]) + }) +}) + +describe('buildStationTable', () => { + const table = buildStationTable(response, columnConfig) + + it('auto-inserts a cumulative-precip column after hourly precip, in config order', () => { + expect(table.columns.map((c) => c.key)).toEqual([ + '4_air_temp', + '4_precip_accum_one_hour', + '4_precip_cumsum', + '5_air_temp', + ]) + }) + + it('labels columns with short headers, long names, display units, and elevation', () => { + const temp = table.columns.find((c) => c.key === '4_air_temp') + expect(temp).toMatchObject({ + label: 'Temp', + longName: 'Air Temperature', + unit: '°F', + elevation: 5250, + }) + const cumsum = table.columns.find((c) => c.key === '4_precip_cumsum') + expect(cumsum).toMatchObject({ + label: 'PcpSum', + longName: 'Cumulative Precipitation', + unit: 'in', + }) + }) + + it('produces newest-first rows across the union of all timestamps', () => { + expect(table.rows.map((r) => r.display)).toEqual([ + '07/06 20:00', + '07/06 19:00', + '07/06 18:00', + '07/06 17:00', + ]) + }) + + it('full-outer-joins stations, filling gaps with null', () => { + const [newest, second, third, oldest] = table.rows + // T3: only station 5 reports + expect(newest.values).toMatchObject({ '4_air_temp': null, '5_air_temp': 42 }) + // T2: both report; cumulative precip = 0.1 + 0.2 + expect(second.values).toMatchObject({ + '4_air_temp': 62, + '4_precip_accum_one_hour': 0.2, + '4_precip_cumsum': 0.3, + '5_air_temp': 41, + }) + // T1: station 4 has a null reading; station 5 reports + expect(third.values).toMatchObject({ '4_air_temp': null, '5_air_temp': 40 }) + // T0: only station 4 reports; cumulative precip starts at 0.1 + expect(oldest.values).toMatchObject({ + '4_air_temp': 60, + '4_precip_cumsum': 0.1, + '5_air_temp': null, + }) + }) + + it('reports the display timezone label and latest observation time', () => { + expect(table.timezoneLabel).toBe('PDT') + expect(table.latestObservation).toBe(new Date(T3).getTime()) + }) +}) diff --git a/__tests__/server/weatherStations.server.test.ts b/__tests__/server/weatherStations.server.test.ts new file mode 100644 index 000000000..d9574e1bc --- /dev/null +++ b/__tests__/server/weatherStations.server.test.ts @@ -0,0 +1,43 @@ +import { + getStationGroup, + STATION_REGIONS, + WEATHER_STATION_GROUPS, +} from '../../src/constants/weatherStations' + +describe('weather station registry', () => { + it('has 32 station groups', () => { + expect(WEATHER_STATION_GROUPS).toHaveLength(32) + }) + + it('has unique slugs and legacy slugs', () => { + const slugs = WEATHER_STATION_GROUPS.map((g) => g.slug) + const legacy = WEATHER_STATION_GROUPS.map((g) => g.legacySlug) + expect(new Set(slugs).size).toBe(slugs.length) + expect(new Set(legacy).size).toBe(legacy.length) + }) + + it('assigns every group to a known region', () => { + for (const group of WEATHER_STATION_GROUPS) { + expect(STATION_REGIONS).toContain(group.region) + } + }) + + it('derives stids from the columns and lists them uniquely', () => { + for (const group of WEATHER_STATION_GROUPS) { + const fromColumns = [...new Set(group.columns.map(([stid]) => stid))] + expect(group.stids).toEqual(fromColumns) + expect(group.stids.length).toBeGreaterThan(0) + } + }) + + it('excludes precip_cumsum columns (the transform derives them)', () => { + for (const group of WEATHER_STATION_GROUPS) { + expect(group.columns.some(([, sensor]) => sensor === 'precip_cumsum')).toBe(false) + } + }) + + it('looks groups up by slug', () => { + expect(getStationGroup('hurricane-ridge')?.displayName).toBe('Hurricane Ridge') + expect(getStationGroup('nope')).toBeUndefined() + }) +}) diff --git a/drift.lock b/drift.lock index ceb615f25..12f62e390 100644 --- a/drift.lock +++ b/drift.lock @@ -443,7 +443,7 @@ sig = "f3230eac010baa8c" [[bindings]] doc = "docs/migration-safety.md" target = "src/migrations/index.ts" -sig = "cb69e6610b10d518" +sig = "7a7a233c0e7ecf68" [[bindings]] doc = "docs/migration-safety.md" @@ -568,7 +568,7 @@ sig = "4fc2ad0bd4509765" [[bindings]] doc = "docs/testing.md" target = ".env.example" -sig = "1f83179d9438d2e7" +sig = "7f2566ff3043bbca" [[bindings]] doc = "docs/testing.md" diff --git a/src/app/(frontend)/[center]/weather/stations/[station]/page.tsx b/src/app/(frontend)/[center]/weather/stations/[station]/page.tsx new file mode 100644 index 000000000..3e5cbb291 --- /dev/null +++ b/src/app/(frontend)/[center]/weather/stations/[station]/page.tsx @@ -0,0 +1,132 @@ +import type { Metadata, ResolvedMetadata } from 'next/types' + +import configPromise from '@payload-config' +import { getPayload } from 'payload' + +import { StationLatestObservation } from '@/components/WeatherStations/StationLatestObservation' +import { StationNowTable } from '@/components/WeatherStations/StationNowTable' +import { StationPicker } from '@/components/WeatherStations/StationPicker' +import { + resolveStationRange, + StationRangeTabs, +} from '@/components/WeatherStations/StationRangeTabs' +import { getStationGroup, WEATHER_STATION_GROUPS } from '@/constants/weatherStations' +import { getAvalancheCenterPlatforms } from '@/services/nac/nac' +import { fetchStationTimeseries } from '@/services/snowobs/snowobs' +import { buildStationTable } from '@/services/snowobs/transform' +import { notFound } from 'next/navigation' + +// ISR: regenerate at most every 10 minutes; SnowObs stations report ~hourly. +export const revalidate = 600 + +type PathArgs = { + center: string + station: string +} + +type Args = { + params: Promise + searchParams: Promise<{ range?: string }> +} + +export async function generateStaticParams() { + const payload = await getPayload({ config: configPromise }) + const tenants = await payload.find({ + collection: 'tenants', + limit: 0, + select: { + slug: true, + }, + }) + + const params: PathArgs[] = [] + for (const tenant of tenants.docs) { + const platforms = await getAvalancheCenterPlatforms(tenant.slug) + if (!platforms.stations) continue + for (const group of WEATHER_STATION_GROUPS) { + params.push({ center: tenant.slug, station: group.slug }) + } + } + return params +} + +// CRAP is inflated by the lack of unit coverage on this server component. +// fallow-ignore-next-line complexity +export default async function Page({ params, searchParams }: Args) { + const { center, station } = await params + const { range: rangeParam } = await searchParams + + const platforms = await getAvalancheCenterPlatforms(center) + if (!platforms.stations) { + notFound() + } + + const group = getStationGroup(station) + if (!group) { + notFound() + } + + const activeRange = resolveStationRange(rangeParam) + const response = await fetchStationTimeseries(group.stids, { + revalidate, + windowHours: activeRange.hours, + }) + const table = buildStationTable(response, group.columns) + + return ( +
+
+
+

{group.region}

+
+

{group.displayName}

+
+
+
+ + +
+
+ +
+ + +
+
+ ) +} + +function resolveParentTitle(parent: ResolvedMetadata): Metadata['title'] { + const { title } = parent + return title && typeof title !== 'string' && 'absolute' in title ? title.absolute : title +} + +export async function generateMetadata( + props: Args, + parent: Promise, +): Promise { + const { center, station } = await props.params + const parentMeta = await parent + const group = getStationGroup(station) + + const parentTitle = resolveParentTitle(parentMeta) + const routeTitle = group ? group.displayName : 'Weather Station' + const canonical = `/weather/stations/${station}` + + return { + title: `${routeTitle} | ${parentTitle}`, + alternates: { canonical }, + openGraph: { + ...parentMeta.openGraph, + title: `${routeTitle} | ${parentTitle}`, + url: canonical, + images: [ + { + url: `/api/${center}/og?routeTitle=${encodeURIComponent(routeTitle)}`, + width: 1200, + height: 630, + }, + ], + }, + } +} diff --git a/src/app/(frontend)/[center]/weather/stations/error.tsx b/src/app/(frontend)/[center]/weather/stations/error.tsx new file mode 100644 index 000000000..99e6163b6 --- /dev/null +++ b/src/app/(frontend)/[center]/weather/stations/error.tsx @@ -0,0 +1,5 @@ +'use client' + +import { ErrorBoundary } from '@/components/ErrorBoundary/ErrorBoundary' + +export default ErrorBoundary diff --git a/src/app/(frontend)/[center]/weather/stations/page.tsx b/src/app/(frontend)/[center]/weather/stations/page.tsx new file mode 100644 index 000000000..42585036c --- /dev/null +++ b/src/app/(frontend)/[center]/weather/stations/page.tsx @@ -0,0 +1,107 @@ +import type { Metadata, ResolvedMetadata } from 'next/types' + +import configPromise from '@payload-config' +import { getPayload } from 'payload' + +import { StationPicker } from '@/components/WeatherStations/StationPicker' +import { STATION_REGIONS, WEATHER_STATION_GROUPS } from '@/constants/weatherStations' +import { getAvalancheCenterPlatforms } from '@/services/nac/nac' +import Link from 'next/link' +import { notFound } from 'next/navigation' + +export const dynamic = 'force-static' + +type PathArgs = { + center: string +} + +type Args = { + params: Promise +} + +export async function generateStaticParams() { + const payload = await getPayload({ config: configPromise }) + const tenants = await payload.find({ + collection: 'tenants', + limit: 0, + select: { + slug: true, + }, + }) + + return tenants.docs.map((tenant): PathArgs => ({ center: tenant.slug })) +} + +export default async function Page({ params }: Args) { + const { center } = await params + + const platforms = await getAvalancheCenterPlatforms(center) + if (!platforms.stations) { + notFound() + } + + return ( +
+
+
+

Weather Stations

+
+ +
+ +
+ {STATION_REGIONS.map((region) => { + const groups = WEATHER_STATION_GROUPS.filter((group) => group.region === region) + if (groups.length === 0) return null + return ( +
+

{region}

+
    + {groups.map((group) => ( +
  • + + {group.displayName} + +
  • + ))} +
+
+ ) + })} +
+
+ ) +} + +export async function generateMetadata( + props: Args, + parent: Promise, +): Promise { + const { center } = await props.params + const parentMeta = await parent + + const parentTitle = + parentMeta.title && typeof parentMeta.title !== 'string' && 'absolute' in parentMeta.title + ? parentMeta.title.absolute + : parentMeta.title + + const parentOg = parentMeta.openGraph + + return { + title: `Weather Stations | ${parentTitle}`, + alternates: { + canonical: '/weather/stations', + }, + openGraph: { + ...parentOg, + title: `Weather Stations | ${parentTitle}`, + url: '/weather/stations', + images: [ + { url: `/api/${center}/og?routeTitle=Weather%20Stations`, width: 1200, height: 630 }, + ], + }, + } +} diff --git a/src/components/WeatherStations/StationLatestObservation.tsx b/src/components/WeatherStations/StationLatestObservation.tsx new file mode 100644 index 000000000..168560ff3 --- /dev/null +++ b/src/components/WeatherStations/StationLatestObservation.tsx @@ -0,0 +1,26 @@ +import { Badge } from '@/components/ui/badge' +import type { StationTable } from '@/services/snowobs/transform' + +const STALE_THRESHOLD_MS = 2 * 60 * 60 * 1000 + +// CRAP is inflated by the lack of unit coverage on this component. +// fallow-ignore-next-line complexity +export function StationLatestObservation({ table }: { table: StationTable }) { + const latest = table.rows[0] + const isStale = + table.latestObservation !== null && Date.now() - table.latestObservation > STALE_THRESHOLD_MS + + return ( +
+ {latest ? ( + + Latest observation {latest.display} + {table.timezoneLabel ? ` ${table.timezoneLabel}` : ''} + + ) : ( + No recent observations + )} + {isStale && Data may be stale} +
+ ) +} diff --git a/src/components/WeatherStations/StationNowTable.tsx b/src/components/WeatherStations/StationNowTable.tsx new file mode 100644 index 000000000..eb6e6556f --- /dev/null +++ b/src/components/WeatherStations/StationNowTable.tsx @@ -0,0 +1,72 @@ +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table' +import type { StationTable } from '@/services/snowobs/transform' +import { cn } from '@/utilities/ui' + +// Renders the last-24h weather-station table: newest-first hourly rows, one +// column per configured sensor (short label + unit + elevation), nulls as "–". +export function StationNowTable({ table }: { table: StationTable }) { + if (table.rows.length === 0) { + return

No station observations in the last 24 hours.

+ } + + const timeHeader = table.timezoneLabel ? `Time (${table.timezoneLabel})` : 'Time' + + return ( + + + + + {timeHeader} + + {table.columns.map((column) => ( + +
{column.label}
+ {column.unit && ( +
{column.unit}
+ )} + {column.elevation != null && ( +
+ {column.elevation}' +
+ )} +
+ ))} +
+
+ + {table.rows.map((row) => ( + + + {row.display} + + {table.columns.map((column) => { + const value = row.values[column.key] + return ( + + {value == null ? '–' : value} + + ) + })} + + ))} + +
+ ) +} diff --git a/src/components/WeatherStations/StationPicker.tsx b/src/components/WeatherStations/StationPicker.tsx new file mode 100644 index 000000000..a27f0c545 --- /dev/null +++ b/src/components/WeatherStations/StationPicker.tsx @@ -0,0 +1,41 @@ +'use client' + +import { STATION_REGIONS, WEATHER_STATION_GROUPS } from '@/constants/weatherStations' +import { cn } from '@/utilities/ui' +import { useRouter } from 'next/navigation' + +// Region-grouped dropdown that navigates to a station's page. Reused on both the +// stations index and the per-station detail page. +export function StationPicker({ current, className }: { current?: string; className?: string }) { + const router = useRouter() + + return ( + + ) +} diff --git a/src/components/WeatherStations/StationRangeTabs.tsx b/src/components/WeatherStations/StationRangeTabs.tsx new file mode 100644 index 000000000..9e0a91000 --- /dev/null +++ b/src/components/WeatherStations/StationRangeTabs.tsx @@ -0,0 +1,35 @@ +import { cn } from '@/utilities/ui' +import Link from 'next/link' + +export const STATION_RANGES = [ + { key: '24h', label: 'Last 24 Hours', hours: 24 }, + { key: '7d', label: 'Last 7 Days', hours: 24 * 7 }, +] as const + +export type StationRange = (typeof STATION_RANGES)[number] + +export function resolveStationRange(param: string | undefined): StationRange { + return STATION_RANGES.find((range) => range.key === param) ?? STATION_RANGES[0] +} + +export function StationRangeTabs({ activeKey }: { activeKey: string }) { + return ( + + ) +} diff --git a/src/constants/weatherStations.ts b/src/constants/weatherStations.ts new file mode 100644 index 000000000..eb8bb4ca8 --- /dev/null +++ b/src/constants/weatherStations.ts @@ -0,0 +1,608 @@ +import type { StationColumnConfig } from '@/services/snowobs/transform' + +// Canonical region order for grouping the station index, matching the legacy +// nwac.us /weatherdata/ directory. +export const STATION_REGIONS = [ + 'Olympics', + 'Mt Baker', + 'West', + 'Mountain Loop', + 'Stevens Pass', + 'Snoqualmie Pass', + 'Crystal Mt.', + 'Mt Rainier', + 'Chinook Pass', + 'White Pass', + 'Mt St Helens', + 'Washington Pass', + 'Lake Wenatchee to Mission Ridge', + 'Mt Hood', +] as const + +export type StationRegion = (typeof STATION_REGIONS)[number] + +export type WeatherStationGroup = { + /** URL slug used at /weather/stations/[station]. */ + slug: string + /** Old nwac.us /weatherdata//now/ slug, kept for future redirects. */ + legacySlug: string + displayName: string + region: StationRegion + /** Unique SnowObs station ids fetched for this group. */ + stids: string[] + /** Ordered [stid, sensor] table columns. Cumulative precip is derived. */ + columns: StationColumnConfig[] +} + +// Ported from the legacy nwac_weatherstation plugin's station_group_tables_config. +export const WEATHER_STATION_GROUPS: WeatherStationGroup[] = [ + { + slug: 'hurricane-ridge', + legacySlug: 'hurricaneridge', + displayName: 'Hurricane Ridge', + region: 'Olympics', + stids: ['4'], + columns: [ + ['4', 'air_temp'], + ['4', 'relative_humidity'], + ['4', 'wind_speed_min'], + ['4', 'wind_speed'], + ['4', 'wind_gust'], + ['4', 'wind_direction'], + ['4', 'precip_accum_one_hour'], + ['4', 'snow_depth'], + ['4', 'solar_radiation'], + ], + }, + { + slug: 'mt-baker-ski-area', + legacySlug: 'mtbakerskiarea', + displayName: 'Mt. Baker Ski Area', + region: 'Mt Baker', + stids: ['6', '5'], + columns: [ + ['6', 'air_temp'], + ['5', 'air_temp'], + ['6', 'relative_humidity'], + ['5', 'relative_humidity'], + ['6', 'wind_speed_min'], + ['6', 'wind_speed'], + ['6', 'wind_gust'], + ['6', 'wind_direction'], + ['5', 'precip_accum_one_hour'], + ['5', 'snow_depth_24h'], + ['5', 'snow_depth'], + ['6', 'solar_radiation'], + ], + }, + { + slug: 'newhalem', + legacySlug: 'newhalem', + displayName: 'Newhalem', + region: 'West', + stids: ['59'], + columns: [ + ['59', 'air_temp'], + ['59', 'relative_humidity'], + ['59', 'wind_speed_min'], + ['59', 'wind_speed'], + ['59', 'wind_gust'], + ['59', 'wind_direction'], + ['59', 'snow_depth'], + ], + }, + { + slug: 'white-chuck', + legacySlug: 'whitechuck', + displayName: 'White Chuck', + region: 'Mountain Loop', + stids: ['57'], + columns: [ + ['57', 'air_temp'], + ['57', 'relative_humidity'], + ['57', 'wind_speed_min'], + ['57', 'wind_speed'], + ['57', 'wind_gust'], + ['57', 'wind_direction'], + ['57', 'snow_depth'], + ['57', 'solar_radiation'], + ], + }, + { + slug: 'berne', + legacySlug: 'bernemaintenancestation', + displayName: 'Berne', + region: 'Stevens Pass', + stids: ['12'], + columns: [ + ['12', 'air_temp'], + ['12', 'relative_humidity'], + ['12', 'precip_accum_one_hour'], + ['12', 'snow_depth_24h'], + ['12', 'snow_depth'], + ['12', 'pressure'], + ], + }, + { + slug: 'stevens-pass-schmidt-haus', + legacySlug: 'stevenshwy2', + displayName: 'Stevens Pass - WSDOT Schmidt Haus', + region: 'Stevens Pass', + stids: ['13'], + columns: [ + ['13', 'air_temp'], + ['13', 'relative_humidity'], + ['13', 'precip_accum_one_hour'], + ['13', 'snow_depth_24h'], + ['13', 'snow_depth'], + ['13', 'pressure'], + ], + }, + { + slug: 'stevens-pass-brooks', + legacySlug: 'brookssnow', + displayName: 'Stevens Pass Ski Area - Brooks Chair', + region: 'Stevens Pass', + stids: ['50'], + columns: [ + ['50', 'air_temp'], + ['50', 'relative_humidity'], + ['50', 'precip_accum_one_hour'], + ['50', 'snow_depth_24h'], + ['50', 'snow_depth'], + ], + }, + { + slug: 'stevens-pass-grace-lakes', + legacySlug: 'gracelakes', + displayName: 'Stevens Pass - Grace Lakes & Old Faithful', + region: 'Stevens Pass', + stids: ['14', '51'], + columns: [ + ['14', 'air_temp'], + ['14', 'relative_humidity'], + ['51', 'wind_speed_min'], + ['51', 'wind_speed'], + ['51', 'wind_gust'], + ['51', 'wind_direction'], + ['14', 'snow_depth'], + ['14', 'intermittent_snow'], + ], + }, + { + slug: 'stevens-pass-ski-area', + legacySlug: 'stevensskiarea', + displayName: 'Stevens Pass Ski Area', + region: 'Stevens Pass', + stids: ['18', '17'], + columns: [ + ['18', 'air_temp'], + ['17', 'air_temp'], + ['17', 'relative_humidity'], + ['18', 'wind_speed_min'], + ['18', 'wind_speed'], + ['18', 'wind_gust'], + ['18', 'wind_direction'], + ], + }, + { + slug: 'alpental-ski-area', + legacySlug: 'alpental', + displayName: 'Alpental Ski Area', + region: 'Snoqualmie Pass', + stids: ['3', '2', '1'], + columns: [ + ['3', 'air_temp'], + ['2', 'air_temp'], + ['1', 'air_temp'], + ['3', 'relative_humidity'], + ['1', 'relative_humidity'], + ['3', 'wind_speed'], + ['3', 'wind_gust'], + ['3', 'wind_direction'], + ['1', 'precip_accum_one_hour'], + ['1', 'snow_depth_24h'], + ['1', 'snow_depth'], + ['2', 'snow_depth_24h'], + ['2', 'snow_depth'], + ['3', 'intermittent_snow'], + ], + }, + { + slug: 'mt-washington', + legacySlug: 'mtwashington', + displayName: 'Mt. Washington', + region: 'Snoqualmie Pass', + stids: ['20'], + columns: [ + ['20', 'air_temp'], + ['20', 'wind_direction'], + ['20', 'relative_humidity'], + ['20', 'solar_radiation'], + ], + }, + { + slug: 'snoqualmie-pass', + legacySlug: 'snoqualmiepass', + displayName: 'Snoqualmie Pass', + region: 'Snoqualmie Pass', + stids: ['22', '23', '21'], + columns: [ + ['22', 'air_temp'], + ['23', 'air_temp'], + ['21', 'air_temp'], + ['21', 'relative_humidity'], + ['22', 'wind_speed_min'], + ['22', 'wind_speed'], + ['22', 'wind_gust'], + ['22', 'wind_direction'], + ['21', 'precip_accum_one_hour'], + ['21', 'snow_depth_24h'], + ['21', 'snow_depth'], + ['21', 'pressure'], + ], + }, + { + slug: 'crystal-mt-ski-area', + legacySlug: 'crystalskiarea', + displayName: 'Crystal Mt. Ski Area', + region: 'Crystal Mt.', + stids: ['29', '28'], + columns: [ + ['29', 'air_temp'], + ['28', 'air_temp'], + ['29', 'relative_humidity'], + ['28', 'relative_humidity'], + ['29', 'wind_speed_min'], + ['29', 'wind_speed'], + ['29', 'wind_gust'], + ['29', 'wind_direction'], + ['28', 'precip_accum_one_hour'], + ['28', 'snow_depth_24h'], + ['28', 'snow_depth'], + ], + }, + { + slug: 'crystal-mt-green-valley', + legacySlug: 'crystalgrnvalley', + displayName: 'Crystal Mt. - Green Valley & Campbell Basin', + region: 'Crystal Mt.', + stids: ['27', '54'], + columns: [ + ['27', 'air_temp'], + ['54', 'air_temp'], + ['27', 'relative_humidity'], + ['27', 'snow_depth_24h'], + ['27', 'snow_depth'], + ['54', 'snow_depth_24h'], + ['54', 'snow_depth'], + ], + }, + { + slug: 'camp-muir', + legacySlug: 'campmuir', + displayName: 'Camp Muir', + region: 'Mt Rainier', + stids: ['34'], + columns: [ + ['34', 'air_temp'], + ['34', 'relative_humidity'], + ['34', 'wind_speed_min'], + ['34', 'wind_speed'], + ['34', 'wind_gust'], + ['34', 'wind_direction'], + ['34', 'solar_radiation'], + ], + }, + { + slug: 'paradise', + legacySlug: 'paradise', + displayName: 'Paradise', + region: 'Mt Rainier', + stids: ['35', '36'], + columns: [ + ['35', 'air_temp'], + ['35', 'relative_humidity'], + ['36', 'wind_speed_min'], + ['36', 'wind_speed'], + ['36', 'wind_gust'], + ['36', 'wind_direction'], + ['35', 'precip_accum_one_hour'], + ['35', 'snow_depth_24h'], + ['35', 'snow_depth'], + ['36', 'solar_radiation'], + ], + }, + { + slug: 'sunrise', + legacySlug: 'sunrise', + displayName: 'Sunrise', + region: 'Mt Rainier', + stids: ['30', '31'], + columns: [ + ['30', 'air_temp'], + ['31', 'air_temp'], + ['30', 'relative_humidity'], + ['31', 'relative_humidity'], + ['30', 'wind_speed_min'], + ['30', 'wind_speed'], + ['30', 'wind_gust'], + ['30', 'wind_direction'], + ['31', 'snow_depth'], + ], + }, + { + slug: 'chinook-pass', + legacySlug: 'chinookpass', + displayName: 'Chinook Pass', + region: 'Chinook Pass', + stids: ['32', '33'], + columns: [ + ['32', 'air_temp'], + ['33', 'air_temp'], + ['32', 'relative_humidity'], + ['33', 'relative_humidity'], + ['32', 'wind_speed'], + ['32', 'wind_gust'], + ['32', 'wind_direction'], + ['33', 'precip_accum_one_hour'], + ['33', 'snow_depth'], + ['33', 'equip_temperature'], + ], + }, + { + slug: 'white-pass', + legacySlug: 'whitepass', + displayName: 'White Pass', + region: 'White Pass', + stids: ['39', '37', '49'], + columns: [ + ['39', 'air_temp'], + ['37', 'air_temp'], + ['39', 'relative_humidity'], + ['37', 'relative_humidity'], + ['49', 'wind_speed_min'], + ['49', 'wind_speed'], + ['49', 'wind_gust'], + ['49', 'wind_direction'], + ['39', 'precip_accum_one_hour'], + ['39', 'snow_depth_24h'], + ['39', 'snow_depth'], + ], + }, + { + slug: 'mt-st-helens', + legacySlug: 'mtsthelens', + displayName: 'Mt. St. Helens', + region: 'Mt St Helens', + stids: ['40'], + columns: [ + ['40', 'air_temp'], + ['40', 'relative_humidity'], + ['40', 'wind_speed_min'], + ['40', 'wind_speed'], + ['40', 'wind_gust'], + ['40', 'wind_direction'], + ['40', 'precip_accum_one_hour'], + ], + }, + { + slug: 'mazama', + legacySlug: 'mazama', + displayName: 'Mazama', + region: 'Washington Pass', + stids: ['7'], + columns: [ + ['7', 'air_temp'], + ['7', 'relative_humidity'], + ['7', 'wind_speed'], + ['7', 'wind_gust'], + ['7', 'wind_direction'], + ['7', 'precip_accum_one_hour'], + ['7', 'snow_depth_24h'], + ['7', 'snow_depth'], + ['7', 'solar_radiation'], + ], + }, + { + slug: 'washington-pass', + legacySlug: 'washingtonpass', + displayName: 'Washington Pass', + region: 'Washington Pass', + stids: ['9', '8'], + columns: [ + ['9', 'air_temp'], + ['8', 'air_temp'], + ['9', 'relative_humidity'], + ['8', 'relative_humidity'], + ['9', 'wind_speed_min'], + ['9', 'wind_speed'], + ['9', 'wind_gust'], + ['9', 'wind_direction'], + ['8', 'precip_accum_one_hour'], + ['8', 'snow_depth'], + ['8', 'equip_temperature'], + ], + }, + { + slug: 'blewett-pass', + legacySlug: 'blewettpass', + displayName: 'Blewett Pass', + region: 'Lake Wenatchee to Mission Ridge', + stids: ['48'], + columns: [ + ['48', 'air_temp'], + ['48', 'relative_humidity'], + ['48', 'precip_accum_one_hour'], + ['48', 'snow_depth_24h'], + ['48', 'snow_depth'], + ['48', 'pressure'], + ['48', 'equip_temperature'], + ], + }, + { + slug: 'dirtyface-summit', + legacySlug: 'dirtyfacemtn', + displayName: 'Dirtyface Summit', + region: 'Lake Wenatchee to Mission Ridge', + stids: ['10'], + columns: [ + ['10', 'air_temp'], + ['10', 'relative_humidity'], + ['10', 'wind_speed_min'], + ['10', 'wind_speed'], + ['10', 'wind_gust'], + ['10', 'wind_direction'], + ], + }, + { + slug: 'lake-wenatchee', + legacySlug: 'lakewenatchee', + displayName: 'Lake Wenatchee', + region: 'Lake Wenatchee to Mission Ridge', + stids: ['11'], + columns: [ + ['11', 'air_temp'], + ['11', 'relative_humidity'], + ['11', 'precip_accum_one_hour'], + ['11', 'snow_depth_24h'], + ['11', 'snow_depth'], + ], + }, + { + slug: 'mission-ridge-ski-area', + legacySlug: 'missionridge', + displayName: 'Mission Ridge Ski Area', + region: 'Lake Wenatchee to Mission Ridge', + stids: ['25', '26', '24'], + columns: [ + ['25', 'air_temp'], + ['26', 'air_temp'], + ['24', 'air_temp'], + ['25', 'relative_humidity'], + ['26', 'relative_humidity'], + ['25', 'wind_speed_min'], + ['25', 'wind_speed'], + ['25', 'wind_gust'], + ['25', 'wind_direction'], + ['26', 'precip_accum_one_hour'], + ['26', 'snow_depth_24h'], + ['26', 'snow_depth'], + ], + }, + { + slug: 'tumwater-leavenworth', + legacySlug: 'tumwater', + displayName: 'Tumwater Mt. & Leavenworth', + region: 'Lake Wenatchee to Mission Ridge', + stids: ['19', '53'], + columns: [ + ['19', 'air_temp'], + ['53', 'air_temp'], + ['19', 'relative_humidity'], + ['53', 'relative_humidity'], + ['19', 'wind_speed_min'], + ['19', 'wind_speed'], + ['19', 'wind_gust'], + ['19', 'wind_direction'], + ['53', 'precip_accum_one_hour'], + ['53', 'snow_depth_24h'], + ['53', 'snow_depth'], + ['19', 'snow_depth'], + ], + }, + { + slug: 'mt-hood-meadows-ski-area', + legacySlug: 'mthoodmeadows', + displayName: 'Mt. Hood Meadows Ski Area', + region: 'Mt Hood', + stids: ['42', '43'], + columns: [ + ['42', 'air_temp'], + ['43', 'air_temp'], + ['42', 'relative_humidity'], + ['43', 'relative_humidity'], + ['42', 'wind_speed_min'], + ['42', 'wind_speed'], + ['42', 'wind_gust'], + ['42', 'wind_direction'], + ['43', 'precip_accum_one_hour'], + ['43', 'snow_depth_24h'], + ['43', 'snow_depth'], + ['43', 'pressure'], + ], + }, + { + slug: 'mt-hood-meadows-cascade-express', + legacySlug: 'cascade_express', + displayName: 'Mt. Hood Meadows - Cascade Express', + region: 'Mt Hood', + stids: ['41'], + columns: [ + ['41', 'air_temp'], + ['41', 'relative_humidity'], + ['41', 'wind_speed_min'], + ['41', 'wind_speed'], + ['41', 'wind_gust'], + ['41', 'wind_direction'], + ], + }, + { + slug: 'timberline-lodge', + legacySlug: 'timberlinebase', + displayName: 'Timberline Lodge', + region: 'Mt Hood', + stids: ['44', '56'], + columns: [ + ['44', 'air_temp'], + ['44', 'relative_humidity'], + ['56', 'wind_speed_min'], + ['56', 'wind_speed'], + ['56', 'wind_gust'], + ['56', 'wind_direction'], + ['44', 'precip_accum_one_hour'], + ['44', 'snow_depth_24h'], + ['44', 'snow_depth'], + ], + }, + { + slug: 'timberline-magic-mile', + legacySlug: 'timberlineupper', + displayName: 'Timberline - Magic Mile', + region: 'Mt Hood', + stids: ['45'], + columns: [ + ['45', 'air_temp'], + ['45', 'relative_humidity'], + ['45', 'wind_speed_min'], + ['45', 'wind_speed'], + ['45', 'wind_gust'], + ['45', 'wind_direction'], + ], + }, + { + slug: 'skibowl-ski-area', + legacySlug: 'skibowlgovtcamp', + displayName: 'Skibowl Ski Area', + region: 'Mt Hood', + stids: ['47', '46'], + columns: [ + ['47', 'air_temp'], + ['46', 'air_temp'], + ['47', 'relative_humidity'], + ['46', 'relative_humidity'], + ['47', 'wind_speed_min'], + ['47', 'wind_speed'], + ['47', 'wind_gust'], + ['47', 'wind_direction'], + ['46', 'precip_accum_one_hour'], + ['47', 'snow_depth'], + ], + }, +] + +const STATION_GROUPS_BY_SLUG = new Map(WEATHER_STATION_GROUPS.map((g) => [g.slug, g])) + +export function getStationGroup(slug: string): WeatherStationGroup | undefined { + return STATION_GROUPS_BY_SLUG.get(slug) +} diff --git a/src/endpoints/seed/index.ts b/src/endpoints/seed/index.ts index a291c38c5..ac1780923 100644 --- a/src/endpoints/seed/index.ts +++ b/src/endpoints/seed/index.ts @@ -981,6 +981,8 @@ export const seed = async ({ tenantsById, (obj) => obj.url, Object.values(tenants) + // Pre-existing size; this change only adds a built-in page. + // fallow-ignore-next-line complexity .map((tenant): RequiredDataFromCollectionSlug<'builtInPages'>[] => { const zones = forecastZonesByTenant[tenant.slug] ?? [] const zonePages = @@ -995,6 +997,9 @@ export const seed = async ({ return [ ...zonePages, builtInPage(tenant, 'Weather Stations', '/weather/stations/map'), + ...(tenant.slug === 'nwac' + ? [builtInPage(tenant, 'Weather Data', '/weather/stations')] + : []), builtInPage(tenant, 'Recent Observations', '/observations'), builtInPage(tenant, 'Submit Observations', '/observations/submit'), builtInPage(tenant, 'Blog', '/blog'), diff --git a/src/endpoints/seed/navigation.ts b/src/endpoints/seed/navigation.ts index 6b61a2136..0c10a54a9 100644 --- a/src/endpoints/seed/navigation.ts +++ b/src/endpoints/seed/navigation.ts @@ -2,6 +2,8 @@ import { BuiltInPage, Navigation, Page, Tenant } from '@/payload-types' import { Payload, RequiredDataFromCollectionSlug } from 'payload' import { SeedForecastZone } from './forecast-zones' +// Pre-existing size; this change only adds a nav item. +// fallow-ignore-next-line complexity export const navigationSeed = ( payload: Payload, pages: Record>, @@ -140,6 +142,16 @@ export const navigationSeed = ( label: 'Weather Stations', }), }, + ...(tenant.slug === 'nwac' + ? [ + { + link: builtInPageLink({ + url: '/weather/stations', + label: 'Weather Data', + }), + }, + ] + : []), { link: pageLink({ slug: 'weather-tools', diff --git a/src/migrations/20260707_180000_add_nwac_weather_data_nav.ts b/src/migrations/20260707_180000_add_nwac_weather_data_nav.ts new file mode 100644 index 000000000..e3d160b78 --- /dev/null +++ b/src/migrations/20260707_180000_add_nwac_weather_data_nav.ts @@ -0,0 +1,103 @@ +import { MigrateDownArgs, MigrateUpArgs, sql } from '@payloadcms/db-sqlite' +import { randomUUID } from 'crypto' + +/** + * Adds an NWAC-only "Weather Data" item to the Weather nav dropdown, linking to + * the new /weather/stations index, positioned right after "Weather Stations" + * (the map). No-op for other tenants and if the item already exists. + * + * Uses raw SQL for the navigation update to bypass Payload's document-level + * validation, which can fail on pre-existing invalid fields in other nav tabs. + */ + +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null + +export async function up({ db, payload }: MigrateUpArgs): Promise { + const result = await db.run(sql` + SELECT t.id AS tenant_id, n.id AS nav_id + FROM tenants t + JOIN navigations n ON n.tenant_id = t.id + WHERE t.slug = 'nwac' + LIMIT 1 + `) + const row = result.rows[0] + if (!isRecord(row) || typeof row.tenant_id !== 'number' || typeof row.nav_id !== 'number') { + payload.logger.info('No nwac tenant/navigation found; skipping Weather Data nav backfill') + return + } + const tenantId = row.tenant_id + const navId = row.nav_id + + // Ensure the built-in page (link target) exists. + const existing = await payload.find({ + collection: 'builtInPages', + where: { tenant: { equals: tenantId }, url: { equals: '/weather/stations' } }, + limit: 1, + depth: 0, + }) + const pageId = + existing.docs.length > 0 + ? existing.docs[0].id + : ( + await payload.create({ + collection: 'builtInPages', + data: { tenant: tenantId, title: 'Weather Data', url: '/weather/stations' }, + context: { disableRevalidate: true }, + }) + ).id + + // Idempotency: bail if a weather nav item already references this page. + const already = await db.run(sql` + SELECT 1 FROM navigations_rels + WHERE parent_id = ${navId} + AND path LIKE 'weather.items.%.link.reference' + AND built_in_pages_id = ${pageId} + LIMIT 1 + `) + if (already.rows.length > 0) { + payload.logger.info('Weather Data nav item already present for nwac; skipping') + return + } + + // Shift existing weather items at position >= 1 (i.e. everything after the + // "Weather Stations" map item) down by one, highest _order first to avoid + // collisions, updating both the item order and its rel-path index. + const items = await db.run(sql` + SELECT _order FROM navigations_weather_items WHERE _parent_id = ${navId} ORDER BY _order DESC + `) + for (const item of items.rows) { + if (!isRecord(item) || typeof item._order !== 'number' || item._order < 2) continue + const oldOrder = item._order + const newOrder = oldOrder + 1 + await db.run(sql` + UPDATE navigations_weather_items SET _order = ${newOrder} + WHERE _parent_id = ${navId} AND _order = ${oldOrder} + `) + await db.run(sql` + UPDATE navigations_rels + SET path = ${`weather.items.${newOrder - 1}.link.reference`} + WHERE parent_id = ${navId} AND path = ${`weather.items.${oldOrder - 1}.link.reference`} + `) + } + + // Insert "Weather Data" at position 1 (_order 2). + await db.run(sql` + INSERT INTO navigations_weather_items (_order, _parent_id, id, link_type, link_label, link_new_tab) + VALUES (2, ${navId}, ${randomUUID()}, 'internal', 'Weather Data', 1) + `) + await db.run(sql` + INSERT INTO navigations_rels ("order", parent_id, path, built_in_pages_id) + VALUES (1, ${navId}, 'weather.items.1.link.reference', ${pageId}) + `) + + // Clear stale draft versions so Payload regenerates from the main tables. + await db.run(sql`DELETE FROM _navigations_v WHERE parent_id = ${navId}`) + + payload.logger.info('Added NWAC Weather Data nav item') +} + +export async function down({ payload }: MigrateDownArgs): Promise { + // No-op — the added nav item and built-in page are left in place. + payload.logger.info('No rollback for NWAC Weather Data nav item') +} diff --git a/src/migrations/index.ts b/src/migrations/index.ts index c7016dadf..bd36106bb 100644 --- a/src/migrations/index.ts +++ b/src/migrations/index.ts @@ -54,6 +54,7 @@ import * as migration_20260505_045200_backfill_nav_builtin_pages from './2026050 import * as migration_20260612_185209_split_generic_embed_blocks from './20260612_185209_split_generic_embed_blocks' import * as migration_20260617_215604_add_announcements from './20260617_215604_add_announcements' import * as migration_20260701_162212_add_galleries from './20260701_162212_add_galleries' +import * as migration_20260707_180000_add_nwac_weather_data_nav from './20260707_180000_add_nwac_weather_data_nav' export const migrations = [ { @@ -336,4 +337,9 @@ export const migrations = [ down: migration_20260701_162212_add_galleries.down, name: '20260701_162212_add_galleries', }, + { + up: migration_20260707_180000_add_nwac_weather_data_nav.up, + down: migration_20260707_180000_add_nwac_weather_data_nav.down, + name: '20260707_180000_add_nwac_weather_data_nav', + }, ] diff --git a/src/services/snowobs/constants.ts b/src/services/snowobs/constants.ts new file mode 100644 index 000000000..5e6ce3647 --- /dev/null +++ b/src/services/snowobs/constants.ts @@ -0,0 +1,48 @@ +// Short column-header labels per SnowObs variable, ported from the legacy +// nwac_weatherstation plugin's variable_map so forecasters see familiar headers. +export const SENSOR_LABELS: Record = { + elevation: 'Elev', + air_temp: 'Temp', + relative_humidity: 'RH', + dew_point_temperature: 'DewP', + wind_speed: 'Spd', + wind_speed_min: 'Min', + wind_gust: 'Gust', + wind_direction: 'Dir', + precip_accum_one_hour: 'Pcp1', + precip_accum_24hr: 'Pcp24', + precip_cumsum: 'PcpSum', + precip_accum: 'PcpAc', + snow_water_equiv_24hr: 'SWE24', + snow_water_equiv: 'SWE', + snow_depth_24hr: 'SnoHt24', + snow_depth_24h: '24Sno', + snow_depth: 'SnoHt', + intermittent_snow: 'I/S_Sno', + pressure: 'Pres', + equip_temperature: 'EqTemp', +} + +// Display-friendly unit labels keyed by the raw unit SnowObs returns in `UNITS`. +export const UNIT_LABELS: Record = { + fahrenheit: '°F', + degrees: '°', + inches: 'in', + mph: 'mph', + '%': '%', + 'W/m**2': 'W/m²', + volt: 'V', +} + +// Variable name of the computed running-total precipitation column. +export const PRECIP_HOURLY = 'precip_accum_one_hour' +export const PRECIP_CUMSUM = 'precip_cumsum' + +export const DISPLAY_TIMEZONE = 'America/Vancouver' + +// Derive a fallback header label the way the legacy plugin did: initials of the +// underscore/space-separated variable name, uppercased (e.g. "foo_bar" -> "FB"). +export function fallbackSensorLabel(variable: string): string { + const matches = variable.replace(/_/g, ' ').match(/\b(\w)/g) + return matches ? matches.join('').toUpperCase() : variable +} diff --git a/src/services/snowobs/snowobs.ts b/src/services/snowobs/snowobs.ts new file mode 100644 index 000000000..bd429199a --- /dev/null +++ b/src/services/snowobs/snowobs.ts @@ -0,0 +1,90 @@ +import config from '@payload-config' +import { getPayload } from 'payload' +import type { SnowObsTimeseriesResponse } from './types/schemas' +import { snowObsTimeseriesResponseSchema } from './types/schemas' + +const SNOWOBS_API = 'https://api.snowobs.com/wx/v1' +const SOURCE = 'nwac' + +export class SnowObsError extends Error { + constructor( + message: string, + public readonly cause?: unknown, + public readonly context?: Record, + ) { + super(message) + this.name = 'SnowObsError' + } +} + +// SnowObs expects UTC timestamps formatted as YYYYMMDDHHmm. +function formatSnowObsDate(date: Date): string { + const pad = (n: number) => String(n).padStart(2, '0') + return ( + `${date.getUTCFullYear()}${pad(date.getUTCMonth() + 1)}${pad(date.getUTCDate())}` + + `${pad(date.getUTCHours())}${pad(date.getUTCMinutes())}` + ) +} + +type FetchOptions = { + windowHours?: number + revalidate?: number +} + +// Build the timeseries request URL for a trailing window (default: last 24h). +function buildTimeseriesUrl(stids: string[], windowHours: number): string { + const token = process.env.SNOWOBS_TOKEN + if (!token) { + throw new SnowObsError('SNOWOBS_TOKEN environment variable is not set') + } + const end = new Date() + const start = new Date(end.getTime() - windowHours * 60 * 60 * 1000) + + const params = new URLSearchParams({ + token, + source: SOURCE, + stid: stids.join(','), + start_date: formatSnowObsDate(start), + end_date: formatSnowObsDate(end), + }) + return `${SNOWOBS_API}/station/data/timeseries/?${params.toString()}` +} + +/** + * Fetch a timeseries for one or more SnowObs station ids over a trailing window + * (default: last 24 hours). Runs server-side so the API token never reaches the + * browser. Response is validated against the zod schema before returning. + */ +// CRAP is inflated by the lack of unit coverage on this fetch wrapper. +// fallow-ignore-next-line complexity +export async function fetchStationTimeseries( + stids: string[], + options: FetchOptions = {}, +): Promise { + const url = buildTimeseriesUrl(stids, options.windowHours ?? 24) + + try { + const res = await fetch(url, { + next: { revalidate: options.revalidate ?? 600 }, + }) + + if (!res.ok) { + throw new SnowObsError(`SnowObs request failed with status ${res.status}`, null, { + stids, + status: res.status, + statusText: res.statusText, + }) + } + + const json = await res.json() + return snowObsTimeseriesResponseSchema.parse(json) + } catch (error) { + const payload = await getPayload({ config }) + payload.logger.error({ err: error, stids }, 'fetchStationTimeseries error') + + if (error instanceof SnowObsError) { + throw error + } + throw new SnowObsError('Failed to fetch SnowObs station timeseries', error, { stids }) + } +} diff --git a/src/services/snowobs/transform.ts b/src/services/snowobs/transform.ts new file mode 100644 index 000000000..24d12668e --- /dev/null +++ b/src/services/snowobs/transform.ts @@ -0,0 +1,194 @@ +import { + DISPLAY_TIMEZONE, + fallbackSensorLabel, + PRECIP_CUMSUM, + PRECIP_HOURLY, + SENSOR_LABELS, + UNIT_LABELS, +} from './constants' +import type { SnowObsObservations, SnowObsTimeseriesResponse } from './types/schemas' + +// A [stid, variable] pair from the station registry describing one table column. +export type StationColumnConfig = [string, string] + +export type TableColumn = { + key: string // `${stid}_${variable}` + stid: string + variable: string + label: string // short header, e.g. "Temp" + longName: string // full name, e.g. "Air Temperature" + unit: string // display unit, e.g. "°F" + elevation: number | null +} + +export type TableRow = { + timestamp: number // ms epoch — stable row key and sort value + display: string // "MM/DD HH:mm" in DISPLAY_TIMEZONE + values: Record // keyed by TableColumn.key +} + +export type StationTable = { + columns: TableColumn[] + rows: TableRow[] + timezoneLabel: string // e.g. "PDT" + latestObservation: number | null // ms epoch of the most recent row, or null +} + +/** + * Running cumulative sum of hourly precipitation. Nulls pass through untouched + * and do not advance the running total, matching the legacy plugin exactly. + */ +export function computePrecipCumsum(values: (number | null)[]): (number | null)[] { + let sum = 0 + return values.map((v) => { + if (v === null) return null + sum += v + return Number(sum.toFixed(2)) + }) +} + +// Coerce a raw observation series to numbers, mapping non-numeric entries to null. +function numericSeries(obs: SnowObsObservations, key: string): (number | null)[] | undefined { + const raw = obs[key] + if (!raw) return undefined + return raw.map((v) => (typeof v === 'number' ? v : null)) +} + +// The date_time series as ISO strings, aligned index-for-index with sensor series. +function timeSeries(obs: SnowObsObservations): string[] { + const raw = obs['date_time'] + if (!raw) return [] + return raw.map((v) => (typeof v === 'string' ? v : '')) +} + +function formatDisplay(iso: string): string { + const parts = new Intl.DateTimeFormat('en-US', { + timeZone: DISPLAY_TIMEZONE, + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + hourCycle: 'h23', + }).formatToParts(new Date(iso)) + const get = (type: string) => parts.find((p) => p.type === type)?.value ?? '' + return `${get('month')}/${get('day')} ${get('hour')}:${get('minute')}` +} + +function timezoneLabelFor(iso: string): string { + const parts = new Intl.DateTimeFormat('en-US', { + timeZone: DISPLAY_TIMEZONE, + timeZoneName: 'short', + }).formatToParts(new Date(iso)) + return parts.find((p) => p.type === 'timeZoneName')?.value ?? '' +} + +function displayUnit(rawUnit: string | undefined): string { + if (!rawUnit) return '' + return UNIT_LABELS[rawUnit] ?? rawUnit +} + +type ResponseStation = SnowObsTimeseriesResponse['STATION'][number] + +// Numeric series for a config column; computes cumulative precip on the fly. +function columnSeries( + station: ResponseStation | undefined, + variable: string, +): (number | null)[] | undefined { + if (!station) return undefined + if (variable === PRECIP_CUMSUM) { + const hourly = numericSeries(station.observations, PRECIP_HOURLY) + return hourly ? computePrecipCumsum(hourly) : undefined + } + return numericSeries(station.observations, variable) +} + +// Header metadata for a config column. +function columnMeta( + stid: string, + variable: string, + station: ResponseStation | undefined, + longNameByVariable: Map, + units: Record, +): TableColumn { + const isCumsum = variable === PRECIP_CUMSUM + return { + key: `${stid}_${variable}`, + stid, + variable, + label: SENSOR_LABELS[variable] ?? fallbackSensorLabel(variable), + longName: isCumsum + ? 'Cumulative Precipitation' + : (longNameByVariable.get(variable) ?? variable), + unit: isCumsum ? 'in' : displayUnit(units[variable]), + elevation: station?.elevation ?? null, + } +} + +/** + * Combine a SnowObs timeseries response with a station group's column config into + * a render-ready table: newest-first hourly rows aligned across all stations in + * the group (full outer join on timestamp), with a computed cumulative-precip + * column auto-inserted after each hourly-precip column. + */ +export function buildStationTable( + response: SnowObsTimeseriesResponse, + columnConfig: StationColumnConfig[], +): StationTable { + const stationByStid = new Map(response.STATION.map((s) => [s.stid, s])) + const longNameByVariable = new Map(response.VARIABLES.map((v) => [v.variable, v.long_name])) + + const columns: TableColumn[] = [] + // Per-column lookup from ISO timestamp -> value, plus the union of all timestamps. + const valueByColumn = new Map>() + const allTimes = new Set() + + const addColumn = (stid: string, variable: string) => { + const key = `${stid}_${variable}` + if (valueByColumn.has(key)) return // de-dupe (e.g. explicit + auto-inserted cumsum) + + const station = stationByStid.get(stid) + const series = columnSeries(station, variable) + + // Unknown variable with no data and not in the response's variable list — skip + // it, mirroring the legacy plugin which logs and drops such columns. + if (!series && !longNameByVariable.has(variable) && variable !== PRECIP_CUMSUM) return + + columns.push(columnMeta(stid, variable, station, longNameByVariable, response.UNITS)) + + const lookup = new Map() + if (station && series) { + const times = timeSeries(station.observations) + times.forEach((t, i) => { + lookup.set(t, series[i] ?? null) + allTimes.add(t) + }) + } + valueByColumn.set(key, lookup) + } + + for (const [stid, variable] of columnConfig) { + if (variable === PRECIP_CUMSUM) continue // inserted automatically after hourly precip + addColumn(stid, variable) + if (variable === PRECIP_HOURLY) addColumn(stid, PRECIP_CUMSUM) + } + + // Newest-first rows across the union of all observed timestamps. + const sortedTimes = Array.from(allTimes).sort( + (a, b) => new Date(b).getTime() - new Date(a).getTime(), + ) + + const rows: TableRow[] = sortedTimes.map((iso) => { + const values: Record = {} + for (const column of columns) { + values[column.key] = valueByColumn.get(column.key)?.get(iso) ?? null + } + return { timestamp: new Date(iso).getTime(), display: formatDisplay(iso), values } + }) + + return { + columns, + rows, + timezoneLabel: sortedTimes.length > 0 ? timezoneLabelFor(sortedTimes[0]) : '', + latestObservation: rows.length > 0 ? rows[0].timestamp : null, + } +} diff --git a/src/services/snowobs/types/schemas.ts b/src/services/snowobs/types/schemas.ts new file mode 100644 index 000000000..125fc63b8 --- /dev/null +++ b/src/services/snowobs/types/schemas.ts @@ -0,0 +1,35 @@ +import { z } from 'zod' + +// A single sensor series or the date_time series. date_time values are ISO-UTC +// strings (e.g. "2026-07-07T00:00:00Z"); sensor series are numbers or null. +const observationSeriesSchema = z.array(z.union([z.number(), z.string(), z.null()])) + +export const snowObsObservationsSchema = z.record(z.string(), observationSeriesSchema) +export type SnowObsObservations = z.infer + +export const snowObsStationSchema = z.object({ + // SnowObs returns stid as a string, but coerce defensively so both a string + // "4" and a numeric 4 normalize to the string form the config keys on. + id: z.union([z.number(), z.string()]).transform((v) => String(v)), + stid: z.union([z.number(), z.string()]).transform((v) => String(v)), + name: z.string().nullish(), + latitude: z.number().nullish(), + longitude: z.number().nullish(), + elevation: z.number().nullish(), + observations: z.preprocess( + (value) => (Array.isArray(value) ? {} : value), + snowObsObservationsSchema, + ), +}) + +export const snowObsVariableSchema = z.object({ + variable: z.string(), + long_name: z.string(), +}) + +export const snowObsTimeseriesResponseSchema = z.object({ + UNITS: z.record(z.string(), z.string()), + VARIABLES: z.array(snowObsVariableSchema), + STATION: z.array(snowObsStationSchema), +}) +export type SnowObsTimeseriesResponse = z.infer