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
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user