Files
kantinen-wrapper/dist/bookmarklet-payload.js
2026-03-05 11:56:55 +01:00

10 lines
150 KiB
JavaScript
Executable File

(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 */ /* IMPORTANT: html must NOT have overflow set, or it creates a scroll container that breaks position: sticky */ html { height: auto !important; min-height: 100% !important; overflow: visible !important; position: static !important; margin: 0 !important; padding: 0 !important; } body { height: auto !important; min-height: 100% !important; overflow-x: clip !important; /* clip prevents horizontal overflow without breaking sticky */ overflow-y: visible !important; position: static !important; margin: 0 !important; padding: 0 !important; } /* Header */ .app-header { flex-shrink: 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; } .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); } /* Language Toggle (FR-100) */ .lang-toggle { display: inline-flex; gap: 0; border-radius: 6px; overflow: hidden; border: 1px solid var(--border-color); background: var(--bg-card); } .lang-btn { padding: 3px 10px; font-size: 0.7rem; font-weight: 600; letter-spacing: 0.03em; background: transparent; color: var(--text-secondary); border: none; cursor: pointer; transition: all 0.2s; } .lang-btn:hover { color: var(--text-primary); background: rgba(100, 116, 139, 0.1); } .lang-btn.active { background: var(--accent-color); color: white; } .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; } /* Notification state for Next Week */ .nav-btn.new-week-available { animation: goldPulse 2s infinite; border-color: #f59e0b; color: var(--accent-color); } .nav-btn.new-week-available.active { color: white; } @keyframes goldPulse { 0% { box-shadow: 0 0 0 0 rgba(245, 158, 11, 0.7); } 70% { box-shadow: 0 0 0 10px rgba(245, 158, 11, 0); } 100% { box-shadow: 0 0 0 0 rgba(245, 158, 11, 0); } } /* 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 - flex column, full width so child scrollbar is at edge */ .container { flex: 1; width: 100%; overflow: hidden; padding: 0 0 0 0; /* Only top padding, no horizontal so child fills width */ display: flex; flex-direction: column; } /* Add horizontal padding to direct children of container to maintain layout */ .container>*:not(.menu-grid) { padding-left: 2rem; padding-right: 2rem; } /* 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); 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; } /* History Modal specific */ .history-modal-content { max-width: 600px; max-height: 85vh; display: flex; flex-direction: column; } .history-modal-content .modal-body { overflow-y: auto; padding: 0; /* Padding is handled by inner elements */ } /* History Styles */ .history-year-group { margin-bottom: 16px; } .history-year-header { background: var(--bg-card); padding: 12px 20px; margin: 0; font-size: 1.2rem; font-weight: 700; color: var(--text-primary); border-bottom: 2px solid var(--border-color); position: sticky; top: 0; z-index: 12; } .history-month-group { border-bottom: 1px solid var(--border-color); } .history-month-header { display: flex; justify-content: space-between; align-items: center; padding: 14px 20px; margin: 0; font-size: 1.05rem; font-weight: 600; color: var(--text-primary); background: var(--bg-body); cursor: pointer; transition: background 0.2s; } .history-month-header:hover { background: var(--border-color); /* Slight hover effect */ } .history-month-summary { display: flex; align-items: center; gap: 12px; font-size: 0.95rem; color: var(--text-secondary); } .history-month-content { display: none; /* Collapsed by default */ background: var(--bg-card); } .history-month-group.open .history-month-content { display: block; /* Expanded when open class is present */ } .history-month-group.open .history-month-header .material-icons-round { transform: rotate(180deg); } .history-month-header .material-icons-round { transition: transform 0.3s; font-size: 20px; } .history-week-group { padding: 12px 20px; border-bottom: 1px dashed var(--border-color); } .history-week-group:last-child { border-bottom: none; } .history-week-header { display: flex; justify-content: space-between; align-items: center; font-size: 0.9rem; font-weight: 600; color: var(--text-secondary); margin-bottom: 10px; } .history-week-summary { font-size: 0.85rem; font-weight: 500; background: rgba(100, 116, 139, 0.1); padding: 4px 10px; border-radius: 12px; } .history-items { display: flex; flex-direction: column; gap: 8px; } .history-item { display: grid; grid-template-columns: 50px 1fr auto; align-items: center; gap: 12px; padding: 10px 12px; background: var(--bg-body); border-radius: 8px; border: 1px solid var(--border-color); } .history-item-date { font-size: 0.85rem; color: var(--text-secondary); font-weight: 500; } .history-item-details { display: flex; flex-direction: column; gap: 4px; } .history-item-name { font-size: 0.95rem; font-weight: 500; color: var(--text-primary); } .history-item-price { font-weight: 600; color: var(--text-primary); } .history-item-status { font-size: 0.8rem; font-weight: 600; color: var(--text-primary); text-transform: uppercase; letter-spacing: 0.5px; } .history-item-cancelled { opacity: 0.5; filter: grayscale(1); } .history-item-price-cancelled { text-decoration: line-through; color: var(--text-secondary); } @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); } .modal-header h2 { margin: 0; font-size: 1.25rem; } .modal-body { padding: 20px; } #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 Container */ .menu-grid { display: flex; flex-direction: column; flex: 1; overflow: hidden; gap: 1rem; } .week-section { margin-bottom: 2rem; } .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; } /* Full-viewport layout: header + scrollable content + footer */ #kantine-wrapper { display: flex; flex-direction: column; height: 100vh; height: 100dvh; /* Dynamic viewport height for mobile browsers */ overflow: hidden; } .days-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 0.75rem; flex: 1; overflow-y: auto; /* This is the scroll container at the window edge */ align-content: start; padding: 0 2rem 2rem 2rem; } /* Card */ .menu-card { background-color: var(--bg-card); border-radius: 12px; border: 1px solid var(--border-color); box-shadow: var(--card-shadow); overflow: clip; /* Clips scrolling content behind sticky header */ transition: box-shadow 0.2s ease; display: flex; flex-direction: column; } /* Past Day Styling - Target specific elements so ordered items can remain visible AND preserve sticky context */ /* We MUST apply filter/opacity to children, not the parent .menu-card, or else position: sticky breaks */ /* Header keeps fully opaque background to hide scrolling items, only grayscales */ .menu-card.past-day .card-header { filter: grayscale(0.8); transition: filter 0.3s; } /* Items become semi-transparent */ .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 { filter: grayscale(0.4); } .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 #8b5cf6; border-radius: 8px; padding: 1rem; margin: 0 -1rem 1.5rem -1rem; position: relative; z-index: 10; } .menu-item.today-ordered { border: 2px solid #8b5cf6; box-shadow: 0 0 20px rgba(139, 92, 246, 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(139, 92, 246, 0.3); } 50% { box-shadow: 0 0 25px rgba(139, 92, 246, 0.6); } 100% { box-shadow: 0 0 15px rgba(139, 92, 246, 0.3); } } .menu-card:hover { 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: var(--bg-card); /* Removed border-radius: 12px 12px 0 0; .menu-card\'s overflow: clip will round the corners initially. When sticky at the top, it will be square and perfectly hide scrolling content! */ /* Sticky within .container scroll area */ position: sticky; top: 0; z-index: 90; } .card-body { padding: 1.25rem; display: grid; grid-template-rows: auto; align-content: start; } .day-name { font-size: 1.125rem; font-weight: 600; } .day-date { font-size: 0.875rem; color: var(--text-secondary); } .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; white-space: pre-wrap; } .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 { flex-shrink: 0; text-align: center; padding: 0.4rem 2rem; color: var(--text-secondary); font-size: 0.8rem; border-top: 1px solid var(--border-color); } /* === 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: var(--bg-card); background-image: linear-gradient(rgba(139, 92, 246, 0.15), rgba(139, 92, 246, 0.15)); border-bottom: 2px solid #8b5cf6; } .card-header.header-green { background-color: var(--bg-card); background-image: linear-gradient(rgba(16, 185, 129, 0.15), rgba(16, 185, 129, 0.15)); border-bottom: 2px solid var(--success-color); } .card-header.header-red { background-color: var(--bg-card); background-image: linear-gradient(rgba(239, 68, 68, 0.15), 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 */ } /* Update Icon */ .update-icon { display: inline-flex; align-items: center; justify-content: center; margin-left: 8px; background-color: rgba(16, 185, 129, 0.2); /* Green tint */ color: var(--success-color); border-radius: 50%; width: 24px; height: 24px; cursor: pointer; font-size: 14px; transition: all 0.2s; text-decoration: none; animation: pulse 2s infinite; } .update-icon:hover { background-color: var(--success-color); color: white; transform: scale(1.1); } @keyframes pulse { 0% { box-shadow: 0 0 0 0 rgba(16, 185, 129, 0.4); } 70% { box-shadow: 0 0 0 6px rgba(16, 185, 129, 0); } 100% { box-shadow: 0 0 0 0 rgba(16, 185, 129, 0); } } /* Order Countdown */ #order-countdown { background: rgba(255, 255, 255, 0.1); padding: 0.25rem 0.75rem; border-radius: 99px; font-size: 0.85rem; display: flex; align-items: center; gap: 0.5rem; white-space: nowrap; border: 1px solid var(--border-color); } #order-countdown span { opacity: 0.7; font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.5px; } #order-countdown.urgent { background: rgba(239, 68, 68, 0.2); border-color: rgba(239, 68, 68, 0.5); color: #ef4444; animation: pulse-red 2s infinite; } @keyframes pulse-red { 0% { box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.4); } 70% { box-shadow: 0 0 0 6px rgba(239, 68, 68, 0); } 100% { box-shadow: 0 0 0 0 rgba(239, 68, 68, 0); } } /* Smart Highlights (Blue Glow - matches today-ordered/flagged pattern) */ .menu-item.highlight-glow { border: 2px solid rgba(59, 130, 246, 0.7); box-shadow: 0 0 20px rgba(59, 130, 246, 0.4); border-radius: 8px; padding: 1rem; margin: 0 -1rem 1.5rem -1rem; background: var(--bg-card); position: relative; z-index: 5; animation: blue-pulse 3s infinite; } @keyframes blue-pulse { 0% { box-shadow: 0 0 15px rgba(59, 130, 246, 0.3); } 50% { box-shadow: 0 0 25px rgba(59, 130, 246, 0.6); } 100% { box-shadow: 0 0 15px rgba(59, 130, 246, 0.3); } } /* Nav Badge with Count */ .nav-badge.has-highlights { background-color: var(--bg-card); /* Neutral background */ color: var(--text-primary); border: 1px solid var(--border-color); padding: 2px 6px; } .nav-badge .highlight-count { color: #3b82f6; /* Blue 500 */ font-weight: 700; margin-left: 4px; } /* Tag Management Modal */ #tags-list { display: flex; flex-wrap: wrap; gap: 0.5rem; margin-top: 1rem; min-height: 50px; } /* Tag badges styled consistently with .badge (verfügbar/ausverkauft) */ .tag-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; background-color: rgba(59, 130, 246, 0.1); color: #3b82f6; border: 1px solid rgba(59, 130, 246, 0.2); gap: 4px; } .tag-remove { cursor: pointer; opacity: 0.7; font-size: 1.1em; line-height: 1; transition: all 0.2s; } .tag-remove:hover { opacity: 1; color: #ef4444; } .input-group { display: flex; gap: 0.5rem; } .input-group input { flex: 1; padding: 0.75rem; background: var(--bg-body); border: 1px solid var(--border-color); color: var(--text-primary); border-radius: 8px; font-family: inherit; } /* Add tag button - styled like .btn-order with nav-btn.active color */ #btn-add-tag { display: inline-flex; align-items: center; gap: 4px; padding: 0.5rem 1rem; border: none; border-radius: 6px; background: var(--accent-color); color: white; font-size: 0.8rem; font-weight: 600; cursor: pointer; transition: all 0.2s ease; font-family: inherit; white-space: nowrap; } #btn-add-tag:hover { filter: brightness(1.15); transform: translateY(-1px); } .matched-tags { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 8px; /* Space between tags and title */ margin-top: -5px; /* Pull closer to header */ } .tag-badge-small { display: inline-flex; align-items: center; font-size: 0.7rem; padding: 2px 8px; border-radius: 4px; background: rgba(59, 130, 246, 0.15); color: #60a5fa; border: 1px solid rgba(59, 130, 246, 0.3); font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; } [data-theme="light"] .tag-badge-small { background: rgba(37, 99, 235, 0.1); color: #2563eb; border: 1px solid rgba(37, 99, 235, 0.2); } /* Installer Changelog */ .changelog-container ul { padding-left: 1.5rem; margin: 0.5rem 0; } .changelog-container li { margin-bottom: 0.4rem; line-height: 1.5; } .changelog-container h3 { margin-top: 1.5rem; margin-bottom: 0.5rem; font-size: 1.1em; color: var(--accent-color); } /* === Version Menu === */ .version-tag { cursor: pointer; transition: opacity 0.2s ease, text-decoration 0.2s ease; } .version-tag:hover { opacity: 1 !important; text-decoration: underline; } .version-list { list-style: none; padding: 0; margin: 0; } .version-item { display: flex; justify-content: space-between; align-items: center; padding: 10px 14px; border-radius: 8px; margin-bottom: 4px; transition: background 0.2s; } .version-item:hover { background: rgba(100, 116, 139, 0.08); } .version-item.current { background: rgba(2, 154, 168, 0.1); border: 1px solid rgba(2, 154, 168, 0.25); } [data-theme="dark"] .version-item:hover { background: rgba(255, 255, 255, 0.05); } [data-theme="dark"] .version-item.current { background: rgba(96, 165, 250, 0.12); border: 1px solid rgba(96, 165, 250, 0.25); } .version-info { display: flex; align-items: center; gap: 10px; } .badge-current { font-size: 0.75rem; font-weight: 600; color: var(--success-color); padding: 2px 8px; border-radius: 4px; background: rgba(5, 150, 105, 0.1); } .badge-new { font-size: 0.75rem; font-weight: 600; color: #029aa8; padding: 2px 8px; border-radius: 4px; background: rgba(2, 154, 168, 0.1); } [data-theme="dark"] .badge-new { color: #60a5fa; background: rgba(96, 165, 250, 0.12); } .install-link { font-size: 0.8rem; font-weight: 500; padding: 4px 12px; border-radius: 6px; background: rgba(2, 154, 168, 0.1); color: #029aa8; text-decoration: none; border: 1px solid rgba(2, 154, 168, 0.25); transition: all 0.2s; white-space: nowrap; } .install-link:hover { background: rgba(2, 154, 168, 0.2); border-color: rgba(2, 154, 168, 0.4); } [data-theme="dark"] .install-link { color: #60a5fa; background: rgba(96, 165, 250, 0.12); border: 1px solid rgba(96, 165, 250, 0.25); } [data-theme="dark"] .install-link:hover { background: rgba(96, 165, 250, 0.2); border-color: rgba(96, 165, 250, 0.4); } .dev-toggle { padding: 10px 14px; border-radius: 8px; background: rgba(100, 116, 139, 0.05); border: 1px solid var(--border-color); } .dev-toggle input[type="checkbox"] { accent-color: #029aa8; width: 16px; height: 16px; } [data-theme="dark"] .dev-toggle input[type="checkbox"] { accent-color: #60a5fa; } ';
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 // === GitHub Release Management ===\n const GITHUB_REPO = 'TauNeutrino/kantine-overview';\n const GITHUB_API = `https://api.github.com/repos/${GITHUB_REPO}`;\n const INSTALLER_BASE = `https://htmlpreview.github.io/?https://github.com/${GITHUB_REPO}/blob`;\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 = localStorage.getItem('kantine_authToken');\n let currentUser = localStorage.getItem('kantine_currentUser');\n let orderMap = new Map();\n let userFlags = new Set(JSON.parse(localStorage.getItem('kantine_flags') || '[]'));\n let pollIntervalId = null;\n let langMode = localStorage.getItem('kantine_lang') || 'de';\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 custom favicon (triangle + fork & knife PNG)\n if (document.querySelectorAll) {\n document.querySelectorAll('link[rel*=\"icon\"]').forEach(el => el.remove());\n }\n const favicon = document.createElement('link');\n favicon.rel = 'icon';\n favicon.type = 'image/png';\n favicon.href = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAKQUlEQVR4nKVXa3RVxRX+9sw5uY/khjwgECAJiCFUQhMSCFZKAYMWBQSDRVsUrcXLy4hg2yA0XgLKqjzEmPAIFIggahFs1YovRCh2iYXQgkQUyiNAgBBCgCQ3955zZnZ/hNRGoau0e635MXtmP8737Zl9hvB/CBGBmQkA/0vJTJg7l/41792b2hhVVrbONYqK9P8UFwClpaV1S05O/tVVnfhfHLU6u1GRRKSioqJeamhoyM/Ozv5eRUXF11eTUBnr1nVrlpJE6LJpMVwkZAQ0uxw4LotEBJGIEG4zytdsHTjkf7zSuMHgAoBOTk7uVVNTMzEhIYErKyuXGoZxl5ORIaiiQgVVaMgZ21qjLEcTswEpQUQAETQY0A7I9MIJBp8BUHmj0JFpmlxVVbVw6O1DPdu3f+xYljU8ISFhFCoqbPZnm0cmTin3KT2VXBGGA2Zt26wsix0rrHUopNkKh1VjUIXAl1u/6D/Lpk0SLYUmAaiuXbsOE1KMmhsIqN6908WkSX4+c+bMEmZ2Y1WFgt9v1kyZUdZe82TDjFAQpEEEBgQIAgwJAWmwEP85gUCgZW3cOAUixuDBxMzy+PHjiyc89BBycgbAcWz53HMLdIeEDqk+n2+6EEJj1Srm4mLXqUlPlMWCF8HjkSAoAQKhhQoCQRAzAFy7BpgJRDp99eqO58lam9TYMKXiyVknYxMS8n0+X8a8+fOVbdvStm3ExsaKuYG5etq0abMfffSBDWvXpJ0DTXd6li3rfxrOeLZsTSAB1gpEktFS+ay0c20EAgGBuXPlzWtW3NLHq5Xl2MGvvd6D499+PetSbe3P5zxTyEldk8g0TXi9XgCgqVOnck5OTvS6l994QVKRvnn54r7VUFttRjI7Dhgg9ngkiEAEJhCEkC1FdT0G2q9cWhKWxqgr7Tr2iGusKalvCI6dYXq+eDL3ztwt773HVSdOUDgURkxsDPr368dkSNw/+t5LGU89Pv7LXmmrbaW6kG07TEJKQRQlxOZGpe7TDCW8HhndHJpyftrMld9Q0AI7p61Y0e2S6cza1bnH9P4nj6R5L587tckbP3iN2zfsNZeZG7WvQkc3BsWgQT9Cu+honD17Fh9u28ZXai+Ih+fM3lWeGLdMgrqQ5TgMIun1UJxtP1UzefoLHVcWT6snKtUMJiHsb1FAQCAgkoSoDztqSL/qox/ZU568MxS29k0IXvxi4YCBSU92TuKFF6pFVv5kDL/jDnRKTMTwu+/CyuUrRGFJMXb16n6PJtGdwiHNgoTwumW8Zc08O3n6C6o433V28vRl8VpPFB432d9E/U7xceDtMm9xjXUwGAqdCQ/MnXbbof2fV1sh14yMbJ65Yxt53W6sTO6BK1WncaW+HjcP6IfnLp7H36uq4PZ6NRMJckUgxrZm1kyZuZQDAQNFRQqBgERRkZO4fGmBacgLJ/1PrLnu8Xtw+fIElCz6qt/G8mBDY4OTvXEdY0GAfSuWMhbN41ePfc3MzJdYc/r63zEWPcve0iU6omSxQsnCYOLS5wuoxZ/xbf8EIPDJJ+7rFuHgTwLGzqFFzuxlL41Y7zb+FCUNTYLEySuXIaVEY3MQW8eMw5CuKch8ZS2+qruAqEgvLNtmm4hKhwy7+Ef/lMMfHfhiGjc1/Y2ICID+9yRaO+G1LiLaObRIR3ftGvfm4qVlbw29ky0p6FD1aTSFw7jS1AQNwutHvoIhJQr63wohJWzbgaUVFab1xtQ+feNy88beimDwebfb1Xr0v5GW4HS9BISUUjdUV8+KbN++S1b3HnrNLRmUn9UPo29OxaTMbMRFelH+9wqMeWcLHknPwBsjxwChZqy/Zyx6Vp3GocNf88i777YBDOvUKfEBAAotV3mbirsW+gIA5eXlpQJoHjNmtFLMes6sAlaNjdwq208eZ2/JIsai+Tzw9fXcYIX5WH0dMzNveGUDb9v2EdfV1SkAWkhx2u/3t2v1fa2AbeA3DIO3bt26sHOXzm6fz8fETGxI1DYHAQCL9n6Gvgmd8Pbo++CKiMBfTlUh59VyCGpxFXYcuFxuXLhwQdx113DVJz29y7p16+ZKKfW1EP93hQSgEhIScpVSY15+uVzV19dLIkL7+PaoOXYMvz2wD79+/10M2bwRtyV2waYRYxBhmDhUV4cBr5bjQH0dms6eQ2paGvbv34/MzExZVlambNt+PCsrK/NaVLQmQADAzPLMmTOLJzw8AcNy74BlO9i+4xPkP+bHkqqjeHr7B2gXH4/9586i78Z1uKdHKjbcPQqCGedDIQz5/QZ4BuagY4cOePX3ryM9PZ0GDLgVI0eOMPbs2fOSyxVxTc4BQBCRiomJ+UVsbGzm/HnzlNZajn9wPHa9/wFKq09gfdUx+EwXHK0hDBOP3NIHYaUxruct2PLAeESYJhrCYTx16CBW7/sreiYmYviIEdBay5LSUhUZGTkoOrrdI0TUBoXWwuAFCxbEXr58ed7sObN1YmJnspXChAcfQv3tgzDjnT8g0uOBZo2m5mbMSc/ArKwcmEKgtq4OMQe/wozkboAQsJpD8O/8GEn+RxHXLga2UuiW0o0KC3+ja2trny8oKIhHywkQrdAbUkpHGGJx95TuTx2sPOiAyDClgcLP/oxnd/8FUS43lFawtMbvRo1ByqEjeHfHTnjcLrDWSEpJwaTH/Hjl+BE89OYmRLk9aAwFMfsHg/DcbYOhlIJmrfpm9pWHDx9excyTHMeRABQBoP79+6fu2bPnwJY3t5h59+YRAHrmsz9j/qc7EeWJhNIKzY4Df6fOeDwtHX2ys7/D5drychz8fDfifnY/Cnd/iihPJBpDQUzMzELZ7cMhhMDH27erYbm5okePHgOPHj36GQBJpmnCcZzNeWPz8ja/sdkBs1n4+ad49tOd8Hm9sGxbhVnTaw88iB9cDuLpeUUQROjYsRNcLhcu1l/EhdpaJKekwD9pEnr1TEPZPw5h8rtvIUqaaGwOitG9vof1d4xAtNvj5D+RL0pLSvcxcw4REXVJ6ZJXXVW95dezCjDizh9jd7SHCz7+kHyRkVCOYiM6iqYmJiH7YgOUaYKY8eWXX+LEieMIhcKIj49DWq9euKn7TbAsC1caG9HOFYFdMV6sPHUSZtjSV5oaRW5qT17Suy99+NZbeHr2HET7fL+sr69fQvHx8e83h0I3BZua1NifjnNX5o3sdvjUKccd4RJhZuFP7bX7/NKSuC0ffNjmFnO5XSAScBwbju18h5JHfnIf47EJ1obDh9O9zLrBkOIx03Np7cQp50yPW7hcrvpBPxw0EsXFxS5mFsxsMLOrQ9mL272vrOaoNcu48+qXCq9GNZhZXt333wx51UYklL1YGlm+gmNWvhgeuGXDrcws9jKbe/fuNQcPHvzdn+IRG5fHdt2wKr/7qpKRV/u5aOmmNyatNgJA6stlw9JXL/v+dffimybRNlLr26Dtvv92fNs3rj5w2ugNtG2LDGbCjh0StbWMceNUm7UblxabTZskKisZRLqNHsA/AQoXyB/6HdQ3AAAAAElFTkSuQmCC';\n document.head.appendChild(favicon);\n\n // Inject Google Fonts if not already present\n if (!document.querySelector('link[href*=\"fonts.googleapis.com/css2?family=Inter\"]')) {\n const fontLink = document.createElement('link');\n fontLink.rel = 'stylesheet';\n fontLink.href = 'https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap';\n document.head.appendChild(fontLink);\n }\n if (!document.querySelector('link[href*=\"Material+Icons+Round\"]')) {\n const iconLink = document.createElement('link');\n iconLink.rel = 'stylesheet';\n iconLink.href = 'https://fonts.googleapis.com/icon?family=Material+Icons+Round';\n document.head.appendChild(iconLink);\n }\n\n document.body.innerHTML = `\n <div id=\"kantine-wrapper\">\n <header class=\"app-header\">\n <div class=\"header-content\">\n <div class=\"brand\">\n <span class=\"material-icons-round logo-icon\">restaurant_menu</span>\n <div class=\"header-left\">\n <h1>Kantinen \u00dcbersicht <small class=\"version-tag\" style=\"font-size: 0.6em; opacity: 0.7; font-weight: 400; cursor: pointer;\" title=\"Klick f\u00fcr Versionsmen\u00fc\">v1.6.3</small></h1>\n <div id=\"last-updated-subtitle\" class=\"subtitle\"></div>\n </div>\n <div class=\"nav-group\" style=\"margin-left: 1rem;\">\n <button id=\"btn-this-week\" class=\"nav-btn active\" title=\"Men\u00fc dieser Woche anzeigen\">Diese Woche</button>\n <button id=\"btn-next-week\" class=\"nav-btn\" title=\"Men\u00fc n\u00e4chster Woche anzeigen\">N\u00e4chste Woche</button>\n </div>\n <button id=\"alarm-bell\" class=\"icon-btn hidden\" aria-label=\"Benachrichtigungen\" title=\"Keine beobachteten Men\u00fcs\" style=\"margin-left: -0.5rem;\">\n <span class=\"material-icons-round\" id=\"alarm-bell-icon\" style=\"color:var(--text-secondary); transition: color 0.3s;\">notifications</span>\n </button>\n </div>\n <div class=\"header-center-wrapper\">\n <div id=\"lang-toggle\" class=\"lang-toggle\" title=\"Sprache der Men\u00fcbeschreibung\">\n <button class=\"lang-btn${langMode === 'de' ? ' active' : ''}\" data-lang=\"de\">DE</button>\n <button class=\"lang-btn${langMode === 'en' ? ' active' : ''}\" data-lang=\"en\">EN</button>\n <button class=\"lang-btn${langMode === 'all' ? ' active' : ''}\" data-lang=\"all\">ALL</button>\n </div>\n <div id=\"header-week-info\" class=\"header-week-info\"></div>\n <div id=\"weekly-cost-display\" class=\"weekly-cost hidden\"></div>\n </div>\n <div class=\"controls\">\n <button id=\"btn-refresh\" class=\"icon-btn\" aria-label=\"Men\u00fcdaten aktualisieren\" title=\"Men\u00fcdaten neu laden\">\n <span class=\"material-icons-round\">refresh</span>\n </button>\n <button id=\"btn-history\" class=\"icon-btn\" aria-label=\"Bestellhistorie\" title=\"Bestellhistorie\">\n <span class=\"material-icons-round\">receipt_long</span>\n </button>\n <button id=\"btn-highlights\" class=\"icon-btn\" aria-label=\"Pers\u00f6nliche Highlights verwalten\" title=\"Pers\u00f6nliche Highlights verwalten\">\n <span class=\"material-icons-round\">label</span>\n </button>\n <button id=\"theme-toggle\" class=\"icon-btn\" aria-label=\"Toggle Theme\" title=\"Erscheinungsbild (Hell/Dunkel) wechseln\">\n <span class=\"material-icons-round theme-icon\">light_mode</span>\n </button>\n <button id=\"btn-login-open\" class=\"user-badge-btn icon-btn-small\" title=\"Mit Bessa.app Account anmelden\">\n <span class=\"material-icons-round\">login</span>\n <span>Anmelden</span>\n </button>\n <div id=\"user-info\" class=\"user-badge hidden\">\n <span class=\"material-icons-round\">person</span>\n <span id=\"user-id-display\"></span>\n <button id=\"btn-logout\" class=\"icon-btn-small\" aria-label=\"Logout\" title=\"Von Bessa.app abmelden\">\n <span class=\"material-icons-round\">logout</span>\n </button>\n </div>\n </div>\n </div>\n </header>\n\n <div id=\"login-modal\" class=\"modal hidden\">\n <div class=\"modal-content\">\n <div class=\"modal-header\">\n <h2>Login</h2>\n <button id=\"btn-login-close\" class=\"icon-btn\" aria-label=\"Close\" title=\"Schlie\u00dfen\">\n <span class=\"material-icons-round\">close</span>\n </button>\n </div>\n <form id=\"login-form\">\n <div class=\"form-group\">\n <label for=\"employee-id\">Mitarbeiternummer</label>\n <input type=\"text\" id=\"employee-id\" name=\"employee-id\" placeholder=\"z.B. 2041\" required>\n <small class=\"help-text\">Deine offizielle Knapp Mitarbeiternummer.</small>\n </div>\n <div class=\"form-group\">\n <label for=\"password\">Passwort</label>\n <input type=\"password\" id=\"password\" name=\"password\" placeholder=\"Bessa Passwort\" required>\n <small class=\"help-text\">Das Passwort f\u00fcr deinen Bessa Account.</small>\n </div>\n <div id=\"login-error\" class=\"error-msg hidden\"></div>\n <div class=\"modal-actions\">\n <button type=\"submit\" class=\"btn-primary wide\">Einloggen</button>\n </div>\n </form>\n </div>\n </div>\n\n <div id=\"progress-modal\" class=\"modal hidden\">\n <div class=\"modal-content\">\n <div class=\"modal-header\">\n <h2>Men\u00fcdaten aktualisieren</h2>\n </div>\n <div class=\"modal-body\" style=\"padding: 20px;\">\n <div class=\"progress-container\">\n <div class=\"progress-bar\">\n <div id=\"progress-fill\" class=\"progress-fill\"></div>\n </div>\n <div id=\"progress-percent\" class=\"progress-percent\">0%</div>\n </div>\n <p id=\"progress-message\" class=\"progress-message\">Initialisierung...</p>\n </div>\n </div>\n </div>\n\n <div id=\"highlights-modal\" class=\"modal hidden\">\n <div class=\"modal-content\">\n <div class=\"modal-header\">\n <h2>Meine Highlights</h2>\n <button id=\"btn-highlights-close\" class=\"icon-btn\" aria-label=\"Close\" title=\"Schlie\u00dfen\">\n <span class=\"material-icons-round\">close</span>\n </button>\n </div>\n <div class=\"modal-body\">\n <p style=\"margin-bottom: 1rem; color: var(--text-secondary);\">\n Markiere Men\u00fcs automatisch, wenn sie diese Schlagw\u00f6rter enthalten.\n </p>\n <div class=\"input-group\">\n <input type=\"text\" id=\"tag-input\" placeholder=\"z.B. Schnitzel, Vegetarisch...\" title=\"Neues Schlagwort zum Hervorheben eingeben\">\n <button id=\"btn-add-tag\" class=\"btn-primary\" title=\"Schlagwort zur Liste hinzuf\u00fcgen\">Hinzuf\u00fcgen</button>\n </div>\n <div id=\"tags-list\"></div>\n </div>\n </div>\n </div>\n\n <div id=\"history-modal\" class=\"modal hidden\">\n <div class=\"modal-content history-modal-content\">\n <div class=\"modal-header\">\n <h2>Bestellhistorie</h2>\n <button id=\"btn-history-close\" class=\"icon-btn\" aria-label=\"Close\" title=\"Schlie\u00dfen\">\n <span class=\"material-icons-round\">close</span>\n </button>\n </div>\n <div class=\"modal-body\">\n <div id=\"history-loading\" class=\"hidden\">\n <p id=\"history-progress-text\" style=\"text-align: center; margin-bottom: 1rem; color: var(--text-secondary);\">Lade Historie...</p>\n <div class=\"progress-container\">\n <div class=\"progress-bar\">\n <div id=\"history-progress-fill\" class=\"progress-fill\"></div>\n </div>\n </div>\n </div>\n <div id=\"history-content\">\n <!-- Dynamically populated -->\n </div>\n </div>\n </div>\n </div>\n\n <div id=\"version-modal\" class=\"modal hidden\">\n <div class=\"modal-content\">\n <div class=\"modal-header\">\n <h2>\ud83d\udce6 Versionen</h2>\n <button id=\"btn-version-close\" class=\"icon-btn\" aria-label=\"Close\" title=\"Schlie\u00dfen\">\n <span class=\"material-icons-round\">close</span>\n </button>\n </div>\n <div class=\"modal-body\">\n <div style=\"margin-bottom: 1rem;\">\n <strong>Aktuell:</strong> <span id=\"version-current\">v1.6.3</span>\n </div>\n <div class=\"dev-toggle\">\n <label style=\"display:flex;align-items:center;gap:8px;cursor:pointer;\">\n <input type=\"checkbox\" id=\"dev-mode-toggle\">\n <span>Dev-Mode (alle Tags anzeigen)</span>\n </label>\n </div>\n <div id=\"version-list-container\" style=\"margin-top:1rem; max-height: 250px; overflow-y: auto;\">\n <p style=\"color:var(--text-secondary);\">Lade Versionen...</p>\n </div>\n <div style=\"margin-top: 1.5rem; padding-top: 1rem; border-top: 1px solid var(--border-color); display: flex; flex-direction: column; gap: 0.75rem; font-size: 0.9em;\">\n <a href=\"https://github.com/TauNeutrino/kantine-overview/issues\" target=\"_blank\" rel=\"noopener noreferrer\" style=\"color: var(--primary-color); text-decoration: none; display: flex; align-items: center; gap: 0.5rem;\" title=\"Melde einen Fehler auf GitHub\">\n <span class=\"material-icons-round\" style=\"font-size: 1.2em;\">bug_report</span> Fehler melden\n </a>\n <a href=\"https://github.com/TauNeutrino/kantine-overview/discussions/categories/ideas\" target=\"_blank\" rel=\"noopener noreferrer\" style=\"color: var(--primary-color); text-decoration: none; display: flex; align-items: center; gap: 0.5rem;\" title=\"Schlage ein neues Feature auf GitHub vor\">\n <span class=\"material-icons-round\" style=\"font-size: 1.2em;\">lightbulb</span> Feature vorschlagen\n </a>\n <button id=\"btn-clear-cache\" style=\"background: none; border: none; padding: 0; color: var(--error-color); text-decoration: none; display: flex; align-items: center; gap: 0.5rem; cursor: pointer; text-align: left; font-size: inherit; font-family: inherit;\" title=\"L\u00f6scht alle lokalen Daten & erzwingt einen Neuladen\">\n <span class=\"material-icons-round\" style=\"font-size: 1.2em;\">delete_forever</span> Lokalen Cache leeren\n </button>\n </div>\n </div>\n </div>\n </div>\n\n <main class=\"container\">\n <div id=\"last-updated-banner\" class=\"banner hidden\">\n <span class=\"material-icons-round\">update</span>\n <span id=\"last-updated-text\">Gerade aktualisiert</span>\n </div>\n <div id=\"loading\" class=\"loading-state\">\n <div class=\"spinner\"></div>\n <p>Lade Men\u00fcdaten...</p>\n </div>\n <div id=\"menu-container\" class=\"menu-grid\"></div>\n </main>\n\n <footer class=\"app-footer\">\n <p>Jetzt Bessa Einfach! &bull; Knapp-Kantine Wrapper &bull; <span id=\"current-year\">${new Date().getFullYear()}</span> by Kaufis-Kitchen</p>\n </footer>\n </div>`;\n }\n\n // === Bind Events ===\n function bindEvents() {\n const btnThisWeek = document.getElementById('btn-this-week');\n const btnNextWeek = document.getElementById('btn-next-week');\n const btnRefresh = document.getElementById('btn-refresh');\n const themeToggle = document.getElementById('theme-toggle');\n const btnLoginOpen = document.getElementById('btn-login-open');\n const btnLoginClose = document.getElementById('btn-login-close');\n const btnLogout = document.getElementById('btn-logout');\n const loginForm = document.getElementById('login-form');\n const loginModal = document.getElementById('login-modal');\n\n // Highlights Modal\n const btnHighlights = document.getElementById('btn-highlights');\n const highlightsModal = document.getElementById('highlights-modal');\n const btnHighlightsClose = document.getElementById('btn-highlights-close');\n const btnAddTag = document.getElementById('btn-add-tag');\n const tagInput = document.getElementById('tag-input');\n\n // History Modal\n const btnHistory = document.getElementById('btn-history');\n const historyModal = document.getElementById('history-modal');\n const btnHistoryClose = document.getElementById('btn-history-close');\n\n // Language Toggle\n document.querySelectorAll('.lang-btn').forEach(btn => {\n btn.addEventListener('click', () => {\n langMode = btn.dataset.lang;\n localStorage.setItem('kantine_lang', langMode);\n document.querySelectorAll('.lang-btn').forEach(b => b.classList.remove('active'));\n btn.classList.add('active');\n renderVisibleWeeks();\n });\n });\n\n if (btnHighlights) {\n btnHighlights.addEventListener('click', () => {\n highlightsModal.classList.remove('hidden');\n });\n }\n\n if (btnHighlightsClose) {\n btnHighlightsClose.addEventListener('click', () => {\n highlightsModal.classList.add('hidden');\n });\n }\n\n btnHistory.addEventListener('click', () => {\n if (!authToken) {\n loginModal.classList.remove('hidden');\n return;\n }\n historyModal.classList.remove('hidden');\n fetchFullOrderHistory();\n });\n\n btnHistoryClose.addEventListener('click', () => {\n historyModal.classList.add('hidden');\n });\n\n window.addEventListener('click', (e) => {\n if (e.target === historyModal) historyModal.classList.add('hidden');\n if (e.target === highlightsModal) highlightsModal.classList.add('hidden');\n });\n\n // Version Menu\n const versionTag = document.querySelector('.version-tag');\n const versionModal = document.getElementById('version-modal');\n const btnVersionClose = document.getElementById('btn-version-close');\n\n if (versionTag) {\n versionTag.addEventListener('click', (e) => {\n e.preventDefault();\n e.stopPropagation();\n openVersionMenu();\n });\n }\n\n if (btnVersionClose) {\n btnVersionClose.addEventListener('click', () => {\n versionModal.classList.add('hidden');\n });\n }\n\n const btnClearCache = document.getElementById('btn-clear-cache');\n if (btnClearCache) {\n btnClearCache.addEventListener('click', () => {\n if (confirm('M\u00f6chtest du wirklich alle lokalen Daten (inkl. Login-Session, Cache und Einstellungen) l\u00f6schen? Die Seite wird danach neu geladen.')) {\n // Only clear our own keys so we don't destroy the host app's (Bessa's) session\n Object.keys(localStorage).forEach(key => {\n if (key.startsWith('kantine_')) {\n localStorage.removeItem(key);\n }\n });\n window.location.reload();\n }\n });\n }\n\n window.addEventListener('click', (e) => {\n if (e.target === versionModal) versionModal.classList.add('hidden');\n });\n\n btnAddTag.addEventListener('click', () => {\n const tag = tagInput.value;\n if (addHighlightTag(tag)) {\n tagInput.value = '';\n renderTagsList();\n }\n });\n\n tagInput.addEventListener('keypress', (e) => {\n if (e.key === 'Enter') {\n btnAddTag.click();\n }\n });\n\n // Theme\n const savedTheme = localStorage.getItem('theme');\n const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;\n const themeIcon = themeToggle.querySelector('.theme-icon');\n\n if (savedTheme === 'dark' || (!savedTheme && prefersDark)) {\n document.documentElement.setAttribute('data-theme', 'dark');\n themeIcon.textContent = 'dark_mode';\n } else {\n document.documentElement.setAttribute('data-theme', 'light');\n themeIcon.textContent = 'light_mode';\n }\n\n themeToggle.addEventListener('click', () => {\n const current = document.documentElement.getAttribute('data-theme');\n const next = current === 'dark' ? 'light' : 'dark';\n document.documentElement.setAttribute('data-theme', next);\n localStorage.setItem('theme', next);\n themeIcon.textContent = next === 'dark' ? 'dark_mode' : 'light_mode';\n });\n\n // Navigation\n btnThisWeek.addEventListener('click', () => {\n if (displayMode !== 'this-week') {\n displayMode = 'this-week';\n btnThisWeek.classList.add('active');\n btnNextWeek.classList.remove('active');\n renderVisibleWeeks();\n }\n });\n\n btnNextWeek.addEventListener('click', () => {\n btnNextWeek.classList.remove('new-week-available');\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 localStorage.setItem('kantine_authToken', data.key);\n localStorage.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) localStorage.setItem('kantine_firstName', userData.first_name);\n if (userData.last_name) localStorage.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 localStorage.removeItem('kantine_authToken');\n localStorage.removeItem('kantine_currentUser');\n localStorage.removeItem('kantine_firstName');\n localStorage.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 localStorage.setItem('kantine_authToken', authToken);\n\n if (parsed.auth.user) {\n currentUser = parsed.auth.user.id || 'unknown';\n localStorage.setItem('kantine_currentUser', currentUser);\n if (parsed.auth.user.firstName) localStorage.setItem('kantine_firstName', parsed.auth.user.firstName);\n if (parsed.auth.user.lastName) localStorage.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 = localStorage.getItem('kantine_authToken');\n currentUser = localStorage.getItem('kantine_currentUser');\n const firstName = localStorage.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 updateNextWeekBadge();\n }\n } catch (error) {\n console.error('Error fetching orders:', error);\n }\n }\n\n // === History Modal Flow ===\n let fullOrderHistoryCache = null;\n\n async function fetchFullOrderHistory() {\n const historyLoading = document.getElementById('history-loading');\n const historyContent = document.getElementById('history-content');\n const progressFill = document.getElementById('history-progress-fill');\n const progressText = document.getElementById('history-progress-text');\n\n // Check local storage cache (we still use memory cache if available)\n let localCache = [];\n if (fullOrderHistoryCache) {\n localCache = fullOrderHistoryCache;\n } else {\n const ls = localStorage.getItem('kantine_history_cache');\n if (ls) {\n try {\n localCache = JSON.parse(ls);\n fullOrderHistoryCache = localCache;\n } catch (e) {\n console.warn('History cache parse error', e);\n }\n }\n }\n\n // Show cached version immediately if we have one\n if (localCache.length > 0) {\n renderHistory(localCache);\n }\n\n if (!authToken) return;\n\n // Start background delta sync\n if (localCache.length === 0) {\n historyContent.innerHTML = '';\n historyLoading.classList.remove('hidden');\n }\n\n progressFill.style.width = '0%';\n progressText.textContent = localCache.length > 0 ? 'Suche nach neuen Bestellungen...' : 'Lade Bestellhistorie...';\n if (localCache.length > 0) historyLoading.classList.remove('hidden');\n\n let nextUrl = localCache.length > 0\n ? `${API_BASE}/user/orders/?venue=${VENUE_ID}&ordering=-created&limit=5`\n : `${API_BASE}/user/orders/?venue=${VENUE_ID}&ordering=-created&limit=50`;\n let fetchedOrders = [];\n let totalCount = 0;\n let requiresFullFetch = localCache.length === 0;\n let deltaComplete = false;\n\n try {\n while (nextUrl && !deltaComplete) {\n const response = await fetch(nextUrl, { headers: apiHeaders(authToken) });\n if (!response.ok) throw new Error(`Fetch failed: ${response.status}`);\n\n const data = await response.json();\n\n if (data.count && totalCount === 0) {\n totalCount = data.count;\n }\n\n const results = data.results || [];\n\n for (const order of results) {\n // Check if we hit an order that is already in our cache AND has the exact same state/update time\n // Bessa returns 'updated' timestamp, we can use it to determine if anything changed\n const existingOrderIndex = localCache.findIndex(cached => cached.id === order.id);\n\n if (!requiresFullFetch && existingOrderIndex !== -1) {\n const existingOrder = localCache[existingOrderIndex];\n // If order exists and wasn't updated since our cache, we've reached the point \n // where everything older is already correctly cached.\n // order.updated is an ISO string like \"2025-02-18T10:30:15.123456Z\"\n if (existingOrder.updated === order.updated && existingOrder.order_state === order.order_state) {\n deltaComplete = true;\n break;\n }\n }\n fetchedOrders.push(order);\n }\n\n // Update progress\n if (!deltaComplete && requiresFullFetch) {\n if (totalCount > 0) {\n const pct = Math.round((fetchedOrders.length / totalCount) * 100);\n progressFill.style.width = `${pct}%`;\n progressText.textContent = `Lade Bestellung ${fetchedOrders.length} von ${totalCount}...`;\n } else {\n progressText.textContent = `Lade Bestellung ${fetchedOrders.length}...`;\n }\n } else if (!deltaComplete) {\n progressText.textContent = `${fetchedOrders.length} neue/ge\u00e4nderte Bestellungen gefunden...`;\n }\n\n nextUrl = deltaComplete ? null : data.next;\n }\n\n // Merge fetched orders with cache\n if (fetchedOrders.length > 0) {\n // We have new/updated orders. We need to merge them into the cache.\n // 1. Create a map of the existing cache for quick ID lookup\n const cacheMap = new Map(localCache.map(o => [o.id, o]));\n\n // 2. Update/Insert the newly fetched orders\n for (const order of fetchedOrders) {\n cacheMap.set(order.id, order); // Overwrites existing, or adds new\n }\n\n // 3. Convert back to array and sort by created date (descending)\n const mergedOrders = Array.from(cacheMap.values());\n mergedOrders.sort((a, b) => new Date(b.created) - new Date(a.created));\n\n fullOrderHistoryCache = mergedOrders;\n try {\n localStorage.setItem('kantine_history_cache', JSON.stringify(mergedOrders));\n } catch (e) {\n console.warn('History cache write error', e);\n }\n\n // Render the updated history\n renderHistory(fullOrderHistoryCache);\n }\n\n } catch (error) {\n console.error('Error in history sync:', error);\n if (localCache.length === 0) {\n historyContent.innerHTML = `<p style=\"color:var(--error-color);text-align:center;\">Fehler beim Laden der Historie.</p>`;\n } else {\n showToast('Hintergrund-Synchronisation fehlgeschlagen', 'error');\n }\n } finally {\n historyLoading.classList.add('hidden');\n }\n }\n\n function renderHistory(orders) {\n const content = document.getElementById('history-content');\n if (!orders || orders.length === 0) {\n content.innerHTML = '<p style=\"text-align:center;color:var(--text-secondary);padding:20px;\">Keine Bestellungen gefunden.</p>';\n return;\n }\n\n // Group by Year -> Month -> Week Number (KW)\n const groups = {};\n\n orders.forEach(order => {\n const d = new Date(order.date);\n const y = d.getFullYear();\n const m = d.getMonth();\n const monthKey = `${y}-${m.toString().padStart(2, '0')}`;\n const monthName = d.toLocaleString('de-AT', { month: 'long' }); // Only month name\n\n const kw = getISOWeek(d);\n\n if (!groups[y]) {\n groups[y] = { year: y, months: {} };\n }\n if (!groups[y].months[monthKey]) {\n groups[y].months[monthKey] = { name: monthName, year: y, monthIndex: m, count: 0, total: 0, weeks: {} };\n }\n if (!groups[y].months[monthKey].weeks[kw]) {\n groups[y].months[monthKey].weeks[kw] = { label: `KW ${kw}`, items: [], count: 0, total: 0 };\n }\n\n const items = order.items || [];\n items.forEach(item => {\n const itemPrice = parseFloat(item.price || order.total || 0);\n groups[y].months[monthKey].weeks[kw].items.push({\n date: order.date,\n name: item.name || 'Men\u00fc',\n price: itemPrice,\n state: order.order_state // 9 is cancelled, 5 is active, 8 is completed\n });\n\n if (order.order_state !== 9) {\n groups[y].months[monthKey].weeks[kw].count++;\n groups[y].months[monthKey].weeks[kw].total += itemPrice;\n groups[y].months[monthKey].count++;\n groups[y].months[monthKey].total += itemPrice;\n }\n });\n });\n\n // Generate HTML \n const sortedYears = Object.keys(groups).sort((a, b) => b - a);\n let html = '';\n\n sortedYears.forEach(yKey => {\n const yearGroup = groups[yKey];\n html += `<div class=\"history-year-group\">\n <h2 class=\"history-year-header\">${yearGroup.year}</h2>`;\n\n const sortedMonths = Object.keys(yearGroup.months).sort((a, b) => b.localeCompare(a));\n\n sortedMonths.forEach(mKey => {\n const monthGroup = yearGroup.months[mKey];\n\n html += `<div class=\"history-month-group\">\n <div class=\"history-month-header\" tabindex=\"0\" role=\"button\" aria-expanded=\"false\" title=\"Klicken, um die Bestellungen f\u00fcr diesen Monat ein-/auszublenden\">\n <div style=\"display:flex; flex-direction:column; gap:4px;\">\n <span>${monthGroup.name}</span>\n <div class=\"history-month-summary\">\n <span>${monthGroup.count} Bestellungen &bull; <strong>\u20ac${monthGroup.total.toFixed(2)}</strong></span>\n </div>\n </div>\n <span class=\"material-icons-round\">expand_more</span>\n </div>\n <div class=\"history-month-content\">`;\n\n const sortedKWs = Object.keys(monthGroup.weeks).sort((a, b) => parseInt(b) - parseInt(a));\n\n sortedKWs.forEach(kw => {\n const week = monthGroup.weeks[kw];\n html += `<div class=\"history-week-group\">\n <div class=\"history-week-header\">\n <strong>${week.label}</strong>\n <span>${week.count} Bestellungen &bull; <strong>\u20ac${week.total.toFixed(2)}</strong></span>\n </div>`;\n\n week.items.forEach(item => {\n const dateObj = new Date(item.date);\n const dayStr = dateObj.toLocaleDateString('de-AT', { weekday: 'short', day: '2-digit', month: '2-digit' });\n\n let statusBadge = '';\n if (item.state === 9) {\n statusBadge = '<span class=\"history-item-status\">Storniert</span>';\n } else if (item.state === 8) {\n statusBadge = '<span class=\"history-item-status\">Abgeschlossen</span>';\n } else {\n statusBadge = '<span class=\"history-item-status\">\u00dcbertragen</span>';\n }\n\n html += `\n <div class=\"history-item ${item.state === 9 ? 'history-item-cancelled' : ''}\">\n <div style=\"font-size: 0.85rem; color: var(--text-secondary);\">${dayStr}</div>\n <div class=\"history-item-details\">\n <span class=\"history-item-name\">${escapeHtml(item.name)}</span>\n <div>${statusBadge}</div>\n </div>\n <div class=\"history-item-price ${item.state === 9 ? 'history-item-price-cancelled' : ''}\">\u20ac${item.price.toFixed(2)}</div>\n </div>`;\n });\n html += `</div>`;\n });\n html += `</div></div>`; // Close month-content and month-group\n });\n html += `</div>`; // Close year-group\n });\n\n content.innerHTML = html;\n\n // Bind Accordion Click Events via JS\n const monthHeaders = content.querySelectorAll('.history-month-header');\n monthHeaders.forEach(header => {\n header.addEventListener('click', () => {\n const parentGroup = header.parentElement;\n const isOpen = parentGroup.classList.contains('open');\n\n // Toggle current\n if (isOpen) {\n parentGroup.classList.remove('open');\n header.setAttribute('aria-expanded', 'false');\n } else {\n parentGroup.classList.add('open');\n header.setAttribute('aria-expanded', 'true');\n }\n });\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:30:00Z`,\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: true,\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 fullOrderHistoryCache = null; // Clear memory cache so next history open triggers delta sync\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 fullOrderHistoryCache = null; // Clear memory cache so next history open triggers delta sync\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 async function refreshFlaggedItems() {\n if (userFlags.size === 0) return;\n const token = authToken || GUEST_TOKEN;\n const datesToFetch = new Set();\n\n for (const flagId of userFlags) {\n const [dateStr] = flagId.split('_');\n datesToFetch.add(dateStr);\n }\n\n let updated = false;\n for (const dateStr of datesToFetch) {\n try {\n const resp = await fetch(`${API_BASE}/venues/${VENUE_ID}/menu/${MENU_ID}/${dateStr}/`, {\n headers: apiHeaders(token)\n });\n if (!resp.ok) continue;\n const data = await resp.json();\n const menuGroups = data.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\n // Update allWeeks in memory\n for (let week of allWeeks) {\n if (!week.days) continue;\n let dayObj = week.days.find(d => d.date === dateStr);\n if (dayObj) {\n dayObj.items = dayItems.map(item => {\n const isUnlimited = item.amount_tracking === false;\n const hasStock = parseInt(item.available_amount) > 0;\n return {\n id: `${dateStr}_${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 updated = true;\n }\n }\n } catch (e) {\n console.error('Error refreshing flag date', dateStr, e);\n }\n }\n\n if (updated) {\n saveMenuCache();\n updateLastUpdatedTime(new Date().toISOString());\n updateAlarmBell();\n renderVisibleWeeks();\n }\n }\n\n function updateAlarmBell() {\n const bellBtn = document.getElementById('alarm-bell');\n const bellIcon = document.getElementById('alarm-bell-icon');\n if (!bellBtn || !bellIcon) return;\n\n if (userFlags.size === 0) {\n bellBtn.classList.add('hidden');\n bellBtn.style.display = 'none';\n bellIcon.style.color = 'var(--text-secondary)';\n bellIcon.style.textShadow = 'none';\n return;\n }\n\n bellBtn.classList.remove('hidden');\n bellBtn.style.display = 'inline-flex';\n\n // Check if any flagged item is available\n let anyAvailable = false;\n for (const wk of allWeeks) {\n if (!wk.days) continue;\n for (const d of wk.days) {\n if (!d.items) continue;\n for (const item of d.items) {\n if (item.available && userFlags.has(item.id)) {\n anyAvailable = true;\n break;\n }\n }\n if (anyAvailable) break;\n }\n if (anyAvailable) break;\n }\n\n let lastUpdatedStr = localStorage.getItem('kantine_last_updated');\n let timeStr = 'gerade eben'; // Fallback instead of Unbekannt\n if (!lastUpdatedStr) {\n lastUpdatedStr = new Date().toISOString();\n localStorage.setItem('kantine_last_updated', lastUpdatedStr);\n }\n\n const lastUpdated = new Date(lastUpdatedStr);\n const diffMs = Date.now() - lastUpdated.getTime();\n const diffMins = Math.floor(diffMs / 60000);\n if (diffMins < 1) timeStr = 'gerade eben';\n else if (diffMins < 60) timeStr = `vor ${diffMins} Min.`;\n else timeStr = `vor ${Math.floor(diffMins / 60)} Std.`;\n\n bellBtn.title = `Zuletzt gepr\u00fcft: ${timeStr}`;\n\n if (anyAvailable) {\n bellIcon.style.color = '#10b981'; // green / success\n bellIcon.style.textShadow = '0 0 10px rgba(16, 185, 129, 0.4)';\n } else {\n bellIcon.style.color = '#f59e0b'; // yellow / warning\n bellIcon.style.textShadow = '0 0 10px rgba(245, 158, 11, 0.4)';\n }\n }\n\n function toggleFlag(date, articleId, name, cutoff) {\n const id = `${date}_${articleId}`;\n let flagAdded = false;\n if (userFlags.has(id)) {\n userFlags.delete(id);\n showToast(`Flag entfernt f\u00fcr ${name}`, 'success');\n } else {\n userFlags.add(id);\n flagAdded = true;\n showToast(`Benachrichtigung aktiviert f\u00fcr ${name}`, 'success');\n if (Notification.permission === 'default') {\n Notification.requestPermission();\n }\n }\n saveFlags();\n updateAlarmBell();\n renderVisibleWeeks();\n\n if (flagAdded) {\n refreshFlaggedItems();\n }\n }\n\n // FR-019: Auto-remove flags whose cutoff has passed\n function cleanupExpiredFlags() {\n const now = new Date();\n const todayStr = now.toISOString().split('T')[0]; // Format: YYYY-MM-DD\n let changed = false;\n\n for (const flagId of [...userFlags]) {\n const [dateStr] = flagId.split('_'); // Format usually is YYYY-MM-DD\n\n // If the flag's date string is entirely in the past (before today)\n // or if it's today but past the 10:00 cutoff time\n let isExpired = false;\n\n if (dateStr < todayStr) {\n isExpired = true;\n } else if (dateStr === todayStr) {\n const cutoff = new Date(dateStr);\n cutoff.setHours(10, 0, 0, 0); // Standard cutoff 10:00\n if (now >= cutoff) {\n isExpired = true;\n }\n }\n\n if (isExpired) {\n userFlags.delete(flagId);\n changed = true;\n }\n }\n if (changed) saveFlags();\n }\n\n // === Polling (Client-Side) ===\n function startPolling() {\n if (pollIntervalId) return;\n if (!authToken) return;\n pollIntervalId = setInterval(() => pollFlaggedItems(), POLL_INTERVAL_MS);\n console.log('Polling started (every 5 min)');\n }\n\n function stopPolling() {\n if (pollIntervalId) {\n clearInterval(pollIntervalId);\n pollIntervalId = null;\n console.log('Polling stopped');\n }\n }\n\n async function pollFlaggedItems() {\n if (userFlags.size === 0 || !authToken) return;\n console.log(`Polling ${userFlags.size} flagged items...`);\n\n for (const flagId of userFlags) {\n const [date, articleIdStr] = flagId.split('_');\n const articleId = parseInt(articleIdStr);\n\n try {\n const response = await fetch(`${API_BASE}/venues/${VENUE_ID}/menu/${MENU_ID}/${date}/`, {\n headers: apiHeaders(authToken)\n });\n if (!response.ok) continue;\n\n const data = await response.json();\n const groups = data.results || [];\n let foundItem = null;\n for (const group of groups) {\n if (group.items) {\n foundItem = group.items.find(i => i.id === articleId || i.article === articleId);\n if (foundItem) break;\n }\n }\n\n if (foundItem) {\n const isAvailable = (foundItem.amount_tracking === false) || (parseInt(foundItem.available_amount) > 0);\n if (isAvailable) {\n const itemName = foundItem.name || 'Unbekannt';\n showToast(`${itemName} ist jetzt verf\u00fcgbar!`, 'success');\n if (Notification.permission === 'granted') {\n new Notification('Kantine Wrapper', {\n body: `${itemName} ist jetzt verf\u00fcgbar!`,\n icon: '\ud83c\udf7d\ufe0f'\n });\n }\n // Refresh menu data to update UI\n loadMenuDataFromAPI();\n }\n }\n } catch (err) {\n console.error(`Poll error for ${flagId}:`, err);\n // Small delay between checks\n await new Promise(r => setTimeout(r, 200));\n }\n }\n }\n\n // === Highlight Management ===\n let highlightTags = JSON.parse(localStorage.getItem('kantine_highlightTags') || '[]');\n\n function saveHighlightTags() {\n localStorage.setItem('kantine_highlightTags', JSON.stringify(highlightTags));\n renderVisibleWeeks(); // Refresh UI to apply changes\n updateNextWeekBadge();\n }\n\n function addHighlightTag(tag) {\n tag = tag.trim().toLowerCase();\n if (tag && !highlightTags.includes(tag)) {\n highlightTags.push(tag);\n saveHighlightTags();\n return true;\n }\n return false;\n }\n\n function removeHighlightTag(tag) {\n highlightTags = highlightTags.filter(t => t !== tag);\n saveHighlightTags();\n }\n\n function renderTagsList() {\n const list = document.getElementById('tags-list');\n list.innerHTML = '';\n highlightTags.forEach(tag => {\n const badge = document.createElement('span');\n badge.className = 'tag-badge';\n badge.innerHTML = `${tag} <span class=\"tag-remove\" data-tag=\"${tag}\" title=\"Schlagwort entfernen\">&times;</span>`;\n list.appendChild(badge);\n });\n\n // Bind remove events\n list.querySelectorAll('.tag-remove').forEach(btn => {\n btn.addEventListener('click', (e) => {\n removeHighlightTag(e.target.dataset.tag);\n renderTagsList();\n });\n });\n }\n\n function checkHighlight(text) {\n if (!text) return [];\n text = text.toLowerCase();\n return highlightTags.filter(tag => text.includes(tag));\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 console.log(`[Cache] localStorage: key=${!!cached} (${cached ? cached.length : 0} chars), ts=${cachedTs}`);\n if (cached) {\n allWeeks = JSON.parse(cached);\n currentWeekNumber = getISOWeek(new Date());\n currentYear = new Date().getFullYear();\n console.log(`[Cache] Parsed ${allWeeks.length} weeks:`, allWeeks.map(w => `KW${w.weekNumber}/${w.year} (${(w.days || []).length} days)`));\n renderVisibleWeeks();\n updateNextWeekBadge();\n updateAlarmBell();\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 // FR-024: Check if cache is fresh enough to skip API refresh\n function isCacheFresh() {\n const cachedTs = localStorage.getItem(CACHE_TS_KEY);\n if (!cachedTs) {\n console.log('[Cache] No timestamp found');\n return false;\n }\n\n // Condition 1: Cache < 1 hour old\n const ageMs = Date.now() - new Date(cachedTs).getTime();\n const ageMin = Math.round(ageMs / 60000);\n if (ageMs > 60 * 60 * 1000) {\n console.log(`[Cache] Stale: ${ageMin}min old (max 60)`);\n return false;\n }\n\n // Condition 2: Data for current week exists\n const thisWeek = getISOWeek(new Date());\n const thisYear = getWeekYear(new Date());\n const hasCurrentWeek = allWeeks.some(w => w.weekNumber === thisWeek && w.year === thisYear && w.days && w.days.length > 0);\n\n console.log(`[Cache] Age: ${ageMin}min, looking for KW${thisWeek}/${thisYear}, found: ${hasCurrentWeek}`);\n return hasCurrentWeek;\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 updateAlarmBell();\n\n progressMessage.textContent = 'Fertig!';\n setTimeout(() => progressModal.classList.add('hidden'), 500);\n\n } catch (error) {\n console.error('Error fetching menu:', error);\n progressModal.classList.add('hidden');\n\n showErrorModal(\n 'Keine Verbindung',\n `Die Men\u00fcdaten konnten nicht geladen werden. M\u00f6glicherweise besteht keine Verbindung zur API oder zur Bessa-Webseite.<br><br><small style=\"color:var(--text-secondary)\">${error.message}</small>`,\n 'Zur Original-Seite',\n 'https://web.bessa.app/knapp-kantine'\n );\n } finally {\n loading.classList.add('hidden');\n }\n }\n\n // === Last Updated Display ===\n let lastUpdatedTimestamp = null;\n let lastUpdatedIntervalId = null;\n\n function updateLastUpdatedTime(isoTimestamp) {\n const subtitle = document.getElementById('last-updated-subtitle');\n if (!isoTimestamp) return;\n lastUpdatedTimestamp = isoTimestamp;\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 const ago = getRelativeTime(date);\n subtitle.textContent = `Aktualisiert: ${dateStr} ${timeStr} (${ago})`;\n } catch (e) {\n subtitle.textContent = '';\n }\n // Auto-refresh relative time every minute\n if (!lastUpdatedIntervalId) {\n lastUpdatedIntervalId = setInterval(() => {\n if (lastUpdatedTimestamp) updateLastUpdatedTime(lastUpdatedTimestamp);\n }, 60 * 1000);\n }\n }\n\n function getRelativeTime(date) {\n const diffMs = Date.now() - date.getTime();\n const diffMin = Math.floor(diffMs / 60000);\n if (diffMin < 1) return 'gerade eben';\n if (diffMin === 1) return 'vor 1 min.';\n if (diffMin < 60) return `vor ${diffMin} min.`;\n const diffH = Math.floor(diffMin / 60);\n if (diffH === 1) return 'vor 1 Std.';\n return `vor ${diffH} Std.`;\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 = `<span class=\"material-icons-round\">${icon}</span><span>${message}</span>`;\n container.appendChild(toast);\n requestAnimationFrame(() => toast.classList.add('show'));\n setTimeout(() => {\n toast.classList.remove('show');\n setTimeout(() => toast.remove(), 300);\n }, 3000);\n }\n\n // === Next Week Badge ===\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 = `<span class=\"ordered\">${daysWithOrders}</span><span class=\"separator\">/</span><span class=\"orderable\">${orderableCount}</span><span class=\"separator\">/</span><span class=\"total\">${totalDataCount}</span>`;\n\n // Color Logic\n badge.classList.remove('badge-violet', 'badge-green', 'badge-red', 'badge-blue');\n\n // Refined Logic (v1.7.4):\n // Violet: If we have orders AND there are no DAYS left that are orderable but un-ordered.\n // (i.e. \"I have ordered everything I can\")\n if (daysWithOrders > 0 && daysWithOrderableAndNoOrder === 0) {\n badge.classList.add('badge-violet');\n } else if (daysWithOrderableAndNoOrder > 0) {\n badge.classList.add('badge-green'); // Orderable days exist without order\n } else if (orderableCount === 0) {\n badge.classList.add('badge-red'); // No orderable days at all & no orders\n } else {\n badge.classList.add('badge-blue'); // Default / partial state\n }\n\n // Advanced Feature: Highlight Count\n let highlightCount = 0;\n if (nextWeekData && nextWeekData.days) {\n nextWeekData.days.forEach(day => {\n day.items.forEach(item => {\n const nameMatches = checkHighlight(item.name);\n const descMatches = checkHighlight(item.description);\n if (nameMatches.length > 0 || descMatches.length > 0) {\n highlightCount++;\n }\n });\n });\n }\n\n if (highlightCount > 0) {\n // Append blue count\n badge.innerHTML += `<span class=\"highlight-count\" title=\"${highlightCount} Highlight Men\u00fcs\">(${highlightCount})</span>`;\n badge.title += ` \u2022 ${highlightCount} Highlights gefunden`;\n badge.classList.add('has-highlights');\n }\n\n // FR-092: Glow Next Week button while data exists but no orders placed\n if (daysWithOrders === 0) {\n btnNextWeek.classList.add('new-week-available');\n // One-time toast notification when new data first arrives\n const storageKey = `kantine_notified_nextweek_${nextYear}_${nextWeek}`;\n if (!localStorage.getItem(storageKey)) {\n localStorage.setItem(storageKey, 'true');\n showToast('Neue Men\u00fcdaten f\u00fcr n\u00e4chste Woche verf\u00fcgbar!', 'info');\n }\n } else {\n btnNextWeek.classList.remove('new-week-available');\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 = `<span class=\"material-icons-round\">shopping_bag</span> <span>Gesamt: ${totalCost.toFixed(2).replace('.', ',')} \u20ac</span>`;\n costDisplay.classList.remove('hidden');\n } else {\n costDisplay.classList.add('hidden');\n }\n }\n\n // === Render Weeks ===\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 <div class=\"empty-state\">\n <p>Keine Men\u00fcdaten f\u00fcr KW ${targetWeek} (${targetYear}) verf\u00fcgbar.</p>\n <small>Versuchen Sie eine andere Woche oder schauen Sie sp\u00e4ter vorbei.</small>\n </div>`;\n document.getElementById('weekly-cost-display').classList.add('hidden');\n return;\n }\n\n updateWeeklyCost(daysInTargetWeek);\n\n // Update header\n const headerWeekInfo = document.getElementById('header-week-info');\n const weekTitle = displayMode === 'this-week' ? 'Diese Woche' : 'N\u00e4chste Woche';\n headerWeekInfo.innerHTML = `\n <div class=\"header-week-title\">${weekTitle}</div>\n <div class=\"header-week-subtitle\">Week ${targetWeek} \u2022 ${targetYear}</div>`;\n\n const grid = document.createElement('div');\n grid.className = 'days-grid';\n\n daysInTargetWeek.sort((a, b) => a.date.localeCompare(b.date));\n\n // Filter weekends\n const workingDays = daysInTargetWeek.filter(d => {\n const date = new Date(d.date);\n const day = date.getDay();\n return day !== 0 && day !== 6;\n });\n\n workingDays.forEach(day => {\n const card = createDayCard(day);\n if (card) grid.appendChild(card);\n });\n\n menuContainer.appendChild(grid);\n setTimeout(() => syncMenuItemHeights(grid), 0);\n }\n\n // === Sync Item Heights ===\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 => `<span class=\"menu-code-badge\">${code}</span>`).join('');\n\n // Determine Day Status for Header Color\n // Violet: Has Order\n // Green: No Order but Orderable\n // Red: No Order and Not Orderable (Locked/Sold Out)\n let headerClass = '';\n const hasAnyOrder = day.items && day.items.some(item => {\n const articleId = item.articleId || parseInt(item.id.split('_')[1]);\n const key = `${day.date}_${articleId}`;\n return orderMap.has(key) && orderMap.get(key).length > 0;\n });\n\n const hasOrderable = day.items && day.items.some(item => {\n // Use pre-calculated available flag from loadMenuDataFromAPI calculation\n return item.available;\n });\n\n if (hasAnyOrder) {\n headerClass = 'header-violet';\n } else if (hasOrderable && !isPastCutoff) {\n headerClass = 'header-green';\n } else {\n // Red if not orderable (or past cutoff)\n headerClass = 'header-red';\n }\n\n if (headerClass) header.classList.add(headerClass);\n\n header.innerHTML = `\n <div class=\"day-header-left\">\n <span class=\"day-name\">${translateDay(day.weekday)}</span>\n <div class=\"day-badges\">${badgesHtml}</div>\n </div>\n <span class=\"day-date\">${dateStr}</span>`;\n card.appendChild(header);\n\n // Body\n const body = document.createElement('div');\n body.className = 'card-body';\n\n const todayDateStr = new Date().toISOString().split('T')[0];\n const isToday = day.date === todayDateStr;\n\n const sortedItems = [...day.items].sort((a, b) => {\n if (isToday) {\n const aId = a.articleId || parseInt(a.id.split('_')[1]);\n const bId = b.articleId || parseInt(b.id.split('_')[1]);\n const aOrdered = orderMap.has(`${day.date}_${aId}`);\n const bOrdered = orderMap.has(`${day.date}_${bId}`);\n\n if (aOrdered && !bOrdered) return -1;\n if (!aOrdered && bOrdered) return 1;\n }\n return a.name.localeCompare(b.name);\n });\n\n sortedItems.forEach(item => {\n const itemEl = document.createElement('div');\n itemEl.className = 'menu-item';\n\n const articleId = item.articleId || parseInt(item.id.split('_')[1]);\n const orderKey = `${day.date}_${articleId}`;\n const orderIds = orderMap.get(orderKey) || [];\n const orderCount = orderIds.length;\n\n // Status badge\n let statusBadge = '';\n if (item.available) {\n statusBadge = item.amountTracking\n ? `<span class=\"badge available\">Verf\u00fcgbar (${item.availableAmount})</span>`\n : `<span class=\"badge available\">Verf\u00fcgbar</span>`;\n } else {\n statusBadge = `<span class=\"badge sold-out\">Ausverkauft</span>`;\n }\n\n // Order badge\n let orderedBadge = '';\n if (orderCount > 0) {\n const countBadge = orderCount > 1 ? `<span class=\"order-count-badge\">${orderCount}</span>` : '';\n orderedBadge = `<span class=\"badge ordered\"><span class=\"material-icons-round\">check_circle</span> Bestellt${countBadge}</span>`;\n itemEl.classList.add('ordered');\n if (new Date(day.date).toDateString() === now.toDateString()) {\n itemEl.classList.add('today-ordered');\n }\n }\n\n // Flagged styles\n const flagId = `${day.date}_${articleId}`;\n const isFlagged = userFlags.has(flagId);\n if (isFlagged) {\n itemEl.classList.add(item.available ? 'flagged-available' : 'flagged-sold-out');\n }\n\n // Highlight matching menu items based on user tags\n const matchedTags = [...new Set([...checkHighlight(item.name), ...checkHighlight(item.description)])];\n if (matchedTags.length > 0) {\n itemEl.classList.add('highlight-glow');\n }\n\n // Action buttons\n let orderButton = '';\n let cancelButton = '';\n let flagButton = '';\n\n if (authToken && !isPastCutoff) {\n // Flag button\n const flagIcon = isFlagged ? 'notifications_active' : 'notifications_none';\n const flagClass = isFlagged ? 'btn-flag active' : 'btn-flag';\n const flagTitle = isFlagged ? 'Benachrichtigung deaktivieren' : 'Benachrichtigen wenn verf\u00fcgbar';\n if (!item.available || isFlagged) {\n flagButton = `<button class=\"${flagClass}\" data-date=\"${day.date}\" data-article=\"${articleId}\" data-name=\"${escapeHtml(item.name)}\" data-cutoff=\"${day.orderCutoff}\" title=\"${flagTitle}\"><span class=\"material-icons-round\">${flagIcon}</span></button>`;\n }\n\n // Order button\n if (item.available) {\n if (orderCount > 0) {\n orderButton = `<button class=\"btn-order btn-order-compact\" data-date=\"${day.date}\" data-article=\"${articleId}\" data-name=\"${escapeHtml(item.name)}\" data-price=\"${item.price}\" data-desc=\"${escapeHtml(item.description || '')}\" title=\"${escapeHtml(item.name)} nochmal bestellen\"><span class=\"material-icons-round\">add</span></button>`;\n } else {\n orderButton = `<button class=\"btn-order\" data-date=\"${day.date}\" data-article=\"${articleId}\" data-name=\"${escapeHtml(item.name)}\" data-price=\"${item.price}\" data-desc=\"${escapeHtml(item.description || '')}\" title=\"${escapeHtml(item.name)} bestellen\"><span class=\"material-icons-round\">add_shopping_cart</span> Bestellen</button>`;\n }\n }\n\n // Cancel button\n if (orderCount > 0) {\n const cancelIcon = orderCount === 1 ? 'close' : 'remove';\n const cancelTitle = orderCount === 1 ? 'Bestellung stornieren' : 'Eine Bestellung stornieren';\n cancelButton = `<button class=\"btn-cancel\" data-date=\"${day.date}\" data-article=\"${articleId}\" data-name=\"${escapeHtml(item.name)}\" title=\"${cancelTitle}\"><span class=\"material-icons-round\">${cancelIcon}</span></button>`;\n }\n }\n\n // Build matched-tags HTML (only if tags found)\n let tagsHtml = '';\n if (matchedTags.length > 0) {\n const badges = matchedTags.map(t => `<span class=\"tag-badge-small\"><span class=\"material-icons-round\" style=\"font-size:10px;margin-right:2px\">star</span>${escapeHtml(t)}</span>`).join('');\n tagsHtml = `<div class=\"matched-tags\">${badges}</div>`;\n }\n\n itemEl.innerHTML = `\n <div class=\"item-header\">\n <span class=\"item-name\">${escapeHtml(item.name)}</span>\n <span class=\"item-price\">${item.price.toFixed(2)} \u20ac</span>\n </div>\n <div class=\"item-status-row\">\n ${orderedBadge}\n ${cancelButton}\n ${orderButton}\n ${flagButton}\n <div class=\"badges\">${statusBadge}</div>\n </div>\n ${tagsHtml}\n <p class=\"item-desc\">${escapeHtml(getLocalizedText(item.description))}</p>`;\n\n // Event: Order\n const orderBtn = itemEl.querySelector('.btn-order');\n if (orderBtn) {\n orderBtn.addEventListener('click', (e) => {\n e.stopPropagation();\n const btn = e.currentTarget;\n btn.disabled = true;\n btn.classList.add('loading');\n placeOrder(btn.dataset.date, parseInt(btn.dataset.article), btn.dataset.name, parseFloat(btn.dataset.price), btn.dataset.desc || '')\n .finally(() => { btn.disabled = false; btn.classList.remove('loading'); });\n });\n }\n\n // Event: Cancel\n const cancelBtn = itemEl.querySelector('.btn-cancel');\n if (cancelBtn) {\n cancelBtn.addEventListener('click', (e) => {\n e.stopPropagation();\n const btn = e.currentTarget;\n btn.disabled = true;\n cancelOrder(btn.dataset.date, parseInt(btn.dataset.article), btn.dataset.name)\n .finally(() => { btn.disabled = false; });\n });\n }\n\n // Event: Flag\n const flagBtn = itemEl.querySelector('.btn-flag');\n if (flagBtn) {\n flagBtn.addEventListener('click', (e) => {\n e.stopPropagation();\n const btn = e.currentTarget;\n toggleFlag(btn.dataset.date, parseInt(btn.dataset.article), btn.dataset.name, btn.dataset.cutoff);\n });\n }\n\n body.appendChild(itemEl);\n });\n\n card.appendChild(body);\n return card;\n }\n\n // === GitHub Release Management ===\n\n // Semver comparison: returns true if remote > local\n function isNewer(remote, local) {\n if (!remote || !local) return false;\n const r = remote.replace(/^v/, '').split('.').map(Number);\n const l = local.replace(/^v/, '').split('.').map(Number);\n for (let i = 0; i < Math.max(r.length, l.length); i++) {\n if ((r[i] || 0) > (l[i] || 0)) return true;\n if ((r[i] || 0) < (l[i] || 0)) return false;\n }\n return false;\n }\n\n // GitHub API headers\n function githubHeaders() {\n return { 'Accept': 'application/vnd.github.v3+json' };\n }\n\n // Fetch versions from GitHub (releases or tags)\n async function fetchVersions(devMode) {\n const endpoint = devMode\n ? `${GITHUB_API}/tags?per_page=20`\n : `${GITHUB_API}/releases?per_page=20`;\n\n const resp = await fetch(endpoint, { headers: githubHeaders() });\n if (!resp.ok) {\n if (resp.status === 403) {\n throw new Error('API Rate Limit erreicht (403). Bitte sp\u00e4ter erneut versuchen.');\n }\n throw new Error(`GitHub API ${resp.status}`);\n }\n const data = await resp.json();\n\n // Normalize to common format: { tag, name, url, body }\n return data.map(item => {\n const tag = devMode ? item.name : item.tag_name;\n return {\n tag,\n name: devMode ? tag : (item.name || tag),\n url: `${INSTALLER_BASE}/${tag}/dist/install.html`,\n body: item.body || ''\n };\n });\n }\n\n // Periodic update check (runs on init + every hour)\n async function checkForUpdates() {\n const currentVersion = 'v1.6.3';\n const devMode = localStorage.getItem('kantine_dev_mode') === 'true';\n\n try {\n const versions = await fetchVersions(devMode);\n if (!versions.length) return;\n\n // Cache for version menu\n localStorage.setItem('kantine_version_cache', JSON.stringify({\n timestamp: Date.now(), devMode, versions\n }));\n\n const latest = versions[0].tag;\n console.log(`[Kantine] Version Check: Local [${currentVersion}] vs Latest [${latest}] (${devMode ? 'dev' : 'stable'})`);\n\n if (!isNewer(latest, currentVersion)) return;\n\n console.log(`[Kantine] Update verf\u00fcgbar: ${latest}`);\n\n // Show \ud83c\udd95 icon in header (only once)\n const headerTitle = document.querySelector('.header-left h1');\n if (headerTitle && !headerTitle.querySelector('.update-icon')) {\n const icon = document.createElement('a');\n icon.className = 'update-icon';\n icon.href = versions[0].url;\n icon.target = '_blank';\n icon.innerHTML = '\ud83c\udd95';\n icon.title = `Update: ${latest} \u2014 Klick zum Installieren`;\n icon.style.cssText = 'margin-left:8px;font-size:1em;text-decoration:none;cursor:pointer;vertical-align:middle;';\n headerTitle.appendChild(icon);\n }\n } catch (e) {\n console.warn('[Kantine] Version check failed:', e);\n }\n }\n\n // Open Version Menu modal\n function openVersionMenu() {\n const modal = document.getElementById('version-modal');\n const container = document.getElementById('version-list-container');\n const devToggle = document.getElementById('dev-mode-toggle');\n const currentVersion = 'v1.6.3';\n\n if (!modal) return;\n modal.classList.remove('hidden');\n\n // Set current version display\n const cur = document.getElementById('version-current');\n if (cur) cur.textContent = currentVersion;\n\n // Init dev toggle\n const devMode = localStorage.getItem('kantine_dev_mode') === 'true';\n devToggle.checked = devMode;\n\n // Load versions (from cache or fresh)\n async function loadVersions(forceRefresh) {\n const dm = devToggle.checked;\n container.innerHTML = '<p style=\"color:var(--text-secondary);\">Lade Versionen...</p>';\n\n function renderVersionsList(versions) {\n if (!versions || !versions.length) {\n container.innerHTML = '<p style=\"color:var(--text-secondary);\">Keine Versionen gefunden.</p>';\n return;\n }\n\n container.innerHTML = '<ul class=\"version-list\"></ul>';\n const list = container.querySelector('.version-list');\n\n versions.forEach(v => {\n const isCurrent = v.tag === currentVersion;\n const isNew = isNewer(v.tag, currentVersion);\n const li = document.createElement('li');\n li.className = 'version-item' + (isCurrent ? ' current' : '');\n\n let badge = '';\n if (isCurrent) badge = '<span class=\"badge-current\">\u2713 Installiert</span>';\n else if (isNew) badge = '<span class=\"badge-new\">\u2b06 Neu!</span>';\n\n let action = '';\n if (!isCurrent) {\n action = `<a href=\"${v.url}\" target=\"_blank\" class=\"install-link\" title=\"${v.tag} installieren\">Installieren</a>`;\n }\n\n li.innerHTML = `\n <div class=\"version-info\">\n <strong>${v.tag}</strong>\n ${badge}\n </div>\n ${action}\n `;\n list.appendChild(li);\n });\n }\n\n try {\n // 1. Show cached versions immediately if available\n const cachedRaw = localStorage.getItem('kantine_version_cache');\n let cached = null;\n if (cachedRaw) {\n try { cached = JSON.parse(cachedRaw); } catch (e) { }\n }\n\n if (cached && cached.devMode === dm && cached.versions) {\n renderVersionsList(cached.versions);\n }\n\n // 2. Fetch fresh versions in background (or foreground if no cache)\n const liveVersions = await fetchVersions(dm);\n\n // Compare with cache to see if we need to re-render\n const liveVersionsStr = JSON.stringify(liveVersions);\n const cachedVersionsStr = cached ? JSON.stringify(cached.versions) : '';\n\n if (liveVersionsStr !== cachedVersionsStr) {\n localStorage.setItem('kantine_version_cache', JSON.stringify({\n timestamp: Date.now(), devMode: dm, versions: liveVersions\n }));\n renderVersionsList(liveVersions);\n }\n\n } catch (e) {\n container.innerHTML = `<p style=\"color:#e94560;\">Fehler: ${e.message}</p>`;\n }\n }\n\n loadVersions(false);\n\n // Dev toggle handler\n devToggle.onchange = () => {\n localStorage.setItem('kantine_dev_mode', devToggle.checked);\n // Clear cache to force refresh when mode changes\n localStorage.removeItem('kantine_version_cache');\n loadVersions(true);\n };\n }\n\n // === Order Countdown ===\n function updateCountdown() {\n // Only show order alarms for logged-in users\n if (!authToken || !currentUser) {\n removeCountdown();\n return;\n }\n\n const now = new Date();\n const currentDay = now.getDay();\n // Skip weekends (0=Sun, 6=Sat)\n if (currentDay === 0 || currentDay === 6) {\n removeCountdown();\n return;\n }\n\n const todayStr = now.toISOString().split('T')[0];\n\n // 1. Check if we already ordered for today\n let hasOrder = false;\n // Optimization: Check orderMap for today's date\n // Keys are \"YYYY-MM-DD_ArticleID\"\n for (const key of orderMap.keys()) {\n if (key.startsWith(todayStr)) {\n hasOrder = true;\n break;\n }\n }\n\n if (hasOrder) {\n removeCountdown();\n return;\n }\n\n // 2. Calculate time to cutoff (10:00 AM)\n const cutoff = new Date();\n cutoff.setHours(10, 0, 0, 0);\n\n const diff = cutoff - now;\n\n // If passed cutoff or more than 3 hours away (e.g. 07:00), maybe don't show?\n // User req: \"heute noch keine bestellung... countdown erscheinen\"\n // Let's show it if within valid order window (e.g. 00:00 - 10:00)\n\n if (diff <= 0) {\n removeCountdown();\n return;\n }\n\n // 3. Render Countdown\n const diffHrs = Math.floor(diff / 3600000);\n const diffMins = Math.floor((diff % 3600000) / 60000);\n\n const headerCenter = document.querySelector('.header-center-wrapper');\n if (!headerCenter) return;\n\n let countdownEl = document.getElementById('order-countdown');\n if (!countdownEl) {\n countdownEl = document.createElement('div');\n countdownEl.id = 'order-countdown';\n // Insert before cost display or append\n headerCenter.insertBefore(countdownEl, headerCenter.firstChild);\n }\n\n countdownEl.innerHTML = `<span>Bestellschluss:</span> <strong>${diffHrs}h ${diffMins}m</strong>`;\n\n // Red Alert if < 1 hour\n if (diff < 3600000) { // 1 hour\n countdownEl.classList.add('urgent');\n\n // Notification logic (One time)\n const notifiedKey = `kantine_notified_${todayStr}`;\n if (!localStorage.getItem(notifiedKey)) {\n if (Notification.permission === 'granted') {\n new Notification('Kantine: Bestellschluss naht!', {\n body: 'Du hast heute noch nichts bestellt. Nur noch 1 Stunde!',\n icon: '\u23f3'\n });\n } else if (Notification.permission === 'default') {\n Notification.requestPermission();\n }\n localStorage.setItem(notifiedKey, 'true');\n }\n } else {\n countdownEl.classList.remove('urgent');\n }\n }\n\n function removeCountdown() {\n const el = document.getElementById('order-countdown');\n if (el) el.remove();\n }\n\n // Update countdown every minute\n setInterval(updateCountdown, 60000);\n // Also update on load\n setTimeout(updateCountdown, 1000);\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\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 // === Language Filter (FR-100) ===\n // DE stems for fallback language detection\n const DE_STEMS = [\n 'mit', 'und', 'oder', 'f\u00fcr', 'vom', 'zum', 'zur', 'gebraten', 'kartoffel', 'gem\u00fcse', 'suppe',\n 'kuchen', 'schwein', 'rind', 'h\u00e4hnchen', 'huhn', 'fisch', 'nudel', 'so\u00dfe', 'sosse', 'wurst',\n 'k\u00fcrbis', 'braten', 'sahne', 'apfel', 'k\u00e4se', 'fleisch', 'pilz', 'kirsch', 'joghurt', 'sp\u00e4tzle',\n 'kn\u00f6del', 'kraut', 'schnitzel', 'p\u00fcree', 'rahm', 'erdbeer', 'schoko', 'vanille', 'tomate',\n 'gurke', 'salat', 'zwiebel', 'paprika', 'reis', 'bohne', 'erbse', 'karotte', 'm\u00f6hre', 'lauch',\n 'knoblauch', 'chili', 'gew\u00fcrz', 'kr\u00e4uter', 'pfeffer', 'salz', 'butter', 'milch', 'eier',\n 'pfanne', 'auflauf', 'gratin', 'ragout', 'gulasch', 'eintopf', 'filet', 'steak', 'brust',\n 'salami', 'schinken', 'speck', 'brokkoli', 'blumenkohl', 'zucchini', 'aubergine',\n 'spinat', 'spargel', 'olive', 'mandel', 'nuss', 'honig', 'senf', 'essig', '\u00f6l', 'brot',\n 'br\u00f6tchen', 'pfannkuchen', 'eis', 'torte', 'dessert', 'kompott', 'obst', 'frucht', 'beere',\n 'plunder', 'dip', 'tofu', 'jambalaya'\n ];\n const EN_STEMS = [\n 'with', 'and', 'or', 'for', 'from', 'to', 'fried', 'potato', 'vegetable', 'soup', 'cake',\n 'pork', 'beef', 'chicken', 'fish', 'noodle', 'sauce', 'sausage', 'pumpkin', 'roast',\n 'cream', 'apple', 'cheese', 'meat', 'mushroom', 'cherry', 'yogurt', 'wedge', 'sweet',\n 'sour', 'dumpling', 'cabbage', 'mash', 'strawberr', 'choco', 'vanilla', 'tomat', 'cucumber',\n 'salad', 'onion', 'pepper', 'rice', 'bean', 'pea', 'carrot', 'leek', 'garlic', 'chili',\n 'spice', 'herb', 'salt', 'butter', 'milk', 'egg', 'pan', 'casserole', 'gratin', 'ragout',\n 'goulash', 'stew', 'filet', 'steak', 'breast', 'salami', 'ham', 'bacon', 'broccoli',\n 'cauliflower', 'zucchini', 'eggplant', 'spinach', 'asparagus', 'olive', 'almond', 'nut',\n 'honey', 'mustard', 'vinegar', 'oil', 'bread', 'bun', 'pancake', 'ice', 'tart', 'dessert',\n 'compote', 'fruit', 'berry', 'dip', 'danish', 'tofu', 'jambalaya'\n ];\n\n /**\n * Splits bilingual menu text into DE and EN parts.\n * Pattern per course: [DE] / [EN](ALLERGENS)\n * Max 3 courses per menu item (sanity check).\n * @param {string} text - The bilingual description text\n * @returns {{ de: string, en: string, raw: string }}\n */\n function splitLanguage(text) {\n if (!text) return { de: '', en: '', raw: '' };\n\n const raw = text;\n const formattedRaw = '\u2022 ' + text.replace(/\\(([A-Z ]+)\\)\\s*(?=\\S)/g, '($1)\\n\u2022 ');\n\n // Utility to compute DE/EN score for a subset of words\n function scoreBlock(wordArray) {\n let de = 0, en = 0;\n wordArray.forEach(word => {\n const w = word.toLowerCase().replace(/[^a-z\u00e4\u00f6\u00fc\u00df]/g, '');\n if (w) {\n let bestDeMatch = 0;\n let bestEnMatch = 0;\n // Full match is better than partial string match\n if (DE_STEMS.includes(w)) bestDeMatch = w.length;\n else DE_STEMS.forEach(s => { if (w.includes(s) && s.length > bestDeMatch) bestDeMatch = s.length; });\n\n if (EN_STEMS.includes(w)) bestEnMatch = w.length;\n else EN_STEMS.forEach(s => { if (w.includes(s) && s.length > bestEnMatch) bestEnMatch = s.length; });\n\n if (bestDeMatch > 0) de += (bestDeMatch / w.length);\n if (bestEnMatch > 0) en += (bestEnMatch / w.length);\n\n // Capitalized noun heuristic matches German text styles typically\n if (/^[A-Z\u00c4\u00d6\u00dc]/.test(word)) {\n de += 0.5;\n }\n }\n });\n return { de, en };\n }\n\n // Heuristic sliding window to split a fragment containing \"EN DE\"\n // E.g., \"Bratwurst with pumpkin Kirschjoghurt\" => enPart: \"Bratwurst with pumpkin\", dePart: \"Kirschjoghurt\"\n function heuristicSplitEnDe(fragment) {\n const words = fragment.trim().split(/\\s+/);\n if (words.length < 2) return { enPart: fragment, nextDe: '' };\n\n let bestK = -1;\n let maxScore = -9999;\n\n for (let k = 1; k < words.length; k++) {\n const left = words.slice(0, k);\n const right = words.slice(k);\n\n const leftScore = scoreBlock(left);\n const rightScore = scoreBlock(right);\n\n // left should be EN, right should be DE\n // Metric = (EN votes in left - DE votes in left) + (DE votes in right - EN votes in right)\n const score = (leftScore.en - leftScore.de) + (rightScore.de - rightScore.en);\n\n // Extra penalty if the split puts a low-case word as the first word of the right (DE) part\n // because a new German sentence usually starts with a capital noun.\n const rightFirstWord = right[0];\n let capitalBonus = 0;\n if (/^[A-Z\u00c4\u00d6\u00dc]/.test(rightFirstWord)) {\n capitalBonus = 2.0;\n }\n\n const finalScore = score + capitalBonus;\n\n if (finalScore > maxScore) {\n maxScore = finalScore;\n bestK = k;\n }\n }\n\n if (bestK !== -1) {\n return {\n enPart: words.slice(0, bestK).join(' '),\n nextDe: words.slice(bestK).join(' ')\n };\n }\n return { enPart: fragment, nextDe: '' };\n }\n\n // Check if text contains the bilingual separator ' / '\n if (!text.includes(' / ')) {\n // Fallback: detect language via keyword scoring\n const words = text.toLowerCase().split(/\\s+/);\n const score = scoreBlock(words);\n\n // No split possible \u2013 return full text for detected language, empty for other\n if (score.en > score.de) {\n return { de: '', en: formattedRaw, raw: formattedRaw };\n }\n return { de: formattedRaw, en: '', raw: formattedRaw };\n }\n\n // Split by ' / ' \u2013 produces alternating DE/EN fragments\n const parts = text.split(' / ');\n // Sanity check: max 3 courses means max 3 slashes \u2192 max 4 parts\n if (parts.length > 4) {\n // Too many slashes \u2013 possibly not bilingual, return as-is\n return { de: formattedRaw, en: '', raw: formattedRaw };\n }\n\n const deParts = [];\n const enParts = [];\n\n // First fragment is always DE (course 1)\n deParts.push(parts[0].trim());\n\n // Process remaining fragments: each contains \"EN(ALLERGENS) next_DE\"\n // Allergen pattern: (LETTERS_AND_SPACES) at the boundary\n const allergenRegex = /\\(([A-Z ]+)\\)\\s*/;\n\n for (let i = 1; i < parts.length; i++) {\n const fragment = parts[i].trim();\n const match = fragment.match(allergenRegex);\n\n if (match) {\n // Split: everything before allergen + allergen = EN, after = next DE\n const allergenEnd = match.index + match[0].length;\n const enPart = fragment.substring(0, match.index).trim();\n const allergenCode = match[1];\n const nextDe = fragment.substring(allergenEnd).trim();\n\n enParts.push(enPart + '(' + allergenCode + ')');\n // Also append allergen to the last DE part\n if (deParts.length > 0) {\n deParts[deParts.length - 1] = deParts[deParts.length - 1] + '(' + allergenCode + ')';\n }\n\n if (nextDe) {\n deParts.push(nextDe);\n }\n } else {\n // No allergen code found!\n // If this is the last fragment, it contains only the English text of the final course.\n // It should not be split again.\n if (i === parts.length - 1) {\n enParts.push(fragment);\n } else {\n // We use the heuristic to find the hidden split-point.\n const split = heuristicSplitEnDe(fragment);\n enParts.push(split.enPart);\n if (split.nextDe) {\n deParts.push(split.nextDe);\n }\n }\n }\n }\n\n return {\n de: deParts.map(p => '\u2022 ' + p).join('\\n'),\n en: enParts.map(p => '\u2022 ' + p).join('\\n'),\n raw: formattedRaw\n };\n }\n\n /**\n * Returns text filtered by the current language mode.\n * @param {string} text - The bilingual text\n * @returns {string}\n */\n function getLocalizedText(text) {\n if (langMode === 'all') return text || '';\n const split = splitLanguage(text);\n if (langMode === 'en') return split.en || split.raw;\n return split.de || split.raw; // 'de' is default\n }\n\n // === Bootstrap ===\n injectUI();\n bindEvents();\n updateAuthUI();\n cleanupExpiredFlags();\n\n // Load cached data first for instant UI, refresh only if stale (FR-024)\n const hadCache = loadMenuCache();\n if (hadCache) {\n document.getElementById('loading').classList.add('hidden');\n if (!isCacheFresh()) {\n console.log('Cache stale or incomplete \u2013 refreshing from API');\n loadMenuDataFromAPI();\n } else {\n console.log('Cache fresh & complete \u2013 skipping API refresh');\n }\n } else {\n loadMenuDataFromAPI();\n }\n\n // Auto-start polling if already logged in\n if (authToken) {\n startPolling();\n }\n\n // Check for updates (now + every hour)\n checkForUpdates();\n setInterval(checkForUpdates, 60 * 60 * 1000);\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 <div class=\"modal-content\">\n <div class=\"modal-header\">\n <h2 style=\"color: var(--error-color); display: flex; align-items: center; gap: 10px;\">\n <span class=\"material-icons-round\">signal_wifi_off</span>\n ${title}\n </h2>\n </div>\n <div style=\"padding: 20px;\">\n <p style=\"margin-bottom: 15px; color: var(--text-primary);\">${htmlContent}</p>\n <div style=\"margin-top: 20px; display: flex; justify-content: center;\">\n <button id=\"btn-error-redirect\" style=\"\n background-color: var(--accent-color); \n color: white; \n padding: 12px 24px; \n border-radius: 8px; \n border: none; \n font-weight: 600; \n cursor: pointer;\n display: flex;\n align-items: center;\n gap: 8px;\n width: 100%;\n justify-content: center;\n transition: transform 0.1s;\n \">\n ${btnText}\n <span class=\"material-icons-round\">open_in_new</span>\n </button>\n </div>\n </div>\n </div>\n `;\n document.body.appendChild(modal);\n\n document.getElementById('btn-error-redirect').addEventListener('click', () => {\n window.location.href = url;\n });\n\n requestAnimationFrame(() => {\n modal.classList.remove('hidden');\n });\n}\n";
document.head.appendChild(sc);
})();