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