diff --git a/.gitignore b/.gitignore index 76a4d86..172bb7d 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,5 @@ pnpm-debug.log* .wrangler/ .dev.vars* !.dev.vars.example + +notes/ \ No newline at end of file diff --git a/src/assets/pixel/info.png b/src/assets/pixel/info.png new file mode 100644 index 0000000..9e6c075 Binary files /dev/null and b/src/assets/pixel/info.png differ diff --git a/src/assets/pixel/widgets/weather-placeholder.svg b/src/assets/pixel/widgets/weather-placeholder.svg new file mode 100644 index 0000000..dba4ba6 --- /dev/null +++ b/src/assets/pixel/widgets/weather-placeholder.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/assets/pixel/widgets/weather/clear-night.png b/src/assets/pixel/widgets/weather/clear-night.png new file mode 100644 index 0000000..e593ead Binary files /dev/null and b/src/assets/pixel/widgets/weather/clear-night.png differ diff --git a/src/assets/pixel/widgets/weather/clear.png b/src/assets/pixel/widgets/weather/clear.png new file mode 100644 index 0000000..2f18cfd Binary files /dev/null and b/src/assets/pixel/widgets/weather/clear.png differ diff --git a/src/assets/pixel/widgets/weather/cloudy.png b/src/assets/pixel/widgets/weather/cloudy.png new file mode 100644 index 0000000..77027f1 Binary files /dev/null and b/src/assets/pixel/widgets/weather/cloudy.png differ diff --git a/src/assets/pixel/widgets/weather/foggy.png b/src/assets/pixel/widgets/weather/foggy.png new file mode 100644 index 0000000..63d7bb7 Binary files /dev/null and b/src/assets/pixel/widgets/weather/foggy.png differ diff --git a/src/assets/pixel/widgets/weather/freezing-rain.png b/src/assets/pixel/widgets/weather/freezing-rain.png new file mode 100644 index 0000000..e673763 Binary files /dev/null and b/src/assets/pixel/widgets/weather/freezing-rain.png differ diff --git a/src/assets/pixel/widgets/weather/hail.png b/src/assets/pixel/widgets/weather/hail.png new file mode 100644 index 0000000..3ce0c34 Binary files /dev/null and b/src/assets/pixel/widgets/weather/hail.png differ diff --git a/src/assets/pixel/widgets/weather/mostly-clear.png b/src/assets/pixel/widgets/weather/mostly-clear.png new file mode 100644 index 0000000..7787412 Binary files /dev/null and b/src/assets/pixel/widgets/weather/mostly-clear.png differ diff --git a/src/assets/pixel/widgets/weather/party-cloudy.png b/src/assets/pixel/widgets/weather/party-cloudy.png new file mode 100644 index 0000000..d61a096 Binary files /dev/null and b/src/assets/pixel/widgets/weather/party-cloudy.png differ diff --git a/src/assets/pixel/widgets/weather/raining.png b/src/assets/pixel/widgets/weather/raining.png new file mode 100644 index 0000000..5dfe6eb Binary files /dev/null and b/src/assets/pixel/widgets/weather/raining.png differ diff --git a/src/assets/pixel/widgets/weather/snow-showers.png b/src/assets/pixel/widgets/weather/snow-showers.png new file mode 100644 index 0000000..0ea386a Binary files /dev/null and b/src/assets/pixel/widgets/weather/snow-showers.png differ diff --git a/src/assets/pixel/widgets/weather/snowing.png b/src/assets/pixel/widgets/weather/snowing.png new file mode 100644 index 0000000..dcf3c08 Binary files /dev/null and b/src/assets/pixel/widgets/weather/snowing.png differ diff --git a/src/components/ui/widgets/ClockWidget.astro b/src/components/ui/widgets/ClockWidget.astro index 7928878..9c33e2a 100644 --- a/src/components/ui/widgets/ClockWidget.astro +++ b/src/components/ui/widgets/ClockWidget.astro @@ -1,11 +1,12 @@ --- import icon from "../../../assets/pixel/widgets/clock.png"; +import { locationConfig } from "../../../data/location"; interface Props { - timezone: string; + timezone?: string; } -const { timezone } = Astro.props; +const { timezone = locationConfig.timezone } = Astro.props; const iconSrc = icon.src; --- diff --git a/src/components/ui/widgets/WeatherWidget.astro b/src/components/ui/widgets/WeatherWidget.astro new file mode 100644 index 0000000..043a5eb --- /dev/null +++ b/src/components/ui/widgets/WeatherWidget.astro @@ -0,0 +1,491 @@ +--- +import fallbackIcon from "../../../assets/pixel/widgets/weather/cloudy.png"; +import info from "../../../assets/pixel/info.png"; + +import { weatherConfig } from "../../../data/widgets"; +--- + + + +
+ + +
+

Weather data

+

+ Weather by Open-Meteo. +

+

+ UV data by CAMS, via + Open-Meteo. +

+

+ Data licensed under{" "} + CC BY 4.0. + Values rounded and conditions formatted for display. +

+
+
+
+
+
+ +

—

+
+ +
+

{weatherConfig.city}

+ +

+ —°F +

+

+ Hi: — + Lo: — +

+
+ +
+
+
Humidity:
+
—
+
+
+
UV Index:
+
—
+
+
+
Feels like:
+
—°F
+
+
+
+ + +
+ + + + diff --git a/src/data/location.ts b/src/data/location.ts new file mode 100644 index 0000000..19f4feb --- /dev/null +++ b/src/data/location.ts @@ -0,0 +1,9 @@ +// Shared by the weather widget and clocks. Update all four values when moving. +// Use city-center coordinates, not your home address. These values are public. +export const locationConfig = { + city: "Norman, OK", + latitude: 35.2226, + longitude: -97.4395, + // IANA timezone; handles daylight saving time automatically. + timezone: "America/Chicago", +} as const; diff --git a/src/data/widgets.ts b/src/data/widgets.ts new file mode 100644 index 0000000..59a4a5b --- /dev/null +++ b/src/data/widgets.ts @@ -0,0 +1,23 @@ +import { locationConfig } from "./location.ts"; + +export const weatherConfig = { + ...locationConfig, + temperatureUnit: "fahrenheit", + refreshIntervalMs: 30 * 60 * 1000, + staleAfterMs: 3 * 60 * 60 * 1000, + unavailableAfterMs: 6 * 60 * 60 * 1000, +} as const; + +// Design fixture only. Replace with fetched weather when connected. +export const weatherPreview = { + city: locationConfig.city, + timezone: locationConfig.timezone, + observedAt: "2026-01-15T22:49:00-06:00", + condition: "Snowing", + temperature: 67, + high: 999, + low: -999, + humidity: 100, + uvIndex: 10, + feelsLike: 67, +}; diff --git a/src/lib/client/weather-display.ts b/src/lib/client/weather-display.ts new file mode 100644 index 0000000..504c586 --- /dev/null +++ b/src/lib/client/weather-display.ts @@ -0,0 +1,96 @@ +/** Open-Meteo's WMO weather interpretation codes. */ +export function conditionLabel(code: number, isDay: boolean): string { + switch (code) { + case 0: + return isDay ? "Sunny" : "Clear"; + case 1: + return "Mostly clear"; + case 2: + return "Partly cloudy"; + case 3: + return "Overcast"; + case 45: + case 48: + return "Foggy"; + case 51: + case 53: + case 55: + return "Drizzle"; + case 56: + case 57: + return "Freezing drizzle"; + case 61: + case 63: + case 65: + return "Raining"; + case 66: + case 67: + return "Freezing rain"; + case 71: + case 73: + case 75: + case 77: + return "Snowing"; + case 80: + case 81: + case 82: + return "Rain showers"; + case 85: + case 86: + return "Snow showers"; + case 95: + case 97: + return "Thunderstorms"; + case 96: + case 99: + return "Storms with hail"; + default: + return "Unknown"; + } +} + +/** Closest available artwork; labels above retain the actual condition. */ +export function conditionIcon(code: number, isDay: boolean): string { + switch (code) { + case 0: + return isDay ? "clear" : "clear-night"; + case 1: + return isDay ? "mostly-clear" : "clear-night"; + case 2: + // Keep the asset's existing spelling. No partly-cloudy night art yet. + return isDay ? "party-cloudy" : "cloudy"; + case 45: + case 48: + return "foggy"; + case 51: + case 53: + case 55: + case 61: + case 63: + case 65: + case 80: + case 81: + case 82: + case 95: + case 97: + return "raining"; + case 56: + case 57: + case 66: + case 67: + return "freezing-rain"; + case 71: + case 73: + case 75: + case 77: + return "snowing"; + case 85: + case 86: + return "snow-showers"; + case 96: + case 99: + return "hail"; + default: + return "cloudy"; + } +} diff --git a/src/lib/client/weather-store.ts b/src/lib/client/weather-store.ts new file mode 100644 index 0000000..b260165 --- /dev/null +++ b/src/lib/client/weather-store.ts @@ -0,0 +1,181 @@ +import { weatherConfig } from "../../data/widgets.ts"; +import { fetchWeather, type WeatherSnapshot } from "./weather.ts"; + +export interface WeatherState { + status: "loading" | "ready" | "stale" | "unavailable"; + snapshot: WeatherSnapshot | null; + refreshing: boolean; + error: string | null; +} + +const CACHE_KEY = `weather:v2:${weatherConfig.latitude}:${weatherConfig.longitude}:${weatherConfig.timezone}:${weatherConfig.temperatureUnit}`; +type CacheStorage = Pick; + +function browserStorage(): CacheStorage | undefined { + try { + return typeof localStorage === "undefined" ? undefined : localStorage; + } catch { + return undefined; + } +} + +// Persisted data is untrusted and may come from an older version of the site. +function validSnapshot(value: unknown): value is WeatherSnapshot { + if (!value || typeof value !== "object") return false; + const data = value as Record; + const finite = (key: string) => + typeof data[key] === "number" && Number.isFinite(data[key]); + const date = (key: string) => + typeof data[key] === "string" && Number.isFinite(Date.parse(data[key])); + return ( + data.city === weatherConfig.city && + data.timezone === weatherConfig.timezone && + data.temperatureUnit === weatherConfig.temperatureUnit && + [ + "temperature", + "feelsLike", + "high", + "low", + "humidity", + "weatherCode", + ].every(finite) && + (data.low as number) <= (data.high as number) && + (data.humidity as number) >= 0 && + (data.humidity as number) <= 100 && + Number.isInteger(data.weatherCode) && + (data.weatherCode as number) >= 0 && + (data.weatherCode as number) <= 99 && + typeof data.isDay === "boolean" && + ["uvIndex"].every( + (key) => + data[key] === null || (finite(key) && (data[key] as number) >= 0), + ) && + date("conditionsAt") && + date("fetchedAt") && + (data.uvAt === null || date("uvAt")) + ); +} + +/** A cache and request coordinator, independent of the widget and its markup. */ +export function createWeatherStore({ + storage = browserStorage(), + load = fetchWeather, + now = Date.now, +}: { + storage?: CacheStorage; + load?: () => Promise; + now?: () => number; +} = {}) { + let snapshot: WeatherSnapshot | null = null; + let pending: Promise | undefined; + let lastAttempt = -Infinity; + let error: string | null = null; + + function acceptable(data: unknown): data is WeatherSnapshot { + return ( + validSnapshot(data) && + Date.parse(data.fetchedAt) <= now() + 60_000 && + Date.parse(data.conditionsAt) <= now() + 60_000 + ); + } + + function readCache() { + try { + const cached: unknown = JSON.parse(storage?.getItem(CACHE_KEY) ?? "null"); + if ( + acceptable(cached) && + (!snapshot || + Date.parse(cached.fetchedAt) > Date.parse(snapshot.fetchedAt)) + ) { + snapshot = cached; + } + } catch { + // Disabled storage or malformed JSON must not break weather fetching. + } + } + readCache(); + + function getState(): WeatherState { + const age = snapshot ? now() - Date.parse(snapshot.conditionsAt) : Infinity; + const status = + age >= weatherConfig.unavailableAfterMs + ? "unavailable" + : age >= weatherConfig.staleAfterMs + ? "stale" + : "ready"; + return { + status: !snapshot && pending ? "loading" : status, + snapshot: status === "unavailable" ? null : snapshot, + refreshing: !!pending, + error, + }; + } + + function refresh(): Promise { + if (pending) return pending; + readCache(); // Another tab may have updated the shared localStorage entry. + const checkedAt = snapshot ? Date.parse(snapshot.fetchedAt) : -Infinity; + if ( + now() - Math.max(checkedAt, lastAttempt) < + weatherConfig.refreshIntervalMs + ) { + return Promise.resolve(getState()); + } + lastAttempt = now(); + // Defer loading so pending is assigned even if load throws synchronously. + pending = Promise.resolve() + .then(load) + .then((data) => { + if (!acceptable(data)) throw new Error("Invalid weather snapshot"); + snapshot = data; + error = null; + try { + storage?.setItem(CACHE_KEY, JSON.stringify(data)); + } catch { + // Keep the in-memory result when storage is blocked or full. + } + }) + .catch(() => { + error = "Weather refresh failed"; + }) + .then(() => { + pending = undefined; + return getState(); + }); + return pending; + } + + return { getState, refresh }; +} + +let sharedStore: ReturnType | undefined; + +/** Start on mount; call the returned cleanup function on unmount. */ +export function watchWeather( + onChange: (state: WeatherState) => void, +): () => void { + const store = (sharedStore ??= createWeatherStore()); + let active = true; + const emit = () => { + if (active) onChange(store.getState()); + }; + const tick = () => { + if (document.visibilityState !== "visible") return; + const refresh = store.refresh(); + emit(); + void refresh.then(emit); + }; + + emit(); + tick(); + // This checks display age every minute; refresh() limits network work to 30m. + const timer = setInterval(tick, 60_000); + document.addEventListener("visibilitychange", tick); + window.addEventListener("pageshow", tick); + return () => { + active = false; + clearInterval(timer); + document.removeEventListener("visibilitychange", tick); + window.removeEventListener("pageshow", tick); + }; +} diff --git a/src/lib/client/weather.ts b/src/lib/client/weather.ts new file mode 100644 index 0000000..10c850d --- /dev/null +++ b/src/lib/client/weather.ts @@ -0,0 +1,163 @@ +import { weatherConfig } from "../../data/widgets.ts"; + +export interface WeatherSnapshot { + city: string; + timezone: string; + temperatureUnit: "fahrenheit"; + temperature: number; + feelsLike: number; + high: number; + low: number; + humidity: number; + weatherCode: number; + isDay: boolean; + uvIndex: number | null; + conditionsAt: string; + uvAt: string | null; + fetchedAt: string; +} + +type FetchOptions = { + signal?: AbortSignal; + // Injection lets us verify failures without contacting the provider. + fetcher?: typeof fetch; +}; + +function object(value: unknown): Record { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error("Invalid Open-Meteo response: expected an object"); + } + return value as Record; +} + +function number( + value: unknown, + name: string, + min = -Infinity, + max = Infinity, +): number { + if ( + typeof value !== "number" || + !Number.isFinite(value) || + value < min || + value > max + ) { + throw new Error(`Invalid Open-Meteo field: ${name}`); + } + return value; +} + +function timestamp(value: unknown): string { + const seconds = number(value, "time", 0); + const date = new Date(seconds * 1000); + if (Number.isNaN(date.getTime())) + throw new Error("Invalid Open-Meteo timestamp"); + return date.toISOString(); +} + +function dailyValue(value: unknown, name: string): number { + if (!Array.isArray(value) || value.length !== 1) { + throw new Error(`Invalid Open-Meteo daily field: ${name}`); + } + return number(value[0], name); +} + +function url(base: string, parameters: Record): URL { + const result = new URL(base); + result.search = new URLSearchParams({ + latitude: String(weatherConfig.latitude), + longitude: String(weatherConfig.longitude), + timezone: weatherConfig.timezone, + timeformat: "unixtime", + forecast_days: "1", + ...parameters, + }).toString(); + return result; +} + +async function request(url: URL, fetcher: typeof fetch, signal: AbortSignal) { + const response = await fetcher(url, { + signal, + credentials: "omit", + referrerPolicy: "no-referrer", + }); + if (!response.ok) { + throw new Error(`Open-Meteo request failed (${response.status})`); + } + return object(await response.json()); +} + +/** One refresh only. Storage, retry policy, and scheduling belong to the caller. */ +export async function fetchWeather({ + signal, + fetcher = fetch, +}: FetchOptions = {}): Promise { + const timeout = AbortSignal.timeout(10_000); + const requestSignal = signal ? AbortSignal.any([signal, timeout]) : timeout; + + const weatherUrl = url("https://api.open-meteo.com/v1/forecast", { + current: + "temperature_2m,apparent_temperature,relative_humidity_2m,weather_code,is_day", + daily: "temperature_2m_max,temperature_2m_min", + temperature_unit: weatherConfig.temperatureUnit, + }); + const airUrl = url("https://air-quality-api.open-meteo.com/v1/air-quality", { + current: "uv_index", + }); + + // Settle independently: optional UV failure must not discard weather. + const [weatherResult, airResult] = await Promise.allSettled([ + request(weatherUrl, fetcher, requestSignal), + request(airUrl, fetcher, requestSignal), + ]); + signal?.throwIfAborted(); + if (weatherResult.status === "rejected") throw weatherResult.reason; + + const current = object(weatherResult.value.current); + const daily = object(weatherResult.value.daily); + const high = dailyValue(daily.temperature_2m_max, "temperature_2m_max"); + const low = dailyValue(daily.temperature_2m_min, "temperature_2m_min"); + if (low > high) throw new Error("Invalid Open-Meteo daily temperature range"); + const day = number(current.is_day, "is_day", 0, 1); + if (day !== 0 && day !== 1) throw new Error("Invalid Open-Meteo day flag"); + const weatherCode = number(current.weather_code, "weather_code", 0, 99); + if (!Number.isInteger(weatherCode)) + throw new Error("Invalid Open-Meteo weather code"); + + let uvIndex: number | null = null; + let uvAt: string | null = null; + if (airResult.status === "fulfilled") { + try { + const air = object(airResult.value.current); + const at = timestamp(air.time); + const uv = + air.uv_index == null ? null : number(air.uv_index, "uv_index", 0); + uvIndex = uv; + uvAt = at; + } catch { + // Malformed optional data is unavailable, never substituted with zero. + } + } + + return { + city: weatherConfig.city, + timezone: weatherConfig.timezone, + temperatureUnit: weatherConfig.temperatureUnit, + temperature: number(current.temperature_2m, "temperature_2m"), + feelsLike: number(current.apparent_temperature, "apparent_temperature"), + humidity: number( + current.relative_humidity_2m, + "relative_humidity_2m", + 0, + 100, + ), + high, + low, + weatherCode, + isDay: day === 1, + uvIndex, + conditionsAt: timestamp(current.time), + uvAt, + fetchedAt: new Date().toISOString(), + }; +} diff --git a/src/pages/index.astro b/src/pages/index.astro index ab7e537..85bc7e2 100644 --- a/src/pages/index.astro +++ b/src/pages/index.astro @@ -1,6 +1,7 @@ --- import BaseLayout from "../layouts/BaseLayout.astro"; import ClockWidget from "../components/ui/widgets/ClockWidget.astro"; +import WeatherWidget from "../components/ui/widgets/WeatherWidget.astro"; import ItemBox from "../components/ui/ItemBox.astro"; import ItemList from "../components/ui/ItemList.astro"; @@ -143,13 +144,13 @@ const homepageProjects = await getHomepageProjects(6);
-

Weather

+
- +
diff --git a/tests/weather-display.test.ts b/tests/weather-display.test.ts new file mode 100644 index 0000000..c33ae98 --- /dev/null +++ b/tests/weather-display.test.ts @@ -0,0 +1,13 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { conditionLabel } from "../src/lib/client/weather-display.ts"; + +test("weather descriptions distinguish day, night, precipitation and unknown codes", () => { + assert.equal(conditionLabel(0, true), "Sunny"); + assert.equal(conditionLabel(0, false), "Clear"); + assert.equal(conditionLabel(73, true), "Snowing"); + assert.equal(conditionLabel(67, false), "Freezing rain"); + assert.equal(conditionLabel(97, false), "Thunderstorms"); + assert.equal(conditionLabel(99, true), "Storms with hail"); + assert.equal(conditionLabel(1000, true), "Unknown"); +}); diff --git a/tests/weather-store.test.ts b/tests/weather-store.test.ts new file mode 100644 index 0000000..f150c4b --- /dev/null +++ b/tests/weather-store.test.ts @@ -0,0 +1,150 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { createWeatherStore } from "../src/lib/client/weather-store.ts"; +import type { WeatherSnapshot } from "../src/lib/client/weather.ts"; +import { locationConfig } from "../src/data/location.ts"; + +const HOUR = 60 * 60 * 1000; +const initialTime = Date.parse("2026-09-25T02:00:00Z"); +function snapshot(at = initialTime): WeatherSnapshot { + return { + city: locationConfig.city, + timezone: locationConfig.timezone, + temperatureUnit: "fahrenheit", + temperature: 72, + feelsLike: 74, + high: 80, + low: 60, + humidity: 50, + weatherCode: 2, + isDay: false, + uvIndex: 0, + conditionsAt: new Date(at).toISOString(), + uvAt: new Date(at).toISOString(), + fetchedAt: new Date(at).toISOString(), + }; +} + +function memoryStorage(initial: string | null = null) { + let value = initial; + return { + getItem: () => value, + setItem: (_key: string, next: string) => { + value = next; + }, + }; +} + +test("reuses persisted weather across reloads, then refreshes at 30 minutes", async () => { + let clock = initialTime; + let calls = 0; + const storage = memoryStorage(); + const options = { + storage, + now: () => clock, + load: async () => { + calls++; + return snapshot(clock); + }, + }; + await createWeatherStore(options).refresh(); + clock += 29 * 60_000; + const reloaded = createWeatherStore(options); + await reloaded.refresh(); + assert.equal(calls, 1); + clock += 60_000; + await reloaded.refresh(); + assert.equal(calls, 2); +}); + +test("overlapping callers share one network refresh", async () => { + let calls = 0; + const store = createWeatherStore({ + storage: memoryStorage(), + now: () => initialTime, + load: async () => { + calls++; + return snapshot(); + }, + }); + const first = store.refresh(); + const second = store.refresh(); + assert.equal(first, second); + assert.equal(store.getState().status, "loading"); + await first; + assert.equal(calls, 1); + assert.equal(store.getState().status, "ready"); +}); + +test("failed refresh retains weather, throttles retries, and ages it out", async () => { + let clock = initialTime + HOUR; + let calls = 0; + const store = createWeatherStore({ + storage: memoryStorage(JSON.stringify(snapshot())), + now: () => clock, + load: async () => { + calls++; + throw new Error("offline"); + }, + }); + assert.equal((await store.refresh()).status, "ready"); + await store.refresh(); + assert.equal(calls, 1); + clock = initialTime + 3 * HOUR; + assert.equal(store.getState().status, "stale"); + clock = initialTime + 6 * HOUR; + assert.equal(store.getState().status, "unavailable"); + assert.equal(store.getState().snapshot, null); +}); + +test("fresh download does not make old conditions fresh", async () => { + const store = createWeatherStore({ + storage: memoryStorage(), + now: () => initialTime + 7 * HOUR, + load: async () => ({ + ...snapshot(), + fetchedAt: new Date(initialTime + 7 * HOUR).toISOString(), + }), + }); + assert.equal((await store.refresh()).status, "unavailable"); +}); + +test("invalid, wrong-location, and future cache entries are ignored", async () => { + for (const cached of [ + "broken JSON", + "null", + JSON.stringify({ ...snapshot(), temperature: null }), + JSON.stringify({ ...snapshot(), city: "Elsewhere" }), + JSON.stringify(snapshot(initialTime + HOUR)), + ]) { + const store = createWeatherStore({ + storage: memoryStorage(cached), + now: () => initialTime, + load: async () => snapshot(), + }); + assert.equal(store.getState().snapshot, null); + assert.equal((await store.refresh()).status, "ready"); + } +}); + +test("blocked storage falls back to an in-memory cache", async () => { + let calls = 0; + const store = createWeatherStore({ + storage: { + getItem() { + throw new Error("blocked"); + }, + setItem() { + throw new Error("full"); + }, + }, + now: () => initialTime, + load: async () => { + calls++; + return snapshot(); + }, + }); + assert.equal((await store.refresh()).status, "ready"); + await store.refresh(); + assert.equal(calls, 1); +}); diff --git a/tests/weather.test.ts b/tests/weather.test.ts new file mode 100644 index 0000000..b53a6bb --- /dev/null +++ b/tests/weather.test.ts @@ -0,0 +1,98 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { fetchWeather } from "../src/lib/client/weather.ts"; +import { locationConfig } from "../src/data/location.ts"; + +const weather = { + current: { + time: 1790290800, + temperature_2m: 72.4, + apparent_temperature: 74.8, + relative_humidity_2m: 48, + weather_code: 2, + is_day: 1, + }, + daily: { temperature_2m_max: [81.2], temperature_2m_min: [59.7] }, +}; +const air = { current: { time: 1790287200, uv_index: 0 } }; + +function mockFetch( + airResponse: () => Response = () => Response.json(air), + weatherBody: unknown = weather, +): typeof fetch { + return async (input, init) => { + const url = new URL(String(input)); + assert.equal(url.searchParams.get("timezone"), locationConfig.timezone); + assert.equal( + url.searchParams.get("latitude"), + String(locationConfig.latitude), + ); + assert.equal( + url.searchParams.get("longitude"), + String(locationConfig.longitude), + ); + assert.equal(url.searchParams.get("forecast_days"), "1"); + assert.equal(url.searchParams.get("timeformat"), "unixtime"); + assert.equal(init?.credentials, "omit"); + if (url.hostname === "air-quality-api.open-meteo.com") return airResponse(); + assert.equal(url.searchParams.get("temperature_unit"), "fahrenheit"); + return Response.json(weatherBody); + }; +} + +test("returns unrounded Fahrenheit values and distinct UTC condition timestamps", async () => { + const data = await fetchWeather({ fetcher: mockFetch() }); + assert.equal(data.temperature, 72.4); + assert.equal(data.high, 81.2); + assert.equal(data.low, 59.7); + assert.equal(data.uvIndex, 0); + assert.equal(data.feelsLike, 74.8); + assert.equal(data.isDay, true); + assert.equal( + data.conditionsAt, + new Date(weather.current.time * 1000).toISOString(), + ); + assert.notEqual(data.conditionsAt, data.uvAt); +}); + +test("UV HTTP, network, and malformed-response failures preserve weather", async () => { + for (const response of [ + () => new Response(null, { status: 429 }), + () => { + throw new TypeError("Network unavailable"); + }, + () => Response.json({ current: { time: "invalid" } }), + () => new Response("not JSON"), + ]) { + const data = await fetchWeather({ fetcher: mockFetch(response) }); + assert.equal(data.temperature, 72.4); + assert.equal(data.uvIndex, null); + assert.equal(data.feelsLike, 74.8); + assert.equal(data.uvAt, null); + } +}); + +test("rejects missing temperatures rather than coercing null to zero", async () => { + await assert.rejects( + fetchWeather({ + fetcher: mockFetch(undefined, { + ...weather, + current: { ...weather.current, temperature_2m: null }, + }), + }), + /temperature_2m/, + ); +}); + +test("rejects weather HTTP failures and caller cancellation", async () => { + await assert.rejects( + fetchWeather({ fetcher: async () => new Response(null, { status: 503 }) }), + /503/, + ); + const controller = new AbortController(); + controller.abort(); + await assert.rejects( + fetchWeather({ fetcher: mockFetch(), signal: controller.signal }), + { name: "AbortError" }, + ); +});