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:
parent
bc7780d494
commit
32f749444e
6
.gitignore
vendored
6
.gitignore
vendored
@ -21,3 +21,9 @@ dist-ssr
|
|||||||
*.njsproj
|
*.njsproj
|
||||||
*.sln
|
*.sln
|
||||||
*.sw?
|
*.sw?
|
||||||
|
|
||||||
|
|
||||||
|
# Build-time secrets (never commit)
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
235
src/lib/api/nws.js
Normal 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
19
src/lib/config.js
Normal 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)'
|
||||||
@ -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,
|
||||||
|
|||||||
@ -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
319
tests/fixtures/nws/forecast.json
vendored
Normal 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
1
tests/fixtures/nws/grid.json
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
{"properties": {"elevation": {"value": 196.9008}}}
|
||||||
1
tests/fixtures/nws/hourly.json
vendored
Normal file
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
112
tests/fixtures/nws/points.json
vendored
Normal 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
87
tests/nws.test.js
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
x
Reference in New Issue
Block a user