feat: add configurable wind units

This commit is contained in:
zv
2026-06-13 13:36:36 +02:00
parent a8d4d1e23c
commit 7ad95550eb
21 changed files with 152 additions and 38 deletions

View File

@@ -1,6 +1,8 @@
import type { Language, TranslationKey } from "@/lib/i18n";
import { translate } from "@/lib/i18n";
import { DEFAULT_WIND_SPEED_UNIT, formatWindSpeed } from "@/lib/weather-utils";
import type { HourlyForecast } from "@/types/forecast";
import type { WindSpeedUnit } from "@/types/units";
export function getForecastConditionKey(code: number | null): TranslationKey {
if (code === 0) return "forecast.condition.clear";
@@ -28,12 +30,9 @@ export function formatForecastRainfall(value: number | null, language: Language)
return `${new Intl.NumberFormat(language === "pl" ? "pl-PL" : "en-GB", { maximumFractionDigits: 1 }).format(value)} mm`;
}
export function formatForecastWind(value: number | null, language: Language) {
export function formatForecastWind(value: number | null, language: Language, unit: WindSpeedUnit = DEFAULT_WIND_SPEED_UNIT) {
if (value === null) return "—";
return `${new Intl.NumberFormat(language === "pl" ? "pl-PL" : "en-GB", {
minimumFractionDigits: 1,
maximumFractionDigits: 1,
}).format(value)} m/s`;
return formatWindSpeed(value, language, unit);
}
function getWarsawForecastHour(date = new Date()) {

View File

@@ -26,6 +26,12 @@ const translations = {
"settings.language.description": "Zmieniaj język interfejsu. Nazwy stacji i treści IMGW pozostają bez automatycznego tłumaczenia.",
"settings.theme.title": "Motyw",
"settings.theme.description": "Przełącz jasny lub ciemny wygląd aplikacji na tym urządzeniu.",
"settings.units.wind.title": "Jednostka wiatru",
"settings.units.wind.description": "Stosowana w prognozie, briefach, powiadomieniach i bieżących pomiarach na tym urządzeniu.",
"settings.units.wind.label": "Wybierz jednostkę wiatru",
"settings.units.wind.ms": "m/s",
"settings.units.wind.kmh": "km/h",
"settings.units.wind.mph": "mph",
"settings.notifications.title": "Powiadomienia o ostrzeżeniach meteo",
"settings.notifications.description": "Przygotuj alerty dla nowych ostrzeżeń meteorologicznych IMGW, takich jak burze, upał, silny wiatr lub intensywny deszcz.",
"settings.notifications.regionTitle": "Obszar alertów",
@@ -262,6 +268,12 @@ const translations = {
"settings.language.description": "Change the interface language. Station names and IMGW content are not translated automatically.",
"settings.theme.title": "Theme",
"settings.theme.description": "Switch the light or dark appearance on this device.",
"settings.units.wind.title": "Wind unit",
"settings.units.wind.description": "Used in forecasts, briefs, notifications and current readings on this device.",
"settings.units.wind.label": "Choose wind unit",
"settings.units.wind.ms": "m/s",
"settings.units.wind.kmh": "km/h",
"settings.units.wind.mph": "mph",
"settings.notifications.title": "Weather warning notifications",
"settings.notifications.description": "Prepare alerts for new IMGW meteorological warnings, such as thunderstorms, heat, strong wind or heavy rain.",
"settings.notifications.regionTitle": "Alert area",

View File

@@ -1,5 +1,6 @@
import type { Language } from "@/lib/i18n";
import type { Province } from "@/types/province";
import type { WindSpeedUnit } from "@/types/units";
interface VapidKeyResponse {
configured: boolean;
@@ -19,6 +20,7 @@ interface SavePushSubscriptionOptions {
longitude?: number | null;
locationName?: string | null;
countyTeryt?: string | null;
windSpeedUnit?: WindSpeedUnit;
}
export async function savePushSubscription(subscription: PushSubscription, province: Province, language: Language, options: SavePushSubscriptionOptions = {}) {
@@ -36,6 +38,7 @@ export async function savePushSubscription(subscription: PushSubscription, provi
longitude: options.longitude ?? null,
locationName: options.locationName ?? null,
countyTeryt: options.countyTeryt ?? null,
windSpeedUnit: options.windSpeedUnit,
}),
});
if (!response.ok) throw new Error("Unable to save push subscription.");

View File

@@ -4,6 +4,8 @@ import { create } from "zustand";
import { persist } from "zustand/middleware";
import type { SelectedLocation } from "@/types/location";
import type { Province } from "@/types/province";
import type { WindSpeedUnit } from "@/types/units";
import { DEFAULT_WIND_SPEED_UNIT } from "@/lib/weather-utils";
type NotificationProvinceMode = "selected" | "manual";
@@ -16,6 +18,7 @@ interface WeatherStore {
tomorrowBriefNotificationsEnabled: boolean;
warningNotificationProvinceMode: NotificationProvinceMode;
warningNotificationProvince: Province | null;
windSpeedUnit: WindSpeedUnit;
toggleFavorite: (id: string) => void;
selectStation: (id: string) => void;
selectLocation: (location: SelectedLocation) => void;
@@ -24,6 +27,7 @@ interface WeatherStore {
setTomorrowBriefNotificationsEnabled: (enabled: boolean) => void;
setWarningNotificationProvinceMode: (mode: NotificationProvinceMode) => void;
setWarningNotificationProvince: (province: Province | null) => void;
setWindSpeedUnit: (unit: WindSpeedUnit) => void;
}
export const useWeatherStore = create<WeatherStore>()(
@@ -37,6 +41,7 @@ export const useWeatherStore = create<WeatherStore>()(
tomorrowBriefNotificationsEnabled: false,
warningNotificationProvinceMode: "selected",
warningNotificationProvince: null,
windSpeedUnit: DEFAULT_WIND_SPEED_UNIT,
toggleFavorite: (id) =>
set((state) => ({
favorites: state.favorites.includes(id)
@@ -50,6 +55,7 @@ export const useWeatherStore = create<WeatherStore>()(
setTomorrowBriefNotificationsEnabled: (enabled) => set({ tomorrowBriefNotificationsEnabled: enabled }),
setWarningNotificationProvinceMode: (mode) => set({ warningNotificationProvinceMode: mode }),
setWarningNotificationProvince: (province) => set({ warningNotificationProvince: province }),
setWindSpeedUnit: (unit) => set({ windSpeedUnit: unit }),
}),
{ name: "wtr:preferences" },
),

View File

@@ -5,6 +5,8 @@ import type { HourlyForecast, WeatherForecast } from "@/types/forecast";
import type { WeatherWarning } from "@/types/imgw";
import type { Province } from "@/types/province";
import { warningMatchesCounty } from "@/lib/warning-regions";
import { DEFAULT_WIND_SPEED_UNIT, formatWindSpeed } from "@/lib/weather-utils";
import type { WindSpeedUnit } from "@/types/units";
export type WeatherBriefSeverity = "normal" | "attention" | "warning";
@@ -24,6 +26,7 @@ interface BuildWeatherBriefInput {
countyTeryt?: string | null;
locationName: string;
language: Language;
windSpeedUnit?: WindSpeedUnit;
now?: Date;
}
@@ -37,14 +40,6 @@ function formatTemperature(value: number, language: Language) {
return `${formatNumber(value, language)}°C`;
}
function formatWind(value: number, language: Language) {
return language === "pl" ? `${formatNumber(value, language, 1)} m/s` : `${formatNumber(value, language, 1)} m/s`;
}
function formatWindKmh(value: number, language: Language) {
return `${formatNumber(value * 3.6, language)} km/h`;
}
function formatRainfall(value: number, language: Language) {
return `${formatNumber(value, language, 1)} mm`;
}
@@ -207,7 +202,7 @@ function getTomorrowCondition(hours: HourlyForecast[], rainfallTotal: number | n
return language === "pl" ? "bez istotnych opadów" : "no significant precipitation";
}
export function buildWeatherBrief({ forecast, warnings, province, countyTeryt, locationName, language, now = new Date() }: BuildWeatherBriefInput): WeatherBrief | null {
export function buildWeatherBrief({ forecast, warnings, province, countyTeryt, locationName, language, windSpeedUnit = DEFAULT_WIND_SPEED_UNIT, now = new Date() }: BuildWeatherBriefInput): WeatherBrief | null {
const hours = getUpcomingHours(forecast.hourly, now, 24);
if (!hours.length) return null;
@@ -271,8 +266,8 @@ export function buildWeatherBrief({ forecast, warnings, province, countyTeryt, l
}
if (maximumWind !== null) {
body.push(language === "pl"
? `Wiatr maksymalnie do ${formatWind(maximumWind, language)}.`
: `Wind up to ${formatWind(maximumWind, language)}.`);
? `Wiatr maksymalnie do ${formatWindSpeed(maximumWind, language, windSpeedUnit)}.`
: `Wind up to ${formatWindSpeed(maximumWind, language, windSpeedUnit)}.`);
}
if (conditionPeakHour) {
body.push(language === "pl"
@@ -284,7 +279,7 @@ export function buildWeatherBrief({ forecast, warnings, province, countyTeryt, l
`${locationName}:`,
temperatureRange,
maximumProbability !== null ? (language === "pl" ? `opad do ${maximumProbability}%` : `rain up to ${maximumProbability}%`) : null,
maximumWind !== null ? (language === "pl" ? `wiatr ${formatWind(maximumWind, language)}` : `wind ${formatWind(maximumWind, language)}`) : null,
maximumWind !== null ? (language === "pl" ? `wiatr ${formatWindSpeed(maximumWind, language, windSpeedUnit)}` : `wind ${formatWindSpeed(maximumWind, language, windSpeedUnit)}`) : null,
].filter(Boolean);
const warningPrefix = topWarning
? language === "pl" ? `IMGW: ${topWarning.title || "ostrzeżenie"}. ` : `IMGW: ${topWarning.title || "warning"}. `
@@ -300,7 +295,7 @@ export function buildWeatherBrief({ forecast, warnings, province, countyTeryt, l
};
}
export function buildTomorrowWeatherBrief({ forecast, warnings, province, countyTeryt, locationName, language, now = new Date() }: BuildWeatherBriefInput): WeatherBrief | null {
export function buildTomorrowWeatherBrief({ forecast, warnings, province, countyTeryt, locationName, language, windSpeedUnit = DEFAULT_WIND_SPEED_UNIT, now = new Date() }: BuildWeatherBriefInput): WeatherBrief | null {
const targetDateKey = getWarsawDateKey(now, 1);
const hours = getForecastHoursForDate(forecast.hourly, targetDateKey);
if (!hours.length) return null;
@@ -378,8 +373,8 @@ export function buildTomorrowWeatherBrief({ forecast, warnings, province, county
}
if (maximumWind !== null) {
body.push(language === "pl"
? `Wiatr maksymalnie do ${formatWindKmh(maximumWind, language)}.`
: `Wind up to ${formatWindKmh(maximumWind, language)}.`);
? `Wiatr maksymalnie do ${formatWindSpeed(maximumWind, language, windSpeedUnit)}.`
: `Wind up to ${formatWindSpeed(maximumWind, language, windSpeedUnit)}.`);
}
const rainPushPart = (hasRainSignal || (rainfallTotal ?? 0) > 0.1) && rainfallTotal !== null
@@ -390,7 +385,7 @@ export function buildTomorrowWeatherBrief({ forecast, warnings, province, county
temperatureRange,
condition,
rainPushPart,
maximumWind !== null ? (language === "pl" ? `wiatr ${formatWindKmh(maximumWind, language)}` : `wind ${formatWindKmh(maximumWind, language)}`) : null,
maximumWind !== null ? (language === "pl" ? `wiatr ${formatWindSpeed(maximumWind, language, windSpeedUnit)}` : `wind ${formatWindSpeed(maximumWind, language, windSpeedUnit)}`) : null,
].filter(Boolean);
const warningPrefix = topWarning
? language === "pl" ? `IMGW: ${topWarning.title || "ostrzeżenie"}. ` : `IMGW: ${topWarning.title || "warning"}. `

View File

@@ -11,8 +11,17 @@ import type {
import { translate, type Language } from "@/lib/i18n";
import { getProvinceFromTeryt, normalizeProvinceName } from "@/lib/provinces";
import type { CurrentWeatherCondition } from "@/types/imgw-current";
import type { WindSpeedUnit } from "@/types/units";
const locales: Record<Language, string> = { pl: "pl-PL", en: "en-GB" };
export const DEFAULT_WIND_SPEED_UNIT: WindSpeedUnit = "ms";
export const WIND_SPEED_UNITS: WindSpeedUnit[] = ["ms", "kmh", "mph"];
const windSpeedUnitLabels: Record<WindSpeedUnit, string> = {
ms: "m/s",
kmh: "km/h",
mph: "mph",
};
export function toNumber(value: unknown): number | null {
if (typeof value === "number") return Number.isFinite(value) ? value : null;
@@ -108,10 +117,35 @@ export function formatHumidity(value: number | null, language: Language = "pl")
return value === null ? translate(language, "common.noData") : `${Math.round(value)}%`;
}
export function formatWind(speed: number | null, direction?: number | null, language: Language = "pl") {
export function isWindSpeedUnit(value: unknown): value is WindSpeedUnit {
return typeof value === "string" && WIND_SPEED_UNITS.includes(value as WindSpeedUnit);
}
export function getWindSpeedUnitLabel(unit: WindSpeedUnit) {
return windSpeedUnitLabels[unit];
}
export function convertWindSpeed(speed: number, unit: WindSpeedUnit) {
if (unit === "kmh") return speed * 3.6;
if (unit === "mph") return speed * 2.2369362921;
return speed;
}
export function formatWindSpeed(speed: number | null, language: Language = "pl", unit: WindSpeedUnit = DEFAULT_WIND_SPEED_UNIT) {
if (speed === null) return translate(language, "common.noData");
const convertedSpeed = convertWindSpeed(speed, unit);
const digits = unit === "ms" ? 1 : 0;
const formattedSpeed = new Intl.NumberFormat(locales[language], {
minimumFractionDigits: digits,
maximumFractionDigits: digits,
}).format(convertedSpeed);
return `${formattedSpeed} ${getWindSpeedUnitLabel(unit)}`;
}
export function formatWind(speed: number | null, direction?: number | null, language: Language = "pl", unit: WindSpeedUnit = DEFAULT_WIND_SPEED_UNIT) {
if (speed === null) return translate(language, "common.noData");
const directionLabel = direction === null || direction === undefined ? "" : ` ${getWindDirection(direction)}`;
return `${speed.toFixed(1)} m/s${directionLabel}`;
return `${formatWindSpeed(speed, language, unit)}${directionLabel}`;
}
export function formatRainfall(value: number | null, language: Language = "pl") {