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_latitude REAL,
|
||||||
weather_longitude REAL,
|
weather_longitude REAL,
|
||||||
weather_unit TEXT NOT NULL DEFAULT 'fahrenheit', -- celsius | fahrenheit
|
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_hourly TEXT NOT NULL DEFAULT '[]', -- JSON array
|
||||||
weather_daily 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
|
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_daily TEXT NOT NULL DEFAULT '[]'");
|
||||||
db.exec('ALTER TABLE global_settings ADD COLUMN weather_updated_at TEXT');
|
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
|
// 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.
|
// 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,
|
latitude: row.weather_latitude,
|
||||||
longitude: row.weather_longitude,
|
longitude: row.weather_longitude,
|
||||||
unit: row.weather_unit,
|
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.
|
// 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,
|
current: row.weather_current ? JSON.parse(row.weather_current) : null,
|
||||||
hourly: JSON.parse(row.weather_hourly),
|
hourly: JSON.parse(row.weather_hourly),
|
||||||
daily: JSON.parse(row.weather_daily),
|
daily: JSON.parse(row.weather_daily),
|
||||||
|
alerts: JSON.parse(row.weather_alerts),
|
||||||
updatedAt: row.weather_updated_at
|
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=?,
|
published_article_max_age_days=?, raw_item_max_age_days=?,
|
||||||
storage_cap_enabled=?, storage_cap_value=?, storage_cap_unit=?,
|
storage_cap_enabled=?, storage_cap_value=?, storage_cap_unit=?,
|
||||||
weather_location_name=?, weather_latitude=?, weather_longitude=?, weather_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`
|
WHERE id = 1`
|
||||||
).run(
|
).run(
|
||||||
merged.mergeStrictness,
|
merged.mergeStrictness,
|
||||||
@@ -85,9 +89,12 @@ export function updateSettings(patch: Partial<GlobalSettings>): GlobalSettings {
|
|||||||
merged.weather.latitude,
|
merged.weather.latitude,
|
||||||
merged.weather.longitude,
|
merged.weather.longitude,
|
||||||
merged.weather.unit,
|
merged.weather.unit,
|
||||||
|
merged.weather.windUnit,
|
||||||
|
merged.weather.pressureUnit,
|
||||||
merged.weather.current ? JSON.stringify(merged.weather.current) : null,
|
merged.weather.current ? JSON.stringify(merged.weather.current) : null,
|
||||||
JSON.stringify(merged.weather.hourly),
|
JSON.stringify(merged.weather.hourly),
|
||||||
JSON.stringify(merged.weather.daily),
|
JSON.stringify(merged.weather.daily),
|
||||||
|
JSON.stringify(merged.weather.alerts),
|
||||||
merged.weather.updatedAt
|
merged.weather.updatedAt
|
||||||
);
|
);
|
||||||
return getSettings();
|
return getSettings();
|
||||||
|
|||||||
@@ -272,9 +272,39 @@ export interface GlobalSettings {
|
|||||||
latitude: number | null;
|
latitude: number | null;
|
||||||
longitude: number | null;
|
longitude: number | null;
|
||||||
unit: 'celsius' | 'fahrenheit';
|
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[];
|
hourly: WeatherHourEntry[];
|
||||||
daily: WeatherDayEntry[];
|
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;
|
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: '❔' };
|
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[]> {
|
export async function geocodeLocation(query: string): Promise<GeocodeResult[]> {
|
||||||
const url = `https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(query)}&count=8`;
|
const url = `https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(query)}&count=8`;
|
||||||
const res = await fetch(url);
|
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 {
|
export interface ForecastResult {
|
||||||
current: { temp: number; conditionText: string; icon: string };
|
current: CurrentConditions;
|
||||||
hourly: { time: string; temp: number; conditionText: string; icon: string }[];
|
hourly: { time: string; temp: number; conditionText: string; icon: string }[];
|
||||||
daily: { date: string; tempMax: number; tempMin: 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(
|
export async function fetchForecast(
|
||||||
latitude: number,
|
latitude: number,
|
||||||
longitude: number,
|
longitude: number,
|
||||||
unit: 'celsius' | 'fahrenheit'
|
unit: 'celsius' | 'fahrenheit',
|
||||||
|
windUnit: 'mph' | 'kph',
|
||||||
|
pressureUnit: 'inHg' | 'hPa'
|
||||||
): Promise<ForecastResult> {
|
): Promise<ForecastResult> {
|
||||||
const url =
|
const url =
|
||||||
`https://api.open-meteo.com/v1/forecast?latitude=${latitude}&longitude=${longitude}` +
|
`https://api.open-meteo.com/v1/forecast?latitude=${latitude}&longitude=${longitude}` +
|
||||||
`¤t=temperature_2m,weather_code&hourly=temperature_2m,weather_code` +
|
`¤t=temperature_2m,apparent_temperature,weather_code,relative_humidity_2m,wind_speed_10m,wind_direction_10m,pressure_msl` +
|
||||||
`&daily=temperature_2m_max,temperature_2m_min,weather_code` +
|
`&hourly=temperature_2m,weather_code,precipitation_probability` +
|
||||||
`&temperature_unit=${unit}&timezone=auto&forecast_days=7`;
|
`&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);
|
const res = await fetch(url);
|
||||||
if (!res.ok) throw new Error(`Forecast API returned ${res.status}`);
|
if (!res.ok) throw new Error(`Forecast API returned ${res.status}`);
|
||||||
const data = (await res.json()) as {
|
const data = (await res.json()) as {
|
||||||
current: { temperature_2m: number; weather_code: number };
|
current: {
|
||||||
hourly: { time: string[]; temperature_2m: number[]; weather_code: number[] };
|
temperature_2m: number;
|
||||||
daily: { time: string[]; temperature_2m_max: number[]; temperature_2m_min: number[]; weather_code: 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
|
// 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 now = Date.now();
|
||||||
const startIdx = Math.max(
|
const startIdx = Math.max(
|
||||||
0,
|
0,
|
||||||
data.hourly.time.findIndex((t) => new Date(t).getTime() >= now)
|
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 hourly = data.hourly.time.slice(startIdx, startIdx + 24).map((time, i) => {
|
||||||
const idx = startIdx + i;
|
const idx = startIdx + i;
|
||||||
const condition = wmoToCondition(data.hourly.weather_code[idx]);
|
const condition = wmoToCondition(data.hourly.weather_code[idx]);
|
||||||
@@ -122,3 +180,36 @@ export async function fetchForecast(
|
|||||||
|
|
||||||
return { current, hourly, daily };
|
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 * as settingsDb from '../storage/db/settings.js';
|
||||||
import { logger } from '../storage/db/logs.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
|
// 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.
|
// weather_* columns via settingsDb, same singleton-row approach as retention.
|
||||||
export async function pollWeatherNow(): Promise<void> {
|
export async function pollWeatherNow(): Promise<void> {
|
||||||
const { weather } = settingsDb.getSettings();
|
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.
|
// No location configured yet — not an error, just nothing to do.
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let forecastUpdate: Partial<typeof weather> = {};
|
||||||
|
let forecastSucceeded = false;
|
||||||
try {
|
try {
|
||||||
const { current, hourly, daily } = await fetchForecast(weather.latitude, weather.longitude, weather.unit);
|
const { current, hourly, daily } = await fetchForecast(
|
||||||
settingsDb.updateSettings({
|
weather.latitude,
|
||||||
weather: { ...weather, current, hourly, daily, updatedAt: new Date().toISOString() }
|
weather.longitude,
|
||||||
});
|
weather.unit,
|
||||||
|
weather.windUnit,
|
||||||
|
weather.pressureUnit
|
||||||
|
);
|
||||||
|
forecastUpdate = { current, hourly, daily };
|
||||||
|
forecastSucceeded = true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Leave the existing cache untouched — a stale forecast beats a blank widget.
|
// 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
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,14 +31,39 @@ export interface WeatherDayEntry {
|
|||||||
icon: string;
|
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 {
|
export interface AdminWeatherSettings {
|
||||||
locationName: string | null;
|
locationName: string | null;
|
||||||
latitude: number | null;
|
latitude: number | null;
|
||||||
longitude: number | null;
|
longitude: number | null;
|
||||||
unit: 'celsius' | 'fahrenheit';
|
unit: 'celsius' | 'fahrenheit';
|
||||||
current: { temp: number; conditionText: string; icon: string } | null;
|
windUnit: 'mph' | 'kph';
|
||||||
|
pressureUnit: 'inHg' | 'hPa';
|
||||||
|
current: WeatherCurrentConditions | null;
|
||||||
hourly: WeatherHourEntry[];
|
hourly: WeatherHourEntry[];
|
||||||
daily: WeatherDayEntry[];
|
daily: WeatherDayEntry[];
|
||||||
|
alerts: WeatherAlert[];
|
||||||
updatedAt: string | null;
|
updatedAt: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -56,6 +56,16 @@
|
|||||||
{ label: '°F', value: 'fahrenheit' },
|
{ label: '°F', value: 'fahrenheit' },
|
||||||
{ label: '°C', value: 'celsius' }
|
{ 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>
|
</script>
|
||||||
|
|
||||||
<div class="panel">
|
<div class="panel">
|
||||||
@@ -96,7 +106,8 @@
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="pill-row" style="margin-top: 10px;">
|
<div class="field-label">Temperature</div>
|
||||||
|
<div class="pill-row">
|
||||||
{#each units as unit}
|
{#each units as unit}
|
||||||
<button
|
<button
|
||||||
class="pill"
|
class="pill"
|
||||||
@@ -111,10 +122,42 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</div>
|
</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;">
|
<p class="hint" style="margin-top: 14px; margin-bottom: 0;">
|
||||||
{#if weather.current}
|
{#if weather.current}
|
||||||
Currently showing: {Math.round(weather.current.temp)}° · {weather.current.conditionText}
|
Currently showing: {Math.round(weather.current.temp)}° (feels like {Math.round(weather.current.feelsLike)}°) ·
|
||||||
(updated {timeAgo(weather.updatedAt ?? '')})
|
{weather.current.conditionText} (updated {timeAgo(weather.updatedAt ?? '')})
|
||||||
{:else}
|
{:else}
|
||||||
Not showing any data yet — configure a location above, it polls immediately.
|
Not showing any data yet — configure a location above, it polls immediately.
|
||||||
{/if}
|
{/if}
|
||||||
@@ -176,6 +219,11 @@
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
.field-label {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin: 12px 0 6px;
|
||||||
|
}
|
||||||
.pill-row {
|
.pill-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
|||||||
@@ -5,13 +5,14 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<a class="widget" href="/weather">
|
<a class="widget" href="/weather">
|
||||||
<span class="title">Weather</span>
|
<span class="title">Weather{weather.locationName ? ` - ${weather.locationName}` : ''}</span>
|
||||||
{#if weather.current}
|
{#if weather.current}
|
||||||
<div class="body">
|
<div class="body">
|
||||||
<span class="icon">{weather.current.icon}</span>
|
<span class="icon">{weather.current.icon}</span>
|
||||||
<div class="readout">
|
<div class="readout">
|
||||||
<span class="temp">{Math.round(weather.current.temp)}°{weather.unit === 'celsius' ? 'C' : 'F'}</span>
|
<span class="temp">{Math.round(weather.current.temp)}°{weather.unit === 'celsius' ? 'C' : 'F'}</span>
|
||||||
<span class="condition">{weather.current.conditionText}</span>
|
<span class="condition">{weather.current.conditionText}</span>
|
||||||
|
<span class="feels-like">Feels like {Math.round(weather.current.feelsLike)}°</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
@@ -32,9 +33,13 @@
|
|||||||
background: var(--surface-2);
|
background: var(--surface-2);
|
||||||
}
|
}
|
||||||
.title {
|
.title {
|
||||||
|
display: block;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.body {
|
.body {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -58,6 +63,10 @@
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
.feels-like {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
.empty {
|
.empty {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
|
|||||||
@@ -107,12 +107,37 @@ export interface WeatherDayEntry {
|
|||||||
icon: string;
|
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 {
|
export interface Weather {
|
||||||
locationName: string | null;
|
locationName: string | null;
|
||||||
unit: 'celsius' | 'fahrenheit';
|
unit: 'celsius' | 'fahrenheit';
|
||||||
current: { temp: number; conditionText: string; icon: string } | null;
|
windUnit: 'mph' | 'kph';
|
||||||
|
pressureUnit: 'inHg' | 'hPa';
|
||||||
|
current: WeatherCurrentConditions | null;
|
||||||
hourly: WeatherHourEntry[];
|
hourly: WeatherHourEntry[];
|
||||||
daily: WeatherDayEntry[];
|
daily: WeatherDayEntry[];
|
||||||
|
alerts: WeatherAlert[];
|
||||||
updatedAt: string | null;
|
updatedAt: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,12 +20,59 @@
|
|||||||
<div class="current">
|
<div class="current">
|
||||||
<span class="icon">{weather.current.icon}</span>
|
<span class="icon">{weather.current.icon}</span>
|
||||||
<div class="readout">
|
<div class="readout">
|
||||||
|
<div class="temp-row">
|
||||||
<span class="temp">{Math.round(weather.current.temp)}°{unitLabel}</span>
|
<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="condition">{weather.current.conditionText}</span>
|
||||||
<span class="updated">Updated {timeAgo(weather.updatedAt ?? '')}</span>
|
<span class="updated">Updated {timeAgo(weather.updatedAt ?? '')}</span>
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<div class="section">
|
||||||
<span class="section-title">Hourly</span>
|
<span class="section-title">Hourly</span>
|
||||||
<div class="hourly-strip">
|
<div class="hourly-strip">
|
||||||
@@ -88,10 +135,19 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
.temp-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
.temp {
|
.temp {
|
||||||
font-size: 40px;
|
font-size: 40px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
.feels-like {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
.condition {
|
.condition {
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
@@ -101,6 +157,29 @@
|
|||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
margin-top: 4px;
|
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 {
|
.section {
|
||||||
margin-bottom: 28px;
|
margin-bottom: 28px;
|
||||||
}
|
}
|
||||||
@@ -110,18 +189,55 @@
|
|||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
}
|
}
|
||||||
.hourly-strip {
|
.alerts-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 18px;
|
flex-direction: column;
|
||||||
overflow-x: auto;
|
gap: 10px;
|
||||||
padding-bottom: 6px;
|
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 {
|
.hour-col {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
}
|
||||||
.hour-time {
|
.hour-time {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
@@ -133,6 +249,11 @@
|
|||||||
.hour-temp {
|
.hour-temp {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.hourly-strip {
|
||||||
|
grid-template-columns: repeat(6, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
.daily-list {
|
.daily-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
Reference in New Issue
Block a user