diff --git a/AGENTS.md b/AGENTS.md index 09ac84e..0830acc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/components/dashboard/dashboard-page.tsx b/components/dashboard/dashboard-page.tsx index f22106b..2c1670a 100644 --- a/components/dashboard/dashboard-page.tsx +++ b/components/dashboard/dashboard-page.tsx @@ -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 ; + if (sectionId === "weatherBrief") + return ( + + ); + if (sectionId === "forecast") + return ( + + ); + if (sectionId === "favorites") return ; + if (sectionId === "featuredStations") return ; + return null; + }; + return (
@@ -84,23 +118,7 @@ export function DashboardPage() { locationName={activeLocation?.name} distanceKm={activeLocation?.distanceKm} /> - - {dashboardSections.weatherBrief && ( - - )} - - - {dashboardSections.featuredStations && } + {dashboardSectionOrder.map(renderDashboardSection)}
); } diff --git a/components/settings/settings-page.tsx b/components/settings/settings-page.tsx index 81457b4..08628b7 100644 --- a/components/settings/settings-page.tsx +++ b/components/settings/settings-page.tsx @@ -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() { -
- {DASHBOARD_SECTION_SETTINGS.map((section) => ( - setDashboardSectionVisible(section.id, checked)} - /> - ))} +
+ {orderedDashboardSections.map((section, index) => { + const sectionTitle = t(section.titleKey); + const checked = dashboardSections[section.id]; + return ( +
+
+ + +
+ + +
+
+
+ ); + })}
diff --git a/lib/dashboard-sections.ts b/lib/dashboard-sections.ts index 6b10f39..a75e318 100644 --- a/lib/dashboard-sections.ts +++ b/lib/dashboard-sections.ts @@ -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; +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(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; +} diff --git a/lib/i18n.tsx b/lib/i18n.tsx index 7049538..1361166 100644 --- a/lib/i18n.tsx +++ b/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.", @@ -340,10 +350,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.", diff --git a/lib/store.ts b/lib/store.ts index 8f3394e..6c84275 100644 --- a/lib/store.ts +++ b/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()( 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()( 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()( | 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()( 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, diff --git a/tests/dashboard-sections.test.ts b/tests/dashboard-sections.test.ts new file mode 100644 index 0000000..c544830 --- /dev/null +++ b/tests/dashboard-sections.test.ts @@ -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, + }); + }); +});