From 91acdb39b86b405eddd857e7dd15b605fff87713 Mon Sep 17 00:00:00 2001 From: zv Date: Sat, 4 Jul 2026 20:16:11 +0200 Subject: [PATCH] feat: add weather unit preferences --- AGENTS.md | 2 +- app/api/notifications/daily-brief/route.ts | 3 +- app/api/notifications/subscriptions/route.ts | 15 ++ app/api/notifications/tomorrow-brief/route.ts | 3 +- components/charts/day-forecast-charts.tsx | 31 +++- components/charts/snapshot-chart.tsx | 27 +++- components/dashboard/weather-brief-card.tsx | 5 + components/forecast/day-forecast-modal.tsx | 3 +- components/forecast/forecast-panel.tsx | 10 +- components/settings/settings-page.tsx | 153 +++++++++++++++++- .../weather/current-conditions-card.tsx | 6 +- components/weather/location-search.tsx | 6 +- components/weather/station-card.tsx | 3 +- components/weather/weather-hero.tsx | 19 ++- docs/notifications.md | 3 +- docs/weather-logic.md | 2 +- lib/forecast-utils.ts | 12 +- lib/i18n.tsx | 58 ++++++- lib/notification-api.ts | 8 +- lib/push-store.ts | 50 ++++++ lib/store.ts | 39 ++++- lib/weather-brief.ts | 27 ++-- lib/weather-utils.ts | 145 ++++++++++++++++- tests/weather-brief.test.ts | 22 ++- tests/weather-utils.test.ts | 37 +++++ types/notifications.ts | 5 +- types/units.ts | 5 +- 27 files changed, 623 insertions(+), 76 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 53b0ebe..09ac84e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -54,7 +54,7 @@ Testy jednostkowe uruchamia `npm run test` przez Vitest, a `npm run test:watch` - 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, dla `PL`, 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. +- Preferencje jednostek temperatury, wiatru, opadu, ciśnienia i dystansu są ustawieniami prezentacyjnymi. Dane źródłowe oraz progi logiki pogody pozostają liczone w `°C`, `m/s`, `mm`, `hPa` i `km`, 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. Briefy są deduplikowane po lokalnej dacie subskrypcji i wysyłane po godzinie skonfigurowanej względem zapisanej strefy czasowej lokalizacji. Wymagają kluczy VAPID w zmiennych środowiskowych. iOS/iPadOS wymaga PWA z ekranu początkowego, ale Android i desktop nie powinny być blokowane wymogiem `standalone`. `lib/push-store.ts` zapisuje subskrypcje i historię wysyłek w SQLite wskazanym przez `WTR_DATABASE_PATH`, domyślnie `./data/wtr.sqlite`. - 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 f650648..b2dd92c 100644 --- a/app/api/notifications/daily-brief/route.ts +++ b/app/api/notifications/daily-brief/route.ts @@ -9,7 +9,7 @@ import { } from "@/lib/push-store"; import { isWebPushConfigured, sendMorningBriefNotification } from "@/lib/push-service"; import { buildWeatherBrief } from "@/lib/weather-brief"; -import { DEFAULT_TEMPERATURE_UNIT, DEFAULT_WIND_SPEED_UNIT } from "@/lib/weather-utils"; +import { DEFAULT_PRECIPITATION_UNIT, DEFAULT_TEMPERATURE_UNIT, DEFAULT_WIND_SPEED_UNIT } from "@/lib/weather-utils"; export const runtime = "nodejs"; @@ -113,6 +113,7 @@ export async function GET(request: Request) { language: subscription.language, temperatureUnit: subscription.temperatureUnit ?? DEFAULT_TEMPERATURE_UNIT, windSpeedUnit: subscription.windSpeedUnit ?? DEFAULT_WIND_SPEED_UNIT, + precipitationUnit: subscription.precipitationUnit ?? DEFAULT_PRECIPITATION_UNIT, now, }); if (!brief) { diff --git a/app/api/notifications/subscriptions/route.ts b/app/api/notifications/subscriptions/route.ts index dc11b9b..fd3e23c 100644 --- a/app/api/notifications/subscriptions/route.ts +++ b/app/api/notifications/subscriptions/route.ts @@ -3,8 +3,14 @@ import { upsertPushSubscription, removePushSubscription } from "@/lib/push-store import { isWebPushConfigured } from "@/lib/push-service"; import { normalizeProvinceName } from "@/lib/provinces"; import { + DEFAULT_DISTANCE_UNIT, + DEFAULT_PRECIPITATION_UNIT, + DEFAULT_PRESSURE_UNIT, DEFAULT_TEMPERATURE_UNIT, DEFAULT_WIND_SPEED_UNIT, + isDistanceUnit, + isPrecipitationUnit, + isPressureUnit, isTemperatureUnit, isWindSpeedUnit, } from "@/lib/weather-utils"; @@ -45,6 +51,9 @@ export async function POST(request: Request) { countyTeryt?: unknown; temperatureUnit?: unknown; windSpeedUnit?: unknown; + precipitationUnit?: unknown; + pressureUnit?: unknown; + distanceUnit?: unknown; } | null; const subscription = body?.subscription; const region: WeatherRegion = body?.region === "GLOBAL" ? "GLOBAL" : "PL"; @@ -52,6 +61,9 @@ export async function POST(request: Request) { const language: Language = body?.language === "en" ? "en" : "pl"; const rawTemperatureUnit = body?.temperatureUnit; const rawWindSpeedUnit = body?.windSpeedUnit; + const rawPrecipitationUnit = body?.precipitationUnit; + const rawPressureUnit = body?.pressureUnit; + const rawDistanceUnit = body?.distanceUnit; const enabled = body?.enabled !== false && region === "PL"; if (!isBrowserPushSubscription(subscription) || (enabled && !province)) { @@ -76,6 +88,9 @@ export async function POST(request: Request) { 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, + precipitationUnit: isPrecipitationUnit(rawPrecipitationUnit) ? rawPrecipitationUnit : DEFAULT_PRECIPITATION_UNIT, + pressureUnit: isPressureUnit(rawPressureUnit) ? rawPressureUnit : DEFAULT_PRESSURE_UNIT, + distanceUnit: isDistanceUnit(rawDistanceUnit) ? rawDistanceUnit : DEFAULT_DISTANCE_UNIT, createdAt: now, updatedAt: now, }); diff --git a/app/api/notifications/tomorrow-brief/route.ts b/app/api/notifications/tomorrow-brief/route.ts index b0d6630..c6bdd11 100644 --- a/app/api/notifications/tomorrow-brief/route.ts +++ b/app/api/notifications/tomorrow-brief/route.ts @@ -9,7 +9,7 @@ import { } from "@/lib/push-store"; import { isWebPushConfigured, sendTomorrowBriefNotification } from "@/lib/push-service"; import { buildTomorrowWeatherBrief } from "@/lib/weather-brief"; -import { DEFAULT_TEMPERATURE_UNIT, DEFAULT_WIND_SPEED_UNIT } from "@/lib/weather-utils"; +import { DEFAULT_PRECIPITATION_UNIT, DEFAULT_TEMPERATURE_UNIT, DEFAULT_WIND_SPEED_UNIT } from "@/lib/weather-utils"; export const runtime = "nodejs"; @@ -125,6 +125,7 @@ export async function GET(request: Request) { language: subscription.language, temperatureUnit: subscription.temperatureUnit ?? DEFAULT_TEMPERATURE_UNIT, windSpeedUnit: subscription.windSpeedUnit ?? DEFAULT_WIND_SPEED_UNIT, + precipitationUnit: subscription.precipitationUnit ?? DEFAULT_PRECIPITATION_UNIT, now, }); if (!brief) { diff --git a/components/charts/day-forecast-charts.tsx b/components/charts/day-forecast-charts.tsx index 5bc8e08..690310c 100644 --- a/components/charts/day-forecast-charts.tsx +++ b/components/charts/day-forecast-charts.tsx @@ -5,9 +5,15 @@ import { Card } from "@/components/ui/card"; import { ChartTooltip } from "@/components/charts/chart-tooltip"; import { CHART_COLORS } from "@/lib/chart-theme"; import { useI18n } from "@/lib/i18n"; -import { formatForecastRainfall, formatForecastTemperature } from "@/lib/forecast-utils"; +import { formatForecastTemperature } from "@/lib/forecast-utils"; import { useWeatherStore } from "@/lib/store"; -import { convertTemperature, formatTemperatureValue, getTemperatureUnitLabel } from "@/lib/weather-utils"; +import { + convertPrecipitation, + convertTemperature, + formatTemperatureValue, + getPrecipitationUnitLabel, + getTemperatureUnitLabel, +} from "@/lib/weather-utils"; import type { HourlyForecast } from "@/types/forecast"; const INITIAL_CHART_DIMENSION = { width: 1, height: 1 }; @@ -22,6 +28,15 @@ function formatHour(value: string) { return value.slice(11, 16); } +function formatChartPrecipitation(value: number | null, language: "pl" | "en", unit: string) { + if (value === null) return "—"; + const formattedValue = new Intl.NumberFormat(language === "pl" ? "pl-PL" : "en-GB", { + minimumFractionDigits: unit === "in" ? 2 : 0, + maximumFractionDigits: unit === "mm" ? (value < 1 ? 2 : 1) : 2, + }).format(value); + return `${formattedValue} ${unit}`; +} + function ChartLegend({ items }: { items: ChartLegendItem[] }) { return (
@@ -46,14 +61,16 @@ function ChartLegend({ items }: { items: ChartLegendItem[] }) { export function DayForecastCharts({ hours }: { hours: HourlyForecast[] }) { const { language, t } = useI18n(); const temperatureUnit = useWeatherStore((state) => state.temperatureUnit); + const precipitationUnit = useWeatherStore((state) => state.precipitationUnit); const rows = hours.map((hour) => ({ time: formatHour(hour.time), temperature: hour.temperature === null ? null : convertTemperature(hour.temperature, temperatureUnit), feelsLike: hour.feelsLike === null ? null : convertTemperature(hour.feelsLike, temperatureUnit), - precipitation: hour.precipitation, + precipitation: hour.precipitation === null ? null : convertPrecipitation(hour.precipitation, precipitationUnit), precipitationProbability: hour.precipitationProbability, })); const temperatureUnitLabel = getTemperatureUnitLabel(temperatureUnit); + const precipitationUnitLabel = getPrecipitationUnitLabel(precipitationUnit); const temperatureLegendItems: ChartLegendItem[] = [ { color: CHART_COLORS.temperature, label: t("forecast.temperature"), marker: "line" }, { color: CHART_COLORS.feelsLike, label: t("forecast.apparentTemperature"), marker: "dashed-line" }, @@ -153,7 +170,7 @@ export function DayForecastCharts({ hours }: { hours: HourlyForecast[] }) { axisLine={false} tickLine={false} tick={{ fill: "currentColor", fontSize: 11 }} - unit=" mm" + unit={` ${precipitationUnitLabel}`} /> entry.dataKey === "precipitation" - ? formatForecastRainfall(typeof entry.value === "number" ? entry.value : null, language) + ? formatChartPrecipitation( + typeof entry.value === "number" ? entry.value : null, + language, + precipitationUnitLabel, + ) : `${typeof entry.value === "number" ? entry.value : "—"}%` } /> diff --git a/components/charts/snapshot-chart.tsx b/components/charts/snapshot-chart.tsx index d492623..fe15074 100644 --- a/components/charts/snapshot-chart.tsx +++ b/components/charts/snapshot-chart.tsx @@ -6,7 +6,13 @@ import { ChartTooltip } from "@/components/charts/chart-tooltip"; import { Card } from "@/components/ui/card"; import { useI18n } from "@/lib/i18n"; import { useWeatherStore } from "@/lib/store"; -import { convertWindSpeed, getWindSpeedUnitLabel } from "@/lib/weather-utils"; +import { + convertPrecipitation, + convertWindSpeed, + convertWindSpeedToBeaufort, + getPrecipitationUnitLabel, + getWindSpeedUnitLabel, +} from "@/lib/weather-utils"; const INITIAL_CHART_DIMENSION = { width: 1, height: 1 }; const SNAPSHOT_COLORS = ["hsl(var(--chart-temperature))", "hsl(var(--chart-feels-like))", "hsl(var(--chart-rainfall))"]; @@ -14,10 +20,17 @@ const SNAPSHOT_COLORS = ["hsl(var(--chart-temperature))", "hsl(var(--chart-feels export function SnapshotChart({ station }: { station: SynopStation }) { const { t } = useI18n(); const windSpeedUnit = useWeatherStore((state) => state.windSpeedUnit); + const precipitationUnit = useWeatherStore((state) => state.precipitationUnit); 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; + station.windSpeed === null + ? null + : windSpeedUnit === "bft" + ? convertWindSpeedToBeaufort(station.windSpeed) + : Number(convertWindSpeed(station.windSpeed, windSpeedUnit).toFixed(windDigits)); + const windMax = windSpeedUnit === "ms" ? 20 : windSpeedUnit === "kmh" ? 72 : windSpeedUnit === "bft" ? 12 : 45; + const rainfall = + station.rainfall === null ? null : Number(convertPrecipitation(station.rainfall, precipitationUnit).toFixed(2)); const rows = [ { name: t("weather.humidity"), value: station.humidity, unit: "%", max: 100, color: SNAPSHOT_COLORS[0] }, { @@ -27,7 +40,13 @@ export function SnapshotChart({ station }: { station: SynopStation }) { max: windMax, color: SNAPSHOT_COLORS[1], }, - { name: t("weather.rainfall"), value: station.rainfall, unit: "mm", max: 30, color: SNAPSHOT_COLORS[2] }, + { + name: t("weather.rainfall"), + value: rainfall, + unit: getPrecipitationUnitLabel(precipitationUnit), + max: convertPrecipitation(30, precipitationUnit), + 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 d08f478..099aea3 100644 --- a/components/dashboard/weather-brief-card.tsx +++ b/components/dashboard/weather-brief-card.tsx @@ -33,6 +33,7 @@ export function WeatherBriefCard({ const selectedLocation = useWeatherStore((state) => state.selectedLocation); const temperatureUnit = useWeatherStore((state) => state.temperatureUnit); const windSpeedUnit = useWeatherStore((state) => state.windSpeedUnit); + const precipitationUnit = useWeatherStore((state) => state.precipitationUnit); const isGlobalLocation = region === "GLOBAL"; const province = isGlobalLocation ? null @@ -50,6 +51,7 @@ export function WeatherBriefCard({ language, temperatureUnit, windSpeedUnit, + precipitationUnit, }); }, [ forecast, @@ -61,6 +63,7 @@ export function WeatherBriefCard({ selectedLocation?.countyTeryt, temperatureUnit, windSpeedUnit, + precipitationUnit, ]); const tomorrowBrief = useMemo(() => { if (!forecast) return null; @@ -73,6 +76,7 @@ export function WeatherBriefCard({ language, temperatureUnit, windSpeedUnit, + precipitationUnit, }); }, [ forecast, @@ -84,6 +88,7 @@ export function WeatherBriefCard({ selectedLocation?.countyTeryt, temperatureUnit, windSpeedUnit, + precipitationUnit, ]); if (!Number.isFinite(latitude) || !Number.isFinite(longitude) || isPending) { diff --git a/components/forecast/day-forecast-modal.tsx b/components/forecast/day-forecast-modal.tsx index 7ea3777..9a4cc0e 100644 --- a/components/forecast/day-forecast-modal.tsx +++ b/components/forecast/day-forecast-modal.tsx @@ -58,6 +58,7 @@ export function DayForecastModal({ const { language, locale, t } = useI18n(); const temperatureUnit = useWeatherStore((state) => state.temperatureUnit); const windSpeedUnit = useWeatherStore((state) => state.windSpeedUnit); + const precipitationUnit = useWeatherStore((state) => state.precipitationUnit); const closeButtonRef = useRef(null); const portalRoot = typeof document === "undefined" ? null : document.body; const maximumWind = useMemo(() => getMaximumWind(hours), [hours]); @@ -150,7 +151,7 @@ export function DayForecastModal({ state.temperatureUnit); const windSpeedUnit = useWeatherStore((state) => state.windSpeedUnit); + const precipitationUnit = useWeatherStore((state) => state.precipitationUnit); const minimumTemperature = getMinimum(hours.map((hour) => hour.temperature)); const maximumTemperature = getMaximum(hours.map((hour) => hour.temperature)); const maximumWind = getMaximum(hours.map((hour) => hour.windSpeed)); @@ -102,7 +103,7 @@ function HourlyForecastSummary({ hours }: { hours: HourlyForecast[] }) { state.temperatureUnit); + const precipitationUnit = useWeatherStore((state) => state.precipitationUnit); const label = formatDay(day.date, locale, t("forecast.today"), index); return ( - {getForecastCondition(day.weatherCode, language)} · {formatForecastRainfall(day.precipitation, language)} + {getForecastCondition(day.weatherCode, language)} ·{" "} + {formatForecastRainfall(day.precipitation, language, precipitationUnit)}
@@ -177,6 +180,7 @@ export function ForecastPanel({ const { language, t } = useI18n(); const temperatureUnit = useWeatherStore((state) => state.temperatureUnit); const windSpeedUnit = useWeatherStore((state) => state.windSpeedUnit); + const precipitationUnit = useWeatherStore((state) => state.precipitationUnit); const { data: forecast, isPending, isError, refetch } = useForecast(latitude, longitude, region); const [selectedDay, setSelectedDay] = useState(null); const closeDayDetails = useCallback(() => setSelectedDay(null), []); @@ -261,7 +265,7 @@ export function ForecastPanel({

- {formatForecastRainfall(hour.precipitation, language)} + {formatForecastRainfall(hour.precipitation, language, precipitationUnit)}

diff --git a/components/settings/settings-page.tsx b/components/settings/settings-page.tsx index 163c767..81457b4 100644 --- a/components/settings/settings-page.tsx +++ b/components/settings/settings-page.tsx @@ -6,10 +6,13 @@ import { BellRing, ChevronDown, Clock3, + CloudRain, + Gauge, Languages, LayoutDashboard, MapPinned, Palette, + Ruler, Settings, Smartphone, Thermometer, @@ -36,8 +39,14 @@ import { } from "@/lib/notification-api"; import { formatProvinceName, getProvinceForSelection, PROVINCES } from "@/lib/provinces"; import { useWeatherStore } from "@/lib/store"; -import { TEMPERATURE_UNITS, WIND_SPEED_UNITS } from "@/lib/weather-utils"; -import type { TemperatureUnit, WindSpeedUnit } from "@/types/units"; +import { + DISTANCE_UNITS, + PRECIPITATION_UNITS, + PRESSURE_UNITS, + TEMPERATURE_UNITS, + WIND_SPEED_UNITS, +} from "@/lib/weather-utils"; +import type { DistanceUnit, PrecipitationUnit, PressureUnit, TemperatureUnit, WindSpeedUnit } from "@/types/units"; type NotificationSupportStatus = | "checking" @@ -154,11 +163,17 @@ function NotificationSwitchLabel({ const windSpeedUnitKeys: Record< WindSpeedUnit, - "settings.units.wind.ms" | "settings.units.wind.kmh" | "settings.units.wind.mph" + | "settings.units.wind.ms" + | "settings.units.wind.kmh" + | "settings.units.wind.mph" + | "settings.units.wind.kt" + | "settings.units.wind.bft" > = { ms: "settings.units.wind.ms", kmh: "settings.units.wind.kmh", mph: "settings.units.wind.mph", + kt: "settings.units.wind.kt", + bft: "settings.units.wind.bft", }; const temperatureUnitKeys: Record = { @@ -166,6 +181,33 @@ const temperatureUnitKeys: Record = { + mm: "settings.units.precipitation.mm", + cm: "settings.units.precipitation.cm", + in: "settings.units.precipitation.in", +}; + +const pressureUnitKeys: Record< + PressureUnit, + | "settings.units.pressure.hpa" + | "settings.units.pressure.kpa" + | "settings.units.pressure.inhg" + | "settings.units.pressure.mmhg" +> = { + hpa: "settings.units.pressure.hpa", + kpa: "settings.units.pressure.kpa", + inhg: "settings.units.pressure.inhg", + mmhg: "settings.units.pressure.mmhg", +}; + +const distanceUnitKeys: Record = { + km: "settings.units.distance.km", + mi: "settings.units.distance.mi", +}; + export function SettingsPage() { const { language, t } = useI18n(); const [notificationStatus, setNotificationStatus] = useState("checking"); @@ -185,6 +227,9 @@ export function SettingsPage() { const manualProvince = useWeatherStore((state) => state.warningNotificationProvince); const temperatureUnit = useWeatherStore((state) => state.temperatureUnit); const windSpeedUnit = useWeatherStore((state) => state.windSpeedUnit); + const precipitationUnit = useWeatherStore((state) => state.precipitationUnit); + const pressureUnit = useWeatherStore((state) => state.pressureUnit); + const distanceUnit = useWeatherStore((state) => state.distanceUnit); const dashboardSections = useWeatherStore((state) => state.dashboardSections); const appSections = useWeatherStore((state) => state.appSections); const setNotificationsEnabled = useWeatherStore((state) => state.setWarningNotificationsEnabled); @@ -194,6 +239,9 @@ export function SettingsPage() { const setManualProvince = useWeatherStore((state) => state.setWarningNotificationProvince); const setTemperatureUnit = useWeatherStore((state) => state.setTemperatureUnit); const setWindSpeedUnit = useWeatherStore((state) => state.setWindSpeedUnit); + const setPrecipitationUnit = useWeatherStore((state) => state.setPrecipitationUnit); + const setPressureUnit = useWeatherStore((state) => state.setPressureUnit); + const setDistanceUnit = useWeatherStore((state) => state.setDistanceUnit); const setDashboardSectionVisible = useWeatherStore((state) => state.setDashboardSectionVisible); const setAppSectionVisible = useWeatherStore((state) => state.setAppSectionVisible); @@ -268,6 +316,9 @@ export function SettingsPage() { countyTeryt: notificationCountyTeryt, temperatureUnit, windSpeedUnit, + precipitationUnit, + pressureUnit, + distanceUnit, }).catch(() => undefined); } @@ -289,6 +340,9 @@ export function SettingsPage() { notificationTimezone, selectedRegion, temperatureUnit, + precipitationUnit, + pressureUnit, + distanceUnit, tomorrowBriefEnabled, vapidPublicKey, windSpeedUnit, @@ -376,6 +430,9 @@ export function SettingsPage() { countyTeryt: notificationCountyTeryt, temperatureUnit, windSpeedUnit, + precipitationUnit, + pressureUnit, + distanceUnit, }); setNotificationsEnabled(true); setNotificationMessage(t("settings.notifications.saveSuccess")); @@ -444,6 +501,9 @@ export function SettingsPage() { countyTeryt: notificationCountyTeryt, temperatureUnit, windSpeedUnit, + precipitationUnit, + pressureUnit, + distanceUnit, }); } catch { setNotificationMessage(t("settings.notifications.saveError")); @@ -470,6 +530,9 @@ export function SettingsPage() { countyTeryt: notificationCountyTeryt, temperatureUnit, windSpeedUnit, + precipitationUnit, + pressureUnit, + distanceUnit, }); } catch { setNotificationMessage(t("settings.notifications.saveError")); @@ -554,6 +617,90 @@ export function SettingsPage() { ))} + +
+ {PRECIPITATION_UNITS.map((unit) => ( + + ))} +
+
+ +
+ {PRESSURE_UNITS.map((unit) => ( + + ))} +
+
+ +
+ {DISTANCE_UNITS.map((unit) => ( + + ))} +
+
diff --git a/components/weather/current-conditions-card.tsx b/components/weather/current-conditions-card.tsx index bc36ebf..83a40ea 100644 --- a/components/weather/current-conditions-card.tsx +++ b/components/weather/current-conditions-card.tsx @@ -19,6 +19,8 @@ export function CurrentConditionsCard({ station }: { station: SynopStation }) { const { language, t } = useI18n(); const temperatureUnit = useWeatherStore((state) => state.temperatureUnit); const windSpeedUnit = useWeatherStore((state) => state.windSpeedUnit); + const precipitationUnit = useWeatherStore((state) => state.precipitationUnit); + const pressureUnit = useWeatherStore((state) => state.pressureUnit); const feelsLike = calculateFeelsLike(station.temperature, station.humidity, station.windSpeed); const metrics = [ { @@ -36,7 +38,7 @@ export function CurrentConditionsCard({ station }: { station: SynopStation }) { { icon: Gauge, label: t("weather.pressure"), - value: formatPressure(station.pressure, language), + value: formatPressure(station.pressure, language, pressureUnit), detail: t("weather.pressureDetail"), }, { @@ -57,7 +59,7 @@ export function CurrentConditionsCard({ station }: { station: SynopStation }) { { icon: Umbrella, label: t("weather.rainfallTotal"), - value: formatRainfall(station.rainfall, language), + value: formatRainfall(station.rainfall, language, precipitationUnit), detail: t("weather.rainfallDetail"), }, { diff --git a/components/weather/location-search.tsx b/components/weather/location-search.tsx index 394aaed..31bb7dd 100644 --- a/components/weather/location-search.tsx +++ b/components/weather/location-search.tsx @@ -7,6 +7,7 @@ import { useLocationSearch } from "@/hooks/use-location-search"; import { useI18n } from "@/lib/i18n"; import { createSelectedLocation, locateSynopStations } from "@/lib/location-utils"; import { useWeatherStore } from "@/lib/store"; +import { formatDistance } from "@/lib/weather-utils"; import type { MeteoStationPosition, SynopStation } from "@/types/imgw"; import type { SelectedLocation } from "@/types/location"; @@ -21,6 +22,7 @@ export function LocationSearch({ const [query, setQuery] = useState(""); const [isFocused, setIsFocused] = useState(false); const selectedLocation = useWeatherStore((state) => state.selectedLocation); + const distanceUnit = useWeatherStore((state) => state.distanceUnit); const selectLocation = useWeatherStore((state) => state.selectLocation); const { data: locations, isFetching, isError } = useLocationSearch(query, language); const locatedStations = useMemo(() => locateSynopStations(stations, positions), [positions, stations]); @@ -112,7 +114,7 @@ export function LocationSearch({ {t("location.nearest")}
- {selected.stationName} · {selected.distanceKm} km + {selected.stationName} · {formatDistance(selected.distanceKm, language, distanceUnit)}
) : ( @@ -135,7 +137,7 @@ export function LocationSearch({ ? t("location.currentSource", { location: selectedLocation.name, station: selectedLocation.stationName, - distance: selectedLocation.distanceKm, + distance: formatDistance(selectedLocation.distanceKm, language, distanceUnit), }) : t("location.currentSourceGlobal", { location: selectedLocation.name })}

diff --git a/components/weather/station-card.tsx b/components/weather/station-card.tsx index 6e266fb..ea33c1a 100644 --- a/components/weather/station-card.tsx +++ b/components/weather/station-card.tsx @@ -25,6 +25,7 @@ export function StationCard({ station, index = 0 }: { station: SynopStation; ind const selectStation = useWeatherStore((state) => state.selectStation); const temperatureUnit = useWeatherStore((state) => state.temperatureUnit); const windSpeedUnit = useWeatherStore((state) => state.windSpeedUnit); + const pressureUnit = useWeatherStore((state) => state.pressureUnit); const favorite = favorites.includes(station.id); const mood = getWeatherMoodFromData(station); const compactWind = station.windSpeed === null ? "—" : formatWind(station.windSpeed, null, language, windSpeedUnit); @@ -77,7 +78,7 @@ export function StationCard({ station, index = 0 }: { station: SynopStation; ind - {station.pressure === null ? "—" : formatPressure(station.pressure, language).split(" ")[0]} + {station.pressure === null ? "—" : formatPressure(station.pressure, language, pressureUnit)} diff --git a/components/weather/weather-hero.tsx b/components/weather/weather-hero.tsx index 1ad2438..a9bd019 100644 --- a/components/weather/weather-hero.tsx +++ b/components/weather/weather-hero.tsx @@ -6,6 +6,7 @@ import { AlertTriangle, Droplets, Gauge, MapPin, Navigation, Umbrella, Wind } fr import { calculateFeelsLike, formatDateTime, + formatDistance, formatHumidity, formatPressure, formatRainfall, @@ -49,6 +50,9 @@ export function WeatherHero({ const { language, t } = useI18n(); const temperatureUnit = useWeatherStore((state) => state.temperatureUnit); const windSpeedUnit = useWeatherStore((state) => state.windSpeedUnit); + const precipitationUnit = useWeatherStore((state) => state.precipitationUnit); + const pressureUnit = useWeatherStore((state) => state.pressureUnit); + const distanceUnit = useWeatherStore((state) => state.distanceUnit); const [devEffectOverride, setDevEffectOverride] = useState(null); const displayedLocationName = locationName ?? station.name; const hasGlobalModel = currentWeather?.coverage === "global-model"; @@ -61,6 +65,7 @@ export function WeatherHero({ distanceKm !== undefined && distanceKm !== null && distanceKm >= 30; + const formattedDistance = formatDistance(distanceKm ?? null, language, distanceUnit); const displayedStation = currentWeather ? { ...station, @@ -91,9 +96,13 @@ export function WeatherHero({ : currentWeather?.precipitation10m !== null && currentWeather?.precipitation10m !== undefined ? t("weather.rainfall10m") : t("weather.rainfallTotal"), - value: formatRainfall(displayedStation.rainfall, language), + value: formatRainfall(displayedStation.rainfall, language, precipitationUnit), + }, + { + icon: Gauge, + label: t("weather.pressure"), + value: formatPressure(displayedStation.pressure, language, pressureUnit), }, - { icon: Gauge, label: t("weather.pressure"), value: formatPressure(displayedStation.pressure, language) }, ]; const effectPrecipitation10m = devEffectOverride === "rain" || devEffectOverride === "thunderstorm" @@ -149,16 +158,16 @@ export function WeatherHero({ : hasFullHybridAnalysis ? t("location.heroHybridSource", { location: displayedLocationName }) : hasPartialHybridAnalysis - ? t("location.heroHybridPartial", { station: station.name, distance: distanceKm ?? 0 }) + ? t("location.heroHybridPartial", { station: station.name, distance: formattedDistance }) : locationName ? t("location.heroStationFallbackWithDistance", { station: station.name, - distance: distanceKm ?? 0, + distance: formattedDistance, }) : t("location.heroStationFallback", { station: station.name })}

{hasFullHybridAnalysis && locationName && ( -

{t("location.heroNearestStation", { station: station.name, distance: distanceKm ?? 0 })}

+

{t("location.heroNearestStation", { station: station.name, distance: formattedDistance })}

)} {hasDistantFallback && (

diff --git a/docs/notifications.md b/docs/notifications.md index 4d136e1..2f7a355 100644 --- a/docs/notifications.md +++ b/docs/notifications.md @@ -52,8 +52,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. +- wybrane jednostki prezentacyjne dla briefów wysyłanych z serwera: temperaturę, wiatr, opad, ciśnienie i dystans. 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 9f29b4d..b927870 100644 --- a/docs/weather-logic.md +++ b/docs/weather-logic.md @@ -120,7 +120,7 @@ Pole `Cloud` z IMGW Hybrid jest używane jako opis nieba: | `>= 25` | Częściowe zachmurzenie | | `< 25` | brak osobnego opisu zachmurzenia | -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`. +Jednostki temperatury, wiatru, opadu, ciśnienia i dystansu wybierane w `/settings` dotyczą prezentacji w UI, briefach i powiadomieniach. Dane źródłowe oraz progi logiki pogody pozostają liczone w `°C`, `m/s`, `mm`, `hPa` i `km`. ## Mood i Efekty Wizualne diff --git a/lib/forecast-utils.ts b/lib/forecast-utils.ts index 471e6c0..f69bbeb 100644 --- a/lib/forecast-utils.ts +++ b/lib/forecast-utils.ts @@ -3,11 +3,13 @@ import { translate } from "@/lib/i18n"; import { DEFAULT_TEMPERATURE_UNIT, DEFAULT_WIND_SPEED_UNIT, + DEFAULT_PRECIPITATION_UNIT, + formatPrecipitation, formatTemperature, formatWindSpeed, } from "@/lib/weather-utils"; import type { DailyForecast, HourlyForecast } from "@/types/forecast"; -import type { TemperatureUnit, WindSpeedUnit } from "@/types/units"; +import type { PrecipitationUnit, TemperatureUnit, WindSpeedUnit } from "@/types/units"; export function getForecastConditionKey(code: number | null): TranslationKey { if (code === 0) return "forecast.condition.clear"; @@ -47,9 +49,13 @@ export function formatForecastTemperature( return formatTemperature(value, language, unit); } -export function formatForecastRainfall(value: number | null, language: Language) { +export function formatForecastRainfall( + value: number | null, + language: Language, + unit: PrecipitationUnit = DEFAULT_PRECIPITATION_UNIT, +) { if (value === null) return "—"; - return `${new Intl.NumberFormat(language === "pl" ? "pl-PL" : "en-GB", { maximumFractionDigits: 1 }).format(value)} mm`; + return formatPrecipitation(value, language, unit); } export function formatForecastWind( diff --git a/lib/i18n.tsx b/lib/i18n.tsx index fa8e9c6..7049538 100644 --- a/lib/i18n.tsx +++ b/lib/i18n.tsx @@ -54,6 +54,27 @@ const translations = { "settings.units.wind.ms": "m/s", "settings.units.wind.kmh": "km/h", "settings.units.wind.mph": "mph", + "settings.units.wind.kt": "kt", + "settings.units.wind.bft": "Bft", + "settings.units.precipitation.title": "Jednostka opadu", + "settings.units.precipitation.description": + "Stosowana w prognozie, briefach, powiadomieniach i bieżących pomiarach na tym urządzeniu.", + "settings.units.precipitation.label": "Wybierz jednostkę opadu", + "settings.units.precipitation.mm": "mm", + "settings.units.precipitation.cm": "cm", + "settings.units.precipitation.in": "in", + "settings.units.pressure.title": "Jednostka ciśnienia", + "settings.units.pressure.description": "Stosowana w bieżących pomiarach na tym urządzeniu.", + "settings.units.pressure.label": "Wybierz jednostkę ciśnienia", + "settings.units.pressure.hpa": "hPa", + "settings.units.pressure.kpa": "kPa", + "settings.units.pressure.inhg": "inHg", + "settings.units.pressure.mmhg": "mm Hg", + "settings.units.distance.title": "Jednostka dystansu", + "settings.units.distance.description": "Stosowana przy odległości do najbliższej stacji.", + "settings.units.distance.label": "Wybierz jednostkę dystansu", + "settings.units.distance.km": "km", + "settings.units.distance.mi": "mi", "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.", @@ -121,17 +142,17 @@ const translations = { "location.nearest": "Najbliższa stacja IMGW", "location.modelSource": "Źródło modelowe", "location.currentSource": - "{location}: współrzędne miejscowości są używane dla lokalnej analizy IMGW Hybrid. Najbliższa stacja pomiarowa IMGW: {station} · około {distance} km.", + "{location}: współrzędne miejscowości są używane dla lokalnej analizy IMGW Hybrid. Najbliższa stacja pomiarowa IMGW: {station} · około {distance}.", "location.currentSourceGlobal": "{location}: współrzędne są używane dla modelowych warunków bieżących i prognozy Open-Meteo.", "location.heroHybridSource": "Analiza IMGW Hybrid dla lokalizacji: {location}", "location.heroGlobalModelSource": "Modelowe warunki bieżące Open-Meteo dla lokalizacji: {location}", "location.heroHybridLoading": "Pobieram lokalną analizę IMGW Hybrid. Tymczasowo pokazuję odczyt stacji: {station}.", "location.heroHybridPartial": - "Lokalna analiza opadu IMGW Hybrid. Pozostałe parametry zastępczo ze stacji IMGW: {station} · około {distance} km", - "location.heroNearestStation": "Najbliższa stacja pomiarowa IMGW: {station} · około {distance} km", + "Lokalna analiza opadu IMGW Hybrid. Pozostałe parametry zastępczo ze stacji IMGW: {station} · około {distance}", + "location.heroNearestStation": "Najbliższa stacja pomiarowa IMGW: {station} · około {distance}", "location.heroStationFallback": "Dane zastępcze ze stacji IMGW: {station}", - "location.heroStationFallbackWithDistance": "Dane zastępcze ze stacji IMGW: {station} · około {distance} km", + "location.heroStationFallbackWithDistance": "Dane zastępcze ze stacji IMGW: {station} · około {distance}", "location.heroDistantFallback": "Stacja jest oddalona od lokalizacji. Lokalne warunki mogą się różnić.", "location.attribution": "Wyszukiwanie miejscowości:", "location.gpsUse": "Użyj mojej lokalizacji", @@ -344,6 +365,27 @@ const translations = { "settings.units.wind.ms": "m/s", "settings.units.wind.kmh": "km/h", "settings.units.wind.mph": "mph", + "settings.units.wind.kt": "kt", + "settings.units.wind.bft": "Bft", + "settings.units.precipitation.title": "Precipitation unit", + "settings.units.precipitation.description": + "Used in forecasts, briefs, notifications and current readings on this device.", + "settings.units.precipitation.label": "Choose precipitation unit", + "settings.units.precipitation.mm": "mm", + "settings.units.precipitation.cm": "cm", + "settings.units.precipitation.in": "in", + "settings.units.pressure.title": "Pressure unit", + "settings.units.pressure.description": "Used in current readings on this device.", + "settings.units.pressure.label": "Choose pressure unit", + "settings.units.pressure.hpa": "hPa", + "settings.units.pressure.kpa": "kPa", + "settings.units.pressure.inhg": "inHg", + "settings.units.pressure.mmhg": "mm Hg", + "settings.units.distance.title": "Distance unit", + "settings.units.distance.description": "Used for the distance to the nearest station.", + "settings.units.distance.label": "Choose distance unit", + "settings.units.distance.km": "km", + "settings.units.distance.mi": "mi", "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.", @@ -410,7 +452,7 @@ const translations = { "location.nearest": "Nearest IMGW station", "location.modelSource": "Model source", "location.currentSource": - "{location}: the place coordinates are used for local IMGW Hybrid analysis. Nearest IMGW measurement station: {station} · approximately {distance} km away.", + "{location}: the place coordinates are used for local IMGW Hybrid analysis. Nearest IMGW measurement station: {station} · approximately {distance} away.", "location.heroHybridSource": "IMGW Hybrid analysis for: {location}", "location.currentSourceGlobal": "{location}: coordinates are used for Open-Meteo model current conditions and forecast.", @@ -418,11 +460,11 @@ const translations = { "location.heroHybridLoading": "Loading local IMGW Hybrid analysis. Temporarily showing the station reading: {station}.", "location.heroHybridPartial": - "Local IMGW Hybrid rainfall analysis. Other parameters use fallback data from IMGW station: {station} · approximately {distance} km away", - "location.heroNearestStation": "Nearest IMGW measurement station: {station} · approximately {distance} km away", + "Local IMGW Hybrid rainfall analysis. Other parameters use fallback data from IMGW station: {station} · approximately {distance} away", + "location.heroNearestStation": "Nearest IMGW measurement station: {station} · approximately {distance} away", "location.heroStationFallback": "Fallback data from IMGW station: {station}", "location.heroStationFallbackWithDistance": - "Fallback data from IMGW station: {station} · approximately {distance} km away", + "Fallback data from IMGW station: {station} · approximately {distance} away", "location.heroDistantFallback": "The station is far from this place. Local conditions may differ.", "location.attribution": "Place search:", "location.gpsUse": "Use my location", diff --git a/lib/notification-api.ts b/lib/notification-api.ts index 6d123de..9e189fd 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 { TemperatureUnit, WindSpeedUnit } from "@/types/units"; +import type { DistanceUnit, PrecipitationUnit, PressureUnit, TemperatureUnit, WindSpeedUnit } from "@/types/units"; import type { WeatherRegion } from "@/types/weather-region"; interface VapidKeyResponse { @@ -26,6 +26,9 @@ interface SavePushSubscriptionOptions { countyTeryt?: string | null; temperatureUnit?: TemperatureUnit; windSpeedUnit?: WindSpeedUnit; + precipitationUnit?: PrecipitationUnit; + pressureUnit?: PressureUnit; + distanceUnit?: DistanceUnit; } export async function savePushSubscription( @@ -52,6 +55,9 @@ export async function savePushSubscription( countyTeryt: options.countyTeryt ?? null, temperatureUnit: options.temperatureUnit, windSpeedUnit: options.windSpeedUnit, + precipitationUnit: options.precipitationUnit, + pressureUnit: options.pressureUnit, + distanceUnit: options.distanceUnit, }), }); if (!response.ok) throw new Error("Unable to save push subscription."); diff --git a/lib/push-store.ts b/lib/push-store.ts index 1360edb..9e80d1e 100644 --- a/lib/push-store.ts +++ b/lib/push-store.ts @@ -2,8 +2,14 @@ import Database from "better-sqlite3"; import fs from "node:fs"; import path from "node:path"; import { + DEFAULT_DISTANCE_UNIT, + DEFAULT_PRECIPITATION_UNIT, + DEFAULT_PRESSURE_UNIT, DEFAULT_TEMPERATURE_UNIT, DEFAULT_WIND_SPEED_UNIT, + isDistanceUnit, + isPrecipitationUnit, + isPressureUnit, isTemperatureUnit, isWindSpeedUnit, } from "@/lib/weather-utils"; @@ -31,6 +37,9 @@ interface PushSubscriptionRow { county_teryt: string | null; temperature_unit: string | null; wind_speed_unit: string | null; + precipitation_unit: string | null; + pressure_unit: string | null; + distance_unit: string | null; created_at: string; updated_at: string; } @@ -71,6 +80,9 @@ function initializeDatabase(database: Database.Database) { county_teryt TEXT, temperature_unit TEXT NOT NULL DEFAULT '${DEFAULT_TEMPERATURE_UNIT}', wind_speed_unit TEXT NOT NULL DEFAULT '${DEFAULT_WIND_SPEED_UNIT}', + precipitation_unit TEXT NOT NULL DEFAULT '${DEFAULT_PRECIPITATION_UNIT}', + pressure_unit TEXT NOT NULL DEFAULT '${DEFAULT_PRESSURE_UNIT}', + distance_unit TEXT NOT NULL DEFAULT '${DEFAULT_DISTANCE_UNIT}', created_at TEXT NOT NULL, updated_at TEXT NOT NULL ); @@ -106,6 +118,27 @@ function initializeDatabase(database: Database.Database) { if (!columns.some((column) => column.name === "timezone")) { database.prepare("ALTER TABLE push_subscriptions ADD COLUMN timezone TEXT").run(); } + if (!columns.some((column) => column.name === "precipitation_unit")) { + database + .prepare( + `ALTER TABLE push_subscriptions ADD COLUMN precipitation_unit TEXT NOT NULL DEFAULT '${DEFAULT_PRECIPITATION_UNIT}'`, + ) + .run(); + } + if (!columns.some((column) => column.name === "pressure_unit")) { + database + .prepare( + `ALTER TABLE push_subscriptions ADD COLUMN pressure_unit TEXT NOT NULL DEFAULT '${DEFAULT_PRESSURE_UNIT}'`, + ) + .run(); + } + if (!columns.some((column) => column.name === "distance_unit")) { + database + .prepare( + `ALTER TABLE push_subscriptions ADD COLUMN distance_unit TEXT NOT NULL DEFAULT '${DEFAULT_DISTANCE_UNIT}'`, + ) + .run(); + } } function getDatabase() { @@ -139,6 +172,11 @@ function parseSubscription(row: PushSubscriptionRow): WarningPushSubscription | countyTeryt: row.county_teryt, temperatureUnit: isTemperatureUnit(row.temperature_unit) ? row.temperature_unit : DEFAULT_TEMPERATURE_UNIT, windSpeedUnit: isWindSpeedUnit(row.wind_speed_unit) ? row.wind_speed_unit : DEFAULT_WIND_SPEED_UNIT, + precipitationUnit: isPrecipitationUnit(row.precipitation_unit) + ? row.precipitation_unit + : DEFAULT_PRECIPITATION_UNIT, + pressureUnit: isPressureUnit(row.pressure_unit) ? row.pressure_unit : DEFAULT_PRESSURE_UNIT, + distanceUnit: isDistanceUnit(row.distance_unit) ? row.distance_unit : DEFAULT_DISTANCE_UNIT, createdAt: row.created_at, updatedAt: row.updated_at, }; @@ -167,6 +205,9 @@ export function upsertPushSubscription(subscription: WarningPushSubscription) { county_teryt, temperature_unit, wind_speed_unit, + precipitation_unit, + pressure_unit, + distance_unit, created_at, updated_at ) VALUES ( @@ -185,6 +226,9 @@ export function upsertPushSubscription(subscription: WarningPushSubscription) { @countyTeryt, @temperatureUnit, @windSpeedUnit, + @precipitationUnit, + @pressureUnit, + @distanceUnit, @createdAt, @updatedAt ) @@ -203,6 +247,9 @@ export function upsertPushSubscription(subscription: WarningPushSubscription) { county_teryt = excluded.county_teryt, temperature_unit = excluded.temperature_unit, wind_speed_unit = excluded.wind_speed_unit, + precipitation_unit = excluded.precipitation_unit, + pressure_unit = excluded.pressure_unit, + distance_unit = excluded.distance_unit, updated_at = excluded.updated_at `, ) @@ -222,6 +269,9 @@ export function upsertPushSubscription(subscription: WarningPushSubscription) { countyTeryt: subscription.countyTeryt, temperatureUnit: subscription.temperatureUnit, windSpeedUnit: subscription.windSpeedUnit, + precipitationUnit: subscription.precipitationUnit, + pressureUnit: subscription.pressureUnit, + distanceUnit: subscription.distanceUnit, createdAt: subscription.createdAt, updatedAt: subscription.updatedAt, }); diff --git a/lib/store.ts b/lib/store.ts index 18a2dec..8f3394e 100644 --- a/lib/store.ts +++ b/lib/store.ts @@ -4,7 +4,7 @@ import { create } from "zustand"; import { persist } from "zustand/middleware"; import type { SelectedLocation } from "@/types/location"; import type { Province } from "@/types/province"; -import type { TemperatureUnit, WindSpeedUnit } from "@/types/units"; +import type { DistanceUnit, PrecipitationUnit, PressureUnit, TemperatureUnit, WindSpeedUnit } from "@/types/units"; import { DEFAULT_APP_SECTION_VISIBILITY, normalizeAppSectionVisibility, @@ -17,7 +17,18 @@ import { type DashboardSectionId, type DashboardSectionVisibility, } from "@/lib/dashboard-sections"; -import { DEFAULT_TEMPERATURE_UNIT, DEFAULT_WIND_SPEED_UNIT } from "@/lib/weather-utils"; +import { + DEFAULT_DISTANCE_UNIT, + DEFAULT_PRECIPITATION_UNIT, + DEFAULT_PRESSURE_UNIT, + DEFAULT_TEMPERATURE_UNIT, + DEFAULT_WIND_SPEED_UNIT, + isDistanceUnit, + isPrecipitationUnit, + isPressureUnit, + isTemperatureUnit, + isWindSpeedUnit, +} from "@/lib/weather-utils"; import type { WeatherRegion } from "@/types/weather-region"; import { getWeatherRegionForCountryCode } from "@/types/weather-region"; @@ -34,6 +45,9 @@ interface WeatherStore { warningNotificationProvince: Province | null; temperatureUnit: TemperatureUnit; windSpeedUnit: WindSpeedUnit; + precipitationUnit: PrecipitationUnit; + pressureUnit: PressureUnit; + distanceUnit: DistanceUnit; dashboardSections: DashboardSectionVisibility; appSections: AppSectionVisibility; toggleFavorite: (id: string) => void; @@ -46,6 +60,9 @@ interface WeatherStore { setWarningNotificationProvince: (province: Province | null) => void; setTemperatureUnit: (unit: TemperatureUnit) => void; setWindSpeedUnit: (unit: WindSpeedUnit) => void; + setPrecipitationUnit: (unit: PrecipitationUnit) => void; + setPressureUnit: (unit: PressureUnit) => void; + setDistanceUnit: (unit: DistanceUnit) => void; setDashboardSectionVisible: (section: DashboardSectionId, visible: boolean) => void; setAppSectionVisible: (section: AppSectionId, visible: boolean) => void; } @@ -63,6 +80,9 @@ export const useWeatherStore = create()( warningNotificationProvince: null, temperatureUnit: DEFAULT_TEMPERATURE_UNIT, windSpeedUnit: DEFAULT_WIND_SPEED_UNIT, + precipitationUnit: DEFAULT_PRECIPITATION_UNIT, + pressureUnit: DEFAULT_PRESSURE_UNIT, + distanceUnit: DEFAULT_DISTANCE_UNIT, dashboardSections: DEFAULT_DASHBOARD_SECTION_VISIBILITY, appSections: DEFAULT_APP_SECTION_VISIBILITY, toggleFavorite: (id) => @@ -80,6 +100,9 @@ export const useWeatherStore = create()( setWarningNotificationProvince: (province) => set({ warningNotificationProvince: province }), setTemperatureUnit: (unit) => set({ temperatureUnit: unit }), setWindSpeedUnit: (unit) => set({ windSpeedUnit: unit }), + setPrecipitationUnit: (unit) => set({ precipitationUnit: unit }), + setPressureUnit: (unit) => set({ pressureUnit: unit }), + setDistanceUnit: (unit) => set({ distanceUnit: unit }), setDashboardSectionVisible: (section, visible) => set((state) => ({ dashboardSections: { ...state.dashboardSections, [section]: visible }, @@ -100,11 +123,21 @@ export const useWeatherStore = create()( if (!state) return persisted; const dashboardSections = normalizeDashboardSectionVisibility(state.dashboardSections); const appSections = normalizeAppSectionVisibility(state.appSections); - if (!location) return { ...state, dashboardSections, appSections }; + const unitPreferences = { + temperatureUnit: isTemperatureUnit(state.temperatureUnit) ? state.temperatureUnit : DEFAULT_TEMPERATURE_UNIT, + windSpeedUnit: isWindSpeedUnit(state.windSpeedUnit) ? state.windSpeedUnit : DEFAULT_WIND_SPEED_UNIT, + precipitationUnit: isPrecipitationUnit(state.precipitationUnit) + ? state.precipitationUnit + : DEFAULT_PRECIPITATION_UNIT, + pressureUnit: isPressureUnit(state.pressureUnit) ? state.pressureUnit : DEFAULT_PRESSURE_UNIT, + distanceUnit: isDistanceUnit(state.distanceUnit) ? state.distanceUnit : DEFAULT_DISTANCE_UNIT, + }; + if (!location) return { ...state, ...unitPreferences, dashboardSections, appSections }; const countryCode = location.countryCode ?? (location.province ? "PL" : null); const region = location.region ?? getWeatherRegionForCountryCode(countryCode); return { ...state, + ...unitPreferences, dashboardSections, appSections, selectedLocation: { diff --git a/lib/weather-brief.ts b/lib/weather-brief.ts index ad6e368..b20bde7 100644 --- a/lib/weather-brief.ts +++ b/lib/weather-brief.ts @@ -8,10 +8,12 @@ import { warningMatchesCounty } from "@/lib/warning-regions"; import { DEFAULT_TEMPERATURE_UNIT, DEFAULT_WIND_SPEED_UNIT, + DEFAULT_PRECIPITATION_UNIT, + formatPrecipitation as formatDisplayPrecipitation, formatTemperature as formatDisplayTemperature, formatWindSpeed, } from "@/lib/weather-utils"; -import type { TemperatureUnit, WindSpeedUnit } from "@/types/units"; +import type { PrecipitationUnit, TemperatureUnit, WindSpeedUnit } from "@/types/units"; export type WeatherBriefSeverity = "normal" | "attention" | "warning"; @@ -33,19 +35,10 @@ interface BuildWeatherBriefInput { language: Language; temperatureUnit?: TemperatureUnit; windSpeedUnit?: WindSpeedUnit; + precipitationUnit?: PrecipitationUnit; now?: Date; } -function formatNumber(value: number, language: Language, digits = 0) { - return new Intl.NumberFormat(language === "pl" ? "pl-PL" : "en-GB", { - maximumFractionDigits: digits, - }).format(value); -} - -function formatRainfall(value: number, language: Language) { - return `${formatNumber(value, language, 1)} mm`; -} - function formatHour(value: string) { return value.slice(11, 16); } @@ -237,6 +230,7 @@ export function buildWeatherBrief({ language, temperatureUnit = DEFAULT_TEMPERATURE_UNIT, windSpeedUnit = DEFAULT_WIND_SPEED_UNIT, + precipitationUnit = DEFAULT_PRECIPITATION_UNIT, now = new Date(), }: BuildWeatherBriefInput): WeatherBrief | null { const hours = getUpcomingHours(forecast.hourly, now, 24); @@ -313,8 +307,8 @@ export function buildWeatherBrief({ : ""; body.push( language === "pl" - ? `Opad: ${rainfallTotal === null ? "brak pełnych danych" : formatRainfall(rainfallTotal, language)}, maks. szansa ${maximumProbability === null ? "brak danych" : `${maximumProbability}%`}.${rainWindow}` - : `Rainfall: ${rainfallTotal === null ? "not fully available" : formatRainfall(rainfallTotal, language)}, max chance ${maximumProbability === null ? "unavailable" : `${maximumProbability}%`}.${rainWindow}`, + ? `Opad: ${rainfallTotal === null ? "brak pełnych danych" : formatDisplayPrecipitation(rainfallTotal, language, precipitationUnit)}, maks. szansa ${maximumProbability === null ? "brak danych" : `${maximumProbability}%`}.${rainWindow}` + : `Rainfall: ${rainfallTotal === null ? "not fully available" : formatDisplayPrecipitation(rainfallTotal, language, precipitationUnit)}, max chance ${maximumProbability === null ? "unavailable" : `${maximumProbability}%`}.${rainWindow}`, ); } if (maximumWind !== null) { @@ -371,6 +365,7 @@ export function buildTomorrowWeatherBrief({ language, temperatureUnit = DEFAULT_TEMPERATURE_UNIT, windSpeedUnit = DEFAULT_WIND_SPEED_UNIT, + precipitationUnit = DEFAULT_PRECIPITATION_UNIT, now = new Date(), }: BuildWeatherBriefInput): WeatherBrief | null { const targetDateKey = getWarsawDateKey(now, 1); @@ -453,8 +448,8 @@ export function buildTomorrowWeatherBrief({ : ""; body.push( language === "pl" - ? `Opad: ${rainfallTotal === null ? "brak pełnych danych" : formatRainfall(rainfallTotal, language)}, maks. szansa ${maximumProbability === null ? "brak danych" : `${maximumProbability}%`}.${rainWindow}` - : `Precipitation: ${rainfallTotal === null ? "not fully available" : formatRainfall(rainfallTotal, language)}, max chance ${maximumProbability === null ? "unavailable" : `${maximumProbability}%`}.${rainWindow}`, + ? `Opad: ${rainfallTotal === null ? "brak pełnych danych" : formatDisplayPrecipitation(rainfallTotal, language, precipitationUnit)}, maks. szansa ${maximumProbability === null ? "brak danych" : `${maximumProbability}%`}.${rainWindow}` + : `Precipitation: ${rainfallTotal === null ? "not fully available" : formatDisplayPrecipitation(rainfallTotal, language, precipitationUnit)}, max chance ${maximumProbability === null ? "unavailable" : `${maximumProbability}%`}.${rainWindow}`, ); } else { body.push(language === "pl" ? "Bez istotnego opadu w prognozie." : "No significant precipitation in the forecast."); @@ -469,7 +464,7 @@ export function buildTomorrowWeatherBrief({ const rainPushPart = (hasRainSignal || (rainfallTotal ?? 0) > 0.1) && rainfallTotal !== null - ? formatRainfall(rainfallTotal, language) + ? formatDisplayPrecipitation(rainfallTotal, language, precipitationUnit) : null; const pushParts = [ `${locationName}:`, diff --git a/lib/weather-utils.ts b/lib/weather-utils.ts index f03c985..06b36a4 100644 --- a/lib/weather-utils.ts +++ b/lib/weather-utils.ts @@ -11,13 +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 { TemperatureUnit, WindSpeedUnit } from "@/types/units"; +import type { DistanceUnit, PrecipitationUnit, PressureUnit, 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 = "kmh"; -export const WIND_SPEED_UNITS: WindSpeedUnit[] = ["ms", "kmh", "mph"]; +export const WIND_SPEED_UNITS: WindSpeedUnit[] = ["ms", "kmh", "mph", "kt", "bft"]; +export const DEFAULT_PRECIPITATION_UNIT: PrecipitationUnit = "mm"; +export const PRECIPITATION_UNITS: PrecipitationUnit[] = ["mm", "cm", "in"]; +export const DEFAULT_PRESSURE_UNIT: PressureUnit = "hpa"; +export const PRESSURE_UNITS: PressureUnit[] = ["hpa", "kpa", "inhg", "mmhg"]; +export const DEFAULT_DISTANCE_UNIT: DistanceUnit = "km"; +export const DISTANCE_UNITS: DistanceUnit[] = ["km", "mi"]; const temperatureUnitLabels: Record = { c: "°C", @@ -28,6 +34,26 @@ const windSpeedUnitLabels: Record = { ms: "m/s", kmh: "km/h", mph: "mph", + kt: "kt", + bft: "Bft", +}; + +const precipitationUnitLabels: Record = { + mm: "mm", + cm: "cm", + in: "in", +}; + +const pressureUnitLabels: Record = { + hpa: "hPa", + kpa: "kPa", + inhg: "inHg", + mmhg: "mm Hg", +}; + +const distanceUnitLabels: Record = { + km: "km", + mi: "mi", }; export function toNumber(value: unknown): number | null { @@ -152,10 +178,6 @@ export function formatTemperature( : formatTemperatureValue(convertTemperature(value, unit), language, unit); } -export function formatPressure(value: number | null, language: Language = "pl") { - return value === null ? translate(language, "common.noData") : `${value.toFixed(1)} hPa`; -} - export function formatHumidity(value: number | null, language: Language = "pl") { return value === null ? translate(language, "common.noData") : `${Math.round(value)}%`; } @@ -168,18 +190,60 @@ export function getWindSpeedUnitLabel(unit: WindSpeedUnit) { return windSpeedUnitLabels[unit]; } +export function isPrecipitationUnit(value: unknown): value is PrecipitationUnit { + return typeof value === "string" && PRECIPITATION_UNITS.includes(value as PrecipitationUnit); +} + +export function getPrecipitationUnitLabel(unit: PrecipitationUnit) { + return precipitationUnitLabels[unit]; +} + +export function isPressureUnit(value: unknown): value is PressureUnit { + return typeof value === "string" && PRESSURE_UNITS.includes(value as PressureUnit); +} + +export function getPressureUnitLabel(unit: PressureUnit) { + return pressureUnitLabels[unit]; +} + +export function isDistanceUnit(value: unknown): value is DistanceUnit { + return typeof value === "string" && DISTANCE_UNITS.includes(value as DistanceUnit); +} + +export function getDistanceUnitLabel(unit: DistanceUnit) { + return distanceUnitLabels[unit]; +} + export function convertWindSpeed(speed: number, unit: WindSpeedUnit) { if (unit === "kmh") return speed * 3.6; if (unit === "mph") return speed * 2.2369362921; + if (unit === "kt") return speed * 1.9438444924; return speed; } +export function convertWindSpeedToBeaufort(speed: number) { + if (speed < 0.3) return 0; + if (speed < 1.6) return 1; + if (speed < 3.4) return 2; + if (speed < 5.5) return 3; + if (speed < 8) return 4; + if (speed < 10.8) return 5; + if (speed < 13.9) return 6; + if (speed < 17.2) return 7; + if (speed < 20.8) return 8; + if (speed < 24.5) return 9; + if (speed < 28.5) return 10; + if (speed < 32.7) return 11; + return 12; +} + export function formatWindSpeed( speed: number | null, language: Language = "pl", unit: WindSpeedUnit = DEFAULT_WIND_SPEED_UNIT, ) { if (speed === null) return translate(language, "common.noData"); + if (unit === "bft") return `${convertWindSpeedToBeaufort(speed)} ${getWindSpeedUnitLabel(unit)}`; const convertedSpeed = convertWindSpeed(speed, unit); const digits = unit === "ms" ? 1 : 0; const formattedSpeed = new Intl.NumberFormat(locales[language], { @@ -200,8 +264,73 @@ export function formatWind( return `${formatWindSpeed(speed, language, unit)}${directionLabel}`; } -export function formatRainfall(value: number | null, language: Language = "pl") { - return value === null ? translate(language, "common.noData") : `${value.toFixed(value < 1 ? 2 : 1)} mm`; +export function formatRainfall( + value: number | null, + language: Language = "pl", + unit: PrecipitationUnit = DEFAULT_PRECIPITATION_UNIT, +) { + return formatPrecipitation(value, language, unit); +} + +export function convertPrecipitation(value: number, unit: PrecipitationUnit) { + if (unit === "cm") return value / 10; + if (unit === "in") return value / 25.4; + return value; +} + +export function formatPrecipitation( + value: number | null, + language: Language = "pl", + unit: PrecipitationUnit = DEFAULT_PRECIPITATION_UNIT, +) { + if (value === null) return translate(language, "common.noData"); + const convertedValue = convertPrecipitation(value, unit); + const digits = unit === "mm" ? (convertedValue < 1 ? 2 : 1) : unit === "cm" ? 2 : 2; + const formattedValue = new Intl.NumberFormat(locales[language], { + minimumFractionDigits: unit === "in" ? 2 : 0, + maximumFractionDigits: digits, + }).format(convertedValue); + return `${formattedValue} ${getPrecipitationUnitLabel(unit)}`; +} + +export function convertPressure(value: number, unit: PressureUnit) { + if (unit === "kpa") return value / 10; + if (unit === "inhg") return value * 0.0295299830714; + if (unit === "mmhg") return value * 0.750061683; + return value; +} + +export function formatPressure( + value: number | null, + language: Language = "pl", + unit: PressureUnit = DEFAULT_PRESSURE_UNIT, +) { + if (value === null) return translate(language, "common.noData"); + const convertedValue = convertPressure(value, unit); + const digits = unit === "inhg" ? 2 : unit === "kpa" ? 1 : unit === "mmhg" ? 0 : 1; + const formattedValue = new Intl.NumberFormat(locales[language], { + minimumFractionDigits: unit === "inhg" ? 2 : 0, + maximumFractionDigits: digits, + }).format(convertedValue); + return `${formattedValue} ${getPressureUnitLabel(unit)}`; +} + +export function convertDistance(value: number, unit: DistanceUnit) { + if (unit === "mi") return value * 0.621371; + return value; +} + +export function formatDistance( + value: number | null, + language: Language = "pl", + unit: DistanceUnit = DEFAULT_DISTANCE_UNIT, +) { + if (value === null) return translate(language, "common.noData"); + const convertedValue = convertDistance(value, unit); + const formattedValue = new Intl.NumberFormat(locales[language], { + maximumFractionDigits: convertedValue < 10 ? 1 : 0, + }).format(convertedValue); + return `${formattedValue} ${getDistanceUnitLabel(unit)}`; } export function formatWaterLevel(value: number | null, language: Language = "pl") { diff --git a/tests/weather-brief.test.ts b/tests/weather-brief.test.ts index 185939b..30e70dd 100644 --- a/tests/weather-brief.test.ts +++ b/tests/weather-brief.test.ts @@ -54,10 +54,16 @@ function warning(overrides: Partial = {}): WeatherWarning { } describe("weather briefs", () => { - it("uses selected temperature and wind units in the morning brief", () => { + it("uses selected temperature, wind and precipitation units in the morning brief", () => { const brief = buildWeatherBrief({ forecast: forecast([ - hour({ time: "2026-06-14T08:00", temperature: 10, windSpeed: 4, precipitationProbability: 80 }), + hour({ + time: "2026-06-14T08:00", + temperature: 10, + windSpeed: 4, + precipitationProbability: 80, + precipitation: 25.4, + }), hour({ time: "2026-06-14T09:00", temperature: 12, windSpeed: 5, precipitationProbability: 30 }), ]), warnings: [], @@ -67,6 +73,7 @@ describe("weather briefs", () => { language: "en", temperatureUnit: "f", windSpeedUnit: "mph", + precipitationUnit: "in", now: new Date("2026-06-14T06:00:00.000Z"), }); @@ -74,25 +81,34 @@ describe("weather briefs", () => { expect(brief?.pushBody).toContain("50°F-54°F"); expect(brief?.pushBody).toContain("wind 11 mph"); expect(brief?.pushBody).not.toContain("km/h"); + expect(brief?.body.join(" ")).toContain("1.00 in"); }); it("warns about tomorrow storms from model weather codes without IMGW warning", () => { const brief = buildTomorrowWeatherBrief({ forecast: forecast([ hour({ time: "2026-06-15T08:00", temperature: 14, weatherCode: 3, precipitationProbability: 20 }), - hour({ time: "2026-06-15T15:00", temperature: 21, weatherCode: 95, precipitationProbability: 30 }), + hour({ + time: "2026-06-15T15:00", + temperature: 21, + weatherCode: 95, + precipitationProbability: 30, + precipitation: 25.4, + }), ]), warnings: [], province: "mazowieckie", countyTeryt: "1465", locationName: "Warsaw", language: "en", + precipitationUnit: "in", now: new Date("2026-06-14T16:00:00.000Z"), }); expect(brief?.severity).toBe("warning"); expect(brief?.headline).toBe("Thunderstorms are possible tomorrow"); expect(brief?.pushBody).toContain("thunderstorms"); + expect(brief?.pushBody).toContain("1.00 in"); expect(brief?.pushBody).not.toContain("IMGW"); }); diff --git a/tests/weather-utils.test.ts b/tests/weather-utils.test.ts index 1ef442f..22f8beb 100644 --- a/tests/weather-utils.test.ts +++ b/tests/weather-utils.test.ts @@ -1,9 +1,18 @@ import { describe, expect, it } from "vitest"; import { + DEFAULT_DISTANCE_UNIT, + DEFAULT_PRECIPITATION_UNIT, + DEFAULT_PRESSURE_UNIT, DEFAULT_WIND_SPEED_UNIT, + formatDistance, + formatPrecipitation, + formatPressure, formatTemperature, formatWindSpeed, + isDistanceUnit, + isPrecipitationUnit, + isPressureUnit, isTemperatureUnit, isWindSpeedUnit, } from "@/lib/weather-utils"; @@ -18,12 +27,40 @@ describe("weather utilities", () => { expect(formatTemperature(10, "en", "f")).toBe("50°F"); expect(formatWindSpeed(4, "en", "mph")).toBe("9 mph"); expect(formatWindSpeed(4, "en", "ms")).toBe("4.0 m/s"); + expect(formatWindSpeed(4, "en", "kt")).toBe("8 kt"); + expect(formatWindSpeed(4, "en", "bft")).toBe("3 Bft"); + }); + + it("formats selected precipitation, pressure and distance units", () => { + expect(DEFAULT_PRECIPITATION_UNIT).toBe("mm"); + expect(DEFAULT_PRESSURE_UNIT).toBe("hpa"); + expect(DEFAULT_DISTANCE_UNIT).toBe("km"); + + expect(formatPrecipitation(25.4, "en", "mm")).toBe("25.4 mm"); + expect(formatPrecipitation(10, "en", "cm")).toBe("1 cm"); + expect(formatPrecipitation(25.4, "en", "in")).toBe("1.00 in"); + + expect(formatPressure(1013.25, "en", "hpa")).toBe("1,013.3 hPa"); + expect(formatPressure(1013.25, "en", "kpa")).toBe("101.3 kPa"); + expect(formatPressure(1013.25, "en", "inhg")).toBe("29.92 inHg"); + expect(formatPressure(1013.25, "en", "mmhg")).toBe("760 mm Hg"); + + expect(formatDistance(10, "en", "km")).toBe("10 km"); + expect(formatDistance(10, "en", "mi")).toBe("6.2 mi"); }); it("validates supported presentation units", () => { expect(isTemperatureUnit("c")).toBe(true); expect(isTemperatureUnit("kelvin")).toBe(false); expect(isWindSpeedUnit("kmh")).toBe(true); + expect(isWindSpeedUnit("kt")).toBe(true); + expect(isWindSpeedUnit("bft")).toBe(true); expect(isWindSpeedUnit("knots")).toBe(false); + expect(isPrecipitationUnit("in")).toBe(true); + expect(isPrecipitationUnit("l")).toBe(false); + expect(isPressureUnit("kpa")).toBe(true); + expect(isPressureUnit("mbar")).toBe(false); + expect(isDistanceUnit("mi")).toBe(true); + expect(isDistanceUnit("m")).toBe(false); }); }); diff --git a/types/notifications.ts b/types/notifications.ts index abdead5..6765999 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 { TemperatureUnit, WindSpeedUnit } from "@/types/units"; +import type { DistanceUnit, PrecipitationUnit, PressureUnit, TemperatureUnit, WindSpeedUnit } from "@/types/units"; import type { WeatherRegion } from "@/types/weather-region"; export interface PushSubscriptionKeys { @@ -30,6 +30,9 @@ export interface WarningPushSubscription { countyTeryt: string | null; temperatureUnit: TemperatureUnit; windSpeedUnit: WindSpeedUnit; + precipitationUnit: PrecipitationUnit; + pressureUnit: PressureUnit; + distanceUnit: DistanceUnit; createdAt: string; updatedAt: string; } diff --git a/types/units.ts b/types/units.ts index 0d71116..1c4401e 100644 --- a/types/units.ts +++ b/types/units.ts @@ -1,2 +1,5 @@ export type TemperatureUnit = "c" | "f"; -export type WindSpeedUnit = "ms" | "kmh" | "mph"; +export type WindSpeedUnit = "ms" | "kmh" | "mph" | "kt" | "bft"; +export type PrecipitationUnit = "mm" | "cm" | "in"; +export type PressureUnit = "hpa" | "inhg" | "mmhg" | "kpa"; +export type DistanceUnit = "km" | "mi";