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