Skip to content
Draft
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
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=
3 changes: 3 additions & 0 deletions .fallowrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@
"tailwindcss-animate"
],
"ignoreExportsUsedInFile": true,
"duplicates": {
"ignore": ["**/weather/stations/**"]
},
"ignoreExports": [
{
"file": "src/components/ui/**",
Expand Down
128 changes: 128 additions & 0 deletions __tests__/server/snowobsTransform.server.test.ts
Original file line number Diff line number Diff line change
@@ -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())
})
})
43 changes: 43 additions & 0 deletions __tests__/server/weatherStations.server.test.ts
Original file line number Diff line number Diff line change
@@ -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()
})
})
4 changes: 2 additions & 2 deletions drift.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -568,7 +568,7 @@ sig = "4fc2ad0bd4509765"
[[bindings]]
doc = "docs/testing.md"
target = ".env.example"
sig = "1f83179d9438d2e7"
sig = "7f2566ff3043bbca"

[[bindings]]
doc = "docs/testing.md"
Expand Down
132 changes: 132 additions & 0 deletions src/app/(frontend)/[center]/weather/stations/[station]/page.tsx
Original file line number Diff line number Diff line change
@@ -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<PathArgs>
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 (
<div className="mb-10 flex flex-col gap-4">
<div className="container flex flex-wrap items-start justify-between gap-3">
<div>
<p className="mb-1 text-sm text-muted-foreground">{group.region}</p>
<div className="prose dark:prose-invert max-w-none">
<h1 className="font-bold">{group.displayName}</h1>
</div>
</div>
<div className="flex flex-col items-end gap-1">
<StationLatestObservation table={table} />
<StationPicker current={group.slug} />
</div>
</div>

<div className="container flex flex-col gap-3">
<StationRangeTabs activeKey={activeRange.key} />
<StationNowTable table={table} />
</div>
</div>
)
}

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<ResolvedMetadata>,
): Promise<Metadata> {
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,
},
],
},
}
}
5 changes: 5 additions & 0 deletions src/app/(frontend)/[center]/weather/stations/error.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
'use client'

import { ErrorBoundary } from '@/components/ErrorBoundary/ErrorBoundary'

export default ErrorBoundary
Loading
Loading