aktueller stand

This commit is contained in:
2026-01-29 18:17:13 +01:00
parent 9f2825edd4
commit ad32f299cf
8 changed files with 491 additions and 18 deletions

View File

@@ -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.`
);
}