chore: sync running docker state
Commit the app code currently running in Docker, including scheduler, store status, journal reminder, maintenance mode, and Foodsharing API check updates. Remove generated runtime logs from version control and ignore local runtime captures.
This commit is contained in:
64
src/App.js
64
src/App.js
@@ -40,6 +40,7 @@ function App() {
|
||||
const [focusedStoreId, setFocusedStoreId] = useState(null);
|
||||
const [nearestStoreLabel, setNearestStoreLabel] = useState(null);
|
||||
const [regularPickupMap, setRegularPickupMap] = useState({});
|
||||
const [registeredPickups, setRegisteredPickups] = useState([]);
|
||||
const bootstrapOnceRef = useRef(false);
|
||||
const minSelectableDate = useMemo(() => startOfDay(new Date()), []);
|
||||
|
||||
@@ -150,6 +151,19 @@ function App() {
|
||||
finishSyncProgress
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!session?.token || isDirty || initializing) {
|
||||
return undefined;
|
||||
}
|
||||
fetchConfig(session.token, { silent: true });
|
||||
const intervalId = setInterval(() => {
|
||||
fetchConfig(session.token, { silent: true });
|
||||
}, 60 * 1000);
|
||||
return () => {
|
||||
clearInterval(intervalId);
|
||||
};
|
||||
}, [fetchConfig, initializing, isDirty, session?.token]);
|
||||
|
||||
useEffect(() => {
|
||||
let aborted = false;
|
||||
async function lookupNearestStore() {
|
||||
@@ -244,6 +258,7 @@ function App() {
|
||||
const resetSessionState = useCallback(() => {
|
||||
setConfig([]);
|
||||
setStores([]);
|
||||
setRegisteredPickups([]);
|
||||
setStatus('');
|
||||
setError('');
|
||||
clearAdminSettings();
|
||||
@@ -252,6 +267,7 @@ function App() {
|
||||
}, [
|
||||
setConfig,
|
||||
setStores,
|
||||
setRegisteredPickups,
|
||||
setStatus,
|
||||
setError,
|
||||
clearAdminSettings,
|
||||
@@ -282,6 +298,38 @@ function App() {
|
||||
sessionToken: session?.token
|
||||
});
|
||||
|
||||
const loadRegisteredPickups = useCallback(
|
||||
async (tokenOverride) => {
|
||||
const tokenToUse = tokenOverride || session?.token;
|
||||
if (!tokenToUse) {
|
||||
setRegisteredPickups([]);
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
const response = await authorizedFetch('/api/pickups/registered', {}, tokenToUse);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
const pickups = Array.isArray(data?.pickups) ? data.pickups : [];
|
||||
setRegisteredPickups(pickups);
|
||||
return pickups;
|
||||
} catch (error) {
|
||||
console.warn('Eingetragene Foodsharing-Slots konnten nicht geladen werden:', error.message);
|
||||
setRegisteredPickups([]);
|
||||
return [];
|
||||
}
|
||||
},
|
||||
[authorizedFetch, session?.token]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!session?.token || initializing) {
|
||||
return;
|
||||
}
|
||||
loadRegisteredPickups(session.token);
|
||||
}, [initializing, loadRegisteredPickups, session?.token]);
|
||||
|
||||
const handleLogin = async (event) => {
|
||||
event.preventDefault();
|
||||
setLoading(true);
|
||||
@@ -319,6 +367,7 @@ function App() {
|
||||
tokenOverride: data.token
|
||||
});
|
||||
}
|
||||
await loadRegisteredPickups(data.token);
|
||||
updateSyncProgress('Login abgeschlossen', 98);
|
||||
setStatus('Anmeldung erfolgreich. Konfiguration geladen.');
|
||||
setTimeout(() => setStatus(''), 3000);
|
||||
@@ -395,6 +444,7 @@ function App() {
|
||||
tokenOverride: storedToken
|
||||
});
|
||||
}
|
||||
await loadRegisteredPickups(storedToken);
|
||||
}
|
||||
finishSyncProgress();
|
||||
} catch (err) {
|
||||
@@ -415,6 +465,7 @@ function App() {
|
||||
bootstrapSession,
|
||||
finishSyncProgress,
|
||||
getStoredToken,
|
||||
loadRegisteredPickups,
|
||||
nudgeSyncProgress,
|
||||
startSyncProgress,
|
||||
syncStoresWithProgress,
|
||||
@@ -672,6 +723,14 @@ function App() {
|
||||
return { ...preferences.location };
|
||||
}, [preferences?.location, nearestStoreLabel]);
|
||||
|
||||
const handleRefreshStoresAndPickups = useCallback(
|
||||
async (options) => {
|
||||
await refreshStoresAndConfig(options);
|
||||
await loadRegisteredPickups();
|
||||
},
|
||||
[loadRegisteredPickups, refreshStoresAndConfig]
|
||||
);
|
||||
|
||||
const sharedNotificationProps = {
|
||||
error: notificationError,
|
||||
message: notificationMessage,
|
||||
@@ -736,7 +795,7 @@ function App() {
|
||||
const dashboardContent = (
|
||||
<DashboardView
|
||||
session={session}
|
||||
onRefresh={refreshStoresAndConfig}
|
||||
onRefresh={handleRefreshStoresAndPickups}
|
||||
onLogout={handleLogout}
|
||||
notificationPanelOpen={notificationPanelOpen}
|
||||
onToggleNotificationPanel={() => setNotificationPanelOpen((prev) => !prev)}
|
||||
@@ -752,6 +811,7 @@ function App() {
|
||||
status={status}
|
||||
visibleConfig={visibleConfig}
|
||||
config={config}
|
||||
registeredPickups={registeredPickups}
|
||||
onToggleActive={handleToggleActive}
|
||||
onToggleProfileCheck={handleToggleProfileCheck}
|
||||
onToggleOnlyNotify={handleToggleOnlyNotify}
|
||||
@@ -852,6 +912,8 @@ function App() {
|
||||
<RangePickerModal
|
||||
entry={activeRangeEntry}
|
||||
minDate={minSelectableDate}
|
||||
config={config}
|
||||
registeredPickups={registeredPickups}
|
||||
onSelectRange={(startDate, endDate) =>
|
||||
activeRangeEntry ? handleDateRangeSelection(activeRangeEntry.id, startDate, endDate) : null
|
||||
}
|
||||
|
||||
@@ -207,7 +207,7 @@ const AdminSettingsPanel = ({
|
||||
|
||||
<SettingSection
|
||||
title="Store-Watch Monitoring"
|
||||
subtitle="Steuert die Team-Status-Abfragen für geschlossene Betriebe (teamSearchStatus)."
|
||||
subtitle="Steuert die Team-Status-Abfragen für geschlossene Betriebe (Foodsharing-Feld `teamStatus`)."
|
||||
>
|
||||
<SettingField
|
||||
label="Store-Watch Cron"
|
||||
@@ -281,6 +281,152 @@ const AdminSettingsPanel = ({
|
||||
</SettingField>
|
||||
</SettingSection>
|
||||
|
||||
<SettingSection
|
||||
title="Nachrichten-Watcher"
|
||||
subtitle="Reagiert auf Betriebs-Teamnachrichten, um danach gezielt nur den betroffenen Pickup-Store zu prüfen."
|
||||
>
|
||||
<div className="rounded-lg border border-blue-200 bg-blue-50 p-4 text-sm text-blue-900">
|
||||
<p className="font-semibold">Hybrid-Modus</p>
|
||||
<p>
|
||||
Socket.IO ist der Primärweg mit sehr wenig Requests. Der Fallback pollt Konversationen nur,
|
||||
wenn der Socket nicht verbunden ist.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<SettingField
|
||||
label="Socket.IO aktiv"
|
||||
description="Hält eine dauerhafte Verbindung zu Foodsharing offen und reagiert auf Push-Events. Das reduziert REST-Polling."
|
||||
>
|
||||
<label className="inline-flex items-center gap-2 text-sm text-gray-700">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={adminSettings.messageWatcherSocketEnabled || false}
|
||||
onChange={(event) => onSettingChange('messageWatcherSocketEnabled', event.target.checked)}
|
||||
className="h-4 w-4 rounded text-purple-600 focus:ring-purple-500"
|
||||
/>
|
||||
<span>Socket-Verbindung verwenden</span>
|
||||
</label>
|
||||
</SettingField>
|
||||
|
||||
<SettingField
|
||||
label="Fallback-Poll aktiv"
|
||||
description="Prüft Konversationen nur zum Cron-Zeitpunkt und nur, wenn der Socket getrennt ist. Sollte für die Anfangsphase aktiv bleiben."
|
||||
>
|
||||
<label className="inline-flex items-center gap-2 text-sm text-gray-700">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={adminSettings.messageWatcherFallbackEnabled || false}
|
||||
onChange={(event) => onSettingChange('messageWatcherFallbackEnabled', event.target.checked)}
|
||||
className="h-4 w-4 rounded text-purple-600 focus:ring-purple-500"
|
||||
/>
|
||||
<span>Fallback-Polling verwenden</span>
|
||||
</label>
|
||||
</SettingField>
|
||||
|
||||
<SettingField
|
||||
label="Fallback-Poll Cron"
|
||||
description="Nur relevant, wenn der Socket nicht verbunden ist. Standard: stündlich tagsüber von 6 bis 23 Uhr."
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
value={adminSettings.messageWatcherFallbackCron || ''}
|
||||
onChange={(event) => onSettingChange('messageWatcherFallbackCron', event.target.value)}
|
||||
className="border rounded p-2 w-full focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
|
||||
placeholder="z. B. 0 6-23 * * *"
|
||||
/>
|
||||
</SettingField>
|
||||
|
||||
<SettingField
|
||||
label="Debug-Logging aktiv"
|
||||
description="Schreibt in der Anfangsphase strukturierte Watcher-Logs in die Debug-Request-Logs: Start, Socket-Status, Events, Fallback-Polls und Trigger."
|
||||
>
|
||||
<label className="inline-flex items-center gap-2 text-sm text-gray-700">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={adminSettings.messageWatcherDebugLogging || false}
|
||||
onChange={(event) => onSettingChange('messageWatcherDebugLogging', event.target.checked)}
|
||||
className="h-4 w-4 rounded text-purple-600 focus:ring-purple-500"
|
||||
/>
|
||||
<span>Ausführlich loggen</span>
|
||||
</label>
|
||||
</SettingField>
|
||||
</SettingSection>
|
||||
|
||||
<SettingSection
|
||||
title="Wartungsmodus"
|
||||
subtitle="Pausiert automatische Foodsharing-Abläufe, wenn gehäuft systemische API-Fehler auftreten."
|
||||
>
|
||||
<div className="rounded-lg border border-amber-200 bg-amber-50 p-4 text-sm text-amber-900">
|
||||
<p className="font-semibold">
|
||||
Status: {adminSettings.maintenanceModeActive ? 'Aktiv' : 'Inaktiv'}
|
||||
</p>
|
||||
{adminSettings.maintenanceModeActivatedAt && (
|
||||
<p>Aktiv seit: {new Date(adminSettings.maintenanceModeActivatedAt).toLocaleString('de-DE')}</p>
|
||||
)}
|
||||
{adminSettings.maintenanceModeReason && <p>Grund: {adminSettings.maintenanceModeReason}</p>}
|
||||
</div>
|
||||
|
||||
<SettingField
|
||||
label="Wartungsmodus aktiv"
|
||||
description="Kann manuell deaktiviert werden. Bei weiter anhaltenden API-Fehlern wird er erneut automatisch aktiviert."
|
||||
>
|
||||
<label className="inline-flex items-center gap-2 text-sm text-gray-700">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={adminSettings.maintenanceModeActive || false}
|
||||
onChange={(event) => onSettingChange('maintenanceModeActive', event.target.checked)}
|
||||
className="h-4 w-4 rounded text-purple-600 focus:ring-purple-500"
|
||||
/>
|
||||
<span>Aktiv</span>
|
||||
</label>
|
||||
</SettingField>
|
||||
|
||||
<SettingField
|
||||
label="Automatische Aktivierung"
|
||||
description="Aktiviert den Wartungsmodus automatisch bei gehäuften relevanten Outbound-Fehlern wie 429, Timeout oder 5xx."
|
||||
>
|
||||
<label className="inline-flex items-center gap-2 text-sm text-gray-700">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={adminSettings.maintenanceModeAutoEnabled || false}
|
||||
onChange={(event) => onSettingChange('maintenanceModeAutoEnabled', event.target.checked)}
|
||||
className="h-4 w-4 rounded text-purple-600 focus:ring-purple-500"
|
||||
/>
|
||||
<span>Automatisch aktivieren</span>
|
||||
</label>
|
||||
</SettingField>
|
||||
|
||||
<SettingField
|
||||
label="Fehlerschwelle"
|
||||
description="Ab wie vielen relevanten Outbound-Fehlern innerhalb des Fensters der Wartungsmodus automatisch aktiviert wird."
|
||||
>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
value={adminSettings.maintenanceModeErrorThreshold}
|
||||
onChange={(event) => onSettingChange('maintenanceModeErrorThreshold', event.target.value, true)}
|
||||
className="border rounded p-2 w-full focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
|
||||
placeholder="z. B. 8"
|
||||
/>
|
||||
</SettingField>
|
||||
|
||||
<SettingField
|
||||
label="Fehlerfenster (Minuten)"
|
||||
description="Zeitfenster für die Auswertung relevanter Outbound-Fehler."
|
||||
>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
value={adminSettings.maintenanceModeErrorWindowMinutes}
|
||||
onChange={(event) =>
|
||||
onSettingChange('maintenanceModeErrorWindowMinutes', event.target.value, true)
|
||||
}
|
||||
className="border rounded p-2 w-full focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
|
||||
placeholder="z. B. 10"
|
||||
/>
|
||||
</SettingField>
|
||||
</SettingSection>
|
||||
|
||||
<SettingSection
|
||||
title="Ignorierte Slots"
|
||||
subtitle="Diese Einträge überspringt der Scheduler dauerhaft (z. B. TVS-Zeiten oder Test-Slots)."
|
||||
|
||||
@@ -13,6 +13,58 @@ import NotificationPanel from './NotificationPanel';
|
||||
|
||||
const CONFIG_TABLE_STATE_KEY = 'dashboardConfigTableState';
|
||||
const columnHelper = createColumnHelper();
|
||||
const formatDateKey = (value) => {
|
||||
const date = value instanceof Date ? value : new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return '';
|
||||
}
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}`;
|
||||
};
|
||||
|
||||
const addDays = (date, days) => {
|
||||
const next = new Date(date);
|
||||
next.setDate(next.getDate() + days);
|
||||
return next;
|
||||
};
|
||||
|
||||
const formatPickupListTime = (value) => {
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return 'Unbekannt';
|
||||
}
|
||||
const time = date.toLocaleTimeString('de-DE', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
const today = new Date();
|
||||
const dateKey = formatDateKey(date);
|
||||
if (dateKey === formatDateKey(today)) {
|
||||
return `Heute ${time}`;
|
||||
}
|
||||
if (dateKey === formatDateKey(addDays(today, 1))) {
|
||||
return `Morgen ${time}`;
|
||||
}
|
||||
const diffDays = Math.round((new Date(`${dateKey}T00:00:00`).getTime() - new Date(`${formatDateKey(today)}T00:00:00`).getTime()) / (24 * 60 * 60 * 1000));
|
||||
if (diffDays > 1 && diffDays < 7) {
|
||||
const weekday = date.toLocaleDateString('de-DE', { weekday: 'short' });
|
||||
return `${weekday} ${time}`;
|
||||
}
|
||||
return date.toLocaleString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
};
|
||||
|
||||
const compareByDate = (a, b) => {
|
||||
const aTime = new Date(a.date).getTime();
|
||||
const bTime = new Date(b.date).getTime();
|
||||
return (Number.isNaN(aTime) ? 0 : aTime) - (Number.isNaN(bTime) ? 0 : bTime);
|
||||
};
|
||||
|
||||
const ColumnTextFilter = ({ column, placeholder }) => {
|
||||
if (!column.getCanFilter()) {
|
||||
@@ -148,6 +200,7 @@ const DashboardView = ({
|
||||
status,
|
||||
visibleConfig,
|
||||
config,
|
||||
registeredPickups,
|
||||
onToggleActive,
|
||||
onToggleProfileCheck,
|
||||
onToggleOnlyNotify,
|
||||
@@ -206,6 +259,38 @@ const DashboardView = ({
|
||||
[visibleConfig]
|
||||
);
|
||||
|
||||
const upcomingRegisteredPickups = useMemo(
|
||||
() =>
|
||||
(Array.isArray(registeredPickups) ? registeredPickups : [])
|
||||
.filter((pickup) => pickup?.date)
|
||||
.slice()
|
||||
.sort(compareByDate),
|
||||
[registeredPickups]
|
||||
);
|
||||
|
||||
const plannedPickupEntries = useMemo(
|
||||
() =>
|
||||
(Array.isArray(visibleConfig) ? visibleConfig : [])
|
||||
.filter((entry) => entry?.active)
|
||||
.map((entry) => {
|
||||
const range = entry.desiredDateRange || (entry.desiredDate ? { start: entry.desiredDate, end: entry.desiredDate } : null);
|
||||
let schedule = 'Aktiv ohne Zeitfenster';
|
||||
if (range?.start || range?.end) {
|
||||
schedule = formatRangeLabel(range.start || range.end || '', range.end || range.start || '');
|
||||
} else if (entry.desiredWeekday) {
|
||||
schedule = entry.desiredWeekday;
|
||||
}
|
||||
return {
|
||||
id: entry.id,
|
||||
label: entry.label || `Store ${entry.id}`,
|
||||
schedule,
|
||||
onlyNotify: !!entry.onlyNotify
|
||||
};
|
||||
})
|
||||
.sort((a, b) => a.label.localeCompare(b.label, 'de-DE')),
|
||||
[formatRangeLabel, visibleConfig]
|
||||
);
|
||||
|
||||
const weekdaysOptions = useMemo(
|
||||
() =>
|
||||
weekdays.map((day) => ({
|
||||
@@ -785,6 +870,61 @@ const DashboardView = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-4 grid gap-3 lg:grid-cols-2">
|
||||
<section className="border border-gray-200 rounded-lg bg-gray-50 p-3">
|
||||
<div className="flex items-center justify-between gap-3 mb-2">
|
||||
<h2 className="text-sm font-semibold text-gray-800">Foodsharing-Slots</h2>
|
||||
<span className="text-xs text-gray-500">{upcomingRegisteredPickups.length}</span>
|
||||
</div>
|
||||
{upcomingRegisteredPickups.length === 0 ? (
|
||||
<p className="text-sm text-gray-500">Keine eingetragenen künftigen Slots gefunden.</p>
|
||||
) : (
|
||||
<ul className="space-y-1.5 max-h-40 overflow-y-auto">
|
||||
{upcomingRegisteredPickups.slice(0, 8).map((pickup) => (
|
||||
<li
|
||||
key={`${pickup.storeId || 'store'}-${pickup.date}-${pickup.description || ''}`}
|
||||
className="flex items-start justify-between gap-3 rounded bg-white px-2 py-1.5 text-sm"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-medium text-gray-800">{pickup.storeName}</p>
|
||||
<p className="truncate text-xs text-gray-500">{pickup.description || 'Abholslot'}</p>
|
||||
</div>
|
||||
<div className="shrink-0 text-right">
|
||||
<p className="text-xs font-medium text-blue-700">{formatPickupListTime(pickup.date)}</p>
|
||||
<p className="text-xs text-gray-500">{pickup.isConfirmed ? 'bestätigt' : 'offen'}</p>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="border border-gray-200 rounded-lg bg-gray-50 p-3">
|
||||
<div className="flex items-center justify-between gap-3 mb-2">
|
||||
<h2 className="text-sm font-semibold text-gray-800">Pickup-Manager geplant</h2>
|
||||
<span className="text-xs text-gray-500">{plannedPickupEntries.length}</span>
|
||||
</div>
|
||||
{plannedPickupEntries.length === 0 ? (
|
||||
<p className="text-sm text-gray-500">Keine aktiven Planungen im Pickup Manager.</p>
|
||||
) : (
|
||||
<ul className="space-y-1.5 max-h-40 overflow-y-auto">
|
||||
{plannedPickupEntries.slice(0, 8).map((entry) => (
|
||||
<li
|
||||
key={entry.id}
|
||||
className="flex items-center justify-between gap-3 rounded bg-white px-2 py-1.5 text-sm"
|
||||
>
|
||||
<p className="min-w-0 truncate font-medium text-gray-800">{entry.label}</p>
|
||||
<div className="shrink-0 text-right">
|
||||
<p className="text-xs font-medium text-emerald-700">{entry.schedule}</p>
|
||||
{entry.onlyNotify && <p className="text-xs text-gray-500">nur Hinweis</p>}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full bg-white border border-gray-200">
|
||||
<thead className="bg-gray-100">
|
||||
|
||||
@@ -320,7 +320,7 @@ const DebugPage = ({ authorizedFetch }) => {
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-800">Request-Debug</h1>
|
||||
<p className="text-gray-500 text-sm">Alle Requests der letzten 14 Tage</p>
|
||||
<p className="text-gray-500 text-sm">Alle Requests der letzten 4 Wochen</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={fetchLogs}
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import ConfirmationDialog from './ConfirmationDialog';
|
||||
import './JournalPage.css';
|
||||
import {
|
||||
formatReferenceLabel,
|
||||
formatReminderOffset,
|
||||
getNextReminderSchedule,
|
||||
getReminderOffset,
|
||||
getReminderReference,
|
||||
referenceEventOptions
|
||||
} from '../utils/journalReminder';
|
||||
|
||||
const intervalLabels = {
|
||||
monthly: 'Monatlich',
|
||||
@@ -12,11 +20,6 @@ const reminderUnitLabels = {
|
||||
weeks: 'Wochen',
|
||||
months: 'Monate'
|
||||
};
|
||||
const reminderUnitSingular = {
|
||||
days: 'Tag',
|
||||
weeks: 'Woche',
|
||||
months: 'Monat'
|
||||
};
|
||||
|
||||
const JournalPage = ({ authorizedFetch, stores }) => {
|
||||
const [entries, setEntries] = useState([]);
|
||||
@@ -53,7 +56,11 @@ const JournalPage = ({ authorizedFetch, stores }) => {
|
||||
reminderEnabled: true,
|
||||
reminderInterval: 'yearly',
|
||||
reminderBeforeValue: 6,
|
||||
reminderBeforeUnit: 'weeks'
|
||||
reminderBeforeUnit: 'weeks',
|
||||
reminderReferenceMode: 'pickupDate',
|
||||
reminderReferenceEventKey: 'easter_sunday',
|
||||
reminderReferenceOffsetValue: 0,
|
||||
reminderReferenceOffsetUnit: 'weeks'
|
||||
});
|
||||
const [images, setImages] = useState([]);
|
||||
const [existingImages, setExistingImages] = useState([]);
|
||||
@@ -89,12 +96,6 @@ const JournalPage = ({ authorizedFetch, stores }) => {
|
||||
return map;
|
||||
}, [sortedStores, entries]);
|
||||
|
||||
const storeFilterOptions = useMemo(() => {
|
||||
const list = Array.from(storeLabelMap.entries()).map(([id, name]) => ({ id, name }));
|
||||
list.sort((a, b) => a.name.localeCompare(b.name));
|
||||
return list;
|
||||
}, [storeLabelMap]);
|
||||
|
||||
const parseDateValue = useCallback((value) => {
|
||||
if (!value) {
|
||||
return null;
|
||||
@@ -106,94 +107,66 @@ const JournalPage = ({ authorizedFetch, stores }) => {
|
||||
return parsed.getTime();
|
||||
}, []);
|
||||
|
||||
const getIntervalMonths = useCallback((interval) => {
|
||||
if (interval === 'monthly') {
|
||||
return 1;
|
||||
const formatDateLabel = useCallback((date) => {
|
||||
if (!(date instanceof Date) || Number.isNaN(date.getTime())) {
|
||||
return 'unbekannt';
|
||||
}
|
||||
if (interval === 'quarterly') {
|
||||
return 3;
|
||||
}
|
||||
return 12;
|
||||
}, []);
|
||||
|
||||
const getReminderOffset = useCallback((reminder) => {
|
||||
const unit = ['days', 'weeks', 'months'].includes(reminder?.beforeUnit) ? reminder.beforeUnit : 'days';
|
||||
const rawValue = Number(reminder?.beforeValue);
|
||||
const fallbackValue = Number(reminder?.daysBefore);
|
||||
const value = Number.isFinite(rawValue)
|
||||
? Math.max(0, rawValue)
|
||||
: Number.isFinite(fallbackValue)
|
||||
? Math.max(0, fallbackValue)
|
||||
: 6;
|
||||
return { unit, value };
|
||||
}, []);
|
||||
|
||||
const subtractReminderOffset = useCallback(
|
||||
(date, reminder) => {
|
||||
const { unit, value } = getReminderOffset(reminder);
|
||||
const copy = new Date(date.getTime());
|
||||
if (unit === 'weeks') {
|
||||
copy.setDate(copy.getDate() - value * 7);
|
||||
return copy;
|
||||
}
|
||||
if (unit === 'months') {
|
||||
copy.setMonth(copy.getMonth() - value);
|
||||
return copy;
|
||||
}
|
||||
copy.setDate(copy.getDate() - value);
|
||||
return copy;
|
||||
},
|
||||
[getReminderOffset]
|
||||
);
|
||||
|
||||
const formatReminderOffset = useCallback(
|
||||
(reminder) => {
|
||||
const { unit, value } = getReminderOffset(reminder);
|
||||
const label = value === 1 ? reminderUnitSingular[unit] : reminderUnitLabels[unit];
|
||||
return `${value} ${label}`;
|
||||
},
|
||||
[getReminderOffset]
|
||||
);
|
||||
|
||||
const addMonths = useCallback((date, months) => {
|
||||
const copy = new Date(date.getTime());
|
||||
copy.setMonth(copy.getMonth() + months);
|
||||
return copy;
|
||||
}, []);
|
||||
|
||||
const startOfDay = useCallback((date) => {
|
||||
return new Date(date.getFullYear(), date.getMonth(), date.getDate());
|
||||
return date.toLocaleDateString('de-DE', {
|
||||
weekday: 'long',
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric'
|
||||
});
|
||||
}, []);
|
||||
|
||||
const getNextReminderTimestamp = useCallback(
|
||||
(entry) => {
|
||||
if (!entry?.reminder?.enabled || !entry.pickupDate) {
|
||||
return null;
|
||||
}
|
||||
const baseDate = new Date(`${entry.pickupDate}T00:00:00`);
|
||||
if (Number.isNaN(baseDate.getTime())) {
|
||||
return null;
|
||||
}
|
||||
const intervalMonths = getIntervalMonths(entry.reminder.interval);
|
||||
const todayStart = startOfDay(new Date());
|
||||
let occurrence = startOfDay(baseDate);
|
||||
const guardYear = todayStart.getFullYear() + 200;
|
||||
while (occurrence < todayStart && occurrence.getFullYear() < guardYear) {
|
||||
occurrence = startOfDay(addMonths(occurrence, intervalMonths));
|
||||
}
|
||||
if (occurrence.getFullYear() >= guardYear) {
|
||||
return null;
|
||||
}
|
||||
let reminderDate = startOfDay(subtractReminderOffset(occurrence, entry.reminder));
|
||||
if (reminderDate < todayStart) {
|
||||
occurrence = startOfDay(addMonths(occurrence, intervalMonths));
|
||||
reminderDate = startOfDay(subtractReminderOffset(occurrence, entry.reminder));
|
||||
}
|
||||
return reminderDate.getTime();
|
||||
const schedule = getNextReminderSchedule(entry);
|
||||
return schedule?.reminderDate?.getTime() ?? null;
|
||||
},
|
||||
[addMonths, getIntervalMonths, startOfDay, subtractReminderOffset]
|
||||
[]
|
||||
);
|
||||
|
||||
const reminderPreview = useMemo(() => {
|
||||
if (!form.reminderEnabled) {
|
||||
return null;
|
||||
}
|
||||
const previewEntry = {
|
||||
pickupDate: form.pickupDate,
|
||||
reminder: {
|
||||
enabled: true,
|
||||
interval: form.reminderReferenceMode === 'event' ? 'yearly' : form.reminderInterval,
|
||||
beforeUnit: form.reminderBeforeUnit,
|
||||
beforeValue: Number(form.reminderBeforeValue),
|
||||
reference: {
|
||||
mode: form.reminderReferenceMode,
|
||||
eventKey: form.reminderReferenceEventKey,
|
||||
occurrenceOffsetValue: Number(form.reminderReferenceOffsetValue),
|
||||
occurrenceOffsetUnit: form.reminderReferenceOffsetUnit
|
||||
}
|
||||
}
|
||||
};
|
||||
const schedule = getNextReminderSchedule(previewEntry);
|
||||
if (!schedule?.occurrence || !schedule?.reminderDate) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
occurrenceLabel: formatDateLabel(schedule.occurrence),
|
||||
reminderLabel: formatDateLabel(schedule.reminderDate)
|
||||
};
|
||||
}, [
|
||||
form.pickupDate,
|
||||
form.reminderBeforeUnit,
|
||||
form.reminderBeforeValue,
|
||||
form.reminderEnabled,
|
||||
form.reminderInterval,
|
||||
form.reminderReferenceEventKey,
|
||||
form.reminderReferenceMode,
|
||||
form.reminderReferenceOffsetUnit,
|
||||
form.reminderReferenceOffsetValue,
|
||||
formatDateLabel
|
||||
]);
|
||||
|
||||
const filteredEntries = useMemo(() => {
|
||||
const query = filters.query.trim().toLowerCase();
|
||||
const fromValue = parseDateValue(filters.dateFrom);
|
||||
@@ -335,15 +308,19 @@ const JournalPage = ({ authorizedFetch, stores }) => {
|
||||
setFormOpen(false);
|
||||
setClosingForm(false);
|
||||
setEditingStoreLabel('');
|
||||
setForm({
|
||||
storeId: '',
|
||||
pickupDate: '',
|
||||
note: '',
|
||||
reminderEnabled: true,
|
||||
reminderInterval: 'yearly',
|
||||
reminderBeforeValue: 6,
|
||||
reminderBeforeUnit: 'weeks'
|
||||
});
|
||||
setForm({
|
||||
storeId: '',
|
||||
pickupDate: '',
|
||||
note: '',
|
||||
reminderEnabled: true,
|
||||
reminderInterval: 'yearly',
|
||||
reminderBeforeValue: 6,
|
||||
reminderBeforeUnit: 'weeks',
|
||||
reminderReferenceMode: 'pickupDate',
|
||||
reminderReferenceEventKey: 'easter_sunday',
|
||||
reminderReferenceOffsetValue: 0,
|
||||
reminderReferenceOffsetUnit: 'weeks'
|
||||
});
|
||||
}, [images]);
|
||||
|
||||
const resetForm = useCallback(() => {
|
||||
@@ -434,9 +411,15 @@ const JournalPage = ({ authorizedFetch, stores }) => {
|
||||
note: form.note.trim(),
|
||||
reminder: {
|
||||
enabled: form.reminderEnabled,
|
||||
interval: form.reminderInterval,
|
||||
interval: form.reminderReferenceMode === 'event' ? 'yearly' : form.reminderInterval,
|
||||
beforeUnit: form.reminderBeforeUnit,
|
||||
beforeValue: Number(form.reminderBeforeValue)
|
||||
beforeValue: Number(form.reminderBeforeValue),
|
||||
reference: {
|
||||
mode: form.reminderReferenceMode,
|
||||
eventKey: form.reminderReferenceEventKey,
|
||||
occurrenceOffsetValue: Number(form.reminderReferenceOffsetValue),
|
||||
occurrenceOffsetUnit: form.reminderReferenceOffsetUnit
|
||||
}
|
||||
},
|
||||
images: imagePayload,
|
||||
keepImageIds: existingImages.map((image) => image.id)
|
||||
@@ -515,7 +498,11 @@ const JournalPage = ({ authorizedFetch, stores }) => {
|
||||
reminderEnabled: entry.reminder?.enabled ?? true,
|
||||
reminderInterval: entry.reminder?.interval || 'yearly',
|
||||
reminderBeforeValue: getReminderOffset(entry.reminder).value,
|
||||
reminderBeforeUnit: getReminderOffset(entry.reminder).unit
|
||||
reminderBeforeUnit: getReminderOffset(entry.reminder).unit,
|
||||
reminderReferenceMode: getReminderReference(entry.reminder).mode,
|
||||
reminderReferenceEventKey: getReminderReference(entry.reminder).eventKey,
|
||||
reminderReferenceOffsetValue: getReminderReference(entry.reminder).occurrenceOffsetValue,
|
||||
reminderReferenceOffsetUnit: getReminderReference(entry.reminder).occurrenceOffsetUnit
|
||||
});
|
||||
setFormOpen(true);
|
||||
};
|
||||
@@ -679,7 +666,7 @@ const JournalPage = ({ authorizedFetch, stores }) => {
|
||||
{filteredEntries.map((entry) => {
|
||||
const reminder = entry.reminder || {};
|
||||
const reminderLabel = reminder.enabled
|
||||
? `${intervalLabels[reminder.interval] || 'Jährlich'}, ${formatReminderOffset(reminder)} vorher`
|
||||
? `${intervalLabels[reminder.interval] || 'Jährlich'}, ${formatReminderOffset(reminder)} vorher, ${formatReferenceLabel(reminder)}`
|
||||
: 'Keine Erinnerung';
|
||||
const storeLabel =
|
||||
entry.storeName ||
|
||||
@@ -979,8 +966,9 @@ const JournalPage = ({ authorizedFetch, stores }) => {
|
||||
<div className="space-y-1">
|
||||
<label className="block text-sm text-gray-600">Intervall</label>
|
||||
<select
|
||||
value={form.reminderInterval}
|
||||
value={form.reminderReferenceMode === 'event' ? 'yearly' : form.reminderInterval}
|
||||
onChange={(event) => handleFormChange({ reminderInterval: event.target.value })}
|
||||
disabled={form.reminderReferenceMode === 'event'}
|
||||
className="w-full"
|
||||
>
|
||||
{Object.entries(intervalLabels).map(([value, label]) => (
|
||||
@@ -1018,9 +1006,84 @@ const JournalPage = ({ authorizedFetch, stores }) => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<div className="space-y-1">
|
||||
<label className="block text-sm text-gray-600">Erinnerung basiert auf</label>
|
||||
<select
|
||||
value={form.reminderReferenceMode}
|
||||
onChange={(event) =>
|
||||
handleFormChange({
|
||||
reminderReferenceMode: event.target.value,
|
||||
reminderInterval: event.target.value === 'event' ? 'yearly' : form.reminderInterval
|
||||
})
|
||||
}
|
||||
className="w-full"
|
||||
>
|
||||
<option value="pickupDate">Gespeichertem Abholdatum</option>
|
||||
<option value="event">Referenz-Event</option>
|
||||
</select>
|
||||
</div>
|
||||
{form.reminderReferenceMode === 'event' ? (
|
||||
<div className="space-y-1">
|
||||
<label className="block text-sm text-gray-600">Referenz-Event</label>
|
||||
<select
|
||||
value={form.reminderReferenceEventKey}
|
||||
onChange={(event) => handleFormChange({ reminderReferenceEventKey: event.target.value })}
|
||||
className="w-full"
|
||||
>
|
||||
{referenceEventOptions.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{form.reminderReferenceMode === 'event' ? (
|
||||
<div className="space-y-1">
|
||||
<label className="block text-sm text-gray-600">Abholung relativ zum Event</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="number"
|
||||
value={form.reminderReferenceOffsetValue}
|
||||
onChange={(event) =>
|
||||
handleFormChange({ reminderReferenceOffsetValue: event.target.value })
|
||||
}
|
||||
className="w-24 border border-gray-300 rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-200"
|
||||
/>
|
||||
<select
|
||||
value={form.reminderReferenceOffsetUnit}
|
||||
onChange={(event) =>
|
||||
handleFormChange({ reminderReferenceOffsetUnit: event.target.value })
|
||||
}
|
||||
className="flex-1"
|
||||
>
|
||||
{Object.entries(reminderUnitLabels).map(([value, label]) => (
|
||||
<option key={value} value={value}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">
|
||||
Positive Werte liegen nach dem Event, negative davor. Beispiel: 2 Wochen nach Ostern oder -4 Wochen vor Ostern.
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
<p className="text-xs text-gray-500">
|
||||
Erinnerung wird standardmäßig jährlich eingeplant.
|
||||
Bei Referenz-Events wird die Erinnerung immer jährlich berechnet.
|
||||
</p>
|
||||
{reminderPreview ? (
|
||||
<div className="rounded-md border border-blue-200 bg-blue-50 px-3 py-3 text-sm text-blue-900">
|
||||
<p>
|
||||
Geplante Abholung: <strong>{reminderPreview.occurrenceLabel}</strong>
|
||||
</p>
|
||||
<p>
|
||||
Erinnerung kommt am: <strong>{reminderPreview.reminderLabel}</strong>
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -1,12 +1,125 @@
|
||||
import { useMemo } from 'react';
|
||||
import { DateRange } from 'react-date-range';
|
||||
import { de } from 'date-fns/locale';
|
||||
import { buildSelectionRange } from '../utils/dateUtils';
|
||||
|
||||
const RangePickerModal = ({ entry, minDate, onSelectRange, onResetRange, onClose }) => {
|
||||
const WEEKDAY_INDEX = {
|
||||
Sonntag: 0,
|
||||
Montag: 1,
|
||||
Dienstag: 2,
|
||||
Mittwoch: 3,
|
||||
Donnerstag: 4,
|
||||
Freitag: 5,
|
||||
Samstag: 6
|
||||
};
|
||||
|
||||
const formatDateKey = (value) => {
|
||||
const date = value instanceof Date ? value : new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return '';
|
||||
}
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}`;
|
||||
};
|
||||
|
||||
const formatTime = (value) => {
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return '';
|
||||
}
|
||||
return date.toLocaleTimeString('de-DE', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
};
|
||||
|
||||
const formatRangeText = (entry) => {
|
||||
const range = entry?.desiredDateRange || (entry?.desiredDate ? { start: entry.desiredDate, end: entry.desiredDate } : null);
|
||||
if (range?.start || range?.end) {
|
||||
const start = range.start || range.end;
|
||||
const end = range.end || range.start;
|
||||
return start === end ? start : `${start} bis ${end}`;
|
||||
}
|
||||
return entry?.desiredWeekday || 'aktiv ohne Zeitfenster';
|
||||
};
|
||||
|
||||
const entryHasPlannedDay = (entry, date, dayKey) => {
|
||||
if (!entry?.active || entry.hidden) {
|
||||
return false;
|
||||
}
|
||||
const range = entry.desiredDateRange || (entry.desiredDate ? { start: entry.desiredDate, end: entry.desiredDate } : null);
|
||||
if (range?.start || range?.end) {
|
||||
const start = range.start || range.end;
|
||||
const end = range.end || range.start;
|
||||
return dayKey >= start && dayKey <= end;
|
||||
}
|
||||
return entry.desiredWeekday ? WEEKDAY_INDEX[entry.desiredWeekday] === date.getDay() : false;
|
||||
};
|
||||
|
||||
const RangePickerModal = ({
|
||||
entry,
|
||||
minDate,
|
||||
config,
|
||||
registeredPickups,
|
||||
onSelectRange,
|
||||
onResetRange,
|
||||
onClose
|
||||
}) => {
|
||||
const registeredPickupsByDate = useMemo(() => {
|
||||
const byDate = new Map();
|
||||
(Array.isArray(registeredPickups) ? registeredPickups : []).forEach((pickup) => {
|
||||
const key = formatDateKey(pickup?.date);
|
||||
if (key) {
|
||||
const items = byDate.get(key) || [];
|
||||
items.push(pickup);
|
||||
byDate.set(key, items);
|
||||
}
|
||||
});
|
||||
return byDate;
|
||||
}, [registeredPickups]);
|
||||
|
||||
const plannedEntries = useMemo(
|
||||
() => (Array.isArray(config) ? config : []).filter((item) => item?.active && !item.hidden),
|
||||
[config]
|
||||
);
|
||||
|
||||
if (!entry) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const renderDayContent = (date) => {
|
||||
const dayKey = formatDateKey(date);
|
||||
const foodsharingPickups = registeredPickupsByDate.get(dayKey) || [];
|
||||
const managerPlans = plannedEntries.filter((item) => entryHasPlannedDay(item, date, dayKey));
|
||||
const hasFoodsharingPickup = foodsharingPickups.length > 0;
|
||||
const hasManagerPlan = managerPlans.length > 0;
|
||||
const titleParts = [
|
||||
...foodsharingPickups.map((pickup) => {
|
||||
const time = formatTime(pickup.date);
|
||||
const label = pickup.storeName || `Store ${pickup.storeId || ''}`.trim();
|
||||
const description = pickup.description ? `, ${pickup.description}` : '';
|
||||
return `Foodsharing: ${label}${time ? ` ${time}` : ''}${description}`;
|
||||
}),
|
||||
...managerPlans.map((plan) => `Pickup Manager: ${plan.label || `Store ${plan.id}`} (${formatRangeText(plan)})`)
|
||||
];
|
||||
return (
|
||||
<span
|
||||
className="relative inline-flex h-7 w-7 items-center justify-center"
|
||||
title={titleParts.length > 0 ? titleParts.join('\n') : undefined}
|
||||
>
|
||||
<span>{date.getDate()}</span>
|
||||
{(hasFoodsharingPickup || hasManagerPlan) && (
|
||||
<span className="absolute bottom-0 flex gap-0.5">
|
||||
{hasFoodsharingPickup && <span className="h-1.5 w-1.5 rounded-full bg-blue-600" />}
|
||||
{hasManagerPlan && <span className="h-1.5 w-1.5 rounded-full bg-emerald-600" />}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-40 flex items-center justify-center bg-black bg-opacity-40 px-4" onClick={onClose}>
|
||||
<div
|
||||
@@ -37,7 +150,18 @@ const RangePickerModal = ({ entry, minDate, onSelectRange, onResetRange, onClose
|
||||
showDateDisplay={false}
|
||||
locale={de}
|
||||
minDate={minDate}
|
||||
dayContentRenderer={renderDayContent}
|
||||
/>
|
||||
<div className="mt-3 flex flex-wrap items-center gap-3 px-3 text-xs text-gray-600">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<span className="h-2 w-2 rounded-full bg-blue-600" />
|
||||
Foodsharing eingetragen
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<span className="h-2 w-2 rounded-full bg-emerald-600" />
|
||||
Pickup Manager geplant
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between px-5 py-3 border-t bg-gray-50 rounded-b-2xl">
|
||||
<button type="button" className="text-sm text-gray-600 hover:text-gray-900" onClick={onResetRange}>
|
||||
|
||||
@@ -441,9 +441,10 @@ const StoreWatchPage = ({
|
||||
const membership = membershipMap.has(String(store.id));
|
||||
const lat = Number(store.location?.lat);
|
||||
const lon = Number(store.location?.lon);
|
||||
const statusValue = store.teamSearchStatus === null || store.teamSearchStatus === undefined
|
||||
const rawTeamStatus = store.teamStatus ?? store.teamSearchStatus;
|
||||
const statusValue = rawTeamStatus === null || rawTeamStatus === undefined
|
||||
? null
|
||||
: Number(store.teamSearchStatus);
|
||||
: Number(rawTeamStatus);
|
||||
const distance =
|
||||
userLocation && Number.isFinite(lat) && Number.isFinite(lon)
|
||||
? haversineDistanceKm(userLocation.lat, userLocation.lon, lat, lon)
|
||||
|
||||
@@ -25,8 +25,8 @@ const useStoreSync = ({
|
||||
}
|
||||
if (!silent) {
|
||||
setStatus('');
|
||||
setLoading(true);
|
||||
}
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const response = await authorizedFetch('/api/config', {}, tokenToUse);
|
||||
@@ -42,7 +42,9 @@ const useStoreSync = ({
|
||||
} catch (err) {
|
||||
setError(`Fehler beim Laden der Konfiguration: ${err.message}`);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
if (!silent) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
},
|
||||
[sessionToken, authorizedFetch, setStatus, setLoading, setError, setConfig, normalizeConfigEntries]
|
||||
|
||||
@@ -20,6 +20,20 @@ export const normalizeAdminSettings = (raw) => {
|
||||
storeWatchInitialDelayMaxSeconds: raw.storeWatchInitialDelayMaxSeconds ?? '',
|
||||
storeWatchRequestDelayMs: raw.storeWatchRequestDelayMs ?? '',
|
||||
storeWatchStatusCacheMaxAgeMinutes: raw.storeWatchStatusCacheMaxAgeMinutes ?? '',
|
||||
messageWatcherSocketEnabled:
|
||||
raw.messageWatcherSocketEnabled === undefined ? true : !!raw.messageWatcherSocketEnabled,
|
||||
messageWatcherFallbackEnabled:
|
||||
raw.messageWatcherFallbackEnabled === undefined ? true : !!raw.messageWatcherFallbackEnabled,
|
||||
messageWatcherFallbackCron: raw.messageWatcherFallbackCron || '0 6-23 * * *',
|
||||
messageWatcherDebugLogging:
|
||||
raw.messageWatcherDebugLogging === undefined ? true : !!raw.messageWatcherDebugLogging,
|
||||
maintenanceModeActive: !!raw.maintenanceModeActive,
|
||||
maintenanceModeAutoEnabled:
|
||||
raw.maintenanceModeAutoEnabled === undefined ? true : !!raw.maintenanceModeAutoEnabled,
|
||||
maintenanceModeActivatedAt: raw.maintenanceModeActivatedAt || '',
|
||||
maintenanceModeReason: raw.maintenanceModeReason || '',
|
||||
maintenanceModeErrorThreshold: raw.maintenanceModeErrorThreshold ?? '',
|
||||
maintenanceModeErrorWindowMinutes: raw.maintenanceModeErrorWindowMinutes ?? '',
|
||||
ignoredSlots: Array.isArray(raw.ignoredSlots)
|
||||
? raw.ignoredSlots.map((slot) => ({
|
||||
storeId: slot?.storeId ? String(slot.storeId) : '',
|
||||
@@ -88,6 +102,16 @@ export const serializeAdminSettings = (adminSettings) => {
|
||||
storeWatchInitialDelayMaxSeconds: toNumberOrUndefined(adminSettings.storeWatchInitialDelayMaxSeconds),
|
||||
storeWatchRequestDelayMs: toNumberOrUndefined(adminSettings.storeWatchRequestDelayMs),
|
||||
storeWatchStatusCacheMaxAgeMinutes: toNumberOrUndefined(adminSettings.storeWatchStatusCacheMaxAgeMinutes),
|
||||
messageWatcherSocketEnabled: !!adminSettings.messageWatcherSocketEnabled,
|
||||
messageWatcherFallbackEnabled: !!adminSettings.messageWatcherFallbackEnabled,
|
||||
messageWatcherFallbackCron: adminSettings.messageWatcherFallbackCron || '',
|
||||
messageWatcherDebugLogging: !!adminSettings.messageWatcherDebugLogging,
|
||||
maintenanceModeActive: !!adminSettings.maintenanceModeActive,
|
||||
maintenanceModeAutoEnabled: !!adminSettings.maintenanceModeAutoEnabled,
|
||||
maintenanceModeActivatedAt: adminSettings.maintenanceModeActivatedAt || null,
|
||||
maintenanceModeReason: adminSettings.maintenanceModeReason || '',
|
||||
maintenanceModeErrorThreshold: toNumberOrUndefined(adminSettings.maintenanceModeErrorThreshold),
|
||||
maintenanceModeErrorWindowMinutes: toNumberOrUndefined(adminSettings.maintenanceModeErrorWindowMinutes),
|
||||
ignoredSlots: (adminSettings.ignoredSlots || []).map((slot) => ({
|
||||
storeId: slot.storeId || '',
|
||||
slotName: slot.slotName || '',
|
||||
|
||||
260
src/utils/journalReminder.js
Normal file
260
src/utils/journalReminder.js
Normal file
@@ -0,0 +1,260 @@
|
||||
export const referenceEventOptions = [
|
||||
{ value: 'new_years_day', label: 'Neujahr' },
|
||||
{ value: 'epiphany', label: 'Heilige Drei Könige' },
|
||||
{ value: 'valentines_day', label: 'Valentinstag' },
|
||||
{ value: 'womens_day', label: 'Internationaler Frauentag' },
|
||||
{ value: 'carnival_monday', label: 'Rosenmontag' },
|
||||
{ value: 'ash_wednesday', label: 'Aschermittwoch' },
|
||||
{ value: 'palm_sunday', label: 'Palmsonntag' },
|
||||
{ value: 'maundy_thursday', label: 'Gründonnerstag' },
|
||||
{ value: 'good_friday', label: 'Karfreitag' },
|
||||
{ value: 'easter_sunday', label: 'Ostersonntag' },
|
||||
{ value: 'easter_monday', label: 'Ostermontag' },
|
||||
{ value: 'may_day', label: 'Tag der Arbeit' },
|
||||
{ value: 'ascension_day', label: 'Christi Himmelfahrt' },
|
||||
{ value: 'pentecost_sunday', label: 'Pfingstsonntag' },
|
||||
{ value: 'pentecost_monday', label: 'Pfingstmontag' },
|
||||
{ value: 'corpus_christi', label: 'Fronleichnam' },
|
||||
{ value: 'german_unity_day', label: 'Tag der Deutschen Einheit' },
|
||||
{ value: 'halloween', label: 'Halloween' },
|
||||
{ value: 'all_saints_day', label: 'Allerheiligen' },
|
||||
{ value: 'st_nicholas_day', label: 'Nikolaus' },
|
||||
{ value: 'christmas_eve', label: 'Heiligabend' },
|
||||
{ value: 'christmas_day', label: '1. Weihnachtstag' },
|
||||
{ value: 'boxing_day', label: '2. Weihnachtstag' },
|
||||
{ value: 'new_years_eve', label: 'Silvester' }
|
||||
];
|
||||
|
||||
const referenceEventLabelMap = referenceEventOptions.reduce((acc, entry) => {
|
||||
acc[entry.value] = entry.label;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
export const reminderUnitLabels = {
|
||||
days: 'Tage',
|
||||
weeks: 'Wochen',
|
||||
months: 'Monate'
|
||||
};
|
||||
|
||||
export const reminderUnitSingular = {
|
||||
days: 'Tag',
|
||||
weeks: 'Woche',
|
||||
months: 'Monat'
|
||||
};
|
||||
|
||||
function startOfDay(date) {
|
||||
return new Date(date.getFullYear(), date.getMonth(), date.getDate());
|
||||
}
|
||||
|
||||
function addMonths(date, months) {
|
||||
const copy = new Date(date.getTime());
|
||||
copy.setMonth(copy.getMonth() + months);
|
||||
return copy;
|
||||
}
|
||||
|
||||
function applyOffset(date, unit, value = 0) {
|
||||
const copy = new Date(date.getTime());
|
||||
const numericValue = Number(value) || 0;
|
||||
if (unit === 'weeks') {
|
||||
copy.setDate(copy.getDate() + numericValue * 7);
|
||||
return copy;
|
||||
}
|
||||
if (unit === 'months') {
|
||||
copy.setMonth(copy.getMonth() + numericValue);
|
||||
return copy;
|
||||
}
|
||||
copy.setDate(copy.getDate() + numericValue);
|
||||
return copy;
|
||||
}
|
||||
|
||||
function subtractOffset(date, unit, value = 0) {
|
||||
return applyOffset(date, unit, -(Number(value) || 0));
|
||||
}
|
||||
|
||||
function getEasterSunday(year) {
|
||||
const a = year % 19;
|
||||
const b = Math.floor(year / 100);
|
||||
const c = year % 100;
|
||||
const d = Math.floor(b / 4);
|
||||
const e = b % 4;
|
||||
const f = Math.floor((b + 8) / 25);
|
||||
const g = Math.floor((b - f + 1) / 3);
|
||||
const h = (19 * a + b - d - g + 15) % 30;
|
||||
const i = Math.floor(c / 4);
|
||||
const k = c % 4;
|
||||
const l = (32 + 2 * e + 2 * i - h - k) % 7;
|
||||
const m = Math.floor((a + 11 * h + 22 * l) / 451);
|
||||
const month = Math.floor((h + l - 7 * m + 114) / 31);
|
||||
const day = ((h + l - 7 * m + 114) % 31) + 1;
|
||||
return new Date(year, month - 1, day);
|
||||
}
|
||||
|
||||
function getReferenceEventDate(eventKey, year) {
|
||||
if (eventKey === 'new_years_day') return new Date(year, 0, 1);
|
||||
if (eventKey === 'epiphany') return new Date(year, 0, 6);
|
||||
if (eventKey === 'valentines_day') return new Date(year, 1, 14);
|
||||
if (eventKey === 'womens_day') return new Date(year, 2, 8);
|
||||
if (eventKey === 'may_day') return new Date(year, 4, 1);
|
||||
if (eventKey === 'german_unity_day') return new Date(year, 9, 3);
|
||||
if (eventKey === 'halloween') return new Date(year, 9, 31);
|
||||
if (eventKey === 'all_saints_day') return new Date(year, 10, 1);
|
||||
if (eventKey === 'st_nicholas_day') return new Date(year, 11, 6);
|
||||
if (eventKey === 'christmas_eve') return new Date(year, 11, 24);
|
||||
if (eventKey === 'christmas_day') return new Date(year, 11, 25);
|
||||
if (eventKey === 'boxing_day') return new Date(year, 11, 26);
|
||||
if (eventKey === 'new_years_eve') return new Date(year, 11, 31);
|
||||
|
||||
const easterSunday = getEasterSunday(year);
|
||||
const offsets = {
|
||||
carnival_monday: -48,
|
||||
ash_wednesday: -46,
|
||||
palm_sunday: -7,
|
||||
maundy_thursday: -3,
|
||||
good_friday: -2,
|
||||
easter_sunday: 0,
|
||||
easter_monday: 1,
|
||||
ascension_day: 39,
|
||||
pentecost_sunday: 49,
|
||||
pentecost_monday: 50,
|
||||
corpus_christi: 60
|
||||
};
|
||||
if (!(eventKey in offsets)) {
|
||||
return null;
|
||||
}
|
||||
return applyOffset(easterSunday, 'days', offsets[eventKey]);
|
||||
}
|
||||
|
||||
export function getIntervalMonths(interval) {
|
||||
if (interval === 'monthly') {
|
||||
return 1;
|
||||
}
|
||||
if (interval === 'quarterly') {
|
||||
return 3;
|
||||
}
|
||||
return 12;
|
||||
}
|
||||
|
||||
export function getReminderOffset(reminder = {}) {
|
||||
const unit = ['days', 'weeks', 'months'].includes(reminder?.beforeUnit) ? reminder.beforeUnit : 'days';
|
||||
const rawValue = Number(reminder?.beforeValue);
|
||||
const fallbackValue = Number(reminder?.daysBefore);
|
||||
const value = Number.isFinite(rawValue)
|
||||
? Math.max(0, rawValue)
|
||||
: Number.isFinite(fallbackValue)
|
||||
? Math.max(0, fallbackValue)
|
||||
: 6;
|
||||
return { unit, value };
|
||||
}
|
||||
|
||||
export function getReminderReference(reminder = {}) {
|
||||
const reference = reminder?.reference;
|
||||
if (!reference || reference.mode !== 'event' || !referenceEventLabelMap[reference.eventKey]) {
|
||||
return {
|
||||
mode: 'pickupDate',
|
||||
eventKey: 'easter_sunday',
|
||||
occurrenceOffsetUnit: 'weeks',
|
||||
occurrenceOffsetValue: 0
|
||||
};
|
||||
}
|
||||
const unit = ['days', 'weeks', 'months'].includes(reference.occurrenceOffsetUnit)
|
||||
? reference.occurrenceOffsetUnit
|
||||
: 'days';
|
||||
const value = Number.isFinite(Number(reference.occurrenceOffsetValue))
|
||||
? Number(reference.occurrenceOffsetValue)
|
||||
: 0;
|
||||
return {
|
||||
mode: 'event',
|
||||
eventKey: reference.eventKey,
|
||||
occurrenceOffsetUnit: unit,
|
||||
occurrenceOffsetValue: value
|
||||
};
|
||||
}
|
||||
|
||||
export function formatUnitLabel(unit, value) {
|
||||
const labels = value === 1 ? reminderUnitSingular : reminderUnitLabels;
|
||||
return `${value} ${labels[unit] || labels.days}`;
|
||||
}
|
||||
|
||||
export function formatRelativeEventOffset(reference = {}) {
|
||||
const eventLabel = referenceEventLabelMap[reference.eventKey] || 'dem Referenz-Event';
|
||||
const unit = ['days', 'weeks', 'months'].includes(reference.occurrenceOffsetUnit)
|
||||
? reference.occurrenceOffsetUnit
|
||||
: 'days';
|
||||
const value = Number(reference.occurrenceOffsetValue) || 0;
|
||||
if (value === 0) {
|
||||
return `am ${eventLabel}`;
|
||||
}
|
||||
const absValue = Math.abs(value);
|
||||
const offsetLabel = formatUnitLabel(unit, absValue);
|
||||
return value > 0 ? `${offsetLabel} nach ${eventLabel}` : `${offsetLabel} vor ${eventLabel}`;
|
||||
}
|
||||
|
||||
export function formatReminderReference(reminder = {}) {
|
||||
const reference = getReminderReference(reminder);
|
||||
if (reference.mode !== 'event') {
|
||||
return 'gespeichertes Abholdatum';
|
||||
}
|
||||
return formatRelativeEventOffset(reference);
|
||||
}
|
||||
|
||||
export function formatReminderOffset(reminder = {}) {
|
||||
const { unit, value } = getReminderOffset(reminder);
|
||||
return formatUnitLabel(unit, value);
|
||||
}
|
||||
|
||||
export function formatReferenceLabel(reminder = {}) {
|
||||
return formatReminderReference(reminder);
|
||||
}
|
||||
|
||||
export function subtractReminderOffset(date, reminder = {}) {
|
||||
const { unit, value } = getReminderOffset(reminder);
|
||||
return subtractOffset(date, unit, value);
|
||||
}
|
||||
|
||||
export function getNextReminderSchedule(entry, today = new Date()) {
|
||||
if (!entry?.reminder?.enabled) {
|
||||
return null;
|
||||
}
|
||||
const todayStart = startOfDay(today);
|
||||
const reference = getReminderReference(entry.reminder);
|
||||
|
||||
if (reference.mode === 'event') {
|
||||
for (let year = todayStart.getFullYear(); year <= todayStart.getFullYear() + 2; year += 1) {
|
||||
const eventDate = getReferenceEventDate(reference.eventKey, year);
|
||||
if (!eventDate) {
|
||||
return null;
|
||||
}
|
||||
const occurrence = startOfDay(
|
||||
applyOffset(eventDate, reference.occurrenceOffsetUnit, reference.occurrenceOffsetValue)
|
||||
);
|
||||
const reminderDate = startOfDay(subtractReminderOffset(occurrence, entry.reminder));
|
||||
if (reminderDate >= todayStart) {
|
||||
return { occurrence, reminderDate };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!entry.pickupDate) {
|
||||
return null;
|
||||
}
|
||||
const baseDate = new Date(`${entry.pickupDate}T00:00:00`);
|
||||
if (Number.isNaN(baseDate.getTime())) {
|
||||
return null;
|
||||
}
|
||||
const intervalMonths = getIntervalMonths(entry.reminder.interval);
|
||||
let occurrence = startOfDay(baseDate);
|
||||
const guardYear = todayStart.getFullYear() + 200;
|
||||
while (occurrence < todayStart && occurrence.getFullYear() < guardYear) {
|
||||
occurrence = startOfDay(addMonths(occurrence, intervalMonths));
|
||||
}
|
||||
if (occurrence.getFullYear() >= guardYear) {
|
||||
return null;
|
||||
}
|
||||
let reminderDate = startOfDay(subtractReminderOffset(occurrence, entry.reminder));
|
||||
if (reminderDate < todayStart) {
|
||||
occurrence = startOfDay(addMonths(occurrence, intervalMonths));
|
||||
reminderDate = startOfDay(subtractReminderOffset(occurrence, entry.reminder));
|
||||
}
|
||||
return { occurrence, reminderDate };
|
||||
}
|
||||
52
src/utils/journalReminder.test.js
Normal file
52
src/utils/journalReminder.test.js
Normal file
@@ -0,0 +1,52 @@
|
||||
import { getNextReminderSchedule } from './journalReminder';
|
||||
|
||||
function toLocalDateString(date) {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
describe('journalReminder', () => {
|
||||
it('calculates Easter-relative reminders correctly', () => {
|
||||
const entry = {
|
||||
pickupDate: '2025-04-20',
|
||||
reminder: {
|
||||
enabled: true,
|
||||
interval: 'yearly',
|
||||
beforeUnit: 'weeks',
|
||||
beforeValue: 6,
|
||||
reference: {
|
||||
mode: 'event',
|
||||
eventKey: 'easter_sunday',
|
||||
occurrenceOffsetUnit: 'weeks',
|
||||
occurrenceOffsetValue: 2
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const schedule = getNextReminderSchedule(entry, new Date('2026-01-10T00:00:00'));
|
||||
|
||||
expect(schedule).not.toBeNull();
|
||||
expect(toLocalDateString(schedule.occurrence)).toBe('2026-04-19');
|
||||
expect(toLocalDateString(schedule.reminderDate)).toBe('2026-03-08');
|
||||
});
|
||||
|
||||
it('keeps fixed pickup-date reminders working', () => {
|
||||
const entry = {
|
||||
pickupDate: '2024-12-24',
|
||||
reminder: {
|
||||
enabled: true,
|
||||
interval: 'yearly',
|
||||
beforeUnit: 'weeks',
|
||||
beforeValue: 2
|
||||
}
|
||||
};
|
||||
|
||||
const schedule = getNextReminderSchedule(entry, new Date('2026-01-10T00:00:00'));
|
||||
|
||||
expect(schedule).not.toBeNull();
|
||||
expect(toLocalDateString(schedule.occurrence)).toBe('2026-12-24');
|
||||
expect(toLocalDateString(schedule.reminderDate)).toBe('2026-12-10');
|
||||
});
|
||||
});
|
||||
81
src/utils/storeMessageWatcher.test.js
Normal file
81
src/utils/storeMessageWatcher.test.js
Normal file
@@ -0,0 +1,81 @@
|
||||
jest.mock('../../services/foodsharingClient', () => ({
|
||||
fetchConversations: jest.fn()
|
||||
}));
|
||||
|
||||
jest.mock('socket.io-client', () => ({
|
||||
io: jest.fn()
|
||||
}));
|
||||
|
||||
jest.mock('../../services/requestLogStore', () => ({
|
||||
add: jest.fn()
|
||||
}));
|
||||
|
||||
jest.mock('../../services/sessionStore', () => ({
|
||||
get: jest.fn()
|
||||
}));
|
||||
|
||||
const {
|
||||
collectStoreIds,
|
||||
extractStoreIdFromHref,
|
||||
getMatchingActiveEntries,
|
||||
getMessageWatcherSettings,
|
||||
isPotentialMessageSocketEvent
|
||||
} = require('../../services/storeMessageWatcher');
|
||||
|
||||
describe('storeMessageWatcher', () => {
|
||||
test('extracts store ids from modern and legacy foodsharing links', () => {
|
||||
expect(extractStoreIdFromHref('/store/33875')).toBe('33875');
|
||||
expect(extractStoreIdFromHref('/?page=fsbetrieb&id=44975')).toBe('44975');
|
||||
expect(extractStoreIdFromHref('/fairteiler/2974')).toBeNull();
|
||||
});
|
||||
|
||||
test('detects relevant socket event names', () => {
|
||||
expect(isPotentialMessageSocketEvent('conversation:message')).toBe(true);
|
||||
expect(isPotentialMessageSocketEvent('notification')).toBe(true);
|
||||
expect(isPotentialMessageSocketEvent('connect')).toBe(false);
|
||||
});
|
||||
|
||||
test('matches only active entries for the affected store', () => {
|
||||
const config = [
|
||||
{ id: '33875', active: true },
|
||||
{ id: 33875, active: false },
|
||||
{ id: '44975', active: true }
|
||||
];
|
||||
|
||||
expect(getMatchingActiveEntries(config, '33875')).toEqual([{ id: '33875', active: true }]);
|
||||
});
|
||||
|
||||
test('collects store ids from socket-like payloads', () => {
|
||||
const ids = Array.from(
|
||||
collectStoreIds([
|
||||
{ conversation: { storeId: 44975 } },
|
||||
{ href: '/store/33875' },
|
||||
{ text: '/?page=fsbetrieb&id=42264' }
|
||||
])
|
||||
).sort();
|
||||
|
||||
expect(ids).toEqual(['33875', '42264', '44975']);
|
||||
});
|
||||
|
||||
test('normalizes message watcher settings', () => {
|
||||
expect(getMessageWatcherSettings({ messageWatcherFallbackCron: '*/15 * * * *' })).toMatchObject({
|
||||
socketEnabled: true,
|
||||
fallbackEnabled: true,
|
||||
fallbackCron: '*/15 * * * *',
|
||||
debugLogging: true
|
||||
});
|
||||
expect(
|
||||
getMessageWatcherSettings({
|
||||
messageWatcherSocketEnabled: false,
|
||||
messageWatcherFallbackEnabled: false,
|
||||
messageWatcherFallbackCron: 'invalid',
|
||||
messageWatcherDebugLogging: false
|
||||
})
|
||||
).toMatchObject({
|
||||
socketEnabled: false,
|
||||
fallbackEnabled: false,
|
||||
fallbackCron: '0 6-23 * * *',
|
||||
debugLogging: false
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user