feat: add temperature unit preference
This commit is contained in:
@@ -49,6 +49,7 @@ Repozytorium nie ma obecnie skryptu testów ani formattera. `npm run typecheck`
|
|||||||
- 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.
|
- 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.
|
- 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 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.
|
- Brief dnia i brief na jutro generuj deterministycznie w `lib/weather-brief.ts` z prognozy modelowej i 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.
|
||||||
- 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. Wymagają kluczy VAPID w zmiennych środowiskowych. iOS/iPadOS wymaga PWA z ekranu początkowego, ale Android i desktop nie powinny być blokowane wymogiem `standalone`. Obecny `lib/push-store.ts` jest pamięciowy i przed produkcją musi zostać zastąpiony trwałym magazynem subskrypcji.
|
- 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. Wymagają kluczy VAPID w zmiennych środowiskowych. iOS/iPadOS wymaga PWA z ekranu początkowego, ale Android i desktop nie powinny być blokowane wymogiem `standalone`. Obecny `lib/push-store.ts` jest pamięciowy i przed produkcją musi zostać zastąpiony trwałym magazynem subskrypcji.
|
||||||
- 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.
|
- 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ą.
|
- Normalizuj zewnętrzne odpowiedzi i obsługuj `null`, puste pola oraz błędne wartości. Brak danych pokazuj jawnie zamiast uzupełniać estymacją.
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { fetchMeteoWarnings } from "@/lib/server-warnings";
|
|||||||
import { getPushSubscriptions, hasSentMorningBrief, markMorningBriefSent, removePushSubscription } from "@/lib/push-store";
|
import { getPushSubscriptions, hasSentMorningBrief, markMorningBriefSent, removePushSubscription } from "@/lib/push-store";
|
||||||
import { isWebPushConfigured, sendMorningBriefNotification } from "@/lib/push-service";
|
import { isWebPushConfigured, sendMorningBriefNotification } from "@/lib/push-service";
|
||||||
import { buildWeatherBrief } from "@/lib/weather-brief";
|
import { buildWeatherBrief } from "@/lib/weather-brief";
|
||||||
import { DEFAULT_WIND_SPEED_UNIT } from "@/lib/weather-utils";
|
import { DEFAULT_TEMPERATURE_UNIT, DEFAULT_WIND_SPEED_UNIT } from "@/lib/weather-utils";
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
@@ -67,6 +67,7 @@ export async function GET(request: Request) {
|
|||||||
countyTeryt: subscription.countyTeryt,
|
countyTeryt: subscription.countyTeryt,
|
||||||
locationName: subscription.locationName ?? "wtr.",
|
locationName: subscription.locationName ?? "wtr.",
|
||||||
language: subscription.language,
|
language: subscription.language,
|
||||||
|
temperatureUnit: subscription.temperatureUnit ?? DEFAULT_TEMPERATURE_UNIT,
|
||||||
windSpeedUnit: subscription.windSpeedUnit ?? DEFAULT_WIND_SPEED_UNIT,
|
windSpeedUnit: subscription.windSpeedUnit ?? DEFAULT_WIND_SPEED_UNIT,
|
||||||
now,
|
now,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { NextResponse } from "next/server";
|
|||||||
import { upsertPushSubscription, removePushSubscription } from "@/lib/push-store";
|
import { upsertPushSubscription, removePushSubscription } from "@/lib/push-store";
|
||||||
import { isWebPushConfigured } from "@/lib/push-service";
|
import { isWebPushConfigured } from "@/lib/push-service";
|
||||||
import { normalizeProvinceName } from "@/lib/provinces";
|
import { normalizeProvinceName } from "@/lib/provinces";
|
||||||
import { DEFAULT_WIND_SPEED_UNIT, isWindSpeedUnit } from "@/lib/weather-utils";
|
import { DEFAULT_TEMPERATURE_UNIT, DEFAULT_WIND_SPEED_UNIT, isTemperatureUnit, isWindSpeedUnit } from "@/lib/weather-utils";
|
||||||
import type { Language } from "@/lib/i18n";
|
import type { Language } from "@/lib/i18n";
|
||||||
import type { BrowserPushSubscription } from "@/types/notifications";
|
import type { BrowserPushSubscription } from "@/types/notifications";
|
||||||
|
|
||||||
@@ -33,11 +33,13 @@ export async function POST(request: Request) {
|
|||||||
longitude?: unknown;
|
longitude?: unknown;
|
||||||
locationName?: unknown;
|
locationName?: unknown;
|
||||||
countyTeryt?: unknown;
|
countyTeryt?: unknown;
|
||||||
|
temperatureUnit?: unknown;
|
||||||
windSpeedUnit?: unknown;
|
windSpeedUnit?: unknown;
|
||||||
} | null;
|
} | null;
|
||||||
const subscription = body?.subscription;
|
const subscription = body?.subscription;
|
||||||
const province = normalizeProvinceName(typeof body?.province === "string" ? body.province : null);
|
const province = normalizeProvinceName(typeof body?.province === "string" ? body.province : null);
|
||||||
const language: Language = body?.language === "en" ? "en" : "pl";
|
const language: Language = body?.language === "en" ? "en" : "pl";
|
||||||
|
const rawTemperatureUnit = body?.temperatureUnit;
|
||||||
const rawWindSpeedUnit = body?.windSpeedUnit;
|
const rawWindSpeedUnit = body?.windSpeedUnit;
|
||||||
|
|
||||||
if (!isBrowserPushSubscription(subscription) || !province) {
|
if (!isBrowserPushSubscription(subscription) || !province) {
|
||||||
@@ -57,6 +59,7 @@ export async function POST(request: Request) {
|
|||||||
longitude: typeof body?.longitude === "number" && Number.isFinite(body.longitude) ? body.longitude : null,
|
longitude: typeof body?.longitude === "number" && Number.isFinite(body.longitude) ? body.longitude : null,
|
||||||
locationName: typeof body?.locationName === "string" && body.locationName.trim() ? body.locationName.trim().slice(0, 80) : null,
|
locationName: typeof body?.locationName === "string" && body.locationName.trim() ? body.locationName.trim().slice(0, 80) : null,
|
||||||
countyTeryt: typeof body?.countyTeryt === "string" && /^\d{4}$/.test(body.countyTeryt) ? body.countyTeryt : null,
|
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,
|
windSpeedUnit: isWindSpeedUnit(rawWindSpeedUnit) ? rawWindSpeedUnit : DEFAULT_WIND_SPEED_UNIT,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { fetchMeteoWarnings } from "@/lib/server-warnings";
|
|||||||
import { getPushSubscriptions, hasSentTomorrowBrief, markTomorrowBriefSent, removePushSubscription } from "@/lib/push-store";
|
import { getPushSubscriptions, hasSentTomorrowBrief, markTomorrowBriefSent, removePushSubscription } from "@/lib/push-store";
|
||||||
import { isWebPushConfigured, sendTomorrowBriefNotification } from "@/lib/push-service";
|
import { isWebPushConfigured, sendTomorrowBriefNotification } from "@/lib/push-service";
|
||||||
import { buildTomorrowWeatherBrief } from "@/lib/weather-brief";
|
import { buildTomorrowWeatherBrief } from "@/lib/weather-brief";
|
||||||
import { DEFAULT_WIND_SPEED_UNIT } from "@/lib/weather-utils";
|
import { DEFAULT_TEMPERATURE_UNIT, DEFAULT_WIND_SPEED_UNIT } from "@/lib/weather-utils";
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
@@ -77,6 +77,7 @@ export async function GET(request: Request) {
|
|||||||
countyTeryt: subscription.countyTeryt,
|
countyTeryt: subscription.countyTeryt,
|
||||||
locationName: subscription.locationName ?? "wtr.",
|
locationName: subscription.locationName ?? "wtr.",
|
||||||
language: subscription.language,
|
language: subscription.language,
|
||||||
|
temperatureUnit: subscription.temperatureUnit ?? DEFAULT_TEMPERATURE_UNIT,
|
||||||
windSpeedUnit: subscription.windSpeedUnit ?? DEFAULT_WIND_SPEED_UNIT,
|
windSpeedUnit: subscription.windSpeedUnit ?? DEFAULT_WIND_SPEED_UNIT,
|
||||||
now,
|
now,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import { Card } from "@/components/ui/card";
|
|||||||
import { CHART_COLORS } from "@/lib/chart-theme";
|
import { CHART_COLORS } from "@/lib/chart-theme";
|
||||||
import { useI18n } from "@/lib/i18n";
|
import { useI18n } from "@/lib/i18n";
|
||||||
import { formatForecastRainfall, formatForecastTemperature } from "@/lib/forecast-utils";
|
import { formatForecastRainfall, formatForecastTemperature } from "@/lib/forecast-utils";
|
||||||
|
import { useWeatherStore } from "@/lib/store";
|
||||||
|
import { convertTemperature, formatTemperatureValue, getTemperatureUnitLabel } from "@/lib/weather-utils";
|
||||||
import type { HourlyForecast } from "@/types/forecast";
|
import type { HourlyForecast } from "@/types/forecast";
|
||||||
|
|
||||||
const INITIAL_CHART_DIMENSION = { width: 1, height: 1 };
|
const INITIAL_CHART_DIMENSION = { width: 1, height: 1 };
|
||||||
@@ -15,13 +17,15 @@ function formatHour(value: string) {
|
|||||||
|
|
||||||
export function DayForecastCharts({ hours }: { hours: HourlyForecast[] }) {
|
export function DayForecastCharts({ hours }: { hours: HourlyForecast[] }) {
|
||||||
const { language, t } = useI18n();
|
const { language, t } = useI18n();
|
||||||
|
const temperatureUnit = useWeatherStore((state) => state.temperatureUnit);
|
||||||
const rows = hours.map((hour) => ({
|
const rows = hours.map((hour) => ({
|
||||||
time: formatHour(hour.time),
|
time: formatHour(hour.time),
|
||||||
temperature: hour.temperature,
|
temperature: hour.temperature === null ? null : convertTemperature(hour.temperature, temperatureUnit),
|
||||||
feelsLike: hour.feelsLike,
|
feelsLike: hour.feelsLike === null ? null : convertTemperature(hour.feelsLike, temperatureUnit),
|
||||||
precipitation: hour.precipitation,
|
precipitation: hour.precipitation,
|
||||||
precipitationProbability: hour.precipitationProbability,
|
precipitationProbability: hour.precipitationProbability,
|
||||||
}));
|
}));
|
||||||
|
const temperatureUnitLabel = getTemperatureUnitLabel(temperatureUnit);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid gap-3 lg:grid-cols-2">
|
<div className="grid gap-3 lg:grid-cols-2">
|
||||||
@@ -33,10 +37,14 @@ export function DayForecastCharts({ hours }: { hours: HourlyForecast[] }) {
|
|||||||
<ComposedChart data={rows} margin={{ left: -20, right: 8, top: 8 }}>
|
<ComposedChart data={rows} margin={{ left: -20, right: 8, top: 8 }}>
|
||||||
<CartesianGrid stroke={CHART_COLORS.grid} strokeDasharray="4 4" vertical={false} />
|
<CartesianGrid stroke={CHART_COLORS.grid} strokeDasharray="4 4" vertical={false} />
|
||||||
<XAxis dataKey="time" axisLine={false} tickLine={false} interval={3} tick={{ fill: "currentColor", fontSize: 11 }} />
|
<XAxis dataKey="time" axisLine={false} tickLine={false} interval={3} tick={{ fill: "currentColor", fontSize: 11 }} />
|
||||||
<YAxis axisLine={false} tickLine={false} tick={{ fill: "currentColor", fontSize: 11 }} unit="°" />
|
<YAxis axisLine={false} tickLine={false} tick={{ fill: "currentColor", fontSize: 11 }} unit={temperatureUnitLabel} />
|
||||||
<Tooltip
|
<Tooltip
|
||||||
contentStyle={{ borderRadius: 14, border: `1px solid ${CHART_COLORS.tooltipBorder}`, background: CHART_COLORS.tooltipBackground, color: CHART_COLORS.tooltipText }}
|
contentStyle={{ borderRadius: 14, border: `1px solid ${CHART_COLORS.tooltipBorder}`, background: CHART_COLORS.tooltipBackground, color: CHART_COLORS.tooltipText }}
|
||||||
formatter={(value) => [formatForecastTemperature(typeof value === "number" ? value : null, language)]}
|
formatter={(value) => [
|
||||||
|
typeof value === "number"
|
||||||
|
? formatTemperatureValue(value, language, temperatureUnit)
|
||||||
|
: formatForecastTemperature(null, language, temperatureUnit),
|
||||||
|
]}
|
||||||
/>
|
/>
|
||||||
<Legend wrapperStyle={{ fontSize: "0.72rem" }} />
|
<Legend wrapperStyle={{ fontSize: "0.72rem" }} />
|
||||||
<Line type="monotone" dataKey="temperature" name={t("forecast.temperature")} stroke={CHART_COLORS.temperature} strokeWidth={3} dot={false} connectNulls />
|
<Line type="monotone" dataKey="temperature" name={t("forecast.temperature")} stroke={CHART_COLORS.temperature} strokeWidth={3} dot={false} connectNulls />
|
||||||
|
|||||||
@@ -20,17 +20,18 @@ export function WeatherBriefCard({ latitude, longitude, locationName }: { latitu
|
|||||||
const { data: warnings = [] } = useWarnings();
|
const { data: warnings = [] } = useWarnings();
|
||||||
const selectedStationId = useWeatherStore((state) => state.selectedStationId);
|
const selectedStationId = useWeatherStore((state) => state.selectedStationId);
|
||||||
const selectedLocation = useWeatherStore((state) => state.selectedLocation);
|
const selectedLocation = useWeatherStore((state) => state.selectedLocation);
|
||||||
|
const temperatureUnit = useWeatherStore((state) => state.temperatureUnit);
|
||||||
const windSpeedUnit = useWeatherStore((state) => state.windSpeedUnit);
|
const windSpeedUnit = useWeatherStore((state) => state.windSpeedUnit);
|
||||||
const province = getProvinceForSelection(selectedLocation?.province, selectedStationId ?? DEFAULT_STATION_ID);
|
const province = getProvinceForSelection(selectedLocation?.province, selectedStationId ?? DEFAULT_STATION_ID);
|
||||||
|
|
||||||
const brief = useMemo(() => {
|
const brief = useMemo(() => {
|
||||||
if (!forecast) return null;
|
if (!forecast) return null;
|
||||||
return buildWeatherBrief({ forecast, warnings, province, countyTeryt: selectedLocation?.countyTeryt, locationName, language, windSpeedUnit });
|
return buildWeatherBrief({ forecast, warnings, province, countyTeryt: selectedLocation?.countyTeryt, locationName, language, temperatureUnit, windSpeedUnit });
|
||||||
}, [forecast, language, locationName, province, selectedLocation?.countyTeryt, warnings, windSpeedUnit]);
|
}, [forecast, language, locationName, province, selectedLocation?.countyTeryt, temperatureUnit, warnings, windSpeedUnit]);
|
||||||
const tomorrowBrief = useMemo(() => {
|
const tomorrowBrief = useMemo(() => {
|
||||||
if (!forecast) return null;
|
if (!forecast) return null;
|
||||||
return buildTomorrowWeatherBrief({ forecast, warnings, province, countyTeryt: selectedLocation?.countyTeryt, locationName, language, windSpeedUnit });
|
return buildTomorrowWeatherBrief({ forecast, warnings, province, countyTeryt: selectedLocation?.countyTeryt, locationName, language, temperatureUnit, windSpeedUnit });
|
||||||
}, [forecast, language, locationName, province, selectedLocation?.countyTeryt, warnings, windSpeedUnit]);
|
}, [forecast, language, locationName, province, selectedLocation?.countyTeryt, temperatureUnit, warnings, windSpeedUnit]);
|
||||||
|
|
||||||
if (!Number.isFinite(latitude) || !Number.isFinite(longitude) || isPending) {
|
if (!Number.isFinite(latitude) || !Number.isFinite(longitude) || isPending) {
|
||||||
return <LoadingSkeleton className="h-48" />;
|
return <LoadingSkeleton className="h-48" />;
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ export function DayForecastModal({
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}) {
|
}) {
|
||||||
const { language, locale, t } = useI18n();
|
const { language, locale, t } = useI18n();
|
||||||
|
const temperatureUnit = useWeatherStore((state) => state.temperatureUnit);
|
||||||
const windSpeedUnit = useWeatherStore((state) => state.windSpeedUnit);
|
const windSpeedUnit = useWeatherStore((state) => state.windSpeedUnit);
|
||||||
const closeButtonRef = useRef<HTMLButtonElement>(null);
|
const closeButtonRef = useRef<HTMLButtonElement>(null);
|
||||||
const portalRoot = typeof document === "undefined" ? null : document.body;
|
const portalRoot = typeof document === "undefined" ? null : document.body;
|
||||||
@@ -131,8 +132,8 @@ export function DayForecastModal({
|
|||||||
<p className="text-sm text-muted">{getForecastCondition(day.weatherCode, language)}</p>
|
<p className="text-sm text-muted">{getForecastCondition(day.weatherCode, language)}</p>
|
||||||
<div className="mt-2 flex items-end gap-4">
|
<div className="mt-2 flex items-end gap-4">
|
||||||
<ForecastIcon code={day.weatherCode} className="mb-2 size-14 text-accent" />
|
<ForecastIcon code={day.weatherCode} className="mb-2 size-14 text-accent" />
|
||||||
<p className="text-6xl font-semibold tracking-[-0.08em] sm:text-7xl">{formatForecastTemperature(day.temperatureMax, language)}</p>
|
<p className="text-6xl font-semibold tracking-[-0.08em] sm:text-7xl">{formatForecastTemperature(day.temperatureMax, language, temperatureUnit)}</p>
|
||||||
<p className="mb-2 text-2xl text-muted">{formatForecastTemperature(day.temperatureMin, language)}</p>
|
<p className="mb-2 text-2xl text-muted">{formatForecastTemperature(day.temperatureMin, language, temperatureUnit)}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-2 sm:min-w-[22rem]">
|
<div className="grid grid-cols-2 gap-2 sm:min-w-[22rem]">
|
||||||
@@ -156,7 +157,7 @@ export function DayForecastModal({
|
|||||||
>
|
>
|
||||||
<p className="text-xs font-medium text-muted">{formatHour(hour.time)}</p>
|
<p className="text-xs font-medium text-muted">{formatHour(hour.time)}</p>
|
||||||
<ForecastIcon code={hour.weatherCode} className="mx-auto my-3 size-6 text-accent" />
|
<ForecastIcon code={hour.weatherCode} className="mx-auto my-3 size-6 text-accent" />
|
||||||
<p className="text-lg font-semibold tracking-tight">{formatForecastTemperature(hour.temperature, language)}</p>
|
<p className="text-lg font-semibold tracking-tight">{formatForecastTemperature(hour.temperature, language, temperatureUnit)}</p>
|
||||||
<p className="mt-2 flex items-center justify-center gap-1 text-[0.66rem] text-accent">
|
<p className="mt-2 flex items-center justify-center gap-1 text-[0.66rem] text-accent">
|
||||||
<Droplets className="size-3" />
|
<Droplets className="size-3" />
|
||||||
{hour.precipitationProbability === null ? "—" : `${hour.precipitationProbability}%`}
|
{hour.precipitationProbability === null ? "—" : `${hour.precipitationProbability}%`}
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ function HourlySummaryMetric({ icon: Icon, label, value }: { icon: LucideIcon; l
|
|||||||
|
|
||||||
function HourlyForecastSummary({ hours }: { hours: HourlyForecast[] }) {
|
function HourlyForecastSummary({ hours }: { hours: HourlyForecast[] }) {
|
||||||
const { language, t } = useI18n();
|
const { language, t } = useI18n();
|
||||||
|
const temperatureUnit = useWeatherStore((state) => state.temperatureUnit);
|
||||||
const windSpeedUnit = useWeatherStore((state) => state.windSpeedUnit);
|
const windSpeedUnit = useWeatherStore((state) => state.windSpeedUnit);
|
||||||
const minimumTemperature = getMinimum(hours.map((hour) => hour.temperature));
|
const minimumTemperature = getMinimum(hours.map((hour) => hour.temperature));
|
||||||
const maximumTemperature = getMaximum(hours.map((hour) => hour.temperature));
|
const maximumTemperature = getMaximum(hours.map((hour) => hour.temperature));
|
||||||
@@ -68,7 +69,7 @@ function HourlyForecastSummary({ hours }: { hours: HourlyForecast[] }) {
|
|||||||
const maximumRainfallProbability = getMaximum(hours.map((hour) => hour.precipitationProbability));
|
const maximumRainfallProbability = getMaximum(hours.map((hour) => hour.precipitationProbability));
|
||||||
const temperatureRange = minimumTemperature === null || maximumTemperature === null
|
const temperatureRange = minimumTemperature === null || maximumTemperature === null
|
||||||
? "—"
|
? "—"
|
||||||
: `${formatForecastTemperature(minimumTemperature, language)} / ${formatForecastTemperature(maximumTemperature, language)}`;
|
: `${formatForecastTemperature(minimumTemperature, language, temperatureUnit)} / ${formatForecastTemperature(maximumTemperature, language, temperatureUnit)}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mt-auto hidden border-t border-border/70 pt-4 lg:block">
|
<div className="mt-auto hidden border-t border-border/70 pt-4 lg:block">
|
||||||
@@ -85,6 +86,7 @@ function HourlyForecastSummary({ hours }: { hours: HourlyForecast[] }) {
|
|||||||
|
|
||||||
function DailyForecastRow({ day, index, onSelect }: { day: DailyForecast; index: number; onSelect: (day: DailyForecast) => void }) {
|
function DailyForecastRow({ day, index, onSelect }: { day: DailyForecast; index: number; onSelect: (day: DailyForecast) => void }) {
|
||||||
const { language, locale, t } = useI18n();
|
const { language, locale, t } = useI18n();
|
||||||
|
const temperatureUnit = useWeatherStore((state) => state.temperatureUnit);
|
||||||
const label = formatDay(day.date, locale, t("forecast.today"), index);
|
const label = formatDay(day.date, locale, t("forecast.today"), index);
|
||||||
return (
|
return (
|
||||||
<motion.li
|
<motion.li
|
||||||
@@ -106,7 +108,7 @@ function DailyForecastRow({ day, index, onSelect }: { day: DailyForecast; index:
|
|||||||
<span className="truncate text-xs text-muted">{getForecastCondition(day.weatherCode, language)} · {formatForecastRainfall(day.precipitation, language)}</span>
|
<span className="truncate text-xs text-muted">{getForecastCondition(day.weatherCode, language)} · {formatForecastRainfall(day.precipitation, language)}</span>
|
||||||
</div>
|
</div>
|
||||||
<span className="hidden items-center gap-1 text-xs text-accent sm:flex"><Droplets className="size-3" />{day.precipitationProbability === null ? "—" : `${day.precipitationProbability}%`}</span>
|
<span className="hidden items-center gap-1 text-xs text-accent sm:flex"><Droplets className="size-3" />{day.precipitationProbability === null ? "—" : `${day.precipitationProbability}%`}</span>
|
||||||
<p className="whitespace-nowrap text-sm"><strong>{formatForecastTemperature(day.temperatureMax, language)}</strong><span className="ml-2 text-muted">{formatForecastTemperature(day.temperatureMin, language)}</span></p>
|
<p className="whitespace-nowrap text-sm"><strong>{formatForecastTemperature(day.temperatureMax, language, temperatureUnit)}</strong><span className="ml-2 text-muted">{formatForecastTemperature(day.temperatureMin, language, temperatureUnit)}</span></p>
|
||||||
<ChevronRight className="hidden size-4 text-muted sm:block" />
|
<ChevronRight className="hidden size-4 text-muted sm:block" />
|
||||||
</motion.button>
|
</motion.button>
|
||||||
</motion.li>
|
</motion.li>
|
||||||
@@ -115,6 +117,7 @@ function DailyForecastRow({ day, index, onSelect }: { day: DailyForecast; index:
|
|||||||
|
|
||||||
export function ForecastPanel({ latitude, longitude, locationName }: { latitude?: number; longitude?: number; locationName: string }) {
|
export function ForecastPanel({ latitude, longitude, locationName }: { latitude?: number; longitude?: number; locationName: string }) {
|
||||||
const { language, t } = useI18n();
|
const { language, t } = useI18n();
|
||||||
|
const temperatureUnit = useWeatherStore((state) => state.temperatureUnit);
|
||||||
const windSpeedUnit = useWeatherStore((state) => state.windSpeedUnit);
|
const windSpeedUnit = useWeatherStore((state) => state.windSpeedUnit);
|
||||||
const { data: forecast, isPending, isError, refetch } = useForecast(latitude, longitude);
|
const { data: forecast, isPending, isError, refetch } = useForecast(latitude, longitude);
|
||||||
const [selectedDay, setSelectedDay] = useState<DailyForecast | null>(null);
|
const [selectedDay, setSelectedDay] = useState<DailyForecast | null>(null);
|
||||||
@@ -161,12 +164,12 @@ export function ForecastPanel({ latitude, longitude, locationName }: { latitude?
|
|||||||
>
|
>
|
||||||
<p className="text-xs font-medium text-muted">{formatHour(hour.time)}</p>
|
<p className="text-xs font-medium text-muted">{formatHour(hour.time)}</p>
|
||||||
<ForecastIcon code={hour.weatherCode} className="mx-auto my-3 size-6 text-accent" />
|
<ForecastIcon code={hour.weatherCode} className="mx-auto my-3 size-6 text-accent" />
|
||||||
<p className="text-lg font-semibold tracking-tight">{formatForecastTemperature(hour.temperature, language)}</p>
|
<p className="text-lg font-semibold tracking-tight">{formatForecastTemperature(hour.temperature, language, temperatureUnit)}</p>
|
||||||
<p className="mt-2 flex items-center justify-center gap-1 text-[0.66rem] text-accent"><Droplets className="size-3" />{hour.precipitationProbability === null ? "—" : `${hour.precipitationProbability}%`}</p>
|
<p className="mt-2 flex items-center justify-center gap-1 text-[0.66rem] text-accent"><Droplets className="size-3" />{hour.precipitationProbability === null ? "—" : `${hour.precipitationProbability}%`}</p>
|
||||||
<div className="mt-3 hidden space-y-1.5 border-t border-border/65 pt-3 text-[0.66rem] text-muted lg:block">
|
<div className="mt-3 hidden space-y-1.5 border-t border-border/65 pt-3 text-[0.66rem] text-muted lg:block">
|
||||||
<p className="flex items-center justify-center gap-1" title={t("forecast.apparentTemperature")}>
|
<p className="flex items-center justify-center gap-1" title={t("forecast.apparentTemperature")}>
|
||||||
<ThermometerSun className="size-3 text-accent" />
|
<ThermometerSun className="size-3 text-accent" />
|
||||||
{formatForecastTemperature(hour.feelsLike, language)}
|
{formatForecastTemperature(hour.feelsLike, language, temperatureUnit)}
|
||||||
</p>
|
</p>
|
||||||
<p className="flex items-center justify-center gap-1" title={t("weather.wind")}>
|
<p className="flex items-center justify-center gap-1" title={t("weather.wind")}>
|
||||||
<Wind className="size-3 text-accent" />
|
<Wind className="size-3 text-accent" />
|
||||||
|
|||||||
@@ -6,9 +6,11 @@ import type { HydroStation } from "@/types/imgw";
|
|||||||
import { formatDateTime, formatFlow, formatTemperature, formatWaterLevel } from "@/lib/weather-utils";
|
import { formatDateTime, formatFlow, formatTemperature, formatWaterLevel } from "@/lib/weather-utils";
|
||||||
import { Card } from "@/components/ui/card";
|
import { Card } from "@/components/ui/card";
|
||||||
import { useI18n } from "@/lib/i18n";
|
import { useI18n } from "@/lib/i18n";
|
||||||
|
import { useWeatherStore } from "@/lib/store";
|
||||||
|
|
||||||
export function HydroStationCard({ station, index = 0 }: { station: HydroStation; index?: number }) {
|
export function HydroStationCard({ station, index = 0 }: { station: HydroStation; index?: number }) {
|
||||||
const { language, t } = useI18n();
|
const { language, t } = useI18n();
|
||||||
|
const temperatureUnit = useWeatherStore((state) => state.temperatureUnit);
|
||||||
return (
|
return (
|
||||||
<motion.article initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: Math.min(index * 0.02, 0.3), duration: 0.3 }}>
|
<motion.article initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: Math.min(index * 0.02, 0.3), duration: 0.3 }}>
|
||||||
<Card className="h-full p-4 transition duration-300 hover:-translate-y-1 hover:bg-surface-raised/90">
|
<Card className="h-full p-4 transition duration-300 hover:-translate-y-1 hover:bg-surface-raised/90">
|
||||||
@@ -20,7 +22,7 @@ export function HydroStationCard({ station, index = 0 }: { station: HydroStation
|
|||||||
</div>
|
</div>
|
||||||
<div className="mt-5 grid grid-cols-3 gap-2">
|
<div className="mt-5 grid grid-cols-3 gap-2">
|
||||||
<HydroMetric icon={Droplets} label={t("hydro.level")} value={formatWaterLevel(station.waterLevel, language)} />
|
<HydroMetric icon={Droplets} label={t("hydro.level")} value={formatWaterLevel(station.waterLevel, language)} />
|
||||||
<HydroMetric icon={Thermometer} label={t("hydro.water")} value={formatTemperature(station.waterTemperature, language)} />
|
<HydroMetric icon={Thermometer} label={t("hydro.water")} value={formatTemperature(station.waterTemperature, language, temperatureUnit)} />
|
||||||
<HydroMetric icon={Activity} label={t("hydro.flow")} value={formatFlow(station.flow, language)} />
|
<HydroMetric icon={Activity} label={t("hydro.flow")} value={formatFlow(station.flow, language)} />
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-4 text-[0.7rem] text-muted">{t("hydro.levelMeasurement", { date: formatDateTime(station.waterLevelMeasuredAt, language) })}</p>
|
<p className="mt-4 text-[0.7rem] text-muted">{t("hydro.levelMeasurement", { date: formatDateTime(station.waterLevelMeasuredAt, language) })}</p>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { Bell, BellRing, Clock3, Languages, MapPinned, Palette, Settings, Smartphone, Wind } from "lucide-react";
|
import { Bell, BellRing, Clock3, Languages, MapPinned, Palette, Settings, Smartphone, Thermometer, Wind } from "lucide-react";
|
||||||
import type { LucideIcon } from "lucide-react";
|
import type { LucideIcon } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card } from "@/components/ui/card";
|
import { Card } from "@/components/ui/card";
|
||||||
@@ -15,9 +15,9 @@ import { locateSynopStations } from "@/lib/location-utils";
|
|||||||
import { decodeBase64UrlKey, deletePushSubscription, fetchVapidPublicKey, savePushSubscription, sendTestPushNotification } from "@/lib/notification-api";
|
import { decodeBase64UrlKey, deletePushSubscription, fetchVapidPublicKey, savePushSubscription, sendTestPushNotification } from "@/lib/notification-api";
|
||||||
import { formatProvinceName, getProvinceForSelection, PROVINCES } from "@/lib/provinces";
|
import { formatProvinceName, getProvinceForSelection, PROVINCES } from "@/lib/provinces";
|
||||||
import { useWeatherStore } from "@/lib/store";
|
import { useWeatherStore } from "@/lib/store";
|
||||||
import { WIND_SPEED_UNITS } from "@/lib/weather-utils";
|
import { TEMPERATURE_UNITS, WIND_SPEED_UNITS } from "@/lib/weather-utils";
|
||||||
import type { Province } from "@/types/province";
|
import type { Province } from "@/types/province";
|
||||||
import type { WindSpeedUnit } from "@/types/units";
|
import type { TemperatureUnit, WindSpeedUnit } from "@/types/units";
|
||||||
|
|
||||||
type NotificationSupportStatus = "checking" | "unconfigured" | "unsupported" | "needs-install" | "default" | "denied" | "granted";
|
type NotificationSupportStatus = "checking" | "unconfigured" | "unsupported" | "needs-install" | "default" | "denied" | "granted";
|
||||||
|
|
||||||
@@ -108,6 +108,11 @@ const windSpeedUnitKeys: Record<WindSpeedUnit, "settings.units.wind.ms" | "setti
|
|||||||
mph: "settings.units.wind.mph",
|
mph: "settings.units.wind.mph",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const temperatureUnitKeys: Record<TemperatureUnit, "settings.units.temperature.c" | "settings.units.temperature.f"> = {
|
||||||
|
c: "settings.units.temperature.c",
|
||||||
|
f: "settings.units.temperature.f",
|
||||||
|
};
|
||||||
|
|
||||||
export function SettingsPage() {
|
export function SettingsPage() {
|
||||||
const { language, t } = useI18n();
|
const { language, t } = useI18n();
|
||||||
const [notificationStatus, setNotificationStatus] = useState<NotificationSupportStatus>("checking");
|
const [notificationStatus, setNotificationStatus] = useState<NotificationSupportStatus>("checking");
|
||||||
@@ -124,12 +129,14 @@ export function SettingsPage() {
|
|||||||
const tomorrowBriefEnabled = useWeatherStore((state) => state.tomorrowBriefNotificationsEnabled);
|
const tomorrowBriefEnabled = useWeatherStore((state) => state.tomorrowBriefNotificationsEnabled);
|
||||||
const provinceMode = useWeatherStore((state) => state.warningNotificationProvinceMode);
|
const provinceMode = useWeatherStore((state) => state.warningNotificationProvinceMode);
|
||||||
const manualProvince = useWeatherStore((state) => state.warningNotificationProvince);
|
const manualProvince = useWeatherStore((state) => state.warningNotificationProvince);
|
||||||
|
const temperatureUnit = useWeatherStore((state) => state.temperatureUnit);
|
||||||
const windSpeedUnit = useWeatherStore((state) => state.windSpeedUnit);
|
const windSpeedUnit = useWeatherStore((state) => state.windSpeedUnit);
|
||||||
const setNotificationsEnabled = useWeatherStore((state) => state.setWarningNotificationsEnabled);
|
const setNotificationsEnabled = useWeatherStore((state) => state.setWarningNotificationsEnabled);
|
||||||
const setMorningBriefEnabled = useWeatherStore((state) => state.setMorningBriefNotificationsEnabled);
|
const setMorningBriefEnabled = useWeatherStore((state) => state.setMorningBriefNotificationsEnabled);
|
||||||
const setTomorrowBriefEnabled = useWeatherStore((state) => state.setTomorrowBriefNotificationsEnabled);
|
const setTomorrowBriefEnabled = useWeatherStore((state) => state.setTomorrowBriefNotificationsEnabled);
|
||||||
const setProvinceMode = useWeatherStore((state) => state.setWarningNotificationProvinceMode);
|
const setProvinceMode = useWeatherStore((state) => state.setWarningNotificationProvinceMode);
|
||||||
const setManualProvince = useWeatherStore((state) => state.setWarningNotificationProvince);
|
const setManualProvince = useWeatherStore((state) => state.setWarningNotificationProvince);
|
||||||
|
const setTemperatureUnit = useWeatherStore((state) => state.setTemperatureUnit);
|
||||||
const setWindSpeedUnit = useWeatherStore((state) => state.setWindSpeedUnit);
|
const setWindSpeedUnit = useWeatherStore((state) => state.setWindSpeedUnit);
|
||||||
|
|
||||||
const selectedStation = stations?.find((station) => station.id === selectedStationId)
|
const selectedStation = stations?.find((station) => station.id === selectedStationId)
|
||||||
@@ -180,6 +187,7 @@ export function SettingsPage() {
|
|||||||
longitude: notificationLongitude,
|
longitude: notificationLongitude,
|
||||||
locationName: notificationLocationName,
|
locationName: notificationLocationName,
|
||||||
countyTeryt: notificationCountyTeryt,
|
countyTeryt: notificationCountyTeryt,
|
||||||
|
temperatureUnit,
|
||||||
windSpeedUnit,
|
windSpeedUnit,
|
||||||
}).catch(() => undefined);
|
}).catch(() => undefined);
|
||||||
}
|
}
|
||||||
@@ -188,7 +196,7 @@ export function SettingsPage() {
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [effectiveProvince, language, morningBriefEnabled, notificationCountyTeryt, notificationLatitude, notificationLocationName, notificationLongitude, notificationStatus, notificationsEnabled, tomorrowBriefEnabled, vapidPublicKey, windSpeedUnit]);
|
}, [effectiveProvince, language, morningBriefEnabled, notificationCountyTeryt, notificationLatitude, notificationLocationName, notificationLongitude, notificationStatus, notificationsEnabled, temperatureUnit, tomorrowBriefEnabled, vapidPublicKey, windSpeedUnit]);
|
||||||
|
|
||||||
const notificationStatusLabel = useMemo(() => {
|
const notificationStatusLabel = useMemo(() => {
|
||||||
switch (notificationStatus) {
|
switch (notificationStatus) {
|
||||||
@@ -247,6 +255,7 @@ export function SettingsPage() {
|
|||||||
longitude: notificationLongitude,
|
longitude: notificationLongitude,
|
||||||
locationName: notificationLocationName,
|
locationName: notificationLocationName,
|
||||||
countyTeryt: notificationCountyTeryt,
|
countyTeryt: notificationCountyTeryt,
|
||||||
|
temperatureUnit,
|
||||||
windSpeedUnit,
|
windSpeedUnit,
|
||||||
});
|
});
|
||||||
setNotificationsEnabled(true);
|
setNotificationsEnabled(true);
|
||||||
@@ -310,6 +319,7 @@ export function SettingsPage() {
|
|||||||
longitude: notificationLongitude,
|
longitude: notificationLongitude,
|
||||||
locationName: notificationLocationName,
|
locationName: notificationLocationName,
|
||||||
countyTeryt: notificationCountyTeryt,
|
countyTeryt: notificationCountyTeryt,
|
||||||
|
temperatureUnit,
|
||||||
windSpeedUnit,
|
windSpeedUnit,
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
@@ -331,6 +341,7 @@ export function SettingsPage() {
|
|||||||
longitude: notificationLongitude,
|
longitude: notificationLongitude,
|
||||||
locationName: notificationLocationName,
|
locationName: notificationLocationName,
|
||||||
countyTeryt: notificationCountyTeryt,
|
countyTeryt: notificationCountyTeryt,
|
||||||
|
temperatureUnit,
|
||||||
windSpeedUnit,
|
windSpeedUnit,
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
@@ -353,6 +364,26 @@ export function SettingsPage() {
|
|||||||
<SettingsRow icon={Palette} title={t("settings.theme.title")} description={t("settings.theme.description")}>
|
<SettingsRow icon={Palette} title={t("settings.theme.title")} description={t("settings.theme.description")}>
|
||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
</SettingsRow>
|
</SettingsRow>
|
||||||
|
<SettingsRow icon={Thermometer} title={t("settings.units.temperature.title")} description={t("settings.units.temperature.description")}>
|
||||||
|
<div className="surface-control inline-flex rounded-full p-1" role="radiogroup" aria-label={t("settings.units.temperature.label")}>
|
||||||
|
{TEMPERATURE_UNITS.map((unit) => (
|
||||||
|
<button
|
||||||
|
key={unit}
|
||||||
|
type="button"
|
||||||
|
role="radio"
|
||||||
|
aria-checked={temperatureUnit === unit}
|
||||||
|
className={`min-w-14 rounded-full border px-3.5 py-1.5 text-sm font-medium transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent ${
|
||||||
|
temperatureUnit === unit
|
||||||
|
? "border-accent/45 bg-accent/10 text-accent shadow-soft"
|
||||||
|
: "border-transparent text-muted hover:bg-surface-muted/70 hover:text-foreground"
|
||||||
|
}`}
|
||||||
|
onClick={() => setTemperatureUnit(unit)}
|
||||||
|
>
|
||||||
|
{t(temperatureUnitKeys[unit])}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</SettingsRow>
|
||||||
<SettingsRow icon={Wind} title={t("settings.units.wind.title")} description={t("settings.units.wind.description")}>
|
<SettingsRow icon={Wind} title={t("settings.units.wind.title")} description={t("settings.units.wind.description")}>
|
||||||
<div className="surface-control inline-flex rounded-full p-1" role="radiogroup" aria-label={t("settings.units.wind.label")}>
|
<div className="surface-control inline-flex rounded-full p-1" role="radiogroup" aria-label={t("settings.units.wind.label")}>
|
||||||
{WIND_SPEED_UNITS.map((unit) => (
|
{WIND_SPEED_UNITS.map((unit) => (
|
||||||
|
|||||||
@@ -9,16 +9,17 @@ import { useWeatherStore } from "@/lib/store";
|
|||||||
|
|
||||||
export function CurrentConditionsCard({ station }: { station: SynopStation }) {
|
export function CurrentConditionsCard({ station }: { station: SynopStation }) {
|
||||||
const { language, t } = useI18n();
|
const { language, t } = useI18n();
|
||||||
|
const temperatureUnit = useWeatherStore((state) => state.temperatureUnit);
|
||||||
const windSpeedUnit = useWeatherStore((state) => state.windSpeedUnit);
|
const windSpeedUnit = useWeatherStore((state) => state.windSpeedUnit);
|
||||||
const feelsLike = calculateFeelsLike(station.temperature, station.humidity, station.windSpeed);
|
const feelsLike = calculateFeelsLike(station.temperature, station.humidity, station.windSpeed);
|
||||||
const metrics = [
|
const metrics = [
|
||||||
{ icon: Thermometer, label: t("weather.feelsLike"), value: formatTemperature(feelsLike, language), detail: t("weather.feelsLikeDetail") },
|
{ icon: Thermometer, label: t("weather.feelsLike"), value: formatTemperature(feelsLike, language, temperatureUnit), detail: t("weather.feelsLikeDetail") },
|
||||||
{ icon: Droplets, label: t("weather.humidity"), value: formatHumidity(station.humidity, language), detail: t("weather.humidityDetail") },
|
{ icon: Droplets, label: t("weather.humidity"), value: formatHumidity(station.humidity, language), detail: t("weather.humidityDetail") },
|
||||||
{ icon: Gauge, label: t("weather.pressure"), value: formatPressure(station.pressure, language), detail: t("weather.pressureDetail") },
|
{ icon: Gauge, label: t("weather.pressure"), value: formatPressure(station.pressure, language), detail: t("weather.pressureDetail") },
|
||||||
{ icon: Wind, label: t("weather.windSpeed"), value: formatWind(station.windSpeed, null, language, windSpeedUnit), detail: t("weather.windSpeedDetail") },
|
{ icon: Wind, label: t("weather.windSpeed"), value: formatWind(station.windSpeed, null, language, windSpeedUnit), detail: t("weather.windSpeedDetail") },
|
||||||
{ icon: Navigation, label: t("weather.windDirection"), value: station.windDirection === null ? t("common.noData") : `${station.windDirection}° ${getWindDirection(station.windDirection)}`, detail: t("weather.windDirectionDetail") },
|
{ icon: Navigation, label: t("weather.windDirection"), value: station.windDirection === null ? t("common.noData") : `${station.windDirection}° ${getWindDirection(station.windDirection)}`, detail: t("weather.windDirectionDetail") },
|
||||||
{ icon: Umbrella, label: t("weather.rainfallTotal"), value: formatRainfall(station.rainfall, language), detail: t("weather.rainfallDetail") },
|
{ icon: Umbrella, label: t("weather.rainfallTotal"), value: formatRainfall(station.rainfall, language), detail: t("weather.rainfallDetail") },
|
||||||
{ icon: CloudSun, label: t("weather.airTemperature"), value: formatTemperature(station.temperature, language), detail: t("weather.temperatureDetail") },
|
{ icon: CloudSun, label: t("weather.airTemperature"), value: formatTemperature(station.temperature, language, temperatureUnit), detail: t("weather.temperatureDetail") },
|
||||||
];
|
];
|
||||||
return (
|
return (
|
||||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export function FeaturedStationsSection({ stations }: { stations: SynopStation[]
|
|||||||
const { language, t } = useI18n();
|
const { language, t } = useI18n();
|
||||||
const selectedStationId = useWeatherStore((state) => state.selectedStationId);
|
const selectedStationId = useWeatherStore((state) => state.selectedStationId);
|
||||||
const selectStation = useWeatherStore((state) => state.selectStation);
|
const selectStation = useWeatherStore((state) => state.selectStation);
|
||||||
|
const temperatureUnit = useWeatherStore((state) => state.temperatureUnit);
|
||||||
const featured = featuredNames.flatMap((name) => stations.find((station) => station.name === name) ?? []);
|
const featured = featuredNames.flatMap((name) => stations.find((station) => station.name === name) ?? []);
|
||||||
return (
|
return (
|
||||||
<section className="space-y-3">
|
<section className="space-y-3">
|
||||||
@@ -33,7 +34,7 @@ export function FeaturedStationsSection({ stations }: { stations: SynopStation[]
|
|||||||
>
|
>
|
||||||
<span className="min-w-0">
|
<span className="min-w-0">
|
||||||
<span className="block truncate text-xs font-medium text-muted">{station.name}</span>
|
<span className="block truncate text-xs font-medium text-muted">{station.name}</span>
|
||||||
<span className="mt-1 block text-xl font-semibold tracking-tight">{formatTemperature(station.temperature, language)}</span>
|
<span className="mt-1 block text-xl font-semibold tracking-tight">{formatTemperature(station.temperature, language, temperatureUnit)}</span>
|
||||||
</span>
|
</span>
|
||||||
<WeatherIcon mood={getWeatherMoodFromData(station)} className="size-6 shrink-0 text-accent" />
|
<WeatherIcon mood={getWeatherMoodFromData(station)} className="size-6 shrink-0 text-accent" />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export function StationCard({ station, index = 0 }: { station: SynopStation; ind
|
|||||||
const favorites = useWeatherStore((state) => state.favorites);
|
const favorites = useWeatherStore((state) => state.favorites);
|
||||||
const toggleFavorite = useWeatherStore((state) => state.toggleFavorite);
|
const toggleFavorite = useWeatherStore((state) => state.toggleFavorite);
|
||||||
const selectStation = useWeatherStore((state) => state.selectStation);
|
const selectStation = useWeatherStore((state) => state.selectStation);
|
||||||
|
const temperatureUnit = useWeatherStore((state) => state.temperatureUnit);
|
||||||
const windSpeedUnit = useWeatherStore((state) => state.windSpeedUnit);
|
const windSpeedUnit = useWeatherStore((state) => state.windSpeedUnit);
|
||||||
const favorite = favorites.includes(station.id);
|
const favorite = favorites.includes(station.id);
|
||||||
const mood = getWeatherMoodFromData(station);
|
const mood = getWeatherMoodFromData(station);
|
||||||
@@ -27,7 +28,7 @@ export function StationCard({ station, index = 0 }: { station: SynopStation; ind
|
|||||||
<div className="flex items-start justify-between gap-2">
|
<div className="flex items-start justify-between gap-2">
|
||||||
<Link href={`/station/${station.id}`} onClick={() => selectStation(station.id)} className="min-w-0 flex-1 rounded-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent">
|
<Link href={`/station/${station.id}`} onClick={() => selectStation(station.id)} className="min-w-0 flex-1 rounded-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent">
|
||||||
<p className="truncate text-sm font-semibold">{station.name}</p>
|
<p className="truncate text-sm font-semibold">{station.name}</p>
|
||||||
<p className="mt-3 text-4xl font-light tracking-[-0.08em]">{formatTemperature(station.temperature, language)}</p>
|
<p className="mt-3 text-4xl font-light tracking-[-0.08em]">{formatTemperature(station.temperature, language, temperatureUnit)}</p>
|
||||||
</Link>
|
</Link>
|
||||||
<div className="flex flex-col items-end gap-2">
|
<div className="flex flex-col items-end gap-2">
|
||||||
<WeatherIcon mood={mood} className="size-9 text-accent" />
|
<WeatherIcon mood={mood} className="size-9 text-accent" />
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ function readDevWeatherEffectOverride(): DevWeatherEffectOverride | null {
|
|||||||
|
|
||||||
export function WeatherHero({ station, currentWeather, currentWeatherLoading = false, locationName, distanceKm }: { station: SynopStation; currentWeather?: ImgwCurrentWeather | null; currentWeatherLoading?: boolean; locationName?: string; distanceKm?: number }) {
|
export function WeatherHero({ station, currentWeather, currentWeatherLoading = false, locationName, distanceKm }: { station: SynopStation; currentWeather?: ImgwCurrentWeather | null; currentWeatherLoading?: boolean; locationName?: string; distanceKm?: number }) {
|
||||||
const { language, t } = useI18n();
|
const { language, t } = useI18n();
|
||||||
|
const temperatureUnit = useWeatherStore((state) => state.temperatureUnit);
|
||||||
const windSpeedUnit = useWeatherStore((state) => state.windSpeedUnit);
|
const windSpeedUnit = useWeatherStore((state) => state.windSpeedUnit);
|
||||||
const [devEffectOverride, setDevEffectOverride] = useState<DevWeatherEffectOverride | null>(null);
|
const [devEffectOverride, setDevEffectOverride] = useState<DevWeatherEffectOverride | null>(null);
|
||||||
const displayedLocationName = locationName ?? station.name;
|
const displayedLocationName = locationName ?? station.name;
|
||||||
@@ -121,10 +122,10 @@ export function WeatherHero({ station, currentWeather, currentWeatherLoading = f
|
|||||||
<div className="mt-8 flex items-end justify-between gap-3 sm:mt-10">
|
<div className="mt-8 flex items-end justify-between gap-3 sm:mt-10">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-[5.8rem] font-semibold leading-[0.85] tracking-[-0.1em] sm:text-[8rem]">
|
<div className="text-[5.8rem] font-semibold leading-[0.85] tracking-[-0.1em] sm:text-[8rem]">
|
||||||
{formatTemperature(displayedStation.temperature, language)}
|
{formatTemperature(displayedStation.temperature, language, temperatureUnit)}
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-5 text-xl font-medium tracking-tight sm:text-2xl">{getWeatherDescription(displayedStation, language, currentWeather?.condition)}</p>
|
<p className="mt-5 text-xl font-medium tracking-tight sm:text-2xl">{getWeatherDescription(displayedStation, language, currentWeather?.condition)}</p>
|
||||||
<p className="mt-1 text-sm text-muted">{t("weather.feelsLike")} {formatTemperature(feelsLike, language)} · {t("weather.measurement")} {formatDateTime(displayedStation.measuredAt, language)}</p>
|
<p className="mt-1 text-sm text-muted">{t("weather.feelsLike")} {formatTemperature(feelsLike, language, temperatureUnit)} · {t("weather.measurement")} {formatDateTime(displayedStation.measuredAt, language)}</p>
|
||||||
</div>
|
</div>
|
||||||
<WeatherIcon mood={mood} condition={currentWeather?.condition} className="mb-4 size-20 text-accent sm:size-28" />
|
<WeatherIcon mood={mood} condition={currentWeather?.condition} className="mb-4 size-20 text-accent sm:size-28" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ Subskrypcja Web Push zapisuje:
|
|||||||
- czy aktywny jest brief na jutro,
|
- czy aktywny jest brief na jutro,
|
||||||
- współrzędne i nazwę lokalizacji,
|
- współrzędne i nazwę lokalizacji,
|
||||||
- opcjonalny powiat TERYT,
|
- opcjonalny powiat TERYT,
|
||||||
|
- wybraną jednostkę temperatury dla briefów wysyłanych z serwera,
|
||||||
- wybraną jednostkę wiatru dla briefów wysyłanych z serwera.
|
- wybraną jednostkę wiatru dla briefów wysyłanych z serwera.
|
||||||
|
|
||||||
Ostrzeżenia meteo są filtrowane po powiecie TERYT, jeśli lokalizacja go dostarcza. W przeciwnym razie używany jest fallback wojewódzki.
|
Ostrzeżenia meteo są filtrowane po powiecie TERYT, jeśli lokalizacja go dostarcza. W przeciwnym razie używany jest fallback wojewódzki.
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ Prognoza godzinowa i dzienna rozpoznaje:
|
|||||||
|
|
||||||
Bieżąca analiza IMGW Hybrid rozpoznaje bezpośrednio opad deszczu, śnieg i burzę. Gdy żadne z tych zjawisk nie występuje, hero może pokazać pomocniczy opis: `Silny wiatr`, `Wilgotne warunki` albo `Spokojne warunki`.
|
Bieżąca analiza IMGW Hybrid rozpoznaje bezpośrednio opad deszczu, śnieg i burzę. Gdy żadne z tych zjawisk nie występuje, hero może pokazać pomocniczy opis: `Silny wiatr`, `Wilgotne warunki` albo `Spokojne warunki`.
|
||||||
|
|
||||||
Jednostka wiatru wybierana w `/settings` dotyczy prezentacji w UI, briefach i powiadomieniach. Dane źródłowe oraz progi logiki pogody pozostają liczone w `m/s`.
|
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`.
|
||||||
|
|
||||||
## Mood i Efekty Wizualne
|
## Mood i Efekty Wizualne
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import type { Language, TranslationKey } from "@/lib/i18n";
|
import type { Language, TranslationKey } from "@/lib/i18n";
|
||||||
import { translate } 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 { HourlyForecast } from "@/types/forecast";
|
||||||
import type { WindSpeedUnit } from "@/types/units";
|
import type { TemperatureUnit, WindSpeedUnit } from "@/types/units";
|
||||||
|
|
||||||
export function getForecastConditionKey(code: number | null): TranslationKey {
|
export function getForecastConditionKey(code: number | null): TranslationKey {
|
||||||
if (code === 0) return "forecast.condition.clear";
|
if (code === 0) return "forecast.condition.clear";
|
||||||
@@ -20,9 +20,9 @@ export function getForecastCondition(code: number | null, language: Language) {
|
|||||||
return translate(language, getForecastConditionKey(code));
|
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 "—";
|
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) {
|
export function formatForecastRainfall(value: number | null, language: Language) {
|
||||||
|
|||||||
10
lib/i18n.tsx
10
lib/i18n.tsx
@@ -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.language.description": "Zmieniaj język interfejsu. Nazwy stacji i treści IMGW pozostają bez automatycznego tłumaczenia.",
|
||||||
"settings.theme.title": "Motyw",
|
"settings.theme.title": "Motyw",
|
||||||
"settings.theme.description": "Przełącz jasny lub ciemny wygląd aplikacji na tym urządzeniu.",
|
"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.title": "Jednostka wiatru",
|
||||||
"settings.units.wind.description": "Stosowana w prognozie, briefach, powiadomieniach i bieżących pomiarach na tym urządzeniu.",
|
"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.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.language.description": "Change the interface language. Station names and IMGW content are not translated automatically.",
|
||||||
"settings.theme.title": "Theme",
|
"settings.theme.title": "Theme",
|
||||||
"settings.theme.description": "Switch the light or dark appearance on this device.",
|
"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.title": "Wind unit",
|
||||||
"settings.units.wind.description": "Used in forecasts, briefs, notifications and current readings on this device.",
|
"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.label": "Choose wind unit",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { Language } from "@/lib/i18n";
|
import type { Language } from "@/lib/i18n";
|
||||||
import type { Province } from "@/types/province";
|
import type { Province } from "@/types/province";
|
||||||
import type { WindSpeedUnit } from "@/types/units";
|
import type { TemperatureUnit, WindSpeedUnit } from "@/types/units";
|
||||||
|
|
||||||
interface VapidKeyResponse {
|
interface VapidKeyResponse {
|
||||||
configured: boolean;
|
configured: boolean;
|
||||||
@@ -20,6 +20,7 @@ interface SavePushSubscriptionOptions {
|
|||||||
longitude?: number | null;
|
longitude?: number | null;
|
||||||
locationName?: string | null;
|
locationName?: string | null;
|
||||||
countyTeryt?: string | null;
|
countyTeryt?: string | null;
|
||||||
|
temperatureUnit?: TemperatureUnit;
|
||||||
windSpeedUnit?: WindSpeedUnit;
|
windSpeedUnit?: WindSpeedUnit;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,6 +39,7 @@ export async function savePushSubscription(subscription: PushSubscription, provi
|
|||||||
longitude: options.longitude ?? null,
|
longitude: options.longitude ?? null,
|
||||||
locationName: options.locationName ?? null,
|
locationName: options.locationName ?? null,
|
||||||
countyTeryt: options.countyTeryt ?? null,
|
countyTeryt: options.countyTeryt ?? null,
|
||||||
|
temperatureUnit: options.temperatureUnit,
|
||||||
windSpeedUnit: options.windSpeedUnit,
|
windSpeedUnit: options.windSpeedUnit,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import { create } from "zustand";
|
|||||||
import { persist } from "zustand/middleware";
|
import { persist } from "zustand/middleware";
|
||||||
import type { SelectedLocation } from "@/types/location";
|
import type { SelectedLocation } from "@/types/location";
|
||||||
import type { Province } from "@/types/province";
|
import type { Province } from "@/types/province";
|
||||||
import type { WindSpeedUnit } from "@/types/units";
|
import type { TemperatureUnit, WindSpeedUnit } from "@/types/units";
|
||||||
import { DEFAULT_WIND_SPEED_UNIT } from "@/lib/weather-utils";
|
import { DEFAULT_TEMPERATURE_UNIT, DEFAULT_WIND_SPEED_UNIT } from "@/lib/weather-utils";
|
||||||
|
|
||||||
type NotificationProvinceMode = "selected" | "manual";
|
type NotificationProvinceMode = "selected" | "manual";
|
||||||
|
|
||||||
@@ -18,6 +18,7 @@ interface WeatherStore {
|
|||||||
tomorrowBriefNotificationsEnabled: boolean;
|
tomorrowBriefNotificationsEnabled: boolean;
|
||||||
warningNotificationProvinceMode: NotificationProvinceMode;
|
warningNotificationProvinceMode: NotificationProvinceMode;
|
||||||
warningNotificationProvince: Province | null;
|
warningNotificationProvince: Province | null;
|
||||||
|
temperatureUnit: TemperatureUnit;
|
||||||
windSpeedUnit: WindSpeedUnit;
|
windSpeedUnit: WindSpeedUnit;
|
||||||
toggleFavorite: (id: string) => void;
|
toggleFavorite: (id: string) => void;
|
||||||
selectStation: (id: string) => void;
|
selectStation: (id: string) => void;
|
||||||
@@ -27,6 +28,7 @@ interface WeatherStore {
|
|||||||
setTomorrowBriefNotificationsEnabled: (enabled: boolean) => void;
|
setTomorrowBriefNotificationsEnabled: (enabled: boolean) => void;
|
||||||
setWarningNotificationProvinceMode: (mode: NotificationProvinceMode) => void;
|
setWarningNotificationProvinceMode: (mode: NotificationProvinceMode) => void;
|
||||||
setWarningNotificationProvince: (province: Province | null) => void;
|
setWarningNotificationProvince: (province: Province | null) => void;
|
||||||
|
setTemperatureUnit: (unit: TemperatureUnit) => void;
|
||||||
setWindSpeedUnit: (unit: WindSpeedUnit) => void;
|
setWindSpeedUnit: (unit: WindSpeedUnit) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,6 +43,7 @@ export const useWeatherStore = create<WeatherStore>()(
|
|||||||
tomorrowBriefNotificationsEnabled: false,
|
tomorrowBriefNotificationsEnabled: false,
|
||||||
warningNotificationProvinceMode: "selected",
|
warningNotificationProvinceMode: "selected",
|
||||||
warningNotificationProvince: null,
|
warningNotificationProvince: null,
|
||||||
|
temperatureUnit: DEFAULT_TEMPERATURE_UNIT,
|
||||||
windSpeedUnit: DEFAULT_WIND_SPEED_UNIT,
|
windSpeedUnit: DEFAULT_WIND_SPEED_UNIT,
|
||||||
toggleFavorite: (id) =>
|
toggleFavorite: (id) =>
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
@@ -55,6 +58,7 @@ export const useWeatherStore = create<WeatherStore>()(
|
|||||||
setTomorrowBriefNotificationsEnabled: (enabled) => set({ tomorrowBriefNotificationsEnabled: enabled }),
|
setTomorrowBriefNotificationsEnabled: (enabled) => set({ tomorrowBriefNotificationsEnabled: enabled }),
|
||||||
setWarningNotificationProvinceMode: (mode) => set({ warningNotificationProvinceMode: mode }),
|
setWarningNotificationProvinceMode: (mode) => set({ warningNotificationProvinceMode: mode }),
|
||||||
setWarningNotificationProvince: (province) => set({ warningNotificationProvince: province }),
|
setWarningNotificationProvince: (province) => set({ warningNotificationProvince: province }),
|
||||||
|
setTemperatureUnit: (unit) => set({ temperatureUnit: unit }),
|
||||||
setWindSpeedUnit: (unit) => set({ windSpeedUnit: unit }),
|
setWindSpeedUnit: (unit) => set({ windSpeedUnit: unit }),
|
||||||
}),
|
}),
|
||||||
{ name: "wtr:preferences" },
|
{ name: "wtr:preferences" },
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import type { HourlyForecast, WeatherForecast } from "@/types/forecast";
|
|||||||
import type { WeatherWarning } from "@/types/imgw";
|
import type { WeatherWarning } from "@/types/imgw";
|
||||||
import type { Province } from "@/types/province";
|
import type { Province } from "@/types/province";
|
||||||
import { warningMatchesCounty } from "@/lib/warning-regions";
|
import { warningMatchesCounty } from "@/lib/warning-regions";
|
||||||
import { DEFAULT_WIND_SPEED_UNIT, formatWindSpeed } from "@/lib/weather-utils";
|
import { DEFAULT_TEMPERATURE_UNIT, DEFAULT_WIND_SPEED_UNIT, formatTemperature as formatDisplayTemperature, formatWindSpeed } from "@/lib/weather-utils";
|
||||||
import type { WindSpeedUnit } from "@/types/units";
|
import type { TemperatureUnit, WindSpeedUnit } from "@/types/units";
|
||||||
|
|
||||||
export type WeatherBriefSeverity = "normal" | "attention" | "warning";
|
export type WeatherBriefSeverity = "normal" | "attention" | "warning";
|
||||||
|
|
||||||
@@ -26,6 +26,7 @@ interface BuildWeatherBriefInput {
|
|||||||
countyTeryt?: string | null;
|
countyTeryt?: string | null;
|
||||||
locationName: string;
|
locationName: string;
|
||||||
language: Language;
|
language: Language;
|
||||||
|
temperatureUnit?: TemperatureUnit;
|
||||||
windSpeedUnit?: WindSpeedUnit;
|
windSpeedUnit?: WindSpeedUnit;
|
||||||
now?: Date;
|
now?: Date;
|
||||||
}
|
}
|
||||||
@@ -36,10 +37,6 @@ function formatNumber(value: number, language: Language, digits = 0) {
|
|||||||
}).format(value);
|
}).format(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatTemperature(value: number, language: Language) {
|
|
||||||
return `${formatNumber(value, language)}°C`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatRainfall(value: number, language: Language) {
|
function formatRainfall(value: number, language: Language) {
|
||||||
return `${formatNumber(value, language, 1)} mm`;
|
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";
|
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);
|
const hours = getUpcomingHours(forecast.hourly, now, 24);
|
||||||
if (!hours.length) return null;
|
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 rainPeakHour = getPeakHour(hours, (hour) => hour.precipitationProbability);
|
||||||
const conditionPeakHour = hours.find((hour) => hour.weatherCode !== null) ?? null;
|
const conditionPeakHour = hours.find((hour) => hour.weatherCode !== null) ?? null;
|
||||||
const temperatureRange = minimumTemperature !== null && maximumTemperature !== null
|
const temperatureRange = minimumTemperature !== null && maximumTemperature !== null
|
||||||
? `${formatTemperature(minimumTemperature, language)}-${formatTemperature(maximumTemperature, language)}`
|
? `${formatDisplayTemperature(minimumTemperature, language, temperatureUnit)}-${formatDisplayTemperature(maximumTemperature, language, temperatureUnit)}`
|
||||||
: null;
|
: null;
|
||||||
const hasRainSignal = (maximumProbability ?? 0) >= 35 || (rainfallTotal ?? 0) >= 0.5;
|
const hasRainSignal = (maximumProbability ?? 0) >= 35 || (rainfallTotal ?? 0) >= 0.5;
|
||||||
const hasWindSignal = (maximumWind ?? 0) >= 8;
|
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 targetDateKey = getWarsawDateKey(now, 1);
|
||||||
const hours = getForecastHoursForDate(forecast.hourly, targetDateKey);
|
const hours = getForecastHoursForDate(forecast.hourly, targetDateKey);
|
||||||
if (!hours.length) return null;
|
if (!hours.length) return null;
|
||||||
@@ -318,7 +315,7 @@ export function buildTomorrowWeatherBrief({ forecast, warnings, province, county
|
|||||||
const hasWindSignal = (maximumWind ?? 0) >= 8;
|
const hasWindSignal = (maximumWind ?? 0) >= 8;
|
||||||
const condition = getTomorrowCondition(hours, rainfallTotal, maximumProbability, language);
|
const condition = getTomorrowCondition(hours, rainfallTotal, maximumProbability, language);
|
||||||
const temperatureRange = minimumTemperature !== null && maximumTemperature !== null
|
const temperatureRange = minimumTemperature !== null && maximumTemperature !== null
|
||||||
? `${formatTemperature(minimumTemperature, language)} / ${formatTemperature(maximumTemperature, language)}`
|
? `${formatDisplayTemperature(minimumTemperature, language, temperatureUnit)} / ${formatDisplayTemperature(maximumTemperature, language, temperatureUnit)}`
|
||||||
: null;
|
: null;
|
||||||
const severity: WeatherBriefSeverity = topWarning || hasThunderstorm ? "warning" : hasRainSignal || hasWindSignal ? "attention" : "normal";
|
const severity: WeatherBriefSeverity = topWarning || hasThunderstorm ? "warning" : hasRainSignal || hasWindSignal ? "attention" : "normal";
|
||||||
const provinceLabel = province ? formatProvinceName(province, language) : null;
|
const provinceLabel = province ? formatProvinceName(province, language) : null;
|
||||||
|
|||||||
@@ -11,12 +11,19 @@ import type {
|
|||||||
import { translate, type Language } from "@/lib/i18n";
|
import { translate, type Language } from "@/lib/i18n";
|
||||||
import { getProvinceFromTeryt, normalizeProvinceName } from "@/lib/provinces";
|
import { getProvinceFromTeryt, normalizeProvinceName } from "@/lib/provinces";
|
||||||
import type { CurrentWeatherCondition } from "@/types/imgw-current";
|
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" };
|
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 DEFAULT_WIND_SPEED_UNIT: WindSpeedUnit = "ms";
|
||||||
export const WIND_SPEED_UNITS: WindSpeedUnit[] = ["ms", "kmh", "mph"];
|
export const WIND_SPEED_UNITS: WindSpeedUnit[] = ["ms", "kmh", "mph"];
|
||||||
|
|
||||||
|
const temperatureUnitLabels: Record<TemperatureUnit, string> = {
|
||||||
|
c: "°C",
|
||||||
|
f: "°F",
|
||||||
|
};
|
||||||
|
|
||||||
const windSpeedUnitLabels: Record<WindSpeedUnit, string> = {
|
const windSpeedUnitLabels: Record<WindSpeedUnit, string> = {
|
||||||
ms: "m/s",
|
ms: "m/s",
|
||||||
kmh: "km/h",
|
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") {
|
export function isTemperatureUnit(value: unknown): value is TemperatureUnit {
|
||||||
return value === null ? translate(language, "common.noData") : `${Math.round(value)}°`;
|
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") {
|
export function formatPressure(value: number | null, language: Language = "pl") {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { Language } from "@/lib/i18n";
|
import type { Language } from "@/lib/i18n";
|
||||||
import type { Province } from "@/types/province";
|
import type { Province } from "@/types/province";
|
||||||
import type { WindSpeedUnit } from "@/types/units";
|
import type { TemperatureUnit, WindSpeedUnit } from "@/types/units";
|
||||||
|
|
||||||
export interface PushSubscriptionKeys {
|
export interface PushSubscriptionKeys {
|
||||||
p256dh: string;
|
p256dh: string;
|
||||||
@@ -25,6 +25,7 @@ export interface WarningPushSubscription {
|
|||||||
longitude: number | null;
|
longitude: number | null;
|
||||||
locationName: string | null;
|
locationName: string | null;
|
||||||
countyTeryt: string | null;
|
countyTeryt: string | null;
|
||||||
|
temperatureUnit: TemperatureUnit;
|
||||||
windSpeedUnit: WindSpeedUnit;
|
windSpeedUnit: WindSpeedUnit;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
|
|||||||
@@ -1 +1,2 @@
|
|||||||
|
export type TemperatureUnit = "c" | "f";
|
||||||
export type WindSpeedUnit = "ms" | "kmh" | "mph";
|
export type WindSpeedUnit = "ms" | "kmh" | "mph";
|
||||||
|
|||||||
Reference in New Issue
Block a user