Expand weather: feels-like, alerts, full current conditions, 2-row hourly

Sidebar widget now reads "Weather - <Location>" and shows a "Feels
like" line (Open-Meteo's own apparent_temperature, which already
blends heat index and wind chill as appropriate rather than needing
season-specific logic here).

The /weather page gains a current-conditions grid (humidity,
precipitation chance, wind direction/speed, pressure, sunrise,
sunset — wind and pressure units independently configurable in the
admin Weather tab) and an alerts section sourced from the US National
Weather Service (free, no key, US-only — fails safe to no alerts
elsewhere) shown between current conditions and the hourly strip.

Also fixes the hourly strip, which is now a fixed 12-column grid (two
rows of 12) instead of one overflowing horizontal-scroll row that
extended into the sidebar's column.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014c1L8ghNBFjfiH64UMViP8
This commit is contained in:
Claude
2026-07-24 22:52:53 +00:00
parent a45a813a41
commit b83640e980
10 changed files with 429 additions and 34 deletions
+11 -1
View File
@@ -181,9 +181,14 @@ export function migrate() {
weather_latitude REAL,
weather_longitude REAL,
weather_unit TEXT NOT NULL DEFAULT 'fahrenheit', -- celsius | fahrenheit
weather_current TEXT, -- JSON {temp, conditionText, icon}, NULL pre-first-poll
weather_wind_unit TEXT NOT NULL DEFAULT 'mph', -- mph | kph
weather_pressure_unit TEXT NOT NULL DEFAULT 'inHg', -- inHg | hPa
-- JSON {temp, feelsLike, conditionText, icon, humidity, precipitationChance,
-- windSpeed, windDirection, pressure, sunrise, sunset}, NULL pre-first-poll
weather_current TEXT,
weather_hourly TEXT NOT NULL DEFAULT '[]', -- JSON array
weather_daily TEXT NOT NULL DEFAULT '[]', -- JSON array
weather_alerts TEXT NOT NULL DEFAULT '[]', -- JSON array — active NWS alerts for the configured location, US-only (see weather/client.ts)
weather_updated_at TEXT -- ISO timestamp, NULL pre-first-poll
);
@@ -292,6 +297,11 @@ export function migrate() {
db.exec("ALTER TABLE global_settings ADD COLUMN weather_daily TEXT NOT NULL DEFAULT '[]'");
db.exec('ALTER TABLE global_settings ADD COLUMN weather_updated_at TEXT');
}
if (!hasColumn('global_settings', 'weather_wind_unit')) {
db.exec("ALTER TABLE global_settings ADD COLUMN weather_wind_unit TEXT NOT NULL DEFAULT 'mph'");
db.exec("ALTER TABLE global_settings ADD COLUMN weather_pressure_unit TEXT NOT NULL DEFAULT 'inHg'");
db.exec("ALTER TABLE global_settings ADD COLUMN weather_alerts TEXT NOT NULL DEFAULT '[]'");
}
// Seed a handful of sensible default tickers so the Stocks widget isn't empty on a
// fresh install — the admin can remove/replace any of them via the Stocks tab.
+8 -1
View File
@@ -28,10 +28,13 @@ function rowToSettings(row: any): GlobalSettings {
latitude: row.weather_latitude,
longitude: row.weather_longitude,
unit: row.weather_unit,
windUnit: row.weather_wind_unit,
pressureUnit: row.weather_pressure_unit,
// Unlike retention, this is genuinely absent pre-first-poll (and pre-location-config) — null-safe parse.
current: row.weather_current ? JSON.parse(row.weather_current) : null,
hourly: JSON.parse(row.weather_hourly),
daily: JSON.parse(row.weather_daily),
alerts: JSON.parse(row.weather_alerts),
updatedAt: row.weather_updated_at
}
};
@@ -60,7 +63,8 @@ export function updateSettings(patch: Partial<GlobalSettings>): GlobalSettings {
published_article_max_age_days=?, raw_item_max_age_days=?,
storage_cap_enabled=?, storage_cap_value=?, storage_cap_unit=?,
weather_location_name=?, weather_latitude=?, weather_longitude=?, weather_unit=?,
weather_current=?, weather_hourly=?, weather_daily=?, weather_updated_at=?
weather_wind_unit=?, weather_pressure_unit=?,
weather_current=?, weather_hourly=?, weather_daily=?, weather_alerts=?, weather_updated_at=?
WHERE id = 1`
).run(
merged.mergeStrictness,
@@ -85,9 +89,12 @@ export function updateSettings(patch: Partial<GlobalSettings>): GlobalSettings {
merged.weather.latitude,
merged.weather.longitude,
merged.weather.unit,
merged.weather.windUnit,
merged.weather.pressureUnit,
merged.weather.current ? JSON.stringify(merged.weather.current) : null,
JSON.stringify(merged.weather.hourly),
JSON.stringify(merged.weather.daily),
JSON.stringify(merged.weather.alerts),
merged.weather.updatedAt
);
return getSettings();
+31 -1
View File
@@ -272,9 +272,39 @@ export interface GlobalSettings {
latitude: number | null;
longitude: number | null;
unit: 'celsius' | 'fahrenheit';
current: { temp: number; conditionText: string; icon: string } | null;
windUnit: 'mph' | 'kph';
pressureUnit: 'inHg' | 'hPa';
current: {
temp: number;
/** Apparent temperature (Open-Meteo's own heat-index/wind-chill blend) — "Feels like". */
feelsLike: number;
conditionText: string;
icon: string;
/** Percent, 0-100. */
humidity: number;
/** Percent, 0-100 — the current hour's forecast precipitation probability (there's no true instantaneous "chance of rain" measurement). */
precipitationChance: number;
/** Already in the admin's configured windUnit. */
windSpeed: number;
/** 8-point compass abbreviation, e.g. "NW". */
windDirection: string;
/** Already in the admin's configured pressureUnit. */
pressure: number;
sunrise: string;
sunset: string;
} | null;
hourly: WeatherHourEntry[];
daily: WeatherDayEntry[];
/** Active NWS alerts (flash flood, hurricane, blizzard, etc.) for the configured location — US-only, empty elsewhere. See weather/client.ts's fetchActiveAlerts. */
alerts: WeatherAlert[];
updatedAt: string | null;
};
}
export interface WeatherAlert {
id: string;
event: string;
headline: string;
severity: string;
expires: string;
}
+103 -12
View File
@@ -53,6 +53,16 @@ export function wmoToCondition(code: number): WeatherCondition {
return WMO_CONDITIONS[code] ?? { text: 'Unknown', icon: '❔' };
}
const COMPASS_POINTS = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW'];
function degreesToCompass(degrees: number): string {
return COMPASS_POINTS[Math.round(degrees / 45) % 8];
}
function hPaToInHg(hpa: number): number {
return hpa * 0.0295299830714;
}
export async function geocodeLocation(query: string): Promise<GeocodeResult[]> {
const url = `https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(query)}&count=8`;
const res = await fetch(url);
@@ -69,8 +79,22 @@ export async function geocodeLocation(query: string): Promise<GeocodeResult[]> {
}));
}
export interface CurrentConditions {
temp: number;
feelsLike: number;
conditionText: string;
icon: string;
humidity: number;
precipitationChance: number;
windSpeed: number;
windDirection: string;
pressure: number;
sunrise: string;
sunset: string;
}
export interface ForecastResult {
current: { temp: number; conditionText: string; icon: string };
current: CurrentConditions;
hourly: { time: string; temp: number; conditionText: string; icon: string }[];
daily: { date: string; tempMax: number; tempMin: number; conditionText: string; icon: string }[];
}
@@ -78,31 +102,65 @@ export interface ForecastResult {
export async function fetchForecast(
latitude: number,
longitude: number,
unit: 'celsius' | 'fahrenheit'
unit: 'celsius' | 'fahrenheit',
windUnit: 'mph' | 'kph',
pressureUnit: 'inHg' | 'hPa'
): Promise<ForecastResult> {
const url =
`https://api.open-meteo.com/v1/forecast?latitude=${latitude}&longitude=${longitude}` +
`&current=temperature_2m,weather_code&hourly=temperature_2m,weather_code` +
`&daily=temperature_2m_max,temperature_2m_min,weather_code` +
`&temperature_unit=${unit}&timezone=auto&forecast_days=7`;
`&current=temperature_2m,apparent_temperature,weather_code,relative_humidity_2m,wind_speed_10m,wind_direction_10m,pressure_msl` +
`&hourly=temperature_2m,weather_code,precipitation_probability` +
`&daily=temperature_2m_max,temperature_2m_min,weather_code,sunrise,sunset` +
`&temperature_unit=${unit}&wind_speed_unit=${windUnit === 'kph' ? 'kmh' : 'mph'}&timezone=auto&forecast_days=7`;
const res = await fetch(url);
if (!res.ok) throw new Error(`Forecast API returned ${res.status}`);
const data = (await res.json()) as {
current: { temperature_2m: number; weather_code: number };
hourly: { time: string[]; temperature_2m: number[]; weather_code: number[] };
daily: { time: string[]; temperature_2m_max: number[]; temperature_2m_min: number[]; weather_code: number[] };
current: {
temperature_2m: number;
apparent_temperature: number;
weather_code: number;
relative_humidity_2m: number;
wind_speed_10m: number;
wind_direction_10m: number;
pressure_msl: number;
};
hourly: { time: string[]; temperature_2m: number[]; weather_code: number[]; precipitation_probability: number[] };
daily: {
time: string[];
temperature_2m_max: number[];
temperature_2m_min: number[];
weather_code: number[];
sunrise: string[];
sunset: string[];
};
};
const currentCondition = wmoToCondition(data.current.weather_code);
const current = { temp: data.current.temperature_2m, conditionText: currentCondition.text, icon: currentCondition.icon };
// hourly.time starts at today's midnight, not the current hour — find the first entry
// at or after now so the strip shown to the user starts from "now", not from midnight.
// at or after now so the strip shown to the user starts from "now", not from midnight,
// and so the current hour's precipitation_probability can stand in for "right now"
// (there's no true instantaneous "chance of rain" measurement, current forecasts don't have one).
const now = Date.now();
const startIdx = Math.max(
0,
data.hourly.time.findIndex((t) => new Date(t).getTime() >= now)
);
const currentCondition = wmoToCondition(data.current.weather_code);
const pressure = pressureUnit === 'inHg' ? hPaToInHg(data.current.pressure_msl) : data.current.pressure_msl;
const current: CurrentConditions = {
temp: data.current.temperature_2m,
feelsLike: data.current.apparent_temperature,
conditionText: currentCondition.text,
icon: currentCondition.icon,
humidity: data.current.relative_humidity_2m,
precipitationChance: data.hourly.precipitation_probability[startIdx] ?? 0,
windSpeed: data.current.wind_speed_10m,
windDirection: degreesToCompass(data.current.wind_direction_10m),
pressure: pressureUnit === 'inHg' ? Math.round(pressure * 100) / 100 : Math.round(pressure),
sunrise: data.daily.sunrise[0],
sunset: data.daily.sunset[0]
};
const hourly = data.hourly.time.slice(startIdx, startIdx + 24).map((time, i) => {
const idx = startIdx + i;
const condition = wmoToCondition(data.hourly.weather_code[idx]);
@@ -122,3 +180,36 @@ export async function fetchForecast(
return { current, hourly, daily };
}
export interface WeatherAlertResult {
id: string;
event: string;
headline: string;
severity: string;
expires: string;
}
// US National Weather Service — free, no key, no account, covers the US and territories
// only. A non-US location will reliably fail this call; that's expected, not an error
// (see poller.ts, which treats a failure here as "no alerts" rather than propagating it).
export async function fetchActiveAlerts(latitude: number, longitude: number): Promise<WeatherAlertResult[]> {
const url = `https://api.weather.gov/alerts/active?point=${latitude},${longitude}`;
const res = await fetch(url, {
headers: {
// NWS's API usage policy requires an identifying User-Agent on every request.
'User-Agent': 'Homefeed/1.0 (self-hosted news aggregator)',
Accept: 'application/geo+json'
}
});
if (!res.ok) throw new Error(`NWS alerts API returned ${res.status}`);
const data = (await res.json()) as {
features: { id: string; properties: { event: string; headline: string; severity: string; expires: string } }[];
};
return data.features.map((f) => ({
id: f.id,
event: f.properties.event,
headline: f.properties.headline,
severity: f.properties.severity,
expires: f.properties.expires
}));
}
+36 -7
View File
@@ -1,9 +1,9 @@
import * as settingsDb from '../storage/db/settings.js';
import { logger } from '../storage/db/logs.js';
import { fetchForecast } from './client.js';
import { fetchForecast, fetchActiveAlerts } from './client.js';
// Called on a schedule (see queue/scheduler.ts) and immediately after the admin changes
// the weather location/unit (see api/admin.ts) — writes straight into global_settings'
// the weather location/units (see api/admin.ts) — writes straight into global_settings'
// weather_* columns via settingsDb, same singleton-row approach as retention.
export async function pollWeatherNow(): Promise<void> {
const { weather } = settingsDb.getSettings();
@@ -11,13 +11,42 @@ export async function pollWeatherNow(): Promise<void> {
// No location configured yet — not an error, just nothing to do.
return;
}
let forecastUpdate: Partial<typeof weather> = {};
let forecastSucceeded = false;
try {
const { current, hourly, daily } = await fetchForecast(weather.latitude, weather.longitude, weather.unit);
settingsDb.updateSettings({
weather: { ...weather, current, hourly, daily, updatedAt: new Date().toISOString() }
});
const { current, hourly, daily } = await fetchForecast(
weather.latitude,
weather.longitude,
weather.unit,
weather.windUnit,
weather.pressureUnit
);
forecastUpdate = { current, hourly, daily };
forecastSucceeded = true;
} catch (err) {
// Leave the existing cache untouched — a stale forecast beats a blank widget.
logger.error('weather', `Poll failed: ${(err as Error).message}`);
logger.error('weather', `Forecast poll failed: ${(err as Error).message}`);
}
// Fetched independently of the forecast — the NWS only covers the US, so this fails
// reliably (and expectedly) for every non-US location. A failure here shouldn't
// touch the forecast update above, and unlike a stale forecast, a stale alert that's
// since expired is worse to keep showing than none at all — clear to empty on failure.
let alerts = weather.alerts;
try {
alerts = await fetchActiveAlerts(weather.latitude, weather.longitude);
} catch (err) {
alerts = [];
logger.warn('weather', `Alerts poll failed (expected outside the US): ${(err as Error).message}`);
}
settingsDb.updateSettings({
weather: {
...weather,
...forecastUpdate,
alerts,
updatedAt: forecastSucceeded ? new Date().toISOString() : weather.updatedAt
}
});
}