Add deterministic daily weather brief
This commit is contained in:
@@ -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} />
|
||||
|
||||
81
components/dashboard/weather-brief-card.tsx
Normal file
81
components/dashboard/weather-brief-card.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Bell, BellRing, Languages, MapPinned, Palette, Settings, Smartphone } from "lucide-react";
|
||||
import { Bell, BellRing, Clock3, Languages, MapPinned, Palette, Settings, Smartphone } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { LanguageToggle } from "@/components/ui/language-toggle";
|
||||
import { ThemeToggle } from "@/components/ui/theme-toggle";
|
||||
import { useMeteoStationPositions } from "@/hooks/use-meteo-stations";
|
||||
import { useWeatherStations } from "@/hooks/use-weather-stations";
|
||||
import { DEFAULT_STATION_ID } from "@/lib/constants";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import { locateSynopStations } from "@/lib/location-utils";
|
||||
import { decodeBase64UrlKey, deletePushSubscription, fetchVapidPublicKey, savePushSubscription, sendTestPushNotification } from "@/lib/notification-api";
|
||||
import { formatProvinceName, getProvinceForSelection, PROVINCES } from "@/lib/provinces";
|
||||
import { useWeatherStore } from "@/lib/store";
|
||||
@@ -60,15 +63,29 @@ export function SettingsPage() {
|
||||
const [isSavingSubscription, setIsSavingSubscription] = useState(false);
|
||||
const [isSendingTestNotification, setIsSendingTestNotification] = useState(false);
|
||||
const [notificationMessage, setNotificationMessage] = useState<string | null>(null);
|
||||
const { data: stations } = useWeatherStations();
|
||||
const { data: positions = [] } = useMeteoStationPositions();
|
||||
const selectedStationId = useWeatherStore((state) => state.selectedStationId);
|
||||
const selectedLocation = useWeatherStore((state) => state.selectedLocation);
|
||||
const notificationsEnabled = useWeatherStore((state) => state.warningNotificationsEnabled);
|
||||
const morningBriefEnabled = useWeatherStore((state) => state.morningBriefNotificationsEnabled);
|
||||
const provinceMode = useWeatherStore((state) => state.warningNotificationProvinceMode);
|
||||
const manualProvince = useWeatherStore((state) => state.warningNotificationProvince);
|
||||
const setNotificationsEnabled = useWeatherStore((state) => state.setWarningNotificationsEnabled);
|
||||
const setMorningBriefEnabled = useWeatherStore((state) => state.setMorningBriefNotificationsEnabled);
|
||||
const setProvinceMode = useWeatherStore((state) => state.setWarningNotificationProvinceMode);
|
||||
const setManualProvince = useWeatherStore((state) => state.setWarningNotificationProvince);
|
||||
|
||||
const selectedStation = stations?.find((station) => station.id === selectedStationId)
|
||||
?? stations?.find((station) => station.id === DEFAULT_STATION_ID)
|
||||
?? stations?.[0];
|
||||
const activeLocation = selectedLocation?.stationId === selectedStation?.id ? selectedLocation : null;
|
||||
const stationPosition = selectedStation
|
||||
? locateSynopStations(stations ?? [], positions).find((station) => station.id === selectedStation.id)
|
||||
: null;
|
||||
const notificationLatitude = Number.isFinite(activeLocation?.latitude) ? activeLocation?.latitude : stationPosition?.latitude ?? null;
|
||||
const notificationLongitude = Number.isFinite(activeLocation?.longitude) ? activeLocation?.longitude : stationPosition?.longitude ?? null;
|
||||
const notificationLocationName = activeLocation?.name ?? selectedStation?.name ?? null;
|
||||
const selectedProvince = getProvinceForSelection(selectedLocation?.province, selectedStationId ?? DEFAULT_STATION_ID);
|
||||
const effectiveProvince = provinceMode === "manual" ? manualProvince : selectedProvince;
|
||||
const effectiveProvinceLabel = effectiveProvince ? formatProvinceName(effectiveProvince, language) : t("settings.notifications.noProvince");
|
||||
@@ -99,14 +116,19 @@ export function SettingsPage() {
|
||||
const registration = await navigator.serviceWorker?.getRegistration();
|
||||
const subscription = await registration?.pushManager.getSubscription();
|
||||
if (!subscription || cancelled) return;
|
||||
await savePushSubscription(subscription, province, language).catch(() => undefined);
|
||||
await savePushSubscription(subscription, province, language, {
|
||||
morningBriefEnabled,
|
||||
latitude: notificationLatitude,
|
||||
longitude: notificationLongitude,
|
||||
locationName: notificationLocationName,
|
||||
}).catch(() => undefined);
|
||||
}
|
||||
|
||||
syncSubscriptionProvince();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [effectiveProvince, language, notificationStatus, notificationsEnabled, vapidPublicKey]);
|
||||
}, [effectiveProvince, language, morningBriefEnabled, notificationLatitude, notificationLocationName, notificationLongitude, notificationStatus, notificationsEnabled, vapidPublicKey]);
|
||||
|
||||
const notificationStatusLabel = useMemo(() => {
|
||||
switch (notificationStatus) {
|
||||
@@ -158,7 +180,12 @@ export function SettingsPage() {
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: decodeBase64UrlKey(vapidPublicKey),
|
||||
});
|
||||
await savePushSubscription(subscription, effectiveProvince, language);
|
||||
await savePushSubscription(subscription, effectiveProvince, language, {
|
||||
morningBriefEnabled,
|
||||
latitude: notificationLatitude,
|
||||
longitude: notificationLongitude,
|
||||
locationName: notificationLocationName,
|
||||
});
|
||||
setNotificationsEnabled(true);
|
||||
setNotificationMessage(t("settings.notifications.saveSuccess"));
|
||||
} catch {
|
||||
@@ -180,6 +207,7 @@ export function SettingsPage() {
|
||||
await subscription.unsubscribe();
|
||||
}
|
||||
setNotificationsEnabled(false);
|
||||
setMorningBriefEnabled(false);
|
||||
setNotificationMessage(t("settings.notifications.disableSuccess"));
|
||||
} catch {
|
||||
setNotificationMessage(t("settings.notifications.saveError"));
|
||||
@@ -204,6 +232,24 @@ export function SettingsPage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function updateMorningBriefPreference(enabled: boolean) {
|
||||
setMorningBriefEnabled(enabled);
|
||||
if (!notificationsEnabled || !effectiveProvince || notificationStatus !== "granted") return;
|
||||
try {
|
||||
const registration = await navigator.serviceWorker?.getRegistration();
|
||||
const subscription = await registration?.pushManager.getSubscription();
|
||||
if (!subscription) return;
|
||||
await savePushSubscription(subscription, effectiveProvince, language, {
|
||||
morningBriefEnabled: enabled,
|
||||
latitude: notificationLatitude,
|
||||
longitude: notificationLongitude,
|
||||
locationName: notificationLocationName,
|
||||
});
|
||||
} catch {
|
||||
setNotificationMessage(t("settings.notifications.saveError"));
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<section>
|
||||
@@ -301,6 +347,23 @@ export function SettingsPage() {
|
||||
{notificationMessage && <p className="mt-3 text-xs font-medium text-accent">{notificationMessage}</p>}
|
||||
</div>
|
||||
|
||||
<label className="mt-3 flex items-start gap-3 rounded-card border border-border/60 bg-surface px-3 py-3 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={morningBriefEnabled}
|
||||
disabled={!notificationsEnabled}
|
||||
onChange={(event) => updateMorningBriefPreference(event.target.checked)}
|
||||
className="mt-1 accent-accent"
|
||||
/>
|
||||
<span>
|
||||
<span className="flex items-center gap-2 font-medium">
|
||||
<Clock3 className="size-3.5 text-accent" aria-hidden="true" />
|
||||
{t("settings.notifications.morningBrief")}
|
||||
</span>
|
||||
<span className="mt-0.5 block text-xs leading-5 text-muted">{t("settings.notifications.morningBriefDescription")}</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div className="mt-4 flex flex-col gap-2 sm:flex-row">
|
||||
<Button type="button" disabled={(!notificationsEnabled && !canUsePush) || isSavingSubscription} onClick={notificationsEnabled ? disableWarningNotifications : enableWarningNotifications}>
|
||||
<Bell className="size-4" />
|
||||
|
||||
Reference in New Issue
Block a user