Compare commits
3 Commits
91acdb39b8
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 2c2515abba | |||
| 6bec7060e0 | |||
| c7da9e0d94 |
@@ -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.
|
||||
- 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.
|
||||
- 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.
|
||||
- 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.
|
||||
|
||||
@@ -19,6 +19,11 @@ import { locateSynopStations } from "@/lib/location-utils";
|
||||
import { DashboardWarnings } from "@/components/warnings/dashboard-warnings";
|
||||
import { WeatherBriefCard } from "@/components/dashboard/weather-brief-card";
|
||||
import type { SynopStation } from "@/types/imgw";
|
||||
import {
|
||||
normalizeDashboardSectionOrder,
|
||||
normalizeDashboardSectionVisibility,
|
||||
type DashboardSectionId,
|
||||
} from "@/lib/dashboard-sections";
|
||||
|
||||
export function DashboardPage() {
|
||||
const { t } = useI18n();
|
||||
@@ -26,7 +31,8 @@ export function DashboardPage() {
|
||||
const { data: positions = [] } = useMeteoStationPositions();
|
||||
const selectedStationId = useWeatherStore((state) => state.selectedStationId);
|
||||
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 =
|
||||
stations?.find((station) => station.id === selectedStationId) ??
|
||||
stations?.find((station) => station.name === DEFAULT_STATION_NAME) ??
|
||||
@@ -73,6 +79,34 @@ export function DashboardPage() {
|
||||
}
|
||||
: 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 (
|
||||
<div className="space-y-10">
|
||||
<LocationSearch stations={stations} positions={positions} />
|
||||
@@ -84,23 +118,7 @@ export function DashboardPage() {
|
||||
locationName={activeLocation?.name}
|
||||
distanceKm={activeLocation?.distanceKm}
|
||||
/>
|
||||
<DashboardWarnings />
|
||||
{dashboardSections.weatherBrief && (
|
||||
<WeatherBriefCard
|
||||
latitude={forecastLatitude}
|
||||
longitude={forecastLongitude}
|
||||
region={activeRegion}
|
||||
locationName={forecastLocationName ?? selectedStation.name}
|
||||
/>
|
||||
)}
|
||||
<ForecastPanel
|
||||
latitude={forecastLatitude}
|
||||
longitude={forecastLongitude}
|
||||
region={activeRegion}
|
||||
locationName={forecastLocationName ?? selectedStation.name}
|
||||
/>
|
||||
<FavoritesSection stations={stations} />
|
||||
{dashboardSections.featuredStations && <FeaturedStationsSection stations={stations} />}
|
||||
{dashboardSectionOrder.map(renderDashboardSection)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
import Link from "next/link";
|
||||
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 { isAppNavItemVisible } from "@/lib/app-sections";
|
||||
import { NAV_ITEMS } from "@/lib/constants";
|
||||
@@ -23,6 +24,18 @@ export function AppShell({ children }: { children: React.ReactNode }) {
|
||||
const { t } = useI18n();
|
||||
const appSections = useWeatherStore((state) => state.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 (
|
||||
<div className="min-h-screen overflow-x-hidden bg-background">
|
||||
@@ -56,6 +69,14 @@ export function AppShell({ children }: { children: React.ReactNode }) {
|
||||
</div>
|
||||
</div>
|
||||
</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>
|
||||
<nav
|
||||
aria-label={t("nav.mobile")}
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
ArrowDown,
|
||||
ArrowUp,
|
||||
Bell,
|
||||
BellRing,
|
||||
ChevronDown,
|
||||
@@ -27,7 +29,11 @@ import { useMeteoStationPositions } from "@/hooks/use-meteo-stations";
|
||||
import { useWeatherStations } from "@/hooks/use-weather-stations";
|
||||
import { APP_SECTION_SETTINGS } from "@/lib/app-sections";
|
||||
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 { locateSynopStations } from "@/lib/location-utils";
|
||||
import {
|
||||
@@ -230,7 +236,8 @@ export function SettingsPage() {
|
||||
const precipitationUnit = useWeatherStore((state) => state.precipitationUnit);
|
||||
const pressureUnit = useWeatherStore((state) => state.pressureUnit);
|
||||
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 setNotificationsEnabled = useWeatherStore((state) => state.setWarningNotificationsEnabled);
|
||||
const setMorningBriefEnabled = useWeatherStore((state) => state.setMorningBriefNotificationsEnabled);
|
||||
@@ -243,6 +250,7 @@ export function SettingsPage() {
|
||||
const setPressureUnit = useWeatherStore((state) => state.setPressureUnit);
|
||||
const setDistanceUnit = useWeatherStore((state) => state.setDistanceUnit);
|
||||
const setDashboardSectionVisible = useWeatherStore((state) => state.setDashboardSectionVisible);
|
||||
const moveDashboardSection = useWeatherStore((state) => state.moveDashboardSection);
|
||||
const setAppSectionVisible = useWeatherStore((state) => state.setAppSectionVisible);
|
||||
|
||||
const selectedStation =
|
||||
@@ -277,6 +285,9 @@ export function SettingsPage() {
|
||||
? formatProvinceName(effectiveProvince, language)
|
||||
: t("settings.notifications.noProvince");
|
||||
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(() => {
|
||||
const abortController = new AbortController();
|
||||
@@ -714,17 +725,58 @@ export function SettingsPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-2">
|
||||
{DASHBOARD_SECTION_SETTINGS.map((section) => (
|
||||
<NotificationSwitchLabel
|
||||
key={section.id}
|
||||
icon={LayoutDashboard}
|
||||
title={t(section.titleKey)}
|
||||
description={t(section.descriptionKey)}
|
||||
checked={dashboardSections[section.id]}
|
||||
onChange={(checked) => setDashboardSectionVisible(section.id, checked)}
|
||||
/>
|
||||
))}
|
||||
<div className="mt-4 space-y-2">
|
||||
{orderedDashboardSections.map((section, index) => {
|
||||
const sectionTitle = t(section.titleKey);
|
||||
const checked = dashboardSections[section.id];
|
||||
return (
|
||||
<div
|
||||
key={section.id}
|
||||
className={`rounded-card border px-3 py-3 transition-colors ${
|
||||
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>
|
||||
</Card>
|
||||
|
||||
|
||||
@@ -55,7 +55,8 @@ Manifest znajduje się w `public/manifest.json`, a service worker w `public/sw.j
|
||||
Service worker:
|
||||
|
||||
- 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`,
|
||||
- obsługuje zdarzenia `push` i kliknięcia w powiadomienia.
|
||||
|
||||
|
||||
@@ -1,12 +1,30 @@
|
||||
import type { TranslationKey } from "@/lib/i18n";
|
||||
|
||||
export const DASHBOARD_SECTION_SETTINGS = [
|
||||
{
|
||||
id: "warnings",
|
||||
titleKey: "settings.dashboardSections.warnings.title",
|
||||
descriptionKey: "settings.dashboardSections.warnings.description",
|
||||
defaultVisible: true,
|
||||
},
|
||||
{
|
||||
id: "weatherBrief",
|
||||
titleKey: "settings.dashboardSections.weatherBrief.title",
|
||||
descriptionKey: "settings.dashboardSections.weatherBrief.description",
|
||||
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",
|
||||
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 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(
|
||||
(visibility, section) => ({
|
||||
@@ -42,3 +65,14 @@ export function normalizeDashboardSectionVisibility(value: unknown): DashboardSe
|
||||
{} 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;
|
||||
}
|
||||
|
||||
25
lib/i18n.tsx
25
lib/i18n.tsx
@@ -28,10 +28,20 @@ const translations = {
|
||||
"settings.theme.title": "Motyw",
|
||||
"settings.theme.description": "Przełącz jasny lub ciemny wygląd aplikacji na tym urządzeniu.",
|
||||
"settings.dashboardSections.title": "Strona główna",
|
||||
"settings.dashboardSections.description": "Wybierz, które dodatkowe sekcje mają być widoczne na dashboardzie.",
|
||||
"settings.dashboardSections.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.description":
|
||||
"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.description":
|
||||
"Pokazuje listę popularnych lokalizacji na dole strony głównej.",
|
||||
@@ -317,6 +327,7 @@ const translations = {
|
||||
"offline.description":
|
||||
"wtr. nie może teraz pobrać aktualnych danych IMGW. Ostatnio odwiedzone widoki mogą być dostępne z pamięci urządzenia.",
|
||||
"offline.back": "Wróć do aplikacji",
|
||||
"offline.banner": "Brak połączenia · dane mogą być nieaktualne",
|
||||
},
|
||||
en: {
|
||||
"nav.weather": "Weather",
|
||||
@@ -340,10 +351,19 @@ const translations = {
|
||||
"settings.theme.title": "Theme",
|
||||
"settings.theme.description": "Switch the light or dark appearance on this device.",
|
||||
"settings.dashboardSections.title": "Home screen",
|
||||
"settings.dashboardSections.description": "Choose which additional sections are visible on the dashboard.",
|
||||
"settings.dashboardSections.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.description":
|
||||
"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.description":
|
||||
"Shows the popular locations list at the bottom of the home screen.",
|
||||
@@ -628,6 +648,7 @@ const translations = {
|
||||
"offline.description":
|
||||
"wtr. cannot fetch current IMGW data right now. Recently visited views may still be available from your device cache.",
|
||||
"offline.back": "Back to the app",
|
||||
"offline.banner": "Offline · data may be out of date",
|
||||
},
|
||||
} as const;
|
||||
|
||||
|
||||
20
lib/store.ts
20
lib/store.ts
@@ -12,9 +12,12 @@ import {
|
||||
type AppSectionVisibility,
|
||||
} from "@/lib/app-sections";
|
||||
import {
|
||||
DEFAULT_DASHBOARD_SECTION_ORDER,
|
||||
DEFAULT_DASHBOARD_SECTION_VISIBILITY,
|
||||
normalizeDashboardSectionOrder,
|
||||
normalizeDashboardSectionVisibility,
|
||||
type DashboardSectionId,
|
||||
type DashboardSectionOrder,
|
||||
type DashboardSectionVisibility,
|
||||
} from "@/lib/dashboard-sections";
|
||||
import {
|
||||
@@ -49,6 +52,7 @@ interface WeatherStore {
|
||||
pressureUnit: PressureUnit;
|
||||
distanceUnit: DistanceUnit;
|
||||
dashboardSections: DashboardSectionVisibility;
|
||||
dashboardSectionOrder: DashboardSectionOrder;
|
||||
appSections: AppSectionVisibility;
|
||||
toggleFavorite: (id: string) => void;
|
||||
selectStation: (id: string) => void;
|
||||
@@ -64,6 +68,7 @@ interface WeatherStore {
|
||||
setPressureUnit: (unit: PressureUnit) => void;
|
||||
setDistanceUnit: (unit: DistanceUnit) => void;
|
||||
setDashboardSectionVisible: (section: DashboardSectionId, visible: boolean) => void;
|
||||
moveDashboardSection: (section: DashboardSectionId, direction: -1 | 1) => void;
|
||||
setAppSectionVisible: (section: AppSectionId, visible: boolean) => void;
|
||||
}
|
||||
|
||||
@@ -84,6 +89,7 @@ export const useWeatherStore = create<WeatherStore>()(
|
||||
pressureUnit: DEFAULT_PRESSURE_UNIT,
|
||||
distanceUnit: DEFAULT_DISTANCE_UNIT,
|
||||
dashboardSections: DEFAULT_DASHBOARD_SECTION_VISIBILITY,
|
||||
dashboardSectionOrder: DEFAULT_DASHBOARD_SECTION_ORDER,
|
||||
appSections: DEFAULT_APP_SECTION_VISIBILITY,
|
||||
toggleFavorite: (id) =>
|
||||
set((state) => ({
|
||||
@@ -107,6 +113,16 @@ export const useWeatherStore = create<WeatherStore>()(
|
||||
set((state) => ({
|
||||
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) =>
|
||||
set((state) => ({
|
||||
appSections: { ...state.appSections, [section]: visible },
|
||||
@@ -122,6 +138,7 @@ export const useWeatherStore = create<WeatherStore>()(
|
||||
| undefined;
|
||||
if (!state) return persisted;
|
||||
const dashboardSections = normalizeDashboardSectionVisibility(state.dashboardSections);
|
||||
const dashboardSectionOrder = normalizeDashboardSectionOrder(state.dashboardSectionOrder);
|
||||
const appSections = normalizeAppSectionVisibility(state.appSections);
|
||||
const unitPreferences = {
|
||||
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,
|
||||
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 region = location.region ?? getWeatherRegionForCountryCode(countryCode);
|
||||
return {
|
||||
...state,
|
||||
...unitPreferences,
|
||||
dashboardSections,
|
||||
dashboardSectionOrder,
|
||||
appSections,
|
||||
selectedLocation: {
|
||||
...location,
|
||||
|
||||
121
public/sw.js
121
public/sw.js
@@ -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 = [
|
||||
"/",
|
||||
"/warnings",
|
||||
"/hydro",
|
||||
"/settings",
|
||||
"/offline",
|
||||
"/manifest.json",
|
||||
@@ -11,8 +19,75 @@ const SHELL = [
|
||||
"/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) => {
|
||||
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();
|
||||
});
|
||||
|
||||
@@ -20,44 +95,42 @@ self.addEventListener("activate", (event) => {
|
||||
event.waitUntil(
|
||||
caches
|
||||
.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.addEventListener("fetch", (event) => {
|
||||
if (event.request.method !== "GET") return;
|
||||
|
||||
const url = new URL(event.request.url);
|
||||
if (
|
||||
url.pathname.startsWith("/api/imgw/") ||
|
||||
url.pathname === "/api/imgw-current" ||
|
||||
url.pathname === "/api/current-weather" ||
|
||||
url.pathname === "/api/forecast"
|
||||
) {
|
||||
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)),
|
||||
);
|
||||
if (url.origin !== self.location.origin) return;
|
||||
|
||||
if (isWeatherApiRequest(url)) {
|
||||
event.respondWith(networkFirst(event.request, API_CACHE));
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.request.mode === "navigate") {
|
||||
event.respondWith(
|
||||
fetch(event.request).catch(() =>
|
||||
caches.match(event.request).then((response) => response || caches.match("/offline")),
|
||||
),
|
||||
);
|
||||
event.respondWith(handleNavigation(event.request));
|
||||
return;
|
||||
}
|
||||
|
||||
if (isStaticAssetRequest(event.request, url)) {
|
||||
event.respondWith(cacheFirst(event.request));
|
||||
}
|
||||
});
|
||||
|
||||
self.addEventListener("push", (event) => {
|
||||
const fallbackPayload = {
|
||||
title: "IMGW",
|
||||
body: "Nowe ostrzeżenie meteorologiczne.",
|
||||
body: "Nowe ostrze\u017cenie meteorologiczne.",
|
||||
url: "/warnings",
|
||||
};
|
||||
let payload = fallbackPayload;
|
||||
|
||||
33
tests/dashboard-sections.test.ts
Normal file
33
tests/dashboard-sections.test.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user