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) => (