diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 012af4f..1a7252f 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -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 diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..f43d611 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,9 @@ +node_modules +.next +out +coverage +data +*.sqlite +*.sqlite-* +tsconfig.tsbuildinfo +next-env.d.ts diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000..caa6777 --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,6 @@ +{ + "printWidth": 120, + "semi": true, + "singleQuote": false, + "trailingComma": "all" +} diff --git a/AGENTS.md b/AGENTS.md index 160d8cb..eaeac12 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/README.md b/README.md index dc61ef9..4496538 100644 --- a/README.md +++ b/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`. diff --git a/app/api/current-weather/route.ts b/app/api/current-weather/route.ts index fc51643..8f81985 100644 --- a/app/api/current-weather/route.ts +++ b/app/api/current-weather/route.ts @@ -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 }, + ); } } diff --git a/app/api/imgw/[...path]/route.ts b/app/api/imgw/[...path]/route.ts index 2645ccd..6646d8d 100644 --- a/app/api/imgw/[...path]/route.ts +++ b/app/api/imgw/[...path]/route.ts @@ -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" }, }); diff --git a/app/api/locations/reverse/route.ts b/app/api/locations/reverse/route.ts index bbd5793..9073dd3 100644 --- a/app/api/locations/reverse/route.ts +++ b/app/api/locations/reverse/route.ts @@ -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 }); } diff --git a/app/api/locations/search/route.ts b/app/api/locations/search/route.ts index a9d1f1a..a48994e 100644 --- a/app/api/locations/search/route.ts +++ b/app/api/locations/search/route.ts @@ -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 }); } diff --git a/app/api/notifications/check/route.ts b/app/api/notifications/check/route.ts index 1fa122b..f93f541 100644 --- a/app/api/notifications/check/route.ts +++ b/app/api/notifications/check/route.ts @@ -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 }, + ); } } diff --git a/app/api/notifications/daily-brief/route.ts b/app/api/notifications/daily-brief/route.ts index 3a117b3..a2c8d5b 100644 --- a/app/api/notifications/daily-brief/route.ts +++ b/app/api/notifications/daily-brief/route.ts @@ -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); } } diff --git a/app/api/notifications/subscriptions/route.ts b/app/api/notifications/subscriptions/route.ts index 6edb270..dc11b9b 100644 --- a/app/api/notifications/subscriptions/route.ts +++ b/app/api/notifications/subscriptions/route.ts @@ -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; - 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 }); } diff --git a/app/api/notifications/test/route.ts b/app/api/notifications/test/route.ts index 03b662c..454a268 100644 --- a/app/api/notifications/test/route.ts +++ b/app/api/notifications/test/route.ts @@ -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 }); } diff --git a/app/api/notifications/tomorrow-brief/route.ts b/app/api/notifications/tomorrow-brief/route.ts index 0d9600d..e020340 100644 --- a/app/api/notifications/tomorrow-brief/route.ts +++ b/app/api/notifications/tomorrow-brief/route.ts @@ -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); } } diff --git a/app/globals.css b/app/globals.css index 15be374..821eaa4 100644 --- a/app/globals.css +++ b/app/globals.css @@ -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)); } } } diff --git a/app/layout.tsx b/app/layout.tsx index 5654dfa..c359703 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -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 ( - - + + {children} diff --git a/app/offline/page.tsx b/app/offline/page.tsx index 3e17d2f..85099a8 100644 --- a/app/offline/page.tsx +++ b/app/offline/page.tsx @@ -8,10 +8,17 @@ export default function OfflinePage() { const { t } = useI18n(); return (
-
+
+ +

{t("offline.title")}

{t("offline.description")}

- {t("offline.back")} + + {t("offline.back")} +
); } diff --git a/components/charts/day-forecast-charts.tsx b/components/charts/day-forecast-charts.tsx index 2f96493..c582458 100644 --- a/components/charts/day-forecast-charts.tsx +++ b/components/charts/day-forecast-charts.tsx @@ -33,13 +33,35 @@ export function DayForecastCharts({ hours }: { hours: HourlyForecast[] }) {

{t("forecast.temperatureChart")}

{t("forecast.temperatureChartDescription")}

- + - - + + [ typeof value === "number" ? formatTemperatureValue(value, language, temperatureUnit) @@ -47,8 +69,25 @@ export function DayForecastCharts({ hours }: { hours: HourlyForecast[] }) { ]} /> - - + +
@@ -58,14 +97,45 @@ export function DayForecastCharts({ hours }: { hours: HourlyForecast[] }) {

{t("forecast.rainfallChart")}

{t("forecast.rainfallChartDescription")}

- + - - - + + + [ name === t("forecast.precipitation") ? formatForecastRainfall(typeof value === "number" ? value : null, language) @@ -74,8 +144,23 @@ export function DayForecastCharts({ hours }: { hours: HourlyForecast[] }) { ]} /> - - + +
diff --git a/components/charts/snapshot-chart.tsx b/components/charts/snapshot-chart.tsx index adea01d..49126a7 100644 --- a/components/charts/snapshot-chart.tsx +++ b/components/charts/snapshot-chart.tsx @@ -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 ( @@ -32,13 +37,31 @@ export function SnapshotChart({ station }: { station: SynopStation }) {

{t("snapshot.title")}

{t("snapshot.description")}

- + - - [`${item.payload.value} ${item.payload.unit}`, item.payload.name]} /> + + [`${item.payload.value} ${item.payload.unit}`, item.payload.name]} + /> - {rows.map((row) => )} + {rows.map((row) => ( + + ))} diff --git a/components/dashboard/dashboard-page.tsx b/components/dashboard/dashboard-page.tsx index eddbbdc..f22106b 100644 --- a/components/dashboard/dashboard-page.tsx +++ b/components/dashboard/dashboard-page.tsx @@ -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 ; - if (isError || !stations?.length || !selectedStation) return refetch()} description={t("dashboard.error")} />; + if (isError || !stations?.length || !selectedStation) + return 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 (
- + - {dashboardSections.weatherBrief && } - + {dashboardSections.weatherBrief && ( + + )} + {dashboardSections.featuredStations && }
diff --git a/components/dashboard/weather-brief-card.tsx b/components/dashboard/weather-brief-card.tsx index a1b6f6c..d08f478 100644 --- a/components/dashboard/weather-brief-card.tsx +++ b/components/dashboard/weather-brief-card.tsx @@ -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 ; @@ -61,11 +111,25 @@ export function WeatherBriefCard({ latitude, longitude, region = "PL", locationN return (
-
- {isWarning ?
- - + +
@@ -160,8 +176,14 @@ export function DayForecastModal({ title={getForecastCondition(weatherCode, language)} >

{formatHour(hour.time)}

- -

{formatForecastTemperature(hour.temperature, language, temperatureUnit)}

+ +

+ {formatForecastTemperature(hour.temperature, language, temperatureUnit)} +

{hour.precipitationProbability === null ? "—" : `${hour.precipitationProbability}%`} diff --git a/components/forecast/forecast-icon.tsx b/components/forecast/forecast-icon.tsx index 309d0fe..ff42bed 100644 --- a/components/forecast/forecast-icon.tsx +++ b/components/forecast/forecast-icon.tsx @@ -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 ; } diff --git a/components/forecast/forecast-panel.tsx b/components/forecast/forecast-panel.tsx index b067293..ee4d0f1 100644 --- a/components/forecast/forecast-panel.tsx +++ b/components/forecast/forecast-panel.tsx @@ -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 (

-

{t("forecast.nextHoursOverview")}

+

+ {t("forecast.nextHoursOverview")} +

- - - + + +
); } -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:

{label}

- {getForecastCondition(day.weatherCode, language)} · {formatForecastRainfall(day.precipitation, language)} + + {getForecastCondition(day.weatherCode, language)} · {formatForecastRainfall(day.precipitation, language)} +
- {day.precipitationProbability === null ? "—" : `${day.precipitationProbability}%`} -

{formatForecastTemperature(day.temperatureMax, language, temperatureUnit)}{formatForecastTemperature(day.temperatureMin, language, temperatureUnit)}

+ + + {day.precipitationProbability === null ? "—" : `${day.precipitationProbability}%`} + +

+ {formatForecastTemperature(day.temperatureMax, language, temperatureUnit)} + + {formatForecastTemperature(day.temperatureMin, language, temperatureUnit)} + +

); } -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 (
-

{t("forecast.label")}

+

+ + {t("forecast.label")} +

{t("forecast.title")}

-

{t("forecast.description", { location: locationName })}

+

+ {t("forecast.description", { location: locationName })} +

{!Number.isFinite(latitude) || !Number.isFinite(longitude) || isPending ? ( @@ -146,7 +205,10 @@ export function ForecastPanel({ latitude, longitude, region = "PL", locationName ) : isError || !forecast ? (

{t("forecast.error")}

- +
) : !forecast.hourly.length || !forecast.daily.length ? ( @@ -154,7 +216,10 @@ export function ForecastPanel({ latitude, longitude, region = "PL", locationName
-

{t("forecast.hourly")}

+

+ + {t("forecast.hourly")} +

    {upcomingHours.map((hour, index) => { @@ -170,11 +235,23 @@ export function ForecastPanel({ latitude, longitude, region = "PL", locationName title={getForecastCondition(weatherCode, language)} >

    {formatHour(hour.time)}

    - -

    {formatForecastTemperature(hour.temperature, language, temperatureUnit)}

    -

    {hour.precipitationProbability === null ? "—" : `${hour.precipitationProbability}%`}

    + +

    + {formatForecastTemperature(hour.temperature, language, temperatureUnit)} +

    +

    + + {hour.precipitationProbability === null ? "—" : `${hour.precipitationProbability}%`} +

    -

    +

    {formatForecastTemperature(hour.feelsLike, language, temperatureUnit)}

    @@ -195,8 +272,15 @@ export function ForecastPanel({ latitude, longitude, region = "PL", locationName -

    {t("forecast.daily")}

    -
      {forecast.daily.map((day, index) => )}
    +

    + + {t("forecast.daily")} +

    +
      + {forecast.daily.map((day, index) => ( + + ))} +
    @@ -205,7 +289,13 @@ export function ForecastPanel({ latitude, longitude, region = "PL", locationName {forecast && } - +
); } diff --git a/components/forecast/forecast-sources.tsx b/components/forecast/forecast-sources.tsx index 5ca8a76..e734f69 100644 --- a/components/forecast/forecast-sources.tsx +++ b/components/forecast/forecast-sources.tsx @@ -13,13 +13,23 @@ export function ForecastSources({ sources }: { sources: ForecastSource[] }) { {t("forecast.source")}{" "} {hasImgw && ( <> - + IMGW ALARO {", "} )} - + Open-Meteo . {t(hasImgw ? "forecast.sourceCombinedDescription" : "forecast.sourceFallbackDescription")} diff --git a/components/hydro/hydro-page.tsx b/components/hydro/hydro-page.tsx index 6bfec84..3378626 100644 --- a/components/hydro/hydro-page.tsx +++ b/components/hydro/hydro-page.tsx @@ -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 ; if (isError) return refetch()} description={t("hydro.error")} />; @@ -34,13 +38,38 @@ export function HydroPage() { -

{t("hydro.results", { total: filteredStations.length, visible: Math.min(visibleCount, filteredStations.length) })}

- {!filteredStations.length ? : ( +

+ {t("hydro.results", { + total: filteredStations.length, + visible: Math.min(visibleCount, filteredStations.length), + })} +

+ {!filteredStations.length ? ( + + ) : ( <> -
{filteredStations.slice(0, visibleCount).map((station, index) => )}
- {visibleCount < filteredStations.length &&
} +
+ {filteredStations.slice(0, visibleCount).map((station, index) => ( + + ))} +
+ {visibleCount < filteredStations.length && ( +
+ +
+ )} )}
diff --git a/components/hydro/hydro-station-card.tsx b/components/hydro/hydro-station-card.tsx index d752265..02359bb 100644 --- a/components/hydro/hydro-station-card.tsx +++ b/components/hydro/hydro-station-card.tsx @@ -12,20 +12,38 @@ export function HydroStationCard({ station, index = 0 }: { station: HydroStation const { language, t } = useI18n(); const temperatureUnit = useWeatherStore((state) => state.temperatureUnit); return ( - +

{station.name}

-

{station.river ?? t("hydro.riverUnavailable")}{station.province ? ` · ${station.province}` : ""}

+

+ + {station.river ?? t("hydro.riverUnavailable")} + {station.province ? ` · ${station.province}` : ""} +

- - + +
-

{t("hydro.levelMeasurement", { date: formatDateTime(station.waterLevelMeasuredAt, language) })}

+

+ {t("hydro.levelMeasurement", { date: formatDateTime(station.waterLevelMeasuredAt, language) })} +

); @@ -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 (
-

{label}

-

{value}

+

+ + {label} +

+

+ {value} +

); } diff --git a/components/layout/app-shell.tsx b/components/layout/app-shell.tsx index 47c14e3..775d1c4 100644 --- a/components/layout/app-shell.tsx +++ b/components/layout/app-shell.tsx @@ -28,14 +28,24 @@ export function AppShell({ children }: { children: React.ReactNode }) {
- + wtr.
{children}
-