Add deterministic daily weather brief

This commit is contained in:
zv
2026-06-11 20:27:10 +02:00
parent 47a292b26e
commit 49265e7c51
16 changed files with 590 additions and 60 deletions

View File

@@ -15,6 +15,7 @@ import { useCurrentWeather } from "@/hooks/use-current-weather";
import { ForecastPanel } from "@/components/forecast/forecast-panel";
import { locateSynopStations } from "@/lib/location-utils";
import { DashboardWarnings } from "@/components/warnings/dashboard-warnings";
import { WeatherBriefCard } from "@/components/dashboard/weather-brief-card";
export function DashboardPage() {
const { t } = useI18n();
@@ -43,6 +44,7 @@ export function DashboardPage() {
<LocationSearch stations={stations} positions={positions} />
<WeatherHero station={selectedStation} currentWeather={currentWeather} currentWeatherLoading={isCurrentWeatherLoading} locationName={activeLocation?.name} distanceKm={activeLocation?.distanceKm} />
<DashboardWarnings />
<WeatherBriefCard latitude={forecastLatitude} longitude={forecastLongitude} locationName={forecastLocationName ?? selectedStation.name} />
<ForecastPanel latitude={forecastLatitude} longitude={forecastLongitude} locationName={forecastLocationName ?? selectedStation.name} />
<FavoritesSection stations={stations} />
<FeaturedStationsSection stations={stations} />

View File

@@ -0,0 +1,81 @@
"use client";
import { useMemo } from "react";
import { AlertTriangle, BellRing, CloudSun, RefreshCw } from "lucide-react";
import { EmptyState } from "@/components/states/empty-state";
import { LoadingSkeleton } from "@/components/states/loading-skeleton";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { useForecast } from "@/hooks/use-forecast";
import { useWarnings } from "@/hooks/use-warnings";
import { DEFAULT_STATION_ID } from "@/lib/constants";
import { useI18n } from "@/lib/i18n";
import { getProvinceForSelection } from "@/lib/provinces";
import { useWeatherStore } from "@/lib/store";
import { buildWeatherBrief } from "@/lib/weather-brief";
export function WeatherBriefCard({ latitude, longitude, locationName }: { latitude?: number; longitude?: number; locationName: string }) {
const { language, t } = useI18n();
const { data: forecast, isPending, isError, refetch } = useForecast(latitude, longitude);
const { data: warnings = [] } = useWarnings();
const selectedStationId = useWeatherStore((state) => state.selectedStationId);
const selectedLocation = useWeatherStore((state) => state.selectedLocation);
const province = getProvinceForSelection(selectedLocation?.province, selectedStationId ?? DEFAULT_STATION_ID);
const brief = useMemo(() => {
if (!forecast) return null;
return buildWeatherBrief({ forecast, warnings, province, locationName, language });
}, [forecast, language, locationName, province, warnings]);
if (!Number.isFinite(latitude) || !Number.isFinite(longitude) || isPending) {
return <LoadingSkeleton className="h-48" />;
}
if (isError || !forecast) {
return (
<Card className="flex min-h-40 flex-col items-center justify-center p-6 text-center">
<p className="text-sm text-muted">{t("brief.error")}</p>
<Button variant="glass" className="mt-4" onClick={() => refetch()}>
<RefreshCw className="size-4" />
{t("common.retry")}
</Button>
</Card>
);
}
if (!brief) {
return <EmptyState title={t("brief.emptyTitle")} description={t("brief.emptyDescription")} />;
}
const isWarning = brief.severity === "warning";
return (
<Card className="p-4 sm:p-5">
<div className="flex items-start gap-3">
<div className={isWarning ? "rounded-control border border-warning/30 bg-warning/10 p-2 text-warning" : "rounded-control border border-border/70 bg-surface-muted/70 p-2 text-accent"}>
{isWarning ? <AlertTriangle className="size-4" aria-hidden="true" /> : <CloudSun className="size-4" aria-hidden="true" />}
</div>
<div className="min-w-0">
<p className={isWarning ? "text-[0.68rem] font-semibold uppercase tracking-[0.16em] text-warning" : "section-kicker"}>
{t("brief.label")}
</p>
<h2 className="mt-1 text-lg font-semibold tracking-tight">{brief.headline}</h2>
<p className="mt-1 text-sm leading-6 text-muted">{t("brief.description", { location: locationName })}</p>
</div>
</div>
<ul className="mt-4 space-y-2">
{brief.body.slice(0, 4).map((line) => (
<li key={line} className="rounded-card border border-border/60 bg-surface-muted/45 px-3 py-2 text-sm leading-6">
{line}
</li>
))}
</ul>
<p className="mt-4 flex items-center gap-2 text-xs leading-5 text-muted">
<BellRing className="size-3.5 text-accent" aria-hidden="true" />
{t("brief.pushHint")}
</p>
</Card>
);
}