feat: refresh stale last pickup information
Track the successful member-list check time and refresh stale pickup information asynchronously after the GUI loads without triggering dormant warnings.
This commit is contained in:
@@ -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
|
Track the successful member-list check time and refresh stale pickup information
|
||||||
actions from the dashboard into the contextual navigation menu.
|
asynchronously after the GUI loads without triggering dormant warnings.
|
||||||
|
|||||||
11
server.js
11
server.js
@@ -12,6 +12,7 @@ const {
|
|||||||
runStoreWatchCheck,
|
runStoreWatchCheck,
|
||||||
runImmediatePickupCheck,
|
runImmediatePickupCheck,
|
||||||
runDormantMembershipCheck,
|
runDormantMembershipCheck,
|
||||||
|
refreshStaleLastPickupInfo,
|
||||||
getRegularPickupSchedule,
|
getRegularPickupSchedule,
|
||||||
scheduleRegularPickupRefresh
|
scheduleRegularPickupRefresh
|
||||||
} = require('./services/pickupScheduler');
|
} = require('./services/pickupScheduler');
|
||||||
@@ -1424,6 +1425,16 @@ app.post('/api/config/check', requireAuth, (req, res) => {
|
|||||||
res.json({ success: true });
|
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) => {
|
app.get('/api/pickups/registered', requireAuth, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const pickups = await withSessionRetry(
|
const pickups = await withSessionRetry(
|
||||||
|
|||||||
63
services/lastPickupInfo.js
Normal file
63
services/lastPickupInfo.js
Normal file
@@ -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
|
||||||
|
};
|
||||||
@@ -15,6 +15,7 @@ const maintenanceMode = require('./maintenanceMode');
|
|||||||
const { computeReminderSchedule } = require('./journalReminderUtils');
|
const { computeReminderSchedule } = require('./journalReminderUtils');
|
||||||
const { startStoreMessageWatcher } = require('./storeMessageWatcher');
|
const { startStoreMessageWatcher } = require('./storeMessageWatcher');
|
||||||
const { getBlockingProfilePickup } = require('./profileCheckTolerance');
|
const { getBlockingProfilePickup } = require('./profileCheckTolerance');
|
||||||
|
const { getStaleLastPickupStoreIds, parseLastFetchTimestamp, updateLastPickupInfo } = require('./lastPickupInfo');
|
||||||
|
|
||||||
function wait(ms) {
|
function wait(ms) {
|
||||||
if (!ms || ms <= 0) {
|
if (!ms || ms <= 0) {
|
||||||
@@ -53,6 +54,7 @@ let regularPickupActive = 0;
|
|||||||
let regularPickupRefreshJob = null;
|
let regularPickupRefreshJob = null;
|
||||||
const dormantWarningCooldowns = new Map();
|
const dormantWarningCooldowns = new Map();
|
||||||
const DORMANT_WARNING_COOLDOWN_MS = 6 * 60 * 60 * 1000;
|
const DORMANT_WARNING_COOLDOWN_MS = 6 * 60 * 60 * 1000;
|
||||||
|
const lastPickupRefreshInFlight = new Map();
|
||||||
|
|
||||||
function ensureRegularPickupCacheDir() {
|
function ensureRegularPickupCacheDir() {
|
||||||
const dir = path.dirname(REGULAR_PICKUP_CACHE_FILE);
|
const dir = path.dirname(REGULAR_PICKUP_CACHE_FILE);
|
||||||
@@ -1385,19 +1387,21 @@ function getMissingLastPickupStoreIds(config = []) {
|
|||||||
|
|
||||||
async function checkDormantMembers(sessionId, options = {}) {
|
async function checkDormantMembers(sessionId, options = {}) {
|
||||||
if (shouldPauseForMaintenance(`Dormant-Check für Session ${sessionId}`)) {
|
if (shouldPauseForMaintenance(`Dormant-Check für Session ${sessionId}`)) {
|
||||||
return;
|
return { checked: 0, updated: false };
|
||||||
}
|
}
|
||||||
const session = sessionStore.get(sessionId);
|
const session = sessionStore.get(sessionId);
|
||||||
if (!session?.profile?.id) {
|
if (!session?.profile?.id) {
|
||||||
return;
|
return { checked: 0, updated: false };
|
||||||
}
|
}
|
||||||
const storeIdSet = Array.isArray(options.storeIds)
|
const storeIdSet = Array.isArray(options.storeIds)
|
||||||
? new Set(options.storeIds.map((storeId) => String(storeId)))
|
? new Set(options.storeIds.map((storeId) => String(storeId)))
|
||||||
: null;
|
: null;
|
||||||
|
const includeSkippedDormant = !!options.includeSkippedDormant;
|
||||||
|
const sendWarnings = options.sendWarnings !== false;
|
||||||
const profileId = session.profile.id;
|
const profileId = session.profile.id;
|
||||||
const ensured = await ensureSession(session);
|
const ensured = await ensureSession(session);
|
||||||
if (!ensured) {
|
if (!ensured) {
|
||||||
return;
|
return { checked: 0, updated: false };
|
||||||
}
|
}
|
||||||
const config = readConfig(profileId);
|
const config = readConfig(profileId);
|
||||||
const skipMap = new Map();
|
const skipMap = new Map();
|
||||||
@@ -1420,7 +1424,7 @@ async function checkDormantMembers(sessionId, options = {}) {
|
|||||||
if (storeIdSet && !storeIdSet.has(storeId)) {
|
if (storeIdSet && !storeIdSet.has(storeId)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (skipMap.get(storeId)) {
|
if (!includeSkippedDormant && skipMap.get(storeId)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
storeTargets.set(storeId, {
|
storeTargets.set(storeId, {
|
||||||
@@ -1432,7 +1436,7 @@ async function checkDormantMembers(sessionId, options = {}) {
|
|||||||
const stores = Array.isArray(session.storesCache?.data) ? session.storesCache.data : [];
|
const stores = Array.isArray(session.storesCache?.data) ? session.storesCache.data : [];
|
||||||
if (stores.length === 0) {
|
if (stores.length === 0) {
|
||||||
console.warn(`[DORMANT] Keine Stores für Session ${sessionId} im Cache gefunden.`);
|
console.warn(`[DORMANT] Keine Stores für Session ${sessionId} im Cache gefunden.`);
|
||||||
return;
|
return { checked: 0, updated: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
const activeStoreIds = new Set();
|
const activeStoreIds = new Set();
|
||||||
@@ -1476,11 +1480,12 @@ async function checkDormantMembers(sessionId, options = {}) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (storeTargets.size === 0) {
|
if (storeTargets.size === 0) {
|
||||||
return;
|
return { checked: 0, updated: false };
|
||||||
}
|
}
|
||||||
const fourMonthsAgo = setMonthOffset(new Date(), -4).getTime();
|
const fourMonthsAgo = setMonthOffset(new Date(), -4).getTime();
|
||||||
const hygieneCutoff = Date.now() + 6 * 7 * 24 * 60 * 60 * 1000;
|
const hygieneCutoff = Date.now() + 6 * 7 * 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
let checked = 0;
|
||||||
for (const target of storeTargets.values()) {
|
for (const target of storeTargets.values()) {
|
||||||
const storeId = target.storeId;
|
const storeId = target.storeId;
|
||||||
let members = [];
|
let members = [];
|
||||||
@@ -1505,33 +1510,21 @@ async function checkDormantMembers(sessionId, options = {}) {
|
|||||||
if (!memberEntry) {
|
if (!memberEntry) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
checked += 1;
|
||||||
const reasons = [];
|
const reasons = [];
|
||||||
const lastFetchRaw = memberEntry.lastFetch ?? memberEntry.last_fetch ?? null;
|
const lastFetchRaw = memberEntry.lastFetch ?? memberEntry.last_fetch ?? null;
|
||||||
let lastFetchMs = null;
|
const lastFetchMs = parseLastFetchTimestamp(lastFetchRaw);
|
||||||
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;
|
|
||||||
}
|
|
||||||
if (Number.isFinite(lastFetchMs)) {
|
|
||||||
const configEntry = configEntryMap.get(storeId)?.entry;
|
const configEntry = configEntryMap.get(storeId)?.entry;
|
||||||
const lastPickupAt = new Date(lastFetchMs).toISOString();
|
if (updateLastPickupInfo(configEntry, lastFetchRaw)) {
|
||||||
if (configEntry && configEntry.lastPickupAt !== lastPickupAt) {
|
|
||||||
configEntry.lastPickupAt = lastPickupAt;
|
|
||||||
configChanged = true;
|
configChanged = true;
|
||||||
}
|
}
|
||||||
}
|
if (sendWarnings && !skipMap.get(storeId) && (!lastFetchMs || lastFetchMs < fourMonthsAgo)) {
|
||||||
if (!lastFetchMs || lastFetchMs < fourMonthsAgo) {
|
|
||||||
const lastFetchLabel = lastFetchMs ? new Date(lastFetchMs).toLocaleDateString('de-DE') : 'unbekannt';
|
const lastFetchLabel = lastFetchMs ? new Date(lastFetchMs).toLocaleDateString('de-DE') : 'unbekannt';
|
||||||
reasons.push(`Letzte Abholung: ${lastFetchLabel} (älter als 4 Monate)`);
|
reasons.push(`Letzte Abholung: ${lastFetchLabel} (älter als 4 Monate)`);
|
||||||
}
|
}
|
||||||
const hygieneCertificateUntil =
|
const hygieneCertificateUntil =
|
||||||
memberEntry.hygieneCertificateUntil ?? memberEntry.hygiene_certificate_until ?? null;
|
memberEntry.hygieneCertificateUntil ?? memberEntry.hygiene_certificate_until ?? null;
|
||||||
if (hygieneCertificateUntil) {
|
if (sendWarnings && !skipMap.get(storeId) && hygieneCertificateUntil) {
|
||||||
const expiry = new Date(String(hygieneCertificateUntil).replace(' ', 'T'));
|
const expiry = new Date(String(hygieneCertificateUntil).replace(' ', 'T'));
|
||||||
if (!Number.isNaN(expiry.getTime()) && expiry.getTime() < hygieneCutoff) {
|
if (!Number.isNaN(expiry.getTime()) && expiry.getTime() < hygieneCutoff) {
|
||||||
reasons.push(
|
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 cooldownKey = `${profileId}:${storeId}`;
|
||||||
const lastSentAt = dormantWarningCooldowns.get(cooldownKey);
|
const lastSentAt = dormantWarningCooldowns.get(cooldownKey);
|
||||||
if (lastSentAt && Date.now() - lastSentAt < DORMANT_WARNING_COOLDOWN_MS) {
|
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);
|
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) {
|
function scheduleDormantMembershipCheck(sessionId, settings) {
|
||||||
@@ -1680,6 +1704,7 @@ module.exports = {
|
|||||||
runStoreWatchCheck,
|
runStoreWatchCheck,
|
||||||
runImmediatePickupCheck,
|
runImmediatePickupCheck,
|
||||||
runDormantMembershipCheck,
|
runDormantMembershipCheck,
|
||||||
|
refreshStaleLastPickupInfo,
|
||||||
getRegularPickupSchedule,
|
getRegularPickupSchedule,
|
||||||
scheduleRegularPickupRefresh
|
scheduleRegularPickupRefresh
|
||||||
};
|
};
|
||||||
|
|||||||
27
src/App.js
27
src/App.js
@@ -164,6 +164,33 @@ function App() {
|
|||||||
};
|
};
|
||||||
}, [fetchConfig, initializing, isDirty, session?.token]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
let aborted = false;
|
let aborted = false;
|
||||||
async function lookupNearestStore() {
|
async function lookupNearestStore() {
|
||||||
|
|||||||
37
src/utils/lastPickupInfo.test.js
Normal file
37
src/utils/lastPickupInfo.test.js
Normal file
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user