diff --git a/.commitmessage b/.commitmessage index f97276e..8ef843a 100644 --- a/.commitmessage +++ b/.commitmessage @@ -1,4 +1,4 @@ -feat: move mobile slot tools into menu +feat: refresh stale last pickup information -Brand the mobile header as Foodsharing Manager and move refresh and notification -actions from the dashboard into the contextual navigation menu. +Track the successful member-list check time and refresh stale pickup information +asynchronously after the GUI loads without triggering dormant warnings. diff --git a/server.js b/server.js index 396a1c7..9bb1b58 100644 --- a/server.js +++ b/server.js @@ -12,6 +12,7 @@ const { runStoreWatchCheck, runImmediatePickupCheck, runDormantMembershipCheck, + refreshStaleLastPickupInfo, getRegularPickupSchedule, scheduleRegularPickupRefresh } = require('./services/pickupScheduler'); @@ -1424,6 +1425,16 @@ app.post('/api/config/check', requireAuth, (req, res) => { res.json({ success: true }); }); +app.post('/api/config/last-pickup/refresh', requireAuth, async (req, res) => { + try { + const result = await refreshStaleLastPickupInfo(req.session.id); + res.json({ success: true, ...result }); + } catch (error) { + console.error('[PICKUP] Letzte Abholung konnte nicht aktualisiert werden:', error.message); + res.status(502).json({ error: 'Letzte Abholung konnte nicht aktualisiert werden.' }); + } +}); + app.get('/api/pickups/registered', requireAuth, async (req, res) => { try { const pickups = await withSessionRetry( diff --git a/services/lastPickupInfo.js b/services/lastPickupInfo.js new file mode 100644 index 0000000..d19a9bf --- /dev/null +++ b/services/lastPickupInfo.js @@ -0,0 +1,63 @@ +const LAST_PICKUP_INFO_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; + +function parseTimestamp(value) { + if (!value) { + return null; + } + const timestamp = value instanceof Date ? value.getTime() : new Date(value).getTime(); + return Number.isFinite(timestamp) ? timestamp : null; +} + +function getStaleLastPickupStoreIds(config = [], now = Date.now()) { + if (!Array.isArray(config)) { + return []; + } + return config + .filter((entry) => { + if (!entry?.id || entry.hidden) { + return false; + } + const checkedAt = parseTimestamp(entry.lastPickupCheckedAt); + return !checkedAt || now - checkedAt > LAST_PICKUP_INFO_MAX_AGE_MS; + }) + .map((entry) => String(entry.id)); +} + +function parseLastFetchTimestamp(value) { + if (typeof value === 'string') { + return parseTimestamp(value); + } + if (!Number.isFinite(Number(value))) { + return null; + } + const numericValue = Number(value); + return numericValue > 1e12 ? numericValue : numericValue * 1000; +} + +function updateLastPickupInfo(entry, lastFetchValue, checkedAt = new Date()) { + if (!entry || typeof entry !== 'object') { + return false; + } + let changed = false; + const checkedAtIso = new Date(checkedAt).toISOString(); + if (entry.lastPickupCheckedAt !== checkedAtIso) { + entry.lastPickupCheckedAt = checkedAtIso; + changed = true; + } + const lastFetchTimestamp = parseLastFetchTimestamp(lastFetchValue); + if (lastFetchTimestamp !== null) { + const lastPickupAt = new Date(lastFetchTimestamp).toISOString(); + if (entry.lastPickupAt !== lastPickupAt) { + entry.lastPickupAt = lastPickupAt; + changed = true; + } + } + return changed; +} + +module.exports = { + LAST_PICKUP_INFO_MAX_AGE_MS, + getStaleLastPickupStoreIds, + parseLastFetchTimestamp, + updateLastPickupInfo +}; diff --git a/services/pickupScheduler.js b/services/pickupScheduler.js index 8b28a49..6c8ede5 100644 --- a/services/pickupScheduler.js +++ b/services/pickupScheduler.js @@ -15,6 +15,7 @@ const maintenanceMode = require('./maintenanceMode'); const { computeReminderSchedule } = require('./journalReminderUtils'); const { startStoreMessageWatcher } = require('./storeMessageWatcher'); const { getBlockingProfilePickup } = require('./profileCheckTolerance'); +const { getStaleLastPickupStoreIds, parseLastFetchTimestamp, updateLastPickupInfo } = require('./lastPickupInfo'); function wait(ms) { if (!ms || ms <= 0) { @@ -53,6 +54,7 @@ let regularPickupActive = 0; let regularPickupRefreshJob = null; const dormantWarningCooldowns = new Map(); const DORMANT_WARNING_COOLDOWN_MS = 6 * 60 * 60 * 1000; +const lastPickupRefreshInFlight = new Map(); function ensureRegularPickupCacheDir() { const dir = path.dirname(REGULAR_PICKUP_CACHE_FILE); @@ -1385,19 +1387,21 @@ function getMissingLastPickupStoreIds(config = []) { async function checkDormantMembers(sessionId, options = {}) { if (shouldPauseForMaintenance(`Dormant-Check für Session ${sessionId}`)) { - return; + return { checked: 0, updated: false }; } const session = sessionStore.get(sessionId); if (!session?.profile?.id) { - return; + return { checked: 0, updated: false }; } const storeIdSet = Array.isArray(options.storeIds) ? new Set(options.storeIds.map((storeId) => String(storeId))) : null; + const includeSkippedDormant = !!options.includeSkippedDormant; + const sendWarnings = options.sendWarnings !== false; const profileId = session.profile.id; const ensured = await ensureSession(session); if (!ensured) { - return; + return { checked: 0, updated: false }; } const config = readConfig(profileId); const skipMap = new Map(); @@ -1420,7 +1424,7 @@ async function checkDormantMembers(sessionId, options = {}) { if (storeIdSet && !storeIdSet.has(storeId)) { return; } - if (skipMap.get(storeId)) { + if (!includeSkippedDormant && skipMap.get(storeId)) { return; } storeTargets.set(storeId, { @@ -1432,7 +1436,7 @@ async function checkDormantMembers(sessionId, options = {}) { const stores = Array.isArray(session.storesCache?.data) ? session.storesCache.data : []; if (stores.length === 0) { console.warn(`[DORMANT] Keine Stores für Session ${sessionId} im Cache gefunden.`); - return; + return { checked: 0, updated: false }; } const activeStoreIds = new Set(); @@ -1476,11 +1480,12 @@ async function checkDormantMembers(sessionId, options = {}) { }); if (storeTargets.size === 0) { - return; + return { checked: 0, updated: false }; } const fourMonthsAgo = setMonthOffset(new Date(), -4).getTime(); const hygieneCutoff = Date.now() + 6 * 7 * 24 * 60 * 60 * 1000; + let checked = 0; for (const target of storeTargets.values()) { const storeId = target.storeId; let members = []; @@ -1505,33 +1510,21 @@ async function checkDormantMembers(sessionId, options = {}) { if (!memberEntry) { continue; } + checked += 1; const reasons = []; const lastFetchRaw = memberEntry.lastFetch ?? memberEntry.last_fetch ?? null; - let lastFetchMs = null; - if (typeof lastFetchRaw === 'string') { - const parsedLastFetch = new Date(lastFetchRaw); - if (!Number.isNaN(parsedLastFetch.getTime())) { - lastFetchMs = parsedLastFetch.getTime(); - } - } else if (Number.isFinite(Number(lastFetchRaw))) { - const numericLastFetch = Number(lastFetchRaw); - lastFetchMs = numericLastFetch > 1e12 ? numericLastFetch : numericLastFetch * 1000; + const lastFetchMs = parseLastFetchTimestamp(lastFetchRaw); + const configEntry = configEntryMap.get(storeId)?.entry; + if (updateLastPickupInfo(configEntry, lastFetchRaw)) { + configChanged = true; } - if (Number.isFinite(lastFetchMs)) { - const configEntry = configEntryMap.get(storeId)?.entry; - const lastPickupAt = new Date(lastFetchMs).toISOString(); - if (configEntry && configEntry.lastPickupAt !== lastPickupAt) { - configEntry.lastPickupAt = lastPickupAt; - configChanged = true; - } - } - if (!lastFetchMs || lastFetchMs < fourMonthsAgo) { + if (sendWarnings && !skipMap.get(storeId) && (!lastFetchMs || lastFetchMs < fourMonthsAgo)) { const lastFetchLabel = lastFetchMs ? new Date(lastFetchMs).toLocaleDateString('de-DE') : 'unbekannt'; reasons.push(`Letzte Abholung: ${lastFetchLabel} (älter als 4 Monate)`); } const hygieneCertificateUntil = memberEntry.hygieneCertificateUntil ?? memberEntry.hygiene_certificate_until ?? null; - if (hygieneCertificateUntil) { + if (sendWarnings && !skipMap.get(storeId) && hygieneCertificateUntil) { const expiry = new Date(String(hygieneCertificateUntil).replace(' ', 'T')); if (!Number.isNaN(expiry.getTime()) && expiry.getTime() < hygieneCutoff) { reasons.push( @@ -1539,7 +1532,7 @@ async function checkDormantMembers(sessionId, options = {}) { ); } } - if (reasons.length > 0) { + if (sendWarnings && !skipMap.get(storeId) && reasons.length > 0) { const cooldownKey = `${profileId}:${storeId}`; const lastSentAt = dormantWarningCooldowns.get(cooldownKey); if (lastSentAt && Date.now() - lastSentAt < DORMANT_WARNING_COOLDOWN_MS) { @@ -1566,6 +1559,37 @@ async function checkDormantMembers(sessionId, options = {}) { console.error(`[DORMANT] Letzte Abholung für Profil ${profileId} konnte nicht gespeichert werden:`, error.message); } } + return { checked, updated: configChanged }; +} + +async function refreshStaleLastPickupInfo(sessionId) { + const session = sessionStore.get(sessionId); + const profileId = session?.profile?.id ? String(session.profile.id) : null; + if (!profileId) { + return { checked: 0, updated: false, stale: 0 }; + } + if (lastPickupRefreshInFlight.has(profileId)) { + return lastPickupRefreshInFlight.get(profileId); + } + const refresh = (async () => { + const config = readConfig(profileId); + const staleStoreIds = getStaleLastPickupStoreIds(config); + if (staleStoreIds.length === 0) { + return { checked: 0, updated: false, stale: 0 }; + } + const result = await checkDormantMembers(sessionId, { + storeIds: staleStoreIds, + includeSkippedDormant: true, + sendWarnings: false + }); + return { ...result, stale: staleStoreIds.length }; + })(); + lastPickupRefreshInFlight.set(profileId, refresh); + try { + return await refresh; + } finally { + lastPickupRefreshInFlight.delete(profileId); + } } function scheduleDormantMembershipCheck(sessionId, settings) { @@ -1680,6 +1704,7 @@ module.exports = { runStoreWatchCheck, runImmediatePickupCheck, runDormantMembershipCheck, + refreshStaleLastPickupInfo, getRegularPickupSchedule, scheduleRegularPickupRefresh }; diff --git a/src/App.js b/src/App.js index 9dde4a5..4984d8c 100644 --- a/src/App.js +++ b/src/App.js @@ -164,6 +164,33 @@ function App() { }; }, [fetchConfig, initializing, isDirty, session?.token]); + useEffect(() => { + if (!session?.token || initializing || isDirty) { + return undefined; + } + let cancelled = false; + async function refreshLastPickupInfo() { + try { + const response = await authorizedFetch('/api/config/last-pickup/refresh', { method: 'POST' }, session.token); + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + await response.json(); + if (!cancelled && !isDirty) { + await fetchConfig(session.token, { silent: true }); + } + } catch (error) { + if (!cancelled) { + console.warn('Letzte Abholung konnte nicht im Hintergrund aktualisiert werden:', error.message); + } + } + } + refreshLastPickupInfo(); + return () => { + cancelled = true; + }; + }, [authorizedFetch, fetchConfig, initializing, isDirty, session?.token]); + useEffect(() => { let aborted = false; async function lookupNearestStore() { diff --git a/src/utils/lastPickupInfo.test.js b/src/utils/lastPickupInfo.test.js new file mode 100644 index 0000000..f1b0ce8 --- /dev/null +++ b/src/utils/lastPickupInfo.test.js @@ -0,0 +1,37 @@ +import { + getStaleLastPickupStoreIds, + parseLastFetchTimestamp, + updateLastPickupInfo +} from '../../services/lastPickupInfo'; + +describe('last pickup info', () => { + const now = new Date('2026-08-05T12:00:00.000Z').getTime(); + + it('selects visible entries with a missing or stale check timestamp', () => { + const config = [ + { id: 'missing' }, + { id: 'stale', lastPickupCheckedAt: '2026-07-29T11:59:59.000Z' }, + { id: 'fresh', lastPickupCheckedAt: '2026-07-30T12:00:01.000Z' }, + { id: 'hidden', hidden: true } + ]; + + expect(getStaleLastPickupStoreIds(config, now)).toEqual(['missing', 'stale']); + }); + + it('records a successful check independently from the pickup date', () => { + const entry = { id: '33875', lastPickupAt: '2026-01-01T00:00:00.000Z' }; + + expect(updateLastPickupInfo(entry, 1785945600, new Date('2026-08-05T12:00:00.000Z'))).toBe(true); + expect(entry).toEqual({ + id: '33875', + lastPickupAt: '2026-08-05T16:00:00.000Z', + lastPickupCheckedAt: '2026-08-05T12:00:00.000Z' + }); + }); + + it('accepts seconds, milliseconds and ISO timestamps from Foodsharing', () => { + expect(parseLastFetchTimestamp(1785945600)).toBe(1785945600000); + expect(parseLastFetchTimestamp(1785945600000)).toBe(1785945600000); + expect(parseLastFetchTimestamp('2026-08-05T00:00:00.000Z')).toBe(1785888000000); + }); +});