From ad32f299cf31f4d582a7d09a1b7b2aa637c938f8 Mon Sep 17 00:00:00 2001 From: Meik Date: Thu, 29 Jan 2026 18:17:13 +0100 Subject: [PATCH] aktueller stand --- server.js | 17 ++ services/adminConfig.js | 29 +++ services/foodsharingClient.js | 12 ++ services/pickupScheduler.js | 282 +++++++++++++++++++++++++-- src/App.js | 41 ++++ src/components/AdminSettingsPanel.js | 23 ++- src/components/DashboardView.js | 83 +++++++- src/utils/adminSettings.js | 22 +++ 8 files changed, 491 insertions(+), 18 deletions(-) diff --git a/server.js b/server.js index 4357ba4..325bb4d 100644 --- a/server.js +++ b/server.js @@ -1317,6 +1317,23 @@ app.post('/api/notifications/test', requireAuth, async (req, res) => { } }); +app.get('/api/stores/:storeId/regular-pickup', requireAuth, async (req, res) => { + const { storeId } = req.params; + if (!storeId) { + return res.status(400).json({ error: 'Store-ID fehlt' }); + } + try { + const rules = await withSessionRetry( + req.session, + () => foodsharingClient.fetchRegularPickup(storeId, req.session.cookieHeader, req.session), + { label: 'fetchRegularPickup' } + ); + res.json(Array.isArray(rules) ? rules : []); + } catch (error) { + res.status(400).json({ error: error.message || 'Regular-Pickup konnte nicht geladen werden' }); + } +}); + app.get('/api/stores', requireAuth, async (req, res) => { res.json(req.session.storesCache?.data || []); }); diff --git a/services/adminConfig.js b/services/adminConfig.js index cfd6d48..be53d3b 100644 --- a/services/adminConfig.js +++ b/services/adminConfig.js @@ -6,6 +6,8 @@ const SETTINGS_FILE = path.join(CONFIG_DIR, 'admin-settings.json'); const DEFAULT_SETTINGS = { scheduleCron: '*/10 7-22 * * *', + pickupFallbackCron: '0 7,12,17,22 * * *', + pickupWindowOffsetsMinutes: [-1, -0.5, 0, 0.5, 1, 1.5], randomDelayMinSeconds: 10, randomDelayMaxSeconds: 120, initialDelayMinSeconds: 5, @@ -53,6 +55,23 @@ function sanitizeNumber(value, fallback) { return fallback; } +function sanitizeNumberArray(value, fallback) { + if (Array.isArray(value)) { + const cleaned = value + .map((entry) => Number(entry)) + .filter((entry) => Number.isFinite(entry)); + return cleaned.length > 0 ? cleaned : fallback; + } + if (typeof value === 'string') { + const cleaned = value + .split(',') + .map((entry) => Number(entry.trim())) + .filter((entry) => Number.isFinite(entry)); + return cleaned.length > 0 ? cleaned : fallback; + } + return fallback; +} + function sanitizeIgnoredSlots(slots = []) { if (!Array.isArray(slots)) { return DEFAULT_SETTINGS.ignoredSlots; @@ -109,6 +128,11 @@ function readSettings() { const parsed = JSON.parse(raw); return { scheduleCron: parsed.scheduleCron || DEFAULT_SETTINGS.scheduleCron, + pickupFallbackCron: parsed.pickupFallbackCron || DEFAULT_SETTINGS.pickupFallbackCron, + pickupWindowOffsetsMinutes: sanitizeNumberArray( + parsed.pickupWindowOffsetsMinutes, + DEFAULT_SETTINGS.pickupWindowOffsetsMinutes + ), randomDelayMinSeconds: sanitizeNumber(parsed.randomDelayMinSeconds, DEFAULT_SETTINGS.randomDelayMinSeconds), randomDelayMaxSeconds: sanitizeNumber(parsed.randomDelayMaxSeconds, DEFAULT_SETTINGS.randomDelayMaxSeconds), initialDelayMinSeconds: sanitizeNumber(parsed.initialDelayMinSeconds, DEFAULT_SETTINGS.initialDelayMinSeconds), @@ -147,6 +171,11 @@ function writeSettings(patch = {}) { const current = readSettings(); const next = { scheduleCron: patch.scheduleCron || current.scheduleCron, + pickupFallbackCron: patch.pickupFallbackCron || current.pickupFallbackCron, + pickupWindowOffsetsMinutes: sanitizeNumberArray( + patch.pickupWindowOffsetsMinutes, + current.pickupWindowOffsetsMinutes + ), randomDelayMinSeconds: sanitizeNumber(patch.randomDelayMinSeconds, current.randomDelayMinSeconds), randomDelayMaxSeconds: sanitizeNumber(patch.randomDelayMaxSeconds, current.randomDelayMaxSeconds), initialDelayMinSeconds: sanitizeNumber(patch.initialDelayMinSeconds, current.initialDelayMinSeconds), diff --git a/services/foodsharingClient.js b/services/foodsharingClient.js index ad4528e..4a435cf 100644 --- a/services/foodsharingClient.js +++ b/services/foodsharingClient.js @@ -442,6 +442,17 @@ async function fetchStoreMembers(storeId, cookieHeader, context) { return Array.isArray(response.data) ? response.data : []; } +async function fetchRegularPickup(storeId, cookieHeader, context) { + if (!storeId) { + return []; + } + const response = await client.get( + `/api/stores/${storeId}/regularPickup`, + buildRequestConfig({ cookieHeader, context }) + ); + return Array.isArray(response.data) ? response.data : []; +} + async function bookSlot(storeId, utcDate, profileId, session) { await client.post( `/api/stores/${storeId}/pickups/${utcDate}/${profileId}`, @@ -464,6 +475,7 @@ module.exports = { fetchRegionStores, fetchStoreDetails, fetchStoreMembers, + fetchRegularPickup, pickupRuleCheck, bookSlot }; diff --git a/services/pickupScheduler.js b/services/pickupScheduler.js index ba3c81c..b8e45bf 100644 --- a/services/pickupScheduler.js +++ b/services/pickupScheduler.js @@ -22,6 +22,10 @@ const storeWatchInFlight = new Map(); const pickupCheckInFlight = new Map(); const pickupCheckLastRun = new Map(); const PICKUP_CHECK_DEDUP_MS = 30 * 1000; +const regularPickupCache = new Map(); +const REGULAR_PICKUP_CACHE_TTL_MS = 12 * 60 * 60 * 1000; +const PICKUP_FALLBACK_RETRY_MS = 60 * 60 * 1000; +const TIME_ZONE = 'Europe/Berlin'; async function fetchSharedStoreStatus(session, storeId, { forceRefresh = false, maxAgeMs } = {}) { if (!storeId) { @@ -83,6 +87,104 @@ function randomDelayMs(minSeconds = 10, maxSeconds = 120) { return Math.floor(Math.random() * (max - min + 1)) + min; } +function getTimeZoneParts(date, timeZone) { + const formatter = new Intl.DateTimeFormat('en-US', { + timeZone, + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + weekday: 'short', + hour12: false + }); + const parts = formatter.formatToParts(date); + const values = {}; + parts.forEach((part) => { + if (part.type !== 'literal') { + values[part.type] = part.value; + } + }); + const weekdayMap = { + Sun: 0, + Mon: 1, + Tue: 2, + Wed: 3, + Thu: 4, + Fri: 5, + Sat: 6 + }; + return { + year: Number(values.year), + month: Number(values.month), + day: Number(values.day), + hour: Number(values.hour), + minute: Number(values.minute), + second: Number(values.second), + weekday: weekdayMap[values.weekday] ?? null + }; +} + +function getTimeZoneOffsetMinutes(timeZone, date) { + const parts = getTimeZoneParts(date, timeZone); + const utcFromParts = Date.UTC( + parts.year, + parts.month - 1, + parts.day, + parts.hour, + parts.minute, + parts.second + ); + return (utcFromParts - date.getTime()) / 60000; +} + +function makeDateInTimeZone(timeZone, year, month, day, hour, minute, second = 0) { + const utcGuess = new Date(Date.UTC(year, month - 1, day, hour, minute, second)); + const offset = getTimeZoneOffsetMinutes(timeZone, utcGuess); + return new Date(utcGuess.getTime() - offset * 60000); +} + +function getStartOfDayInTimeZone(date, timeZone) { + const parts = getTimeZoneParts(date, timeZone); + if (!parts.year || !parts.month || !parts.day) { + return null; + } + return makeDateInTimeZone(timeZone, parts.year, parts.month, parts.day, 0, 0, 0); +} + +function normalizeRegularPickupWeekday(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; +} + +function parseTimeString(value) { + if (!value || typeof value !== 'string') { + return null; + } + const [hourText, minuteText, secondText] = value.split(':'); + const hour = Number(hourText); + const minute = Number(minuteText); + const second = Number(secondText || 0); + if (![hour, minute, second].every((entry) => Number.isFinite(entry))) { + return null; + } + return { hour, minute, second }; +} + +function addDays(date, days) { + return new Date(date.getTime() + days * 24 * 60 * 60 * 1000); +} + function startOfDay(date) { return new Date(date.getFullYear(), date.getMonth(), date.getDate()); } @@ -131,6 +233,11 @@ function resolveSettings(settings) { } return { scheduleCron: settings.scheduleCron || DEFAULT_SETTINGS.scheduleCron, + pickupFallbackCron: settings.pickupFallbackCron || DEFAULT_SETTINGS.pickupFallbackCron, + pickupWindowOffsetsMinutes: + Array.isArray(settings.pickupWindowOffsetsMinutes) && settings.pickupWindowOffsetsMinutes.length > 0 + ? settings.pickupWindowOffsetsMinutes + : DEFAULT_SETTINGS.pickupWindowOffsetsMinutes, randomDelayMinSeconds: Number.isFinite(settings.randomDelayMinSeconds) ? settings.randomDelayMinSeconds : DEFAULT_SETTINGS.randomDelayMinSeconds, @@ -173,6 +280,107 @@ function resolveSettings(settings) { }; } +async function fetchRegularPickupSchedule(session, storeId) { + if (!session?.profile?.id || !storeId) { + return []; + } + const key = String(storeId); + const cached = regularPickupCache.get(key); + if (cached && Date.now() - cached.fetchedAt <= REGULAR_PICKUP_CACHE_TTL_MS) { + return cached.rules; + } + try { + const rules = await withSessionRetry( + session, + () => foodsharingClient.fetchRegularPickup(storeId, session.cookieHeader, session), + { label: 'fetchRegularPickup' } + ); + const normalized = Array.isArray(rules) ? rules : []; + regularPickupCache.set(key, { fetchedAt: Date.now(), rules: normalized }); + return normalized; + } catch (error) { + if (cached?.rules) { + return cached.rules; + } + return []; + } +} + +function resolveEffectiveNow(entry, now) { + const startValue = toDateValue(entry?.desiredDateRange?.start || entry?.desiredDate); + if (startValue && startValue > now.getTime()) { + return new Date(startValue); + } + return now; +} + +async function getNextPickupCheckTime(session, entry, settings) { + const now = new Date(); + const effectiveNow = resolveEffectiveNow(entry, now); + const offsets = (settings.pickupWindowOffsetsMinutes || []) + .map((value) => Number(value)) + .filter((value) => Number.isFinite(value)) + .map((value) => value * 60 * 1000) + .sort((a, b) => a - b); + + if (offsets.length === 0) { + return null; + } + + const rules = await fetchRegularPickupSchedule(session, entry.id); + if (!Array.isArray(rules) || rules.length === 0) { + return null; + } + + const nowParts = getTimeZoneParts(effectiveNow, TIME_ZONE); + if (nowParts.weekday === null) { + return null; + } + const todayStart = getStartOfDayInTimeZone(effectiveNow, TIME_ZONE); + if (!todayStart) { + return null; + } + + let best = null; + + rules.forEach((rule) => { + const weekday = normalizeRegularPickupWeekday(rule.weekday); + const timeParts = parseTimeString(rule.startTimeOfPickup); + if (weekday === null || !timeParts) { + return; + } + let daysAhead = (weekday - nowParts.weekday + 7) % 7; + let candidateStart = addDays(todayStart, daysAhead); + const candidateParts = getTimeZoneParts(candidateStart, TIME_ZONE); + let slotDate = makeDateInTimeZone( + TIME_ZONE, + candidateParts.year, + candidateParts.month, + candidateParts.day, + timeParts.hour, + timeParts.minute, + timeParts.second + ); + + let candidate = offsets + .map((offset) => new Date(slotDate.getTime() + offset)) + .find((date) => date.getTime() > effectiveNow.getTime()); + + if (!candidate) { + slotDate = addDays(slotDate, 7); + candidate = offsets + .map((offset) => new Date(slotDate.getTime() + offset)) + .find((date) => date.getTime() > effectiveNow.getTime()); + } + + if (candidate && (!best || candidate.getTime() < best.getTime())) { + best = candidate; + } + }); + + return best; +} + function deactivateEntryInMemory(entry) { if (entry) { entry.active = false; @@ -660,22 +868,73 @@ function scheduleStoreWatchers(sessionId, settings) { return true; } -function scheduleEntry(sessionId, entry, settings) { - const cronExpression = settings.scheduleCron || DEFAULT_SETTINGS.scheduleCron; +function scheduleFallbackPickupChecks(sessionId, settings) { + const cronExpression = settings.pickupFallbackCron || DEFAULT_SETTINGS.pickupFallbackCron; + if (!cronExpression) { + return null; + } const job = cron.schedule( cronExpression, () => { - const delay = randomDelayMs( - settings.randomDelayMinSeconds, - settings.randomDelayMaxSeconds - ); - setTimeout(() => checkEntry(sessionId, entry, settings), delay); + const session = sessionStore.get(sessionId); + if (!session?.profile?.id) { + return; + } + const delay = randomDelayMs(settings.randomDelayMinSeconds, settings.randomDelayMaxSeconds); + setTimeout(() => { + const config = readConfig(session.profile.id); + runImmediatePickupCheck(sessionId, config, settings).catch((error) => { + console.error('[PICKUP] Fallback-Check fehlgeschlagen:', error.message); + }); + }, delay); }, - { - timezone: 'Europe/Berlin' - } + { timezone: TIME_ZONE } ); sessionStore.attachJob(sessionId, job); + return job; +} + +function scheduleEntry(sessionId, entry, settings) { + let timeoutId = null; + let stopped = false; + + const scheduleNext = async () => { + if (stopped) { + return; + } + const session = sessionStore.get(sessionId); + if (!session) { + return; + } + let nextTime = null; + try { + nextTime = await getNextPickupCheckTime(session, entry, settings); + } catch (error) { + console.warn(`[PICKUP] Konnte nächste Slot-Zeit für ${entry?.id} nicht berechnen:`, error.message); + } + const delayMs = nextTime + ? Math.max(0, nextTime.getTime() - Date.now()) + : PICKUP_FALLBACK_RETRY_MS; + timeoutId = setTimeout(async () => { + timeoutId = null; + if (stopped) { + return; + } + await checkEntry(sessionId, entry, settings); + scheduleNext(); + }, delayMs); + }; + + scheduleNext(); + const job = { + stop: () => { + stopped = true; + if (timeoutId) { + clearTimeout(timeoutId); + } + } + }; + sessionStore.attachJob(sessionId, job); } function scheduleConfig(sessionId, config, settings) { @@ -683,6 +942,7 @@ function scheduleConfig(sessionId, config, settings) { sessionStore.clearJobs(sessionId); scheduleDormantMembershipCheck(sessionId); const watchScheduled = scheduleStoreWatchers(sessionId, resolvedSettings); + scheduleFallbackPickupChecks(sessionId, resolvedSettings); scheduleJournalReminders(sessionId); const entries = Array.isArray(config) ? config : []; const activeEntries = entries.filter((entry) => entry.active); @@ -698,7 +958,7 @@ function scheduleConfig(sessionId, config, settings) { } activeEntries.forEach((entry) => scheduleEntry(sessionId, entry, resolvedSettings)); console.log( - `[INFO] Scheduler für Session ${sessionId} mit ${activeEntries.length} Jobs aktiv (Cron: ${resolvedSettings.scheduleCron}).` + `[INFO] Scheduler für Session ${sessionId} mit ${activeEntries.length} Jobs aktiv.` ); } diff --git a/src/App.js b/src/App.js index 3171c9c..d01e593 100644 --- a/src/App.js +++ b/src/App.js @@ -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} diff --git a/src/components/AdminSettingsPanel.js b/src/components/AdminSettingsPanel.js index b59b1df..a7f1300 100644 --- a/src/components/AdminSettingsPanel.js +++ b/src/components/AdminSettingsPanel.js @@ -91,15 +91,28 @@ const AdminSettingsPanel = ({ subtitle="Bestimmt, wann der Bot freie Slots sucht und wie stark er Anfragen verteilt." > 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 * * *" + /> + + + + 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" /> diff --git a/src/components/DashboardView.js b/src/components/DashboardView.js index f60a763..763dc44 100644 --- a/src/components/DashboardView.js +++ b/src/components/DashboardView.js @@ -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 = ({ ), - cell: ({ row }) => ( + cell: ({ row }) => { + const scheduleLabel = formatRegularPickup(regularPickupMap?.[row.original.id]); + return (
{row.original.label} {row.original.hidden && (ausgeblendet)}

#{row.original.id}

+ {scheduleLabel && ( +

Slots: {scheduleLabel}

+ )}
- ), + ); + }, sortingFn: 'alphanumeric', filterFn: 'includesString' }), @@ -493,6 +570,8 @@ const DashboardView = ({ onWeekdayChange, formatRangeLabel, canDelete, + formatRegularPickup, + regularPickupMap, weekdays, weekdaysOptions ] diff --git a/src/utils/adminSettings.js b/src/utils/adminSettings.js index ca99e21..bedcebb 100644 --- a/src/utils/adminSettings.js +++ b/src/utils/adminSettings.js @@ -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),