Files
kantinen-wrapper/dist/install.html

81 lines
68 KiB
HTML
Executable File

<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<title>Kantine Wrapper Installer (v1.1.1)</title>
<style>
body { font-family: 'Inter', sans-serif; max-width: 600px; margin: 40px auto; padding: 20px; background: #1a1a2e; color: #eee; }
h1 { color: #029AA8; } /* Knapp Teal */
.instructions { background: #16213e; padding: 20px; border-radius: 12px; margin: 20px 0; }
.instructions ol li { margin: 10px 0; }
a.bookmarklet { display: inline-block; background: #029AA8; color: white; padding: 12px 24px; border-radius: 8px; text-decoration: none; font-weight: 600; font-size: 18px; cursor: grab; }
a.bookmarklet:hover { background: #006269; }
code { background: #0f3460; padding: 2px 6px; border-radius: 4px; }
</style>
</head>
<body>
<h1>🍽️ Kantine Wrapper <span style="font-size:0.5em; opacity:0.6; font-weight:400; vertical-align:middle; margin-left:10px;">v1.1.1</span></h1>
<div class="card">
<h2>Changelog</h2>
<div class="changelog-container">
<h3>v1.1.1 (2026-02-16)</h3>
<ul>
<li>**Fix**: Kritischer Fehler behoben, der das Laden des Wrappers verhinderte. 🐛</li>
</ul>
<h3>v1.1.0 (2026-02-16)</h3>
<ul>
<li>**Feature: Bestell-Countdown**: Zeigt 1 Stunde vor Bestellschluss einen roten Countdown an. ⏳</li>
<li>**Feature: Smart Highlights**: Markiere deine Lieblingsspeisen (z.B. "Schnitzel", "Vegetarisch"), damit sie leuchten. 🌟</li>
<li>**Feature: Changelog**: Diese Übersicht der Änderungen. 📜</li>
<li>**Verbesserung**: Live-Check der Version beim Update.</li>
</ul>
<h3>v1.0.3 (2026-02-13)</h3>
<ul>
<li>**Fix**: Update-Link öffnet nun den Installer direkt als Webseite (via htmlpreview).</li>
</ul>
<h3>v1.0.2 (2026-02-13)</h3>
<ul>
<li>**Sync**: Version mit GitHub synchronisiert.</li>
</ul>
<h3>v1.0.1 (2026-02-12)</h3>
<ul>
<li>**UI**: Besseres Design für "Nächste Woche" (Badges).</li>
<li>**Core**: Grundlegende Funktionen (Bestellen, Guthaben, Token-Store).</li>
</div>
</div>
<div class="card">
<h2>So funktioniert's</h2>
<ol>
<li>Ziehe den Button unten in deine <strong>Lesezeichen-Leiste</strong> (Drag & Drop)</li>
<li>Navigiere zu <a href="https://web.bessa.app/knapp-kantine" style="color:#029AA8">web.bessa.app/knapp-kantine</a></li>
<li>Klicke auf das Lesezeichen <code>Kantine v1.1.1</code></li>
</ol>
</div>
<div class="card">
<h2>✨ Features</h2>
<ul>
<li>📅 <strong>Wochenübersicht:</strong> Die ganze Woche auf einen Blick.</li>
<li>💰 <strong>Kostenkontrolle:</strong> Automatische Berechnung der Wochensumme.</li>
<li>🔑 <strong>Auto-Login:</strong> Nutzt deine bestehende Session.</li>
<li>🏷️ <strong>Badges & Status:</strong> Menü-Codes (M1, M2) und Bestellstatus direkt sichtbar.</li>
<li>🛡️ <strong>Offline-Support:</strong> Speichert Menüdaten lokal.</li>
</ul>
<div style="margin-top: 30px; padding: 15px; background: rgba(233, 69, 96, 0.1); border: 1px solid rgba(233, 69, 96, 0.3); border-radius: 8px; font-size: 0.85em; color: #ddd;">
<strong>⚠️ Haftungsausschluss:</strong><br>
Die Verwendung dieses Bookmarklets erfolgt auf eigene Verantwortung. Der Entwickler übernimmt keine Haftung für Schäden, Datenverlust oder ungewollte Bestellungen, die durch die Nutzung dieser Software entstehen.
</div>
</div>
<p>👇 Diesen Button in die Lesezeichen-Leiste ziehen:</p>
<p><a class="bookmarklet" id="bookmarklet-link" href="#">⏳ Wird generiert...</a></p>
<script>
document.getElementById('bookmarklet-link').href =
"javascript:(function(){/**\n * Kantine Wrapper \u2013 Client-Only Bookmarklet\n * Replaces Bessa page content with enhanced weekly menu view.\n * All API calls go directly to api.bessa.app (same origin).\n * Data stored in localStorage (flags, theme, auth).\n */\n(function () {\n 'use strict';\n\n // Prevent double injection\n if (window.__KANTINE_LOADED) return;\n window.__KANTINE_LOADED = true;\n\n // === Constants ===\n const API_BASE = 'https://api.bessa.app/v1';\n const GUEST_TOKEN = 'c3418725e95a9f90e3645cbc846b4d67c7c66131';\n const CLIENT_VERSION = '1.7.0_prod/2026-01-26';\n const VENUE_ID = 591;\n const MENU_ID = 7;\n const POLL_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes\n\n // === State ===\n let allWeeks = [];\n let currentWeekNumber = getISOWeek(new Date());\n let currentYear = new Date().getFullYear();\n let displayMode = 'this-week';\n let authToken = sessionStorage.getItem('kantine_authToken');\n let currentUser = sessionStorage.getItem('kantine_currentUser');\n let orderMap = new Map();\n let userFlags = new Set(JSON.parse(localStorage.getItem('kantine_flags') || '[]'));\n let pollIntervalId = null;\n\n // === API Helpers ===\n function apiHeaders(token) {\n return {\n 'Authorization': `Token ${token || GUEST_TOKEN}`,\n 'Accept': 'application/json',\n 'Content-Type': 'application/json',\n 'X-Client-Version': CLIENT_VERSION\n };\n }\n\n // === Inject UI ===\n function injectUI() {\n // Replace entire page content\n document.title = 'Kantine Weekly Menu';\n\n // Inject Google Fonts if not already present\n if (!document.querySelector('link[href*=\"fonts.googleapis.com/css2?family=Inter\"]')) {\n const fontLink = document.createElement('link');\n fontLink.rel = 'stylesheet';\n fontLink.href = 'https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap';\n document.head.appendChild(fontLink);\n }\n if (!document.querySelector('link[href*=\"Material+Icons+Round\"]')) {\n const iconLink = document.createElement('link');\n iconLink.rel = 'stylesheet';\n iconLink.href = 'https://fonts.googleapis.com/icon?family=Material+Icons+Round';\n document.head.appendChild(iconLink);\n }\n\n document.body.innerHTML = `\n <div id=\"kantine-wrapper\">\n <header class=\"app-header\">\n <div class=\"header-content\">\n <div class=\"brand\">\n <span class=\"material-icons-round logo-icon\">restaurant_menu</span>\n <div class=\"header-left\">\n <h1>Kantinen \u00dcbersicht <small style=\"font-size: 0.6em; opacity: 0.7; font-weight: 400;\">v1.1.1</small></h1>\n <div id=\"last-updated-subtitle\" class=\"subtitle\"></div>\n </div>\n </div>\n <div class=\"header-center-wrapper\">\n <div id=\"header-week-info\" class=\"header-week-info\"></div>\n <div id=\"weekly-cost-display\" class=\"weekly-cost hidden\"></div>\n </div>\n <div class=\"controls\">\n <button id=\"btn-refresh\" class=\"icon-btn\" aria-label=\"Men\u00fcdaten aktualisieren\" title=\"Men\u00fcdaten neu laden\">\n <span class=\"material-icons-round\">refresh</span>\n </button>\n <button id=\"btn-highlights\" class=\"icon-btn\" aria-label=\"Pers\u00f6nliche Highlights verwalten\" title=\"Pers\u00f6nliche Highlights verwalten\">\n <span class=\"material-icons-round\">label</span>\n </button>\n <div class=\"nav-group\">\n <button id=\"btn-this-week\" class=\"nav-btn active\">Diese Woche</button>\n <button id=\"btn-next-week\" class=\"nav-btn\">N\u00e4chste Woche</button>\n </div>\n <button id=\"theme-toggle\" class=\"icon-btn\" aria-label=\"Toggle Theme\">\n <span class=\"material-icons-round theme-icon\">light_mode</span>\n </button>\n <button id=\"btn-login-open\" class=\"user-badge-btn icon-btn-small\">\n <span class=\"material-icons-round\">login</span>\n <span>Anmelden</span>\n </button>\n <div id=\"user-info\" class=\"user-badge hidden\">\n <span class=\"material-icons-round\">person</span>\n <span id=\"user-id-display\"></span>\n <button id=\"btn-logout\" class=\"icon-btn-small\" aria-label=\"Logout\">\n <span class=\"material-icons-round\">logout</span>\n </button>\n </div>\n </div>\n </div>\n </header>\n\n <div id=\"login-modal\" class=\"modal hidden\">\n <div class=\"modal-content\">\n <div class=\"modal-header\">\n <h2>Login</h2>\n <button id=\"btn-login-close\" class=\"icon-btn\" aria-label=\"Close\">\n <span class=\"material-icons-round\">close</span>\n </button>\n </div>\n <form id=\"login-form\">\n <div class=\"form-group\">\n <label for=\"employee-id\">Mitarbeiternummer</label>\n <input type=\"text\" id=\"employee-id\" name=\"employee-id\" placeholder=\"z.B. 2041\" required>\n <small class=\"help-text\">Deine offizielle Knapp Mitarbeiternummer.</small>\n </div>\n <div class=\"form-group\">\n <label for=\"password\">Passwort</label>\n <input type=\"password\" id=\"password\" name=\"password\" placeholder=\"Bessa Passwort\" required>\n <small class=\"help-text\">Das Passwort f\u00fcr deinen Bessa Account.</small>\n </div>\n <div id=\"login-error\" class=\"error-msg hidden\"></div>\n <div class=\"modal-actions\">\n <button type=\"submit\" class=\"btn-primary wide\">Einloggen</button>\n </div>\n </form>\n </div>\n </div>\n\n <div id=\"progress-modal\" class=\"modal hidden\">\n <div class=\"modal-content\">\n <div class=\"modal-header\">\n <h2>Men\u00fcdaten aktualisieren</h2>\n </div>\n <div class=\"modal-body\" style=\"padding: 20px;\">\n <div class=\"progress-container\">\n <div class=\"progress-bar\">\n <div id=\"progress-fill\" class=\"progress-fill\"></div>\n </div>\n <div id=\"progress-percent\" class=\"progress-percent\">0%</div>\n </div>\n <p id=\"progress-message\" class=\"progress-message\">Initialisierung...</p>\n </div>\n </div>\n </div>\n\n <div id=\"highlights-modal\" class=\"modal hidden\">\n <div class=\"modal-content\">\n <div class=\"modal-header\">\n <h2>Meine Highlights</h2>\n <button id=\"btn-highlights-close\" class=\"icon-btn\" aria-label=\"Close\">\n <span class=\"material-icons-round\">close</span>\n </button>\n </div>\n <div class=\"modal-body\">\n <p style=\"margin-bottom: 1rem; color: var(--text-secondary);\">\n Markiere Men\u00fcs automatisch, wenn sie diese Schlagw\u00f6rter enthalten.\n </p>\n <div class=\"input-group\">\n <input type=\"text\" id=\"tag-input\" placeholder=\"z.B. Schnitzel, Vegetarisch...\">\n <button id=\"btn-add-tag\" class=\"btn-primary\">Hinzuf\u00fcgen</button>\n </div>\n <div id=\"tags-list\"></div>\n </div>\n </div>\n </div>\n\n <main class=\"container\">\n <div id=\"last-updated-banner\" class=\"banner hidden\">\n <span class=\"material-icons-round\">update</span>\n <span id=\"last-updated-text\">Gerade aktualisiert</span>\n </div>\n <div id=\"loading\" class=\"loading-state\">\n <div class=\"spinner\"></div>\n <p>Lade Men\u00fcdaten...</p>\n </div>\n <div id=\"menu-container\" class=\"menu-grid\"></div>\n </main>\n\n <footer class=\"app-footer\">\n <p>Bessa Knapp-Kantine Wrapper &bull; <span id=\"current-year\">${new Date().getFullYear()}</span></p>\n </footer>\n </div>`;\n }\n\n // === Bind Events ===\n function bindEvents() {\n const btnThisWeek = document.getElementById('btn-this-week');\n const btnNextWeek = document.getElementById('btn-next-week');\n const btnRefresh = document.getElementById('btn-refresh');\n const themeToggle = document.getElementById('theme-toggle');\n const btnLoginOpen = document.getElementById('btn-login-open');\n const btnLoginClose = document.getElementById('btn-login-close');\n const btnLogout = document.getElementById('btn-logout');\n const loginForm = document.getElementById('login-form');\n const loginModal = document.getElementById('login-modal');\n\n // Highlights Modal\n const btnHighlights = document.getElementById('btn-highlights');\n const highlightsModal = document.getElementById('highlights-modal');\n const btnHighlightsClose = document.getElementById('btn-highlights-close');\n const btnAddTag = document.getElementById('btn-add-tag');\n const tagInput = document.getElementById('tag-input');\n\n btnHighlights.addEventListener('click', () => {\n highlightsModal.classList.remove('hidden');\n renderTagsList();\n tagInput.focus();\n });\n\n btnHighlightsClose.addEventListener('click', () => {\n highlightsModal.classList.add('hidden');\n });\n\n window.addEventListener('click', (e) => {\n if (e.target === highlightsModal) highlightsModal.classList.add('hidden');\n });\n\n btnAddTag.addEventListener('click', () => {\n const tag = tagInput.value;\n if (addHighlightTag(tag)) {\n tagInput.value = '';\n renderTagsList();\n }\n });\n\n tagInput.addEventListener('keypress', (e) => {\n if (e.key === 'Enter') {\n btnAddTag.click();\n }\n });\n\n // Theme\n const savedTheme = localStorage.getItem('theme');\n const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;\n const themeIcon = themeToggle.querySelector('.theme-icon');\n\n if (savedTheme === 'dark' || (!savedTheme && prefersDark)) {\n document.documentElement.setAttribute('data-theme', 'dark');\n themeIcon.textContent = 'dark_mode';\n } else {\n document.documentElement.setAttribute('data-theme', 'light');\n themeIcon.textContent = 'light_mode';\n }\n\n themeToggle.addEventListener('click', () => {\n const current = document.documentElement.getAttribute('data-theme');\n const next = current === 'dark' ? 'light' : 'dark';\n document.documentElement.setAttribute('data-theme', next);\n localStorage.setItem('theme', next);\n themeIcon.textContent = next === 'dark' ? 'dark_mode' : 'light_mode';\n });\n\n // Navigation\n btnThisWeek.addEventListener('click', () => {\n if (displayMode !== 'this-week') {\n displayMode = 'this-week';\n btnThisWeek.classList.add('active');\n btnNextWeek.classList.remove('active');\n renderVisibleWeeks();\n }\n });\n\n btnNextWeek.addEventListener('click', () => {\n if (displayMode !== 'next-week') {\n displayMode = 'next-week';\n btnNextWeek.classList.add('active');\n btnThisWeek.classList.remove('active');\n renderVisibleWeeks();\n }\n });\n\n // Refresh \u2013 fetch fresh data from Bessa API\n btnRefresh.addEventListener('click', () => {\n if (!authToken) {\n loginModal.classList.remove('hidden');\n return;\n }\n loadMenuDataFromAPI();\n });\n\n // Login Modal\n btnLoginOpen.addEventListener('click', () => {\n loginModal.classList.remove('hidden');\n document.getElementById('login-error').classList.add('hidden');\n loginForm.reset();\n });\n\n btnLoginClose.addEventListener('click', () => {\n loginModal.classList.add('hidden');\n });\n\n window.addEventListener('click', (e) => {\n if (e.target === loginModal) loginModal.classList.add('hidden');\n });\n\n // Login Form Submit\n loginForm.addEventListener('submit', async (e) => {\n e.preventDefault();\n const employeeId = document.getElementById('employee-id').value.trim();\n const password = document.getElementById('password').value;\n const loginError = document.getElementById('login-error');\n const submitBtn = loginForm.querySelector('button[type=\"submit\"]');\n const originalText = submitBtn.textContent;\n\n submitBtn.disabled = true;\n submitBtn.textContent = 'Wird eingeloggt...';\n\n try {\n const email = `knapp-${employeeId}@bessa.app`;\n const response = await fetch(`${API_BASE}/auth/login/`, {\n method: 'POST',\n headers: apiHeaders(GUEST_TOKEN),\n body: JSON.stringify({ email, password })\n });\n\n const data = await response.json();\n\n if (response.ok) {\n authToken = data.key;\n currentUser = employeeId;\n sessionStorage.setItem('kantine_authToken', data.key);\n sessionStorage.setItem('kantine_currentUser', employeeId);\n\n // Fetch user name\n try {\n const userResp = await fetch(`${API_BASE}/auth/user/`, {\n headers: apiHeaders(authToken)\n });\n if (userResp.ok) {\n const userData = await userResp.json();\n if (userData.first_name) sessionStorage.setItem('kantine_firstName', userData.first_name);\n if (userData.last_name) sessionStorage.setItem('kantine_lastName', userData.last_name);\n }\n } catch (err) {\n console.error('Failed to fetch user info:', err);\n }\n\n updateAuthUI();\n loginModal.classList.add('hidden');\n fetchOrders();\n loginForm.reset();\n startPolling();\n\n // Reload menu data with auth for full details\n loadMenuDataFromAPI();\n } else {\n loginError.textContent = data.non_field_errors?.[0] || data.error || 'Login fehlgeschlagen';\n loginError.classList.remove('hidden');\n }\n } catch (error) {\n console.error('Login error:', error);\n loginError.textContent = 'Ein Fehler ist aufgetreten';\n loginError.classList.remove('hidden');\n } finally {\n submitBtn.disabled = false;\n submitBtn.textContent = originalText;\n }\n });\n\n // Logout\n btnLogout.addEventListener('click', () => {\n sessionStorage.removeItem('kantine_authToken');\n sessionStorage.removeItem('kantine_currentUser');\n sessionStorage.removeItem('kantine_firstName');\n sessionStorage.removeItem('kantine_lastName');\n authToken = null;\n currentUser = null;\n orderMap = new Map();\n stopPolling();\n updateAuthUI();\n renderVisibleWeeks();\n });\n }\n\n // === Auth UI ===\n function updateAuthUI() {\n // Try to recover session from Bessa's storage if not already logged in\n if (!authToken) {\n try {\n const akita = localStorage.getItem('AkitaStores');\n if (akita) {\n const parsed = JSON.parse(akita);\n if (parsed.auth && parsed.auth.token) {\n console.log('Found existing Bessa session!');\n authToken = parsed.auth.token;\n sessionStorage.setItem('kantine_authToken', authToken);\n\n if (parsed.auth.user) {\n currentUser = parsed.auth.user.id || 'unknown';\n sessionStorage.setItem('kantine_currentUser', currentUser);\n if (parsed.auth.user.firstName) sessionStorage.setItem('kantine_firstName', parsed.auth.user.firstName);\n if (parsed.auth.user.lastName) sessionStorage.setItem('kantine_lastName', parsed.auth.user.lastName);\n }\n }\n }\n } catch (e) {\n console.warn('Failed to parse AkitaStores:', e);\n }\n }\n\n authToken = sessionStorage.getItem('kantine_authToken');\n currentUser = sessionStorage.getItem('kantine_currentUser');\n const firstName = sessionStorage.getItem('kantine_firstName');\n const btnLoginOpen = document.getElementById('btn-login-open');\n const userInfo = document.getElementById('user-info');\n const userIdDisplay = document.getElementById('user-id-display');\n\n if (authToken) {\n btnLoginOpen.classList.add('hidden');\n userInfo.classList.remove('hidden');\n userIdDisplay.textContent = firstName || (currentUser ? `User ${currentUser}` : 'Angemeldet');\n fetchOrders(); // Always fetch fresh orders on auth update\n } else {\n btnLoginOpen.classList.remove('hidden');\n userInfo.classList.add('hidden');\n userIdDisplay.textContent = '';\n }\n\n renderVisibleWeeks();\n }\n\n // === Fetch Orders from Bessa ===\n async function fetchOrders() {\n if (!authToken) return;\n try {\n // Use user/orders endpoint for reliable history\n const response = await fetch(`${API_BASE}/user/orders/?venue=${VENUE_ID}&ordering=-created&limit=50`, {\n headers: apiHeaders(authToken)\n });\n const data = await response.json();\n\n if (response.ok) {\n orderMap = new Map();\n const results = data.results || [];\n\n for (const order of results) {\n // Filter out cancelled orders (State 9)\n // Accepting State 1 (Created?), 5 (Placed?), 8 (Completed)\n // TODO: Verify exact states. Subagent saw 5=Active, 8=Completed, 9=Cancelled.\n if (order.order_state === 9) continue;\n\n // Extract date properly (it comes as ISO string)\n const orderDate = order.date.split('T')[0];\n\n for (const item of (order.items || [])) {\n const key = `${orderDate}_${item.article}`;\n if (!orderMap.has(key)) orderMap.set(key, []);\n orderMap.get(key).push(order.id);\n }\n }\n console.log(`Fetched ${results.length} orders, mapped active ones.`);\n renderVisibleWeeks();\n }\n } catch (error) {\n console.error('Error fetching orders:', error);\n }\n }\n\n // === Place Order ===\n async function placeOrder(date, articleId, name, price, description) {\n if (!authToken) return;\n try {\n // Get user data for customer object\n const userResp = await fetch(`${API_BASE}/auth/user/`, {\n headers: apiHeaders(authToken)\n });\n if (!userResp.ok) {\n showToast('Fehler: Benutzerdaten konnten nicht geladen werden', 'error');\n return;\n }\n const userData = await userResp.json();\n const now = new Date().toISOString();\n\n const orderPayload = {\n uuid: crypto.randomUUID(),\n created: now,\n updated: now,\n order_type: 7,\n items: [{\n article: articleId,\n course_group: null,\n modifiers: [],\n uuid: crypto.randomUUID(),\n name: name,\n description: description || '',\n price: String(parseFloat(price)),\n amount: 1,\n vat: '10.00',\n comment: ''\n }],\n table: null,\n total: parseFloat(price),\n tip: 0,\n currency: 'EUR',\n venue: VENUE_ID,\n states: [],\n order_state: 1,\n date: `${date}T10:00:00.000Z`,\n payment_method: 'payroll',\n customer: {\n first_name: userData.first_name,\n last_name: userData.last_name,\n email: userData.email,\n newsletter: false\n },\n preorder: false,\n delivery_fee: 0,\n cash_box_table_name: null,\n take_away: false\n };\n\n const response = await fetch(`${API_BASE}/user/orders/`, {\n method: 'POST',\n headers: apiHeaders(authToken),\n body: JSON.stringify(orderPayload)\n });\n\n if (response.ok || response.status === 201) {\n showToast(`Bestellt: ${name}`, 'success');\n await fetchOrders();\n } else {\n const data = await response.json();\n showToast(`Fehler: ${data.detail || data.non_field_errors?.[0] || 'Bestellung fehlgeschlagen'}`, 'error');\n }\n } catch (error) {\n console.error('Order error:', error);\n showToast('Netzwerkfehler bei Bestellung', 'error');\n }\n }\n\n // === Cancel Order ===\n async function cancelOrder(date, articleId, name) {\n if (!authToken) return;\n const key = `${date}_${articleId}`;\n const orderIds = orderMap.get(key);\n if (!orderIds || orderIds.length === 0) return;\n\n // LIFO: cancel most recent\n const orderId = orderIds[orderIds.length - 1];\n try {\n const response = await fetch(`${API_BASE}/user/orders/${orderId}/cancel/`, {\n method: 'PATCH',\n headers: apiHeaders(authToken),\n body: JSON.stringify({})\n });\n\n if (response.ok) {\n showToast(`Storniert: ${name}`, 'success');\n await fetchOrders();\n } else {\n const data = await response.json();\n showToast(`Fehler: ${data.detail || 'Stornierung fehlgeschlagen'}`, 'error');\n }\n } catch (error) {\n console.error('Cancel error:', error);\n showToast('Netzwerkfehler bei Stornierung', 'error');\n }\n }\n\n // === Flag Management (localStorage) ===\n function saveFlags() {\n localStorage.setItem('kantine_flags', JSON.stringify([...userFlags]));\n }\n\n function toggleFlag(date, articleId, name, cutoff) {\n const id = `${date}_${articleId}`;\n if (userFlags.has(id)) {\n userFlags.delete(id);\n showToast(`Flag entfernt f\u00fcr ${name}`, 'success');\n } else {\n userFlags.add(id);\n showToast(`Benachrichtigung aktiviert f\u00fcr ${name}`, 'success');\n if (Notification.permission === 'default') {\n Notification.requestPermission();\n }\n }\n saveFlags();\n renderVisibleWeeks();\n }\n\n // FR-019: Auto-remove flags whose cutoff has passed\n function cleanupExpiredFlags() {\n const now = new Date();\n let changed = false;\n for (const flagId of [...userFlags]) {\n const [date] = flagId.split('_');\n const cutoff = new Date(date);\n cutoff.setHours(10, 0, 0, 0); // Standard cutoff 10:00\n if (now >= cutoff) {\n userFlags.delete(flagId);\n changed = true;\n }\n }\n if (changed) saveFlags();\n }\n\n // === Polling (Client-Side) ===\n function startPolling() {\n if (pollIntervalId) return;\n if (!authToken) return;\n pollIntervalId = setInterval(() => pollFlaggedItems(), POLL_INTERVAL_MS);\n console.log('Polling started (every 5 min)');\n }\n\n function stopPolling() {\n if (pollIntervalId) {\n clearInterval(pollIntervalId);\n pollIntervalId = null;\n console.log('Polling stopped');\n }\n }\n\n async function pollFlaggedItems() {\n if (userFlags.size === 0 || !authToken) return;\n console.log(`Polling ${userFlags.size} flagged items...`);\n\n for (const flagId of userFlags) {\n const [date, articleIdStr] = flagId.split('_');\n const articleId = parseInt(articleIdStr);\n\n try {\n const response = await fetch(`${API_BASE}/venues/${VENUE_ID}/menu/${MENU_ID}/${date}/`, {\n headers: apiHeaders(authToken)\n });\n if (!response.ok) continue;\n\n const data = await response.json();\n const groups = data.results || [];\n let foundItem = null;\n for (const group of groups) {\n if (group.items) {\n foundItem = group.items.find(i => i.id === articleId || i.article === articleId);\n if (foundItem) break;\n }\n }\n\n if (foundItem) {\n const isAvailable = (foundItem.amount_tracking === false) || (parseInt(foundItem.available_amount) > 0);\n if (isAvailable) {\n const itemName = foundItem.name || 'Unbekannt';\n showToast(`${itemName} ist jetzt verf\u00fcgbar!`, 'success');\n if (Notification.permission === 'granted') {\n new Notification('Kantine Wrapper', {\n body: `${itemName} ist jetzt verf\u00fcgbar!`,\n icon: '\ud83c\udf7d\ufe0f'\n });\n }\n // Refresh menu data to update UI\n loadMenuDataFromAPI();\n }\n }\n } catch (err) {\n console.error(`Poll error for ${flagId}:`, err);\n // Small delay between checks\n await new Promise(r => setTimeout(r, 200));\n }\n }\n }\n\n // === Highlight Management ===\n let highlightTags = JSON.parse(localStorage.getItem('kantine_highlightTags') || '[]');\n\nfunction saveHighlightTags() {\n localStorage.setItem('kantine_highlightTags', JSON.stringify(highlightTags));\n renderVisibleWeeks(); // Refresh UI to apply changes\n updateNextWeekBadge();\n}\n\nfunction addHighlightTag(tag) {\n tag = tag.trim().toLowerCase();\n if (tag && !highlightTags.includes(tag)) {\n highlightTags.push(tag);\n saveHighlightTags();\n return true;\n }\n return false;\n}\n\nfunction removeHighlightTag(tag) {\n highlightTags = highlightTags.filter(t => t !== tag);\n saveHighlightTags();\n}\n\nfunction renderTagsList() {\n const list = document.getElementById('tags-list');\n list.innerHTML = '';\n highlightTags.forEach(tag => {\n const badge = document.createElement('span');\n badge.className = 'tag-badge';\n badge.innerHTML = `${tag} <span class=\"tag-remove\" data-tag=\"${tag}\">&times;</span>`;\n list.appendChild(badge);\n });\n\n // Bind remove events\n list.querySelectorAll('.tag-remove').forEach(btn => {\n btn.addEventListener('click', (e) => {\n removeHighlightTag(e.target.dataset.tag);\n renderTagsList();\n });\n });\n}\n\nfunction checkHighlight(text) {\n if (!text) return false;\n text = text.toLowerCase();\n return highlightTags.some(tag => text.includes(tag));\n}\n\n// === Local Menu Cache (localStorage) ===\nconst CACHE_KEY = 'kantine_menuCache';\nconst CACHE_TS_KEY = 'kantine_menuCacheTs';\n\nfunction saveMenuCache() {\n try {\n localStorage.setItem(CACHE_KEY, JSON.stringify(allWeeks));\n localStorage.setItem(CACHE_TS_KEY, new Date().toISOString());\n } catch (e) {\n console.warn('Failed to cache menu data:', e);\n }\n}\n\nfunction loadMenuCache() {\n try {\n const cached = localStorage.getItem(CACHE_KEY);\n const cachedTs = localStorage.getItem(CACHE_TS_KEY);\n if (cached) {\n allWeeks = JSON.parse(cached);\n currentWeekNumber = getISOWeek(new Date());\n currentYear = new Date().getFullYear();\n renderVisibleWeeks();\n updateNextWeekBadge();\n if (cachedTs) updateLastUpdatedTime(cachedTs);\n console.log('Loaded menu from cache');\n return true;\n }\n } catch (e) {\n console.warn('Failed to load cached menu:', e);\n }\n return false;\n}\n\n// === Menu Data Fetching (Direct from Bessa API) ===\nasync function loadMenuDataFromAPI() {\n const loading = document.getElementById('loading');\n const progressModal = document.getElementById('progress-modal');\n const progressFill = document.getElementById('progress-fill');\n const progressPercent = document.getElementById('progress-percent');\n const progressMessage = document.getElementById('progress-message');\n\n loading.classList.remove('hidden');\n\n const token = authToken || GUEST_TOKEN;\n\n try {\n // Show progress modal\n progressModal.classList.remove('hidden');\n progressMessage.textContent = 'Hole verf\u00fcgbare Daten...';\n progressFill.style.width = '0%';\n progressPercent.textContent = '0%';\n\n // 1. Fetch available dates\n const datesResponse = await fetch(`${API_BASE}/venues/${VENUE_ID}/menu/dates/`, {\n headers: apiHeaders(token)\n });\n\n if (!datesResponse.ok) throw new Error(`Failed to fetch dates: ${datesResponse.status}`);\n\n const datesData = await datesResponse.json();\n let availableDates = datesData.results || [];\n\n // Filter \u2013 last 7 days + future, limit 30\n const cutoff = new Date();\n cutoff.setDate(cutoff.getDate() - 7);\n const cutoffStr = cutoff.toISOString().split('T')[0];\n\n availableDates = availableDates\n .filter(d => d.date >= cutoffStr)\n .sort((a, b) => a.date.localeCompare(b.date))\n .slice(0, 30);\n\n const totalDates = availableDates.length;\n progressMessage.textContent = `${totalDates} Tage gefunden. Lade Details...`;\n\n // 2. Fetch details for each date\n const allDays = [];\n let completed = 0;\n\n for (const dateObj of availableDates) {\n const dateStr = dateObj.date;\n const pct = Math.round(((completed + 1) / totalDates) * 100);\n progressFill.style.width = `${pct}%`;\n progressPercent.textContent = `${pct}%`;\n progressMessage.textContent = `Lade Men\u00fc f\u00fcr ${dateStr}...`;\n\n try {\n const detailResp = await fetch(`${API_BASE}/venues/${VENUE_ID}/menu/${MENU_ID}/${dateStr}/`, {\n headers: apiHeaders(token)\n });\n\n if (detailResp.ok) {\n const detailData = await detailResp.json();\n // Debug: log raw API response for first date\n if (completed === 0) {\n console.log('[Kantine Debug] Raw API response for', dateStr, ':', JSON.stringify(detailData).substring(0, 2000));\n }\n const menuGroups = detailData.results || [];\n let dayItems = [];\n for (const group of menuGroups) {\n if (group.items && Array.isArray(group.items)) {\n dayItems = dayItems.concat(group.items);\n }\n }\n if (dayItems.length > 0) {\n // Debug: log first item structure\n if (completed === 0) {\n console.log('[Kantine Debug] First item keys:', Object.keys(dayItems[0]));\n console.log('[Kantine Debug] First item:', JSON.stringify(dayItems[0]).substring(0, 500));\n }\n allDays.push({\n date: dateStr,\n menu_items: dayItems,\n orders: dateObj.orders || []\n });\n }\n }\n } catch (err) {\n console.error(`Failed to fetch details for ${dateStr}:`, err);\n }\n\n completed++;\n // Small delay to avoid rate limiting\n await new Promise(r => setTimeout(r, 100));\n }\n\n // 3. Group by ISO week (Merge with existing to preserve past days)\n const weeksMap = new Map();\n\n // Hydrate from existing cache (preserve past data)\n if (allWeeks && allWeeks.length > 0) {\n allWeeks.forEach(w => {\n const key = `${w.year}-${w.weekNumber}`;\n try {\n weeksMap.set(key, {\n year: w.year,\n weekNumber: w.weekNumber,\n days: w.days ? w.days.map(d => ({ ...d, items: d.items ? [...d.items] : [] })) : []\n });\n } catch (e) { console.warn('Error hydrating week:', e); }\n });\n }\n\n for (const day of allDays) {\n const d = new Date(day.date);\n const weekNum = getISOWeek(d);\n const year = getWeekYear(d);\n const key = `${year}-${weekNum}`;\n\n if (!weeksMap.has(key)) {\n weeksMap.set(key, { year, weekNumber: weekNum, days: [] });\n }\n\n const weekObj = weeksMap.get(key);\n const weekday = d.toLocaleDateString('en-US', { weekday: 'long' });\n const orderCutoffDate = new Date(day.date);\n orderCutoffDate.setHours(10, 0, 0, 0);\n\n const newDayObj = {\n date: day.date,\n weekday: weekday,\n orderCutoff: orderCutoffDate.toISOString(),\n items: day.menu_items.map(item => {\n const isUnlimited = item.amount_tracking === false;\n const hasStock = parseInt(item.available_amount) > 0;\n return {\n id: `${day.date}_${item.id}`,\n articleId: item.id,\n name: item.name || 'Unknown',\n description: item.description || '',\n price: parseFloat(item.price) || 0,\n available: isUnlimited || hasStock,\n availableAmount: parseInt(item.available_amount) || 0,\n amountTracking: item.amount_tracking !== false\n };\n })\n };\n\n // Merge: Overwrite if exists, push if new\n const existingIndex = weekObj.days.findIndex(existing => existing.date === day.date);\n if (existingIndex >= 0) {\n weekObj.days[existingIndex] = newDayObj;\n } else {\n weekObj.days.push(newDayObj);\n }\n }\n\n // Sort weeks and days\n allWeeks = Array.from(weeksMap.values()).sort((a, b) => {\n if (a.year !== b.year) return a.year - b.year;\n return a.weekNumber - b.weekNumber;\n });\n allWeeks.forEach(w => {\n if (w.days) w.days.sort((a, b) => a.date.localeCompare(b.date));\n });\n\n // Save to localStorage cache\n saveMenuCache();\n\n // Update timestamp\n updateLastUpdatedTime(new Date().toISOString());\n\n currentWeekNumber = getISOWeek(new Date());\n currentYear = new Date().getFullYear();\n\n\n\n updateAuthUI(); // This will trigger fetchOrders if logged in\n renderVisibleWeeks();\n updateNextWeekBadge();\n\n progressMessage.textContent = 'Fertig!';\n setTimeout(() => progressModal.classList.add('hidden'), 500);\n\n } catch (error) {\n console.error('Error fetching menu:', error);\n progressModal.classList.add('hidden');\n\n showErrorModal(\n 'Keine Verbindung',\n `Die Men\u00fcdaten konnten nicht geladen werden. M\u00f6glicherweise besteht keine Verbindung zur API oder zur Bessa-Webseite.<br><br><small style=\"color:var(--text-secondary)\">${error.message}</small>`,\n 'Zur Original-Seite',\n 'https://web.bessa.app/knapp-kantine'\n );\n } finally {\n loading.classList.add('hidden');\n }\n}\n\n// === Last Updated Display ===\nfunction updateLastUpdatedTime(isoTimestamp) {\n const subtitle = document.getElementById('last-updated-subtitle');\n if (!isoTimestamp) return;\n try {\n const date = new Date(isoTimestamp);\n const timeStr = date.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });\n const dateStr = date.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' });\n subtitle.textContent = `Aktualisiert: ${dateStr} ${timeStr}`;\n } catch (e) {\n subtitle.textContent = '';\n }\n}\n\n// === Toast Notification ===\nfunction showToast(message, type = 'info') {\n let container = document.getElementById('toast-container');\n if (!container) {\n container = document.createElement('div');\n container.id = 'toast-container';\n document.body.appendChild(container);\n }\n const toast = document.createElement('div');\n toast.className = `toast toast-${type}`;\n const icon = type === 'success' ? 'check_circle' : type === 'error' ? 'error' : 'info';\n toast.innerHTML = `<span class=\"material-icons-round\">${icon}</span><span>${message}</span>`;\n container.appendChild(toast);\n requestAnimationFrame(() => toast.classList.add('show'));\n setTimeout(() => {\n toast.classList.remove('show');\n setTimeout(() => toast.remove(), 300);\n }, 3000);\n}\n\n// === Next Week Badge ===\nfunction updateNextWeekBadge() {\n const btnNextWeek = document.getElementById('btn-next-week');\n let nextWeek = currentWeekNumber + 1;\n let nextYear = currentYear;\n if (nextWeek > 52) { nextWeek = 1; nextYear++; }\n\n const nextWeekData = allWeeks.find(w => w.weekNumber === nextWeek && w.year === nextYear);\n let totalDataCount = 0;\n let orderableCount = 0;\n let daysWithOrders = 0;\n let daysWithOrderableAndNoOrder = 0;\n\n if (nextWeekData && nextWeekData.days) {\n nextWeekData.days.forEach(day => {\n if (day.items && day.items.length > 0) {\n totalDataCount++;\n const isOrderable = day.items.some(item => item.available);\n if (isOrderable) orderableCount++;\n\n let hasOrder = false;\n day.items.forEach(item => {\n const articleId = item.articleId || parseInt(item.id.split('_')[1]);\n const key = `${day.date}_${articleId}`;\n if (orderMap.has(key) && orderMap.get(key).length > 0) hasOrder = true;\n });\n\n if (hasOrder) daysWithOrders++;\n if (isOrderable && !hasOrder) daysWithOrderableAndNoOrder++;\n }\n });\n }\n\n let badge = btnNextWeek.querySelector('.nav-badge');\n if (totalDataCount > 0) {\n if (!badge) {\n badge = document.createElement('span');\n badge.className = 'nav-badge';\n btnNextWeek.appendChild(badge);\n }\n\n // Format: ( Ordered / Orderable / Total )\n badge.title = `${daysWithOrders} bestellt / ${orderableCount} bestellbar / ${totalDataCount} gesamt`;\n badge.innerHTML = `<span class=\"ordered\">${daysWithOrders}</span><span class=\"separator\">/</span><span class=\"orderable\">${orderableCount}</span><span class=\"separator\">/</span><span class=\"total\">${totalDataCount}</span>`;\n\n // Color Logic\n badge.classList.remove('badge-violet', 'badge-green', 'badge-red', 'badge-blue');\n\n // Refined Logic (v1.7.4):\n // Violet: If we have orders AND there are no DAYS left that are orderable but un-ordered.\n // (i.e. \"I have ordered everything I can\")\n if (daysWithOrders > 0 && daysWithOrderableAndNoOrder === 0) {\n badge.classList.add('badge-violet');\n } else if (daysWithOrderableAndNoOrder > 0) {\n badge.classList.add('badge-green'); // Orderable days exist without order\n } else if (orderableCount === 0) {\n badge.classList.add('badge-red'); // No orderable days at all & no orders\n } else {\n badge.classList.add('badge-blue'); // Default / partial state\n }\n\n // Advanced Feature: Highlight Count\n let highlightCount = 0;\n if (nextWeekData && nextWeekData.days) {\n nextWeekData.days.forEach(day => {\n day.items.forEach(item => {\n if (checkHighlight(item.name) || checkHighlight(item.description)) {\n highlightCount++;\n }\n });\n });\n }\n\n if (highlightCount > 0) {\n // Append blue count\n badge.innerHTML += `<span class=\"highlight-count\" title=\"${highlightCount} Highlight Men\u00fcs\">(${highlightCount})</span>`;\n badge.title += ` \u2022 ${highlightCount} Highlights gefunden`;\n badge.classList.add('has-highlights');\n }\n\n } else if (badge) {\n badge.remove();\n }\n}\n\n// === Weekly Cost ===\nfunction updateWeeklyCost(days) {\n let totalCost = 0;\n if (days && days.length > 0) {\n days.forEach(day => {\n if (day.items) {\n day.items.forEach(item => {\n const articleId = item.articleId || parseInt(item.id.split('_')[1]);\n const key = `${day.date}_${articleId}`;\n const orders = orderMap.get(key) || [];\n if (orders.length > 0) totalCost += item.price * orders.length;\n });\n }\n });\n }\n\n const costDisplay = document.getElementById('weekly-cost-display');\n if (totalCost > 0) {\n costDisplay.innerHTML = `<span class=\"material-icons-round\">shopping_bag</span> <span>Gesamt: ${totalCost.toFixed(2).replace('.', ',')} \u20ac</span>`;\n costDisplay.classList.remove('hidden');\n } else {\n costDisplay.classList.add('hidden');\n }\n}\n\n// === Render Weeks ===\nfunction renderVisibleWeeks() {\n const menuContainer = document.getElementById('menu-container');\n if (!menuContainer) return;\n menuContainer.innerHTML = '';\n\n let targetWeek = currentWeekNumber;\n let targetYear = currentYear;\n\n if (displayMode === 'next-week') {\n targetWeek++;\n if (targetWeek > 52) { targetWeek = 1; targetYear++; }\n }\n\n // Flatten & filter by week + year\n const allDays = allWeeks.flatMap(w => w.days || []);\n const daysInTargetWeek = allDays.filter(day => {\n const d = new Date(day.date);\n return getISOWeek(d) === targetWeek && getWeekYear(d) === targetYear;\n });\n\n if (daysInTargetWeek.length === 0) {\n menuContainer.innerHTML = `\n <div class=\"empty-state\">\n <p>Keine Men\u00fcdaten f\u00fcr KW ${targetWeek} (${targetYear}) verf\u00fcgbar.</p>\n <small>Versuchen Sie eine andere Woche oder schauen Sie sp\u00e4ter vorbei.</small>\n </div>`;\n document.getElementById('weekly-cost-display').classList.add('hidden');\n return;\n }\n\n updateWeeklyCost(daysInTargetWeek);\n\n // Update header\n const headerWeekInfo = document.getElementById('header-week-info');\n const weekTitle = displayMode === 'this-week' ? 'Diese Woche' : 'N\u00e4chste Woche';\n headerWeekInfo.innerHTML = `\n <div class=\"header-week-title\">${weekTitle}</div>\n <div class=\"header-week-subtitle\">Week ${targetWeek} \u2022 ${targetYear}</div>`;\n\n const grid = document.createElement('div');\n grid.className = 'days-grid';\n\n daysInTargetWeek.sort((a, b) => a.date.localeCompare(b.date));\n\n // Filter weekends\n const workingDays = daysInTargetWeek.filter(d => {\n const date = new Date(d.date);\n const day = date.getDay();\n return day !== 0 && day !== 6;\n });\n\n workingDays.forEach(day => {\n const card = createDayCard(day);\n if (card) grid.appendChild(card);\n });\n\n menuContainer.appendChild(grid);\n setTimeout(() => syncMenuItemHeights(grid), 0);\n}\n\n// === Sync Item Heights ===\nfunction syncMenuItemHeights(grid) {\n const cards = grid.querySelectorAll('.menu-card');\n if (cards.length === 0) return;\n let maxItems = 0;\n cards.forEach(card => {\n maxItems = Math.max(maxItems, card.querySelectorAll('.menu-item').length);\n });\n for (let i = 0; i < maxItems; i++) {\n let maxHeight = 0;\n const itemsAtPos = [];\n cards.forEach(card => {\n const items = card.querySelectorAll('.menu-item');\n if (items[i]) {\n items[i].style.height = 'auto';\n maxHeight = Math.max(maxHeight, items[i].offsetHeight);\n itemsAtPos.push(items[i]);\n }\n });\n itemsAtPos.forEach(item => { item.style.height = `${maxHeight}px`; });\n }\n}\n\n// === Create Day Card ===\nfunction createDayCard(day) {\n if (!day.items || day.items.length === 0) return null;\n\n const card = document.createElement('div');\n card.className = 'menu-card';\n\n const now = new Date();\n const cardDate = new Date(day.date);\n\n let isPastCutoff = false;\n if (day.orderCutoff) {\n isPastCutoff = now >= new Date(day.orderCutoff);\n } else {\n const today = new Date();\n today.setHours(0, 0, 0, 0);\n const cd = new Date(day.date);\n cd.setHours(0, 0, 0, 0);\n isPastCutoff = cd < today;\n }\n\n if (isPastCutoff) card.classList.add('past-day');\n\n // Collect ordered menu codes\n const menuBadges = [];\n if (day.items) {\n day.items.forEach(item => {\n const articleId = item.articleId || parseInt(item.id.split('_')[1]);\n const orderKey = `${day.date}_${articleId}`;\n const orders = orderMap.get(orderKey) || [];\n const count = orders.length;\n\n if (count > 0) {\n // Regex for M1, M2, M1F etc.\n const match = item.name.match(/([M][1-9][Ff]?)/);\n if (match) {\n let code = match[1];\n if (count > 1) code += '+';\n menuBadges.push(code);\n }\n }\n });\n }\n\n // Header\n const header = document.createElement('div');\n header.className = 'card-header';\n const dateStr = cardDate.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' });\n\n const badgesHtml = menuBadges.map(code => `<span class=\"menu-code-badge\">${code}</span>`).join('');\n\n // Determine Day Status for Header Color\n // Violet: Has Order\n // Green: No Order but Orderable\n // Red: No Order and Not Orderable (Locked/Sold Out)\n let headerClass = '';\n const hasAnyOrder = day.items && day.items.some(item => {\n const articleId = item.articleId || parseInt(item.id.split('_')[1]);\n const key = `${day.date}_${articleId}`;\n return orderMap.has(key) && orderMap.get(key).length > 0;\n });\n\n const hasOrderable = day.items && day.items.some(item => {\n // Use pre-calculated available flag from loadMenuDataFromAPI calculation\n return item.available;\n });\n\n if (hasAnyOrder) {\n headerClass = 'header-violet';\n } else if (hasOrderable && !isPastCutoff) {\n headerClass = 'header-green';\n } else {\n // Red if not orderable (or past cutoff)\n headerClass = 'header-red';\n }\n\n if (headerClass) header.classList.add(headerClass);\n\n header.innerHTML = `\n <div class=\"day-header-left\">\n <span class=\"day-name\">${translateDay(day.weekday)}</span>\n <div class=\"day-badges\">${badgesHtml}</div>\n </div>\n <span class=\"day-date\">${dateStr}</span>`;\n card.appendChild(header);\n\n // Body\n const body = document.createElement('div');\n body.className = 'card-body';\n\n const todayDateStr = new Date().toISOString().split('T')[0];\n const isToday = day.date === todayDateStr;\n\n const sortedItems = [...day.items].sort((a, b) => {\n if (isToday) {\n const aId = a.articleId || parseInt(a.id.split('_')[1]);\n const bId = b.articleId || parseInt(b.id.split('_')[1]);\n const aOrdered = orderMap.has(`${day.date}_${aId}`);\n const bOrdered = orderMap.has(`${day.date}_${bId}`);\n\n if (aOrdered && !bOrdered) return -1;\n if (!aOrdered && bOrdered) return 1;\n }\n return a.name.localeCompare(b.name);\n });\n\n sortedItems.forEach(item => {\n const itemEl = document.createElement('div');\n itemEl.className = 'menu-item';\n\n const articleId = item.articleId || parseInt(item.id.split('_')[1]);\n const orderKey = `${day.date}_${articleId}`;\n const orderIds = orderMap.get(orderKey) || [];\n const orderCount = orderIds.length;\n\n // Status badge\n let statusBadge = '';\n if (item.available) {\n statusBadge = item.amountTracking\n ? `<span class=\"badge available\">Verf\u00fcgbar (${item.availableAmount})</span>`\n : `<span class=\"badge available\">Verf\u00fcgbar</span>`;\n } else {\n statusBadge = `<span class=\"badge sold-out\">Ausverkauft</span>`;\n }\n\n // Order badge\n let orderedBadge = '';\n if (orderCount > 0) {\n const countBadge = orderCount > 1 ? `<span class=\"order-count-badge\">${orderCount}</span>` : '';\n orderedBadge = `<span class=\"badge ordered\"><span class=\"material-icons-round\">check_circle</span> Bestellt${countBadge}</span>`;\n itemEl.classList.add('ordered');\n if (new Date(day.date).toDateString() === now.toDateString()) {\n itemEl.classList.add('today-ordered');\n }\n }\n\n // Flagged styles\n const flagId = `${day.date}_${articleId}`;\n const isFlagged = userFlags.has(flagId);\n if (isFlagged) {\n itemEl.classList.add(item.available ? 'flagged-available' : 'flagged-sold-out');\n }\n\n // Action buttons\n let orderButton = '';\n let cancelButton = '';\n let flagButton = '';\n\n if (authToken && !isPastCutoff) {\n // Flag button\n const flagIcon = isFlagged ? 'notifications_active' : 'notifications_none';\n const flagClass = isFlagged ? 'btn-flag active' : 'btn-flag';\n const flagTitle = isFlagged ? 'Benachrichtigung deaktivieren' : 'Benachrichtigen wenn verf\u00fcgbar';\n if (!item.available || isFlagged) {\n flagButton = `<button class=\"${flagClass}\" data-date=\"${day.date}\" data-article=\"${articleId}\" data-name=\"${escapeHtml(item.name)}\" data-cutoff=\"${day.orderCutoff}\" title=\"${flagTitle}\"><span class=\"material-icons-round\">${flagIcon}</span></button>`;\n }\n\n // Order button\n if (item.available) {\n if (orderCount > 0) {\n orderButton = `<button class=\"btn-order btn-order-compact\" data-date=\"${day.date}\" data-article=\"${articleId}\" data-name=\"${escapeHtml(item.name)}\" data-price=\"${item.price}\" data-desc=\"${escapeHtml(item.description || '')}\" title=\"${escapeHtml(item.name)} nochmal bestellen\"><span class=\"material-icons-round\">add</span></button>`;\n } else {\n orderButton = `<button class=\"btn-order\" data-date=\"${day.date}\" data-article=\"${articleId}\" data-name=\"${escapeHtml(item.name)}\" data-price=\"${item.price}\" data-desc=\"${escapeHtml(item.description || '')}\" title=\"${escapeHtml(item.name)} bestellen\"><span class=\"material-icons-round\">add_shopping_cart</span> Bestellen</button>`;\n }\n }\n\n // Cancel button\n if (orderCount > 0) {\n const cancelIcon = orderCount === 1 ? 'close' : 'remove';\n const cancelTitle = orderCount === 1 ? 'Bestellung stornieren' : 'Eine Bestellung stornieren';\n cancelButton = `<button class=\"btn-cancel\" data-date=\"${day.date}\" data-article=\"${articleId}\" data-name=\"${escapeHtml(item.name)}\" title=\"${cancelTitle}\"><span class=\"material-icons-round\">${cancelIcon}</span></button>`;\n }\n }\n\n itemEl.innerHTML = `\n <div class=\"item-header\">\n <span class=\"item-name\">${escapeHtml(item.name)}</span>\n <span class=\"item-price\">${item.price.toFixed(2)} \u20ac</span>\n </div>\n <div class=\"item-status-row\">\n ${orderedBadge}\n ${cancelButton}\n ${orderButton}\n ${flagButton}\n <div class=\"badges\">${statusBadge}</div>\n </div>\n <p class=\"item-desc\">${escapeHtml(item.description)}</p>`;\n\n // Event: Order\n const orderBtn = itemEl.querySelector('.btn-order');\n if (orderBtn) {\n orderBtn.addEventListener('click', (e) => {\n e.stopPropagation();\n const btn = e.currentTarget;\n btn.disabled = true;\n btn.classList.add('loading');\n placeOrder(btn.dataset.date, parseInt(btn.dataset.article), btn.dataset.name, parseFloat(btn.dataset.price), btn.dataset.desc || '')\n .finally(() => { btn.disabled = false; btn.classList.remove('loading'); });\n });\n }\n\n // Event: Cancel\n const cancelBtn = itemEl.querySelector('.btn-cancel');\n if (cancelBtn) {\n cancelBtn.addEventListener('click', (e) => {\n e.stopPropagation();\n const btn = e.currentTarget;\n btn.disabled = true;\n cancelOrder(btn.dataset.date, parseInt(btn.dataset.article), btn.dataset.name)\n .finally(() => { btn.disabled = false; });\n });\n }\n\n // Event: Flag\n const flagBtn = itemEl.querySelector('.btn-flag');\n if (flagBtn) {\n flagBtn.addEventListener('click', (e) => {\n e.stopPropagation();\n const btn = e.currentTarget;\n toggleFlag(btn.dataset.date, parseInt(btn.dataset.article), btn.dataset.name, btn.dataset.cutoff);\n });\n }\n\n body.appendChild(itemEl);\n });\n\n card.appendChild(body);\n return card;\n}\n\n// === Version Check ===\nasync function checkForUpdates() {\n icon.className = 'update-icon';\n icon.href = url;\n icon.target = '_blank';\n icon.innerHTML = '\ud83c\udd95'; // User requested icon\n icon.title = `Neue Version verf\u00fcgbar (${newVersion}). Klick f\u00fcr download`;\n\n headerTitle.appendChild(icon);\n showToast(`Update verf\u00fcgbar: ${newVersion}`, 'info');\n}\n\n// === Order Countdown ===\nfunction updateCountdown() {\n const now = new Date();\n const currentDay = now.getDay();\n // Skip weekends (0=Sun, 6=Sat)\n if (currentDay === 0 || currentDay === 6) {\n removeCountdown();\n return;\n }\n\n const todayStr = now.toISOString().split('T')[0];\n\n // 1. Check if we already ordered for today\n let hasOrder = false;\n // Optimization: Check orderMap for today's date\n // Keys are \"YYYY-MM-DD_ArticleID\"\n for (const key of orderMap.keys()) {\n if (key.startsWith(todayStr)) {\n hasOrder = true;\n break;\n }\n }\n\n if (hasOrder) {\n removeCountdown();\n return;\n }\n\n // 2. Calculate time to cutoff (10:00 AM)\n const cutoff = new Date();\n cutoff.setHours(10, 0, 0, 0);\n\n const diff = cutoff - now;\n\n // If passed cutoff or more than 3 hours away (e.g. 07:00), maybe don't show?\n // User req: \"heute noch keine bestellung... countdown erscheinen\"\n // Let's show it if within valid order window (e.g. 00:00 - 10:00)\n\n if (diff <= 0) {\n removeCountdown();\n return;\n }\n\n // 3. Render Countdown\n const diffHrs = Math.floor(diff / 3600000);\n const diffMins = Math.floor((diff % 3600000) / 60000);\n\n const headerCenter = document.querySelector('.header-center-wrapper');\n if (!headerCenter) return;\n\n let countdownEl = document.getElementById('order-countdown');\n if (!countdownEl) {\n countdownEl = document.createElement('div');\n countdownEl.id = 'order-countdown';\n // Insert before cost display or append\n headerCenter.insertBefore(countdownEl, headerCenter.firstChild);\n }\n\n countdownEl.innerHTML = `<span>Bestellschluss:</span> <strong>${diffHrs}h ${diffMins}m</strong>`;\n\n // Red Alert if < 1 hour\n if (diff < 3600000) { // 1 hour\n countdownEl.classList.add('urgent');\n\n // Notification logic (One time)\n const notifiedKey = `kantine_notified_${todayStr}`;\n if (!sessionStorage.getItem(notifiedKey)) {\n if (Notification.permission === 'granted') {\n new Notification('Kantine: Bestellschluss naht!', {\n body: 'Du hast heute noch nichts bestellt. Nur noch 1 Stunde!',\n icon: '\u23f3'\n });\n } else if (Notification.permission === 'default') {\n Notification.requestPermission();\n }\n sessionStorage.setItem(notifiedKey, 'true');\n }\n } else {\n countdownEl.classList.remove('urgent');\n }\n}\n\nfunction removeCountdown() {\n const el = document.getElementById('order-countdown');\n if (el) el.remove();\n}\n\n// Update countdown every minute\nsetInterval(updateCountdown, 60000);\n// Also update on load\nsetTimeout(updateCountdown, 1000);\n\n// === Helpers === \nfunction getISOWeek(date) {\n const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()));\n const dayNum = d.getUTCDay() || 7;\n d.setUTCDate(d.getUTCDate() + 4 - dayNum);\n const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));\n return Math.ceil(((d - yearStart) / 86400000 + 1) / 7);\n}\n\nfunction getWeekYear(d) {\n const date = new Date(d.getTime());\n date.setDate(date.getDate() + 3 - (date.getDay() + 6) % 7);\n return date.getFullYear();\n}\n\n\nfunction translateDay(englishDay) {\n const map = { Monday: 'Montag', Tuesday: 'Dienstag', Wednesday: 'Mittwoch', Thursday: 'Donnerstag', Friday: 'Freitag', Saturday: 'Samstag', Sunday: 'Sonntag' };\n return map[englishDay] || englishDay;\n}\n\nfunction escapeHtml(text) {\n const div = document.createElement('div');\n div.textContent = text || '';\n return div.innerHTML;\n}\n\n// === Bootstrap ===\ninjectUI();\nbindEvents();\nupdateAuthUI();\ncleanupExpiredFlags();\n\n// Load cached data first for instant UI, then refresh from API\nconst hadCache = loadMenuCache();\nif (hadCache) {\n // Hide loading spinner since cache is shown\n document.getElementById('loading').classList.add('hidden');\n}\nloadMenuDataFromAPI();\n\n// Auto-start polling if already logged in\nif (authToken) {\n startPolling();\n}\n\n// Check for updates\ncheckForUpdates();\n\nconsole.log('Kantine Wrapper loaded \u2705');\n}) ();\n\n// === Error Modal ===\nfunction showErrorModal(title, htmlContent, btnText, url) {\n const modalId = 'error-modal';\n let modal = document.getElementById(modalId);\n if (modal) modal.remove();\n\n modal = document.createElement('div');\n modal.id = modalId;\n modal.className = 'modal hidden';\n modal.innerHTML = `\n <div class=\"modal-content\">\n <div class=\"modal-header\">\n <h2 style=\"color: var(--error-color); display: flex; align-items: center; gap: 10px;\">\n <span class=\"material-icons-round\">signal_wifi_off</span>\n ${title}\n </h2>\n </div>\n <div style=\"padding: 20px;\">\n <p style=\"margin-bottom: 15px; color: var(--text-primary);\">${htmlContent}</p>\n <div style=\"margin-top: 20px; display: flex; justify-content: center;\">\n <button id=\"btn-error-redirect\" style=\"\n background-color: var(--accent-color); \n color: white; \n padding: 12px 24px; \n border-radius: 8px; \n border: none; \n font-weight: 600; \n cursor: pointer;\n display: flex;\n align-items: center;\n gap: 8px;\n width: 100%;\n justify-content: center;\n transition: transform 0.1s;\n \">\n ${btnText}\n <span class=\"material-icons-round\">open_in_new</span>\n </button>\n </div>\n </div>\n </div>\n `;\n document.body.appendChild(modal);\n\n document.getElementById('btn-error-redirect').addEventListener('click', () => {\n window.location.href = url;\n });\n\n requestAnimationFrame(() => {\n modal.classList.remove('hidden');\n });\n}\n})();"
document.getElementById('bookmarklet-link').textContent = 'Kantine v1.1.1';
</script>
</body>
</html>