diff --git a/server.js b/server.js
index 3f4cf4c..c44c695 100644
--- a/server.js
+++ b/server.js
@@ -12,6 +12,7 @@ const adminConfig = require('./services/adminConfig');
const { readNotificationSettings, writeNotificationSettings } = require('./services/userSettingsStore');
const notificationService = require('./services/notificationService');
const { readStoreWatch, writeStoreWatch } = require('./services/storeWatchStore');
+const { readPreferences, writePreferences } = require('./services/userPreferencesStore');
const ONE_YEAR_MS = 365 * 24 * 60 * 60 * 1000;
const adminEmail = (process.env.ADMIN_EMAIL || '').toLowerCase();
@@ -463,6 +464,35 @@ app.post('/api/store-watch/subscriptions', requireAuth, (req, res) => {
res.json({ success: true, stores: persisted });
});
+app.get('/api/user/preferences', requireAuth, (req, res) => {
+ const preferences = readPreferences(req.session.profile.id);
+ res.json(preferences);
+});
+
+app.post('/api/user/preferences/location', requireAuth, (req, res) => {
+ const { lat, lon } = req.body || {};
+ if (lat === null || lon === null || lat === undefined || lon === undefined) {
+ const updated = writePreferences(req.session.profile.id, { location: null });
+ return res.json({ location: updated.location });
+ }
+ const parsedLat = Number(lat);
+ const parsedLon = Number(lon);
+ if (
+ !Number.isFinite(parsedLat) ||
+ !Number.isFinite(parsedLon) ||
+ parsedLat < -90 ||
+ parsedLat > 90 ||
+ parsedLon < -180 ||
+ parsedLon > 180
+ ) {
+ return res.status(400).json({ error: 'Ungültige Koordinaten' });
+ }
+ const updated = writePreferences(req.session.profile.id, {
+ location: { lat: parsedLat, lon: parsedLon }
+ });
+ res.json({ location: updated.location });
+});
+
app.get('/api/config', requireAuth, (req, res) => {
const config = readConfig(req.session.profile.id);
res.json(config);
diff --git a/services/userPreferencesStore.js b/services/userPreferencesStore.js
new file mode 100644
index 0000000..ed1bc23
--- /dev/null
+++ b/services/userPreferencesStore.js
@@ -0,0 +1,75 @@
+const fs = require('fs');
+const path = require('path');
+
+const PREF_DIR = path.join(__dirname, '..', 'config');
+
+const DEFAULT_PREFERENCES = {
+ location: null
+};
+
+function ensureDir() {
+ if (!fs.existsSync(PREF_DIR)) {
+ fs.mkdirSync(PREF_DIR, { recursive: true });
+ }
+}
+
+function getPreferencesPath(profileId = 'shared') {
+ return path.join(PREF_DIR, `${profileId}-preferences.json`);
+}
+
+function sanitizeLocation(location) {
+ if (!location) {
+ return null;
+ }
+ const lat = Number(location.lat);
+ const lon = Number(location.lon);
+ if (!Number.isFinite(lat) || !Number.isFinite(lon)) {
+ return null;
+ }
+ if (lat < -90 || lat > 90 || lon < -180 || lon > 180) {
+ return null;
+ }
+ return {
+ lat,
+ lon,
+ updatedAt: Date.now()
+ };
+}
+
+function readPreferences(profileId) {
+ ensureDir();
+ const filePath = getPreferencesPath(profileId);
+ if (!fs.existsSync(filePath)) {
+ fs.writeFileSync(filePath, JSON.stringify(DEFAULT_PREFERENCES, null, 2));
+ return { ...DEFAULT_PREFERENCES };
+ }
+ try {
+ const raw = fs.readFileSync(filePath, 'utf8');
+ const parsed = JSON.parse(raw);
+ return {
+ location: sanitizeLocation(parsed.location) || null
+ };
+ } catch (error) {
+ console.error(`[PREFERENCES] Konnte Datei ${filePath} nicht lesen:`, error.message);
+ return { ...DEFAULT_PREFERENCES };
+ }
+}
+
+function writePreferences(profileId, patch = {}) {
+ const current = readPreferences(profileId);
+ const next = {
+ location:
+ patch.location === undefined
+ ? current.location
+ : sanitizeLocation(patch.location)
+ };
+ ensureDir();
+ const filePath = getPreferencesPath(profileId);
+ fs.writeFileSync(filePath, JSON.stringify(next, null, 2));
+ return next;
+}
+
+module.exports = {
+ readPreferences,
+ writePreferences
+};
diff --git a/src/App.js b/src/App.js
index 6e44ab1..d7998d5 100644
--- a/src/App.js
+++ b/src/App.js
@@ -12,6 +12,7 @@ import useStoreSync from './hooks/useStoreSync';
import useDirtyNavigationGuard from './hooks/useDirtyNavigationGuard';
import useSessionManager from './hooks/useSessionManager';
import useAdminSettings from './hooks/useAdminSettings';
+import useUserPreferences from './hooks/useUserPreferences';
import NavigationTabs from './components/NavigationTabs';
import LoginView from './components/LoginView';
import DashboardView from './components/DashboardView';
@@ -116,6 +117,17 @@ function App() {
setLoading
});
+ const {
+ preferences,
+ loading: preferencesLoading,
+ saving: locationSaving,
+ error: preferencesError,
+ updateLocation
+ } = useUserPreferences({
+ authorizedFetch,
+ sessionToken: session?.token
+ });
+
const {
fetchConfig,
syncStoresWithProgress,
@@ -667,6 +679,11 @@ function App() {
canDelete={Boolean(session?.isAdmin)}
focusedStoreId={focusedStoreId}
onClearFocus={() => setFocusedStoreId(null)}
+ userLocation={preferences?.location || null}
+ locationLoading={preferencesLoading}
+ locationSaving={locationSaving}
+ locationError={preferencesError}
+ onUpdateLocation={updateLocation}
/>
);
@@ -696,7 +713,16 @@ function App() {
- } />
+
+ }
+ />
} />
diff --git a/src/components/DashboardView.js b/src/components/DashboardView.js
index cb0a766..fbd66e1 100644
--- a/src/components/DashboardView.js
+++ b/src/components/DashboardView.js
@@ -1,4 +1,4 @@
-import { useEffect, useMemo } from 'react';
+import { useCallback, useEffect, useMemo, useState } from 'react';
import NotificationPanel from './NotificationPanel';
const DashboardView = ({
@@ -31,7 +31,12 @@ const DashboardView = ({
onDeleteEntry,
canDelete,
focusedStoreId,
- onClearFocus
+ onClearFocus,
+ userLocation,
+ locationLoading,
+ locationSaving,
+ locationError,
+ onUpdateLocation
}) => {
useEffect(() => {
if (!focusedStoreId) {
@@ -53,6 +58,44 @@ const DashboardView = ({
row.classList.remove('dashboard-row-highlight', 'ring-4', 'ring-blue-400');
};
}, [focusedStoreId, onClearFocus]);
+ const [geoBusy, setGeoBusy] = useState(false);
+ const [geoError, setGeoError] = useState('');
+
+ const handleDetectLocation = useCallback(() => {
+ if (!navigator.geolocation) {
+ setGeoError('Standortbestimmung wird von diesem Browser nicht unterstützt.');
+ return;
+ }
+ setGeoBusy(true);
+ setGeoError('');
+ navigator.geolocation.getCurrentPosition(
+ async (position) => {
+ const lat = position.coords.latitude;
+ const lon = position.coords.longitude;
+ await onUpdateLocation?.({ lat, lon });
+ setGeoBusy(false);
+ },
+ (error) => {
+ setGeoBusy(false);
+ setGeoError(
+ error.code === error.PERMISSION_DENIED
+ ? 'Zugriff auf den Standort wurde verweigert.'
+ : 'Standort konnte nicht ermittelt werden.'
+ );
+ },
+ {
+ enableHighAccuracy: true,
+ timeout: 10000,
+ maximumAge: 0
+ }
+ );
+ }, [onUpdateLocation]);
+
+ const handleClearLocation = useCallback(async () => {
+ setGeoError('');
+ await onUpdateLocation?.(null);
+ }, [onUpdateLocation]);
+
const {
error: notificationError,
message: notificationMessage,
@@ -229,10 +272,56 @@ const DashboardView = ({
{status && (
{status}
-
- )}
+
+ )}
-
+
+
+
+
Standort für Entfernungssortierung
+
+ Damit die Monitoring-Liste nach Entfernung sortieren kann, hinterlege hier deinen Standort.
+
+
+
+
+ {userLocation && (
+
+ )}
+
+
+
+ {locationLoading ? (
+
Standort wird geladen...
+ ) : userLocation ? (
+
+ Aktueller Standort: {userLocation.lat.toFixed(4)}, {userLocation.lon.toFixed(4)} (
+ {userLocation.updatedAt ? new Date(userLocation.updatedAt).toLocaleString('de-DE') : 'Zeit unbekannt'})
+
+ ) : (
+
Kein Standort hinterlegt.
+ )}
+
+ {(locationError || geoError) && (
+
{locationError || geoError}
+ )}
+
+
+
diff --git a/src/components/StoreWatchPage.js b/src/components/StoreWatchPage.js
index 4b5c9c5..1ea417f 100644
--- a/src/components/StoreWatchPage.js
+++ b/src/components/StoreWatchPage.js
@@ -7,6 +7,7 @@ import {
getSortedRowModel,
useReactTable
} from '@tanstack/react-table';
+import { haversineDistanceKm } from '../utils/distance';
const columnHelper = createColumnHelper();
@@ -45,7 +46,7 @@ const ColumnSelectFilter = ({ column, options }) => {
);
};
-const StoreWatchPage = ({ authorizedFetch, knownStores = [] }) => {
+const StoreWatchPage = ({ authorizedFetch, knownStores = [], userLocation }) => {
const [regions, setRegions] = useState([]);
const [selectedRegionId, setSelectedRegionId] = useState('');
const [storesByRegion, setStoresByRegion] = useState({});
@@ -65,18 +66,34 @@ const StoreWatchPage = ({ authorizedFetch, knownStores = [] }) => {
[watchList]
);
- const selectedRegion = useMemo(
- () => regions.find((region) => String(region.id) === String(selectedRegionId)) || null,
- [regions, selectedRegionId]
- );
+ const selectedRegion = useMemo(() => {
+ if (selectedRegionId === 'all') {
+ return null;
+ }
+ return regions.find((region) => String(region.id) === String(selectedRegionId)) || null;
+ }, [regions, selectedRegionId]);
const currentStores = useMemo(() => {
+ if (selectedRegionId === 'all') {
+ const combined = new Map();
+ regions.forEach((region) => {
+ const entry = storesByRegion[String(region.id)];
+ if (entry?.stores) {
+ entry.stores.forEach((store) => {
+ if (store?.id) {
+ combined.set(String(store.id), store);
+ }
+ });
+ }
+ });
+ return Array.from(combined.values());
+ }
const regionEntry = storesByRegion[String(selectedRegionId)];
if (!regionEntry || !Array.isArray(regionEntry.stores)) {
return [];
}
return regionEntry.stores;
- }, [storesByRegion, selectedRegionId]);
+ }, [regions, storesByRegion, selectedRegionId]);
const eligibleStores = useMemo(
() => currentStores.filter((store) => Number(store.cooperationStatus) === 5),
@@ -95,11 +112,21 @@ const StoreWatchPage = ({ authorizedFetch, knownStores = [] }) => {
const tableData = useMemo(
() =>
- eligibleStores.map((store) => ({
- ...store,
- membership: membershipMap.has(String(store.id))
- })),
- [eligibleStores, membershipMap]
+ eligibleStores.map((store) => {
+ const membership = membershipMap.has(String(store.id));
+ const lat = Number(store.location?.lat);
+ const lon = Number(store.location?.lon);
+ const distance =
+ userLocation && Number.isFinite(lat) && Number.isFinite(lon)
+ ? haversineDistanceKm(userLocation.lat, userLocation.lon, lat, lon)
+ : null;
+ return {
+ ...store,
+ membership,
+ distanceKm: distance
+ };
+ }),
+ [eligibleStores, membershipMap, userLocation]
);
const columns = useMemo(
@@ -238,6 +265,46 @@ const StoreWatchPage = ({ authorizedFetch, knownStores = [] }) => {
return Number(b) - Number(a);
}
}),
+ columnHelper.accessor('distanceKm', {
+ header: ({ column }) => (
+
+
+ {!userLocation &&
Standort erforderlich
}
+
+ ),
+ cell: ({ getValue }) => {
+ const value = getValue();
+ if (!value && value !== 0) {
+ return –;
+ }
+ return {value.toFixed(2)} km;
+ },
+ sortingFn: (rowA, rowB, columnId) => {
+ const a = rowA.getValue(columnId);
+ const b = rowB.getValue(columnId);
+ if (a === null || a === undefined) {
+ return 1;
+ }
+ if (b === null || b === undefined) {
+ return -1;
+ }
+ return a - b;
+ },
+ enableColumnFilter: false,
+ enableSorting: !!userLocation
+ }),
columnHelper.display({
id: 'watch',
header: () => Überwachen,
@@ -257,7 +324,7 @@ const StoreWatchPage = ({ authorizedFetch, knownStores = [] }) => {
}
})
],
- [handleToggleStore, watchedIds]
+ [handleToggleStore, watchedIds, userLocation]
);
const table = useReactTable({
@@ -321,14 +388,16 @@ const StoreWatchPage = ({ authorizedFetch, knownStores = [] }) => {
}, [authorizedFetch]);
const fetchStoresForRegion = useCallback(
- async (regionId, { force } = {}) => {
+ async (regionId, { force, silent } = {}) => {
if (!authorizedFetch || !regionId) {
return;
}
if (!force && storesByRegion[String(regionId)]) {
return;
}
- setStoresLoading(true);
+ if (!silent) {
+ setStoresLoading(true);
+ }
setError('');
try {
const endpoint = force
@@ -350,22 +419,55 @@ const StoreWatchPage = ({ authorizedFetch, knownStores = [] }) => {
} catch (err) {
setError(`Betriebe konnten nicht geladen werden: ${err.message}`);
} finally {
- setStoresLoading(false);
+ if (!silent) {
+ setStoresLoading(false);
+ }
}
},
[authorizedFetch, storesByRegion]
);
+ const fetchAllRegions = useCallback(
+ async ({ force } = {}) => {
+ if (!authorizedFetch || regions.length === 0) {
+ return;
+ }
+ const targets = regions.filter(
+ (region) => force || !storesByRegion[String(region.id)]
+ );
+ if (targets.length === 0) {
+ return;
+ }
+ setStoresLoading(true);
+ setError('');
+ try {
+ for (const region of targets) {
+ await fetchStoresForRegion(region.id, { force, silent: true });
+ }
+ } catch (err) {
+ setError(`Betriebe konnten nicht geladen werden: ${err.message}`);
+ } finally {
+ setStoresLoading(false);
+ }
+ },
+ [authorizedFetch, regions, storesByRegion, fetchStoresForRegion]
+ );
+
useEffect(() => {
loadRegions();
loadSubscriptions();
}, [loadRegions, loadSubscriptions]);
useEffect(() => {
- if (selectedRegionId) {
+ if (!selectedRegionId) {
+ return;
+ }
+ if (selectedRegionId === 'all') {
+ fetchAllRegions({ force: false });
+ } else {
fetchStoresForRegion(selectedRegionId);
}
- }, [selectedRegionId, fetchStoresForRegion]);
+ }, [selectedRegionId, fetchStoresForRegion, fetchAllRegions]);
const handleToggleStore = useCallback(
(store, checked) => {
@@ -486,6 +588,7 @@ const StoreWatchPage = ({ authorizedFetch, knownStores = [] }) => {
disabled={regionLoading}
>
+
{regions.map((region) => (