From 7ad95550eba4dd560ff8f50ea43225071735ecee Mon Sep 17 00:00:00 2001 From: zv Date: Sat, 13 Jun 2026 13:36:36 +0200 Subject: [PATCH] feat: add configurable wind units --- app/api/notifications/daily-brief/route.ts | 2 + app/api/notifications/subscriptions/route.ts | 4 ++ app/api/notifications/tomorrow-brief/route.ts | 2 + components/charts/snapshot-chart.tsx | 8 +++- components/dashboard/weather-brief-card.tsx | 9 +++-- components/forecast/day-forecast-modal.tsx | 4 +- components/forecast/forecast-panel.tsx | 7 +++- components/settings/settings-page.tsx | 38 ++++++++++++++++++- .../weather/current-conditions-card.tsx | 4 +- components/weather/station-card.tsx | 5 ++- components/weather/weather-hero.tsx | 4 +- docs/notifications.md | 3 +- docs/weather-logic.md | 2 + lib/forecast-utils.ts | 9 ++--- lib/i18n.tsx | 12 ++++++ lib/notification-api.ts | 3 ++ lib/store.ts | 6 +++ lib/weather-brief.ts | 27 ++++++------- lib/weather-utils.ts | 38 ++++++++++++++++++- types/notifications.ts | 2 + types/units.ts | 1 + 21 files changed, 152 insertions(+), 38 deletions(-) create mode 100644 types/units.ts diff --git a/app/api/notifications/daily-brief/route.ts b/app/api/notifications/daily-brief/route.ts index 7aef425..28702b7 100644 --- a/app/api/notifications/daily-brief/route.ts +++ b/app/api/notifications/daily-brief/route.ts @@ -4,6 +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"; export const runtime = "nodejs"; @@ -66,6 +67,7 @@ export async function GET(request: Request) { countyTeryt: subscription.countyTeryt, locationName: subscription.locationName ?? "wtr.", language: subscription.language, + windSpeedUnit: subscription.windSpeedUnit ?? DEFAULT_WIND_SPEED_UNIT, now, }); if (!brief) { diff --git a/app/api/notifications/subscriptions/route.ts b/app/api/notifications/subscriptions/route.ts index 952aeb3..1bbfad4 100644 --- a/app/api/notifications/subscriptions/route.ts +++ b/app/api/notifications/subscriptions/route.ts @@ -2,6 +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 type { Language } from "@/lib/i18n"; import type { BrowserPushSubscription } from "@/types/notifications"; @@ -32,10 +33,12 @@ export async function POST(request: Request) { longitude?: unknown; locationName?: unknown; countyTeryt?: 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 rawWindSpeedUnit = body?.windSpeedUnit; if (!isBrowserPushSubscription(subscription) || !province) { return NextResponse.json({ error: "Invalid push subscription." }, { status: 400 }); @@ -54,6 +57,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, + 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 655ee6e..b18aaec 100644 --- a/app/api/notifications/tomorrow-brief/route.ts +++ b/app/api/notifications/tomorrow-brief/route.ts @@ -4,6 +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"; export const runtime = "nodejs"; @@ -76,6 +77,7 @@ export async function GET(request: Request) { countyTeryt: subscription.countyTeryt, locationName: subscription.locationName ?? "wtr.", language: subscription.language, + windSpeedUnit: subscription.windSpeedUnit ?? DEFAULT_WIND_SPEED_UNIT, now, }); if (!brief) { diff --git a/components/charts/snapshot-chart.tsx b/components/charts/snapshot-chart.tsx index e9104e4..adea01d 100644 --- a/components/charts/snapshot-chart.tsx +++ b/components/charts/snapshot-chart.tsx @@ -4,6 +4,8 @@ import { Bar, BarChart, Cell, ResponsiveContainer, Tooltip, XAxis, YAxis } from import type { SynopStation } from "@/types/imgw"; import { Card } from "@/components/ui/card"; import { useI18n } from "@/lib/i18n"; +import { useWeatherStore } from "@/lib/store"; +import { convertWindSpeed, getWindSpeedUnitLabel } from "@/lib/weather-utils"; const INITIAL_CHART_DIMENSION = { width: 1, height: 1 }; const SNAPSHOT_COLORS = [ @@ -14,9 +16,13 @@ const SNAPSHOT_COLORS = [ export function SnapshotChart({ station }: { station: SynopStation }) { const { t } = useI18n(); + const windSpeedUnit = useWeatherStore((state) => state.windSpeedUnit); + const windDigits = windSpeedUnit === "ms" ? 1 : 0; + const windSpeed = station.windSpeed === null ? null : Number(convertWindSpeed(station.windSpeed, windSpeedUnit).toFixed(windDigits)); + const windMax = windSpeedUnit === "ms" ? 20 : windSpeedUnit === "kmh" ? 72 : 45; const rows = [ { name: t("weather.humidity"), value: station.humidity, unit: "%", max: 100, color: SNAPSHOT_COLORS[0] }, - { name: t("weather.wind"), value: station.windSpeed, unit: "m/s", max: 20, color: SNAPSHOT_COLORS[1] }, + { name: t("weather.wind"), value: windSpeed, unit: getWindSpeedUnitLabel(windSpeedUnit), max: windMax, color: SNAPSHOT_COLORS[1] }, { name: t("weather.rainfall"), value: station.rainfall, unit: "mm", max: 30, color: SNAPSHOT_COLORS[2] }, ].filter((row) => row.value !== null).map((row) => ({ ...row, normalized: Math.min(100, ((row.value ?? 0) / row.max) * 100) })); diff --git a/components/dashboard/weather-brief-card.tsx b/components/dashboard/weather-brief-card.tsx index 8989283..b35d5b6 100644 --- a/components/dashboard/weather-brief-card.tsx +++ b/components/dashboard/weather-brief-card.tsx @@ -20,16 +20,17 @@ export function WeatherBriefCard({ latitude, longitude, locationName }: { latitu const { data: warnings = [] } = useWarnings(); const selectedStationId = useWeatherStore((state) => state.selectedStationId); const selectedLocation = useWeatherStore((state) => state.selectedLocation); + 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 }); - }, [forecast, language, locationName, province, selectedLocation?.countyTeryt, warnings]); + return buildWeatherBrief({ forecast, warnings, province, countyTeryt: selectedLocation?.countyTeryt, locationName, language, windSpeedUnit }); + }, [forecast, language, locationName, province, selectedLocation?.countyTeryt, warnings, windSpeedUnit]); const tomorrowBrief = useMemo(() => { if (!forecast) return null; - return buildTomorrowWeatherBrief({ forecast, warnings, province, countyTeryt: selectedLocation?.countyTeryt, locationName, language }); - }, [forecast, language, locationName, province, selectedLocation?.countyTeryt, warnings]); + return buildTomorrowWeatherBrief({ forecast, warnings, province, countyTeryt: selectedLocation?.countyTeryt, locationName, language, windSpeedUnit }); + }, [forecast, language, locationName, province, selectedLocation?.countyTeryt, 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 06d779e..be37097 100644 --- a/components/forecast/day-forecast-modal.tsx +++ b/components/forecast/day-forecast-modal.tsx @@ -15,6 +15,7 @@ import { formatForecastWind, getForecastCondition, } from "@/lib/forecast-utils"; +import { useWeatherStore } from "@/lib/store"; import type { DailyForecast, ForecastSource, HourlyForecast } from "@/types/forecast"; function formatHour(value: string | null) { @@ -53,6 +54,7 @@ export function DayForecastModal({ onClose: () => void; }) { const { language, locale, t } = useI18n(); + const windSpeedUnit = useWeatherStore((state) => state.windSpeedUnit); const closeButtonRef = useRef(null); const portalRoot = typeof document === "undefined" ? null : document.body; const maximumWind = useMemo(() => getMaximumWind(hours), [hours]); @@ -135,7 +137,7 @@ export function DayForecastModal({
- +
diff --git a/components/forecast/forecast-panel.tsx b/components/forecast/forecast-panel.tsx index 864d98d..c7ce70f 100644 --- a/components/forecast/forecast-panel.tsx +++ b/components/forecast/forecast-panel.tsx @@ -21,6 +21,7 @@ import { getHourlyForecastForDay, getUpcomingHourlyForecast, } from "@/lib/forecast-utils"; +import { useWeatherStore } from "@/lib/store"; import type { DailyForecast, HourlyForecast } from "@/types/forecast"; function formatHour(value: string) { @@ -59,6 +60,7 @@ function HourlySummaryMetric({ icon: Icon, label, value }: { icon: LucideIcon; l function HourlyForecastSummary({ hours }: { hours: HourlyForecast[] }) { const { language, t } = useI18n(); + const windSpeedUnit = useWeatherStore((state) => state.windSpeedUnit); const minimumTemperature = getMinimum(hours.map((hour) => hour.temperature)); const maximumTemperature = getMaximum(hours.map((hour) => hour.temperature)); const maximumWind = getMaximum(hours.map((hour) => hour.windSpeed)); @@ -73,7 +75,7 @@ function HourlyForecastSummary({ hours }: { hours: HourlyForecast[] }) {

{t("forecast.nextHoursOverview")}

- +
@@ -113,6 +115,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 windSpeedUnit = useWeatherStore((state) => state.windSpeedUnit); const { data: forecast, isPending, isError, refetch } = useForecast(latitude, longitude); const [selectedDay, setSelectedDay] = useState(null); const closeDayDetails = useCallback(() => setSelectedDay(null), []); @@ -167,7 +170,7 @@ export function ForecastPanel({ latitude, longitude, locationName }: { latitude?

- {formatForecastWind(hour.windSpeed, language)} + {formatForecastWind(hour.windSpeed, language, windSpeedUnit)}

diff --git a/components/settings/settings-page.tsx b/components/settings/settings-page.tsx index 2ea5aee..255a5a3 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 } from "lucide-react"; +import { Bell, BellRing, Clock3, Languages, MapPinned, Palette, Settings, Smartphone, Wind } from "lucide-react"; import type { LucideIcon } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Card } from "@/components/ui/card"; @@ -15,7 +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 type { Province } from "@/types/province"; +import type { WindSpeedUnit } from "@/types/units"; type NotificationSupportStatus = "checking" | "unconfigured" | "unsupported" | "needs-install" | "default" | "denied" | "granted"; @@ -100,6 +102,12 @@ function NotificationSwitchLabel({ icon: Icon, title, description, checked, disa ); } +const windSpeedUnitKeys: Record = { + ms: "settings.units.wind.ms", + kmh: "settings.units.wind.kmh", + mph: "settings.units.wind.mph", +}; + export function SettingsPage() { const { language, t } = useI18n(); const [notificationStatus, setNotificationStatus] = useState("checking"); @@ -116,11 +124,13 @@ export function SettingsPage() { const tomorrowBriefEnabled = useWeatherStore((state) => state.tomorrowBriefNotificationsEnabled); const provinceMode = useWeatherStore((state) => state.warningNotificationProvinceMode); const manualProvince = useWeatherStore((state) => state.warningNotificationProvince); + 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 setWindSpeedUnit = useWeatherStore((state) => state.setWindSpeedUnit); const selectedStation = stations?.find((station) => station.id === selectedStationId) ?? stations?.find((station) => station.id === DEFAULT_STATION_ID) @@ -170,6 +180,7 @@ export function SettingsPage() { longitude: notificationLongitude, locationName: notificationLocationName, countyTeryt: notificationCountyTeryt, + windSpeedUnit, }).catch(() => undefined); } @@ -177,7 +188,7 @@ export function SettingsPage() { return () => { cancelled = true; }; - }, [effectiveProvince, language, morningBriefEnabled, notificationCountyTeryt, notificationLatitude, notificationLocationName, notificationLongitude, notificationStatus, notificationsEnabled, tomorrowBriefEnabled, vapidPublicKey]); + }, [effectiveProvince, language, morningBriefEnabled, notificationCountyTeryt, notificationLatitude, notificationLocationName, notificationLongitude, notificationStatus, notificationsEnabled, tomorrowBriefEnabled, vapidPublicKey, windSpeedUnit]); const notificationStatusLabel = useMemo(() => { switch (notificationStatus) { @@ -236,6 +247,7 @@ export function SettingsPage() { longitude: notificationLongitude, locationName: notificationLocationName, countyTeryt: notificationCountyTeryt, + windSpeedUnit, }); setNotificationsEnabled(true); setNotificationMessage(t("settings.notifications.saveSuccess")); @@ -298,6 +310,7 @@ export function SettingsPage() { longitude: notificationLongitude, locationName: notificationLocationName, countyTeryt: notificationCountyTeryt, + windSpeedUnit, }); } catch { setNotificationMessage(t("settings.notifications.saveError")); @@ -318,6 +331,7 @@ export function SettingsPage() { longitude: notificationLongitude, locationName: notificationLocationName, countyTeryt: notificationCountyTeryt, + windSpeedUnit, }); } catch { setNotificationMessage(t("settings.notifications.saveError")); @@ -339,6 +353,26 @@ export function SettingsPage() { + +

+ {WIND_SPEED_UNITS.map((unit) => ( + + ))} +
+ diff --git a/components/weather/current-conditions-card.tsx b/components/weather/current-conditions-card.tsx index a91b9a9..9126cff 100644 --- a/components/weather/current-conditions-card.tsx +++ b/components/weather/current-conditions-card.tsx @@ -5,15 +5,17 @@ import { calculateFeelsLike, formatHumidity, formatPressure, formatRainfall, for import type { SynopStation } from "@/types/imgw"; import { MetricCard } from "@/components/weather/metric-card"; import { useI18n } from "@/lib/i18n"; +import { useWeatherStore } from "@/lib/store"; export function CurrentConditionsCard({ station }: { station: SynopStation }) { const { language, t } = useI18n(); + 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: 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), detail: t("weather.windSpeedDetail") }, + { 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") }, diff --git a/components/weather/station-card.tsx b/components/weather/station-card.tsx index e1b7240..b13dbf1 100644 --- a/components/weather/station-card.tsx +++ b/components/weather/station-card.tsx @@ -4,7 +4,7 @@ import Link from "next/link"; import { motion } from "framer-motion"; import { Droplets, Gauge, Heart, Wind } from "lucide-react"; import { useWeatherStore } from "@/lib/store"; -import { formatHumidity, formatPressure, formatTemperature, getWeatherMoodFromData } from "@/lib/weather-utils"; +import { formatHumidity, formatPressure, formatTemperature, formatWind, getWeatherMoodFromData } from "@/lib/weather-utils"; import type { SynopStation } from "@/types/imgw"; import { Card } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; @@ -17,9 +17,10 @@ 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 windSpeedUnit = useWeatherStore((state) => state.windSpeedUnit); const favorite = favorites.includes(station.id); const mood = getWeatherMoodFromData(station); - const compactWind = station.windSpeed === null ? "—" : `${station.windSpeed.toFixed(1)} m/s`; + const compactWind = station.windSpeed === null ? "—" : formatWind(station.windSpeed, null, language, windSpeedUnit); return ( diff --git a/components/weather/weather-hero.tsx b/components/weather/weather-hero.tsx index a89dda0..a419bc9 100644 --- a/components/weather/weather-hero.tsx +++ b/components/weather/weather-hero.tsx @@ -19,6 +19,7 @@ import type { ImgwCurrentWeather } from "@/types/imgw-current"; import { WeatherIcon } from "@/components/weather/weather-icon"; import { WeatherEffects } from "@/components/weather/weather-effects"; import { useI18n } from "@/lib/i18n"; +import { useWeatherStore } from "@/lib/store"; type DevWeatherEffectOverride = "rain" | "thunderstorm" | "none"; @@ -43,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 windSpeedUnit = useWeatherStore((state) => state.windSpeedUnit); const [devEffectOverride, setDevEffectOverride] = useState(null); const displayedLocationName = locationName ?? station.name; const hasFullHybridAnalysis = currentWeather?.coverage === "full" || currentWeather?.coverage === "hourly"; @@ -63,7 +65,7 @@ export function WeatherHero({ station, currentWeather, currentWeatherLoading = f const feelsLike = currentWeather?.feelsLike ?? calculateFeelsLike(displayedStation.temperature, displayedStation.humidity, displayedStation.windSpeed); const metrics = [ { icon: Droplets, label: t("weather.humidity"), value: formatHumidity(displayedStation.humidity, language) }, - { icon: Wind, label: t("weather.wind"), value: formatWind(displayedStation.windSpeed, null, language) }, + { icon: Wind, label: t("weather.wind"), value: formatWind(displayedStation.windSpeed, null, language, windSpeedUnit) }, { icon: Umbrella, label: currentWeather?.precipitation10m !== null && currentWeather?.precipitation10m !== undefined ? t("weather.rainfall10m") : t("weather.rainfallTotal"), value: formatRainfall(displayedStation.rainfall, language) }, { icon: Gauge, label: t("weather.pressure"), value: formatPressure(displayedStation.pressure, language) }, ]; diff --git a/docs/notifications.md b/docs/notifications.md index 2a4222c..6bf05a6 100644 --- a/docs/notifications.md +++ b/docs/notifications.md @@ -42,7 +42,8 @@ Subskrypcja Web Push zapisuje: - czy aktywny jest brief poranny, - czy aktywny jest brief na jutro, - współrzędne i nazwę lokalizacji, -- opcjonalny powiat TERYT. +- opcjonalny powiat TERYT, +- 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 3ed9d00..b85968c 100644 --- a/docs/weather-logic.md +++ b/docs/weather-logic.md @@ -79,6 +79,8 @@ 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`. + ## Mood i Efekty Wizualne Hero aktualnej pogody używa uproszczonego moodu do wyboru ikony, tekstu i małego akcentu stanu. diff --git a/lib/forecast-utils.ts b/lib/forecast-utils.ts index 56fd340..f1609ec 100644 --- a/lib/forecast-utils.ts +++ b/lib/forecast-utils.ts @@ -1,6 +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 type { HourlyForecast } from "@/types/forecast"; +import type { WindSpeedUnit } from "@/types/units"; export function getForecastConditionKey(code: number | null): TranslationKey { if (code === 0) return "forecast.condition.clear"; @@ -28,12 +30,9 @@ export function formatForecastRainfall(value: number | null, language: Language) return `${new Intl.NumberFormat(language === "pl" ? "pl-PL" : "en-GB", { maximumFractionDigits: 1 }).format(value)} mm`; } -export function formatForecastWind(value: number | null, language: Language) { +export function formatForecastWind(value: number | null, language: Language, unit: WindSpeedUnit = DEFAULT_WIND_SPEED_UNIT) { if (value === null) return "—"; - return `${new Intl.NumberFormat(language === "pl" ? "pl-PL" : "en-GB", { - minimumFractionDigits: 1, - maximumFractionDigits: 1, - }).format(value)} m/s`; + return formatWindSpeed(value, language, unit); } function getWarsawForecastHour(date = new Date()) { diff --git a/lib/i18n.tsx b/lib/i18n.tsx index 74231fa..3ef8b40 100644 --- a/lib/i18n.tsx +++ b/lib/i18n.tsx @@ -26,6 +26,12 @@ 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.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", + "settings.units.wind.ms": "m/s", + "settings.units.wind.kmh": "km/h", + "settings.units.wind.mph": "mph", "settings.notifications.title": "Powiadomienia o ostrzeżeniach meteo", "settings.notifications.description": "Przygotuj alerty dla nowych ostrzeżeń meteorologicznych IMGW, takich jak burze, upał, silny wiatr lub intensywny deszcz.", "settings.notifications.regionTitle": "Obszar alertów", @@ -262,6 +268,12 @@ 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.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", + "settings.units.wind.ms": "m/s", + "settings.units.wind.kmh": "km/h", + "settings.units.wind.mph": "mph", "settings.notifications.title": "Weather warning notifications", "settings.notifications.description": "Prepare alerts for new IMGW meteorological warnings, such as thunderstorms, heat, strong wind or heavy rain.", "settings.notifications.regionTitle": "Alert area", diff --git a/lib/notification-api.ts b/lib/notification-api.ts index b94d52c..8110fc7 100644 --- a/lib/notification-api.ts +++ b/lib/notification-api.ts @@ -1,5 +1,6 @@ import type { Language } from "@/lib/i18n"; import type { Province } from "@/types/province"; +import type { WindSpeedUnit } from "@/types/units"; interface VapidKeyResponse { configured: boolean; @@ -19,6 +20,7 @@ interface SavePushSubscriptionOptions { longitude?: number | null; locationName?: string | null; countyTeryt?: string | null; + windSpeedUnit?: WindSpeedUnit; } export async function savePushSubscription(subscription: PushSubscription, province: Province, language: Language, options: SavePushSubscriptionOptions = {}) { @@ -36,6 +38,7 @@ export async function savePushSubscription(subscription: PushSubscription, provi longitude: options.longitude ?? null, locationName: options.locationName ?? null, countyTeryt: options.countyTeryt ?? null, + windSpeedUnit: options.windSpeedUnit, }), }); if (!response.ok) throw new Error("Unable to save push subscription."); diff --git a/lib/store.ts b/lib/store.ts index d04eabe..9cfb054 100644 --- a/lib/store.ts +++ b/lib/store.ts @@ -4,6 +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"; type NotificationProvinceMode = "selected" | "manual"; @@ -16,6 +18,7 @@ interface WeatherStore { tomorrowBriefNotificationsEnabled: boolean; warningNotificationProvinceMode: NotificationProvinceMode; warningNotificationProvince: Province | null; + windSpeedUnit: WindSpeedUnit; toggleFavorite: (id: string) => void; selectStation: (id: string) => void; selectLocation: (location: SelectedLocation) => void; @@ -24,6 +27,7 @@ interface WeatherStore { setTomorrowBriefNotificationsEnabled: (enabled: boolean) => void; setWarningNotificationProvinceMode: (mode: NotificationProvinceMode) => void; setWarningNotificationProvince: (province: Province | null) => void; + setWindSpeedUnit: (unit: WindSpeedUnit) => void; } export const useWeatherStore = create()( @@ -37,6 +41,7 @@ export const useWeatherStore = create()( tomorrowBriefNotificationsEnabled: false, warningNotificationProvinceMode: "selected", warningNotificationProvince: null, + windSpeedUnit: DEFAULT_WIND_SPEED_UNIT, toggleFavorite: (id) => set((state) => ({ favorites: state.favorites.includes(id) @@ -50,6 +55,7 @@ export const useWeatherStore = create()( setTomorrowBriefNotificationsEnabled: (enabled) => set({ tomorrowBriefNotificationsEnabled: enabled }), setWarningNotificationProvinceMode: (mode) => set({ warningNotificationProvinceMode: mode }), setWarningNotificationProvince: (province) => set({ warningNotificationProvince: province }), + setWindSpeedUnit: (unit) => set({ windSpeedUnit: unit }), }), { name: "wtr:preferences" }, ), diff --git a/lib/weather-brief.ts b/lib/weather-brief.ts index 887d5bf..4bbdaa0 100644 --- a/lib/weather-brief.ts +++ b/lib/weather-brief.ts @@ -5,6 +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"; export type WeatherBriefSeverity = "normal" | "attention" | "warning"; @@ -24,6 +26,7 @@ interface BuildWeatherBriefInput { countyTeryt?: string | null; locationName: string; language: Language; + windSpeedUnit?: WindSpeedUnit; now?: Date; } @@ -37,14 +40,6 @@ function formatTemperature(value: number, language: Language) { return `${formatNumber(value, language)}°C`; } -function formatWind(value: number, language: Language) { - return language === "pl" ? `${formatNumber(value, language, 1)} m/s` : `${formatNumber(value, language, 1)} m/s`; -} - -function formatWindKmh(value: number, language: Language) { - return `${formatNumber(value * 3.6, language)} km/h`; -} - function formatRainfall(value: number, language: Language) { return `${formatNumber(value, language, 1)} mm`; } @@ -207,7 +202,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, now = new Date() }: BuildWeatherBriefInput): WeatherBrief | null { +export function buildWeatherBrief({ forecast, warnings, province, countyTeryt, locationName, language, windSpeedUnit = DEFAULT_WIND_SPEED_UNIT, now = new Date() }: BuildWeatherBriefInput): WeatherBrief | null { const hours = getUpcomingHours(forecast.hourly, now, 24); if (!hours.length) return null; @@ -271,8 +266,8 @@ export function buildWeatherBrief({ forecast, warnings, province, countyTeryt, l } if (maximumWind !== null) { body.push(language === "pl" - ? `Wiatr maksymalnie do ${formatWind(maximumWind, language)}.` - : `Wind up to ${formatWind(maximumWind, language)}.`); + ? `Wiatr maksymalnie do ${formatWindSpeed(maximumWind, language, windSpeedUnit)}.` + : `Wind up to ${formatWindSpeed(maximumWind, language, windSpeedUnit)}.`); } if (conditionPeakHour) { body.push(language === "pl" @@ -284,7 +279,7 @@ export function buildWeatherBrief({ forecast, warnings, province, countyTeryt, l `${locationName}:`, temperatureRange, maximumProbability !== null ? (language === "pl" ? `opad do ${maximumProbability}%` : `rain up to ${maximumProbability}%`) : null, - maximumWind !== null ? (language === "pl" ? `wiatr ${formatWind(maximumWind, language)}` : `wind ${formatWind(maximumWind, language)}`) : null, + maximumWind !== null ? (language === "pl" ? `wiatr ${formatWindSpeed(maximumWind, language, windSpeedUnit)}` : `wind ${formatWindSpeed(maximumWind, language, windSpeedUnit)}`) : null, ].filter(Boolean); const warningPrefix = topWarning ? language === "pl" ? `IMGW: ${topWarning.title || "ostrzeżenie"}. ` : `IMGW: ${topWarning.title || "warning"}. ` @@ -300,7 +295,7 @@ export function buildWeatherBrief({ forecast, warnings, province, countyTeryt, l }; } -export function buildTomorrowWeatherBrief({ forecast, warnings, province, countyTeryt, locationName, language, now = new Date() }: BuildWeatherBriefInput): WeatherBrief | null { +export function buildTomorrowWeatherBrief({ forecast, warnings, province, countyTeryt, locationName, language, 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; @@ -378,8 +373,8 @@ export function buildTomorrowWeatherBrief({ forecast, warnings, province, county } if (maximumWind !== null) { body.push(language === "pl" - ? `Wiatr maksymalnie do ${formatWindKmh(maximumWind, language)}.` - : `Wind up to ${formatWindKmh(maximumWind, language)}.`); + ? `Wiatr maksymalnie do ${formatWindSpeed(maximumWind, language, windSpeedUnit)}.` + : `Wind up to ${formatWindSpeed(maximumWind, language, windSpeedUnit)}.`); } const rainPushPart = (hasRainSignal || (rainfallTotal ?? 0) > 0.1) && rainfallTotal !== null @@ -390,7 +385,7 @@ export function buildTomorrowWeatherBrief({ forecast, warnings, province, county temperatureRange, condition, rainPushPart, - maximumWind !== null ? (language === "pl" ? `wiatr ${formatWindKmh(maximumWind, language)}` : `wind ${formatWindKmh(maximumWind, language)}`) : null, + maximumWind !== null ? (language === "pl" ? `wiatr ${formatWindSpeed(maximumWind, language, windSpeedUnit)}` : `wind ${formatWindSpeed(maximumWind, language, windSpeedUnit)}`) : null, ].filter(Boolean); const warningPrefix = topWarning ? language === "pl" ? `IMGW: ${topWarning.title || "ostrzeżenie"}. ` : `IMGW: ${topWarning.title || "warning"}. ` diff --git a/lib/weather-utils.ts b/lib/weather-utils.ts index 03e3b4b..17f00d9 100644 --- a/lib/weather-utils.ts +++ b/lib/weather-utils.ts @@ -11,8 +11,17 @@ 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"; const locales: Record = { pl: "pl-PL", en: "en-GB" }; +export const DEFAULT_WIND_SPEED_UNIT: WindSpeedUnit = "ms"; +export const WIND_SPEED_UNITS: WindSpeedUnit[] = ["ms", "kmh", "mph"]; + +const windSpeedUnitLabels: Record = { + ms: "m/s", + kmh: "km/h", + mph: "mph", +}; export function toNumber(value: unknown): number | null { if (typeof value === "number") return Number.isFinite(value) ? value : null; @@ -108,10 +117,35 @@ export function formatHumidity(value: number | null, language: Language = "pl") return value === null ? translate(language, "common.noData") : `${Math.round(value)}%`; } -export function formatWind(speed: number | null, direction?: number | null, language: Language = "pl") { +export function isWindSpeedUnit(value: unknown): value is WindSpeedUnit { + return typeof value === "string" && WIND_SPEED_UNITS.includes(value as WindSpeedUnit); +} + +export function getWindSpeedUnitLabel(unit: WindSpeedUnit) { + return windSpeedUnitLabels[unit]; +} + +export function convertWindSpeed(speed: number, unit: WindSpeedUnit) { + if (unit === "kmh") return speed * 3.6; + if (unit === "mph") return speed * 2.2369362921; + return speed; +} + +export function formatWindSpeed(speed: number | null, language: Language = "pl", unit: WindSpeedUnit = DEFAULT_WIND_SPEED_UNIT) { + if (speed === null) return translate(language, "common.noData"); + const convertedSpeed = convertWindSpeed(speed, unit); + const digits = unit === "ms" ? 1 : 0; + const formattedSpeed = new Intl.NumberFormat(locales[language], { + minimumFractionDigits: digits, + maximumFractionDigits: digits, + }).format(convertedSpeed); + return `${formattedSpeed} ${getWindSpeedUnitLabel(unit)}`; +} + +export function formatWind(speed: number | null, direction?: number | null, language: Language = "pl", unit: WindSpeedUnit = DEFAULT_WIND_SPEED_UNIT) { if (speed === null) return translate(language, "common.noData"); const directionLabel = direction === null || direction === undefined ? "" : ` ${getWindDirection(direction)}`; - return `${speed.toFixed(1)} m/s${directionLabel}`; + return `${formatWindSpeed(speed, language, unit)}${directionLabel}`; } export function formatRainfall(value: number | null, language: Language = "pl") { diff --git a/types/notifications.ts b/types/notifications.ts index 6edf52b..dfb3bd9 100644 --- a/types/notifications.ts +++ b/types/notifications.ts @@ -1,5 +1,6 @@ import type { Language } from "@/lib/i18n"; import type { Province } from "@/types/province"; +import type { WindSpeedUnit } from "@/types/units"; export interface PushSubscriptionKeys { p256dh: string; @@ -24,6 +25,7 @@ export interface WarningPushSubscription { longitude: number | null; locationName: string | null; countyTeryt: string | null; + windSpeedUnit: WindSpeedUnit; createdAt: string; updatedAt: string; } diff --git a/types/units.ts b/types/units.ts new file mode 100644 index 0000000..fd2f81b --- /dev/null +++ b/types/units.ts @@ -0,0 +1 @@ +export type WindSpeedUnit = "ms" | "kmh" | "mph";