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:
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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} />);
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user