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
run: npm run lint
- name: Format check
run: npm run format:check
- name: 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 run dev
npm run lint
npm run format
npm run format:check
npm run typecheck
npm run build
npm run start
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
@@ -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`.
- 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 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ę.
- 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.

View File

@@ -63,15 +63,17 @@ Przykład znajduje się w [.env.example](.env.example).
## Komendy
| Komenda | Opis |
| --- | --- |
| ------------------------------ | --------------------------------------------------------------------------------------- |
| `npm run dev` | Uruchamia serwer deweloperski Next.js. |
| `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 build` | Buduje aplikację produkcyjnie i uruchamia kontrolę TypeScript wykonywaną przez Next.js. |
| `npm run start` | Uruchamia zbudowaną aplikację. |
| `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
@@ -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:
- `npm run lint`,
- `npm run format:check`,
- `npm run typecheck`,
- `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" },
});
} 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 { isImgwNoProductsResponse, readImgwResponseBody } from "@/lib/imgw-empty-response";
const ALLOWED_PATHS = new Set([
"synop",
"hydro",
"meteo",
"warningsmeteo",
"warningshydro",
"product",
]);
const ALLOWED_PATHS = new Set(["synop", "hydro", "meteo", "warningsmeteo", "warningshydro", "product"]);
const IMGW_BASE_URL = "https://danepubliczne.imgw.pl/api/data";
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) {
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([], {
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 },
});
if (!response.ok) return NextResponse.json({ error: "Reverse geocoding is unavailable." }, { status: 502 });
const data = await response.json() as RawReverseLocation;
const name = data.address?.city
?? data.address?.town
?? data.address?.village
?? data.address?.municipality
?? data.name
?? data.display_name?.split(",")[0]?.trim();
const data = (await response.json()) as RawReverseLocation;
const name =
data.address?.city ??
data.address?.town ??
data.address?.village ??
data.address?.municipality ??
data.name ??
data.display_name?.split(",")[0]?.trim();
if (!name) return NextResponse.json({ error: "Place name is unavailable." }, { status: 404 });
const countryCode = data.address?.country_code?.toUpperCase() ?? null;
return NextResponse.json({
return NextResponse.json(
{
name,
province: data.address?.state ?? null,
district: data.address?.county ?? null,
@@ -68,9 +70,11 @@ export async function GET(request: Request) {
admin2: data.address?.county ?? null,
timezone: null,
region: getWeatherRegionForCountryCode(countryCode),
}, {
},
{
headers: { "Cache-Control": "public, s-maxage=86400, stale-while-revalidate=604800" },
});
},
);
} catch {
return NextResponse.json({ error: "Reverse geocoding is unavailable." }, { status: 502 });
}

View File

@@ -30,11 +30,13 @@ export async function GET(request: Request) {
try {
const response = await fetch(`${GEOCODING_URL}?${params}`, { next: { revalidate: 86400 } });
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) => {
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;
return [{
return [
{
id: location.id,
name: location.name,
latitude: location.latitude as number,
@@ -47,9 +49,12 @@ export async function GET(request: Request) {
admin2: location.admin2 ?? null,
timezone: location.timezone ?? null,
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 {
return NextResponse.json({ error: "Location search is unavailable." }, { status: 502 });
}

View File

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

View File

@@ -1,7 +1,12 @@
import { NextResponse } from "next/server";
import { fetchServerForecast } from "@/lib/server-forecast";
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 { buildWeatherBrief } from "@/lib/weather-brief";
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 briefTime = normalizeTime(process.env.NOTIFICATIONS_MORNING_BRIEF_TIME, DEFAULT_MORNING_BRIEF_TIME);
const subscriptions = getPushSubscriptions().filter((subscription) => (
subscription.morningBriefEnabled
&& Number.isFinite(subscription.latitude)
&& Number.isFinite(subscription.longitude)
));
const subscriptions = getPushSubscriptions().filter(
(subscription) =>
subscription.morningBriefEnabled &&
Number.isFinite(subscription.latitude) &&
Number.isFinite(subscription.longitude),
);
let warningsUnavailable = false;
const hasPolishSubscriptions = subscriptions.some((subscription) => subscription.region === "PL");
const warnings = hasPolishSubscriptions
@@ -79,7 +85,11 @@ export async function GET(request: Request) {
}
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({
forecast,
warnings: subscription.region === "PL" ? warnings : [],
@@ -100,7 +110,8 @@ export async function GET(request: Request) {
sent += 1;
} catch (error) {
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);
}
}

View File

@@ -2,7 +2,12 @@ import { NextResponse } from "next/server";
import { upsertPushSubscription, removePushSubscription } from "@/lib/push-store";
import { isWebPushConfigured } from "@/lib/push-service";
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 { BrowserPushSubscription } from "@/types/notifications";
import type { WeatherRegion } from "@/types/weather-region";
@@ -12,10 +17,12 @@ export const runtime = "nodejs";
function isBrowserPushSubscription(value: unknown): value is BrowserPushSubscription {
if (!value || typeof value !== "object") return false;
const subscription = value as Partial<BrowserPushSubscription>;
return typeof subscription.endpoint === "string"
&& subscription.endpoint.startsWith("https://")
&& typeof subscription.keys?.p256dh === "string"
&& typeof subscription.keys.auth === "string";
return (
typeof subscription.endpoint === "string" &&
subscription.endpoint.startsWith("https://") &&
typeof subscription.keys?.p256dh === "string" &&
typeof subscription.keys.auth === "string"
);
}
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 });
}
const body = await request.json().catch(() => null) as {
const body = (await request.json().catch(() => null)) as {
subscription?: unknown;
province?: unknown;
region?: unknown;
@@ -63,7 +70,8 @@ export async function POST(request: Request) {
tomorrowBriefEnabled: body?.tomorrowBriefEnabled === true,
latitude: typeof body?.latitude === "number" && Number.isFinite(body.latitude) ? body.latitude : 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,
countyTeryt: typeof body?.countyTeryt === "string" && /^\d{4}$/.test(body.countyTeryt) ? body.countyTeryt : null,
temperatureUnit: isTemperatureUnit(rawTemperatureUnit) ? rawTemperatureUnit : DEFAULT_TEMPERATURE_UNIT,
@@ -76,7 +84,7 @@ export async function POST(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) {
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 });
}
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) {
return NextResponse.json({ error: "Invalid push subscription endpoint." }, { status: 400 });
}
@@ -23,7 +23,8 @@ export async function POST(request: Request) {
await sendTestNotification(subscription);
return NextResponse.json({ ok: true });
} 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);
return NextResponse.json({ error: "Unable to send test notification." }, { status: 502 });
}

View File

@@ -1,7 +1,12 @@
import { NextResponse } from "next/server";
import { fetchServerForecast } from "@/lib/server-forecast";
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 { buildTomorrowWeatherBrief } from "@/lib/weather-brief";
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")}`;
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", {
timeZone: timeZone || "Europe/Warsaw",
year: "numeric",
month: "2-digit",
day: "2-digit",
}).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 };
}
@@ -58,11 +66,12 @@ export async function GET(request: Request) {
const now = new Date();
const briefTime = normalizeTime(process.env.NOTIFICATIONS_TOMORROW_BRIEF_TIME, DEFAULT_TOMORROW_BRIEF_TIME);
const subscriptions = getPushSubscriptions().filter((subscription) => (
subscription.tomorrowBriefEnabled
&& Number.isFinite(subscription.latitude)
&& Number.isFinite(subscription.longitude)
));
const subscriptions = getPushSubscriptions().filter(
(subscription) =>
subscription.tomorrowBriefEnabled &&
Number.isFinite(subscription.latitude) &&
Number.isFinite(subscription.longitude),
);
let warningsUnavailable = false;
const hasPolishSubscriptions = subscriptions.some((subscription) => subscription.region === "PL");
const warnings = hasPolishSubscriptions
@@ -88,7 +97,11 @@ export async function GET(request: Request) {
}
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({
forecast,
warnings: subscription.region === "PL" ? warnings : [],
@@ -109,7 +122,8 @@ export async function GET(request: Request) {
sent += 1;
} catch (error) {
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);
}
}

View File

@@ -128,11 +128,8 @@ select {
@media (min-width: 640px) {
@layer utilities {
.modal-safe-area {
padding:
calc(1rem + env(safe-area-inset-top))
calc(1rem + env(safe-area-inset-right))
calc(1rem + env(safe-area-inset-bottom))
calc(1rem + env(safe-area-inset-left));
padding: calc(1rem + env(safe-area-inset-top)) 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) {
@layer utilities {
.modal-safe-area {
padding:
calc(2rem + env(safe-area-inset-top))
calc(2rem + env(safe-area-inset-right))
calc(2rem + env(safe-area-inset-bottom))
calc(2rem + env(safe-area-inset-left));
padding: calc(2rem + env(safe-area-inset-top)) 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",
appleWebApp: { capable: true, statusBarStyle: "black-translucent", title: "wtr." },
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",
},
};
@@ -46,8 +49,12 @@ export default function RootLayout({ children }: Readonly<{ children: React.Reac
return (
<html lang="pl" suppressHydrationWarning data-scroll-behavior="smooth">
<body className={`${inter.variable} font-sans`}>
<Script id="wtr-theme" strategy="beforeInteractive">{themeScript}</Script>
<Script id="wtr-language" strategy="beforeInteractive">{languageScript}</Script>
<Script id="wtr-theme" strategy="beforeInteractive">
{themeScript}
</Script>
<Script id="wtr-language" strategy="beforeInteractive">
{languageScript}
</Script>
<Providers>
<AppShell>{children}</AppShell>
</Providers>

View File

@@ -8,10 +8,17 @@ export default function OfflinePage() {
const { t } = useI18n();
return (
<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>
<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>
);
}

View File

@@ -33,13 +33,35 @@ export function DayForecastCharts({ hours }: { hours: HourlyForecast[] }) {
<h3 className="text-sm font-semibold">{t("forecast.temperatureChart")}</h3>
<p className="mt-1 text-xs leading-5 text-muted">{t("forecast.temperatureChartDescription")}</p>
<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 }}>
<CartesianGrid stroke={CHART_COLORS.grid} strokeDasharray="4 4" vertical={false} />
<XAxis dataKey="time" axisLine={false} tickLine={false} interval={3} tick={{ fill: "currentColor", fontSize: 11 }} />
<YAxis axisLine={false} tickLine={false} tick={{ fill: "currentColor", fontSize: 11 }} unit={temperatureUnitLabel} />
<XAxis
dataKey="time"
axisLine={false}
tickLine={false}
interval={3}
tick={{ fill: "currentColor", fontSize: 11 }}
/>
<YAxis
axisLine={false}
tickLine={false}
tick={{ fill: "currentColor", fontSize: 11 }}
unit={temperatureUnitLabel}
/>
<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) => [
typeof value === "number"
? formatTemperatureValue(value, language, temperatureUnit)
@@ -47,8 +69,25 @@ export function DayForecastCharts({ hours }: { hours: HourlyForecast[] }) {
]}
/>
<Legend wrapperStyle={{ fontSize: "0.72rem" }} />
<Line type="monotone" dataKey="temperature" name={t("forecast.temperature")} stroke={CHART_COLORS.temperature} strokeWidth={3} dot={false} connectNulls />
<Line type="monotone" dataKey="feelsLike" name={t("forecast.apparentTemperature")} stroke={CHART_COLORS.feelsLike} strokeWidth={2} strokeDasharray="5 4" dot={false} connectNulls />
<Line
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>
</ResponsiveContainer>
</div>
@@ -58,14 +97,45 @@ export function DayForecastCharts({ hours }: { hours: HourlyForecast[] }) {
<h3 className="text-sm font-semibold">{t("forecast.rainfallChart")}</h3>
<p className="mt-1 text-xs leading-5 text-muted">{t("forecast.rainfallChartDescription")}</p>
<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 }}>
<CartesianGrid stroke={CHART_COLORS.grid} strokeDasharray="4 4" vertical={false} />
<XAxis dataKey="time" 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="%" />
<XAxis
dataKey="time"
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
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) => [
name === t("forecast.precipitation")
? formatForecastRainfall(typeof value === "number" ? value : null, language)
@@ -74,8 +144,23 @@ export function DayForecastCharts({ hours }: { hours: HourlyForecast[] }) {
]}
/>
<Legend wrapperStyle={{ fontSize: "0.72rem" }} />
<Bar 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 />
<Bar
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>
</ResponsiveContainer>
</div>

View File

@@ -8,23 +8,28 @@ import { useWeatherStore } from "@/lib/store";
import { convertWindSpeed, getWindSpeedUnitLabel } from "@/lib/weather-utils";
const INITIAL_CHART_DIMENSION = { width: 1, height: 1 };
const SNAPSHOT_COLORS = [
"hsl(var(--chart-temperature))",
"hsl(var(--chart-feels-like))",
"hsl(var(--chart-rainfall))",
];
const SNAPSHOT_COLORS = ["hsl(var(--chart-temperature))", "hsl(var(--chart-feels-like))", "hsl(var(--chart-rainfall))"];
export function SnapshotChart({ station }: { station: SynopStation }) {
const { t } = useI18n();
const windSpeedUnit = useWeatherStore((state) => state.windSpeedUnit);
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 rows = [
{ 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] },
].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 (
<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>
<p className="mt-1 text-sm leading-6 text-muted">{t("snapshot.description")}</p>
<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 }}>
<XAxis type="number" hide domain={[0, 100]} />
<YAxis 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]} />
<YAxis
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}>
{rows.map((row) => <Cell fill={row.color} key={row.name} />)}
{rows.map((row) => (
<Cell fill={row.color} key={row.name} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>

View File

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

View File

@@ -15,7 +15,17 @@ import { useWeatherStore } from "@/lib/store";
import { buildTomorrowWeatherBrief, buildWeatherBrief } from "@/lib/weather-brief";
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 { data: forecast, isPending, isError, refetch } = useForecast(latitude, longitude, region);
const { data: warnings = [] } = useWarnings();
@@ -24,17 +34,57 @@ export function WeatherBriefCard({ latitude, longitude, region = "PL", locationN
const temperatureUnit = useWeatherStore((state) => state.temperatureUnit);
const windSpeedUnit = useWeatherStore((state) => state.windSpeedUnit);
const isGlobalLocation = region === "GLOBAL";
const province = isGlobalLocation ? null : getProvinceForSelection(selectedLocation?.province, selectedStationId ?? DEFAULT_STATION_ID);
const relevantWarnings = useMemo(() => isGlobalLocation ? [] : warnings, [isGlobalLocation, warnings]);
const province = isGlobalLocation
? null
: getProvinceForSelection(selectedLocation?.province, selectedStationId ?? DEFAULT_STATION_ID);
const relevantWarnings = useMemo(() => (isGlobalLocation ? [] : warnings), [isGlobalLocation, warnings]);
const brief = useMemo(() => {
if (!forecast) return null;
return buildWeatherBrief({ forecast, warnings: relevantWarnings, province, countyTeryt: isGlobalLocation ? null : selectedLocation?.countyTeryt, locationName, language, temperatureUnit, windSpeedUnit });
}, [forecast, isGlobalLocation, language, locationName, province, relevantWarnings, selectedLocation?.countyTeryt, temperatureUnit, windSpeedUnit]);
return buildWeatherBrief({
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(() => {
if (!forecast) return null;
return buildTomorrowWeatherBrief({ forecast, warnings: relevantWarnings, province, countyTeryt: isGlobalLocation ? null : selectedLocation?.countyTeryt, locationName, language, temperatureUnit, windSpeedUnit });
}, [forecast, isGlobalLocation, language, locationName, province, relevantWarnings, selectedLocation?.countyTeryt, temperatureUnit, windSpeedUnit]);
return buildTomorrowWeatherBrief({
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) {
return <LoadingSkeleton className="h-48" />;
@@ -61,11 +111,25 @@ export function WeatherBriefCard({ latitude, longitude, region = "PL", locationN
return (
<Card className="p-4 sm:p-5">
<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"}>
{isWarning ? <AlertTriangle className="size-4" aria-hidden="true" /> : <CloudSun className="size-4" aria-hidden="true" />}
<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"
}
>
{isWarning ? (
<AlertTriangle className="size-4" aria-hidden="true" />
) : (
<CloudSun className="size-4" aria-hidden="true" />
)}
</div>
<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")}
</p>
<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">
{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}
</li>
))}
@@ -87,7 +154,10 @@ export function WeatherBriefCard({ latitude, longitude, region = "PL", locationN
<h3 className="mt-1 text-sm font-semibold">{tomorrowBrief.headline}</h3>
<ul className="mt-3 space-y-2">
{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}
</li>
))}

View File

@@ -82,7 +82,9 @@ export function DayForecastModal({
}, [day, onClose]);
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 = (
@@ -114,7 +116,9 @@ export function DayForecastModal({
<CloudSun className="size-4" />
{t("forecast.dayDetails")}
</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>
</div>
<button
@@ -134,13 +138,25 @@ export function DayForecastModal({
<p className="text-sm text-muted">{getForecastCondition(day.weatherCode, language)}</p>
<div className="mt-2 flex items-end gap-4">
<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="mb-2 text-2xl text-muted">{formatForecastTemperature(day.temperatureMin, language, temperatureUnit)}</p>
<p className="text-6xl font-semibold tracking-[-0.08em] sm:text-7xl">
{formatForecastTemperature(day.temperatureMax, language, temperatureUnit)}
</p>
<p className="mb-2 text-2xl text-muted">
{formatForecastTemperature(day.temperatureMin, language, temperatureUnit)}
</p>
</div>
</div>
<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 icon={Wind} label={t("forecast.maxWind")} value={formatForecastWind(maximumWind, language, windSpeedUnit)} />
<DayMetric
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={Sunset} label={t("forecast.sunset")} value={formatHour(day.sunset)} />
</div>
@@ -160,8 +176,14 @@ export function DayForecastModal({
title={getForecastCondition(weatherCode, language)}
>
<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" />
<p className="text-lg font-semibold tracking-tight">{formatForecastTemperature(hour.temperature, language, temperatureUnit)}</p>
<ForecastIcon
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">
<Droplets className="size-3" />
{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 }) {
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
export function ForecastIcon({
code,
className = "",
isNight = false,
}: {
code: number | null;
className?: string;
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;
return <Icon className={className} strokeWidth={1.45} />;
}

View File

@@ -2,7 +2,18 @@
import { useCallback, useState } from "react";
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 { DayForecastModal } from "@/components/forecast/day-forecast-modal";
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 rainfallTotal = getTotal(hours.map((hour) => hour.precipitation));
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)}`;
return (
<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">
<HourlySummaryMetric icon={ThermometerSun} label={t("forecast.temperatureRange")} value={temperatureRange} />
<HourlySummaryMetric icon={Wind} 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}%`} />
<HourlySummaryMetric
icon={Wind}
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>
);
}
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 temperatureUnit = useWeatherStore((state) => state.temperatureUnit);
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>
<div className="flex min-w-0 items-center gap-2">
<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>
<span className="hidden items-center gap-1 text-xs text-accent sm:flex"><Droplets className="size-3" />{day.precipitationProbability === null ? "—" : `${day.precipitationProbability}%`}</span>
<p className="whitespace-nowrap text-sm"><strong>{formatForecastTemperature(day.temperatureMax, language, temperatureUnit)}</strong><span className="ml-2 text-muted">{formatForecastTemperature(day.temperatureMin, language, temperatureUnit)}</span></p>
<span className="hidden items-center gap-1 text-xs text-accent sm:flex">
<Droplets className="size-3" />
{day.precipitationProbability === null ? "—" : `${day.precipitationProbability}%`}
</span>
<p className="whitespace-nowrap text-sm">
<strong>{formatForecastTemperature(day.temperatureMax, language, temperatureUnit)}</strong>
<span className="ml-2 text-muted">
{formatForecastTemperature(day.temperatureMin, language, temperatureUnit)}
</span>
</p>
<ChevronRight className="hidden size-4 text-muted sm:block" />
</motion.button>
</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 temperatureUnit = useWeatherStore((state) => state.temperatureUnit);
const windSpeedUnit = useWeatherStore((state) => state.windSpeedUnit);
@@ -133,9 +187,14 @@ export function ForecastPanel({ latitude, longitude, region = "PL", locationName
return (
<section className="space-y-3">
<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>
<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>
{!Number.isFinite(latitude) || !Number.isFinite(longitude) || isPending ? (
@@ -146,7 +205,10 @@ export function ForecastPanel({ latitude, longitude, region = "PL", locationName
) : isError || !forecast ? (
<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>
<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>
) : !forecast.hourly.length || !forecast.daily.length ? (
<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="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">
<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">
<ul className="flex min-w-max gap-2">
{upcomingHours.map((hour, index) => {
@@ -170,11 +235,23 @@ export function ForecastPanel({ latitude, longitude, region = "PL", locationName
title={getForecastCondition(weatherCode, language)}
>
<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" />
<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>
<ForecastIcon
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">
<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">
<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" />
{formatForecastTemperature(hour.feelsLike, language, temperatureUnit)}
</p>
@@ -195,8 +272,15 @@ export function ForecastPanel({ latitude, longitude, region = "PL", locationName
<HourlyForecastSummary hours={upcomingHours} />
</Card>
<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>
<ul className="mt-2">{forecast.daily.map((day, index) => <DailyForecastRow day={day} index={index} key={day.date} onSelect={setSelectedDay} />)}</ul>
<h3 className="flex items-center gap-2 text-sm font-semibold">
<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>
</div>
<DayForecastCharts hours={todayHours} />
@@ -205,7 +289,13 @@ export function ForecastPanel({ latitude, longitude, region = "PL", locationName
{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>
);
}

View File

@@ -13,13 +13,23 @@ export function ForecastSources({ sources }: { sources: ForecastSource[] }) {
{t("forecast.source")}{" "}
{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" />
</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" />
</a>
. {t(hasImgw ? "forecast.sourceCombinedDescription" : "forecast.sourceFallbackDescription")}

View File

@@ -17,10 +17,14 @@ export function HydroPage() {
const { data: stations, isPending, isError, refetch } = useHydroStations();
const [query, setQuery] = useState("");
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);
return haystack.includes(query.trim().toLocaleLowerCase(locale));
}), [locale, query, stations]);
}),
[locale, query, stations],
);
if (isPending) return <PageLoadingSkeleton />;
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">
<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" />
<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>
<p className="text-xs text-muted">{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")} /> : (
<p className="text-xs text-muted">
{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>
{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 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>
{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>

View File

@@ -12,20 +12,38 @@ export function HydroStationCard({ station, index = 0 }: { station: HydroStation
const { language, t } = useI18n();
const temperatureUnit = useWeatherStore((state) => state.temperatureUnit);
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">
<div>
<div>
<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 className="mt-5 grid grid-cols-3 gap-2">
<HydroMetric 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={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)} />
</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>
</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 }) {
return (
<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="mt-1.5 truncate text-xs font-semibold" title={value}>{value}</p>
<p className="flex items-center gap-1 text-[0.65rem] text-muted">
<Icon className="size-3" />
{label}
</p>
<p className="mt-1.5 truncate text-xs font-semibold" title={value}>
{value}
</p>
</div>
);
}

View File

@@ -28,14 +28,24 @@ export function AppShell({ children }: { children: React.ReactNode }) {
<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">
<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>
</Link>
<nav aria-label={t("nav.main")} className="hidden items-center gap-1 md:flex">
{visibleNavItems.map((item) => {
const active = item.href === "/" ? pathname === "/" : pathname.startsWith(item.href);
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)}
</Link>
);
@@ -47,12 +57,22 @@ export function AppShell({ children }: { children: React.ReactNode }) {
</div>
</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>
<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) => {
const Icon = iconsByHref[item.href] ?? CloudSun;
const active = item.href === "/" ? pathname === "/" : pathname.startsWith(item.href);
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" />
<span className="max-w-full truncate">{t(item.labelKey)}</span>
</Link>

View File

@@ -1,7 +1,20 @@
"use client";
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 { Button } from "@/components/ui/button";
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 { useI18n } from "@/lib/i18n";
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 { useWeatherStore } from "@/lib/store";
import { TEMPERATURE_UNITS, WIND_SPEED_UNITS } from "@/lib/weather-utils";
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() {
if (typeof window === "undefined") return false;
@@ -38,12 +64,23 @@ function isAppleMobilePlatform() {
function getNotificationSupportStatus(): NotificationSupportStatus {
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";
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 (
<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">
@@ -64,24 +101,36 @@ function SwitchIndicator({ checked, disabled }: { checked: boolean; disabled?: b
return (
<span
className={`relative inline-flex h-7 w-12 shrink-0 items-center rounded-control border px-0.5 transition-colors ${
checked
? "border-accent/55 bg-accent"
: "border-border/70 bg-surface-muted"
checked ? "border-accent/55 bg-accent" : "border-border/70 bg-surface-muted"
} ${disabled ? "opacity-55" : ""}`}
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>
);
}
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 (
<label
className={`mt-3 flex cursor-pointer items-start justify-between gap-4 rounded-card border px-3 py-3 text-sm transition-colors ${
checked
? "border-accent/45 bg-accent/10"
: "border-border/60 bg-surface"
checked ? "border-accent/45 bg-accent/10" : "border-border/60 bg-surface"
} ${disabled ? "cursor-not-allowed opacity-65" : "hover:border-accent/45"}`}
>
<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",
kmh: "settings.units.wind.kmh",
mph: "settings.units.wind.mph",
@@ -145,25 +197,37 @@ export function SettingsPage() {
const setDashboardSectionVisible = useWeatherStore((state) => state.setDashboardSectionVisible);
const setAppSectionVisible = useWeatherStore((state) => state.setAppSectionVisible);
const selectedStation = stations?.find((station) => station.id === selectedStationId)
?? stations?.find((station) => station.id === DEFAULT_STATION_ID)
?? stations?.[0];
const selectedStation =
stations?.find((station) => station.id === selectedStationId) ??
stations?.find((station) => station.id === DEFAULT_STATION_ID) ??
stations?.[0];
const activeLocation = selectedLocation;
const selectedRegion = activeLocation?.region ?? "PL";
const stationPosition = selectedStation
? locateSynopStations(stations ?? [], positions).find((station) => station.id === selectedStation.id)
: null;
const notificationLatitude = Number.isFinite(activeLocation?.latitude) ? activeLocation?.latitude : stationPosition?.latitude ?? null;
const notificationLongitude = Number.isFinite(activeLocation?.longitude) ? activeLocation?.longitude : stationPosition?.longitude ?? null;
const notificationLatitude = Number.isFinite(activeLocation?.latitude)
? activeLocation?.latitude
: (stationPosition?.latitude ?? null);
const notificationLongitude = Number.isFinite(activeLocation?.longitude)
? activeLocation?.longitude
: (stationPosition?.longitude ?? null);
const notificationLocationName = activeLocation?.name ?? selectedStation?.name ?? null;
const notificationTimezone = activeLocation?.timezone ?? (selectedRegion === "PL" ? "Europe/Warsaw" : null);
const notificationCountyTeryt = selectedRegion === "PL" ? activeLocation?.countyTeryt ?? null : null;
const selectedProvince = selectedRegion === "PL" ? getProvinceForSelection(selectedLocation?.province, selectedStationId ?? DEFAULT_STATION_ID) : null;
const effectiveProvince = selectedRegion === "PL" ? provinceMode === "manual" ? manualProvince : selectedProvince : null;
const notificationCountyTeryt = selectedRegion === "PL" ? (activeLocation?.countyTeryt ?? null) : null;
const selectedProvince =
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 effectiveProvinceLabel = selectedRegion === "GLOBAL"
const effectiveProvinceLabel =
selectedRegion === "GLOBAL"
? 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";
useEffect(() => {
@@ -211,7 +275,24 @@ export function SettingsPage() {
return () => {
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(() => {
if (!provinceDropdownOpen) return;
@@ -243,11 +324,18 @@ export function SettingsPage() {
}
}, [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() {
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() {
@@ -270,10 +358,12 @@ export function SettingsPage() {
}
const registration = await getServiceWorkerRegistration();
const existingSubscription = await registration.pushManager.getSubscription();
const subscription = existingSubscription ?? await registration.pushManager.subscribe({
const subscription =
existingSubscription ??
(await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: decodeBase64UrlKey(vapidPublicKey),
});
}));
await savePushSubscription(subscription, effectiveProvince, language, {
region: selectedRegion,
officialWarningsEnabled: canUseOfficialWarnings,
@@ -387,20 +477,35 @@ export function SettingsPage() {
return (
<div className="space-y-6">
<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>
<p className="mt-2 max-w-2xl text-sm leading-6 text-muted">{t("settings.description")}</p>
</section>
<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 />
</SettingsRow>
<SettingsRow icon={Palette} title={t("settings.theme.title")} description={t("settings.theme.description")}>
<ThemeToggle />
</SettingsRow>
<SettingsRow icon={Thermometer} title={t("settings.units.temperature.title")} description={t("settings.units.temperature.description")}>
<div className="surface-control inline-flex rounded-full p-1" role="radiogroup" aria-label={t("settings.units.temperature.label")}>
<SettingsRow
icon={Thermometer}
title={t("settings.units.temperature.title")}
description={t("settings.units.temperature.description")}
>
<div
className="surface-control inline-flex rounded-full p-1"
role="radiogroup"
aria-label={t("settings.units.temperature.label")}
>
{TEMPERATURE_UNITS.map((unit) => (
<button
key={unit}
@@ -419,8 +524,16 @@ export function SettingsPage() {
))}
</div>
</SettingsRow>
<SettingsRow icon={Wind} title={t("settings.units.wind.title")} description={t("settings.units.wind.description")}>
<div className="surface-control inline-flex rounded-full p-1" role="radiogroup" aria-label={t("settings.units.wind.label")}>
<SettingsRow
icon={Wind}
title={t("settings.units.wind.title")}
description={t("settings.units.wind.description")}
>
<div
className="surface-control inline-flex rounded-full p-1"
role="radiogroup"
aria-label={t("settings.units.wind.label")}
>
{WIND_SPEED_UNITS.map((unit) => (
<button
key={unit}
@@ -509,7 +622,9 @@ export function SettingsPage() {
<MapPinned className="mt-0.5 size-4 text-accent" aria-hidden="true" />
<div>
<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>
@@ -525,7 +640,11 @@ export function SettingsPage() {
/>
<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>
</label>
<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}
disabled={selectedRegion === "GLOBAL" || provinceMode !== "manual"}
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)}
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>
{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>
))}
</div>
@@ -621,9 +743,15 @@ export function SettingsPage() {
<span className="min-w-0">
<span className="flex items-center gap-2 font-medium">
<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 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">
{isSavingSubscription
? t("settings.notifications.saving")
@@ -632,7 +760,10 @@ export function SettingsPage() {
: t("settings.notifications.disabledStatus")}
</span>
</span>
<SwitchIndicator checked={notificationsEnabled} disabled={(!notificationsEnabled && !canUsePush) || isSavingSubscription} />
<SwitchIndicator
checked={notificationsEnabled}
disabled={(!notificationsEnabled && !canUsePush) || isSavingSubscription}
/>
</button>
{notificationMessage && <p className="mt-3 text-xs font-medium text-accent">{notificationMessage}</p>}
@@ -655,9 +786,16 @@ export function SettingsPage() {
/>
<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" />
{isSendingTestNotification ? t("settings.notifications.sendingTest") : t("settings.notifications.sendTest")}
{isSendingTestNotification
? t("settings.notifications.sendingTest")
: t("settings.notifications.sendTest")}
</Button>
</div>

View File

@@ -2,10 +2,20 @@ import type { LucideIcon } from "lucide-react";
import { CircleCheckBig } from "lucide-react";
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 (
<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>
<p className="mt-2 max-w-md text-sm leading-6 text-muted">{description}</p>
</Card>

View File

@@ -5,14 +5,27 @@ import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
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();
return (
<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>
<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>
);
}

View File

@@ -5,7 +5,12 @@ import { useI18n } from "@/lib/i18n";
export function LoadingSkeleton({ className = "" }: { className?: string }) {
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() {
@@ -13,7 +18,9 @@ export function PageLoadingSkeleton() {
<div className="space-y-5" aria-busy="true">
<LoadingSkeleton className="h-[25rem]" />
<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>
);

View File

@@ -53,17 +53,25 @@ export function DashboardWarnings() {
};
}, []);
const province = isGlobalLocation ? null : getProvinceForSelection(selectedLocation?.province, selectedStationId ?? DEFAULT_STATION_ID);
const regionLabel = selectedLocation?.countyTeryt ? selectedLocation.district ?? selectedLocation.name : province ? formatProvinceName(province, language) : null;
const province = isGlobalLocation
? null
: getProvinceForSelection(selectedLocation?.province, selectedStationId ?? DEFAULT_STATION_ID);
const regionLabel = selectedLocation?.countyTeryt
? (selectedLocation.district ?? selectedLocation.name)
: province
? formatProvinceName(province, language)
: null;
const relevantWarnings = useMemo(() => {
if (isGlobalLocation || !warnings || !province || now === null) return [];
return warnings
.filter((warning) => {
const validTo = getTimestamp(warning.validTo);
return warning.kind === "meteo"
&& warningMatchesLocalSelection(warning, province, selectedLocation)
&& (validTo === null || validTo > now);
return (
warning.kind === "meteo" &&
warningMatchesLocalSelection(warning, province, selectedLocation) &&
(validTo === null || validTo > now)
);
})
.sort((a, b) => compareDashboardWarnings(a, b, now));
}, [isGlobalLocation, now, province, selectedLocation, warnings]);
@@ -87,12 +95,8 @@ export function DashboardWarnings() {
<AlertTriangle className="size-4" aria-hidden="true" />
</div>
<div>
<p className="text-[0.68rem] font-semibold uppercase tracking-[0.2em] text-warning">
IMGW · {regionLabel}
</p>
<h2 className="mt-1 text-base font-semibold text-foreground">
{t("warnings.dashboard.title")}
</h2>
<p className="text-[0.68rem] font-semibold uppercase tracking-[0.2em] text-warning">IMGW · {regionLabel}</p>
<h2 className="mt-1 text-base font-semibold text-foreground">{t("warnings.dashboard.title")}</h2>
</div>
</div>
<Link
@@ -109,13 +113,11 @@ export function DashboardWarnings() {
const active = isWarningActive(warning, now);
const validityLabel = active
? 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 (
<article
key={warning.id}
className="rounded-card border border-warning/20 bg-surface px-3.5 py-3"
>
<article 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">
{t(active ? "warnings.dashboard.active" : "warnings.dashboard.upcoming")}
{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 Icon = warning.kind === "hydro" ? Waves : CloudLightning;
const level = warning.level;
const levelLabel = level === -1 ? t("warnings.drought") : level === null ? t("warnings.levelUnknown") : t("warnings.level", { level });
const areasLabel = warning.areas.length > 8
const levelLabel =
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.join("; ");
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">
<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>
<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>
<div className="rounded-card bg-warning/10 p-2.5 text-warning">
<Icon className="size-5" />
</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>
<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>
</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>}
<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"><MapPinned className="mt-0.5 size-3.5 shrink-0" />{areasLabel || t("warnings.areaUnknown")}</p>
<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">
<MapPinned className="mt-0.5 size-3.5 shrink-0" />
{areasLabel || t("warnings.areaUnknown")}
</p>
</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>
</motion.article>
);

View File

@@ -16,7 +16,9 @@ import type { WeatherWarning } from "@/types/imgw";
function WarningGrid({ warnings, indexOffset = 0 }: { warnings: WeatherWarning[]; indexOffset?: number }) {
return (
<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>
);
}
@@ -27,37 +29,60 @@ export function WarningsPanel() {
const selectedStationId = useWeatherStore((state) => state.selectedStationId);
const selectedLocation = useWeatherStore((state) => state.selectedLocation);
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 (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);
if (!province) return <WarningGrid warnings={warnings} />;
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 otherWarnings = warnings.filter((warning) => !warningMatchesLocalSelection(warning, province, selectedLocation));
const otherWarnings = warnings.filter(
(warning) => !warningMatchesLocalSelection(warning, province, selectedLocation),
);
return (
<div className="space-y-9">
<section className="space-y-4">
<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>
<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>
{localWarnings.length
? <WarningGrid warnings={localWarnings} />
: <EmptyState title={t("warnings.myProvinceEmptyTitle")} description={t("warnings.myProvinceEmptyDescription", { province: localLabel })} />}
{localWarnings.length ? (
<WarningGrid warnings={localWarnings} />
) : (
<EmptyState
title={t("warnings.myProvinceEmptyTitle")}
description={t("warnings.myProvinceEmptyDescription", { province: localLabel })}
/>
)}
</section>
{otherWarnings.length > 0 && (
<section className="space-y-4">
<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>
</div>
<WarningGrid warnings={otherWarnings} indexOffset={localWarnings.length} />

View File

@@ -1,7 +1,15 @@
"use client";
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 { MetricCard } from "@/components/weather/metric-card";
import { useI18n } from "@/lib/i18n";
@@ -13,17 +21,57 @@ export function CurrentConditionsCard({ station }: { station: SynopStation }) {
const windSpeedUnit = useWeatherStore((state) => state.windSpeedUnit);
const feelsLike = calculateFeelsLike(station.temperature, station.humidity, station.windSpeed);
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: 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") },
{
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: 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 (
<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>
);
}

View File

@@ -67,11 +67,13 @@ export function CurrentLocationControl({ stations }: { stations: LocatedSynopSta
},
(error) => {
setIsLocating(false);
setMessage(error.code === error.PERMISSION_DENIED
setMessage(
error.code === error.PERMISSION_DENIED
? t("location.gpsDenied")
: error.code === error.TIMEOUT
? t("location.gpsTimeout")
: t("location.gpsPositionUnavailable"));
: t("location.gpsPositionUnavailable"),
);
},
{ enableHighAccuracy: true, maximumAge: 5 * 60 * 1000, timeout: 12_000 },
);
@@ -87,11 +89,14 @@ export function CurrentLocationControl({ stations }: { stations: LocatedSynopSta
useEffect(() => {
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;
autoLocated.current = true;
locate();
}).catch(() => undefined);
})
.catch(() => undefined);
}, [locate]);
return (
@@ -99,17 +104,27 @@ export function CurrentLocationControl({ stations }: { stations: LocatedSynopSta
{showPrompt && (
<div className="glass-subtle rounded-card p-3.5">
<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">
<p className="text-sm font-semibold">{t("location.gpsPromptTitle")}</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">
<Button type="button" onClick={locate} disabled={isLocating}>
{isLocating ? <LoaderCircle className="size-4 animate-spin" /> : <LocateFixed className="size-4" />}
{isLocating ? t("location.gpsLocating") : t("location.gpsAllow")}
</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>
@@ -121,11 +136,23 @@ export function CurrentLocationControl({ stations }: { stations: LocatedSynopSta
{isLocating ? <LoaderCircle className="size-4 animate-spin" /> : <LocateFixed className="size-4" />}
{isLocating ? t("location.gpsLocating") : t("location.gpsUse")}
</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>
)}
<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>
</div>
);

View File

@@ -14,8 +14,15 @@ export function FavoritesSection({ stations }: { stations: SynopStation[] }) {
return (
<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="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">{favorites.map((station, index) => <StationCard key={station.id} station={station} index={index} />)}</div>
<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="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>
);
}

View File

@@ -19,7 +19,10 @@ export function FeaturedStationsSection({ stations }: { stations: SynopStation[]
return (
<section className="space-y-3">
<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>
</div>
<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"
key={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="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>
<WeatherIcon mood={getWeatherMoodFromData(station)} className="size-6 shrink-0 text-accent" />
</button>

View File

@@ -10,7 +10,13 @@ import { useWeatherStore } from "@/lib/store";
import type { MeteoStationPosition, SynopStation } from "@/types/imgw";
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 [query, setQuery] = useState("");
const [isFocused, setIsFocused] = useState(false);
@@ -18,12 +24,17 @@ export function LocationSearch({ stations, positions }: { stations: SynopStation
const selectLocation = useWeatherStore((state) => state.selectLocation);
const { data: locations, isFetching, isError } = useLocationSearch(query, language);
const locatedStations = useMemo(() => locateSynopStations(stations, positions), [positions, stations]);
const suggestions = useMemo(() => (locations ?? []).map((location) => ({
const suggestions = useMemo(
() =>
(locations ?? []).map((location) => ({
location,
selected: createSelectedLocation(location, locatedStations),
})), [locatedStations, locations]);
})),
[locatedStations, locations],
);
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) {
selectLocation(location);
@@ -48,7 +59,11 @@ export function LocationSearch({ stations, positions }: { stations: SynopStation
<div className="relative z-20">
<label className="relative block">
<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
value={query}
onChange={(event) => setQuery(event.target.value)}
@@ -59,13 +74,26 @@ export function LocationSearch({ stations, positions }: { stations: SynopStation
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"
/>
{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>
{showSuggestions && (
<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> :
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> :
{isPreparingStations ? (
<p className="px-3 py-4 text-sm text-muted">{t("location.preparing")}</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 }) => (
<button
type="button"
@@ -75,15 +103,28 @@ export function LocationSearch({ stations, positions }: { stations: SynopStation
>
<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>
{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>
))}
))
)}
</div>
)}
</div>
@@ -91,12 +132,24 @@ export function LocationSearch({ stations, positions }: { stations: SynopStation
{selectedLocation && (
<p className="mt-3 px-1 text-xs text-muted">
{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 })}
</p>
)}
<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>
</div>
</section>

View File

@@ -4,9 +4,25 @@ import { motion } from "framer-motion";
import type { LucideIcon } from "lucide-react";
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 (
<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">
<div className="flex items-center gap-2 text-xs font-medium uppercase tracking-[0.14em] text-muted">
<Icon className="size-4 text-accent" />

View File

@@ -4,7 +4,13 @@ import Link from "next/link";
import { motion } from "framer-motion";
import { Droplets, Gauge, Heart, Wind } from "lucide-react";
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 { Card } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
@@ -23,24 +29,56 @@ export function StationCard({ station, index = 0 }: { station: SynopStation; ind
const mood = getWeatherMoodFromData(station);
const compactWind = station.windSpeed === null ? "—" : formatWind(station.windSpeed, null, language, windSpeedUnit);
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">
<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="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>
<div className="flex flex-col items-end gap-2">
<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")} />
</Button>
</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">
<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
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"
>
<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>
</Card>
</motion.div>

View File

@@ -27,7 +27,13 @@ export function StationDetailPage({ id }: { id: string }) {
return (
<div className="space-y-6">
<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)}>
<Heart className={cn("size-4", favorite && "fill-rose-500 text-rose-500")} />
{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]">
<SnapshotChart station={station} />
<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>
<p className="mt-2 text-sm leading-6 text-muted">{t("station.qualityDescription")}</p>
<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><dt className="text-muted">{t("station.source")}</dt><dd className="mt-0.5 font-medium">{t("station.publicApi")}</dd></div>
<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>
</Card>
</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 isRaining = (precipitation10m ?? 0) > 0;
@@ -55,7 +61,11 @@ export function WeatherEffects({ precipitation10m, thunderstorm = false }: { pre
<motion.span
key={`rain-${index}`}
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" }}
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 }}
@@ -65,7 +75,11 @@ export function WeatherEffects({ precipitation10m, thunderstorm = false }: { pre
<motion.span
key={`mist-${index}`}
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" }}
className="absolute -top-8 h-7 w-px rotate-[10deg] rounded-full bg-accent/55"
style={{ left: drop.left }}
@@ -80,7 +94,11 @@ export function WeatherEffects({ precipitation10m, thunderstorm = false }: { pre
key={`lightning-${index}`}
viewBox="0 0 96 176"
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 }}
className={`absolute text-foreground dark:text-white ${bolt.className}`}
>

View File

@@ -31,7 +31,21 @@ function readDevWeatherEffectOverride(): DevWeatherEffectOverride | 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 temperatureUnit = useWeatherStore((state) => state.temperatureUnit);
const windSpeedUnit = useWeatherStore((state) => state.windSpeedUnit);
@@ -40,8 +54,15 @@ export function WeatherHero({ station, currentWeather, currentWeatherLoading = f
const hasGlobalModel = currentWeather?.coverage === "global-model";
const hasFullHybridAnalysis = currentWeather?.coverage === "full" || currentWeather?.coverage === "hourly";
const hasPartialHybridAnalysis = currentWeather?.coverage === "precipitation-only";
const hasDistantFallback = !hasGlobalModel && !hasFullHybridAnalysis && !currentWeatherLoading && distanceKm !== undefined && distanceKm !== null && distanceKm >= 30;
const displayedStation = currentWeather ? {
const hasDistantFallback =
!hasGlobalModel &&
!hasFullHybridAnalysis &&
!currentWeatherLoading &&
distanceKm !== undefined &&
distanceKm !== null &&
distanceKm >= 30;
const displayedStation = currentWeather
? {
...station,
measuredAt: hasFullHybridAnalysis || hasGlobalModel ? currentWeather.measuredAt : station.measuredAt,
temperature: currentWeather.temperature ?? station.temperature,
@@ -50,21 +71,38 @@ export function WeatherHero({ station, currentWeather, currentWeatherLoading = f
humidity: currentWeather.humidity ?? station.humidity,
pressure: currentWeather.pressure ?? station.pressure,
rainfall: currentWeather.precipitation10m ?? station.rainfall,
} : station;
}
: station;
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 = [
{ 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) },
];
const effectPrecipitation10m = devEffectOverride === "rain" || devEffectOverride === "thunderstorm"
const effectPrecipitation10m =
devEffectOverride === "rain" || devEffectOverride === "thunderstorm"
? 0.1
: devEffectOverride === "none"
? 0
: currentWeather?.precipitation10m;
const effectThunderstorm = devEffectOverride === "thunderstorm"
const effectThunderstorm =
devEffectOverride === "thunderstorm"
? true
: devEffectOverride === "rain" || devEffectOverride === "none"
? false
@@ -97,10 +135,14 @@ export function WeatherHero({ station, currentWeather, currentWeatherLoading = f
<div className="relative z-10">
<div>
<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 className="mt-2 space-y-1 text-xs text-muted">
<p>{currentWeatherLoading
<p>
{currentWeatherLoading
? t("location.heroHybridLoading", { station: station.name })
: hasGlobalModel
? t("location.heroGlobalModelSource", { location: displayedLocationName })
@@ -109,10 +151,21 @@ export function WeatherHero({ station, currentWeather, currentWeatherLoading = f
: hasPartialHybridAnalysis
? t("location.heroHybridPartial", { station: station.name, distance: distanceKm ?? 0 })
: locationName
? t("location.heroStationFallbackWithDistance", { station: station.name, distance: distanceKm ?? 0 })
: 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>}
? t("location.heroStationFallbackWithDistance", {
station: station.name,
distance: distanceKm ?? 0,
})
: 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 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)}
</div>
<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>
<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 className="mt-8 grid grid-cols-2 gap-2.5 sm:mt-10 lg:grid-cols-4">
{metrics.map(({ icon: Icon, label, value }) => (
<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>
</div>
))}

View File

@@ -2,8 +2,23 @@ import { Cloud, CloudLightning, CloudRain, CloudSun, MoonStar, Snowflake, Thermo
import type { WeatherMood } from "@/types/imgw";
import type { CurrentWeatherCondition } from "@/types/imgw-current";
export function WeatherIcon({ mood, condition, className = "" }: { mood: WeatherMood; condition?: CurrentWeatherCondition; className?: string }) {
const Icon = condition === "thunderstorm" ? CloudLightning : condition === "rain" ? CloudRain : condition === "snow" ? Snowflake : {
export function WeatherIcon({
mood,
condition,
className = "",
}: {
mood: WeatherMood;
condition?: CurrentWeatherCondition;
className?: string;
}) {
const Icon =
condition === "thunderstorm"
? CloudLightning
: condition === "rain"
? CloudRain
: condition === "snow"
? Snowflake
: {
warm: ThermometerSun,
cloudy: Cloud,
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
| 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/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`. |
@@ -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.
| Metoda | Endpoint | Przeznaczenie |
| --- | --- | --- |
| -------- | ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `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. |
| `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
| Endpoint | Cache |
| --- | --- |
| ------------------------ | ------------------------------------------------- |
| `/api/forecast` | `s-maxage=900`, `stale-while-revalidate=1800` |
| `/api/current-weather` | `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ą:
```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.

View File

@@ -5,7 +5,7 @@
## IMGW
| Dane | Źródło |
| --- | --- |
| ----------------------------- | ------------------------------------------------------ |
| 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` |
| 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
| Capability | Polska | Global |
| --- | --- | --- |
| --------------------- | ------------------------------ | ---------------------------- |
| Bieżące warunki | IMGW Hybrid + fallback `synop` | Open-Meteo model |
| Prognoza godzinowa | 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
| Dane | Cache |
| --- | --- |
| ----------------------------------- | ------------------------------------------ |
| IMGW Hybrid | 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` |

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`,
- 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`,
- 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

View File

@@ -92,7 +92,7 @@ IMGW Hybrid dostarcza m.in. opad 10-minutowy. Ten parametr jest prezentowany odd
Prognoza godzinowa i dzienna rozpoznaje:
| Stan | Opis w interfejsie |
| --- | --- |
| -------------- | ---------------------- |
| `clear` | Bezchmurnie |
| `partlyCloudy` | Częściowe zachmurzenie |
| `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:
| Cloud | Opis w hero |
| --- | --- |
| ------- | -------------------------------- |
| `>= 75` | Pochmurno |
| `>= 25` | Częściowe zachmurzenie |
| `< 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.
| Mood | Reguła |
| --- | --- |
| -------- | ------------------------------------ |
| `night` | godzina przed `06:00` lub od `21:00` |
| `wind` | wiatr od `8 m/s` |
| `cold` | temperatura do `3°C` |

View File

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

View File

@@ -20,19 +20,25 @@ export const DASHBOARD_SECTION_SETTINGS = [
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 const DEFAULT_DASHBOARD_SECTION_VISIBILITY = DASHBOARD_SECTION_SETTINGS.reduce((visibility, section) => ({
export const DEFAULT_DASHBOARD_SECTION_VISIBILITY = DASHBOARD_SECTION_SETTINGS.reduce(
(visibility, section) => ({
...visibility,
[section.id]: section.defaultVisible,
}), {} as DashboardSectionVisibility);
}),
{} as DashboardSectionVisibility,
);
export function normalizeDashboardSectionVisibility(value: unknown): DashboardSectionVisibility {
const stored = value && typeof value === "object" ? value as Partial<Record<DashboardSectionId, unknown>> : {};
return DASHBOARD_SECTION_SETTINGS.reduce((visibility, section) => ({
const stored = value && typeof value === "object" ? (value as Partial<Record<DashboardSectionId, unknown>>) : {};
return DASHBOARD_SECTION_SETTINGS.reduce(
(visibility, section) => ({
...visibility,
[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[] {
return Array.isArray(value) ? value.flatMap((candidate) => {
return Array.isArray(value)
? value.flatMap((candidate) => {
if (!candidate || typeof candidate !== "object") return [];
const row = candidate as Partial<HourlyForecast>;
const time = readString(row.time);
if (!time) return [];
return [{
return [
{
time,
temperature: readNumber(row.temperature),
feelsLike: readNumber(row.feelsLike),
@@ -33,17 +35,21 @@ function normalizeHourlyForecast(value: unknown): HourlyForecast[] {
weatherCode: readNumber(row.weatherCode),
windSpeed: readNumber(row.windSpeed),
source: isForecastSource(row.source) ? row.source : "open-meteo",
}];
}) : [];
},
];
})
: [];
}
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 [];
const row = candidate as Partial<DailyForecast>;
const date = readString(row.date);
if (!date) return [];
return [{
return [
{
date,
temperatureMax: readNumber(row.temperatureMax),
temperatureMin: readNumber(row.temperatureMin),
@@ -53,14 +59,17 @@ function normalizeDailyForecast(value: unknown): DailyForecast[] {
sunrise: readString(row.sunrise),
sunset: readString(row.sunset),
sources: normalizeSources(row.sources),
}];
}) : [];
},
];
})
: [];
}
function normalizeForecast(raw: Partial<WeatherForecast>): WeatherForecast {
const latitude = Number(raw.latitude);
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";
return {
latitude,
@@ -71,7 +80,9 @@ function normalizeForecast(raw: Partial<WeatherForecast>): WeatherForecast {
daily: normalizeDailyForecast(raw.daily),
sources: normalizeSources(raw.sources),
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 response = await fetch(`/api/forecast?${params}`, { signal });
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) => {
const time = readString(series, "time", index);
if (!time) return [];
return [{
return [
{
time,
temperature: readNumber(series, "temperature_2m", index),
feelsLike: readNumber(series, "apparent_temperature", index),
@@ -75,7 +76,8 @@ function normalizeOpenMeteoHourly(series: RawForecastSeries = {}): HourlyForecas
weatherCode: readNumber(series, "weather_code", index),
windSpeed: readNumber(series, "wind_speed_10m", index),
source: "open-meteo" as const,
}];
},
];
});
}
@@ -83,7 +85,8 @@ function normalizeOpenMeteoDaily(series: RawForecastSeries = {}): DailyForecast[
return asArray(series.time).flatMap((_, index) => {
const date = readString(series, "time", index);
if (!date) return [];
return [{
return [
{
date,
temperatureMax: readNumber(series, "temperature_2m_max", index),
temperatureMin: readNumber(series, "temperature_2m_min", index),
@@ -93,14 +96,16 @@ function normalizeOpenMeteoDaily(series: RawForecastSeries = {}): DailyForecast[
sunrise: readString(series, "sunrise", index),
sunset: readString(series, "sunset", index),
sources: ["open-meteo" as const],
}];
},
];
});
}
function normalizeOpenMeteoForecast(raw: RawWeatherForecast, region: WeatherRegion): WeatherForecast {
const latitude = Number(raw.latitude);
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 {
latitude,
longitude,
@@ -169,9 +174,11 @@ function getWeatherCodePriority(code: number | null) {
function getRepresentativeWeatherCode(hours: HourlyForecast[], fallback: number | null) {
if (!hours.length) return fallback;
return hours.reduce((selected, hour) => (
getWeatherCodePriority(hour.weatherCode) > getWeatherCodePriority(selected) ? hour.weatherCode : selected
), null as number | null);
return hours.reduce(
(selected, hour) =>
getWeatherCodePriority(hour.weatherCode) > getWeatherCodePriority(selected) ? hour.weatherCode : selected,
null as number | null,
);
}
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`));
return {
...day,
temperatureMax: getMaximum(dayHours.map((hour) => hour.temperature), day.temperatureMax),
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),
temperatureMax: getMaximum(
dayHours.map((hour) => hour.temperature),
day.temperatureMax,
),
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),
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 imgwHours = normalizeImgwHourly(imgwPayload);
let hasImgwHours = false;

View File

@@ -1,6 +1,11 @@
import type { Language, TranslationKey } 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 { 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;
}
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 hasLikelyPrecipitation = (hour.precipitationProbability ?? 0) >= 70;
if (isDryWeatherCode(hour.weatherCode) && (hasMeasuredPrecipitation || hasLikelyPrecipitation)) return 61;
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 "—";
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`;
}
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 "—";
return formatWindSpeed(value, language, unit);
}
@@ -76,7 +91,8 @@ function getForecastHourOfDay(time: string) {
export function getUpcomingHourlyForecast(hours: HourlyForecast[], limit = 24) {
const currentHour = getWarsawForecastHour();
const currentTimestamp = parseForecastHour(currentHour);
const upcomingHours = currentTimestamp === null
const upcomingHours =
currentTimestamp === null
? hours.filter((hour) => hour.time >= currentHour)
: hours.filter((hour) => {
const hourTimestamp = parseForecastHour(hour.time);
@@ -86,7 +102,8 @@ export function getUpcomingHourlyForecast(hours: HourlyForecast[], limit = 24) {
if (upcomingHours.length) return upcomingHours.slice(0, limit);
const currentHourOfDay = getForecastHourOfDay(currentHour);
const fallbackHours = currentHourOfDay === null
const fallbackHours =
currentHourOfDay === null
? hours
: hours.filter((hour) => {
const forecastHourOfDay = getForecastHourOfDay(hour.time);

View File

@@ -21,17 +21,21 @@ const translations = {
"theme.dark": "Włącz ciemny motyw",
"settings.section": "Preferencje",
"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.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.description": "Przełącz jasny lub ciemny wygląd aplikacji na tym urządzeniu.",
"settings.dashboardSections.title": "Strona główna",
"settings.dashboardSections.description": "Wybierz, które dodatkowe sekcje mają być widoczne na dashboardzie.",
"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.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.description": "Wybierz, które dodatkowe widoki mają być widoczne w nawigacji.",
"settings.appSections.warnings.title": "Ostrzeżenia",
@@ -39,33 +43,41 @@ const translations = {
"settings.appSections.hydro.title": "Hydro",
"settings.appSections.hydro.description": "Pokazuje widok monitoringu hydrologicznego IMGW w głównej nawigacji.",
"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.c": "°C",
"settings.units.temperature.f": "°F",
"settings.units.wind.title": "Jednostka wiatru",
"settings.units.wind.description": "Stosowana w prognozie, briefach, powiadomieniach i bieżących pomiarach na tym urządzeniu.",
"settings.units.wind.description":
"Stosowana w prognozie, briefach, powiadomieniach i bieżących pomiarach na tym urządzeniu.",
"settings.units.wind.label": "Wybierz jednostkę wiatru",
"settings.units.wind.ms": "m/s",
"settings.units.wind.kmh": "km/h",
"settings.units.wind.mph": "mph",
"settings.notifications.title": "Powiadomienia o ostrzeżeniach meteo",
"settings.notifications.description": "Przygotuj alerty dla nowych ostrzeżeń meteorologicznych IMGW, takich jak burze, upał, silny wiatr lub intensywny deszcz.",
"settings.notifications.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.regionDescription": "Aktualnie wybrany obszar dla przyszłych powiadomień: {province}.",
"settings.notifications.regionSelected": "Używaj lokalizacji wybranej w aplikacji",
"settings.notifications.regionManual": "Wybierz województwo ręcznie",
"settings.notifications.noProvince": "brak wybranego województwa",
"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.enable": "Alerty IMGW",
"settings.notifications.enableDescription": "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.enableDescription":
"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.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.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.disable": "Wyłącz na tym urządzeniu",
"settings.notifications.enabledStatus": "Włączone",
@@ -82,11 +94,14 @@ const translations = {
"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.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.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.implementationNote": "Serwerowy sprawdzacz wysyła nowe ostrzeżenia meteo IMGW oraz briefy przez Web Push. Subskrypcje i historia wysyłek są przechowywane w SQLite.",
"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.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",
"common.noData": "Brak danych",
"common.loading": "Ładowanie danych",
@@ -95,9 +110,11 @@ const translations = {
"error.description": "Sprawdź połączenie i spróbuj ponownie.",
"dashboard.error": "Nie udało się pobrać listy stacji synoptycznych IMGW.",
"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.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.emptyTitle": "Brak briefu",
"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.nearest": "Najbliższa stacja IMGW",
"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.currentSourceGlobal": "{location}: współrzędne są używane dla modelowych warunków bieżących i prognozy Open-Meteo.",
"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.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.heroGlobalModelSource": "Modelowe warunki bieżące Open-Meteo dla lokalizacji: {location}",
"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.heroStationFallback": "Dane zastępcze ze stacji IMGW: {station}",
"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.gpsLocating": "Ustalam lokalizację…",
"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.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.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.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.gpsFallbackName": "Bieżąca lokalizacja",
"location.gpsSelected": "Wybrano lokalizację: {location}.",
@@ -171,7 +195,8 @@ const translations = {
"weather.temperatureDetail": "Temperatura powietrza",
"forecast.label": "Prognoza modelowa",
"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.daily": "Prognoza 7-dniowa",
"forecast.today": "Dzisiaj",
@@ -196,7 +221,8 @@ const translations = {
"forecast.maxProbability": "Maks. szansa opadu",
"forecast.pastHour": "Miniona godzina",
"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.error": "Nie udało się pobrać prognozy modelowej.",
"forecast.emptyTitle": "Brak prognozy",
@@ -214,7 +240,8 @@ const translations = {
"station.all": "Wszystkie stacje",
"station.label": "Stacja {name}",
"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.quality": "Jakość danych",
"station.lastMeasurementImgw": "Ostatni pomiar IMGW",
@@ -224,21 +251,26 @@ const translations = {
"station.publicApi": "Publiczne API IMGW",
"snapshot.label": "Snapshot pomiarowy",
"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.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.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.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.otherRegionsDescription": "Aktywne komunikaty IMGW dla pozostałych obszarów Polski.",
"warnings.error": "Nie udało się pobrać ostrzeżeń meteorologicznych ani hydrologicznych.",
"warnings.emptyTitle": "Brak aktywnych ostrzeżeń",
"warnings.emptyDescription": "IMGW nie publikuje obecnie ostrzeżeń meteorologicznych ani hydrologicznych.",
"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.levelUnknown": "Poziom nieokreślony",
"warnings.level": "Stopień {level}",
@@ -259,7 +291,8 @@ const translations = {
"warnings.dashboard.viewAll": "Zobacz wszystkie",
"hydro.section": "Monitoring wód IMGW",
"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.searchLabel": "Szukaj stacji hydrologicznej",
"hydro.searchPlaceholder": "Szukaj stacji, rzeki lub województwa…",
@@ -272,7 +305,8 @@ const translations = {
"hydro.flow": "Przepływ",
"hydro.levelMeasurement": "Pomiar poziomu: {date}",
"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",
},
en: {
@@ -290,17 +324,21 @@ const translations = {
"theme.dark": "Enable dark theme",
"settings.section": "Preferences",
"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.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.description": "Switch the light or dark appearance on this device.",
"settings.dashboardSections.title": "Home screen",
"settings.dashboardSections.description": "Choose which additional sections are visible on the dashboard.",
"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.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.description": "Choose which additional views are visible in navigation.",
"settings.appSections.warnings.title": "Warnings",
@@ -308,7 +346,8 @@ const translations = {
"settings.appSections.hydro.title": "Hydro",
"settings.appSections.hydro.description": "Shows the IMGW hydrological monitoring view in main navigation.",
"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.c": "°C",
"settings.units.temperature.f": "°F",
@@ -319,22 +358,28 @@ const translations = {
"settings.units.wind.kmh": "km/h",
"settings.units.wind.mph": "mph",
"settings.notifications.title": "Weather warning notifications",
"settings.notifications.description": "Prepare alerts for new IMGW meteorological warnings, such as thunderstorms, heat, strong wind or heavy rain.",
"settings.notifications.description":
"Prepare alerts for new IMGW meteorological warnings, such as thunderstorms, heat, strong wind or heavy rain.",
"settings.notifications.regionTitle": "Alert area",
"settings.notifications.regionDescription": "Current area for future notifications: {province}.",
"settings.notifications.regionSelected": "Use the location selected in the app",
"settings.notifications.regionManual": "Choose province manually",
"settings.notifications.noProvince": "no province selected",
"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.enable": "IMGW alerts",
"settings.notifications.enableDescription": "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.enableDescription":
"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.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.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.disable": "Disable on this device",
"settings.notifications.enabledStatus": "Enabled",
@@ -351,11 +396,14 @@ const translations = {
"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.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.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.implementationNote": "The server checker sends new IMGW meteorological warnings and briefs through Web Push. Subscriptions and delivery history are stored in SQLite.",
"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.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",
"common.noData": "No data",
"common.loading": "Loading data",
@@ -379,28 +427,36 @@ const translations = {
"location.empty": "No matching place was found.",
"location.nearest": "Nearest IMGW station",
"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.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.heroHybridLoading": "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.heroHybridLoading":
"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.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.attribution": "Place search:",
"location.gpsUse": "Use my location",
"location.gpsLocating": "Finding 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.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.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.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.gpsFallbackName": "Current location",
"location.gpsSelected": "Selected location: {location}.",
@@ -436,11 +492,13 @@ const translations = {
"weather.pressureDetail": "Atmospheric pressure",
"weather.windSpeedDetail": "Current IMGW reading",
"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",
"forecast.label": "Model forecast",
"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.daily": "7-day forecast",
"forecast.today": "Today",
@@ -465,7 +523,8 @@ const translations = {
"forecast.maxProbability": "Max. rain chance",
"forecast.pastHour": "Past hour",
"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.error": "Unable to load the model forecast.",
"forecast.emptyTitle": "Forecast unavailable",
@@ -483,7 +542,8 @@ const translations = {
"station.all": "All stations",
"station.label": "Station {name}",
"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.quality": "Data details",
"station.lastMeasurementImgw": "Latest IMGW measurement",
@@ -493,12 +553,15 @@ const translations = {
"station.publicApi": "Public IMGW API",
"snapshot.label": "Measurement snapshot",
"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.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.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.myProvinceEmptyDescription": "IMGW is not currently publishing active warnings for {province}.",
"warnings.otherRegions": "Other regions",
@@ -507,7 +570,8 @@ const translations = {
"warnings.emptyTitle": "No active warnings",
"warnings.emptyDescription": "IMGW is not currently publishing any meteorological or hydrological warnings.",
"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.levelUnknown": "Level not specified",
"warnings.level": "Level {level}",
@@ -528,7 +592,8 @@ const translations = {
"warnings.dashboard.viewAll": "View all",
"hydro.section": "IMGW water monitoring",
"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.searchLabel": "Search hydrological stations",
"hydro.searchPlaceholder": "Search by station, river or province…",
@@ -541,7 +606,8 @@ const translations = {
"hydro.flow": "Flow",
"hydro.levelMeasurement": "Level measurement: {date}",
"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",
},
} as const;
@@ -583,12 +649,15 @@ export function I18nProvider({ children }: PropsWithChildren) {
setLanguageState(nextLanguage);
}, []);
const value = useMemo<I18nContextValue>(() => ({
const value = useMemo<I18nContextValue>(
() => ({
language,
locale: language === "pl" ? "pl-PL" : "en-GB",
setLanguage,
t: (key, params) => translate(language, key, params),
}), [language, setLanguage]);
}),
[language, setLanguage],
);
return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>;
}

View File

@@ -1,8 +1,4 @@
import {
normalizeHydroStation,
normalizeSynopStation,
normalizeWarning,
} from "@/lib/weather-utils";
import { normalizeHydroStation, normalizeSynopStation, normalizeWarning } from "@/lib/weather-utils";
import type {
HydroStation,
MeteoStationPosition,
@@ -66,7 +62,7 @@ export async function fetchWarnings(signal?: AbortSignal): Promise<WeatherWarnin
fetchWarningsByKind("meteo", 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")) {
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) {
return hasNumericValue(candidate.Temperature)
&& hasNumericValue(candidate.Chill)
&& hasNumericValue(candidate.Humidity)
&& hasNumericValue(candidate.Wind_Speed)
&& hasNumericValue(candidate.PressureMSL);
return (
hasNumericValue(candidate.Temperature) &&
hasNumericValue(candidate.Chill) &&
hasNumericValue(candidate.Humidity) &&
hasNumericValue(candidate.Wind_Speed) &&
hasNumericValue(candidate.PressureMSL)
);
}
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[]) {
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));
if (!tenMinuteRow) return hourlyRow ?? null;
if (!hourlyRow) return tenMinuteRow;
@@ -74,19 +82,20 @@ function pickFullWeatherRow(rows: NormalizedHybridRow[]) {
function pickPrecipitationRow(rows: NormalizedHybridRow[], fullRow: NormalizedHybridRow | null) {
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 (!fullRow) return precipitationRows[0];
return precipitationRows.reduce((best, candidate) => (
Math.abs(candidate.timestamp - fullRow.timestamp) < Math.abs(best.timestamp - fullRow.timestamp) ? candidate : best
));
return precipitationRows.reduce((best, candidate) =>
Math.abs(candidate.timestamp - fullRow.timestamp) < Math.abs(best.timestamp - fullRow.timestamp) ? candidate : best,
);
}
export function normalizeImgwCurrentWeather(payload: RawImgwHybridWeatherResponse): ImgwCurrentWeather | null {
if (!payload.data?.Valid || !Array.isArray(payload.data.Data)) return null;
const rows = payload.data.Data
.flatMap((candidate): NormalizedHybridRow[] => {
const rows = payload.data.Data.flatMap((candidate): NormalizedHybridRow[] => {
if (!candidate || typeof candidate !== "object") return [];
const row = candidate as RawImgwHybridWeatherRow;
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 response = await fetch(`/api/imgw-current?${params}`, { signal });
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 response = await fetch(`/api/current-weather?${params}`, { signal });
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) {
if (!value || typeof value !== "object") return false;
const response = value as { status?: unknown; message?: unknown };
return response.status === false
&& typeof response.message === "string"
&& response.message.toLocaleLowerCase("en-US").includes("no products were found");
return (
response.status === false &&
typeof response.message === "string" &&
response.message.toLocaleLowerCase("en-US").includes("no products were found")
);
}
export async function readImgwResponseBody(response: Response) {

View File

@@ -1,14 +1,22 @@
import type { Language } from "@/lib/i18n";
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 response = await fetch(`/api/locations/search?${params}`, { signal });
if (!response.ok) throw new Error("Location search is temporarily unavailable.");
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 response = await fetch(`/api/locations/reverse?${params}`);
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) {
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 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;
return earthRadiusKm * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
}
@@ -81,12 +82,26 @@ export function findNearestSynopStation(
}
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[],
): SelectedLocation {
const countryCode = location.countryCode?.toUpperCase() ?? null;
const region = location.region ?? getWeatherRegionForCountryCode(countryCode);
const nearest = region === "PL"
const nearest =
region === "PL"
? stations.reduce<{ station: LocatedSynopStation; distanceKm: number } | null>((best, station) => {
const distance = distanceKm(location.latitude, location.longitude, station.latitude, station.longitude);
return !best || distance < best.distanceKm ? { station, distanceKm: distance } : best;
@@ -103,7 +118,8 @@ export function createSelectedLocation(
admin2: location.admin2,
timezone: location.timezone,
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,
longitude: location.longitude,
stationId: nearest?.station.id ?? null,

View File

@@ -28,7 +28,12 @@ interface SavePushSubscriptionOptions {
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", {
method: "POST",
headers: { "content-type": "application/json" },

View File

@@ -85,22 +85,22 @@ const provinceByStationId: Record<string, Province> = {
};
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" },
"lubelskie": { pl: "lubelskie", en: "Lublin" },
"lubuskie": { pl: "lubuskie", en: "Lubusz" },
"łódzkie": { pl: "łódzkie", en: "Łódź" },
"małopolskie": { pl: "małopolskie", en: "Lesser Poland" },
"mazowieckie": { pl: "mazowieckie", en: "Masovian" },
"opolskie": { pl: "opolskie", en: "Opole" },
"podkarpackie": { pl: "podkarpackie", en: "Subcarpathian" },
"podlaskie": { pl: "podlaskie", en: "Podlaskie" },
"pomorskie": { pl: "pomorskie", en: "Pomeranian" },
ląskie": { pl: "śląskie", en: "Silesian" },
więtokrzyskie": { pl: "świętokrzyskie", en: "Świętokrzyskie" },
lubelskie: { pl: "lubelskie", en: "Lublin" },
lubuskie: { pl: "lubuskie", en: "Lubusz" },
łódzkie: { pl: "łódzkie", en: "Łódź" },
małopolskie: { pl: "małopolskie", en: "Lesser Poland" },
mazowieckie: { pl: "mazowieckie", en: "Masovian" },
opolskie: { pl: "opolskie", en: "Opole" },
podkarpackie: { pl: "podkarpackie", en: "Subcarpathian" },
podlaskie: { pl: "podlaskie", en: "Podlaskie" },
pomorskie: { pl: "pomorskie", en: "Pomeranian" },
śląskie: { pl: "śląskie", en: "Silesian" },
świętokrzyskie: { pl: "świętokrzyskie", en: "Świętokrzyskie" },
"warmińsko-mazurskie": { pl: "warmińsko-mazurskie", en: "Warmian-Masurian" },
"wielkopolskie": { pl: "wielkopolskie", en: "Greater Poland" },
"zachodniopomorskie": { pl: "zachodniopomorskie", en: "West Pomeranian" },
wielkopolskie: { pl: "wielkopolskie", en: "Greater Poland" },
zachodniopomorskie: { pl: "zachodniopomorskie", en: "West Pomeranian" },
};
export const PROVINCES = Object.keys(provinceLabels) as Province[];
@@ -123,11 +123,11 @@ export function getProvinceFromTeryt(code: string) {
}
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) {
return value ? provinceBySimplifiedName[simplifyProvinceName(value)] ?? null : null;
return value ? (provinceBySimplifiedName[simplifyProvinceName(value)] ?? null) : 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) {
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 validity = formatWarningValidity(warning, preference.language);
const level = getWarningLevelLabel(warning, preference.language);
const bodyParts = preference.language === "pl"
? [`${region}: ${level}`, validity, warning.probability !== null ? `prawdopodobieństwo ${warning.probability}%` : null]
const bodyParts =
preference.language === "pl"
? [
`${region}: ${level}`,
validity,
warning.probability !== null ? `prawdopodobieństwo ${warning.probability}%` : null,
]
: [`${region}: ${level}`, validity, warning.probability !== null ? `${warning.probability}% probability` : null];
return {
@@ -73,10 +80,13 @@ export async function sendWarningNotification(preference: WarningPushSubscriptio
export async function sendTestNotification(preference: WarningPushSubscription) {
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({
title: preference.language === "pl" ? "wtr.: test powiadomień" : "wtr.: notification test",
body: preference.language === "pl"
body:
preference.language === "pl"
? `Powiadomienia są aktywne dla: ${location}.`
: `Notifications are active for: ${location}.`,
url: "/settings",

View File

@@ -1,7 +1,12 @@
import Database from "better-sqlite3";
import fs from "node:fs";
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 { Province } from "@/types/province";
import type { WeatherRegion } from "@/types/weather-region";
@@ -121,7 +126,7 @@ function parseSubscription(row: PushSubscriptionRow): WarningPushSubscription |
return {
endpoint: row.endpoint,
subscription,
province: row.province === "global" ? null : row.province as Province,
province: row.province === "global" ? null : (row.province as Province),
region,
language: row.language === "en" ? "en" : "pl",
enabled: row.enabled === 1,
@@ -143,7 +148,9 @@ function parseSubscription(row: PushSubscriptionRow): WarningPushSubscription |
}
export function upsertPushSubscription(subscription: WarningPushSubscription) {
getDatabase().prepare(`
getDatabase()
.prepare(
`
INSERT INTO push_subscriptions (
endpoint,
subscription_json,
@@ -197,7 +204,9 @@ export function upsertPushSubscription(subscription: WarningPushSubscription) {
temperature_unit = excluded.temperature_unit,
wind_speed_unit = excluded.wind_speed_unit,
updated_at = excluded.updated_at
`).run({
`,
)
.run({
endpoint: subscription.endpoint,
subscriptionJson: JSON.stringify(subscription.subscription),
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 ((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;
}
@@ -76,7 +87,7 @@ export async function fetchImgwHybridCurrentWeather(latitude: number, longitude:
});
const response = await fetch(`${IMGW_HYBRID_URL}?${params}`, { next: { revalidate: 120 } });
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) {
@@ -109,5 +120,5 @@ export async function fetchServerCurrentWeather(latitude: number, longitude: num
});
const response = await fetch(`${OPEN_METEO_FORECAST_URL}?${params}`, { next: { revalidate: 120 } });
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) {
if (!response?.ok) return null;
try {
return await response.json() as RawImgwForecastResponse;
return (await response.json()) as RawImgwForecastResponse;
} catch {
return null;
}
@@ -29,7 +29,8 @@ export async function fetchServerForecast(latitude: number, longitude: number, r
latitude: String(latitude),
longitude: String(longitude),
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",
forecast_days: "7",
wind_speed_unit: "ms",
@@ -42,14 +43,20 @@ export async function fetchServerForecast(latitude: number, longitude: number, r
});
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"
? 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),
]);
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);
return mergeForecastSources(openMeteoPayload, imgwPayload, region);
}

View File

@@ -14,8 +14,10 @@ export async function fetchMeteoWarnings(signal?: AbortSignal): Promise<WeatherW
if (!response.ok) {
const details = await readImgwResponseBody(response);
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)) : [];
}

View File

@@ -5,8 +5,18 @@ import { persist } from "zustand/middleware";
import type { SelectedLocation } from "@/types/location";
import type { Province } from "@/types/province";
import type { TemperatureUnit, WindSpeedUnit } from "@/types/units";
import { 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_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 type { WeatherRegion } from "@/types/weather-region";
import { getWeatherRegionForCountryCode } from "@/types/weather-region";
@@ -70,10 +80,12 @@ export const useWeatherStore = create<WeatherStore>()(
setWarningNotificationProvince: (province) => set({ warningNotificationProvince: province }),
setTemperatureUnit: (unit) => set({ temperatureUnit: unit }),
setWindSpeedUnit: (unit) => set({ windSpeedUnit: unit }),
setDashboardSectionVisible: (section, visible) => set((state) => ({
setDashboardSectionVisible: (section, visible) =>
set((state) => ({
dashboardSections: { ...state.dashboardSections, [section]: visible },
})),
setAppSectionVisible: (section, visible) => set((state) => ({
setAppSectionVisible: (section, visible) =>
set((state) => ({
appSections: { ...state.appSections, [section]: visible },
})),
}),
@@ -81,7 +93,10 @@ export const useWeatherStore = create<WeatherStore>()(
name: "wtr:preferences",
migrate: (persisted) => {
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;
const dashboardSections = normalizeDashboardSectionVisibility(state.dashboardSections);
const appSections = normalizeAppSectionVisibility(state.appSections);

View File

@@ -50,7 +50,8 @@ const mazowieckieCountyTerytByName: Record<string, string> = {
};
function normalizeRegionName(value: string | null | undefined) {
return value
return (
value
?.replace(/[Łł]/g, "l")
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
@@ -62,7 +63,8 @@ function normalizeRegionName(value: string | null | undefined) {
.replace(/\s+/g, "_")
.replace(/[^a-z0-9_]/g, "")
.replace(/^warszawa_zachodnia$/, "warszawski_zachodni")
.trim() ?? "";
.trim() ?? ""
);
}
function normalizeTerytCountyCode(value: string) {
@@ -70,7 +72,11 @@ function normalizeTerytCountyCode(value: string) {
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);
if (normalizedProvince !== "mazowieckie") return null;
@@ -79,7 +85,7 @@ export function getCountyTerytForLocation(province: string | null | undefined, d
if (districtMatch) return districtMatch;
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) {
@@ -87,7 +93,11 @@ export function warningMatchesCounty(warning: WeatherWarning, countyTeryt: strin
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) {
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 { Province } from "@/types/province";
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";
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 ?? "";
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", {
timeZone: "Europe/Warsaw",
year: "numeric",
month: "2-digit",
day: "2-digit",
}).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")}`;
}
@@ -98,7 +106,9 @@ function getUpcomingHours(hours: HourlyForecast[], now: Date, limit = 24) {
const currentTimestamp = parseForecastHour(currentHour);
const upcoming = hours.filter((hour) => {
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);
}
@@ -135,11 +145,18 @@ function getPeakHour(hours: HourlyForecast[], selector: (hour: HourlyForecast) =
}, 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);
return warning.kind === "meteo"
&& (countyTeryt ? warningMatchesCounty(warning, countyTeryt) : !province || warning.provinces.includes(province))
&& (validTo === null || validTo > now);
return (
warning.kind === "meteo" &&
(countyTeryt ? warningMatchesCounty(warning, countyTeryt) : !province || warning.provinces.includes(province)) &&
(validTo === null || validTo > now)
);
}
function warningOverlapsWarsawDate(warning: WeatherWarning, dateKey: string) {
@@ -148,10 +165,17 @@ function warningOverlapsWarsawDate(warning: WeatherWarning, dateKey: string) {
return (!validFromKey || validFromKey <= dateKey) && (!validToKey || validToKey >= dateKey);
}
function isRelevantMeteoWarningForDate(warning: WeatherWarning, province: Province | null, countyTeryt: string | null | undefined, dateKey: string) {
return warning.kind === "meteo"
&& (countyTeryt ? warningMatchesCounty(warning, countyTeryt) : !province || warning.provinces.includes(province))
&& warningOverlapsWarsawDate(warning, dateKey);
function isRelevantMeteoWarningForDate(
warning: WeatherWarning,
province: Province | null,
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) {
@@ -164,7 +188,12 @@ function hasWeatherCode(hours: HourlyForecast[], predicate: (code: number) => bo
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 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));
@@ -199,7 +228,17 @@ function getTomorrowCondition(hours: HourlyForecast[], rainfallTotal: number | n
return language === "pl" ? "bez istotnych opadów" : "no significant precipitation";
}
export function buildWeatherBrief({ forecast, warnings, province, countyTeryt, locationName, language, 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);
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 rainPeakHour = getPeakHour(hours, (hour) => hour.precipitationProbability);
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)}`
: null;
const hasRainSignal = (maximumProbability ?? 0) >= 35 || (rainfallTotal ?? 0) >= 0.5;
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 headline = topWarning
@@ -241,45 +285,71 @@ export function buildWeatherBrief({ forecast, warnings, province, countyTeryt, l
const body: string[] = [];
if (topWarning) {
const warningRegion = provinceLabel ? `${provinceLabel}: ` : "";
const probability = topWarning.probability !== null
? language === "pl" ? ` Prawdopodobieństwo: ${topWarning.probability}%.` : ` Probability: ${topWarning.probability}%.`
const 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 || "weather warning"}${topWarning.level !== null ? `, level ${topWarning.level}` : ""}.${probability}`);
: `${warningRegion}${topWarning.title || "weather warning"}${topWarning.level !== null ? `, level ${topWarning.level}` : ""}.${probability}`,
);
}
if (temperatureRange) {
body.push(language === "pl"
body.push(
language === "pl"
? `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) {
const rainWindow = rainPeakHour && maximumProbability !== null && maximumProbability >= 20
? language === "pl" ? ` Największa szansa około ${formatHour(rainPeakHour.time)}.` : ` Highest chance around ${formatHour(rainPeakHour.time)}.`
const rainWindow =
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}`
: `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) {
body.push(language === "pl"
body.push(
language === "pl"
? `Wiatr maksymalnie do ${formatWindSpeed(maximumWind, language, windSpeedUnit)}.`
: `Wind up to ${formatWindSpeed(maximumWind, language, windSpeedUnit)}.`);
: `Wind up to ${formatWindSpeed(maximumWind, language, windSpeedUnit)}.`,
);
}
if (conditionPeakHour) {
body.push(language === "pl"
body.push(
language === "pl"
? `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 = [
`${locationName}:`,
temperatureRange,
maximumProbability !== 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,
maximumProbability !== 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);
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 {
@@ -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 hours = getForecastHoursForDate(forecast.hourly, targetDateKey);
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 rainPeakHour = getPeakHour(hours, (hour) => hour.precipitationProbability);
const hasThunderstorm = hasWeatherCode(hours, (code) => code >= 95);
const hasRainSignal = hasThunderstorm
|| (rainfallTotal ?? 0) >= 0.5
|| (maximumProbability ?? 0) >= 35
|| hasWeatherCode(hours, (code) => (code >= 51 && code <= 67) || (code >= 80 && code <= 82));
const hasRainSignal =
hasThunderstorm ||
(rainfallTotal ?? 0) >= 0.5 ||
(maximumProbability ?? 0) >= 35 ||
hasWeatherCode(hours, (code) => (code >= 51 && code <= 67) || (code >= 80 && code <= 82));
const hasWindSignal = (maximumWind ?? 0) >= 8;
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)}`
: 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 headline = topWarning
@@ -343,38 +426,49 @@ export function buildTomorrowWeatherBrief({ forecast, warnings, province, county
const body: string[] = [];
if (topWarning) {
const warningRegion = provinceLabel ? `${provinceLabel}: ` : "";
const probability = topWarning.probability !== null
? language === "pl" ? ` Prawdopodobieństwo: ${topWarning.probability}%.` : ` Probability: ${topWarning.probability}%.`
const 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 || "weather warning"}${topWarning.level !== null ? `, level ${topWarning.level}` : ""}.${probability}`);
: `${warningRegion}${topWarning.title || "weather warning"}${topWarning.level !== null ? `, level ${topWarning.level}` : ""}.${probability}`,
);
}
if (temperatureRange) {
body.push(language === "pl"
? `Temperatura jutro: ${temperatureRange}.`
: `Temperature tomorrow: ${temperatureRange}.`);
body.push(
language === "pl" ? `Temperatura jutro: ${temperatureRange}.` : `Temperature tomorrow: ${temperatureRange}.`,
);
}
body.push(language === "pl"
? `Sygnał modelu: ${condition}.`
: `Model signal: ${condition}.`);
body.push(language === "pl" ? `Sygnał modelu: ${condition}.` : `Model signal: ${condition}.`);
if (hasRainSignal || (rainfallTotal ?? 0) > 0) {
const rainWindow = rainPeakHour && maximumProbability !== null && maximumProbability >= 20
? language === "pl" ? ` Największa szansa około ${formatHour(rainPeakHour.time)}.` : ` Highest chance around ${formatHour(rainPeakHour.time)}.`
const rainWindow =
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}`
: `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 {
body.push(language === "pl" ? "Bez istotnego opadu w prognozie." : "No significant precipitation in the forecast.");
}
if (maximumWind !== null) {
body.push(language === "pl"
body.push(
language === "pl"
? `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)
: null;
const pushParts = [
@@ -382,10 +476,16 @@ export function buildTomorrowWeatherBrief({ forecast, warnings, province, county
temperatureRange,
condition,
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);
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 {

View File

@@ -85,10 +85,14 @@ export function normalizeHydroStation(raw: RawHydroStation): HydroStation | null
}
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.teryt ?? []).map(getProvinceFromTeryt),
].filter((province) => province !== null))];
].filter((province) => province !== null),
),
];
const describedAreas = (raw.obszary ?? [])
.map((area) => area.opis?.trim() || area.wojewodztwo?.trim())
.filter((area): area is string => Boolean(area));
@@ -106,7 +110,9 @@ export function normalizeWarning(raw: RawWarning, kind: WarningKind, index: numb
publishedAt: normalizeDate(raw.opublikowano),
probability: toNumber(raw.prawdopodobienstwo),
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,
office: raw.biuro?.trim() || null,
};
@@ -125,15 +131,25 @@ export function convertTemperature(value: number, unit: TemperatureUnit) {
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], {
maximumFractionDigits: 0,
}).format(value);
return `${formattedValue}${getTemperatureUnitLabel(unit)}`;
}
export function formatTemperature(value: number | null, language: Language = "pl", unit: TemperatureUnit = DEFAULT_TEMPERATURE_UNIT) {
return value === null ? translate(language, "common.noData") : formatTemperatureValue(convertTemperature(value, unit), language, unit);
export function formatTemperature(
value: number | null,
language: Language = "pl",
unit: TemperatureUnit = DEFAULT_TEMPERATURE_UNIT,
) {
return value === null
? translate(language, "common.noData")
: formatTemperatureValue(convertTemperature(value, unit), language, unit);
}
export function formatPressure(value: number | null, language: Language = "pl") {
@@ -158,7 +174,11 @@ export function convertWindSpeed(speed: number, unit: WindSpeedUnit) {
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");
const convertedSpeed = convertWindSpeed(speed, unit);
const digits = unit === "ms" ? 1 : 0;
@@ -169,7 +189,12 @@ export function formatWindSpeed(speed: number | null, language: Language = "pl",
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");
const directionLabel = direction === null || direction === undefined ? "" : ` ${getWindDirection(direction)}`;
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`;
}
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;
const date = new Date(value);
if (Number.isNaN(date.getTime())) return fallback;
@@ -215,9 +244,17 @@ export function calculateFeelsLike(temperature: number | null, humidity: number
const c7 = 0.002211732;
const c8 = 0.00072546;
const c9 = -0.000003582;
return c1 + 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 (
c1 +
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;
}
@@ -243,8 +280,10 @@ function getForecastConditionDescription(code: number | null, language: Language
if (code === 3) return translate(language, "forecast.condition.cloudy");
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 >= 61 && code <= 67) || (code >= 80 && code <= 82))) 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 >= 61 && code <= 67) || (code >= 80 && code <= 82)))
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");
return null;
}
@@ -270,7 +309,11 @@ export function getWeatherDescription(
if ((station.windSpeed ?? 0) >= 8) return translate(language, "weather.strongWind");
const currentDescription = getForecastConditionDescription(currentWeatherCode ?? null, 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 (cloudCoverDescription) return cloudCoverDescription;
const forecastDescription = getForecastConditionDescription(forecastWeatherCode ?? null, language);

17
package-lock.json generated
View File

@@ -32,6 +32,7 @@
"eslint": "^9.17.0",
"eslint-config-next": "^16.2.6",
"postcss": "^8.4.49",
"prettier": "^3.8.4",
"tailwindcss": "^3.4.17",
"typescript": "^5.7.2"
}
@@ -6184,6 +6185,22 @@
"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": {
"version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",

View File

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

View File

@@ -1,5 +1,15 @@
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) => {
event.waitUntil(caches.open(CACHE_NAME).then((cache) => cache.addAll(SHELL)));
@@ -8,7 +18,9 @@ self.addEventListener("install", (event) => {
self.addEventListener("activate", (event) => {
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();
});
@@ -16,7 +28,12 @@ self.addEventListener("activate", (event) => {
self.addEventListener("fetch", (event) => {
if (event.request.method !== "GET") return;
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(
fetch(event.request)
.then((response) => {
@@ -29,7 +46,11 @@ self.addEventListener("fetch", (event) => {
return;
}
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 cronSecret = process.env.NOTIFICATIONS_CRON_SECRET ?? "";
const warningIntervalMinutes = readPositiveNumber(process.env.NOTIFICATIONS_WARNING_INTERVAL_MINUTES, DEFAULT_WARNING_INTERVAL_MINUTES);
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);
const warningIntervalMinutes = readPositiveNumber(
process.env.NOTIFICATIONS_WARNING_INTERVAL_MINUTES,
DEFAULT_WARNING_INTERVAL_MINUTES,
);
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 lastMorningBriefCheckAt = 0;
@@ -112,6 +124,8 @@ async function tick() {
}
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();
setInterval(tick, LOOP_INTERVAL_MS);

View File

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

View File

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

View File

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