diff --git a/server.js b/server.js index dec8d6a..e84a8b7 100644 --- a/server.js +++ b/server.js @@ -105,7 +105,9 @@ async function restoreSessionsFromDisk() { }; const isAdminUser = isAdmin(profile); let config = readConfig(profile.id); - const stores = await foodsharingClient.fetchStores(auth.cookieHeader, profile.id); + const stores = await foodsharingClient.fetchStores(auth.cookieHeader, profile.id, { + delayBetweenRequestsMs: schedulerSettings.storePickupCheckDelayMs + }); const { merged, changed } = mergeStoresIntoConfig(config, stores); if (changed) { config = merged; @@ -169,9 +171,12 @@ app.post('/api/auth/login', async (req, res) => { email: auth.profile.email || email }; const isAdminUser = isAdmin(profile); + const settings = adminConfig.readSettings(); let config = readConfig(profile.id); - const stores = await foodsharingClient.fetchStores(auth.cookieHeader, profile.id); + const stores = await foodsharingClient.fetchStores(auth.cookieHeader, profile.id, { + delayBetweenRequestsMs: settings.storePickupCheckDelayMs + }); const { merged, changed } = mergeStoresIntoConfig(config, stores); if (changed) { config = merged; @@ -193,7 +198,6 @@ app.post('/api/auth/login', async (req, res) => { }, existingToken, ONE_YEAR_MS); credentialStore.save(profile.id, { email, password, token: session.id }); - const settings = adminConfig.readSettings(); scheduleConfig(session.id, config, settings); return res.json({ @@ -217,7 +221,10 @@ app.post('/api/auth/logout', requireAuth, (req, res) => { }); app.get('/api/auth/session', requireAuth, async (req, res) => { - const stores = await foodsharingClient.fetchStores(req.session.cookieHeader, req.session.profile.id); + const settings = adminConfig.readSettings(); + const stores = await foodsharingClient.fetchStores(req.session.cookieHeader, req.session.profile.id, { + delayBetweenRequestsMs: settings.storePickupCheckDelayMs + }); let config = readConfig(req.session.profile.id); const { merged, changed } = mergeStoresIntoConfig(config, stores); if (changed) { @@ -228,7 +235,7 @@ app.get('/api/auth/session', requireAuth, async (req, res) => { profile: req.session.profile, stores, isAdmin: !!req.session.isAdmin, - adminSettings: req.session.isAdmin ? adminConfig.readSettings() : undefined + adminSettings: req.session.isAdmin ? settings : undefined }); }); @@ -254,7 +261,10 @@ app.post('/api/config', requireAuth, (req, res) => { }); app.get('/api/stores', requireAuth, async (req, res) => { - const stores = await foodsharingClient.fetchStores(req.session.cookieHeader, req.session.profile.id); + const settings = adminConfig.readSettings(); + const stores = await foodsharingClient.fetchStores(req.session.cookieHeader, req.session.profile.id, { + delayBetweenRequestsMs: settings.storePickupCheckDelayMs + }); let config = readConfig(req.session.profile.id); const { merged, changed } = mergeStoresIntoConfig(config, stores); if (changed) { diff --git a/services/adminConfig.js b/services/adminConfig.js index 4bfaf8d..7038a5e 100644 --- a/services/adminConfig.js +++ b/services/adminConfig.js @@ -10,6 +10,7 @@ const DEFAULT_SETTINGS = { randomDelayMaxSeconds: 120, initialDelayMinSeconds: 5, initialDelayMaxSeconds: 30, + storePickupCheckDelayMs: 400, ignoredSlots: [ { storeId: '51450', @@ -60,6 +61,10 @@ function readSettings() { randomDelayMaxSeconds: sanitizeNumber(parsed.randomDelayMaxSeconds, DEFAULT_SETTINGS.randomDelayMaxSeconds), initialDelayMinSeconds: sanitizeNumber(parsed.initialDelayMinSeconds, DEFAULT_SETTINGS.initialDelayMinSeconds), initialDelayMaxSeconds: sanitizeNumber(parsed.initialDelayMaxSeconds, DEFAULT_SETTINGS.initialDelayMaxSeconds), + storePickupCheckDelayMs: sanitizeNumber( + parsed.storePickupCheckDelayMs, + DEFAULT_SETTINGS.storePickupCheckDelayMs + ), ignoredSlots: sanitizeIgnoredSlots(parsed.ignoredSlots) }; } catch (error) { @@ -76,6 +81,10 @@ function writeSettings(patch = {}) { randomDelayMaxSeconds: sanitizeNumber(patch.randomDelayMaxSeconds, current.randomDelayMaxSeconds), initialDelayMinSeconds: sanitizeNumber(patch.initialDelayMinSeconds, current.initialDelayMinSeconds), initialDelayMaxSeconds: sanitizeNumber(patch.initialDelayMaxSeconds, current.initialDelayMaxSeconds), + storePickupCheckDelayMs: sanitizeNumber( + patch.storePickupCheckDelayMs, + current.storePickupCheckDelayMs + ), ignoredSlots: patch.ignoredSlots !== undefined ? sanitizeIgnoredSlots(patch.ignoredSlots) diff --git a/services/foodsharingClient.js b/services/foodsharingClient.js index bf2615a..6aaadb3 100644 --- a/services/foodsharingClient.js +++ b/services/foodsharingClient.js @@ -115,10 +115,17 @@ async function fetchProfile(cookieHeader) { } } -async function fetchStores(cookieHeader, profileId) { +function wait(ms = 0) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function fetchStores(cookieHeader, profileId, options = {}) { if (!profileId) { return []; } + const delayBetweenRequestsMs = Number.isFinite(options.delayBetweenRequestsMs) + ? Math.max(0, options.delayBetweenRequestsMs) + : 0; try { const response = await client.get(`/api/user/${profileId}/stores`, { headers: buildHeaders(cookieHeader), @@ -136,42 +143,37 @@ async function fetchStores(cookieHeader, profileId) { zip: store.zip || '' })); - return annotateStoresWithPickupSlots(normalized, cookieHeader); + return annotateStoresWithPickupSlots(normalized, cookieHeader, delayBetweenRequestsMs); } catch (error) { console.warn('Stores konnten nicht geladen werden:', error.message); return []; } } -async function annotateStoresWithPickupSlots(stores, cookieHeader, concurrency = 5) { +async function annotateStoresWithPickupSlots(stores, cookieHeader, delayBetweenRequestsMs = 0) { if (!Array.isArray(stores) || stores.length === 0) { return []; } - const cappedConcurrency = Math.max(1, Math.min(concurrency, stores.length)); - const results = new Array(stores.length); - let pointer = 0; + const delayMs = Number.isFinite(delayBetweenRequestsMs) ? Math.max(0, delayBetweenRequestsMs) : 0; + const annotated = []; - async function worker() { - while (true) { - const currentIndex = pointer++; - if (currentIndex >= stores.length) { - return; - } - const store = stores[currentIndex]; - let hasPickupSlots = null; - try { - const pickups = await fetchPickups(store.id, cookieHeader); - hasPickupSlots = Array.isArray(pickups) && pickups.length > 0; - } catch (error) { - console.warn(`Pickups für Store ${store.id} konnten nicht geprüft werden:`, error.message); - } - results[currentIndex] = { ...store, hasPickupSlots }; + for (let index = 0; index < stores.length; index += 1) { + const store = stores[index]; + if (delayMs > 0 && index > 0) { + await wait(delayMs); } + let hasPickupSlots = null; + try { + const pickups = await fetchPickups(store.id, cookieHeader); + hasPickupSlots = Array.isArray(pickups) && pickups.length > 0; + } catch (error) { + console.warn(`Pickups für Store ${store.id} konnten nicht geprüft werden:`, error.message); + } + annotated.push({ ...store, hasPickupSlots }); } - await Promise.all(Array.from({ length: cappedConcurrency }, () => worker())); - return results; + return annotated; } async function fetchPickups(storeId, cookieHeader) { diff --git a/src/App.js b/src/App.js index 0a42ab4..3697cbb 100644 --- a/src/App.js +++ b/src/App.js @@ -25,9 +25,39 @@ function App() { const [availableCollapsed, setAvailableCollapsed] = useState(true); const [adminSettings, setAdminSettings] = useState(null); const [adminSettingsLoading, setAdminSettingsLoading] = useState(false); + const [syncProgress, setSyncProgress] = useState({ active: false, percent: 0, message: '', block: false }); const weekdays = ['Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag', 'Sonntag']; + const startSyncProgress = useCallback((message, percent, block = false) => { + setSyncProgress({ active: true, percent, message, block }); + }, []); + + const updateSyncProgress = useCallback((message, percent) => { + setSyncProgress((prev) => { + if (!prev.active) { + return prev; + } + return { + ...prev, + message: message || prev.message, + percent: Math.min(100, Math.max(percent, prev.percent)) + }; + }); + }, []); + + const finishSyncProgress = useCallback(() => { + setSyncProgress((prev) => { + if (!prev.active) { + return prev; + } + return { ...prev, percent: 100 }; + }); + setTimeout(() => { + setSyncProgress({ active: false, percent: 0, message: '', block: false }); + }, 400); + }, []); + const normalizeAdminSettings = useCallback((raw) => { if (!raw) { return null; @@ -38,6 +68,7 @@ function App() { randomDelayMaxSeconds: raw.randomDelayMaxSeconds ?? '', initialDelayMinSeconds: raw.initialDelayMinSeconds ?? '', initialDelayMaxSeconds: raw.initialDelayMaxSeconds ?? '', + storePickupCheckDelayMs: raw.storePickupCheckDelayMs ?? '', ignoredSlots: Array.isArray(raw.ignoredSlots) ? raw.ignoredSlots.map((slot) => ({ storeId: slot?.storeId ? String(slot.storeId) : '', @@ -70,9 +101,15 @@ function App() { }, [resetSessionState]); const bootstrapSession = useCallback( - async (token) => { + async (token, { withProgress = false } = {}) => { + if (!token) { + return; + } setLoading(true); setError(''); + if (withProgress) { + startSyncProgress('Session wird aufgebaut...', 10, true); + } try { const response = await fetch('/api/auth/session', { headers: { Authorization: `Bearer ${token}` } @@ -86,6 +123,9 @@ function App() { } const data = await response.json(); setSession({ token, profile: data.profile, isAdmin: data.isAdmin }); + if (withProgress) { + updateSyncProgress('Betriebe werden geprüft...', 45); + } setStores(Array.isArray(data.stores) ? data.stores : []); setAdminSettings(data.isAdmin ? normalizeAdminSettings(data.adminSettings) : null); @@ -100,14 +140,23 @@ function App() { throw new Error(`HTTP ${configResponse.status}`); } const configData = await configResponse.json(); + if (withProgress) { + updateSyncProgress('Konfiguration wird geladen...', 75); + } setConfig(Array.isArray(configData) ? configData : []); + if (withProgress) { + updateSyncProgress('Synchronisierung abgeschlossen', 95); + } } catch (err) { setError(`Session konnte nicht wiederhergestellt werden: ${err.message}`); } finally { setLoading(false); + if (withProgress) { + finishSyncProgress(); + } } }, - [handleUnauthorized, normalizeAdminSettings] + [handleUnauthorized, normalizeAdminSettings, startSyncProgress, updateSyncProgress, finishSyncProgress] ); useEffect(() => { @@ -200,10 +249,7 @@ function App() { } catch (storageError) { console.warn('Konnte Token nicht speichern:', storageError); } - setSession({ token: data.token, profile: data.profile, isAdmin: data.isAdmin }); - setConfig(Array.isArray(data.config) ? data.config : []); - setStores(Array.isArray(data.stores) ? data.stores : []); - setAdminSettings(data.isAdmin ? normalizeAdminSettings(data.adminSettings) : null); + await bootstrapSession(data.token, { withProgress: true }); setStatus('Anmeldung erfolgreich. Konfiguration geladen.'); setTimeout(() => setStatus(''), 3000); } catch (err) { @@ -276,6 +322,24 @@ function App() { } }; + const refreshStoresAndConfig = useCallback( + async ({ block = false } = {}) => { + if (!session?.token) { + return; + } + startSyncProgress('Betriebe werden geprüft...', 15, block); + try { + await fetchStoresList(); + updateSyncProgress('Konfiguration wird aktualisiert...', 70); + await fetchConfig(undefined, { silent: true }); + updateSyncProgress('Synchronisierung abgeschlossen', 95); + } finally { + finishSyncProgress(); + } + }, + [session?.token, fetchStoresList, fetchConfig, startSyncProgress, updateSyncProgress, finishSyncProgress] + ); + const saveConfig = async () => { if (!session?.token) { return; @@ -571,6 +635,7 @@ function App() { randomDelayMaxSeconds: toNumber(adminSettings.randomDelayMaxSeconds), initialDelayMinSeconds: toNumber(adminSettings.initialDelayMinSeconds), initialDelayMaxSeconds: toNumber(adminSettings.initialDelayMaxSeconds), + storePickupCheckDelayMs: toNumber(adminSettings.storePickupCheckDelayMs), ignoredSlots: (adminSettings.ignoredSlots || []).map((slot) => ({ storeId: slot.storeId || '', description: slot.description || '' @@ -672,7 +737,7 @@ function App() {
+
+ + handleAdminSettingChange('storePickupCheckDelayMs', e.target.value, true)} + className="border rounded p-2 w-full focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-purple-500" + placeholder="z. B. 400" + /> +

Hilft Rate-Limits beim Abfragen der Pickups zu vermeiden.

+
@@ -1166,16 +1244,19 @@ function App() { return ( -
-
- - - - - } /> - + <> +
+
+ + + + + } /> + +
-
+ + ); } @@ -1222,4 +1303,32 @@ function AdminAccessMessage() { ); } +function StoreSyncOverlay({ state }) { + if (!state?.active) { + return null; + } + const percent = Math.round(state.percent || 0); + const backgroundColor = state.block ? 'rgba(255,255,255,0.95)' : 'rgba(15,23,42,0.4)'; + return ( +
+
+

{state.message || 'Synchronisiere...'}

+
+
+
+
+ {percent}% + Bitte warten... +
+

+ Die Verzögerung schützt vor Rate-Limits während die Betriebe geprüft werden. +

+
+
+ ); +} + export default App;