Watch Stores mit Offen-Spalte

This commit is contained in:
2025-11-10 18:27:09 +01:00
parent 34080fe292
commit aaf83eeb95
3 changed files with 294 additions and 38 deletions

163
server.js
View File

@@ -13,6 +13,7 @@ const { readNotificationSettings, writeNotificationSettings } = require('./servi
const notificationService = require('./services/notificationService'); const notificationService = require('./services/notificationService');
const { readStoreWatch, writeStoreWatch } = require('./services/storeWatchStore'); const { readStoreWatch, writeStoreWatch } = require('./services/storeWatchStore');
const { readPreferences, writePreferences } = require('./services/userPreferencesStore'); const { readPreferences, writePreferences } = require('./services/userPreferencesStore');
const { readStoreStatus, writeStoreStatus } = require('./services/storeStatusStore');
const ONE_YEAR_MS = 365 * 24 * 60 * 60 * 1000; const ONE_YEAR_MS = 365 * 24 * 60 * 60 * 1000;
const adminEmail = (process.env.ADMIN_EMAIL || '').toLowerCase(); const adminEmail = (process.env.ADMIN_EMAIL || '').toLowerCase();
@@ -24,6 +25,35 @@ const storeRefreshJobs = new Map();
const cachedStoreSnapshots = new Map(); const cachedStoreSnapshots = new Map();
const regionStoreCache = new Map(); const regionStoreCache = new Map();
const REGION_STORE_CACHE_MS = 15 * 60 * 1000; const REGION_STORE_CACHE_MS = 15 * 60 * 1000;
const STORE_STATUS_MAX_AGE_MS = 60 * 24 * 60 * 60 * 1000;
const storeStatusCache = new Map();
(function bootstrapStoreStatusCache() {
try {
const cached = readStoreStatus();
Object.entries(cached || {}).forEach(([storeId, entry]) => {
if (entry && typeof entry === 'object') {
storeStatusCache.set(String(storeId), {
teamSearchStatus:
entry.teamSearchStatus === null || entry.teamSearchStatus === undefined
? null
: Number(entry.teamSearchStatus),
fetchedAt: Number(entry.fetchedAt) || 0
});
}
});
} catch (error) {
console.error('[STORE-STATUS] Bootstrap fehlgeschlagen:', error.message);
}
})();
function persistStoreStatusCache() {
const payload = {};
storeStatusCache.forEach((value, key) => {
payload[key] = value;
});
writeStoreStatus(payload);
}
app.use(cors()); app.use(cors());
app.use(express.json({ limit: '1mb' })); app.use(express.json({ limit: '1mb' }));
@@ -111,6 +141,90 @@ function setCachedRegionStores(regionId, payload) {
}); });
} }
function getCachedStoreStatus(storeId) {
return storeStatusCache.get(String(storeId)) || null;
}
async function refreshStoreStatus(storeIds = [], cookieHeader, { force = false } = {}) {
if (!Array.isArray(storeIds) || storeIds.length === 0 || !cookieHeader) {
return { refreshed: 0 };
}
const now = Date.now();
let refreshed = 0;
for (const id of storeIds) {
const storeId = String(id);
const entry = storeStatusCache.get(storeId);
const ageOk = entry && now - (entry.fetchedAt || 0) <= STORE_STATUS_MAX_AGE_MS;
if (!force && ageOk) {
continue;
}
try {
const details = await foodsharingClient.fetchStoreDetails(storeId, cookieHeader);
const status = Number(details?.teamSearchStatus);
const normalized = Number.isFinite(status) ? status : null;
storeStatusCache.set(storeId, {
teamSearchStatus: normalized,
fetchedAt: now
});
refreshed += 1;
} catch (error) {
console.error(`[STORE-STATUS] Status für Store ${storeId} konnte nicht aktualisiert werden:`, error.message);
}
}
if (refreshed > 0) {
persistStoreStatusCache();
}
return { refreshed };
}
async function enrichStoresWithTeamStatus(stores = [], cookieHeader, { forceRefresh = false } = {}) {
if (!Array.isArray(stores) || stores.length === 0) {
return { stores, statusMeta: { total: 0, refreshed: 0, fromCache: 0, missing: 0 } };
}
const now = Date.now();
const staleIds = [];
let cacheHits = 0;
stores.forEach((store) => {
const entry = getCachedStoreStatus(store.id);
const fresh = entry && now - (entry.fetchedAt || 0) <= STORE_STATUS_MAX_AGE_MS;
if (entry && fresh && !forceRefresh) {
store.teamSearchStatus = entry.teamSearchStatus;
store.teamStatusUpdatedAt = entry.fetchedAt || null;
cacheHits += 1;
} else {
staleIds.push(store.id);
}
});
let refreshed = 0;
if (staleIds.length > 0) {
const result = await refreshStoreStatus(staleIds, cookieHeader, { force: forceRefresh });
refreshed = result.refreshed || 0;
}
stores.forEach((store) => {
if (store.teamSearchStatus === undefined) {
const entry = getCachedStoreStatus(store.id);
if (entry) {
store.teamSearchStatus = entry.teamSearchStatus;
store.teamStatusUpdatedAt = entry.fetchedAt || null;
} else {
store.teamSearchStatus = null;
store.teamStatusUpdatedAt = null;
}
}
});
const statusMeta = {
total: stores.length,
refreshed,
fromCache: cacheHits,
missing: stores.filter((store) => store.teamStatusUpdatedAt == null).length,
generatedAt: Date.now()
};
return { stores, statusMeta };
}
function isStoreCacheFresh(session) { function isStoreCacheFresh(session) {
if (!session?.storesCache?.fetchedAt) { if (!session?.storesCache?.fetchedAt) {
return false; return false;
@@ -409,25 +523,46 @@ app.get('/api/store-watch/regions/:regionId/stores', requireAuth, async (req, re
if (!regionId) { if (!regionId) {
return res.status(400).json({ error: 'Region-ID fehlt' }); return res.status(400).json({ error: 'Region-ID fehlt' });
} }
const forceRefresh = req.query.force === '1'; const forceRegionRefresh = req.query.force === '1';
if (!forceRefresh) { const forceStatusRefresh = req.query.forceStatus === '1';
let basePayload = null;
if (!forceRegionRefresh) {
const cached = getCachedRegionStores(regionId); const cached = getCachedRegionStores(regionId);
if (cached) { if (cached) {
return res.json(cached); basePayload = cached;
} }
} }
try {
const result = await foodsharingClient.fetchRegionStores(regionId, req.session.cookieHeader); if (!basePayload) {
const payload = { try {
total: Number(result?.total) || 0, const result = await foodsharingClient.fetchRegionStores(regionId, req.session.cookieHeader);
stores: Array.isArray(result?.stores) ? result.stores : [] basePayload = {
}; total: Number(result?.total) || 0,
setCachedRegionStores(regionId, payload); stores: Array.isArray(result?.stores) ? result.stores : []
res.json(payload); };
} catch (error) { setCachedRegionStores(regionId, basePayload);
console.error(`[STORE-WATCH] Stores für Region ${regionId} konnten nicht geladen werden:`, error.message); } catch (error) {
res.status(500).json({ error: 'Betriebe konnten nicht geladen werden' }); console.error(`[STORE-WATCH] Stores für Region ${regionId} konnten nicht geladen werden:`, error.message);
return res.status(500).json({ error: 'Betriebe konnten nicht geladen werden' });
}
} }
const filteredStores = basePayload.stores
.filter((store) => Number(store.cooperationStatus) === 5)
.map((store) => ({ ...store, id: String(store.id) }));
const { stores: enrichedStores, statusMeta } = await enrichStoresWithTeamStatus(
filteredStores,
req.session.cookieHeader,
{ forceRefresh: forceStatusRefresh }
);
res.json({
total: filteredStores.length,
stores: enrichedStores,
statusMeta
});
}); });
app.get('/api/store-watch/subscriptions', requireAuth, (req, res) => { app.get('/api/store-watch/subscriptions', requireAuth, (req, res) => {

View File

@@ -0,0 +1,40 @@
const fs = require('fs');
const path = require('path');
const STORE_STATUS_FILE = path.join(__dirname, '..', 'config', 'store-watch-status.json');
function ensureDir() {
const dir = path.dirname(STORE_STATUS_FILE);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
}
function readStoreStatus() {
ensureDir();
if (!fs.existsSync(STORE_STATUS_FILE)) {
fs.writeFileSync(STORE_STATUS_FILE, JSON.stringify({}, null, 2));
return {};
}
try {
const raw = fs.readFileSync(STORE_STATUS_FILE, 'utf8');
const parsed = JSON.parse(raw);
if (parsed && typeof parsed === 'object') {
return parsed;
}
return {};
} catch (error) {
console.error('[STORE-STATUS] Konnte Cache nicht lesen:', error.message);
return {};
}
}
function writeStoreStatus(cache = {}) {
ensureDir();
fs.writeFileSync(STORE_STATUS_FILE, JSON.stringify(cache, null, 2));
}
module.exports = {
readStoreStatus,
writeStoreStatus
};

View File

@@ -144,6 +144,29 @@ const StoreWatchPage = ({ authorizedFetch, knownStores = [], userLocation }) =>
const activeRegionLabel = selectedRegionId === 'all' ? 'allen Regionen' : selectedRegion?.name || 'dieser Region'; const activeRegionLabel = selectedRegionId === 'all' ? 'allen Regionen' : selectedRegion?.name || 'dieser Region';
const selectedStatusMeta = useMemo(() => {
if (selectedRegionId === 'all') {
const metas = regions
.map((region) => storesByRegion[String(region.id)]?.statusMeta)
.filter(Boolean);
if (metas.length === 0) {
return null;
}
const aggregated = metas.reduce(
(acc, meta) => ({
total: acc.total + (meta.total || 0),
refreshed: acc.refreshed + (meta.refreshed || 0),
fromCache: acc.fromCache + (meta.fromCache || 0),
missing: acc.missing + (meta.missing || 0),
generatedAt: Math.max(acc.generatedAt, meta.generatedAt || 0)
}),
{ total: 0, refreshed: 0, fromCache: 0, missing: 0, generatedAt: 0 }
);
return aggregated;
}
return storesByRegion[String(selectedRegionId)]?.statusMeta || null;
}, [selectedRegionId, storesByRegion, regions]);
const lastUpdatedAt = useMemo(() => { const lastUpdatedAt = useMemo(() => {
if (selectedRegionId === 'all') { if (selectedRegionId === 'all') {
const timestamps = regions const timestamps = regions
@@ -179,7 +202,27 @@ const StoreWatchPage = ({ authorizedFetch, knownStores = [], userLocation }) =>
return regionEntry.stores; return regionEntry.stores;
}, [regions, storesByRegion, selectedRegionId]); }, [regions, storesByRegion, selectedRegionId]);
const regionStores = useMemo(() => currentStores, [currentStores]); const regionStores = useMemo(
() => currentStores.filter((store) => Number(store.cooperationStatus) === 5),
[currentStores]
);
const statusSummary = useMemo(() => {
if (!selectedStatusMeta) {
return 'Team-Status noch nicht geladen.';
}
const parts = [
`${selectedStatusMeta.refreshed || 0} aktualisiert`,
`${selectedStatusMeta.fromCache || 0} aus Cache`
];
if (selectedStatusMeta.missing) {
parts.push(`${selectedStatusMeta.missing} ohne Daten`);
}
const timestamp = selectedStatusMeta.generatedAt
? new Date(selectedStatusMeta.generatedAt).toLocaleString('de-DE')
: null;
return `Team-Status: ${parts.join(', ')}${timestamp ? ` (Stand ${timestamp})` : ''}`;
}, [selectedStatusMeta]);
const membershipMap = useMemo(() => { const membershipMap = useMemo(() => {
const map = new Map(); const map = new Map();
@@ -197,7 +240,7 @@ const StoreWatchPage = ({ authorizedFetch, knownStores = [], userLocation }) =>
const storeId = String(store.id || store.storeId); const storeId = String(store.id || store.storeId);
const existing = prev.find((entry) => entry.storeId === storeId); const existing = prev.find((entry) => entry.storeId === storeId);
if (checked) { if (checked) {
if (!store.isOpen || existing) { if (existing) {
return prev; return prev;
} }
setDirty(true); setDirty(true);
@@ -239,15 +282,20 @@ const StoreWatchPage = ({ authorizedFetch, knownStores = [], userLocation }) =>
const membership = membershipMap.has(String(store.id)); const membership = membershipMap.has(String(store.id));
const lat = Number(store.location?.lat); const lat = Number(store.location?.lat);
const lon = Number(store.location?.lon); const lon = Number(store.location?.lon);
const statusValue = store.teamSearchStatus === null || store.teamSearchStatus === undefined
? null
: Number(store.teamSearchStatus);
const distance = const distance =
userLocation && Number.isFinite(lat) && Number.isFinite(lon) userLocation && Number.isFinite(lat) && Number.isFinite(lon)
? haversineDistanceKm(userLocation.lat, userLocation.lon, lat, lon) ? haversineDistanceKm(userLocation.lat, userLocation.lon, lat, lon)
: null; : null;
const isOpen = Number(store.cooperationStatus) === 5; const isOpen = statusValue === 1;
return { return {
...store, ...store,
membership, membership,
distanceKm: distance, distanceKm: distance,
teamStatusUpdatedAt: store.teamStatusUpdatedAt || null,
teamSearchStatus: statusValue,
isOpen isOpen
}; };
}), }),
@@ -364,16 +412,25 @@ const StoreWatchPage = ({ authorizedFetch, knownStores = [], userLocation }) =>
/> />
</div> </div>
), ),
cell: ({ getValue }) => { cell: ({ row, getValue }) => {
const value = getValue(); const value = getValue();
const updatedAt = row.original.teamStatusUpdatedAt
? new Date(row.original.teamStatusUpdatedAt).toLocaleDateString('de-DE')
: null;
if (value === null) {
return <span className="text-sm text-gray-500"></span>;
}
return ( return (
<span <div className="text-center">
className={`inline-flex items-center justify-center rounded px-2 py-1 text-xs font-semibold ${ <span
value ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-500' className={`inline-flex items-center justify-center rounded px-2 py-1 text-xs font-semibold ${
}`} value ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-500'
> }`}
{value ? 'Ja' : 'Nein'} >
</span> {value ? 'Ja' : 'Nein'}
</span>
{updatedAt && <p className="text-[10px] text-gray-500 mt-0.5">{updatedAt}</p>}
</div>
); );
}, },
filterFn: (row, columnId, value) => { filterFn: (row, columnId, value) => {
@@ -571,11 +628,11 @@ const StoreWatchPage = ({ authorizedFetch, knownStores = [], userLocation }) =>
}, [authorizedFetch]); }, [authorizedFetch]);
const fetchStoresForRegion = useCallback( const fetchStoresForRegion = useCallback(
async (regionId, { force, silent } = {}) => { async (regionId, { force, silent, forceStatus } = {}) => {
if (!authorizedFetch || !regionId) { if (!authorizedFetch || !regionId) {
return; return;
} }
if (!force && storesByRegion[String(regionId)]) { if (!force && !forceStatus && storesByRegion[String(regionId)]) {
return; return;
} }
if (!silent) { if (!silent) {
@@ -583,9 +640,15 @@ const StoreWatchPage = ({ authorizedFetch, knownStores = [], userLocation }) =>
} }
setError(''); setError('');
try { try {
const endpoint = force const params = new URLSearchParams();
? `/api/store-watch/regions/${regionId}/stores?force=1` if (force) {
: `/api/store-watch/regions/${regionId}/stores`; params.append('force', '1');
}
if (forceStatus) {
params.append('forceStatus', '1');
}
const qs = params.toString();
const endpoint = `/api/store-watch/regions/${regionId}/stores${qs ? `?${qs}` : ''}`;
const response = await authorizedFetch(endpoint); const response = await authorizedFetch(endpoint);
if (!response.ok) { if (!response.ok) {
throw new Error(`HTTP ${response.status}`); throw new Error(`HTTP ${response.status}`);
@@ -596,7 +659,8 @@ const StoreWatchPage = ({ authorizedFetch, knownStores = [], userLocation }) =>
[String(regionId)]: { [String(regionId)]: {
total: Number(data.total) || 0, total: Number(data.total) || 0,
stores: Array.isArray(data.stores) ? data.stores : [], stores: Array.isArray(data.stores) ? data.stores : [],
fetchedAt: Date.now() fetchedAt: Date.now(),
statusMeta: data.statusMeta || null
} }
})); }));
} catch (err) { } catch (err) {
@@ -611,12 +675,12 @@ const StoreWatchPage = ({ authorizedFetch, knownStores = [], userLocation }) =>
); );
const fetchAllRegions = useCallback( const fetchAllRegions = useCallback(
async ({ force } = {}) => { async ({ force, forceStatus } = {}) => {
if (!authorizedFetch || regions.length === 0) { if (!authorizedFetch || regions.length === 0) {
return; return;
} }
const targets = regions.filter( const targets = regions.filter(
(region) => force || !storesByRegion[String(region.id)] (region) => force || forceStatus || !storesByRegion[String(region.id)]
); );
if (targets.length === 0) { if (targets.length === 0) {
return; return;
@@ -625,7 +689,7 @@ const StoreWatchPage = ({ authorizedFetch, knownStores = [], userLocation }) =>
setError(''); setError('');
try { try {
for (const region of targets) { for (const region of targets) {
await fetchStoresForRegion(region.id, { force, silent: true }); await fetchStoresForRegion(region.id, { force, silent: true, forceStatus });
} }
} catch (err) { } catch (err) {
setError(`Betriebe konnten nicht geladen werden: ${err.message}`); setError(`Betriebe konnten nicht geladen werden: ${err.message}`);
@@ -684,6 +748,14 @@ const StoreWatchPage = ({ authorizedFetch, knownStores = [], userLocation }) =>
loadSubscriptions(); loadSubscriptions();
}, [loadSubscriptions]); }, [loadSubscriptions]);
const handleStatusRefresh = useCallback(() => {
if (selectedRegionId === 'all') {
fetchAllRegions({ force: true, forceStatus: true });
} else if (selectedRegionId) {
fetchStoresForRegion(selectedRegionId, { forceStatus: true });
}
}, [selectedRegionId, fetchAllRegions, fetchStoresForRegion]);
if (!authorizedFetch) { if (!authorizedFetch) {
return ( return (
<div className="p-4 max-w-4xl mx-auto"> <div className="p-4 max-w-4xl mx-auto">
@@ -735,7 +807,7 @@ const StoreWatchPage = ({ authorizedFetch, knownStores = [], userLocation }) =>
))} ))}
</select> </select>
</div> </div>
<div className="flex gap-2"> <div className="flex flex-wrap gap-2">
<button <button
type="button" type="button"
onClick={() => loadRegions()} onClick={() => loadRegions()}
@@ -756,11 +828,20 @@ const StoreWatchPage = ({ authorizedFetch, knownStores = [], userLocation }) =>
> >
Betriebe aktualisieren Betriebe aktualisieren
</button> </button>
<button
type="button"
onClick={handleStatusRefresh}
className="px-4 py-2 border rounded-md text-sm bg-white hover:bg-gray-100"
disabled={regionLoading || (selectedRegionId === 'all' && regions.length === 0)}
>
Team-Status aktualisieren
</button>
</div> </div>
</div> </div>
<p className="text-xs text-gray-500 mt-2"> <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 mt-2 text-xs text-gray-500">
Es werden nur Regionen angezeigt, in denen du Mitglied mit Klassifikation 1 bist. <span>Es werden nur Regionen angezeigt, in denen du Mitglied mit Klassifikation 1 bist.</span>
</p> <span>{statusSummary}</span>
</div>
</div> </div>
<div className="mb-6"> <div className="mb-6">