Compare commits

...

2 Commits

Author SHA1 Message Date
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
9 changed files with 306 additions and 59 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,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);
const checked = dashboardSections[section.id];
return (
<div
key={section.id} key={section.id}
icon={LayoutDashboard} className={`rounded-card border px-3 py-3 transition-colors ${
title={t(section.titleKey)} checked ? "border-accent/35 bg-accent/5" : "border-border/60 bg-surface"
description={t(section.descriptionKey)} }`}
checked={dashboardSections[section.id]} >
onChange={(checked) => setDashboardSectionVisible(section.id, checked)} <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.",
@@ -340,10 +350,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.",

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,4 +1,10 @@
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 = [
"/", "/",
"/settings", "/settings",
@@ -11,8 +17,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 +93,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,
});
});
});