aktueller Stand
This commit is contained in:
22
server.js
22
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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
143
src/App.js
143
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() {
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<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"
|
||||
>
|
||||
Betriebe aktualisieren
|
||||
@@ -721,10 +786,11 @@ function App() {
|
||||
statusClass = 'text-gray-500';
|
||||
} else if (needsRestore) {
|
||||
statusLabel = 'Ausgeblendet – erneut hinzufügen';
|
||||
statusClass = 'text-amber-600';
|
||||
}
|
||||
if (blockedByNoPickups) {
|
||||
statusLabel = 'Keine Pickups – automatisch verborgen';
|
||||
statusClass = 'text-amber-600';
|
||||
statusClass = 'text-red-600';
|
||||
}
|
||||
return (
|
||||
<button
|
||||
@@ -1104,6 +1170,18 @@ function App() {
|
||||
/>
|
||||
</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 className="border border-purple-200 rounded-lg bg-white p-4">
|
||||
@@ -1166,16 +1244,19 @@ function App() {
|
||||
|
||||
return (
|
||||
<Router>
|
||||
<div className="min-h-screen bg-gray-100 py-6">
|
||||
<div className="max-w-7xl mx-auto px-4">
|
||||
<NavigationTabs isAdmin={session?.isAdmin} />
|
||||
<Routes>
|
||||
<Route path="/" element={dashboardContent} />
|
||||
<Route path="/admin" element={adminPageContent} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
<>
|
||||
<div className="min-h-screen bg-gray-100 py-6">
|
||||
<div className="max-w-7xl mx-auto px-4">
|
||||
<NavigationTabs isAdmin={session?.isAdmin} />
|
||||
<Routes>
|
||||
<Route path="/" element={dashboardContent} />
|
||||
<Route path="/admin" element={adminPageContent} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<StoreSyncOverlay state={syncProgress} />
|
||||
</>
|
||||
</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;
|
||||
|
||||
Reference in New Issue
Block a user