/** * Kantine Wrapper – Client-Only Bookmarklet * Replaces Bessa page content with enhanced weekly menu view. * All API calls go directly to api.bessa.app (same origin). * Data stored in localStorage (flags, theme, auth). */ (function () { 'use strict'; // Prevent double injection if (window.__KANTINE_LOADED) return; window.__KANTINE_LOADED = true; // === Constants === const API_BASE = 'https://api.bessa.app/v1'; const GUEST_TOKEN = 'c3418725e95a9f90e3645cbc846b4d67c7c66131'; const CLIENT_VERSION = '1.7.0_prod/2026-01-26'; const VENUE_ID = 591; const MENU_ID = 7; const POLL_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes // === State === let allWeeks = []; let currentWeekNumber = getISOWeek(new Date()); let currentYear = new Date().getFullYear(); let displayMode = 'this-week'; let authToken = sessionStorage.getItem('kantine_authToken'); let currentUser = sessionStorage.getItem('kantine_currentUser'); let orderMap = new Map(); let userFlags = new Set(JSON.parse(localStorage.getItem('kantine_flags') || '[]')); let pollIntervalId = null; // === API Helpers === function apiHeaders(token) { return { 'Authorization': `Token ${token || GUEST_TOKEN}`, 'Accept': 'application/json', 'Content-Type': 'application/json', 'X-Client-Version': CLIENT_VERSION }; } // === Inject UI === function injectUI() { // Replace entire page content document.title = 'Kantine Weekly Menu'; // Inject Google Fonts if not already present if (!document.querySelector('link[href*="fonts.googleapis.com/css2?family=Inter"]')) { const fontLink = document.createElement('link'); fontLink.rel = 'stylesheet'; fontLink.href = 'https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap'; document.head.appendChild(fontLink); } if (!document.querySelector('link[href*="Material+Icons+Round"]')) { const iconLink = document.createElement('link'); iconLink.rel = 'stylesheet'; iconLink.href = 'https://fonts.googleapis.com/icon?family=Material+Icons+Round'; document.head.appendChild(iconLink); } document.body.innerHTML = `
restaurant_menu

Kantinen Übersicht {{VERSION}}

Lade Menüdaten...

`; } // === Bind Events === function bindEvents() { const btnThisWeek = document.getElementById('btn-this-week'); const btnNextWeek = document.getElementById('btn-next-week'); const btnRefresh = document.getElementById('btn-refresh'); const themeToggle = document.getElementById('theme-toggle'); const btnLoginOpen = document.getElementById('btn-login-open'); const btnLoginClose = document.getElementById('btn-login-close'); const btnLogout = document.getElementById('btn-logout'); const loginForm = document.getElementById('login-form'); const loginModal = document.getElementById('login-modal'); // Theme const savedTheme = localStorage.getItem('theme'); const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches; const themeIcon = themeToggle.querySelector('.theme-icon'); if (savedTheme === 'dark' || (!savedTheme && prefersDark)) { 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 current = document.documentElement.getAttribute('data-theme'); const next = current === 'dark' ? 'light' : 'dark'; document.documentElement.setAttribute('data-theme', next); localStorage.setItem('theme', next); themeIcon.textContent = next === 'dark' ? 'dark_mode' : 'light_mode'; }); // Navigation btnThisWeek.addEventListener('click', () => { if (displayMode !== 'this-week') { displayMode = 'this-week'; btnThisWeek.classList.add('active'); btnNextWeek.classList.remove('active'); renderVisibleWeeks(); } }); btnNextWeek.addEventListener('click', () => { if (displayMode !== 'next-week') { displayMode = 'next-week'; btnNextWeek.classList.add('active'); btnThisWeek.classList.remove('active'); renderVisibleWeeks(); } }); // Refresh – fetch fresh data from Bessa API btnRefresh.addEventListener('click', () => { if (!authToken) { loginModal.classList.remove('hidden'); return; } loadMenuDataFromAPI(); }); // Login Modal btnLoginOpen.addEventListener('click', () => { loginModal.classList.remove('hidden'); document.getElementById('login-error').classList.add('hidden'); loginForm.reset(); }); btnLoginClose.addEventListener('click', () => { loginModal.classList.add('hidden'); }); window.addEventListener('click', (e) => { if (e.target === loginModal) loginModal.classList.add('hidden'); }); // Login Form Submit loginForm.addEventListener('submit', async (e) => { e.preventDefault(); const employeeId = document.getElementById('employee-id').value.trim(); const password = document.getElementById('password').value; const loginError = document.getElementById('login-error'); const submitBtn = loginForm.querySelector('button[type="submit"]'); const originalText = submitBtn.textContent; submitBtn.disabled = true; submitBtn.textContent = 'Wird eingeloggt...'; try { const email = `knapp-${employeeId}@bessa.app`; const response = await fetch(`${API_BASE}/auth/login/`, { method: 'POST', headers: apiHeaders(GUEST_TOKEN), body: JSON.stringify({ email, password }) }); const data = await response.json(); if (response.ok) { authToken = data.key; currentUser = employeeId; sessionStorage.setItem('kantine_authToken', data.key); sessionStorage.setItem('kantine_currentUser', employeeId); // Fetch user name try { const userResp = await fetch(`${API_BASE}/auth/user/`, { headers: apiHeaders(authToken) }); if (userResp.ok) { const userData = await userResp.json(); if (userData.first_name) sessionStorage.setItem('kantine_firstName', userData.first_name); if (userData.last_name) sessionStorage.setItem('kantine_lastName', userData.last_name); } } catch (err) { console.error('Failed to fetch user info:', err); } updateAuthUI(); loginModal.classList.add('hidden'); fetchOrders(); loginForm.reset(); startPolling(); // Reload menu data with auth for full details loadMenuDataFromAPI(); } else { loginError.textContent = data.non_field_errors?.[0] || data.error || 'Login fehlgeschlagen'; 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; } }); // Logout btnLogout.addEventListener('click', () => { sessionStorage.removeItem('kantine_authToken'); sessionStorage.removeItem('kantine_currentUser'); sessionStorage.removeItem('kantine_firstName'); sessionStorage.removeItem('kantine_lastName'); authToken = null; currentUser = null; orderMap = new Map(); stopPolling(); updateAuthUI(); renderVisibleWeeks(); }); } // === Auth UI === function updateAuthUI() { // Try to recover session from Bessa's storage if not already logged in if (!authToken) { try { const akita = localStorage.getItem('AkitaStores'); if (akita) { const parsed = JSON.parse(akita); if (parsed.auth && parsed.auth.token) { console.log('Found existing Bessa session!'); authToken = parsed.auth.token; sessionStorage.setItem('kantine_authToken', authToken); if (parsed.auth.user) { currentUser = parsed.auth.user.id || 'unknown'; sessionStorage.setItem('kantine_currentUser', currentUser); if (parsed.auth.user.firstName) sessionStorage.setItem('kantine_firstName', parsed.auth.user.firstName); if (parsed.auth.user.lastName) sessionStorage.setItem('kantine_lastName', parsed.auth.user.lastName); } } } } catch (e) { console.warn('Failed to parse AkitaStores:', e); } } authToken = sessionStorage.getItem('kantine_authToken'); currentUser = sessionStorage.getItem('kantine_currentUser'); const firstName = sessionStorage.getItem('kantine_firstName'); const btnLoginOpen = document.getElementById('btn-login-open'); const userInfo = document.getElementById('user-info'); const userIdDisplay = document.getElementById('user-id-display'); if (authToken) { btnLoginOpen.classList.add('hidden'); userInfo.classList.remove('hidden'); userIdDisplay.textContent = firstName || (currentUser ? `User ${currentUser}` : 'Angemeldet'); fetchOrders(); // Always fetch fresh orders on auth update } else { btnLoginOpen.classList.remove('hidden'); userInfo.classList.add('hidden'); userIdDisplay.textContent = ''; } renderVisibleWeeks(); } // === Fetch Orders from Bessa === async function fetchOrders() { if (!authToken) return; try { // Use user/orders endpoint for reliable history const response = await fetch(`${API_BASE}/user/orders/?venue=${VENUE_ID}&ordering=-created&limit=50`, { headers: apiHeaders(authToken) }); const data = await response.json(); if (response.ok) { orderMap = new Map(); const results = data.results || []; for (const order of results) { // Filter out cancelled orders (State 9) // Accepting State 1 (Created?), 5 (Placed?), 8 (Completed) // TODO: Verify exact states. Subagent saw 5=Active, 8=Completed, 9=Cancelled. if (order.order_state === 9) continue; // Extract date properly (it comes as ISO string) const orderDate = order.date.split('T')[0]; for (const item of (order.items || [])) { const key = `${orderDate}_${item.article}`; if (!orderMap.has(key)) orderMap.set(key, []); orderMap.get(key).push(order.id); } } console.log(`Fetched ${results.length} orders, mapped active ones.`); renderVisibleWeeks(); } } catch (error) { console.error('Error fetching orders:', error); } } // === Place Order === async function placeOrder(date, articleId, name, price, description) { if (!authToken) return; try { // Get user data for customer object const userResp = await fetch(`${API_BASE}/auth/user/`, { headers: apiHeaders(authToken) }); if (!userResp.ok) { showToast('Fehler: Benutzerdaten konnten nicht geladen werden', 'error'); return; } const userData = await userResp.json(); const now = new Date().toISOString(); const orderPayload = { uuid: crypto.randomUUID(), created: now, updated: now, order_type: 7, items: [{ article: articleId, course_group: null, modifiers: [], uuid: crypto.randomUUID(), name: name, description: description || '', price: String(parseFloat(price)), amount: 1, vat: '10.00', comment: '' }], table: null, total: parseFloat(price), tip: 0, currency: 'EUR', venue: VENUE_ID, states: [], order_state: 1, date: `${date}T10:00:00.000Z`, payment_method: 'payroll', customer: { first_name: userData.first_name, last_name: userData.last_name, email: userData.email, newsletter: false }, preorder: false, delivery_fee: 0, cash_box_table_name: null, take_away: false }; const response = await fetch(`${API_BASE}/user/orders/`, { method: 'POST', headers: apiHeaders(authToken), body: JSON.stringify(orderPayload) }); if (response.ok || response.status === 201) { showToast(`Bestellt: ${name}`, 'success'); await fetchOrders(); } else { const data = await response.json(); showToast(`Fehler: ${data.detail || data.non_field_errors?.[0] || 'Bestellung fehlgeschlagen'}`, 'error'); } } catch (error) { console.error('Order error:', error); showToast('Netzwerkfehler bei Bestellung', 'error'); } } // === Cancel 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 most recent const orderId = orderIds[orderIds.length - 1]; try { const response = await fetch(`${API_BASE}/user/orders/${orderId}/cancel/`, { method: 'PATCH', headers: apiHeaders(authToken), body: JSON.stringify({}) }); if (response.ok) { showToast(`Storniert: ${name}`, 'success'); await fetchOrders(); } else { const data = await response.json(); showToast(`Fehler: ${data.detail || 'Stornierung fehlgeschlagen'}`, 'error'); } } catch (error) { console.error('Cancel error:', error); showToast('Netzwerkfehler bei Stornierung', 'error'); } } // === Flag Management (localStorage) === function saveFlags() { localStorage.setItem('kantine_flags', JSON.stringify([...userFlags])); } function toggleFlag(date, articleId, name, cutoff) { const id = `${date}_${articleId}`; if (userFlags.has(id)) { userFlags.delete(id); showToast(`Flag entfernt für ${name}`, 'success'); } else { userFlags.add(id); showToast(`Benachrichtigung aktiviert für ${name}`, 'success'); if (Notification.permission === 'default') { Notification.requestPermission(); } } saveFlags(); renderVisibleWeeks(); } // FR-019: Auto-remove flags whose cutoff has passed function cleanupExpiredFlags() { const now = new Date(); let changed = false; for (const flagId of [...userFlags]) { const [date] = flagId.split('_'); const cutoff = new Date(date); cutoff.setHours(10, 0, 0, 0); // Standard cutoff 10:00 if (now >= cutoff) { userFlags.delete(flagId); changed = true; } } if (changed) saveFlags(); } // === Polling (Client-Side) === function startPolling() { if (pollIntervalId) return; if (!authToken) return; pollIntervalId = setInterval(() => pollFlaggedItems(), POLL_INTERVAL_MS); console.log('Polling started (every 5 min)'); } function stopPolling() { if (pollIntervalId) { clearInterval(pollIntervalId); pollIntervalId = null; console.log('Polling stopped'); } } async function pollFlaggedItems() { if (userFlags.size === 0 || !authToken) return; console.log(`Polling ${userFlags.size} flagged items...`); for (const flagId of userFlags) { const [date, articleIdStr] = flagId.split('_'); const articleId = parseInt(articleIdStr); try { const response = await fetch(`${API_BASE}/venues/${VENUE_ID}/menu/${MENU_ID}/${date}/`, { headers: apiHeaders(authToken) }); if (!response.ok) continue; const data = await response.json(); const groups = data.results || []; let foundItem = null; for (const group of groups) { if (group.items) { foundItem = group.items.find(i => i.id === articleId || i.article === articleId); if (foundItem) break; } } if (foundItem) { const isAvailable = (foundItem.amount_tracking === false) || (parseInt(foundItem.available_amount) > 0); if (isAvailable) { const itemName = foundItem.name || 'Unbekannt'; showToast(`${itemName} ist jetzt verfügbar!`, 'success'); if (Notification.permission === 'granted') { new Notification('Kantine Wrapper', { body: `${itemName} ist jetzt verfügbar!`, icon: '🍽️' }); } // Refresh menu data to update UI loadMenuDataFromAPI(); break; // One refresh is enough } } } catch (err) { console.error(`Poll error for ${flagId}:`, err); } // Small delay between checks await new Promise(r => setTimeout(r, 200)); } } // === Local Menu Cache (localStorage) === const CACHE_KEY = 'kantine_menuCache'; const CACHE_TS_KEY = 'kantine_menuCacheTs'; function saveMenuCache() { try { localStorage.setItem(CACHE_KEY, JSON.stringify(allWeeks)); localStorage.setItem(CACHE_TS_KEY, new Date().toISOString()); } catch (e) { console.warn('Failed to cache menu data:', e); } } function loadMenuCache() { try { const cached = localStorage.getItem(CACHE_KEY); const cachedTs = localStorage.getItem(CACHE_TS_KEY); if (cached) { allWeeks = JSON.parse(cached); currentWeekNumber = getISOWeek(new Date()); currentYear = new Date().getFullYear(); renderVisibleWeeks(); updateNextWeekBadge(); if (cachedTs) updateLastUpdatedTime(cachedTs); console.log('Loaded menu from cache'); return true; } } catch (e) { console.warn('Failed to load cached menu:', e); } return false; } // === Menu Data Fetching (Direct from Bessa API) === async function loadMenuDataFromAPI() { const loading = document.getElementById('loading'); const progressModal = document.getElementById('progress-modal'); const progressFill = document.getElementById('progress-fill'); const progressPercent = document.getElementById('progress-percent'); const progressMessage = document.getElementById('progress-message'); loading.classList.remove('hidden'); const token = authToken || GUEST_TOKEN; try { // Show progress modal progressModal.classList.remove('hidden'); progressMessage.textContent = 'Hole verfügbare Daten...'; progressFill.style.width = '0%'; progressPercent.textContent = '0%'; // 1. Fetch available dates const datesResponse = await fetch(`${API_BASE}/venues/${VENUE_ID}/menu/dates/`, { headers: apiHeaders(token) }); if (!datesResponse.ok) throw new Error(`Failed to fetch dates: ${datesResponse.status}`); const datesData = await datesResponse.json(); let availableDates = datesData.results || []; // Filter – last 7 days + future, limit 30 const cutoff = new Date(); cutoff.setDate(cutoff.getDate() - 7); const cutoffStr = cutoff.toISOString().split('T')[0]; availableDates = availableDates .filter(d => d.date >= cutoffStr) .sort((a, b) => a.date.localeCompare(b.date)) .slice(0, 30); const totalDates = availableDates.length; progressMessage.textContent = `${totalDates} Tage gefunden. Lade Details...`; // 2. Fetch details for each date const allDays = []; let completed = 0; for (const dateObj of availableDates) { const dateStr = dateObj.date; const pct = Math.round(((completed + 1) / totalDates) * 100); progressFill.style.width = `${pct}%`; progressPercent.textContent = `${pct}%`; progressMessage.textContent = `Lade Menü für ${dateStr}...`; try { const detailResp = await fetch(`${API_BASE}/venues/${VENUE_ID}/menu/${MENU_ID}/${dateStr}/`, { headers: apiHeaders(token) }); if (detailResp.ok) { const detailData = await detailResp.json(); // Debug: log raw API response for first date if (completed === 0) { console.log('[Kantine Debug] Raw API response for', dateStr, ':', JSON.stringify(detailData).substring(0, 2000)); } const menuGroups = detailData.results || []; let dayItems = []; for (const group of menuGroups) { if (group.items && Array.isArray(group.items)) { dayItems = dayItems.concat(group.items); } } if (dayItems.length > 0) { // Debug: log first item structure if (completed === 0) { console.log('[Kantine Debug] First item keys:', Object.keys(dayItems[0])); console.log('[Kantine Debug] First item:', JSON.stringify(dayItems[0]).substring(0, 500)); } allDays.push({ date: dateStr, menu_items: dayItems, orders: dateObj.orders || [] }); } } } catch (err) { console.error(`Failed to fetch details for ${dateStr}:`, err); } completed++; // Small delay to avoid rate limiting await new Promise(r => setTimeout(r, 100)); } // 3. Group by ISO week (Merge with existing to preserve past days) const weeksMap = new Map(); // Hydrate from existing cache (preserve past data) if (allWeeks && allWeeks.length > 0) { allWeeks.forEach(w => { const key = `${w.year}-${w.weekNumber}`; try { weeksMap.set(key, { year: w.year, weekNumber: w.weekNumber, days: w.days ? w.days.map(d => ({ ...d, items: d.items ? [...d.items] : [] })) : [] }); } catch (e) { console.warn('Error hydrating week:', e); } }); } for (const day of allDays) { const d = new Date(day.date); const weekNum = getISOWeek(d); const year = getWeekYear(d); const key = `${year}-${weekNum}`; if (!weeksMap.has(key)) { weeksMap.set(key, { year, weekNumber: weekNum, days: [] }); } const weekObj = weeksMap.get(key); const weekday = d.toLocaleDateString('en-US', { weekday: 'long' }); const orderCutoffDate = new Date(day.date); orderCutoffDate.setHours(10, 0, 0, 0); const newDayObj = { date: day.date, weekday: weekday, orderCutoff: orderCutoffDate.toISOString(), items: day.menu_items.map(item => { const isUnlimited = item.amount_tracking === false; const hasStock = parseInt(item.available_amount) > 0; return { id: `${day.date}_${item.id}`, articleId: item.id, name: item.name || 'Unknown', description: item.description || '', price: parseFloat(item.price) || 0, available: isUnlimited || hasStock, availableAmount: parseInt(item.available_amount) || 0, amountTracking: item.amount_tracking !== false }; }) }; // Merge: Overwrite if exists, push if new const existingIndex = weekObj.days.findIndex(existing => existing.date === day.date); if (existingIndex >= 0) { weekObj.days[existingIndex] = newDayObj; } else { weekObj.days.push(newDayObj); } } // Sort weeks and days allWeeks = Array.from(weeksMap.values()).sort((a, b) => { if (a.year !== b.year) return a.year - b.year; return a.weekNumber - b.weekNumber; }); allWeeks.forEach(w => { if (w.days) w.days.sort((a, b) => a.date.localeCompare(b.date)); }); // Save to localStorage cache saveMenuCache(); // Update timestamp updateLastUpdatedTime(new Date().toISOString()); currentWeekNumber = getISOWeek(new Date()); currentYear = new Date().getFullYear(); updateAuthUI(); // This will trigger fetchOrders if logged in renderVisibleWeeks(); updateNextWeekBadge(); progressMessage.textContent = 'Fertig!'; setTimeout(() => progressModal.classList.add('hidden'), 500); } catch (error) { console.error('Error fetching menu:', error); progressModal.classList.add('hidden'); showErrorModal( 'Keine Verbindung', `Die Menüdaten konnten nicht geladen werden. Möglicherweise besteht keine Verbindung zur API oder zur Bessa-Webseite.

${error.message}`, 'Zur Original-Seite', 'https://web.bessa.app/knapp-kantine' ); } finally { loading.classList.add('hidden'); } } // === Last Updated Display === function updateLastUpdatedTime(isoTimestamp) { const subtitle = document.getElementById('last-updated-subtitle'); if (!isoTimestamp) return; try { const date = new Date(isoTimestamp); const timeStr = date.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' }); const dateStr = date.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' }); subtitle.textContent = `Aktualisiert: ${dateStr} ${timeStr}`; } catch (e) { subtitle.textContent = ''; } } // === Toast Notification === 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 = `${icon}${message}`; container.appendChild(toast); requestAnimationFrame(() => toast.classList.add('show')); setTimeout(() => { toast.classList.remove('show'); setTimeout(() => toast.remove(), 300); }, 3000); } // === Next Week Badge === function updateNextWeekBadge() { const btnNextWeek = document.getElementById('btn-next-week'); let nextWeek = currentWeekNumber + 1; let nextYear = currentYear; if (nextWeek > 52) { nextWeek = 1; nextYear++; } const nextWeekData = allWeeks.find(w => w.weekNumber === nextWeek && w.year === nextYear); let totalDataCount = 0; let orderableCount = 0; let daysWithOrders = 0; let daysWithOrderableAndNoOrder = 0; if (nextWeekData && nextWeekData.days) { nextWeekData.days.forEach(day => { if (day.items && day.items.length > 0) { totalDataCount++; const isOrderable = day.items.some(item => item.available); if (isOrderable) orderableCount++; let hasOrder = false; day.items.forEach(item => { const articleId = item.articleId || parseInt(item.id.split('_')[1]); const key = `${day.date}_${articleId}`; if (orderMap.has(key) && orderMap.get(key).length > 0) hasOrder = true; }); if (hasOrder) daysWithOrders++; if (isOrderable && !hasOrder) daysWithOrderableAndNoOrder++; } }); } let badge = btnNextWeek.querySelector('.nav-badge'); if (totalDataCount > 0) { if (!badge) { badge = document.createElement('span'); badge.className = 'nav-badge'; btnNextWeek.appendChild(badge); } // Format: ( Ordered / Orderable / Total ) badge.title = `${daysWithOrders} bestellt / ${orderableCount} bestellbar / ${totalDataCount} gesamt`; badge.innerHTML = `${daysWithOrders}/${orderableCount}/${totalDataCount}`; // Color Logic badge.classList.remove('badge-violet', 'badge-green', 'badge-red', 'badge-blue'); // Refined Logic (v1.7.4): // Violet: If we have orders AND there are no DAYS left that are orderable but un-ordered. // (i.e. "I have ordered everything I can") if (daysWithOrders > 0 && daysWithOrderableAndNoOrder === 0) { badge.classList.add('badge-violet'); } else if (daysWithOrderableAndNoOrder > 0) { badge.classList.add('badge-green'); // Orderable days exist without order } else if (orderableCount === 0) { badge.classList.add('badge-red'); // No orderable days at all & no orders } else { badge.classList.add('badge-blue'); // Default / partial state } } else if (badge) { badge.remove(); } } // === Weekly Cost === function updateWeeklyCost(days) { let totalCost = 0; if (days && days.length > 0) { days.forEach(day => { if (day.items) { day.items.forEach(item => { const articleId = item.articleId || parseInt(item.id.split('_')[1]); 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 = `shopping_bag Gesamt: ${totalCost.toFixed(2).replace('.', ',')} €`; costDisplay.classList.remove('hidden'); } else { costDisplay.classList.add('hidden'); } } // === Render Weeks === function renderVisibleWeeks() { const menuContainer = document.getElementById('menu-container'); if (!menuContainer) return; menuContainer.innerHTML = ''; let targetWeek = currentWeekNumber; let targetYear = currentYear; if (displayMode === 'next-week') { targetWeek++; if (targetWeek > 52) { targetWeek = 1; targetYear++; } } // Flatten & filter by week + year const allDays = allWeeks.flatMap(w => w.days || []); const daysInTargetWeek = allDays.filter(day => { const d = new Date(day.date); return getISOWeek(d) === targetWeek && getWeekYear(d) === targetYear; }); 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.
`; document.getElementById('weekly-cost-display').classList.add('hidden'); return; } updateWeeklyCost(daysInTargetWeek); // Update header const headerWeekInfo = document.getElementById('header-week-info'); const weekTitle = displayMode === 'this-week' ? 'Diese Woche' : 'Nächste Woche'; headerWeekInfo.innerHTML = `
${weekTitle}
Week ${targetWeek} • ${targetYear}
`; const grid = document.createElement('div'); grid.className = 'days-grid'; daysInTargetWeek.sort((a, b) => a.date.localeCompare(b.date)); // Filter weekends const workingDays = daysInTargetWeek.filter(d => { const date = new Date(d.date); const day = date.getDay(); return day !== 0 && day !== 6; }); workingDays.forEach(day => { const card = createDayCard(day); if (card) grid.appendChild(card); }); menuContainer.appendChild(grid); setTimeout(() => syncMenuItemHeights(grid), 0); } // === Sync Item Heights === function syncMenuItemHeights(grid) { const cards = grid.querySelectorAll('.menu-card'); if (cards.length === 0) return; let maxItems = 0; cards.forEach(card => { maxItems = Math.max(maxItems, card.querySelectorAll('.menu-item').length); }); for (let i = 0; i < maxItems; i++) { let maxHeight = 0; const itemsAtPos = []; cards.forEach(card => { const items = card.querySelectorAll('.menu-item'); if (items[i]) { items[i].style.height = 'auto'; maxHeight = Math.max(maxHeight, items[i].offsetHeight); itemsAtPos.push(items[i]); } }); itemsAtPos.forEach(item => { item.style.height = `${maxHeight}px`; }); } } // === Create Day Card === function createDayCard(day) { if (!day.items || day.items.length === 0) return null; const card = document.createElement('div'); card.className = 'menu-card'; const now = new Date(); const cardDate = new Date(day.date); let isPastCutoff = false; if (day.orderCutoff) { isPastCutoff = now >= new Date(day.orderCutoff); } else { const today = new Date(); today.setHours(0, 0, 0, 0); const cd = new Date(day.date); cd.setHours(0, 0, 0, 0); isPastCutoff = cd < today; } if (isPastCutoff) card.classList.add('past-day'); // Collect ordered menu codes const menuBadges = []; if (day.items) { day.items.forEach(item => { const articleId = item.articleId || parseInt(item.id.split('_')[1]); const orderKey = `${day.date}_${articleId}`; const orders = orderMap.get(orderKey) || []; const count = orders.length; if (count > 0) { // Regex for M1, M2, M1F etc. const match = item.name.match(/([M][1-9][Ff]?)/); if (match) { let code = match[1]; if (count > 1) code += '+'; menuBadges.push(code); } } }); } // Header const header = document.createElement('div'); header.className = 'card-header'; const dateStr = cardDate.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' }); const badgesHtml = menuBadges.map(code => `${code}`).join(''); // Determine Day Status for Header Color // Violet: Has Order // Green: No Order but Orderable // Red: No Order and Not Orderable (Locked/Sold Out) let headerClass = ''; const hasAnyOrder = day.items && day.items.some(item => { const articleId = item.articleId || parseInt(item.id.split('_')[1]); const key = `${day.date}_${articleId}`; return orderMap.has(key) && orderMap.get(key).length > 0; }); const hasOrderable = day.items && day.items.some(item => { // Use pre-calculated available flag from loadMenuDataFromAPI calculation return item.available; }); if (hasAnyOrder) { headerClass = 'header-violet'; } else if (hasOrderable && !isPastCutoff) { headerClass = 'header-green'; } else { // Red if not orderable (or past cutoff) headerClass = 'header-red'; } if (headerClass) header.classList.add(headerClass); header.innerHTML = `
${translateDay(day.weekday)}
${badgesHtml}
${dateStr}`; card.appendChild(header); // Body const body = document.createElement('div'); body.className = 'card-body'; const todayDateStr = new Date().toISOString().split('T')[0]; const isToday = day.date === todayDateStr; const sortedItems = [...day.items].sort((a, b) => { if (isToday) { const aId = a.articleId || parseInt(a.id.split('_')[1]); const bId = b.articleId || parseInt(b.id.split('_')[1]); const aOrdered = orderMap.has(`${day.date}_${aId}`); const bOrdered = orderMap.has(`${day.date}_${bId}`); if (aOrdered && !bOrdered) return -1; if (!aOrdered && bOrdered) return 1; } return a.name.localeCompare(b.name); }); sortedItems.forEach(item => { const itemEl = document.createElement('div'); itemEl.className = 'menu-item'; const articleId = item.articleId || parseInt(item.id.split('_')[1]); const orderKey = `${day.date}_${articleId}`; const orderIds = orderMap.get(orderKey) || []; const orderCount = orderIds.length; // Status badge let statusBadge = ''; if (item.available) { statusBadge = item.amountTracking ? `Verfügbar (${item.availableAmount})` : `Verfügbar`; } else { statusBadge = `Ausverkauft`; } // Order badge let orderedBadge = ''; if (orderCount > 0) { const countBadge = orderCount > 1 ? `${orderCount}` : ''; orderedBadge = `check_circle Bestellt${countBadge}`; itemEl.classList.add('ordered'); if (new Date(day.date).toDateString() === now.toDateString()) { itemEl.classList.add('today-ordered'); } } // Flagged styles const flagId = `${day.date}_${articleId}`; const isFlagged = userFlags.has(flagId); if (isFlagged) { itemEl.classList.add(item.available ? 'flagged-available' : 'flagged-sold-out'); } // Action buttons let orderButton = ''; let cancelButton = ''; let flagButton = ''; if (authToken && !isPastCutoff) { // Flag button const flagIcon = isFlagged ? 'notifications_active' : 'notifications_none'; const flagClass = isFlagged ? 'btn-flag active' : 'btn-flag'; const flagTitle = isFlagged ? 'Benachrichtigung deaktivieren' : 'Benachrichtigen wenn verfügbar'; if (!item.available || isFlagged) { flagButton = ``; } // Order button if (item.available) { if (orderCount > 0) { orderButton = ``; } else { orderButton = ``; } } // Cancel button if (orderCount > 0) { const cancelIcon = orderCount === 1 ? 'close' : 'remove'; const cancelTitle = orderCount === 1 ? 'Bestellung stornieren' : 'Eine Bestellung stornieren'; cancelButton = ``; } } itemEl.innerHTML = `
${escapeHtml(item.name)} ${item.price.toFixed(2)} €
${orderedBadge} ${cancelButton} ${orderButton} ${flagButton}
${statusBadge}

${escapeHtml(item.description)}

`; // Event: Order 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'); }); }); } // Event: Cancel 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; }); }); } // Event: Flag 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); }); } body.appendChild(itemEl); }); card.appendChild(body); return card; } // === Version Check === async function checkForUpdates() { const CurrentVersion = '{{VERSION}}'; // Injected by build script const VersionUrl = 'https://raw.githubusercontent.com/TauNeutrino/kantine-overview/main/version.txt'; // Use htmlpreview.github.io to render the HTML directly in browser const InstallerUrl = 'https://htmlpreview.github.io/?https://github.com/TauNeutrino/kantine-overview/blob/main/dist/install.html'; console.log(`[Kantine] Checking for updates... (Current: ${CurrentVersion})`); try { const response = await fetch(VersionUrl, { cache: 'no-cache' }); if (!response.ok) return; const remoteVersion = (await response.text()).trim(); console.log(`[Kantine] Remote version: ${remoteVersion}`); if (remoteVersion && remoteVersion !== CurrentVersion) { // Simple semantic version check or string inequality // Assuming format v1.0.0 showUpdateIcon(remoteVersion, InstallerUrl); } } catch (error) { console.warn('[Kantine] Version check failed:', error); } } function showUpdateIcon(newVersion, url) { const headerTitle = document.querySelector('.header-left h1'); if (!headerTitle) return; // Check if already added if (headerTitle.querySelector('.update-icon')) return; const icon = document.createElement('a'); icon.className = 'update-icon'; icon.href = url; icon.target = '_blank'; icon.innerHTML = '🆕'; // User requested icon icon.title = `Neue Version verfügbar (${newVersion}). Klick für download`; headerTitle.appendChild(icon); showToast(`Update verfügbar: ${newVersion}`, 'info'); } // === Helpers === function getISOWeek(date) { const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate())); const dayNum = d.getUTCDay() || 7; d.setUTCDate(d.getUTCDate() + 4 - dayNum); const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1)); return Math.ceil(((d - yearStart) / 86400000 + 1) / 7); } function getWeekYear(d) { const date = new Date(d.getTime()); date.setDate(date.getDate() + 3 - (date.getDay() + 6) % 7); return date.getFullYear(); } function translateDay(englishDay) { const map = { Monday: 'Montag', Tuesday: 'Dienstag', Wednesday: 'Mittwoch', Thursday: 'Donnerstag', Friday: 'Freitag', Saturday: 'Samstag', Sunday: 'Sonntag' }; return map[englishDay] || englishDay; } function escapeHtml(text) { const div = document.createElement('div'); div.textContent = text || ''; return div.innerHTML; } // === Bootstrap === injectUI(); bindEvents(); updateAuthUI(); cleanupExpiredFlags(); // Load cached data first for instant UI, then refresh from API const hadCache = loadMenuCache(); if (hadCache) { // Hide loading spinner since cache is shown document.getElementById('loading').classList.add('hidden'); } loadMenuDataFromAPI(); // Auto-start polling if already logged in if (authToken) { startPolling(); } // Check for updates checkForUpdates(); console.log('Kantine Wrapper loaded ✅'); })(); // === Error Modal === function showErrorModal(title, htmlContent, btnText, url) { const modalId = 'error-modal'; let modal = document.getElementById(modalId); if (modal) modal.remove(); modal = document.createElement('div'); modal.id = modalId; modal.className = 'modal hidden'; modal.innerHTML = ` `; document.body.appendChild(modal); document.getElementById('btn-error-redirect').addEventListener('click', () => { window.location.href = url; }); requestAnimationFrame(() => { modal.classList.remove('hidden'); }); }