diff --git a/.commitmessage b/.commitmessage index 07f235e..5580a49 100644 --- a/.commitmessage +++ b/.commitmessage @@ -1,4 +1,4 @@ -feat: restrict admin areas and show mobile pickups +fix: show membership date for stores without pickups -Limit monitoring and journal access to admins, reduce regular-user navigation, -and show the latest pickup directly in mobile configuration cards. +Treat Foodsharing's zero lastFetch value as no pickup, store memberSince, and +show compact, visible mobile status information without hover tooltips. diff --git a/services/lastPickupInfo.js b/services/lastPickupInfo.js index d19a9bf..e90977d 100644 --- a/services/lastPickupInfo.js +++ b/services/lastPickupInfo.js @@ -25,16 +25,23 @@ function getStaleLastPickupStoreIds(config = [], now = Date.now()) { function parseLastFetchTimestamp(value) { if (typeof value === 'string') { - return parseTimestamp(value); + if (value.trim() !== '' && Number.isFinite(Number(value))) { + return parseLastFetchTimestamp(Number(value)); + } + const timestamp = parseTimestamp(value); + return timestamp && timestamp > 0 ? timestamp : null; } if (!Number.isFinite(Number(value))) { return null; } const numericValue = Number(value); + if (numericValue <= 0) { + return null; + } return numericValue > 1e12 ? numericValue : numericValue * 1000; } -function updateLastPickupInfo(entry, lastFetchValue, checkedAt = new Date()) { +function updateLastPickupInfo(entry, lastFetchValue, memberSinceValue, checkedAt = new Date()) { if (!entry || typeof entry !== 'object') { return false; } @@ -51,6 +58,17 @@ function updateLastPickupInfo(entry, lastFetchValue, checkedAt = new Date()) { entry.lastPickupAt = lastPickupAt; changed = true; } + } else if (entry.lastPickupAt) { + delete entry.lastPickupAt; + changed = true; + } + const memberSinceTimestamp = parseTimestamp(memberSinceValue); + if (memberSinceTimestamp !== null && memberSinceTimestamp > 0) { + const memberSinceAt = new Date(memberSinceTimestamp).toISOString(); + if (entry.memberSinceAt !== memberSinceAt) { + entry.memberSinceAt = memberSinceAt; + changed = true; + } } return changed; } diff --git a/services/pickupScheduler.js b/services/pickupScheduler.js index 4aaa3a2..82a052a 100644 --- a/services/pickupScheduler.js +++ b/services/pickupScheduler.js @@ -1515,7 +1515,8 @@ async function checkDormantMembers(sessionId, options = {}) { const lastFetchRaw = memberEntry.lastFetch ?? memberEntry.last_fetch ?? null; const lastFetchMs = parseLastFetchTimestamp(lastFetchRaw); const configEntry = configEntryMap.get(storeId)?.entry; - if (updateLastPickupInfo(configEntry, lastFetchRaw)) { + const memberSinceRaw = memberEntry.memberSince ?? memberEntry.member_since ?? null; + if (updateLastPickupInfo(configEntry, lastFetchRaw, memberSinceRaw)) { configChanged = true; } if (sendWarnings && !skipMap.get(storeId) && (!lastFetchMs || lastFetchMs < fourMonthsAgo)) { diff --git a/src/components/DashboardView.js b/src/components/DashboardView.js index 5bec4de..a543d15 100644 --- a/src/components/DashboardView.js +++ b/src/components/DashboardView.js @@ -103,23 +103,17 @@ const ColumnSelectFilter = ({ column, options, placeholder = 'Alle' }) => { }; const formatLastPickupLabel = (value) => { - if (!value) { - return 'Unbekannt'; - } const date = new Date(value); - if (Number.isNaN(date.getTime())) { - return 'Unbekannt'; + if (!value || Number.isNaN(date.getTime()) || date.getTime() <= 0) { + return null; } return date.toLocaleDateString('de-DE'); }; const formatLastPickupRelative = (value) => { - if (!value) { - return 'Unbekannt'; - } const date = new Date(value); - if (Number.isNaN(date.getTime())) { - return 'Unbekannt'; + if (!value || Number.isNaN(date.getTime()) || date.getTime() <= 0) { + return null; } return formatDistanceToNowStrict(date, { addSuffix: true, locale: de }); }; @@ -133,6 +127,23 @@ const getLastPickupTimestamp = (value) => { return Number.isNaN(time) ? -1 : time; }; +const formatMemberSinceLabel = (value) => { + const date = new Date(value); + if (!value || Number.isNaN(date.getTime()) || date.getTime() <= 0) { + return null; + } + return date.toLocaleDateString('de-DE'); +}; + +const getLastPickupDescription = (entry) => { + const lastPickupLabel = formatLastPickupLabel(entry.lastPickupAt); + if (lastPickupLabel) { + return `Letzte Abholung: ${lastPickupLabel}`; + } + const memberSinceLabel = formatMemberSinceLabel(entry.memberSinceAt); + return memberSinceLabel ? `Noch nicht abgeholt. Beitritt: ${memberSinceLabel}` : 'Noch nicht abgeholt'; +}; + const getLastPickupStyle = (value) => { const time = getLastPickupTimestamp(value); if (time <= 0) { @@ -496,13 +507,16 @@ const DashboardView = ({ cell: ({ row }) => { const lastPickupAt = row.original.lastPickupAt; const style = getLastPickupStyle(lastPickupAt); + const relative = formatLastPickupRelative(lastPickupAt); + const memberSince = formatMemberSinceLabel(row.original.memberSinceAt); return ( - {formatLastPickupRelative(lastPickupAt)} + {relative || 'Noch nicht abgeholt'} + {!relative && memberSince && Beitritt: {memberSince}} ); }, @@ -520,9 +534,7 @@ const DashboardView = ({
{ - const date = new Date(value); - if (!value || Number.isNaN(date.getTime())) { - return { relative: 'Unbekannt', exact: null }; +const formatDate = (date) => date.toLocaleDateString('de-DE', { + day: '2-digit', + month: '2-digit', + year: 'numeric' +}); + +const formatLastPickup = (lastPickupAt, memberSinceAt) => { + const date = new Date(lastPickupAt); + if (!lastPickupAt || Number.isNaN(date.getTime()) || date.getTime() <= 0) { + const memberSince = new Date(memberSinceAt); + return { + state: 'never', + memberSince: memberSinceAt && !Number.isNaN(memberSince.getTime()) + ? formatDate(memberSince) + : null + }; } + const ageDays = (Date.now() - date.getTime()) / (1000 * 60 * 60 * 24); return { + state: ageDays >= 90 ? 'stale' : 'current', relative: formatDistanceToNowStrict(date, { addSuffix: true, locale: de }), - exact: date.toLocaleDateString('de-DE') + exact: formatDate(date) }; }; @@ -97,7 +111,7 @@ const MobileConfigList = ({ {filteredEntries.map((entry) => { const range = entry.desiredDateRange || (entry.desiredDate ? { start: entry.desiredDate, end: entry.desiredDate } : null); const slots = formatRegularPickup(regularPickupMap?.[entry.id]); - const lastPickup = formatLastPickup(entry.lastPickupAt); + const lastPickup = formatLastPickup(entry.lastPickupAt, entry.memberSinceAt); return (
{entry.label || `Store ${entry.id}`}

#{entry.id}

{slots &&

Slots: {slots}

} -

- Letzte Abholung: {lastPickup.relative} +

+ {lastPickup.state === 'never' ? ( + <> + Noch nicht abgeholt{lastPickup.memberSince && <> · Beitritt: {lastPickup.memberSince}} + + ) : ( + <> + Letzte Abholung: {lastPickup.relative} · {lastPickup.exact} + + )}

{ expect(screen.getAllByText(/Letzte Abholung:/)[0]).toBeInTheDocument(); }); + it('shows a visible membership date when no pickup has happened yet', () => { + render(); + + expect(screen.getByText(/Noch nicht abgeholt/)).toBeInTheDocument(); + expect(screen.getByText('01.07.2026')).toBeInTheDocument(); + }); + + it('marks stale pickups in the mobile card', () => { + render(); + + expect(document.querySelector('.mobile-config-card__last-pickup--stale')).toBeInTheDocument(); + }); + it('uses the existing configuration callbacks', () => { render(); diff --git a/src/utils/lastPickupInfo.test.js b/src/utils/lastPickupInfo.test.js index f1b0ce8..bfded25 100644 --- a/src/utils/lastPickupInfo.test.js +++ b/src/utils/lastPickupInfo.test.js @@ -21,11 +21,23 @@ describe('last pickup info', () => { 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(updateLastPickupInfo(entry, 1785945600, '2024-05-01T00:00:00.000Z', 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' + lastPickupCheckedAt: '2026-08-05T12:00:00.000Z', + memberSinceAt: '2024-05-01T00:00:00.000Z' + }); + }); + + it('treats a zero last-fetch value as no pickup and records the membership date', () => { + const entry = { id: '69375', lastPickupAt: '1970-01-01T00:00:00.000Z' }; + + expect(updateLastPickupInfo(entry, 0, '2025-07-12T00:00:00.000Z', new Date('2026-08-05T12:00:00.000Z'))).toBe(true); + expect(entry).toEqual({ + id: '69375', + lastPickupCheckedAt: '2026-08-05T12:00:00.000Z', + memberSinceAt: '2025-07-12T00:00:00.000Z' }); }); @@ -33,5 +45,7 @@ describe('last pickup info', () => { expect(parseLastFetchTimestamp(1785945600)).toBe(1785945600000); expect(parseLastFetchTimestamp(1785945600000)).toBe(1785945600000); expect(parseLastFetchTimestamp('2026-08-05T00:00:00.000Z')).toBe(1785888000000); + expect(parseLastFetchTimestamp(0)).toBeNull(); + expect(parseLastFetchTimestamp('0')).toBeNull(); }); });