From cb113714f94a7bf1d0d70c4af78be5916c9a0c5f Mon Sep 17 00:00:00 2001 From: zv Date: Sat, 13 Jun 2026 21:05:01 +0200 Subject: [PATCH] feat: add temperature unit preference --- AGENTS.md | 1 + app/api/notifications/daily-brief/route.ts | 3 +- app/api/notifications/subscriptions/route.ts | 5 ++- app/api/notifications/tomorrow-brief/route.ts | 3 +- components/charts/day-forecast-charts.tsx | 16 ++++++-- components/dashboard/weather-brief-card.tsx | 9 +++-- components/forecast/day-forecast-modal.tsx | 7 ++-- components/forecast/forecast-panel.tsx | 11 ++++-- components/hydro/hydro-station-card.tsx | 4 +- components/settings/settings-page.tsx | 39 +++++++++++++++++-- .../weather/current-conditions-card.tsx | 5 ++- .../weather/featured-stations-section.tsx | 3 +- components/weather/station-card.tsx | 3 +- components/weather/weather-hero.tsx | 5 ++- docs/notifications.md | 1 + docs/weather-logic.md | 2 +- lib/forecast-utils.ts | 8 ++-- lib/i18n.tsx | 10 +++++ lib/notification-api.ts | 4 +- lib/store.ts | 8 +++- lib/weather-brief.ts | 17 ++++---- lib/weather-utils.ts | 33 ++++++++++++++-- types/notifications.ts | 3 +- types/units.ts | 1 + 24 files changed, 150 insertions(+), 51 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index e304aba..5d4b8b5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -49,6 +49,7 @@ Repozytorium nie ma obecnie skryptu testów ani formattera. `npm run typecheck` - Listy ostrzeżeń zachowują priorytet lokalnego obszaru, a wewnątrz każdej grupy pokazują ostrzeżenia meteorologiczne przed hydrologicznymi. Jeśli lokalizacja ma rozpoznany powiat TERYT, ostrzeżenia meteo filtruj po tym powiecie; w przeciwnym razie stosuj fallback wojewódzki. W obrębie rodzaju zachowuj kolejność publikacji od najnowszych. - Dashboard pokazuje kompaktowo wyłącznie aktywne i nadchodzące ostrzeżenia meteo dla wybranego obszaru. Filtruj je po `validTo` względem czasu przeglądarki i automatycznie usuwaj wygasłe komunikaty bez przeładowania strony. - Brief dnia i brief na jutro generuj deterministycznie w `lib/weather-brief.ts` z prognozy modelowej i ostrzeżeń IMGW. Nie traktuj ich jako odpowiedzi modelu AI i nie wymagaj klucza OpenAI API. Brief na jutro korzysta z pełnego jutrzejszego dnia prognozy i może informować o burzach na podstawie kodów modelu także bez oficjalnego ostrzeżenia IMGW. +- Preferencje jednostek temperatury i wiatru są ustawieniami prezentacyjnymi. Dane źródłowe oraz progi logiki pogody pozostają liczone w `°C` i `m/s`, a wybrane jednostki zapisuj w subskrypcji Web Push, żeby briefy serwerowe używały tego samego formatu co UI. - Powiadomienia Web Push o ostrzeżeniach meteo, porannym briefie i wieczornym briefie na jutro konfiguruj przez `/settings`, `public/sw.js`, route handlery `app/api/notifications/*`, w tym testowy `/api/notifications/test`, oraz self-hostowany worker `scripts/notification-worker.mjs`. Endpointy harmonogramu nie uruchamiają się same bez workera albo zewnętrznego crona. Wymagają kluczy VAPID w zmiennych środowiskowych. iOS/iPadOS wymaga PWA z ekranu początkowego, ale Android i desktop nie powinny być blokowane wymogiem `standalone`. Obecny `lib/push-store.ts` jest pamięciowy i przed produkcją musi zostać zastąpiony trwałym magazynem subskrypcji. - GPS wymaga świadomej zgody użytkownika i HTTPS. Zaokrąglaj współrzędne przed użyciem i utrzymuj widoczną atrybucję OpenStreetMap dla reverse geocodingu Nominatim. - Normalizuj zewnętrzne odpowiedzi i obsługuj `null`, puste pola oraz błędne wartości. Brak danych pokazuj jawnie zamiast uzupełniać estymacją. diff --git a/app/api/notifications/daily-brief/route.ts b/app/api/notifications/daily-brief/route.ts index 28702b7..fbecb73 100644 --- a/app/api/notifications/daily-brief/route.ts +++ b/app/api/notifications/daily-brief/route.ts @@ -4,7 +4,7 @@ import { fetchMeteoWarnings } from "@/lib/server-warnings"; import { getPushSubscriptions, hasSentMorningBrief, markMorningBriefSent, removePushSubscription } from "@/lib/push-store"; import { isWebPushConfigured, sendMorningBriefNotification } from "@/lib/push-service"; import { buildWeatherBrief } from "@/lib/weather-brief"; -import { DEFAULT_WIND_SPEED_UNIT } from "@/lib/weather-utils"; +import { DEFAULT_TEMPERATURE_UNIT, DEFAULT_WIND_SPEED_UNIT } from "@/lib/weather-utils"; export const runtime = "nodejs"; @@ -67,6 +67,7 @@ export async function GET(request: Request) { countyTeryt: subscription.countyTeryt, locationName: subscription.locationName ?? "wtr.", language: subscription.language, + temperatureUnit: subscription.temperatureUnit ?? DEFAULT_TEMPERATURE_UNIT, windSpeedUnit: subscription.windSpeedUnit ?? DEFAULT_WIND_SPEED_UNIT, now, }); diff --git a/app/api/notifications/subscriptions/route.ts b/app/api/notifications/subscriptions/route.ts index 1bbfad4..c0a6fea 100644 --- a/app/api/notifications/subscriptions/route.ts +++ b/app/api/notifications/subscriptions/route.ts @@ -2,7 +2,7 @@ import { NextResponse } from "next/server"; import { upsertPushSubscription, removePushSubscription } from "@/lib/push-store"; import { isWebPushConfigured } from "@/lib/push-service"; import { normalizeProvinceName } from "@/lib/provinces"; -import { DEFAULT_WIND_SPEED_UNIT, isWindSpeedUnit } from "@/lib/weather-utils"; +import { DEFAULT_TEMPERATURE_UNIT, DEFAULT_WIND_SPEED_UNIT, isTemperatureUnit, isWindSpeedUnit } from "@/lib/weather-utils"; import type { Language } from "@/lib/i18n"; import type { BrowserPushSubscription } from "@/types/notifications"; @@ -33,11 +33,13 @@ export async function POST(request: Request) { longitude?: unknown; locationName?: unknown; countyTeryt?: unknown; + temperatureUnit?: unknown; windSpeedUnit?: unknown; } | null; const subscription = body?.subscription; const province = normalizeProvinceName(typeof body?.province === "string" ? body.province : null); const language: Language = body?.language === "en" ? "en" : "pl"; + const rawTemperatureUnit = body?.temperatureUnit; const rawWindSpeedUnit = body?.windSpeedUnit; if (!isBrowserPushSubscription(subscription) || !province) { @@ -57,6 +59,7 @@ export async function POST(request: Request) { longitude: typeof body?.longitude === "number" && Number.isFinite(body.longitude) ? body.longitude : null, locationName: typeof body?.locationName === "string" && body.locationName.trim() ? body.locationName.trim().slice(0, 80) : null, countyTeryt: typeof body?.countyTeryt === "string" && /^\d{4}$/.test(body.countyTeryt) ? body.countyTeryt : null, + temperatureUnit: isTemperatureUnit(rawTemperatureUnit) ? rawTemperatureUnit : DEFAULT_TEMPERATURE_UNIT, windSpeedUnit: isWindSpeedUnit(rawWindSpeedUnit) ? rawWindSpeedUnit : DEFAULT_WIND_SPEED_UNIT, createdAt: now, updatedAt: now, diff --git a/app/api/notifications/tomorrow-brief/route.ts b/app/api/notifications/tomorrow-brief/route.ts index b18aaec..26c2f3c 100644 --- a/app/api/notifications/tomorrow-brief/route.ts +++ b/app/api/notifications/tomorrow-brief/route.ts @@ -4,7 +4,7 @@ import { fetchMeteoWarnings } from "@/lib/server-warnings"; import { getPushSubscriptions, hasSentTomorrowBrief, markTomorrowBriefSent, removePushSubscription } from "@/lib/push-store"; import { isWebPushConfigured, sendTomorrowBriefNotification } from "@/lib/push-service"; import { buildTomorrowWeatherBrief } from "@/lib/weather-brief"; -import { DEFAULT_WIND_SPEED_UNIT } from "@/lib/weather-utils"; +import { DEFAULT_TEMPERATURE_UNIT, DEFAULT_WIND_SPEED_UNIT } from "@/lib/weather-utils"; export const runtime = "nodejs"; @@ -77,6 +77,7 @@ export async function GET(request: Request) { countyTeryt: subscription.countyTeryt, locationName: subscription.locationName ?? "wtr.", language: subscription.language, + temperatureUnit: subscription.temperatureUnit ?? DEFAULT_TEMPERATURE_UNIT, windSpeedUnit: subscription.windSpeedUnit ?? DEFAULT_WIND_SPEED_UNIT, now, }); diff --git a/components/charts/day-forecast-charts.tsx b/components/charts/day-forecast-charts.tsx index 4630369..2f96493 100644 --- a/components/charts/day-forecast-charts.tsx +++ b/components/charts/day-forecast-charts.tsx @@ -5,6 +5,8 @@ import { Card } from "@/components/ui/card"; import { CHART_COLORS } from "@/lib/chart-theme"; import { useI18n } from "@/lib/i18n"; import { formatForecastRainfall, formatForecastTemperature } from "@/lib/forecast-utils"; +import { useWeatherStore } from "@/lib/store"; +import { convertTemperature, formatTemperatureValue, getTemperatureUnitLabel } from "@/lib/weather-utils"; import type { HourlyForecast } from "@/types/forecast"; const INITIAL_CHART_DIMENSION = { width: 1, height: 1 }; @@ -15,13 +17,15 @@ function formatHour(value: string) { export function DayForecastCharts({ hours }: { hours: HourlyForecast[] }) { const { language, t } = useI18n(); + const temperatureUnit = useWeatherStore((state) => state.temperatureUnit); const rows = hours.map((hour) => ({ time: formatHour(hour.time), - temperature: hour.temperature, - feelsLike: hour.feelsLike, + temperature: hour.temperature === null ? null : convertTemperature(hour.temperature, temperatureUnit), + feelsLike: hour.feelsLike === null ? null : convertTemperature(hour.feelsLike, temperatureUnit), precipitation: hour.precipitation, precipitationProbability: hour.precipitationProbability, })); + const temperatureUnitLabel = getTemperatureUnitLabel(temperatureUnit); return (
@@ -33,10 +37,14 @@ export function DayForecastCharts({ hours }: { hours: HourlyForecast[] }) { - + [formatForecastTemperature(typeof value === "number" ? value : null, language)]} + formatter={(value) => [ + typeof value === "number" + ? formatTemperatureValue(value, language, temperatureUnit) + : formatForecastTemperature(null, language, temperatureUnit), + ]} /> diff --git a/components/dashboard/weather-brief-card.tsx b/components/dashboard/weather-brief-card.tsx index b35d5b6..839fc06 100644 --- a/components/dashboard/weather-brief-card.tsx +++ b/components/dashboard/weather-brief-card.tsx @@ -20,17 +20,18 @@ export function WeatherBriefCard({ latitude, longitude, locationName }: { latitu const { data: warnings = [] } = useWarnings(); const selectedStationId = useWeatherStore((state) => state.selectedStationId); const selectedLocation = useWeatherStore((state) => state.selectedLocation); + const temperatureUnit = useWeatherStore((state) => state.temperatureUnit); const windSpeedUnit = useWeatherStore((state) => state.windSpeedUnit); const province = getProvinceForSelection(selectedLocation?.province, selectedStationId ?? DEFAULT_STATION_ID); const brief = useMemo(() => { if (!forecast) return null; - return buildWeatherBrief({ forecast, warnings, province, countyTeryt: selectedLocation?.countyTeryt, locationName, language, windSpeedUnit }); - }, [forecast, language, locationName, province, selectedLocation?.countyTeryt, warnings, windSpeedUnit]); + return buildWeatherBrief({ forecast, warnings, province, countyTeryt: selectedLocation?.countyTeryt, locationName, language, temperatureUnit, windSpeedUnit }); + }, [forecast, language, locationName, province, selectedLocation?.countyTeryt, temperatureUnit, warnings, windSpeedUnit]); const tomorrowBrief = useMemo(() => { if (!forecast) return null; - return buildTomorrowWeatherBrief({ forecast, warnings, province, countyTeryt: selectedLocation?.countyTeryt, locationName, language, windSpeedUnit }); - }, [forecast, language, locationName, province, selectedLocation?.countyTeryt, warnings, windSpeedUnit]); + return buildTomorrowWeatherBrief({ forecast, warnings, province, countyTeryt: selectedLocation?.countyTeryt, locationName, language, temperatureUnit, windSpeedUnit }); + }, [forecast, language, locationName, province, selectedLocation?.countyTeryt, temperatureUnit, warnings, windSpeedUnit]); if (!Number.isFinite(latitude) || !Number.isFinite(longitude) || isPending) { return ; diff --git a/components/forecast/day-forecast-modal.tsx b/components/forecast/day-forecast-modal.tsx index be37097..708cdea 100644 --- a/components/forecast/day-forecast-modal.tsx +++ b/components/forecast/day-forecast-modal.tsx @@ -54,6 +54,7 @@ export function DayForecastModal({ onClose: () => void; }) { const { language, locale, t } = useI18n(); + const temperatureUnit = useWeatherStore((state) => state.temperatureUnit); const windSpeedUnit = useWeatherStore((state) => state.windSpeedUnit); const closeButtonRef = useRef(null); const portalRoot = typeof document === "undefined" ? null : document.body; @@ -131,8 +132,8 @@ export function DayForecastModal({

{getForecastCondition(day.weatherCode, language)}

-

{formatForecastTemperature(day.temperatureMax, language)}

-

{formatForecastTemperature(day.temperatureMin, language)}

+

{formatForecastTemperature(day.temperatureMax, language, temperatureUnit)}

+

{formatForecastTemperature(day.temperatureMin, language, temperatureUnit)}

@@ -156,7 +157,7 @@ export function DayForecastModal({ >

{formatHour(hour.time)}

-

{formatForecastTemperature(hour.temperature, language)}

+

{formatForecastTemperature(hour.temperature, language, temperatureUnit)}

{hour.precipitationProbability === null ? "—" : `${hour.precipitationProbability}%`} diff --git a/components/forecast/forecast-panel.tsx b/components/forecast/forecast-panel.tsx index c7ce70f..9117cb2 100644 --- a/components/forecast/forecast-panel.tsx +++ b/components/forecast/forecast-panel.tsx @@ -60,6 +60,7 @@ function HourlySummaryMetric({ icon: Icon, label, value }: { icon: LucideIcon; l function HourlyForecastSummary({ hours }: { hours: HourlyForecast[] }) { const { language, t } = useI18n(); + const temperatureUnit = useWeatherStore((state) => state.temperatureUnit); const windSpeedUnit = useWeatherStore((state) => state.windSpeedUnit); const minimumTemperature = getMinimum(hours.map((hour) => hour.temperature)); const maximumTemperature = getMaximum(hours.map((hour) => hour.temperature)); @@ -68,7 +69,7 @@ function HourlyForecastSummary({ hours }: { hours: HourlyForecast[] }) { const maximumRainfallProbability = getMaximum(hours.map((hour) => hour.precipitationProbability)); const temperatureRange = minimumTemperature === null || maximumTemperature === null ? "—" - : `${formatForecastTemperature(minimumTemperature, language)} / ${formatForecastTemperature(maximumTemperature, language)}`; + : `${formatForecastTemperature(minimumTemperature, language, temperatureUnit)} / ${formatForecastTemperature(maximumTemperature, language, temperatureUnit)}`; return (

@@ -85,6 +86,7 @@ function HourlyForecastSummary({ hours }: { hours: HourlyForecast[] }) { function DailyForecastRow({ day, index, onSelect }: { day: DailyForecast; index: number; onSelect: (day: DailyForecast) => void }) { const { language, locale, t } = useI18n(); + const temperatureUnit = useWeatherStore((state) => state.temperatureUnit); const label = formatDay(day.date, locale, t("forecast.today"), index); return ( {getForecastCondition(day.weatherCode, language)} · {formatForecastRainfall(day.precipitation, language)}
{day.precipitationProbability === null ? "—" : `${day.precipitationProbability}%`} -

{formatForecastTemperature(day.temperatureMax, language)}{formatForecastTemperature(day.temperatureMin, language)}

+

{formatForecastTemperature(day.temperatureMax, language, temperatureUnit)}{formatForecastTemperature(day.temperatureMin, language, temperatureUnit)}

@@ -115,6 +117,7 @@ function DailyForecastRow({ day, index, onSelect }: { day: DailyForecast; index: export function ForecastPanel({ latitude, longitude, locationName }: { latitude?: number; longitude?: number; locationName: string }) { const { language, t } = useI18n(); + const temperatureUnit = useWeatherStore((state) => state.temperatureUnit); const windSpeedUnit = useWeatherStore((state) => state.windSpeedUnit); const { data: forecast, isPending, isError, refetch } = useForecast(latitude, longitude); const [selectedDay, setSelectedDay] = useState(null); @@ -161,12 +164,12 @@ export function ForecastPanel({ latitude, longitude, locationName }: { latitude? >

{formatHour(hour.time)}

-

{formatForecastTemperature(hour.temperature, language)}

+

{formatForecastTemperature(hour.temperature, language, temperatureUnit)}

{hour.precipitationProbability === null ? "—" : `${hour.precipitationProbability}%`}

- {formatForecastTemperature(hour.feelsLike, language)} + {formatForecastTemperature(hour.feelsLike, language, temperatureUnit)}

diff --git a/components/hydro/hydro-station-card.tsx b/components/hydro/hydro-station-card.tsx index c810b56..d752265 100644 --- a/components/hydro/hydro-station-card.tsx +++ b/components/hydro/hydro-station-card.tsx @@ -6,9 +6,11 @@ import type { HydroStation } from "@/types/imgw"; import { formatDateTime, formatFlow, formatTemperature, formatWaterLevel } from "@/lib/weather-utils"; import { Card } from "@/components/ui/card"; import { useI18n } from "@/lib/i18n"; +import { useWeatherStore } from "@/lib/store"; export function HydroStationCard({ station, index = 0 }: { station: HydroStation; index?: number }) { const { language, t } = useI18n(); + const temperatureUnit = useWeatherStore((state) => state.temperatureUnit); return ( @@ -20,7 +22,7 @@ export function HydroStationCard({ station, index = 0 }: { station: HydroStation

- +

{t("hydro.levelMeasurement", { date: formatDateTime(station.waterLevelMeasuredAt, language) })}

diff --git a/components/settings/settings-page.tsx b/components/settings/settings-page.tsx index a517fdf..be71adb 100644 --- a/components/settings/settings-page.tsx +++ b/components/settings/settings-page.tsx @@ -1,7 +1,7 @@ "use client"; import { useEffect, useMemo, useState } from "react"; -import { Bell, BellRing, Clock3, Languages, MapPinned, Palette, Settings, Smartphone, Wind } from "lucide-react"; +import { Bell, BellRing, Clock3, Languages, MapPinned, Palette, Settings, Smartphone, Thermometer, Wind } from "lucide-react"; import type { LucideIcon } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Card } from "@/components/ui/card"; @@ -15,9 +15,9 @@ 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"; -import { WIND_SPEED_UNITS } from "@/lib/weather-utils"; +import { TEMPERATURE_UNITS, WIND_SPEED_UNITS } from "@/lib/weather-utils"; import type { Province } from "@/types/province"; -import type { WindSpeedUnit } from "@/types/units"; +import type { TemperatureUnit, WindSpeedUnit } from "@/types/units"; type NotificationSupportStatus = "checking" | "unconfigured" | "unsupported" | "needs-install" | "default" | "denied" | "granted"; @@ -108,6 +108,11 @@ const windSpeedUnitKeys: Record = { + c: "settings.units.temperature.c", + f: "settings.units.temperature.f", +}; + export function SettingsPage() { const { language, t } = useI18n(); const [notificationStatus, setNotificationStatus] = useState("checking"); @@ -124,12 +129,14 @@ export function SettingsPage() { const tomorrowBriefEnabled = useWeatherStore((state) => state.tomorrowBriefNotificationsEnabled); const provinceMode = useWeatherStore((state) => state.warningNotificationProvinceMode); const manualProvince = useWeatherStore((state) => state.warningNotificationProvince); + const temperatureUnit = useWeatherStore((state) => state.temperatureUnit); const windSpeedUnit = useWeatherStore((state) => state.windSpeedUnit); const setNotificationsEnabled = useWeatherStore((state) => state.setWarningNotificationsEnabled); const setMorningBriefEnabled = useWeatherStore((state) => state.setMorningBriefNotificationsEnabled); const setTomorrowBriefEnabled = useWeatherStore((state) => state.setTomorrowBriefNotificationsEnabled); const setProvinceMode = useWeatherStore((state) => state.setWarningNotificationProvinceMode); const setManualProvince = useWeatherStore((state) => state.setWarningNotificationProvince); + const setTemperatureUnit = useWeatherStore((state) => state.setTemperatureUnit); const setWindSpeedUnit = useWeatherStore((state) => state.setWindSpeedUnit); const selectedStation = stations?.find((station) => station.id === selectedStationId) @@ -180,6 +187,7 @@ export function SettingsPage() { longitude: notificationLongitude, locationName: notificationLocationName, countyTeryt: notificationCountyTeryt, + temperatureUnit, windSpeedUnit, }).catch(() => undefined); } @@ -188,7 +196,7 @@ export function SettingsPage() { return () => { cancelled = true; }; - }, [effectiveProvince, language, morningBriefEnabled, notificationCountyTeryt, notificationLatitude, notificationLocationName, notificationLongitude, notificationStatus, notificationsEnabled, tomorrowBriefEnabled, vapidPublicKey, windSpeedUnit]); + }, [effectiveProvince, language, morningBriefEnabled, notificationCountyTeryt, notificationLatitude, notificationLocationName, notificationLongitude, notificationStatus, notificationsEnabled, temperatureUnit, tomorrowBriefEnabled, vapidPublicKey, windSpeedUnit]); const notificationStatusLabel = useMemo(() => { switch (notificationStatus) { @@ -247,6 +255,7 @@ export function SettingsPage() { longitude: notificationLongitude, locationName: notificationLocationName, countyTeryt: notificationCountyTeryt, + temperatureUnit, windSpeedUnit, }); setNotificationsEnabled(true); @@ -310,6 +319,7 @@ export function SettingsPage() { longitude: notificationLongitude, locationName: notificationLocationName, countyTeryt: notificationCountyTeryt, + temperatureUnit, windSpeedUnit, }); } catch { @@ -331,6 +341,7 @@ export function SettingsPage() { longitude: notificationLongitude, locationName: notificationLocationName, countyTeryt: notificationCountyTeryt, + temperatureUnit, windSpeedUnit, }); } catch { @@ -353,6 +364,26 @@ export function SettingsPage() { + +
+ {TEMPERATURE_UNITS.map((unit) => ( + + ))} +
+
{WIND_SPEED_UNITS.map((unit) => ( diff --git a/components/weather/current-conditions-card.tsx b/components/weather/current-conditions-card.tsx index 9126cff..f8b86c0 100644 --- a/components/weather/current-conditions-card.tsx +++ b/components/weather/current-conditions-card.tsx @@ -9,16 +9,17 @@ import { useWeatherStore } from "@/lib/store"; export function CurrentConditionsCard({ station }: { station: SynopStation }) { const { language, t } = useI18n(); + const temperatureUnit = useWeatherStore((state) => state.temperatureUnit); const windSpeedUnit = useWeatherStore((state) => state.windSpeedUnit); const feelsLike = calculateFeelsLike(station.temperature, station.humidity, station.windSpeed); const metrics = [ - { icon: Thermometer, label: t("weather.feelsLike"), value: formatTemperature(feelsLike, language), detail: t("weather.feelsLikeDetail") }, + { icon: Thermometer, label: t("weather.feelsLike"), value: formatTemperature(feelsLike, language, temperatureUnit), detail: t("weather.feelsLikeDetail") }, { icon: Droplets, label: t("weather.humidity"), value: formatHumidity(station.humidity, language), detail: t("weather.humidityDetail") }, { icon: Gauge, label: t("weather.pressure"), value: formatPressure(station.pressure, language), detail: t("weather.pressureDetail") }, { icon: Wind, label: t("weather.windSpeed"), value: formatWind(station.windSpeed, null, language, windSpeedUnit), detail: t("weather.windSpeedDetail") }, { icon: Navigation, label: t("weather.windDirection"), value: station.windDirection === null ? t("common.noData") : `${station.windDirection}° ${getWindDirection(station.windDirection)}`, detail: t("weather.windDirectionDetail") }, { icon: Umbrella, label: t("weather.rainfallTotal"), value: formatRainfall(station.rainfall, language), detail: t("weather.rainfallDetail") }, - { icon: CloudSun, label: t("weather.airTemperature"), value: formatTemperature(station.temperature, language), detail: t("weather.temperatureDetail") }, + { icon: CloudSun, label: t("weather.airTemperature"), value: formatTemperature(station.temperature, language, temperatureUnit), detail: t("weather.temperatureDetail") }, ]; return (
diff --git a/components/weather/featured-stations-section.tsx b/components/weather/featured-stations-section.tsx index 4676f18..a8661ab 100644 --- a/components/weather/featured-stations-section.tsx +++ b/components/weather/featured-stations-section.tsx @@ -14,6 +14,7 @@ export function FeaturedStationsSection({ stations }: { stations: SynopStation[] const { language, t } = useI18n(); const selectedStationId = useWeatherStore((state) => state.selectedStationId); const selectStation = useWeatherStore((state) => state.selectStation); + const temperatureUnit = useWeatherStore((state) => state.temperatureUnit); const featured = featuredNames.flatMap((name) => stations.find((station) => station.name === name) ?? []); return (
@@ -33,7 +34,7 @@ export function FeaturedStationsSection({ stations }: { stations: SynopStation[] > {station.name} - {formatTemperature(station.temperature, language)} + {formatTemperature(station.temperature, language, temperatureUnit)} diff --git a/components/weather/station-card.tsx b/components/weather/station-card.tsx index b13dbf1..e37c037 100644 --- a/components/weather/station-card.tsx +++ b/components/weather/station-card.tsx @@ -17,6 +17,7 @@ export function StationCard({ station, index = 0 }: { station: SynopStation; ind const favorites = useWeatherStore((state) => state.favorites); const toggleFavorite = useWeatherStore((state) => state.toggleFavorite); const selectStation = useWeatherStore((state) => state.selectStation); + const temperatureUnit = useWeatherStore((state) => state.temperatureUnit); const windSpeedUnit = useWeatherStore((state) => state.windSpeedUnit); const favorite = favorites.includes(station.id); const mood = getWeatherMoodFromData(station); @@ -27,7 +28,7 @@ export function StationCard({ station, index = 0 }: { station: SynopStation; ind
selectStation(station.id)} className="min-w-0 flex-1 rounded-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent">

{station.name}

-

{formatTemperature(station.temperature, language)}

+

{formatTemperature(station.temperature, language, temperatureUnit)}

diff --git a/components/weather/weather-hero.tsx b/components/weather/weather-hero.tsx index a419bc9..9542887 100644 --- a/components/weather/weather-hero.tsx +++ b/components/weather/weather-hero.tsx @@ -44,6 +44,7 @@ function readDevWeatherEffectOverride(): DevWeatherEffectOverride | null { export function WeatherHero({ station, currentWeather, currentWeatherLoading = false, locationName, distanceKm }: { station: SynopStation; currentWeather?: ImgwCurrentWeather | null; currentWeatherLoading?: boolean; locationName?: string; distanceKm?: number }) { const { language, t } = useI18n(); + const temperatureUnit = useWeatherStore((state) => state.temperatureUnit); const windSpeedUnit = useWeatherStore((state) => state.windSpeedUnit); const [devEffectOverride, setDevEffectOverride] = useState(null); const displayedLocationName = locationName ?? station.name; @@ -121,10 +122,10 @@ export function WeatherHero({ station, currentWeather, currentWeatherLoading = f
- {formatTemperature(displayedStation.temperature, language)} + {formatTemperature(displayedStation.temperature, language, temperatureUnit)}

{getWeatherDescription(displayedStation, language, currentWeather?.condition)}

-

{t("weather.feelsLike")} {formatTemperature(feelsLike, language)} · {t("weather.measurement")} {formatDateTime(displayedStation.measuredAt, language)}

+

{t("weather.feelsLike")} {formatTemperature(feelsLike, language, temperatureUnit)} · {t("weather.measurement")} {formatDateTime(displayedStation.measuredAt, language)}

diff --git a/docs/notifications.md b/docs/notifications.md index 6bf05a6..a20ab48 100644 --- a/docs/notifications.md +++ b/docs/notifications.md @@ -43,6 +43,7 @@ Subskrypcja Web Push zapisuje: - czy aktywny jest brief na jutro, - współrzędne i nazwę lokalizacji, - opcjonalny powiat TERYT, +- wybraną jednostkę temperatury dla briefów wysyłanych z serwera, - wybraną jednostkę wiatru dla briefów wysyłanych z serwera. Ostrzeżenia meteo są filtrowane po powiecie TERYT, jeśli lokalizacja go dostarcza. W przeciwnym razie używany jest fallback wojewódzki. diff --git a/docs/weather-logic.md b/docs/weather-logic.md index b85968c..84f70d0 100644 --- a/docs/weather-logic.md +++ b/docs/weather-logic.md @@ -79,7 +79,7 @@ Prognoza godzinowa i dzienna rozpoznaje: Bieżąca analiza IMGW Hybrid rozpoznaje bezpośrednio opad deszczu, śnieg i burzę. Gdy żadne z tych zjawisk nie występuje, hero może pokazać pomocniczy opis: `Silny wiatr`, `Wilgotne warunki` albo `Spokojne warunki`. -Jednostka wiatru wybierana w `/settings` dotyczy prezentacji w UI, briefach i powiadomieniach. Dane źródłowe oraz progi logiki pogody pozostają liczone w `m/s`. +Jednostki temperatury i wiatru wybierane w `/settings` dotyczą prezentacji w UI, briefach i powiadomieniach. Dane źródłowe oraz progi logiki pogody pozostają liczone w `°C` i `m/s`. ## Mood i Efekty Wizualne diff --git a/lib/forecast-utils.ts b/lib/forecast-utils.ts index f1609ec..c9a6621 100644 --- a/lib/forecast-utils.ts +++ b/lib/forecast-utils.ts @@ -1,8 +1,8 @@ import type { Language, TranslationKey } from "@/lib/i18n"; import { translate } from "@/lib/i18n"; -import { DEFAULT_WIND_SPEED_UNIT, formatWindSpeed } from "@/lib/weather-utils"; +import { DEFAULT_TEMPERATURE_UNIT, DEFAULT_WIND_SPEED_UNIT, formatTemperature, formatWindSpeed } from "@/lib/weather-utils"; import type { HourlyForecast } from "@/types/forecast"; -import type { WindSpeedUnit } from "@/types/units"; +import type { TemperatureUnit, WindSpeedUnit } from "@/types/units"; export function getForecastConditionKey(code: number | null): TranslationKey { if (code === 0) return "forecast.condition.clear"; @@ -20,9 +20,9 @@ export function getForecastCondition(code: number | null, language: Language) { return translate(language, getForecastConditionKey(code)); } -export function formatForecastTemperature(value: number | null, language: Language) { +export function formatForecastTemperature(value: number | null, language: Language, unit: TemperatureUnit = DEFAULT_TEMPERATURE_UNIT) { if (value === null) return "—"; - return `${new Intl.NumberFormat(language === "pl" ? "pl-PL" : "en-GB", { maximumFractionDigits: 0 }).format(value)}°`; + return formatTemperature(value, language, unit); } export function formatForecastRainfall(value: number | null, language: Language) { diff --git a/lib/i18n.tsx b/lib/i18n.tsx index 3ef8b40..6aacd2f 100644 --- a/lib/i18n.tsx +++ b/lib/i18n.tsx @@ -26,6 +26,11 @@ const translations = { "settings.language.description": "Zmieniaj język interfejsu. Nazwy stacji i treści IMGW pozostają bez automatycznego tłumaczenia.", "settings.theme.title": "Motyw", "settings.theme.description": "Przełącz jasny lub ciemny wygląd aplikacji na tym urządzeniu.", + "settings.units.temperature.title": "Jednostka temperatury", + "settings.units.temperature.description": "Stosowana w prognozie, briefach, powiadomieniach i bieżących pomiarach na tym urządzeniu.", + "settings.units.temperature.label": "Wybierz jednostkę temperatury", + "settings.units.temperature.c": "°C", + "settings.units.temperature.f": "°F", "settings.units.wind.title": "Jednostka wiatru", "settings.units.wind.description": "Stosowana w prognozie, briefach, powiadomieniach i bieżących pomiarach na tym urządzeniu.", "settings.units.wind.label": "Wybierz jednostkę wiatru", @@ -268,6 +273,11 @@ const translations = { "settings.language.description": "Change the interface language. Station names and IMGW content are not translated automatically.", "settings.theme.title": "Theme", "settings.theme.description": "Switch the light or dark appearance on this device.", + "settings.units.temperature.title": "Temperature unit", + "settings.units.temperature.description": "Used in forecasts, briefs, notifications and current readings on this device.", + "settings.units.temperature.label": "Choose temperature unit", + "settings.units.temperature.c": "°C", + "settings.units.temperature.f": "°F", "settings.units.wind.title": "Wind unit", "settings.units.wind.description": "Used in forecasts, briefs, notifications and current readings on this device.", "settings.units.wind.label": "Choose wind unit", diff --git a/lib/notification-api.ts b/lib/notification-api.ts index 8110fc7..bf5c374 100644 --- a/lib/notification-api.ts +++ b/lib/notification-api.ts @@ -1,6 +1,6 @@ import type { Language } from "@/lib/i18n"; import type { Province } from "@/types/province"; -import type { WindSpeedUnit } from "@/types/units"; +import type { TemperatureUnit, WindSpeedUnit } from "@/types/units"; interface VapidKeyResponse { configured: boolean; @@ -20,6 +20,7 @@ interface SavePushSubscriptionOptions { longitude?: number | null; locationName?: string | null; countyTeryt?: string | null; + temperatureUnit?: TemperatureUnit; windSpeedUnit?: WindSpeedUnit; } @@ -38,6 +39,7 @@ export async function savePushSubscription(subscription: PushSubscription, provi longitude: options.longitude ?? null, locationName: options.locationName ?? null, countyTeryt: options.countyTeryt ?? null, + temperatureUnit: options.temperatureUnit, windSpeedUnit: options.windSpeedUnit, }), }); diff --git a/lib/store.ts b/lib/store.ts index 9cfb054..62085fd 100644 --- a/lib/store.ts +++ b/lib/store.ts @@ -4,8 +4,8 @@ import { create } from "zustand"; import { persist } from "zustand/middleware"; import type { SelectedLocation } from "@/types/location"; import type { Province } from "@/types/province"; -import type { WindSpeedUnit } from "@/types/units"; -import { DEFAULT_WIND_SPEED_UNIT } from "@/lib/weather-utils"; +import type { TemperatureUnit, WindSpeedUnit } from "@/types/units"; +import { DEFAULT_TEMPERATURE_UNIT, DEFAULT_WIND_SPEED_UNIT } from "@/lib/weather-utils"; type NotificationProvinceMode = "selected" | "manual"; @@ -18,6 +18,7 @@ interface WeatherStore { tomorrowBriefNotificationsEnabled: boolean; warningNotificationProvinceMode: NotificationProvinceMode; warningNotificationProvince: Province | null; + temperatureUnit: TemperatureUnit; windSpeedUnit: WindSpeedUnit; toggleFavorite: (id: string) => void; selectStation: (id: string) => void; @@ -27,6 +28,7 @@ interface WeatherStore { setTomorrowBriefNotificationsEnabled: (enabled: boolean) => void; setWarningNotificationProvinceMode: (mode: NotificationProvinceMode) => void; setWarningNotificationProvince: (province: Province | null) => void; + setTemperatureUnit: (unit: TemperatureUnit) => void; setWindSpeedUnit: (unit: WindSpeedUnit) => void; } @@ -41,6 +43,7 @@ export const useWeatherStore = create()( tomorrowBriefNotificationsEnabled: false, warningNotificationProvinceMode: "selected", warningNotificationProvince: null, + temperatureUnit: DEFAULT_TEMPERATURE_UNIT, windSpeedUnit: DEFAULT_WIND_SPEED_UNIT, toggleFavorite: (id) => set((state) => ({ @@ -55,6 +58,7 @@ export const useWeatherStore = create()( setTomorrowBriefNotificationsEnabled: (enabled) => set({ tomorrowBriefNotificationsEnabled: enabled }), setWarningNotificationProvinceMode: (mode) => set({ warningNotificationProvinceMode: mode }), setWarningNotificationProvince: (province) => set({ warningNotificationProvince: province }), + setTemperatureUnit: (unit) => set({ temperatureUnit: unit }), setWindSpeedUnit: (unit) => set({ windSpeedUnit: unit }), }), { name: "wtr:preferences" }, diff --git a/lib/weather-brief.ts b/lib/weather-brief.ts index 4bbdaa0..cc54588 100644 --- a/lib/weather-brief.ts +++ b/lib/weather-brief.ts @@ -5,8 +5,8 @@ import type { HourlyForecast, WeatherForecast } from "@/types/forecast"; import type { WeatherWarning } from "@/types/imgw"; import type { Province } from "@/types/province"; import { warningMatchesCounty } from "@/lib/warning-regions"; -import { DEFAULT_WIND_SPEED_UNIT, formatWindSpeed } from "@/lib/weather-utils"; -import type { WindSpeedUnit } from "@/types/units"; +import { DEFAULT_TEMPERATURE_UNIT, DEFAULT_WIND_SPEED_UNIT, formatTemperature as formatDisplayTemperature, formatWindSpeed } from "@/lib/weather-utils"; +import type { TemperatureUnit, WindSpeedUnit } from "@/types/units"; export type WeatherBriefSeverity = "normal" | "attention" | "warning"; @@ -26,6 +26,7 @@ interface BuildWeatherBriefInput { countyTeryt?: string | null; locationName: string; language: Language; + temperatureUnit?: TemperatureUnit; windSpeedUnit?: WindSpeedUnit; now?: Date; } @@ -36,10 +37,6 @@ function formatNumber(value: number, language: Language, digits = 0) { }).format(value); } -function formatTemperature(value: number, language: Language) { - return `${formatNumber(value, language)}°C`; -} - function formatRainfall(value: number, language: Language) { return `${formatNumber(value, language, 1)} mm`; } @@ -202,7 +199,7 @@ function getTomorrowCondition(hours: HourlyForecast[], rainfallTotal: number | n return language === "pl" ? "bez istotnych opadów" : "no significant precipitation"; } -export function buildWeatherBrief({ forecast, warnings, province, countyTeryt, locationName, language, windSpeedUnit = DEFAULT_WIND_SPEED_UNIT, now = new Date() }: BuildWeatherBriefInput): WeatherBrief | null { +export function buildWeatherBrief({ forecast, warnings, province, countyTeryt, locationName, language, temperatureUnit = DEFAULT_TEMPERATURE_UNIT, windSpeedUnit = DEFAULT_WIND_SPEED_UNIT, now = new Date() }: BuildWeatherBriefInput): WeatherBrief | null { const hours = getUpcomingHours(forecast.hourly, now, 24); if (!hours.length) return null; @@ -218,7 +215,7 @@ export function buildWeatherBrief({ forecast, warnings, province, countyTeryt, l const rainPeakHour = getPeakHour(hours, (hour) => hour.precipitationProbability); const conditionPeakHour = hours.find((hour) => hour.weatherCode !== null) ?? null; const temperatureRange = minimumTemperature !== null && maximumTemperature !== null - ? `${formatTemperature(minimumTemperature, language)}-${formatTemperature(maximumTemperature, language)}` + ? `${formatDisplayTemperature(minimumTemperature, language, temperatureUnit)}-${formatDisplayTemperature(maximumTemperature, language, temperatureUnit)}` : null; const hasRainSignal = (maximumProbability ?? 0) >= 35 || (rainfallTotal ?? 0) >= 0.5; const hasWindSignal = (maximumWind ?? 0) >= 8; @@ -295,7 +292,7 @@ export function buildWeatherBrief({ forecast, warnings, province, countyTeryt, l }; } -export function buildTomorrowWeatherBrief({ forecast, warnings, province, countyTeryt, locationName, language, windSpeedUnit = DEFAULT_WIND_SPEED_UNIT, now = new Date() }: BuildWeatherBriefInput): WeatherBrief | null { +export function buildTomorrowWeatherBrief({ forecast, warnings, province, countyTeryt, locationName, language, temperatureUnit = DEFAULT_TEMPERATURE_UNIT, windSpeedUnit = DEFAULT_WIND_SPEED_UNIT, now = new Date() }: BuildWeatherBriefInput): WeatherBrief | null { const targetDateKey = getWarsawDateKey(now, 1); const hours = getForecastHoursForDate(forecast.hourly, targetDateKey); if (!hours.length) return null; @@ -318,7 +315,7 @@ export function buildTomorrowWeatherBrief({ forecast, warnings, province, county const hasWindSignal = (maximumWind ?? 0) >= 8; const condition = getTomorrowCondition(hours, rainfallTotal, maximumProbability, language); const temperatureRange = minimumTemperature !== null && maximumTemperature !== null - ? `${formatTemperature(minimumTemperature, language)} / ${formatTemperature(maximumTemperature, language)}` + ? `${formatDisplayTemperature(minimumTemperature, language, temperatureUnit)} / ${formatDisplayTemperature(maximumTemperature, language, temperatureUnit)}` : null; const severity: WeatherBriefSeverity = topWarning || hasThunderstorm ? "warning" : hasRainSignal || hasWindSignal ? "attention" : "normal"; const provinceLabel = province ? formatProvinceName(province, language) : null; diff --git a/lib/weather-utils.ts b/lib/weather-utils.ts index 17f00d9..14f5693 100644 --- a/lib/weather-utils.ts +++ b/lib/weather-utils.ts @@ -11,12 +11,19 @@ import type { import { translate, type Language } from "@/lib/i18n"; import { getProvinceFromTeryt, normalizeProvinceName } from "@/lib/provinces"; import type { CurrentWeatherCondition } from "@/types/imgw-current"; -import type { WindSpeedUnit } from "@/types/units"; +import type { TemperatureUnit, WindSpeedUnit } from "@/types/units"; const locales: Record = { pl: "pl-PL", en: "en-GB" }; +export const DEFAULT_TEMPERATURE_UNIT: TemperatureUnit = "c"; +export const TEMPERATURE_UNITS: TemperatureUnit[] = ["c", "f"]; export const DEFAULT_WIND_SPEED_UNIT: WindSpeedUnit = "ms"; export const WIND_SPEED_UNITS: WindSpeedUnit[] = ["ms", "kmh", "mph"]; +const temperatureUnitLabels: Record = { + c: "°C", + f: "°F", +}; + const windSpeedUnitLabels: Record = { ms: "m/s", kmh: "km/h", @@ -105,8 +112,28 @@ export function normalizeWarning(raw: RawWarning, kind: WarningKind, index: numb }; } -export function formatTemperature(value: number | null, language: Language = "pl") { - return value === null ? translate(language, "common.noData") : `${Math.round(value)}°`; +export function isTemperatureUnit(value: unknown): value is TemperatureUnit { + return typeof value === "string" && TEMPERATURE_UNITS.includes(value as TemperatureUnit); +} + +export function getTemperatureUnitLabel(unit: TemperatureUnit) { + return temperatureUnitLabels[unit]; +} + +export function convertTemperature(value: number, unit: TemperatureUnit) { + if (unit === "f") return (value * 9) / 5 + 32; + return value; +} + +export function formatTemperatureValue(value: number, language: Language = "pl", unit: TemperatureUnit = DEFAULT_TEMPERATURE_UNIT) { + const formattedValue = new Intl.NumberFormat(locales[language], { + maximumFractionDigits: 0, + }).format(value); + return `${formattedValue}${getTemperatureUnitLabel(unit)}`; +} + +export function formatTemperature(value: number | null, language: Language = "pl", unit: TemperatureUnit = DEFAULT_TEMPERATURE_UNIT) { + return value === null ? translate(language, "common.noData") : formatTemperatureValue(convertTemperature(value, unit), language, unit); } export function formatPressure(value: number | null, language: Language = "pl") { diff --git a/types/notifications.ts b/types/notifications.ts index dfb3bd9..0ffd20e 100644 --- a/types/notifications.ts +++ b/types/notifications.ts @@ -1,6 +1,6 @@ import type { Language } from "@/lib/i18n"; import type { Province } from "@/types/province"; -import type { WindSpeedUnit } from "@/types/units"; +import type { TemperatureUnit, WindSpeedUnit } from "@/types/units"; export interface PushSubscriptionKeys { p256dh: string; @@ -25,6 +25,7 @@ export interface WarningPushSubscription { longitude: number | null; locationName: string | null; countyTeryt: string | null; + temperatureUnit: TemperatureUnit; windSpeedUnit: WindSpeedUnit; createdAt: string; updatedAt: string; diff --git a/types/units.ts b/types/units.ts index fd2f81b..0d71116 100644 --- a/types/units.ts +++ b/types/units.ts @@ -1 +1,2 @@ +export type TemperatureUnit = "c" | "f"; export type WindSpeedUnit = "ms" | "kmh" | "mph";