(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";