From cad72232d9d2c779c57160a8a5560cd16a3d3721 Mon Sep 17 00:00:00 2001 From: Meik Date: Mon, 17 Nov 2025 21:37:22 +0100 Subject: [PATCH] =?UTF-8?q?button=20zum=20pr=C3=BCfen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server.js | 36 ++- services/notificationService.js | 38 +++ services/pickupScheduler.js | 47 ++- services/storeWatchStore.js | 4 +- src/components/StoreWatchPage.js | 481 +++++++++++++++++++++++-------- 5 files changed, 477 insertions(+), 129 deletions(-) diff --git a/server.js b/server.js index eb4a7f6..252ca47 100644 --- a/server.js +++ b/server.js @@ -7,7 +7,7 @@ const sessionStore = require('./services/sessionStore'); const credentialStore = require('./services/credentialStore'); const { readConfig, writeConfig } = require('./services/configStore'); const foodsharingClient = require('./services/foodsharingClient'); -const { scheduleConfig } = require('./services/pickupScheduler'); +const { scheduleConfig, runStoreWatchCheck } = require('./services/pickupScheduler'); const adminConfig = require('./services/adminConfig'); const { readNotificationSettings, writeNotificationSettings } = require('./services/userSettingsStore'); const notificationService = require('./services/notificationService'); @@ -295,6 +295,7 @@ async function notifyWatchersForStatusChanges(changes = [], storeInfoMap = new M }); } watcher.lastTeamSearchStatus = change.newStatus; + watcher.lastStatusCheckAt = change.fetchedAt || Date.now(); changed = true; } } @@ -789,15 +790,16 @@ app.post('/api/store-watch/subscriptions', requireAuth, (req, res) => { if (!storeId || !regionId) { return; } - const entry = { - storeId: String(storeId), - storeName: store?.storeName || store?.name || `Store ${storeId}`, - regionId: String(regionId), - regionName: store?.regionName || store?.region?.name || '', - lastTeamSearchStatus: previousMap.get(String(storeId))?.lastTeamSearchStatus ?? null - }; - normalized.push(entry); - }); + const entry = { + storeId: String(storeId), + storeName: store?.storeName || store?.name || `Store ${storeId}`, + regionId: String(regionId), + regionName: store?.regionName || store?.region?.name || '', + lastTeamSearchStatus: previousMap.get(String(storeId))?.lastTeamSearchStatus ?? null, + lastStatusCheckAt: previousMap.get(String(storeId))?.lastStatusCheckAt ?? null + }; + normalized.push(entry); + }); const persisted = writeStoreWatch(req.session.profile.id, normalized); const config = readConfig(req.session.profile.id); @@ -805,6 +807,20 @@ app.post('/api/store-watch/subscriptions', requireAuth, (req, res) => { res.json({ success: true, stores: persisted }); }); +app.post('/api/store-watch/check', requireAuth, async (req, res) => { + try { + const settings = adminConfig.readSettings(); + const summary = await runStoreWatchCheck(req.session.id, settings, { + sendSummary: true, + triggeredBy: 'manual' + }); + res.json({ success: true, stores: Array.isArray(summary) ? summary : [] }); + } catch (error) { + console.error('[STORE-WATCH] Ad-hoc-Prüfung fehlgeschlagen:', error.message); + res.status(500).json({ error: 'Ad-hoc-Prüfung fehlgeschlagen' }); + } +}); + app.get('/api/user/preferences', requireAuth, async (req, res) => { const preferences = readPreferences(req.session.profile.id); let location = preferences.location; diff --git a/services/notificationService.js b/services/notificationService.js index 068ea02..334e38d 100644 --- a/services/notificationService.js +++ b/services/notificationService.js @@ -98,6 +98,16 @@ async function sendSlotNotification({ profileId, storeName, pickupDate, onlyNoti }); } +function formatStoreWatchStatus(status) { + if (status === 1) { + return 'Suchend'; + } + if (status === 0) { + return 'Nicht suchend'; + } + return 'Status unbekannt'; +} + async function sendStoreWatchNotification({ profileId, storeName, storeId, regionName }) { const storeLink = storeId ? `https://foodsharing.de/store/${storeId}` : null; const title = `Team sucht Verstärkung: ${storeName}`; @@ -112,6 +122,33 @@ async function sendStoreWatchNotification({ profileId, storeName, storeId, regio }); } +async function sendStoreWatchSummaryNotification({ profileId, entries = [], triggeredBy = 'manual' }) { + if (!profileId || !Array.isArray(entries) || entries.length === 0) { + return; + } + const lines = entries + .map((entry) => { + const regionSuffix = entry.regionName ? ` (${entry.regionName})` : ''; + const statusLabel = formatStoreWatchStatus(entry.status); + const timestamp = entry.checkedAt ? ` – Stand ${formatDateLabel(entry.checkedAt)}` : ''; + const errorLabel = entry.error ? ` – Fehler: ${entry.error}` : ''; + return `• ${entry.storeName || `Store ${entry.storeId}`}${regionSuffix}: ${statusLabel}${timestamp}${errorLabel}`; + }) + .join('\n'); + const prefix = + triggeredBy === 'manual' + ? 'Manuell angestoßene Store-Watch-Prüfung abgeschlossen:' + : 'Store-Watch-Prüfung abgeschlossen:'; + const title = + triggeredBy === 'manual' ? 'Ad-hoc Store-Watch-Prüfung' : 'Store-Watch-Prüfung'; + const message = `${prefix}\n${lines}`; + await notifyChannels(profileId, { + title, + message, + priority: 'default' + }); +} + async function sendDesiredWindowMissedNotification({ profileId, storeName, desiredWindowLabel }) { if (!profileId) { return; @@ -165,6 +202,7 @@ async function sendTestNotification(profileId, channel) { module.exports = { sendSlotNotification, sendStoreWatchNotification, + sendStoreWatchSummaryNotification, sendTestNotification, sendDesiredWindowMissedNotification }; diff --git a/services/pickupScheduler.js b/services/pickupScheduler.js index 7d276f5..260907d 100644 --- a/services/pickupScheduler.js +++ b/services/pickupScheduler.js @@ -411,28 +411,30 @@ async function checkEntry(sessionId, entry, settings) { } } -async function checkWatchedStores(sessionId, settings = DEFAULT_SETTINGS) { +async function checkWatchedStores(sessionId, settings = DEFAULT_SETTINGS, options = {}) { const session = sessionStore.get(sessionId); if (!session?.profile?.id) { - return; + return []; } const watchers = readStoreWatch(session.profile.id); if (!Array.isArray(watchers) || watchers.length === 0) { - return; + return []; } const ready = await ensureSession(session); if (!ready) { - return; + return []; } const perRequestDelay = Math.max(0, Number(settings?.storeWatchRequestDelayMs) || 0); let changed = false; + const summary = []; for (let index = 0; index < watchers.length; index += 1) { const watcher = watchers[index]; try { const details = await foodsharingClient.fetchStoreDetails(watcher.storeId, session.cookieHeader); const status = details?.teamSearchStatus === 1 ? 1 : 0; + const checkedAt = Date.now(); if (status === 1 && watcher.lastTeamSearchStatus !== 1) { await notificationService.sendStoreWatchNotification({ profileId: session.profile.id, @@ -445,8 +447,25 @@ async function checkWatchedStores(sessionId, settings = DEFAULT_SETTINGS) { watcher.lastTeamSearchStatus = status; changed = true; } + watcher.lastStatusCheckAt = checkedAt; + changed = true; + summary.push({ + storeId: watcher.storeId, + storeName: watcher.storeName, + regionName: watcher.regionName, + status, + checkedAt + }); } catch (error) { console.error(`[WATCH] Prüfung für Store ${watcher.storeId} fehlgeschlagen:`, error.message); + summary.push({ + storeId: watcher.storeId, + storeName: watcher.storeName, + regionName: watcher.regionName, + status: null, + checkedAt: Date.now(), + error: error.message || 'Unbekannter Fehler' + }); } finally { const hasNext = index < watchers.length - 1; if (hasNext && perRequestDelay > 0) { @@ -458,6 +477,18 @@ async function checkWatchedStores(sessionId, settings = DEFAULT_SETTINGS) { if (changed) { writeStoreWatch(session.profile.id, watchers); } + if (options.sendSummary && summary.length > 0) { + try { + await notificationService.sendStoreWatchSummaryNotification({ + profileId: session.profile.id, + entries: summary, + triggeredBy: options.triggeredBy || 'manual' + }); + } catch (error) { + console.error('[WATCH] Zusammenfassung konnte nicht versendet werden:', error.message); + } + } + return summary; } function scheduleStoreWatchers(sessionId, settings) { @@ -538,6 +569,12 @@ function scheduleConfig(sessionId, config, settings) { ); } +async function runStoreWatchCheck(sessionId, settings, options = {}) { + const resolvedSettings = resolveSettings(settings); + return checkWatchedStores(sessionId, resolvedSettings, options); +} + module.exports = { - scheduleConfig + scheduleConfig, + runStoreWatchCheck }; diff --git a/services/storeWatchStore.js b/services/storeWatchStore.js index e299bb2..777ab03 100644 --- a/services/storeWatchStore.js +++ b/services/storeWatchStore.js @@ -17,6 +17,7 @@ function sanitizeEntry(entry) { if (!entry || !entry.storeId) { return null; } + const parsedLastCheck = Number(entry.lastStatusCheckAt); const normalized = { storeId: String(entry.storeId), storeName: entry.storeName ? String(entry.storeName).trim() : `Store ${entry.storeId}`, @@ -27,7 +28,8 @@ function sanitizeEntry(entry) { ? 1 : entry.lastTeamSearchStatus === 0 ? 0 - : null + : null, + lastStatusCheckAt: Number.isFinite(parsedLastCheck) ? parsedLastCheck : null }; if (!normalized.regionId) { return null; diff --git a/src/components/StoreWatchPage.js b/src/components/StoreWatchPage.js index e3a6692..c389f50 100644 --- a/src/components/StoreWatchPage.js +++ b/src/components/StoreWatchPage.js @@ -13,6 +13,31 @@ import NotificationPanel from './NotificationPanel'; const REGION_STORAGE_KEY = 'storeWatchRegionSelection'; const WATCH_TABLE_STATE_KEY = 'storeWatchTableState'; +const PANEL_STORAGE_KEY = 'storeWatchPanels'; +const PANEL_IDS = ['stores', 'watch']; + +function createDefaultPanelState() { + return { + order: [...PANEL_IDS], + collapsed: PANEL_IDS.reduce( + (acc, panelId) => ({ + ...acc, + [panelId]: false + }), + {} + ) + }; +} + +function formatWatchStatusLabel(status) { + if (status === 1) { + return 'Suchend'; + } + if (status === 0) { + return 'Nicht suchend'; + } + return 'Unbekannt'; +} const columnHelper = createColumnHelper(); @@ -21,6 +46,52 @@ const DEFAULT_TABLE_STATE = { columnFilters: [{ id: 'membership', value: 'false' }] }; +function normalizePanelLayout(state) { + const fallback = createDefaultPanelState(); + if (!state || typeof state !== 'object') { + return fallback; + } + const rawOrder = Array.isArray(state.order) ? state.order : []; + const normalizedOrder = rawOrder.filter((panelId) => PANEL_IDS.includes(panelId)); + const dedupedOrder = [...new Set([...normalizedOrder, ...PANEL_IDS])]; + const collapsed = { ...fallback.collapsed }; + PANEL_IDS.forEach((panelId) => { + collapsed[panelId] = Boolean(state?.collapsed?.[panelId]); + }); + return { + order: dedupedOrder, + collapsed + }; +} + +function readPanelLayout() { + if (typeof window === 'undefined') { + return createDefaultPanelState(); + } + try { + const raw = window.localStorage.getItem(PANEL_STORAGE_KEY); + if (!raw) { + return createDefaultPanelState(); + } + const parsed = JSON.parse(raw); + return normalizePanelLayout(parsed); + } catch { + return createDefaultPanelState(); + } +} + +function persistPanelLayout(state) { + if (typeof window === 'undefined') { + return; + } + try { + const normalized = normalizePanelLayout(state); + window.localStorage.setItem(PANEL_STORAGE_KEY, JSON.stringify(normalized)); + } catch { + /* ignore */ + } +} + const ColumnTextFilter = ({ column, placeholder }) => { if (!column.getCanFilter()) { return null; @@ -120,9 +191,14 @@ const StoreWatchPage = ({ const [error, setError] = useState(''); const [dirty, setDirty] = useState(false); const [saving, setSaving] = useState(false); + const [adhocChecking, setAdhocChecking] = useState(false); + const [lastAdhocCheck, setLastAdhocCheck] = useState(null); const initialTableState = useMemo(() => readWatchTableState(), []); const [sorting, setSorting] = useState(initialTableState.sorting); const [columnFilters, setColumnFilters] = useState(initialTableState.columnFilters); + const [panelLayout, setPanelLayout] = useState(() => readPanelLayout()); + const normalizedPanelLayout = useMemo(() => normalizePanelLayout(panelLayout), [panelLayout]); + const panelOrder = normalizedPanelLayout.order; const aggregatedRegionStores = useMemo(() => { const list = []; Object.values(storesByRegion).forEach((entry) => { @@ -173,6 +249,44 @@ const StoreWatchPage = ({ useEffect(() => { persistWatchTableState({ sorting, columnFilters }); }, [sorting, columnFilters]); + useEffect(() => { + persistPanelLayout(panelLayout); + }, [panelLayout]); + const togglePanelCollapsed = useCallback((panelId) => { + if (!PANEL_IDS.includes(panelId)) { + return; + } + setPanelLayout((prev) => { + const normalized = normalizePanelLayout(prev); + return { + ...normalized, + collapsed: { + ...normalized.collapsed, + [panelId]: !normalized.collapsed[panelId] + } + }; + }); + }, []); + const movePanel = useCallback((panelId, direction) => { + if (!PANEL_IDS.includes(panelId) || !direction) { + return; + } + setPanelLayout((prev) => { + const normalized = normalizePanelLayout(prev); + const order = [...normalized.order]; + const currentIndex = order.indexOf(panelId); + const delta = direction === 'up' ? -1 : 1; + const nextIndex = currentIndex + delta; + if (currentIndex === -1 || nextIndex < 0 || nextIndex >= order.length) { + return normalized; + } + [order[currentIndex], order[nextIndex]] = [order[nextIndex], order[currentIndex]]; + return { + ...normalized, + order + }; + }); + }, []); const watchedIds = useMemo( () => new Set(watchList.map((entry) => String(entry.storeId))), @@ -187,6 +301,7 @@ const StoreWatchPage = ({ }, [regions, selectedRegionId]); const activeRegionLabel = selectedRegionId === 'all' ? 'allen Regionen' : selectedRegion?.name || 'dieser Region'; + const storesPanelTitle = selectedRegionId === 'all' ? 'Betriebe' : `Betriebe in ${activeRegionLabel}`; const selectedStatusMeta = useMemo(() => { if (selectedRegionId === 'all') { @@ -854,6 +969,201 @@ const StoreWatchPage = ({ fetchStoresForRegion(selectedRegionId, { forceStatus: true }); } }, [selectedRegionId, fetchAllRegions, fetchStoresForRegion]); + const handleAdhocWatchCheck = useCallback(async () => { + if (!authorizedFetch || adhocChecking || watchList.length === 0) { + return; + } + setAdhocChecking(true); + setError(''); + try { + const response = await authorizedFetch('/api/store-watch/check', { method: 'POST' }); + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + const data = await response.json(); + const summary = Array.isArray(data.stores) ? data.stores : []; + setLastAdhocCheck({ + checkedAt: Date.now(), + stores: summary + }); + setStatus('Ad-hoc-Prüfung abgeschlossen. Zusammenfassung versendet.'); + setTimeout(() => setStatus(''), 4000); + await loadSubscriptions(); + } catch (err) { + setError(`Ad-hoc-Prüfung fehlgeschlagen: ${err.message}`); + } finally { + setAdhocChecking(false); + } + }, [authorizedFetch, adhocChecking, watchList.length, loadSubscriptions]); + const panelTitles = { + stores: storesPanelTitle, + watch: `Überwachte Betriebe (${watchList.length})` + }; + const renderPanelRightContent = (panelId) => { + if (panelId === 'stores') { + if (!lastUpdatedAt) { + return null; + } + return ( + + Aktualisiert: {new Date(lastUpdatedAt).toLocaleTimeString('de-DE')} + + ); + } + if (panelId === 'watch') { + return ( +
+ + + +
+ ); + } + return null; + }; + const renderPanelContent = (panelId) => { + if (panelId === 'stores') { + return ( + <> + {storesLoading &&

Lade Betriebe...

} + {!storesLoading && table.getRowModel().rows.length === 0 && ( +

+ Keine Betriebe gefunden. Prüfe Filter oder sortiere anders. +

+ )} + {!storesLoading && table.getRowModel().rows.length > 0 && ( +
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + ))} + + ))} + + + {table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + ))} + + ))} + {table.getRowModel().rows.length === 0 && ( + + + + )} + +
+ {flexRender(header.column.columnDef.header, header.getContext())} +
+ {flexRender(cell.column.columnDef.cell, cell.getContext())} +
+ Keine Betriebe entsprechen den aktuellen Filtern. +
+
+ )} + + ); + } + if (panelId === 'watch') { + return ( + <> + {subscriptionsLoading &&

Lade aktuelle Auswahl...

} + {!subscriptionsLoading && watchList.length === 0 && ( +

Noch keine Betriebe ausgewählt.

+ )} + {watchList.length > 0 && ( +
+ {watchList.map((entry) => ( +
+
+
+

{entry.storeName}

+

+ #{entry.storeId} — {entry.regionName || 'Region unbekannt'} +

+
+ +
+

+ Letzter Status:{' '} + {formatWatchStatusLabel(entry.lastTeamSearchStatus)}{' '} + {entry.lastStatusCheckAt + ? `(geprüft am ${new Date(entry.lastStatusCheckAt).toLocaleString('de-DE')})` + : '(noch nicht geprüft)'} +

+
+ ))} +
+ )} + {lastAdhocCheck?.stores?.length > 0 && ( +
+

+ Letzte Ad-hoc-Prüfung:{' '} + {new Date(lastAdhocCheck.checkedAt).toLocaleString('de-DE')} +

+ +
+ )} + + ); + } + return null; + }; if (!authorizedFetch) { return ( @@ -985,121 +1295,66 @@ const StoreWatchPage = ({ -
-
-

Betriebe in {activeRegionLabel}

- {lastUpdatedAt && ( - - Aktualisiert: {new Date(lastUpdatedAt).toLocaleTimeString('de-DE')} - - )} -
- {storesLoading &&

Lade Betriebe...

} - {!storesLoading && table.getRowModel().rows.length === 0 && ( -

- Keine Betriebe gefunden. Prüfe Filter oder sortiere anders. -

- )} - {!storesLoading && table.getRowModel().rows.length > 0 && ( -
- - - {table.getHeaderGroups().map((headerGroup) => ( - - {headerGroup.headers.map((header) => ( - - ))} - - ))} - - - {table.getRowModel().rows.map((row) => ( - - {row.getVisibleCells().map((cell) => ( - - ))} - - ))} - {table.getRowModel().rows.length === 0 && ( - - - - )} - -
- {flexRender(header.column.columnDef.header, header.getContext())} -
- {flexRender(cell.column.columnDef.cell, cell.getContext())} -
- Keine Betriebe entsprechen den aktuellen Filtern. -
-
- )} -
- -
-
-

- Überwachte Betriebe ({watchList.length}) -

-
- - -
-
- {subscriptionsLoading &&

Lade aktuelle Auswahl...

} - {!subscriptionsLoading && watchList.length === 0 && ( -

Noch keine Betriebe ausgewählt.

- )} - {watchList.length > 0 && ( -
- {watchList.map((entry) => ( -
-
-
-

{entry.storeName}

-

- #{entry.storeId} — {entry.regionName || 'Region unbekannt'} -

-
- + {panelOrder.map((panelId) => { + const collapsed = Boolean(normalizedPanelLayout.collapsed?.[panelId]); + const title = panelTitles[panelId] || panelId; + const panelIndex = panelOrder.indexOf(panelId); + const canMoveUp = panelIndex > 0; + const canMoveDown = panelIndex < panelOrder.length - 1; + const rightContent = renderPanelRightContent(panelId); + const showRightColumn = Boolean(rightContent) || (collapsed && panelOrder.length > 1); + return ( +
+
+ + {showRightColumn && ( +
+ {rightContent} + {collapsed && panelOrder.length > 1 && ( +
+ Reihenfolge: +
+ + +
+
+ )}
-

- Letzter Status:{' '} - {entry.lastTeamSearchStatus === 1 - ? 'Suchend' - : entry.lastTeamSearchStatus === 0 - ? 'Nicht suchend' - : 'Unbekannt'} -

+ )} +
+ {!collapsed &&
{renderPanelContent(panelId)}
} + {collapsed && ( +
+ Bereich ist eingeklappt. Über die Pfeile kann die Reihenfolge angepasst werden.
- ))} -
- )} -
+ )} + + ); + })} {dirty && (