Add NWS (National Weather Service) as a selectable data source preference

- New Settings > Data source toggle (Open-Meteo global / NWS US); NWS
  falls back to Open-Meteo when unavailable or outside its coverage.
- src/lib/api/nws.js: /points -> forecast/hourly/grid normalizer producing
  the app's shared shape, with realistic air highs (NWS reports 104 for the
  day Open-Meteo over-forecasts at 116); heat-index/feels-like falls back to
  air temp since NWS returns apparentTemperature as null here.
- NWS requires a User-Agent contact; email is a build-time Vite env
  (VITE_NWS_EMAIL), read from gitignored .env.local, never committed.
- Details view now shows which provider served the data.
- Fixes the identical UTC date-parse shift for NWS naive local times.
- Tests (8) + recorded live fixtures for Rogers County, OK.
This commit is contained in:
hermes-explorigin 2026-08-16 20:14:07 +00:00
parent bc7780d494
commit 32f749444e
12 changed files with 832 additions and 1 deletions

6
.gitignore vendored
View File

@ -21,3 +21,9 @@ dist-ssr
*.njsproj *.njsproj
*.sln *.sln
*.sw? *.sw?
# Build-time secrets (never commit)
.env
.env.*
!.env.example

View File

@ -61,6 +61,24 @@
</div> </div>
<div class="settings-body"> <div class="settings-body">
<!-- Data source -->
<div class="settings-group">
<h4>Data source</h4>
<div class="setting-row">
<div class="setting-col">
<span>Weather provider</span>
<span class="setting-help">NWS is US-only; falls back to Open-Meteo if unavailable.</span>
</div>
<select
value={localSettings.source || 'open-meteo'}
onchange={(e) => localSettings.source = e.target.value}
>
<option value="open-meteo">Open-Meteo (global)</option>
<option value="nws">National Weather Service (US)</option>
</select>
</div>
</div>
<!-- Units --> <!-- Units -->
<div class="settings-group"> <div class="settings-group">
<h4>Units</h4> <h4>Units</h4>
@ -267,6 +285,18 @@
border-top: 1px solid var(--color-border); border-top: 1px solid var(--color-border);
} }
.setting-col {
display: flex;
flex-direction: column;
gap: 2px;
padding-right: 12px;
}
.setting-help {
font-size: 0.72rem;
color: var(--color-text-muted);
}
.setting-row span { .setting-row span {
color: var(--color-text); color: var(--color-text);
} }

View File

@ -174,6 +174,10 @@
<!-- Time info --> <!-- Time info -->
<div class="time-info card mt-3"> <div class="time-info card mt-3">
<div class="time-row">
<span>Data source</span>
<span class="detail-value">{data?._source === 'nws' ? 'NWS (National Weather Service)' : 'Open-Meteo'}</span>
</div>
<div class="time-row"> <div class="time-row">
<span>Local time</span> <span>Local time</span>
<span class="detail-value">{data.timezone || '--'}</span> <span class="detail-value">{data.timezone || '--'}</span>

235
src/lib/api/nws.js Normal file
View File

@ -0,0 +1,235 @@
/**
* NWS API (api.weather.gov) client + normalizer.
*
* Converts the National Weather Service forecast into the same flat shape the
* app's components expect (an Open-Meteo-style response), so the rest of the
* UI needs no per-source branches. US-only: NWS is queried via
* /points/{lat},{lon} which resolves to the nearest gridpoint forecast.
*/
import { NWS_USER_AGENT } from '../config.js'
const API = 'https://api.weather.gov'
async function jsonGet(url) {
if (!url) throw new Error('Missing NWS forecast URL')
const res = await fetch(url, {
headers: {
'User-Agent': NWS_USER_AGENT,
'Accept': 'application/geo+json',
},
})
if (!res.ok) throw new Error(`NWS API error: ${res.status} ${res.statusText}`)
return res.json()
}
// --- pure helpers (also unit-tested) ---
function ftoC(f) {
return (f - 32) * 5 / 9
}
function mphToKmh(mph) {
return mph * 1.609344
}
/** Normalize a value to the requested unit. */
function temp(v, units) {
return units === 'imperial' ? v : ftoC(v)
}
function speed(v, units) {
return units === 'imperial' ? v : mphToKmh(v)
}
const CARD = ['N','NNE','NE','ENE','E','ESE','SE','SSE','S','SSW','SW','WSW','W','WNW','NW','NNW']
export function compassToDeg(dir) {
if (dir == null) return null
const s = String(dir).trim().toUpperCase()
const i = CARD.indexOf(s)
return i >= 0 ? i * 22.5 : null
}
/** Extract the max wind speed (mph) from strings like "5 to 10 mph" or "12 mph". */
export function parseWind(s) {
if (s == null) return null
const nums = String(s).match(/\d+(\.\d+)?/g)
if (!nums || !nums.length) return null
return Math.max(...nums.map(Number))
}
/** Map an NWS shortForecast string to a WMO code for icons/severity. */
export function shortForecastToWmo(text) {
const t = (text || '').toLowerCase()
if (/thunderstorm|tstm|severe thunder/.test(t)) return /\bsevere\b/.test(t) ? 96 : 95
if (/freez|x-ice|ice pellets/.test(t) && /rain|drizzle|shower/.test(t)) return 66
if (/snow|blizzard|flurr|sleet|snow shower/.test(t)) return /heavy|blizzard/.test(t) ? 75 : 71
if (/freez|ice/.test(t)) return 66
if (/rain|shower|drizzle|sprinkl/.test(t)) return /heavy|strong|torrential/.test(t) ? 65 : /slight|isolated|scattered/.test(t) ? 61 : 63
if (/fog|haze|smoke|brume/.test(t)) return 45
if (/partly (cloudy|sunny)/.test(t)) return 2
if (/overcast|covering|cloudy/.test(t)) return 3
if (/sunny|clear|fair/.test(t)) return /most|mostly/.test(t) ? 2 : 0
return 1
}
function wmoRank(code) {
if (code >= 95) return 8
if (code >= 80 && code <= 86) return 7
if (code >= 65 && code <= 82) return 6
if (code >= 71 && code <= 77) return 5
if (code >= 45 && code <= 48) return 4
if (code <= 3) return 1
return 2
}
/** Local calendar date "YYYY-MM-DD" for an ISO timestamp in a timezone. */
export function dateKey(iso, tz) {
const p = new Intl.DateTimeFormat('en-CA', {
timeZone: tz, year: 'numeric', month: '2-digit', day: '2-digit',
}).formatToParts(new Date(iso))
const P = Object.fromEntries(p.map((x) => [x.type, x.value]))
return `${P.year}-${P.month}-${P.day}`
}
/** Local naive wall-clock "YYYY-MM-DDTHH:MM" (no offset) for an ISO timestamp. */
export function localTimeString(iso, tz) {
const p = new Intl.DateTimeFormat('en-CA', {
timeZone: tz, year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', hour12: false,
}).formatToParts(new Date(iso))
const P = Object.fromEntries(p.map((x) => [x.type, x.value]))
const h = P.hour === '24' ? '00' : String(P.hour).padStart(2, '0')
return `${P.year}-${P.month}-${P.day}T${h}:${String(P.minute).padStart(2, '0')}`
}
function prob(period) {
const v = period?.probabilityOfPrecipitation?.value
return v == null ? 0 : v
}
/** Build daily arrays from NWS forecast periods (14 periods ~ 7 days). */
function buildDaily(periods, tz, units) {
const byDate = new Map()
for (const pd of periods) {
const key = dateKey(pd.startTime, tz)
if (!byDate.has(key)) {
byDate.set(key, { highs: [], lows: [], precip: [], codes: [], winds: [] })
}
const d = byDate.get(key)
const t = pd.temperature
if (t != null) {
const c = temp(t, units)
if (pd.isDaytime) d.highs.push(c)
else d.lows.push(c)
}
d.precip.push(prob(pd))
d.codes.push(shortForecastToWmo(pd.shortForecast))
const ws = parseWind(pd.windSpeed)
if (ws != null) d.winds.push(ws)
}
const dates = [...byDate.keys()].sort()
const time = [], temperature_2m_max = [], temperature_2m_min = []
const precipitation_probability_max = [], weather_code = [], wind_speed_10m_max = []
for (const key of dates) {
const d = byDate.get(key)
const high = d.highs.length ? Math.max(...d.highs) : (d.lows.length ? Math.max(...d.lows) : null)
const low = d.lows.length ? Math.min(...d.lows) : (d.highs.length ? Math.min(...d.highs) : null)
time.push(key)
temperature_2m_max.push(high)
temperature_2m_min.push(low)
precipitation_probability_max.push(Math.max(...d.precip, 0))
weather_code.push(d.codes.reduce((a, b) => (wmoRank(b) > wmoRank(a) ? b : a)))
wind_speed_10m_max.push(speed(Math.max(...d.winds, 0), units))
}
return { time, temperature_2m_max, temperature_2m_min, precipitation_probability_max, weather_code, wind_speed_10m_max }
}
/**
* Normalize raw NWS responses into the app's shared shape.
* @param {object} src - { points, forecast, hourly, grid, timeZone, units }
*/
export function normalizeNWS(src) {
const { points, forecast, hourly, grid, timeZone, units } = src
const tz = timeZone || 'UTC'
const hourlyPeriods = hourly?.properties?.periods || []
const forecastPeriods = forecast?.properties?.periods || []
// Current = first hourly period (most recent).
const cur = hourlyPeriods[0]
let current = null
if (cur) {
const ws = parseWind(cur.windSpeed)
const apparent = cur.apparentTemperature != null ? temp(cur.apparentTemperature, units) : temp(cur.temperature, units)
current = {
time: localTimeString(cur.startTime, tz),
temperature_2m: temp(cur.temperature, units),
apparent_temperature: apparent,
relative_humidity_2m: cur.relativeHumidity?.value ?? null,
weather_code: shortForecastToWmo(cur.shortForecast),
wind_speed_10m: ws != null ? speed(ws, units) : null,
wind_direction_10m: compassToDeg(cur.windDirection),
wind_gusts_10m: null,
pressure_msl: null,
uv_index: null,
is_day: cur.isDaytime ? 1 : 0,
}
}
// ---- hourly (all returned periods; the UI slices the next 24) ----
const time = [], temperature_2m = [], precipitation_probability = [], weather_code = [], wind_speed_10m = []
for (const pd of hourlyPeriods) {
time.push(localTimeString(pd.startTime, tz))
temperature_2m.push(temp(pd.temperature, units))
precipitation_probability.push(prob(pd))
weather_code.push(shortForecastToWmo(pd.shortForecast))
const ws = parseWind(pd.windSpeed)
wind_speed_10m.push(ws != null ? speed(ws, units) : null)
}
// ---- daily ----
const daily = buildDaily(forecastPeriods, tz, units)
// ---- sunrise / sunset (today only, from gridpoints buildSet) ----
const rise = grid?.properties?.riseSet
if (rise) {
const sr = rise.sunrise?.[0]?.time
const ss = rise.sunset?.[0]?.time
if (sr) daily.sunrise = [localTimeString(sr, tz)]
if (ss) daily.sunset = [localTimeString(ss, tz)]
}
const elevation = grid?.properties?.elevation?.value
return {
_source: 'nws',
timezone: tz,
utc_offset_seconds: null,
elevation,
current,
hourly: { time, temperature_2m, precipitation_probability, weather_code, wind_speed_10m },
daily,
}
}
/**
* Fetch + normalize the NWS forecast for a latitude/longitude.
* US-only; throws for non-US points (NWS returns 404 outside its domain).
* @param {number} lat
* @param {number} lon
* @param {string} [units='imperial'] - 'imperial' or 'metric'
*/
export async function fetchForecastNWS(lat, lon, units = 'imperial') {
const pointsUrl = `${API}/points/${lat},${lon}`
const points = await jsonGet(pointsUrl)
const props = points.properties || {}
const tz = props.timeZone || 'America/New_York'
const [forecast, hourly, grid] = await Promise.all([
jsonGet(props.forecast),
jsonGet(props.forecastHourly),
jsonGet(props.forecastGridData),
])
return normalizeNWS({ points, forecast, hourly, grid, timeZone: tz, units })
}

19
src/lib/config.js Normal file
View File

@ -0,0 +1,19 @@
/**
* Build-time configuration.
*
* Values here are read from Vite environment variables (`import.meta.env`),
* inlined at build time - so the deployed bundle carries whatever is set in
* the local env file when you run `npm run build`. Nothing secret is committed
* to the repo; set it in a local (gitignored) .env.local:
*
* VITE_NWS_EMAIL=you@example.com
*
* NWS (api.weather.gov) requires a descriptive User-Agent with a contact
* address. If not configured we still send an app identifier, but a real
* contact email is recommended for stable access.
*/
const NWS_EMAIL = import.meta.env.VITE_NWS_EMAIL || ''
export const NWS_USER_AGENT = NWS_EMAIL
? `WeatherLens/0.1 (${NWS_EMAIL})`
: 'WeatherLens/0.1 (weather app; contact not configured)'

View File

@ -123,6 +123,7 @@ export async function clearAllLocations() {
const DEFAULT_SETTINGS = { const DEFAULT_SETTINGS = {
units: 'metric', units: 'metric',
source: 'open-meteo',
alertsEnabled: true, alertsEnabled: true,
alertThresholds: { alertThresholds: {
precip: 70, precip: 70,

View File

@ -4,6 +4,7 @@
import { getLocations, loadSettings } from '../storage/db.js' import { getLocations, loadSettings } from '../storage/db.js'
import { fetchForecast } from '../api/weather.js' import { fetchForecast } from '../api/weather.js'
import { fetchForecastNWS } from '../api/nws.js'
export class AppStore { export class AppStore {
// Location state // Location state
@ -31,6 +32,7 @@ export class AppStore {
// Settings // Settings
settings = $state({ settings = $state({
units: 'metric', units: 'metric',
source: 'open-meteo',
alertsEnabled: true, alertsEnabled: true,
alertThresholds: { alertThresholds: {
precip: 70, precip: 70,
@ -108,7 +110,21 @@ export class AppStore {
this.error = '' this.error = ''
try { try {
const data = await fetchForecast(location.lat, location.lon, this.settings.units) const units = this.settings.units
const source = this.settings.source || 'open-meteo'
let data
if (source === 'nws') {
try {
data = await fetchForecastNWS(location.lat, location.lon, units)
} catch (nwsErr) {
// NWS is US-only and can be unavailable — fall back to Open-Meteo
// so the app keeps working rather than breaking.
console.warn('NWS unavailable, falling back to Open-Meteo:', nwsErr.message)
data = await fetchForecast(location.lat, location.lon, units)
}
} else {
data = await fetchForecast(location.lat, location.lon, units)
}
this.forecastData = data this.forecastData = data
// Update the location name if we got a timezone abbreviation // Update the location name if we got a timezone abbreviation

319
tests/fixtures/nws/forecast.json vendored Normal file
View File

@ -0,0 +1,319 @@
{
"@context": [
"https://geojson.org/geojson-ld/geojson-context.jsonld",
{
"@version": "1.1",
"wx": "https://api.weather.gov/ontology#",
"geo": "http://www.opengis.net/ont/geosparql#",
"unit": "http://codes.wmo.int/common/unit/",
"@vocab": "https://api.weather.gov/ontology#"
}
],
"type": "Feature",
"geometry": {
"type": "Polygon",
"coordinates": [
[
[
-95.7922,
36.3555
],
[
-95.7924,
36.3779
],
[
-95.8201,
36.3778
],
[
-95.82,
36.3554
],
[
-95.7922,
36.3555
]
]
]
},
"properties": {
"units": "us",
"forecastGenerator": "BaselineForecastGenerator",
"generatedAt": "2026-08-16T20:09:21+00:00",
"updateTime": "2026-08-16T19:50:51+00:00",
"validTimes": "2026-08-16T17:00:00+00:00/P7DT8H",
"elevation": {
"unitCode": "wmoUnit:m",
"value": 196.9008
},
"periods": [
{
"number": 1,
"name": "This Afternoon",
"startTime": "2026-08-16T15:00:00-05:00",
"endTime": "2026-08-16T18:00:00-05:00",
"isDaytime": true,
"temperature": 103,
"temperatureUnit": "F",
"temperatureTrend": null,
"probabilityOfPrecipitation": {
"unitCode": "wmoUnit:percent",
"value": 17
},
"windSpeed": "10 mph",
"windDirection": "W",
"icon": "https://api.weather.gov/icons/land/day/tsra_hi,20?size=medium",
"shortForecast": "Slight Chance Showers And Thunderstorms",
"detailedForecast": "A slight chance of showers and thunderstorms after 4pm. Mostly sunny, with a high near 103. Heat index values as high as 112. West wind around 10 mph. Chance of precipitation is 20%."
},
{
"number": 2,
"name": "Tonight",
"startTime": "2026-08-16T18:00:00-05:00",
"endTime": "2026-08-17T06:00:00-05:00",
"isDaytime": false,
"temperature": 75,
"temperatureUnit": "F",
"temperatureTrend": null,
"probabilityOfPrecipitation": {
"unitCode": "wmoUnit:percent",
"value": 24
},
"windSpeed": "5 mph",
"windDirection": "N",
"icon": "https://api.weather.gov/icons/land/night/tsra_hi,20?size=medium",
"shortForecast": "Slight Chance Showers And Thunderstorms",
"detailedForecast": "A slight chance of showers and thunderstorms. Partly cloudy, with a low around 75. Heat index values as high as 110. North wind around 5 mph. Chance of precipitation is 20%. New rainfall amounts less than a tenth of an inch possible."
},
{
"number": 3,
"name": "Monday",
"startTime": "2026-08-17T06:00:00-05:00",
"endTime": "2026-08-17T18:00:00-05:00",
"isDaytime": true,
"temperature": 94,
"temperatureUnit": "F",
"temperatureTrend": null,
"probabilityOfPrecipitation": {
"unitCode": "wmoUnit:percent",
"value": 24
},
"windSpeed": "5 to 10 mph",
"windDirection": "N",
"icon": "https://api.weather.gov/icons/land/day/tsra_hi,20?size=medium",
"shortForecast": "Slight Chance Showers And Thunderstorms",
"detailedForecast": "A slight chance of showers and thunderstorms. Mostly sunny, with a high near 94. Heat index values as high as 105. North wind 5 to 10 mph. Chance of precipitation is 20%. New rainfall amounts less than a tenth of an inch possible."
},
{
"number": 4,
"name": "Monday Night",
"startTime": "2026-08-17T18:00:00-05:00",
"endTime": "2026-08-18T06:00:00-05:00",
"isDaytime": false,
"temperature": 73,
"temperatureUnit": "F",
"temperatureTrend": null,
"probabilityOfPrecipitation": {
"unitCode": "wmoUnit:percent",
"value": 16
},
"windSpeed": "0 to 5 mph",
"windDirection": "NE",
"icon": "https://api.weather.gov/icons/land/night/tsra_hi,20/few?size=medium",
"shortForecast": "Slight Chance Showers And Thunderstorms then Mostly Clear",
"detailedForecast": "A slight chance of showers and thunderstorms before 7pm. Mostly clear, with a low around 73. Heat index values as high as 103. Northeast wind 0 to 5 mph. Chance of precipitation is 20%."
},
{
"number": 5,
"name": "Tuesday",
"startTime": "2026-08-18T06:00:00-05:00",
"endTime": "2026-08-18T18:00:00-05:00",
"isDaytime": true,
"temperature": 101,
"temperatureUnit": "F",
"temperatureTrend": null,
"probabilityOfPrecipitation": {
"unitCode": "wmoUnit:percent",
"value": 11
},
"windSpeed": "0 to 5 mph",
"windDirection": "SE",
"icon": "https://api.weather.gov/icons/land/day/hot?size=medium",
"shortForecast": "Sunny",
"detailedForecast": "Sunny, with a high near 101. Southeast wind 0 to 5 mph."
},
{
"number": 6,
"name": "Tuesday Night",
"startTime": "2026-08-18T18:00:00-05:00",
"endTime": "2026-08-19T06:00:00-05:00",
"isDaytime": false,
"temperature": 79,
"temperatureUnit": "F",
"temperatureTrend": null,
"probabilityOfPrecipitation": {
"unitCode": "wmoUnit:percent",
"value": 1
},
"windSpeed": "5 to 10 mph",
"windDirection": "S",
"icon": "https://api.weather.gov/icons/land/night/few?size=medium",
"shortForecast": "Mostly Clear",
"detailedForecast": "Mostly clear, with a low around 79. South wind 5 to 10 mph."
},
{
"number": 7,
"name": "Wednesday",
"startTime": "2026-08-19T06:00:00-05:00",
"endTime": "2026-08-19T18:00:00-05:00",
"isDaytime": true,
"temperature": 104,
"temperatureUnit": "F",
"temperatureTrend": null,
"probabilityOfPrecipitation": {
"unitCode": "wmoUnit:percent",
"value": 1
},
"windSpeed": "10 mph",
"windDirection": "SW",
"icon": "https://api.weather.gov/icons/land/day/hot?size=medium",
"shortForecast": "Sunny",
"detailedForecast": "Sunny, with a high near 104. Southwest wind around 10 mph."
},
{
"number": 8,
"name": "Wednesday Night",
"startTime": "2026-08-19T18:00:00-05:00",
"endTime": "2026-08-20T06:00:00-05:00",
"isDaytime": false,
"temperature": 76,
"temperatureUnit": "F",
"temperatureTrend": null,
"probabilityOfPrecipitation": {
"unitCode": "wmoUnit:percent",
"value": 12
},
"windSpeed": "10 to 15 mph",
"windDirection": "S",
"icon": "https://api.weather.gov/icons/land/night/few?size=medium",
"shortForecast": "Mostly Clear",
"detailedForecast": "Mostly clear, with a low around 76. South wind 10 to 15 mph, with gusts as high as 20 mph."
},
{
"number": 9,
"name": "Thursday",
"startTime": "2026-08-20T06:00:00-05:00",
"endTime": "2026-08-20T18:00:00-05:00",
"isDaytime": true,
"temperature": 98,
"temperatureUnit": "F",
"temperatureTrend": null,
"probabilityOfPrecipitation": {
"unitCode": "wmoUnit:percent",
"value": 12
},
"windSpeed": "5 to 10 mph",
"windDirection": "NE",
"icon": "https://api.weather.gov/icons/land/day/sct?size=medium",
"shortForecast": "Mostly Sunny",
"detailedForecast": "Mostly sunny, with a high near 98. Northeast wind 5 to 10 mph."
},
{
"number": 10,
"name": "Thursday Night",
"startTime": "2026-08-20T18:00:00-05:00",
"endTime": "2026-08-21T06:00:00-05:00",
"isDaytime": false,
"temperature": 72,
"temperatureUnit": "F",
"temperatureTrend": null,
"probabilityOfPrecipitation": {
"unitCode": "wmoUnit:percent",
"value": 7
},
"windSpeed": "5 to 10 mph",
"windDirection": "NE",
"icon": "https://api.weather.gov/icons/land/night/few?size=medium",
"shortForecast": "Mostly Clear",
"detailedForecast": "Mostly clear, with a low around 72. Northeast wind 5 to 10 mph."
},
{
"number": 11,
"name": "Friday",
"startTime": "2026-08-21T06:00:00-05:00",
"endTime": "2026-08-21T18:00:00-05:00",
"isDaytime": true,
"temperature": 97,
"temperatureUnit": "F",
"temperatureTrend": null,
"probabilityOfPrecipitation": {
"unitCode": "wmoUnit:percent",
"value": 7
},
"windSpeed": "5 mph",
"windDirection": "NE",
"icon": "https://api.weather.gov/icons/land/day/few?size=medium",
"shortForecast": "Sunny",
"detailedForecast": "Sunny, with a high near 97. Northeast wind around 5 mph."
},
{
"number": 12,
"name": "Friday Night",
"startTime": "2026-08-21T18:00:00-05:00",
"endTime": "2026-08-22T06:00:00-05:00",
"isDaytime": false,
"temperature": 72,
"temperatureUnit": "F",
"temperatureTrend": null,
"probabilityOfPrecipitation": {
"unitCode": "wmoUnit:percent",
"value": 3
},
"windSpeed": "5 to 10 mph",
"windDirection": "NE",
"icon": "https://api.weather.gov/icons/land/night/few?size=medium",
"shortForecast": "Mostly Clear",
"detailedForecast": "Mostly clear, with a low around 72. Northeast wind 5 to 10 mph."
},
{
"number": 13,
"name": "Saturday",
"startTime": "2026-08-22T06:00:00-05:00",
"endTime": "2026-08-22T18:00:00-05:00",
"isDaytime": true,
"temperature": 99,
"temperatureUnit": "F",
"temperatureTrend": null,
"probabilityOfPrecipitation": {
"unitCode": "wmoUnit:percent",
"value": 4
},
"windSpeed": "5 mph",
"windDirection": "E",
"icon": "https://api.weather.gov/icons/land/day/few?size=medium",
"shortForecast": "Sunny",
"detailedForecast": "Sunny, with a high near 99. East wind around 5 mph."
},
{
"number": 14,
"name": "Saturday Night",
"startTime": "2026-08-22T18:00:00-05:00",
"endTime": "2026-08-23T06:00:00-05:00",
"isDaytime": false,
"temperature": 73,
"temperatureUnit": "F",
"temperatureTrend": null,
"probabilityOfPrecipitation": {
"unitCode": "wmoUnit:percent",
"value": 5
},
"windSpeed": "5 to 10 mph",
"windDirection": "E",
"icon": "https://api.weather.gov/icons/land/night/few?size=medium",
"shortForecast": "Mostly Clear",
"detailedForecast": "Mostly clear, with a low around 73. East wind 5 to 10 mph."
}
]
}
}

1
tests/fixtures/nws/grid.json vendored Normal file
View File

@ -0,0 +1 @@
{"properties": {"elevation": {"value": 196.9008}}}

1
tests/fixtures/nws/hourly.json vendored Normal file

File diff suppressed because one or more lines are too long

112
tests/fixtures/nws/points.json vendored Normal file
View File

@ -0,0 +1,112 @@
{
"@context": [
"https://geojson.org/geojson-ld/geojson-context.jsonld",
{
"@version": "1.1",
"wx": "https://api.weather.gov/ontology#",
"s": "https://schema.org/",
"geo": "http://www.opengis.net/ont/geosparql#",
"unit": "http://codes.wmo.int/common/unit/",
"@vocab": "https://api.weather.gov/ontology#",
"geometry": {
"@id": "s:GeoCoordinates",
"@type": "geo:wktLiteral"
},
"city": "s:addressLocality",
"state": "s:addressRegion",
"distance": {
"@id": "s:Distance",
"@type": "s:QuantitativeValue"
},
"bearing": {
"@type": "s:QuantitativeValue"
},
"value": {
"@id": "s:value"
},
"unitCode": {
"@id": "s:unitCode",
"@type": "@id"
},
"forecastOffice": {
"@type": "@id"
},
"forecastGridData": {
"@type": "@id"
},
"publicZone": {
"@type": "@id"
},
"county": {
"@type": "@id"
}
}
],
"id": "https://api.weather.gov/points/36.3567,-95.8146",
"type": "Feature",
"geometry": {
"type": "Point",
"coordinates": [
-95.8146,
36.3567
]
},
"properties": {
"@id": "https://api.weather.gov/points/36.3567,-95.8146",
"@type": "wx:Point",
"cwa": "TSA",
"type": "land",
"forecastOffice": "https://api.weather.gov/offices/TSA",
"gridId": "TSA",
"gridX": 48,
"gridY": 114,
"forecast": "https://api.weather.gov/gridpoints/TSA/48,114/forecast",
"forecastHourly": "https://api.weather.gov/gridpoints/TSA/48,114/forecast/hourly",
"forecastGridData": "https://api.weather.gov/gridpoints/TSA/48,114",
"observationStations": "https://api.weather.gov/gridpoints/TSA/48,114/stations",
"relativeLocation": {
"type": "Feature",
"geometry": {
"type": "Point",
"coordinates": [
-95.8617563,
36.3692625
]
},
"properties": {
"city": "Collinsville",
"state": "OK",
"distance": {
"unitCode": "wmoUnit:m",
"value": 0
},
"bearing": {
"unitCode": "wmoUnit:degree_(angle)",
"value": 0
}
}
},
"forecastZone": "https://api.weather.gov/zones/forecast/OKZ060",
"county": "https://api.weather.gov/zones/county/OKC143",
"fireWeatherZone": "https://api.weather.gov/zones/fire/OKZ060",
"timeZone": "America/Chicago",
"radarStation": "KINX",
"astronomicalData": {
"sunrise": "2026-08-16T06:42:13-05:00",
"sunset": "2026-08-16T20:12:46-05:00",
"transit": "2026-08-16T13:27:29-05:00",
"civilTwilightBegin": "2026-08-16T06:15:02-05:00",
"civilTwilightEnd": "2026-08-16T20:39:57-05:00",
"nauticalTwilightBegin": "2026-08-16T05:42:19-05:00",
"nauticalTwilightEnd": "2026-08-16T21:12:40-05:00",
"astronomicalTwilightBegin": "2026-08-16T05:07:54-05:00",
"astronomicalTwilightEnd": "2026-08-16T21:47:05-05:00"
},
"nwr": {
"transmitter": "KIH27",
"sameCode": "040143",
"areaBroadcast": "https://api.weather.gov/radio/KIH27/broadcast",
"pointBroadcast": "https://api.weather.gov/points/36.3567,-95.8146/radio"
}
}
}

87
tests/nws.test.js Normal file
View File

@ -0,0 +1,87 @@
import { describe, it, expect } from 'vitest'
import { readFileSync } from 'node:fs'
import path from 'node:path'
import {
compassToDeg,
parseWind,
shortForecastToWmo,
dateKey,
localTimeString,
normalizeNWS,
} from '../src/lib/api/nws.js'
const FIX = (name) => JSON.parse(readFileSync(path.join('tests/fixtures/nws', name), 'utf8'))
describe('nws helpers', () => {
it('shortForecastToWmo maps text to WMO codes', () => {
expect(shortForecastToWmo('Sunny')).toBe(0)
expect(shortForecastToWmo('Mostly Sunny')).toBe(2)
expect(shortForecastToWmo('Partly Cloudy')).toBe(2)
expect(shortForecastToWmo('Overcast')).toBe(3)
expect(shortForecastToWmo('Slight Chance Showers And Thunderstorms')).toBe(95)
expect(shortForecastToWmo('Heavy Rain')).toBe(65)
expect(shortForecastToWmo('Light Snow')).toBe(71)
expect(shortForecastToWmo('Areas of Fog')).toBe(45)
})
it('compassToDeg maps compass points to degrees', () => {
expect(compassToDeg('N')).toBe(0)
expect(compassToDeg('NE')).toBe(45)
expect(compassToDeg('SW')).toBe(225)
expect(compassToDeg('W')).toBe(270)
expect(compassToDeg(null)).toBeNull()
})
it('parseWind takes the max speed from a range string', () => {
expect(parseWind('5 to 10 mph')).toBe(10)
expect(parseWind('12 mph')).toBe(12)
expect(parseWind('gusts up to 20 mph')).toBe(20)
expect(parseWind(null)).toBeNull()
})
it('dateKey and localTimeString produce naive local times (no UTC shift)', () => {
const tz = 'America/Chicago'
expect(dateKey('2026-08-16T15:00:00-05:00', tz)).toBe('2026-08-16')
expect(localTimeString('2026-08-16T15:00:00-05:00', tz)).toBe('2026-08-16T15:00')
expect(localTimeString('2026-08-19T15:00:00-05:00', tz)).toMatch(/^2026-08-19T/)
})
})
describe('normalizeNWS (recorded live fixture for Rogers County, OK)', () => {
const points = FIX('points.json')
const forecast = FIX('forecast.json')
const hourly = FIX('hourly.json')
const grid = FIX('grid.json')
const timeZone = points.properties.timeZone
const data = normalizeNWS({ points, forecast, hourly, grid, timeZone, units: 'imperial' })
it('marks the source as nws', () => {
expect(data._source).toBe('nws')
})
it('normalizes air high (not heat index) for the 7-day, matching NWS values', () => {
// NWS daytime highs: Today 103, Mon 94, Wed 104 ... (NO 116 outlier)
expect(data.daily.time).toHaveLength(7)
expect(data.daily.temperature_2m_max[0]).toBe(103) // Today
expect(data.daily.temperature_2m_max[1]).toBe(94) // Monday
expect(data.daily.temperature_2m_max[3]).toBe(104) // Wednesday
expect(Math.max(...data.daily.temperature_2m_max)).toBeLessThan(115)
expect(data.daily.temperature_2m_min[0]).toBe(75)
expect(data.daily.precipitation_probability_max[0]).toBe(24)
})
it('builds a realistic current from the first hourly period', () => {
expect(data.current.temperature_2m).toBe(102)
expect(data.current.relative_humidity_2m).toBe(35)
expect(data.current.wind_direction_10m).toBe(225) // SW
expect(data.current.time).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/)
})
it('normalizes to metric on request', () => {
const m = normalizeNWS({ points, forecast, hourly, grid, timeZone, units: 'metric' })
expect(m.current.temperature_2m).toBeCloseTo((102 - 32) * 5 / 9, 1)
expect(m.daily.temperature_2m_max[0]).toBeCloseTo((103 - 32) * 5 / 9, 1)
expect(m.current.wind_speed_10m).toBeCloseTo(10 * 1.609344, 1)
})
})