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,
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.

View File

@@ -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;
}

View File

@@ -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)) {

View File

@@ -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 (
<span
className="text-sm text-gray-700"
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>
);
},
@@ -520,9 +534,7 @@ const DashboardView = ({
<div
className="text-center"
title={
row.original.lastPickupAt
? `Letzte Abholung: ${formatLastPickupLabel(row.original.lastPickupAt)}`
: 'Keine Info zur letzten Abholung'
getLastPickupDescription(row.original)
}
>
<input
@@ -543,9 +555,7 @@ const DashboardView = ({
<div
className="text-center"
title={
row.original.lastPickupAt
? `Letzte Abholung: ${formatLastPickupLabel(row.original.lastPickupAt)}`
: 'Keine Info zur letzten Abholung'
getLastPickupDescription(row.original)
}
>
<input

View File

@@ -137,10 +137,18 @@
}
.mobile-config-card__last-pickup strong {
color: #334155;
color: inherit;
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 {
flex: 0 0 auto;
width: 2.5rem;

View File

@@ -9,14 +9,28 @@ const FILTER_OPTIONS = [
{ value: 'inactive', label: 'Inaktiv' }
];
const formatLastPickup = (value) => {
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 (
<article
key={entry.id}
@@ -109,8 +123,16 @@ const MobileConfigList = ({
<h3 className="mobile-config-card__title">{entry.label || `Store ${entry.id}`}</h3>
<p className="mobile-config-card__id">#{entry.id}</p>
{slots && <p className="mobile-config-card__slots">Slots: {slots}</p>}
<p className="mobile-config-card__last-pickup" title={lastPickup.exact || undefined}>
Letzte Abholung: <strong>{lastPickup.relative}</strong>
<p className={`mobile-config-card__last-pickup mobile-config-card__last-pickup--${lastPickup.state}`}>
{lastPickup.state === 'never' ? (
<>
Noch nicht abgeholt{lastPickup.memberSince && <> · Beitritt: <strong>{lastPickup.memberSince}</strong></>}
</>
) : (
<>
Letzte Abholung: <strong>{lastPickup.relative}</strong> · {lastPickup.exact}
</>
)}
</p>
</div>
<a

View File

@@ -55,6 +55,19 @@ describe('MobileConfigList', () => {
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', () => {
render(<MobileConfigList {...props} />);

View File

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