feat(weather): add hourly precipitation summaries and peak timing to weather card

Fetch hourly precipitation probabilities from Open-Meteo alongside daily
forecasts. Generate human-readable precip summaries ("Rain likely 2–5 PM",
"Rain likely all day") for today and each forecast day. Display today's
summary as a styled callout and show peak precipitation hour in forecast rows.
Also fix briefing pipeline to parse all weather location rows (not just first).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-17 16:05:26 -04:00
parent 443b11f287
commit ddab0db781
4 changed files with 188 additions and 19 deletions
+42 -3
View File
@@ -9,6 +9,8 @@ interface ForecastDay {
precip_probability: number | null precip_probability: number | null
precip_mm: number | null precip_mm: number | null
windspeed_max: number windspeed_max: number
precip_summary?: string
precip_peak_hour?: string
} }
interface WeatherData { interface WeatherData {
@@ -21,6 +23,7 @@ interface WeatherData {
yesterday_high: number | null yesterday_high: number | null
yesterday_low: number | null yesterday_low: number | null
wind_unit?: string wind_unit?: string
precip_summary?: string | null
forecast: ForecastDay[] forecast: ForecastDay[]
} }
@@ -68,6 +71,11 @@ const fetchedAtLabel = computed(() => {
return '' return ''
} }
}) })
function hasPrecip(day: ForecastDay): boolean {
return (day.precip_probability != null && day.precip_probability > 0) ||
(day.precip_mm != null && day.precip_mm > 0)
}
</script> </script>
<template> <template>
@@ -85,6 +93,9 @@ const fetchedAtLabel = computed(() => {
Today: {{ weather.today_high }}° / {{ weather.today_low }}° Today: {{ weather.today_high }}° / {{ weather.today_low }}°
<span v-if="tempDelta" class="weather-delta"> · {{ tempDelta }}</span> <span v-if="tempDelta" class="weather-delta"> · {{ tempDelta }}</span>
</div> </div>
<div v-if="weather.precip_summary" class="weather-precip-summary">
💧 {{ weather.precip_summary }}
</div>
<table class="weather-forecast" v-if="weather.forecast.length"> <table class="weather-forecast" v-if="weather.forecast.length">
<thead> <thead>
<tr> <tr>
@@ -100,8 +111,14 @@ const fetchedAtLabel = computed(() => {
<td class="forecast-day-name">{{ day.day }}</td> <td class="forecast-day-name">{{ day.day }}</td>
<td class="forecast-icon">{{ weatherIcon(day.condition) }}</td> <td class="forecast-icon">{{ weatherIcon(day.condition) }}</td>
<td class="forecast-temps">{{ day.high }}° / {{ day.low }}°</td> <td class="forecast-temps">{{ day.high }}° / {{ day.low }}°</td>
<td class="forecast-precip" :class="{ 'forecast-precip--dry': !(day.precip_probability != null && day.precip_probability > 0) && !(day.precip_mm != null && day.precip_mm > 0) }"> <td class="forecast-precip" :class="{ 'forecast-precip--dry': !hasPrecip(day) }">
<template v-if="day.precip_probability != null && day.precip_probability > 0">{{ day.precip_probability }}%</template> <template v-if="day.precip_summary">
<span class="precip-detail" :title="day.precip_summary">
{{ day.precip_probability }}%
<span v-if="day.precip_peak_hour" class="precip-peak">{{ day.precip_peak_hour }}</span>
</span>
</template>
<template v-else-if="day.precip_probability != null && day.precip_probability > 0">{{ day.precip_probability }}%</template>
<template v-else-if="day.precip_mm != null && day.precip_mm > 0">{{ day.precip_mm.toFixed(1) }}mm</template> <template v-else-if="day.precip_mm != null && day.precip_mm > 0">{{ day.precip_mm.toFixed(1) }}mm</template>
<template v-else>&mdash;</template> <template v-else>&mdash;</template>
</td> </td>
@@ -168,7 +185,7 @@ const fetchedAtLabel = computed(() => {
.weather-today { .weather-today {
color: var(--color-text-secondary); color: var(--color-text-secondary);
margin-bottom: 0.75rem; margin-bottom: 0.25rem;
font-size: 0.85rem; font-size: 0.85rem;
} }
@@ -177,6 +194,16 @@ const fetchedAtLabel = computed(() => {
font-size: 0.82rem; font-size: 0.82rem;
} }
.weather-precip-summary {
color: var(--color-text-secondary);
font-size: 0.83rem;
margin-bottom: 0.75rem;
padding: 0.35rem 0.5rem;
background: color-mix(in srgb, var(--color-primary) 8%, transparent);
border-radius: var(--radius-sm, 6px);
border-left: 2px solid color-mix(in srgb, var(--color-primary) 40%, transparent);
}
.weather-forecast { .weather-forecast {
width: 100%; width: 100%;
border-collapse: collapse; border-collapse: collapse;
@@ -227,6 +254,18 @@ const fetchedAtLabel = computed(() => {
opacity: 0.35; opacity: 0.35;
} }
.precip-detail {
display: inline-flex;
align-items: baseline;
gap: 0.3em;
}
.precip-peak {
font-size: 0.7rem;
color: var(--color-text-muted);
opacity: 0.8;
}
.forecast-wind { .forecast-wind {
text-align: right; text-align: right;
color: var(--color-text-muted); color: var(--color-text-muted);
@@ -379,7 +379,11 @@ async def run_compilation(
if item.get("id") if item.get("id")
] ]
weather_card = parse_weather_card_data(weather_rows[0], temp_unit) if weather_rows else None weather_cards = [
card for row in weather_rows
if (card := parse_weather_card_data(row, temp_unit)) is not None
]
weather_card = weather_cards[0] if weather_cards else None
briefing_text, agentic_messages = await run_agentic_briefing( briefing_text, agentic_messages = await run_agentic_briefing(
user_id, slot, model, conv_id=None, rss_override=filtered_rss, user_id, slot, model, conv_id=None, rss_override=filtered_rss,
+78 -13
View File
@@ -17,6 +17,7 @@ OPEN_METEO_DAILY = (
"temperature_2m_max,temperature_2m_min,precipitation_sum," "temperature_2m_max,temperature_2m_min,precipitation_sum,"
"precipitation_probability_max,weathercode,windspeed_10m_max" "precipitation_probability_max,weathercode,windspeed_10m_max"
) )
OPEN_METEO_HOURLY = "precipitation_probability"
# WMO weather code → description (subset; covers the most common codes) # WMO weather code → description (subset; covers the most common codes)
_WMO_CODES: dict[int, str] = { _WMO_CODES: dict[int, str] = {
@@ -93,6 +94,55 @@ def detect_changes(old_days: list[dict], new_days: list[dict]) -> list[str]:
return changes return changes
def _summarize_precip(hourly_probs: list[tuple[int, int]], threshold: int = 30) -> str | None:
"""Build a human-readable precipitation summary from (hour, probability) pairs.
Returns None when no significant precipitation is expected.
"""
wet_hours = [(h, p) for h, p in hourly_probs if p >= threshold]
if not wet_hours:
return None
peak_hour, peak_prob = max(wet_hours, key=lambda x: x[1])
daytime_hours = [h for h, _ in hourly_probs if 6 <= h <= 22]
if not daytime_hours:
return None
wet_daytime = [h for h, p in hourly_probs if 6 <= h <= 22 and p >= threshold]
if len(wet_daytime) >= 10:
return f"Rain likely all day (up to {peak_prob}%)"
if not wet_daytime:
return None
def _fmt_hour(h: int) -> str:
if h == 0 or h == 24:
return "12 AM"
if h == 12:
return "12 PM"
return f"{h} AM" if h < 12 else f"{h - 12} PM"
start = wet_daytime[0]
end = wet_daytime[-1]
if start == end:
return f"{peak_prob}% chance around {_fmt_hour(start)}"
return f"Rain likely {_fmt_hour(start)}{_fmt_hour(end + 1)} (up to {peak_prob}%)"
def _extract_hourly_precip_for_date(raw: dict, date_str: str) -> list[tuple[int, int]]:
"""Extract (hour, probability) pairs for a specific date from cached forecast JSON."""
hourly = raw.get("hourly", {})
times = hourly.get("precipitation_probability", [])
time_labels = hourly.get("time", [])
pairs: list[tuple[int, int]] = []
prefix = date_str + "T"
for i, t in enumerate(time_labels):
if t.startswith(prefix) and i < len(times) and times[i] is not None:
hour = int(t[11:13])
pairs.append((hour, times[i]))
return pairs
def parse_weather_card_data( def parse_weather_card_data(
cache_row, cache_row,
temp_unit: str = "C", temp_unit: str = "C",
@@ -138,6 +188,30 @@ def parse_weather_card_data(
wind_unit = "mph" if imperial else "km/h" wind_unit = "mph" if imperial else "km/h"
today_hourly = _extract_hourly_precip_for_date(raw, today_str)
today_precip_summary = _summarize_precip(today_hourly)
def _forecast_day(d: dict) -> dict:
entry: dict = {
"day": day_label(d["date"]),
"condition": d["description"],
"high": to_temp(d["temp_max"]),
"low": to_temp(d["temp_min"]),
"precip_probability": d["precip_probability"],
"precip_mm": d["precip_mm"],
"windspeed_max": to_wind(d["windspeed_max"]),
}
hourly = _extract_hourly_precip_for_date(raw, d["date"])
summary = _summarize_precip(hourly)
if summary:
entry["precip_summary"] = summary
if hourly:
peak = max(hourly, key=lambda x: x[1])
if peak[1] >= 30:
h = peak[0]
entry["precip_peak_hour"] = f"{h} AM" if h < 12 else ("12 PM" if h == 12 else f"{h - 12} PM")
return entry
return { return {
"location": getattr(cache_row, "location_label", ""), "location": getattr(cache_row, "location_label", ""),
"fetched_at": cache_row.fetched_at.isoformat(), "fetched_at": cache_row.fetched_at.isoformat(),
@@ -148,18 +222,8 @@ def parse_weather_card_data(
"yesterday_high": to_temp(yesterday_day["temp_max"]) if yesterday_day else None, "yesterday_high": to_temp(yesterday_day["temp_max"]) if yesterday_day else None,
"yesterday_low": to_temp(yesterday_day["temp_min"]) if yesterday_day else None, "yesterday_low": to_temp(yesterday_day["temp_min"]) if yesterday_day else None,
"wind_unit": wind_unit, "wind_unit": wind_unit,
"forecast": [ "precip_summary": today_precip_summary,
{ "forecast": [_forecast_day(d) for d in future_days],
"day": day_label(d["date"]),
"condition": d["description"],
"high": to_temp(d["temp_max"]),
"low": to_temp(d["temp_min"]),
"precip_probability": d["precip_probability"],
"precip_mm": d["precip_mm"],
"windspeed_max": to_wind(d["windspeed_max"]),
}
for d in future_days
],
} }
@@ -259,12 +323,13 @@ async def fetch_hourly_precip(lat: float, lon: float) -> dict[str, int]:
async def _fetch_open_meteo(lat: float, lon: float) -> dict: async def _fetch_open_meteo(lat: float, lon: float) -> dict:
"""Fetch 7-day forecast from Open-Meteo with current conditions and yesterday's data.""" """Fetch 7-day forecast from Open-Meteo with current conditions, hourly precip, and yesterday's data."""
async with httpx.AsyncClient(timeout=15.0) as client: async with httpx.AsyncClient(timeout=15.0) as client:
resp = await client.get(OPEN_METEO_URL, params={ resp = await client.get(OPEN_METEO_URL, params={
"latitude": lat, "latitude": lat,
"longitude": lon, "longitude": lon,
"daily": OPEN_METEO_DAILY, "daily": OPEN_METEO_DAILY,
"hourly": OPEN_METEO_HOURLY,
"current_weather": "true", "current_weather": "true",
"past_days": 1, "past_days": 1,
"timezone": "auto", "timezone": "auto",
+63 -2
View File
@@ -59,7 +59,6 @@ def test_detect_forecast_changes_rain_added():
def test_parse_weather_card_data_returns_none_when_stale(): def test_parse_weather_card_data_returns_none_when_stale():
"""Should return None when cache is older than 24 hours.""" """Should return None when cache is older than 24 hours."""
from datetime import datetime, timezone, timedelta from datetime import datetime, timezone, timedelta
from unittest.mock import MagicMock
from fabledassistant.services.weather import parse_weather_card_data from fabledassistant.services.weather import parse_weather_card_data
cache = MagicMock() cache = MagicMock()
@@ -71,7 +70,6 @@ def test_parse_weather_card_data_returns_none_when_stale():
def test_parse_weather_card_data_returns_card_schema(): def test_parse_weather_card_data_returns_card_schema():
"""Should return the correct metadata.weather schema for fresh cache.""" """Should return the correct metadata.weather schema for fresh cache."""
from datetime import datetime, timezone, timedelta, date from datetime import datetime, timezone, timedelta, date
from unittest.mock import MagicMock
from fabledassistant.services.weather import parse_weather_card_data from fabledassistant.services.weather import parse_weather_card_data
today = date.today().isoformat() today = date.today().isoformat()
@@ -103,3 +101,66 @@ def test_parse_weather_card_data_returns_card_schema():
assert card["yesterday_low"] == 9 assert card["yesterday_low"] == 9
assert len(card["forecast"]) >= 1 assert len(card["forecast"]) >= 1
assert card["location"] == "Berlin, DE" assert card["location"] == "Berlin, DE"
assert card["precip_summary"] is None
def test_summarize_precip_dry():
from fabledassistant.services.weather import _summarize_precip
assert _summarize_precip([(h, 10) for h in range(24)]) is None
def test_summarize_precip_all_day():
from fabledassistant.services.weather import _summarize_precip
result = _summarize_precip([(h, 60) for h in range(24)])
assert result is not None
assert "all day" in result.lower()
def test_summarize_precip_window():
from fabledassistant.services.weather import _summarize_precip
hourly = [(h, 5) for h in range(24)]
for h in range(14, 18):
hourly[h] = (h, 65)
result = _summarize_precip(hourly)
assert result is not None
assert "2 PM" in result
assert "65%" in result
def test_parse_weather_card_includes_hourly_precip_summary():
from datetime import datetime, timezone, timedelta, date
from fabledassistant.services.weather import parse_weather_card_data
today = date.today().isoformat()
yesterday = (date.today() - timedelta(days=1)).isoformat()
tomorrow = (date.today() + timedelta(days=1)).isoformat()
hourly_times = [f"{today}T{h:02d}:00" for h in range(24)]
hourly_probs = [5] * 24
for h in range(14, 18):
hourly_probs[h] = 70
cache = MagicMock()
cache.fetched_at = datetime.now(timezone.utc)
cache.location_label = "Berlin, DE"
cache.forecast_json = {
"current_weather": {"temperature": 12.0, "weathercode": 61},
"daily": {
"time": [yesterday, today, tomorrow],
"temperature_2m_max": [14.0, 16.0, 18.0],
"temperature_2m_min": [9.0, 8.0, 10.0],
"precipitation_sum": [0.0, 5.0, 1.5],
"precipitation_probability_max": [0, 70, 30],
"weathercode": [1, 61, 61],
"windspeed_10m_max": [10.0, 12.0, 8.0],
},
"hourly": {
"time": hourly_times,
"precipitation_probability": hourly_probs,
},
}
card = parse_weather_card_data(cache)
assert card is not None
assert card["precip_summary"] is not None
assert "2 PM" in card["precip_summary"]