ddab0db781
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>
279 lines
7.0 KiB
Vue
279 lines
7.0 KiB
Vue
<script setup lang="ts">
|
|
import { computed } from 'vue'
|
|
|
|
interface ForecastDay {
|
|
day: string
|
|
condition: string
|
|
high: number
|
|
low: number
|
|
precip_probability: number | null
|
|
precip_mm: number | null
|
|
windspeed_max: number
|
|
precip_summary?: string
|
|
precip_peak_hour?: string
|
|
}
|
|
|
|
interface WeatherData {
|
|
location: string
|
|
fetched_at: string
|
|
current_temp: number
|
|
condition: string
|
|
today_high: number | null
|
|
today_low: number | null
|
|
yesterday_high: number | null
|
|
yesterday_low: number | null
|
|
wind_unit?: string
|
|
precip_summary?: string | null
|
|
forecast: ForecastDay[]
|
|
}
|
|
|
|
const props = defineProps<{
|
|
weather: WeatherData | null
|
|
tempUnit?: string
|
|
}>()
|
|
|
|
function weatherIcon(condition: string): string {
|
|
const c = condition.toLowerCase()
|
|
if (c.includes('thunderstorm')) return '⛈️'
|
|
if (c.includes('hail')) return '🌨️'
|
|
if (c.includes('snow showers')) return '🌨️'
|
|
if (c.includes('snow')) return '❄️'
|
|
if (c.includes('rain showers: violent')) return '⛈️'
|
|
if (c.includes('rain showers')) return '🌦️'
|
|
if (c.includes('drizzle') || c.includes('rain')) return '🌧️'
|
|
if (c.includes('fog')) return '🌫️'
|
|
if (c.includes('overcast')) return '☁️'
|
|
if (c.includes('partly cloudy')) return '⛅'
|
|
if (c.includes('mainly clear')) return '🌤️'
|
|
if (c.includes('clear')) return '☀️'
|
|
return '🌡️'
|
|
}
|
|
|
|
const unit = computed(() => props.tempUnit ?? 'C')
|
|
|
|
const tempDelta = computed(() => {
|
|
const w = props.weather
|
|
if (!w || w.today_high == null || w.yesterday_high == null) return null
|
|
const diff = w.today_high - w.yesterday_high
|
|
if (Math.abs(diff) < 1) return 'Same as yesterday'
|
|
const dir = diff > 0 ? 'warmer' : 'cooler'
|
|
return `${Math.abs(diff)}° ${dir} than yesterday`
|
|
})
|
|
|
|
const fetchedAtLabel = computed(() => {
|
|
if (!props.weather?.fetched_at) return ''
|
|
try {
|
|
return new Date(props.weather.fetched_at).toLocaleTimeString([], {
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
})
|
|
} catch {
|
|
return ''
|
|
}
|
|
})
|
|
|
|
function hasPrecip(day: ForecastDay): boolean {
|
|
return (day.precip_probability != null && day.precip_probability > 0) ||
|
|
(day.precip_mm != null && day.precip_mm > 0)
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<div v-if="weather" class="weather-card">
|
|
<div class="weather-header">
|
|
<span class="weather-location">{{ weather.location }}</span>
|
|
<span class="weather-fetched-at">as of {{ fetchedAtLabel }}</span>
|
|
</div>
|
|
<div class="weather-current">
|
|
<span class="weather-icon">{{ weatherIcon(weather.condition) }}</span>
|
|
<span class="weather-temp">{{ weather.current_temp }}°{{ unit }}</span>
|
|
<span class="weather-condition">{{ weather.condition }}</span>
|
|
</div>
|
|
<div class="weather-today" v-if="weather.today_high != null">
|
|
Today: {{ weather.today_high }}° / {{ weather.today_low }}°
|
|
<span v-if="tempDelta" class="weather-delta"> · {{ tempDelta }}</span>
|
|
</div>
|
|
<div v-if="weather.precip_summary" class="weather-precip-summary">
|
|
💧 {{ weather.precip_summary }}
|
|
</div>
|
|
<table class="weather-forecast" v-if="weather.forecast.length">
|
|
<thead>
|
|
<tr>
|
|
<th></th>
|
|
<th></th>
|
|
<th>Hi / Lo</th>
|
|
<th>💧</th>
|
|
<th>💨 {{ weather.wind_unit ?? 'km/h' }}</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-for="day in weather.forecast" :key="day.day">
|
|
<td class="forecast-day-name">{{ day.day }}</td>
|
|
<td class="forecast-icon">{{ weatherIcon(day.condition) }}</td>
|
|
<td class="forecast-temps">{{ day.high }}° / {{ day.low }}°</td>
|
|
<td class="forecast-precip" :class="{ 'forecast-precip--dry': !hasPrecip(day) }">
|
|
<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>—</template>
|
|
</td>
|
|
<td class="forecast-wind">{{ day.windspeed_max }}</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<div v-else class="weather-card weather-unavailable">
|
|
Weather data unavailable — will retry at next slot.
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.weather-card {
|
|
background: color-mix(in srgb, var(--color-surface) 80%, transparent);
|
|
border: 1px solid var(--color-border);
|
|
border-radius: var(--radius-lg);
|
|
padding: 1rem 1.25rem;
|
|
margin-bottom: 1rem;
|
|
font-size: 0.9rem;
|
|
container-type: inline-size;
|
|
}
|
|
|
|
.weather-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: baseline;
|
|
margin-bottom: 0.5rem;
|
|
}
|
|
|
|
.weather-location {
|
|
font-weight: 600;
|
|
font-size: 0.95rem;
|
|
}
|
|
|
|
.weather-fetched-at {
|
|
color: var(--color-text-muted);
|
|
font-size: 0.78rem;
|
|
}
|
|
|
|
.weather-current {
|
|
display: flex;
|
|
align-items: baseline;
|
|
gap: 0.75rem;
|
|
margin-bottom: 0.5rem;
|
|
}
|
|
|
|
.weather-icon {
|
|
font-size: clamp(1.5rem, 5cqi, 2.5rem);
|
|
line-height: 1;
|
|
}
|
|
|
|
.weather-temp {
|
|
font-size: clamp(1.5rem, 5cqi, 2.5rem);
|
|
font-weight: 700;
|
|
line-height: 1;
|
|
}
|
|
|
|
.weather-condition {
|
|
color: var(--color-text-muted);
|
|
font-size: 0.9rem;
|
|
}
|
|
|
|
.weather-today {
|
|
color: var(--color-text-secondary);
|
|
margin-bottom: 0.25rem;
|
|
font-size: 0.85rem;
|
|
}
|
|
|
|
.weather-delta {
|
|
color: var(--color-text-muted);
|
|
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 {
|
|
width: 100%;
|
|
border-collapse: collapse;
|
|
margin-top: 0.75rem;
|
|
border-top: 1px solid var(--color-border);
|
|
font-size: 0.8rem;
|
|
}
|
|
|
|
.weather-forecast thead th {
|
|
font-size: 0.68rem;
|
|
font-weight: 600;
|
|
color: var(--color-text-muted);
|
|
text-align: right;
|
|
padding: 0.5rem 0.4rem 0.25rem;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.weather-forecast thead th:first-child,
|
|
.weather-forecast thead th:nth-child(2) {
|
|
text-align: left;
|
|
}
|
|
|
|
.weather-forecast tbody td {
|
|
padding: 0.3rem 0.4rem;
|
|
white-space: nowrap;
|
|
vertical-align: middle;
|
|
}
|
|
|
|
.forecast-day-name {
|
|
font-weight: 600;
|
|
}
|
|
|
|
.forecast-icon {
|
|
font-size: clamp(0.9rem, 3cqi, 1.3rem);
|
|
line-height: 1;
|
|
}
|
|
|
|
.forecast-temps {
|
|
text-align: right;
|
|
}
|
|
|
|
.forecast-precip {
|
|
text-align: right;
|
|
color: var(--color-text-muted);
|
|
}
|
|
|
|
.forecast-precip--dry {
|
|
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 {
|
|
text-align: right;
|
|
color: var(--color-text-muted);
|
|
}
|
|
|
|
.weather-unavailable {
|
|
color: var(--color-text-muted);
|
|
font-style: italic;
|
|
}
|
|
</style>
|