minor changes
This commit is contained in:
131
server.js
131
server.js
@@ -27,6 +27,34 @@ const regionStoreCache = new Map();
|
|||||||
const REGION_STORE_CACHE_MS = 15 * 60 * 1000;
|
const REGION_STORE_CACHE_MS = 15 * 60 * 1000;
|
||||||
const STORE_STATUS_MAX_AGE_MS = 60 * 24 * 60 * 60 * 1000;
|
const STORE_STATUS_MAX_AGE_MS = 60 * 24 * 60 * 60 * 1000;
|
||||||
const storeStatusCache = new Map();
|
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() {
|
(function bootstrapStoreStatusCache() {
|
||||||
try {
|
try {
|
||||||
@@ -145,6 +173,91 @@ function getCachedStoreStatus(storeId) {
|
|||||||
return storeStatusCache.get(String(storeId)) || null;
|
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()) {
|
async function notifyWatchersForStatusChanges(changes = [], storeInfoMap = new Map()) {
|
||||||
if (!Array.isArray(changes) || changes.length === 0) {
|
if (!Array.isArray(changes) || changes.length === 0) {
|
||||||
return;
|
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) => {
|
app.get('/api/store-watch/regions', requireAuth, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const details = await foodsharingClient.fetchProfile(req.session.cookieHeader);
|
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)
|
.filter((store) => Number(store.cooperationStatus) === 5)
|
||||||
.map((store) => ({ ...store, id: String(store.id) }));
|
.map((store) => ({ ...store, id: String(store.id) }));
|
||||||
|
|
||||||
|
ingestStoreLocations(filteredStores);
|
||||||
|
|
||||||
const { stores: enrichedStores, statusMeta } = await enrichStoresWithTeamStatus(
|
const { stores: enrichedStores, statusMeta } = await enrichStoresWithTeamStatus(
|
||||||
filteredStores,
|
filteredStores,
|
||||||
req.session.cookieHeader,
|
req.session.cookieHeader,
|
||||||
|
|||||||
68
src/App.js
68
src/App.js
@@ -5,7 +5,6 @@ import './App.css';
|
|||||||
import 'react-date-range/dist/styles.css';
|
import 'react-date-range/dist/styles.css';
|
||||||
import 'react-date-range/dist/theme/default.css';
|
import 'react-date-range/dist/theme/default.css';
|
||||||
import { formatDateValue, formatRangeLabel } from './utils/dateUtils';
|
import { formatDateValue, formatRangeLabel } from './utils/dateUtils';
|
||||||
import { inferLocationLabel } from './utils/locationLabel';
|
|
||||||
import useSyncProgress from './hooks/useSyncProgress';
|
import useSyncProgress from './hooks/useSyncProgress';
|
||||||
import useNotificationSettings from './hooks/useNotificationSettings';
|
import useNotificationSettings from './hooks/useNotificationSettings';
|
||||||
import useConfigManager from './hooks/useConfigManager';
|
import useConfigManager from './hooks/useConfigManager';
|
||||||
@@ -37,6 +36,7 @@ function App() {
|
|||||||
const [confirmDialog, setConfirmDialog] = useState({ open: false, resolve: null });
|
const [confirmDialog, setConfirmDialog] = useState({ open: false, resolve: null });
|
||||||
const [notificationPanelOpen, setNotificationPanelOpen] = useState(false);
|
const [notificationPanelOpen, setNotificationPanelOpen] = useState(false);
|
||||||
const [focusedStoreId, setFocusedStoreId] = useState(null);
|
const [focusedStoreId, setFocusedStoreId] = useState(null);
|
||||||
|
const [nearestStoreLabel, setNearestStoreLabel] = useState(null);
|
||||||
const minSelectableDate = useMemo(() => startOfDay(new Date()), []);
|
const minSelectableDate = useMemo(() => startOfDay(new Date()), []);
|
||||||
|
|
||||||
const weekdays = ['Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag', 'Sonntag'];
|
const weekdays = ['Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag', 'Sonntag'];
|
||||||
@@ -147,6 +147,51 @@ function App() {
|
|||||||
finishSyncProgress
|
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 {
|
const {
|
||||||
adminSettings,
|
adminSettings,
|
||||||
adminSettingsLoading,
|
adminSettingsLoading,
|
||||||
@@ -595,17 +640,18 @@ function App() {
|
|||||||
if (!preferences?.location) {
|
if (!preferences?.location) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const info = inferLocationLabel(preferences.location, stores);
|
if (preferences.location.label) {
|
||||||
if (!info) {
|
return preferences.location;
|
||||||
return { ...preferences.location };
|
|
||||||
}
|
}
|
||||||
return {
|
if (nearestStoreLabel) {
|
||||||
...preferences.location,
|
return {
|
||||||
label: info.label,
|
...preferences.location,
|
||||||
labelDistanceKm: info.distanceKm,
|
label: nearestStoreLabel.label,
|
||||||
labelWithinRange: info.withinRange !== false
|
labelDistanceKm: nearestStoreLabel.distanceKm
|
||||||
};
|
};
|
||||||
}, [preferences?.location, stores]);
|
}
|
||||||
|
return { ...preferences.location };
|
||||||
|
}, [preferences?.location, nearestStoreLabel]);
|
||||||
|
|
||||||
const sharedNotificationProps = {
|
const sharedNotificationProps = {
|
||||||
error: notificationError,
|
error: notificationError,
|
||||||
|
|||||||
Reference in New Issue
Block a user