diff --git a/server.js b/server.js
index 4357ba4..325bb4d 100644
--- a/server.js
+++ b/server.js
@@ -1317,6 +1317,23 @@ app.post('/api/notifications/test', requireAuth, async (req, res) => {
}
});
+app.get('/api/stores/:storeId/regular-pickup', requireAuth, async (req, res) => {
+ const { storeId } = req.params;
+ if (!storeId) {
+ return res.status(400).json({ error: 'Store-ID fehlt' });
+ }
+ try {
+ const rules = await withSessionRetry(
+ req.session,
+ () => foodsharingClient.fetchRegularPickup(storeId, req.session.cookieHeader, req.session),
+ { label: 'fetchRegularPickup' }
+ );
+ res.json(Array.isArray(rules) ? rules : []);
+ } catch (error) {
+ res.status(400).json({ error: error.message || 'Regular-Pickup konnte nicht geladen werden' });
+ }
+});
+
app.get('/api/stores', requireAuth, async (req, res) => {
res.json(req.session.storesCache?.data || []);
});
diff --git a/services/adminConfig.js b/services/adminConfig.js
index cfd6d48..be53d3b 100644
--- a/services/adminConfig.js
+++ b/services/adminConfig.js
@@ -6,6 +6,8 @@ const SETTINGS_FILE = path.join(CONFIG_DIR, 'admin-settings.json');
const DEFAULT_SETTINGS = {
scheduleCron: '*/10 7-22 * * *',
+ pickupFallbackCron: '0 7,12,17,22 * * *',
+ pickupWindowOffsetsMinutes: [-1, -0.5, 0, 0.5, 1, 1.5],
randomDelayMinSeconds: 10,
randomDelayMaxSeconds: 120,
initialDelayMinSeconds: 5,
@@ -53,6 +55,23 @@ function sanitizeNumber(value, fallback) {
return fallback;
}
+function sanitizeNumberArray(value, fallback) {
+ if (Array.isArray(value)) {
+ const cleaned = value
+ .map((entry) => Number(entry))
+ .filter((entry) => Number.isFinite(entry));
+ return cleaned.length > 0 ? cleaned : fallback;
+ }
+ if (typeof value === 'string') {
+ const cleaned = value
+ .split(',')
+ .map((entry) => Number(entry.trim()))
+ .filter((entry) => Number.isFinite(entry));
+ return cleaned.length > 0 ? cleaned : fallback;
+ }
+ return fallback;
+}
+
function sanitizeIgnoredSlots(slots = []) {
if (!Array.isArray(slots)) {
return DEFAULT_SETTINGS.ignoredSlots;
@@ -109,6 +128,11 @@ function readSettings() {
const parsed = JSON.parse(raw);
return {
scheduleCron: parsed.scheduleCron || DEFAULT_SETTINGS.scheduleCron,
+ pickupFallbackCron: parsed.pickupFallbackCron || DEFAULT_SETTINGS.pickupFallbackCron,
+ pickupWindowOffsetsMinutes: sanitizeNumberArray(
+ parsed.pickupWindowOffsetsMinutes,
+ DEFAULT_SETTINGS.pickupWindowOffsetsMinutes
+ ),
randomDelayMinSeconds: sanitizeNumber(parsed.randomDelayMinSeconds, DEFAULT_SETTINGS.randomDelayMinSeconds),
randomDelayMaxSeconds: sanitizeNumber(parsed.randomDelayMaxSeconds, DEFAULT_SETTINGS.randomDelayMaxSeconds),
initialDelayMinSeconds: sanitizeNumber(parsed.initialDelayMinSeconds, DEFAULT_SETTINGS.initialDelayMinSeconds),
@@ -147,6 +171,11 @@ function writeSettings(patch = {}) {
const current = readSettings();
const next = {
scheduleCron: patch.scheduleCron || current.scheduleCron,
+ pickupFallbackCron: patch.pickupFallbackCron || current.pickupFallbackCron,
+ pickupWindowOffsetsMinutes: sanitizeNumberArray(
+ patch.pickupWindowOffsetsMinutes,
+ current.pickupWindowOffsetsMinutes
+ ),
randomDelayMinSeconds: sanitizeNumber(patch.randomDelayMinSeconds, current.randomDelayMinSeconds),
randomDelayMaxSeconds: sanitizeNumber(patch.randomDelayMaxSeconds, current.randomDelayMaxSeconds),
initialDelayMinSeconds: sanitizeNumber(patch.initialDelayMinSeconds, current.initialDelayMinSeconds),
diff --git a/services/foodsharingClient.js b/services/foodsharingClient.js
index ad4528e..4a435cf 100644
--- a/services/foodsharingClient.js
+++ b/services/foodsharingClient.js
@@ -442,6 +442,17 @@ async function fetchStoreMembers(storeId, cookieHeader, context) {
return Array.isArray(response.data) ? response.data : [];
}
+async function fetchRegularPickup(storeId, cookieHeader, context) {
+ if (!storeId) {
+ return [];
+ }
+ const response = await client.get(
+ `/api/stores/${storeId}/regularPickup`,
+ buildRequestConfig({ cookieHeader, context })
+ );
+ return Array.isArray(response.data) ? response.data : [];
+}
+
async function bookSlot(storeId, utcDate, profileId, session) {
await client.post(
`/api/stores/${storeId}/pickups/${utcDate}/${profileId}`,
@@ -464,6 +475,7 @@ module.exports = {
fetchRegionStores,
fetchStoreDetails,
fetchStoreMembers,
+ fetchRegularPickup,
pickupRuleCheck,
bookSlot
};
diff --git a/services/pickupScheduler.js b/services/pickupScheduler.js
index ba3c81c..b8e45bf 100644
--- a/services/pickupScheduler.js
+++ b/services/pickupScheduler.js
@@ -22,6 +22,10 @@ const storeWatchInFlight = new Map();
const pickupCheckInFlight = new Map();
const pickupCheckLastRun = new Map();
const PICKUP_CHECK_DEDUP_MS = 30 * 1000;
+const regularPickupCache = new Map();
+const REGULAR_PICKUP_CACHE_TTL_MS = 12 * 60 * 60 * 1000;
+const PICKUP_FALLBACK_RETRY_MS = 60 * 60 * 1000;
+const TIME_ZONE = 'Europe/Berlin';
async function fetchSharedStoreStatus(session, storeId, { forceRefresh = false, maxAgeMs } = {}) {
if (!storeId) {
@@ -83,6 +87,104 @@ function randomDelayMs(minSeconds = 10, maxSeconds = 120) {
return Math.floor(Math.random() * (max - min + 1)) + min;
}
+function getTimeZoneParts(date, timeZone) {
+ const formatter = new Intl.DateTimeFormat('en-US', {
+ timeZone,
+ year: 'numeric',
+ month: '2-digit',
+ day: '2-digit',
+ hour: '2-digit',
+ minute: '2-digit',
+ second: '2-digit',
+ weekday: 'short',
+ hour12: false
+ });
+ const parts = formatter.formatToParts(date);
+ const values = {};
+ parts.forEach((part) => {
+ if (part.type !== 'literal') {
+ values[part.type] = part.value;
+ }
+ });
+ const weekdayMap = {
+ Sun: 0,
+ Mon: 1,
+ Tue: 2,
+ Wed: 3,
+ Thu: 4,
+ Fri: 5,
+ Sat: 6
+ };
+ return {
+ year: Number(values.year),
+ month: Number(values.month),
+ day: Number(values.day),
+ hour: Number(values.hour),
+ minute: Number(values.minute),
+ second: Number(values.second),
+ weekday: weekdayMap[values.weekday] ?? null
+ };
+}
+
+function getTimeZoneOffsetMinutes(timeZone, date) {
+ const parts = getTimeZoneParts(date, timeZone);
+ const utcFromParts = Date.UTC(
+ parts.year,
+ parts.month - 1,
+ parts.day,
+ parts.hour,
+ parts.minute,
+ parts.second
+ );
+ return (utcFromParts - date.getTime()) / 60000;
+}
+
+function makeDateInTimeZone(timeZone, year, month, day, hour, minute, second = 0) {
+ const utcGuess = new Date(Date.UTC(year, month - 1, day, hour, minute, second));
+ const offset = getTimeZoneOffsetMinutes(timeZone, utcGuess);
+ return new Date(utcGuess.getTime() - offset * 60000);
+}
+
+function getStartOfDayInTimeZone(date, timeZone) {
+ const parts = getTimeZoneParts(date, timeZone);
+ if (!parts.year || !parts.month || !parts.day) {
+ return null;
+ }
+ return makeDateInTimeZone(timeZone, parts.year, parts.month, parts.day, 0, 0, 0);
+}
+
+function normalizeRegularPickupWeekday(value) {
+ const num = Number(value);
+ if (!Number.isFinite(num)) {
+ return null;
+ }
+ if (num === 0) {
+ return 0;
+ }
+ if (num >= 1 && num <= 7) {
+ return num % 7;
+ }
+ return null;
+}
+
+function parseTimeString(value) {
+ if (!value || typeof value !== 'string') {
+ return null;
+ }
+ const [hourText, minuteText, secondText] = value.split(':');
+ const hour = Number(hourText);
+ const minute = Number(minuteText);
+ const second = Number(secondText || 0);
+ if (![hour, minute, second].every((entry) => Number.isFinite(entry))) {
+ return null;
+ }
+ return { hour, minute, second };
+}
+
+function addDays(date, days) {
+ return new Date(date.getTime() + days * 24 * 60 * 60 * 1000);
+}
+
function startOfDay(date) {
return new Date(date.getFullYear(), date.getMonth(), date.getDate());
}
@@ -131,6 +233,11 @@ function resolveSettings(settings) {
}
return {
scheduleCron: settings.scheduleCron || DEFAULT_SETTINGS.scheduleCron,
+ pickupFallbackCron: settings.pickupFallbackCron || DEFAULT_SETTINGS.pickupFallbackCron,
+ pickupWindowOffsetsMinutes:
+ Array.isArray(settings.pickupWindowOffsetsMinutes) && settings.pickupWindowOffsetsMinutes.length > 0
+ ? settings.pickupWindowOffsetsMinutes
+ : DEFAULT_SETTINGS.pickupWindowOffsetsMinutes,
randomDelayMinSeconds: Number.isFinite(settings.randomDelayMinSeconds)
? settings.randomDelayMinSeconds
: DEFAULT_SETTINGS.randomDelayMinSeconds,
@@ -173,6 +280,107 @@ function resolveSettings(settings) {
};
}
+async function fetchRegularPickupSchedule(session, storeId) {
+ if (!session?.profile?.id || !storeId) {
+ return [];
+ }
+ const key = String(storeId);
+ const cached = regularPickupCache.get(key);
+ if (cached && Date.now() - cached.fetchedAt <= REGULAR_PICKUP_CACHE_TTL_MS) {
+ return cached.rules;
+ }
+ try {
+ const rules = await withSessionRetry(
+ session,
+ () => foodsharingClient.fetchRegularPickup(storeId, session.cookieHeader, session),
+ { label: 'fetchRegularPickup' }
+ );
+ const normalized = Array.isArray(rules) ? rules : [];
+ regularPickupCache.set(key, { fetchedAt: Date.now(), rules: normalized });
+ return normalized;
+ } catch (error) {
+ if (cached?.rules) {
+ return cached.rules;
+ }
+ return [];
+ }
+}
+
+function resolveEffectiveNow(entry, now) {
+ const startValue = toDateValue(entry?.desiredDateRange?.start || entry?.desiredDate);
+ if (startValue && startValue > now.getTime()) {
+ return new Date(startValue);
+ }
+ return now;
+}
+
+async function getNextPickupCheckTime(session, entry, settings) {
+ const now = new Date();
+ const effectiveNow = resolveEffectiveNow(entry, now);
+ const offsets = (settings.pickupWindowOffsetsMinutes || [])
+ .map((value) => Number(value))
+ .filter((value) => Number.isFinite(value))
+ .map((value) => value * 60 * 1000)
+ .sort((a, b) => a - b);
+
+ if (offsets.length === 0) {
+ return null;
+ }
+
+ const rules = await fetchRegularPickupSchedule(session, entry.id);
+ if (!Array.isArray(rules) || rules.length === 0) {
+ return null;
+ }
+
+ const nowParts = getTimeZoneParts(effectiveNow, TIME_ZONE);
+ if (nowParts.weekday === null) {
+ return null;
+ }
+ const todayStart = getStartOfDayInTimeZone(effectiveNow, TIME_ZONE);
+ if (!todayStart) {
+ return null;
+ }
+
+ let best = null;
+
+ rules.forEach((rule) => {
+ const weekday = normalizeRegularPickupWeekday(rule.weekday);
+ const timeParts = parseTimeString(rule.startTimeOfPickup);
+ if (weekday === null || !timeParts) {
+ return;
+ }
+ let daysAhead = (weekday - nowParts.weekday + 7) % 7;
+ let candidateStart = addDays(todayStart, daysAhead);
+ const candidateParts = getTimeZoneParts(candidateStart, TIME_ZONE);
+ let slotDate = makeDateInTimeZone(
+ TIME_ZONE,
+ candidateParts.year,
+ candidateParts.month,
+ candidateParts.day,
+ timeParts.hour,
+ timeParts.minute,
+ timeParts.second
+ );
+
+ let candidate = offsets
+ .map((offset) => new Date(slotDate.getTime() + offset))
+ .find((date) => date.getTime() > effectiveNow.getTime());
+
+ if (!candidate) {
+ slotDate = addDays(slotDate, 7);
+ candidate = offsets
+ .map((offset) => new Date(slotDate.getTime() + offset))
+ .find((date) => date.getTime() > effectiveNow.getTime());
+ }
+
+ if (candidate && (!best || candidate.getTime() < best.getTime())) {
+ best = candidate;
+ }
+ });
+
+ return best;
+}
+
function deactivateEntryInMemory(entry) {
if (entry) {
entry.active = false;
@@ -660,22 +868,73 @@ function scheduleStoreWatchers(sessionId, settings) {
return true;
}
-function scheduleEntry(sessionId, entry, settings) {
- const cronExpression = settings.scheduleCron || DEFAULT_SETTINGS.scheduleCron;
+function scheduleFallbackPickupChecks(sessionId, settings) {
+ const cronExpression = settings.pickupFallbackCron || DEFAULT_SETTINGS.pickupFallbackCron;
+ if (!cronExpression) {
+ return null;
+ }
const job = cron.schedule(
cronExpression,
() => {
- const delay = randomDelayMs(
- settings.randomDelayMinSeconds,
- settings.randomDelayMaxSeconds
- );
- setTimeout(() => checkEntry(sessionId, entry, settings), delay);
+ const session = sessionStore.get(sessionId);
+ if (!session?.profile?.id) {
+ return;
+ }
+ const delay = randomDelayMs(settings.randomDelayMinSeconds, settings.randomDelayMaxSeconds);
+ setTimeout(() => {
+ const config = readConfig(session.profile.id);
+ runImmediatePickupCheck(sessionId, config, settings).catch((error) => {
+ console.error('[PICKUP] Fallback-Check fehlgeschlagen:', error.message);
+ });
+ }, delay);
},
- {
- timezone: 'Europe/Berlin'
- }
+ { timezone: TIME_ZONE }
);
sessionStore.attachJob(sessionId, job);
+ return job;
+}
+
+function scheduleEntry(sessionId, entry, settings) {
+ let timeoutId = null;
+ let stopped = false;
+
+ const scheduleNext = async () => {
+ if (stopped) {
+ return;
+ }
+ const session = sessionStore.get(sessionId);
+ if (!session) {
+ return;
+ }
+ let nextTime = null;
+ try {
+ nextTime = await getNextPickupCheckTime(session, entry, settings);
+ } catch (error) {
+ console.warn(`[PICKUP] Konnte nächste Slot-Zeit für ${entry?.id} nicht berechnen:`, error.message);
+ }
+ const delayMs = nextTime
+ ? Math.max(0, nextTime.getTime() - Date.now())
+ : PICKUP_FALLBACK_RETRY_MS;
+ timeoutId = setTimeout(async () => {
+ timeoutId = null;
+ if (stopped) {
+ return;
+ }
+ await checkEntry(sessionId, entry, settings);
+ scheduleNext();
+ }, delayMs);
+ };
+
+ scheduleNext();
+ const job = {
+ stop: () => {
+ stopped = true;
+ if (timeoutId) {
+ clearTimeout(timeoutId);
+ }
+ }
+ };
+ sessionStore.attachJob(sessionId, job);
}
function scheduleConfig(sessionId, config, settings) {
@@ -683,6 +942,7 @@ function scheduleConfig(sessionId, config, settings) {
sessionStore.clearJobs(sessionId);
scheduleDormantMembershipCheck(sessionId);
const watchScheduled = scheduleStoreWatchers(sessionId, resolvedSettings);
+ scheduleFallbackPickupChecks(sessionId, resolvedSettings);
scheduleJournalReminders(sessionId);
const entries = Array.isArray(config) ? config : [];
const activeEntries = entries.filter((entry) => entry.active);
@@ -698,7 +958,7 @@ function scheduleConfig(sessionId, config, settings) {
}
activeEntries.forEach((entry) => scheduleEntry(sessionId, entry, resolvedSettings));
console.log(
- `[INFO] Scheduler für Session ${sessionId} mit ${activeEntries.length} Jobs aktiv (Cron: ${resolvedSettings.scheduleCron}).`
+ `[INFO] Scheduler für Session ${sessionId} mit ${activeEntries.length} Jobs aktiv.`
);
}
diff --git a/src/App.js b/src/App.js
index 3171c9c..d01e593 100644
--- a/src/App.js
+++ b/src/App.js
@@ -39,6 +39,7 @@ function App() {
const [notificationPanelOpen, setNotificationPanelOpen] = useState(false);
const [focusedStoreId, setFocusedStoreId] = useState(null);
const [nearestStoreLabel, setNearestStoreLabel] = useState(null);
+ const [regularPickupMap, setRegularPickupMap] = useState({});
const minSelectableDate = useMemo(() => startOfDay(new Date()), []);
const weekdays = ['Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag', 'Sonntag'];
@@ -551,6 +552,45 @@ function App() {
const visibleConfig = useMemo(() => config.filter((item) => !item.hidden), [config]);
+ useEffect(() => {
+ let cancelled = false;
+ if (!session?.token || !authorizedFetch || visibleConfig.length === 0) {
+ return () => {
+ cancelled = true;
+ };
+ }
+ const uniqueIds = Array.from(new Set(visibleConfig.map((item) => String(item.id))));
+ const missing = uniqueIds.filter((id) => regularPickupMap[id] === undefined);
+ if (missing.length === 0) {
+ return () => {
+ cancelled = true;
+ };
+ }
+ const fetchSchedules = async () => {
+ for (const id of missing) {
+ try {
+ const response = await authorizedFetch(`/api/stores/${id}/regular-pickup`);
+ if (!response.ok) {
+ throw new Error(`HTTP ${response.status}`);
+ }
+ const data = await response.json();
+ const rules = Array.isArray(data) ? data : Array.isArray(data?.rules) ? data.rules : [];
+ if (!cancelled) {
+ setRegularPickupMap((prev) => ({ ...prev, [id]: rules }));
+ }
+ } catch (err) {
+ if (!cancelled) {
+ setRegularPickupMap((prev) => ({ ...prev, [id]: [] }));
+ }
+ }
+ }
+ };
+ fetchSchedules();
+ return () => {
+ cancelled = true;
+ };
+ }, [authorizedFetch, regularPickupMap, session?.token, visibleConfig]);
+
const activeRangeEntry = useMemo(() => {
if (!activeRangePicker) {
return null;
@@ -736,6 +776,7 @@ function App() {
onToggleStores={() => setAvailableCollapsed((prev) => !prev)}
onStoreSelect={handleStoreSelection}
configMap={configMap}
+ regularPickupMap={regularPickupMap}
error={error}
onDismissError={() => setError('')}
status={status}
diff --git a/src/components/AdminSettingsPanel.js b/src/components/AdminSettingsPanel.js
index b59b1df..a7f1300 100644
--- a/src/components/AdminSettingsPanel.js
+++ b/src/components/AdminSettingsPanel.js
@@ -91,15 +91,28 @@ const AdminSettingsPanel = ({
subtitle="Bestimmt, wann der Bot freie Slots sucht und wie stark er Anfragen verteilt."
>