diff --git a/server.js b/server.js index c58a55e..2ded18c 100644 --- a/server.js +++ b/server.js @@ -27,6 +27,34 @@ const regionStoreCache = new Map(); const REGION_STORE_CACHE_MS = 15 * 60 * 1000; const STORE_STATUS_MAX_AGE_MS = 60 * 24 * 60 * 60 * 1000; const storeStatusCache = new Map(); +const storeLocationIndex = new Map(); +let storeLocationIndexUpdatedAt = 0; +const STORE_LOCATION_INDEX_TTL_MS = 12 * 60 * 60 * 1000; + +function toRadians(value) { + return (value * Math.PI) / 180; +} + +function haversineDistanceKm(lat1, lon1, lat2, lon2) { + if ( + !Number.isFinite(lat1) || + !Number.isFinite(lon1) || + !Number.isFinite(lat2) || + !Number.isFinite(lon2) + ) { + return null; + } + const R = 6371; + const dLat = toRadians(lat2 - lat1); + const dLon = toRadians(lon2 - lon1); + const radLat1 = toRadians(lat1); + const radLat2 = toRadians(lat2); + const a = + Math.sin(dLat / 2) * Math.sin(dLat / 2) + + Math.cos(radLat1) * Math.cos(radLat2) * Math.sin(dLon / 2) * Math.sin(dLon / 2); + const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); + return R * c; +} (function bootstrapStoreStatusCache() { try { @@ -145,6 +173,91 @@ function getCachedStoreStatus(storeId) { return storeStatusCache.get(String(storeId)) || null; } +function ingestStoreLocations(stores = []) { + let changed = false; + stores.forEach((store) => { + const lat = Number(store?.location?.lat); + const lon = Number(store?.location?.lon); + if (!Number.isFinite(lat) || !Number.isFinite(lon)) { + return; + } + const storeId = String(store.id); + const entry = { + storeId, + lat, + lon, + name: store.name || `Store ${storeId}`, + city: store.city || '', + regionName: store.region?.name || '', + label: + store.city && store.region?.name && store.city.toLowerCase() !== store.region.name.toLowerCase() + ? `${store.city} • ${store.region.name}` + : store.city || store.region?.name || store.name || `Store ${storeId}` + }; + storeLocationIndex.set(storeId, entry); + changed = true; + }); + if (changed) { + storeLocationIndexUpdatedAt = Date.now(); + } +} + +async function ensureStoreLocationIndex(session, { force = false } = {}) { + if (!session?.cookieHeader) { + throw new Error('Keine gültige Session für Standortbestimmung verfügbar.'); + } + const fresh = Date.now() - storeLocationIndexUpdatedAt < STORE_LOCATION_INDEX_TTL_MS; + if (!force && storeLocationIndex.size > 0 && fresh) { + return; + } + const details = await foodsharingClient.fetchProfile(session.cookieHeader); + const regions = Array.isArray(details?.regions) + ? details.regions.filter((region) => Number(region?.classification) === 1) + : []; + if (regions.length === 0) { + return; + } + for (const region of regions) { + let payload = getCachedRegionStores(region.id); + if (!payload) { + const result = await foodsharingClient.fetchRegionStores(region.id, session.cookieHeader); + payload = { + total: Number(result?.total) || 0, + stores: Array.isArray(result?.stores) ? result.stores : [] + }; + setCachedRegionStores(region.id, payload); + } + const filtered = payload.stores + .filter((store) => Number(store.cooperationStatus) === 5) + .map((store) => ({ ...store, id: String(store.id) })); + ingestStoreLocations(filtered); + } +} + +function findNearestStoreLocation(lat, lon) { + if (storeLocationIndex.size === 0) { + return null; + } + let closest = null; + storeLocationIndex.forEach((entry) => { + const distance = haversineDistanceKm(lat, lon, entry.lat, entry.lon); + if (distance === null) { + return; + } + if (!closest || distance < closest.distanceKm) { + closest = { + storeId: entry.storeId, + label: entry.label, + name: entry.name, + city: entry.city, + regionName: entry.regionName, + distanceKm: distance + }; + } + }); + return closest; +} + async function notifyWatchersForStatusChanges(changes = [], storeInfoMap = new Map()) { if (!Array.isArray(changes) || changes.length === 0) { return; @@ -580,6 +693,22 @@ app.get('/api/profile', requireAuth, async (req, res) => { }); }); +app.get('/api/location/nearest-store', requireAuth, async (req, res) => { + const lat = Number(req.query.lat); + const lon = Number(req.query.lon); + if (!Number.isFinite(lat) || !Number.isFinite(lon)) { + return res.status(400).json({ error: 'Ungültige Koordinaten' }); + } + try { + await ensureStoreLocationIndex(req.session); + const store = findNearestStoreLocation(lat, lon); + res.json({ store }); + } catch (error) { + console.error('[LOCATION] Reverse Lookup fehlgeschlagen:', error.message); + res.status(500).json({ error: 'Ort konnte nicht bestimmt werden' }); + } +}); + app.get('/api/store-watch/regions', requireAuth, async (req, res) => { try { const details = await foodsharingClient.fetchProfile(req.session.cookieHeader); @@ -627,6 +756,8 @@ app.get('/api/store-watch/regions/:regionId/stores', requireAuth, async (req, re .filter((store) => Number(store.cooperationStatus) === 5) .map((store) => ({ ...store, id: String(store.id) })); + ingestStoreLocations(filteredStores); + const { stores: enrichedStores, statusMeta } = await enrichStoresWithTeamStatus( filteredStores, req.session.cookieHeader, diff --git a/src/App.js b/src/App.js index edae5fe..8b6985e 100644 --- a/src/App.js +++ b/src/App.js @@ -5,7 +5,6 @@ import './App.css'; import 'react-date-range/dist/styles.css'; import 'react-date-range/dist/theme/default.css'; import { formatDateValue, formatRangeLabel } from './utils/dateUtils'; -import { inferLocationLabel } from './utils/locationLabel'; import useSyncProgress from './hooks/useSyncProgress'; import useNotificationSettings from './hooks/useNotificationSettings'; import useConfigManager from './hooks/useConfigManager'; @@ -37,6 +36,7 @@ function App() { const [confirmDialog, setConfirmDialog] = useState({ open: false, resolve: null }); const [notificationPanelOpen, setNotificationPanelOpen] = useState(false); const [focusedStoreId, setFocusedStoreId] = useState(null); + const [nearestStoreLabel, setNearestStoreLabel] = useState(null); const minSelectableDate = useMemo(() => startOfDay(new Date()), []); const weekdays = ['Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag', 'Sonntag']; @@ -147,6 +147,51 @@ function App() { finishSyncProgress }); + useEffect(() => { + let aborted = false; + async function lookupNearestStore() { + if ( + !authorizedFetch || + !preferences?.location || + !Number.isFinite(preferences.location.lat) || + !Number.isFinite(preferences.location.lon) + ) { + setNearestStoreLabel(null); + return; + } + try { + const params = new URLSearchParams({ + lat: String(preferences.location.lat), + lon: String(preferences.location.lon) + }); + const response = await authorizedFetch(`/api/location/nearest-store?${params.toString()}`); + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + const data = await response.json(); + if (!aborted) { + setNearestStoreLabel( + data.store + ? { + label: data.store.label, + distanceKm: data.store.distanceKm + } + : null + ); + } + } catch (error) { + if (!aborted) { + setNearestStoreLabel(null); + console.error('Standortsuche fehlgeschlagen:', error.message); + } + } + } + lookupNearestStore(); + return () => { + aborted = true; + }; + }, [authorizedFetch, preferences?.location?.lat, preferences?.location?.lon]); + const { adminSettings, adminSettingsLoading, @@ -595,17 +640,18 @@ function App() { if (!preferences?.location) { return null; } - const info = inferLocationLabel(preferences.location, stores); - if (!info) { - return { ...preferences.location }; + if (preferences.location.label) { + return preferences.location; } - return { - ...preferences.location, - label: info.label, - labelDistanceKm: info.distanceKm, - labelWithinRange: info.withinRange !== false - }; - }, [preferences?.location, stores]); + if (nearestStoreLabel) { + return { + ...preferences.location, + label: nearestStoreLabel.label, + labelDistanceKm: nearestStoreLabel.distanceKm + }; + } + return { ...preferences.location }; + }, [preferences?.location, nearestStoreLabel]); const sharedNotificationProps = { error: notificationError,