Compare commits

...

3 Commits

Author SHA1 Message Date
zv
2c2515abba fix: show offline status in pwa
All checks were successful
CI / Lint, test, typecheck and build (push) Successful in 11m21s
2026-07-04 20:53:13 +02:00
zv
6bec7060e0 fix: cache pwa static assets offline
Some checks failed
CI / Lint, test, typecheck and build (push) Has been cancelled
2026-07-04 20:41:19 +02:00
zv
c7da9e0d94 feat: allow dashboard section reordering 2026-07-04 20:31:56 +02:00
10 changed files with 332 additions and 60 deletions

View File

@@ -54,6 +54,7 @@ Testy jednostkowe uruchamia `npm run test` przez Vitest, a `npm run test:watch`
- Listy ostrzeżeń zachowują priorytet lokalnego obszaru, a wewnątrz każdej grupy pokazują ostrzeżenia meteorologiczne przed hydrologicznymi. Jeśli lokalizacja ma rozpoznany powiat TERYT, ostrzeżenia meteo filtruj po tym powiecie; w przeciwnym razie stosuj fallback wojewódzki. W obrębie rodzaju zachowuj kolejność publikacji od najnowszych. - Listy ostrzeżeń zachowują priorytet lokalnego obszaru, a wewnątrz każdej grupy pokazują ostrzeżenia meteorologiczne przed hydrologicznymi. Jeśli lokalizacja ma rozpoznany powiat TERYT, ostrzeżenia meteo filtruj po tym powiecie; w przeciwnym razie stosuj fallback wojewódzki. W obrębie rodzaju zachowuj kolejność publikacji od najnowszych.
- Dashboard pokazuje kompaktowo wyłącznie aktywne i nadchodzące ostrzeżenia meteo dla wybranego obszaru. Filtruj je po `validTo` względem czasu przeglądarki i automatycznie usuwaj wygasłe komunikaty bez przeładowania strony. - Dashboard pokazuje kompaktowo wyłącznie aktywne i nadchodzące ostrzeżenia meteo dla wybranego obszaru. Filtruj je po `validTo` względem czasu przeglądarki i automatycznie usuwaj wygasłe komunikaty bez przeładowania strony.
- Brief dnia i brief na jutro generuj deterministycznie w `lib/weather-brief.ts` z prognozy modelowej i, dla `PL`, ostrzeżeń IMGW. Nie traktuj ich jako odpowiedzi modelu AI i nie wymagaj klucza OpenAI API. Brief na jutro korzysta z pełnego jutrzejszego dnia prognozy i może informować o burzach na podstawie kodów modelu także bez oficjalnego ostrzeżenia IMGW. - Brief dnia i brief na jutro generuj deterministycznie w `lib/weather-brief.ts` z prognozy modelowej i, dla `PL`, ostrzeżeń IMGW. Nie traktuj ich jako odpowiedzi modelu AI i nie wymagaj klucza OpenAI API. Brief na jutro korzysta z pełnego jutrzejszego dnia prognozy i może informować o burzach na podstawie kodów modelu także bez oficjalnego ostrzeżenia IMGW.
- Sekcje dashboardu pod główną kartą pogody definiuj centralnie w `lib/dashboard-sections.ts`; widoczność i kolejność zapisuj w store, a komponent dashboardu renderuj według znormalizowanej kolejności.
- Preferencje jednostek temperatury, wiatru, opadu, ciśnienia i dystansu są ustawieniami prezentacyjnymi. Dane źródłowe oraz progi logiki pogody pozostają liczone w `°C`, `m/s`, `mm`, `hPa` i `km`, a wybrane jednostki zapisuj w subskrypcji Web Push, żeby briefy serwerowe używały tego samego formatu co UI. - Preferencje jednostek temperatury, wiatru, opadu, ciśnienia i dystansu są ustawieniami prezentacyjnymi. Dane źródłowe oraz progi logiki pogody pozostają liczone w `°C`, `m/s`, `mm`, `hPa` i `km`, a wybrane jednostki zapisuj w subskrypcji Web Push, żeby briefy serwerowe używały tego samego formatu co UI.
- Powiadomienia Web Push o ostrzeżeniach meteo, porannym briefie i wieczornym briefie na jutro konfiguruj przez `/settings`, `public/sw.js`, route handlery `app/api/notifications/*`, w tym testowy `/api/notifications/test`, oraz self-hostowany worker `scripts/notification-worker.mjs`. Endpointy harmonogramu nie uruchamiają się same bez workera albo zewnętrznego crona. Briefy są deduplikowane po lokalnej dacie subskrypcji i wysyłane po godzinie skonfigurowanej względem zapisanej strefy czasowej lokalizacji. Wymagają kluczy VAPID w zmiennych środowiskowych. iOS/iPadOS wymaga PWA z ekranu początkowego, ale Android i desktop nie powinny być blokowane wymogiem `standalone`. `lib/push-store.ts` zapisuje subskrypcje i historię wysyłek w SQLite wskazanym przez `WTR_DATABASE_PATH`, domyślnie `./data/wtr.sqlite`. - Powiadomienia Web Push o ostrzeżeniach meteo, porannym briefie i wieczornym briefie na jutro konfiguruj przez `/settings`, `public/sw.js`, route handlery `app/api/notifications/*`, w tym testowy `/api/notifications/test`, oraz self-hostowany worker `scripts/notification-worker.mjs`. Endpointy harmonogramu nie uruchamiają się same bez workera albo zewnętrznego crona. Briefy są deduplikowane po lokalnej dacie subskrypcji i wysyłane po godzinie skonfigurowanej względem zapisanej strefy czasowej lokalizacji. Wymagają kluczy VAPID w zmiennych środowiskowych. iOS/iPadOS wymaga PWA z ekranu początkowego, ale Android i desktop nie powinny być blokowane wymogiem `standalone`. `lib/push-store.ts` zapisuje subskrypcje i historię wysyłek w SQLite wskazanym przez `WTR_DATABASE_PATH`, domyślnie `./data/wtr.sqlite`.
- GPS wymaga świadomej zgody użytkownika i HTTPS. Zaokrąglaj współrzędne przed użyciem i utrzymuj widoczną atrybucję OpenStreetMap dla reverse geocodingu Nominatim. - GPS wymaga świadomej zgody użytkownika i HTTPS. Zaokrąglaj współrzędne przed użyciem i utrzymuj widoczną atrybucję OpenStreetMap dla reverse geocodingu Nominatim.

View File

@@ -19,6 +19,11 @@ import { locateSynopStations } from "@/lib/location-utils";
import { DashboardWarnings } from "@/components/warnings/dashboard-warnings"; import { DashboardWarnings } from "@/components/warnings/dashboard-warnings";
import { WeatherBriefCard } from "@/components/dashboard/weather-brief-card"; import { WeatherBriefCard } from "@/components/dashboard/weather-brief-card";
import type { SynopStation } from "@/types/imgw"; import type { SynopStation } from "@/types/imgw";
import {
normalizeDashboardSectionOrder,
normalizeDashboardSectionVisibility,
type DashboardSectionId,
} from "@/lib/dashboard-sections";
export function DashboardPage() { export function DashboardPage() {
const { t } = useI18n(); const { t } = useI18n();
@@ -26,7 +31,8 @@ export function DashboardPage() {
const { data: positions = [] } = useMeteoStationPositions(); const { data: positions = [] } = useMeteoStationPositions();
const selectedStationId = useWeatherStore((state) => state.selectedStationId); const selectedStationId = useWeatherStore((state) => state.selectedStationId);
const selectedLocation = useWeatherStore((state) => state.selectedLocation); const selectedLocation = useWeatherStore((state) => state.selectedLocation);
const dashboardSections = useWeatherStore((state) => state.dashboardSections); const dashboardSections = normalizeDashboardSectionVisibility(useWeatherStore((state) => state.dashboardSections));
const dashboardSectionOrder = normalizeDashboardSectionOrder(useWeatherStore((state) => state.dashboardSectionOrder));
const selectedStation = const selectedStation =
stations?.find((station) => station.id === selectedStationId) ?? stations?.find((station) => station.id === selectedStationId) ??
stations?.find((station) => station.name === DEFAULT_STATION_NAME) ?? stations?.find((station) => station.name === DEFAULT_STATION_NAME) ??
@@ -73,6 +79,34 @@ export function DashboardPage() {
} }
: selectedStation; : selectedStation;
const renderDashboardSection = (sectionId: DashboardSectionId) => {
if (!dashboardSections[sectionId]) return null;
if (sectionId === "warnings") return <DashboardWarnings key={sectionId} />;
if (sectionId === "weatherBrief")
return (
<WeatherBriefCard
key={sectionId}
latitude={forecastLatitude}
longitude={forecastLongitude}
region={activeRegion}
locationName={forecastLocationName ?? selectedStation.name}
/>
);
if (sectionId === "forecast")
return (
<ForecastPanel
key={sectionId}
latitude={forecastLatitude}
longitude={forecastLongitude}
region={activeRegion}
locationName={forecastLocationName ?? selectedStation.name}
/>
);
if (sectionId === "favorites") return <FavoritesSection key={sectionId} stations={stations} />;
if (sectionId === "featuredStations") return <FeaturedStationsSection key={sectionId} stations={stations} />;
return null;
};
return ( return (
<div className="space-y-10"> <div className="space-y-10">
<LocationSearch stations={stations} positions={positions} /> <LocationSearch stations={stations} positions={positions} />
@@ -84,23 +118,7 @@ export function DashboardPage() {
locationName={activeLocation?.name} locationName={activeLocation?.name}
distanceKm={activeLocation?.distanceKm} distanceKm={activeLocation?.distanceKm}
/> />
<DashboardWarnings /> {dashboardSectionOrder.map(renderDashboardSection)}
{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> </div>
); );
} }

View File

@@ -2,7 +2,8 @@
import Link from "next/link"; import Link from "next/link";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { CloudSun, Droplets, Settings, TriangleAlert } from "lucide-react"; import { useEffect, useState } from "react";
import { CloudSun, Droplets, Settings, TriangleAlert, WifiOff } from "lucide-react";
import type { LucideIcon } from "lucide-react"; import type { LucideIcon } from "lucide-react";
import { isAppNavItemVisible } from "@/lib/app-sections"; import { isAppNavItemVisible } from "@/lib/app-sections";
import { NAV_ITEMS } from "@/lib/constants"; import { NAV_ITEMS } from "@/lib/constants";
@@ -23,6 +24,18 @@ export function AppShell({ children }: { children: React.ReactNode }) {
const { t } = useI18n(); const { t } = useI18n();
const appSections = useWeatherStore((state) => state.appSections); const appSections = useWeatherStore((state) => state.appSections);
const visibleNavItems = NAV_ITEMS.filter((item) => isAppNavItemVisible(item.href, appSections)); const visibleNavItems = NAV_ITEMS.filter((item) => isAppNavItemVisible(item.href, appSections));
const [isOffline, setIsOffline] = useState(false);
useEffect(() => {
const updateConnectionStatus = () => setIsOffline(!navigator.onLine);
updateConnectionStatus();
window.addEventListener("online", updateConnectionStatus);
window.addEventListener("offline", updateConnectionStatus);
return () => {
window.removeEventListener("online", updateConnectionStatus);
window.removeEventListener("offline", updateConnectionStatus);
};
}, []);
return ( return (
<div className="min-h-screen overflow-x-hidden bg-background"> <div className="min-h-screen overflow-x-hidden bg-background">
@@ -56,6 +69,14 @@ export function AppShell({ children }: { children: React.ReactNode }) {
</div> </div>
</div> </div>
</header> </header>
{isOffline && (
<div className="border-b border-warning/25 bg-warning/10 px-4 py-2 text-sm font-medium text-warning sm:px-6 lg:px-8">
<div className="mx-auto flex max-w-7xl items-center gap-2">
<WifiOff className="size-4 shrink-0" aria-hidden="true" />
<span>{t("offline.banner")}</span>
</div>
</div>
)}
<main className="app-main-safe mx-auto max-w-7xl px-4 pt-5 sm:px-6 sm:pt-8 lg:px-8">{children}</main> <main className="app-main-safe mx-auto max-w-7xl px-4 pt-5 sm:px-6 sm:pt-8 lg:px-8">{children}</main>
<nav <nav
aria-label={t("nav.mobile")} aria-label={t("nav.mobile")}

View File

@@ -2,6 +2,8 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { import {
ArrowDown,
ArrowUp,
Bell, Bell,
BellRing, BellRing,
ChevronDown, ChevronDown,
@@ -27,7 +29,11 @@ import { useMeteoStationPositions } from "@/hooks/use-meteo-stations";
import { useWeatherStations } from "@/hooks/use-weather-stations"; import { useWeatherStations } from "@/hooks/use-weather-stations";
import { APP_SECTION_SETTINGS } from "@/lib/app-sections"; import { APP_SECTION_SETTINGS } from "@/lib/app-sections";
import { DEFAULT_STATION_ID } from "@/lib/constants"; import { DEFAULT_STATION_ID } from "@/lib/constants";
import { DASHBOARD_SECTION_SETTINGS } from "@/lib/dashboard-sections"; import {
DASHBOARD_SECTION_SETTINGS,
normalizeDashboardSectionOrder,
normalizeDashboardSectionVisibility,
} from "@/lib/dashboard-sections";
import { useI18n } from "@/lib/i18n"; import { useI18n } from "@/lib/i18n";
import { locateSynopStations } from "@/lib/location-utils"; import { locateSynopStations } from "@/lib/location-utils";
import { import {
@@ -230,7 +236,8 @@ export function SettingsPage() {
const precipitationUnit = useWeatherStore((state) => state.precipitationUnit); const precipitationUnit = useWeatherStore((state) => state.precipitationUnit);
const pressureUnit = useWeatherStore((state) => state.pressureUnit); const pressureUnit = useWeatherStore((state) => state.pressureUnit);
const distanceUnit = useWeatherStore((state) => state.distanceUnit); const distanceUnit = useWeatherStore((state) => state.distanceUnit);
const dashboardSections = useWeatherStore((state) => state.dashboardSections); const dashboardSections = normalizeDashboardSectionVisibility(useWeatherStore((state) => state.dashboardSections));
const dashboardSectionOrder = normalizeDashboardSectionOrder(useWeatherStore((state) => state.dashboardSectionOrder));
const appSections = useWeatherStore((state) => state.appSections); const appSections = useWeatherStore((state) => state.appSections);
const setNotificationsEnabled = useWeatherStore((state) => state.setWarningNotificationsEnabled); const setNotificationsEnabled = useWeatherStore((state) => state.setWarningNotificationsEnabled);
const setMorningBriefEnabled = useWeatherStore((state) => state.setMorningBriefNotificationsEnabled); const setMorningBriefEnabled = useWeatherStore((state) => state.setMorningBriefNotificationsEnabled);
@@ -243,6 +250,7 @@ export function SettingsPage() {
const setPressureUnit = useWeatherStore((state) => state.setPressureUnit); const setPressureUnit = useWeatherStore((state) => state.setPressureUnit);
const setDistanceUnit = useWeatherStore((state) => state.setDistanceUnit); const setDistanceUnit = useWeatherStore((state) => state.setDistanceUnit);
const setDashboardSectionVisible = useWeatherStore((state) => state.setDashboardSectionVisible); const setDashboardSectionVisible = useWeatherStore((state) => state.setDashboardSectionVisible);
const moveDashboardSection = useWeatherStore((state) => state.moveDashboardSection);
const setAppSectionVisible = useWeatherStore((state) => state.setAppSectionVisible); const setAppSectionVisible = useWeatherStore((state) => state.setAppSectionVisible);
const selectedStation = const selectedStation =
@@ -277,6 +285,9 @@ export function SettingsPage() {
? formatProvinceName(effectiveProvince, language) ? formatProvinceName(effectiveProvince, language)
: t("settings.notifications.noProvince"); : t("settings.notifications.noProvince");
const manualProvinceValue = manualProvince ?? selectedProvince ?? "mazowieckie"; const manualProvinceValue = manualProvince ?? selectedProvince ?? "mazowieckie";
const orderedDashboardSections = dashboardSectionOrder
.map((sectionId) => DASHBOARD_SECTION_SETTINGS.find((section) => section.id === sectionId))
.filter((section): section is (typeof DASHBOARD_SECTION_SETTINGS)[number] => Boolean(section));
useEffect(() => { useEffect(() => {
const abortController = new AbortController(); const abortController = new AbortController();
@@ -714,17 +725,58 @@ export function SettingsPage() {
</div> </div>
</div> </div>
<div className="mt-2"> <div className="mt-4 space-y-2">
{DASHBOARD_SECTION_SETTINGS.map((section) => ( {orderedDashboardSections.map((section, index) => {
<NotificationSwitchLabel const sectionTitle = t(section.titleKey);
key={section.id} const checked = dashboardSections[section.id];
icon={LayoutDashboard} return (
title={t(section.titleKey)} <div
description={t(section.descriptionKey)} key={section.id}
checked={dashboardSections[section.id]} className={`rounded-card border px-3 py-3 transition-colors ${
onChange={(checked) => setDashboardSectionVisible(section.id, checked)} checked ? "border-accent/35 bg-accent/5" : "border-border/60 bg-surface"
/> }`}
))} >
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<label className="flex min-w-0 flex-1 cursor-pointer items-center justify-between gap-4">
<span className="min-w-0">
<span className="block text-sm font-semibold">{sectionTitle}</span>
<span className="mt-1 block text-sm leading-6 text-muted">{t(section.descriptionKey)}</span>
</span>
<input
type="checkbox"
checked={checked}
onChange={(event) => setDashboardSectionVisible(section.id, event.target.checked)}
className="sr-only"
/>
<SwitchIndicator checked={checked} />
</label>
<div className="flex items-center gap-2 self-end sm:self-center" aria-label={sectionTitle}>
<Button
type="button"
variant="icon"
className="size-9"
disabled={index === 0}
aria-label={t("settings.dashboardSections.moveUp", { section: sectionTitle })}
onClick={() => moveDashboardSection(section.id, -1)}
>
<ArrowUp className="size-4" aria-hidden="true" />
</Button>
<Button
type="button"
variant="icon"
className="size-9"
disabled={index === orderedDashboardSections.length - 1}
aria-label={t("settings.dashboardSections.moveDown", { section: sectionTitle })}
onClick={() => moveDashboardSection(section.id, 1)}
>
<ArrowDown className="size-4" aria-hidden="true" />
</Button>
</div>
</div>
</div>
);
})}
</div> </div>
</Card> </Card>

View File

@@ -55,7 +55,8 @@ Manifest znajduje się w `public/manifest.json`, a service worker w `public/sw.j
Service worker: Service worker:
- cache'uje powłokę aplikacji, - cache'uje powłokę aplikacji,
- obsługuje fallback `/offline` dla nawigacji, - obsługuje fallback `/offline` dla nawigacji i zapisuje udane nawigacje do cache,
- cache'uje statyczne assety Next.js, style, skrypty, fonty, obrazy, manifest i ikony,
- cache'uje wybrane odpowiedzi API: `/api/imgw/*`, `/api/imgw-current`, `/api/current-weather`, `/api/forecast`, - cache'uje wybrane odpowiedzi API: `/api/imgw/*`, `/api/imgw-current`, `/api/current-weather`, `/api/forecast`,
- obsługuje zdarzenia `push` i kliknięcia w powiadomienia. - obsługuje zdarzenia `push` i kliknięcia w powiadomienia.

View File

@@ -1,12 +1,30 @@
import type { TranslationKey } from "@/lib/i18n"; import type { TranslationKey } from "@/lib/i18n";
export const DASHBOARD_SECTION_SETTINGS = [ export const DASHBOARD_SECTION_SETTINGS = [
{
id: "warnings",
titleKey: "settings.dashboardSections.warnings.title",
descriptionKey: "settings.dashboardSections.warnings.description",
defaultVisible: true,
},
{ {
id: "weatherBrief", id: "weatherBrief",
titleKey: "settings.dashboardSections.weatherBrief.title", titleKey: "settings.dashboardSections.weatherBrief.title",
descriptionKey: "settings.dashboardSections.weatherBrief.description", descriptionKey: "settings.dashboardSections.weatherBrief.description",
defaultVisible: true, defaultVisible: true,
}, },
{
id: "forecast",
titleKey: "settings.dashboardSections.forecast.title",
descriptionKey: "settings.dashboardSections.forecast.description",
defaultVisible: true,
},
{
id: "favorites",
titleKey: "settings.dashboardSections.favorites.title",
descriptionKey: "settings.dashboardSections.favorites.description",
defaultVisible: true,
},
{ {
id: "featuredStations", id: "featuredStations",
titleKey: "settings.dashboardSections.featuredStations.title", titleKey: "settings.dashboardSections.featuredStations.title",
@@ -23,6 +41,11 @@ export const DASHBOARD_SECTION_SETTINGS = [
export type DashboardSectionId = (typeof DASHBOARD_SECTION_SETTINGS)[number]["id"]; export type DashboardSectionId = (typeof DASHBOARD_SECTION_SETTINGS)[number]["id"];
export type DashboardSectionVisibility = Record<DashboardSectionId, boolean>; export type DashboardSectionVisibility = Record<DashboardSectionId, boolean>;
export type DashboardSectionOrder = DashboardSectionId[];
export const DEFAULT_DASHBOARD_SECTION_ORDER = DASHBOARD_SECTION_SETTINGS.map(
(section) => section.id,
) as DashboardSectionOrder;
export const DEFAULT_DASHBOARD_SECTION_VISIBILITY = DASHBOARD_SECTION_SETTINGS.reduce( export const DEFAULT_DASHBOARD_SECTION_VISIBILITY = DASHBOARD_SECTION_SETTINGS.reduce(
(visibility, section) => ({ (visibility, section) => ({
@@ -42,3 +65,14 @@ export function normalizeDashboardSectionVisibility(value: unknown): DashboardSe
{} as DashboardSectionVisibility, {} as DashboardSectionVisibility,
); );
} }
export function normalizeDashboardSectionOrder(value: unknown): DashboardSectionOrder {
const allowedIds = new Set<DashboardSectionId>(DASHBOARD_SECTION_SETTINGS.map((section) => section.id));
const stored = Array.isArray(value)
? value.filter((sectionId): sectionId is DashboardSectionId => allowedIds.has(sectionId as DashboardSectionId))
: [];
return [
...stored,
...DEFAULT_DASHBOARD_SECTION_ORDER.filter((sectionId) => !stored.includes(sectionId)),
] as DashboardSectionOrder;
}

View File

@@ -28,10 +28,20 @@ const translations = {
"settings.theme.title": "Motyw", "settings.theme.title": "Motyw",
"settings.theme.description": "Przełącz jasny lub ciemny wygląd aplikacji na tym urządzeniu.", "settings.theme.description": "Przełącz jasny lub ciemny wygląd aplikacji na tym urządzeniu.",
"settings.dashboardSections.title": "Strona główna", "settings.dashboardSections.title": "Strona główna",
"settings.dashboardSections.description": "Wybierz, które dodatkowe sekcje mają być widoczne na dashboardzie.", "settings.dashboardSections.description": "Wybierz widoczność i kolejność sekcji pod główną kartą pogody.",
"settings.dashboardSections.moveUp": "Przenieś sekcję {section} wyżej",
"settings.dashboardSections.moveDown": "Przenieś sekcję {section} niżej",
"settings.dashboardSections.warnings.title": "Ostrzeżenia",
"settings.dashboardSections.warnings.description":
"Pokazuje aktywne i nadchodzące ostrzeżenia dla wybranej lokalizacji. Na stronie głównej sekcja pojawia się tylko wtedy, gdy jest aktywne ostrzeżenie meteo.",
"settings.dashboardSections.weatherBrief.title": "Brief dnia", "settings.dashboardSections.weatherBrief.title": "Brief dnia",
"settings.dashboardSections.weatherBrief.description": "settings.dashboardSections.weatherBrief.description":
"Pokazuje deterministyczne podsumowanie dnia i krótką prognozę na jutro.", "Pokazuje deterministyczne podsumowanie dnia i krótką prognozę na jutro.",
"settings.dashboardSections.forecast.title": "Prognoza 7-dniowa",
"settings.dashboardSections.forecast.description":
"Pokazuje najbliższe godziny, prognozę tygodniową i wykresy dnia.",
"settings.dashboardSections.favorites.title": "Ulubione",
"settings.dashboardSections.favorites.description": "Pokazuje zapisane lokalizacje i stacje na stronie głównej.",
"settings.dashboardSections.featuredStations.title": "Szybki wybór", "settings.dashboardSections.featuredStations.title": "Szybki wybór",
"settings.dashboardSections.featuredStations.description": "settings.dashboardSections.featuredStations.description":
"Pokazuje listę popularnych lokalizacji na dole strony głównej.", "Pokazuje listę popularnych lokalizacji na dole strony głównej.",
@@ -317,6 +327,7 @@ const translations = {
"offline.description": "offline.description":
"wtr. nie może teraz pobrać aktualnych danych IMGW. Ostatnio odwiedzone widoki mogą być dostępne z pamięci urządzenia.", "wtr. nie może teraz pobrać aktualnych danych IMGW. Ostatnio odwiedzone widoki mogą być dostępne z pamięci urządzenia.",
"offline.back": "Wróć do aplikacji", "offline.back": "Wróć do aplikacji",
"offline.banner": "Brak połączenia · dane mogą być nieaktualne",
}, },
en: { en: {
"nav.weather": "Weather", "nav.weather": "Weather",
@@ -340,10 +351,19 @@ const translations = {
"settings.theme.title": "Theme", "settings.theme.title": "Theme",
"settings.theme.description": "Switch the light or dark appearance on this device.", "settings.theme.description": "Switch the light or dark appearance on this device.",
"settings.dashboardSections.title": "Home screen", "settings.dashboardSections.title": "Home screen",
"settings.dashboardSections.description": "Choose which additional sections are visible on the dashboard.", "settings.dashboardSections.description": "Choose visibility and order for sections below the main weather card.",
"settings.dashboardSections.moveUp": "Move {section} up",
"settings.dashboardSections.moveDown": "Move {section} down",
"settings.dashboardSections.warnings.title": "Warnings",
"settings.dashboardSections.warnings.description":
"Shows active and upcoming warnings for the selected location. On the home screen, this section appears only when an active weather warning exists.",
"settings.dashboardSections.weatherBrief.title": "Daily brief", "settings.dashboardSections.weatherBrief.title": "Daily brief",
"settings.dashboardSections.weatherBrief.description": "settings.dashboardSections.weatherBrief.description":
"Shows the deterministic daily summary and a short forecast for tomorrow.", "Shows the deterministic daily summary and a short forecast for tomorrow.",
"settings.dashboardSections.forecast.title": "7-day forecast",
"settings.dashboardSections.forecast.description": "Shows the next hours, weekly forecast and daily charts.",
"settings.dashboardSections.favorites.title": "Favorites",
"settings.dashboardSections.favorites.description": "Shows saved places and stations on the home screen.",
"settings.dashboardSections.featuredStations.title": "Quick select", "settings.dashboardSections.featuredStations.title": "Quick select",
"settings.dashboardSections.featuredStations.description": "settings.dashboardSections.featuredStations.description":
"Shows the popular locations list at the bottom of the home screen.", "Shows the popular locations list at the bottom of the home screen.",
@@ -628,6 +648,7 @@ const translations = {
"offline.description": "offline.description":
"wtr. cannot fetch current IMGW data right now. Recently visited views may still be available from your device cache.", "wtr. cannot fetch current IMGW data right now. Recently visited views may still be available from your device cache.",
"offline.back": "Back to the app", "offline.back": "Back to the app",
"offline.banner": "Offline · data may be out of date",
}, },
} as const; } as const;

View File

@@ -12,9 +12,12 @@ import {
type AppSectionVisibility, type AppSectionVisibility,
} from "@/lib/app-sections"; } from "@/lib/app-sections";
import { import {
DEFAULT_DASHBOARD_SECTION_ORDER,
DEFAULT_DASHBOARD_SECTION_VISIBILITY, DEFAULT_DASHBOARD_SECTION_VISIBILITY,
normalizeDashboardSectionOrder,
normalizeDashboardSectionVisibility, normalizeDashboardSectionVisibility,
type DashboardSectionId, type DashboardSectionId,
type DashboardSectionOrder,
type DashboardSectionVisibility, type DashboardSectionVisibility,
} from "@/lib/dashboard-sections"; } from "@/lib/dashboard-sections";
import { import {
@@ -49,6 +52,7 @@ interface WeatherStore {
pressureUnit: PressureUnit; pressureUnit: PressureUnit;
distanceUnit: DistanceUnit; distanceUnit: DistanceUnit;
dashboardSections: DashboardSectionVisibility; dashboardSections: DashboardSectionVisibility;
dashboardSectionOrder: DashboardSectionOrder;
appSections: AppSectionVisibility; appSections: AppSectionVisibility;
toggleFavorite: (id: string) => void; toggleFavorite: (id: string) => void;
selectStation: (id: string) => void; selectStation: (id: string) => void;
@@ -64,6 +68,7 @@ interface WeatherStore {
setPressureUnit: (unit: PressureUnit) => void; setPressureUnit: (unit: PressureUnit) => void;
setDistanceUnit: (unit: DistanceUnit) => void; setDistanceUnit: (unit: DistanceUnit) => void;
setDashboardSectionVisible: (section: DashboardSectionId, visible: boolean) => void; setDashboardSectionVisible: (section: DashboardSectionId, visible: boolean) => void;
moveDashboardSection: (section: DashboardSectionId, direction: -1 | 1) => void;
setAppSectionVisible: (section: AppSectionId, visible: boolean) => void; setAppSectionVisible: (section: AppSectionId, visible: boolean) => void;
} }
@@ -84,6 +89,7 @@ export const useWeatherStore = create<WeatherStore>()(
pressureUnit: DEFAULT_PRESSURE_UNIT, pressureUnit: DEFAULT_PRESSURE_UNIT,
distanceUnit: DEFAULT_DISTANCE_UNIT, distanceUnit: DEFAULT_DISTANCE_UNIT,
dashboardSections: DEFAULT_DASHBOARD_SECTION_VISIBILITY, dashboardSections: DEFAULT_DASHBOARD_SECTION_VISIBILITY,
dashboardSectionOrder: DEFAULT_DASHBOARD_SECTION_ORDER,
appSections: DEFAULT_APP_SECTION_VISIBILITY, appSections: DEFAULT_APP_SECTION_VISIBILITY,
toggleFavorite: (id) => toggleFavorite: (id) =>
set((state) => ({ set((state) => ({
@@ -107,6 +113,16 @@ export const useWeatherStore = create<WeatherStore>()(
set((state) => ({ set((state) => ({
dashboardSections: { ...state.dashboardSections, [section]: visible }, dashboardSections: { ...state.dashboardSections, [section]: visible },
})), })),
moveDashboardSection: (section, direction) =>
set((state) => {
const order = normalizeDashboardSectionOrder(state.dashboardSectionOrder);
const index = order.indexOf(section);
const nextIndex = index + direction;
if (index === -1 || nextIndex < 0 || nextIndex >= order.length) return state;
const nextOrder = [...order];
[nextOrder[index], nextOrder[nextIndex]] = [nextOrder[nextIndex], nextOrder[index]];
return { dashboardSectionOrder: nextOrder };
}),
setAppSectionVisible: (section, visible) => setAppSectionVisible: (section, visible) =>
set((state) => ({ set((state) => ({
appSections: { ...state.appSections, [section]: visible }, appSections: { ...state.appSections, [section]: visible },
@@ -122,6 +138,7 @@ export const useWeatherStore = create<WeatherStore>()(
| undefined; | undefined;
if (!state) return persisted; if (!state) return persisted;
const dashboardSections = normalizeDashboardSectionVisibility(state.dashboardSections); const dashboardSections = normalizeDashboardSectionVisibility(state.dashboardSections);
const dashboardSectionOrder = normalizeDashboardSectionOrder(state.dashboardSectionOrder);
const appSections = normalizeAppSectionVisibility(state.appSections); const appSections = normalizeAppSectionVisibility(state.appSections);
const unitPreferences = { const unitPreferences = {
temperatureUnit: isTemperatureUnit(state.temperatureUnit) ? state.temperatureUnit : DEFAULT_TEMPERATURE_UNIT, temperatureUnit: isTemperatureUnit(state.temperatureUnit) ? state.temperatureUnit : DEFAULT_TEMPERATURE_UNIT,
@@ -132,13 +149,14 @@ export const useWeatherStore = create<WeatherStore>()(
pressureUnit: isPressureUnit(state.pressureUnit) ? state.pressureUnit : DEFAULT_PRESSURE_UNIT, pressureUnit: isPressureUnit(state.pressureUnit) ? state.pressureUnit : DEFAULT_PRESSURE_UNIT,
distanceUnit: isDistanceUnit(state.distanceUnit) ? state.distanceUnit : DEFAULT_DISTANCE_UNIT, distanceUnit: isDistanceUnit(state.distanceUnit) ? state.distanceUnit : DEFAULT_DISTANCE_UNIT,
}; };
if (!location) return { ...state, ...unitPreferences, dashboardSections, appSections }; if (!location) return { ...state, ...unitPreferences, dashboardSections, dashboardSectionOrder, appSections };
const countryCode = location.countryCode ?? (location.province ? "PL" : null); const countryCode = location.countryCode ?? (location.province ? "PL" : null);
const region = location.region ?? getWeatherRegionForCountryCode(countryCode); const region = location.region ?? getWeatherRegionForCountryCode(countryCode);
return { return {
...state, ...state,
...unitPreferences, ...unitPreferences,
dashboardSections, dashboardSections,
dashboardSectionOrder,
appSections, appSections,
selectedLocation: { selectedLocation: {
...location, ...location,

View File

@@ -1,6 +1,14 @@
const CACHE_NAME = "wtr-shell-v5"; const CACHE_VERSION = "v6";
const CACHE_PREFIX = "wtr-";
const SHELL_CACHE = `${CACHE_PREFIX}shell-${CACHE_VERSION}`;
const RUNTIME_CACHE = `${CACHE_PREFIX}runtime-${CACHE_VERSION}`;
const API_CACHE = `${CACHE_PREFIX}api-${CACHE_VERSION}`;
const ACTIVE_CACHES = new Set([SHELL_CACHE, RUNTIME_CACHE, API_CACHE]);
const SHELL = [ const SHELL = [
"/", "/",
"/warnings",
"/hydro",
"/settings", "/settings",
"/offline", "/offline",
"/manifest.json", "/manifest.json",
@@ -11,8 +19,75 @@ const SHELL = [
"/icons/maskable-512.png", "/icons/maskable-512.png",
]; ];
function isCacheableResponse(response) {
return response && response.ok && response.type !== "opaque";
}
async function putInCache(cacheName, request, response) {
if (!isCacheableResponse(response)) return;
const cache = await caches.open(cacheName);
await cache.put(request, response.clone());
}
function isWeatherApiRequest(url) {
return (
url.pathname.startsWith("/api/imgw/") ||
url.pathname === "/api/imgw-current" ||
url.pathname === "/api/current-weather" ||
url.pathname === "/api/forecast"
);
}
function isStaticAssetRequest(request, url) {
return (
url.origin === self.location.origin &&
(url.pathname.startsWith("/_next/static/") ||
url.pathname.startsWith("/icons/") ||
url.pathname === "/manifest.json" ||
["font", "image", "script", "style", "worker"].includes(request.destination))
);
}
async function cacheFirst(request) {
const cached = await caches.match(request);
if (cached) return cached;
const response = await fetch(request);
await putInCache(RUNTIME_CACHE, request, response);
return response;
}
async function networkFirst(request, cacheName) {
try {
const response = await fetch(request);
await putInCache(cacheName, request, response);
return response;
} catch {
return (
(await caches.match(request)) ||
new Response(JSON.stringify({ error: "Offline." }), {
status: 503,
headers: { "Content-Type": "application/json" },
})
);
}
}
async function handleNavigation(request) {
try {
const response = await fetch(request);
await putInCache(SHELL_CACHE, request, response);
return response;
} catch {
return (await caches.match(request)) || (await caches.match("/")) || (await caches.match("/offline"));
}
}
self.addEventListener("install", (event) => { self.addEventListener("install", (event) => {
event.waitUntil(caches.open(CACHE_NAME).then((cache) => cache.addAll(SHELL))); event.waitUntil(
caches
.open(SHELL_CACHE)
.then((cache) => Promise.allSettled(SHELL.map((url) => cache.add(new Request(url, { cache: "reload" }))))),
);
self.skipWaiting(); self.skipWaiting();
}); });
@@ -20,44 +95,42 @@ self.addEventListener("activate", (event) => {
event.waitUntil( event.waitUntil(
caches caches
.keys() .keys()
.then((keys) => Promise.all(keys.filter((key) => key !== CACHE_NAME).map((key) => caches.delete(key)))), .then((keys) =>
Promise.all(
keys
.filter((key) => key.startsWith(CACHE_PREFIX) && !ACTIVE_CACHES.has(key))
.map((key) => caches.delete(key)),
),
),
); );
self.clients.claim(); self.clients.claim();
}); });
self.addEventListener("fetch", (event) => { self.addEventListener("fetch", (event) => {
if (event.request.method !== "GET") return; if (event.request.method !== "GET") return;
const url = new URL(event.request.url); const url = new URL(event.request.url);
if ( if (url.origin !== self.location.origin) return;
url.pathname.startsWith("/api/imgw/") ||
url.pathname === "/api/imgw-current" || if (isWeatherApiRequest(url)) {
url.pathname === "/api/current-weather" || event.respondWith(networkFirst(event.request, API_CACHE));
url.pathname === "/api/forecast"
) {
event.respondWith(
fetch(event.request)
.then((response) => {
const copy = response.clone();
caches.open(CACHE_NAME).then((cache) => cache.put(event.request, copy));
return response;
})
.catch(() => caches.match(event.request)),
);
return; return;
} }
if (event.request.mode === "navigate") { if (event.request.mode === "navigate") {
event.respondWith( event.respondWith(handleNavigation(event.request));
fetch(event.request).catch(() => return;
caches.match(event.request).then((response) => response || caches.match("/offline")), }
),
); if (isStaticAssetRequest(event.request, url)) {
event.respondWith(cacheFirst(event.request));
} }
}); });
self.addEventListener("push", (event) => { self.addEventListener("push", (event) => {
const fallbackPayload = { const fallbackPayload = {
title: "IMGW", title: "IMGW",
body: "Nowe ostrzeżenie meteorologiczne.", body: "Nowe ostrze\u017cenie meteorologiczne.",
url: "/warnings", url: "/warnings",
}; };
let payload = fallbackPayload; let payload = fallbackPayload;

View File

@@ -0,0 +1,33 @@
import { describe, expect, it } from "vitest";
import {
DEFAULT_DASHBOARD_SECTION_ORDER,
normalizeDashboardSectionOrder,
normalizeDashboardSectionVisibility,
} from "@/lib/dashboard-sections";
describe("dashboard sections", () => {
it("keeps stored section order and appends missing defaults", () => {
expect(normalizeDashboardSectionOrder(["forecast", "weatherBrief", "unknown"])).toEqual([
"forecast",
"weatherBrief",
"warnings",
"favorites",
"featuredStations",
]);
});
it("falls back to the default order for invalid values", () => {
expect(normalizeDashboardSectionOrder(null)).toEqual(DEFAULT_DASHBOARD_SECTION_ORDER);
});
it("normalizes visibility for older stored preferences", () => {
expect(normalizeDashboardSectionVisibility({ weatherBrief: false })).toEqual({
warnings: true,
weatherBrief: false,
forecast: true,
favorites: true,
featuredStations: false,
});
});
});