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:
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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}` +
|
||||
`¤t=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`;
|
||||
`¤t=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
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user