document.addEventListener('DOMContentLoaded', () => { // State let allWeeks = []; let currentWeekNumber = getISOWeek(new Date()); let currentYear = new Date().getFullYear(); let displayMode = 'this-week'; // 'this-week' or 'next-week' let authToken = sessionStorage.getItem('authToken'); let currentUser = sessionStorage.getItem('currentUser'); // orderMap: key = "date_articleId" -> value = [orderId, orderId, ...] let orderMap = new Map(); // userFlags: Set of "date_articleId" that are flagged let userFlags = new Set(); let eventSource = null; // Long-lived SSE connection // DOM Elements const themeToggle = document.getElementById('theme-toggle'); const themeIcon = themeToggle.querySelector('.theme-icon'); const loading = document.getElementById('loading'); const menuContainer = document.getElementById('menu-container'); const lastUpdatedBanner = document.getElementById('last-updated-banner'); const lastUpdatedText = document.getElementById('last-updated-text'); const btnThisWeek = document.getElementById('btn-this-week'); const btnNextWeek = document.getElementById('btn-next-week'); // Login Elements const btnLoginOpen = document.getElementById('btn-login-open'); const btnLoginClose = document.getElementById('btn-login-close'); const loginModal = document.getElementById('login-modal'); const loginForm = document.getElementById('login-form'); const loginError = document.getElementById('login-error'); const userInfo = document.getElementById('user-info'); const userIdDisplay = document.getElementById('user-id-display'); const btnLogout = document.getElementById('btn-logout'); // Refresh Elements const btnRefresh = document.getElementById('btn-refresh'); const progressModal = document.getElementById('progress-modal'); const progressFill = document.getElementById('progress-fill'); const progressPercent = document.getElementById('progress-percent'); const progressMessage = document.getElementById('progress-message'); // === Theme Handling === const prefersDarkScheme = window.matchMedia('(prefers-color-scheme: dark)'); const savedTheme = localStorage.getItem('theme'); if (savedTheme === 'dark' || (!savedTheme && prefersDarkScheme.matches)) { document.documentElement.setAttribute('data-theme', 'dark'); themeIcon.textContent = 'dark_mode'; } else { document.documentElement.setAttribute('data-theme', 'light'); themeIcon.textContent = 'light_mode'; } themeToggle.addEventListener('click', () => { const currentTheme = document.documentElement.getAttribute('data-theme'); const newTheme = currentTheme === 'dark' ? 'light' : 'dark'; document.documentElement.setAttribute('data-theme', newTheme); localStorage.setItem('theme', newTheme); themeIcon.textContent = newTheme === 'dark' ? 'dark_mode' : 'light_mode'; }); // === Navigation handling === btnThisWeek.addEventListener('click', () => { if (displayMode !== 'this-week') { displayMode = 'this-week'; updateNavState(); renderVisibleWeeks(); } }); btnNextWeek.addEventListener('click', () => { if (displayMode !== 'next-week') { displayMode = 'next-week'; updateNavState(); renderVisibleWeeks(); } }); function updateNavState() { if (displayMode === 'this-week') { btnThisWeek.classList.add('active'); btnNextWeek.classList.remove('active'); } else { btnThisWeek.classList.remove('active'); btnNextWeek.classList.add('active'); } } // === Login Handling === btnLoginOpen.addEventListener('click', () => { loginModal.classList.remove('hidden'); loginError.classList.add('hidden'); loginForm.reset(); }); btnLoginClose.addEventListener('click', () => { loginModal.classList.add('hidden'); }); window.addEventListener('click', (e) => { if (e.target === loginModal) { loginModal.classList.add('hidden'); } }); loginForm.addEventListener('submit', async (e) => { e.preventDefault(); const employeeId = document.getElementById('employee-id').value; const password = document.getElementById('password').value; loginError.classList.add('hidden'); const submitBtn = loginForm.querySelector('button[type="submit"]'); const originalText = submitBtn.textContent; submitBtn.disabled = true; submitBtn.textContent = 'Logging in...'; try { const response = await fetch('/api/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ employeeId, password }) }); const data = await response.json(); if (response.ok) { authToken = data.key; currentUser = employeeId; // Save to session storage sessionStorage.setItem('authToken', data.key); sessionStorage.setItem('currentUser', employeeId); if (data.firstName) { sessionStorage.setItem('firstName', data.firstName); } if (data.lastName) { sessionStorage.setItem('lastName', data.lastName); } // Update UI updateAuthUI(); loginModal.classList.add('hidden'); // Load user specific data fetchOrders(); fetchFlags(); // Clear form document.getElementById('employee-id').value = ''; document.getElementById('password').value = ''; // Initialize Notification/Polling SSE initSSE(); } else { loginError.textContent = data.error || 'Login failed'; loginError.classList.remove('hidden'); } } catch (error) { console.error('Login error:', error); loginError.textContent = 'Ein Fehler ist aufgetreten'; loginError.classList.remove('hidden'); } finally { submitBtn.disabled = false; submitBtn.textContent = originalText; } }); btnLogout.addEventListener('click', () => { sessionStorage.removeItem('authToken'); sessionStorage.removeItem('currentUser'); sessionStorage.removeItem('firstName'); sessionStorage.removeItem('lastName'); authToken = null; currentUser = null; orderMap = new Map(); updateAuthUI(); renderVisibleWeeks(); // Re-render to update badges }); function updateAuthUI() { authToken = sessionStorage.getItem('authToken'); currentUser = sessionStorage.getItem('currentUser'); let firstName = sessionStorage.getItem('firstName'); if (authToken && currentUser) { // Self-healing: If name is missing, fetch it if (!firstName) { fetch('/api/me', { headers: { 'Authorization': `Token ${authToken}` } }) .then(res => res.json()) .then(data => { if (data.firstName) { sessionStorage.setItem('firstName', data.firstName); if (data.lastName) sessionStorage.setItem('lastName', data.lastName); // Re-run updateAuthUI to display the fetched name updateAuthUI(); } }) .catch(err => console.error('Failed to fetch user info:', err)); } btnLoginOpen.classList.add('hidden'); userInfo.classList.remove('hidden'); // Display First Name if available, otherwise User ID const displayName = firstName || `User ${currentUser}`; userIdDisplay.textContent = displayName; } else { btnLoginOpen.classList.remove('hidden'); userInfo.classList.add('hidden'); userIdDisplay.textContent = ''; } if (authToken) { fetchOrders(); fetchFlags(); initSSE(); } // Re-render to potentially show order badges renderVisibleWeeks(); } async function fetchOrders() { if (!authToken) return; try { const response = await fetch('/api/user/orders', { headers: { 'Authorization': `Token ${authToken}` } }); const data = await response.json(); if (response.ok) { // Build orderMap from dateOrders: key="date_articleId" -> [orderId, ...] orderMap = new Map(); if (data.dateOrders) { for (const dayData of data.dateOrders) { for (const order of dayData.orders) { for (const item of order.items) { const key = `${dayData.date}_${item.articleId}`; if (!orderMap.has(key)) { orderMap.set(key, []); } orderMap.get(key).push(order.id); } } } } renderVisibleWeeks(); } } catch (error) { console.error('Error fetching orders:', error); } } async function fetchFlags() { if (!authToken) return; try { const response = await fetch('/api/flags', { headers: { 'Authorization': `Token ${authToken}` } }); const flags = await response.json(); userFlags.clear(); if (Array.isArray(flags)) { flags.forEach(f => userFlags.add(f.id)); } renderVisibleWeeks(); } catch (error) { console.error('Error fetching flags:', error); } } async function toggleFlag(date, articleId, name, cutoff, description) { if (!authToken) return; const id = `${date}_${articleId}`; const isFlagged = userFlags.has(id); try { if (isFlagged) { // Remove flag const response = await fetch(`/api/flags/${id}`, { method: 'DELETE', headers: { 'Authorization': `Token ${authToken}` } }); if (response.ok) { userFlags.delete(id); showToast(`Flag entfernt für ${name}`, 'success'); } } else { // Add flag const response = await fetch('/api/flags', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Token ${authToken}` }, body: JSON.stringify({ id, date, articleId, userId: currentUser, // Sending ID for logging cutoff, name, description }) }); if (response.ok) { userFlags.add(id); showToast(`Benachrichtigung aktiviert für ${name}`, 'success'); // Request notification permission if not granted if (Notification.permission === 'default') { Notification.requestPermission(); } } } renderVisibleWeeks(); // Re-render to update UI } catch (error) { console.error('Flag toggle error:', error); showToast('Fehler beim Aktualisieren des Flags', 'error'); } } // Place an order for a menu item async function placeOrder(date, articleId, name, price, description) { if (!authToken) return; try { const response = await fetch('/api/order', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Token ${authToken}` }, body: JSON.stringify({ date, articleId, name, price, description }) }); const data = await response.json(); if (response.ok || response.status === 201) { showToast(`Bestellt: ${name}`, 'success'); await fetchOrders(); // Re-sync from Bessa } else { showToast(`Fehler: ${data.error || 'Bestellung fehlgeschlagen'}`, 'error'); } } catch (error) { console.error('Order error:', error); showToast('Netzwerkfehler bei Bestellung', 'error'); } } // Cancel an order (LIFO: cancels the most recent order) async function cancelOrder(date, articleId, name) { if (!authToken) return; const key = `${date}_${articleId}`; const orderIds = orderMap.get(key); if (!orderIds || orderIds.length === 0) return; // LIFO: cancel the last (most recent) order const orderId = orderIds[orderIds.length - 1]; try { const response = await fetch('/api/order/cancel', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Token ${authToken}` }, body: JSON.stringify({ orderId }) }); const data = await response.json(); if (response.ok) { showToast(`Storniert: ${name}`, 'success'); await fetchOrders(); // Re-sync from Bessa } else { showToast(`Fehler: ${data.error || 'Stornierung fehlgeschlagen'}`, 'error'); } } catch (error) { console.error('Cancel error:', error); showToast('Netzwerkfehler bei Stornierung', 'error'); } } // Toast notification system function showToast(message, type = 'info') { let container = document.getElementById('toast-container'); if (!container) { container = document.createElement('div'); container.id = 'toast-container'; document.body.appendChild(container); } const toast = document.createElement('div'); toast.className = `toast toast-${type}`; const icon = type === 'success' ? 'check_circle' : type === 'error' ? 'error' : 'info'; toast.innerHTML = `${message}`; container.appendChild(toast); // Animate in requestAnimationFrame(() => toast.classList.add('show')); // Auto-remove after 3 seconds setTimeout(() => { toast.classList.remove('show'); setTimeout(() => toast.remove(), 300); }, 3000); } // === Data Fetching === // === Data Fetching === // Initial load loadMenuData(); function loadMenuData() { loading.classList.remove('hidden'); return fetch('/api/menus') .then(response => response.json()) .then(data => { // Update Last Updated Text updateLastUpdatedTime(data.updated || data.scrapedAt); // Parse data if (data.weeks && Array.isArray(data.weeks)) { allWeeks = data.weeks; } else if (data.days && Array.isArray(data.days)) { allWeeks = [data]; } else { allWeeks = Object.values(data).filter(val => val && val.days); } // Recalculate current week to be sure currentWeekNumber = getISOWeek(new Date()); // If we have a session, fetch orders if (authToken) { // updateAuthUI(); // Already called in login check if strictly needed, but safe here fetchOrders(); } updateAuthUI(); // Ensure UI is consistent // Initial render renderVisibleWeeks(); updateNextWeekBadge(); }) .catch(error => { console.error('Error fetching menu:', error); loading.innerHTML = `
Failed to load menu data.
`; }) .finally(() => { loading.classList.add('hidden'); }); } // Initialize Long-Lived SSE for Notifications & Distributed Polling function initSSE() { if (eventSource) return; // Already connected if (!authToken) return; console.log('Connecting to SSE events...'); eventSource = new EventSource('/api/events'); eventSource.addEventListener('connected', (e) => { console.log('SSE Connected:', JSON.parse(e.data)); }); // Handle Polling Request (Distributed Polling) eventSource.addEventListener('poll_request', async (e) => { const data = JSON.parse(e.data); console.log('Received Poll Request:', data); // Fetch status check await checkItemStatusAndReport(data); }); // Handle Item Update (Notification) eventSource.addEventListener('item_update', (e) => { const data = JSON.parse(e.data); console.log('Item Update:', data); if (data.status === 'available') { // Show Notification showToast(`${data.name} ist jetzt verfügbar!`, 'success'); if (Notification.permission === 'granted') { new Notification('Kantine Wrapper', { body: `${data.name} ist jetzt verfügbar!`, icon: '/favicon.ico' // Assuming favicon exists }); } // Update local data (hacky refresh or fetch?) // Ideally we update just the item in memory? // Let's reload everything for safety or trigger a targeted update? // Reloading is safest to get correct stock numbers loadMenuData(); } }); eventSource.onerror = (e) => { console.error('SSE Error:', e); eventSource.close(); eventSource = null; // Retry after delay? setTimeout(() => initSSE(), 5000); }; } async function checkItemStatusAndReport(task) { try { // We need to fetch the menu for that specific date to check availability // Using the existing Bessa proxy indirectly via the menu-scraper approach? // No, we are the client. The client (browser) needs to fetch? // Wait, frontend cannot fetch from Bessa API directly (CORS). // The "Distributed Polling" implies the CLIENT (User's Browser) triggers a check using THEIR session. // BUT the Client Browser cannot call `api.bessa.app` directly due to CORS if Bessa doesn't allow it. // Bessa API CORS might block localhost:3000. // // CRITICAL CHECK: Does Bessa API allow CORS from anywhere? // If not, "Distributed Polling" via Browser is impossible without a proxy. // And if we use a proxy (our server), we defeat the purpose of "Distributed Polling" to avoid server rate limits/tokens, // UNLESS the server just proxies the request but uses the CLIENT'S headers provided in the request? // BUT logic FR-015 says "Distributed Polling... Server orchestrates... Client checks". // If Browser cannot call Bessa, "Client" must mean "Server Agent acting on behalf of Client" OR we assume we can call Bessa. // // Assumption: Bessa API might not support CORS for 3rd party apps. // However, the proxy is OUR server. // Requirement says: "Polling muss über authentifizierte User-Clients erfolgen". // Implementation: Browser calls `POST /api/check-item` (using user token) -> Server calls Bessa (using user token) -> Server returns result. // This is valid distributed polling because: // 1. It uses the USER'S token (not a system token). // 2. The traffic originates from the Server IP technically, but authorized as User. // Wait, if 100 users are online, and Server calls Bessa 100 times, it's still Server IP. // Bessa might rate limit IP. // // IF Bessa allows CORS, Browser calls Bessa directly. // Let's assume for now we use our PROXY to fetch data (standard way) but using the User's credentials. // The orchestrator just triggers it. // // Refined Flow: // 1. Server sends `poll_request` to Browser. // 2. Browser calls local `/api/refresh-item` (new endpoint? or just reuse `/api/menus`? No, specific check). // 3. Server proxies to Bessa using Browser's Token. // 4. Server reports back to Orchestrator (internally? or Browser reports result?) // // Actually, if we use the Proxy map, the Browser calls `/api/order` etc. // Let's add a lightweight `/api/menu-status` endpoint that proxies to Bessa. // Then Browser calls that. // BUT: If the server is doing the call (Proxy), we are still spamming from Server IP. // User Requirement: "Polling traffik wird reduziert". // User Comment: "verteilt sich das polling auf unterschiedliche user ... traffic reduziert". // This implies IP distribution? Or just Token distribution? // If it implies IP distribution, it MUST come from Browser directly. // If Bessa has CORS, we are screwed on IP distribution. // Let's assume we try a fetch via our proxy for now (Token distribution). // Wait! We don't have a `checkItem` proxy endpoint yet. // I should rely on the `fetchOrders` or a new endpoint? // We have `/api/refresh-progress` which scrapes everything. Too heavy. // I will use a simple fetch to `api.bessa.app` from Browser and see if it works? // If CORS blocks, we fail. // // ALTERNATIVE: Use the `MenuScraper` on backend but configured with User Token? // No, that's complex. // // Let's try to add a proxy endpoint in `server.ts` for checking status? // I will stick to the plan: Browser reports result. // I will try to fetch via proxy: `GET /api/proxy/menu/...`? // I'll add `GET /api/menu-item-status` to `server.ts` later or now? // I missed adding a specific "Check Status" endpoint in `server.ts`. // `fetchMenuForDate` in `menu-scraper` exists but it's backend. // // Workaround: // Browser calls `/api/menus`? No that returns cached data. // // Let's add `POST /api/check-availability` to `server.ts` that proxies to Bessa? // Yes, I need to update `server.ts` one more time or just use `fetch` if CORS allows. // I'll assume CORS BLOCKS. So I need a proxy. // I will add `POST /api/check-availability` to `server.ts` quickly? // Or I can use the existing order-fetching logic? `GET /api/user/orders` fetches data from Bessa. // `orders` endpoint fetches `menu/dates/` then details? // `GET /api/user/orders` calls `/venues/591/menu/dates/`. It returns specific Date details. // I can use `GET /api/user/orders`? It returns `dateOrders` which contains `orders` but maybe not full menu availability? // // Let's look at `server.ts` again. `GET /api/user/orders` fetches orders, not menu items availability. // // I need a proxy. I will add `app.post('/api/proxy/check-item', ...)` to `server.ts` in the next step. // For now, I'll implement the frontend to call this endpoint. const response = await fetch('/api/check-item', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Token ${authToken}` }, body: JSON.stringify({ date: task.date, articleId: task.articleId }) }); if (response.ok) { const result = await response.json(); // Report back await fetch('/api/poll-result', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ flagId: task.flagId, isAvailable: result.available }) }); } } catch (e) { console.error('Check Item Error:', e); } } // Update the "Nächste Woche" button badge with available day count function updateNextWeekBadge() { let nextWeek = currentWeekNumber + 1; let nextYear = currentYear; // Handle year boundary (week 52/53 -> week 1) if (nextWeek > 52) { nextWeek = 1; nextYear++; } const nextWeekData = allWeeks.find(w => w.weekNumber === nextWeek && w.year === nextYear ); let totalDataCount = 0; let orderableCount = 0; if (nextWeekData && nextWeekData.days) { nextWeekData.days.forEach(day => { if (day.items && day.items.length > 0) { totalDataCount++; // Check if at least one item is orderable const hasOrderableItem = day.items.some(item => item.available); if (hasOrderableItem) { orderableCount++; } } }); } // Update or create badge let badge = btnNextWeek.querySelector('.nav-badge'); // Show badge if we have any data if (totalDataCount > 0) { if (!badge) { badge = document.createElement('span'); badge.className = 'nav-badge'; btnNextWeek.appendChild(badge); } // Render split badge: Orderable / Total badge.title = `${orderableCount} Tage bestellbar / ${totalDataCount} Tage mit Menüdaten`; badge.innerHTML = ` ${orderableCount} / ${totalDataCount} `; } else if (badge) { badge.remove(); } } function updateWeeklyCost(days) { let totalCost = 0; if (days && days.length > 0) { days.forEach(day => { if (day.items) { day.items.forEach(item => { const articleId = item.id || item.articleId; const key = `${day.date}_${articleId}`; const orders = orderMap.get(key) || []; if (orders.length > 0) { totalCost += item.price * orders.length; } }); } }); } const costDisplay = document.getElementById('weekly-cost-display'); if (totalCost > 0) { costDisplay.innerHTML = ` Gesamt: ${totalCost.toFixed(2).replace('.', ',')} €`; costDisplay.classList.remove('hidden'); } else { costDisplay.classList.add('hidden'); } } function renderVisibleWeeks() { menuContainer.innerHTML = ''; let targetWeek = currentWeekNumber; let targetYear = currentYear; if (displayMode === 'next-week') { targetWeek++; if (targetWeek > 52) { targetWeek = 1; targetYear++; } } // --- REGROUPING LOGIC --- // Flatten all days from all weeks into a single array const allDays = allWeeks.flatMap(w => w.days || []); // Filter days that belong to the target week const daysInTargetWeek = allDays.filter(day => { const d = new Date(day.date); const w = getISOWeek(d); // Simple year check (won't be perfect for week 1/52 boundary across years but suffices for now) return w === targetWeek; }); if (daysInTargetWeek.length === 0) { menuContainer.innerHTML = `Keine Menüdaten für KW ${targetWeek} (${targetYear}) verfügbar.
Versuchen Sie eine andere Woche oder schauen Sie später vorbei.${escapeHtml(item.description)}
`; // Attach event listeners for order/cancel buttons const orderBtn = itemEl.querySelector('.btn-order'); if (orderBtn) { orderBtn.addEventListener('click', (e) => { e.stopPropagation(); const btn = e.currentTarget; btn.disabled = true; btn.classList.add('loading'); placeOrder( btn.dataset.date, parseInt(btn.dataset.article), btn.dataset.name, parseFloat(btn.dataset.price), btn.dataset.desc || '' ).finally(() => { btn.disabled = false; btn.classList.remove('loading'); }); }); } const cancelBtn = itemEl.querySelector('.btn-cancel'); if (cancelBtn) { cancelBtn.addEventListener('click', (e) => { e.stopPropagation(); const btn = e.currentTarget; btn.disabled = true; cancelOrder( btn.dataset.date, parseInt(btn.dataset.article), btn.dataset.name ).finally(() => { btn.disabled = false; }); }); } const flagBtn = itemEl.querySelector('.btn-flag'); if (flagBtn) { flagBtn.addEventListener('click', (e) => { e.stopPropagation(); const btn = e.currentTarget; toggleFlag( btn.dataset.date, parseInt(btn.dataset.article), btn.dataset.name, btn.dataset.cutoff, '' // desc ); }); } body.appendChild(itemEl); }); } else { body.innerHTML = `