Compare commits
2 Commits
91acdb39b8
...
6bec7060e0
| Author | SHA1 | Date | |
|---|---|---|---|
| 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.
|
- 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.
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
23
lib/i18n.tsx
23
lib/i18n.tsx
@@ -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.",
|
||||||
|
|||||||
20
lib/store.ts
20
lib/store.ts
@@ -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,
|
||||||
|
|||||||
119
public/sw.js
119
public/sw.js
@@ -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;
|
||||||
|
|||||||
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