diff --git a/dist/bookmarklet-payload.js b/dist/bookmarklet-payload.js
index 1c9ed67..d02f05d 100755
--- a/dist/bookmarklet-payload.js
+++ b/dist/bookmarklet-payload.js
@@ -4,6 +4,6 @@ var s=document.createElement('style');
s.textContent=':root { /* Premium Slate/Gray-Blue Palette - Light Mode */ --bg-body: #f1f5f9; /* Slate 100 */ --bg-card: #ffffff; --text-primary: #334155; /* Slate 700 */ --text-secondary: #64748b; --accent-color: #0f172a; /* Slate 900 (High contrast) */ --border-color: #cbd5e1; /* Slate 300 */ --banner-bg: #e2e8f0; --banner-text: #1e293b; --success-color: #059669; --error-color: #dc2626; --card-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.05), 0 2px 4px -2px rgb(0 0 0 / 0.05); --header-bg: rgba(255, 255, 255, 0.9); --header-border: 1px solid rgba(203, 213, 225, 0.6); } [data-theme="dark"] { /* Premium Slate/Gray-Blue Palette - Dark Mode */ --bg-body: #1e293b; /* Deep Slate Gray (Requested) */ --bg-card: #334155; /* Slate 700 */ --text-primary: #f8fafc; /* Slate 50 */ --text-secondary: #cbd5e1; /* Slate 300 */ --accent-color: #60a5fa; /* Blue 400 */ --border-color: #475569; /* Slate 600 */ --banner-bg: #475569; --banner-text: #e2e8f0; --header-bg: rgba(30, 41, 59, 0.9); --header-border: 1px solid rgba(71, 85, 105, 0.6); --card-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.4); } * { box-sizing: border-box; margin: 0; padding: 0; } body { font-family: \'Inter\', system-ui, -apple-system, sans-serif; background-color: var(--bg-body); color: var(--text-primary); transition: background-color 0.3s ease, color 0.3s ease; line-height: 1.5; -webkit-font-smoothing: antialiased; } /* Fix scrolling bug: Reset html/body styles from host page */ html, body { height: auto !important; min-height: 100% !important; overflow-y: auto !important; overflow-x: hidden !important; position: static !important; margin: 0 !important; padding: 0 !important; } /* Header */ .app-header { position: sticky; top: 0; z-index: 100; backdrop-filter: blur(12px); background-color: var(--header-bg); border-bottom: var(--header-border); padding: 1rem 0; } .header-content { width: 100%; /* Full width */ padding: 0 2rem; /* Comfortable padding */ display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; gap: 1rem; } .brand { display: flex; align-items: center; gap: 0.75rem; } .brand-text { display: flex; flex-direction: column; } .brand h1 { font-size: 1.25rem; font-weight: 700; letter-spacing: -0.025em; margin-bottom: 0; } .subtitle { font-size: 0.85rem; color: var(--text-secondary); font-weight: 400; margin-left: 2px; } .logo-icon { font-size: 1.5rem; color: var(--accent-color); } /* Controls */ .controls { display: flex; align-items: center; gap: 1.5rem; justify-self: end; } /* Header Week Info (centered) */ .header-week-info { text-align: center; line-height: 1.3; } .header-center-wrapper { display: flex; flex-direction: row; align-items: center; gap: 1.5rem; justify-content: center; } .weekly-cost { white-space: nowrap; font-size: 0.9rem; font-weight: 600; color: var(--success-color); background-color: var(--bg-body); padding: 0.25rem 0.75rem; border-radius: 20px; box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05); border: 1px solid var(--border-color); } .header-week-title { font-size: 1.1rem; font-weight: 600; color: var(--text-primary); } .header-week-subtitle { font-size: 0.85rem; color: var(--text-secondary); } .nav-group { display: flex; background-color: var(--bg-card); border: 1px solid var(--border-color); padding: 0.25rem; border-radius: 8px; } .nav-btn { background: none; border: none; padding: 0.5rem 1rem; font-size: 0.875rem; font-weight: 500; color: var(--text-secondary); cursor: pointer; border-radius: 6px; transition: all 0.2s; display: flex; align-items: center; gap: 0.5rem; } .nav-btn:hover { color: var(--text-primary); background-color: rgba(100, 116, 139, 0.1); } .nav-btn.active { background-color: var(--accent-color); color: white; } /* Badge for nav buttons (day count indicator) */ .nav-badge { background-color: var(--error-color); color: white; font-size: 0.75rem; font-weight: 600; padding: 0 6px; border-radius: 10px; min-width: 18px; height: 18px; display: inline-flex; align-items: center; justify-content: center; margin-left: 8px; gap: 3px; line-height: 1; } .nav-badge .orderable { color: #fff; font-weight: 800; } .nav-badge .separator { opacity: 0.6; font-weight: 400; } .nav-badge .total { opacity: 0.8; font-weight: 400; } .nav-btn.active .nav-badge { background: rgba(255, 255, 255, 0.3); } /* Primary style for Login Button to match header */ #btn-login-open { background-color: var(--accent-color); color: white; padding: 0.5rem 1.25rem; border-radius: 8px; font-weight: 600; letter-spacing: 0.025em; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); } #btn-login-open:hover { background-color: #334155; /* Slightly lighter than slate-900 */ transform: translateY(-1px); box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); } /* User Badge Button (Login) */ .user-badge-btn { display: flex; align-items: center; gap: 8px; padding: 6px 12px; background: var(--bg-card); border: 1px solid var(--border-color); border-radius: 20px; font-size: 0.9rem; font-weight: 500; color: var(--text-primary); cursor: pointer; transition: all 0.2s; } .user-badge-btn:hover { background: rgba(100, 116, 139, 0.1); border-color: var(--accent-color); } .user-badge-btn .material-icons-round { font-size: 1.25rem; color: var(--accent-color); } .icon-btn { background: none; border: none; color: var(--text-primary); cursor: pointer; padding: 0.5rem; border-radius: 50%; transition: background-color 0.2s; display: flex; align-items: center; justify-content: center; } .icon-btn:hover { background-color: rgba(100, 116, 139, 0.1); } /* Refresh button animation */ #btn-refresh.refreshing .material-icons-round { animation: rotate 1s linear infinite; } @keyframes rotate { from { transform: rotate(0deg); } to { transform: rotate(360deg); } } /* Progress Modal */ .progress-container { margin-bottom: 1.5rem; } .progress-bar { width: 100%; height: 8px; background-color: var(--border-color); border-radius: 4px; overflow: hidden; margin-bottom: 0.75rem; } .progress-fill { height: 100%; background: linear-gradient(90deg, var(--accent-color) 0%, #60a5fa 100%); width: 0%; transition: width 0.3s ease; border-radius: 4px; } .progress-percent { text-align: center; font-size: 1.5rem; font-weight: 700; color: var(--text-primary); margin-bottom: 0.5rem; } .progress-message { text-align: center; color: var(--text-secondary); font-size: 0.9rem; font-weight: 500; } .weekly-cost { background-color: rgba(59, 130, 246, 0.1); /* Blue tint */ color: var(--accent-color); padding: 0.4rem 0.8rem; border-radius: 8px; font-weight: 600; font-size: 0.9rem; display: flex; align-items: center; gap: 0.5rem; border: 1px solid rgba(59, 130, 246, 0.2); } .weekly-cost .material-icons-round { font-size: 18px; } /* Container */ .container { width: 100%; /* Full width */ margin: 2rem auto; padding: 0 2rem; min-height: 80vh; } /* Banner */ .banner { background-color: var(--banner-bg); color: var(--banner-text); padding: 0.75rem 1rem; border-radius: 8px; display: flex; align-items: center; gap: 0.5rem; margin-bottom: 2rem; font-size: 0.875rem; font-weight: 500; border: 1px solid var(--border-color); max-width: fit-content; } /* User Badge */ .user-badge { display: flex; align-items: center; gap: 8px; padding: 6px 12px; background: var(--bg-card); /* Changed from --surface */ border: 1px solid var(--border-color); /* Changed from --border */ border-radius: 20px; font-size: 0.9rem; font-weight: 500; } .icon-btn-small { background: none; border: none; padding: 4px; cursor: pointer; color: var(--text-secondary); /* Changed from --text-muted */ display: flex; align-items: center; justify-content: center; border-radius: 50%; transition: all 0.2s; } .icon-btn-small:hover { color: var(--error-color); /* Changed from --danger */ background: rgba(239, 68, 68, 0.1); } /* Modal */ .modal { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0, 0, 0, 0.5); backdrop-filter: blur(4px); display: flex; align-items: center; justify-content: center; z-index: 1000; transition: all 0.3s; } .modal.hidden { opacity: 0; pointer-events: none; } .modal-content { background: var(--bg-card); /* Changed from --surface */ width: 90%; max-width: 400px; border-radius: 16px; box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04); overflow: hidden; animation: modalSlide 0.3s ease-out; } @keyframes modalSlide { from { transform: translateY(20px); opacity: 0; } to { transform: translateY(0); opacity: 1; } } .modal-header { display: flex; align-items: center; justify-content: space-between; padding: 20px; border-bottom: 1px solid var(--border-color); /* Changed from --border */ } .modal-header h2 { margin: 0; font-size: 1.25rem; } #login-form { padding: 20px; } .form-group { margin-bottom: 20px; } .form-group label { display: block; margin-bottom: 6px; font-weight: 500; font-size: 0.9rem; } .form-group input { width: 100%; padding: 10px 12px; border: 1px solid var(--border-color); /* Changed from --border */ border-radius: 8px; background: var(--bg-body); /* Changed from --bg */ color: var(--text-primary); /* Changed from --text */ font-family: inherit; transition: border-color 0.2s; } .form-group input:focus { outline: none; border-color: var(--accent-color); /* Changed from --primary */ } .help-text { display: block; margin-top: 4px; color: var(--text-secondary); /* Changed from --text-muted */ font-size: 0.75rem; } .error-msg { margin-bottom: 16px; padding: 10px; background: rgba(239, 68, 68, 0.1); color: var(--error-color); /* Changed from --danger */ border-radius: 8px; font-size: 0.85rem; text-align: center; } .modal-actions { margin-top: 24px; } .btn-primary.wide { width: 100%; justify-content: center; } .hidden { display: none !important; } /* Menu Grid */ .menu-grid { display: grid; gap: 2rem; } .week-section { margin-bottom: 3rem; } .week-header { margin-bottom: 1.5rem; border-bottom: 1px solid var(--border-color); padding-bottom: 1rem; text-align: center; } .week-title { font-size: 1.75rem; font-weight: 700; color: var(--text-primary); } .week-range { color: var(--text-secondary); font-size: 0.9rem; margin-top: 0.25rem; } .days-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 0.75rem; } /* Card */ .menu-card { background-color: var(--bg-card); border-radius: 12px; border: 1px solid var(--border-color); box-shadow: var(--card-shadow); overflow: hidden; transition: transform 0.2s ease, box-shadow 0.2s ease; display: flex; flex-direction: column; } /* Past Day Styling - Target specific elements so ordered items can remain visible */ .menu-card.past-day .card-header, .menu-card.past-day .menu-item:not(.ordered) { opacity: 0.6; filter: grayscale(0.8); transition: opacity 0.3s, filter 0.3s; } .menu-card.past-day:hover .card-header, .menu-card.past-day:hover .menu-item:not(.ordered) { opacity: 0.8; filter: grayscale(0.4); } /* Enhancements for ordered items */ .menu-card.past-day .menu-item.ordered { /* No opacity/filter here - fully visible */ background: var(--bg-card); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); border: 1px solid var(--accent-color); border-radius: 8px; padding: 1rem; margin: 0 -1rem 1.5rem -1rem; position: relative; z-index: 10; } .menu-item.today-ordered { border: 2px solid var(--accent-color); box-shadow: 0 0 20px rgba(96, 165, 250, 0.4); border-radius: 8px; padding: 1rem; margin: 0 -1rem 1.5rem -1rem; background: var(--bg-card); position: relative; z-index: 5; animation: pulse-glow 3s infinite; } @keyframes pulse-glow { 0% { box-shadow: 0 0 15px rgba(96, 165, 250, 0.3); } 50% { box-shadow: 0 0 25px rgba(96, 165, 250, 0.6); } 100% { box-shadow: 0 0 15px rgba(96, 165, 250, 0.3); } } .menu-card:hover { transform: translateY(-2px); box-shadow: 0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1); } .card-header { padding: 1rem 1.25rem; border-bottom: 1px solid var(--border-color); display: flex; justify-content: space-between; align-items: baseline; background-color: rgba(100, 116, 139, 0.05); } .day-name { font-size: 1.125rem; font-weight: 600; } .day-date { font-size: 0.875rem; color: var(--text-secondary); } .card-body { padding: 1.25rem; display: grid; grid-template-rows: auto; /* Each menu item gets its own row */ align-content: start; } .empty-state { color: var(--text-secondary); font-style: italic; text-align: center; padding: 1rem; } /* Menu Items */ .menu-item { margin-bottom: 1.5rem; padding-bottom: 1.5rem; border-bottom: 1px solid var(--border-color); } .menu-item:last-child { margin-bottom: 0; padding-bottom: 0; border-bottom: none; } .item-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 0.5rem; gap: 1rem; } .item-name { font-weight: 600; color: var(--text-primary); font-size: 1rem; } .item-price { font-weight: 700; color: var(--accent-color); white-space: nowrap; } .item-desc { font-size: 0.875rem; color: var(--text-secondary); line-height: 1.6; margin-bottom: 0.75rem; } .badges { display: flex; gap: 0.5rem; margin-left: auto; } .item-status-row { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.75rem; } .badge { display: inline-flex; align-items: center; justify-content: center; height: 24px; font-size: 0.75rem; padding: 0 10px; border-radius: 4px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; line-height: normal; white-space: nowrap; } .badge.available { background-color: rgba(16, 185, 129, 0.1); /* Emerald 500 / 10% */ color: var(--success-color); border: 1px solid rgba(16, 185, 129, 0.2); } .badge.sold-out { background-color: rgba(239, 68, 68, 0.1); /* Red 500 / 10% */ color: var(--error-color); border: 1px solid rgba(239, 68, 68, 0.2); } .badge.ordered { background-color: rgba(139, 92, 246, 0.1); /* Violet 500 / 10% */ color: #8b5cf6; border: 1px solid rgba(139, 92, 246, 0.2); gap: 4px; } .badge.ordered .material-icons-round { font-size: 1rem; } /* Loading */ .loading-state { text-align: center; padding: 4rem; color: var(--text-secondary); } .spinner { width: 40px; height: 40px; border: 3px solid var(--border-color); border-top-color: var(--accent-color); border-radius: 50%; margin: 0 auto 1rem; animation: spin 1s linear infinite; } @keyframes spin { to { transform: rotate(360deg); } } /* Footer */ .app-footer { text-align: center; padding: 2rem; color: var(--text-secondary); font-size: 0.875rem; border-top: 1px solid var(--border-color); margin-top: auto; } /* === Order / Cancel Buttons (inline in status row) === */ .btn-order { display: inline-flex; align-items: center; gap: 4px; padding: 4px 10px; border: none; border-radius: 6px; background: var(--success-color); color: white; font-size: 0.75rem; font-weight: 600; cursor: pointer; transition: all 0.2s ease; font-family: inherit; } .btn-order .material-icons-round { font-size: 16px; } .btn-order:hover:not(:disabled) { filter: brightness(1.15); transform: translateY(-1px); } .btn-order:disabled { opacity: 0.5; cursor: not-allowed; } .btn-order.loading { pointer-events: none; opacity: 0.6; } .btn-order-compact { padding: 2px 4px; gap: 0; } .btn-order-compact .material-icons-round { font-size: 16px; } .btn-cancel { display: inline-flex; align-items: center; justify-content: center; padding: 4px 6px; border: none; border-radius: 6px; background: var(--error-color); color: white; font-size: 0.75rem; cursor: pointer; transition: all 0.2s ease; font-family: inherit; } .btn-cancel .material-icons-round { font-size: 16px; } .btn-cancel:hover:not(:disabled) { filter: brightness(1.15); transform: translateY(-1px); } .btn-cancel:disabled { opacity: 0.5; cursor: not-allowed; } /* Past days: hide action buttons */ .past-day .item-actions { display: none; } /* Order count badge (for multi-orders) */ .order-count-badge { display: inline-flex; align-items: center; justify-content: center; background: rgba(255, 255, 255, 0.3); color: white; font-size: 0.65rem; font-weight: 700; min-width: 16px; height: 16px; padding: 0 4px; border-radius: 8px; margin-left: 4px; line-height: 1; } /* === Toast Notifications === */ #toast-container { position: fixed; bottom: 20px; right: 20px; z-index: 10000; display: flex; flex-direction: column; gap: 8px; pointer-events: none; } .toast { display: flex; align-items: center; gap: 8px; padding: 10px 16px; border-radius: 8px; font-size: 0.85rem; font-weight: 500; font-family: \'Inter\', sans-serif; color: white; backdrop-filter: blur(10px); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); pointer-events: auto; transform: translateX(120%); opacity: 0; transition: transform 0.3s ease, opacity 0.3s ease; } .toast.show { transform: translateX(0); opacity: 1; } .toast .material-icons-round { font-size: 18px; } .toast-success { background: rgba(5, 150, 105, 0.95); } .toast-error { background: rgba(220, 38, 38, 0.95); } .toast-info { background: rgba(59, 130, 246, 0.95); } /* === Mobile Responsiveness === */ @media (max-width: 600px) { .header-content { flex-direction: column; gap: 1rem; padding: 0.75rem; } .week-nav { width: 100%; justify-content: center; } .nav-pills { width: 100%; justify-content: space-between; } .nav-btn { flex: 1; justify-content: center; padding: 0.5rem; font-size: 0.85rem; } .days-grid { grid-template-columns: 1fr; /* Force single column */ } .main-content { padding: 1rem; } .week-title { font-size: 1.5rem; } /* Adjust toast position for mobile */ .toast-container { bottom: 1rem; right: 1rem; left: 1rem; /* Center on mobile */ width: auto; } .menu-card { margin-bottom: 1rem; } } /* === Flagging & Notification Styles === */ .btn-flag { display: inline-flex; align-items: center; justify-content: center; background: transparent; border: 1px solid var(--text-secondary); color: var(--text-secondary); border-radius: 6px; padding: 4px; cursor: pointer; transition: all 0.2s; margin-right: 0.5rem; width: 28px; height: 28px; } .btn-flag:hover { background: rgba(234, 179, 8, 0.1); /* Yellow-500 / 10% */ color: #eab308; border-color: #eab308; } .btn-flag.active { background: rgba(234, 179, 8, 0.1); color: #eab308; border-color: #eab308; } .btn-flag .material-icons-round { font-size: 1.1rem; } /* Flagged & Sold Out (Yellow Glow) */ .menu-item.flagged-sold-out { border: 1px solid #eab308; box-shadow: 0 0 10px rgba(234, 179, 8, 0.2); border-radius: 8px; padding: 1rem; margin: 0 -1rem 1.5rem -1rem; background: var(--bg-card); position: relative; z-index: 5; animation: yellow-pulse 3s infinite; } @keyframes yellow-pulse { 0% { box-shadow: 0 0 8px rgba(234, 179, 8, 0.2); } 50% { box-shadow: 0 0 16px rgba(234, 179, 8, 0.5); } 100% { box-shadow: 0 0 8px rgba(234, 179, 8, 0.2); } } /* Flagged & Available (Green Glow) */ .menu-item.flagged-available { border: 2px solid var(--success-color); box-shadow: 0 0 15px rgba(16, 185, 129, 0.3); border-radius: 8px; padding: 1rem; margin: 0 -1rem 1.5rem -1rem; background: var(--bg-card); position: relative; z-index: 5; animation: green-pulse 3s infinite; } @keyframes green-pulse { 0% { box-shadow: 0 0 10px rgba(16, 185, 129, 0.3); } 50% { box-shadow: 0 0 20px rgba(16, 185, 129, 0.6); } 100% { box-shadow: 0 0 10px rgba(16, 185, 129, 0.3); } } /* Day Header Badges */ .day-header-left { display: flex; align-items: center; gap: 0.75rem; } .menu-code-badge { font-size: 0.75rem; font-weight: 700; color: #8b5cf6; /* Violet 500 */ background-color: rgba(139, 92, 246, 0.15); border: 1px solid rgba(139, 92, 246, 0.3); padding: 2px 6px; border-radius: 6px; line-height: normal; display: inline-block; } /* Detailed Badge Colors */ .nav-badge.badge-violet { background-color: #8b5cf6; } .nav-badge.badge-green { background-color: var(--success-color); } .nav-badge.badge-red { background-color: var(--error-color); } .nav-badge.badge-blue { background-color: var(--accent-color); } ';
document.head.appendChild(s);
var sc=document.createElement('script');
-sc.textContent="/**\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
\n \n\n
\n\n
\n
\n \n
\n
\n
Initialisierung...
\n
\n
\n
\n\n
\n \n update\n Gerade aktualisiert\n
\n \n
\n
Lade Men\u00fcdaten...
\n
\n \n \n\n \n
`;\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 // 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 break; // One refresh is enough\n }\n }\n } catch (err) {\n console.error(`Poll error for ${flagId}:`, err);\n }\n\n // Small delay between checks\n await new Promise(r => setTimeout(r, 200));\n }\n }\n\n // === Local Menu Cache (localStorage) ===\n const CACHE_KEY = 'kantine_menuCache';\n const CACHE_TS_KEY = 'kantine_menuCacheTs';\n\n function 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\n function 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) ===\n async 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.
${error.message}`,\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 ===\n function 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 ===\n function 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 = `${icon}${message}`;\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 ===\n function 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 = `${daysWithOrders}/${orderableCount}/${totalDataCount}`;\n\n // Color Logic\n badge.classList.remove('badge-violet', 'badge-green', 'badge-red', 'badge-blue');\n\n if (daysWithOrders === totalDataCount && totalDataCount > 0) {\n badge.classList.add('badge-violet'); // All days ordered\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\n } else {\n badge.classList.add('badge-blue'); // Default / partial state\n }\n\n } else if (badge) {\n badge.remove();\n }\n }\n\n // === Weekly Cost ===\n function 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 = `shopping_bag Gesamt: ${totalCost.toFixed(2).replace('.', ',')} \u20ac`;\n costDisplay.classList.remove('hidden');\n } else {\n costDisplay.classList.add('hidden');\n }\n }\n\n // === Render Weeks ===\n function 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 \n
Keine Men\u00fcdaten f\u00fcr KW ${targetWeek} (${targetYear}) verf\u00fcgbar.
\n
Versuchen Sie eine andere Woche oder schauen Sie sp\u00e4ter vorbei.\n
`;\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 \n `;\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 ===\n function 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 ===\n function 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 => ``).join('');\n\n header.innerHTML = `\n \n ${dateStr}`;\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 ? `Verf\u00fcgbar (${item.availableAmount})`\n : `Verf\u00fcgbar`;\n } else {\n statusBadge = `Ausverkauft`;\n }\n\n // Order badge\n let orderedBadge = '';\n if (orderCount > 0) {\n const countBadge = orderCount > 1 ? `${orderCount}` : '';\n orderedBadge = `check_circle Bestellt${countBadge}`;\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 = ``;\n }\n\n // Order button\n if (item.available) {\n if (orderCount > 0) {\n orderButton = ``;\n } else {\n orderButton = ``;\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 = ``;\n }\n }\n\n itemEl.innerHTML = `\n \n \n ${orderedBadge}\n ${cancelButton}\n ${orderButton}\n ${flagButton}\n
${statusBadge}
\n
\n ${escapeHtml(item.description)}
`;\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 // === Helpers ===\n function 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\n function 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 function 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\n function escapeHtml(text) {\n const div = document.createElement('div');\n div.textContent = text || '';\n return div.innerHTML;\n }\n\n // === Bootstrap ===\n injectUI();\n bindEvents();\n updateAuthUI();\n cleanupExpiredFlags();\n\n // Load cached data first for instant UI, then refresh from API\n const hadCache = loadMenuCache();\n if (hadCache) {\n // Hide loading spinner since cache is shown\n document.getElementById('loading').classList.add('hidden');\n }\n loadMenuDataFromAPI();\n\n // Auto-start polling if already logged in\n if (authToken) {\n startPolling();\n }\n\n console.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 \n \n
\n
${htmlContent}
\n
\n \n
\n
\n
\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";
+sc.textContent="/**\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 \n \n\n
\n\n
\n
\n \n
\n
\n
Initialisierung...
\n
\n
\n
\n\n
\n \n update\n Gerade aktualisiert\n
\n \n
\n
Lade Men\u00fcdaten...
\n
\n \n \n\n \n
`;\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 // 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 break; // One refresh is enough\n }\n }\n } catch (err) {\n console.error(`Poll error for ${flagId}:`, err);\n }\n\n // Small delay between checks\n await new Promise(r => setTimeout(r, 200));\n }\n }\n\n // === Local Menu Cache (localStorage) ===\n const CACHE_KEY = 'kantine_menuCache';\n const CACHE_TS_KEY = 'kantine_menuCacheTs';\n\n function 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\n function 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) ===\n async 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.
${error.message}`,\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 ===\n function 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 ===\n function 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 = `${icon}${message}`;\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 ===\n function 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 = `${daysWithOrders}/${orderableCount}/${totalDataCount}`;\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 } else if (badge) {\n badge.remove();\n }\n }\n\n // === Weekly Cost ===\n function 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 = `shopping_bag Gesamt: ${totalCost.toFixed(2).replace('.', ',')} \u20ac`;\n costDisplay.classList.remove('hidden');\n } else {\n costDisplay.classList.add('hidden');\n }\n }\n\n // === Render Weeks ===\n function 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 \n
Keine Men\u00fcdaten f\u00fcr KW ${targetWeek} (${targetYear}) verf\u00fcgbar.
\n
Versuchen Sie eine andere Woche oder schauen Sie sp\u00e4ter vorbei.\n
`;\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 \n `;\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 ===\n function 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 ===\n function 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 => ``).join('');\n\n header.innerHTML = `\n \n ${dateStr}`;\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 ? `Verf\u00fcgbar (${item.availableAmount})`\n : `Verf\u00fcgbar`;\n } else {\n statusBadge = `Ausverkauft`;\n }\n\n // Order badge\n let orderedBadge = '';\n if (orderCount > 0) {\n const countBadge = orderCount > 1 ? `${orderCount}` : '';\n orderedBadge = `check_circle Bestellt${countBadge}`;\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 = ``;\n }\n\n // Order button\n if (item.available) {\n if (orderCount > 0) {\n orderButton = ``;\n } else {\n orderButton = ``;\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 = ``;\n }\n }\n\n itemEl.innerHTML = `\n \n \n ${orderedBadge}\n ${cancelButton}\n ${orderButton}\n ${flagButton}\n
${statusBadge}
\n
\n ${escapeHtml(item.description)}
`;\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 // === Helpers ===\n function 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\n function 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 function 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\n function escapeHtml(text) {\n const div = document.createElement('div');\n div.textContent = text || '';\n return div.innerHTML;\n }\n\n // === Bootstrap ===\n injectUI();\n bindEvents();\n updateAuthUI();\n cleanupExpiredFlags();\n\n // Load cached data first for instant UI, then refresh from API\n const hadCache = loadMenuCache();\n if (hadCache) {\n // Hide loading spinner since cache is shown\n document.getElementById('loading').classList.add('hidden');\n }\n loadMenuDataFromAPI();\n\n // Auto-start polling if already logged in\n if (authToken) {\n startPolling();\n }\n\n console.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 \n \n
\n
${htmlContent}
\n
\n \n
\n
\n
\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.head.appendChild(sc);
})();
diff --git a/dist/bookmarklet.txt b/dist/bookmarklet.txt
index 0f12790..c53a2de 100755
--- a/dist/bookmarklet.txt
+++ b/dist/bookmarklet.txt
@@ -1 +1 @@
-javascript:(function(){ if(window.__KANTINE_LOADED){alert('Kantine Wrapper already loaded!');return;} var s=document.createElement('style'); s.textContent=':root { /* Premium Slate/Gray-Blue Palette - Light Mode */ --bg-body: #f1f5f9; /* Slate 100 */ --bg-card: #ffffff; --text-primary: #334155; /* Slate 700 */ --text-secondary: #64748b; --accent-color: #0f172a; /* Slate 900 (High contrast) */ --border-color: #cbd5e1; /* Slate 300 */ --banner-bg: #e2e8f0; --banner-text: #1e293b; --success-color: #059669; --error-color: #dc2626; --card-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.05), 0 2px 4px -2px rgb(0 0 0 / 0.05); --header-bg: rgba(255, 255, 255, 0.9); --header-border: 1px solid rgba(203, 213, 225, 0.6); } [data-theme="dark"] { /* Premium Slate/Gray-Blue Palette - Dark Mode */ --bg-body: #1e293b; /* Deep Slate Gray (Requested) */ --bg-card: #334155; /* Slate 700 */ --text-primary: #f8fafc; /* Slate 50 */ --text-secondary: #cbd5e1; /* Slate 300 */ --accent-color: #60a5fa; /* Blue 400 */ --border-color: #475569; /* Slate 600 */ --banner-bg: #475569; --banner-text: #e2e8f0; --header-bg: rgba(30, 41, 59, 0.9); --header-border: 1px solid rgba(71, 85, 105, 0.6); --card-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.4); } * { box-sizing: border-box; margin: 0; padding: 0; } body { font-family: \'Inter\', system-ui, -apple-system, sans-serif; background-color: var(--bg-body); color: var(--text-primary); transition: background-color 0.3s ease, color 0.3s ease; line-height: 1.5; -webkit-font-smoothing: antialiased; } /* Fix scrolling bug: Reset html/body styles from host page */ html, body { height: auto !important; min-height: 100% !important; overflow-y: auto !important; overflow-x: hidden !important; position: static !important; margin: 0 !important; padding: 0 !important; } /* Header */ .app-header { position: sticky; top: 0; z-index: 100; backdrop-filter: blur(12px); background-color: var(--header-bg); border-bottom: var(--header-border); padding: 1rem 0; } .header-content { width: 100%; /* Full width */ padding: 0 2rem; /* Comfortable padding */ display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; gap: 1rem; } .brand { display: flex; align-items: center; gap: 0.75rem; } .brand-text { display: flex; flex-direction: column; } .brand h1 { font-size: 1.25rem; font-weight: 700; letter-spacing: -0.025em; margin-bottom: 0; } .subtitle { font-size: 0.85rem; color: var(--text-secondary); font-weight: 400; margin-left: 2px; } .logo-icon { font-size: 1.5rem; color: var(--accent-color); } /* Controls */ .controls { display: flex; align-items: center; gap: 1.5rem; justify-self: end; } /* Header Week Info (centered) */ .header-week-info { text-align: center; line-height: 1.3; } .header-center-wrapper { display: flex; flex-direction: row; align-items: center; gap: 1.5rem; justify-content: center; } .weekly-cost { white-space: nowrap; font-size: 0.9rem; font-weight: 600; color: var(--success-color); background-color: var(--bg-body); padding: 0.25rem 0.75rem; border-radius: 20px; box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05); border: 1px solid var(--border-color); } .header-week-title { font-size: 1.1rem; font-weight: 600; color: var(--text-primary); } .header-week-subtitle { font-size: 0.85rem; color: var(--text-secondary); } .nav-group { display: flex; background-color: var(--bg-card); border: 1px solid var(--border-color); padding: 0.25rem; border-radius: 8px; } .nav-btn { background: none; border: none; padding: 0.5rem 1rem; font-size: 0.875rem; font-weight: 500; color: var(--text-secondary); cursor: pointer; border-radius: 6px; transition: all 0.2s; display: flex; align-items: center; gap: 0.5rem; } .nav-btn:hover { color: var(--text-primary); background-color: rgba(100, 116, 139, 0.1); } .nav-btn.active { background-color: var(--accent-color); color: white; } /* Badge for nav buttons (day count indicator) */ .nav-badge { background-color: var(--error-color); color: white; font-size: 0.75rem; font-weight: 600; padding: 0 6px; border-radius: 10px; min-width: 18px; height: 18px; display: inline-flex; align-items: center; justify-content: center; margin-left: 8px; gap: 3px; line-height: 1; } .nav-badge .orderable { color: #fff; font-weight: 800; } .nav-badge .separator { opacity: 0.6; font-weight: 400; } .nav-badge .total { opacity: 0.8; font-weight: 400; } .nav-btn.active .nav-badge { background: rgba(255, 255, 255, 0.3); } /* Primary style for Login Button to match header */ #btn-login-open { background-color: var(--accent-color); color: white; padding: 0.5rem 1.25rem; border-radius: 8px; font-weight: 600; letter-spacing: 0.025em; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); } #btn-login-open:hover { background-color: #334155; /* Slightly lighter than slate-900 */ transform: translateY(-1px); box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); } /* User Badge Button (Login) */ .user-badge-btn { display: flex; align-items: center; gap: 8px; padding: 6px 12px; background: var(--bg-card); border: 1px solid var(--border-color); border-radius: 20px; font-size: 0.9rem; font-weight: 500; color: var(--text-primary); cursor: pointer; transition: all 0.2s; } .user-badge-btn:hover { background: rgba(100, 116, 139, 0.1); border-color: var(--accent-color); } .user-badge-btn .material-icons-round { font-size: 1.25rem; color: var(--accent-color); } .icon-btn { background: none; border: none; color: var(--text-primary); cursor: pointer; padding: 0.5rem; border-radius: 50%; transition: background-color 0.2s; display: flex; align-items: center; justify-content: center; } .icon-btn:hover { background-color: rgba(100, 116, 139, 0.1); } /* Refresh button animation */ #btn-refresh.refreshing .material-icons-round { animation: rotate 1s linear infinite; } @keyframes rotate { from { transform: rotate(0deg); } to { transform: rotate(360deg); } } /* Progress Modal */ .progress-container { margin-bottom: 1.5rem; } .progress-bar { width: 100%; height: 8px; background-color: var(--border-color); border-radius: 4px; overflow: hidden; margin-bottom: 0.75rem; } .progress-fill { height: 100%; background: linear-gradient(90deg, var(--accent-color) 0%, #60a5fa 100%); width: 0%; transition: width 0.3s ease; border-radius: 4px; } .progress-percent { text-align: center; font-size: 1.5rem; font-weight: 700; color: var(--text-primary); margin-bottom: 0.5rem; } .progress-message { text-align: center; color: var(--text-secondary); font-size: 0.9rem; font-weight: 500; } .weekly-cost { background-color: rgba(59, 130, 246, 0.1); /* Blue tint */ color: var(--accent-color); padding: 0.4rem 0.8rem; border-radius: 8px; font-weight: 600; font-size: 0.9rem; display: flex; align-items: center; gap: 0.5rem; border: 1px solid rgba(59, 130, 246, 0.2); } .weekly-cost .material-icons-round { font-size: 18px; } /* Container */ .container { width: 100%; /* Full width */ margin: 2rem auto; padding: 0 2rem; min-height: 80vh; } /* Banner */ .banner { background-color: var(--banner-bg); color: var(--banner-text); padding: 0.75rem 1rem; border-radius: 8px; display: flex; align-items: center; gap: 0.5rem; margin-bottom: 2rem; font-size: 0.875rem; font-weight: 500; border: 1px solid var(--border-color); max-width: fit-content; } /* User Badge */ .user-badge { display: flex; align-items: center; gap: 8px; padding: 6px 12px; background: var(--bg-card); /* Changed from --surface */ border: 1px solid var(--border-color); /* Changed from --border */ border-radius: 20px; font-size: 0.9rem; font-weight: 500; } .icon-btn-small { background: none; border: none; padding: 4px; cursor: pointer; color: var(--text-secondary); /* Changed from --text-muted */ display: flex; align-items: center; justify-content: center; border-radius: 50%; transition: all 0.2s; } .icon-btn-small:hover { color: var(--error-color); /* Changed from --danger */ background: rgba(239, 68, 68, 0.1); } /* Modal */ .modal { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0, 0, 0, 0.5); backdrop-filter: blur(4px); display: flex; align-items: center; justify-content: center; z-index: 1000; transition: all 0.3s; } .modal.hidden { opacity: 0; pointer-events: none; } .modal-content { background: var(--bg-card); /* Changed from --surface */ width: 90%; max-width: 400px; border-radius: 16px; box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04); overflow: hidden; animation: modalSlide 0.3s ease-out; } @keyframes modalSlide { from { transform: translateY(20px); opacity: 0; } to { transform: translateY(0); opacity: 1; } } .modal-header { display: flex; align-items: center; justify-content: space-between; padding: 20px; border-bottom: 1px solid var(--border-color); /* Changed from --border */ } .modal-header h2 { margin: 0; font-size: 1.25rem; } #login-form { padding: 20px; } .form-group { margin-bottom: 20px; } .form-group label { display: block; margin-bottom: 6px; font-weight: 500; font-size: 0.9rem; } .form-group input { width: 100%; padding: 10px 12px; border: 1px solid var(--border-color); /* Changed from --border */ border-radius: 8px; background: var(--bg-body); /* Changed from --bg */ color: var(--text-primary); /* Changed from --text */ font-family: inherit; transition: border-color 0.2s; } .form-group input:focus { outline: none; border-color: var(--accent-color); /* Changed from --primary */ } .help-text { display: block; margin-top: 4px; color: var(--text-secondary); /* Changed from --text-muted */ font-size: 0.75rem; } .error-msg { margin-bottom: 16px; padding: 10px; background: rgba(239, 68, 68, 0.1); color: var(--error-color); /* Changed from --danger */ border-radius: 8px; font-size: 0.85rem; text-align: center; } .modal-actions { margin-top: 24px; } .btn-primary.wide { width: 100%; justify-content: center; } .hidden { display: none !important; } /* Menu Grid */ .menu-grid { display: grid; gap: 2rem; } .week-section { margin-bottom: 3rem; } .week-header { margin-bottom: 1.5rem; border-bottom: 1px solid var(--border-color); padding-bottom: 1rem; text-align: center; } .week-title { font-size: 1.75rem; font-weight: 700; color: var(--text-primary); } .week-range { color: var(--text-secondary); font-size: 0.9rem; margin-top: 0.25rem; } .days-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 0.75rem; } /* Card */ .menu-card { background-color: var(--bg-card); border-radius: 12px; border: 1px solid var(--border-color); box-shadow: var(--card-shadow); overflow: hidden; transition: transform 0.2s ease, box-shadow 0.2s ease; display: flex; flex-direction: column; } /* Past Day Styling - Target specific elements so ordered items can remain visible */ .menu-card.past-day .card-header, .menu-card.past-day .menu-item:not(.ordered) { opacity: 0.6; filter: grayscale(0.8); transition: opacity 0.3s, filter 0.3s; } .menu-card.past-day:hover .card-header, .menu-card.past-day:hover .menu-item:not(.ordered) { opacity: 0.8; filter: grayscale(0.4); } /* Enhancements for ordered items */ .menu-card.past-day .menu-item.ordered { /* No opacity/filter here - fully visible */ background: var(--bg-card); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); border: 1px solid var(--accent-color); border-radius: 8px; padding: 1rem; margin: 0 -1rem 1.5rem -1rem; position: relative; z-index: 10; } .menu-item.today-ordered { border: 2px solid var(--accent-color); box-shadow: 0 0 20px rgba(96, 165, 250, 0.4); border-radius: 8px; padding: 1rem; margin: 0 -1rem 1.5rem -1rem; background: var(--bg-card); position: relative; z-index: 5; animation: pulse-glow 3s infinite; } @keyframes pulse-glow { 0% { box-shadow: 0 0 15px rgba(96, 165, 250, 0.3); } 50% { box-shadow: 0 0 25px rgba(96, 165, 250, 0.6); } 100% { box-shadow: 0 0 15px rgba(96, 165, 250, 0.3); } } .menu-card:hover { transform: translateY(-2px); box-shadow: 0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1); } .card-header { padding: 1rem 1.25rem; border-bottom: 1px solid var(--border-color); display: flex; justify-content: space-between; align-items: baseline; background-color: rgba(100, 116, 139, 0.05); } .day-name { font-size: 1.125rem; font-weight: 600; } .day-date { font-size: 0.875rem; color: var(--text-secondary); } .card-body { padding: 1.25rem; display: grid; grid-template-rows: auto; /* Each menu item gets its own row */ align-content: start; } .empty-state { color: var(--text-secondary); font-style: italic; text-align: center; padding: 1rem; } /* Menu Items */ .menu-item { margin-bottom: 1.5rem; padding-bottom: 1.5rem; border-bottom: 1px solid var(--border-color); } .menu-item:last-child { margin-bottom: 0; padding-bottom: 0; border-bottom: none; } .item-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 0.5rem; gap: 1rem; } .item-name { font-weight: 600; color: var(--text-primary); font-size: 1rem; } .item-price { font-weight: 700; color: var(--accent-color); white-space: nowrap; } .item-desc { font-size: 0.875rem; color: var(--text-secondary); line-height: 1.6; margin-bottom: 0.75rem; } .badges { display: flex; gap: 0.5rem; margin-left: auto; } .item-status-row { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.75rem; } .badge { display: inline-flex; align-items: center; justify-content: center; height: 24px; font-size: 0.75rem; padding: 0 10px; border-radius: 4px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; line-height: normal; white-space: nowrap; } .badge.available { background-color: rgba(16, 185, 129, 0.1); /* Emerald 500 / 10% */ color: var(--success-color); border: 1px solid rgba(16, 185, 129, 0.2); } .badge.sold-out { background-color: rgba(239, 68, 68, 0.1); /* Red 500 / 10% */ color: var(--error-color); border: 1px solid rgba(239, 68, 68, 0.2); } .badge.ordered { background-color: rgba(139, 92, 246, 0.1); /* Violet 500 / 10% */ color: #8b5cf6; border: 1px solid rgba(139, 92, 246, 0.2); gap: 4px; } .badge.ordered .material-icons-round { font-size: 1rem; } /* Loading */ .loading-state { text-align: center; padding: 4rem; color: var(--text-secondary); } .spinner { width: 40px; height: 40px; border: 3px solid var(--border-color); border-top-color: var(--accent-color); border-radius: 50%; margin: 0 auto 1rem; animation: spin 1s linear infinite; } @keyframes spin { to { transform: rotate(360deg); } } /* Footer */ .app-footer { text-align: center; padding: 2rem; color: var(--text-secondary); font-size: 0.875rem; border-top: 1px solid var(--border-color); margin-top: auto; } /* === Order / Cancel Buttons (inline in status row) === */ .btn-order { display: inline-flex; align-items: center; gap: 4px; padding: 4px 10px; border: none; border-radius: 6px; background: var(--success-color); color: white; font-size: 0.75rem; font-weight: 600; cursor: pointer; transition: all 0.2s ease; font-family: inherit; } .btn-order .material-icons-round { font-size: 16px; } .btn-order:hover:not(:disabled) { filter: brightness(1.15); transform: translateY(-1px); } .btn-order:disabled { opacity: 0.5; cursor: not-allowed; } .btn-order.loading { pointer-events: none; opacity: 0.6; } .btn-order-compact { padding: 2px 4px; gap: 0; } .btn-order-compact .material-icons-round { font-size: 16px; } .btn-cancel { display: inline-flex; align-items: center; justify-content: center; padding: 4px 6px; border: none; border-radius: 6px; background: var(--error-color); color: white; font-size: 0.75rem; cursor: pointer; transition: all 0.2s ease; font-family: inherit; } .btn-cancel .material-icons-round { font-size: 16px; } .btn-cancel:hover:not(:disabled) { filter: brightness(1.15); transform: translateY(-1px); } .btn-cancel:disabled { opacity: 0.5; cursor: not-allowed; } /* Past days: hide action buttons */ .past-day .item-actions { display: none; } /* Order count badge (for multi-orders) */ .order-count-badge { display: inline-flex; align-items: center; justify-content: center; background: rgba(255, 255, 255, 0.3); color: white; font-size: 0.65rem; font-weight: 700; min-width: 16px; height: 16px; padding: 0 4px; border-radius: 8px; margin-left: 4px; line-height: 1; } /* === Toast Notifications === */ #toast-container { position: fixed; bottom: 20px; right: 20px; z-index: 10000; display: flex; flex-direction: column; gap: 8px; pointer-events: none; } .toast { display: flex; align-items: center; gap: 8px; padding: 10px 16px; border-radius: 8px; font-size: 0.85rem; font-weight: 500; font-family: \'Inter\', sans-serif; color: white; backdrop-filter: blur(10px); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); pointer-events: auto; transform: translateX(120%); opacity: 0; transition: transform 0.3s ease, opacity 0.3s ease; } .toast.show { transform: translateX(0); opacity: 1; } .toast .material-icons-round { font-size: 18px; } .toast-success { background: rgba(5, 150, 105, 0.95); } .toast-error { background: rgba(220, 38, 38, 0.95); } .toast-info { background: rgba(59, 130, 246, 0.95); } /* === Mobile Responsiveness === */ @media (max-width: 600px) { .header-content { flex-direction: column; gap: 1rem; padding: 0.75rem; } .week-nav { width: 100%; justify-content: center; } .nav-pills { width: 100%; justify-content: space-between; } .nav-btn { flex: 1; justify-content: center; padding: 0.5rem; font-size: 0.85rem; } .days-grid { grid-template-columns: 1fr; /* Force single column */ } .main-content { padding: 1rem; } .week-title { font-size: 1.5rem; } /* Adjust toast position for mobile */ .toast-container { bottom: 1rem; right: 1rem; left: 1rem; /* Center on mobile */ width: auto; } .menu-card { margin-bottom: 1rem; } } /* === Flagging & Notification Styles === */ .btn-flag { display: inline-flex; align-items: center; justify-content: center; background: transparent; border: 1px solid var(--text-secondary); color: var(--text-secondary); border-radius: 6px; padding: 4px; cursor: pointer; transition: all 0.2s; margin-right: 0.5rem; width: 28px; height: 28px; } .btn-flag:hover { background: rgba(234, 179, 8, 0.1); /* Yellow-500 / 10% */ color: #eab308; border-color: #eab308; } .btn-flag.active { background: rgba(234, 179, 8, 0.1); color: #eab308; border-color: #eab308; } .btn-flag .material-icons-round { font-size: 1.1rem; } /* Flagged & Sold Out (Yellow Glow) */ .menu-item.flagged-sold-out { border: 1px solid #eab308; box-shadow: 0 0 10px rgba(234, 179, 8, 0.2); border-radius: 8px; padding: 1rem; margin: 0 -1rem 1.5rem -1rem; background: var(--bg-card); position: relative; z-index: 5; animation: yellow-pulse 3s infinite; } @keyframes yellow-pulse { 0% { box-shadow: 0 0 8px rgba(234, 179, 8, 0.2); } 50% { box-shadow: 0 0 16px rgba(234, 179, 8, 0.5); } 100% { box-shadow: 0 0 8px rgba(234, 179, 8, 0.2); } } /* Flagged & Available (Green Glow) */ .menu-item.flagged-available { border: 2px solid var(--success-color); box-shadow: 0 0 15px rgba(16, 185, 129, 0.3); border-radius: 8px; padding: 1rem; margin: 0 -1rem 1.5rem -1rem; background: var(--bg-card); position: relative; z-index: 5; animation: green-pulse 3s infinite; } @keyframes green-pulse { 0% { box-shadow: 0 0 10px rgba(16, 185, 129, 0.3); } 50% { box-shadow: 0 0 20px rgba(16, 185, 129, 0.6); } 100% { box-shadow: 0 0 10px rgba(16, 185, 129, 0.3); } } /* Day Header Badges */ .day-header-left { display: flex; align-items: center; gap: 0.75rem; } .menu-code-badge { font-size: 0.75rem; font-weight: 700; color: #8b5cf6; /* Violet 500 */ background-color: rgba(139, 92, 246, 0.15); border: 1px solid rgba(139, 92, 246, 0.3); padding: 2px 6px; border-radius: 6px; line-height: normal; display: inline-block; } /* Detailed Badge Colors */ .nav-badge.badge-violet { background-color: #8b5cf6; } .nav-badge.badge-green { background-color: var(--success-color); } .nav-badge.badge-red { background-color: var(--error-color); } .nav-badge.badge-blue { background-color: var(--accent-color); } '; document.head.appendChild(s); var sc=document.createElement('script'); sc.textContent="/**\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 \n \n\n
\n\n
\n
\n \n
\n
\n
Initialisierung...
\n
\n
\n
\n\n
\n \n update\n Gerade aktualisiert\n
\n \n
\n
Lade Men\u00fcdaten...
\n
\n \n \n\n \n
`;\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 // 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 break; // One refresh is enough\n }\n }\n } catch (err) {\n console.error(`Poll error for ${flagId}:`, err);\n }\n\n // Small delay between checks\n await new Promise(r => setTimeout(r, 200));\n }\n }\n\n // === Local Menu Cache (localStorage) ===\n const CACHE_KEY = 'kantine_menuCache';\n const CACHE_TS_KEY = 'kantine_menuCacheTs';\n\n function 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\n function 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) ===\n async 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.
${error.message}`,\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 ===\n function 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 ===\n function 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 = `${icon}${message}`;\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 ===\n function 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 = `${daysWithOrders}/${orderableCount}/${totalDataCount}`;\n\n // Color Logic\n badge.classList.remove('badge-violet', 'badge-green', 'badge-red', 'badge-blue');\n\n if (daysWithOrders === totalDataCount && totalDataCount > 0) {\n badge.classList.add('badge-violet'); // All days ordered\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\n } else {\n badge.classList.add('badge-blue'); // Default / partial state\n }\n\n } else if (badge) {\n badge.remove();\n }\n }\n\n // === Weekly Cost ===\n function 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 = `shopping_bag Gesamt: ${totalCost.toFixed(2).replace('.', ',')} \u20ac`;\n costDisplay.classList.remove('hidden');\n } else {\n costDisplay.classList.add('hidden');\n }\n }\n\n // === Render Weeks ===\n function 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 \n
Keine Men\u00fcdaten f\u00fcr KW ${targetWeek} (${targetYear}) verf\u00fcgbar.
\n
Versuchen Sie eine andere Woche oder schauen Sie sp\u00e4ter vorbei.\n
`;\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 \n `;\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 ===\n function 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 ===\n function 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 => ``).join('');\n\n header.innerHTML = `\n \n ${dateStr}`;\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 ? `Verf\u00fcgbar (${item.availableAmount})`\n : `Verf\u00fcgbar`;\n } else {\n statusBadge = `Ausverkauft`;\n }\n\n // Order badge\n let orderedBadge = '';\n if (orderCount > 0) {\n const countBadge = orderCount > 1 ? `${orderCount}` : '';\n orderedBadge = `check_circle Bestellt${countBadge}`;\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 = ``;\n }\n\n // Order button\n if (item.available) {\n if (orderCount > 0) {\n orderButton = ``;\n } else {\n orderButton = ``;\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 = ``;\n }\n }\n\n itemEl.innerHTML = `\n \n \n ${orderedBadge}\n ${cancelButton}\n ${orderButton}\n ${flagButton}\n
${statusBadge}
\n
\n ${escapeHtml(item.description)}
`;\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 // === Helpers ===\n function 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\n function 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 function 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\n function escapeHtml(text) {\n const div = document.createElement('div');\n div.textContent = text || '';\n return div.innerHTML;\n }\n\n // === Bootstrap ===\n injectUI();\n bindEvents();\n updateAuthUI();\n cleanupExpiredFlags();\n\n // Load cached data first for instant UI, then refresh from API\n const hadCache = loadMenuCache();\n if (hadCache) {\n // Hide loading spinner since cache is shown\n document.getElementById('loading').classList.add('hidden');\n }\n loadMenuDataFromAPI();\n\n // Auto-start polling if already logged in\n if (authToken) {\n startPolling();\n }\n\n console.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 \n \n
\n
${htmlContent}
\n
\n \n
\n
\n
\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.head.appendChild(sc); })();
+javascript:(function(){ if(window.__KANTINE_LOADED){alert('Kantine Wrapper already loaded!');return;} var s=document.createElement('style'); s.textContent=':root { /* Premium Slate/Gray-Blue Palette - Light Mode */ --bg-body: #f1f5f9; /* Slate 100 */ --bg-card: #ffffff; --text-primary: #334155; /* Slate 700 */ --text-secondary: #64748b; --accent-color: #0f172a; /* Slate 900 (High contrast) */ --border-color: #cbd5e1; /* Slate 300 */ --banner-bg: #e2e8f0; --banner-text: #1e293b; --success-color: #059669; --error-color: #dc2626; --card-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.05), 0 2px 4px -2px rgb(0 0 0 / 0.05); --header-bg: rgba(255, 255, 255, 0.9); --header-border: 1px solid rgba(203, 213, 225, 0.6); } [data-theme="dark"] { /* Premium Slate/Gray-Blue Palette - Dark Mode */ --bg-body: #1e293b; /* Deep Slate Gray (Requested) */ --bg-card: #334155; /* Slate 700 */ --text-primary: #f8fafc; /* Slate 50 */ --text-secondary: #cbd5e1; /* Slate 300 */ --accent-color: #60a5fa; /* Blue 400 */ --border-color: #475569; /* Slate 600 */ --banner-bg: #475569; --banner-text: #e2e8f0; --header-bg: rgba(30, 41, 59, 0.9); --header-border: 1px solid rgba(71, 85, 105, 0.6); --card-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.4); } * { box-sizing: border-box; margin: 0; padding: 0; } body { font-family: \'Inter\', system-ui, -apple-system, sans-serif; background-color: var(--bg-body); color: var(--text-primary); transition: background-color 0.3s ease, color 0.3s ease; line-height: 1.5; -webkit-font-smoothing: antialiased; } /* Fix scrolling bug: Reset html/body styles from host page */ html, body { height: auto !important; min-height: 100% !important; overflow-y: auto !important; overflow-x: hidden !important; position: static !important; margin: 0 !important; padding: 0 !important; } /* Header */ .app-header { position: sticky; top: 0; z-index: 100; backdrop-filter: blur(12px); background-color: var(--header-bg); border-bottom: var(--header-border); padding: 1rem 0; } .header-content { width: 100%; /* Full width */ padding: 0 2rem; /* Comfortable padding */ display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; gap: 1rem; } .brand { display: flex; align-items: center; gap: 0.75rem; } .brand-text { display: flex; flex-direction: column; } .brand h1 { font-size: 1.25rem; font-weight: 700; letter-spacing: -0.025em; margin-bottom: 0; } .subtitle { font-size: 0.85rem; color: var(--text-secondary); font-weight: 400; margin-left: 2px; } .logo-icon { font-size: 1.5rem; color: var(--accent-color); } /* Controls */ .controls { display: flex; align-items: center; gap: 1.5rem; justify-self: end; } /* Header Week Info (centered) */ .header-week-info { text-align: center; line-height: 1.3; } .header-center-wrapper { display: flex; flex-direction: row; align-items: center; gap: 1.5rem; justify-content: center; } .weekly-cost { white-space: nowrap; font-size: 0.9rem; font-weight: 600; color: var(--success-color); background-color: var(--bg-body); padding: 0.25rem 0.75rem; border-radius: 20px; box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05); border: 1px solid var(--border-color); } .header-week-title { font-size: 1.1rem; font-weight: 600; color: var(--text-primary); } .header-week-subtitle { font-size: 0.85rem; color: var(--text-secondary); } .nav-group { display: flex; background-color: var(--bg-card); border: 1px solid var(--border-color); padding: 0.25rem; border-radius: 8px; } .nav-btn { background: none; border: none; padding: 0.5rem 1rem; font-size: 0.875rem; font-weight: 500; color: var(--text-secondary); cursor: pointer; border-radius: 6px; transition: all 0.2s; display: flex; align-items: center; gap: 0.5rem; } .nav-btn:hover { color: var(--text-primary); background-color: rgba(100, 116, 139, 0.1); } .nav-btn.active { background-color: var(--accent-color); color: white; } /* Badge for nav buttons (day count indicator) */ .nav-badge { background-color: var(--error-color); color: white; font-size: 0.75rem; font-weight: 600; padding: 0 6px; border-radius: 10px; min-width: 18px; height: 18px; display: inline-flex; align-items: center; justify-content: center; margin-left: 8px; gap: 3px; line-height: 1; } .nav-badge .orderable { color: #fff; font-weight: 800; } .nav-badge .separator { opacity: 0.6; font-weight: 400; } .nav-badge .total { opacity: 0.8; font-weight: 400; } .nav-btn.active .nav-badge { background: rgba(255, 255, 255, 0.3); } /* Primary style for Login Button to match header */ #btn-login-open { background-color: var(--accent-color); color: white; padding: 0.5rem 1.25rem; border-radius: 8px; font-weight: 600; letter-spacing: 0.025em; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); } #btn-login-open:hover { background-color: #334155; /* Slightly lighter than slate-900 */ transform: translateY(-1px); box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); } /* User Badge Button (Login) */ .user-badge-btn { display: flex; align-items: center; gap: 8px; padding: 6px 12px; background: var(--bg-card); border: 1px solid var(--border-color); border-radius: 20px; font-size: 0.9rem; font-weight: 500; color: var(--text-primary); cursor: pointer; transition: all 0.2s; } .user-badge-btn:hover { background: rgba(100, 116, 139, 0.1); border-color: var(--accent-color); } .user-badge-btn .material-icons-round { font-size: 1.25rem; color: var(--accent-color); } .icon-btn { background: none; border: none; color: var(--text-primary); cursor: pointer; padding: 0.5rem; border-radius: 50%; transition: background-color 0.2s; display: flex; align-items: center; justify-content: center; } .icon-btn:hover { background-color: rgba(100, 116, 139, 0.1); } /* Refresh button animation */ #btn-refresh.refreshing .material-icons-round { animation: rotate 1s linear infinite; } @keyframes rotate { from { transform: rotate(0deg); } to { transform: rotate(360deg); } } /* Progress Modal */ .progress-container { margin-bottom: 1.5rem; } .progress-bar { width: 100%; height: 8px; background-color: var(--border-color); border-radius: 4px; overflow: hidden; margin-bottom: 0.75rem; } .progress-fill { height: 100%; background: linear-gradient(90deg, var(--accent-color) 0%, #60a5fa 100%); width: 0%; transition: width 0.3s ease; border-radius: 4px; } .progress-percent { text-align: center; font-size: 1.5rem; font-weight: 700; color: var(--text-primary); margin-bottom: 0.5rem; } .progress-message { text-align: center; color: var(--text-secondary); font-size: 0.9rem; font-weight: 500; } .weekly-cost { background-color: rgba(59, 130, 246, 0.1); /* Blue tint */ color: var(--accent-color); padding: 0.4rem 0.8rem; border-radius: 8px; font-weight: 600; font-size: 0.9rem; display: flex; align-items: center; gap: 0.5rem; border: 1px solid rgba(59, 130, 246, 0.2); } .weekly-cost .material-icons-round { font-size: 18px; } /* Container */ .container { width: 100%; /* Full width */ margin: 2rem auto; padding: 0 2rem; min-height: 80vh; } /* Banner */ .banner { background-color: var(--banner-bg); color: var(--banner-text); padding: 0.75rem 1rem; border-radius: 8px; display: flex; align-items: center; gap: 0.5rem; margin-bottom: 2rem; font-size: 0.875rem; font-weight: 500; border: 1px solid var(--border-color); max-width: fit-content; } /* User Badge */ .user-badge { display: flex; align-items: center; gap: 8px; padding: 6px 12px; background: var(--bg-card); /* Changed from --surface */ border: 1px solid var(--border-color); /* Changed from --border */ border-radius: 20px; font-size: 0.9rem; font-weight: 500; } .icon-btn-small { background: none; border: none; padding: 4px; cursor: pointer; color: var(--text-secondary); /* Changed from --text-muted */ display: flex; align-items: center; justify-content: center; border-radius: 50%; transition: all 0.2s; } .icon-btn-small:hover { color: var(--error-color); /* Changed from --danger */ background: rgba(239, 68, 68, 0.1); } /* Modal */ .modal { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0, 0, 0, 0.5); backdrop-filter: blur(4px); display: flex; align-items: center; justify-content: center; z-index: 1000; transition: all 0.3s; } .modal.hidden { opacity: 0; pointer-events: none; } .modal-content { background: var(--bg-card); /* Changed from --surface */ width: 90%; max-width: 400px; border-radius: 16px; box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04); overflow: hidden; animation: modalSlide 0.3s ease-out; } @keyframes modalSlide { from { transform: translateY(20px); opacity: 0; } to { transform: translateY(0); opacity: 1; } } .modal-header { display: flex; align-items: center; justify-content: space-between; padding: 20px; border-bottom: 1px solid var(--border-color); /* Changed from --border */ } .modal-header h2 { margin: 0; font-size: 1.25rem; } #login-form { padding: 20px; } .form-group { margin-bottom: 20px; } .form-group label { display: block; margin-bottom: 6px; font-weight: 500; font-size: 0.9rem; } .form-group input { width: 100%; padding: 10px 12px; border: 1px solid var(--border-color); /* Changed from --border */ border-radius: 8px; background: var(--bg-body); /* Changed from --bg */ color: var(--text-primary); /* Changed from --text */ font-family: inherit; transition: border-color 0.2s; } .form-group input:focus { outline: none; border-color: var(--accent-color); /* Changed from --primary */ } .help-text { display: block; margin-top: 4px; color: var(--text-secondary); /* Changed from --text-muted */ font-size: 0.75rem; } .error-msg { margin-bottom: 16px; padding: 10px; background: rgba(239, 68, 68, 0.1); color: var(--error-color); /* Changed from --danger */ border-radius: 8px; font-size: 0.85rem; text-align: center; } .modal-actions { margin-top: 24px; } .btn-primary.wide { width: 100%; justify-content: center; } .hidden { display: none !important; } /* Menu Grid */ .menu-grid { display: grid; gap: 2rem; } .week-section { margin-bottom: 3rem; } .week-header { margin-bottom: 1.5rem; border-bottom: 1px solid var(--border-color); padding-bottom: 1rem; text-align: center; } .week-title { font-size: 1.75rem; font-weight: 700; color: var(--text-primary); } .week-range { color: var(--text-secondary); font-size: 0.9rem; margin-top: 0.25rem; } .days-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 0.75rem; } /* Card */ .menu-card { background-color: var(--bg-card); border-radius: 12px; border: 1px solid var(--border-color); box-shadow: var(--card-shadow); overflow: hidden; transition: transform 0.2s ease, box-shadow 0.2s ease; display: flex; flex-direction: column; } /* Past Day Styling - Target specific elements so ordered items can remain visible */ .menu-card.past-day .card-header, .menu-card.past-day .menu-item:not(.ordered) { opacity: 0.6; filter: grayscale(0.8); transition: opacity 0.3s, filter 0.3s; } .menu-card.past-day:hover .card-header, .menu-card.past-day:hover .menu-item:not(.ordered) { opacity: 0.8; filter: grayscale(0.4); } /* Enhancements for ordered items */ .menu-card.past-day .menu-item.ordered { /* No opacity/filter here - fully visible */ background: var(--bg-card); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); border: 1px solid var(--accent-color); border-radius: 8px; padding: 1rem; margin: 0 -1rem 1.5rem -1rem; position: relative; z-index: 10; } .menu-item.today-ordered { border: 2px solid var(--accent-color); box-shadow: 0 0 20px rgba(96, 165, 250, 0.4); border-radius: 8px; padding: 1rem; margin: 0 -1rem 1.5rem -1rem; background: var(--bg-card); position: relative; z-index: 5; animation: pulse-glow 3s infinite; } @keyframes pulse-glow { 0% { box-shadow: 0 0 15px rgba(96, 165, 250, 0.3); } 50% { box-shadow: 0 0 25px rgba(96, 165, 250, 0.6); } 100% { box-shadow: 0 0 15px rgba(96, 165, 250, 0.3); } } .menu-card:hover { transform: translateY(-2px); box-shadow: 0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1); } .card-header { padding: 1rem 1.25rem; border-bottom: 1px solid var(--border-color); display: flex; justify-content: space-between; align-items: baseline; background-color: rgba(100, 116, 139, 0.05); } .day-name { font-size: 1.125rem; font-weight: 600; } .day-date { font-size: 0.875rem; color: var(--text-secondary); } .card-body { padding: 1.25rem; display: grid; grid-template-rows: auto; /* Each menu item gets its own row */ align-content: start; } .empty-state { color: var(--text-secondary); font-style: italic; text-align: center; padding: 1rem; } /* Menu Items */ .menu-item { margin-bottom: 1.5rem; padding-bottom: 1.5rem; border-bottom: 1px solid var(--border-color); } .menu-item:last-child { margin-bottom: 0; padding-bottom: 0; border-bottom: none; } .item-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 0.5rem; gap: 1rem; } .item-name { font-weight: 600; color: var(--text-primary); font-size: 1rem; } .item-price { font-weight: 700; color: var(--accent-color); white-space: nowrap; } .item-desc { font-size: 0.875rem; color: var(--text-secondary); line-height: 1.6; margin-bottom: 0.75rem; } .badges { display: flex; gap: 0.5rem; margin-left: auto; } .item-status-row { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.75rem; } .badge { display: inline-flex; align-items: center; justify-content: center; height: 24px; font-size: 0.75rem; padding: 0 10px; border-radius: 4px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; line-height: normal; white-space: nowrap; } .badge.available { background-color: rgba(16, 185, 129, 0.1); /* Emerald 500 / 10% */ color: var(--success-color); border: 1px solid rgba(16, 185, 129, 0.2); } .badge.sold-out { background-color: rgba(239, 68, 68, 0.1); /* Red 500 / 10% */ color: var(--error-color); border: 1px solid rgba(239, 68, 68, 0.2); } .badge.ordered { background-color: rgba(139, 92, 246, 0.1); /* Violet 500 / 10% */ color: #8b5cf6; border: 1px solid rgba(139, 92, 246, 0.2); gap: 4px; } .badge.ordered .material-icons-round { font-size: 1rem; } /* Loading */ .loading-state { text-align: center; padding: 4rem; color: var(--text-secondary); } .spinner { width: 40px; height: 40px; border: 3px solid var(--border-color); border-top-color: var(--accent-color); border-radius: 50%; margin: 0 auto 1rem; animation: spin 1s linear infinite; } @keyframes spin { to { transform: rotate(360deg); } } /* Footer */ .app-footer { text-align: center; padding: 2rem; color: var(--text-secondary); font-size: 0.875rem; border-top: 1px solid var(--border-color); margin-top: auto; } /* === Order / Cancel Buttons (inline in status row) === */ .btn-order { display: inline-flex; align-items: center; gap: 4px; padding: 4px 10px; border: none; border-radius: 6px; background: var(--success-color); color: white; font-size: 0.75rem; font-weight: 600; cursor: pointer; transition: all 0.2s ease; font-family: inherit; } .btn-order .material-icons-round { font-size: 16px; } .btn-order:hover:not(:disabled) { filter: brightness(1.15); transform: translateY(-1px); } .btn-order:disabled { opacity: 0.5; cursor: not-allowed; } .btn-order.loading { pointer-events: none; opacity: 0.6; } .btn-order-compact { padding: 2px 4px; gap: 0; } .btn-order-compact .material-icons-round { font-size: 16px; } .btn-cancel { display: inline-flex; align-items: center; justify-content: center; padding: 4px 6px; border: none; border-radius: 6px; background: var(--error-color); color: white; font-size: 0.75rem; cursor: pointer; transition: all 0.2s ease; font-family: inherit; } .btn-cancel .material-icons-round { font-size: 16px; } .btn-cancel:hover:not(:disabled) { filter: brightness(1.15); transform: translateY(-1px); } .btn-cancel:disabled { opacity: 0.5; cursor: not-allowed; } /* Past days: hide action buttons */ .past-day .item-actions { display: none; } /* Order count badge (for multi-orders) */ .order-count-badge { display: inline-flex; align-items: center; justify-content: center; background: rgba(255, 255, 255, 0.3); color: white; font-size: 0.65rem; font-weight: 700; min-width: 16px; height: 16px; padding: 0 4px; border-radius: 8px; margin-left: 4px; line-height: 1; } /* === Toast Notifications === */ #toast-container { position: fixed; bottom: 20px; right: 20px; z-index: 10000; display: flex; flex-direction: column; gap: 8px; pointer-events: none; } .toast { display: flex; align-items: center; gap: 8px; padding: 10px 16px; border-radius: 8px; font-size: 0.85rem; font-weight: 500; font-family: \'Inter\', sans-serif; color: white; backdrop-filter: blur(10px); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); pointer-events: auto; transform: translateX(120%); opacity: 0; transition: transform 0.3s ease, opacity 0.3s ease; } .toast.show { transform: translateX(0); opacity: 1; } .toast .material-icons-round { font-size: 18px; } .toast-success { background: rgba(5, 150, 105, 0.95); } .toast-error { background: rgba(220, 38, 38, 0.95); } .toast-info { background: rgba(59, 130, 246, 0.95); } /* === Mobile Responsiveness === */ @media (max-width: 600px) { .header-content { flex-direction: column; gap: 1rem; padding: 0.75rem; } .week-nav { width: 100%; justify-content: center; } .nav-pills { width: 100%; justify-content: space-between; } .nav-btn { flex: 1; justify-content: center; padding: 0.5rem; font-size: 0.85rem; } .days-grid { grid-template-columns: 1fr; /* Force single column */ } .main-content { padding: 1rem; } .week-title { font-size: 1.5rem; } /* Adjust toast position for mobile */ .toast-container { bottom: 1rem; right: 1rem; left: 1rem; /* Center on mobile */ width: auto; } .menu-card { margin-bottom: 1rem; } } /* === Flagging & Notification Styles === */ .btn-flag { display: inline-flex; align-items: center; justify-content: center; background: transparent; border: 1px solid var(--text-secondary); color: var(--text-secondary); border-radius: 6px; padding: 4px; cursor: pointer; transition: all 0.2s; margin-right: 0.5rem; width: 28px; height: 28px; } .btn-flag:hover { background: rgba(234, 179, 8, 0.1); /* Yellow-500 / 10% */ color: #eab308; border-color: #eab308; } .btn-flag.active { background: rgba(234, 179, 8, 0.1); color: #eab308; border-color: #eab308; } .btn-flag .material-icons-round { font-size: 1.1rem; } /* Flagged & Sold Out (Yellow Glow) */ .menu-item.flagged-sold-out { border: 1px solid #eab308; box-shadow: 0 0 10px rgba(234, 179, 8, 0.2); border-radius: 8px; padding: 1rem; margin: 0 -1rem 1.5rem -1rem; background: var(--bg-card); position: relative; z-index: 5; animation: yellow-pulse 3s infinite; } @keyframes yellow-pulse { 0% { box-shadow: 0 0 8px rgba(234, 179, 8, 0.2); } 50% { box-shadow: 0 0 16px rgba(234, 179, 8, 0.5); } 100% { box-shadow: 0 0 8px rgba(234, 179, 8, 0.2); } } /* Flagged & Available (Green Glow) */ .menu-item.flagged-available { border: 2px solid var(--success-color); box-shadow: 0 0 15px rgba(16, 185, 129, 0.3); border-radius: 8px; padding: 1rem; margin: 0 -1rem 1.5rem -1rem; background: var(--bg-card); position: relative; z-index: 5; animation: green-pulse 3s infinite; } @keyframes green-pulse { 0% { box-shadow: 0 0 10px rgba(16, 185, 129, 0.3); } 50% { box-shadow: 0 0 20px rgba(16, 185, 129, 0.6); } 100% { box-shadow: 0 0 10px rgba(16, 185, 129, 0.3); } } /* Day Header Badges */ .day-header-left { display: flex; align-items: center; gap: 0.75rem; } .menu-code-badge { font-size: 0.75rem; font-weight: 700; color: #8b5cf6; /* Violet 500 */ background-color: rgba(139, 92, 246, 0.15); border: 1px solid rgba(139, 92, 246, 0.3); padding: 2px 6px; border-radius: 6px; line-height: normal; display: inline-block; } /* Detailed Badge Colors */ .nav-badge.badge-violet { background-color: #8b5cf6; } .nav-badge.badge-green { background-color: var(--success-color); } .nav-badge.badge-red { background-color: var(--error-color); } .nav-badge.badge-blue { background-color: var(--accent-color); } '; document.head.appendChild(s); var sc=document.createElement('script'); sc.textContent="/**\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 \n \n\n
\n\n
\n
\n \n
\n
\n
Initialisierung...
\n
\n
\n
\n\n
\n \n update\n Gerade aktualisiert\n
\n \n
\n
Lade Men\u00fcdaten...
\n
\n \n \n\n \n
`;\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 // 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 break; // One refresh is enough\n }\n }\n } catch (err) {\n console.error(`Poll error for ${flagId}:`, err);\n }\n\n // Small delay between checks\n await new Promise(r => setTimeout(r, 200));\n }\n }\n\n // === Local Menu Cache (localStorage) ===\n const CACHE_KEY = 'kantine_menuCache';\n const CACHE_TS_KEY = 'kantine_menuCacheTs';\n\n function 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\n function 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) ===\n async 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.
${error.message}`,\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 ===\n function 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 ===\n function 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 = `${icon}${message}`;\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 ===\n function 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 = `${daysWithOrders}/${orderableCount}/${totalDataCount}`;\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 } else if (badge) {\n badge.remove();\n }\n }\n\n // === Weekly Cost ===\n function 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 = `shopping_bag Gesamt: ${totalCost.toFixed(2).replace('.', ',')} \u20ac`;\n costDisplay.classList.remove('hidden');\n } else {\n costDisplay.classList.add('hidden');\n }\n }\n\n // === Render Weeks ===\n function 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 \n
Keine Men\u00fcdaten f\u00fcr KW ${targetWeek} (${targetYear}) verf\u00fcgbar.
\n
Versuchen Sie eine andere Woche oder schauen Sie sp\u00e4ter vorbei.\n
`;\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 \n `;\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 ===\n function 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 ===\n function 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 => ``).join('');\n\n header.innerHTML = `\n \n ${dateStr}`;\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 ? `Verf\u00fcgbar (${item.availableAmount})`\n : `Verf\u00fcgbar`;\n } else {\n statusBadge = `Ausverkauft`;\n }\n\n // Order badge\n let orderedBadge = '';\n if (orderCount > 0) {\n const countBadge = orderCount > 1 ? `${orderCount}` : '';\n orderedBadge = `check_circle Bestellt${countBadge}`;\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 = ``;\n }\n\n // Order button\n if (item.available) {\n if (orderCount > 0) {\n orderButton = ``;\n } else {\n orderButton = ``;\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 = ``;\n }\n }\n\n itemEl.innerHTML = `\n \n \n ${orderedBadge}\n ${cancelButton}\n ${orderButton}\n ${flagButton}\n
${statusBadge}
\n
\n ${escapeHtml(item.description)}
`;\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 // === Helpers ===\n function 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\n function 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 function 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\n function escapeHtml(text) {\n const div = document.createElement('div');\n div.textContent = text || '';\n return div.innerHTML;\n }\n\n // === Bootstrap ===\n injectUI();\n bindEvents();\n updateAuthUI();\n cleanupExpiredFlags();\n\n // Load cached data first for instant UI, then refresh from API\n const hadCache = loadMenuCache();\n if (hadCache) {\n // Hide loading spinner since cache is shown\n document.getElementById('loading').classList.add('hidden');\n }\n loadMenuDataFromAPI();\n\n // Auto-start polling if already logged in\n if (authToken) {\n startPolling();\n }\n\n console.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 \n \n
\n
${htmlContent}
\n
\n \n
\n
\n
\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.head.appendChild(sc); })();
diff --git a/dist/install.html b/dist/install.html
index afe6ef7..f648423 100755
--- a/dist/install.html
+++ b/dist/install.html
@@ -27,7 +27,7 @@
⏳ Wird generiert...