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
}
});
}
+26 -1
View File
@@ -31,14 +31,39 @@ export interface WeatherDayEntry {
icon: string;
}
export interface WeatherCurrentConditions {
temp: number;
feelsLike: number;
conditionText: string;
icon: string;
humidity: number;
precipitationChance: number;
windSpeed: number;
windDirection: string;
pressure: number;
sunrise: string;
sunset: string;
}
export interface WeatherAlert {
id: string;
event: string;
headline: string;
severity: string;
expires: string;
}
export interface AdminWeatherSettings {
locationName: string | null;
latitude: number | null;
longitude: number | null;
unit: 'celsius' | 'fahrenheit';
current: { temp: number; conditionText: string; icon: string } | null;
windUnit: 'mph' | 'kph';
pressureUnit: 'inHg' | 'hPa';
current: WeatherCurrentConditions | null;
hourly: WeatherHourEntry[];
daily: WeatherDayEntry[];
alerts: WeatherAlert[];
updatedAt: string | null;
}
@@ -56,6 +56,16 @@
{ label: '°F', value: 'fahrenheit' },
{ label: '°C', value: 'celsius' }
];
const windUnits: { label: string; value: 'mph' | 'kph' }[] = [
{ label: 'mph', value: 'mph' },
{ label: 'kph', value: 'kph' }
];
const pressureUnits: { label: string; value: 'inHg' | 'hPa' }[] = [
{ label: 'inHg', value: 'inHg' },
{ label: 'hPa', value: 'hPa' }
];
</script>
<div class="panel">
@@ -96,7 +106,8 @@
</span>
</div>
<div class="pill-row" style="margin-top: 10px;">
<div class="field-label">Temperature</div>
<div class="pill-row">
{#each units as unit}
<button
class="pill"
@@ -111,10 +122,42 @@
{/each}
</div>
<div class="field-label">Wind speed</div>
<div class="pill-row">
{#each windUnits as unit}
<button
class="pill"
class:active={weather.windUnit === unit.value}
onclick={() => {
weather.windUnit = unit.value;
scheduleSave();
}}
>
{unit.label}
</button>
{/each}
</div>
<div class="field-label">Pressure</div>
<div class="pill-row">
{#each pressureUnits as unit}
<button
class="pill"
class:active={weather.pressureUnit === unit.value}
onclick={() => {
weather.pressureUnit = unit.value;
scheduleSave();
}}
>
{unit.label}
</button>
{/each}
</div>
<p class="hint" style="margin-top: 14px; margin-bottom: 0;">
{#if weather.current}
Currently showing: {Math.round(weather.current.temp)}° · {weather.current.conditionText}
(updated {timeAgo(weather.updatedAt ?? '')})
Currently showing: {Math.round(weather.current.temp)}° (feels like {Math.round(weather.current.feelsLike)}°) ·
{weather.current.conditionText} (updated {timeAgo(weather.updatedAt ?? '')})
{:else}
Not showing any data yet — configure a location above, it polls immediately.
{/if}
@@ -176,6 +219,11 @@
font-size: 12px;
color: var(--text-muted);
}
.field-label {
font-size: 11px;
color: var(--text-muted);
margin: 12px 0 6px;
}
.pill-row {
display: flex;
gap: 8px;
@@ -5,13 +5,14 @@
</script>
<a class="widget" href="/weather">
<span class="title">Weather</span>
<span class="title">Weather{weather.locationName ? ` - ${weather.locationName}` : ''}</span>
{#if weather.current}
<div class="body">
<span class="icon">{weather.current.icon}</span>
<div class="readout">
<span class="temp">{Math.round(weather.current.temp)}°{weather.unit === 'celsius' ? 'C' : 'F'}</span>
<span class="condition">{weather.current.conditionText}</span>
<span class="feels-like">Feels like {Math.round(weather.current.feelsLike)}°</span>
</div>
</div>
{:else}
@@ -32,9 +33,13 @@
background: var(--surface-2);
}
.title {
display: block;
font-size: 12px;
font-weight: 500;
color: var(--text-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.body {
display: flex;
@@ -58,6 +63,10 @@
font-size: 12px;
color: var(--text-secondary);
}
.feels-like {
font-size: 11px;
color: var(--text-muted);
}
.empty {
font-size: 12px;
color: var(--text-muted);
+26 -1
View File
@@ -107,12 +107,37 @@ export interface WeatherDayEntry {
icon: string;
}
export interface WeatherCurrentConditions {
temp: number;
feelsLike: number;
conditionText: string;
icon: string;
humidity: number;
precipitationChance: number;
windSpeed: number;
windDirection: string;
pressure: number;
sunrise: string;
sunset: string;
}
export interface WeatherAlert {
id: string;
event: string;
headline: string;
severity: string;
expires: string;
}
export interface Weather {
locationName: string | null;
unit: 'celsius' | 'fahrenheit';
current: { temp: number; conditionText: string; icon: string } | null;
windUnit: 'mph' | 'kph';
pressureUnit: 'inHg' | 'hPa';
current: WeatherCurrentConditions | null;
hourly: WeatherHourEntry[];
daily: WeatherDayEntry[];
alerts: WeatherAlert[];
updatedAt: string | null;
}
+126 -5
View File
@@ -20,12 +20,59 @@
<div class="current">
<span class="icon">{weather.current.icon}</span>
<div class="readout">
<div class="temp-row">
<span class="temp">{Math.round(weather.current.temp)}°{unitLabel}</span>
<span class="feels-like">Feels like {Math.round(weather.current.feelsLike)}°</span>
</div>
<span class="condition">{weather.current.conditionText}</span>
<span class="updated">Updated {timeAgo(weather.updatedAt ?? '')}</span>
</div>
</div>
<div class="conditions-grid">
<div class="stat">
<span class="stat-label">Humidity</span>
<span class="stat-value">{weather.current.humidity}%</span>
</div>
<div class="stat">
<span class="stat-label">Precip. chance</span>
<span class="stat-value">{weather.current.precipitationChance}%</span>
</div>
<div class="stat">
<span class="stat-label">Wind</span>
<span class="stat-value">{weather.current.windDirection} {Math.round(weather.current.windSpeed)} {weather.windUnit}</span>
</div>
<div class="stat">
<span class="stat-label">Pressure</span>
<span class="stat-value">{weather.current.pressure} {weather.pressureUnit}</span>
</div>
<div class="stat">
<span class="stat-label">Sunrise</span>
<span class="stat-value">{new Date(weather.current.sunrise).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })}</span>
</div>
<div class="stat">
<span class="stat-label">Sunset</span>
<span class="stat-value">{new Date(weather.current.sunset).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })}</span>
</div>
</div>
{#if weather.alerts.length > 0}
<div class="section">
<span class="section-title">Weather alerts</span>
<div class="alerts-list">
{#each weather.alerts as alert (alert.id)}
<div class="alert-row severity-{alert.severity.toLowerCase()}">
<div class="alert-head">
<span class="alert-event">{alert.event}</span>
<span class="alert-expires">Until {new Date(alert.expires).toLocaleString([], { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' })}</span>
</div>
<p class="alert-headline">{alert.headline}</p>
</div>
{/each}
</div>
</div>
{/if}
<div class="section">
<span class="section-title">Hourly</span>
<div class="hourly-strip">
@@ -88,10 +135,19 @@
display: flex;
flex-direction: column;
}
.temp-row {
display: flex;
align-items: baseline;
gap: 10px;
}
.temp {
font-size: 40px;
font-weight: 500;
}
.feels-like {
font-size: 13px;
color: var(--text-muted);
}
.condition {
font-size: 15px;
color: var(--text-secondary);
@@ -101,6 +157,29 @@
color: var(--text-muted);
margin-top: 4px;
}
.conditions-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(110px, 1fr));
gap: 16px;
max-width: 640px;
margin-bottom: 28px;
padding: 16px;
background: var(--surface-1);
border-radius: 12px;
}
.stat {
display: flex;
flex-direction: column;
gap: 2px;
}
.stat-label {
font-size: 11px;
color: var(--text-muted);
}
.stat-value {
font-size: 15px;
font-weight: 500;
}
.section {
margin-bottom: 28px;
}
@@ -110,18 +189,55 @@
font-weight: 500;
margin-bottom: 12px;
}
.hourly-strip {
.alerts-list {
display: flex;
gap: 18px;
overflow-x: auto;
padding-bottom: 6px;
flex-direction: column;
gap: 10px;
max-width: 640px;
}
.alert-row {
border-left: 3px solid var(--text-muted);
background: var(--surface-1);
border-radius: 0 var(--radius) var(--radius) 0;
padding: 10px 14px;
}
.alert-row.severity-extreme,
.alert-row.severity-severe {
border-left-color: var(--text-danger);
}
.alert-row.severity-moderate {
border-left-color: var(--border-accent);
}
.alert-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 10px;
}
.alert-event {
font-size: 13px;
font-weight: 500;
}
.alert-expires {
font-size: 11px;
color: var(--text-muted);
white-space: nowrap;
}
.alert-headline {
font-size: 12px;
color: var(--text-secondary);
margin: 4px 0 0;
}
.hourly-strip {
display: grid;
grid-template-columns: repeat(12, 1fr);
gap: 14px 8px;
}
.hour-col {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
flex-shrink: 0;
}
.hour-time {
font-size: 11px;
@@ -133,6 +249,11 @@
.hour-temp {
font-size: 13px;
}
@media (max-width: 640px) {
.hourly-strip {
grid-template-columns: repeat(6, 1fr);
}
}
.daily-list {
display: flex;
flex-direction: column;