From c58b54faf657a4a690d3f769c1d78f756968e86a Mon Sep 17 00:00:00 2001 From: Michael Kaufmann Date: Fri, 13 Feb 2026 11:20:13 +0100 Subject: [PATCH] chore: bump version to v1.0.0 for initial release --- build-bookmarklet.sh | 2 +- dist/bookmarklet-payload.js | 2 +- dist/bookmarklet.txt | 2 +- dist/install.html | 10 +++++----- dist/kantine-standalone.html | 2 +- 5 files changed, 9 insertions(+), 9 deletions(-) diff --git a/build-bookmarklet.sh b/build-bookmarklet.sh index 03a880c..99f49d9 100755 --- a/build-bookmarklet.sh +++ b/build-bookmarklet.sh @@ -9,7 +9,7 @@ CSS_FILE="$SCRIPT_DIR/style.css" JS_FILE="$SCRIPT_DIR/kantine.js" # === VERSION === -VERSION="v1.8.6" +VERSION="v1.0.0" mkdir -p "$DIST_DIR" diff --git a/dist/bookmarklet-payload.js b/dist/bookmarklet-payload.js index 14278e3..769ebb8 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); } /* Day Header Status Colors (User Request) */ .card-header.header-violet { background-color: rgba(139, 92, 246, 0.15); border-bottom: 2px solid #8b5cf6; } .card-header.header-green { background-color: rgba(16, 185, 129, 0.15); border-bottom: 2px solid var(--success-color); } .card-header.header-red { background-color: rgba(239, 68, 68, 0.15); border-bottom: 2px solid var(--error-color); } .card-header.header-violet .day-name, .card-header.header-green .day-name, .card-header.header-red .day-name { font-weight: 700; color: var(--text-primary); /* Ensure text remains standard 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 restaurant_menu\n
\n

Kantinen \u00dcbersicht v1.8.6

\n
\n
\n
\n
\n
\n
\n
\n
\n \n
\n \n \n
\n \n \n
\n person\n \n \n
\n
\n
\n
\n\n
\n
\n
\n

Login

\n \n
\n
\n
\n \n \n Deine offizielle Knapp Mitarbeiternummer.\n
\n
\n \n \n Das Passwort f\u00fcr deinen Bessa Account.\n
\n
\n
\n \n
\n
\n
\n
\n\n
\n
\n
\n

Men\u00fcdaten aktualisieren

\n
\n
\n
\n
\n
\n
\n
0%
\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

Bessa Knapp-Kantine Wrapper • ${new Date().getFullYear()}

\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
${weekTitle}
\n
Week ${targetWeek} \u2022 ${targetYear}
`;\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 => `${code}`).join('');\n\n // Determine Day Status for Header Color\n // Violet: Has Order\n // Green: No Order but Orderable\n // Red: No Order and Not Orderable (Locked/Sold Out)\n let headerClass = '';\n const hasAnyOrder = day.items && day.items.some(item => {\n const articleId = item.articleId || parseInt(item.id.split('_')[1]);\n const key = `${day.date}_${articleId}`;\n return orderMap.has(key) && orderMap.get(key).length > 0;\n });\n\n const hasOrderable = day.items && day.items.some(item => {\n // Use pre-calculated available flag from loadMenuDataFromAPI calculation\n return item.available;\n });\n\n if (hasAnyOrder) {\n headerClass = 'header-violet';\n } else if (hasOrderable && !isPastCutoff) {\n headerClass = 'header-green';\n } else {\n // Red if not orderable (or past cutoff)\n headerClass = 'header-red';\n }\n\n if (headerClass) header.classList.add(headerClass);\n\n header.innerHTML = `\n
\n ${translateDay(day.weekday)}\n
${badgesHtml}
\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 ${escapeHtml(item.name)}\n ${item.price.toFixed(2)} \u20ac\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 signal_wifi_off\n ${title}\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 restaurant_menu\n
\n

Kantinen \u00dcbersicht v1.0.0

\n
\n
\n
\n
\n
\n
\n
\n
\n \n
\n \n \n
\n \n \n
\n person\n \n \n
\n
\n
\n
\n\n
\n
\n
\n

Login

\n \n
\n
\n
\n \n \n Deine offizielle Knapp Mitarbeiternummer.\n
\n
\n \n \n Das Passwort f\u00fcr deinen Bessa Account.\n
\n
\n
\n \n
\n
\n
\n
\n\n
\n
\n
\n

Men\u00fcdaten aktualisieren

\n
\n
\n
\n
\n
\n
\n
0%
\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

Bessa Knapp-Kantine Wrapper • ${new Date().getFullYear()}

\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
${weekTitle}
\n
Week ${targetWeek} \u2022 ${targetYear}
`;\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 => `${code}`).join('');\n\n // Determine Day Status for Header Color\n // Violet: Has Order\n // Green: No Order but Orderable\n // Red: No Order and Not Orderable (Locked/Sold Out)\n let headerClass = '';\n const hasAnyOrder = day.items && day.items.some(item => {\n const articleId = item.articleId || parseInt(item.id.split('_')[1]);\n const key = `${day.date}_${articleId}`;\n return orderMap.has(key) && orderMap.get(key).length > 0;\n });\n\n const hasOrderable = day.items && day.items.some(item => {\n // Use pre-calculated available flag from loadMenuDataFromAPI calculation\n return item.available;\n });\n\n if (hasAnyOrder) {\n headerClass = 'header-violet';\n } else if (hasOrderable && !isPastCutoff) {\n headerClass = 'header-green';\n } else {\n // Red if not orderable (or past cutoff)\n headerClass = 'header-red';\n }\n\n if (headerClass) header.classList.add(headerClass);\n\n header.innerHTML = `\n
\n ${translateDay(day.weekday)}\n
${badgesHtml}
\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 ${escapeHtml(item.name)}\n ${item.price.toFixed(2)} \u20ac\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 signal_wifi_off\n ${title}\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 c1a8542..75a0eea 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); } /* Day Header Status Colors (User Request) */ .card-header.header-violet { background-color: rgba(139, 92, 246, 0.15); border-bottom: 2px solid #8b5cf6; } .card-header.header-green { background-color: rgba(16, 185, 129, 0.15); border-bottom: 2px solid var(--success-color); } .card-header.header-red { background-color: rgba(239, 68, 68, 0.15); border-bottom: 2px solid var(--error-color); } .card-header.header-violet .day-name, .card-header.header-green .day-name, .card-header.header-red .day-name { font-weight: 700; color: var(--text-primary); /* Ensure text remains standard 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 restaurant_menu\n
\n

Kantinen \u00dcbersicht v1.8.6

\n
\n
\n
\n
\n
\n
\n
\n
\n \n
\n \n \n
\n \n \n
\n person\n \n \n
\n
\n
\n
\n\n
\n
\n
\n

Login

\n \n
\n
\n
\n \n \n Deine offizielle Knapp Mitarbeiternummer.\n
\n
\n \n \n Das Passwort f\u00fcr deinen Bessa Account.\n
\n
\n
\n \n
\n
\n
\n
\n\n
\n
\n
\n

Men\u00fcdaten aktualisieren

\n
\n
\n
\n
\n
\n
\n
0%
\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

Bessa Knapp-Kantine Wrapper • ${new Date().getFullYear()}

\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
${weekTitle}
\n
Week ${targetWeek} \u2022 ${targetYear}
`;\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 => `${code}`).join('');\n\n // Determine Day Status for Header Color\n // Violet: Has Order\n // Green: No Order but Orderable\n // Red: No Order and Not Orderable (Locked/Sold Out)\n let headerClass = '';\n const hasAnyOrder = day.items && day.items.some(item => {\n const articleId = item.articleId || parseInt(item.id.split('_')[1]);\n const key = `${day.date}_${articleId}`;\n return orderMap.has(key) && orderMap.get(key).length > 0;\n });\n\n const hasOrderable = day.items && day.items.some(item => {\n // Use pre-calculated available flag from loadMenuDataFromAPI calculation\n return item.available;\n });\n\n if (hasAnyOrder) {\n headerClass = 'header-violet';\n } else if (hasOrderable && !isPastCutoff) {\n headerClass = 'header-green';\n } else {\n // Red if not orderable (or past cutoff)\n headerClass = 'header-red';\n }\n\n if (headerClass) header.classList.add(headerClass);\n\n header.innerHTML = `\n
\n ${translateDay(day.weekday)}\n
${badgesHtml}
\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 ${escapeHtml(item.name)}\n ${item.price.toFixed(2)} \u20ac\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 signal_wifi_off\n ${title}\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); } /* Day Header Status Colors (User Request) */ .card-header.header-violet { background-color: rgba(139, 92, 246, 0.15); border-bottom: 2px solid #8b5cf6; } .card-header.header-green { background-color: rgba(16, 185, 129, 0.15); border-bottom: 2px solid var(--success-color); } .card-header.header-red { background-color: rgba(239, 68, 68, 0.15); border-bottom: 2px solid var(--error-color); } .card-header.header-violet .day-name, .card-header.header-green .day-name, .card-header.header-red .day-name { font-weight: 700; color: var(--text-primary); /* Ensure text remains standard 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 restaurant_menu\n
\n

Kantinen \u00dcbersicht v1.0.0

\n
\n
\n
\n
\n
\n
\n
\n
\n \n
\n \n \n
\n \n \n
\n person\n \n \n
\n
\n
\n
\n\n
\n
\n
\n

Login

\n \n
\n
\n
\n \n \n Deine offizielle Knapp Mitarbeiternummer.\n
\n
\n \n \n Das Passwort f\u00fcr deinen Bessa Account.\n
\n
\n
\n \n
\n
\n
\n
\n\n
\n
\n
\n

Men\u00fcdaten aktualisieren

\n
\n
\n
\n
\n
\n
\n
0%
\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

Bessa Knapp-Kantine Wrapper • ${new Date().getFullYear()}

\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
${weekTitle}
\n
Week ${targetWeek} \u2022 ${targetYear}
`;\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 => `${code}`).join('');\n\n // Determine Day Status for Header Color\n // Violet: Has Order\n // Green: No Order but Orderable\n // Red: No Order and Not Orderable (Locked/Sold Out)\n let headerClass = '';\n const hasAnyOrder = day.items && day.items.some(item => {\n const articleId = item.articleId || parseInt(item.id.split('_')[1]);\n const key = `${day.date}_${articleId}`;\n return orderMap.has(key) && orderMap.get(key).length > 0;\n });\n\n const hasOrderable = day.items && day.items.some(item => {\n // Use pre-calculated available flag from loadMenuDataFromAPI calculation\n return item.available;\n });\n\n if (hasAnyOrder) {\n headerClass = 'header-violet';\n } else if (hasOrderable && !isPastCutoff) {\n headerClass = 'header-green';\n } else {\n // Red if not orderable (or past cutoff)\n headerClass = 'header-red';\n }\n\n if (headerClass) header.classList.add(headerClass);\n\n header.innerHTML = `\n
\n ${translateDay(day.weekday)}\n
${badgesHtml}
\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 ${escapeHtml(item.name)}\n ${item.price.toFixed(2)} \u20ac\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 signal_wifi_off\n ${title}\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 0faf7d6..2b8ad73 100755 --- a/dist/install.html +++ b/dist/install.html @@ -2,7 +2,7 @@ - Kantine Wrapper Installer (v1.8.6) + Kantine Wrapper Installer (v1.0.0) -

🍽️ Kantine Wrapper v1.8.6

+

🍽️ Kantine Wrapper v1.0.0

Installation

  1. Ziehe den Button unten in deine Lesezeichen-Leiste (Drag & Drop)
  2. Navigiere zu web.bessa.app/knapp-kantine
  3. -
  4. Klicke auf das Lesezeichen Kantine v1.8.6
  5. +
  6. Klicke auf das Lesezeichen Kantine v1.0.0

✨ Features

@@ -41,8 +41,8 @@

⏳ Wird generiert...

diff --git a/dist/kantine-standalone.html b/dist/kantine-standalone.html index 8cbdd73..ebc62ff 100755 --- a/dist/kantine-standalone.html +++ b/dist/kantine-standalone.html @@ -1248,7 +1248,7 @@ body {
restaurant_menu
-

Kantinen Übersicht v1.8.6

+

Kantinen Übersicht v1.0.0