chore: add prettier formatting
All checks were successful
CI / Lint, typecheck and build (push) Successful in 9m56s
All checks were successful
CI / Lint, typecheck and build (push) Successful in 9m56s
This commit is contained in:
@@ -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
9
.prettierignore
Normal file
@@ -0,0 +1,9 @@
|
||||
node_modules
|
||||
.next
|
||||
out
|
||||
coverage
|
||||
data
|
||||
*.sqlite
|
||||
*.sqlite-*
|
||||
tsconfig.tsbuildinfo
|
||||
next-env.d.ts
|
||||
6
.prettierrc.json
Normal file
6
.prettierrc.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"printWidth": 120,
|
||||
"semi": true,
|
||||
"singleQuote": false,
|
||||
"trailingComma": "all"
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
21
README.md
21
README.md
@@ -62,16 +62,18 @@ 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 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. |
|
||||
| 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`.
|
||||
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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" },
|
||||
});
|
||||
|
||||
@@ -49,28 +49,32 @@ 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({
|
||||
name,
|
||||
province: data.address?.state ?? null,
|
||||
district: data.address?.county ?? null,
|
||||
country: data.address?.country ?? null,
|
||||
countryCode,
|
||||
admin1: data.address?.state ?? null,
|
||||
admin2: data.address?.county ?? null,
|
||||
timezone: null,
|
||||
region: getWeatherRegionForCountryCode(countryCode),
|
||||
}, {
|
||||
headers: { "Cache-Control": "public, s-maxage=86400, stale-while-revalidate=604800" },
|
||||
});
|
||||
return NextResponse.json(
|
||||
{
|
||||
name,
|
||||
province: data.address?.state ?? null,
|
||||
district: data.address?.county ?? null,
|
||||
country: data.address?.country ?? null,
|
||||
countryCode,
|
||||
admin1: data.address?.state ?? null,
|
||||
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 });
|
||||
}
|
||||
|
||||
@@ -30,26 +30,31 @@ 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 [{
|
||||
id: location.id,
|
||||
name: location.name,
|
||||
latitude: location.latitude as number,
|
||||
longitude: location.longitude as number,
|
||||
province: location.admin1 ?? null,
|
||||
district: location.admin2 ?? null,
|
||||
country: location.country ?? null,
|
||||
countryCode,
|
||||
admin1: location.admin1 ?? null,
|
||||
admin2: location.admin2 ?? null,
|
||||
timezone: location.timezone ?? null,
|
||||
region: getWeatherRegionForCountryCode(countryCode),
|
||||
}];
|
||||
return [
|
||||
{
|
||||
id: location.id,
|
||||
name: location.name,
|
||||
latitude: location.latitude as number,
|
||||
longitude: location.longitude as number,
|
||||
province: location.admin1 ?? null,
|
||||
district: location.admin2 ?? null,
|
||||
country: location.country ?? null,
|
||||
countryCode,
|
||||
admin1: location.admin1 ?? null,
|
||||
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 });
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
error: "Unable to check IMGW meteorological warnings.",
|
||||
details: getErrorMessage(error),
|
||||
}, { status: 502 });
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "Unable to check IMGW meteorological warnings.",
|
||||
details: getErrorMessage(error),
|
||||
},
|
||||
{ status: 502 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,18 +54,19 @@ 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
|
||||
? await fetchMeteoWarnings(AbortSignal.timeout(12_000)).catch(() => {
|
||||
warningsUnavailable = true;
|
||||
return [];
|
||||
})
|
||||
warningsUnavailable = true;
|
||||
return [];
|
||||
})
|
||||
: [];
|
||||
let sent = 0;
|
||||
let skipped = 0;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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,18 +66,19 @@ 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
|
||||
? await fetchMeteoWarnings(AbortSignal.timeout(12_000)).catch(() => {
|
||||
warningsUnavailable = true;
|
||||
return [];
|
||||
})
|
||||
warningsUnavailable = true;
|
||||
return [];
|
||||
})
|
||||
: [];
|
||||
let sent = 0;
|
||||
let skipped = 0;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -27,44 +27,78 @@ 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" ? {
|
||||
id: `global:${activeLocation.latitude},${activeLocation.longitude}`,
|
||||
name: activeLocation.name,
|
||||
measuredAt: null,
|
||||
temperature: null,
|
||||
windSpeed: null,
|
||||
windDirection: null,
|
||||
humidity: null,
|
||||
rainfall: null,
|
||||
pressure: null,
|
||||
} : selectedStation;
|
||||
const heroStation: SynopStation =
|
||||
activeLocation?.region === "GLOBAL"
|
||||
? {
|
||||
id: `global:${activeLocation.latitude},${activeLocation.longitude}`,
|
||||
name: activeLocation.name,
|
||||
measuredAt: null,
|
||||
temperature: null,
|
||||
windSpeed: null,
|
||||
windDirection: null,
|
||||
humidity: null,
|
||||
rainfall: null,
|
||||
pressure: null,
|
||||
}
|
||||
: 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>
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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}%`}
|
||||
|
||||
@@ -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
|
||||
: Cloud;
|
||||
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} />;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
? "—"
|
||||
: `${formatForecastTemperature(minimumTemperature, language, temperatureUnit)} / ${formatForecastTemperature(maximumTemperature, language, temperatureUnit)}`;
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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 haystack = `${station.name} ${station.river ?? ""} ${station.province ?? ""}`.toLocaleLowerCase(locale);
|
||||
return haystack.includes(query.trim().toLocaleLowerCase(locale));
|
||||
}), [locale, query, stations]);
|
||||
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],
|
||||
);
|
||||
|
||||
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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
? t("settings.notifications.globalRegion")
|
||||
: effectiveProvince ? formatProvinceName(effectiveProvince, language) : t("settings.notifications.noProvince");
|
||||
const effectiveProvinceLabel =
|
||||
selectedRegion === "GLOBAL"
|
||||
? t("settings.notifications.globalRegion")
|
||||
: 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({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: decodeBase64UrlKey(vapidPublicKey),
|
||||
});
|
||||
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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 })}`}
|
||||
|
||||
@@ -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
|
||||
? `${warning.areas.slice(0, 8).join(", ")} ${t("warnings.moreAreas", { count: warning.areas.length - 8 })}`
|
||||
: warning.areas.join("; ");
|
||||
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>
|
||||
<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>
|
||||
<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>
|
||||
);
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -67,11 +67,13 @@ export function CurrentLocationControl({ stations }: { stations: LocatedSynopSta
|
||||
},
|
||||
(error) => {
|
||||
setIsLocating(false);
|
||||
setMessage(error.code === error.PERMISSION_DENIED
|
||||
? t("location.gpsDenied")
|
||||
: error.code === error.TIMEOUT
|
||||
? t("location.gpsTimeout")
|
||||
: t("location.gpsPositionUnavailable"));
|
||||
setMessage(
|
||||
error.code === error.PERMISSION_DENIED
|
||||
? t("location.gpsDenied")
|
||||
: error.code === error.TIMEOUT
|
||||
? t("location.gpsTimeout")
|
||||
: 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) => {
|
||||
if (permission.state !== "granted" || autoLocated.current) return;
|
||||
autoLocated.current = true;
|
||||
locate();
|
||||
}).catch(() => undefined);
|
||||
navigator.permissions
|
||||
.query({ name: "geolocation" })
|
||||
.then((permission) => {
|
||||
if (permission.state !== "granted" || autoLocated.current) return;
|
||||
autoLocated.current = true;
|
||||
locate();
|
||||
})
|
||||
.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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) => ({
|
||||
location,
|
||||
selected: createSelectedLocation(location, locatedStations),
|
||||
})), [locatedStations, locations]);
|
||||
const suggestions = useMemo(
|
||||
() =>
|
||||
(locations ?? []).map((location) => ({
|
||||
location,
|
||||
selected: createSelectedLocation(location, locatedStations),
|
||||
})),
|
||||
[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,31 +74,57 @@ 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> :
|
||||
suggestions.map(({ location, selected }) => (
|
||||
<button
|
||||
type="button"
|
||||
key={location.id}
|
||||
onClick={() => chooseLocation(selected)}
|
||||
className="flex w-full items-start justify-between gap-3 rounded-card px-3 py-3 text-left transition hover:bg-surface-muted/70 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"
|
||||
>
|
||||
<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>
|
||||
{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"
|
||||
key={location.id}
|
||||
onClick={() => chooseLocation(selected)}
|
||||
className="flex w-full items-start justify-between gap-3 rounded-card px-3 py-3 text-left transition hover:bg-surface-muted/70 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"
|
||||
>
|
||||
<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>
|
||||
{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.modelSource")}<br /><strong className="font-semibold text-foreground">Open-Meteo</strong></span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</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.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>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}`}
|
||||
>
|
||||
|
||||
@@ -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,35 +54,59 @@ 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 ? {
|
||||
...station,
|
||||
measuredAt: hasFullHybridAnalysis || hasGlobalModel ? currentWeather.measuredAt : station.measuredAt,
|
||||
temperature: currentWeather.temperature ?? station.temperature,
|
||||
windSpeed: currentWeather.windSpeed ?? station.windSpeed,
|
||||
windDirection: currentWeather.windDirection ?? station.windDirection,
|
||||
humidity: currentWeather.humidity ?? station.humidity,
|
||||
pressure: currentWeather.pressure ?? station.pressure,
|
||||
rainfall: currentWeather.precipitation10m ?? station.rainfall,
|
||||
} : station;
|
||||
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,
|
||||
windSpeed: currentWeather.windSpeed ?? station.windSpeed,
|
||||
windDirection: currentWeather.windDirection ?? station.windDirection,
|
||||
humidity: currentWeather.humidity ?? station.humidity,
|
||||
pressure: currentWeather.pressure ?? station.pressure,
|
||||
rainfall: currentWeather.precipitation10m ?? station.rainfall,
|
||||
}
|
||||
: 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"
|
||||
? 0.1
|
||||
: devEffectOverride === "none"
|
||||
? 0
|
||||
: currentWeather?.precipitation10m;
|
||||
const effectThunderstorm = devEffectOverride === "thunderstorm"
|
||||
? true
|
||||
: devEffectOverride === "rain" || devEffectOverride === "none"
|
||||
? false
|
||||
: currentWeather?.condition === "thunderstorm";
|
||||
const effectPrecipitation10m =
|
||||
devEffectOverride === "rain" || devEffectOverride === "thunderstorm"
|
||||
? 0.1
|
||||
: devEffectOverride === "none"
|
||||
? 0
|
||||
: currentWeather?.precipitation10m;
|
||||
const effectThunderstorm =
|
||||
devEffectOverride === "thunderstorm"
|
||||
? true
|
||||
: devEffectOverride === "rain" || devEffectOverride === "none"
|
||||
? false
|
||||
: currentWeather?.condition === "thunderstorm";
|
||||
const weatherDescription = getWeatherDescription(
|
||||
displayedStation,
|
||||
language,
|
||||
@@ -97,22 +135,37 @@ 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
|
||||
? t("location.heroHybridLoading", { station: station.name })
|
||||
: hasGlobalModel
|
||||
? t("location.heroGlobalModelSource", { location: displayedLocationName })
|
||||
: hasFullHybridAnalysis
|
||||
? t("location.heroHybridSource", { location: displayedLocationName })
|
||||
: 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>}
|
||||
<p>
|
||||
{currentWeatherLoading
|
||||
? t("location.heroHybridLoading", { station: station.name })
|
||||
: hasGlobalModel
|
||||
? t("location.heroGlobalModelSource", { location: displayedLocationName })
|
||||
: hasFullHybridAnalysis
|
||||
? t("location.heroHybridSource", { location: displayedLocationName })
|
||||
: 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>
|
||||
)}
|
||||
</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>
|
||||
))}
|
||||
|
||||
@@ -2,14 +2,29 @@ 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 : {
|
||||
warm: ThermometerSun,
|
||||
cloudy: Cloud,
|
||||
wind: Wind,
|
||||
cold: Snowflake,
|
||||
night: MoonStar,
|
||||
mild: CloudSun,
|
||||
}[mood];
|
||||
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,
|
||||
cold: Snowflake,
|
||||
night: MoonStar,
|
||||
mild: CloudSun,
|
||||
}[mood];
|
||||
return <Icon className={className} strokeWidth={1.35} />;
|
||||
}
|
||||
|
||||
50
docs/api.md
50
docs/api.md
@@ -4,28 +4,28 @@ 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}®ion={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}®ion={PL\|GLOBAL}` | Zwraca 7-dniową prognozę modelową dla współrzędnych. Dla `PL` łączy IMGW ALARO z Open-Meteo, dla `GLOBAL` używa Open-Meteo. Niepoprawne współrzędne zwracają `400`, awaria źródła `502`. |
|
||||
| `GET` | `/api/imgw-current?latitude={lat}&longitude={lon}` | Pobiera surową lokalną analizę IMGW Hybrid dla współrzędnych. Niepoprawne współrzędne zwracają `400`, awaria źródła `502`. |
|
||||
| `GET` | `/api/imgw/{path}` | Proxy allowlistowanych endpointów IMGW `danepubliczne.imgw.pl`. Obsługuje kolekcje `synop`, `hydro`, `meteo`, `warningsmeteo`, `warningshydro`, `product` oraz szczegół `synop/id/{id}`. Nieobsługiwana ścieżka zwraca `404`. |
|
||||
| `GET` | `/api/locations/search?query={query}&language={pl\|en}` | Wyszukuje miejscowości globalnie przez Open-Meteo Geocoding. Wyniki zawierają `countryCode`, `admin1`, `admin2`, `timezone` i region `PL` albo `GLOBAL`. Zapytania krótsze niż 2 znaki albo dłuższe niż 80 znaków zwracają pustą listę. |
|
||||
| `GET` | `/api/locations/reverse?latitude={lat}&longitude={lon}&language={pl\|en}` | Ustala nazwę miejsca dla pozycji GPS przez Nominatim / OpenStreetMap. Współrzędne są zaokrąglane do trzech miejsc po przecinku. |
|
||||
| Metoda | Endpoint | Przeznaczenie |
|
||||
| ------ | ------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `GET` | `/api/current-weather?latitude={lat}&longitude={lon}®ion={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}®ion={PL\|GLOBAL}` | Zwraca 7-dniową prognozę modelową dla współrzędnych. Dla `PL` łączy IMGW ALARO z Open-Meteo, dla `GLOBAL` używa Open-Meteo. Niepoprawne współrzędne zwracają `400`, awaria źródła `502`. |
|
||||
| `GET` | `/api/imgw-current?latitude={lat}&longitude={lon}` | Pobiera surową lokalną analizę IMGW Hybrid dla współrzędnych. Niepoprawne współrzędne zwracają `400`, awaria źródła `502`. |
|
||||
| `GET` | `/api/imgw/{path}` | Proxy allowlistowanych endpointów IMGW `danepubliczne.imgw.pl`. Obsługuje kolekcje `synop`, `hydro`, `meteo`, `warningsmeteo`, `warningshydro`, `product` oraz szczegół `synop/id/{id}`. Nieobsługiwana ścieżka zwraca `404`. |
|
||||
| `GET` | `/api/locations/search?query={query}&language={pl\|en}` | Wyszukuje miejscowości globalnie przez Open-Meteo Geocoding. Wyniki zawierają `countryCode`, `admin1`, `admin2`, `timezone` i region `PL` albo `GLOBAL`. Zapytania krótsze niż 2 znaki albo dłuższe niż 80 znaków zwracają pustą listę. |
|
||||
| `GET` | `/api/locations/reverse?latitude={lat}&longitude={lon}&language={pl\|en}` | Ustala nazwę miejsca dla pozycji GPS przez Nominatim / OpenStreetMap. Współrzędne są zaokrąglane do trzech miejsc po przecinku. |
|
||||
|
||||
## Powiadomienia
|
||||
|
||||
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. |
|
||||
| `POST` | `/api/notifications/test` | Wysyła powiadomienie testowe na wskazany endpoint subskrypcji. |
|
||||
| `GET` | `/api/notifications/check` | Endpoint harmonogramu sprawdzający nowe ostrzeżenia meteorologiczne IMGW i wysyłający Web Push do pasujących subskrypcji. |
|
||||
| `GET` | `/api/notifications/daily-brief` | Endpoint harmonogramu wysyłający raz dziennie poranny brief dla subskrypcji z włączoną opcją i zapisaną lokalizacją. |
|
||||
| `GET` | `/api/notifications/tomorrow-brief` | Endpoint harmonogramu wysyłający wieczorny brief z prognozą na kolejny dzień. |
|
||||
| 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. |
|
||||
| `POST` | `/api/notifications/test` | Wysyła powiadomienie testowe na wskazany endpoint subskrypcji. |
|
||||
| `GET` | `/api/notifications/check` | Endpoint harmonogramu sprawdzający nowe ostrzeżenia meteorologiczne IMGW i wysyłający Web Push do pasujących subskrypcji. |
|
||||
| `GET` | `/api/notifications/daily-brief` | Endpoint harmonogramu wysyłający raz dziennie poranny brief dla subskrypcji z włączoną opcją i zapisaną lokalizacją. |
|
||||
| `GET` | `/api/notifications/tomorrow-brief` | Endpoint harmonogramu wysyłający wieczorny brief z prognozą na kolejny dzień. |
|
||||
|
||||
## Autoryzacja Harmonogramu
|
||||
|
||||
@@ -46,13 +46,13 @@ 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` |
|
||||
| `/api/imgw/{path}` | `s-maxage=300`, `stale-while-revalidate=600` |
|
||||
| `/api/locations/search` | `s-maxage=86400`, `stale-while-revalidate=604800` |
|
||||
| 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` |
|
||||
| `/api/imgw/{path}` | `s-maxage=300`, `stale-while-revalidate=600` |
|
||||
| `/api/locations/search` | `s-maxage=86400`, `stale-while-revalidate=604800` |
|
||||
| `/api/locations/reverse` | `s-maxage=86400`, `stale-while-revalidate=604800` |
|
||||
|
||||
Route handlery zwracają kontrolowane odpowiedzi JSON. Błędy zewnętrznych usług są mapowane na czytelne statusy HTTP, zwykle `400`, `404`, `502` albo `503`.
|
||||
@@ -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.
|
||||
|
||||
@@ -4,17 +4,17 @@
|
||||
|
||||
## 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` |
|
||||
| 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` |
|
||||
| Pojedyncza stacja synoptyczna | `https://danepubliczne.imgw.pl/api/data/synop/id/{id}` |
|
||||
| Dane hydrologiczne | `https://danepubliczne.imgw.pl/api/data/hydro/` |
|
||||
| Ostrzeżenia meteorologiczne | `https://danepubliczne.imgw.pl/api/data/warningsmeteo` |
|
||||
| Ostrzeżenia hydrologiczne | `https://danepubliczne.imgw.pl/api/data/warningshydro` |
|
||||
| Dane meteorologiczne | `https://danepubliczne.imgw.pl/api/data/meteo/` |
|
||||
| Lista produktów | `https://danepubliczne.imgw.pl/api/data/product` |
|
||||
| Dane hydrologiczne | `https://danepubliczne.imgw.pl/api/data/hydro/` |
|
||||
| Ostrzeżenia meteorologiczne | `https://danepubliczne.imgw.pl/api/data/warningsmeteo` |
|
||||
| Ostrzeżenia hydrologiczne | `https://danepubliczne.imgw.pl/api/data/warningshydro` |
|
||||
| Dane meteorologiczne | `https://danepubliczne.imgw.pl/api/data/meteo/` |
|
||||
| Lista produktów | `https://danepubliczne.imgw.pl/api/data/product` |
|
||||
|
||||
IMGW jest traktowane jako źródło bieżących pomiarów, hydro i ostrzeżeń. Prognoza modelowa jest w interfejsie rozdzielona od pomiarów.
|
||||
|
||||
@@ -30,15 +30,15 @@ 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 |
|
||||
| Oficjalne ostrzeżenia | IMGW | niedostępne |
|
||||
| Filtrowanie powiatowe | TERYT | niedostępne |
|
||||
| Briefy | tak | tak, bez oficjalnych alertów |
|
||||
| Push alerty oficjalne | IMGW | niedostępne |
|
||||
| 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 |
|
||||
| Oficjalne ostrzeżenia | IMGW | niedostępne |
|
||||
| Filtrowanie powiatowe | TERYT | niedostępne |
|
||||
| Briefy | tak | tak, bez oficjalnych alertów |
|
||||
| Push alerty oficjalne | IMGW | niedostępne |
|
||||
|
||||
## Nominatim / OpenStreetMap
|
||||
|
||||
@@ -54,14 +54,14 @@ 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` |
|
||||
| Proxy IMGW `danepubliczne.imgw.pl` | 300 sekund, `stale-while-revalidate=600` |
|
||||
| Wyszukiwanie miejscowości | 24 godziny, `stale-while-revalidate=7 dni` |
|
||||
| Reverse geocoding | 24 godziny, `stale-while-revalidate=7 dni` |
|
||||
| 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` |
|
||||
| Proxy IMGW `danepubliczne.imgw.pl` | 300 sekund, `stale-while-revalidate=600` |
|
||||
| Wyszukiwanie miejscowości | 24 godziny, `stale-while-revalidate=7 dni` |
|
||||
| Reverse geocoding | 24 godziny, `stale-while-revalidate=7 dni` |
|
||||
|
||||
Service worker dodatkowo może cache'ować odpowiedzi `GET` dla `/api/imgw/*`, `/api/imgw-current`, `/api/current-weather` i `/api/forecast`, aby ostatnio pobrane dane były dostępne przy problemach z siecią.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -91,17 +91,17 @@ IMGW Hybrid dostarcza m.in. opad 10-minutowy. Ten parametr jest prezentowany odd
|
||||
|
||||
Prognoza godzinowa i dzienna rozpoznaje:
|
||||
|
||||
| Stan | Opis w interfejsie |
|
||||
| --- | --- |
|
||||
| `clear` | Bezchmurnie |
|
||||
| Stan | Opis w interfejsie |
|
||||
| -------------- | ---------------------- |
|
||||
| `clear` | Bezchmurnie |
|
||||
| `partlyCloudy` | Częściowe zachmurzenie |
|
||||
| `cloudy` | Pochmurno |
|
||||
| `fog` | Mgła |
|
||||
| `drizzle` | Mżawka |
|
||||
| `rain` | Opady deszczu |
|
||||
| `snow` | Opady śniegu |
|
||||
| `thunderstorm` | Burza |
|
||||
| `unknown` | Brak opisu |
|
||||
| `cloudy` | Pochmurno |
|
||||
| `fog` | Mgła |
|
||||
| `drizzle` | Mżawka |
|
||||
| `rain` | Opady deszczu |
|
||||
| `snow` | Opady śniegu |
|
||||
| `thunderstorm` | Burza |
|
||||
| `unknown` | Brak opisu |
|
||||
|
||||
Bieżąca analiza IMGW Hybrid rozpoznaje bezpośrednio opad deszczu, śnieg i burzę. Dashboard hero używa opisu warunków w następującym priorytecie:
|
||||
|
||||
@@ -114,11 +114,11 @@ 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 |
|
||||
| Cloud | Opis w hero |
|
||||
| ------- | -------------------------------- |
|
||||
| `>= 75` | Pochmurno |
|
||||
| `>= 25` | Częściowe zachmurzenie |
|
||||
| `< 25` | brak osobnego opisu zachmurzenia |
|
||||
|
||||
Jednostki temperatury i wiatru wybierane w `/settings` dotyczą prezentacji w UI, briefach i powiadomieniach. Dane źródłowe oraz progi logiki pogody pozostają liczone w `°C` i `m/s`.
|
||||
|
||||
@@ -126,14 +126,14 @@ 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` |
|
||||
| `cloudy` | wilgotność od `80%` |
|
||||
| `warm` | temperatura od `20°C` |
|
||||
| `mild` | pozostałe przypadki |
|
||||
| Mood | Reguła |
|
||||
| -------- | ------------------------------------ |
|
||||
| `night` | godzina przed `06:00` lub od `21:00` |
|
||||
| `wind` | wiatr od `8 m/s` |
|
||||
| `cold` | temperatura do `3°C` |
|
||||
| `cloudy` | wilgotność od `80%` |
|
||||
| `warm` | temperatura od `20°C` |
|
||||
| `mild` | pozostałe przypadki |
|
||||
|
||||
Warstwa efektów wizualnych jest ograniczona do subtelnych efektów informacyjnych: kropli przy lokalnym opadzie oraz błysku przy burzy. Mood hero jest heurystyką, a nie pełną klasyfikacją sterowaną kodem warunków IMGW Hybrid.
|
||||
|
||||
|
||||
@@ -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) => ({
|
||||
...visibility,
|
||||
[section.id]: section.defaultVisible,
|
||||
}), {} as AppSectionVisibility);
|
||||
export const DEFAULT_APP_SECTION_VISIBILITY = APP_SECTION_SETTINGS.reduce(
|
||||
(visibility, section) => ({
|
||||
...visibility,
|
||||
[section.id]: section.defaultVisible,
|
||||
}),
|
||||
{} 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) => ({
|
||||
...visibility,
|
||||
[section.id]: typeof stored[section.id] === "boolean" ? stored[section.id] : section.defaultVisible,
|
||||
}), {} as AppSectionVisibility);
|
||||
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,
|
||||
);
|
||||
}
|
||||
|
||||
export function isAppNavItemVisible(href: string, visibility: AppSectionVisibility) {
|
||||
|
||||
@@ -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) => ({
|
||||
...visibility,
|
||||
[section.id]: section.defaultVisible,
|
||||
}), {} as DashboardSectionVisibility);
|
||||
export const DEFAULT_DASHBOARD_SECTION_VISIBILITY = DASHBOARD_SECTION_SETTINGS.reduce(
|
||||
(visibility, section) => ({
|
||||
...visibility,
|
||||
[section.id]: section.defaultVisible,
|
||||
}),
|
||||
{} 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) => ({
|
||||
...visibility,
|
||||
[section.id]: typeof stored[section.id] === "boolean" ? stored[section.id] : section.defaultVisible,
|
||||
}), {} as DashboardSectionVisibility);
|
||||
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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -19,48 +19,57 @@ function normalizeSources(value: unknown): ForecastSource[] {
|
||||
}
|
||||
|
||||
function normalizeHourlyForecast(value: unknown): HourlyForecast[] {
|
||||
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 [{
|
||||
time,
|
||||
temperature: readNumber(row.temperature),
|
||||
feelsLike: readNumber(row.feelsLike),
|
||||
precipitationProbability: readNumber(row.precipitationProbability),
|
||||
precipitation: readNumber(row.precipitation),
|
||||
weatherCode: readNumber(row.weatherCode),
|
||||
windSpeed: readNumber(row.windSpeed),
|
||||
source: isForecastSource(row.source) ? row.source : "open-meteo",
|
||||
}];
|
||||
}) : [];
|
||||
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 [
|
||||
{
|
||||
time,
|
||||
temperature: readNumber(row.temperature),
|
||||
feelsLike: readNumber(row.feelsLike),
|
||||
precipitationProbability: readNumber(row.precipitationProbability),
|
||||
precipitation: readNumber(row.precipitation),
|
||||
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) => {
|
||||
if (!candidate || typeof candidate !== "object") return [];
|
||||
const row = candidate as Partial<DailyForecast>;
|
||||
const date = readString(row.date);
|
||||
if (!date) return [];
|
||||
return [{
|
||||
date,
|
||||
temperatureMax: readNumber(row.temperatureMax),
|
||||
temperatureMin: readNumber(row.temperatureMin),
|
||||
precipitationProbability: readNumber(row.precipitationProbability),
|
||||
precipitation: readNumber(row.precipitation),
|
||||
weatherCode: readNumber(row.weatherCode),
|
||||
sunrise: readString(row.sunrise),
|
||||
sunset: readString(row.sunset),
|
||||
sources: normalizeSources(row.sources),
|
||||
}];
|
||||
}) : [];
|
||||
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 [
|
||||
{
|
||||
date,
|
||||
temperatureMax: readNumber(row.temperatureMax),
|
||||
temperatureMin: readNumber(row.temperatureMin),
|
||||
precipitationProbability: readNumber(row.precipitationProbability),
|
||||
precipitation: readNumber(row.precipitation),
|
||||
weatherCode: readNumber(row.weatherCode),
|
||||
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>);
|
||||
}
|
||||
|
||||
@@ -66,16 +66,18 @@ function normalizeOpenMeteoHourly(series: RawForecastSeries = {}): HourlyForecas
|
||||
return asArray(series.time).flatMap((_, index) => {
|
||||
const time = readString(series, "time", index);
|
||||
if (!time) return [];
|
||||
return [{
|
||||
time,
|
||||
temperature: readNumber(series, "temperature_2m", index),
|
||||
feelsLike: readNumber(series, "apparent_temperature", index),
|
||||
precipitationProbability: readNumber(series, "precipitation_probability", index),
|
||||
precipitation: readNumber(series, "precipitation", index),
|
||||
weatherCode: readNumber(series, "weather_code", index),
|
||||
windSpeed: readNumber(series, "wind_speed_10m", index),
|
||||
source: "open-meteo" as const,
|
||||
}];
|
||||
return [
|
||||
{
|
||||
time,
|
||||
temperature: readNumber(series, "temperature_2m", index),
|
||||
feelsLike: readNumber(series, "apparent_temperature", index),
|
||||
precipitationProbability: readNumber(series, "precipitation_probability", index),
|
||||
precipitation: readNumber(series, "precipitation", index),
|
||||
weatherCode: readNumber(series, "weather_code", index),
|
||||
windSpeed: readNumber(series, "wind_speed_10m", index),
|
||||
source: "open-meteo" as const,
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
@@ -83,24 +85,27 @@ function normalizeOpenMeteoDaily(series: RawForecastSeries = {}): DailyForecast[
|
||||
return asArray(series.time).flatMap((_, index) => {
|
||||
const date = readString(series, "time", index);
|
||||
if (!date) return [];
|
||||
return [{
|
||||
date,
|
||||
temperatureMax: readNumber(series, "temperature_2m_max", index),
|
||||
temperatureMin: readNumber(series, "temperature_2m_min", index),
|
||||
precipitationProbability: readNumber(series, "precipitation_probability_max", index),
|
||||
precipitation: readNumber(series, "precipitation_sum", index),
|
||||
weatherCode: readNumber(series, "weather_code", index),
|
||||
sunrise: readString(series, "sunrise", index),
|
||||
sunset: readString(series, "sunset", index),
|
||||
sources: ["open-meteo" as const],
|
||||
}];
|
||||
return [
|
||||
{
|
||||
date,
|
||||
temperatureMax: readNumber(series, "temperature_2m_max", index),
|
||||
temperatureMin: readNumber(series, "temperature_2m_min", index),
|
||||
precipitationProbability: readNumber(series, "precipitation_probability_max", index),
|
||||
precipitation: readNumber(series, "precipitation_sum", index),
|
||||
weatherCode: readNumber(series, "weather_code", index),
|
||||
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;
|
||||
|
||||
@@ -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,22 +91,24 @@ function getForecastHourOfDay(time: string) {
|
||||
export function getUpcomingHourlyForecast(hours: HourlyForecast[], limit = 24) {
|
||||
const currentHour = getWarsawForecastHour();
|
||||
const currentTimestamp = parseForecastHour(currentHour);
|
||||
const upcomingHours = currentTimestamp === null
|
||||
? hours.filter((hour) => hour.time >= currentHour)
|
||||
: hours.filter((hour) => {
|
||||
const hourTimestamp = parseForecastHour(hour.time);
|
||||
return hourTimestamp === null ? hour.time >= currentHour : hourTimestamp >= currentTimestamp;
|
||||
});
|
||||
const upcomingHours =
|
||||
currentTimestamp === null
|
||||
? hours.filter((hour) => hour.time >= currentHour)
|
||||
: hours.filter((hour) => {
|
||||
const hourTimestamp = parseForecastHour(hour.time);
|
||||
return hourTimestamp === null ? hour.time >= currentHour : hourTimestamp >= currentTimestamp;
|
||||
});
|
||||
|
||||
if (upcomingHours.length) return upcomingHours.slice(0, limit);
|
||||
|
||||
const currentHourOfDay = getForecastHourOfDay(currentHour);
|
||||
const fallbackHours = currentHourOfDay === null
|
||||
? hours
|
||||
: hours.filter((hour) => {
|
||||
const forecastHourOfDay = getForecastHourOfDay(hour.time);
|
||||
return forecastHourOfDay === null || forecastHourOfDay >= currentHourOfDay;
|
||||
});
|
||||
const fallbackHours =
|
||||
currentHourOfDay === null
|
||||
? hours
|
||||
: hours.filter((hour) => {
|
||||
const forecastHourOfDay = getForecastHourOfDay(hour.time);
|
||||
return forecastHourOfDay === null || forecastHourOfDay >= currentHourOfDay;
|
||||
});
|
||||
|
||||
return (fallbackHours.length ? fallbackHours : hours).slice(0, limit);
|
||||
}
|
||||
|
||||
213
lib/i18n.tsx
213
lib/i18n.tsx
@@ -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>(() => ({
|
||||
language,
|
||||
locale: language === "pl" ? "pl-PL" : "en-GB",
|
||||
setLanguage,
|
||||
t: (key, params) => translate(language, key, params),
|
||||
}), [language, setLanguage]);
|
||||
const value = useMemo<I18nContextValue>(
|
||||
() => ({
|
||||
language,
|
||||
locale: language === "pl" ? "pl-PL" : "en-GB",
|
||||
setLanguage,
|
||||
t: (key, params) => translate(language, key, params),
|
||||
}),
|
||||
[language, setLanguage],
|
||||
);
|
||||
|
||||
return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>;
|
||||
}
|
||||
|
||||
@@ -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.");
|
||||
}
|
||||
|
||||
@@ -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,25 +82,26 @@ 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[] => {
|
||||
if (!candidate || typeof candidate !== "object") return [];
|
||||
const row = candidate as RawImgwHybridWeatherRow;
|
||||
if (row.Type !== "Type_Ten_Minutes" && row.Type !== "Type_Hour") return [];
|
||||
const normalizedRow = normalizeHybridRow(row);
|
||||
return normalizedRow ? [normalizedRow] : [];
|
||||
});
|
||||
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 [];
|
||||
const normalizedRow = normalizeHybridRow(row);
|
||||
return normalizedRow ? [normalizedRow] : [];
|
||||
});
|
||||
const fullRow = pickFullWeatherRow(rows);
|
||||
const precipitationRow = pickPrecipitationRow(rows, fullRow);
|
||||
const row = fullRow ?? precipitationRow;
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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.");
|
||||
|
||||
@@ -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,17 +82,31 @@ 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"
|
||||
? 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;
|
||||
}, null)
|
||||
: null;
|
||||
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;
|
||||
}, null)
|
||||
: null;
|
||||
|
||||
return {
|
||||
name: location.name,
|
||||
@@ -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,
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -49,13 +49,20 @@ 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]
|
||||
: [`${region}: ${level}`, validity, warning.probability !== null ? `${warning.probability}% 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 {
|
||||
title: `IMGW: ${title}`,
|
||||
@@ -73,12 +80,15 @@ 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"
|
||||
? `Powiadomienia są aktywne dla: ${location}.`
|
||||
: `Notifications are active for: ${location}.`,
|
||||
body:
|
||||
preference.language === "pl"
|
||||
? `Powiadomienia są aktywne dla: ${location}.`
|
||||
: `Notifications are active for: ${location}.`,
|
||||
url: "/settings",
|
||||
});
|
||||
await webPush.sendNotification(preference.subscription as PushSubscription, payload);
|
||||
|
||||
@@ -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,25 +204,27 @@ export function upsertPushSubscription(subscription: WarningPushSubscription) {
|
||||
temperature_unit = excluded.temperature_unit,
|
||||
wind_speed_unit = excluded.wind_speed_unit,
|
||||
updated_at = excluded.updated_at
|
||||
`).run({
|
||||
endpoint: subscription.endpoint,
|
||||
subscriptionJson: JSON.stringify(subscription.subscription),
|
||||
province: subscription.province ?? "global",
|
||||
region: subscription.region,
|
||||
language: subscription.language,
|
||||
enabled: subscription.enabled ? 1 : 0,
|
||||
morningBriefEnabled: subscription.morningBriefEnabled ? 1 : 0,
|
||||
tomorrowBriefEnabled: subscription.tomorrowBriefEnabled ? 1 : 0,
|
||||
latitude: subscription.latitude,
|
||||
longitude: subscription.longitude,
|
||||
locationName: subscription.locationName,
|
||||
timezone: subscription.timezone,
|
||||
countyTeryt: subscription.countyTeryt,
|
||||
temperatureUnit: subscription.temperatureUnit,
|
||||
windSpeedUnit: subscription.windSpeedUnit,
|
||||
createdAt: subscription.createdAt,
|
||||
updatedAt: subscription.updatedAt,
|
||||
});
|
||||
`,
|
||||
)
|
||||
.run({
|
||||
endpoint: subscription.endpoint,
|
||||
subscriptionJson: JSON.stringify(subscription.subscription),
|
||||
province: subscription.province ?? "global",
|
||||
region: subscription.region,
|
||||
language: subscription.language,
|
||||
enabled: subscription.enabled ? 1 : 0,
|
||||
morningBriefEnabled: subscription.morningBriefEnabled ? 1 : 0,
|
||||
tomorrowBriefEnabled: subscription.tomorrowBriefEnabled ? 1 : 0,
|
||||
latitude: subscription.latitude,
|
||||
longitude: subscription.longitude,
|
||||
locationName: subscription.locationName,
|
||||
timezone: subscription.timezone,
|
||||
countyTeryt: subscription.countyTeryt,
|
||||
temperatureUnit: subscription.temperatureUnit,
|
||||
windSpeedUnit: subscription.windSpeedUnit,
|
||||
createdAt: subscription.createdAt,
|
||||
updatedAt: subscription.updatedAt,
|
||||
});
|
||||
}
|
||||
|
||||
export function removePushSubscription(endpoint: string) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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)) : [];
|
||||
}
|
||||
|
||||
33
lib/store.ts
33
lib/store.ts
@@ -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,18 +80,23 @@ 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) => ({
|
||||
dashboardSections: { ...state.dashboardSections, [section]: visible },
|
||||
})),
|
||||
setAppSectionVisible: (section, visible) => set((state) => ({
|
||||
appSections: { ...state.appSections, [section]: visible },
|
||||
})),
|
||||
setDashboardSectionVisible: (section, visible) =>
|
||||
set((state) => ({
|
||||
dashboardSections: { ...state.dashboardSections, [section]: visible },
|
||||
})),
|
||||
setAppSectionVisible: (section, visible) =>
|
||||
set((state) => ({
|
||||
appSections: { ...state.appSections, [section]: visible },
|
||||
})),
|
||||
}),
|
||||
{
|
||||
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);
|
||||
|
||||
@@ -50,19 +50,21 @@ const mazowieckieCountyTerytByName: Record<string, string> = {
|
||||
};
|
||||
|
||||
function normalizeRegionName(value: string | null | undefined) {
|
||||
return value
|
||||
?.replace(/[Łł]/g, "l")
|
||||
.normalize("NFD")
|
||||
.replace(/[\u0300-\u036f]/g, "")
|
||||
.toLocaleLowerCase("pl-PL")
|
||||
.replace(/^powiat\s+/, "")
|
||||
.replace(/^m\.\s*/, "")
|
||||
.replace(/^miasto\s+/, "")
|
||||
.replace(/\s+powiat$/, "")
|
||||
.replace(/\s+/g, "_")
|
||||
.replace(/[^a-z0-9_]/g, "")
|
||||
.replace(/^warszawa_zachodnia$/, "warszawski_zachodni")
|
||||
.trim() ?? "";
|
||||
return (
|
||||
value
|
||||
?.replace(/[Łł]/g, "l")
|
||||
.normalize("NFD")
|
||||
.replace(/[\u0300-\u036f]/g, "")
|
||||
.toLocaleLowerCase("pl-PL")
|
||||
.replace(/^powiat\s+/, "")
|
||||
.replace(/^m\.\s*/, "")
|
||||
.replace(/^miasto\s+/, "")
|
||||
.replace(/\s+powiat$/, "")
|
||||
.replace(/\s+/g, "_")
|
||||
.replace(/[^a-z0-9_]/g, "")
|
||||
.replace(/^warszawa_zachodnia$/, "warszawski_zachodni")
|
||||
.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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
? `${formatDisplayTemperature(minimumTemperature, language, temperatureUnit)}-${formatDisplayTemperature(maximumTemperature, language, temperatureUnit)}`
|
||||
: 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}%.`
|
||||
: "";
|
||||
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}`);
|
||||
const probability =
|
||||
topWarning.probability !== null
|
||||
? language === "pl"
|
||||
? ` Prawdopodobieństwo: ${topWarning.probability}%.`
|
||||
: ` Probability: ${topWarning.probability}%.`
|
||||
: "";
|
||||
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}`,
|
||||
);
|
||||
}
|
||||
if (temperatureRange) {
|
||||
body.push(language === "pl"
|
||||
? `Temperatura w najbliższych 24 godzinach: ${temperatureRange}.`
|
||||
: `Temperature over the next 24 hours: ${temperatureRange}.`);
|
||||
body.push(
|
||||
language === "pl"
|
||||
? `Temperatura w najbliższych 24 godzinach: ${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)}.`
|
||||
: "";
|
||||
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}`);
|
||||
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"
|
||||
? `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}`,
|
||||
);
|
||||
}
|
||||
if (maximumWind !== null) {
|
||||
body.push(language === "pl"
|
||||
? `Wiatr maksymalnie do ${formatWindSpeed(maximumWind, language, windSpeedUnit)}.`
|
||||
: `Wind up to ${formatWindSpeed(maximumWind, language, windSpeedUnit)}.`);
|
||||
body.push(
|
||||
language === "pl"
|
||||
? `Wiatr maksymalnie do ${formatWindSpeed(maximumWind, language, windSpeedUnit)}.`
|
||||
: `Wind up to ${formatWindSpeed(maximumWind, language, windSpeedUnit)}.`,
|
||||
);
|
||||
}
|
||||
if (conditionPeakHour) {
|
||||
body.push(language === "pl"
|
||||
? `Dominujący sygnał modelu: ${getForecastCondition(conditionPeakHour.weatherCode, language).toLowerCase()}.`
|
||||
: `Model signal: ${getForecastCondition(conditionPeakHour.weatherCode, language).toLowerCase()}.`);
|
||||
body.push(
|
||||
language === "pl"
|
||||
? `Dominujący sygnał modelu: ${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
|
||||
? `${formatDisplayTemperature(minimumTemperature, language, temperatureUnit)} / ${formatDisplayTemperature(maximumTemperature, language, temperatureUnit)}`
|
||||
: null;
|
||||
const severity: WeatherBriefSeverity = topWarning || hasThunderstorm ? "warning" : hasRainSignal || hasWindSignal ? "attention" : "normal";
|
||||
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 provinceLabel = province ? formatProvinceName(province, language) : null;
|
||||
|
||||
const headline = topWarning
|
||||
@@ -343,49 +426,66 @@ 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}%.`
|
||||
: "";
|
||||
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}`);
|
||||
const probability =
|
||||
topWarning.probability !== null
|
||||
? language === "pl"
|
||||
? ` Prawdopodobieństwo: ${topWarning.probability}%.`
|
||||
: ` Probability: ${topWarning.probability}%.`
|
||||
: "";
|
||||
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}`,
|
||||
);
|
||||
}
|
||||
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)}.`
|
||||
: "";
|
||||
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}`);
|
||||
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"
|
||||
? `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}`,
|
||||
);
|
||||
} else {
|
||||
body.push(language === "pl" ? "Bez istotnego opadu w prognozie." : "No significant precipitation in the forecast.");
|
||||
}
|
||||
if (maximumWind !== null) {
|
||||
body.push(language === "pl"
|
||||
? `Wiatr maksymalnie do ${formatWindSpeed(maximumWind, language, windSpeedUnit)}.`
|
||||
: `Wind up to ${formatWindSpeed(maximumWind, language, windSpeedUnit)}.`);
|
||||
body.push(
|
||||
language === "pl"
|
||||
? `Wiatr maksymalnie do ${formatWindSpeed(maximumWind, language, windSpeedUnit)}.`
|
||||
: `Wind up to ${formatWindSpeed(maximumWind, language, windSpeedUnit)}.`,
|
||||
);
|
||||
}
|
||||
|
||||
const rainPushPart = (hasRainSignal || (rainfallTotal ?? 0) > 0.1) && rainfallTotal !== null
|
||||
? formatRainfall(rainfallTotal, language)
|
||||
: null;
|
||||
const rainPushPart =
|
||||
(hasRainSignal || (rainfallTotal ?? 0) > 0.1) && rainfallTotal !== null
|
||||
? formatRainfall(rainfallTotal, language)
|
||||
: null;
|
||||
const pushParts = [
|
||||
`${locationName}:`,
|
||||
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 {
|
||||
|
||||
@@ -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([
|
||||
...(raw.obszary ?? []).map((area) => normalizeProvinceName(area.wojewodztwo)),
|
||||
...(raw.teryt ?? []).map(getProvinceFromTeryt),
|
||||
].filter((province) => province !== null))];
|
||||
const provinces = [
|
||||
...new Set(
|
||||
[
|
||||
...(raw.obszary ?? []).map((area) => normalizeProvinceName(area.wojewodztwo)),
|
||||
...(raw.teryt ?? []).map(getProvinceFromTeryt),
|
||||
].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
17
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
29
public/sw.js
29
public/sw.js
@@ -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")),
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user