aktueller stand

This commit is contained in:
2026-01-29 18:17:13 +01:00
parent 9f2825edd4
commit ad32f299cf
8 changed files with 491 additions and 18 deletions

View File

@@ -39,6 +39,7 @@ function App() {
const [notificationPanelOpen, setNotificationPanelOpen] = useState(false);
const [focusedStoreId, setFocusedStoreId] = useState(null);
const [nearestStoreLabel, setNearestStoreLabel] = useState(null);
const [regularPickupMap, setRegularPickupMap] = useState({});
const minSelectableDate = useMemo(() => startOfDay(new Date()), []);
const weekdays = ['Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag', 'Sonntag'];
@@ -551,6 +552,45 @@ function App() {
const visibleConfig = useMemo(() => config.filter((item) => !item.hidden), [config]);
useEffect(() => {
let cancelled = false;
if (!session?.token || !authorizedFetch || visibleConfig.length === 0) {
return () => {
cancelled = true;
};
}
const uniqueIds = Array.from(new Set(visibleConfig.map((item) => String(item.id))));
const missing = uniqueIds.filter((id) => regularPickupMap[id] === undefined);
if (missing.length === 0) {
return () => {
cancelled = true;
};
}
const fetchSchedules = async () => {
for (const id of missing) {
try {
const response = await authorizedFetch(`/api/stores/${id}/regular-pickup`);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const data = await response.json();
const rules = Array.isArray(data) ? data : Array.isArray(data?.rules) ? data.rules : [];
if (!cancelled) {
setRegularPickupMap((prev) => ({ ...prev, [id]: rules }));
}
} catch (err) {
if (!cancelled) {
setRegularPickupMap((prev) => ({ ...prev, [id]: [] }));
}
}
}
};
fetchSchedules();
return () => {
cancelled = true;
};
}, [authorizedFetch, regularPickupMap, session?.token, visibleConfig]);
const activeRangeEntry = useMemo(() => {
if (!activeRangePicker) {
return null;
@@ -736,6 +776,7 @@ function App() {
onToggleStores={() => setAvailableCollapsed((prev) => !prev)}
onStoreSelect={handleStoreSelection}
configMap={configMap}
regularPickupMap={regularPickupMap}
error={error}
onDismissError={() => setError('')}
status={status}

View File

@@ -91,15 +91,28 @@ const AdminSettingsPanel = ({
subtitle="Bestimmt, wann der Bot freie Slots sucht und wie stark er Anfragen verteilt."
>
<SettingField
label="Cron-Ausdruck"
description="Legt fest, zu welchen Zeiten die reine Slot-Suche läuft. Nutzt die klassische Cron-Syntax (Serverzeit)."
label="Fallback-Cron"
description="Zeitplan für die seltene Komplettprüfung (Fallback), falls keine Slot-Zeiten bekannt sind oder Sonder-Slots auftauchen."
>
<input
type="text"
value={adminSettings.scheduleCron}
onChange={(event) => onSettingChange('scheduleCron', event.target.value)}
value={adminSettings.pickupFallbackCron}
onChange={(event) => onSettingChange('pickupFallbackCron', 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. */10 7-22 * * *"
placeholder="z. B. 0 7,12,17,22 * * *"
/>
</SettingField>
<SettingField
label="Check-Fenster um Slot-Zeiten (Minuten)"
description="Minuten relativ zur Slot-Zeit, kommagetrennt. Beispiel: -1,-0.5,0,0.5,1,1.5"
>
<input
type="text"
value={adminSettings.pickupWindowOffsetsMinutes}
onChange={(event) => onSettingChange('pickupWindowOffsetsMinutes', 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="-1,-0.5,0,0.5,1,1.5"
/>
</SettingField>

View File

@@ -142,6 +142,7 @@ const DashboardView = ({
onToggleStores,
onStoreSelect,
configMap,
regularPickupMap,
error,
onDismissError,
status,
@@ -214,6 +215,76 @@ const DashboardView = ({
[weekdays]
);
const formatRegularPickup = useCallback((rules) => {
if (!Array.isArray(rules) || rules.length === 0) {
return null;
}
const weekdayLabels = ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa'];
const normalizeWeekday = (value) => {
const num = Number(value);
if (!Number.isFinite(num)) {
return null;
}
if (num === 0) {
return 0;
}
if (num >= 1 && num <= 7) {
return num % 7;
}
return null;
};
const byTime = new Map();
rules.forEach((rule) => {
const day = normalizeWeekday(rule.weekday);
const time = typeof rule.startTimeOfPickup === 'string' ? rule.startTimeOfPickup.slice(0, 5) : '';
if (day === null || !time) {
return;
}
if (!byTime.has(time)) {
byTime.set(time, new Set());
}
byTime.get(time).add(day);
});
if (byTime.size === 0) {
return null;
}
const buildRanges = (days) => {
if (days.length === 0) {
return [];
}
const ranges = [];
let start = days[0];
let last = days[0];
for (let i = 1; i < days.length; i += 1) {
const current = days[i];
if (current === last + 1) {
last = current;
} else {
ranges.push([start, last]);
start = current;
last = current;
}
}
ranges.push([start, last]);
return ranges;
};
const formatRange = ([start, end]) => {
if (start === end) {
return weekdayLabels[start];
}
return `${weekdayLabels[start]}-${weekdayLabels[end]}`;
};
const parts = Array.from(byTime.entries())
.sort(([timeA], [timeB]) => timeA.localeCompare(timeB))
.map(([time, daysSet]) => {
const days = Array.from(daysSet).sort((a, b) => a - b);
const ranges = buildRanges(days).map(formatRange).join(',');
return `${ranges} ${time}`;
})
.filter(Boolean);
return parts.length > 0 ? parts.join(' | ') : null;
}, []);
const configColumns = useMemo(
() => [
columnHelper.display({
@@ -250,12 +321,17 @@ const DashboardView = ({
<ColumnTextFilter column={column} placeholder="Name / ID" />
</div>
),
cell: ({ row }) => (
cell: ({ row }) => {
const scheduleLabel = formatRegularPickup(regularPickupMap?.[row.original.id]);
return (
<div className="flex items-start justify-between gap-3">
<div>
<span className="font-medium">{row.original.label}</span>
{row.original.hidden && <span className="ml-2 text-xs text-gray-400">(ausgeblendet)</span>}
<p className="text-xs text-gray-500">#{row.original.id}</p>
{scheduleLabel && (
<p className="text-xs text-gray-500 mt-1">Slots: {scheduleLabel}</p>
)}
</div>
<a
href={`https://foodsharing.de/store/${row.original.id}`}
@@ -271,7 +347,8 @@ const DashboardView = ({
</svg>
</a>
</div>
),
);
},
sortingFn: 'alphanumeric',
filterFn: 'includesString'
}),
@@ -493,6 +570,8 @@ const DashboardView = ({
onWeekdayChange,
formatRangeLabel,
canDelete,
formatRegularPickup,
regularPickupMap,
weekdays,
weekdaysOptions
]

View File

@@ -4,6 +4,10 @@ export const normalizeAdminSettings = (raw) => {
}
return {
scheduleCron: raw.scheduleCron || '',
pickupFallbackCron: raw.pickupFallbackCron || '',
pickupWindowOffsetsMinutes: Array.isArray(raw.pickupWindowOffsetsMinutes)
? raw.pickupWindowOffsetsMinutes.join(', ')
: '',
randomDelayMinSeconds: raw.randomDelayMinSeconds ?? '',
randomDelayMaxSeconds: raw.randomDelayMaxSeconds ?? '',
initialDelayMinSeconds: raw.initialDelayMinSeconds ?? '',
@@ -46,12 +50,30 @@ const toNumberOrUndefined = (value) => {
return Number.isFinite(parsed) ? parsed : undefined;
};
const toNumberArray = (value) => {
if (Array.isArray(value)) {
return value
.map((entry) => Number(entry))
.filter((entry) => Number.isFinite(entry));
}
if (typeof value !== 'string') {
return undefined;
}
const parsed = value
.split(',')
.map((entry) => Number(entry.trim()))
.filter((entry) => Number.isFinite(entry));
return parsed.length > 0 ? parsed : undefined;
};
export const serializeAdminSettings = (adminSettings) => {
if (!adminSettings) {
return null;
}
return {
scheduleCron: adminSettings.scheduleCron,
pickupFallbackCron: adminSettings.pickupFallbackCron,
pickupWindowOffsetsMinutes: toNumberArray(adminSettings.pickupWindowOffsetsMinutes),
randomDelayMinSeconds: toNumberOrUndefined(adminSettings.randomDelayMinSeconds),
randomDelayMaxSeconds: toNumberOrUndefined(adminSettings.randomDelayMaxSeconds),
initialDelayMinSeconds: toNumberOrUndefined(adminSettings.initialDelayMinSeconds),