Lade Men\u00fcdaten...
\njavascript:(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: 2rem 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: 1rem 2rem; color: var(--text-secondary); font-size: 0.875rem; 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,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';\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
Lade Men\u00fcdaten...
\nFehler beim Laden der Historie.
`;\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 = 'Keine Bestellungen gefunden.
';\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 += `Keine Men\u00fcdaten f\u00fcr KW ${targetWeek} (${targetYear}) verf\u00fcgbar.
\n Versuchen Sie eine andere Woche oder schauen Sie sp\u00e4ter vorbei.\n${escapeHtml(getLocalizedText(item.description))}
`;\n\n // Event: Order\n const orderBtn = itemEl.querySelector('.btn-order');\n if (orderBtn) {\n orderBtn.addEventListener('click', (e) => {\n e.stopPropagation();\n const btn = e.currentTarget;\n btn.disabled = true;\n btn.classList.add('loading');\n placeOrder(btn.dataset.date, parseInt(btn.dataset.article), btn.dataset.name, parseFloat(btn.dataset.price), btn.dataset.desc || '')\n .finally(() => { btn.disabled = false; btn.classList.remove('loading'); });\n });\n }\n\n // Event: Cancel\n const cancelBtn = itemEl.querySelector('.btn-cancel');\n if (cancelBtn) {\n cancelBtn.addEventListener('click', (e) => {\n e.stopPropagation();\n const btn = e.currentTarget;\n btn.disabled = true;\n cancelOrder(btn.dataset.date, parseInt(btn.dataset.article), btn.dataset.name)\n .finally(() => { btn.disabled = false; });\n });\n }\n\n // Event: Flag\n const flagBtn = itemEl.querySelector('.btn-flag');\n if (flagBtn) {\n flagBtn.addEventListener('click', (e) => {\n e.stopPropagation();\n const btn = e.currentTarget;\n toggleFlag(btn.dataset.date, parseInt(btn.dataset.article), btn.dataset.name, btn.dataset.cutoff);\n });\n }\n\n body.appendChild(itemEl);\n });\n\n card.appendChild(body);\n return card;\n }\n\n // === 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.2';\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.2';\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 = 'Lade Versionen...
';\n\n function renderVersionsList(versions) {\n if (!versions || !versions.length) {\n container.innerHTML = 'Keine Versionen gefunden.
';\n return;\n }\n\n container.innerHTML = 'Fehler: ${e.message}
`;\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 = `Bestellschluss: ${diffHrs}h ${diffMins}m`;\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 it's not the last part (or even if it is, but we highly suspect merged languages),\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 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 \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); })();