aktueller Stand

This commit is contained in:
root
2025-11-09 15:04:29 +01:00
parent e90f9299c3
commit 685d50d56a
4 changed files with 176 additions and 46 deletions

View File

@@ -105,7 +105,9 @@ async function restoreSessionsFromDisk() {
}; };
const isAdminUser = isAdmin(profile); const isAdminUser = isAdmin(profile);
let config = readConfig(profile.id); 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); const { merged, changed } = mergeStoresIntoConfig(config, stores);
if (changed) { if (changed) {
config = merged; config = merged;
@@ -169,9 +171,12 @@ app.post('/api/auth/login', async (req, res) => {
email: auth.profile.email || email email: auth.profile.email || email
}; };
const isAdminUser = isAdmin(profile); const isAdminUser = isAdmin(profile);
const settings = adminConfig.readSettings();
let config = readConfig(profile.id); 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); const { merged, changed } = mergeStoresIntoConfig(config, stores);
if (changed) { if (changed) {
config = merged; config = merged;
@@ -193,7 +198,6 @@ app.post('/api/auth/login', async (req, res) => {
}, existingToken, ONE_YEAR_MS); }, existingToken, ONE_YEAR_MS);
credentialStore.save(profile.id, { email, password, token: session.id }); credentialStore.save(profile.id, { email, password, token: session.id });
const settings = adminConfig.readSettings();
scheduleConfig(session.id, config, settings); scheduleConfig(session.id, config, settings);
return res.json({ return res.json({
@@ -217,7 +221,10 @@ app.post('/api/auth/logout', requireAuth, (req, res) => {
}); });
app.get('/api/auth/session', requireAuth, async (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); let config = readConfig(req.session.profile.id);
const { merged, changed } = mergeStoresIntoConfig(config, stores); const { merged, changed } = mergeStoresIntoConfig(config, stores);
if (changed) { if (changed) {
@@ -228,7 +235,7 @@ app.get('/api/auth/session', requireAuth, async (req, res) => {
profile: req.session.profile, profile: req.session.profile,
stores, stores,
isAdmin: !!req.session.isAdmin, 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) => { 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); let config = readConfig(req.session.profile.id);
const { merged, changed } = mergeStoresIntoConfig(config, stores); const { merged, changed } = mergeStoresIntoConfig(config, stores);
if (changed) { if (changed) {

View File

@@ -10,6 +10,7 @@ const DEFAULT_SETTINGS = {
randomDelayMaxSeconds: 120, randomDelayMaxSeconds: 120,
initialDelayMinSeconds: 5, initialDelayMinSeconds: 5,
initialDelayMaxSeconds: 30, initialDelayMaxSeconds: 30,
storePickupCheckDelayMs: 400,
ignoredSlots: [ ignoredSlots: [
{ {
storeId: '51450', storeId: '51450',
@@ -60,6 +61,10 @@ function readSettings() {
randomDelayMaxSeconds: sanitizeNumber(parsed.randomDelayMaxSeconds, DEFAULT_SETTINGS.randomDelayMaxSeconds), randomDelayMaxSeconds: sanitizeNumber(parsed.randomDelayMaxSeconds, DEFAULT_SETTINGS.randomDelayMaxSeconds),
initialDelayMinSeconds: sanitizeNumber(parsed.initialDelayMinSeconds, DEFAULT_SETTINGS.initialDelayMinSeconds), initialDelayMinSeconds: sanitizeNumber(parsed.initialDelayMinSeconds, DEFAULT_SETTINGS.initialDelayMinSeconds),
initialDelayMaxSeconds: sanitizeNumber(parsed.initialDelayMaxSeconds, DEFAULT_SETTINGS.initialDelayMaxSeconds), initialDelayMaxSeconds: sanitizeNumber(parsed.initialDelayMaxSeconds, DEFAULT_SETTINGS.initialDelayMaxSeconds),
storePickupCheckDelayMs: sanitizeNumber(
parsed.storePickupCheckDelayMs,
DEFAULT_SETTINGS.storePickupCheckDelayMs
),
ignoredSlots: sanitizeIgnoredSlots(parsed.ignoredSlots) ignoredSlots: sanitizeIgnoredSlots(parsed.ignoredSlots)
}; };
} catch (error) { } catch (error) {
@@ -76,6 +81,10 @@ function writeSettings(patch = {}) {
randomDelayMaxSeconds: sanitizeNumber(patch.randomDelayMaxSeconds, current.randomDelayMaxSeconds), randomDelayMaxSeconds: sanitizeNumber(patch.randomDelayMaxSeconds, current.randomDelayMaxSeconds),
initialDelayMinSeconds: sanitizeNumber(patch.initialDelayMinSeconds, current.initialDelayMinSeconds), initialDelayMinSeconds: sanitizeNumber(patch.initialDelayMinSeconds, current.initialDelayMinSeconds),
initialDelayMaxSeconds: sanitizeNumber(patch.initialDelayMaxSeconds, current.initialDelayMaxSeconds), initialDelayMaxSeconds: sanitizeNumber(patch.initialDelayMaxSeconds, current.initialDelayMaxSeconds),
storePickupCheckDelayMs: sanitizeNumber(
patch.storePickupCheckDelayMs,
current.storePickupCheckDelayMs
),
ignoredSlots: ignoredSlots:
patch.ignoredSlots !== undefined patch.ignoredSlots !== undefined
? sanitizeIgnoredSlots(patch.ignoredSlots) ? sanitizeIgnoredSlots(patch.ignoredSlots)

View File

@@ -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) { if (!profileId) {
return []; return [];
} }
const delayBetweenRequestsMs = Number.isFinite(options.delayBetweenRequestsMs)
? Math.max(0, options.delayBetweenRequestsMs)
: 0;
try { try {
const response = await client.get(`/api/user/${profileId}/stores`, { const response = await client.get(`/api/user/${profileId}/stores`, {
headers: buildHeaders(cookieHeader), headers: buildHeaders(cookieHeader),
@@ -136,29 +143,26 @@ async function fetchStores(cookieHeader, profileId) {
zip: store.zip || '' zip: store.zip || ''
})); }));
return annotateStoresWithPickupSlots(normalized, cookieHeader); return annotateStoresWithPickupSlots(normalized, cookieHeader, delayBetweenRequestsMs);
} catch (error) { } catch (error) {
console.warn('Stores konnten nicht geladen werden:', error.message); console.warn('Stores konnten nicht geladen werden:', error.message);
return []; return [];
} }
} }
async function annotateStoresWithPickupSlots(stores, cookieHeader, concurrency = 5) { async function annotateStoresWithPickupSlots(stores, cookieHeader, delayBetweenRequestsMs = 0) {
if (!Array.isArray(stores) || stores.length === 0) { if (!Array.isArray(stores) || stores.length === 0) {
return []; return [];
} }
const cappedConcurrency = Math.max(1, Math.min(concurrency, stores.length)); const delayMs = Number.isFinite(delayBetweenRequestsMs) ? Math.max(0, delayBetweenRequestsMs) : 0;
const results = new Array(stores.length); const annotated = [];
let pointer = 0;
async function worker() { for (let index = 0; index < stores.length; index += 1) {
while (true) { const store = stores[index];
const currentIndex = pointer++; if (delayMs > 0 && index > 0) {
if (currentIndex >= stores.length) { await wait(delayMs);
return;
} }
const store = stores[currentIndex];
let hasPickupSlots = null; let hasPickupSlots = null;
try { try {
const pickups = await fetchPickups(store.id, cookieHeader); const pickups = await fetchPickups(store.id, cookieHeader);
@@ -166,12 +170,10 @@ async function annotateStoresWithPickupSlots(stores, cookieHeader, concurrency =
} catch (error) { } catch (error) {
console.warn(`Pickups für Store ${store.id} konnten nicht geprüft werden:`, error.message); console.warn(`Pickups für Store ${store.id} konnten nicht geprüft werden:`, error.message);
} }
results[currentIndex] = { ...store, hasPickupSlots }; annotated.push({ ...store, hasPickupSlots });
}
} }
await Promise.all(Array.from({ length: cappedConcurrency }, () => worker())); return annotated;
return results;
} }
async function fetchPickups(storeId, cookieHeader) { async function fetchPickups(storeId, cookieHeader) {

View File

@@ -25,9 +25,39 @@ function App() {
const [availableCollapsed, setAvailableCollapsed] = useState(true); const [availableCollapsed, setAvailableCollapsed] = useState(true);
const [adminSettings, setAdminSettings] = useState(null); const [adminSettings, setAdminSettings] = useState(null);
const [adminSettingsLoading, setAdminSettingsLoading] = useState(false); 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 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) => { const normalizeAdminSettings = useCallback((raw) => {
if (!raw) { if (!raw) {
return null; return null;
@@ -38,6 +68,7 @@ function App() {
randomDelayMaxSeconds: raw.randomDelayMaxSeconds ?? '', randomDelayMaxSeconds: raw.randomDelayMaxSeconds ?? '',
initialDelayMinSeconds: raw.initialDelayMinSeconds ?? '', initialDelayMinSeconds: raw.initialDelayMinSeconds ?? '',
initialDelayMaxSeconds: raw.initialDelayMaxSeconds ?? '', initialDelayMaxSeconds: raw.initialDelayMaxSeconds ?? '',
storePickupCheckDelayMs: raw.storePickupCheckDelayMs ?? '',
ignoredSlots: Array.isArray(raw.ignoredSlots) ignoredSlots: Array.isArray(raw.ignoredSlots)
? raw.ignoredSlots.map((slot) => ({ ? raw.ignoredSlots.map((slot) => ({
storeId: slot?.storeId ? String(slot.storeId) : '', storeId: slot?.storeId ? String(slot.storeId) : '',
@@ -70,9 +101,15 @@ function App() {
}, [resetSessionState]); }, [resetSessionState]);
const bootstrapSession = useCallback( const bootstrapSession = useCallback(
async (token) => { async (token, { withProgress = false } = {}) => {
if (!token) {
return;
}
setLoading(true); setLoading(true);
setError(''); setError('');
if (withProgress) {
startSyncProgress('Session wird aufgebaut...', 10, true);
}
try { try {
const response = await fetch('/api/auth/session', { const response = await fetch('/api/auth/session', {
headers: { Authorization: `Bearer ${token}` } headers: { Authorization: `Bearer ${token}` }
@@ -86,6 +123,9 @@ function App() {
} }
const data = await response.json(); const data = await response.json();
setSession({ token, profile: data.profile, isAdmin: data.isAdmin }); setSession({ token, profile: data.profile, isAdmin: data.isAdmin });
if (withProgress) {
updateSyncProgress('Betriebe werden geprüft...', 45);
}
setStores(Array.isArray(data.stores) ? data.stores : []); setStores(Array.isArray(data.stores) ? data.stores : []);
setAdminSettings(data.isAdmin ? normalizeAdminSettings(data.adminSettings) : null); setAdminSettings(data.isAdmin ? normalizeAdminSettings(data.adminSettings) : null);
@@ -100,14 +140,23 @@ function App() {
throw new Error(`HTTP ${configResponse.status}`); throw new Error(`HTTP ${configResponse.status}`);
} }
const configData = await configResponse.json(); const configData = await configResponse.json();
if (withProgress) {
updateSyncProgress('Konfiguration wird geladen...', 75);
}
setConfig(Array.isArray(configData) ? configData : []); setConfig(Array.isArray(configData) ? configData : []);
if (withProgress) {
updateSyncProgress('Synchronisierung abgeschlossen', 95);
}
} catch (err) { } catch (err) {
setError(`Session konnte nicht wiederhergestellt werden: ${err.message}`); setError(`Session konnte nicht wiederhergestellt werden: ${err.message}`);
} finally { } finally {
setLoading(false); setLoading(false);
if (withProgress) {
finishSyncProgress();
}
} }
}, },
[handleUnauthorized, normalizeAdminSettings] [handleUnauthorized, normalizeAdminSettings, startSyncProgress, updateSyncProgress, finishSyncProgress]
); );
useEffect(() => { useEffect(() => {
@@ -200,10 +249,7 @@ function App() {
} catch (storageError) { } catch (storageError) {
console.warn('Konnte Token nicht speichern:', storageError); console.warn('Konnte Token nicht speichern:', storageError);
} }
setSession({ token: data.token, profile: data.profile, isAdmin: data.isAdmin }); await bootstrapSession(data.token, { withProgress: true });
setConfig(Array.isArray(data.config) ? data.config : []);
setStores(Array.isArray(data.stores) ? data.stores : []);
setAdminSettings(data.isAdmin ? normalizeAdminSettings(data.adminSettings) : null);
setStatus('Anmeldung erfolgreich. Konfiguration geladen.'); setStatus('Anmeldung erfolgreich. Konfiguration geladen.');
setTimeout(() => setStatus(''), 3000); setTimeout(() => setStatus(''), 3000);
} catch (err) { } 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 () => { const saveConfig = async () => {
if (!session?.token) { if (!session?.token) {
return; return;
@@ -571,6 +635,7 @@ function App() {
randomDelayMaxSeconds: toNumber(adminSettings.randomDelayMaxSeconds), randomDelayMaxSeconds: toNumber(adminSettings.randomDelayMaxSeconds),
initialDelayMinSeconds: toNumber(adminSettings.initialDelayMinSeconds), initialDelayMinSeconds: toNumber(adminSettings.initialDelayMinSeconds),
initialDelayMaxSeconds: toNumber(adminSettings.initialDelayMaxSeconds), initialDelayMaxSeconds: toNumber(adminSettings.initialDelayMaxSeconds),
storePickupCheckDelayMs: toNumber(adminSettings.storePickupCheckDelayMs),
ignoredSlots: (adminSettings.ignoredSlots || []).map((slot) => ({ ignoredSlots: (adminSettings.ignoredSlots || []).map((slot) => ({
storeId: slot.storeId || '', storeId: slot.storeId || '',
description: slot.description || '' description: slot.description || ''
@@ -672,7 +737,7 @@ function App() {
</div> </div>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
<button <button
onClick={fetchStoresList} onClick={() => refreshStoresAndConfig({ block: false })}
className="bg-blue-500 hover:bg-blue-600 text-white py-2 px-4 rounded focus:outline-none focus:ring-2 focus:ring-blue-500 transition-colors" className="bg-blue-500 hover:bg-blue-600 text-white py-2 px-4 rounded focus:outline-none focus:ring-2 focus:ring-blue-500 transition-colors"
> >
Betriebe aktualisieren Betriebe aktualisieren
@@ -721,10 +786,11 @@ function App() {
statusClass = 'text-gray-500'; statusClass = 'text-gray-500';
} else if (needsRestore) { } else if (needsRestore) {
statusLabel = 'Ausgeblendet erneut hinzufügen'; statusLabel = 'Ausgeblendet erneut hinzufügen';
statusClass = 'text-amber-600';
} }
if (blockedByNoPickups) { if (blockedByNoPickups) {
statusLabel = 'Keine Pickups automatisch verborgen'; statusLabel = 'Keine Pickups automatisch verborgen';
statusClass = 'text-amber-600'; statusClass = 'text-red-600';
} }
return ( return (
<button <button
@@ -1104,6 +1170,18 @@ function App() {
/> />
</div> </div>
</div> </div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Verzögerung Store-Prüfung (ms)</label>
<input
type="number"
min="0"
value={adminSettings.storePickupCheckDelayMs}
onChange={(e) => 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"
/>
<p className="text-xs text-gray-500 mt-1">Hilft Rate-Limits beim Abfragen der Pickups zu vermeiden.</p>
</div>
</div> </div>
<div className="border border-purple-200 rounded-lg bg-white p-4"> <div className="border border-purple-200 rounded-lg bg-white p-4">
@@ -1166,6 +1244,7 @@ function App() {
return ( return (
<Router> <Router>
<>
<div className="min-h-screen bg-gray-100 py-6"> <div className="min-h-screen bg-gray-100 py-6">
<div className="max-w-7xl mx-auto px-4"> <div className="max-w-7xl mx-auto px-4">
<NavigationTabs isAdmin={session?.isAdmin} /> <NavigationTabs isAdmin={session?.isAdmin} />
@@ -1176,6 +1255,8 @@ function App() {
</Routes> </Routes>
</div> </div>
</div> </div>
<StoreSyncOverlay state={syncProgress} />
</>
</Router> </Router>
); );
} }
@@ -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 (
<div className="fixed inset-0 z-50 flex items-center justify-center px-4" style={{ backgroundColor }}>
<div className="bg-white shadow-2xl rounded-lg p-6 w-full max-w-md">
<p className="text-gray-800 font-semibold mb-3">{state.message || 'Synchronisiere...'}</p>
<div className="w-full bg-gray-200 rounded-full h-3 overflow-hidden">
<div
className="h-3 bg-blue-600 transition-all duration-300 ease-out"
style={{ width: `${percent}%` }}
></div>
</div>
<div className="flex items-center justify-between mt-2 text-sm text-gray-500">
<span>{percent}%</span>
<span>Bitte warten...</span>
</div>
<p className="text-xs text-gray-400 mt-2">
Die Verzögerung schützt vor Rate-Limits während die Betriebe geprüft werden.
</p>
</div>
</div>
);
}
export default App; export default App;