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 (
+
+
+
+
+ {sectionTitle}
+ {t(section.descriptionKey)}
+
+ setDashboardSectionVisible(section.id, event.target.checked)}
+ className="sr-only"
+ />
+
+
+
+
+
moveDashboardSection(section.id, -1)}
+ >
+
+
+
moveDashboardSection(section.id, 1)}
+ >
+
+
+
+
+
+ );
+ })}
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,
+ });
+ });
+});