feat: add temperature unit preference

This commit is contained in:
zv
2026-06-13 21:05:01 +02:00
parent 66ead03f49
commit cb113714f9
24 changed files with 150 additions and 51 deletions

View File

@@ -1,8 +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 { DEFAULT_TEMPERATURE_UNIT, DEFAULT_WIND_SPEED_UNIT, formatTemperature, formatWindSpeed } from "@/lib/weather-utils";
import type { HourlyForecast } from "@/types/forecast";
import type { WindSpeedUnit } from "@/types/units";
import type { TemperatureUnit, WindSpeedUnit } from "@/types/units";
export function getForecastConditionKey(code: number | null): TranslationKey {
if (code === 0) return "forecast.condition.clear";
@@ -20,9 +20,9 @@ export function getForecastCondition(code: number | null, language: Language) {
return translate(language, getForecastConditionKey(code));
}
export function formatForecastTemperature(value: number | null, language: Language) {
export function formatForecastTemperature(value: number | null, language: Language, unit: TemperatureUnit = DEFAULT_TEMPERATURE_UNIT) {
if (value === null) return "—";
return `${new Intl.NumberFormat(language === "pl" ? "pl-PL" : "en-GB", { maximumFractionDigits: 0 }).format(value)}°`;
return formatTemperature(value, language, unit);
}
export function formatForecastRainfall(value: number | null, language: Language) {

View File

@@ -26,6 +26,11 @@ 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.temperature.title": "Jednostka temperatury",
"settings.units.temperature.description": "Stosowana w prognozie, briefach, powiadomieniach i bieżących pomiarach na tym urządzeniu.",
"settings.units.temperature.label": "Wybierz jednostkę temperatury",
"settings.units.temperature.c": "°C",
"settings.units.temperature.f": "°F",
"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",
@@ -268,6 +273,11 @@ 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.temperature.title": "Temperature unit",
"settings.units.temperature.description": "Used in forecasts, briefs, notifications and current readings on this device.",
"settings.units.temperature.label": "Choose temperature unit",
"settings.units.temperature.c": "°C",
"settings.units.temperature.f": "°F",
"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",

View File

@@ -1,6 +1,6 @@
import type { Language } from "@/lib/i18n";
import type { Province } from "@/types/province";
import type { WindSpeedUnit } from "@/types/units";
import type { TemperatureUnit, WindSpeedUnit } from "@/types/units";
interface VapidKeyResponse {
configured: boolean;
@@ -20,6 +20,7 @@ interface SavePushSubscriptionOptions {
longitude?: number | null;
locationName?: string | null;
countyTeryt?: string | null;
temperatureUnit?: TemperatureUnit;
windSpeedUnit?: WindSpeedUnit;
}
@@ -38,6 +39,7 @@ export async function savePushSubscription(subscription: PushSubscription, provi
longitude: options.longitude ?? null,
locationName: options.locationName ?? null,
countyTeryt: options.countyTeryt ?? null,
temperatureUnit: options.temperatureUnit,
windSpeedUnit: options.windSpeedUnit,
}),
});

View File

@@ -4,8 +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";
import type { TemperatureUnit, WindSpeedUnit } from "@/types/units";
import { DEFAULT_TEMPERATURE_UNIT, DEFAULT_WIND_SPEED_UNIT } from "@/lib/weather-utils";
type NotificationProvinceMode = "selected" | "manual";
@@ -18,6 +18,7 @@ interface WeatherStore {
tomorrowBriefNotificationsEnabled: boolean;
warningNotificationProvinceMode: NotificationProvinceMode;
warningNotificationProvince: Province | null;
temperatureUnit: TemperatureUnit;
windSpeedUnit: WindSpeedUnit;
toggleFavorite: (id: string) => void;
selectStation: (id: string) => void;
@@ -27,6 +28,7 @@ interface WeatherStore {
setTomorrowBriefNotificationsEnabled: (enabled: boolean) => void;
setWarningNotificationProvinceMode: (mode: NotificationProvinceMode) => void;
setWarningNotificationProvince: (province: Province | null) => void;
setTemperatureUnit: (unit: TemperatureUnit) => void;
setWindSpeedUnit: (unit: WindSpeedUnit) => void;
}
@@ -41,6 +43,7 @@ export const useWeatherStore = create<WeatherStore>()(
tomorrowBriefNotificationsEnabled: false,
warningNotificationProvinceMode: "selected",
warningNotificationProvince: null,
temperatureUnit: DEFAULT_TEMPERATURE_UNIT,
windSpeedUnit: DEFAULT_WIND_SPEED_UNIT,
toggleFavorite: (id) =>
set((state) => ({
@@ -55,6 +58,7 @@ export const useWeatherStore = create<WeatherStore>()(
setTomorrowBriefNotificationsEnabled: (enabled) => set({ tomorrowBriefNotificationsEnabled: enabled }),
setWarningNotificationProvinceMode: (mode) => set({ warningNotificationProvinceMode: mode }),
setWarningNotificationProvince: (province) => set({ warningNotificationProvince: province }),
setTemperatureUnit: (unit) => set({ temperatureUnit: unit }),
setWindSpeedUnit: (unit) => set({ windSpeedUnit: unit }),
}),
{ name: "wtr:preferences" },

View File

@@ -5,8 +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";
import { DEFAULT_TEMPERATURE_UNIT, DEFAULT_WIND_SPEED_UNIT, formatTemperature as formatDisplayTemperature, formatWindSpeed } from "@/lib/weather-utils";
import type { TemperatureUnit, WindSpeedUnit } from "@/types/units";
export type WeatherBriefSeverity = "normal" | "attention" | "warning";
@@ -26,6 +26,7 @@ interface BuildWeatherBriefInput {
countyTeryt?: string | null;
locationName: string;
language: Language;
temperatureUnit?: TemperatureUnit;
windSpeedUnit?: WindSpeedUnit;
now?: Date;
}
@@ -36,10 +37,6 @@ function formatNumber(value: number, language: Language, digits = 0) {
}).format(value);
}
function formatTemperature(value: number, language: Language) {
return `${formatNumber(value, language)}°C`;
}
function formatRainfall(value: number, language: Language) {
return `${formatNumber(value, language, 1)} mm`;
}
@@ -202,7 +199,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, windSpeedUnit = DEFAULT_WIND_SPEED_UNIT, now = new Date() }: BuildWeatherBriefInput): WeatherBrief | null {
export function buildWeatherBrief({ forecast, warnings, province, countyTeryt, locationName, language, temperatureUnit = DEFAULT_TEMPERATURE_UNIT, windSpeedUnit = DEFAULT_WIND_SPEED_UNIT, now = new Date() }: BuildWeatherBriefInput): WeatherBrief | null {
const hours = getUpcomingHours(forecast.hourly, now, 24);
if (!hours.length) return null;
@@ -218,7 +215,7 @@ export function buildWeatherBrief({ forecast, warnings, province, countyTeryt, l
const rainPeakHour = getPeakHour(hours, (hour) => hour.precipitationProbability);
const conditionPeakHour = hours.find((hour) => hour.weatherCode !== null) ?? null;
const temperatureRange = minimumTemperature !== null && maximumTemperature !== null
? `${formatTemperature(minimumTemperature, language)}-${formatTemperature(maximumTemperature, language)}`
? `${formatDisplayTemperature(minimumTemperature, language, temperatureUnit)}-${formatDisplayTemperature(maximumTemperature, language, temperatureUnit)}`
: null;
const hasRainSignal = (maximumProbability ?? 0) >= 35 || (rainfallTotal ?? 0) >= 0.5;
const hasWindSignal = (maximumWind ?? 0) >= 8;
@@ -295,7 +292,7 @@ export function buildWeatherBrief({ forecast, warnings, province, countyTeryt, l
};
}
export function buildTomorrowWeatherBrief({ forecast, warnings, province, countyTeryt, locationName, language, windSpeedUnit = DEFAULT_WIND_SPEED_UNIT, now = new Date() }: BuildWeatherBriefInput): WeatherBrief | null {
export function buildTomorrowWeatherBrief({ forecast, warnings, province, countyTeryt, locationName, language, temperatureUnit = DEFAULT_TEMPERATURE_UNIT, 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;
@@ -318,7 +315,7 @@ export function buildTomorrowWeatherBrief({ forecast, warnings, province, county
const hasWindSignal = (maximumWind ?? 0) >= 8;
const condition = getTomorrowCondition(hours, rainfallTotal, maximumProbability, language);
const temperatureRange = minimumTemperature !== null && maximumTemperature !== null
? `${formatTemperature(minimumTemperature, language)} / ${formatTemperature(maximumTemperature, language)}`
? `${formatDisplayTemperature(minimumTemperature, language, temperatureUnit)} / ${formatDisplayTemperature(maximumTemperature, language, temperatureUnit)}`
: null;
const severity: WeatherBriefSeverity = topWarning || hasThunderstorm ? "warning" : hasRainSignal || hasWindSignal ? "attention" : "normal";
const provinceLabel = province ? formatProvinceName(province, language) : null;

View File

@@ -11,12 +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 { WindSpeedUnit } from "@/types/units";
import type { TemperatureUnit, WindSpeedUnit } from "@/types/units";
const locales: Record<Language, string> = { 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 = "ms";
export const WIND_SPEED_UNITS: WindSpeedUnit[] = ["ms", "kmh", "mph"];
const temperatureUnitLabels: Record<TemperatureUnit, string> = {
c: "°C",
f: "°F",
};
const windSpeedUnitLabels: Record<WindSpeedUnit, string> = {
ms: "m/s",
kmh: "km/h",
@@ -105,8 +112,28 @@ export function normalizeWarning(raw: RawWarning, kind: WarningKind, index: numb
};
}
export function formatTemperature(value: number | null, language: Language = "pl") {
return value === null ? translate(language, "common.noData") : `${Math.round(value)}°`;
export function isTemperatureUnit(value: unknown): value is TemperatureUnit {
return typeof value === "string" && TEMPERATURE_UNITS.includes(value as TemperatureUnit);
}
export function getTemperatureUnitLabel(unit: TemperatureUnit) {
return temperatureUnitLabels[unit];
}
export function convertTemperature(value: number, unit: TemperatureUnit) {
if (unit === "f") return (value * 9) / 5 + 32;
return value;
}
export function formatTemperatureValue(value: number, language: Language = "pl", unit: TemperatureUnit = DEFAULT_TEMPERATURE_UNIT) {
const formattedValue = new Intl.NumberFormat(locales[language], {
maximumFractionDigits: 0,
}).format(value);
return `${formattedValue}${getTemperatureUnitLabel(unit)}`;
}
export function formatTemperature(value: number | null, language: Language = "pl", unit: TemperatureUnit = DEFAULT_TEMPERATURE_UNIT) {
return value === null ? translate(language, "common.noData") : formatTemperatureValue(convertTemperature(value, unit), language, unit);
}
export function formatPressure(value: number | null, language: Language = "pl") {