chore: add prettier formatting
All checks were successful
CI / Lint, typecheck and build (push) Successful in 9m56s

This commit is contained in:
zv
2026-06-14 20:26:56 +02:00
parent 8bbd9397a1
commit ee55521803
79 changed files with 2451 additions and 969 deletions

View File

@@ -28,6 +28,9 @@ jobs:
- name: Lint - name: Lint
run: npm run lint run: npm run lint
- name: Format check
run: npm run format:check
- name: Typecheck - name: Typecheck
run: npm run typecheck run: npm run typecheck

9
.prettierignore Normal file
View File

@@ -0,0 +1,9 @@
node_modules
.next
out
coverage
data
*.sqlite
*.sqlite-*
tsconfig.tsbuildinfo
next-env.d.ts

6
.prettierrc.json Normal file
View File

@@ -0,0 +1,6 @@
{
"printWidth": 120,
"semi": true,
"singleQuote": false,
"trailingComma": "all"
}

View File

@@ -23,13 +23,15 @@ Wymagany jest Node.js 20.9 lub nowszy.
npm install npm install
npm run dev npm run dev
npm run lint npm run lint
npm run format
npm run format:check
npm run typecheck npm run typecheck
npm run build npm run build
npm run start npm run start
npm run notifications:worker npm run notifications:worker
``` ```
Repozytorium nie ma obecnie skryptu testów ani formattera. `npm run typecheck` uruchamia `tsc --noEmit`, a `npm run build` uruchamia produkcyjny build Next.js. `npm run notifications:worker` uruchamia osobny proces Node do cyklicznego wywoływania endpointów Web Push na self-hostingu; wczytuje `.env` i `.env.local`, ale nadal wymaga równolegle działającego `npm run start` albo poprawnego `WTR_APP_URL`. Nie opisuj ani nie uruchamiaj nieistniejących komend jako standardowego workflow. Repozytorium nie ma obecnie skryptu testów. `npm run format` i `npm run format:check` używają Prettiera. `npm run typecheck` uruchamia `tsc --noEmit`, a `npm run build` uruchamia produkcyjny build Next.js. `npm run notifications:worker` uruchamia osobny proces Node do cyklicznego wywoływania endpointów Web Push na self-hostingu; wczytuje `.env` i `.env.local`, ale nadal wymaga równolegle działającego `npm run start` albo poprawnego `WTR_APP_URL`. Nie opisuj ani nie uruchamiaj nieistniejących komend jako standardowego workflow.
## Konwencje kodu ## Konwencje kodu
@@ -107,7 +109,7 @@ Przy zmianach UI:
- Zmiany w README, docs, tekstach i komentarzach: nie uruchamiaj `npm run lint`, `npm run typecheck` ani `npm run build`; wystarczy `git diff` i ewentualnie `git diff --check`. - Zmiany w README, docs, tekstach i komentarzach: nie uruchamiaj `npm run lint`, `npm run typecheck` ani `npm run build`; wystarczy `git diff` i ewentualnie `git diff --check`.
- Małe zmiany wizualne, np. klasy Tailwind, border, radius, spacing, kolory albo copy w JSX: nie uruchamiaj rutynowo `npm run build`. Uruchom `npm run lint` tylko, gdy zmiana mogła naruszyć składnię albo reguły lintingu, a `npm run typecheck` tylko przy zmianach typów, propsów lub logiki. - Małe zmiany wizualne, np. klasy Tailwind, border, radius, spacing, kolory albo copy w JSX: nie uruchamiaj rutynowo `npm run build`. Uruchom `npm run lint` tylko, gdy zmiana mogła naruszyć składnię albo reguły lintingu, a `npm run typecheck` tylko przy zmianach typów, propsów lub logiki.
- Zmiany w komponentach z logiką, hookach, typach, parserach i danych pogodowych: uruchom `npm run typecheck`; dodaj `npm run lint`, jeśli zmiana dotyka kodu TS/TSX. - Zmiany w komponentach z logiką, hookach, typach, parserach i danych pogodowych: uruchom `npm run typecheck`; dodaj `npm run lint`, jeśli zmiana dotyka kodu TS/TSX.
- Zmiany wysokiego ryzyka, np. Next routing, config, dependencies, `package-lock`, PWA/service worker, API/server code, build config i obsługa env: uruchom pełny zestaw `npm run lint`, `npm run typecheck` i `npm run build`. - Zmiany wysokiego ryzyka, np. Next routing, config, dependencies, `package-lock`, PWA/service worker, API/server code, build config i obsługa env: uruchom pełny zestaw `npm run lint`, `npm run format:check`, `npm run typecheck` i `npm run build`.
- Przed większym pushem albo większym refaktorem możesz uruchomić pełną weryfikację. - Przed większym pushem albo większym refaktorem możesz uruchomić pełną weryfikację.
- Sprawdź `git status` przed zakończeniem. Używaj `git diff --check` wtedy, gdy zmiana mogła wprowadzić problemy whitespace. Nie commituj wygenerowanego churnu w `next-env.d.ts`. - Sprawdź `git status` przed zakończeniem. Używaj `git diff --check` wtedy, gdy zmiana mogła wprowadzić problemy whitespace. Nie commituj wygenerowanego churnu w `next-env.d.ts`.
- Commituj zakończone zmiany w formacie Conventional Commits, np. `fix: correct warning filtering`, ale nie używaj eskalacji dla `git commit` bez realnej potrzeby. - Commituj zakończone zmiany w formacie Conventional Commits, np. `fix: correct warning filtering`, ale nie używaj eskalacji dla `git commit` bez realnej potrzeby.

View File

@@ -63,15 +63,17 @@ Przykład znajduje się w [.env.example](.env.example).
## Komendy ## Komendy
| Komenda | Opis | | Komenda | Opis |
| --- | --- | | ------------------------------ | --------------------------------------------------------------------------------------- |
| `npm run dev` | Uruchamia serwer deweloperski Next.js. | | `npm run dev` | Uruchamia serwer deweloperski Next.js. |
| `npm run lint` | Uruchamia ESLint. | | `npm run lint` | Uruchamia ESLint. |
| `npm run format` | Formatuje obsługiwane pliki przez Prettier. |
| `npm run format:check` | Sprawdza formatowanie Prettier bez zapisywania zmian. |
| `npm run typecheck` | Uruchamia `tsc --noEmit`. | | `npm run typecheck` | Uruchamia `tsc --noEmit`. |
| `npm run build` | Buduje aplikację produkcyjnie i uruchamia kontrolę TypeScript wykonywaną przez Next.js. | | `npm run build` | Buduje aplikację produkcyjnie i uruchamia kontrolę TypeScript wykonywaną przez Next.js. |
| `npm run start` | Uruchamia zbudowaną aplikację. | | `npm run start` | Uruchamia zbudowaną aplikację. |
| `npm run notifications:worker` | Uruchamia self-hostowany worker powiadomień. Wymaga działającej aplikacji Next.js. | | `npm run notifications:worker` | Uruchamia self-hostowany worker powiadomień. Wymaga działającej aplikacji Next.js. |
Repozytorium nie ma obecnie skryptu testów ani formattera. Repozytorium nie ma obecnie skryptu testów. Formatowanie jest obsługiwane przez Prettier.
## CI ## CI
@@ -80,6 +82,7 @@ Repozytorium używa Gitea Actions. Workflow znajduje się w [.gitea/workflows/ci
Pipeline działa na `ubuntu-latest`, instaluje zależności przez `npm ci`, a następnie uruchamia: Pipeline działa na `ubuntu-latest`, instaluje zależności przez `npm ci`, a następnie uruchamia:
- `npm run lint`, - `npm run lint`,
- `npm run format:check`,
- `npm run typecheck`, - `npm run typecheck`,
- `npm run build`. - `npm run build`.

View File

@@ -26,6 +26,12 @@ export async function GET(request: Request) {
headers: { "Cache-Control": "public, s-maxage=120, stale-while-revalidate=300" }, headers: { "Cache-Control": "public, s-maxage=120, stale-while-revalidate=300" },
}); });
} catch { } catch {
return NextResponse.json({ error: region === "PL" ? "IMGW Hybrid service is unavailable." : "Open-Meteo current conditions are unavailable." }, { status: 502 }); return NextResponse.json(
{
error:
region === "PL" ? "IMGW Hybrid service is unavailable." : "Open-Meteo current conditions are unavailable.",
},
{ status: 502 },
);
} }
} }

View File

@@ -1,14 +1,7 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { isImgwNoProductsResponse, readImgwResponseBody } from "@/lib/imgw-empty-response"; import { isImgwNoProductsResponse, readImgwResponseBody } from "@/lib/imgw-empty-response";
const ALLOWED_PATHS = new Set([ const ALLOWED_PATHS = new Set(["synop", "hydro", "meteo", "warningsmeteo", "warningshydro", "product"]);
"synop",
"hydro",
"meteo",
"warningsmeteo",
"warningshydro",
"product",
]);
const IMGW_BASE_URL = "https://danepubliczne.imgw.pl/api/data"; const IMGW_BASE_URL = "https://danepubliczne.imgw.pl/api/data";
const USER_AGENT = "wtr./1.0 (weather data proxy)"; const USER_AGENT = "wtr./1.0 (weather data proxy)";
@@ -30,7 +23,11 @@ export async function GET(_: Request, context: { params: Promise<{ path: string[
}); });
if (!response.ok) { if (!response.ok) {
const body = await readImgwResponseBody(response); const body = await readImgwResponseBody(response);
if ((resource === "warningsmeteo" || resource === "warningshydro") && response.status === 404 && isImgwNoProductsResponse(body.json)) { if (
(resource === "warningsmeteo" || resource === "warningshydro") &&
response.status === 404 &&
isImgwNoProductsResponse(body.json)
) {
return NextResponse.json([], { return NextResponse.json([], {
headers: { "Cache-Control": "public, s-maxage=300, stale-while-revalidate=600" }, headers: { "Cache-Control": "public, s-maxage=300, stale-while-revalidate=600" },
}); });

View File

@@ -49,16 +49,18 @@ export async function GET(request: Request) {
headers: { Accept: "application/json", "User-Agent": USER_AGENT }, headers: { Accept: "application/json", "User-Agent": USER_AGENT },
}); });
if (!response.ok) return NextResponse.json({ error: "Reverse geocoding is unavailable." }, { status: 502 }); if (!response.ok) return NextResponse.json({ error: "Reverse geocoding is unavailable." }, { status: 502 });
const data = await response.json() as RawReverseLocation; const data = (await response.json()) as RawReverseLocation;
const name = data.address?.city const name =
?? data.address?.town data.address?.city ??
?? data.address?.village data.address?.town ??
?? data.address?.municipality data.address?.village ??
?? data.name data.address?.municipality ??
?? data.display_name?.split(",")[0]?.trim(); data.name ??
data.display_name?.split(",")[0]?.trim();
if (!name) return NextResponse.json({ error: "Place name is unavailable." }, { status: 404 }); if (!name) return NextResponse.json({ error: "Place name is unavailable." }, { status: 404 });
const countryCode = data.address?.country_code?.toUpperCase() ?? null; const countryCode = data.address?.country_code?.toUpperCase() ?? null;
return NextResponse.json({ return NextResponse.json(
{
name, name,
province: data.address?.state ?? null, province: data.address?.state ?? null,
district: data.address?.county ?? null, district: data.address?.county ?? null,
@@ -68,9 +70,11 @@ export async function GET(request: Request) {
admin2: data.address?.county ?? null, admin2: data.address?.county ?? null,
timezone: null, timezone: null,
region: getWeatherRegionForCountryCode(countryCode), region: getWeatherRegionForCountryCode(countryCode),
}, { },
{
headers: { "Cache-Control": "public, s-maxage=86400, stale-while-revalidate=604800" }, headers: { "Cache-Control": "public, s-maxage=86400, stale-while-revalidate=604800" },
}); },
);
} catch { } catch {
return NextResponse.json({ error: "Reverse geocoding is unavailable." }, { status: 502 }); return NextResponse.json({ error: "Reverse geocoding is unavailable." }, { status: 502 });
} }

View File

@@ -30,11 +30,13 @@ export async function GET(request: Request) {
try { try {
const response = await fetch(`${GEOCODING_URL}?${params}`, { next: { revalidate: 86400 } }); const response = await fetch(`${GEOCODING_URL}?${params}`, { next: { revalidate: 86400 } });
if (!response.ok) return NextResponse.json({ error: "Location search is unavailable." }, { status: 502 }); if (!response.ok) return NextResponse.json({ error: "Location search is unavailable." }, { status: 502 });
const data = await response.json() as { results?: RawLocation[] }; const data = (await response.json()) as { results?: RawLocation[] };
const results = (data.results ?? []).flatMap((location) => { const results = (data.results ?? []).flatMap((location) => {
if (!location.id || !location.name || !Number.isFinite(location.latitude) || !Number.isFinite(location.longitude)) return []; if (!location.id || !location.name || !Number.isFinite(location.latitude) || !Number.isFinite(location.longitude))
return [];
const countryCode = location.country_code?.toUpperCase() ?? null; const countryCode = location.country_code?.toUpperCase() ?? null;
return [{ return [
{
id: location.id, id: location.id,
name: location.name, name: location.name,
latitude: location.latitude as number, latitude: location.latitude as number,
@@ -47,9 +49,12 @@ export async function GET(request: Request) {
admin2: location.admin2 ?? null, admin2: location.admin2 ?? null,
timezone: location.timezone ?? null, timezone: location.timezone ?? null,
region: getWeatherRegionForCountryCode(countryCode), region: getWeatherRegionForCountryCode(countryCode),
}]; },
];
});
return NextResponse.json(results, {
headers: { "Cache-Control": "public, s-maxage=86400, stale-while-revalidate=604800" },
}); });
return NextResponse.json(results, { headers: { "Cache-Control": "public, s-maxage=86400, stale-while-revalidate=604800" } });
} catch { } catch {
return NextResponse.json({ error: "Location search is unavailable." }, { status: 502 }); return NextResponse.json({ error: "Location search is unavailable." }, { status: 502 });
} }

View File

@@ -1,6 +1,12 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { fetchMeteoWarnings } from "@/lib/server-warnings"; import { fetchMeteoWarnings } from "@/lib/server-warnings";
import { getPushSubscriptions, hasSentWarning, markWarningSent, pruneSentWarnings, removePushSubscription } from "@/lib/push-store"; import {
getPushSubscriptions,
hasSentWarning,
markWarningSent,
pruneSentWarnings,
removePushSubscription,
} from "@/lib/push-store";
import { isWebPushConfigured, sendWarningNotification } from "@/lib/push-service"; import { isWebPushConfigured, sendWarningNotification } from "@/lib/push-service";
import { warningMatchesCounty } from "@/lib/warning-regions"; import { warningMatchesCounty } from "@/lib/warning-regions";
import type { WeatherWarning } from "@/types/imgw"; import type { WeatherWarning } from "@/types/imgw";
@@ -40,8 +46,12 @@ export async function GET(request: Request) {
try { try {
const now = Date.now(); const now = Date.now();
const warnings = (await fetchMeteoWarnings(AbortSignal.timeout(12_000))).filter((warning) => isRelevantWarning(warning, now)); const warnings = (await fetchMeteoWarnings(AbortSignal.timeout(12_000))).filter((warning) =>
const subscriptions = getPushSubscriptions().filter((subscription) => subscription.region === "PL" && subscription.enabled && subscription.province); isRelevantWarning(warning, now),
);
const subscriptions = getPushSubscriptions().filter(
(subscription) => subscription.region === "PL" && subscription.enabled && subscription.province,
);
const activeWarningIds = new Set(warnings.map((warning) => warning.id)); const activeWarningIds = new Set(warnings.map((warning) => warning.id));
let sent = 0; let sent = 0;
let skipped = 0; let skipped = 0;
@@ -50,9 +60,11 @@ export async function GET(request: Request) {
pruneSentWarnings(activeWarningIds); pruneSentWarnings(activeWarningIds);
for (const subscription of subscriptions) { for (const subscription of subscriptions) {
const matchingWarnings = warnings.filter((warning) => ( const matchingWarnings = warnings.filter((warning) =>
subscription.countyTeryt ? warningMatchesCounty(warning, subscription.countyTeryt) : subscription.province !== null && warning.provinces.includes(subscription.province) subscription.countyTeryt
)); ? warningMatchesCounty(warning, subscription.countyTeryt)
: subscription.province !== null && warning.provinces.includes(subscription.province),
);
for (const warning of matchingWarnings) { for (const warning of matchingWarnings) {
if (hasSentWarning(subscription.endpoint, warning.id)) { if (hasSentWarning(subscription.endpoint, warning.id)) {
skipped += 1; skipped += 1;
@@ -65,7 +77,8 @@ export async function GET(request: Request) {
sent += 1; sent += 1;
} catch (error) { } catch (error) {
failed += 1; failed += 1;
const statusCode = typeof error === "object" && error !== null && "statusCode" in error ? Number(error.statusCode) : null; const statusCode =
typeof error === "object" && error !== null && "statusCode" in error ? Number(error.statusCode) : null;
if (statusCode === 404 || statusCode === 410) removePushSubscription(subscription.endpoint); if (statusCode === 404 || statusCode === 410) removePushSubscription(subscription.endpoint);
} }
} }
@@ -80,9 +93,12 @@ export async function GET(request: Request) {
failed, failed,
}); });
} catch (error) { } catch (error) {
return NextResponse.json({ return NextResponse.json(
{
error: "Unable to check IMGW meteorological warnings.", error: "Unable to check IMGW meteorological warnings.",
details: getErrorMessage(error), details: getErrorMessage(error),
}, { status: 502 }); },
{ status: 502 },
);
} }
} }

View File

@@ -1,7 +1,12 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { fetchServerForecast } from "@/lib/server-forecast"; import { fetchServerForecast } from "@/lib/server-forecast";
import { fetchMeteoWarnings } from "@/lib/server-warnings"; 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_TEMPERATURE_UNIT, DEFAULT_WIND_SPEED_UNIT } from "@/lib/weather-utils"; import { DEFAULT_TEMPERATURE_UNIT, DEFAULT_WIND_SPEED_UNIT } from "@/lib/weather-utils";
@@ -49,11 +54,12 @@ export async function GET(request: Request) {
const now = new Date(); const now = new Date();
const briefTime = normalizeTime(process.env.NOTIFICATIONS_MORNING_BRIEF_TIME, DEFAULT_MORNING_BRIEF_TIME); const briefTime = normalizeTime(process.env.NOTIFICATIONS_MORNING_BRIEF_TIME, DEFAULT_MORNING_BRIEF_TIME);
const subscriptions = getPushSubscriptions().filter((subscription) => ( const subscriptions = getPushSubscriptions().filter(
subscription.morningBriefEnabled (subscription) =>
&& Number.isFinite(subscription.latitude) subscription.morningBriefEnabled &&
&& Number.isFinite(subscription.longitude) Number.isFinite(subscription.latitude) &&
)); Number.isFinite(subscription.longitude),
);
let warningsUnavailable = false; let warningsUnavailable = false;
const hasPolishSubscriptions = subscriptions.some((subscription) => subscription.region === "PL"); const hasPolishSubscriptions = subscriptions.some((subscription) => subscription.region === "PL");
const warnings = hasPolishSubscriptions const warnings = hasPolishSubscriptions
@@ -79,7 +85,11 @@ export async function GET(request: Request) {
} }
try { try {
const forecast = await fetchServerForecast(subscription.latitude as number, subscription.longitude as number, subscription.region); const forecast = await fetchServerForecast(
subscription.latitude as number,
subscription.longitude as number,
subscription.region,
);
const brief = buildWeatherBrief({ const brief = buildWeatherBrief({
forecast, forecast,
warnings: subscription.region === "PL" ? warnings : [], warnings: subscription.region === "PL" ? warnings : [],
@@ -100,7 +110,8 @@ export async function GET(request: Request) {
sent += 1; sent += 1;
} catch (error) { } catch (error) {
failed += 1; failed += 1;
const statusCode = typeof error === "object" && error !== null && "statusCode" in error ? Number(error.statusCode) : null; const statusCode =
typeof error === "object" && error !== null && "statusCode" in error ? Number(error.statusCode) : null;
if (statusCode === 404 || statusCode === 410) removePushSubscription(subscription.endpoint); if (statusCode === 404 || statusCode === 410) removePushSubscription(subscription.endpoint);
} }
} }

View File

@@ -2,7 +2,12 @@ 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_TEMPERATURE_UNIT, DEFAULT_WIND_SPEED_UNIT, isTemperatureUnit, 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";
import type { WeatherRegion } from "@/types/weather-region"; import type { WeatherRegion } from "@/types/weather-region";
@@ -12,10 +17,12 @@ export const runtime = "nodejs";
function isBrowserPushSubscription(value: unknown): value is BrowserPushSubscription { function isBrowserPushSubscription(value: unknown): value is BrowserPushSubscription {
if (!value || typeof value !== "object") return false; if (!value || typeof value !== "object") return false;
const subscription = value as Partial<BrowserPushSubscription>; const subscription = value as Partial<BrowserPushSubscription>;
return typeof subscription.endpoint === "string" return (
&& subscription.endpoint.startsWith("https://") typeof subscription.endpoint === "string" &&
&& typeof subscription.keys?.p256dh === "string" subscription.endpoint.startsWith("https://") &&
&& typeof subscription.keys.auth === "string"; typeof subscription.keys?.p256dh === "string" &&
typeof subscription.keys.auth === "string"
);
} }
export async function POST(request: Request) { export async function POST(request: Request) {
@@ -23,7 +30,7 @@ export async function POST(request: Request) {
return NextResponse.json({ error: "Web Push is not configured." }, { status: 503 }); return NextResponse.json({ error: "Web Push is not configured." }, { status: 503 });
} }
const body = await request.json().catch(() => null) as { const body = (await request.json().catch(() => null)) as {
subscription?: unknown; subscription?: unknown;
province?: unknown; province?: unknown;
region?: unknown; region?: unknown;
@@ -63,7 +70,8 @@ export async function POST(request: Request) {
tomorrowBriefEnabled: body?.tomorrowBriefEnabled === true, tomorrowBriefEnabled: body?.tomorrowBriefEnabled === true,
latitude: typeof body?.latitude === "number" && Number.isFinite(body.latitude) ? body.latitude : null, latitude: typeof body?.latitude === "number" && Number.isFinite(body.latitude) ? body.latitude : null,
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,
timezone: typeof body?.timezone === "string" && body.timezone.trim() ? body.timezone.trim().slice(0, 80) : null, timezone: typeof body?.timezone === "string" && body.timezone.trim() ? body.timezone.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, temperatureUnit: isTemperatureUnit(rawTemperatureUnit) ? rawTemperatureUnit : DEFAULT_TEMPERATURE_UNIT,
@@ -76,7 +84,7 @@ export async function POST(request: Request) {
} }
export async function DELETE(request: Request) { export async function DELETE(request: Request) {
const body = await request.json().catch(() => null) as { endpoint?: unknown } | null; const body = (await request.json().catch(() => null)) as { endpoint?: unknown } | null;
if (typeof body?.endpoint !== "string" || !body.endpoint) { if (typeof body?.endpoint !== "string" || !body.endpoint) {
return NextResponse.json({ error: "Invalid push subscription endpoint." }, { status: 400 }); return NextResponse.json({ error: "Invalid push subscription endpoint." }, { status: 400 });
} }

View File

@@ -9,7 +9,7 @@ export async function POST(request: Request) {
return NextResponse.json({ error: "Web Push is not configured." }, { status: 503 }); return NextResponse.json({ error: "Web Push is not configured." }, { status: 503 });
} }
const body = await request.json().catch(() => null) as { endpoint?: unknown } | null; const body = (await request.json().catch(() => null)) as { endpoint?: unknown } | null;
if (typeof body?.endpoint !== "string" || !body.endpoint) { if (typeof body?.endpoint !== "string" || !body.endpoint) {
return NextResponse.json({ error: "Invalid push subscription endpoint." }, { status: 400 }); return NextResponse.json({ error: "Invalid push subscription endpoint." }, { status: 400 });
} }
@@ -23,7 +23,8 @@ export async function POST(request: Request) {
await sendTestNotification(subscription); await sendTestNotification(subscription);
return NextResponse.json({ ok: true }); return NextResponse.json({ ok: true });
} catch (error) { } catch (error) {
const statusCode = typeof error === "object" && error !== null && "statusCode" in error ? Number(error.statusCode) : null; const statusCode =
typeof error === "object" && error !== null && "statusCode" in error ? Number(error.statusCode) : null;
if (statusCode === 404 || statusCode === 410) removePushSubscription(subscription.endpoint); if (statusCode === 404 || statusCode === 410) removePushSubscription(subscription.endpoint);
return NextResponse.json({ error: "Unable to send test notification." }, { status: 502 }); return NextResponse.json({ error: "Unable to send test notification." }, { status: 502 });
} }

View File

@@ -1,7 +1,12 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { fetchServerForecast } from "@/lib/server-forecast"; import { fetchServerForecast } from "@/lib/server-forecast";
import { fetchMeteoWarnings } from "@/lib/server-warnings"; 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_TEMPERATURE_UNIT, DEFAULT_WIND_SPEED_UNIT } from "@/lib/weather-utils"; import { DEFAULT_TEMPERATURE_UNIT, DEFAULT_WIND_SPEED_UNIT } from "@/lib/weather-utils";
@@ -37,14 +42,17 @@ function getLocalDateParts(timeZone: string | null, date = new Date(), dayOffset
const time = `${part("hour")}:${part("minute")}`; const time = `${part("hour")}:${part("minute")}`;
if (dayOffset === 0) return { dateKey, time }; if (dayOffset === 0) return { dateKey, time };
const shiftedDate = new Date(Date.UTC(Number(part("year")), Number(part("month")) - 1, Number(part("day")) + dayOffset, 12)); const shiftedDate = new Date(
Date.UTC(Number(part("year")), Number(part("month")) - 1, Number(part("day")) + dayOffset, 12),
);
const shiftedParts = new Intl.DateTimeFormat("en-CA", { const shiftedParts = new Intl.DateTimeFormat("en-CA", {
timeZone: timeZone || "Europe/Warsaw", timeZone: timeZone || "Europe/Warsaw",
year: "numeric", year: "numeric",
month: "2-digit", month: "2-digit",
day: "2-digit", day: "2-digit",
}).formatToParts(shiftedDate); }).formatToParts(shiftedDate);
const shiftedPart = (type: Intl.DateTimeFormatPartTypes) => shiftedParts.find((entry) => entry.type === type)?.value ?? ""; const shiftedPart = (type: Intl.DateTimeFormatPartTypes) =>
shiftedParts.find((entry) => entry.type === type)?.value ?? "";
return { dateKey: `${shiftedPart("year")}-${shiftedPart("month")}-${shiftedPart("day")}`, time }; return { dateKey: `${shiftedPart("year")}-${shiftedPart("month")}-${shiftedPart("day")}`, time };
} }
@@ -58,11 +66,12 @@ export async function GET(request: Request) {
const now = new Date(); const now = new Date();
const briefTime = normalizeTime(process.env.NOTIFICATIONS_TOMORROW_BRIEF_TIME, DEFAULT_TOMORROW_BRIEF_TIME); const briefTime = normalizeTime(process.env.NOTIFICATIONS_TOMORROW_BRIEF_TIME, DEFAULT_TOMORROW_BRIEF_TIME);
const subscriptions = getPushSubscriptions().filter((subscription) => ( const subscriptions = getPushSubscriptions().filter(
subscription.tomorrowBriefEnabled (subscription) =>
&& Number.isFinite(subscription.latitude) subscription.tomorrowBriefEnabled &&
&& Number.isFinite(subscription.longitude) Number.isFinite(subscription.latitude) &&
)); Number.isFinite(subscription.longitude),
);
let warningsUnavailable = false; let warningsUnavailable = false;
const hasPolishSubscriptions = subscriptions.some((subscription) => subscription.region === "PL"); const hasPolishSubscriptions = subscriptions.some((subscription) => subscription.region === "PL");
const warnings = hasPolishSubscriptions const warnings = hasPolishSubscriptions
@@ -88,7 +97,11 @@ export async function GET(request: Request) {
} }
try { try {
const forecast = await fetchServerForecast(subscription.latitude as number, subscription.longitude as number, subscription.region); const forecast = await fetchServerForecast(
subscription.latitude as number,
subscription.longitude as number,
subscription.region,
);
const brief = buildTomorrowWeatherBrief({ const brief = buildTomorrowWeatherBrief({
forecast, forecast,
warnings: subscription.region === "PL" ? warnings : [], warnings: subscription.region === "PL" ? warnings : [],
@@ -109,7 +122,8 @@ export async function GET(request: Request) {
sent += 1; sent += 1;
} catch (error) { } catch (error) {
failed += 1; failed += 1;
const statusCode = typeof error === "object" && error !== null && "statusCode" in error ? Number(error.statusCode) : null; const statusCode =
typeof error === "object" && error !== null && "statusCode" in error ? Number(error.statusCode) : null;
if (statusCode === 404 || statusCode === 410) removePushSubscription(subscription.endpoint); if (statusCode === 404 || statusCode === 410) removePushSubscription(subscription.endpoint);
} }
} }

View File

@@ -128,11 +128,8 @@ select {
@media (min-width: 640px) { @media (min-width: 640px) {
@layer utilities { @layer utilities {
.modal-safe-area { .modal-safe-area {
padding: padding: calc(1rem + env(safe-area-inset-top)) calc(1rem + env(safe-area-inset-right))
calc(1rem + env(safe-area-inset-top)) calc(1rem + env(safe-area-inset-bottom)) calc(1rem + env(safe-area-inset-left));
calc(1rem + env(safe-area-inset-right))
calc(1rem + env(safe-area-inset-bottom))
calc(1rem + env(safe-area-inset-left));
} }
} }
} }
@@ -140,11 +137,8 @@ select {
@media (min-width: 1024px) { @media (min-width: 1024px) {
@layer utilities { @layer utilities {
.modal-safe-area { .modal-safe-area {
padding: padding: calc(2rem + env(safe-area-inset-top)) calc(2rem + env(safe-area-inset-right))
calc(2rem + env(safe-area-inset-top)) calc(2rem + env(safe-area-inset-bottom)) calc(2rem + env(safe-area-inset-left));
calc(2rem + env(safe-area-inset-right))
calc(2rem + env(safe-area-inset-bottom))
calc(2rem + env(safe-area-inset-left));
} }
} }
} }

View File

@@ -27,7 +27,10 @@ export const metadata: Metadata = {
manifest: "/manifest.json", manifest: "/manifest.json",
appleWebApp: { capable: true, statusBarStyle: "black-translucent", title: "wtr." }, appleWebApp: { capable: true, statusBarStyle: "black-translucent", title: "wtr." },
icons: { icons: {
icon: [{ url: "/icons/icon.svg", type: "image/svg+xml" }, { url: "/icons/icon-192.png", sizes: "192x192", type: "image/png" }], icon: [
{ url: "/icons/icon.svg", type: "image/svg+xml" },
{ url: "/icons/icon-192.png", sizes: "192x192", type: "image/png" },
],
apple: "/icons/icon-192.png", apple: "/icons/icon-192.png",
}, },
}; };
@@ -46,8 +49,12 @@ export default function RootLayout({ children }: Readonly<{ children: React.Reac
return ( return (
<html lang="pl" suppressHydrationWarning data-scroll-behavior="smooth"> <html lang="pl" suppressHydrationWarning data-scroll-behavior="smooth">
<body className={`${inter.variable} font-sans`}> <body className={`${inter.variable} font-sans`}>
<Script id="wtr-theme" strategy="beforeInteractive">{themeScript}</Script> <Script id="wtr-theme" strategy="beforeInteractive">
<Script id="wtr-language" strategy="beforeInteractive">{languageScript}</Script> {themeScript}
</Script>
<Script id="wtr-language" strategy="beforeInteractive">
{languageScript}
</Script>
<Providers> <Providers>
<AppShell>{children}</AppShell> <AppShell>{children}</AppShell>
</Providers> </Providers>

View File

@@ -8,10 +8,17 @@ export default function OfflinePage() {
const { t } = useI18n(); const { t } = useI18n();
return ( return (
<section className="glass mx-auto mt-12 max-w-lg rounded-panel p-8 text-center"> <section className="glass mx-auto mt-12 max-w-lg rounded-panel p-8 text-center">
<div className="mx-auto flex size-14 items-center justify-center rounded-control bg-accent/10 text-accent"><WifiOff className="size-6" /></div> <div className="mx-auto flex size-14 items-center justify-center rounded-control bg-accent/10 text-accent">
<WifiOff className="size-6" />
</div>
<h1 className="mt-5 text-2xl font-semibold tracking-tight">{t("offline.title")}</h1> <h1 className="mt-5 text-2xl font-semibold tracking-tight">{t("offline.title")}</h1>
<p className="mt-2 text-sm leading-6 text-muted">{t("offline.description")}</p> <p className="mt-2 text-sm leading-6 text-muted">{t("offline.description")}</p>
<Link href="/" className="mt-6 inline-flex rounded-control bg-foreground px-4 py-2.5 text-sm font-medium text-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent">{t("offline.back")}</Link> <Link
href="/"
className="mt-6 inline-flex rounded-control bg-foreground px-4 py-2.5 text-sm font-medium text-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"
>
{t("offline.back")}
</Link>
</section> </section>
); );
} }

View File

@@ -33,13 +33,35 @@ export function DayForecastCharts({ hours }: { hours: HourlyForecast[] }) {
<h3 className="text-sm font-semibold">{t("forecast.temperatureChart")}</h3> <h3 className="text-sm font-semibold">{t("forecast.temperatureChart")}</h3>
<p className="mt-1 text-xs leading-5 text-muted">{t("forecast.temperatureChartDescription")}</p> <p className="mt-1 text-xs leading-5 text-muted">{t("forecast.temperatureChartDescription")}</p>
<div className="mt-4 h-56 min-w-0"> <div className="mt-4 h-56 min-w-0">
<ResponsiveContainer width="100%" height="100%" minWidth={0} minHeight={0} initialDimension={INITIAL_CHART_DIMENSION}> <ResponsiveContainer
width="100%"
height="100%"
minWidth={0}
minHeight={0}
initialDimension={INITIAL_CHART_DIMENSION}
>
<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
<YAxis axisLine={false} tickLine={false} tick={{ fill: "currentColor", fontSize: 11 }} unit={temperatureUnitLabel} /> dataKey="time"
axisLine={false}
tickLine={false}
interval={3}
tick={{ fill: "currentColor", fontSize: 11 }}
/>
<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) => [ formatter={(value) => [
typeof value === "number" typeof value === "number"
? formatTemperatureValue(value, language, temperatureUnit) ? formatTemperatureValue(value, language, temperatureUnit)
@@ -47,8 +69,25 @@ export function DayForecastCharts({ hours }: { hours: HourlyForecast[] }) {
]} ]}
/> />
<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
<Line type="monotone" dataKey="feelsLike" name={t("forecast.apparentTemperature")} stroke={CHART_COLORS.feelsLike} strokeWidth={2} strokeDasharray="5 4" dot={false} connectNulls /> type="monotone"
dataKey="temperature"
name={t("forecast.temperature")}
stroke={CHART_COLORS.temperature}
strokeWidth={3}
dot={false}
connectNulls
/>
<Line
type="monotone"
dataKey="feelsLike"
name={t("forecast.apparentTemperature")}
stroke={CHART_COLORS.feelsLike}
strokeWidth={2}
strokeDasharray="5 4"
dot={false}
connectNulls
/>
</ComposedChart> </ComposedChart>
</ResponsiveContainer> </ResponsiveContainer>
</div> </div>
@@ -58,14 +97,45 @@ export function DayForecastCharts({ hours }: { hours: HourlyForecast[] }) {
<h3 className="text-sm font-semibold">{t("forecast.rainfallChart")}</h3> <h3 className="text-sm font-semibold">{t("forecast.rainfallChart")}</h3>
<p className="mt-1 text-xs leading-5 text-muted">{t("forecast.rainfallChartDescription")}</p> <p className="mt-1 text-xs leading-5 text-muted">{t("forecast.rainfallChartDescription")}</p>
<div className="mt-4 h-56 min-w-0"> <div className="mt-4 h-56 min-w-0">
<ResponsiveContainer width="100%" height="100%" minWidth={0} minHeight={0} initialDimension={INITIAL_CHART_DIMENSION}> <ResponsiveContainer
width="100%"
height="100%"
minWidth={0}
minHeight={0}
initialDimension={INITIAL_CHART_DIMENSION}
>
<ComposedChart data={rows} margin={{ left: -20, right: -10, top: 8 }}> <ComposedChart data={rows} margin={{ left: -20, right: -10, 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
<YAxis yAxisId="rainfall" axisLine={false} tickLine={false} tick={{ fill: "currentColor", fontSize: 11 }} unit=" mm" /> dataKey="time"
<YAxis yAxisId="probability" orientation="right" domain={[0, 100]} axisLine={false} tickLine={false} tick={{ fill: "currentColor", fontSize: 11 }} unit="%" /> axisLine={false}
tickLine={false}
interval={3}
tick={{ fill: "currentColor", fontSize: 11 }}
/>
<YAxis
yAxisId="rainfall"
axisLine={false}
tickLine={false}
tick={{ fill: "currentColor", fontSize: 11 }}
unit=" mm"
/>
<YAxis
yAxisId="probability"
orientation="right"
domain={[0, 100]}
axisLine={false}
tickLine={false}
tick={{ fill: "currentColor", fontSize: 11 }}
unit="%"
/>
<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, name) => [ formatter={(value, name) => [
name === t("forecast.precipitation") name === t("forecast.precipitation")
? formatForecastRainfall(typeof value === "number" ? value : null, language) ? formatForecastRainfall(typeof value === "number" ? value : null, language)
@@ -74,8 +144,23 @@ export function DayForecastCharts({ hours }: { hours: HourlyForecast[] }) {
]} ]}
/> />
<Legend wrapperStyle={{ fontSize: "0.72rem" }} /> <Legend wrapperStyle={{ fontSize: "0.72rem" }} />
<Bar yAxisId="rainfall" dataKey="precipitation" name={t("forecast.precipitation")} fill={CHART_COLORS.rainfall} radius={[5, 5, 0, 0]} /> <Bar
<Line yAxisId="probability" type="monotone" dataKey="precipitationProbability" name={t("forecast.precipitationProbability")} stroke={CHART_COLORS.probability} strokeWidth={2} dot={false} connectNulls /> yAxisId="rainfall"
dataKey="precipitation"
name={t("forecast.precipitation")}
fill={CHART_COLORS.rainfall}
radius={[5, 5, 0, 0]}
/>
<Line
yAxisId="probability"
type="monotone"
dataKey="precipitationProbability"
name={t("forecast.precipitationProbability")}
stroke={CHART_COLORS.probability}
strokeWidth={2}
dot={false}
connectNulls
/>
</ComposedChart> </ComposedChart>
</ResponsiveContainer> </ResponsiveContainer>
</div> </div>

View File

@@ -8,23 +8,28 @@ import { useWeatherStore } from "@/lib/store";
import { convertWindSpeed, getWindSpeedUnitLabel } from "@/lib/weather-utils"; import { convertWindSpeed, getWindSpeedUnitLabel } from "@/lib/weather-utils";
const INITIAL_CHART_DIMENSION = { width: 1, height: 1 }; const INITIAL_CHART_DIMENSION = { width: 1, height: 1 };
const SNAPSHOT_COLORS = [ const SNAPSHOT_COLORS = ["hsl(var(--chart-temperature))", "hsl(var(--chart-feels-like))", "hsl(var(--chart-rainfall))"];
"hsl(var(--chart-temperature))",
"hsl(var(--chart-feels-like))",
"hsl(var(--chart-rainfall))",
];
export function SnapshotChart({ station }: { station: SynopStation }) { export function SnapshotChart({ station }: { station: SynopStation }) {
const { t } = useI18n(); const { t } = useI18n();
const windSpeedUnit = useWeatherStore((state) => state.windSpeedUnit); const windSpeedUnit = useWeatherStore((state) => state.windSpeedUnit);
const windDigits = windSpeedUnit === "ms" ? 1 : 0; const windDigits = windSpeedUnit === "ms" ? 1 : 0;
const windSpeed = station.windSpeed === null ? null : Number(convertWindSpeed(station.windSpeed, windSpeedUnit).toFixed(windDigits)); const windSpeed =
station.windSpeed === null ? null : Number(convertWindSpeed(station.windSpeed, windSpeedUnit).toFixed(windDigits));
const windMax = windSpeedUnit === "ms" ? 20 : windSpeedUnit === "kmh" ? 72 : 45; const windMax = windSpeedUnit === "ms" ? 20 : windSpeedUnit === "kmh" ? 72 : 45;
const rows = [ const rows = [
{ name: t("weather.humidity"), value: station.humidity, unit: "%", max: 100, color: SNAPSHOT_COLORS[0] }, { name: t("weather.humidity"), value: station.humidity, unit: "%", max: 100, color: SNAPSHOT_COLORS[0] },
{ name: t("weather.wind"), value: windSpeed, unit: getWindSpeedUnitLabel(windSpeedUnit), max: windMax, color: SNAPSHOT_COLORS[1] }, {
name: t("weather.wind"),
value: windSpeed,
unit: getWindSpeedUnitLabel(windSpeedUnit),
max: windMax,
color: SNAPSHOT_COLORS[1],
},
{ name: t("weather.rainfall"), value: station.rainfall, unit: "mm", max: 30, color: SNAPSHOT_COLORS[2] }, { name: t("weather.rainfall"), value: station.rainfall, unit: "mm", max: 30, color: SNAPSHOT_COLORS[2] },
].filter((row) => row.value !== null).map((row) => ({ ...row, normalized: Math.min(100, ((row.value ?? 0) / row.max) * 100) })); ]
.filter((row) => row.value !== null)
.map((row) => ({ ...row, normalized: Math.min(100, ((row.value ?? 0) / row.max) * 100) }));
return ( return (
<Card className="p-5"> <Card className="p-5">
@@ -32,13 +37,31 @@ export function SnapshotChart({ station }: { station: SynopStation }) {
<h2 className="mt-2 text-xl font-semibold tracking-tight">{t("snapshot.title")}</h2> <h2 className="mt-2 text-xl font-semibold tracking-tight">{t("snapshot.title")}</h2>
<p className="mt-1 text-sm leading-6 text-muted">{t("snapshot.description")}</p> <p className="mt-1 text-sm leading-6 text-muted">{t("snapshot.description")}</p>
<div className="mt-5 h-52 min-w-0"> <div className="mt-5 h-52 min-w-0">
<ResponsiveContainer width="100%" height="100%" minWidth={0} minHeight={0} initialDimension={INITIAL_CHART_DIMENSION}> <ResponsiveContainer
width="100%"
height="100%"
minWidth={0}
minHeight={0}
initialDimension={INITIAL_CHART_DIMENSION}
>
<BarChart data={rows} layout="vertical" margin={{ left: 0, right: 16 }}> <BarChart data={rows} layout="vertical" margin={{ left: 0, right: 16 }}>
<XAxis type="number" hide domain={[0, 100]} /> <XAxis type="number" hide domain={[0, 100]} />
<YAxis type="category" dataKey="name" width={86} axisLine={false} tickLine={false} tick={{ fill: "currentColor", fontSize: 12 }} /> <YAxis
<Tooltip cursor={{ fill: "hsl(var(--border) / 0.22)" }} formatter={(_, __, item) => [`${item.payload.value} ${item.payload.unit}`, item.payload.name]} /> type="category"
dataKey="name"
width={86}
axisLine={false}
tickLine={false}
tick={{ fill: "currentColor", fontSize: 12 }}
/>
<Tooltip
cursor={{ fill: "hsl(var(--border) / 0.22)" }}
formatter={(_, __, item) => [`${item.payload.value} ${item.payload.unit}`, item.payload.name]}
/>
<Bar dataKey="normalized" radius={[0, 8, 8, 0]} barSize={14}> <Bar dataKey="normalized" radius={[0, 8, 8, 0]} barSize={14}>
{rows.map((row) => <Cell fill={row.color} key={row.name} />)} {rows.map((row) => (
<Cell fill={row.color} key={row.name} />
))}
</Bar> </Bar>
</BarChart> </BarChart>
</ResponsiveContainer> </ResponsiveContainer>

View File

@@ -27,26 +27,40 @@ export function DashboardPage() {
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 dashboardSections = useWeatherStore((state) => state.dashboardSections); const dashboardSections = useWeatherStore((state) => state.dashboardSections);
const selectedStation = stations?.find((station) => station.id === selectedStationId) const selectedStation =
?? stations?.find((station) => station.name === DEFAULT_STATION_NAME) stations?.find((station) => station.id === selectedStationId) ??
?? stations?.[0]; stations?.find((station) => station.name === DEFAULT_STATION_NAME) ??
stations?.[0];
const activeLocation = selectedLocation; const activeLocation = selectedLocation;
const activeRegion = activeLocation?.region ?? "PL"; const activeRegion = activeLocation?.region ?? "PL";
const stationPosition = selectedStation const stationPosition = selectedStation
? locateSynopStations(stations ?? [], positions).find((station) => station.id === selectedStation.id) ? locateSynopStations(stations ?? [], positions).find((station) => station.id === selectedStation.id)
: null; : null;
const hasActiveLocationCoordinates = Number.isFinite(activeLocation?.latitude) && Number.isFinite(activeLocation?.longitude); const hasActiveLocationCoordinates =
Number.isFinite(activeLocation?.latitude) && Number.isFinite(activeLocation?.longitude);
const forecastLatitude = hasActiveLocationCoordinates ? activeLocation?.latitude : stationPosition?.latitude; const forecastLatitude = hasActiveLocationCoordinates ? activeLocation?.latitude : stationPosition?.latitude;
const forecastLongitude = hasActiveLocationCoordinates ? activeLocation?.longitude : stationPosition?.longitude; const forecastLongitude = hasActiveLocationCoordinates ? activeLocation?.longitude : stationPosition?.longitude;
const forecastLocationName = hasActiveLocationCoordinates ? activeLocation?.name ?? selectedStation?.name : selectedStation?.name; const forecastLocationName = hasActiveLocationCoordinates
const { data: currentWeather, isPending: isCurrentWeatherPending } = useCurrentWeather(forecastLatitude, forecastLongitude, activeRegion); ? (activeLocation?.name ?? selectedStation?.name)
: selectedStation?.name;
const { data: currentWeather, isPending: isCurrentWeatherPending } = useCurrentWeather(
forecastLatitude,
forecastLongitude,
activeRegion,
);
const { data: forecast } = useForecast(forecastLatitude, forecastLongitude, activeRegion); const { data: forecast } = useForecast(forecastLatitude, forecastLongitude, activeRegion);
const currentForecastWeatherCode = forecast ? getUpcomingHourlyForecast(forecast.hourly, 1)[0]?.weatherCode ?? null : null; const currentForecastWeatherCode = forecast
const isCurrentWeatherLoading = Number.isFinite(forecastLatitude) && Number.isFinite(forecastLongitude) && isCurrentWeatherPending; ? (getUpcomingHourlyForecast(forecast.hourly, 1)[0]?.weatherCode ?? null)
: null;
const isCurrentWeatherLoading =
Number.isFinite(forecastLatitude) && Number.isFinite(forecastLongitude) && isCurrentWeatherPending;
if (isPending) return <PageLoadingSkeleton />; if (isPending) return <PageLoadingSkeleton />;
if (isError || !stations?.length || !selectedStation) return <ErrorState onRetry={() => refetch()} description={t("dashboard.error")} />; if (isError || !stations?.length || !selectedStation)
return <ErrorState onRetry={() => refetch()} description={t("dashboard.error")} />;
const heroStation: SynopStation = activeLocation?.region === "GLOBAL" ? { const heroStation: SynopStation =
activeLocation?.region === "GLOBAL"
? {
id: `global:${activeLocation.latitude},${activeLocation.longitude}`, id: `global:${activeLocation.latitude},${activeLocation.longitude}`,
name: activeLocation.name, name: activeLocation.name,
measuredAt: null, measuredAt: null,
@@ -56,15 +70,35 @@ export function DashboardPage() {
humidity: null, humidity: null,
rainfall: null, rainfall: null,
pressure: null, pressure: null,
} : selectedStation; }
: selectedStation;
return ( return (
<div className="space-y-10"> <div className="space-y-10">
<LocationSearch stations={stations} positions={positions} /> <LocationSearch stations={stations} positions={positions} />
<WeatherHero station={heroStation} currentWeather={currentWeather} currentWeatherLoading={isCurrentWeatherLoading} currentForecastWeatherCode={currentForecastWeatherCode} locationName={activeLocation?.name} distanceKm={activeLocation?.distanceKm} /> <WeatherHero
station={heroStation}
currentWeather={currentWeather}
currentWeatherLoading={isCurrentWeatherLoading}
currentForecastWeatherCode={currentForecastWeatherCode}
locationName={activeLocation?.name}
distanceKm={activeLocation?.distanceKm}
/>
<DashboardWarnings /> <DashboardWarnings />
{dashboardSections.weatherBrief && <WeatherBriefCard latitude={forecastLatitude} longitude={forecastLongitude} region={activeRegion} locationName={forecastLocationName ?? selectedStation.name} />} {dashboardSections.weatherBrief && (
<ForecastPanel latitude={forecastLatitude} longitude={forecastLongitude} region={activeRegion} locationName={forecastLocationName ?? selectedStation.name} /> <WeatherBriefCard
latitude={forecastLatitude}
longitude={forecastLongitude}
region={activeRegion}
locationName={forecastLocationName ?? selectedStation.name}
/>
)}
<ForecastPanel
latitude={forecastLatitude}
longitude={forecastLongitude}
region={activeRegion}
locationName={forecastLocationName ?? selectedStation.name}
/>
<FavoritesSection stations={stations} /> <FavoritesSection stations={stations} />
{dashboardSections.featuredStations && <FeaturedStationsSection stations={stations} />} {dashboardSections.featuredStations && <FeaturedStationsSection stations={stations} />}
</div> </div>

View File

@@ -15,7 +15,17 @@ import { useWeatherStore } from "@/lib/store";
import { buildTomorrowWeatherBrief, buildWeatherBrief } from "@/lib/weather-brief"; import { buildTomorrowWeatherBrief, buildWeatherBrief } from "@/lib/weather-brief";
import type { WeatherRegion } from "@/types/weather-region"; import type { WeatherRegion } from "@/types/weather-region";
export function WeatherBriefCard({ latitude, longitude, region = "PL", locationName }: { latitude?: number; longitude?: number; region?: WeatherRegion; locationName: string }) { export function WeatherBriefCard({
latitude,
longitude,
region = "PL",
locationName,
}: {
latitude?: number;
longitude?: number;
region?: WeatherRegion;
locationName: string;
}) {
const { language, t } = useI18n(); const { language, t } = useI18n();
const { data: forecast, isPending, isError, refetch } = useForecast(latitude, longitude, region); const { data: forecast, isPending, isError, refetch } = useForecast(latitude, longitude, region);
const { data: warnings = [] } = useWarnings(); const { data: warnings = [] } = useWarnings();
@@ -24,17 +34,57 @@ export function WeatherBriefCard({ latitude, longitude, region = "PL", locationN
const temperatureUnit = useWeatherStore((state) => state.temperatureUnit); const temperatureUnit = useWeatherStore((state) => state.temperatureUnit);
const windSpeedUnit = useWeatherStore((state) => state.windSpeedUnit); const windSpeedUnit = useWeatherStore((state) => state.windSpeedUnit);
const isGlobalLocation = region === "GLOBAL"; const isGlobalLocation = region === "GLOBAL";
const province = isGlobalLocation ? null : getProvinceForSelection(selectedLocation?.province, selectedStationId ?? DEFAULT_STATION_ID); const province = isGlobalLocation
const relevantWarnings = useMemo(() => isGlobalLocation ? [] : warnings, [isGlobalLocation, warnings]); ? null
: getProvinceForSelection(selectedLocation?.province, selectedStationId ?? DEFAULT_STATION_ID);
const relevantWarnings = useMemo(() => (isGlobalLocation ? [] : warnings), [isGlobalLocation, warnings]);
const brief = useMemo(() => { const brief = useMemo(() => {
if (!forecast) return null; if (!forecast) return null;
return buildWeatherBrief({ forecast, warnings: relevantWarnings, province, countyTeryt: isGlobalLocation ? null : selectedLocation?.countyTeryt, locationName, language, temperatureUnit, windSpeedUnit }); return buildWeatherBrief({
}, [forecast, isGlobalLocation, language, locationName, province, relevantWarnings, selectedLocation?.countyTeryt, temperatureUnit, windSpeedUnit]); forecast,
warnings: relevantWarnings,
province,
countyTeryt: isGlobalLocation ? null : selectedLocation?.countyTeryt,
locationName,
language,
temperatureUnit,
windSpeedUnit,
});
}, [
forecast,
isGlobalLocation,
language,
locationName,
province,
relevantWarnings,
selectedLocation?.countyTeryt,
temperatureUnit,
windSpeedUnit,
]);
const tomorrowBrief = useMemo(() => { const tomorrowBrief = useMemo(() => {
if (!forecast) return null; if (!forecast) return null;
return buildTomorrowWeatherBrief({ forecast, warnings: relevantWarnings, province, countyTeryt: isGlobalLocation ? null : selectedLocation?.countyTeryt, locationName, language, temperatureUnit, windSpeedUnit }); return buildTomorrowWeatherBrief({
}, [forecast, isGlobalLocation, language, locationName, province, relevantWarnings, selectedLocation?.countyTeryt, temperatureUnit, windSpeedUnit]); forecast,
warnings: relevantWarnings,
province,
countyTeryt: isGlobalLocation ? null : selectedLocation?.countyTeryt,
locationName,
language,
temperatureUnit,
windSpeedUnit,
});
}, [
forecast,
isGlobalLocation,
language,
locationName,
province,
relevantWarnings,
selectedLocation?.countyTeryt,
temperatureUnit,
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" />;
@@ -61,11 +111,25 @@ export function WeatherBriefCard({ latitude, longitude, region = "PL", locationN
return ( return (
<Card className="p-4 sm:p-5"> <Card className="p-4 sm:p-5">
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<div className={isWarning ? "rounded-control border border-warning/30 bg-warning/10 p-2 text-warning" : "rounded-control border border-border/70 bg-surface-muted/70 p-2 text-accent"}> <div
{isWarning ? <AlertTriangle className="size-4" aria-hidden="true" /> : <CloudSun className="size-4" aria-hidden="true" />} className={
isWarning
? "rounded-control border border-warning/30 bg-warning/10 p-2 text-warning"
: "rounded-control border border-border/70 bg-surface-muted/70 p-2 text-accent"
}
>
{isWarning ? (
<AlertTriangle className="size-4" aria-hidden="true" />
) : (
<CloudSun className="size-4" aria-hidden="true" />
)}
</div> </div>
<div className="min-w-0"> <div className="min-w-0">
<p className={isWarning ? "text-[0.68rem] font-semibold uppercase tracking-[0.16em] text-warning" : "section-kicker"}> <p
className={
isWarning ? "text-[0.68rem] font-semibold uppercase tracking-[0.16em] text-warning" : "section-kicker"
}
>
{t("brief.label")} {t("brief.label")}
</p> </p>
<h2 className="mt-1 text-lg font-semibold tracking-tight">{brief.headline}</h2> <h2 className="mt-1 text-lg font-semibold tracking-tight">{brief.headline}</h2>
@@ -75,7 +139,10 @@ export function WeatherBriefCard({ latitude, longitude, region = "PL", locationN
<ul className="mt-4 space-y-2"> <ul className="mt-4 space-y-2">
{brief.body.slice(0, 4).map((line) => ( {brief.body.slice(0, 4).map((line) => (
<li key={line} className="rounded-card border border-border/60 bg-surface-muted/45 px-3 py-2 text-sm leading-6"> <li
key={line}
className="rounded-card border border-border/60 bg-surface-muted/45 px-3 py-2 text-sm leading-6"
>
{line} {line}
</li> </li>
))} ))}
@@ -87,7 +154,10 @@ export function WeatherBriefCard({ latitude, longitude, region = "PL", locationN
<h3 className="mt-1 text-sm font-semibold">{tomorrowBrief.headline}</h3> <h3 className="mt-1 text-sm font-semibold">{tomorrowBrief.headline}</h3>
<ul className="mt-3 space-y-2"> <ul className="mt-3 space-y-2">
{tomorrowBrief.body.slice(0, 3).map((line) => ( {tomorrowBrief.body.slice(0, 3).map((line) => (
<li key={line} className="rounded-card border border-border/60 bg-surface-muted/45 px-3 py-2 text-sm leading-6"> <li
key={line}
className="rounded-card border border-border/60 bg-surface-muted/45 px-3 py-2 text-sm leading-6"
>
{line} {line}
</li> </li>
))} ))}

View File

@@ -82,7 +82,9 @@ export function DayForecastModal({
}, [day, onClose]); }, [day, onClose]);
const formattedDate = day const formattedDate = day
? new Intl.DateTimeFormat(locale, { weekday: "long", day: "numeric", month: "long", timeZone: "UTC" }).format(new Date(`${day.date}T12:00:00Z`)) ? new Intl.DateTimeFormat(locale, { weekday: "long", day: "numeric", month: "long", timeZone: "UTC" }).format(
new Date(`${day.date}T12:00:00Z`),
)
: ""; : "";
const modal = ( const modal = (
@@ -114,7 +116,9 @@ export function DayForecastModal({
<CloudSun className="size-4" /> <CloudSun className="size-4" />
{t("forecast.dayDetails")} {t("forecast.dayDetails")}
</p> </p>
<h2 id="day-forecast-title" className="mt-2 text-2xl font-semibold tracking-tight sm:text-3xl">{locationName}</h2> <h2 id="day-forecast-title" className="mt-2 text-2xl font-semibold tracking-tight sm:text-3xl">
{locationName}
</h2>
<p className="mt-1 capitalize text-muted">{formattedDate}</p> <p className="mt-1 capitalize text-muted">{formattedDate}</p>
</div> </div>
<button <button
@@ -134,13 +138,25 @@ 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, temperatureUnit)}</p> <p className="text-6xl font-semibold tracking-[-0.08em] sm:text-7xl">
<p className="mb-2 text-2xl text-muted">{formatForecastTemperature(day.temperatureMin, language, temperatureUnit)}</p> {formatForecastTemperature(day.temperatureMax, language, temperatureUnit)}
</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]">
<DayMetric icon={Droplets} label={t("forecast.precipitation")} value={formatForecastRainfall(day.precipitation, language)} /> <DayMetric
<DayMetric icon={Wind} label={t("forecast.maxWind")} value={formatForecastWind(maximumWind, language, windSpeedUnit)} /> icon={Droplets}
label={t("forecast.precipitation")}
value={formatForecastRainfall(day.precipitation, language)}
/>
<DayMetric
icon={Wind}
label={t("forecast.maxWind")}
value={formatForecastWind(maximumWind, language, windSpeedUnit)}
/>
<DayMetric icon={Sunrise} label={t("forecast.sunrise")} value={formatHour(day.sunrise)} /> <DayMetric icon={Sunrise} label={t("forecast.sunrise")} value={formatHour(day.sunrise)} />
<DayMetric icon={Sunset} label={t("forecast.sunset")} value={formatHour(day.sunset)} /> <DayMetric icon={Sunset} label={t("forecast.sunset")} value={formatHour(day.sunset)} />
</div> </div>
@@ -160,8 +176,14 @@ export function DayForecastModal({
title={getForecastCondition(weatherCode, language)} title={getForecastCondition(weatherCode, language)}
> >
<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={weatherCode} isNight={isForecastNight(hour.time, day)} className="mx-auto my-3 size-6 text-accent" /> <ForecastIcon
<p className="text-lg font-semibold tracking-tight">{formatForecastTemperature(hour.temperature, language, temperatureUnit)}</p> code={weatherCode}
isNight={isForecastNight(hour.time, day)}
className="mx-auto my-3 size-6 text-accent"
/>
<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}%`}

View File

@@ -1,13 +1,44 @@
import { Cloud, CloudDrizzle, CloudFog, CloudLightning, CloudMoon, CloudRain, CloudSnow, CloudSun, MoonStar, Sun } from "lucide-react"; import {
Cloud,
CloudDrizzle,
CloudFog,
CloudLightning,
CloudMoon,
CloudRain,
CloudSnow,
CloudSun,
MoonStar,
Sun,
} from "lucide-react";
export function ForecastIcon({ code, className = "", isNight = false }: { code: number | null; className?: string; isNight?: boolean }) { export function ForecastIcon({
const Icon = code === 0 ? isNight ? MoonStar : Sun code,
: code === 1 || code === 2 ? isNight ? CloudMoon : CloudSun className = "",
: code === 45 || code === 48 ? CloudFog isNight = false,
: code !== null && code >= 51 && code <= 57 ? CloudDrizzle }: {
: code !== null && ((code >= 61 && code <= 67) || (code >= 80 && code <= 82)) ? CloudRain code: number | null;
: code !== null && ((code >= 71 && code <= 77) || code === 85 || code === 86) ? CloudSnow className?: string;
: code !== null && code >= 95 ? CloudLightning isNight?: boolean;
}) {
const Icon =
code === 0
? isNight
? MoonStar
: Sun
: code === 1 || code === 2
? isNight
? CloudMoon
: CloudSun
: code === 45 || code === 48
? CloudFog
: code !== null && code >= 51 && code <= 57
? CloudDrizzle
: code !== null && ((code >= 61 && code <= 67) || (code >= 80 && code <= 82))
? CloudRain
: code !== null && ((code >= 71 && code <= 77) || code === 85 || code === 86)
? CloudSnow
: code !== null && code >= 95
? CloudLightning
: Cloud; : Cloud;
return <Icon className={className} strokeWidth={1.45} />; return <Icon className={className} strokeWidth={1.45} />;
} }

View File

@@ -2,7 +2,18 @@
import { useCallback, useState } from "react"; import { useCallback, useState } from "react";
import { motion } from "framer-motion"; import { motion } from "framer-motion";
import { CalendarDays, ChevronRight, Clock3, CloudRain, CloudSun, Droplets, RefreshCw, ThermometerSun, Wind, type LucideIcon } from "lucide-react"; import {
CalendarDays,
ChevronRight,
Clock3,
CloudRain,
CloudSun,
Droplets,
RefreshCw,
ThermometerSun,
Wind,
type LucideIcon,
} from "lucide-react";
import { DayForecastCharts } from "@/components/charts/day-forecast-charts"; import { DayForecastCharts } from "@/components/charts/day-forecast-charts";
import { DayForecastModal } from "@/components/forecast/day-forecast-modal"; import { DayForecastModal } from "@/components/forecast/day-forecast-modal";
import { ForecastIcon } from "@/components/forecast/forecast-icon"; import { ForecastIcon } from "@/components/forecast/forecast-icon";
@@ -71,24 +82,47 @@ function HourlyForecastSummary({ hours }: { hours: HourlyForecast[] }) {
const maximumWind = getMaximum(hours.map((hour) => hour.windSpeed)); const maximumWind = getMaximum(hours.map((hour) => hour.windSpeed));
const rainfallTotal = getTotal(hours.map((hour) => hour.precipitation)); const rainfallTotal = getTotal(hours.map((hour) => hour.precipitation));
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, temperatureUnit)} / ${formatForecastTemperature(maximumTemperature, language, temperatureUnit)}`; : `${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">
<p className="text-[0.68rem] font-semibold uppercase tracking-[0.14em] text-muted">{t("forecast.nextHoursOverview")}</p> <p className="text-[0.68rem] font-semibold uppercase tracking-[0.14em] text-muted">
{t("forecast.nextHoursOverview")}
</p>
<div className="mt-3 grid grid-cols-4 gap-2"> <div className="mt-3 grid grid-cols-4 gap-2">
<HourlySummaryMetric icon={ThermometerSun} label={t("forecast.temperatureRange")} value={temperatureRange} /> <HourlySummaryMetric icon={ThermometerSun} label={t("forecast.temperatureRange")} value={temperatureRange} />
<HourlySummaryMetric icon={Wind} label={t("forecast.maxWind")} value={formatForecastWind(maximumWind, language, windSpeedUnit)} /> <HourlySummaryMetric
<HourlySummaryMetric icon={CloudRain} label={t("forecast.rainfallTotal")} value={formatForecastRainfall(rainfallTotal, language)} /> icon={Wind}
<HourlySummaryMetric icon={Droplets} label={t("forecast.maxProbability")} value={maximumRainfallProbability === null ? "—" : `${maximumRainfallProbability}%`} /> label={t("forecast.maxWind")}
value={formatForecastWind(maximumWind, language, windSpeedUnit)}
/>
<HourlySummaryMetric
icon={CloudRain}
label={t("forecast.rainfallTotal")}
value={formatForecastRainfall(rainfallTotal, language)}
/>
<HourlySummaryMetric
icon={Droplets}
label={t("forecast.maxProbability")}
value={maximumRainfallProbability === null ? "—" : `${maximumRainfallProbability}%`}
/>
</div> </div>
</div> </div>
); );
} }
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 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);
@@ -109,17 +143,37 @@ function DailyForecastRow({ day, index, onSelect }: { day: DailyForecast; index:
<p className="text-sm font-semibold capitalize">{label}</p> <p className="text-sm font-semibold capitalize">{label}</p>
<div className="flex min-w-0 items-center gap-2"> <div className="flex min-w-0 items-center gap-2">
<ForecastIcon code={day.weatherCode} className="size-6 shrink-0 text-accent" /> <ForecastIcon code={day.weatherCode} className="size-6 shrink-0 text-accent" />
<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">
<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> <Droplets className="size-3" />
{day.precipitationProbability === null ? "—" : `${day.precipitationProbability}%`}
</span>
<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>
); );
} }
export function ForecastPanel({ latitude, longitude, region = "PL", locationName }: { latitude?: number; longitude?: number; region?: WeatherRegion; locationName: string }) { export function ForecastPanel({
latitude,
longitude,
region = "PL",
locationName,
}: {
latitude?: number;
longitude?: number;
region?: WeatherRegion;
locationName: string;
}) {
const { language, t } = useI18n(); const { language, t } = useI18n();
const temperatureUnit = useWeatherStore((state) => state.temperatureUnit); const temperatureUnit = useWeatherStore((state) => state.temperatureUnit);
const windSpeedUnit = useWeatherStore((state) => state.windSpeedUnit); const windSpeedUnit = useWeatherStore((state) => state.windSpeedUnit);
@@ -133,9 +187,14 @@ export function ForecastPanel({ latitude, longitude, region = "PL", locationName
return ( return (
<section className="space-y-3"> <section className="space-y-3">
<div> <div>
<p className="section-kicker flex items-center gap-2"><CloudSun className="size-4" />{t("forecast.label")}</p> <p className="section-kicker flex items-center gap-2">
<CloudSun className="size-4" />
{t("forecast.label")}
</p>
<h2 className="mt-2 text-xl font-semibold tracking-tight">{t("forecast.title")}</h2> <h2 className="mt-2 text-xl font-semibold tracking-tight">{t("forecast.title")}</h2>
<p className="mt-1 max-w-3xl text-sm leading-6 text-muted">{t("forecast.description", { location: locationName })}</p> <p className="mt-1 max-w-3xl text-sm leading-6 text-muted">
{t("forecast.description", { location: locationName })}
</p>
</div> </div>
{!Number.isFinite(latitude) || !Number.isFinite(longitude) || isPending ? ( {!Number.isFinite(latitude) || !Number.isFinite(longitude) || isPending ? (
@@ -146,7 +205,10 @@ export function ForecastPanel({ latitude, longitude, region = "PL", locationName
) : isError || !forecast ? ( ) : isError || !forecast ? (
<Card className="flex min-h-40 flex-col items-center justify-center p-6 text-center"> <Card className="flex min-h-40 flex-col items-center justify-center p-6 text-center">
<p className="text-sm text-muted">{t("forecast.error")}</p> <p className="text-sm text-muted">{t("forecast.error")}</p>
<Button variant="glass" className="mt-4" onClick={() => refetch()}><RefreshCw className="size-4" />{t("common.retry")}</Button> <Button variant="glass" className="mt-4" onClick={() => refetch()}>
<RefreshCw className="size-4" />
{t("common.retry")}
</Button>
</Card> </Card>
) : !forecast.hourly.length || !forecast.daily.length ? ( ) : !forecast.hourly.length || !forecast.daily.length ? (
<EmptyState title={t("forecast.emptyTitle")} description={t("forecast.emptyDescription")} /> <EmptyState title={t("forecast.emptyTitle")} description={t("forecast.emptyDescription")} />
@@ -154,7 +216,10 @@ export function ForecastPanel({ latitude, longitude, region = "PL", locationName
<div className="space-y-3"> <div className="space-y-3">
<div className="grid items-start gap-3 lg:grid-cols-[1.35fr_1fr] lg:items-stretch"> <div className="grid items-start gap-3 lg:grid-cols-[1.35fr_1fr] lg:items-stretch">
<Card className="flex flex-col overflow-hidden p-4 sm:p-5 lg:h-full"> <Card className="flex flex-col overflow-hidden p-4 sm:p-5 lg:h-full">
<h3 className="flex items-center gap-2 text-sm font-semibold"><Clock3 className="size-4 text-accent" />{t("forecast.hourly")}</h3> <h3 className="flex items-center gap-2 text-sm font-semibold">
<Clock3 className="size-4 text-accent" />
{t("forecast.hourly")}
</h3>
<div className="weather-scrollbar -mx-4 mt-4 overflow-x-auto px-4 pb-2 sm:-mx-5 sm:px-5 lg:mt-5"> <div className="weather-scrollbar -mx-4 mt-4 overflow-x-auto px-4 pb-2 sm:-mx-5 sm:px-5 lg:mt-5">
<ul className="flex min-w-max gap-2"> <ul className="flex min-w-max gap-2">
{upcomingHours.map((hour, index) => { {upcomingHours.map((hour, index) => {
@@ -170,11 +235,23 @@ export function ForecastPanel({ latitude, longitude, region = "PL", locationName
title={getForecastCondition(weatherCode, language)} title={getForecastCondition(weatherCode, language)}
> >
<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={weatherCode} isNight={isForecastNight(hour.time, day)} className="mx-auto my-3 size-6 text-accent" /> <ForecastIcon
<p className="text-lg font-semibold tracking-tight">{formatForecastTemperature(hour.temperature, language, temperatureUnit)}</p> code={weatherCode}
<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> isNight={isForecastNight(hour.time, day)}
className="mx-auto my-3 size-6 text-accent"
/>
<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>
<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, temperatureUnit)} {formatForecastTemperature(hour.feelsLike, language, temperatureUnit)}
</p> </p>
@@ -195,8 +272,15 @@ export function ForecastPanel({ latitude, longitude, region = "PL", locationName
<HourlyForecastSummary hours={upcomingHours} /> <HourlyForecastSummary hours={upcomingHours} />
</Card> </Card>
<Card className="p-4 sm:p-5"> <Card className="p-4 sm:p-5">
<h3 className="flex items-center gap-2 text-sm font-semibold"><CalendarDays className="size-4 text-accent" />{t("forecast.daily")}</h3> <h3 className="flex items-center gap-2 text-sm font-semibold">
<ul className="mt-2">{forecast.daily.map((day, index) => <DailyForecastRow day={day} index={index} key={day.date} onSelect={setSelectedDay} />)}</ul> <CalendarDays className="size-4 text-accent" />
{t("forecast.daily")}
</h3>
<ul className="mt-2">
{forecast.daily.map((day, index) => (
<DailyForecastRow day={day} index={index} key={day.date} onSelect={setSelectedDay} />
))}
</ul>
</Card> </Card>
</div> </div>
<DayForecastCharts hours={todayHours} /> <DayForecastCharts hours={todayHours} />
@@ -205,7 +289,13 @@ export function ForecastPanel({ latitude, longitude, region = "PL", locationName
{forecast && <ForecastSources sources={forecast.sources} />} {forecast && <ForecastSources sources={forecast.sources} />}
<DayForecastModal day={selectedDay} hours={selectedDayHours} locationName={locationName} sources={forecast?.sources ?? []} onClose={closeDayDetails} /> <DayForecastModal
day={selectedDay}
hours={selectedDayHours}
locationName={locationName}
sources={forecast?.sources ?? []}
onClose={closeDayDetails}
/>
</section> </section>
); );
} }

View File

@@ -13,13 +13,23 @@ export function ForecastSources({ sources }: { sources: ForecastSource[] }) {
{t("forecast.source")}{" "} {t("forecast.source")}{" "}
{hasImgw && ( {hasImgw && (
<> <>
<a href="https://meteo.imgw.pl/pogoda/" target="_blank" rel="noreferrer" className="inline-flex items-center gap-1 underline decoration-muted/60 underline-offset-2 transition hover:text-accent"> <a
href="https://meteo.imgw.pl/pogoda/"
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 underline decoration-muted/60 underline-offset-2 transition hover:text-accent"
>
IMGW ALARO <ExternalLink className="size-3" /> IMGW ALARO <ExternalLink className="size-3" />
</a> </a>
{", "} {", "}
</> </>
)} )}
<a href="https://open-meteo.com/" target="_blank" rel="noreferrer" className="inline-flex items-center gap-1 underline decoration-muted/60 underline-offset-2 transition hover:text-accent"> <a
href="https://open-meteo.com/"
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 underline decoration-muted/60 underline-offset-2 transition hover:text-accent"
>
Open-Meteo <ExternalLink className="size-3" /> Open-Meteo <ExternalLink className="size-3" />
</a> </a>
. {t(hasImgw ? "forecast.sourceCombinedDescription" : "forecast.sourceFallbackDescription")} . {t(hasImgw ? "forecast.sourceCombinedDescription" : "forecast.sourceFallbackDescription")}

View File

@@ -17,10 +17,14 @@ export function HydroPage() {
const { data: stations, isPending, isError, refetch } = useHydroStations(); const { data: stations, isPending, isError, refetch } = useHydroStations();
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [visibleCount, setVisibleCount] = useState(PAGE_SIZE); const [visibleCount, setVisibleCount] = useState(PAGE_SIZE);
const filteredStations = useMemo(() => (stations ?? []).filter((station) => { const filteredStations = useMemo(
() =>
(stations ?? []).filter((station) => {
const haystack = `${station.name} ${station.river ?? ""} ${station.province ?? ""}`.toLocaleLowerCase(locale); const haystack = `${station.name} ${station.river ?? ""} ${station.province ?? ""}`.toLocaleLowerCase(locale);
return haystack.includes(query.trim().toLocaleLowerCase(locale)); return haystack.includes(query.trim().toLocaleLowerCase(locale));
}), [locale, query, stations]); }),
[locale, query, stations],
);
if (isPending) return <PageLoadingSkeleton />; if (isPending) return <PageLoadingSkeleton />;
if (isError) return <ErrorState onRetry={() => refetch()} description={t("hydro.error")} />; if (isError) return <ErrorState onRetry={() => refetch()} description={t("hydro.error")} />;
@@ -34,13 +38,38 @@ export function HydroPage() {
<label className="glass relative block rounded-panel p-3"> <label className="glass relative block rounded-panel p-3">
<span className="sr-only">{t("hydro.searchLabel")}</span> <span className="sr-only">{t("hydro.searchLabel")}</span>
<Search className="pointer-events-none absolute left-6 top-1/2 size-4 -translate-y-1/2 text-muted" /> <Search className="pointer-events-none absolute left-6 top-1/2 size-4 -translate-y-1/2 text-muted" />
<input value={query} onChange={(event) => { setQuery(event.target.value); setVisibleCount(PAGE_SIZE); }} placeholder={t("hydro.searchPlaceholder")} className="w-full rounded-card border border-border/70 bg-surface-raised/80 py-3 pl-10 pr-4 text-base placeholder:text-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent sm:text-sm" /> <input
value={query}
onChange={(event) => {
setQuery(event.target.value);
setVisibleCount(PAGE_SIZE);
}}
placeholder={t("hydro.searchPlaceholder")}
className="w-full rounded-card border border-border/70 bg-surface-raised/80 py-3 pl-10 pr-4 text-base placeholder:text-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent sm:text-sm"
/>
</label> </label>
<p className="text-xs text-muted">{t("hydro.results", { total: filteredStations.length, visible: Math.min(visibleCount, filteredStations.length) })}</p> <p className="text-xs text-muted">
{!filteredStations.length ? <EmptyState icon={Waves} title={t("stations.emptyTitle")} description={t("hydro.emptyDescription")} /> : ( {t("hydro.results", {
total: filteredStations.length,
visible: Math.min(visibleCount, filteredStations.length),
})}
</p>
{!filteredStations.length ? (
<EmptyState icon={Waves} title={t("stations.emptyTitle")} description={t("hydro.emptyDescription")} />
) : (
<> <>
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">{filteredStations.slice(0, visibleCount).map((station, index) => <HydroStationCard key={station.id} station={station} index={index} />)}</div> <div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{visibleCount < filteredStations.length && <div className="flex justify-center pt-2"><Button variant="glass" onClick={() => setVisibleCount((count) => count + PAGE_SIZE)}>{t("hydro.more")}</Button></div>} {filteredStations.slice(0, visibleCount).map((station, index) => (
<HydroStationCard key={station.id} station={station} index={index} />
))}
</div>
{visibleCount < filteredStations.length && (
<div className="flex justify-center pt-2">
<Button variant="glass" onClick={() => setVisibleCount((count) => count + PAGE_SIZE)}>
{t("hydro.more")}
</Button>
</div>
)}
</> </>
)} )}
</div> </div>

View File

@@ -12,20 +12,38 @@ export function HydroStationCard({ station, index = 0 }: { station: HydroStation
const { language, t } = useI18n(); const { language, t } = useI18n();
const temperatureUnit = useWeatherStore((state) => state.temperatureUnit); 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">
<div> <div>
<div> <div>
<h2 className="font-semibold tracking-tight">{station.name}</h2> <h2 className="font-semibold tracking-tight">{station.name}</h2>
<p className="mt-1 flex items-center gap-1 text-xs text-muted"><MapPin className="size-3" />{station.river ?? t("hydro.riverUnavailable")}{station.province ? ` · ${station.province}` : ""}</p> <p className="mt-1 flex items-center gap-1 text-xs text-muted">
<MapPin className="size-3" />
{station.river ?? t("hydro.riverUnavailable")}
{station.province ? ` · ${station.province}` : ""}
</p>
</div> </div>
</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
<HydroMetric icon={Thermometer} label={t("hydro.water")} value={formatTemperature(station.waterTemperature, language, temperatureUnit)} /> icon={Droplets}
label={t("hydro.level")}
value={formatWaterLevel(station.waterLevel, 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>
</Card> </Card>
</motion.article> </motion.article>
); );
@@ -34,8 +52,13 @@ export function HydroStationCard({ station, index = 0 }: { station: HydroStation
function HydroMetric({ icon: Icon, label, value }: { icon: typeof Droplets; label: string; value: string }) { function HydroMetric({ icon: Icon, label, value }: { icon: typeof Droplets; label: string; value: string }) {
return ( return (
<div className="rounded-card bg-surface-muted/60 p-2.5"> <div className="rounded-card bg-surface-muted/60 p-2.5">
<p className="flex items-center gap-1 text-[0.65rem] text-muted"><Icon className="size-3" />{label}</p> <p className="flex items-center gap-1 text-[0.65rem] text-muted">
<p className="mt-1.5 truncate text-xs font-semibold" title={value}>{value}</p> <Icon className="size-3" />
{label}
</p>
<p className="mt-1.5 truncate text-xs font-semibold" title={value}>
{value}
</p>
</div> </div>
); );
} }

View File

@@ -28,14 +28,24 @@ export function AppShell({ children }: { children: React.ReactNode }) {
<div className="min-h-screen overflow-x-hidden bg-background"> <div className="min-h-screen overflow-x-hidden bg-background">
<header className="app-header-safe sticky top-0 z-40 border-b border-border/70 bg-surface"> <header className="app-header-safe sticky top-0 z-40 border-b border-border/70 bg-surface">
<div className="mx-auto flex max-w-7xl items-center justify-between px-4 py-3 sm:px-6 lg:px-8"> <div className="mx-auto flex max-w-7xl items-center justify-between px-4 py-3 sm:px-6 lg:px-8">
<Link href="/" className="text-2xl font-semibold tracking-[-0.09em] text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"> <Link
href="/"
className="text-2xl font-semibold tracking-[-0.09em] text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"
>
wtr<span className="text-accent">.</span> wtr<span className="text-accent">.</span>
</Link> </Link>
<nav aria-label={t("nav.main")} className="hidden items-center gap-1 md:flex"> <nav aria-label={t("nav.main")} className="hidden items-center gap-1 md:flex">
{visibleNavItems.map((item) => { {visibleNavItems.map((item) => {
const active = item.href === "/" ? pathname === "/" : pathname.startsWith(item.href); const active = item.href === "/" ? pathname === "/" : pathname.startsWith(item.href);
return ( return (
<Link key={item.href} href={item.href} className={cn("rounded-control px-4 py-2 text-sm font-medium transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent", active ? "bg-foreground text-background shadow-soft" : "text-muted hover:bg-surface-muted/70")}> <Link
key={item.href}
href={item.href}
className={cn(
"rounded-control px-4 py-2 text-sm font-medium transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent",
active ? "bg-foreground text-background shadow-soft" : "text-muted hover:bg-surface-muted/70",
)}
>
{t(item.labelKey)} {t(item.labelKey)}
</Link> </Link>
); );
@@ -47,12 +57,22 @@ export function AppShell({ children }: { children: React.ReactNode }) {
</div> </div>
</header> </header>
<main className="app-main-safe mx-auto max-w-7xl px-4 pt-5 sm:px-6 sm:pt-8 lg:px-8">{children}</main> <main className="app-main-safe mx-auto max-w-7xl px-4 pt-5 sm:px-6 sm:pt-8 lg:px-8">{children}</main>
<nav aria-label={t("nav.mobile")} className="mobile-nav-safe fixed z-50 flex justify-around rounded-panel border border-border/70 bg-surface p-1.5 shadow-card md:hidden"> <nav
aria-label={t("nav.mobile")}
className="mobile-nav-safe fixed z-50 flex justify-around rounded-panel border border-border/70 bg-surface p-1.5 shadow-card md:hidden"
>
{visibleNavItems.map((item) => { {visibleNavItems.map((item) => {
const Icon = iconsByHref[item.href] ?? CloudSun; const Icon = iconsByHref[item.href] ?? CloudSun;
const active = item.href === "/" ? pathname === "/" : pathname.startsWith(item.href); const active = item.href === "/" ? pathname === "/" : pathname.startsWith(item.href);
return ( return (
<Link key={item.href} href={item.href} className={cn("flex min-w-0 flex-1 flex-col items-center gap-1 rounded-card px-2 py-2 text-[0.68rem] font-medium transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent", active ? "bg-foreground text-background" : "text-muted")}> <Link
key={item.href}
href={item.href}
className={cn(
"flex min-w-0 flex-1 flex-col items-center gap-1 rounded-card px-2 py-2 text-[0.68rem] font-medium transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent",
active ? "bg-foreground text-background" : "text-muted",
)}
>
<Icon className="size-4" /> <Icon className="size-4" />
<span className="max-w-full truncate">{t(item.labelKey)}</span> <span className="max-w-full truncate">{t(item.labelKey)}</span>
</Link> </Link>

View File

@@ -1,7 +1,20 @@
"use client"; "use client";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { Bell, BellRing, ChevronDown, Clock3, Languages, LayoutDashboard, MapPinned, Palette, Settings, Smartphone, Thermometer, Wind } from "lucide-react"; import {
Bell,
BellRing,
ChevronDown,
Clock3,
Languages,
LayoutDashboard,
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";
@@ -14,13 +27,26 @@ import { DEFAULT_STATION_ID } from "@/lib/constants";
import { DASHBOARD_SECTION_SETTINGS } from "@/lib/dashboard-sections"; import { DASHBOARD_SECTION_SETTINGS } from "@/lib/dashboard-sections";
import { useI18n } from "@/lib/i18n"; import { useI18n } from "@/lib/i18n";
import { locateSynopStations } from "@/lib/location-utils"; 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 { TEMPERATURE_UNITS, WIND_SPEED_UNITS } from "@/lib/weather-utils"; import { TEMPERATURE_UNITS, WIND_SPEED_UNITS } from "@/lib/weather-utils";
import type { TemperatureUnit, 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";
function isStandaloneApp() { function isStandaloneApp() {
if (typeof window === "undefined") return false; if (typeof window === "undefined") return false;
@@ -38,12 +64,23 @@ function isAppleMobilePlatform() {
function getNotificationSupportStatus(): NotificationSupportStatus { function getNotificationSupportStatus(): NotificationSupportStatus {
if (typeof window === "undefined") return "checking"; if (typeof window === "undefined") return "checking";
if (!("Notification" in window) || !("serviceWorker" in navigator) || !("PushManager" in window)) return "unsupported"; if (!("Notification" in window) || !("serviceWorker" in navigator) || !("PushManager" in window))
return "unsupported";
if (isAppleMobilePlatform() && !isStandaloneApp()) return "needs-install"; if (isAppleMobilePlatform() && !isStandaloneApp()) return "needs-install";
return Notification.permission; return Notification.permission;
} }
function SettingsRow({ icon: Icon, title, description, children }: { icon: typeof Settings; title: string; description: string; children: React.ReactNode }) { function SettingsRow({
icon: Icon,
title,
description,
children,
}: {
icon: typeof Settings;
title: string;
description: string;
children: React.ReactNode;
}) {
return ( return (
<div className="flex flex-col gap-4 border-t border-border/65 py-4 first:border-t-0 first:pt-0 last:pb-0 sm:flex-row sm:items-center sm:justify-between"> <div className="flex flex-col gap-4 border-t border-border/65 py-4 first:border-t-0 first:pt-0 last:pb-0 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
@@ -64,24 +101,36 @@ function SwitchIndicator({ checked, disabled }: { checked: boolean; disabled?: b
return ( return (
<span <span
className={`relative inline-flex h-7 w-12 shrink-0 items-center rounded-control border px-0.5 transition-colors ${ className={`relative inline-flex h-7 w-12 shrink-0 items-center rounded-control border px-0.5 transition-colors ${
checked checked ? "border-accent/55 bg-accent" : "border-border/70 bg-surface-muted"
? "border-accent/55 bg-accent"
: "border-border/70 bg-surface-muted"
} ${disabled ? "opacity-55" : ""}`} } ${disabled ? "opacity-55" : ""}`}
aria-hidden="true" aria-hidden="true"
> >
<span className={`size-5 rounded-full bg-surface-raised shadow-soft transition-transform ${checked ? "translate-x-5" : "translate-x-0"}`} /> <span
className={`size-5 rounded-full bg-surface-raised shadow-soft transition-transform ${checked ? "translate-x-5" : "translate-x-0"}`}
/>
</span> </span>
); );
} }
function NotificationSwitchLabel({ icon: Icon, title, description, checked, disabled, onChange }: { icon: LucideIcon; title: string; description: string; checked: boolean; disabled?: boolean; onChange: (checked: boolean) => void }) { function NotificationSwitchLabel({
icon: Icon,
title,
description,
checked,
disabled,
onChange,
}: {
icon: LucideIcon;
title: string;
description: string;
checked: boolean;
disabled?: boolean;
onChange: (checked: boolean) => void;
}) {
return ( return (
<label <label
className={`mt-3 flex cursor-pointer items-start justify-between gap-4 rounded-card border px-3 py-3 text-sm transition-colors ${ className={`mt-3 flex cursor-pointer items-start justify-between gap-4 rounded-card border px-3 py-3 text-sm transition-colors ${
checked checked ? "border-accent/45 bg-accent/10" : "border-border/60 bg-surface"
? "border-accent/45 bg-accent/10"
: "border-border/60 bg-surface"
} ${disabled ? "cursor-not-allowed opacity-65" : "hover:border-accent/45"}`} } ${disabled ? "cursor-not-allowed opacity-65" : "hover:border-accent/45"}`}
> >
<input <input
@@ -103,7 +152,10 @@ function NotificationSwitchLabel({ icon: Icon, title, description, checked, disa
); );
} }
const windSpeedUnitKeys: Record<WindSpeedUnit, "settings.units.wind.ms" | "settings.units.wind.kmh" | "settings.units.wind.mph"> = { const windSpeedUnitKeys: Record<
WindSpeedUnit,
"settings.units.wind.ms" | "settings.units.wind.kmh" | "settings.units.wind.mph"
> = {
ms: "settings.units.wind.ms", ms: "settings.units.wind.ms",
kmh: "settings.units.wind.kmh", kmh: "settings.units.wind.kmh",
mph: "settings.units.wind.mph", mph: "settings.units.wind.mph",
@@ -145,25 +197,37 @@ export function SettingsPage() {
const setDashboardSectionVisible = useWeatherStore((state) => state.setDashboardSectionVisible); const setDashboardSectionVisible = useWeatherStore((state) => state.setDashboardSectionVisible);
const setAppSectionVisible = useWeatherStore((state) => state.setAppSectionVisible); const setAppSectionVisible = useWeatherStore((state) => state.setAppSectionVisible);
const selectedStation = stations?.find((station) => station.id === selectedStationId) const selectedStation =
?? stations?.find((station) => station.id === DEFAULT_STATION_ID) stations?.find((station) => station.id === selectedStationId) ??
?? stations?.[0]; stations?.find((station) => station.id === DEFAULT_STATION_ID) ??
stations?.[0];
const activeLocation = selectedLocation; const activeLocation = selectedLocation;
const selectedRegion = activeLocation?.region ?? "PL"; const selectedRegion = activeLocation?.region ?? "PL";
const stationPosition = selectedStation const stationPosition = selectedStation
? locateSynopStations(stations ?? [], positions).find((station) => station.id === selectedStation.id) ? locateSynopStations(stations ?? [], positions).find((station) => station.id === selectedStation.id)
: null; : null;
const notificationLatitude = Number.isFinite(activeLocation?.latitude) ? activeLocation?.latitude : stationPosition?.latitude ?? null; const notificationLatitude = Number.isFinite(activeLocation?.latitude)
const notificationLongitude = Number.isFinite(activeLocation?.longitude) ? activeLocation?.longitude : stationPosition?.longitude ?? null; ? activeLocation?.latitude
: (stationPosition?.latitude ?? null);
const notificationLongitude = Number.isFinite(activeLocation?.longitude)
? activeLocation?.longitude
: (stationPosition?.longitude ?? null);
const notificationLocationName = activeLocation?.name ?? selectedStation?.name ?? null; const notificationLocationName = activeLocation?.name ?? selectedStation?.name ?? null;
const notificationTimezone = activeLocation?.timezone ?? (selectedRegion === "PL" ? "Europe/Warsaw" : null); const notificationTimezone = activeLocation?.timezone ?? (selectedRegion === "PL" ? "Europe/Warsaw" : null);
const notificationCountyTeryt = selectedRegion === "PL" ? activeLocation?.countyTeryt ?? null : null; const notificationCountyTeryt = selectedRegion === "PL" ? (activeLocation?.countyTeryt ?? null) : null;
const selectedProvince = selectedRegion === "PL" ? getProvinceForSelection(selectedLocation?.province, selectedStationId ?? DEFAULT_STATION_ID) : null; const selectedProvince =
const effectiveProvince = selectedRegion === "PL" ? provinceMode === "manual" ? manualProvince : selectedProvince : null; selectedRegion === "PL"
? getProvinceForSelection(selectedLocation?.province, selectedStationId ?? DEFAULT_STATION_ID)
: null;
const effectiveProvince =
selectedRegion === "PL" ? (provinceMode === "manual" ? manualProvince : selectedProvince) : null;
const canUseOfficialWarnings = selectedRegion === "PL" && Boolean(effectiveProvince); const canUseOfficialWarnings = selectedRegion === "PL" && Boolean(effectiveProvince);
const effectiveProvinceLabel = selectedRegion === "GLOBAL" const effectiveProvinceLabel =
selectedRegion === "GLOBAL"
? t("settings.notifications.globalRegion") ? t("settings.notifications.globalRegion")
: effectiveProvince ? formatProvinceName(effectiveProvince, language) : t("settings.notifications.noProvince"); : effectiveProvince
? formatProvinceName(effectiveProvince, language)
: t("settings.notifications.noProvince");
const manualProvinceValue = manualProvince ?? selectedProvince ?? "mazowieckie"; const manualProvinceValue = manualProvince ?? selectedProvince ?? "mazowieckie";
useEffect(() => { useEffect(() => {
@@ -211,7 +275,24 @@ export function SettingsPage() {
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [canUseOfficialWarnings, effectiveProvince, language, morningBriefEnabled, notificationCountyTeryt, notificationLatitude, notificationLocationName, notificationLongitude, notificationStatus, notificationsEnabled, notificationTimezone, selectedRegion, temperatureUnit, tomorrowBriefEnabled, vapidPublicKey, windSpeedUnit]); }, [
canUseOfficialWarnings,
effectiveProvince,
language,
morningBriefEnabled,
notificationCountyTeryt,
notificationLatitude,
notificationLocationName,
notificationLongitude,
notificationStatus,
notificationsEnabled,
notificationTimezone,
selectedRegion,
temperatureUnit,
tomorrowBriefEnabled,
vapidPublicKey,
windSpeedUnit,
]);
useEffect(() => { useEffect(() => {
if (!provinceDropdownOpen) return; if (!provinceDropdownOpen) return;
@@ -243,11 +324,18 @@ export function SettingsPage() {
} }
}, [notificationStatus, t]); }, [notificationStatus, t]);
const canUsePush = Boolean(vapidPublicKey && (selectedRegion === "GLOBAL" || effectiveProvince) && notificationStatus !== "unsupported" && notificationStatus !== "needs-install" && notificationStatus !== "denied" && notificationStatus !== "unconfigured"); const canUsePush = Boolean(
vapidPublicKey &&
(selectedRegion === "GLOBAL" || effectiveProvince) &&
notificationStatus !== "unsupported" &&
notificationStatus !== "needs-install" &&
notificationStatus !== "denied" &&
notificationStatus !== "unconfigured",
);
async function getServiceWorkerRegistration() { async function getServiceWorkerRegistration() {
if (!("serviceWorker" in navigator)) throw new Error("Service worker is not available."); if (!("serviceWorker" in navigator)) throw new Error("Service worker is not available.");
return await navigator.serviceWorker.getRegistration() ?? await navigator.serviceWorker.register("/sw.js"); return (await navigator.serviceWorker.getRegistration()) ?? (await navigator.serviceWorker.register("/sw.js"));
} }
async function enableWarningNotifications() { async function enableWarningNotifications() {
@@ -270,10 +358,12 @@ export function SettingsPage() {
} }
const registration = await getServiceWorkerRegistration(); const registration = await getServiceWorkerRegistration();
const existingSubscription = await registration.pushManager.getSubscription(); const existingSubscription = await registration.pushManager.getSubscription();
const subscription = existingSubscription ?? await registration.pushManager.subscribe({ const subscription =
existingSubscription ??
(await registration.pushManager.subscribe({
userVisibleOnly: true, userVisibleOnly: true,
applicationServerKey: decodeBase64UrlKey(vapidPublicKey), applicationServerKey: decodeBase64UrlKey(vapidPublicKey),
}); }));
await savePushSubscription(subscription, effectiveProvince, language, { await savePushSubscription(subscription, effectiveProvince, language, {
region: selectedRegion, region: selectedRegion,
officialWarningsEnabled: canUseOfficialWarnings, officialWarningsEnabled: canUseOfficialWarnings,
@@ -387,20 +477,35 @@ export function SettingsPage() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<section> <section>
<p className="section-kicker flex items-center gap-2"><Settings className="size-4" />{t("settings.section")}</p> <p className="section-kicker flex items-center gap-2">
<Settings className="size-4" />
{t("settings.section")}
</p>
<h1 className="mt-2 text-3xl font-semibold tracking-tight">{t("settings.title")}</h1> <h1 className="mt-2 text-3xl font-semibold tracking-tight">{t("settings.title")}</h1>
<p className="mt-2 max-w-2xl text-sm leading-6 text-muted">{t("settings.description")}</p> <p className="mt-2 max-w-2xl text-sm leading-6 text-muted">{t("settings.description")}</p>
</section> </section>
<Card className="p-4 sm:p-5"> <Card className="p-4 sm:p-5">
<SettingsRow icon={Languages} title={t("settings.language.title")} description={t("settings.language.description")}> <SettingsRow
icon={Languages}
title={t("settings.language.title")}
description={t("settings.language.description")}
>
<LanguageToggle /> <LanguageToggle />
</SettingsRow> </SettingsRow>
<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")}> <SettingsRow
<div className="surface-control inline-flex rounded-full p-1" role="radiogroup" aria-label={t("settings.units.temperature.label")}> 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) => ( {TEMPERATURE_UNITS.map((unit) => (
<button <button
key={unit} key={unit}
@@ -419,8 +524,16 @@ export function SettingsPage() {
))} ))}
</div> </div>
</SettingsRow> </SettingsRow>
<SettingsRow icon={Wind} title={t("settings.units.wind.title")} description={t("settings.units.wind.description")}> <SettingsRow
<div className="surface-control inline-flex rounded-full p-1" role="radiogroup" aria-label={t("settings.units.wind.label")}> 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")}
>
{WIND_SPEED_UNITS.map((unit) => ( {WIND_SPEED_UNITS.map((unit) => (
<button <button
key={unit} key={unit}
@@ -509,7 +622,9 @@ export function SettingsPage() {
<MapPinned className="mt-0.5 size-4 text-accent" aria-hidden="true" /> <MapPinned className="mt-0.5 size-4 text-accent" aria-hidden="true" />
<div> <div>
<h3 className="text-sm font-semibold">{t("settings.notifications.regionTitle")}</h3> <h3 className="text-sm font-semibold">{t("settings.notifications.regionTitle")}</h3>
<p className="mt-1 text-sm leading-6 text-muted">{t("settings.notifications.regionDescription", { province: effectiveProvinceLabel })}</p> <p className="mt-1 text-sm leading-6 text-muted">
{t("settings.notifications.regionDescription", { province: effectiveProvinceLabel })}
</p>
</div> </div>
</div> </div>
@@ -525,7 +640,11 @@ export function SettingsPage() {
/> />
<span> <span>
<span className="block font-medium">{t("settings.notifications.regionSelected")}</span> <span className="block font-medium">{t("settings.notifications.regionSelected")}</span>
<span className="mt-0.5 block text-xs leading-5 text-muted">{selectedProvince ? formatProvinceName(selectedProvince, language) : t("settings.notifications.noProvince")}</span> <span className="mt-0.5 block text-xs leading-5 text-muted">
{selectedProvince
? formatProvinceName(selectedProvince, language)
: t("settings.notifications.noProvince")}
</span>
</span> </span>
</label> </label>
<label className="flex items-start gap-3 rounded-card border border-border/60 bg-surface px-3 py-3 text-sm"> <label className="flex items-start gap-3 rounded-card border border-border/60 bg-surface px-3 py-3 text-sm">
@@ -550,7 +669,8 @@ export function SettingsPage() {
aria-expanded={provinceDropdownOpen} aria-expanded={provinceDropdownOpen}
disabled={selectedRegion === "GLOBAL" || provinceMode !== "manual"} disabled={selectedRegion === "GLOBAL" || provinceMode !== "manual"}
onBlur={(event) => { onBlur={(event) => {
if (!event.currentTarget.parentElement?.contains(event.relatedTarget as Node | null)) setProvinceDropdownOpen(false); if (!event.currentTarget.parentElement?.contains(event.relatedTarget as Node | null))
setProvinceDropdownOpen(false);
}} }}
onClick={() => setProvinceDropdownOpen((current) => !current)} onClick={() => setProvinceDropdownOpen((current) => !current)}
className="surface-control flex w-full items-center justify-between gap-3 rounded-control py-2 pl-3 pr-3 text-left text-sm disabled:opacity-60" className="surface-control flex w-full items-center justify-between gap-3 rounded-control py-2 pl-3 pr-3 text-left text-sm disabled:opacity-60"
@@ -581,7 +701,9 @@ export function SettingsPage() {
}`} }`}
> >
<span className="truncate">{formatProvinceName(province, language)}</span> <span className="truncate">{formatProvinceName(province, language)}</span>
{manualProvinceValue === province && <span className="size-1.5 rounded-full bg-accent" aria-hidden="true" />} {manualProvinceValue === province && (
<span className="size-1.5 rounded-full bg-accent" aria-hidden="true" />
)}
</button> </button>
))} ))}
</div> </div>
@@ -621,9 +743,15 @@ export function SettingsPage() {
<span className="min-w-0"> <span className="min-w-0">
<span className="flex items-center gap-2 font-medium"> <span className="flex items-center gap-2 font-medium">
<Bell className="size-3.5 text-accent" aria-hidden="true" /> <Bell className="size-3.5 text-accent" aria-hidden="true" />
{selectedRegion === "GLOBAL" ? t("settings.notifications.enableDevice") : t("settings.notifications.enable")} {selectedRegion === "GLOBAL"
? t("settings.notifications.enableDevice")
: t("settings.notifications.enable")}
</span>
<span className="mt-0.5 block text-xs leading-5 text-muted">
{selectedRegion === "GLOBAL"
? t("settings.notifications.enableGlobalDescription")
: t("settings.notifications.enableDescription")}
</span> </span>
<span className="mt-0.5 block text-xs leading-5 text-muted">{selectedRegion === "GLOBAL" ? t("settings.notifications.enableGlobalDescription") : t("settings.notifications.enableDescription")}</span>
<span className="mt-2 block text-xs font-semibold text-accent"> <span className="mt-2 block text-xs font-semibold text-accent">
{isSavingSubscription {isSavingSubscription
? t("settings.notifications.saving") ? t("settings.notifications.saving")
@@ -632,7 +760,10 @@ export function SettingsPage() {
: t("settings.notifications.disabledStatus")} : t("settings.notifications.disabledStatus")}
</span> </span>
</span> </span>
<SwitchIndicator checked={notificationsEnabled} disabled={(!notificationsEnabled && !canUsePush) || isSavingSubscription} /> <SwitchIndicator
checked={notificationsEnabled}
disabled={(!notificationsEnabled && !canUsePush) || isSavingSubscription}
/>
</button> </button>
{notificationMessage && <p className="mt-3 text-xs font-medium text-accent">{notificationMessage}</p>} {notificationMessage && <p className="mt-3 text-xs font-medium text-accent">{notificationMessage}</p>}
@@ -655,9 +786,16 @@ export function SettingsPage() {
/> />
<div className="mt-4"> <div className="mt-4">
<Button type="button" variant="glass" disabled={!notificationsEnabled || isSavingSubscription || isSendingTestNotification} onClick={sendTestNotification}> <Button
type="button"
variant="glass"
disabled={!notificationsEnabled || isSavingSubscription || isSendingTestNotification}
onClick={sendTestNotification}
>
<BellRing className="size-4" /> <BellRing className="size-4" />
{isSendingTestNotification ? t("settings.notifications.sendingTest") : t("settings.notifications.sendTest")} {isSendingTestNotification
? t("settings.notifications.sendingTest")
: t("settings.notifications.sendTest")}
</Button> </Button>
</div> </div>

View File

@@ -2,10 +2,20 @@ import type { LucideIcon } from "lucide-react";
import { CircleCheckBig } from "lucide-react"; import { CircleCheckBig } from "lucide-react";
import { Card } from "@/components/ui/card"; import { Card } from "@/components/ui/card";
export function EmptyState({ title, description, icon: Icon = CircleCheckBig }: { title: string; description: string; icon?: LucideIcon }) { export function EmptyState({
title,
description,
icon: Icon = CircleCheckBig,
}: {
title: string;
description: string;
icon?: LucideIcon;
}) {
return ( return (
<Card className="flex min-h-52 flex-col items-center justify-center p-8 text-center"> <Card className="flex min-h-52 flex-col items-center justify-center p-8 text-center">
<div className="mb-4 rounded-control bg-accent/10 p-3 text-accent"><Icon className="size-6" /></div> <div className="mb-4 rounded-control bg-accent/10 p-3 text-accent">
<Icon className="size-6" />
</div>
<h2 className="text-lg font-semibold">{title}</h2> <h2 className="text-lg font-semibold">{title}</h2>
<p className="mt-2 max-w-md text-sm leading-6 text-muted">{description}</p> <p className="mt-2 max-w-md text-sm leading-6 text-muted">{description}</p>
</Card> </Card>

View File

@@ -5,14 +5,27 @@ import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card"; import { Card } from "@/components/ui/card";
import { useI18n } from "@/lib/i18n"; import { useI18n } from "@/lib/i18n";
export function ErrorState({ title, description, onRetry }: { title?: string; description?: string; onRetry: () => void }) { export function ErrorState({
title,
description,
onRetry,
}: {
title?: string;
description?: string;
onRetry: () => void;
}) {
const { t } = useI18n(); const { t } = useI18n();
return ( return (
<Card className="flex min-h-52 flex-col items-center justify-center p-8 text-center"> <Card className="flex min-h-52 flex-col items-center justify-center p-8 text-center">
<div className="mb-4 rounded-control bg-warning/10 p-3 text-warning"><TriangleAlert className="size-6" /></div> <div className="mb-4 rounded-control bg-warning/10 p-3 text-warning">
<TriangleAlert className="size-6" />
</div>
<h2 className="text-lg font-semibold">{title ?? t("error.title")}</h2> <h2 className="text-lg font-semibold">{title ?? t("error.title")}</h2>
<p className="mb-5 mt-2 max-w-md text-sm leading-6 text-muted">{description ?? t("error.description")}</p> <p className="mb-5 mt-2 max-w-md text-sm leading-6 text-muted">{description ?? t("error.description")}</p>
<Button variant="glass" onClick={onRetry}><RefreshCw className="size-4" />{t("common.retry")}</Button> <Button variant="glass" onClick={onRetry}>
<RefreshCw className="size-4" />
{t("common.retry")}
</Button>
</Card> </Card>
); );
} }

View File

@@ -5,7 +5,12 @@ import { useI18n } from "@/lib/i18n";
export function LoadingSkeleton({ className = "" }: { className?: string }) { export function LoadingSkeleton({ className = "" }: { className?: string }) {
const { t } = useI18n(); const { t } = useI18n();
return <div className={cn("animate-pulse rounded-panel bg-surface-muted/70", className)} aria-label={t("common.loading")} />; return (
<div
className={cn("animate-pulse rounded-panel bg-surface-muted/70", className)}
aria-label={t("common.loading")}
/>
);
} }
export function PageLoadingSkeleton() { export function PageLoadingSkeleton() {
@@ -13,7 +18,9 @@ export function PageLoadingSkeleton() {
<div className="space-y-5" aria-busy="true"> <div className="space-y-5" aria-busy="true">
<LoadingSkeleton className="h-[25rem]" /> <LoadingSkeleton className="h-[25rem]" />
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4"> <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
{Array.from({ length: 4 }, (_, index) => <LoadingSkeleton className="h-36" key={index} />)} {Array.from({ length: 4 }, (_, index) => (
<LoadingSkeleton className="h-36" key={index} />
))}
</div> </div>
</div> </div>
); );

View File

@@ -53,17 +53,25 @@ export function DashboardWarnings() {
}; };
}, []); }, []);
const province = isGlobalLocation ? null : getProvinceForSelection(selectedLocation?.province, selectedStationId ?? DEFAULT_STATION_ID); const province = isGlobalLocation
const regionLabel = selectedLocation?.countyTeryt ? selectedLocation.district ?? selectedLocation.name : province ? formatProvinceName(province, language) : null; ? null
: getProvinceForSelection(selectedLocation?.province, selectedStationId ?? DEFAULT_STATION_ID);
const regionLabel = selectedLocation?.countyTeryt
? (selectedLocation.district ?? selectedLocation.name)
: province
? formatProvinceName(province, language)
: null;
const relevantWarnings = useMemo(() => { const relevantWarnings = useMemo(() => {
if (isGlobalLocation || !warnings || !province || now === null) return []; if (isGlobalLocation || !warnings || !province || now === null) return [];
return warnings return warnings
.filter((warning) => { .filter((warning) => {
const validTo = getTimestamp(warning.validTo); const validTo = getTimestamp(warning.validTo);
return warning.kind === "meteo" return (
&& warningMatchesLocalSelection(warning, province, selectedLocation) warning.kind === "meteo" &&
&& (validTo === null || validTo > now); warningMatchesLocalSelection(warning, province, selectedLocation) &&
(validTo === null || validTo > now)
);
}) })
.sort((a, b) => compareDashboardWarnings(a, b, now)); .sort((a, b) => compareDashboardWarnings(a, b, now));
}, [isGlobalLocation, now, province, selectedLocation, warnings]); }, [isGlobalLocation, now, province, selectedLocation, warnings]);
@@ -87,12 +95,8 @@ export function DashboardWarnings() {
<AlertTriangle className="size-4" aria-hidden="true" /> <AlertTriangle className="size-4" aria-hidden="true" />
</div> </div>
<div> <div>
<p className="text-[0.68rem] font-semibold uppercase tracking-[0.2em] text-warning"> <p className="text-[0.68rem] font-semibold uppercase tracking-[0.2em] text-warning">IMGW · {regionLabel}</p>
IMGW · {regionLabel} <h2 className="mt-1 text-base font-semibold text-foreground">{t("warnings.dashboard.title")}</h2>
</p>
<h2 className="mt-1 text-base font-semibold text-foreground">
{t("warnings.dashboard.title")}
</h2>
</div> </div>
</div> </div>
<Link <Link
@@ -109,13 +113,11 @@ export function DashboardWarnings() {
const active = isWarningActive(warning, now); const active = isWarningActive(warning, now);
const validityLabel = active const validityLabel = active
? warning.validTo && t("warnings.dashboard.validUntil", { date: formatDateTime(warning.validTo, language) }) ? warning.validTo && t("warnings.dashboard.validUntil", { date: formatDateTime(warning.validTo, language) })
: warning.validFrom && t("warnings.dashboard.validFrom", { date: formatDateTime(warning.validFrom, language) }); : warning.validFrom &&
t("warnings.dashboard.validFrom", { date: formatDateTime(warning.validFrom, language) });
return ( return (
<article <article key={warning.id} className="rounded-card border border-warning/20 bg-surface px-3.5 py-3">
key={warning.id}
className="rounded-card border border-warning/20 bg-surface px-3.5 py-3"
>
<p className="text-[0.68rem] font-semibold uppercase tracking-[0.16em] text-warning"> <p className="text-[0.68rem] font-semibold uppercase tracking-[0.16em] text-warning">
{t(active ? "warnings.dashboard.active" : "warnings.dashboard.upcoming")} {t(active ? "warnings.dashboard.active" : "warnings.dashboard.upcoming")}
{warning.level !== null && ` · ${t("warnings.level", { level: warning.level })}`} {warning.level !== null && ` · ${t("warnings.level", { level: warning.level })}`}

View File

@@ -12,25 +12,57 @@ export function WarningCard({ warning, index = 0 }: { warning: WeatherWarning; i
const { language, t } = useI18n(); const { language, t } = useI18n();
const Icon = warning.kind === "hydro" ? Waves : CloudLightning; const Icon = warning.kind === "hydro" ? Waves : CloudLightning;
const level = warning.level; const level = warning.level;
const levelLabel = level === -1 ? t("warnings.drought") : level === null ? t("warnings.levelUnknown") : t("warnings.level", { level }); const levelLabel =
const areasLabel = warning.areas.length > 8 level === -1 ? t("warnings.drought") : level === null ? t("warnings.levelUnknown") : t("warnings.level", { level });
const areasLabel =
warning.areas.length > 8
? `${warning.areas.slice(0, 8).join(", ")} ${t("warnings.moreAreas", { count: warning.areas.length - 8 })}` ? `${warning.areas.slice(0, 8).join(", ")} ${t("warnings.moreAreas", { count: warning.areas.length - 8 })}`
: warning.areas.join("; "); : warning.areas.join("; ");
return ( return (
<motion.article initial={{ opacity: 0, y: 12 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: Math.min(index * 0.04, 0.4), duration: 0.35 }}> <motion.article
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: Math.min(index * 0.04, 0.4), duration: 0.35 }}
>
<Card className="h-full overflow-hidden p-5"> <Card className="h-full overflow-hidden p-5">
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
<div className="rounded-card bg-warning/10 p-2.5 text-warning"><Icon className="size-5" /></div> <div className="rounded-card bg-warning/10 p-2.5 text-warning">
<span className={cn("rounded-control border px-2.5 py-1 text-xs font-semibold", level === -1 ? "border-warning/30 bg-warning/10 text-warning" : "border-warning/25 bg-warning/10 text-warning")}>{levelLabel}</span> <Icon className="size-5" />
</div> </div>
<p className="mt-5 text-xs font-semibold uppercase tracking-[0.15em] text-muted">{warning.kind === "hydro" ? t("warnings.hydro") : t("warnings.meteo")}</p> <span
<h2 className="mt-2 text-lg font-semibold tracking-tight">{warning.title || (warning.kind === "hydro" ? t("warnings.genericHydro") : t("warnings.genericMeteo"))}</h2> className={cn(
"rounded-control border px-2.5 py-1 text-xs font-semibold",
level === -1
? "border-warning/30 bg-warning/10 text-warning"
: "border-warning/25 bg-warning/10 text-warning",
)}
>
{levelLabel}
</span>
</div>
<p className="mt-5 text-xs font-semibold uppercase tracking-[0.15em] text-muted">
{warning.kind === "hydro" ? t("warnings.hydro") : t("warnings.meteo")}
</p>
<h2 className="mt-2 text-lg font-semibold tracking-tight">
{warning.title || (warning.kind === "hydro" ? t("warnings.genericHydro") : t("warnings.genericMeteo"))}
</h2>
{warning.description && <p className="mt-3 line-clamp-5 text-sm leading-6 text-muted">{warning.description}</p>} {warning.description && <p className="mt-3 line-clamp-5 text-sm leading-6 text-muted">{warning.description}</p>}
<div className="mt-5 space-y-2 text-xs text-muted"> <div className="mt-5 space-y-2 text-xs text-muted">
<p className="flex items-start gap-2"><CalendarClock className="mt-0.5 size-3.5 shrink-0" />{formatDateTime(warning.validFrom, language)} {warning.validTo ? formatDateTime(warning.validTo, language) : t("warnings.untilCancelled")}</p> <p className="flex items-start gap-2">
<p className="flex items-start gap-2"><MapPinned className="mt-0.5 size-3.5 shrink-0" />{areasLabel || t("warnings.areaUnknown")}</p> <CalendarClock className="mt-0.5 size-3.5 shrink-0" />
{formatDateTime(warning.validFrom, language)} {" "}
{warning.validTo ? formatDateTime(warning.validTo, language) : t("warnings.untilCancelled")}
</p>
<p className="flex items-start gap-2">
<MapPinned className="mt-0.5 size-3.5 shrink-0" />
{areasLabel || t("warnings.areaUnknown")}
</p>
</div> </div>
{warning.probability !== null && <p className="mt-4 text-xs font-medium text-muted">{t("warnings.probability", { value: warning.probability })}</p>} {warning.probability !== null && (
<p className="mt-4 text-xs font-medium text-muted">
{t("warnings.probability", { value: warning.probability })}
</p>
)}
</Card> </Card>
</motion.article> </motion.article>
); );

View File

@@ -16,7 +16,9 @@ import type { WeatherWarning } from "@/types/imgw";
function WarningGrid({ warnings, indexOffset = 0 }: { warnings: WeatherWarning[]; indexOffset?: number }) { function WarningGrid({ warnings, indexOffset = 0 }: { warnings: WeatherWarning[]; indexOffset?: number }) {
return ( return (
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3"> <div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{warnings.map((warning, index) => <WarningCard key={warning.id} warning={warning} index={index + indexOffset} />)} {warnings.map((warning, index) => (
<WarningCard key={warning.id} warning={warning} index={index + indexOffset} />
))}
</div> </div>
); );
} }
@@ -27,37 +29,60 @@ export function WarningsPanel() {
const selectedStationId = useWeatherStore((state) => state.selectedStationId); const selectedStationId = useWeatherStore((state) => state.selectedStationId);
const selectedLocation = useWeatherStore((state) => state.selectedLocation); const selectedLocation = useWeatherStore((state) => state.selectedLocation);
if (selectedLocation?.region === "GLOBAL") { if (selectedLocation?.region === "GLOBAL") {
return <EmptyState title={t("warnings.globalUnavailableTitle")} description={t("warnings.globalUnavailableDescription")} />; return (
<EmptyState
title={t("warnings.globalUnavailableTitle")}
description={t("warnings.globalUnavailableDescription")}
/>
);
} }
if (isPending) return <PageLoadingSkeleton />; if (isPending) return <PageLoadingSkeleton />;
if (isError) return <ErrorState onRetry={() => refetch()} description={t("warnings.error")} />; if (isError) return <ErrorState onRetry={() => refetch()} description={t("warnings.error")} />;
if (!warnings?.length) return <EmptyState title={t("warnings.emptyTitle")} description={t("warnings.emptyDescription")} />; if (!warnings?.length)
return <EmptyState title={t("warnings.emptyTitle")} description={t("warnings.emptyDescription")} />;
const province = getProvinceForSelection(selectedLocation?.province, selectedStationId ?? DEFAULT_STATION_ID); const province = getProvinceForSelection(selectedLocation?.province, selectedStationId ?? DEFAULT_STATION_ID);
if (!province) return <WarningGrid warnings={warnings} />; if (!province) return <WarningGrid warnings={warnings} />;
const provinceLabel = formatProvinceName(province, language); const provinceLabel = formatProvinceName(province, language);
const localLabel = selectedLocation?.countyTeryt ? selectedLocation.district ?? selectedLocation.name : provinceLabel; const localLabel = selectedLocation?.countyTeryt
? (selectedLocation.district ?? selectedLocation.name)
: provinceLabel;
const localWarnings = warnings.filter((warning) => warningMatchesLocalSelection(warning, province, selectedLocation)); const localWarnings = warnings.filter((warning) => warningMatchesLocalSelection(warning, province, selectedLocation));
const otherWarnings = warnings.filter((warning) => !warningMatchesLocalSelection(warning, province, selectedLocation)); const otherWarnings = warnings.filter(
(warning) => !warningMatchesLocalSelection(warning, province, selectedLocation),
);
return ( return (
<div className="space-y-9"> <div className="space-y-9">
<section className="space-y-4"> <section className="space-y-4">
<div> <div>
<p className="section-kicker flex items-center gap-2"><MapPinned className="size-4" />{t("warnings.myProvince")}</p> <p className="section-kicker flex items-center gap-2">
<MapPinned className="size-4" />
{t("warnings.myProvince")}
</p>
<h2 className="mt-2 text-2xl font-semibold capitalize tracking-tight">{localLabel}</h2> <h2 className="mt-2 text-2xl font-semibold capitalize tracking-tight">{localLabel}</h2>
<p className="mt-1 max-w-2xl text-sm leading-6 text-muted">{t("warnings.myProvinceDescription", { province: localLabel })}</p> <p className="mt-1 max-w-2xl text-sm leading-6 text-muted">
{t("warnings.myProvinceDescription", { province: localLabel })}
</p>
</div> </div>
{localWarnings.length {localWarnings.length ? (
? <WarningGrid warnings={localWarnings} /> <WarningGrid warnings={localWarnings} />
: <EmptyState title={t("warnings.myProvinceEmptyTitle")} description={t("warnings.myProvinceEmptyDescription", { province: localLabel })} />} ) : (
<EmptyState
title={t("warnings.myProvinceEmptyTitle")}
description={t("warnings.myProvinceEmptyDescription", { province: localLabel })}
/>
)}
</section> </section>
{otherWarnings.length > 0 && ( {otherWarnings.length > 0 && (
<section className="space-y-4"> <section className="space-y-4">
<div> <div>
<p className="flex items-center gap-2 text-xs font-semibold uppercase tracking-[0.16em] text-muted"><Map className="size-4" />{t("warnings.otherRegions")}</p> <p className="flex items-center gap-2 text-xs font-semibold uppercase tracking-[0.16em] text-muted">
<Map className="size-4" />
{t("warnings.otherRegions")}
</p>
<p className="mt-1 max-w-2xl text-sm leading-6 text-muted">{t("warnings.otherRegionsDescription")}</p> <p className="mt-1 max-w-2xl text-sm leading-6 text-muted">{t("warnings.otherRegionsDescription")}</p>
</div> </div>
<WarningGrid warnings={otherWarnings} indexOffset={localWarnings.length} /> <WarningGrid warnings={otherWarnings} indexOffset={localWarnings.length} />

View File

@@ -1,7 +1,15 @@
"use client"; "use client";
import { CloudSun, Droplets, Gauge, Navigation, Thermometer, Umbrella, Wind } from "lucide-react"; import { CloudSun, Droplets, Gauge, Navigation, Thermometer, Umbrella, Wind } from "lucide-react";
import { calculateFeelsLike, formatHumidity, formatPressure, formatRainfall, formatTemperature, formatWind, getWindDirection } from "@/lib/weather-utils"; import {
calculateFeelsLike,
formatHumidity,
formatPressure,
formatRainfall,
formatTemperature,
formatWind,
getWindDirection,
} from "@/lib/weather-utils";
import type { SynopStation } from "@/types/imgw"; import type { SynopStation } from "@/types/imgw";
import { MetricCard } from "@/components/weather/metric-card"; import { MetricCard } from "@/components/weather/metric-card";
import { useI18n } from "@/lib/i18n"; import { useI18n } from "@/lib/i18n";
@@ -13,17 +21,57 @@ export function CurrentConditionsCard({ station }: { station: SynopStation }) {
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, temperatureUnit), detail: t("weather.feelsLikeDetail") }, {
{ icon: Droplets, label: t("weather.humidity"), value: formatHumidity(station.humidity, language), detail: t("weather.humidityDetail") }, icon: Thermometer,
{ icon: Gauge, label: t("weather.pressure"), value: formatPressure(station.pressure, language), detail: t("weather.pressureDetail") }, label: t("weather.feelsLike"),
{ icon: Wind, label: t("weather.windSpeed"), value: formatWind(station.windSpeed, null, language, windSpeedUnit), detail: t("weather.windSpeedDetail") }, value: formatTemperature(feelsLike, language, temperatureUnit),
{ icon: Navigation, label: t("weather.windDirection"), value: station.windDirection === null ? t("common.noData") : `${station.windDirection}° ${getWindDirection(station.windDirection)}`, detail: t("weather.windDirectionDetail") }, detail: t("weather.feelsLikeDetail"),
{ 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, temperatureUnit), detail: t("weather.temperatureDetail") }, {
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: 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: 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, 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">
{metrics.map((metric, index) => <MetricCard {...metric} index={index} key={metric.label} />)} {metrics.map((metric, index) => (
<MetricCard {...metric} index={index} key={metric.label} />
))}
</div> </div>
); );
} }

View File

@@ -67,11 +67,13 @@ export function CurrentLocationControl({ stations }: { stations: LocatedSynopSta
}, },
(error) => { (error) => {
setIsLocating(false); setIsLocating(false);
setMessage(error.code === error.PERMISSION_DENIED setMessage(
error.code === error.PERMISSION_DENIED
? t("location.gpsDenied") ? t("location.gpsDenied")
: error.code === error.TIMEOUT : error.code === error.TIMEOUT
? t("location.gpsTimeout") ? t("location.gpsTimeout")
: t("location.gpsPositionUnavailable")); : t("location.gpsPositionUnavailable"),
);
}, },
{ enableHighAccuracy: true, maximumAge: 5 * 60 * 1000, timeout: 12_000 }, { enableHighAccuracy: true, maximumAge: 5 * 60 * 1000, timeout: 12_000 },
); );
@@ -87,11 +89,14 @@ export function CurrentLocationControl({ stations }: { stations: LocatedSynopSta
useEffect(() => { useEffect(() => {
if (!window.isSecureContext || autoLocated.current || !navigator.permissions?.query) return; if (!window.isSecureContext || autoLocated.current || !navigator.permissions?.query) return;
navigator.permissions.query({ name: "geolocation" }).then((permission) => { navigator.permissions
.query({ name: "geolocation" })
.then((permission) => {
if (permission.state !== "granted" || autoLocated.current) return; if (permission.state !== "granted" || autoLocated.current) return;
autoLocated.current = true; autoLocated.current = true;
locate(); locate();
}).catch(() => undefined); })
.catch(() => undefined);
}, [locate]); }, [locate]);
return ( return (
@@ -99,17 +104,27 @@ export function CurrentLocationControl({ stations }: { stations: LocatedSynopSta
{showPrompt && ( {showPrompt && (
<div className="glass-subtle rounded-card p-3.5"> <div className="glass-subtle rounded-card p-3.5">
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<div className="rounded-control bg-accent/10 p-2 text-accent"><MapPinned className="size-4" /></div> <div className="rounded-control bg-accent/10 p-2 text-accent">
<MapPinned className="size-4" />
</div>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="text-sm font-semibold">{t("location.gpsPromptTitle")}</p> <p className="text-sm font-semibold">{t("location.gpsPromptTitle")}</p>
<p className="mt-1 text-xs leading-5 text-muted">{t("location.gpsPromptDescription")}</p> <p className="mt-1 text-xs leading-5 text-muted">{t("location.gpsPromptDescription")}</p>
{!isSecureContext && <p className="mt-2 flex items-start gap-1.5 text-xs leading-5 text-warning"><ShieldAlert className="mt-0.5 size-3.5 shrink-0" />{t("location.gpsSecureContext")}</p>} {!isSecureContext && (
<p className="mt-2 flex items-start gap-1.5 text-xs leading-5 text-warning">
<ShieldAlert className="mt-0.5 size-3.5 shrink-0" />
{t("location.gpsSecureContext")}
</p>
)}
<div className="mt-3 flex flex-wrap gap-2"> <div className="mt-3 flex flex-wrap gap-2">
<Button type="button" onClick={locate} disabled={isLocating}> <Button type="button" onClick={locate} disabled={isLocating}>
{isLocating ? <LoaderCircle className="size-4 animate-spin" /> : <LocateFixed className="size-4" />} {isLocating ? <LoaderCircle className="size-4 animate-spin" /> : <LocateFixed className="size-4" />}
{isLocating ? t("location.gpsLocating") : t("location.gpsAllow")} {isLocating ? t("location.gpsLocating") : t("location.gpsAllow")}
</Button> </Button>
<Button type="button" variant="ghost" onClick={dismissPrompt}><X className="size-4" />{t("location.gpsNotNow")}</Button> <Button type="button" variant="ghost" onClick={dismissPrompt}>
<X className="size-4" />
{t("location.gpsNotNow")}
</Button>
</div> </div>
</div> </div>
</div> </div>
@@ -121,11 +136,23 @@ export function CurrentLocationControl({ stations }: { stations: LocatedSynopSta
{isLocating ? <LoaderCircle className="size-4 animate-spin" /> : <LocateFixed className="size-4" />} {isLocating ? <LoaderCircle className="size-4 animate-spin" /> : <LocateFixed className="size-4" />}
{isLocating ? t("location.gpsLocating") : t("location.gpsUse")} {isLocating ? t("location.gpsLocating") : t("location.gpsUse")}
</Button> </Button>
{message && <p aria-live="polite" className="max-w-xl text-xs leading-5 text-muted">{message}</p>} {message && (
<p aria-live="polite" className="max-w-xl text-xs leading-5 text-muted">
{message}
</p>
)}
</div> </div>
)} )}
<p className="text-[0.68rem] text-muted"> <p className="text-[0.68rem] text-muted">
{t("location.gpsAttribution")} <a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noreferrer" className="inline-flex items-center gap-1 underline decoration-muted/60 underline-offset-2 transition hover:text-accent">OpenStreetMap <ExternalLink className="size-3" /></a> {t("location.gpsAttribution")}{" "}
<a
href="https://www.openstreetmap.org/copyright"
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 underline decoration-muted/60 underline-offset-2 transition hover:text-accent"
>
OpenStreetMap <ExternalLink className="size-3" />
</a>
</p> </p>
</div> </div>
); );

View File

@@ -14,8 +14,15 @@ export function FavoritesSection({ stations }: { stations: SynopStation[] }) {
return ( return (
<section className="space-y-3"> <section className="space-y-3">
<div className="flex items-center gap-2 text-sm font-semibold"><Heart className="size-4 fill-rose-500 text-rose-500" />{t("favorites.title")}</div> <div className="flex items-center gap-2 text-sm font-semibold">
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">{favorites.map((station, index) => <StationCard key={station.id} station={station} index={index} />)}</div> <Heart className="size-4 fill-rose-500 text-rose-500" />
{t("favorites.title")}
</div>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
{favorites.map((station, index) => (
<StationCard key={station.id} station={station} index={index} />
))}
</div>
</section> </section>
); );
} }

View File

@@ -19,7 +19,10 @@ export function FeaturedStationsSection({ stations }: { stations: SynopStation[]
return ( return (
<section className="space-y-3"> <section className="space-y-3">
<div> <div>
<p className="section-kicker flex items-center gap-2"><MapPinned className="size-4" />{t("featured.label")}</p> <p className="section-kicker flex items-center gap-2">
<MapPinned className="size-4" />
{t("featured.label")}
</p>
<h2 className="mt-2 text-xl font-semibold tracking-tight">{t("featured.title")}</h2> <h2 className="mt-2 text-xl font-semibold tracking-tight">{t("featured.title")}</h2>
</div> </div>
<div className="grid grid-cols-2 gap-2.5 sm:grid-cols-3 lg:grid-cols-6"> <div className="grid grid-cols-2 gap-2.5 sm:grid-cols-3 lg:grid-cols-6">
@@ -30,11 +33,16 @@ export function FeaturedStationsSection({ stations }: { stations: SynopStation[]
type="button" type="button"
key={station.id} key={station.id}
onClick={() => selectStation(station.id)} onClick={() => selectStation(station.id)}
className={cn("glass-subtle flex items-center justify-between gap-2 rounded-card p-3 text-left transition hover:-translate-y-0.5 hover:bg-surface-raised/80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent", active && "border-accent/60 bg-surface-raised/90")} className={cn(
"glass-subtle flex items-center justify-between gap-2 rounded-card p-3 text-left transition hover:-translate-y-0.5 hover:bg-surface-raised/80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent",
active && "border-accent/60 bg-surface-raised/90",
)}
> >
<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, temperatureUnit)}</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>

View File

@@ -10,7 +10,13 @@ import { useWeatherStore } from "@/lib/store";
import type { MeteoStationPosition, SynopStation } from "@/types/imgw"; import type { MeteoStationPosition, SynopStation } from "@/types/imgw";
import type { SelectedLocation } from "@/types/location"; import type { SelectedLocation } from "@/types/location";
export function LocationSearch({ stations, positions }: { stations: SynopStation[]; positions: MeteoStationPosition[] }) { export function LocationSearch({
stations,
positions,
}: {
stations: SynopStation[];
positions: MeteoStationPosition[];
}) {
const { language, t } = useI18n(); const { language, t } = useI18n();
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [isFocused, setIsFocused] = useState(false); const [isFocused, setIsFocused] = useState(false);
@@ -18,12 +24,17 @@ export function LocationSearch({ stations, positions }: { stations: SynopStation
const selectLocation = useWeatherStore((state) => state.selectLocation); const selectLocation = useWeatherStore((state) => state.selectLocation);
const { data: locations, isFetching, isError } = useLocationSearch(query, language); const { data: locations, isFetching, isError } = useLocationSearch(query, language);
const locatedStations = useMemo(() => locateSynopStations(stations, positions), [positions, stations]); const locatedStations = useMemo(() => locateSynopStations(stations, positions), [positions, stations]);
const suggestions = useMemo(() => (locations ?? []).map((location) => ({ const suggestions = useMemo(
() =>
(locations ?? []).map((location) => ({
location, location,
selected: createSelectedLocation(location, locatedStations), selected: createSelectedLocation(location, locatedStations),
})), [locatedStations, locations]); })),
[locatedStations, locations],
);
const showSuggestions = isFocused && query.trim().length >= 2; const showSuggestions = isFocused && query.trim().length >= 2;
const isPreparingStations = positions.length === 0 && suggestions.some(({ selected }) => selected.region === "PL" && !selected.stationName); const isPreparingStations =
positions.length === 0 && suggestions.some(({ selected }) => selected.region === "PL" && !selected.stationName);
function chooseLocation(location: SelectedLocation) { function chooseLocation(location: SelectedLocation) {
selectLocation(location); selectLocation(location);
@@ -48,7 +59,11 @@ export function LocationSearch({ stations, positions }: { stations: SynopStation
<div className="relative z-20"> <div className="relative z-20">
<label className="relative block"> <label className="relative block">
<span className="sr-only">{t("location.searchLabel")}</span> <span className="sr-only">{t("location.searchLabel")}</span>
{isFetching || isPreparingStations ? <LoaderCircle className="pointer-events-none absolute left-3.5 top-1/2 size-4 -translate-y-1/2 animate-spin text-accent" /> : <Search className="pointer-events-none absolute left-3.5 top-1/2 size-4 -translate-y-1/2 text-muted" />} {isFetching || isPreparingStations ? (
<LoaderCircle className="pointer-events-none absolute left-3.5 top-1/2 size-4 -translate-y-1/2 animate-spin text-accent" />
) : (
<Search className="pointer-events-none absolute left-3.5 top-1/2 size-4 -translate-y-1/2 text-muted" />
)}
<input <input
value={query} value={query}
onChange={(event) => setQuery(event.target.value)} onChange={(event) => setQuery(event.target.value)}
@@ -59,13 +74,26 @@ export function LocationSearch({ stations, positions }: { stations: SynopStation
autoComplete="off" autoComplete="off"
className="w-full rounded-card border border-border/70 bg-surface-raised/80 py-3.5 pl-10 pr-10 text-base placeholder:text-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent sm:text-sm" className="w-full rounded-card border border-border/70 bg-surface-raised/80 py-3.5 pl-10 pr-10 text-base placeholder:text-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent sm:text-sm"
/> />
{query && <button type="button" aria-label={t("location.clear")} onClick={() => setQuery("")} className="absolute right-3 top-1/2 -translate-y-1/2 rounded-control p-1 text-muted transition hover:bg-surface-muted/70 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"><X className="size-4" /></button>} {query && (
<button
type="button"
aria-label={t("location.clear")}
onClick={() => setQuery("")}
className="absolute right-3 top-1/2 -translate-y-1/2 rounded-control p-1 text-muted transition hover:bg-surface-muted/70 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"
>
<X className="size-4" />
</button>
)}
</label> </label>
{showSuggestions && ( {showSuggestions && (
<div className="glass absolute inset-x-0 top-[calc(100%+0.5rem)] overflow-hidden rounded-panel p-2 shadow-card"> <div className="glass absolute inset-x-0 top-[calc(100%+0.5rem)] overflow-hidden rounded-panel p-2 shadow-card">
{isPreparingStations ? <p className="px-3 py-4 text-sm text-muted">{t("location.preparing")}</p> : {isPreparingStations ? (
isError ? <p className="px-3 py-4 text-sm text-muted">{t("location.error")}</p> : <p className="px-3 py-4 text-sm text-muted">{t("location.preparing")}</p>
!isFetching && !suggestions.length ? <p className="px-3 py-4 text-sm text-muted">{t("location.empty")}</p> : ) : isError ? (
<p className="px-3 py-4 text-sm text-muted">{t("location.error")}</p>
) : !isFetching && !suggestions.length ? (
<p className="px-3 py-4 text-sm text-muted">{t("location.empty")}</p>
) : (
suggestions.map(({ location, selected }) => ( suggestions.map(({ location, selected }) => (
<button <button
type="button" type="button"
@@ -75,15 +103,28 @@ export function LocationSearch({ stations, positions }: { stations: SynopStation
> >
<span> <span>
<span className="block text-sm font-semibold">{location.name}</span> <span className="block text-sm font-semibold">{location.name}</span>
<span className="mt-0.5 block text-xs text-muted">{[location.admin2, location.admin1, location.country].filter(Boolean).join(" · ")}</span> <span className="mt-0.5 block text-xs text-muted">
{[location.admin2, location.admin1, location.country].filter(Boolean).join(" · ")}
</span>
</span> </span>
{selected.stationName && selected.distanceKm !== null ? ( {selected.stationName && selected.distanceKm !== null ? (
<span className="shrink-0 text-right text-[0.68rem] leading-5 text-muted">{t("location.nearest")}<br /><strong className="font-semibold text-foreground">{selected.stationName} · {selected.distanceKm} km</strong></span> <span className="shrink-0 text-right text-[0.68rem] leading-5 text-muted">
{t("location.nearest")}
<br />
<strong className="font-semibold text-foreground">
{selected.stationName} · {selected.distanceKm} km
</strong>
</span>
) : ( ) : (
<span className="shrink-0 text-right text-[0.68rem] leading-5 text-muted">{t("location.modelSource")}<br /><strong className="font-semibold text-foreground">Open-Meteo</strong></span> <span className="shrink-0 text-right text-[0.68rem] leading-5 text-muted">
{t("location.modelSource")}
<br />
<strong className="font-semibold text-foreground">Open-Meteo</strong>
</span>
)} )}
</button> </button>
))} ))
)}
</div> </div>
)} )}
</div> </div>
@@ -91,12 +132,24 @@ export function LocationSearch({ stations, positions }: { stations: SynopStation
{selectedLocation && ( {selectedLocation && (
<p className="mt-3 px-1 text-xs text-muted"> <p className="mt-3 px-1 text-xs text-muted">
{selectedLocation.stationName && selectedLocation.distanceKm !== null {selectedLocation.stationName && selectedLocation.distanceKm !== null
? t("location.currentSource", { location: selectedLocation.name, station: selectedLocation.stationName, distance: selectedLocation.distanceKm }) ? t("location.currentSource", {
location: selectedLocation.name,
station: selectedLocation.stationName,
distance: selectedLocation.distanceKm,
})
: t("location.currentSourceGlobal", { location: selectedLocation.name })} : t("location.currentSourceGlobal", { location: selectedLocation.name })}
</p> </p>
)} )}
<p className="mt-3 px-1 text-[0.68rem] text-muted"> <p className="mt-3 px-1 text-[0.68rem] text-muted">
{t("location.attribution")} <a href="https://open-meteo.com/en/docs/geocoding-api" target="_blank" rel="noreferrer" className="underline decoration-muted/60 underline-offset-2 transition hover:text-accent">Open-Meteo / GeoNames</a> {t("location.attribution")}{" "}
<a
href="https://open-meteo.com/en/docs/geocoding-api"
target="_blank"
rel="noreferrer"
className="underline decoration-muted/60 underline-offset-2 transition hover:text-accent"
>
Open-Meteo / GeoNames
</a>
</p> </p>
</div> </div>
</section> </section>

View File

@@ -4,9 +4,25 @@ import { motion } from "framer-motion";
import type { LucideIcon } from "lucide-react"; import type { LucideIcon } from "lucide-react";
import { Card } from "@/components/ui/card"; import { Card } from "@/components/ui/card";
export function MetricCard({ icon: Icon, label, value, detail, index = 0 }: { icon: LucideIcon; label: string; value: string; detail?: string; index?: number }) { export function MetricCard({
icon: Icon,
label,
value,
detail,
index = 0,
}: {
icon: LucideIcon;
label: string;
value: string;
detail?: string;
index?: number;
}) {
return ( return (
<motion.div initial={{ opacity: 0, y: 12 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: index * 0.04, duration: 0.35 }}> <motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.04, duration: 0.35 }}
>
<Card className="h-full p-4 sm:p-5"> <Card className="h-full p-4 sm:p-5">
<div className="flex items-center gap-2 text-xs font-medium uppercase tracking-[0.14em] text-muted"> <div className="flex items-center gap-2 text-xs font-medium uppercase tracking-[0.14em] text-muted">
<Icon className="size-4 text-accent" /> <Icon className="size-4 text-accent" />

View File

@@ -4,7 +4,13 @@ import Link from "next/link";
import { motion } from "framer-motion"; import { motion } from "framer-motion";
import { Droplets, Gauge, Heart, Wind } from "lucide-react"; import { Droplets, Gauge, Heart, Wind } from "lucide-react";
import { useWeatherStore } from "@/lib/store"; import { useWeatherStore } from "@/lib/store";
import { formatHumidity, formatPressure, formatTemperature, formatWind, getWeatherMoodFromData } from "@/lib/weather-utils"; import {
formatHumidity,
formatPressure,
formatTemperature,
formatWind,
getWeatherMoodFromData,
} from "@/lib/weather-utils";
import type { SynopStation } from "@/types/imgw"; import type { SynopStation } from "@/types/imgw";
import { Card } from "@/components/ui/card"; import { Card } from "@/components/ui/card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -23,24 +29,56 @@ export function StationCard({ station, index = 0 }: { station: SynopStation; ind
const mood = getWeatherMoodFromData(station); const mood = getWeatherMoodFromData(station);
const compactWind = station.windSpeed === null ? "—" : formatWind(station.windSpeed, null, language, windSpeedUnit); const compactWind = station.windSpeed === null ? "—" : formatWind(station.windSpeed, null, language, windSpeedUnit);
return ( return (
<motion.div initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: Math.min(index * 0.025, 0.3), duration: 0.3 }}> <motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: Math.min(index * 0.025, 0.3), duration: 0.3 }}
>
<Card className="group relative h-full overflow-hidden p-4 transition duration-300 hover:-translate-y-1 hover:bg-surface-raised/90"> <Card className="group relative h-full overflow-hidden p-4 transition duration-300 hover:-translate-y-1 hover:bg-surface-raised/90">
<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, temperatureUnit)}</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" />
<Button variant="ghost" className="size-8 p-0" aria-label={favorite ? t("favorites.removeStation", { name: station.name }) : t("favorites.addStation", { name: station.name })} onClick={() => toggleFavorite(station.id)}> <Button
variant="ghost"
className="size-8 p-0"
aria-label={
favorite
? t("favorites.removeStation", { name: station.name })
: t("favorites.addStation", { name: station.name })
}
onClick={() => toggleFavorite(station.id)}
>
<Heart className={cn("size-4", favorite && "fill-rose-500 text-rose-500")} /> <Heart className={cn("size-4", favorite && "fill-rose-500 text-rose-500")} />
</Button> </Button>
</div> </div>
</div> </div>
<Link href={`/station/${station.id}`} onClick={() => selectStation(station.id)} className="mt-4 grid grid-cols-3 gap-2 rounded-lg text-[0.68rem] text-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"> <Link
<span className="flex items-center gap-1"><Droplets className="size-3" />{formatHumidity(station.humidity, language)}</span> href={`/station/${station.id}`}
<span className="flex items-center gap-1"><Wind className="size-3" />{compactWind}</span> onClick={() => selectStation(station.id)}
<span className="flex items-center gap-1"><Gauge className="size-3" />{station.pressure === null ? "—" : formatPressure(station.pressure, language).split(" ")[0]}</span> className="mt-4 grid grid-cols-3 gap-2 rounded-lg text-[0.68rem] text-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"
>
<span className="flex items-center gap-1">
<Droplets className="size-3" />
{formatHumidity(station.humidity, language)}
</span>
<span className="flex items-center gap-1">
<Wind className="size-3" />
{compactWind}
</span>
<span className="flex items-center gap-1">
<Gauge className="size-3" />
{station.pressure === null ? "—" : formatPressure(station.pressure, language).split(" ")[0]}
</span>
</Link> </Link>
</Card> </Card>
</motion.div> </motion.div>

View File

@@ -27,7 +27,13 @@ export function StationDetailPage({ id }: { id: string }) {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex flex-wrap items-center justify-between gap-3"> <div className="flex flex-wrap items-center justify-between gap-3">
<Link href="/" className="inline-flex items-center gap-2 rounded-control px-1 py-1 text-sm font-medium text-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"><ArrowLeft className="size-4" />{t("station.all")}</Link> <Link
href="/"
className="inline-flex items-center gap-2 rounded-control px-1 py-1 text-sm font-medium text-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"
>
<ArrowLeft className="size-4" />
{t("station.all")}
</Link>
<Button variant="glass" onClick={() => toggleFavorite(station.id)}> <Button variant="glass" onClick={() => toggleFavorite(station.id)}>
<Heart className={cn("size-4", favorite && "fill-rose-500 text-rose-500")} /> <Heart className={cn("size-4", favorite && "fill-rose-500 text-rose-500")} />
{favorite ? t("favorites.remove") : t("favorites.add")} {favorite ? t("favorites.remove") : t("favorites.add")}
@@ -43,12 +49,21 @@ export function StationDetailPage({ id }: { id: string }) {
<div className="grid gap-4 lg:grid-cols-[1.3fr_0.7fr]"> <div className="grid gap-4 lg:grid-cols-[1.3fr_0.7fr]">
<SnapshotChart station={station} /> <SnapshotChart station={station} />
<Card className="p-5"> <Card className="p-5">
<div className="section-kicker flex items-center gap-2"><ShieldCheck className="size-5" /><p>{t("station.quality")}</p></div> <div className="section-kicker flex items-center gap-2">
<ShieldCheck className="size-5" />
<p>{t("station.quality")}</p>
</div>
<h2 className="mt-4 text-xl font-semibold tracking-tight">{t("station.lastMeasurementImgw")}</h2> <h2 className="mt-4 text-xl font-semibold tracking-tight">{t("station.lastMeasurementImgw")}</h2>
<p className="mt-2 text-sm leading-6 text-muted">{t("station.qualityDescription")}</p> <p className="mt-2 text-sm leading-6 text-muted">{t("station.qualityDescription")}</p>
<dl className="mt-6 space-y-3 text-sm"> <dl className="mt-6 space-y-3 text-sm">
<div><dt className="text-muted">{t("station.lastMeasurement")}</dt><dd className="mt-0.5 font-medium">{formatDateTime(station.measuredAt, language)}</dd></div> <div>
<div><dt className="text-muted">{t("station.source")}</dt><dd className="mt-0.5 font-medium">{t("station.publicApi")}</dd></div> <dt className="text-muted">{t("station.lastMeasurement")}</dt>
<dd className="mt-0.5 font-medium">{formatDateTime(station.measuredAt, language)}</dd>
</div>
<div>
<dt className="text-muted">{t("station.source")}</dt>
<dd className="mt-0.5 font-medium">{t("station.publicApi")}</dd>
</div>
</dl> </dl>
</Card> </Card>
</div> </div>

View File

@@ -43,7 +43,13 @@ const lightningBolts = [
}, },
]; ];
export function WeatherEffects({ precipitation10m, thunderstorm = false }: { precipitation10m?: number | null; thunderstorm?: boolean }) { export function WeatherEffects({
precipitation10m,
thunderstorm = false,
}: {
precipitation10m?: number | null;
thunderstorm?: boolean;
}) {
const reduceMotion = useReducedMotion(); const reduceMotion = useReducedMotion();
const isRaining = (precipitation10m ?? 0) > 0; const isRaining = (precipitation10m ?? 0) > 0;
@@ -55,7 +61,11 @@ export function WeatherEffects({ precipitation10m, thunderstorm = false }: { pre
<motion.span <motion.span
key={`rain-${index}`} key={`rain-${index}`}
initial={{ y: "-18vh", opacity: 0 }} initial={{ y: "-18vh", opacity: 0 }}
animate={reduceMotion ? { y: `${(index % 8) * 14}vh`, opacity: 0.28 } : { y: ["-18vh", "118vh"], opacity: [0, drop.opacity, 0] }} animate={
reduceMotion
? { y: `${(index % 8) * 14}vh`, opacity: 0.28 }
: { y: ["-18vh", "118vh"], opacity: [0, drop.opacity, 0] }
}
transition={{ duration: drop.duration, delay: drop.delay, repeat: Infinity, ease: "linear" }} transition={{ duration: drop.duration, delay: drop.delay, repeat: Infinity, ease: "linear" }}
className={`absolute -top-12 w-0.5 rotate-[10deg] rounded-full bg-foreground/70 shadow-[0_0_8px_hsl(var(--foreground)/0.18)] ${drop.lengthClass}`} className={`absolute -top-12 w-0.5 rotate-[10deg] rounded-full bg-foreground/70 shadow-[0_0_8px_hsl(var(--foreground)/0.18)] ${drop.lengthClass}`}
style={{ left: drop.left }} style={{ left: drop.left }}
@@ -65,7 +75,11 @@ export function WeatherEffects({ precipitation10m, thunderstorm = false }: { pre
<motion.span <motion.span
key={`mist-${index}`} key={`mist-${index}`}
initial={{ y: "-10vh", opacity: 0 }} initial={{ y: "-10vh", opacity: 0 }}
animate={reduceMotion ? { y: `${(index % 6) * 18}vh`, opacity: 0.16 } : { y: ["-10vh", "112vh"], opacity: [0, 0.26, 0] }} animate={
reduceMotion
? { y: `${(index % 6) * 18}vh`, opacity: 0.16 }
: { y: ["-10vh", "112vh"], opacity: [0, 0.26, 0] }
}
transition={{ duration: drop.duration, delay: drop.delay, repeat: Infinity, ease: "linear" }} transition={{ duration: drop.duration, delay: drop.delay, repeat: Infinity, ease: "linear" }}
className="absolute -top-8 h-7 w-px rotate-[10deg] rounded-full bg-accent/55" className="absolute -top-8 h-7 w-px rotate-[10deg] rounded-full bg-accent/55"
style={{ left: drop.left }} style={{ left: drop.left }}
@@ -80,7 +94,11 @@ export function WeatherEffects({ precipitation10m, thunderstorm = false }: { pre
key={`lightning-${index}`} key={`lightning-${index}`}
viewBox="0 0 96 176" viewBox="0 0 96 176"
preserveAspectRatio="xMidYMid meet" preserveAspectRatio="xMidYMid meet"
animate={reduceMotion ? { opacity: bolt.opacity * 0.22 } : { opacity: [0, 0, bolt.opacity, bolt.opacity * 0.08, 0], scaleY: [0.98, 0.98, 1.02, 1, 0.99] }} animate={
reduceMotion
? { opacity: bolt.opacity * 0.22 }
: { opacity: [0, 0, bolt.opacity, bolt.opacity * 0.08, 0], scaleY: [0.98, 0.98, 1.02, 1, 0.99] }
}
transition={{ duration: 3.6, repeat: Infinity, repeatDelay: 2.4, delay: bolt.delay }} transition={{ duration: 3.6, repeat: Infinity, repeatDelay: 2.4, delay: bolt.delay }}
className={`absolute text-foreground dark:text-white ${bolt.className}`} className={`absolute text-foreground dark:text-white ${bolt.className}`}
> >

View File

@@ -31,7 +31,21 @@ function readDevWeatherEffectOverride(): DevWeatherEffectOverride | null {
return null; return null;
} }
export function WeatherHero({ station, currentWeather, currentWeatherLoading = false, currentForecastWeatherCode, locationName, distanceKm }: { station: SynopStation; currentWeather?: ImgwCurrentWeather | null; currentWeatherLoading?: boolean; currentForecastWeatherCode?: number | null; locationName?: string; distanceKm?: number | null }) { export function WeatherHero({
station,
currentWeather,
currentWeatherLoading = false,
currentForecastWeatherCode,
locationName,
distanceKm,
}: {
station: SynopStation;
currentWeather?: ImgwCurrentWeather | null;
currentWeatherLoading?: boolean;
currentForecastWeatherCode?: number | null;
locationName?: string;
distanceKm?: number | null;
}) {
const { language, t } = useI18n(); const { language, t } = useI18n();
const temperatureUnit = useWeatherStore((state) => state.temperatureUnit); const temperatureUnit = useWeatherStore((state) => state.temperatureUnit);
const windSpeedUnit = useWeatherStore((state) => state.windSpeedUnit); const windSpeedUnit = useWeatherStore((state) => state.windSpeedUnit);
@@ -40,8 +54,15 @@ export function WeatherHero({ station, currentWeather, currentWeatherLoading = f
const hasGlobalModel = currentWeather?.coverage === "global-model"; const hasGlobalModel = currentWeather?.coverage === "global-model";
const hasFullHybridAnalysis = currentWeather?.coverage === "full" || currentWeather?.coverage === "hourly"; const hasFullHybridAnalysis = currentWeather?.coverage === "full" || currentWeather?.coverage === "hourly";
const hasPartialHybridAnalysis = currentWeather?.coverage === "precipitation-only"; const hasPartialHybridAnalysis = currentWeather?.coverage === "precipitation-only";
const hasDistantFallback = !hasGlobalModel && !hasFullHybridAnalysis && !currentWeatherLoading && distanceKm !== undefined && distanceKm !== null && distanceKm >= 30; const hasDistantFallback =
const displayedStation = currentWeather ? { !hasGlobalModel &&
!hasFullHybridAnalysis &&
!currentWeatherLoading &&
distanceKm !== undefined &&
distanceKm !== null &&
distanceKm >= 30;
const displayedStation = currentWeather
? {
...station, ...station,
measuredAt: hasFullHybridAnalysis || hasGlobalModel ? currentWeather.measuredAt : station.measuredAt, measuredAt: hasFullHybridAnalysis || hasGlobalModel ? currentWeather.measuredAt : station.measuredAt,
temperature: currentWeather.temperature ?? station.temperature, temperature: currentWeather.temperature ?? station.temperature,
@@ -50,21 +71,38 @@ export function WeatherHero({ station, currentWeather, currentWeatherLoading = f
humidity: currentWeather.humidity ?? station.humidity, humidity: currentWeather.humidity ?? station.humidity,
pressure: currentWeather.pressure ?? station.pressure, pressure: currentWeather.pressure ?? station.pressure,
rainfall: currentWeather.precipitation10m ?? station.rainfall, rainfall: currentWeather.precipitation10m ?? station.rainfall,
} : station; }
: station;
const mood = getWeatherMoodFromData(displayedStation); const mood = getWeatherMoodFromData(displayedStation);
const feelsLike = currentWeather?.feelsLike ?? calculateFeelsLike(displayedStation.temperature, displayedStation.humidity, displayedStation.windSpeed); const feelsLike =
currentWeather?.feelsLike ??
calculateFeelsLike(displayedStation.temperature, displayedStation.humidity, displayedStation.windSpeed);
const metrics = [ const metrics = [
{ icon: Droplets, label: t("weather.humidity"), value: formatHumidity(displayedStation.humidity, language) }, { icon: Droplets, label: t("weather.humidity"), value: formatHumidity(displayedStation.humidity, language) },
{ icon: Wind, label: t("weather.wind"), value: formatWind(displayedStation.windSpeed, null, language, windSpeedUnit) }, {
{ icon: Umbrella, label: hasGlobalModel ? t("weather.currentPrecipitation") : currentWeather?.precipitation10m !== null && currentWeather?.precipitation10m !== undefined ? t("weather.rainfall10m") : t("weather.rainfallTotal"), value: formatRainfall(displayedStation.rainfall, language) }, icon: Wind,
label: t("weather.wind"),
value: formatWind(displayedStation.windSpeed, null, language, windSpeedUnit),
},
{
icon: Umbrella,
label: hasGlobalModel
? t("weather.currentPrecipitation")
: currentWeather?.precipitation10m !== null && currentWeather?.precipitation10m !== undefined
? t("weather.rainfall10m")
: t("weather.rainfallTotal"),
value: formatRainfall(displayedStation.rainfall, language),
},
{ icon: Gauge, label: t("weather.pressure"), value: formatPressure(displayedStation.pressure, language) }, { icon: Gauge, label: t("weather.pressure"), value: formatPressure(displayedStation.pressure, language) },
]; ];
const effectPrecipitation10m = devEffectOverride === "rain" || devEffectOverride === "thunderstorm" const effectPrecipitation10m =
devEffectOverride === "rain" || devEffectOverride === "thunderstorm"
? 0.1 ? 0.1
: devEffectOverride === "none" : devEffectOverride === "none"
? 0 ? 0
: currentWeather?.precipitation10m; : currentWeather?.precipitation10m;
const effectThunderstorm = devEffectOverride === "thunderstorm" const effectThunderstorm =
devEffectOverride === "thunderstorm"
? true ? true
: devEffectOverride === "rain" || devEffectOverride === "none" : devEffectOverride === "rain" || devEffectOverride === "none"
? false ? false
@@ -97,10 +135,14 @@ export function WeatherHero({ station, currentWeather, currentWeatherLoading = f
<div className="relative z-10"> <div className="relative z-10">
<div> <div>
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<span className="flex items-center gap-1.5 text-sm font-medium text-muted"><MapPin className="size-4" />{displayedLocationName}</span> <span className="flex items-center gap-1.5 text-sm font-medium text-muted">
<MapPin className="size-4" />
{displayedLocationName}
</span>
</div> </div>
<div className="mt-2 space-y-1 text-xs text-muted"> <div className="mt-2 space-y-1 text-xs text-muted">
<p>{currentWeatherLoading <p>
{currentWeatherLoading
? t("location.heroHybridLoading", { station: station.name }) ? t("location.heroHybridLoading", { station: station.name })
: hasGlobalModel : hasGlobalModel
? t("location.heroGlobalModelSource", { location: displayedLocationName }) ? t("location.heroGlobalModelSource", { location: displayedLocationName })
@@ -109,10 +151,21 @@ export function WeatherHero({ station, currentWeather, currentWeatherLoading = f
: hasPartialHybridAnalysis : hasPartialHybridAnalysis
? t("location.heroHybridPartial", { station: station.name, distance: distanceKm ?? 0 }) ? t("location.heroHybridPartial", { station: station.name, distance: distanceKm ?? 0 })
: locationName : locationName
? t("location.heroStationFallbackWithDistance", { station: station.name, distance: distanceKm ?? 0 }) ? t("location.heroStationFallbackWithDistance", {
: t("location.heroStationFallback", { station: station.name })}</p> station: station.name,
{hasFullHybridAnalysis && locationName && <p>{t("location.heroNearestStation", { station: station.name, distance: distanceKm ?? 0 })}</p>} distance: distanceKm ?? 0,
{hasDistantFallback && <p className="flex items-start gap-1.5 text-warning"><AlertTriangle className="mt-0.5 size-3.5 shrink-0" />{t("location.heroDistantFallback")}</p>} })
: t("location.heroStationFallback", { station: station.name })}
</p>
{hasFullHybridAnalysis && locationName && (
<p>{t("location.heroNearestStation", { station: station.name, distance: distanceKm ?? 0 })}</p>
)}
{hasDistantFallback && (
<p className="flex items-start gap-1.5 text-warning">
<AlertTriangle className="mt-0.5 size-3.5 shrink-0" />
{t("location.heroDistantFallback")}
</p>
)}
</div> </div>
</div> </div>
<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">
@@ -121,14 +174,25 @@ export function WeatherHero({ station, currentWeather, currentWeatherLoading = f
{formatTemperature(displayedStation.temperature, language, temperatureUnit)} {formatTemperature(displayedStation.temperature, language, temperatureUnit)}
</div> </div>
<p className="mt-5 text-xl font-medium tracking-tight sm:text-2xl">{weatherDescription}</p> <p className="mt-5 text-xl font-medium tracking-tight sm:text-2xl">{weatherDescription}</p>
<p className="mt-1 text-sm text-muted">{t("weather.feelsLike")} {formatTemperature(feelsLike, language, temperatureUnit)} · {hasGlobalModel ? t("weather.modelUpdate") : t("weather.measurement")} {formatDateTime(displayedStation.measuredAt, language)}</p> <p className="mt-1 text-sm text-muted">
{t("weather.feelsLike")} {formatTemperature(feelsLike, language, temperatureUnit)} ·{" "}
{hasGlobalModel ? t("weather.modelUpdate") : 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>
<div className="mt-8 grid grid-cols-2 gap-2.5 sm:mt-10 lg:grid-cols-4"> <div className="mt-8 grid grid-cols-2 gap-2.5 sm:mt-10 lg:grid-cols-4">
{metrics.map(({ icon: Icon, label, value }) => ( {metrics.map(({ icon: Icon, label, value }) => (
<div key={label} className="rounded-card border border-border/60 bg-surface-muted p-3.5"> <div key={label} className="rounded-card border border-border/60 bg-surface-muted p-3.5">
<div className="flex items-center gap-2 text-xs text-muted"><Icon className="size-3.5 text-accent" />{label}</div> <div className="flex items-center gap-2 text-xs text-muted">
<Icon className="size-3.5 text-accent" />
{label}
</div>
<p className="mt-2 text-base font-semibold">{value}</p> <p className="mt-2 text-base font-semibold">{value}</p>
</div> </div>
))} ))}

View File

@@ -2,8 +2,23 @@ import { Cloud, CloudLightning, CloudRain, CloudSun, MoonStar, Snowflake, Thermo
import type { WeatherMood } from "@/types/imgw"; import type { WeatherMood } from "@/types/imgw";
import type { CurrentWeatherCondition } from "@/types/imgw-current"; import type { CurrentWeatherCondition } from "@/types/imgw-current";
export function WeatherIcon({ mood, condition, className = "" }: { mood: WeatherMood; condition?: CurrentWeatherCondition; className?: string }) { export function WeatherIcon({
const Icon = condition === "thunderstorm" ? CloudLightning : condition === "rain" ? CloudRain : condition === "snow" ? Snowflake : { mood,
condition,
className = "",
}: {
mood: WeatherMood;
condition?: CurrentWeatherCondition;
className?: string;
}) {
const Icon =
condition === "thunderstorm"
? CloudLightning
: condition === "rain"
? CloudRain
: condition === "snow"
? Snowflake
: {
warm: ThermometerSun, warm: ThermometerSun,
cloudy: Cloud, cloudy: Cloud,
wind: Wind, wind: Wind,

View File

@@ -5,7 +5,7 @@ Wszystkie zewnętrzne źródła danych są wywoływane przez route handlery Next
## Dane Pogodowe i Lokalizacje ## Dane Pogodowe i Lokalizacje
| Metoda | Endpoint | Przeznaczenie | | Metoda | Endpoint | Przeznaczenie |
| --- | --- | --- | | ------ | ------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `GET` | `/api/current-weather?latitude={lat}&longitude={lon}&region={PL\|GLOBAL}` | Zwraca znormalizowane bieżące warunki. Dla `PL` używa IMGW Hybrid, dla `GLOBAL` modelowych warunków Open-Meteo. | | `GET` | `/api/current-weather?latitude={lat}&longitude={lon}&region={PL\|GLOBAL}` | Zwraca znormalizowane bieżące warunki. Dla `PL` używa IMGW Hybrid, dla `GLOBAL` modelowych warunków Open-Meteo. |
| `GET` | `/api/forecast?latitude={lat}&longitude={lon}&region={PL\|GLOBAL}` | Zwraca 7-dniową prognozę modelową dla współrzędnych. Dla `PL` łączy IMGW ALARO z Open-Meteo, dla `GLOBAL` używa Open-Meteo. Niepoprawne współrzędne zwracają `400`, awaria źródła `502`. | | `GET` | `/api/forecast?latitude={lat}&longitude={lon}&region={PL\|GLOBAL}` | Zwraca 7-dniową prognozę modelową dla współrzędnych. Dla `PL` łączy IMGW ALARO z Open-Meteo, dla `GLOBAL` używa Open-Meteo. Niepoprawne współrzędne zwracają `400`, awaria źródła `502`. |
| `GET` | `/api/imgw-current?latitude={lat}&longitude={lon}` | Pobiera surową lokalną analizę IMGW Hybrid dla współrzędnych. Niepoprawne współrzędne zwracają `400`, awaria źródła `502`. | | `GET` | `/api/imgw-current?latitude={lat}&longitude={lon}` | Pobiera surową lokalną analizę IMGW Hybrid dla współrzędnych. Niepoprawne współrzędne zwracają `400`, awaria źródła `502`. |
@@ -18,7 +18,7 @@ Wszystkie zewnętrzne źródła danych są wywoływane przez route handlery Next
Endpointy powiadomień działają w runtime Node.js, bo korzystają z `web-push` i lokalnego magazynu SQLite dla subskrypcji. Endpointy powiadomień działają w runtime Node.js, bo korzystają z `web-push` i lokalnego magazynu SQLite dla subskrypcji.
| Metoda | Endpoint | Przeznaczenie | | Metoda | Endpoint | Przeznaczenie |
| --- | --- | --- | | -------- | ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `GET` | `/api/notifications/vapid-key` | Zwraca publiczny klucz VAPID oraz informację, czy Web Push jest skonfigurowany. | | `GET` | `/api/notifications/vapid-key` | Zwraca publiczny klucz VAPID oraz informację, czy Web Push jest skonfigurowany. |
| `POST` | `/api/notifications/subscriptions` | Zapisuje lub aktualizuje subskrypcję Web Push dla urządzenia. Przyjmuje region `PL`/`GLOBAL`, preferencje ostrzeżeń, briefu porannego, briefu wieczornego, lokalizację, język, województwo dla `PL` i opcjonalny powiat TERYT. | | `POST` | `/api/notifications/subscriptions` | Zapisuje lub aktualizuje subskrypcję Web Push dla urządzenia. Przyjmuje region `PL`/`GLOBAL`, preferencje ostrzeżeń, briefu porannego, briefu wieczornego, lokalizację, język, województwo dla `PL` i opcjonalny powiat TERYT. |
| `DELETE` | `/api/notifications/subscriptions` | Usuwa subskrypcję Web Push po jej endpointcie. | | `DELETE` | `/api/notifications/subscriptions` | Usuwa subskrypcję Web Push po jej endpointcie. |
@@ -47,7 +47,7 @@ Jeśli `NOTIFICATIONS_CRON_SECRET` nie jest ustawiony, endpointy harmonogramu s
## Cache i Odpowiedzi ## Cache i Odpowiedzi
| Endpoint | Cache | | Endpoint | Cache |
| --- | --- | | ------------------------ | ------------------------------------------------- |
| `/api/forecast` | `s-maxage=900`, `stale-while-revalidate=1800` | | `/api/forecast` | `s-maxage=900`, `stale-while-revalidate=1800` |
| `/api/current-weather` | `s-maxage=120`, `stale-while-revalidate=300` | | `/api/current-weather` | `s-maxage=120`, `stale-while-revalidate=300` |
| `/api/imgw-current` | `s-maxage=120`, `stale-while-revalidate=300` | | `/api/imgw-current` | `s-maxage=120`, `stale-while-revalidate=300` |
@@ -62,7 +62,7 @@ Route handlery zwracają kontrolowane odpowiedzi JSON. Błędy zewnętrznych us
Publiczne endpointy ostrzeżeń IMGW potrafią zwrócić HTTP `404` z treścią: Publiczne endpointy ostrzeżeń IMGW potrafią zwrócić HTTP `404` z treścią:
```json ```json
{"status":false,"message":"No products were found"} { "status": false, "message": "No products were found" }
``` ```
Dla `warningsmeteo` i `warningshydro` aplikacja traktuje ten wariant jako poprawną pustą listę ostrzeżeń, a nie awarię źródła danych. Dla `warningsmeteo` i `warningshydro` aplikacja traktuje ten wariant jako poprawną pustą listę ostrzeżeń, a nie awarię źródła danych.

View File

@@ -5,7 +5,7 @@
## IMGW ## IMGW
| Dane | Źródło | | Dane | Źródło |
| --- | --- | | ----------------------------- | ------------------------------------------------------ |
| Bieżąca analiza IMGW Hybrid | `https://meteo.imgw.pl/api/v1/forecast/fcapi` | | Bieżąca analiza IMGW Hybrid | `https://meteo.imgw.pl/api/v1/forecast/fcapi` |
| Prognoza godzinowa IMGW ALARO | `https://meteo.imgw.pl/api/v1/forecast/fcapi?m=alaro` | | Prognoza godzinowa IMGW ALARO | `https://meteo.imgw.pl/api/v1/forecast/fcapi?m=alaro` |
| Dane synoptyczne | `https://danepubliczne.imgw.pl/api/data/synop` | | Dane synoptyczne | `https://danepubliczne.imgw.pl/api/data/synop` |
@@ -31,7 +31,7 @@ Przed większym, publicznym lub komercyjnym wdrożeniem należy sprawdzić aktua
## Capabilities per Region ## Capabilities per Region
| Capability | Polska | Global | | Capability | Polska | Global |
| --- | --- | --- | | --------------------- | ------------------------------ | ---------------------------- |
| Bieżące warunki | IMGW Hybrid + fallback `synop` | Open-Meteo model | | Bieżące warunki | IMGW Hybrid + fallback `synop` | Open-Meteo model |
| Prognoza godzinowa | IMGW ALARO + Open-Meteo | Open-Meteo | | Prognoza godzinowa | IMGW ALARO + Open-Meteo | Open-Meteo |
| Prognoza dzienna | IMGW ALARO/Open-Meteo | Open-Meteo | | Prognoza dzienna | IMGW ALARO/Open-Meteo | Open-Meteo |
@@ -55,7 +55,7 @@ Przed wdrożeniem o większym ruchu należy sprawdzić aktualną politykę użyc
## Cache ## Cache
| Dane | Cache | | Dane | Cache |
| --- | --- | | ----------------------------------- | ------------------------------------------ |
| IMGW Hybrid | 120 sekund, `stale-while-revalidate=300` | | IMGW Hybrid | 120 sekund, `stale-while-revalidate=300` |
| Modelowe warunki bieżące Open-Meteo | 120 sekund, `stale-while-revalidate=300` | | Modelowe warunki bieżące Open-Meteo | 120 sekund, `stale-while-revalidate=300` |
| Prognoza modelowa | 900 sekund, `stale-while-revalidate=1800` | | Prognoza modelowa | 900 sekund, `stale-while-revalidate=1800` |

View File

@@ -77,9 +77,9 @@ Repozytorium używa Gitea Actions jako końcowej bramki jakości po pushu. Lokal
- dokumentacja, teksty i komentarze: przegląd diffu oraz opcjonalnie `git diff --check`, - dokumentacja, teksty i komentarze: przegląd diffu oraz opcjonalnie `git diff --check`,
- drobne zmiany wizualne: bez rutynowego builda; `npm run lint` tylko przy ryzyku błędu składni lub reguł lintingu, - drobne zmiany wizualne: bez rutynowego builda; `npm run lint` tylko przy ryzyku błędu składni lub reguł lintingu,
- zmiany w logice, hookach, typach, parserach i danych pogodowych: `npm run typecheck`, a dla TS/TSX także `npm run lint`, - zmiany w logice, hookach, typach, parserach i danych pogodowych: `npm run typecheck`, a dla TS/TSX także `npm run lint`,
- routing Next.js, config, zależności, `package-lock`, PWA/service worker, API/server code, build config i obsługa env: `npm run lint`, `npm run typecheck` oraz `npm run build`. - routing Next.js, config, zależności, `package-lock`, PWA/service worker, API/server code, build config i obsługa env: `npm run lint`, `npm run format:check`, `npm run typecheck` oraz `npm run build`.
Repozytorium nie ma obecnie skryptu testów ani formattera. Repozytorium nie ma obecnie skryptu testów. Formatowanie jest obsługiwane przez Prettier (`npm run format` i `npm run format:check`).
## Bezpieczeństwo Zależności ## Bezpieczeństwo Zależności

View File

@@ -92,7 +92,7 @@ IMGW Hybrid dostarcza m.in. opad 10-minutowy. Ten parametr jest prezentowany odd
Prognoza godzinowa i dzienna rozpoznaje: Prognoza godzinowa i dzienna rozpoznaje:
| Stan | Opis w interfejsie | | Stan | Opis w interfejsie |
| --- | --- | | -------------- | ---------------------- |
| `clear` | Bezchmurnie | | `clear` | Bezchmurnie |
| `partlyCloudy` | Częściowe zachmurzenie | | `partlyCloudy` | Częściowe zachmurzenie |
| `cloudy` | Pochmurno | | `cloudy` | Pochmurno |
@@ -115,7 +115,7 @@ Bieżąca analiza IMGW Hybrid rozpoznaje bezpośrednio opad deszczu, śnieg i bu
Pole `Cloud` z IMGW Hybrid jest używane jako opis nieba: Pole `Cloud` z IMGW Hybrid jest używane jako opis nieba:
| Cloud | Opis w hero | | Cloud | Opis w hero |
| --- | --- | | ------- | -------------------------------- |
| `>= 75` | Pochmurno | | `>= 75` | Pochmurno |
| `>= 25` | Częściowe zachmurzenie | | `>= 25` | Częściowe zachmurzenie |
| `< 25` | brak osobnego opisu zachmurzenia | | `< 25` | brak osobnego opisu zachmurzenia |
@@ -127,7 +127,7 @@ Jednostki temperatury i wiatru wybierane w `/settings` dotyczą prezentacji w UI
Hero aktualnej pogody używa uproszczonego moodu do wyboru ikony, tekstu i małego akcentu stanu. Hero aktualnej pogody używa uproszczonego moodu do wyboru ikony, tekstu i małego akcentu stanu.
| Mood | Reguła | | Mood | Reguła |
| --- | --- | | -------- | ------------------------------------ |
| `night` | godzina przed `06:00` lub od `21:00` | | `night` | godzina przed `06:00` lub od `21:00` |
| `wind` | wiatr od `8 m/s` | | `wind` | wiatr od `8 m/s` |
| `cold` | temperatura do `3°C` | | `cold` | temperatura do `3°C` |

View File

@@ -23,21 +23,27 @@ export const APP_SECTION_SETTINGS = [
defaultVisible: boolean; defaultVisible: boolean;
}>; }>;
export type AppSectionId = typeof APP_SECTION_SETTINGS[number]["id"]; export type AppSectionId = (typeof APP_SECTION_SETTINGS)[number]["id"];
export type AppSectionVisibility = Record<AppSectionId, boolean>; export type AppSectionVisibility = Record<AppSectionId, boolean>;
export const DEFAULT_APP_SECTION_VISIBILITY = APP_SECTION_SETTINGS.reduce((visibility, section) => ({ export const DEFAULT_APP_SECTION_VISIBILITY = APP_SECTION_SETTINGS.reduce(
(visibility, section) => ({
...visibility, ...visibility,
[section.id]: section.defaultVisible, [section.id]: section.defaultVisible,
}), {} as AppSectionVisibility); }),
{} as AppSectionVisibility,
);
export function normalizeAppSectionVisibility(value: unknown): AppSectionVisibility { export function normalizeAppSectionVisibility(value: unknown): AppSectionVisibility {
const stored = value && typeof value === "object" ? value as Partial<Record<AppSectionId, unknown>> : {}; const stored = value && typeof value === "object" ? (value as Partial<Record<AppSectionId, unknown>>) : {};
return APP_SECTION_SETTINGS.reduce((visibility, section) => ({ return APP_SECTION_SETTINGS.reduce(
(visibility, section) => ({
...visibility, ...visibility,
[section.id]: typeof stored[section.id] === "boolean" ? stored[section.id] : section.defaultVisible, [section.id]: typeof stored[section.id] === "boolean" ? stored[section.id] : section.defaultVisible,
}), {} as AppSectionVisibility); }),
{} as AppSectionVisibility,
);
} }
export function isAppNavItemVisible(href: string, visibility: AppSectionVisibility) { export function isAppNavItemVisible(href: string, visibility: AppSectionVisibility) {

View File

@@ -20,19 +20,25 @@ export const DASHBOARD_SECTION_SETTINGS = [
defaultVisible: boolean; defaultVisible: boolean;
}>; }>;
export type DashboardSectionId = typeof DASHBOARD_SECTION_SETTINGS[number]["id"]; export type DashboardSectionId = (typeof DASHBOARD_SECTION_SETTINGS)[number]["id"];
export type DashboardSectionVisibility = Record<DashboardSectionId, boolean>; export type DashboardSectionVisibility = Record<DashboardSectionId, boolean>;
export const DEFAULT_DASHBOARD_SECTION_VISIBILITY = DASHBOARD_SECTION_SETTINGS.reduce((visibility, section) => ({ export const DEFAULT_DASHBOARD_SECTION_VISIBILITY = DASHBOARD_SECTION_SETTINGS.reduce(
(visibility, section) => ({
...visibility, ...visibility,
[section.id]: section.defaultVisible, [section.id]: section.defaultVisible,
}), {} as DashboardSectionVisibility); }),
{} as DashboardSectionVisibility,
);
export function normalizeDashboardSectionVisibility(value: unknown): DashboardSectionVisibility { export function normalizeDashboardSectionVisibility(value: unknown): DashboardSectionVisibility {
const stored = value && typeof value === "object" ? value as Partial<Record<DashboardSectionId, unknown>> : {}; const stored = value && typeof value === "object" ? (value as Partial<Record<DashboardSectionId, unknown>>) : {};
return DASHBOARD_SECTION_SETTINGS.reduce((visibility, section) => ({ return DASHBOARD_SECTION_SETTINGS.reduce(
(visibility, section) => ({
...visibility, ...visibility,
[section.id]: typeof stored[section.id] === "boolean" ? stored[section.id] : section.defaultVisible, [section.id]: typeof stored[section.id] === "boolean" ? stored[section.id] : section.defaultVisible,
}), {} as DashboardSectionVisibility); }),
{} as DashboardSectionVisibility,
);
} }

View File

@@ -19,12 +19,14 @@ function normalizeSources(value: unknown): ForecastSource[] {
} }
function normalizeHourlyForecast(value: unknown): HourlyForecast[] { function normalizeHourlyForecast(value: unknown): HourlyForecast[] {
return Array.isArray(value) ? value.flatMap((candidate) => { return Array.isArray(value)
? value.flatMap((candidate) => {
if (!candidate || typeof candidate !== "object") return []; if (!candidate || typeof candidate !== "object") return [];
const row = candidate as Partial<HourlyForecast>; const row = candidate as Partial<HourlyForecast>;
const time = readString(row.time); const time = readString(row.time);
if (!time) return []; if (!time) return [];
return [{ return [
{
time, time,
temperature: readNumber(row.temperature), temperature: readNumber(row.temperature),
feelsLike: readNumber(row.feelsLike), feelsLike: readNumber(row.feelsLike),
@@ -33,17 +35,21 @@ function normalizeHourlyForecast(value: unknown): HourlyForecast[] {
weatherCode: readNumber(row.weatherCode), weatherCode: readNumber(row.weatherCode),
windSpeed: readNumber(row.windSpeed), windSpeed: readNumber(row.windSpeed),
source: isForecastSource(row.source) ? row.source : "open-meteo", source: isForecastSource(row.source) ? row.source : "open-meteo",
}]; },
}) : []; ];
})
: [];
} }
function normalizeDailyForecast(value: unknown): DailyForecast[] { function normalizeDailyForecast(value: unknown): DailyForecast[] {
return Array.isArray(value) ? value.flatMap((candidate) => { return Array.isArray(value)
? value.flatMap((candidate) => {
if (!candidate || typeof candidate !== "object") return []; if (!candidate || typeof candidate !== "object") return [];
const row = candidate as Partial<DailyForecast>; const row = candidate as Partial<DailyForecast>;
const date = readString(row.date); const date = readString(row.date);
if (!date) return []; if (!date) return [];
return [{ return [
{
date, date,
temperatureMax: readNumber(row.temperatureMax), temperatureMax: readNumber(row.temperatureMax),
temperatureMin: readNumber(row.temperatureMin), temperatureMin: readNumber(row.temperatureMin),
@@ -53,14 +59,17 @@ function normalizeDailyForecast(value: unknown): DailyForecast[] {
sunrise: readString(row.sunrise), sunrise: readString(row.sunrise),
sunset: readString(row.sunset), sunset: readString(row.sunset),
sources: normalizeSources(row.sources), sources: normalizeSources(row.sources),
}]; },
}) : []; ];
})
: [];
} }
function normalizeForecast(raw: Partial<WeatherForecast>): WeatherForecast { function normalizeForecast(raw: Partial<WeatherForecast>): WeatherForecast {
const latitude = Number(raw.latitude); const latitude = Number(raw.latitude);
const longitude = Number(raw.longitude); const longitude = Number(raw.longitude);
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) throw new Error("Forecast service returned invalid coordinates."); if (!Number.isFinite(latitude) || !Number.isFinite(longitude))
throw new Error("Forecast service returned invalid coordinates.");
const region: WeatherRegion = raw.region === "GLOBAL" ? "GLOBAL" : "PL"; const region: WeatherRegion = raw.region === "GLOBAL" ? "GLOBAL" : "PL";
return { return {
latitude, latitude,
@@ -71,7 +80,9 @@ function normalizeForecast(raw: Partial<WeatherForecast>): WeatherForecast {
daily: normalizeDailyForecast(raw.daily), daily: normalizeDailyForecast(raw.daily),
sources: normalizeSources(raw.sources), sources: normalizeSources(raw.sources),
capabilities: raw.capabilities ?? getWeatherCapabilities(region), capabilities: raw.capabilities ?? getWeatherCapabilities(region),
unavailableReasons: Array.isArray(raw.unavailableReasons) ? raw.unavailableReasons.filter((value): value is string => typeof value === "string") : [], unavailableReasons: Array.isArray(raw.unavailableReasons)
? raw.unavailableReasons.filter((value): value is string => typeof value === "string")
: [],
}; };
} }
@@ -79,5 +90,5 @@ export async function fetchForecast(latitude: number, longitude: number, region:
const params = new URLSearchParams({ latitude: String(latitude), longitude: String(longitude), region }); const params = new URLSearchParams({ latitude: String(latitude), longitude: String(longitude), region });
const response = await fetch(`/api/forecast?${params}`, { signal }); const response = await fetch(`/api/forecast?${params}`, { signal });
if (!response.ok) throw new Error("Unable to load forecast."); if (!response.ok) throw new Error("Unable to load forecast.");
return normalizeForecast(await response.json() as Partial<WeatherForecast>); return normalizeForecast((await response.json()) as Partial<WeatherForecast>);
} }

View File

@@ -66,7 +66,8 @@ function normalizeOpenMeteoHourly(series: RawForecastSeries = {}): HourlyForecas
return asArray(series.time).flatMap((_, index) => { return asArray(series.time).flatMap((_, index) => {
const time = readString(series, "time", index); const time = readString(series, "time", index);
if (!time) return []; if (!time) return [];
return [{ return [
{
time, time,
temperature: readNumber(series, "temperature_2m", index), temperature: readNumber(series, "temperature_2m", index),
feelsLike: readNumber(series, "apparent_temperature", index), feelsLike: readNumber(series, "apparent_temperature", index),
@@ -75,7 +76,8 @@ function normalizeOpenMeteoHourly(series: RawForecastSeries = {}): HourlyForecas
weatherCode: readNumber(series, "weather_code", index), weatherCode: readNumber(series, "weather_code", index),
windSpeed: readNumber(series, "wind_speed_10m", index), windSpeed: readNumber(series, "wind_speed_10m", index),
source: "open-meteo" as const, source: "open-meteo" as const,
}]; },
];
}); });
} }
@@ -83,7 +85,8 @@ function normalizeOpenMeteoDaily(series: RawForecastSeries = {}): DailyForecast[
return asArray(series.time).flatMap((_, index) => { return asArray(series.time).flatMap((_, index) => {
const date = readString(series, "time", index); const date = readString(series, "time", index);
if (!date) return []; if (!date) return [];
return [{ return [
{
date, date,
temperatureMax: readNumber(series, "temperature_2m_max", index), temperatureMax: readNumber(series, "temperature_2m_max", index),
temperatureMin: readNumber(series, "temperature_2m_min", index), temperatureMin: readNumber(series, "temperature_2m_min", index),
@@ -93,14 +96,16 @@ function normalizeOpenMeteoDaily(series: RawForecastSeries = {}): DailyForecast[
sunrise: readString(series, "sunrise", index), sunrise: readString(series, "sunrise", index),
sunset: readString(series, "sunset", index), sunset: readString(series, "sunset", index),
sources: ["open-meteo" as const], sources: ["open-meteo" as const],
}]; },
];
}); });
} }
function normalizeOpenMeteoForecast(raw: RawWeatherForecast, region: WeatherRegion): WeatherForecast { function normalizeOpenMeteoForecast(raw: RawWeatherForecast, region: WeatherRegion): WeatherForecast {
const latitude = Number(raw.latitude); const latitude = Number(raw.latitude);
const longitude = Number(raw.longitude); const longitude = Number(raw.longitude);
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) throw new Error("Forecast service returned invalid coordinates."); if (!Number.isFinite(latitude) || !Number.isFinite(longitude))
throw new Error("Forecast service returned invalid coordinates.");
return { return {
latitude, latitude,
longitude, longitude,
@@ -169,9 +174,11 @@ function getWeatherCodePriority(code: number | null) {
function getRepresentativeWeatherCode(hours: HourlyForecast[], fallback: number | null) { function getRepresentativeWeatherCode(hours: HourlyForecast[], fallback: number | null) {
if (!hours.length) return fallback; if (!hours.length) return fallback;
return hours.reduce((selected, hour) => ( return hours.reduce(
getWeatherCodePriority(hour.weatherCode) > getWeatherCodePriority(selected) ? hour.weatherCode : selected (selected, hour) =>
), null as number | null); getWeatherCodePriority(hour.weatherCode) > getWeatherCodePriority(selected) ? hour.weatherCode : selected,
null as number | null,
);
} }
function getSources(hours: HourlyForecast[], fallback: ForecastSource[]) { function getSources(hours: HourlyForecast[], fallback: ForecastSource[]) {
@@ -183,16 +190,32 @@ function summarizeDay(day: DailyForecast, hours: HourlyForecast[]): DailyForecas
const dayHours = hours.filter((hour) => hour.time.startsWith(`${day.date}T`)); const dayHours = hours.filter((hour) => hour.time.startsWith(`${day.date}T`));
return { return {
...day, ...day,
temperatureMax: getMaximum(dayHours.map((hour) => hour.temperature), day.temperatureMax), temperatureMax: getMaximum(
temperatureMin: getMinimum(dayHours.map((hour) => hour.temperature), day.temperatureMin), dayHours.map((hour) => hour.temperature),
precipitationProbability: getMaximum(dayHours.map((hour) => hour.precipitationProbability), day.precipitationProbability), day.temperatureMax,
precipitation: getTotal(dayHours.map((hour) => hour.precipitation), day.precipitation), ),
temperatureMin: getMinimum(
dayHours.map((hour) => hour.temperature),
day.temperatureMin,
),
precipitationProbability: getMaximum(
dayHours.map((hour) => hour.precipitationProbability),
day.precipitationProbability,
),
precipitation: getTotal(
dayHours.map((hour) => hour.precipitation),
day.precipitation,
),
weatherCode: getRepresentativeWeatherCode(dayHours, day.weatherCode), weatherCode: getRepresentativeWeatherCode(dayHours, day.weatherCode),
sources: getSources(dayHours, day.sources), sources: getSources(dayHours, day.sources),
}; };
} }
export function mergeForecastSources(openMeteoPayload: RawWeatherForecast, imgwPayload?: RawImgwForecastResponse | null, region: WeatherRegion = "PL"): WeatherForecast { export function mergeForecastSources(
openMeteoPayload: RawWeatherForecast,
imgwPayload?: RawImgwForecastResponse | null,
region: WeatherRegion = "PL",
): WeatherForecast {
const openMeteoForecast = normalizeOpenMeteoForecast(openMeteoPayload, region); const openMeteoForecast = normalizeOpenMeteoForecast(openMeteoPayload, region);
const imgwHours = normalizeImgwHourly(imgwPayload); const imgwHours = normalizeImgwHourly(imgwPayload);
let hasImgwHours = false; let hasImgwHours = false;

View File

@@ -1,6 +1,11 @@
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_TEMPERATURE_UNIT, DEFAULT_WIND_SPEED_UNIT, formatTemperature, formatWindSpeed } from "@/lib/weather-utils"; import {
DEFAULT_TEMPERATURE_UNIT,
DEFAULT_WIND_SPEED_UNIT,
formatTemperature,
formatWindSpeed,
} from "@/lib/weather-utils";
import type { DailyForecast, HourlyForecast } from "@/types/forecast"; import type { DailyForecast, HourlyForecast } from "@/types/forecast";
import type { TemperatureUnit, WindSpeedUnit } from "@/types/units"; import type { TemperatureUnit, WindSpeedUnit } from "@/types/units";
@@ -24,14 +29,20 @@ function isDryWeatherCode(code: number | null) {
return code === null || (code >= 0 && code <= 3) || code === 45 || code === 48; return code === null || (code >= 0 && code <= 3) || code === 45 || code === 48;
} }
export function getEffectiveHourlyWeatherCode(hour: Pick<HourlyForecast, "weatherCode" | "precipitation" | "precipitationProbability">) { export function getEffectiveHourlyWeatherCode(
hour: Pick<HourlyForecast, "weatherCode" | "precipitation" | "precipitationProbability">,
) {
const hasMeasuredPrecipitation = (hour.precipitation ?? 0) > 0; const hasMeasuredPrecipitation = (hour.precipitation ?? 0) > 0;
const hasLikelyPrecipitation = (hour.precipitationProbability ?? 0) >= 70; const hasLikelyPrecipitation = (hour.precipitationProbability ?? 0) >= 70;
if (isDryWeatherCode(hour.weatherCode) && (hasMeasuredPrecipitation || hasLikelyPrecipitation)) return 61; if (isDryWeatherCode(hour.weatherCode) && (hasMeasuredPrecipitation || hasLikelyPrecipitation)) return 61;
return hour.weatherCode; return hour.weatherCode;
} }
export function formatForecastTemperature(value: number | null, language: Language, unit: TemperatureUnit = DEFAULT_TEMPERATURE_UNIT) { export function formatForecastTemperature(
value: number | null,
language: Language,
unit: TemperatureUnit = DEFAULT_TEMPERATURE_UNIT,
) {
if (value === null) return "—"; if (value === null) return "—";
return formatTemperature(value, language, unit); return formatTemperature(value, language, unit);
} }
@@ -41,7 +52,11 @@ export function formatForecastRainfall(value: number | null, language: Language)
return `${new Intl.NumberFormat(language === "pl" ? "pl-PL" : "en-GB", { maximumFractionDigits: 1 }).format(value)} mm`; return `${new Intl.NumberFormat(language === "pl" ? "pl-PL" : "en-GB", { maximumFractionDigits: 1 }).format(value)} mm`;
} }
export function formatForecastWind(value: number | null, language: Language, unit: WindSpeedUnit = DEFAULT_WIND_SPEED_UNIT) { export function formatForecastWind(
value: number | null,
language: Language,
unit: WindSpeedUnit = DEFAULT_WIND_SPEED_UNIT,
) {
if (value === null) return "—"; if (value === null) return "—";
return formatWindSpeed(value, language, unit); return formatWindSpeed(value, language, unit);
} }
@@ -76,7 +91,8 @@ function getForecastHourOfDay(time: string) {
export function getUpcomingHourlyForecast(hours: HourlyForecast[], limit = 24) { export function getUpcomingHourlyForecast(hours: HourlyForecast[], limit = 24) {
const currentHour = getWarsawForecastHour(); const currentHour = getWarsawForecastHour();
const currentTimestamp = parseForecastHour(currentHour); const currentTimestamp = parseForecastHour(currentHour);
const upcomingHours = currentTimestamp === null const upcomingHours =
currentTimestamp === null
? hours.filter((hour) => hour.time >= currentHour) ? hours.filter((hour) => hour.time >= currentHour)
: hours.filter((hour) => { : hours.filter((hour) => {
const hourTimestamp = parseForecastHour(hour.time); const hourTimestamp = parseForecastHour(hour.time);
@@ -86,7 +102,8 @@ export function getUpcomingHourlyForecast(hours: HourlyForecast[], limit = 24) {
if (upcomingHours.length) return upcomingHours.slice(0, limit); if (upcomingHours.length) return upcomingHours.slice(0, limit);
const currentHourOfDay = getForecastHourOfDay(currentHour); const currentHourOfDay = getForecastHourOfDay(currentHour);
const fallbackHours = currentHourOfDay === null const fallbackHours =
currentHourOfDay === null
? hours ? hours
: hours.filter((hour) => { : hours.filter((hour) => {
const forecastHourOfDay = getForecastHourOfDay(hour.time); const forecastHourOfDay = getForecastHourOfDay(hour.time);

View File

@@ -21,17 +21,21 @@ const translations = {
"theme.dark": "Włącz ciemny motyw", "theme.dark": "Włącz ciemny motyw",
"settings.section": "Preferencje", "settings.section": "Preferencje",
"settings.title": "Ustawienia", "settings.title": "Ustawienia",
"settings.description": "Zarządzaj językiem, wyglądem i przygotowaniem powiadomień o ostrzeżeniach meteorologicznych IMGW.", "settings.description":
"Zarządzaj językiem, wyglądem i przygotowaniem powiadomień o ostrzeżeniach meteorologicznych IMGW.",
"settings.language.title": "Język", "settings.language.title": "Język",
"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.dashboardSections.title": "Strona główna", "settings.dashboardSections.title": "Strona główna",
"settings.dashboardSections.description": "Wybierz, które dodatkowe sekcje mają być widoczne na dashboardzie.", "settings.dashboardSections.description": "Wybierz, które dodatkowe sekcje mają być widoczne na dashboardzie.",
"settings.dashboardSections.weatherBrief.title": "Brief dnia", "settings.dashboardSections.weatherBrief.title": "Brief dnia",
"settings.dashboardSections.weatherBrief.description": "Pokazuje deterministyczne podsumowanie dnia i krótką prognozę na jutro.", "settings.dashboardSections.weatherBrief.description":
"Pokazuje deterministyczne podsumowanie dnia i krótką prognozę na jutro.",
"settings.dashboardSections.featuredStations.title": "Szybki wybór", "settings.dashboardSections.featuredStations.title": "Szybki wybór",
"settings.dashboardSections.featuredStations.description": "Pokazuje listę popularnych lokalizacji na dole strony głównej.", "settings.dashboardSections.featuredStations.description":
"Pokazuje listę popularnych lokalizacji na dole strony głównej.",
"settings.appSections.title": "Sekcje aplikacji", "settings.appSections.title": "Sekcje aplikacji",
"settings.appSections.description": "Wybierz, które dodatkowe widoki mają być widoczne w nawigacji.", "settings.appSections.description": "Wybierz, które dodatkowe widoki mają być widoczne w nawigacji.",
"settings.appSections.warnings.title": "Ostrzeżenia", "settings.appSections.warnings.title": "Ostrzeżenia",
@@ -39,33 +43,41 @@ const translations = {
"settings.appSections.hydro.title": "Hydro", "settings.appSections.hydro.title": "Hydro",
"settings.appSections.hydro.description": "Pokazuje widok monitoringu hydrologicznego IMGW w głównej nawigacji.", "settings.appSections.hydro.description": "Pokazuje widok monitoringu hydrologicznego IMGW w głównej nawigacji.",
"settings.units.temperature.title": "Jednostka temperatury", "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.description":
"Stosowana w prognozie, briefach, powiadomieniach i bieżących pomiarach na tym urządzeniu.",
"settings.units.temperature.label": "Wybierz jednostkę temperatury", "settings.units.temperature.label": "Wybierz jednostkę temperatury",
"settings.units.temperature.c": "°C", "settings.units.temperature.c": "°C",
"settings.units.temperature.f": "°F", "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",
"settings.units.wind.ms": "m/s", "settings.units.wind.ms": "m/s",
"settings.units.wind.kmh": "km/h", "settings.units.wind.kmh": "km/h",
"settings.units.wind.mph": "mph", "settings.units.wind.mph": "mph",
"settings.notifications.title": "Powiadomienia o ostrzeżeniach meteo", "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.description":
"Przygotuj alerty dla nowych ostrzeżeń meteorologicznych IMGW, takich jak burze, upał, silny wiatr lub intensywny deszcz.",
"settings.notifications.regionTitle": "Obszar alertów", "settings.notifications.regionTitle": "Obszar alertów",
"settings.notifications.regionDescription": "Aktualnie wybrany obszar dla przyszłych powiadomień: {province}.", "settings.notifications.regionDescription": "Aktualnie wybrany obszar dla przyszłych powiadomień: {province}.",
"settings.notifications.regionSelected": "Używaj lokalizacji wybranej w aplikacji", "settings.notifications.regionSelected": "Używaj lokalizacji wybranej w aplikacji",
"settings.notifications.regionManual": "Wybierz województwo ręcznie", "settings.notifications.regionManual": "Wybierz województwo ręcznie",
"settings.notifications.noProvince": "brak wybranego województwa", "settings.notifications.noProvince": "brak wybranego województwa",
"settings.notifications.globalRegion": "lokalizacja poza Polską", "settings.notifications.globalRegion": "lokalizacja poza Polską",
"settings.notifications.globalAlertsUnavailable": "Oficjalne ostrzeżenia IMGW są obecnie obsługiwane tylko dla Polski. Dla tej lokalizacji możesz używać briefów opartych o prognozę modelową Open-Meteo.", "settings.notifications.globalAlertsUnavailable":
"Oficjalne ostrzeżenia IMGW są obecnie obsługiwane tylko dla Polski. Dla tej lokalizacji możesz używać briefów opartych o prognozę modelową Open-Meteo.",
"settings.notifications.deviceTitle": "To urządzenie", "settings.notifications.deviceTitle": "To urządzenie",
"settings.notifications.enable": "Alerty IMGW", "settings.notifications.enable": "Alerty IMGW",
"settings.notifications.enableDescription": "Subskrypcja zostanie zapisana dla wybranego województwa i użyta przez serwerowy sprawdzacz ostrzeżeń.", "settings.notifications.enableDescription":
"settings.notifications.enableGlobalDescription": "Subskrypcja zostanie zapisana dla wybranej lokalizacji i użyta do briefów opartych o prognozę modelową.", "Subskrypcja zostanie zapisana dla wybranego województwa i użyta przez serwerowy sprawdzacz ostrzeżeń.",
"settings.notifications.enableGlobalDescription":
"Subskrypcja zostanie zapisana dla wybranej lokalizacji i użyta do briefów opartych o prognozę modelową.",
"settings.notifications.morningBrief": "Brief poranny o 7:00", "settings.notifications.morningBrief": "Brief poranny o 7:00",
"settings.notifications.morningBriefDescription": "Używa wybranej lokalizacji i prognozy modelowej, aby wysłać krótkie podsumowanie dnia na to urządzenie.", "settings.notifications.morningBriefDescription":
"Używa wybranej lokalizacji i prognozy modelowej, aby wysłać krótkie podsumowanie dnia na to urządzenie.",
"settings.notifications.tomorrowBrief": "Brief na jutro o 18:00", "settings.notifications.tomorrowBrief": "Brief na jutro o 18:00",
"settings.notifications.tomorrowBriefDescription": "Wysyła wieczorem krótką prognozę na kolejny dzień, w tym sygnały burz i opadów z modelu.", "settings.notifications.tomorrowBriefDescription":
"Wysyła wieczorem krótką prognozę na kolejny dzień, w tym sygnały burz i opadów z modelu.",
"settings.notifications.enableDevice": "Włącz na tym urządzeniu", "settings.notifications.enableDevice": "Włącz na tym urządzeniu",
"settings.notifications.disable": "Wyłącz na tym urządzeniu", "settings.notifications.disable": "Wyłącz na tym urządzeniu",
"settings.notifications.enabledStatus": "Włączone", "settings.notifications.enabledStatus": "Włączone",
@@ -82,11 +94,14 @@ const translations = {
"settings.notifications.statusChecking": "Sprawdzam obsługę powiadomień w tej przeglądarce.", "settings.notifications.statusChecking": "Sprawdzam obsługę powiadomień w tej przeglądarce.",
"settings.notifications.statusUnconfigured": "Serwer nie ma jeszcze skonfigurowanych kluczy Web Push VAPID.", "settings.notifications.statusUnconfigured": "Serwer nie ma jeszcze skonfigurowanych kluczy Web Push VAPID.",
"settings.notifications.statusUnsupported": "Ta przeglądarka nie udostępnia Web Push dla tej aplikacji.", "settings.notifications.statusUnsupported": "Ta przeglądarka nie udostępnia Web Push dla tej aplikacji.",
"settings.notifications.statusNeedsInstall": "Na iPhonie dodaj aplikację do ekranu początkowego przez Udostępnij → Dodaj do ekranu początkowego, a potem otwórz ją z ikony.", "settings.notifications.statusNeedsInstall":
"Na iPhonie dodaj aplikację do ekranu początkowego przez Udostępnij → Dodaj do ekranu początkowego, a potem otwórz ją z ikony.",
"settings.notifications.statusDenied": "Powiadomienia są zablokowane w ustawieniach systemu lub przeglądarki.", "settings.notifications.statusDenied": "Powiadomienia są zablokowane w ustawieniach systemu lub przeglądarki.",
"settings.notifications.statusGranted": "To urządzenie ma zgodę na powiadomienia.", "settings.notifications.statusGranted": "To urządzenie ma zgodę na powiadomienia.",
"settings.notifications.statusReady": "Możesz poprosić system o zgodę na powiadomienia. Android i desktop działają bez instalowania PWA, jeśli przeglądarka obsługuje Web Push.", "settings.notifications.statusReady":
"settings.notifications.implementationNote": "Serwerowy sprawdzacz wysyła nowe ostrzeżenia meteo IMGW oraz briefy przez Web Push. Subskrypcje i historia wysyłek są przechowywane w SQLite.", "Możesz poprosić system o zgodę na powiadomienia. Android i desktop działają bez instalowania PWA, jeśli przeglądarka obsługuje Web Push.",
"settings.notifications.implementationNote":
"Serwerowy sprawdzacz wysyła nowe ostrzeżenia meteo IMGW oraz briefy przez Web Push. Subskrypcje i historia wysyłek są przechowywane w SQLite.",
"pwa.install": "Zainstaluj", "pwa.install": "Zainstaluj",
"common.noData": "Brak danych", "common.noData": "Brak danych",
"common.loading": "Ładowanie danych", "common.loading": "Ładowanie danych",
@@ -95,9 +110,11 @@ const translations = {
"error.description": "Sprawdź połączenie i spróbuj ponownie.", "error.description": "Sprawdź połączenie i spróbuj ponownie.",
"dashboard.error": "Nie udało się pobrać listy stacji synoptycznych IMGW.", "dashboard.error": "Nie udało się pobrać listy stacji synoptycznych IMGW.",
"brief.label": "Brief dnia", "brief.label": "Brief dnia",
"brief.description": "Automatyczne podsumowanie dla lokalizacji: {location}. Bez zewnętrznego modelu AI, wyłącznie z prognozy i ostrzeżeń.", "brief.description":
"Automatyczne podsumowanie dla lokalizacji: {location}. Bez zewnętrznego modelu AI, wyłącznie z prognozy i ostrzeżeń.",
"brief.tomorrowLabel": "Jutro", "brief.tomorrowLabel": "Jutro",
"brief.pushHint": "Krótkie wersje mogą być wysyłane jako powiadomienia o 7:00 i 18:00 po włączeniu ich w ustawieniach.", "brief.pushHint":
"Krótkie wersje mogą być wysyłane jako powiadomienia o 7:00 i 18:00 po włączeniu ich w ustawieniach.",
"brief.error": "Nie udało się przygotować briefu dnia.", "brief.error": "Nie udało się przygotować briefu dnia.",
"brief.emptyTitle": "Brak briefu", "brief.emptyTitle": "Brak briefu",
"brief.emptyDescription": "Prognoza nie zawiera wystarczających danych dla najbliższej doby.", "brief.emptyDescription": "Prognoza nie zawiera wystarczających danych dla najbliższej doby.",
@@ -110,12 +127,15 @@ const translations = {
"location.empty": "Nie znaleziono pasującej miejscowości.", "location.empty": "Nie znaleziono pasującej miejscowości.",
"location.nearest": "Najbliższa stacja IMGW", "location.nearest": "Najbliższa stacja IMGW",
"location.modelSource": "Źródło modelowe", "location.modelSource": "Źródło modelowe",
"location.currentSource": "{location}: współrzędne miejscowości są używane dla lokalnej analizy IMGW Hybrid. Najbliższa stacja pomiarowa IMGW: {station} · około {distance} km.", "location.currentSource":
"location.currentSourceGlobal": "{location}: współrzędne są używane dla modelowych warunków bieżących i prognozy Open-Meteo.", "{location}: współrzędne miejscowości są używane dla lokalnej analizy IMGW Hybrid. Najbliższa stacja pomiarowa IMGW: {station} · około {distance} km.",
"location.currentSourceGlobal":
"{location}: współrzędne są używane dla modelowych warunków bieżących i prognozy Open-Meteo.",
"location.heroHybridSource": "Analiza IMGW Hybrid dla lokalizacji: {location}", "location.heroHybridSource": "Analiza IMGW Hybrid dla lokalizacji: {location}",
"location.heroGlobalModelSource": "Modelowe warunki bieżące Open-Meteo dla lokalizacji: {location}", "location.heroGlobalModelSource": "Modelowe warunki bieżące Open-Meteo dla lokalizacji: {location}",
"location.heroHybridLoading": "Pobieram lokalną analizę IMGW Hybrid. Tymczasowo pokazuję odczyt stacji: {station}.", "location.heroHybridLoading": "Pobieram lokalną analizę IMGW Hybrid. Tymczasowo pokazuję odczyt stacji: {station}.",
"location.heroHybridPartial": "Lokalna analiza opadu IMGW Hybrid. Pozostałe parametry zastępczo ze stacji IMGW: {station} · około {distance} km", "location.heroHybridPartial":
"Lokalna analiza opadu IMGW Hybrid. Pozostałe parametry zastępczo ze stacji IMGW: {station} · około {distance} km",
"location.heroNearestStation": "Najbliższa stacja pomiarowa IMGW: {station} · około {distance} km", "location.heroNearestStation": "Najbliższa stacja pomiarowa IMGW: {station} · około {distance} km",
"location.heroStationFallback": "Dane zastępcze ze stacji IMGW: {station}", "location.heroStationFallback": "Dane zastępcze ze stacji IMGW: {station}",
"location.heroStationFallbackWithDistance": "Dane zastępcze ze stacji IMGW: {station} · około {distance} km", "location.heroStationFallbackWithDistance": "Dane zastępcze ze stacji IMGW: {station} · około {distance} km",
@@ -124,14 +144,18 @@ const translations = {
"location.gpsUse": "Użyj mojej lokalizacji", "location.gpsUse": "Użyj mojej lokalizacji",
"location.gpsLocating": "Ustalam lokalizację…", "location.gpsLocating": "Ustalam lokalizację…",
"location.gpsPromptTitle": "Pokazać pogodę dla Twojej lokalizacji?", "location.gpsPromptTitle": "Pokazać pogodę dla Twojej lokalizacji?",
"location.gpsPromptDescription": "Po Twojej zgodzie wtr. użyje GPS, aby wybrać miejscowość i prognozę. W Polsce dobierze też najbliższą stację IMGW. Pozycja zostanie zaokrąglona do około 100 metrów.", "location.gpsPromptDescription":
"Po Twojej zgodzie wtr. użyje GPS, aby wybrać miejscowość i prognozę. W Polsce dobierze też najbliższą stację IMGW. Pozycja zostanie zaokrąglona do około 100 metrów.",
"location.gpsAllow": "Użyj GPS", "location.gpsAllow": "Użyj GPS",
"location.gpsNotNow": "Nie teraz", "location.gpsNotNow": "Nie teraz",
"location.gpsSecureContext": "GPS wymaga HTTPS. Na iPhonie lokalny adres HTTP z adresem IP nie może wyświetlić systemowego pytania o położenie. Użyj wdrożonej wersji HTTPS.", "location.gpsSecureContext":
"GPS wymaga HTTPS. Na iPhonie lokalny adres HTTP z adresem IP nie może wyświetlić systemowego pytania o położenie. Użyj wdrożonej wersji HTTPS.",
"location.gpsUnavailable": "Ta przeglądarka nie udostępnia lokalizacji GPS.", "location.gpsUnavailable": "Ta przeglądarka nie udostępnia lokalizacji GPS.",
"location.gpsDenied": "Dostęp do lokalizacji został odrzucony. Możesz zmienić uprawnienia witryny w ustawieniach przeglądarki.", "location.gpsDenied":
"Dostęp do lokalizacji został odrzucony. Możesz zmienić uprawnienia witryny w ustawieniach przeglądarki.",
"location.gpsTimeout": "Nie udało się ustalić lokalizacji w wymaganym czasie. Spróbuj ponownie.", "location.gpsTimeout": "Nie udało się ustalić lokalizacji w wymaganym czasie. Spróbuj ponownie.",
"location.gpsPositionUnavailable": "Nie udało się ustalić lokalizacji GPS. Sprawdź ustawienia urządzenia i spróbuj ponownie.", "location.gpsPositionUnavailable":
"Nie udało się ustalić lokalizacji GPS. Sprawdź ustawienia urządzenia i spróbuj ponownie.",
"location.gpsStationsPending": "Lista stacji IMGW nie jest jeszcze gotowa. Spróbuj ponownie za chwilę.", "location.gpsStationsPending": "Lista stacji IMGW nie jest jeszcze gotowa. Spróbuj ponownie za chwilę.",
"location.gpsFallbackName": "Bieżąca lokalizacja", "location.gpsFallbackName": "Bieżąca lokalizacja",
"location.gpsSelected": "Wybrano lokalizację: {location}.", "location.gpsSelected": "Wybrano lokalizację: {location}.",
@@ -171,7 +195,8 @@ const translations = {
"weather.temperatureDetail": "Temperatura powietrza", "weather.temperatureDetail": "Temperatura powietrza",
"forecast.label": "Prognoza modelowa", "forecast.label": "Prognoza modelowa",
"forecast.title": "Najbliższe godziny i dni", "forecast.title": "Najbliższe godziny i dni",
"forecast.description": "Prognoza dla {location}. Bieżące warunki powyżej pochodzą z IMGW, a poniższe wartości są prognozą modelową preferującą IMGW.", "forecast.description":
"Prognoza dla {location}. Bieżące warunki powyżej pochodzą z IMGW, a poniższe wartości są prognozą modelową preferującą IMGW.",
"forecast.hourly": "Najbliższe 24 godziny", "forecast.hourly": "Najbliższe 24 godziny",
"forecast.daily": "Prognoza 7-dniowa", "forecast.daily": "Prognoza 7-dniowa",
"forecast.today": "Dzisiaj", "forecast.today": "Dzisiaj",
@@ -196,7 +221,8 @@ const translations = {
"forecast.maxProbability": "Maks. szansa opadu", "forecast.maxProbability": "Maks. szansa opadu",
"forecast.pastHour": "Miniona godzina", "forecast.pastHour": "Miniona godzina",
"forecast.source": "Źródło prognozy:", "forecast.source": "Źródło prognozy:",
"forecast.sourceCombinedDescription": "IMGW ALARO dostarcza dostępne godziny prognozy, a Open-Meteo uzupełnia prawdopodobieństwo opadu i dalszy horyzont do 7 dni.", "forecast.sourceCombinedDescription":
"IMGW ALARO dostarcza dostępne godziny prognozy, a Open-Meteo uzupełnia prawdopodobieństwo opadu i dalszy horyzont do 7 dni.",
"forecast.sourceFallbackDescription": "Prognoza jest wyświetlana jako modelowa prognoza Open-Meteo.", "forecast.sourceFallbackDescription": "Prognoza jest wyświetlana jako modelowa prognoza Open-Meteo.",
"forecast.error": "Nie udało się pobrać prognozy modelowej.", "forecast.error": "Nie udało się pobrać prognozy modelowej.",
"forecast.emptyTitle": "Brak prognozy", "forecast.emptyTitle": "Brak prognozy",
@@ -214,7 +240,8 @@ const translations = {
"station.all": "Wszystkie stacje", "station.all": "Wszystkie stacje",
"station.label": "Stacja {name}", "station.label": "Stacja {name}",
"station.parameters": "Aktualne parametry", "station.parameters": "Aktualne parametry",
"station.parametersDescription": "Najnowszy pomiar udostępniony przez IMGW. Brakujące wartości są oznaczone bez uzupełniania ich danymi szacunkowymi.", "station.parametersDescription":
"Najnowszy pomiar udostępniony przez IMGW. Brakujące wartości są oznaczone bez uzupełniania ich danymi szacunkowymi.",
"station.error": "Nie udało się pobrać danych wybranej stacji IMGW.", "station.error": "Nie udało się pobrać danych wybranej stacji IMGW.",
"station.quality": "Jakość danych", "station.quality": "Jakość danych",
"station.lastMeasurementImgw": "Ostatni pomiar IMGW", "station.lastMeasurementImgw": "Ostatni pomiar IMGW",
@@ -224,21 +251,26 @@ const translations = {
"station.publicApi": "Publiczne API IMGW", "station.publicApi": "Publiczne API IMGW",
"snapshot.label": "Snapshot pomiarowy", "snapshot.label": "Snapshot pomiarowy",
"snapshot.title": "Aktualne proporcje parametrów", "snapshot.title": "Aktualne proporcje parametrów",
"snapshot.description": "Wizualizacja bieżącego odczytu. API synoptyczne IMGW nie udostępnia historii ani prognozy godzinowej.", "snapshot.description":
"Wizualizacja bieżącego odczytu. API synoptyczne IMGW nie udostępnia historii ani prognozy godzinowej.",
"warnings.section": "Komunikaty IMGW", "warnings.section": "Komunikaty IMGW",
"warnings.title": "Ostrzeżenia", "warnings.title": "Ostrzeżenia",
"warnings.description": "Aktualne ostrzeżenia meteorologiczne i hydrologiczne publikowane przez IMGW. Szczegóły obszaru i czasu obowiązywania pochodzą bezpośrednio z API.", "warnings.description":
"Aktualne ostrzeżenia meteorologiczne i hydrologiczne publikowane przez IMGW. Szczegóły obszaru i czasu obowiązywania pochodzą bezpośrednio z API.",
"warnings.myProvince": "Mój obszar", "warnings.myProvince": "Mój obszar",
"warnings.myProvinceDescription": "Najpierw pokazujemy komunikaty IMGW dotyczące obszaru {province}, zgodnie z lokalizacją wybraną w pogodzie.", "warnings.myProvinceDescription":
"Najpierw pokazujemy komunikaty IMGW dotyczące obszaru {province}, zgodnie z lokalizacją wybraną w pogodzie.",
"warnings.myProvinceEmptyTitle": "Brak ostrzeżeń dla Twojego obszaru", "warnings.myProvinceEmptyTitle": "Brak ostrzeżeń dla Twojego obszaru",
"warnings.myProvinceEmptyDescription": "IMGW nie publikuje obecnie aktywnych ostrzeżeń dotyczących obszaru {province}.", "warnings.myProvinceEmptyDescription":
"IMGW nie publikuje obecnie aktywnych ostrzeżeń dotyczących obszaru {province}.",
"warnings.otherRegions": "Pozostałe regiony", "warnings.otherRegions": "Pozostałe regiony",
"warnings.otherRegionsDescription": "Aktywne komunikaty IMGW dla pozostałych obszarów Polski.", "warnings.otherRegionsDescription": "Aktywne komunikaty IMGW dla pozostałych obszarów Polski.",
"warnings.error": "Nie udało się pobrać ostrzeżeń meteorologicznych ani hydrologicznych.", "warnings.error": "Nie udało się pobrać ostrzeżeń meteorologicznych ani hydrologicznych.",
"warnings.emptyTitle": "Brak aktywnych ostrzeżeń", "warnings.emptyTitle": "Brak aktywnych ostrzeżeń",
"warnings.emptyDescription": "IMGW nie publikuje obecnie ostrzeżeń meteorologicznych ani hydrologicznych.", "warnings.emptyDescription": "IMGW nie publikuje obecnie ostrzeżeń meteorologicznych ani hydrologicznych.",
"warnings.globalUnavailableTitle": "Oficjalne ostrzeżenia tylko dla Polski", "warnings.globalUnavailableTitle": "Oficjalne ostrzeżenia tylko dla Polski",
"warnings.globalUnavailableDescription": "Oficjalne ostrzeżenia pogodowe są obecnie obsługiwane tylko dla Polski przez IMGW. Dla tej lokalizacji możesz korzystać z prognozy modelowej Open-Meteo, ale nie traktujemy jej jako oficjalnego alertu.", "warnings.globalUnavailableDescription":
"Oficjalne ostrzeżenia pogodowe są obecnie obsługiwane tylko dla Polski przez IMGW. Dla tej lokalizacji możesz korzystać z prognozy modelowej Open-Meteo, ale nie traktujemy jej jako oficjalnego alertu.",
"warnings.drought": "Susza hydrologiczna", "warnings.drought": "Susza hydrologiczna",
"warnings.levelUnknown": "Poziom nieokreślony", "warnings.levelUnknown": "Poziom nieokreślony",
"warnings.level": "Stopień {level}", "warnings.level": "Stopień {level}",
@@ -259,7 +291,8 @@ const translations = {
"warnings.dashboard.viewAll": "Zobacz wszystkie", "warnings.dashboard.viewAll": "Zobacz wszystkie",
"hydro.section": "Monitoring wód IMGW", "hydro.section": "Monitoring wód IMGW",
"hydro.title": "Hydro", "hydro.title": "Hydro",
"hydro.description": "Najnowsze dostępne pomiary poziomu wody, temperatury i przepływu. Każdy parametr może mieć własny czas aktualizacji.", "hydro.description":
"Najnowsze dostępne pomiary poziomu wody, temperatury i przepływu. Każdy parametr może mieć własny czas aktualizacji.",
"hydro.error": "Nie udało się pobrać stacji hydrologicznych IMGW.", "hydro.error": "Nie udało się pobrać stacji hydrologicznych IMGW.",
"hydro.searchLabel": "Szukaj stacji hydrologicznej", "hydro.searchLabel": "Szukaj stacji hydrologicznej",
"hydro.searchPlaceholder": "Szukaj stacji, rzeki lub województwa…", "hydro.searchPlaceholder": "Szukaj stacji, rzeki lub województwa…",
@@ -272,7 +305,8 @@ const translations = {
"hydro.flow": "Przepływ", "hydro.flow": "Przepływ",
"hydro.levelMeasurement": "Pomiar poziomu: {date}", "hydro.levelMeasurement": "Pomiar poziomu: {date}",
"offline.title": "Brak połączenia", "offline.title": "Brak połączenia",
"offline.description": "wtr. nie może teraz pobrać aktualnych danych IMGW. Ostatnio odwiedzone widoki mogą być dostępne z pamięci urządzenia.", "offline.description":
"wtr. nie może teraz pobrać aktualnych danych IMGW. Ostatnio odwiedzone widoki mogą być dostępne z pamięci urządzenia.",
"offline.back": "Wróć do aplikacji", "offline.back": "Wróć do aplikacji",
}, },
en: { en: {
@@ -290,17 +324,21 @@ const translations = {
"theme.dark": "Enable dark theme", "theme.dark": "Enable dark theme",
"settings.section": "Preferences", "settings.section": "Preferences",
"settings.title": "Settings", "settings.title": "Settings",
"settings.description": "Manage language, appearance and preparation for IMGW meteorological warning notifications.", "settings.description":
"Manage language, appearance and preparation for IMGW meteorological warning notifications.",
"settings.language.title": "Language", "settings.language.title": "Language",
"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.dashboardSections.title": "Home screen", "settings.dashboardSections.title": "Home screen",
"settings.dashboardSections.description": "Choose which additional sections are visible on the dashboard.", "settings.dashboardSections.description": "Choose which additional sections are visible on the dashboard.",
"settings.dashboardSections.weatherBrief.title": "Daily brief", "settings.dashboardSections.weatherBrief.title": "Daily brief",
"settings.dashboardSections.weatherBrief.description": "Shows the deterministic daily summary and a short forecast for tomorrow.", "settings.dashboardSections.weatherBrief.description":
"Shows the deterministic daily summary and a short forecast for tomorrow.",
"settings.dashboardSections.featuredStations.title": "Quick select", "settings.dashboardSections.featuredStations.title": "Quick select",
"settings.dashboardSections.featuredStations.description": "Shows the popular locations list at the bottom of the home screen.", "settings.dashboardSections.featuredStations.description":
"Shows the popular locations list at the bottom of the home screen.",
"settings.appSections.title": "App sections", "settings.appSections.title": "App sections",
"settings.appSections.description": "Choose which additional views are visible in navigation.", "settings.appSections.description": "Choose which additional views are visible in navigation.",
"settings.appSections.warnings.title": "Warnings", "settings.appSections.warnings.title": "Warnings",
@@ -308,7 +346,8 @@ const translations = {
"settings.appSections.hydro.title": "Hydro", "settings.appSections.hydro.title": "Hydro",
"settings.appSections.hydro.description": "Shows the IMGW hydrological monitoring view in main navigation.", "settings.appSections.hydro.description": "Shows the IMGW hydrological monitoring view in main navigation.",
"settings.units.temperature.title": "Temperature unit", "settings.units.temperature.title": "Temperature unit",
"settings.units.temperature.description": "Used in forecasts, briefs, notifications and current readings on this device.", "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.label": "Choose temperature unit",
"settings.units.temperature.c": "°C", "settings.units.temperature.c": "°C",
"settings.units.temperature.f": "°F", "settings.units.temperature.f": "°F",
@@ -319,22 +358,28 @@ const translations = {
"settings.units.wind.kmh": "km/h", "settings.units.wind.kmh": "km/h",
"settings.units.wind.mph": "mph", "settings.units.wind.mph": "mph",
"settings.notifications.title": "Weather warning notifications", "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.description":
"Prepare alerts for new IMGW meteorological warnings, such as thunderstorms, heat, strong wind or heavy rain.",
"settings.notifications.regionTitle": "Alert area", "settings.notifications.regionTitle": "Alert area",
"settings.notifications.regionDescription": "Current area for future notifications: {province}.", "settings.notifications.regionDescription": "Current area for future notifications: {province}.",
"settings.notifications.regionSelected": "Use the location selected in the app", "settings.notifications.regionSelected": "Use the location selected in the app",
"settings.notifications.regionManual": "Choose province manually", "settings.notifications.regionManual": "Choose province manually",
"settings.notifications.noProvince": "no province selected", "settings.notifications.noProvince": "no province selected",
"settings.notifications.globalRegion": "location outside Poland", "settings.notifications.globalRegion": "location outside Poland",
"settings.notifications.globalAlertsUnavailable": "Official IMGW warnings are currently supported only for Poland. For this location you can use briefs based on the Open-Meteo model forecast.", "settings.notifications.globalAlertsUnavailable":
"Official IMGW warnings are currently supported only for Poland. For this location you can use briefs based on the Open-Meteo model forecast.",
"settings.notifications.deviceTitle": "This device", "settings.notifications.deviceTitle": "This device",
"settings.notifications.enable": "IMGW alerts", "settings.notifications.enable": "IMGW alerts",
"settings.notifications.enableDescription": "The subscription will be saved for the selected province and used by the server warning checker.", "settings.notifications.enableDescription":
"settings.notifications.enableGlobalDescription": "The subscription will be saved for the selected location and used for model forecast briefs.", "The subscription will be saved for the selected province and used by the server warning checker.",
"settings.notifications.enableGlobalDescription":
"The subscription will be saved for the selected location and used for model forecast briefs.",
"settings.notifications.morningBrief": "Morning brief at 7:00", "settings.notifications.morningBrief": "Morning brief at 7:00",
"settings.notifications.morningBriefDescription": "Uses the selected location and model forecast to send a short daily summary to this device.", "settings.notifications.morningBriefDescription":
"Uses the selected location and model forecast to send a short daily summary to this device.",
"settings.notifications.tomorrowBrief": "Tomorrow brief at 18:00", "settings.notifications.tomorrowBrief": "Tomorrow brief at 18:00",
"settings.notifications.tomorrowBriefDescription": "Sends a short evening forecast for the next day, including storm and rain signals from the model.", "settings.notifications.tomorrowBriefDescription":
"Sends a short evening forecast for the next day, including storm and rain signals from the model.",
"settings.notifications.enableDevice": "Enable on this device", "settings.notifications.enableDevice": "Enable on this device",
"settings.notifications.disable": "Disable on this device", "settings.notifications.disable": "Disable on this device",
"settings.notifications.enabledStatus": "Enabled", "settings.notifications.enabledStatus": "Enabled",
@@ -351,11 +396,14 @@ const translations = {
"settings.notifications.statusChecking": "Checking notification support in this browser.", "settings.notifications.statusChecking": "Checking notification support in this browser.",
"settings.notifications.statusUnconfigured": "The server does not have Web Push VAPID keys configured yet.", "settings.notifications.statusUnconfigured": "The server does not have Web Push VAPID keys configured yet.",
"settings.notifications.statusUnsupported": "This browser does not provide Web Push for this app.", "settings.notifications.statusUnsupported": "This browser does not provide Web Push for this app.",
"settings.notifications.statusNeedsInstall": "On iPhone, add the app to the Home Screen using Share → Add to Home Screen, then open it from the icon.", "settings.notifications.statusNeedsInstall":
"On iPhone, add the app to the Home Screen using Share → Add to Home Screen, then open it from the icon.",
"settings.notifications.statusDenied": "Notifications are blocked in system or browser settings.", "settings.notifications.statusDenied": "Notifications are blocked in system or browser settings.",
"settings.notifications.statusGranted": "This device has notification permission.", "settings.notifications.statusGranted": "This device has notification permission.",
"settings.notifications.statusReady": "You can ask the system for notification permission. Android and desktop work without installing the PWA when the browser supports Web Push.", "settings.notifications.statusReady":
"settings.notifications.implementationNote": "The server checker sends new IMGW meteorological warnings and briefs through Web Push. Subscriptions and delivery history are stored in SQLite.", "You can ask the system for notification permission. Android and desktop work without installing the PWA when the browser supports Web Push.",
"settings.notifications.implementationNote":
"The server checker sends new IMGW meteorological warnings and briefs through Web Push. Subscriptions and delivery history are stored in SQLite.",
"pwa.install": "Install", "pwa.install": "Install",
"common.noData": "No data", "common.noData": "No data",
"common.loading": "Loading data", "common.loading": "Loading data",
@@ -379,28 +427,36 @@ const translations = {
"location.empty": "No matching place was found.", "location.empty": "No matching place was found.",
"location.nearest": "Nearest IMGW station", "location.nearest": "Nearest IMGW station",
"location.modelSource": "Model source", "location.modelSource": "Model source",
"location.currentSource": "{location}: the place coordinates are used for local IMGW Hybrid analysis. Nearest IMGW measurement station: {station} · approximately {distance} km away.", "location.currentSource":
"{location}: the place coordinates are used for local IMGW Hybrid analysis. Nearest IMGW measurement station: {station} · approximately {distance} km away.",
"location.heroHybridSource": "IMGW Hybrid analysis for: {location}", "location.heroHybridSource": "IMGW Hybrid analysis for: {location}",
"location.currentSourceGlobal": "{location}: coordinates are used for Open-Meteo model current conditions and forecast.", "location.currentSourceGlobal":
"{location}: coordinates are used for Open-Meteo model current conditions and forecast.",
"location.heroGlobalModelSource": "Open-Meteo model current conditions for: {location}", "location.heroGlobalModelSource": "Open-Meteo model current conditions for: {location}",
"location.heroHybridLoading": "Loading local IMGW Hybrid analysis. Temporarily showing the station reading: {station}.", "location.heroHybridLoading":
"location.heroHybridPartial": "Local IMGW Hybrid rainfall analysis. Other parameters use fallback data from IMGW station: {station} · approximately {distance} km away", "Loading local IMGW Hybrid analysis. Temporarily showing the station reading: {station}.",
"location.heroHybridPartial":
"Local IMGW Hybrid rainfall analysis. Other parameters use fallback data from IMGW station: {station} · approximately {distance} km away",
"location.heroNearestStation": "Nearest IMGW measurement station: {station} · approximately {distance} km away", "location.heroNearestStation": "Nearest IMGW measurement station: {station} · approximately {distance} km away",
"location.heroStationFallback": "Fallback data from IMGW station: {station}", "location.heroStationFallback": "Fallback data from IMGW station: {station}",
"location.heroStationFallbackWithDistance": "Fallback data from IMGW station: {station} · approximately {distance} km away", "location.heroStationFallbackWithDistance":
"Fallback data from IMGW station: {station} · approximately {distance} km away",
"location.heroDistantFallback": "The station is far from this place. Local conditions may differ.", "location.heroDistantFallback": "The station is far from this place. Local conditions may differ.",
"location.attribution": "Place search:", "location.attribution": "Place search:",
"location.gpsUse": "Use my location", "location.gpsUse": "Use my location",
"location.gpsLocating": "Finding your location…", "location.gpsLocating": "Finding your location…",
"location.gpsPromptTitle": "Show weather for your location?", "location.gpsPromptTitle": "Show weather for your location?",
"location.gpsPromptDescription": "With your permission, wtr. will use GPS to select your place and forecast. In Poland it will also match the nearest IMGW station. Your position will be rounded to approximately 100 metres.", "location.gpsPromptDescription":
"With your permission, wtr. will use GPS to select your place and forecast. In Poland it will also match the nearest IMGW station. Your position will be rounded to approximately 100 metres.",
"location.gpsAllow": "Use GPS", "location.gpsAllow": "Use GPS",
"location.gpsNotNow": "Not now", "location.gpsNotNow": "Not now",
"location.gpsSecureContext": "GPS requires HTTPS. On iPhone, a local HTTP address using an IP cannot display the system location prompt. Use the deployed HTTPS version.", "location.gpsSecureContext":
"GPS requires HTTPS. On iPhone, a local HTTP address using an IP cannot display the system location prompt. Use the deployed HTTPS version.",
"location.gpsUnavailable": "This browser does not provide GPS location access.", "location.gpsUnavailable": "This browser does not provide GPS location access.",
"location.gpsDenied": "Location access was denied. You can change the website permission in your browser settings.", "location.gpsDenied": "Location access was denied. You can change the website permission in your browser settings.",
"location.gpsTimeout": "Your location could not be determined in time. Try again.", "location.gpsTimeout": "Your location could not be determined in time. Try again.",
"location.gpsPositionUnavailable": "Your GPS location could not be determined. Check your device settings and try again.", "location.gpsPositionUnavailable":
"Your GPS location could not be determined. Check your device settings and try again.",
"location.gpsStationsPending": "The IMGW station list is not ready yet. Try again in a moment.", "location.gpsStationsPending": "The IMGW station list is not ready yet. Try again in a moment.",
"location.gpsFallbackName": "Current location", "location.gpsFallbackName": "Current location",
"location.gpsSelected": "Selected location: {location}.", "location.gpsSelected": "Selected location: {location}.",
@@ -436,11 +492,13 @@ const translations = {
"weather.pressureDetail": "Atmospheric pressure", "weather.pressureDetail": "Atmospheric pressure",
"weather.windSpeedDetail": "Current IMGW reading", "weather.windSpeedDetail": "Current IMGW reading",
"weather.windDirectionDetail": "Direction the wind is coming from", "weather.windDirectionDetail": "Direction the wind is coming from",
"weather.rainfallDetail": "Accumulated rainfall total from the IMGW reading. It does not mean that it is raining right now.", "weather.rainfallDetail":
"Accumulated rainfall total from the IMGW reading. It does not mean that it is raining right now.",
"weather.temperatureDetail": "Air temperature", "weather.temperatureDetail": "Air temperature",
"forecast.label": "Model forecast", "forecast.label": "Model forecast",
"forecast.title": "Upcoming hours and days", "forecast.title": "Upcoming hours and days",
"forecast.description": "Forecast for {location}. The current conditions above come from IMGW. The values below are a model forecast preferring IMGW.", "forecast.description":
"Forecast for {location}. The current conditions above come from IMGW. The values below are a model forecast preferring IMGW.",
"forecast.hourly": "Next 24 hours", "forecast.hourly": "Next 24 hours",
"forecast.daily": "7-day forecast", "forecast.daily": "7-day forecast",
"forecast.today": "Today", "forecast.today": "Today",
@@ -465,7 +523,8 @@ const translations = {
"forecast.maxProbability": "Max. rain chance", "forecast.maxProbability": "Max. rain chance",
"forecast.pastHour": "Past hour", "forecast.pastHour": "Past hour",
"forecast.source": "Forecast source:", "forecast.source": "Forecast source:",
"forecast.sourceCombinedDescription": "IMGW ALARO provides the available forecast hours. Open-Meteo supplements precipitation probability and extends the horizon to 7 days.", "forecast.sourceCombinedDescription":
"IMGW ALARO provides the available forecast hours. Open-Meteo supplements precipitation probability and extends the horizon to 7 days.",
"forecast.sourceFallbackDescription": "The forecast is shown as an Open-Meteo model forecast.", "forecast.sourceFallbackDescription": "The forecast is shown as an Open-Meteo model forecast.",
"forecast.error": "Unable to load the model forecast.", "forecast.error": "Unable to load the model forecast.",
"forecast.emptyTitle": "Forecast unavailable", "forecast.emptyTitle": "Forecast unavailable",
@@ -483,7 +542,8 @@ const translations = {
"station.all": "All stations", "station.all": "All stations",
"station.label": "Station {name}", "station.label": "Station {name}",
"station.parameters": "Current parameters", "station.parameters": "Current parameters",
"station.parametersDescription": "The latest measurement published by IMGW. Missing values are clearly marked and never replaced with estimates.", "station.parametersDescription":
"The latest measurement published by IMGW. Missing values are clearly marked and never replaced with estimates.",
"station.error": "Unable to load data for the selected IMGW station.", "station.error": "Unable to load data for the selected IMGW station.",
"station.quality": "Data details", "station.quality": "Data details",
"station.lastMeasurementImgw": "Latest IMGW measurement", "station.lastMeasurementImgw": "Latest IMGW measurement",
@@ -493,12 +553,15 @@ const translations = {
"station.publicApi": "Public IMGW API", "station.publicApi": "Public IMGW API",
"snapshot.label": "Measurement snapshot", "snapshot.label": "Measurement snapshot",
"snapshot.title": "Current parameter proportions", "snapshot.title": "Current parameter proportions",
"snapshot.description": "Visualisation of the current reading. The IMGW synoptic API does not provide historical data or an hourly forecast.", "snapshot.description":
"Visualisation of the current reading. The IMGW synoptic API does not provide historical data or an hourly forecast.",
"warnings.section": "IMGW notices", "warnings.section": "IMGW notices",
"warnings.title": "Warnings", "warnings.title": "Warnings",
"warnings.description": "Current meteorological and hydrological warnings published by IMGW. Area and validity details come directly from the API.", "warnings.description":
"Current meteorological and hydrological warnings published by IMGW. Area and validity details come directly from the API.",
"warnings.myProvince": "My area", "warnings.myProvince": "My area",
"warnings.myProvinceDescription": "IMGW notices for {province} are shown first, based on the location selected in weather.", "warnings.myProvinceDescription":
"IMGW notices for {province} are shown first, based on the location selected in weather.",
"warnings.myProvinceEmptyTitle": "No warnings for your area", "warnings.myProvinceEmptyTitle": "No warnings for your area",
"warnings.myProvinceEmptyDescription": "IMGW is not currently publishing active warnings for {province}.", "warnings.myProvinceEmptyDescription": "IMGW is not currently publishing active warnings for {province}.",
"warnings.otherRegions": "Other regions", "warnings.otherRegions": "Other regions",
@@ -507,7 +570,8 @@ const translations = {
"warnings.emptyTitle": "No active warnings", "warnings.emptyTitle": "No active warnings",
"warnings.emptyDescription": "IMGW is not currently publishing any meteorological or hydrological warnings.", "warnings.emptyDescription": "IMGW is not currently publishing any meteorological or hydrological warnings.",
"warnings.globalUnavailableTitle": "Official warnings only for Poland", "warnings.globalUnavailableTitle": "Official warnings only for Poland",
"warnings.globalUnavailableDescription": "Official weather warnings are currently supported only for Poland through IMGW. For this location you can use the Open-Meteo model forecast, but it is not treated as an official alert.", "warnings.globalUnavailableDescription":
"Official weather warnings are currently supported only for Poland through IMGW. For this location you can use the Open-Meteo model forecast, but it is not treated as an official alert.",
"warnings.drought": "Hydrological drought", "warnings.drought": "Hydrological drought",
"warnings.levelUnknown": "Level not specified", "warnings.levelUnknown": "Level not specified",
"warnings.level": "Level {level}", "warnings.level": "Level {level}",
@@ -528,7 +592,8 @@ const translations = {
"warnings.dashboard.viewAll": "View all", "warnings.dashboard.viewAll": "View all",
"hydro.section": "IMGW water monitoring", "hydro.section": "IMGW water monitoring",
"hydro.title": "Hydro", "hydro.title": "Hydro",
"hydro.description": "Latest available water level, temperature and flow readings. Each parameter may have a different update time.", "hydro.description":
"Latest available water level, temperature and flow readings. Each parameter may have a different update time.",
"hydro.error": "Unable to load IMGW hydrological stations.", "hydro.error": "Unable to load IMGW hydrological stations.",
"hydro.searchLabel": "Search hydrological stations", "hydro.searchLabel": "Search hydrological stations",
"hydro.searchPlaceholder": "Search by station, river or province…", "hydro.searchPlaceholder": "Search by station, river or province…",
@@ -541,7 +606,8 @@ const translations = {
"hydro.flow": "Flow", "hydro.flow": "Flow",
"hydro.levelMeasurement": "Level measurement: {date}", "hydro.levelMeasurement": "Level measurement: {date}",
"offline.title": "No connection", "offline.title": "No connection",
"offline.description": "wtr. cannot fetch current IMGW data right now. Recently visited views may still be available from your device cache.", "offline.description":
"wtr. cannot fetch current IMGW data right now. Recently visited views may still be available from your device cache.",
"offline.back": "Back to the app", "offline.back": "Back to the app",
}, },
} as const; } as const;
@@ -583,12 +649,15 @@ export function I18nProvider({ children }: PropsWithChildren) {
setLanguageState(nextLanguage); setLanguageState(nextLanguage);
}, []); }, []);
const value = useMemo<I18nContextValue>(() => ({ const value = useMemo<I18nContextValue>(
() => ({
language, language,
locale: language === "pl" ? "pl-PL" : "en-GB", locale: language === "pl" ? "pl-PL" : "en-GB",
setLanguage, setLanguage,
t: (key, params) => translate(language, key, params), t: (key, params) => translate(language, key, params),
}), [language, setLanguage]); }),
[language, setLanguage],
);
return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>; return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>;
} }

View File

@@ -1,8 +1,4 @@
import { import { normalizeHydroStation, normalizeSynopStation, normalizeWarning } from "@/lib/weather-utils";
normalizeHydroStation,
normalizeSynopStation,
normalizeWarning,
} from "@/lib/weather-utils";
import type { import type {
HydroStation, HydroStation,
MeteoStationPosition, MeteoStationPosition,
@@ -66,7 +62,7 @@ export async function fetchWarnings(signal?: AbortSignal): Promise<WeatherWarnin
fetchWarningsByKind("meteo", signal), fetchWarningsByKind("meteo", signal),
fetchWarningsByKind("hydro", signal), fetchWarningsByKind("hydro", signal),
]); ]);
const warnings = results.flatMap((result) => result.status === "fulfilled" ? result.value : []); const warnings = results.flatMap((result) => (result.status === "fulfilled" ? result.value : []));
if (results.every((result) => result.status === "rejected")) { if (results.every((result) => result.status === "rejected")) {
throw new Error("Nie udało się pobrać ostrzeżeń IMGW."); throw new Error("Nie udało się pobrać ostrzeżeń IMGW.");
} }

View File

@@ -53,19 +53,27 @@ function hasNumericValue(value: unknown) {
} }
function isFullWeatherRow(candidate: RawImgwHybridWeatherRow) { function isFullWeatherRow(candidate: RawImgwHybridWeatherRow) {
return hasNumericValue(candidate.Temperature) return (
&& hasNumericValue(candidate.Chill) hasNumericValue(candidate.Temperature) &&
&& hasNumericValue(candidate.Humidity) hasNumericValue(candidate.Chill) &&
&& hasNumericValue(candidate.Wind_Speed) hasNumericValue(candidate.Humidity) &&
&& hasNumericValue(candidate.PressureMSL); hasNumericValue(candidate.Wind_Speed) &&
hasNumericValue(candidate.PressureMSL)
);
} }
function hasPrecipitationValue(candidate: RawImgwHybridWeatherRow) { function hasPrecipitationValue(candidate: RawImgwHybridWeatherRow) {
return hasNumericValue(candidate.Precipitation10m) || hasNumericValue(candidate.Rain10m) || hasNumericValue(candidate.Snow10m); return (
hasNumericValue(candidate.Precipitation10m) ||
hasNumericValue(candidate.Rain10m) ||
hasNumericValue(candidate.Snow10m)
);
} }
function pickFullWeatherRow(rows: NormalizedHybridRow[]) { function pickFullWeatherRow(rows: NormalizedHybridRow[]) {
const tenMinuteRow = rows.find((candidate) => candidate.row.Type === "Type_Ten_Minutes" && isFullWeatherRow(candidate.row)); const tenMinuteRow = rows.find(
(candidate) => candidate.row.Type === "Type_Ten_Minutes" && isFullWeatherRow(candidate.row),
);
const hourlyRow = rows.find((candidate) => candidate.row.Type === "Type_Hour" && isFullWeatherRow(candidate.row)); const hourlyRow = rows.find((candidate) => candidate.row.Type === "Type_Hour" && isFullWeatherRow(candidate.row));
if (!tenMinuteRow) return hourlyRow ?? null; if (!tenMinuteRow) return hourlyRow ?? null;
if (!hourlyRow) return tenMinuteRow; if (!hourlyRow) return tenMinuteRow;
@@ -74,19 +82,20 @@ function pickFullWeatherRow(rows: NormalizedHybridRow[]) {
function pickPrecipitationRow(rows: NormalizedHybridRow[], fullRow: NormalizedHybridRow | null) { function pickPrecipitationRow(rows: NormalizedHybridRow[], fullRow: NormalizedHybridRow | null) {
if (fullRow && hasPrecipitationValue(fullRow.row)) return fullRow; if (fullRow && hasPrecipitationValue(fullRow.row)) return fullRow;
const precipitationRows = rows.filter((candidate) => candidate.row.Type === "Type_Ten_Minutes" && hasPrecipitationValue(candidate.row)); const precipitationRows = rows.filter(
(candidate) => candidate.row.Type === "Type_Ten_Minutes" && hasPrecipitationValue(candidate.row),
);
if (!precipitationRows.length) return null; if (!precipitationRows.length) return null;
if (!fullRow) return precipitationRows[0]; if (!fullRow) return precipitationRows[0];
return precipitationRows.reduce((best, candidate) => ( return precipitationRows.reduce((best, candidate) =>
Math.abs(candidate.timestamp - fullRow.timestamp) < Math.abs(best.timestamp - fullRow.timestamp) ? candidate : best Math.abs(candidate.timestamp - fullRow.timestamp) < Math.abs(best.timestamp - fullRow.timestamp) ? candidate : best,
)); );
} }
export function normalizeImgwCurrentWeather(payload: RawImgwHybridWeatherResponse): ImgwCurrentWeather | null { export function normalizeImgwCurrentWeather(payload: RawImgwHybridWeatherResponse): ImgwCurrentWeather | null {
if (!payload.data?.Valid || !Array.isArray(payload.data.Data)) return null; if (!payload.data?.Valid || !Array.isArray(payload.data.Data)) return null;
const rows = payload.data.Data const rows = payload.data.Data.flatMap((candidate): NormalizedHybridRow[] => {
.flatMap((candidate): NormalizedHybridRow[] => {
if (!candidate || typeof candidate !== "object") return []; if (!candidate || typeof candidate !== "object") return [];
const row = candidate as RawImgwHybridWeatherRow; const row = candidate as RawImgwHybridWeatherRow;
if (row.Type !== "Type_Ten_Minutes" && row.Type !== "Type_Hour") return []; if (row.Type !== "Type_Ten_Minutes" && row.Type !== "Type_Hour") return [];
@@ -131,12 +140,17 @@ export async function fetchImgwCurrentWeather(latitude: number, longitude: numbe
const params = new URLSearchParams({ latitude: String(latitude), longitude: String(longitude) }); const params = new URLSearchParams({ latitude: String(latitude), longitude: String(longitude) });
const response = await fetch(`/api/imgw-current?${params}`, { signal }); const response = await fetch(`/api/imgw-current?${params}`, { signal });
if (!response.ok) throw new Error("Nie udało się pobrać bieżącej analizy IMGW Hybrid."); if (!response.ok) throw new Error("Nie udało się pobrać bieżącej analizy IMGW Hybrid.");
return normalizeImgwCurrentWeather(await response.json() as RawImgwHybridWeatherResponse); return normalizeImgwCurrentWeather((await response.json()) as RawImgwHybridWeatherResponse);
} }
export async function fetchCurrentWeather(latitude: number, longitude: number, region: WeatherRegion, signal?: AbortSignal) { export async function fetchCurrentWeather(
latitude: number,
longitude: number,
region: WeatherRegion,
signal?: AbortSignal,
) {
const params = new URLSearchParams({ latitude: String(latitude), longitude: String(longitude), region }); const params = new URLSearchParams({ latitude: String(latitude), longitude: String(longitude), region });
const response = await fetch(`/api/current-weather?${params}`, { signal }); const response = await fetch(`/api/current-weather?${params}`, { signal });
if (!response.ok) throw new Error("Nie udało się pobrać bieżących warunków pogodowych."); if (!response.ok) throw new Error("Nie udało się pobrać bieżących warunków pogodowych.");
return await response.json() as ImgwCurrentWeather | null; return (await response.json()) as ImgwCurrentWeather | null;
} }

View File

@@ -1,9 +1,11 @@
export function isImgwNoProductsResponse(value: unknown) { export function isImgwNoProductsResponse(value: unknown) {
if (!value || typeof value !== "object") return false; if (!value || typeof value !== "object") return false;
const response = value as { status?: unknown; message?: unknown }; const response = value as { status?: unknown; message?: unknown };
return response.status === false return (
&& typeof response.message === "string" response.status === false &&
&& response.message.toLocaleLowerCase("en-US").includes("no products were found"); typeof response.message === "string" &&
response.message.toLocaleLowerCase("en-US").includes("no products were found")
);
} }
export async function readImgwResponseBody(response: Response) { export async function readImgwResponseBody(response: Response) {

View File

@@ -1,14 +1,22 @@
import type { Language } from "@/lib/i18n"; import type { Language } from "@/lib/i18n";
import type { LocationSearchResult, ReverseLocationResult } from "@/types/location"; import type { LocationSearchResult, ReverseLocationResult } from "@/types/location";
export async function fetchLocations(query: string, language: Language, signal?: AbortSignal): Promise<LocationSearchResult[]> { export async function fetchLocations(
query: string,
language: Language,
signal?: AbortSignal,
): Promise<LocationSearchResult[]> {
const params = new URLSearchParams({ query, language }); const params = new URLSearchParams({ query, language });
const response = await fetch(`/api/locations/search?${params}`, { signal }); const response = await fetch(`/api/locations/search?${params}`, { signal });
if (!response.ok) throw new Error("Location search is temporarily unavailable."); if (!response.ok) throw new Error("Location search is temporarily unavailable.");
return response.json() as Promise<LocationSearchResult[]>; return response.json() as Promise<LocationSearchResult[]>;
} }
export async function fetchReverseLocation(latitude: number, longitude: number, language: Language): Promise<ReverseLocationResult> { export async function fetchReverseLocation(
latitude: number,
longitude: number,
language: Language,
): Promise<ReverseLocationResult> {
const params = new URLSearchParams({ latitude: String(latitude), longitude: String(longitude), language }); const params = new URLSearchParams({ latitude: String(latitude), longitude: String(longitude), language });
const response = await fetch(`/api/locations/reverse?${params}`); const response = await fetch(`/api/locations/reverse?${params}`);
if (!response.ok) throw new Error("Reverse location search is temporarily unavailable."); if (!response.ok) throw new Error("Reverse location search is temporarily unavailable.");

View File

@@ -30,10 +30,11 @@ function normalizeName(value: string) {
function distanceKm(latitudeA: number, longitudeA: number, latitudeB: number, longitudeB: number) { function distanceKm(latitudeA: number, longitudeA: number, latitudeB: number, longitudeB: number) {
const earthRadiusKm = 6371; const earthRadiusKm = 6371;
const toRadians = (value: number) => value * Math.PI / 180; const toRadians = (value: number) => (value * Math.PI) / 180;
const latitudeDistance = toRadians(latitudeB - latitudeA); const latitudeDistance = toRadians(latitudeB - latitudeA);
const longitudeDistance = toRadians(longitudeB - longitudeA); const longitudeDistance = toRadians(longitudeB - longitudeA);
const a = Math.sin(latitudeDistance / 2) ** 2 + const a =
Math.sin(latitudeDistance / 2) ** 2 +
Math.cos(toRadians(latitudeA)) * Math.cos(toRadians(latitudeB)) * Math.sin(longitudeDistance / 2) ** 2; Math.cos(toRadians(latitudeA)) * Math.cos(toRadians(latitudeB)) * Math.sin(longitudeDistance / 2) ** 2;
return earthRadiusKm * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); return earthRadiusKm * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
} }
@@ -81,12 +82,26 @@ export function findNearestSynopStation(
} }
export function createSelectedLocation( export function createSelectedLocation(
location: Pick<LocationSearchResult, "name" | "province" | "district" | "country" | "countryCode" | "admin1" | "admin2" | "timezone" | "region" | "latitude" | "longitude">, location: Pick<
LocationSearchResult,
| "name"
| "province"
| "district"
| "country"
| "countryCode"
| "admin1"
| "admin2"
| "timezone"
| "region"
| "latitude"
| "longitude"
>,
stations: LocatedSynopStation[], stations: LocatedSynopStation[],
): SelectedLocation { ): SelectedLocation {
const countryCode = location.countryCode?.toUpperCase() ?? null; const countryCode = location.countryCode?.toUpperCase() ?? null;
const region = location.region ?? getWeatherRegionForCountryCode(countryCode); const region = location.region ?? getWeatherRegionForCountryCode(countryCode);
const nearest = region === "PL" const nearest =
region === "PL"
? stations.reduce<{ station: LocatedSynopStation; distanceKm: number } | null>((best, station) => { ? stations.reduce<{ station: LocatedSynopStation; distanceKm: number } | null>((best, station) => {
const distance = distanceKm(location.latitude, location.longitude, station.latitude, station.longitude); const distance = distanceKm(location.latitude, location.longitude, station.latitude, station.longitude);
return !best || distance < best.distanceKm ? { station, distanceKm: distance } : best; return !best || distance < best.distanceKm ? { station, distanceKm: distance } : best;
@@ -103,7 +118,8 @@ export function createSelectedLocation(
admin2: location.admin2, admin2: location.admin2,
timezone: location.timezone, timezone: location.timezone,
region, region,
countyTeryt: region === "PL" ? getCountyTerytForLocation(location.province, location.district, location.name) : null, countyTeryt:
region === "PL" ? getCountyTerytForLocation(location.province, location.district, location.name) : null,
latitude: location.latitude, latitude: location.latitude,
longitude: location.longitude, longitude: location.longitude,
stationId: nearest?.station.id ?? null, stationId: nearest?.station.id ?? null,

View File

@@ -28,7 +28,12 @@ interface SavePushSubscriptionOptions {
windSpeedUnit?: WindSpeedUnit; windSpeedUnit?: WindSpeedUnit;
} }
export async function savePushSubscription(subscription: PushSubscription, province: Province | null, language: Language, options: SavePushSubscriptionOptions = {}) { export async function savePushSubscription(
subscription: PushSubscription,
province: Province | null,
language: Language,
options: SavePushSubscriptionOptions = {},
) {
const response = await fetch("/api/notifications/subscriptions", { const response = await fetch("/api/notifications/subscriptions", {
method: "POST", method: "POST",
headers: { "content-type": "application/json" }, headers: { "content-type": "application/json" },

View File

@@ -85,22 +85,22 @@ const provinceByStationId: Record<string, Province> = {
}; };
const provinceLabels: Record<Province, Record<Language, string>> = { const provinceLabels: Record<Province, Record<Language, string>> = {
"dolnośląskie": { pl: "dolnośląskie", en: "Lower Silesian" }, dolnośląskie: { pl: "dolnośląskie", en: "Lower Silesian" },
"kujawsko-pomorskie": { pl: "kujawsko-pomorskie", en: "Kuyavian-Pomeranian" }, "kujawsko-pomorskie": { pl: "kujawsko-pomorskie", en: "Kuyavian-Pomeranian" },
"lubelskie": { pl: "lubelskie", en: "Lublin" }, lubelskie: { pl: "lubelskie", en: "Lublin" },
"lubuskie": { pl: "lubuskie", en: "Lubusz" }, lubuskie: { pl: "lubuskie", en: "Lubusz" },
"łódzkie": { pl: "łódzkie", en: "Łódź" }, łódzkie: { pl: "łódzkie", en: "Łódź" },
"małopolskie": { pl: "małopolskie", en: "Lesser Poland" }, małopolskie: { pl: "małopolskie", en: "Lesser Poland" },
"mazowieckie": { pl: "mazowieckie", en: "Masovian" }, mazowieckie: { pl: "mazowieckie", en: "Masovian" },
"opolskie": { pl: "opolskie", en: "Opole" }, opolskie: { pl: "opolskie", en: "Opole" },
"podkarpackie": { pl: "podkarpackie", en: "Subcarpathian" }, podkarpackie: { pl: "podkarpackie", en: "Subcarpathian" },
"podlaskie": { pl: "podlaskie", en: "Podlaskie" }, podlaskie: { pl: "podlaskie", en: "Podlaskie" },
"pomorskie": { pl: "pomorskie", en: "Pomeranian" }, pomorskie: { pl: "pomorskie", en: "Pomeranian" },
ląskie": { pl: "śląskie", en: "Silesian" }, śląskie: { pl: "śląskie", en: "Silesian" },
więtokrzyskie": { pl: "świętokrzyskie", en: "Świętokrzyskie" }, świętokrzyskie: { pl: "świętokrzyskie", en: "Świętokrzyskie" },
"warmińsko-mazurskie": { pl: "warmińsko-mazurskie", en: "Warmian-Masurian" }, "warmińsko-mazurskie": { pl: "warmińsko-mazurskie", en: "Warmian-Masurian" },
"wielkopolskie": { pl: "wielkopolskie", en: "Greater Poland" }, wielkopolskie: { pl: "wielkopolskie", en: "Greater Poland" },
"zachodniopomorskie": { pl: "zachodniopomorskie", en: "West Pomeranian" }, zachodniopomorskie: { pl: "zachodniopomorskie", en: "West Pomeranian" },
}; };
export const PROVINCES = Object.keys(provinceLabels) as Province[]; export const PROVINCES = Object.keys(provinceLabels) as Province[];
@@ -123,11 +123,11 @@ export function getProvinceFromTeryt(code: string) {
} }
export function getProvinceForStation(stationId: string | null) { export function getProvinceForStation(stationId: string | null) {
return stationId ? provinceByStationId[stationId] ?? null : null; return stationId ? (provinceByStationId[stationId] ?? null) : null;
} }
export function normalizeProvinceName(value: string | null | undefined) { export function normalizeProvinceName(value: string | null | undefined) {
return value ? provinceBySimplifiedName[simplifyProvinceName(value)] ?? null : null; return value ? (provinceBySimplifiedName[simplifyProvinceName(value)] ?? null) : null;
} }
export function getProvinceForSelection(locationProvince: string | null | undefined, stationId: string | null) { export function getProvinceForSelection(locationProvince: string | null | undefined, stationId: string | null) {

View File

@@ -49,12 +49,19 @@ function formatWarningValidity(warning: WeatherWarning, language: Language) {
} }
function buildWarningPayload(preference: WarningPushSubscription, warning: WeatherWarning) { function buildWarningPayload(preference: WarningPushSubscription, warning: WeatherWarning) {
const region = preference.province ? formatProvinceName(preference.province, preference.language) : preference.locationName ?? "wtr."; const region = preference.province
? formatProvinceName(preference.province, preference.language)
: (preference.locationName ?? "wtr.");
const title = warning.title || (preference.language === "pl" ? "Ostrzeżenie meteorologiczne" : "Weather warning"); const title = warning.title || (preference.language === "pl" ? "Ostrzeżenie meteorologiczne" : "Weather warning");
const validity = formatWarningValidity(warning, preference.language); const validity = formatWarningValidity(warning, preference.language);
const level = getWarningLevelLabel(warning, preference.language); const level = getWarningLevelLabel(warning, preference.language);
const bodyParts = preference.language === "pl" const bodyParts =
? [`${region}: ${level}`, validity, warning.probability !== null ? `prawdopodobieństwo ${warning.probability}%` : null] preference.language === "pl"
? [
`${region}: ${level}`,
validity,
warning.probability !== null ? `prawdopodobieństwo ${warning.probability}%` : null,
]
: [`${region}: ${level}`, validity, warning.probability !== null ? `${warning.probability}% probability` : null]; : [`${region}: ${level}`, validity, warning.probability !== null ? `${warning.probability}% probability` : null];
return { return {
@@ -73,10 +80,13 @@ export async function sendWarningNotification(preference: WarningPushSubscriptio
export async function sendTestNotification(preference: WarningPushSubscription) { export async function sendTestNotification(preference: WarningPushSubscription) {
ensureWebPushConfigured(); ensureWebPushConfigured();
const location = preference.province ? formatProvinceName(preference.province, preference.language) : preference.locationName ?? "Open-Meteo"; const location = preference.province
? formatProvinceName(preference.province, preference.language)
: (preference.locationName ?? "Open-Meteo");
const payload = JSON.stringify({ const payload = JSON.stringify({
title: preference.language === "pl" ? "wtr.: test powiadomień" : "wtr.: notification test", title: preference.language === "pl" ? "wtr.: test powiadomień" : "wtr.: notification test",
body: preference.language === "pl" body:
preference.language === "pl"
? `Powiadomienia są aktywne dla: ${location}.` ? `Powiadomienia są aktywne dla: ${location}.`
: `Notifications are active for: ${location}.`, : `Notifications are active for: ${location}.`,
url: "/settings", url: "/settings",

View File

@@ -1,7 +1,12 @@
import Database from "better-sqlite3"; import Database from "better-sqlite3";
import fs from "node:fs"; import fs from "node:fs";
import path from "node:path"; import path from "node:path";
import { DEFAULT_TEMPERATURE_UNIT, DEFAULT_WIND_SPEED_UNIT, isTemperatureUnit, isWindSpeedUnit } from "@/lib/weather-utils"; import {
DEFAULT_TEMPERATURE_UNIT,
DEFAULT_WIND_SPEED_UNIT,
isTemperatureUnit,
isWindSpeedUnit,
} from "@/lib/weather-utils";
import type { WarningPushSubscription } from "@/types/notifications"; import type { WarningPushSubscription } from "@/types/notifications";
import type { Province } from "@/types/province"; import type { Province } from "@/types/province";
import type { WeatherRegion } from "@/types/weather-region"; import type { WeatherRegion } from "@/types/weather-region";
@@ -121,7 +126,7 @@ function parseSubscription(row: PushSubscriptionRow): WarningPushSubscription |
return { return {
endpoint: row.endpoint, endpoint: row.endpoint,
subscription, subscription,
province: row.province === "global" ? null : row.province as Province, province: row.province === "global" ? null : (row.province as Province),
region, region,
language: row.language === "en" ? "en" : "pl", language: row.language === "en" ? "en" : "pl",
enabled: row.enabled === 1, enabled: row.enabled === 1,
@@ -143,7 +148,9 @@ function parseSubscription(row: PushSubscriptionRow): WarningPushSubscription |
} }
export function upsertPushSubscription(subscription: WarningPushSubscription) { export function upsertPushSubscription(subscription: WarningPushSubscription) {
getDatabase().prepare(` getDatabase()
.prepare(
`
INSERT INTO push_subscriptions ( INSERT INTO push_subscriptions (
endpoint, endpoint,
subscription_json, subscription_json,
@@ -197,7 +204,9 @@ export function upsertPushSubscription(subscription: WarningPushSubscription) {
temperature_unit = excluded.temperature_unit, temperature_unit = excluded.temperature_unit,
wind_speed_unit = excluded.wind_speed_unit, wind_speed_unit = excluded.wind_speed_unit,
updated_at = excluded.updated_at updated_at = excluded.updated_at
`).run({ `,
)
.run({
endpoint: subscription.endpoint, endpoint: subscription.endpoint,
subscriptionJson: JSON.stringify(subscription.subscription), subscriptionJson: JSON.stringify(subscription.subscription),
province: subscription.province ?? "global", province: subscription.province ?? "global",

View File

@@ -26,10 +26,21 @@ interface RawOpenMeteoCurrentResponse {
}; };
} }
function getOpenMeteoCondition(weatherCode: number | null, rain: number | null, showers: number | null, snowfall: number | null): CurrentWeatherCondition { function getOpenMeteoCondition(
weatherCode: number | null,
rain: number | null,
showers: number | null,
snowfall: number | null,
): CurrentWeatherCondition {
if (weatherCode !== null && weatherCode >= 95) return "thunderstorm"; if (weatherCode !== null && weatherCode >= 95) return "thunderstorm";
if ((snowfall ?? 0) > 0 || (weatherCode !== null && weatherCode >= 71 && weatherCode <= 77)) return "snow"; if ((snowfall ?? 0) > 0 || (weatherCode !== null && weatherCode >= 71 && weatherCode <= 77)) return "snow";
if ((rain ?? 0) > 0 || (showers ?? 0) > 0 || (weatherCode !== null && weatherCode >= 51 && weatherCode <= 67) || (weatherCode !== null && weatherCode >= 80 && weatherCode <= 82)) return "rain"; if (
(rain ?? 0) > 0 ||
(showers ?? 0) > 0 ||
(weatherCode !== null && weatherCode >= 51 && weatherCode <= 67) ||
(weatherCode !== null && weatherCode >= 80 && weatherCode <= 82)
)
return "rain";
return null; return null;
} }
@@ -76,7 +87,7 @@ export async function fetchImgwHybridCurrentWeather(latitude: number, longitude:
}); });
const response = await fetch(`${IMGW_HYBRID_URL}?${params}`, { next: { revalidate: 120 } }); const response = await fetch(`${IMGW_HYBRID_URL}?${params}`, { next: { revalidate: 120 } });
if (!response.ok) throw new Error("IMGW Hybrid service is unavailable."); if (!response.ok) throw new Error("IMGW Hybrid service is unavailable.");
return await response.json() as RawImgwHybridWeatherResponse; return (await response.json()) as RawImgwHybridWeatherResponse;
} }
export async function fetchServerCurrentWeather(latitude: number, longitude: number, region: WeatherRegion) { export async function fetchServerCurrentWeather(latitude: number, longitude: number, region: WeatherRegion) {
@@ -109,5 +120,5 @@ export async function fetchServerCurrentWeather(latitude: number, longitude: num
}); });
const response = await fetch(`${OPEN_METEO_FORECAST_URL}?${params}`, { next: { revalidate: 120 } }); const response = await fetch(`${OPEN_METEO_FORECAST_URL}?${params}`, { next: { revalidate: 120 } });
if (!response.ok) throw new Error("Open-Meteo current conditions are unavailable."); if (!response.ok) throw new Error("Open-Meteo current conditions are unavailable.");
return normalizeOpenMeteoCurrent(await response.json() as RawOpenMeteoCurrentResponse); return normalizeOpenMeteoCurrent((await response.json()) as RawOpenMeteoCurrentResponse);
} }

View File

@@ -18,7 +18,7 @@ export function parseForecastCoordinate(value: string | null, min: number, max:
async function readImgwPayload(response: Response | null) { async function readImgwPayload(response: Response | null) {
if (!response?.ok) return null; if (!response?.ok) return null;
try { try {
return await response.json() as RawImgwForecastResponse; return (await response.json()) as RawImgwForecastResponse;
} catch { } catch {
return null; return null;
} }
@@ -29,7 +29,8 @@ export async function fetchServerForecast(latitude: number, longitude: number, r
latitude: String(latitude), latitude: String(latitude),
longitude: String(longitude), longitude: String(longitude),
hourly: "temperature_2m,apparent_temperature,precipitation_probability,precipitation,weather_code,wind_speed_10m", hourly: "temperature_2m,apparent_temperature,precipitation_probability,precipitation,weather_code,wind_speed_10m",
daily: "weather_code,temperature_2m_max,temperature_2m_min,precipitation_probability_max,precipitation_sum,sunrise,sunset", daily:
"weather_code,temperature_2m_max,temperature_2m_min,precipitation_probability_max,precipitation_sum,sunrise,sunset",
timezone: region === "PL" ? "Europe/Warsaw" : "auto", timezone: region === "PL" ? "Europe/Warsaw" : "auto",
forecast_days: "7", forecast_days: "7",
wind_speed_unit: "ms", wind_speed_unit: "ms",
@@ -42,14 +43,20 @@ export async function fetchServerForecast(latitude: number, longitude: number, r
}); });
const [openMeteoResponse, imgwResponse] = await Promise.all([ const [openMeteoResponse, imgwResponse] = await Promise.all([
fetch(`${OPEN_METEO_FORECAST_URL}?${openMeteoParams}`, { next: { revalidate: 900 }, signal: AbortSignal.timeout(OPEN_METEO_TIMEOUT_MS) }), fetch(`${OPEN_METEO_FORECAST_URL}?${openMeteoParams}`, {
next: { revalidate: 900 },
signal: AbortSignal.timeout(OPEN_METEO_TIMEOUT_MS),
}),
region === "PL" region === "PL"
? fetch(`${IMGW_FORECAST_URL}?${imgwParams}`, { next: { revalidate: 900 }, signal: AbortSignal.timeout(IMGW_TIMEOUT_MS) }).catch(() => null) ? fetch(`${IMGW_FORECAST_URL}?${imgwParams}`, {
next: { revalidate: 900 },
signal: AbortSignal.timeout(IMGW_TIMEOUT_MS),
}).catch(() => null)
: Promise.resolve(null), : Promise.resolve(null),
]); ]);
if (!openMeteoResponse.ok) throw new Error("Forecast service is unavailable."); if (!openMeteoResponse.ok) throw new Error("Forecast service is unavailable.");
const openMeteoPayload = await openMeteoResponse.json() as RawWeatherForecast; const openMeteoPayload = (await openMeteoResponse.json()) as RawWeatherForecast;
const imgwPayload = await readImgwPayload(imgwResponse); const imgwPayload = await readImgwPayload(imgwResponse);
return mergeForecastSources(openMeteoPayload, imgwPayload, region); return mergeForecastSources(openMeteoPayload, imgwPayload, region);
} }

View File

@@ -14,8 +14,10 @@ export async function fetchMeteoWarnings(signal?: AbortSignal): Promise<WeatherW
if (!response.ok) { if (!response.ok) {
const details = await readImgwResponseBody(response); const details = await readImgwResponseBody(response);
if (response.status === 404 && isImgwNoProductsResponse(details.json)) return []; if (response.status === 404 && isImgwNoProductsResponse(details.json)) return [];
throw new Error(`Unable to load IMGW meteorological warnings: ${response.status}${details.text ? ` ${details.text.slice(0, 240)}` : ""}`); throw new Error(
`Unable to load IMGW meteorological warnings: ${response.status}${details.text ? ` ${details.text.slice(0, 240)}` : ""}`,
);
} }
const rows = await response.json() as RawWarning[]; const rows = (await response.json()) as RawWarning[];
return Array.isArray(rows) ? rows.map((warning, index) => normalizeWarning(warning, "meteo", index)) : []; return Array.isArray(rows) ? rows.map((warning, index) => normalizeWarning(warning, "meteo", index)) : [];
} }

View File

@@ -5,8 +5,18 @@ 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 { TemperatureUnit, WindSpeedUnit } from "@/types/units"; import type { TemperatureUnit, WindSpeedUnit } from "@/types/units";
import { DEFAULT_APP_SECTION_VISIBILITY, normalizeAppSectionVisibility, type AppSectionId, type AppSectionVisibility } from "@/lib/app-sections"; import {
import { DEFAULT_DASHBOARD_SECTION_VISIBILITY, normalizeDashboardSectionVisibility, type DashboardSectionId, type DashboardSectionVisibility } from "@/lib/dashboard-sections"; DEFAULT_APP_SECTION_VISIBILITY,
normalizeAppSectionVisibility,
type AppSectionId,
type AppSectionVisibility,
} from "@/lib/app-sections";
import {
DEFAULT_DASHBOARD_SECTION_VISIBILITY,
normalizeDashboardSectionVisibility,
type DashboardSectionId,
type DashboardSectionVisibility,
} from "@/lib/dashboard-sections";
import { DEFAULT_TEMPERATURE_UNIT, DEFAULT_WIND_SPEED_UNIT } from "@/lib/weather-utils"; import { DEFAULT_TEMPERATURE_UNIT, DEFAULT_WIND_SPEED_UNIT } from "@/lib/weather-utils";
import type { WeatherRegion } from "@/types/weather-region"; import type { WeatherRegion } from "@/types/weather-region";
import { getWeatherRegionForCountryCode } from "@/types/weather-region"; import { getWeatherRegionForCountryCode } from "@/types/weather-region";
@@ -70,10 +80,12 @@ export const useWeatherStore = create<WeatherStore>()(
setWarningNotificationProvince: (province) => set({ warningNotificationProvince: province }), setWarningNotificationProvince: (province) => set({ warningNotificationProvince: province }),
setTemperatureUnit: (unit) => set({ temperatureUnit: unit }), setTemperatureUnit: (unit) => set({ temperatureUnit: unit }),
setWindSpeedUnit: (unit) => set({ windSpeedUnit: unit }), setWindSpeedUnit: (unit) => set({ windSpeedUnit: unit }),
setDashboardSectionVisible: (section, visible) => set((state) => ({ setDashboardSectionVisible: (section, visible) =>
set((state) => ({
dashboardSections: { ...state.dashboardSections, [section]: visible }, dashboardSections: { ...state.dashboardSections, [section]: visible },
})), })),
setAppSectionVisible: (section, visible) => set((state) => ({ setAppSectionVisible: (section, visible) =>
set((state) => ({
appSections: { ...state.appSections, [section]: visible }, appSections: { ...state.appSections, [section]: visible },
})), })),
}), }),
@@ -81,7 +93,10 @@ export const useWeatherStore = create<WeatherStore>()(
name: "wtr:preferences", name: "wtr:preferences",
migrate: (persisted) => { migrate: (persisted) => {
const state = persisted as Partial<WeatherStore> | undefined; const state = persisted as Partial<WeatherStore> | undefined;
const location = state?.selectedLocation as (Partial<SelectedLocation> & { region?: WeatherRegion }) | null | undefined; const location = state?.selectedLocation as
| (Partial<SelectedLocation> & { region?: WeatherRegion })
| null
| undefined;
if (!state) return persisted; if (!state) return persisted;
const dashboardSections = normalizeDashboardSectionVisibility(state.dashboardSections); const dashboardSections = normalizeDashboardSectionVisibility(state.dashboardSections);
const appSections = normalizeAppSectionVisibility(state.appSections); const appSections = normalizeAppSectionVisibility(state.appSections);

View File

@@ -50,7 +50,8 @@ const mazowieckieCountyTerytByName: Record<string, string> = {
}; };
function normalizeRegionName(value: string | null | undefined) { function normalizeRegionName(value: string | null | undefined) {
return value return (
value
?.replace(/[Łł]/g, "l") ?.replace(/[Łł]/g, "l")
.normalize("NFD") .normalize("NFD")
.replace(/[\u0300-\u036f]/g, "") .replace(/[\u0300-\u036f]/g, "")
@@ -62,7 +63,8 @@ function normalizeRegionName(value: string | null | undefined) {
.replace(/\s+/g, "_") .replace(/\s+/g, "_")
.replace(/[^a-z0-9_]/g, "") .replace(/[^a-z0-9_]/g, "")
.replace(/^warszawa_zachodnia$/, "warszawski_zachodni") .replace(/^warszawa_zachodnia$/, "warszawski_zachodni")
.trim() ?? ""; .trim() ?? ""
);
} }
function normalizeTerytCountyCode(value: string) { function normalizeTerytCountyCode(value: string) {
@@ -70,7 +72,11 @@ function normalizeTerytCountyCode(value: string) {
return /^\d{4}/.test(code) ? code.slice(0, 4) : null; return /^\d{4}/.test(code) ? code.slice(0, 4) : null;
} }
export function getCountyTerytForLocation(province: string | null | undefined, district: string | null | undefined, locationName: string | null | undefined) { export function getCountyTerytForLocation(
province: string | null | undefined,
district: string | null | undefined,
locationName: string | null | undefined,
) {
const normalizedProvince = getProvinceForSelection(province, null); const normalizedProvince = getProvinceForSelection(province, null);
if (normalizedProvince !== "mazowieckie") return null; if (normalizedProvince !== "mazowieckie") return null;
@@ -79,7 +85,7 @@ export function getCountyTerytForLocation(province: string | null | undefined, d
if (districtMatch) return districtMatch; if (districtMatch) return districtMatch;
const normalizedLocation = normalizeRegionName(locationName); const normalizedLocation = normalizeRegionName(locationName);
return normalizedLocation ? mazowieckieCountyTerytByName[normalizedLocation] ?? null : null; return normalizedLocation ? (mazowieckieCountyTerytByName[normalizedLocation] ?? null) : null;
} }
export function warningMatchesCounty(warning: WeatherWarning, countyTeryt: string | null | undefined) { export function warningMatchesCounty(warning: WeatherWarning, countyTeryt: string | null | undefined) {
@@ -87,7 +93,11 @@ export function warningMatchesCounty(warning: WeatherWarning, countyTeryt: strin
return warning.terytCodes.some((code) => normalizeTerytCountyCode(code) === countyTeryt); return warning.terytCodes.some((code) => normalizeTerytCountyCode(code) === countyTeryt);
} }
export function warningMatchesLocalSelection(warning: WeatherWarning, province: Province | null, selectedLocation?: SelectedLocation | null) { export function warningMatchesLocalSelection(
warning: WeatherWarning,
province: Province | null,
selectedLocation?: SelectedLocation | null,
) {
if (warning.kind === "meteo" && selectedLocation?.countyTeryt) { if (warning.kind === "meteo" && selectedLocation?.countyTeryt) {
return warningMatchesCounty(warning, selectedLocation.countyTeryt); return warningMatchesCounty(warning, selectedLocation.countyTeryt);
} }

View File

@@ -5,7 +5,12 @@ 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_TEMPERATURE_UNIT, DEFAULT_WIND_SPEED_UNIT, formatTemperature as formatDisplayTemperature, formatWindSpeed } from "@/lib/weather-utils"; import {
DEFAULT_TEMPERATURE_UNIT,
DEFAULT_WIND_SPEED_UNIT,
formatTemperature as formatDisplayTemperature,
formatWindSpeed,
} from "@/lib/weather-utils";
import type { TemperatureUnit, WindSpeedUnit } from "@/types/units"; import type { TemperatureUnit, WindSpeedUnit } from "@/types/units";
export type WeatherBriefSeverity = "normal" | "attention" | "warning"; export type WeatherBriefSeverity = "normal" | "attention" | "warning";
@@ -74,14 +79,17 @@ function getWarsawDateKey(date: Date, dayOffset = 0) {
const part = (type: Intl.DateTimeFormatPartTypes) => parts.find((entry) => entry.type === type)?.value ?? ""; const part = (type: Intl.DateTimeFormatPartTypes) => parts.find((entry) => entry.type === type)?.value ?? "";
if (dayOffset === 0) return `${part("year")}-${part("month")}-${part("day")}`; if (dayOffset === 0) return `${part("year")}-${part("month")}-${part("day")}`;
const shiftedDate = new Date(Date.UTC(Number(part("year")), Number(part("month")) - 1, Number(part("day")) + dayOffset, 12)); const shiftedDate = new Date(
Date.UTC(Number(part("year")), Number(part("month")) - 1, Number(part("day")) + dayOffset, 12),
);
const shiftedParts = new Intl.DateTimeFormat("en-CA", { const shiftedParts = new Intl.DateTimeFormat("en-CA", {
timeZone: "Europe/Warsaw", timeZone: "Europe/Warsaw",
year: "numeric", year: "numeric",
month: "2-digit", month: "2-digit",
day: "2-digit", day: "2-digit",
}).formatToParts(shiftedDate); }).formatToParts(shiftedDate);
const shiftedPart = (type: Intl.DateTimeFormatPartTypes) => shiftedParts.find((entry) => entry.type === type)?.value ?? ""; const shiftedPart = (type: Intl.DateTimeFormatPartTypes) =>
shiftedParts.find((entry) => entry.type === type)?.value ?? "";
return `${shiftedPart("year")}-${shiftedPart("month")}-${shiftedPart("day")}`; return `${shiftedPart("year")}-${shiftedPart("month")}-${shiftedPart("day")}`;
} }
@@ -98,7 +106,9 @@ function getUpcomingHours(hours: HourlyForecast[], now: Date, limit = 24) {
const currentTimestamp = parseForecastHour(currentHour); const currentTimestamp = parseForecastHour(currentHour);
const upcoming = hours.filter((hour) => { const upcoming = hours.filter((hour) => {
const hourTimestamp = parseForecastHour(hour.time); const hourTimestamp = parseForecastHour(hour.time);
return hourTimestamp === null || currentTimestamp === null ? hour.time >= currentHour : hourTimestamp >= currentTimestamp; return hourTimestamp === null || currentTimestamp === null
? hour.time >= currentHour
: hourTimestamp >= currentTimestamp;
}); });
return upcoming.slice(0, limit); return upcoming.slice(0, limit);
} }
@@ -135,11 +145,18 @@ function getPeakHour(hours: HourlyForecast[], selector: (hour: HourlyForecast) =
}, null); }, null);
} }
function isRelevantMeteoWarning(warning: WeatherWarning, province: Province | null, countyTeryt: string | null | undefined, now: number) { function isRelevantMeteoWarning(
warning: WeatherWarning,
province: Province | null,
countyTeryt: string | null | undefined,
now: number,
) {
const validTo = getTimestamp(warning.validTo); const validTo = getTimestamp(warning.validTo);
return warning.kind === "meteo" return (
&& (countyTeryt ? warningMatchesCounty(warning, countyTeryt) : !province || warning.provinces.includes(province)) warning.kind === "meteo" &&
&& (validTo === null || validTo > now); (countyTeryt ? warningMatchesCounty(warning, countyTeryt) : !province || warning.provinces.includes(province)) &&
(validTo === null || validTo > now)
);
} }
function warningOverlapsWarsawDate(warning: WeatherWarning, dateKey: string) { function warningOverlapsWarsawDate(warning: WeatherWarning, dateKey: string) {
@@ -148,10 +165,17 @@ function warningOverlapsWarsawDate(warning: WeatherWarning, dateKey: string) {
return (!validFromKey || validFromKey <= dateKey) && (!validToKey || validToKey >= dateKey); return (!validFromKey || validFromKey <= dateKey) && (!validToKey || validToKey >= dateKey);
} }
function isRelevantMeteoWarningForDate(warning: WeatherWarning, province: Province | null, countyTeryt: string | null | undefined, dateKey: string) { function isRelevantMeteoWarningForDate(
return warning.kind === "meteo" warning: WeatherWarning,
&& (countyTeryt ? warningMatchesCounty(warning, countyTeryt) : !province || warning.provinces.includes(province)) province: Province | null,
&& warningOverlapsWarsawDate(warning, dateKey); countyTeryt: string | null | undefined,
dateKey: string,
) {
return (
warning.kind === "meteo" &&
(countyTeryt ? warningMatchesCounty(warning, countyTeryt) : !province || warning.provinces.includes(province)) &&
warningOverlapsWarsawDate(warning, dateKey)
);
} }
function compareWarnings(a: WeatherWarning, b: WeatherWarning) { function compareWarnings(a: WeatherWarning, b: WeatherWarning) {
@@ -164,7 +188,12 @@ function hasWeatherCode(hours: HourlyForecast[], predicate: (code: number) => bo
return hours.some((hour) => hour.weatherCode !== null && predicate(hour.weatherCode)); return hours.some((hour) => hour.weatherCode !== null && predicate(hour.weatherCode));
} }
function getTomorrowCondition(hours: HourlyForecast[], rainfallTotal: number | null, maximumProbability: number | null, language: Language) { function getTomorrowCondition(
hours: HourlyForecast[],
rainfallTotal: number | null,
maximumProbability: number | null,
language: Language,
) {
const hasThunderstorm = hasWeatherCode(hours, (code) => code >= 95); const hasThunderstorm = hasWeatherCode(hours, (code) => code >= 95);
const hasSnow = hasWeatherCode(hours, (code) => (code >= 71 && code <= 77) || code === 85 || code === 86); const hasSnow = hasWeatherCode(hours, (code) => (code >= 71 && code <= 77) || code === 85 || code === 86);
const hasRain = hasWeatherCode(hours, (code) => (code >= 61 && code <= 67) || (code >= 80 && code <= 82)); const hasRain = hasWeatherCode(hours, (code) => (code >= 61 && code <= 67) || (code >= 80 && code <= 82));
@@ -199,7 +228,17 @@ 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, temperatureUnit = DEFAULT_TEMPERATURE_UNIT, 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;
@@ -214,12 +253,17 @@ export function buildWeatherBrief({ forecast, warnings, province, countyTeryt, l
const maximumProbability = getMaximum(hours.map((hour) => hour.precipitationProbability)); const maximumProbability = getMaximum(hours.map((hour) => hour.precipitationProbability));
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
? `${formatDisplayTemperature(minimumTemperature, language, temperatureUnit)}-${formatDisplayTemperature(maximumTemperature, language, temperatureUnit)}` ? `${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;
const severity: WeatherBriefSeverity = topWarning ? "warning" : hasRainSignal || hasWindSignal ? "attention" : "normal"; const severity: WeatherBriefSeverity = topWarning
? "warning"
: hasRainSignal || hasWindSignal
? "attention"
: "normal";
const provinceLabel = province ? formatProvinceName(province, language) : null; const provinceLabel = province ? formatProvinceName(province, language) : null;
const headline = topWarning const headline = topWarning
@@ -241,45 +285,71 @@ export function buildWeatherBrief({ forecast, warnings, province, countyTeryt, l
const body: string[] = []; const body: string[] = [];
if (topWarning) { if (topWarning) {
const warningRegion = provinceLabel ? `${provinceLabel}: ` : ""; const warningRegion = provinceLabel ? `${provinceLabel}: ` : "";
const probability = topWarning.probability !== null const probability =
? language === "pl" ? ` Prawdopodobieństwo: ${topWarning.probability}%.` : ` Probability: ${topWarning.probability}%.` topWarning.probability !== null
? language === "pl"
? ` Prawdopodobieństwo: ${topWarning.probability}%.`
: ` Probability: ${topWarning.probability}%.`
: ""; : "";
body.push(language === "pl" body.push(
language === "pl"
? `${warningRegion}${topWarning.title || "ostrzeżenie meteorologiczne"}${topWarning.level !== null ? `, stopień ${topWarning.level}` : ""}.${probability}` ? `${warningRegion}${topWarning.title || "ostrzeżenie meteorologiczne"}${topWarning.level !== null ? `, stopień ${topWarning.level}` : ""}.${probability}`
: `${warningRegion}${topWarning.title || "weather warning"}${topWarning.level !== null ? `, level ${topWarning.level}` : ""}.${probability}`); : `${warningRegion}${topWarning.title || "weather warning"}${topWarning.level !== null ? `, level ${topWarning.level}` : ""}.${probability}`,
);
} }
if (temperatureRange) { if (temperatureRange) {
body.push(language === "pl" body.push(
language === "pl"
? `Temperatura w najbliższych 24 godzinach: ${temperatureRange}.` ? `Temperatura w najbliższych 24 godzinach: ${temperatureRange}.`
: `Temperature over the next 24 hours: ${temperatureRange}.`); : `Temperature over the next 24 hours: ${temperatureRange}.`,
);
} }
if (rainfallTotal !== null || maximumProbability !== null) { if (rainfallTotal !== null || maximumProbability !== null) {
const rainWindow = rainPeakHour && maximumProbability !== null && maximumProbability >= 20 const rainWindow =
? language === "pl" ? ` Największa szansa około ${formatHour(rainPeakHour.time)}.` : ` Highest chance around ${formatHour(rainPeakHour.time)}.` rainPeakHour && maximumProbability !== null && maximumProbability >= 20
? language === "pl"
? ` Największa szansa około ${formatHour(rainPeakHour.time)}.`
: ` Highest chance around ${formatHour(rainPeakHour.time)}.`
: ""; : "";
body.push(language === "pl" body.push(
language === "pl"
? `Opad: ${rainfallTotal === null ? "brak pełnych danych" : formatRainfall(rainfallTotal, language)}, maks. szansa ${maximumProbability === null ? "brak danych" : `${maximumProbability}%`}.${rainWindow}` ? `Opad: ${rainfallTotal === null ? "brak pełnych danych" : formatRainfall(rainfallTotal, language)}, maks. szansa ${maximumProbability === null ? "brak danych" : `${maximumProbability}%`}.${rainWindow}`
: `Rainfall: ${rainfallTotal === null ? "not fully available" : formatRainfall(rainfallTotal, language)}, max chance ${maximumProbability === null ? "unavailable" : `${maximumProbability}%`}.${rainWindow}`); : `Rainfall: ${rainfallTotal === null ? "not fully available" : formatRainfall(rainfallTotal, language)}, max chance ${maximumProbability === null ? "unavailable" : `${maximumProbability}%`}.${rainWindow}`,
);
} }
if (maximumWind !== null) { if (maximumWind !== null) {
body.push(language === "pl" body.push(
language === "pl"
? `Wiatr maksymalnie do ${formatWindSpeed(maximumWind, language, windSpeedUnit)}.` ? `Wiatr maksymalnie do ${formatWindSpeed(maximumWind, language, windSpeedUnit)}.`
: `Wind up to ${formatWindSpeed(maximumWind, language, windSpeedUnit)}.`); : `Wind up to ${formatWindSpeed(maximumWind, language, windSpeedUnit)}.`,
);
} }
if (conditionPeakHour) { if (conditionPeakHour) {
body.push(language === "pl" body.push(
language === "pl"
? `Dominujący sygnał modelu: ${getForecastCondition(conditionPeakHour.weatherCode, language).toLowerCase()}.` ? `Dominujący sygnał modelu: ${getForecastCondition(conditionPeakHour.weatherCode, language).toLowerCase()}.`
: `Model signal: ${getForecastCondition(conditionPeakHour.weatherCode, language).toLowerCase()}.`); : `Model signal: ${getForecastCondition(conditionPeakHour.weatherCode, language).toLowerCase()}.`,
);
} }
const pushParts = [ const pushParts = [
`${locationName}:`, `${locationName}:`,
temperatureRange, temperatureRange,
maximumProbability !== null ? (language === "pl" ? `opad do ${maximumProbability}%` : `rain up to ${maximumProbability}%`) : null, maximumProbability !== null
maximumWind !== null ? (language === "pl" ? `wiatr ${formatWindSpeed(maximumWind, language, windSpeedUnit)}` : `wind ${formatWindSpeed(maximumWind, language, windSpeedUnit)}`) : null, ? language === "pl"
? `opad do ${maximumProbability}%`
: `rain up to ${maximumProbability}%`
: null,
maximumWind !== null
? language === "pl"
? `wiatr ${formatWindSpeed(maximumWind, language, windSpeedUnit)}`
: `wind ${formatWindSpeed(maximumWind, language, windSpeedUnit)}`
: null,
].filter(Boolean); ].filter(Boolean);
const warningPrefix = topWarning const warningPrefix = topWarning
? language === "pl" ? `IMGW: ${topWarning.title || "ostrzeżenie"}. ` : `IMGW: ${topWarning.title || "warning"}. ` ? language === "pl"
? `IMGW: ${topWarning.title || "ostrzeżenie"}. `
: `IMGW: ${topWarning.title || "warning"}. `
: ""; : "";
return { return {
@@ -292,7 +362,17 @@ export function buildWeatherBrief({ forecast, warnings, province, countyTeryt, l
}; };
} }
export function buildTomorrowWeatherBrief({ forecast, warnings, province, countyTeryt, locationName, language, temperatureUnit = DEFAULT_TEMPERATURE_UNIT, 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;
@@ -308,16 +388,19 @@ export function buildTomorrowWeatherBrief({ forecast, warnings, province, county
const maximumProbability = getMaximum(hours.map((hour) => hour.precipitationProbability)); const maximumProbability = getMaximum(hours.map((hour) => hour.precipitationProbability));
const rainPeakHour = getPeakHour(hours, (hour) => hour.precipitationProbability); const rainPeakHour = getPeakHour(hours, (hour) => hour.precipitationProbability);
const hasThunderstorm = hasWeatherCode(hours, (code) => code >= 95); const hasThunderstorm = hasWeatherCode(hours, (code) => code >= 95);
const hasRainSignal = hasThunderstorm const hasRainSignal =
|| (rainfallTotal ?? 0) >= 0.5 hasThunderstorm ||
|| (maximumProbability ?? 0) >= 35 (rainfallTotal ?? 0) >= 0.5 ||
|| hasWeatherCode(hours, (code) => (code >= 51 && code <= 67) || (code >= 80 && code <= 82)); (maximumProbability ?? 0) >= 35 ||
hasWeatherCode(hours, (code) => (code >= 51 && code <= 67) || (code >= 80 && code <= 82));
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
? `${formatDisplayTemperature(minimumTemperature, language, temperatureUnit)} / ${formatDisplayTemperature(maximumTemperature, language, temperatureUnit)}` ? `${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;
const headline = topWarning const headline = topWarning
@@ -343,38 +426,49 @@ export function buildTomorrowWeatherBrief({ forecast, warnings, province, county
const body: string[] = []; const body: string[] = [];
if (topWarning) { if (topWarning) {
const warningRegion = provinceLabel ? `${provinceLabel}: ` : ""; const warningRegion = provinceLabel ? `${provinceLabel}: ` : "";
const probability = topWarning.probability !== null const probability =
? language === "pl" ? ` Prawdopodobieństwo: ${topWarning.probability}%.` : ` Probability: ${topWarning.probability}%.` topWarning.probability !== null
? language === "pl"
? ` Prawdopodobieństwo: ${topWarning.probability}%.`
: ` Probability: ${topWarning.probability}%.`
: ""; : "";
body.push(language === "pl" body.push(
language === "pl"
? `${warningRegion}${topWarning.title || "ostrzeżenie meteorologiczne"}${topWarning.level !== null ? `, stopień ${topWarning.level}` : ""}.${probability}` ? `${warningRegion}${topWarning.title || "ostrzeżenie meteorologiczne"}${topWarning.level !== null ? `, stopień ${topWarning.level}` : ""}.${probability}`
: `${warningRegion}${topWarning.title || "weather warning"}${topWarning.level !== null ? `, level ${topWarning.level}` : ""}.${probability}`); : `${warningRegion}${topWarning.title || "weather warning"}${topWarning.level !== null ? `, level ${topWarning.level}` : ""}.${probability}`,
);
} }
if (temperatureRange) { if (temperatureRange) {
body.push(language === "pl" body.push(
? `Temperatura jutro: ${temperatureRange}.` language === "pl" ? `Temperatura jutro: ${temperatureRange}.` : `Temperature tomorrow: ${temperatureRange}.`,
: `Temperature tomorrow: ${temperatureRange}.`); );
} }
body.push(language === "pl" body.push(language === "pl" ? `Sygnał modelu: ${condition}.` : `Model signal: ${condition}.`);
? `Sygnał modelu: ${condition}.`
: `Model signal: ${condition}.`);
if (hasRainSignal || (rainfallTotal ?? 0) > 0) { if (hasRainSignal || (rainfallTotal ?? 0) > 0) {
const rainWindow = rainPeakHour && maximumProbability !== null && maximumProbability >= 20 const rainWindow =
? language === "pl" ? ` Największa szansa około ${formatHour(rainPeakHour.time)}.` : ` Highest chance around ${formatHour(rainPeakHour.time)}.` rainPeakHour && maximumProbability !== null && maximumProbability >= 20
? language === "pl"
? ` Największa szansa około ${formatHour(rainPeakHour.time)}.`
: ` Highest chance around ${formatHour(rainPeakHour.time)}.`
: ""; : "";
body.push(language === "pl" body.push(
language === "pl"
? `Opad: ${rainfallTotal === null ? "brak pełnych danych" : formatRainfall(rainfallTotal, language)}, maks. szansa ${maximumProbability === null ? "brak danych" : `${maximumProbability}%`}.${rainWindow}` ? `Opad: ${rainfallTotal === null ? "brak pełnych danych" : formatRainfall(rainfallTotal, language)}, maks. szansa ${maximumProbability === null ? "brak danych" : `${maximumProbability}%`}.${rainWindow}`
: `Precipitation: ${rainfallTotal === null ? "not fully available" : formatRainfall(rainfallTotal, language)}, max chance ${maximumProbability === null ? "unavailable" : `${maximumProbability}%`}.${rainWindow}`); : `Precipitation: ${rainfallTotal === null ? "not fully available" : formatRainfall(rainfallTotal, language)}, max chance ${maximumProbability === null ? "unavailable" : `${maximumProbability}%`}.${rainWindow}`,
);
} else { } else {
body.push(language === "pl" ? "Bez istotnego opadu w prognozie." : "No significant precipitation in the forecast."); body.push(language === "pl" ? "Bez istotnego opadu w prognozie." : "No significant precipitation in the forecast.");
} }
if (maximumWind !== null) { if (maximumWind !== null) {
body.push(language === "pl" body.push(
language === "pl"
? `Wiatr maksymalnie do ${formatWindSpeed(maximumWind, language, windSpeedUnit)}.` ? `Wiatr maksymalnie do ${formatWindSpeed(maximumWind, language, windSpeedUnit)}.`
: `Wind up to ${formatWindSpeed(maximumWind, language, windSpeedUnit)}.`); : `Wind up to ${formatWindSpeed(maximumWind, language, windSpeedUnit)}.`,
);
} }
const rainPushPart = (hasRainSignal || (rainfallTotal ?? 0) > 0.1) && rainfallTotal !== null const rainPushPart =
(hasRainSignal || (rainfallTotal ?? 0) > 0.1) && rainfallTotal !== null
? formatRainfall(rainfallTotal, language) ? formatRainfall(rainfallTotal, language)
: null; : null;
const pushParts = [ const pushParts = [
@@ -382,10 +476,16 @@ export function buildTomorrowWeatherBrief({ forecast, warnings, province, county
temperatureRange, temperatureRange,
condition, condition,
rainPushPart, rainPushPart,
maximumWind !== null ? (language === "pl" ? `wiatr ${formatWindSpeed(maximumWind, language, windSpeedUnit)}` : `wind ${formatWindSpeed(maximumWind, language, windSpeedUnit)}`) : null, maximumWind !== null
? language === "pl"
? `wiatr ${formatWindSpeed(maximumWind, language, windSpeedUnit)}`
: `wind ${formatWindSpeed(maximumWind, language, windSpeedUnit)}`
: null,
].filter(Boolean); ].filter(Boolean);
const warningPrefix = topWarning const warningPrefix = topWarning
? language === "pl" ? `IMGW: ${topWarning.title || "ostrzeżenie"}. ` : `IMGW: ${topWarning.title || "warning"}. ` ? language === "pl"
? `IMGW: ${topWarning.title || "ostrzeżenie"}. `
: `IMGW: ${topWarning.title || "warning"}. `
: ""; : "";
return { return {

View File

@@ -85,10 +85,14 @@ export function normalizeHydroStation(raw: RawHydroStation): HydroStation | null
} }
export function normalizeWarning(raw: RawWarning, kind: WarningKind, index: number): WeatherWarning { export function normalizeWarning(raw: RawWarning, kind: WarningKind, index: number): WeatherWarning {
const provinces = [...new Set([ const provinces = [
...new Set(
[
...(raw.obszary ?? []).map((area) => normalizeProvinceName(area.wojewodztwo)), ...(raw.obszary ?? []).map((area) => normalizeProvinceName(area.wojewodztwo)),
...(raw.teryt ?? []).map(getProvinceFromTeryt), ...(raw.teryt ?? []).map(getProvinceFromTeryt),
].filter((province) => province !== null))]; ].filter((province) => province !== null),
),
];
const describedAreas = (raw.obszary ?? []) const describedAreas = (raw.obszary ?? [])
.map((area) => area.opis?.trim() || area.wojewodztwo?.trim()) .map((area) => area.opis?.trim() || area.wojewodztwo?.trim())
.filter((area): area is string => Boolean(area)); .filter((area): area is string => Boolean(area));
@@ -106,7 +110,9 @@ export function normalizeWarning(raw: RawWarning, kind: WarningKind, index: numb
publishedAt: normalizeDate(raw.opublikowano), publishedAt: normalizeDate(raw.opublikowano),
probability: toNumber(raw.prawdopodobienstwo), probability: toNumber(raw.prawdopodobienstwo),
areas, areas,
terytCodes: Array.isArray(raw.teryt) ? raw.teryt.filter((code): code is string => typeof code === "string" && code.trim().length > 0) : [], terytCodes: Array.isArray(raw.teryt)
? raw.teryt.filter((code): code is string => typeof code === "string" && code.trim().length > 0)
: [],
provinces, provinces,
office: raw.biuro?.trim() || null, office: raw.biuro?.trim() || null,
}; };
@@ -125,15 +131,25 @@ export function convertTemperature(value: number, unit: TemperatureUnit) {
return value; return value;
} }
export function formatTemperatureValue(value: number, language: Language = "pl", unit: TemperatureUnit = DEFAULT_TEMPERATURE_UNIT) { export function formatTemperatureValue(
value: number,
language: Language = "pl",
unit: TemperatureUnit = DEFAULT_TEMPERATURE_UNIT,
) {
const formattedValue = new Intl.NumberFormat(locales[language], { const formattedValue = new Intl.NumberFormat(locales[language], {
maximumFractionDigits: 0, maximumFractionDigits: 0,
}).format(value); }).format(value);
return `${formattedValue}${getTemperatureUnitLabel(unit)}`; return `${formattedValue}${getTemperatureUnitLabel(unit)}`;
} }
export function formatTemperature(value: number | null, language: Language = "pl", unit: TemperatureUnit = DEFAULT_TEMPERATURE_UNIT) { export function formatTemperature(
return value === null ? translate(language, "common.noData") : formatTemperatureValue(convertTemperature(value, unit), language, unit); 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") {
@@ -158,7 +174,11 @@ export function convertWindSpeed(speed: number, unit: WindSpeedUnit) {
return speed; return speed;
} }
export function formatWindSpeed(speed: number | null, language: Language = "pl", unit: WindSpeedUnit = DEFAULT_WIND_SPEED_UNIT) { export function formatWindSpeed(
speed: number | null,
language: Language = "pl",
unit: WindSpeedUnit = DEFAULT_WIND_SPEED_UNIT,
) {
if (speed === null) return translate(language, "common.noData"); if (speed === null) return translate(language, "common.noData");
const convertedSpeed = convertWindSpeed(speed, unit); const convertedSpeed = convertWindSpeed(speed, unit);
const digits = unit === "ms" ? 1 : 0; const digits = unit === "ms" ? 1 : 0;
@@ -169,7 +189,12 @@ export function formatWindSpeed(speed: number | null, language: Language = "pl",
return `${formattedSpeed} ${getWindSpeedUnitLabel(unit)}`; return `${formattedSpeed} ${getWindSpeedUnitLabel(unit)}`;
} }
export function formatWind(speed: number | null, direction?: number | null, language: Language = "pl", unit: WindSpeedUnit = DEFAULT_WIND_SPEED_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"); if (speed === null) return translate(language, "common.noData");
const directionLabel = direction === null || direction === undefined ? "" : ` ${getWindDirection(direction)}`; const directionLabel = direction === null || direction === undefined ? "" : ` ${getWindDirection(direction)}`;
return `${formatWindSpeed(speed, language, unit)}${directionLabel}`; return `${formatWindSpeed(speed, language, unit)}${directionLabel}`;
@@ -187,7 +212,11 @@ export function formatFlow(value: number | null, language: Language = "pl") {
return value === null ? translate(language, "common.noData") : `${value.toFixed(2)} m³/s`; return value === null ? translate(language, "common.noData") : `${value.toFixed(2)} m³/s`;
} }
export function formatDateTime(value: string | null, language: Language = "pl", fallback = translate(language, "common.noData")) { export function formatDateTime(
value: string | null,
language: Language = "pl",
fallback = translate(language, "common.noData"),
) {
if (!value) return fallback; if (!value) return fallback;
const date = new Date(value); const date = new Date(value);
if (Number.isNaN(date.getTime())) return fallback; if (Number.isNaN(date.getTime())) return fallback;
@@ -215,9 +244,17 @@ export function calculateFeelsLike(temperature: number | null, humidity: number
const c7 = 0.002211732; const c7 = 0.002211732;
const c8 = 0.00072546; const c8 = 0.00072546;
const c9 = -0.000003582; const c9 = -0.000003582;
return c1 + c2 * temperature + c3 * humidity + c4 * temperature * humidity + c5 * temperature ** 2 + return (
c6 * humidity ** 2 + c7 * temperature ** 2 * humidity + c8 * temperature * humidity ** 2 + c1 +
c9 * temperature ** 2 * humidity ** 2; c2 * temperature +
c3 * humidity +
c4 * temperature * humidity +
c5 * temperature ** 2 +
c6 * humidity ** 2 +
c7 * temperature ** 2 * humidity +
c8 * temperature * humidity ** 2 +
c9 * temperature ** 2 * humidity ** 2
);
} }
return temperature; return temperature;
} }
@@ -243,8 +280,10 @@ function getForecastConditionDescription(code: number | null, language: Language
if (code === 3) return translate(language, "forecast.condition.cloudy"); if (code === 3) return translate(language, "forecast.condition.cloudy");
if (code === 45 || code === 48) return translate(language, "forecast.condition.fog"); if (code === 45 || code === 48) return translate(language, "forecast.condition.fog");
if (code !== null && code >= 51 && code <= 57) return translate(language, "forecast.condition.drizzle"); if (code !== null && code >= 51 && code <= 57) return translate(language, "forecast.condition.drizzle");
if (code !== null && ((code >= 61 && code <= 67) || (code >= 80 && code <= 82))) return translate(language, "forecast.condition.rain"); if (code !== null && ((code >= 61 && code <= 67) || (code >= 80 && code <= 82)))
if (code !== null && ((code >= 71 && code <= 77) || code === 85 || code === 86)) return translate(language, "forecast.condition.snow"); return translate(language, "forecast.condition.rain");
if (code !== null && ((code >= 71 && code <= 77) || code === 85 || code === 86))
return translate(language, "forecast.condition.snow");
if (code !== null && code >= 95) return translate(language, "forecast.condition.thunderstorm"); if (code !== null && code >= 95) return translate(language, "forecast.condition.thunderstorm");
return null; return null;
} }
@@ -270,7 +309,11 @@ export function getWeatherDescription(
if ((station.windSpeed ?? 0) >= 8) return translate(language, "weather.strongWind"); if ((station.windSpeed ?? 0) >= 8) return translate(language, "weather.strongWind");
const currentDescription = getForecastConditionDescription(currentWeatherCode ?? null, language); const currentDescription = getForecastConditionDescription(currentWeatherCode ?? null, language);
const cloudCoverDescription = getCloudCoverDescription(currentCloudCover, language); const cloudCoverDescription = getCloudCoverDescription(currentCloudCover, language);
if (cloudCoverDescription && (currentWeatherCode === null || currentWeatherCode === undefined || currentWeatherCode === 0)) return cloudCoverDescription; if (
cloudCoverDescription &&
(currentWeatherCode === null || currentWeatherCode === undefined || currentWeatherCode === 0)
)
return cloudCoverDescription;
if (currentDescription) return currentDescription; if (currentDescription) return currentDescription;
if (cloudCoverDescription) return cloudCoverDescription; if (cloudCoverDescription) return cloudCoverDescription;
const forecastDescription = getForecastConditionDescription(forecastWeatherCode ?? null, language); const forecastDescription = getForecastConditionDescription(forecastWeatherCode ?? null, language);

17
package-lock.json generated
View File

@@ -32,6 +32,7 @@
"eslint": "^9.17.0", "eslint": "^9.17.0",
"eslint-config-next": "^16.2.6", "eslint-config-next": "^16.2.6",
"postcss": "^8.4.49", "postcss": "^8.4.49",
"prettier": "^3.8.4",
"tailwindcss": "^3.4.17", "tailwindcss": "^3.4.17",
"typescript": "^5.7.2" "typescript": "^5.7.2"
} }
@@ -6184,6 +6185,22 @@
"node": ">= 0.8.0" "node": ">= 0.8.0"
} }
}, },
"node_modules/prettier": {
"version": "3.8.4",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.4.tgz",
"integrity": "sha512-N2MylSdi48+5N/6S5j+maeHbUSIzzZ5uOcX5Hm4QpV8Dkb1HFjfAKTKX6yNPJQD9AhcT3ifHNB66tWTTJDi11Q==",
"dev": true,
"license": "MIT",
"bin": {
"prettier": "bin/prettier.cjs"
},
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/prettier/prettier?sponsor=1"
}
},
"node_modules/prop-types": { "node_modules/prop-types": {
"version": "15.8.1", "version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",

View File

@@ -7,6 +7,8 @@
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"lint": "eslint .", "lint": "eslint .",
"format": "prettier --write .",
"format:check": "prettier --check .",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"notifications:worker": "node scripts/notification-worker.mjs" "notifications:worker": "node scripts/notification-worker.mjs"
}, },
@@ -35,6 +37,7 @@
"eslint": "^9.17.0", "eslint": "^9.17.0",
"eslint-config-next": "^16.2.6", "eslint-config-next": "^16.2.6",
"postcss": "^8.4.49", "postcss": "^8.4.49",
"prettier": "^3.8.4",
"tailwindcss": "^3.4.17", "tailwindcss": "^3.4.17",
"typescript": "^5.7.2" "typescript": "^5.7.2"
} }

View File

@@ -1,5 +1,15 @@
const CACHE_NAME = "wtr-shell-v5"; const CACHE_NAME = "wtr-shell-v5";
const SHELL = ["/", "/settings", "/offline", "/manifest.json", "/icons/icon.svg", "/icons/maskable.svg", "/icons/icon-192.png", "/icons/icon-512.png", "/icons/maskable-512.png"]; const SHELL = [
"/",
"/settings",
"/offline",
"/manifest.json",
"/icons/icon.svg",
"/icons/maskable.svg",
"/icons/icon-192.png",
"/icons/icon-512.png",
"/icons/maskable-512.png",
];
self.addEventListener("install", (event) => { self.addEventListener("install", (event) => {
event.waitUntil(caches.open(CACHE_NAME).then((cache) => cache.addAll(SHELL))); event.waitUntil(caches.open(CACHE_NAME).then((cache) => cache.addAll(SHELL)));
@@ -8,7 +18,9 @@ self.addEventListener("install", (event) => {
self.addEventListener("activate", (event) => { self.addEventListener("activate", (event) => {
event.waitUntil( event.waitUntil(
caches.keys().then((keys) => Promise.all(keys.filter((key) => key !== CACHE_NAME).map((key) => caches.delete(key)))), caches
.keys()
.then((keys) => Promise.all(keys.filter((key) => key !== CACHE_NAME).map((key) => caches.delete(key)))),
); );
self.clients.claim(); self.clients.claim();
}); });
@@ -16,7 +28,12 @@ self.addEventListener("activate", (event) => {
self.addEventListener("fetch", (event) => { self.addEventListener("fetch", (event) => {
if (event.request.method !== "GET") return; if (event.request.method !== "GET") return;
const url = new URL(event.request.url); const url = new URL(event.request.url);
if (url.pathname.startsWith("/api/imgw/") || url.pathname === "/api/imgw-current" || url.pathname === "/api/current-weather" || url.pathname === "/api/forecast") { if (
url.pathname.startsWith("/api/imgw/") ||
url.pathname === "/api/imgw-current" ||
url.pathname === "/api/current-weather" ||
url.pathname === "/api/forecast"
) {
event.respondWith( event.respondWith(
fetch(event.request) fetch(event.request)
.then((response) => { .then((response) => {
@@ -29,7 +46,11 @@ self.addEventListener("fetch", (event) => {
return; return;
} }
if (event.request.mode === "navigate") { if (event.request.mode === "navigate") {
event.respondWith(fetch(event.request).catch(() => caches.match(event.request).then((response) => response || caches.match("/offline")))); event.respondWith(
fetch(event.request).catch(() =>
caches.match(event.request).then((response) => response || caches.match("/offline")),
),
);
} }
}); });

View File

@@ -13,10 +13,22 @@ loadEnvFile(".env.local", true);
const appUrl = normalizeAppUrl(process.env.WTR_APP_URL ?? process.env.NEXT_PUBLIC_APP_URL ?? DEFAULT_APP_URL); const appUrl = normalizeAppUrl(process.env.WTR_APP_URL ?? process.env.NEXT_PUBLIC_APP_URL ?? DEFAULT_APP_URL);
const cronSecret = process.env.NOTIFICATIONS_CRON_SECRET ?? ""; const cronSecret = process.env.NOTIFICATIONS_CRON_SECRET ?? "";
const warningIntervalMinutes = readPositiveNumber(process.env.NOTIFICATIONS_WARNING_INTERVAL_MINUTES, DEFAULT_WARNING_INTERVAL_MINUTES); const warningIntervalMinutes = readPositiveNumber(
const briefIntervalMinutes = readPositiveNumber(process.env.NOTIFICATIONS_BRIEF_INTERVAL_MINUTES, DEFAULT_BRIEF_INTERVAL_MINUTES); process.env.NOTIFICATIONS_WARNING_INTERVAL_MINUTES,
const morningBriefTime = normalizeTime(process.env.NOTIFICATIONS_MORNING_BRIEF_TIME ?? DEFAULT_MORNING_BRIEF_TIME, DEFAULT_MORNING_BRIEF_TIME); DEFAULT_WARNING_INTERVAL_MINUTES,
const tomorrowBriefTime = normalizeTime(process.env.NOTIFICATIONS_TOMORROW_BRIEF_TIME ?? DEFAULT_TOMORROW_BRIEF_TIME, DEFAULT_TOMORROW_BRIEF_TIME); );
const briefIntervalMinutes = readPositiveNumber(
process.env.NOTIFICATIONS_BRIEF_INTERVAL_MINUTES,
DEFAULT_BRIEF_INTERVAL_MINUTES,
);
const morningBriefTime = normalizeTime(
process.env.NOTIFICATIONS_MORNING_BRIEF_TIME ?? DEFAULT_MORNING_BRIEF_TIME,
DEFAULT_MORNING_BRIEF_TIME,
);
const tomorrowBriefTime = normalizeTime(
process.env.NOTIFICATIONS_TOMORROW_BRIEF_TIME ?? DEFAULT_TOMORROW_BRIEF_TIME,
DEFAULT_TOMORROW_BRIEF_TIME,
);
let lastWarningCheckAt = 0; let lastWarningCheckAt = 0;
let lastMorningBriefCheckAt = 0; let lastMorningBriefCheckAt = 0;
@@ -112,6 +124,8 @@ async function tick() {
} }
console.log(`wtr. notification worker: ${appUrl}`); console.log(`wtr. notification worker: ${appUrl}`);
console.log(`Warnings every ${warningIntervalMinutes} min. Brief checks every ${briefIntervalMinutes} min; targets: ${morningBriefTime} and ${tomorrowBriefTime} in each subscription timezone.`); console.log(
`Warnings every ${warningIntervalMinutes} min. Brief checks every ${briefIntervalMinutes} min; targets: ${morningBriefTime} and ${tomorrowBriefTime} in each subscription timezone.`,
);
void tick(); void tick();
setInterval(tick, LOOP_INTERVAL_MS); setInterval(tick, LOOP_INTERVAL_MS);

View File

@@ -2,12 +2,7 @@ import type { Config } from "tailwindcss";
export default { export default {
darkMode: ["class"], darkMode: ["class"],
content: [ content: ["./app/**/*.{ts,tsx}", "./components/**/*.{ts,tsx}", "./hooks/**/*.{ts,tsx}", "./lib/**/*.{ts,tsx}"],
"./app/**/*.{ts,tsx}",
"./components/**/*.{ts,tsx}",
"./hooks/**/*.{ts,tsx}",
"./lib/**/*.{ts,tsx}",
],
theme: { theme: {
extend: { extend: {
colors: { colors: {

View File

@@ -1,11 +1,7 @@
{ {
"compilerOptions": { "compilerOptions": {
"target": "ES2017", "target": "ES2017",
"lib": [ "lib": ["dom", "dom.iterable", "esnext"],
"dom",
"dom.iterable",
"esnext"
],
"allowJs": false, "allowJs": false,
"skipLibCheck": true, "skipLibCheck": true,
"strict": true, "strict": true,
@@ -23,19 +19,9 @@
} }
], ],
"paths": { "paths": {
"@/*": [ "@/*": ["./*"]
"./*"
]
} }
}, },
"include": [ "include": ["next-env.d.ts", ".next/types/**/*.ts", "**/*.ts", "**/*.tsx", ".next/dev/types/**/*.ts"],
"next-env.d.ts", "exclude": ["node_modules"]
".next/types/**/*.ts",
"**/*.ts",
"**/*.tsx",
".next/dev/types/**/*.ts"
],
"exclude": [
"node_modules"
]
} }

View File

@@ -81,7 +81,7 @@ export interface RawWarning {
id?: string | null; id?: string | null;
opublikowano?: string | null; opublikowano?: string | null;
stopien?: string | null; stopien?: string | null;
"stopień"?: string | null; stopień?: string | null;
nazwa_zdarzenia?: string | null; nazwa_zdarzenia?: string | null;
data_od?: string | null; data_od?: string | null;
data_do?: string | null; data_do?: string | null;