fix: show membership date for stores without pickups

Treat Foodsharing's zero lastFetch value as no pickup, store memberSince, and
show compact, visible mobile status information without hover tooltips.
This commit is contained in:
2026-08-05 20:22:18 +02:00
parent a64bfe2d3f
commit 61c2488a1e
8 changed files with 121 additions and 35 deletions

View File

@@ -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, Treat Foodsharing's zero lastFetch value as no pickup, store memberSince, and
and show the latest pickup directly in mobile configuration cards. show compact, visible mobile status information without hover tooltips.

View File

@@ -25,16 +25,23 @@ function getStaleLastPickupStoreIds(config = [], now = Date.now()) {
function parseLastFetchTimestamp(value) { function parseLastFetchTimestamp(value) {
if (typeof value === 'string') { 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))) { if (!Number.isFinite(Number(value))) {
return null; return null;
} }
const numericValue = Number(value); const numericValue = Number(value);
if (numericValue <= 0) {
return null;
}
return numericValue > 1e12 ? numericValue : numericValue * 1000; 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') { if (!entry || typeof entry !== 'object') {
return false; return false;
} }
@@ -51,6 +58,17 @@ function updateLastPickupInfo(entry, lastFetchValue, checkedAt = new Date()) {
entry.lastPickupAt = lastPickupAt; entry.lastPickupAt = lastPickupAt;
changed = true; 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; return changed;
} }

View File

@@ -1515,7 +1515,8 @@ async function checkDormantMembers(sessionId, options = {}) {
const lastFetchRaw = memberEntry.lastFetch ?? memberEntry.last_fetch ?? null; const lastFetchRaw = memberEntry.lastFetch ?? memberEntry.last_fetch ?? null;
const lastFetchMs = parseLastFetchTimestamp(lastFetchRaw); const lastFetchMs = parseLastFetchTimestamp(lastFetchRaw);
const configEntry = configEntryMap.get(storeId)?.entry; 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; configChanged = true;
} }
if (sendWarnings && !skipMap.get(storeId) && (!lastFetchMs || lastFetchMs < fourMonthsAgo)) { if (sendWarnings && !skipMap.get(storeId) && (!lastFetchMs || lastFetchMs < fourMonthsAgo)) {

View File

@@ -103,23 +103,17 @@ const ColumnSelectFilter = ({ column, options, placeholder = 'Alle' }) => {
}; };
const formatLastPickupLabel = (value) => { const formatLastPickupLabel = (value) => {
if (!value) {
return 'Unbekannt';
}
const date = new Date(value); const date = new Date(value);
if (Number.isNaN(date.getTime())) { if (!value || Number.isNaN(date.getTime()) || date.getTime() <= 0) {
return 'Unbekannt'; return null;
} }
return date.toLocaleDateString('de-DE'); return date.toLocaleDateString('de-DE');
}; };
const formatLastPickupRelative = (value) => { const formatLastPickupRelative = (value) => {
if (!value) {
return 'Unbekannt';
}
const date = new Date(value); const date = new Date(value);
if (Number.isNaN(date.getTime())) { if (!value || Number.isNaN(date.getTime()) || date.getTime() <= 0) {
return 'Unbekannt'; return null;
} }
return formatDistanceToNowStrict(date, { addSuffix: true, locale: de }); return formatDistanceToNowStrict(date, { addSuffix: true, locale: de });
}; };
@@ -133,6 +127,23 @@ const getLastPickupTimestamp = (value) => {
return Number.isNaN(time) ? -1 : time; 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 getLastPickupStyle = (value) => {
const time = getLastPickupTimestamp(value); const time = getLastPickupTimestamp(value);
if (time <= 0) { if (time <= 0) {
@@ -496,13 +507,16 @@ const DashboardView = ({
cell: ({ row }) => { cell: ({ row }) => {
const lastPickupAt = row.original.lastPickupAt; const lastPickupAt = row.original.lastPickupAt;
const style = getLastPickupStyle(lastPickupAt); const style = getLastPickupStyle(lastPickupAt);
const relative = formatLastPickupRelative(lastPickupAt);
const memberSince = formatMemberSinceLabel(row.original.memberSinceAt);
return ( return (
<span <span
className="text-sm text-gray-700" className="text-sm text-gray-700"
style={style} style={style}
title={formatLastPickupLabel(lastPickupAt)} title={getLastPickupDescription(row.original)}
> >
{formatLastPickupRelative(lastPickupAt)} {relative || 'Noch nicht abgeholt'}
{!relative && memberSince && <span className="block text-xs">Beitritt: {memberSince}</span>}
</span> </span>
); );
}, },
@@ -520,9 +534,7 @@ const DashboardView = ({
<div <div
className="text-center" className="text-center"
title={ title={
row.original.lastPickupAt getLastPickupDescription(row.original)
? `Letzte Abholung: ${formatLastPickupLabel(row.original.lastPickupAt)}`
: 'Keine Info zur letzten Abholung'
} }
> >
<input <input
@@ -543,9 +555,7 @@ const DashboardView = ({
<div <div
className="text-center" className="text-center"
title={ title={
row.original.lastPickupAt getLastPickupDescription(row.original)
? `Letzte Abholung: ${formatLastPickupLabel(row.original.lastPickupAt)}`
: 'Keine Info zur letzten Abholung'
} }
> >
<input <input

View File

@@ -137,10 +137,18 @@
} }
.mobile-config-card__last-pickup strong { .mobile-config-card__last-pickup strong {
color: #334155; color: inherit;
font-weight: 600; font-weight: 600;
} }
.mobile-config-card__last-pickup--stale {
color: #b91c1c !important;
}
.mobile-config-card__last-pickup--never {
color: #1d4ed8 !important;
}
.mobile-config-card__store-link { .mobile-config-card__store-link {
flex: 0 0 auto; flex: 0 0 auto;
width: 2.5rem; width: 2.5rem;

View File

@@ -9,14 +9,28 @@ const FILTER_OPTIONS = [
{ value: 'inactive', label: 'Inaktiv' } { value: 'inactive', label: 'Inaktiv' }
]; ];
const formatLastPickup = (value) => { const formatDate = (date) => date.toLocaleDateString('de-DE', {
const date = new Date(value); day: '2-digit',
if (!value || Number.isNaN(date.getTime())) { month: '2-digit',
return { relative: 'Unbekannt', exact: null }; 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 { 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 }), relative: formatDistanceToNowStrict(date, { addSuffix: true, locale: de }),
exact: date.toLocaleDateString('de-DE') exact: formatDate(date)
}; };
}; };
@@ -97,7 +111,7 @@ const MobileConfigList = ({
{filteredEntries.map((entry) => { {filteredEntries.map((entry) => {
const range = entry.desiredDateRange || (entry.desiredDate ? { start: entry.desiredDate, end: entry.desiredDate } : null); const range = entry.desiredDateRange || (entry.desiredDate ? { start: entry.desiredDate, end: entry.desiredDate } : null);
const slots = formatRegularPickup(regularPickupMap?.[entry.id]); const slots = formatRegularPickup(regularPickupMap?.[entry.id]);
const lastPickup = formatLastPickup(entry.lastPickupAt); const lastPickup = formatLastPickup(entry.lastPickupAt, entry.memberSinceAt);
return ( return (
<article <article
key={entry.id} key={entry.id}
@@ -109,8 +123,16 @@ const MobileConfigList = ({
<h3 className="mobile-config-card__title">{entry.label || `Store ${entry.id}`}</h3> <h3 className="mobile-config-card__title">{entry.label || `Store ${entry.id}`}</h3>
<p className="mobile-config-card__id">#{entry.id}</p> <p className="mobile-config-card__id">#{entry.id}</p>
{slots && <p className="mobile-config-card__slots">Slots: {slots}</p>} {slots && <p className="mobile-config-card__slots">Slots: {slots}</p>}
<p className="mobile-config-card__last-pickup" title={lastPickup.exact || undefined}> <p className={`mobile-config-card__last-pickup mobile-config-card__last-pickup--${lastPickup.state}`}>
Letzte Abholung: <strong>{lastPickup.relative}</strong> {lastPickup.state === 'never' ? (
<>
Noch nicht abgeholt{lastPickup.memberSince && <> · Beitritt: <strong>{lastPickup.memberSince}</strong></>}
</>
) : (
<>
Letzte Abholung: <strong>{lastPickup.relative}</strong> · {lastPickup.exact}
</>
)}
</p> </p>
</div> </div>
<a <a

View File

@@ -55,6 +55,19 @@ describe('MobileConfigList', () => {
expect(screen.getAllByText(/Letzte Abholung:/)[0]).toBeInTheDocument(); expect(screen.getAllByText(/Letzte Abholung:/)[0]).toBeInTheDocument();
}); });
it('shows a visible membership date when no pickup has happened yet', () => {
render(<MobileConfigList {...props} entries={[{ ...entry, lastPickupAt: null, memberSinceAt: '2026-07-01T00:00:00.000Z' }]} />);
expect(screen.getByText(/Noch nicht abgeholt/)).toBeInTheDocument();
expect(screen.getByText('01.07.2026')).toBeInTheDocument();
});
it('marks stale pickups in the mobile card', () => {
render(<MobileConfigList {...props} entries={[{ ...entry, lastPickupAt: '2025-01-01T00:00:00.000Z' }]} />);
expect(document.querySelector('.mobile-config-card__last-pickup--stale')).toBeInTheDocument();
});
it('uses the existing configuration callbacks', () => { it('uses the existing configuration callbacks', () => {
render(<MobileConfigList {...props} />); render(<MobileConfigList {...props} />);

View File

@@ -21,11 +21,23 @@ describe('last pickup info', () => {
it('records a successful check independently from the pickup date', () => { it('records a successful check independently from the pickup date', () => {
const entry = { id: '33875', lastPickupAt: '2026-01-01T00:00:00.000Z' }; 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({ expect(entry).toEqual({
id: '33875', id: '33875',
lastPickupAt: '2026-08-05T16:00:00.000Z', 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(1785945600)).toBe(1785945600000);
expect(parseLastFetchTimestamp(1785945600000)).toBe(1785945600000); expect(parseLastFetchTimestamp(1785945600000)).toBe(1785945600000);
expect(parseLastFetchTimestamp('2026-08-05T00:00:00.000Z')).toBe(1785888000000); expect(parseLastFetchTimestamp('2026-08-05T00:00:00.000Z')).toBe(1785888000000);
expect(parseLastFetchTimestamp(0)).toBeNull();
expect(parseLastFetchTimestamp('0')).toBeNull();
}); });
}); });