From 6cee38e99f8551ecc610e29854f90abfdbfb8bbe Mon Sep 17 00:00:00 2001 From: Kantine Wrapper Date: Thu, 26 Feb 2026 08:35:15 +0100 Subject: [PATCH] chore: update build artifacts for v1.4.24 --- dist/bookmarklet-payload.js | 2 +- dist/bookmarklet.txt | 2 +- dist/install.html | 16 ++++++++++------ dist/kantine-standalone.html | 11 ++++++----- 4 files changed, 18 insertions(+), 13 deletions(-) diff --git a/dist/bookmarklet-payload.js b/dist/bookmarklet-payload.js index d835975..00d80f9 100755 --- a/dist/bookmarklet-payload.js +++ b/dist/bookmarklet-payload.js @@ -4,6 +4,6 @@ var s=document.createElement('style'); s.textContent=':root { /* Premium Slate/Gray-Blue Palette - Light Mode */ --bg-body: #f1f5f9; /* Slate 100 */ --bg-card: #ffffff; --text-primary: #334155; /* Slate 700 */ --text-secondary: #64748b; --accent-color: #0f172a; /* Slate 900 (High contrast) */ --border-color: #cbd5e1; /* Slate 300 */ --banner-bg: #e2e8f0; --banner-text: #1e293b; --success-color: #059669; --error-color: #dc2626; --card-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.05), 0 2px 4px -2px rgb(0 0 0 / 0.05); --header-bg: rgba(255, 255, 255, 0.9); --header-border: 1px solid rgba(203, 213, 225, 0.6); } [data-theme="dark"] { /* Premium Slate/Gray-Blue Palette - Dark Mode */ --bg-body: #1e293b; /* Deep Slate Gray (Requested) */ --bg-card: #334155; /* Slate 700 */ --text-primary: #f8fafc; /* Slate 50 */ --text-secondary: #cbd5e1; /* Slate 300 */ --accent-color: #60a5fa; /* Blue 400 */ --border-color: #475569; /* Slate 600 */ --banner-bg: #475569; --banner-text: #e2e8f0; --header-bg: rgba(30, 41, 59, 0.9); --header-border: 1px solid rgba(71, 85, 105, 0.6); --card-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.4); } * { box-sizing: border-box; margin: 0; padding: 0; } body { font-family: \'Inter\', system-ui, -apple-system, sans-serif; background-color: var(--bg-body); color: var(--text-primary); transition: background-color 0.3s ease, color 0.3s ease; line-height: 1.5; -webkit-font-smoothing: antialiased; } /* Fix scrolling bug: Reset html/body styles from host page */ html, body { height: auto !important; min-height: 100% !important; overflow-y: auto !important; overflow-x: hidden !important; position: static !important; margin: 0 !important; padding: 0 !important; } /* Header */ .app-header { position: sticky; top: 0; z-index: 100; backdrop-filter: blur(12px); background-color: var(--header-bg); border-bottom: var(--header-border); padding: 1rem 0; } .header-content { width: 100%; /* Full width */ padding: 0 2rem; /* Comfortable padding */ display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; gap: 1rem; } .brand { display: flex; align-items: center; gap: 0.75rem; } .brand-text { display: flex; flex-direction: column; } .brand h1 { font-size: 1.25rem; font-weight: 700; letter-spacing: -0.025em; margin-bottom: 0; } .subtitle { font-size: 0.85rem; color: var(--text-secondary); font-weight: 400; margin-left: 2px; } .logo-icon { font-size: 1.5rem; color: var(--accent-color); } /* Controls */ .controls { display: flex; align-items: center; gap: 1.5rem; justify-self: end; } /* Header Week Info (centered) */ .header-week-info { text-align: center; line-height: 1.3; } .header-center-wrapper { display: flex; flex-direction: row; align-items: center; gap: 1.5rem; justify-content: center; } .header-week-title { font-size: 1.1rem; font-weight: 600; color: var(--text-primary); } .header-week-subtitle { font-size: 0.85rem; color: var(--text-secondary); } .nav-group { display: flex; background-color: var(--bg-card); border: 1px solid var(--border-color); padding: 0.25rem; border-radius: 8px; } .nav-btn { background: none; border: none; padding: 0.5rem 1rem; font-size: 0.875rem; font-weight: 500; color: var(--text-secondary); cursor: pointer; border-radius: 6px; transition: all 0.2s; display: flex; align-items: center; gap: 0.5rem; } .nav-btn:hover { color: var(--text-primary); background-color: rgba(100, 116, 139, 0.1); } .nav-btn.active { background-color: var(--accent-color); color: white; } /* 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 */ .container { width: 100%; /* Full width */ margin: 2rem auto; padding: 0 2rem; min-height: 80vh; } /* Banner */ .banner { background-color: var(--banner-bg); color: var(--banner-text); padding: 0.75rem 1rem; border-radius: 8px; display: flex; align-items: center; gap: 0.5rem; margin-bottom: 2rem; font-size: 0.875rem; font-weight: 500; border: 1px solid var(--border-color); max-width: fit-content; } /* User Badge */ .user-badge { display: flex; align-items: center; gap: 8px; padding: 6px 12px; background: var(--bg-card); /* Changed from --surface */ border: 1px solid var(--border-color); /* Changed from --border */ border-radius: 20px; font-size: 0.9rem; font-weight: 500; } .icon-btn-small { background: none; border: none; padding: 4px; cursor: pointer; color: var(--text-secondary); /* Changed from --text-muted */ display: flex; align-items: center; justify-content: center; border-radius: 50%; transition: all 0.2s; } .icon-btn-small:hover { color: var(--error-color); /* Changed from --danger */ background: rgba(239, 68, 68, 0.1); } /* Modal */ .modal { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0, 0, 0, 0.5); backdrop-filter: blur(4px); display: flex; align-items: center; justify-content: center; z-index: 1000; transition: all 0.3s; } .modal.hidden { opacity: 0; pointer-events: none; } .modal-content { background: var(--bg-card); 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 */ .menu-grid { display: grid; gap: 2rem; } .week-section { margin-bottom: 3rem; } .week-header { margin-bottom: 1.5rem; border-bottom: 1px solid var(--border-color); padding-bottom: 1rem; text-align: center; } .week-title { font-size: 1.75rem; font-weight: 700; color: var(--text-primary); } .week-range { color: var(--text-secondary); font-size: 0.9rem; margin-top: 0.25rem; } .days-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 0.75rem; } /* Card */ .menu-card { background-color: var(--bg-card); border-radius: 12px; border: 1px solid var(--border-color); box-shadow: var(--card-shadow); overflow: hidden; transition: transform 0.2s ease, box-shadow 0.2s ease; display: flex; flex-direction: column; } /* Past Day Styling - Target specific elements so ordered items can remain visible */ .menu-card.past-day .card-header, .menu-card.past-day .menu-item:not(.ordered) { opacity: 0.6; filter: grayscale(0.8); transition: opacity 0.3s, filter 0.3s; } .menu-card.past-day:hover .card-header, .menu-card.past-day:hover .menu-item:not(.ordered) { opacity: 0.8; filter: grayscale(0.4); } /* Enhancements for ordered items */ .menu-card.past-day .menu-item.ordered { /* No opacity/filter here - fully visible */ background: var(--bg-card); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); border: 1px solid #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 { transform: translateY(-2px); box-shadow: 0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1); } .card-header { padding: 1rem 1.25rem; border-bottom: 1px solid var(--border-color); display: flex; justify-content: space-between; align-items: baseline; background-color: rgba(100, 116, 139, 0.05); } .day-name { font-size: 1.125rem; font-weight: 600; } .day-date { font-size: 0.875rem; color: var(--text-secondary); } .card-body { padding: 1.25rem; display: grid; grid-template-rows: auto; /* Each menu item gets its own row */ align-content: start; } .empty-state { color: var(--text-secondary); font-style: italic; text-align: center; padding: 1rem; } /* Menu Items */ .menu-item { margin-bottom: 1.5rem; padding-bottom: 1.5rem; border-bottom: 1px solid var(--border-color); } .menu-item:last-child { margin-bottom: 0; padding-bottom: 0; border-bottom: none; } .item-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 0.5rem; gap: 1rem; } .item-name { font-weight: 600; color: var(--text-primary); font-size: 1rem; } .item-price { font-weight: 700; color: var(--accent-color); white-space: nowrap; } .item-desc { font-size: 0.875rem; color: var(--text-secondary); line-height: 1.6; margin-bottom: 0.75rem; } .badges { display: flex; gap: 0.5rem; margin-left: auto; } .item-status-row { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.75rem; } .badge { display: inline-flex; align-items: center; justify-content: center; height: 24px; font-size: 0.75rem; padding: 0 10px; border-radius: 4px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; line-height: normal; white-space: nowrap; } .badge.available { background-color: rgba(16, 185, 129, 0.1); /* Emerald 500 / 10% */ color: var(--success-color); border: 1px solid rgba(16, 185, 129, 0.2); } .badge.sold-out { background-color: rgba(239, 68, 68, 0.1); /* Red 500 / 10% */ color: var(--error-color); border: 1px solid rgba(239, 68, 68, 0.2); } .badge.ordered { background-color: rgba(139, 92, 246, 0.1); /* Violet 500 / 10% */ color: #8b5cf6; border: 1px solid rgba(139, 92, 246, 0.2); gap: 4px; } .badge.ordered .material-icons-round { font-size: 1rem; } /* Loading */ .loading-state { text-align: center; padding: 4rem; color: var(--text-secondary); } .spinner { width: 40px; height: 40px; border: 3px solid var(--border-color); border-top-color: var(--accent-color); border-radius: 50%; margin: 0 auto 1rem; animation: spin 1s linear infinite; } @keyframes spin { to { transform: rotate(360deg); } } /* Footer */ .app-footer { text-align: center; padding: 2rem; color: var(--text-secondary); font-size: 0.875rem; border-top: 1px solid var(--border-color); margin-top: auto; } /* === Order / Cancel Buttons (inline in status row) === */ .btn-order { display: inline-flex; align-items: center; gap: 4px; padding: 4px 10px; border: none; border-radius: 6px; background: var(--success-color); color: white; font-size: 0.75rem; font-weight: 600; cursor: pointer; transition: all 0.2s ease; font-family: inherit; } .btn-order .material-icons-round { font-size: 16px; } .btn-order:hover:not(:disabled) { filter: brightness(1.15); transform: translateY(-1px); } .btn-order:disabled { opacity: 0.5; cursor: not-allowed; } .btn-order.loading { pointer-events: none; opacity: 0.6; } .btn-order-compact { padding: 2px 4px; gap: 0; } .btn-order-compact .material-icons-round { font-size: 16px; } .btn-cancel { display: inline-flex; align-items: center; justify-content: center; padding: 4px 6px; border: none; border-radius: 6px; background: var(--error-color); color: white; font-size: 0.75rem; cursor: pointer; transition: all 0.2s ease; font-family: inherit; } .btn-cancel .material-icons-round { font-size: 16px; } .btn-cancel:hover:not(:disabled) { filter: brightness(1.15); transform: translateY(-1px); } .btn-cancel:disabled { opacity: 0.5; cursor: not-allowed; } /* Past days: hide action buttons */ .past-day .item-actions { display: none; } /* Order count badge (for multi-orders) */ .order-count-badge { display: inline-flex; align-items: center; justify-content: center; background: rgba(255, 255, 255, 0.3); color: white; font-size: 0.65rem; font-weight: 700; min-width: 16px; height: 16px; padding: 0 4px; border-radius: 8px; margin-left: 4px; line-height: 1; } /* === Toast Notifications === */ #toast-container { position: fixed; bottom: 20px; right: 20px; z-index: 10000; display: flex; flex-direction: column; gap: 8px; pointer-events: none; } .toast { display: flex; align-items: center; gap: 8px; padding: 10px 16px; border-radius: 8px; font-size: 0.85rem; font-weight: 500; font-family: \'Inter\', sans-serif; color: white; backdrop-filter: blur(10px); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); pointer-events: auto; transform: translateX(120%); opacity: 0; transition: transform 0.3s ease, opacity 0.3s ease; } .toast.show { transform: translateX(0); opacity: 1; } .toast .material-icons-round { font-size: 18px; } .toast-success { background: rgba(5, 150, 105, 0.95); } .toast-error { background: rgba(220, 38, 38, 0.95); } .toast-info { background: rgba(59, 130, 246, 0.95); } /* === Mobile Responsiveness === */ @media (max-width: 600px) { .header-content { flex-direction: column; gap: 1rem; padding: 0.75rem; } .week-nav { width: 100%; justify-content: center; } .nav-pills { width: 100%; justify-content: space-between; } .nav-btn { flex: 1; justify-content: center; padding: 0.5rem; font-size: 0.85rem; } .days-grid { grid-template-columns: 1fr; /* Force single column */ } .main-content { padding: 1rem; } .week-title { font-size: 1.5rem; } /* Adjust toast position for mobile */ .toast-container { bottom: 1rem; right: 1rem; left: 1rem; /* Center on mobile */ width: auto; } .menu-card { margin-bottom: 1rem; } } /* === Flagging & Notification Styles === */ .btn-flag { display: inline-flex; align-items: center; justify-content: center; background: transparent; border: 1px solid var(--text-secondary); color: var(--text-secondary); border-radius: 6px; padding: 4px; cursor: pointer; transition: all 0.2s; margin-right: 0.5rem; width: 28px; height: 28px; } .btn-flag:hover { background: rgba(234, 179, 8, 0.1); /* Yellow-500 / 10% */ color: #eab308; border-color: #eab308; } .btn-flag.active { background: rgba(234, 179, 8, 0.1); color: #eab308; border-color: #eab308; } .btn-flag .material-icons-round { font-size: 1.1rem; } /* Flagged & Sold Out (Yellow Glow) */ .menu-item.flagged-sold-out { border: 1px solid #eab308; box-shadow: 0 0 10px rgba(234, 179, 8, 0.2); border-radius: 8px; padding: 1rem; margin: 0 -1rem 1.5rem -1rem; background: var(--bg-card); position: relative; z-index: 5; animation: yellow-pulse 3s infinite; } @keyframes yellow-pulse { 0% { box-shadow: 0 0 8px rgba(234, 179, 8, 0.2); } 50% { box-shadow: 0 0 16px rgba(234, 179, 8, 0.5); } 100% { box-shadow: 0 0 8px rgba(234, 179, 8, 0.2); } } /* Flagged & Available (Green Glow) */ .menu-item.flagged-available { border: 2px solid var(--success-color); box-shadow: 0 0 15px rgba(16, 185, 129, 0.3); border-radius: 8px; padding: 1rem; margin: 0 -1rem 1.5rem -1rem; background: var(--bg-card); position: relative; z-index: 5; animation: green-pulse 3s infinite; } @keyframes green-pulse { 0% { box-shadow: 0 0 10px rgba(16, 185, 129, 0.3); } 50% { box-shadow: 0 0 20px rgba(16, 185, 129, 0.6); } 100% { box-shadow: 0 0 10px rgba(16, 185, 129, 0.3); } } /* Day Header Badges */ .day-header-left { display: flex; align-items: center; gap: 0.75rem; } .menu-code-badge { font-size: 0.75rem; font-weight: 700; color: #8b5cf6; /* Violet 500 */ background-color: rgba(139, 92, 246, 0.15); border: 1px solid rgba(139, 92, 246, 0.3); padding: 2px 6px; border-radius: 6px; line-height: normal; display: inline-block; } /* Detailed Badge Colors */ .nav-badge.badge-violet { background-color: #8b5cf6; } .nav-badge.badge-green { background-color: var(--success-color); } .nav-badge.badge-red { background-color: var(--error-color); } .nav-badge.badge-blue { background-color: var(--accent-color); } /* Day Header Status Colors (User Request) */ .card-header.header-violet { background-color: rgba(139, 92, 246, 0.15); border-bottom: 2px solid #8b5cf6; } .card-header.header-green { background-color: rgba(16, 185, 129, 0.15); border-bottom: 2px solid var(--success-color); } .card-header.header-red { background-color: rgba(239, 68, 68, 0.15); border-bottom: 2px solid var(--error-color); } .card-header.header-violet .day-name, .card-header.header-green .day-name, .card-header.header-red .day-name { font-weight: 700; color: var(--text-primary); /* Ensure text remains standard color */ } /* 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 = sessionStorage.getItem('kantine_authToken');\n let currentUser = sessionStorage.getItem('kantine_currentUser');\n let orderMap = new Map();\n let userFlags = new Set(JSON.parse(localStorage.getItem('kantine_flags') || '[]'));\n let pollIntervalId = null;\n\n // === API Helpers ===\n function apiHeaders(token) {\n return {\n 'Authorization': `Token ${token || GUEST_TOKEN}`,\n 'Accept': 'application/json',\n 'Content-Type': 'application/json',\n 'X-Client-Version': CLIENT_VERSION\n };\n }\n\n // === Inject UI ===\n function injectUI() {\n // Replace entire page content\n document.title = 'Kantine Weekly Menu';\n\n // Inject custom favicon (triangle + fork & knife)\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/svg+xml';\n favicon.href = 'data:image/svg+xml,' + encodeURIComponent('');\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
\n
\n
\n
\n restaurant_menu\n
\n

Kantinen \u00dcbersicht v1.4.23

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

Login

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

Men\u00fcdaten aktualisieren

\n
\n
\n
\n
\n
\n
\n
0%
\n
\n

Initialisierung...

\n
\n
\n
\n\n
\n
\n
\n

Meine Highlights

\n \n
\n
\n

\n Markiere Men\u00fcs automatisch, wenn sie diese Schlagw\u00f6rter enthalten.\n

\n
\n \n \n
\n
\n
\n
\n
\n\n
\n
\n
\n

Bestellhistorie

\n \n
\n
\n
\n

Lade Historie...

\n
\n
\n
\n
\n
\n
\n
\n \n
\n
\n
\n
\n\n
\n
\n
\n

\ud83d\udce6 Versionen

\n \n
\n
\n
\n Aktuell: v1.4.23\n
\n
\n \n
\n
\n

Lade Versionen...

\n
\n
\n \n bug_report Fehler melden\n \n \n lightbulb Feature vorschlagen\n \n \n
\n
\n
\n
\n\n
\n
\n update\n Gerade aktualisiert\n
\n
\n
\n

Lade Men\u00fcdaten...

\n
\n
\n
\n\n
\n

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

\n
\n
`;\n }\n\n // === Bind Events ===\n function bindEvents() {\n const btnThisWeek = document.getElementById('btn-this-week');\n const btnNextWeek = document.getElementById('btn-next-week');\n const btnRefresh = document.getElementById('btn-refresh');\n const themeToggle = document.getElementById('theme-toggle');\n const btnLoginOpen = document.getElementById('btn-login-open');\n const btnLoginClose = document.getElementById('btn-login-close');\n const btnLogout = document.getElementById('btn-logout');\n const loginForm = document.getElementById('login-form');\n const loginModal = document.getElementById('login-modal');\n\n // 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 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 localStorage.clear();\n sessionStorage.clear();\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 sessionStorage.setItem('kantine_authToken', data.key);\n sessionStorage.setItem('kantine_currentUser', employeeId);\n\n // Fetch user name\n try {\n const userResp = await fetch(`${API_BASE}/auth/user/`, {\n headers: apiHeaders(authToken)\n });\n if (userResp.ok) {\n const userData = await userResp.json();\n if (userData.first_name) sessionStorage.setItem('kantine_firstName', userData.first_name);\n if (userData.last_name) sessionStorage.setItem('kantine_lastName', userData.last_name);\n }\n } catch (err) {\n console.error('Failed to fetch user info:', err);\n }\n\n updateAuthUI();\n loginModal.classList.add('hidden');\n fetchOrders();\n loginForm.reset();\n startPolling();\n\n // Reload menu data with auth for full details\n loadMenuDataFromAPI();\n } else {\n loginError.textContent = data.non_field_errors?.[0] || data.error || 'Login fehlgeschlagen';\n loginError.classList.remove('hidden');\n }\n } catch (error) {\n console.error('Login error:', error);\n loginError.textContent = 'Ein Fehler ist aufgetreten';\n loginError.classList.remove('hidden');\n } finally {\n submitBtn.disabled = false;\n submitBtn.textContent = originalText;\n }\n });\n\n // Logout\n btnLogout.addEventListener('click', () => {\n sessionStorage.removeItem('kantine_authToken');\n sessionStorage.removeItem('kantine_currentUser');\n sessionStorage.removeItem('kantine_firstName');\n sessionStorage.removeItem('kantine_lastName');\n authToken = null;\n currentUser = null;\n orderMap = new Map();\n stopPolling();\n updateAuthUI();\n renderVisibleWeeks();\n });\n }\n\n // === Auth UI ===\n function updateAuthUI() {\n // Try to recover session from Bessa's storage if not already logged in\n if (!authToken) {\n try {\n const akita = localStorage.getItem('AkitaStores');\n if (akita) {\n const parsed = JSON.parse(akita);\n if (parsed.auth && parsed.auth.token) {\n console.log('Found existing Bessa session!');\n authToken = parsed.auth.token;\n sessionStorage.setItem('kantine_authToken', authToken);\n\n if (parsed.auth.user) {\n currentUser = parsed.auth.user.id || 'unknown';\n sessionStorage.setItem('kantine_currentUser', currentUser);\n if (parsed.auth.user.firstName) sessionStorage.setItem('kantine_firstName', parsed.auth.user.firstName);\n if (parsed.auth.user.lastName) sessionStorage.setItem('kantine_lastName', parsed.auth.user.lastName);\n }\n }\n }\n } catch (e) {\n console.warn('Failed to parse AkitaStores:', e);\n }\n }\n\n authToken = sessionStorage.getItem('kantine_authToken');\n currentUser = sessionStorage.getItem('kantine_currentUser');\n const firstName = sessionStorage.getItem('kantine_firstName');\n const btnLoginOpen = document.getElementById('btn-login-open');\n const userInfo = document.getElementById('user-info');\n const userIdDisplay = document.getElementById('user-id-display');\n\n if (authToken) {\n btnLoginOpen.classList.add('hidden');\n userInfo.classList.remove('hidden');\n userIdDisplay.textContent = firstName || (currentUser ? `User ${currentUser}` : 'Angemeldet');\n fetchOrders(); // Always fetch fresh orders on auth update\n } else {\n btnLoginOpen.classList.remove('hidden');\n userInfo.classList.add('hidden');\n userIdDisplay.textContent = '';\n }\n\n renderVisibleWeeks();\n }\n\n // === Fetch Orders from Bessa ===\n async function fetchOrders() {\n if (!authToken) return;\n try {\n // Use user/orders endpoint for reliable history\n const response = await fetch(`${API_BASE}/user/orders/?venue=${VENUE_ID}&ordering=-created&limit=50`, {\n headers: apiHeaders(authToken)\n });\n const data = await response.json();\n\n if (response.ok) {\n orderMap = new Map();\n const results = data.results || [];\n\n for (const order of results) {\n // Filter out cancelled orders (State 9)\n // Accepting State 1 (Created?), 5 (Placed?), 8 (Completed)\n // TODO: Verify exact states. Subagent saw 5=Active, 8=Completed, 9=Cancelled.\n if (order.order_state === 9) continue;\n\n // Extract date properly (it comes as ISO string)\n const orderDate = order.date.split('T')[0];\n\n for (const item of (order.items || [])) {\n const key = `${orderDate}_${item.article}`;\n if (!orderMap.has(key)) orderMap.set(key, []);\n orderMap.get(key).push(order.id);\n }\n }\n console.log(`Fetched ${results.length} orders, mapped active ones.`);\n renderVisibleWeeks();\n 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 = `

Fehler 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 += `
\n

${yearGroup.year}

`;\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 += `
\n
\n
\n ${monthGroup.name}\n
\n ${monthGroup.count} Bestellungen • \u20ac${monthGroup.total.toFixed(2)}\n
\n
\n expand_more\n
\n
`;\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 += `
\n
\n ${week.label}\n ${week.count} Bestellungen • \u20ac${week.total.toFixed(2)}\n
`;\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 = 'Storniert';\n } else if (item.state === 8) {\n statusBadge = 'Abgeschlossen';\n } else {\n statusBadge = '\u00dcbertragen';\n }\n\n html += `\n
\n
${dayStr}
\n
\n ${escapeHtml(item.name)}\n
${statusBadge}
\n
\n
\u20ac${item.price.toFixed(2)}
\n
`;\n });\n html += `
`;\n });\n html += `
`; // Close month-content and month-group\n });\n html += `
`; // 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:00:00.000Z`,\n payment_method: 'payroll',\n customer: {\n first_name: userData.first_name,\n last_name: userData.last_name,\n email: userData.email,\n newsletter: false\n },\n preorder: false,\n delivery_fee: 0,\n cash_box_table_name: null,\n take_away: false\n };\n\n const response = await fetch(`${API_BASE}/user/orders/`, {\n method: 'POST',\n headers: apiHeaders(authToken),\n body: JSON.stringify(orderPayload)\n });\n\n if (response.ok || response.status === 201) {\n showToast(`Bestellt: ${name}`, 'success');\n 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} ×`;\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.

${error.message}`,\n 'Zur Original-Seite',\n 'https://web.bessa.app/knapp-kantine'\n );\n } finally {\n loading.classList.add('hidden');\n }\n }\n\n // === Last Updated Display ===\n 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 = `${icon}${message}`;\n container.appendChild(toast);\n requestAnimationFrame(() => toast.classList.add('show'));\n setTimeout(() => {\n toast.classList.remove('show');\n setTimeout(() => toast.remove(), 300);\n }, 3000);\n }\n\n // === Next Week Badge ===\n function updateNextWeekBadge() {\n const btnNextWeek = document.getElementById('btn-next-week');\n let nextWeek = currentWeekNumber + 1;\n let nextYear = currentYear;\n if (nextWeek > 52) { nextWeek = 1; nextYear++; }\n\n const nextWeekData = allWeeks.find(w => w.weekNumber === nextWeek && w.year === nextYear);\n let totalDataCount = 0;\n let orderableCount = 0;\n let daysWithOrders = 0;\n let daysWithOrderableAndNoOrder = 0;\n\n if (nextWeekData && nextWeekData.days) {\n nextWeekData.days.forEach(day => {\n if (day.items && day.items.length > 0) {\n totalDataCount++;\n const isOrderable = day.items.some(item => item.available);\n if (isOrderable) orderableCount++;\n\n let hasOrder = false;\n day.items.forEach(item => {\n const articleId = item.articleId || parseInt(item.id.split('_')[1]);\n const key = `${day.date}_${articleId}`;\n if (orderMap.has(key) && orderMap.get(key).length > 0) hasOrder = true;\n });\n\n if (hasOrder) daysWithOrders++;\n if (isOrderable && !hasOrder) daysWithOrderableAndNoOrder++;\n }\n });\n }\n\n let badge = btnNextWeek.querySelector('.nav-badge');\n if (totalDataCount > 0) {\n if (!badge) {\n badge = document.createElement('span');\n badge.className = 'nav-badge';\n btnNextWeek.appendChild(badge);\n }\n\n // Format: ( Ordered / Orderable / Total )\n badge.title = `${daysWithOrders} bestellt / ${orderableCount} bestellbar / ${totalDataCount} gesamt`;\n badge.innerHTML = `${daysWithOrders}/${orderableCount}/${totalDataCount}`;\n\n // Color Logic\n badge.classList.remove('badge-violet', 'badge-green', 'badge-red', 'badge-blue');\n\n // Refined Logic (v1.7.4):\n // Violet: If we have orders AND there are no DAYS left that are orderable but un-ordered.\n // (i.e. \"I have ordered everything I can\")\n if (daysWithOrders > 0 && daysWithOrderableAndNoOrder === 0) {\n badge.classList.add('badge-violet');\n } else if (daysWithOrderableAndNoOrder > 0) {\n badge.classList.add('badge-green'); // Orderable days exist without order\n } else if (orderableCount === 0) {\n badge.classList.add('badge-red'); // No orderable days at all & no orders\n } else {\n badge.classList.add('badge-blue'); // Default / partial state\n }\n\n // 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 += `(${highlightCount})`;\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 = `shopping_bag Gesamt: ${totalCost.toFixed(2).replace('.', ',')} \u20ac`;\n costDisplay.classList.remove('hidden');\n } else {\n costDisplay.classList.add('hidden');\n }\n }\n\n // === Render Weeks ===\n function renderVisibleWeeks() {\n const menuContainer = document.getElementById('menu-container');\n if (!menuContainer) return;\n menuContainer.innerHTML = '';\n\n let targetWeek = currentWeekNumber;\n let targetYear = currentYear;\n\n if (displayMode === 'next-week') {\n targetWeek++;\n if (targetWeek > 52) { targetWeek = 1; targetYear++; }\n }\n\n // Flatten & filter by week + year\n const allDays = allWeeks.flatMap(w => w.days || []);\n const daysInTargetWeek = allDays.filter(day => {\n const d = new Date(day.date);\n return getISOWeek(d) === targetWeek && getWeekYear(d) === targetYear;\n });\n\n if (daysInTargetWeek.length === 0) {\n menuContainer.innerHTML = `\n
\n

Keine Men\u00fcdaten f\u00fcr KW ${targetWeek} (${targetYear}) verf\u00fcgbar.

\n Versuchen Sie eine andere Woche oder schauen Sie sp\u00e4ter vorbei.\n
`;\n document.getElementById('weekly-cost-display').classList.add('hidden');\n return;\n }\n\n updateWeeklyCost(daysInTargetWeek);\n\n // Update header\n const headerWeekInfo = document.getElementById('header-week-info');\n const weekTitle = displayMode === 'this-week' ? 'Diese Woche' : 'N\u00e4chste Woche';\n headerWeekInfo.innerHTML = `\n
${weekTitle}
\n
Week ${targetWeek} \u2022 ${targetYear}
`;\n\n const grid = document.createElement('div');\n grid.className = 'days-grid';\n\n daysInTargetWeek.sort((a, b) => a.date.localeCompare(b.date));\n\n // Filter weekends\n const workingDays = daysInTargetWeek.filter(d => {\n const date = new Date(d.date);\n const day = date.getDay();\n return day !== 0 && day !== 6;\n });\n\n workingDays.forEach(day => {\n const card = createDayCard(day);\n if (card) grid.appendChild(card);\n });\n\n menuContainer.appendChild(grid);\n setTimeout(() => syncMenuItemHeights(grid), 0);\n }\n\n // === Sync Item Heights ===\n function syncMenuItemHeights(grid) {\n const cards = grid.querySelectorAll('.menu-card');\n if (cards.length === 0) return;\n let maxItems = 0;\n cards.forEach(card => {\n maxItems = Math.max(maxItems, card.querySelectorAll('.menu-item').length);\n });\n for (let i = 0; i < maxItems; i++) {\n let maxHeight = 0;\n const itemsAtPos = [];\n cards.forEach(card => {\n const items = card.querySelectorAll('.menu-item');\n if (items[i]) {\n items[i].style.height = 'auto';\n maxHeight = Math.max(maxHeight, items[i].offsetHeight);\n itemsAtPos.push(items[i]);\n }\n });\n itemsAtPos.forEach(item => { item.style.height = `${maxHeight}px`; });\n }\n }\n\n // === Create Day Card ===\n function createDayCard(day) {\n if (!day.items || day.items.length === 0) return null;\n\n const card = document.createElement('div');\n card.className = 'menu-card';\n\n const now = new Date();\n const cardDate = new Date(day.date);\n\n let isPastCutoff = false;\n if (day.orderCutoff) {\n isPastCutoff = now >= new Date(day.orderCutoff);\n } else {\n const today = new Date();\n today.setHours(0, 0, 0, 0);\n const cd = new Date(day.date);\n cd.setHours(0, 0, 0, 0);\n isPastCutoff = cd < today;\n }\n\n if (isPastCutoff) card.classList.add('past-day');\n\n // Collect ordered menu codes\n const menuBadges = [];\n if (day.items) {\n day.items.forEach(item => {\n const articleId = item.articleId || parseInt(item.id.split('_')[1]);\n const orderKey = `${day.date}_${articleId}`;\n const orders = orderMap.get(orderKey) || [];\n const count = orders.length;\n\n if (count > 0) {\n // Regex for M1, M2, M1F etc.\n const match = item.name.match(/([M][1-9][Ff]?)/);\n if (match) {\n let code = match[1];\n if (count > 1) code += '+';\n menuBadges.push(code);\n }\n }\n });\n }\n\n // Header\n const header = document.createElement('div');\n header.className = 'card-header';\n const dateStr = cardDate.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' });\n\n const badgesHtml = menuBadges.map(code => `${code}`).join('');\n\n // Determine Day Status for Header Color\n // Violet: Has Order\n // Green: No Order but Orderable\n // Red: No Order and Not Orderable (Locked/Sold Out)\n let headerClass = '';\n const hasAnyOrder = day.items && day.items.some(item => {\n const articleId = item.articleId || parseInt(item.id.split('_')[1]);\n const key = `${day.date}_${articleId}`;\n return orderMap.has(key) && orderMap.get(key).length > 0;\n });\n\n const hasOrderable = day.items && day.items.some(item => {\n // Use pre-calculated available flag from loadMenuDataFromAPI calculation\n return item.available;\n });\n\n if (hasAnyOrder) {\n headerClass = 'header-violet';\n } else if (hasOrderable && !isPastCutoff) {\n headerClass = 'header-green';\n } else {\n // Red if not orderable (or past cutoff)\n headerClass = 'header-red';\n }\n\n if (headerClass) header.classList.add(headerClass);\n\n header.innerHTML = `\n
\n ${translateDay(day.weekday)}\n
${badgesHtml}
\n
\n ${dateStr}`;\n card.appendChild(header);\n\n // Body\n const body = document.createElement('div');\n body.className = 'card-body';\n\n const todayDateStr = new Date().toISOString().split('T')[0];\n const isToday = day.date === todayDateStr;\n\n const sortedItems = [...day.items].sort((a, b) => {\n if (isToday) {\n const aId = a.articleId || parseInt(a.id.split('_')[1]);\n const bId = b.articleId || parseInt(b.id.split('_')[1]);\n const aOrdered = orderMap.has(`${day.date}_${aId}`);\n const bOrdered = orderMap.has(`${day.date}_${bId}`);\n\n if (aOrdered && !bOrdered) return -1;\n if (!aOrdered && bOrdered) return 1;\n }\n return a.name.localeCompare(b.name);\n });\n\n sortedItems.forEach(item => {\n const itemEl = document.createElement('div');\n itemEl.className = 'menu-item';\n\n const articleId = item.articleId || parseInt(item.id.split('_')[1]);\n const orderKey = `${day.date}_${articleId}`;\n const orderIds = orderMap.get(orderKey) || [];\n const orderCount = orderIds.length;\n\n // Status badge\n let statusBadge = '';\n if (item.available) {\n statusBadge = item.amountTracking\n ? `Verf\u00fcgbar (${item.availableAmount})`\n : `Verf\u00fcgbar`;\n } else {\n statusBadge = `Ausverkauft`;\n }\n\n // Order badge\n let orderedBadge = '';\n if (orderCount > 0) {\n const countBadge = orderCount > 1 ? `${orderCount}` : '';\n orderedBadge = `check_circle Bestellt${countBadge}`;\n itemEl.classList.add('ordered');\n if (new Date(day.date).toDateString() === now.toDateString()) {\n itemEl.classList.add('today-ordered');\n }\n }\n\n // Flagged styles\n const flagId = `${day.date}_${articleId}`;\n const isFlagged = userFlags.has(flagId);\n if (isFlagged) {\n itemEl.classList.add(item.available ? 'flagged-available' : 'flagged-sold-out');\n }\n\n // 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 = ``;\n }\n\n // Order button\n if (item.available) {\n if (orderCount > 0) {\n orderButton = ``;\n } else {\n orderButton = ``;\n }\n }\n\n // Cancel button\n if (orderCount > 0) {\n const cancelIcon = orderCount === 1 ? 'close' : 'remove';\n const cancelTitle = orderCount === 1 ? 'Bestellung stornieren' : 'Eine Bestellung stornieren';\n cancelButton = ``;\n }\n }\n\n // Build matched-tags HTML (only if tags found)\n let tagsHtml = '';\n if (matchedTags.length > 0) {\n const badges = matchedTags.map(t => `star${escapeHtml(t)}`).join('');\n tagsHtml = `
${badges}
`;\n }\n\n itemEl.innerHTML = `\n
\n ${escapeHtml(item.name)}\n ${item.price.toFixed(2)} \u20ac\n
\n
\n ${orderedBadge}\n ${cancelButton}\n ${orderButton}\n ${flagButton}\n
${statusBadge}
\n
\n ${tagsHtml}\n

${escapeHtml(item.description)}

`;\n\n // Event: Order\n const orderBtn = itemEl.querySelector('.btn-order');\n if (orderBtn) {\n orderBtn.addEventListener('click', (e) => {\n e.stopPropagation();\n const btn = e.currentTarget;\n btn.disabled = true;\n btn.classList.add('loading');\n placeOrder(btn.dataset.date, parseInt(btn.dataset.article), btn.dataset.name, parseFloat(btn.dataset.price), btn.dataset.desc || '')\n .finally(() => { btn.disabled = false; btn.classList.remove('loading'); });\n });\n }\n\n // Event: Cancel\n const cancelBtn = itemEl.querySelector('.btn-cancel');\n if (cancelBtn) {\n cancelBtn.addEventListener('click', (e) => {\n e.stopPropagation();\n const btn = e.currentTarget;\n btn.disabled = true;\n cancelOrder(btn.dataset.date, parseInt(btn.dataset.article), btn.dataset.name)\n .finally(() => { btn.disabled = false; });\n });\n }\n\n // Event: Flag\n const flagBtn = itemEl.querySelector('.btn-flag');\n if (flagBtn) {\n flagBtn.addEventListener('click', (e) => {\n e.stopPropagation();\n const btn = e.currentTarget;\n toggleFlag(btn.dataset.date, parseInt(btn.dataset.article), btn.dataset.name, btn.dataset.cutoff);\n });\n }\n\n body.appendChild(itemEl);\n });\n\n card.appendChild(body);\n return card;\n }\n\n // === 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.4.23';\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.4.23';\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 = '';\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 = '\u2713 Installiert';\n else if (isNew) badge = '\u2b06 Neu!';\n\n let action = '';\n if (!isCurrent) {\n action = `Installieren`;\n }\n\n li.innerHTML = `\n
\n ${v.tag}\n ${badge}\n
\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 = `

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 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 (!sessionStorage.getItem(notifiedKey)) {\n if (Notification.permission === 'granted') {\n new Notification('Kantine: Bestellschluss naht!', {\n body: 'Du hast heute noch nichts bestellt. Nur noch 1 Stunde!',\n icon: '\u23f3'\n });\n } else if (Notification.permission === 'default') {\n Notification.requestPermission();\n }\n sessionStorage.setItem(notifiedKey, 'true');\n }\n } else {\n countdownEl.classList.remove('urgent');\n }\n }\n\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 // === 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

\n signal_wifi_off\n ${title}\n

\n
\n
\n

${htmlContent}

\n
\n \n
\n
\n
\n `;\n document.body.appendChild(modal);\n\n document.getElementById('btn-error-redirect').addEventListener('click', () => {\n window.location.href = url;\n });\n\n requestAnimationFrame(() => {\n modal.classList.remove('hidden');\n });\n}\n"; +sc.textContent="/**\n * Kantine Wrapper \u2013 Client-Only Bookmarklet\n * Replaces Bessa page content with enhanced weekly menu view.\n * All API calls go directly to api.bessa.app (same origin).\n * Data stored in localStorage (flags, theme, auth).\n */\n(function () {\n 'use strict';\n\n // Prevent double injection\n if (window.__KANTINE_LOADED) return;\n window.__KANTINE_LOADED = true;\n\n // === Constants ===\n const API_BASE = 'https://api.bessa.app/v1';\n const GUEST_TOKEN = 'c3418725e95a9f90e3645cbc846b4d67c7c66131';\n const CLIENT_VERSION = '1.7.0_prod/2026-01-26';\n const VENUE_ID = 591;\n const MENU_ID = 7;\n const POLL_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes\n\n // === 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 = sessionStorage.getItem('kantine_authToken');\n let currentUser = sessionStorage.getItem('kantine_currentUser');\n let orderMap = new Map();\n let userFlags = new Set(JSON.parse(localStorage.getItem('kantine_flags') || '[]'));\n let pollIntervalId = null;\n\n // === API Helpers ===\n function apiHeaders(token) {\n return {\n 'Authorization': `Token ${token || GUEST_TOKEN}`,\n 'Accept': 'application/json',\n 'Content-Type': 'application/json',\n 'X-Client-Version': CLIENT_VERSION\n };\n }\n\n // === Inject UI ===\n function injectUI() {\n // Replace entire page content\n document.title = 'Kantine Weekly Menu';\n\n // Inject custom favicon (triangle + fork & knife)\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/svg+xml';\n // Pre-encoded Base64 of the SVG favicon (triangle + fork & knife)\n favicon.href = 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA2NCA2NCI+PGcgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoMiwxMCkiPjxyZWN0IHg9IjEiIHk9IjAiIHdpZHRoPSIxLjgiIGhlaWdodD0iMTYiIHJ4PSIuOSIgZmlsbD0iIzMzMyIvPjxyZWN0IHg9IjQuNiIgeT0iMCIgd2lkdGg9IjEuOCIgaGVpZ2h0PSIxNiIgcng9Ii45IiBmaWxsPSIjMzMzIi8+PHJlY3QgeD0iOC4yIiB5PSIwIiB3aWR0aD0iMS44IiBoZWlnaHQ9IjE2IiByeD0iLjkiIGZpbGw9IiMzMzMiLz48cmVjdCB4PSIxIiB5PSIxNCIgd2lkdGg9IjkiIGhlaWdodD0iMy41IiByeD0iMS41IiBmaWxsPSIjMzMzIi8+PHJlY3QgeD0iMy41IiB5PSIxNi41IiB3aWR0aD0iNCIgaGVpZ2h0PSIyNCIgcng9IjIiIGZpbGw9IiMzMzMiLz48L2c+PHBvbHlnb24gcG9pbnRzPSIzMiw4IDQ3LDQ4IDE3LDQ4IiBmaWxsPSJub25lIiBzdHJva2U9IiMzMzMiIHN0cm9rZS13aWR0aD0iNCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIvPjxnIHRyYW5zZm9ybT0idHJhbnNsYXRlKDUwLDEwKSI+PHBhdGggZD0iTTMsMEMzLDAsMywwLDMsMEwzLDE3TDEwLDE0QzEwLDYsNywwLDMsMFoiIGZpbGw9IiMzMzMiLz48cmVjdCB4PSIxLjUiIHk9IjAiIHdpZHRoPSIyIiBoZWlnaHQ9IjE4IiByeD0iMSIgZmlsbD0iIzMzMyIvPjxyZWN0IHg9IjEuNSIgeT0iMTYuNSIgd2lkdGg9IjguNSIgaGVpZ2h0PSIzLjUiIHJ4PSIxLjIiIGZpbGw9IiMzMzMiLz48cmVjdCB4PSIzLjUiIHk9IjE5IiB3aWR0aD0iNCIgaGVpZ2h0PSIyMiIgcng9IjIiIGZpbGw9IiMzMzMiLz48L2c+PC9zdmc+';\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
\n
\n
\n
\n restaurant_menu\n
\n

Kantinen \u00dcbersicht v1.4.24

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

Login

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

Men\u00fcdaten aktualisieren

\n
\n
\n
\n
\n
\n
\n
0%
\n
\n

Initialisierung...

\n
\n
\n
\n\n
\n
\n
\n

Meine Highlights

\n \n
\n
\n

\n Markiere Men\u00fcs automatisch, wenn sie diese Schlagw\u00f6rter enthalten.\n

\n
\n \n \n
\n
\n
\n
\n
\n\n
\n
\n
\n

Bestellhistorie

\n \n
\n
\n
\n

Lade Historie...

\n
\n
\n
\n
\n
\n
\n
\n \n
\n
\n
\n
\n\n
\n
\n
\n

\ud83d\udce6 Versionen

\n \n
\n
\n
\n Aktuell: v1.4.24\n
\n
\n \n
\n
\n

Lade Versionen...

\n
\n
\n \n bug_report Fehler melden\n \n \n lightbulb Feature vorschlagen\n \n \n
\n
\n
\n
\n\n
\n
\n update\n Gerade aktualisiert\n
\n
\n
\n

Lade Men\u00fcdaten...

\n
\n
\n
\n\n
\n

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

\n
\n
`;\n }\n\n // === Bind Events ===\n function bindEvents() {\n const btnThisWeek = document.getElementById('btn-this-week');\n const btnNextWeek = document.getElementById('btn-next-week');\n const btnRefresh = document.getElementById('btn-refresh');\n const themeToggle = document.getElementById('theme-toggle');\n const btnLoginOpen = document.getElementById('btn-login-open');\n const btnLoginClose = document.getElementById('btn-login-close');\n const btnLogout = document.getElementById('btn-logout');\n const loginForm = document.getElementById('login-form');\n const loginModal = document.getElementById('login-modal');\n\n // 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 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 localStorage.clear();\n sessionStorage.clear();\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 sessionStorage.setItem('kantine_authToken', data.key);\n sessionStorage.setItem('kantine_currentUser', employeeId);\n\n // Fetch user name\n try {\n const userResp = await fetch(`${API_BASE}/auth/user/`, {\n headers: apiHeaders(authToken)\n });\n if (userResp.ok) {\n const userData = await userResp.json();\n if (userData.first_name) sessionStorage.setItem('kantine_firstName', userData.first_name);\n if (userData.last_name) sessionStorage.setItem('kantine_lastName', userData.last_name);\n }\n } catch (err) {\n console.error('Failed to fetch user info:', err);\n }\n\n updateAuthUI();\n loginModal.classList.add('hidden');\n fetchOrders();\n loginForm.reset();\n startPolling();\n\n // Reload menu data with auth for full details\n loadMenuDataFromAPI();\n } else {\n loginError.textContent = data.non_field_errors?.[0] || data.error || 'Login fehlgeschlagen';\n loginError.classList.remove('hidden');\n }\n } catch (error) {\n console.error('Login error:', error);\n loginError.textContent = 'Ein Fehler ist aufgetreten';\n loginError.classList.remove('hidden');\n } finally {\n submitBtn.disabled = false;\n submitBtn.textContent = originalText;\n }\n });\n\n // Logout\n btnLogout.addEventListener('click', () => {\n sessionStorage.removeItem('kantine_authToken');\n sessionStorage.removeItem('kantine_currentUser');\n sessionStorage.removeItem('kantine_firstName');\n sessionStorage.removeItem('kantine_lastName');\n authToken = null;\n currentUser = null;\n orderMap = new Map();\n stopPolling();\n updateAuthUI();\n renderVisibleWeeks();\n });\n }\n\n // === Auth UI ===\n function updateAuthUI() {\n // Try to recover session from Bessa's storage if not already logged in\n if (!authToken) {\n try {\n const akita = localStorage.getItem('AkitaStores');\n if (akita) {\n const parsed = JSON.parse(akita);\n if (parsed.auth && parsed.auth.token) {\n console.log('Found existing Bessa session!');\n authToken = parsed.auth.token;\n sessionStorage.setItem('kantine_authToken', authToken);\n\n if (parsed.auth.user) {\n currentUser = parsed.auth.user.id || 'unknown';\n sessionStorage.setItem('kantine_currentUser', currentUser);\n if (parsed.auth.user.firstName) sessionStorage.setItem('kantine_firstName', parsed.auth.user.firstName);\n if (parsed.auth.user.lastName) sessionStorage.setItem('kantine_lastName', parsed.auth.user.lastName);\n }\n }\n }\n } catch (e) {\n console.warn('Failed to parse AkitaStores:', e);\n }\n }\n\n authToken = sessionStorage.getItem('kantine_authToken');\n currentUser = sessionStorage.getItem('kantine_currentUser');\n const firstName = sessionStorage.getItem('kantine_firstName');\n const btnLoginOpen = document.getElementById('btn-login-open');\n const userInfo = document.getElementById('user-info');\n const userIdDisplay = document.getElementById('user-id-display');\n\n if (authToken) {\n btnLoginOpen.classList.add('hidden');\n userInfo.classList.remove('hidden');\n userIdDisplay.textContent = firstName || (currentUser ? `User ${currentUser}` : 'Angemeldet');\n fetchOrders(); // Always fetch fresh orders on auth update\n } else {\n btnLoginOpen.classList.remove('hidden');\n userInfo.classList.add('hidden');\n userIdDisplay.textContent = '';\n }\n\n renderVisibleWeeks();\n }\n\n // === Fetch Orders from Bessa ===\n async function fetchOrders() {\n if (!authToken) return;\n try {\n // Use user/orders endpoint for reliable history\n const response = await fetch(`${API_BASE}/user/orders/?venue=${VENUE_ID}&ordering=-created&limit=50`, {\n headers: apiHeaders(authToken)\n });\n const data = await response.json();\n\n if (response.ok) {\n orderMap = new Map();\n const results = data.results || [];\n\n for (const order of results) {\n // Filter out cancelled orders (State 9)\n // Accepting State 1 (Created?), 5 (Placed?), 8 (Completed)\n // TODO: Verify exact states. Subagent saw 5=Active, 8=Completed, 9=Cancelled.\n if (order.order_state === 9) continue;\n\n // Extract date properly (it comes as ISO string)\n const orderDate = order.date.split('T')[0];\n\n for (const item of (order.items || [])) {\n const key = `${orderDate}_${item.article}`;\n if (!orderMap.has(key)) orderMap.set(key, []);\n orderMap.get(key).push(order.id);\n }\n }\n console.log(`Fetched ${results.length} orders, mapped active ones.`);\n renderVisibleWeeks();\n 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 = `

Fehler 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 += `
\n

${yearGroup.year}

`;\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 += `
\n
\n
\n ${monthGroup.name}\n
\n ${monthGroup.count} Bestellungen • \u20ac${monthGroup.total.toFixed(2)}\n
\n
\n expand_more\n
\n
`;\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 += `
\n
\n ${week.label}\n ${week.count} Bestellungen • \u20ac${week.total.toFixed(2)}\n
`;\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 = 'Storniert';\n } else if (item.state === 8) {\n statusBadge = 'Abgeschlossen';\n } else {\n statusBadge = '\u00dcbertragen';\n }\n\n html += `\n
\n
${dayStr}
\n
\n ${escapeHtml(item.name)}\n
${statusBadge}
\n
\n
\u20ac${item.price.toFixed(2)}
\n
`;\n });\n html += `
`;\n });\n html += `
`; // Close month-content and month-group\n });\n html += `
`; // 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:00:00.000Z`,\n payment_method: 'payroll',\n customer: {\n first_name: userData.first_name,\n last_name: userData.last_name,\n email: userData.email,\n newsletter: false\n },\n preorder: false,\n delivery_fee: 0,\n cash_box_table_name: null,\n take_away: false\n };\n\n const response = await fetch(`${API_BASE}/user/orders/`, {\n method: 'POST',\n headers: apiHeaders(authToken),\n body: JSON.stringify(orderPayload)\n });\n\n if (response.ok || response.status === 201) {\n showToast(`Bestellt: ${name}`, 'success');\n 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} ×`;\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.

${error.message}`,\n 'Zur Original-Seite',\n 'https://web.bessa.app/knapp-kantine'\n );\n } finally {\n loading.classList.add('hidden');\n }\n }\n\n // === Last Updated Display ===\n 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 = `${icon}${message}`;\n container.appendChild(toast);\n requestAnimationFrame(() => toast.classList.add('show'));\n setTimeout(() => {\n toast.classList.remove('show');\n setTimeout(() => toast.remove(), 300);\n }, 3000);\n }\n\n // === Next Week Badge ===\n function updateNextWeekBadge() {\n const btnNextWeek = document.getElementById('btn-next-week');\n let nextWeek = currentWeekNumber + 1;\n let nextYear = currentYear;\n if (nextWeek > 52) { nextWeek = 1; nextYear++; }\n\n const nextWeekData = allWeeks.find(w => w.weekNumber === nextWeek && w.year === nextYear);\n let totalDataCount = 0;\n let orderableCount = 0;\n let daysWithOrders = 0;\n let daysWithOrderableAndNoOrder = 0;\n\n if (nextWeekData && nextWeekData.days) {\n nextWeekData.days.forEach(day => {\n if (day.items && day.items.length > 0) {\n totalDataCount++;\n const isOrderable = day.items.some(item => item.available);\n if (isOrderable) orderableCount++;\n\n let hasOrder = false;\n day.items.forEach(item => {\n const articleId = item.articleId || parseInt(item.id.split('_')[1]);\n const key = `${day.date}_${articleId}`;\n if (orderMap.has(key) && orderMap.get(key).length > 0) hasOrder = true;\n });\n\n if (hasOrder) daysWithOrders++;\n if (isOrderable && !hasOrder) daysWithOrderableAndNoOrder++;\n }\n });\n }\n\n let badge = btnNextWeek.querySelector('.nav-badge');\n if (totalDataCount > 0) {\n if (!badge) {\n badge = document.createElement('span');\n badge.className = 'nav-badge';\n btnNextWeek.appendChild(badge);\n }\n\n // Format: ( Ordered / Orderable / Total )\n badge.title = `${daysWithOrders} bestellt / ${orderableCount} bestellbar / ${totalDataCount} gesamt`;\n badge.innerHTML = `${daysWithOrders}/${orderableCount}/${totalDataCount}`;\n\n // Color Logic\n badge.classList.remove('badge-violet', 'badge-green', 'badge-red', 'badge-blue');\n\n // Refined Logic (v1.7.4):\n // Violet: If we have orders AND there are no DAYS left that are orderable but un-ordered.\n // (i.e. \"I have ordered everything I can\")\n if (daysWithOrders > 0 && daysWithOrderableAndNoOrder === 0) {\n badge.classList.add('badge-violet');\n } else if (daysWithOrderableAndNoOrder > 0) {\n badge.classList.add('badge-green'); // Orderable days exist without order\n } else if (orderableCount === 0) {\n badge.classList.add('badge-red'); // No orderable days at all & no orders\n } else {\n badge.classList.add('badge-blue'); // Default / partial state\n }\n\n // 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 += `(${highlightCount})`;\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 = `shopping_bag Gesamt: ${totalCost.toFixed(2).replace('.', ',')} \u20ac`;\n costDisplay.classList.remove('hidden');\n } else {\n costDisplay.classList.add('hidden');\n }\n }\n\n // === Render Weeks ===\n function renderVisibleWeeks() {\n const menuContainer = document.getElementById('menu-container');\n if (!menuContainer) return;\n menuContainer.innerHTML = '';\n\n let targetWeek = currentWeekNumber;\n let targetYear = currentYear;\n\n if (displayMode === 'next-week') {\n targetWeek++;\n if (targetWeek > 52) { targetWeek = 1; targetYear++; }\n }\n\n // Flatten & filter by week + year\n const allDays = allWeeks.flatMap(w => w.days || []);\n const daysInTargetWeek = allDays.filter(day => {\n const d = new Date(day.date);\n return getISOWeek(d) === targetWeek && getWeekYear(d) === targetYear;\n });\n\n if (daysInTargetWeek.length === 0) {\n menuContainer.innerHTML = `\n
\n

Keine Men\u00fcdaten f\u00fcr KW ${targetWeek} (${targetYear}) verf\u00fcgbar.

\n Versuchen Sie eine andere Woche oder schauen Sie sp\u00e4ter vorbei.\n
`;\n document.getElementById('weekly-cost-display').classList.add('hidden');\n return;\n }\n\n updateWeeklyCost(daysInTargetWeek);\n\n // Update header\n const headerWeekInfo = document.getElementById('header-week-info');\n const weekTitle = displayMode === 'this-week' ? 'Diese Woche' : 'N\u00e4chste Woche';\n headerWeekInfo.innerHTML = `\n
${weekTitle}
\n
Week ${targetWeek} \u2022 ${targetYear}
`;\n\n const grid = document.createElement('div');\n grid.className = 'days-grid';\n\n daysInTargetWeek.sort((a, b) => a.date.localeCompare(b.date));\n\n // Filter weekends\n const workingDays = daysInTargetWeek.filter(d => {\n const date = new Date(d.date);\n const day = date.getDay();\n return day !== 0 && day !== 6;\n });\n\n workingDays.forEach(day => {\n const card = createDayCard(day);\n if (card) grid.appendChild(card);\n });\n\n menuContainer.appendChild(grid);\n setTimeout(() => syncMenuItemHeights(grid), 0);\n }\n\n // === Sync Item Heights ===\n function syncMenuItemHeights(grid) {\n const cards = grid.querySelectorAll('.menu-card');\n if (cards.length === 0) return;\n let maxItems = 0;\n cards.forEach(card => {\n maxItems = Math.max(maxItems, card.querySelectorAll('.menu-item').length);\n });\n for (let i = 0; i < maxItems; i++) {\n let maxHeight = 0;\n const itemsAtPos = [];\n cards.forEach(card => {\n const items = card.querySelectorAll('.menu-item');\n if (items[i]) {\n items[i].style.height = 'auto';\n maxHeight = Math.max(maxHeight, items[i].offsetHeight);\n itemsAtPos.push(items[i]);\n }\n });\n itemsAtPos.forEach(item => { item.style.height = `${maxHeight}px`; });\n }\n }\n\n // === Create Day Card ===\n function createDayCard(day) {\n if (!day.items || day.items.length === 0) return null;\n\n const card = document.createElement('div');\n card.className = 'menu-card';\n\n const now = new Date();\n const cardDate = new Date(day.date);\n\n let isPastCutoff = false;\n if (day.orderCutoff) {\n isPastCutoff = now >= new Date(day.orderCutoff);\n } else {\n const today = new Date();\n today.setHours(0, 0, 0, 0);\n const cd = new Date(day.date);\n cd.setHours(0, 0, 0, 0);\n isPastCutoff = cd < today;\n }\n\n if (isPastCutoff) card.classList.add('past-day');\n\n // Collect ordered menu codes\n const menuBadges = [];\n if (day.items) {\n day.items.forEach(item => {\n const articleId = item.articleId || parseInt(item.id.split('_')[1]);\n const orderKey = `${day.date}_${articleId}`;\n const orders = orderMap.get(orderKey) || [];\n const count = orders.length;\n\n if (count > 0) {\n // Regex for M1, M2, M1F etc.\n const match = item.name.match(/([M][1-9][Ff]?)/);\n if (match) {\n let code = match[1];\n if (count > 1) code += '+';\n menuBadges.push(code);\n }\n }\n });\n }\n\n // Header\n const header = document.createElement('div');\n header.className = 'card-header';\n const dateStr = cardDate.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' });\n\n const badgesHtml = menuBadges.map(code => `${code}`).join('');\n\n // Determine Day Status for Header Color\n // Violet: Has Order\n // Green: No Order but Orderable\n // Red: No Order and Not Orderable (Locked/Sold Out)\n let headerClass = '';\n const hasAnyOrder = day.items && day.items.some(item => {\n const articleId = item.articleId || parseInt(item.id.split('_')[1]);\n const key = `${day.date}_${articleId}`;\n return orderMap.has(key) && orderMap.get(key).length > 0;\n });\n\n const hasOrderable = day.items && day.items.some(item => {\n // Use pre-calculated available flag from loadMenuDataFromAPI calculation\n return item.available;\n });\n\n if (hasAnyOrder) {\n headerClass = 'header-violet';\n } else if (hasOrderable && !isPastCutoff) {\n headerClass = 'header-green';\n } else {\n // Red if not orderable (or past cutoff)\n headerClass = 'header-red';\n }\n\n if (headerClass) header.classList.add(headerClass);\n\n header.innerHTML = `\n
\n ${translateDay(day.weekday)}\n
${badgesHtml}
\n
\n ${dateStr}`;\n card.appendChild(header);\n\n // Body\n const body = document.createElement('div');\n body.className = 'card-body';\n\n const todayDateStr = new Date().toISOString().split('T')[0];\n const isToday = day.date === todayDateStr;\n\n const sortedItems = [...day.items].sort((a, b) => {\n if (isToday) {\n const aId = a.articleId || parseInt(a.id.split('_')[1]);\n const bId = b.articleId || parseInt(b.id.split('_')[1]);\n const aOrdered = orderMap.has(`${day.date}_${aId}`);\n const bOrdered = orderMap.has(`${day.date}_${bId}`);\n\n if (aOrdered && !bOrdered) return -1;\n if (!aOrdered && bOrdered) return 1;\n }\n return a.name.localeCompare(b.name);\n });\n\n sortedItems.forEach(item => {\n const itemEl = document.createElement('div');\n itemEl.className = 'menu-item';\n\n const articleId = item.articleId || parseInt(item.id.split('_')[1]);\n const orderKey = `${day.date}_${articleId}`;\n const orderIds = orderMap.get(orderKey) || [];\n const orderCount = orderIds.length;\n\n // Status badge\n let statusBadge = '';\n if (item.available) {\n statusBadge = item.amountTracking\n ? `Verf\u00fcgbar (${item.availableAmount})`\n : `Verf\u00fcgbar`;\n } else {\n statusBadge = `Ausverkauft`;\n }\n\n // Order badge\n let orderedBadge = '';\n if (orderCount > 0) {\n const countBadge = orderCount > 1 ? `${orderCount}` : '';\n orderedBadge = `check_circle Bestellt${countBadge}`;\n itemEl.classList.add('ordered');\n if (new Date(day.date).toDateString() === now.toDateString()) {\n itemEl.classList.add('today-ordered');\n }\n }\n\n // Flagged styles\n const flagId = `${day.date}_${articleId}`;\n const isFlagged = userFlags.has(flagId);\n if (isFlagged) {\n itemEl.classList.add(item.available ? 'flagged-available' : 'flagged-sold-out');\n }\n\n // 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 = ``;\n }\n\n // Order button\n if (item.available) {\n if (orderCount > 0) {\n orderButton = ``;\n } else {\n orderButton = ``;\n }\n }\n\n // Cancel button\n if (orderCount > 0) {\n const cancelIcon = orderCount === 1 ? 'close' : 'remove';\n const cancelTitle = orderCount === 1 ? 'Bestellung stornieren' : 'Eine Bestellung stornieren';\n cancelButton = ``;\n }\n }\n\n // Build matched-tags HTML (only if tags found)\n let tagsHtml = '';\n if (matchedTags.length > 0) {\n const badges = matchedTags.map(t => `star${escapeHtml(t)}`).join('');\n tagsHtml = `
${badges}
`;\n }\n\n itemEl.innerHTML = `\n
\n ${escapeHtml(item.name)}\n ${item.price.toFixed(2)} \u20ac\n
\n
\n ${orderedBadge}\n ${cancelButton}\n ${orderButton}\n ${flagButton}\n
${statusBadge}
\n
\n ${tagsHtml}\n

${escapeHtml(item.description)}

`;\n\n // Event: Order\n const orderBtn = itemEl.querySelector('.btn-order');\n if (orderBtn) {\n orderBtn.addEventListener('click', (e) => {\n e.stopPropagation();\n const btn = e.currentTarget;\n btn.disabled = true;\n btn.classList.add('loading');\n placeOrder(btn.dataset.date, parseInt(btn.dataset.article), btn.dataset.name, parseFloat(btn.dataset.price), btn.dataset.desc || '')\n .finally(() => { btn.disabled = false; btn.classList.remove('loading'); });\n });\n }\n\n // Event: Cancel\n const cancelBtn = itemEl.querySelector('.btn-cancel');\n if (cancelBtn) {\n cancelBtn.addEventListener('click', (e) => {\n e.stopPropagation();\n const btn = e.currentTarget;\n btn.disabled = true;\n cancelOrder(btn.dataset.date, parseInt(btn.dataset.article), btn.dataset.name)\n .finally(() => { btn.disabled = false; });\n });\n }\n\n // Event: Flag\n const flagBtn = itemEl.querySelector('.btn-flag');\n if (flagBtn) {\n flagBtn.addEventListener('click', (e) => {\n e.stopPropagation();\n const btn = e.currentTarget;\n toggleFlag(btn.dataset.date, parseInt(btn.dataset.article), btn.dataset.name, btn.dataset.cutoff);\n });\n }\n\n body.appendChild(itemEl);\n });\n\n card.appendChild(body);\n return card;\n }\n\n // === 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.4.24';\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.4.24';\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 = '';\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 = '\u2713 Installiert';\n else if (isNew) badge = '\u2b06 Neu!';\n\n let action = '';\n if (!isCurrent) {\n action = `Installieren`;\n }\n\n li.innerHTML = `\n
\n ${v.tag}\n ${badge}\n
\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 = `

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 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 (!sessionStorage.getItem(notifiedKey)) {\n if (Notification.permission === 'granted') {\n new Notification('Kantine: Bestellschluss naht!', {\n body: 'Du hast heute noch nichts bestellt. Nur noch 1 Stunde!',\n icon: '\u23f3'\n });\n } else if (Notification.permission === 'default') {\n Notification.requestPermission();\n }\n sessionStorage.setItem(notifiedKey, 'true');\n }\n } else {\n countdownEl.classList.remove('urgent');\n }\n }\n\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 // === 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

\n signal_wifi_off\n ${title}\n

\n
\n
\n

${htmlContent}

\n
\n \n
\n
\n
\n `;\n document.body.appendChild(modal);\n\n document.getElementById('btn-error-redirect').addEventListener('click', () => {\n window.location.href = url;\n });\n\n requestAnimationFrame(() => {\n modal.classList.remove('hidden');\n });\n}\n"; document.head.appendChild(sc); })(); diff --git a/dist/bookmarklet.txt b/dist/bookmarklet.txt index 522da96..73aa201 100755 --- a/dist/bookmarklet.txt +++ b/dist/bookmarklet.txt @@ -1 +1 @@ -javascript:(function(){ if(window.__KANTINE_LOADED){alert('Kantine Wrapper already loaded!');return;} var s=document.createElement('style'); s.textContent=':root { /* Premium Slate/Gray-Blue Palette - Light Mode */ --bg-body: #f1f5f9; /* Slate 100 */ --bg-card: #ffffff; --text-primary: #334155; /* Slate 700 */ --text-secondary: #64748b; --accent-color: #0f172a; /* Slate 900 (High contrast) */ --border-color: #cbd5e1; /* Slate 300 */ --banner-bg: #e2e8f0; --banner-text: #1e293b; --success-color: #059669; --error-color: #dc2626; --card-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.05), 0 2px 4px -2px rgb(0 0 0 / 0.05); --header-bg: rgba(255, 255, 255, 0.9); --header-border: 1px solid rgba(203, 213, 225, 0.6); } [data-theme="dark"] { /* Premium Slate/Gray-Blue Palette - Dark Mode */ --bg-body: #1e293b; /* Deep Slate Gray (Requested) */ --bg-card: #334155; /* Slate 700 */ --text-primary: #f8fafc; /* Slate 50 */ --text-secondary: #cbd5e1; /* Slate 300 */ --accent-color: #60a5fa; /* Blue 400 */ --border-color: #475569; /* Slate 600 */ --banner-bg: #475569; --banner-text: #e2e8f0; --header-bg: rgba(30, 41, 59, 0.9); --header-border: 1px solid rgba(71, 85, 105, 0.6); --card-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.4); } * { box-sizing: border-box; margin: 0; padding: 0; } body { font-family: \'Inter\', system-ui, -apple-system, sans-serif; background-color: var(--bg-body); color: var(--text-primary); transition: background-color 0.3s ease, color 0.3s ease; line-height: 1.5; -webkit-font-smoothing: antialiased; } /* Fix scrolling bug: Reset html/body styles from host page */ html, body { height: auto !important; min-height: 100% !important; overflow-y: auto !important; overflow-x: hidden !important; position: static !important; margin: 0 !important; padding: 0 !important; } /* Header */ .app-header { position: sticky; top: 0; z-index: 100; backdrop-filter: blur(12px); background-color: var(--header-bg); border-bottom: var(--header-border); padding: 1rem 0; } .header-content { width: 100%; /* Full width */ padding: 0 2rem; /* Comfortable padding */ display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; gap: 1rem; } .brand { display: flex; align-items: center; gap: 0.75rem; } .brand-text { display: flex; flex-direction: column; } .brand h1 { font-size: 1.25rem; font-weight: 700; letter-spacing: -0.025em; margin-bottom: 0; } .subtitle { font-size: 0.85rem; color: var(--text-secondary); font-weight: 400; margin-left: 2px; } .logo-icon { font-size: 1.5rem; color: var(--accent-color); } /* Controls */ .controls { display: flex; align-items: center; gap: 1.5rem; justify-self: end; } /* Header Week Info (centered) */ .header-week-info { text-align: center; line-height: 1.3; } .header-center-wrapper { display: flex; flex-direction: row; align-items: center; gap: 1.5rem; justify-content: center; } .header-week-title { font-size: 1.1rem; font-weight: 600; color: var(--text-primary); } .header-week-subtitle { font-size: 0.85rem; color: var(--text-secondary); } .nav-group { display: flex; background-color: var(--bg-card); border: 1px solid var(--border-color); padding: 0.25rem; border-radius: 8px; } .nav-btn { background: none; border: none; padding: 0.5rem 1rem; font-size: 0.875rem; font-weight: 500; color: var(--text-secondary); cursor: pointer; border-radius: 6px; transition: all 0.2s; display: flex; align-items: center; gap: 0.5rem; } .nav-btn:hover { color: var(--text-primary); background-color: rgba(100, 116, 139, 0.1); } .nav-btn.active { background-color: var(--accent-color); color: white; } /* 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 */ .container { width: 100%; /* Full width */ margin: 2rem auto; padding: 0 2rem; min-height: 80vh; } /* Banner */ .banner { background-color: var(--banner-bg); color: var(--banner-text); padding: 0.75rem 1rem; border-radius: 8px; display: flex; align-items: center; gap: 0.5rem; margin-bottom: 2rem; font-size: 0.875rem; font-weight: 500; border: 1px solid var(--border-color); max-width: fit-content; } /* User Badge */ .user-badge { display: flex; align-items: center; gap: 8px; padding: 6px 12px; background: var(--bg-card); /* Changed from --surface */ border: 1px solid var(--border-color); /* Changed from --border */ border-radius: 20px; font-size: 0.9rem; font-weight: 500; } .icon-btn-small { background: none; border: none; padding: 4px; cursor: pointer; color: var(--text-secondary); /* Changed from --text-muted */ display: flex; align-items: center; justify-content: center; border-radius: 50%; transition: all 0.2s; } .icon-btn-small:hover { color: var(--error-color); /* Changed from --danger */ background: rgba(239, 68, 68, 0.1); } /* Modal */ .modal { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0, 0, 0, 0.5); backdrop-filter: blur(4px); display: flex; align-items: center; justify-content: center; z-index: 1000; transition: all 0.3s; } .modal.hidden { opacity: 0; pointer-events: none; } .modal-content { background: var(--bg-card); 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 */ .menu-grid { display: grid; gap: 2rem; } .week-section { margin-bottom: 3rem; } .week-header { margin-bottom: 1.5rem; border-bottom: 1px solid var(--border-color); padding-bottom: 1rem; text-align: center; } .week-title { font-size: 1.75rem; font-weight: 700; color: var(--text-primary); } .week-range { color: var(--text-secondary); font-size: 0.9rem; margin-top: 0.25rem; } .days-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 0.75rem; } /* Card */ .menu-card { background-color: var(--bg-card); border-radius: 12px; border: 1px solid var(--border-color); box-shadow: var(--card-shadow); overflow: hidden; transition: transform 0.2s ease, box-shadow 0.2s ease; display: flex; flex-direction: column; } /* Past Day Styling - Target specific elements so ordered items can remain visible */ .menu-card.past-day .card-header, .menu-card.past-day .menu-item:not(.ordered) { opacity: 0.6; filter: grayscale(0.8); transition: opacity 0.3s, filter 0.3s; } .menu-card.past-day:hover .card-header, .menu-card.past-day:hover .menu-item:not(.ordered) { opacity: 0.8; filter: grayscale(0.4); } /* Enhancements for ordered items */ .menu-card.past-day .menu-item.ordered { /* No opacity/filter here - fully visible */ background: var(--bg-card); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); border: 1px solid #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 { transform: translateY(-2px); box-shadow: 0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1); } .card-header { padding: 1rem 1.25rem; border-bottom: 1px solid var(--border-color); display: flex; justify-content: space-between; align-items: baseline; background-color: rgba(100, 116, 139, 0.05); } .day-name { font-size: 1.125rem; font-weight: 600; } .day-date { font-size: 0.875rem; color: var(--text-secondary); } .card-body { padding: 1.25rem; display: grid; grid-template-rows: auto; /* Each menu item gets its own row */ align-content: start; } .empty-state { color: var(--text-secondary); font-style: italic; text-align: center; padding: 1rem; } /* Menu Items */ .menu-item { margin-bottom: 1.5rem; padding-bottom: 1.5rem; border-bottom: 1px solid var(--border-color); } .menu-item:last-child { margin-bottom: 0; padding-bottom: 0; border-bottom: none; } .item-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 0.5rem; gap: 1rem; } .item-name { font-weight: 600; color: var(--text-primary); font-size: 1rem; } .item-price { font-weight: 700; color: var(--accent-color); white-space: nowrap; } .item-desc { font-size: 0.875rem; color: var(--text-secondary); line-height: 1.6; margin-bottom: 0.75rem; } .badges { display: flex; gap: 0.5rem; margin-left: auto; } .item-status-row { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.75rem; } .badge { display: inline-flex; align-items: center; justify-content: center; height: 24px; font-size: 0.75rem; padding: 0 10px; border-radius: 4px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; line-height: normal; white-space: nowrap; } .badge.available { background-color: rgba(16, 185, 129, 0.1); /* Emerald 500 / 10% */ color: var(--success-color); border: 1px solid rgba(16, 185, 129, 0.2); } .badge.sold-out { background-color: rgba(239, 68, 68, 0.1); /* Red 500 / 10% */ color: var(--error-color); border: 1px solid rgba(239, 68, 68, 0.2); } .badge.ordered { background-color: rgba(139, 92, 246, 0.1); /* Violet 500 / 10% */ color: #8b5cf6; border: 1px solid rgba(139, 92, 246, 0.2); gap: 4px; } .badge.ordered .material-icons-round { font-size: 1rem; } /* Loading */ .loading-state { text-align: center; padding: 4rem; color: var(--text-secondary); } .spinner { width: 40px; height: 40px; border: 3px solid var(--border-color); border-top-color: var(--accent-color); border-radius: 50%; margin: 0 auto 1rem; animation: spin 1s linear infinite; } @keyframes spin { to { transform: rotate(360deg); } } /* Footer */ .app-footer { text-align: center; padding: 2rem; color: var(--text-secondary); font-size: 0.875rem; border-top: 1px solid var(--border-color); margin-top: auto; } /* === Order / Cancel Buttons (inline in status row) === */ .btn-order { display: inline-flex; align-items: center; gap: 4px; padding: 4px 10px; border: none; border-radius: 6px; background: var(--success-color); color: white; font-size: 0.75rem; font-weight: 600; cursor: pointer; transition: all 0.2s ease; font-family: inherit; } .btn-order .material-icons-round { font-size: 16px; } .btn-order:hover:not(:disabled) { filter: brightness(1.15); transform: translateY(-1px); } .btn-order:disabled { opacity: 0.5; cursor: not-allowed; } .btn-order.loading { pointer-events: none; opacity: 0.6; } .btn-order-compact { padding: 2px 4px; gap: 0; } .btn-order-compact .material-icons-round { font-size: 16px; } .btn-cancel { display: inline-flex; align-items: center; justify-content: center; padding: 4px 6px; border: none; border-radius: 6px; background: var(--error-color); color: white; font-size: 0.75rem; cursor: pointer; transition: all 0.2s ease; font-family: inherit; } .btn-cancel .material-icons-round { font-size: 16px; } .btn-cancel:hover:not(:disabled) { filter: brightness(1.15); transform: translateY(-1px); } .btn-cancel:disabled { opacity: 0.5; cursor: not-allowed; } /* Past days: hide action buttons */ .past-day .item-actions { display: none; } /* Order count badge (for multi-orders) */ .order-count-badge { display: inline-flex; align-items: center; justify-content: center; background: rgba(255, 255, 255, 0.3); color: white; font-size: 0.65rem; font-weight: 700; min-width: 16px; height: 16px; padding: 0 4px; border-radius: 8px; margin-left: 4px; line-height: 1; } /* === Toast Notifications === */ #toast-container { position: fixed; bottom: 20px; right: 20px; z-index: 10000; display: flex; flex-direction: column; gap: 8px; pointer-events: none; } .toast { display: flex; align-items: center; gap: 8px; padding: 10px 16px; border-radius: 8px; font-size: 0.85rem; font-weight: 500; font-family: \'Inter\', sans-serif; color: white; backdrop-filter: blur(10px); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); pointer-events: auto; transform: translateX(120%); opacity: 0; transition: transform 0.3s ease, opacity 0.3s ease; } .toast.show { transform: translateX(0); opacity: 1; } .toast .material-icons-round { font-size: 18px; } .toast-success { background: rgba(5, 150, 105, 0.95); } .toast-error { background: rgba(220, 38, 38, 0.95); } .toast-info { background: rgba(59, 130, 246, 0.95); } /* === Mobile Responsiveness === */ @media (max-width: 600px) { .header-content { flex-direction: column; gap: 1rem; padding: 0.75rem; } .week-nav { width: 100%; justify-content: center; } .nav-pills { width: 100%; justify-content: space-between; } .nav-btn { flex: 1; justify-content: center; padding: 0.5rem; font-size: 0.85rem; } .days-grid { grid-template-columns: 1fr; /* Force single column */ } .main-content { padding: 1rem; } .week-title { font-size: 1.5rem; } /* Adjust toast position for mobile */ .toast-container { bottom: 1rem; right: 1rem; left: 1rem; /* Center on mobile */ width: auto; } .menu-card { margin-bottom: 1rem; } } /* === Flagging & Notification Styles === */ .btn-flag { display: inline-flex; align-items: center; justify-content: center; background: transparent; border: 1px solid var(--text-secondary); color: var(--text-secondary); border-radius: 6px; padding: 4px; cursor: pointer; transition: all 0.2s; margin-right: 0.5rem; width: 28px; height: 28px; } .btn-flag:hover { background: rgba(234, 179, 8, 0.1); /* Yellow-500 / 10% */ color: #eab308; border-color: #eab308; } .btn-flag.active { background: rgba(234, 179, 8, 0.1); color: #eab308; border-color: #eab308; } .btn-flag .material-icons-round { font-size: 1.1rem; } /* Flagged & Sold Out (Yellow Glow) */ .menu-item.flagged-sold-out { border: 1px solid #eab308; box-shadow: 0 0 10px rgba(234, 179, 8, 0.2); border-radius: 8px; padding: 1rem; margin: 0 -1rem 1.5rem -1rem; background: var(--bg-card); position: relative; z-index: 5; animation: yellow-pulse 3s infinite; } @keyframes yellow-pulse { 0% { box-shadow: 0 0 8px rgba(234, 179, 8, 0.2); } 50% { box-shadow: 0 0 16px rgba(234, 179, 8, 0.5); } 100% { box-shadow: 0 0 8px rgba(234, 179, 8, 0.2); } } /* Flagged & Available (Green Glow) */ .menu-item.flagged-available { border: 2px solid var(--success-color); box-shadow: 0 0 15px rgba(16, 185, 129, 0.3); border-radius: 8px; padding: 1rem; margin: 0 -1rem 1.5rem -1rem; background: var(--bg-card); position: relative; z-index: 5; animation: green-pulse 3s infinite; } @keyframes green-pulse { 0% { box-shadow: 0 0 10px rgba(16, 185, 129, 0.3); } 50% { box-shadow: 0 0 20px rgba(16, 185, 129, 0.6); } 100% { box-shadow: 0 0 10px rgba(16, 185, 129, 0.3); } } /* Day Header Badges */ .day-header-left { display: flex; align-items: center; gap: 0.75rem; } .menu-code-badge { font-size: 0.75rem; font-weight: 700; color: #8b5cf6; /* Violet 500 */ background-color: rgba(139, 92, 246, 0.15); border: 1px solid rgba(139, 92, 246, 0.3); padding: 2px 6px; border-radius: 6px; line-height: normal; display: inline-block; } /* Detailed Badge Colors */ .nav-badge.badge-violet { background-color: #8b5cf6; } .nav-badge.badge-green { background-color: var(--success-color); } .nav-badge.badge-red { background-color: var(--error-color); } .nav-badge.badge-blue { background-color: var(--accent-color); } /* Day Header Status Colors (User Request) */ .card-header.header-violet { background-color: rgba(139, 92, 246, 0.15); border-bottom: 2px solid #8b5cf6; } .card-header.header-green { background-color: rgba(16, 185, 129, 0.15); border-bottom: 2px solid var(--success-color); } .card-header.header-red { background-color: rgba(239, 68, 68, 0.15); border-bottom: 2px solid var(--error-color); } .card-header.header-violet .day-name, .card-header.header-green .day-name, .card-header.header-red .day-name { font-weight: 700; color: var(--text-primary); /* Ensure text remains standard color */ } /* 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 = sessionStorage.getItem('kantine_authToken');\n let currentUser = sessionStorage.getItem('kantine_currentUser');\n let orderMap = new Map();\n let userFlags = new Set(JSON.parse(localStorage.getItem('kantine_flags') || '[]'));\n let pollIntervalId = null;\n\n // === API Helpers ===\n function apiHeaders(token) {\n return {\n 'Authorization': `Token ${token || GUEST_TOKEN}`,\n 'Accept': 'application/json',\n 'Content-Type': 'application/json',\n 'X-Client-Version': CLIENT_VERSION\n };\n }\n\n // === Inject UI ===\n function injectUI() {\n // Replace entire page content\n document.title = 'Kantine Weekly Menu';\n\n // Inject custom favicon (triangle + fork & knife)\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/svg+xml';\n favicon.href = 'data:image/svg+xml,' + encodeURIComponent('');\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
\n
\n
\n
\n restaurant_menu\n
\n

Kantinen \u00dcbersicht v1.4.23

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

Login

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

Men\u00fcdaten aktualisieren

\n
\n
\n
\n
\n
\n
\n
0%
\n
\n

Initialisierung...

\n
\n
\n
\n\n
\n
\n
\n

Meine Highlights

\n \n
\n
\n

\n Markiere Men\u00fcs automatisch, wenn sie diese Schlagw\u00f6rter enthalten.\n

\n
\n \n \n
\n
\n
\n
\n
\n\n
\n
\n
\n

Bestellhistorie

\n \n
\n
\n
\n

Lade Historie...

\n
\n
\n
\n
\n
\n
\n
\n \n
\n
\n
\n
\n\n
\n
\n
\n

\ud83d\udce6 Versionen

\n \n
\n
\n
\n Aktuell: v1.4.23\n
\n
\n \n
\n
\n

Lade Versionen...

\n
\n
\n \n bug_report Fehler melden\n \n \n lightbulb Feature vorschlagen\n \n \n
\n
\n
\n
\n\n
\n
\n update\n Gerade aktualisiert\n
\n
\n
\n

Lade Men\u00fcdaten...

\n
\n
\n
\n\n
\n

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

\n
\n
`;\n }\n\n // === Bind Events ===\n function bindEvents() {\n const btnThisWeek = document.getElementById('btn-this-week');\n const btnNextWeek = document.getElementById('btn-next-week');\n const btnRefresh = document.getElementById('btn-refresh');\n const themeToggle = document.getElementById('theme-toggle');\n const btnLoginOpen = document.getElementById('btn-login-open');\n const btnLoginClose = document.getElementById('btn-login-close');\n const btnLogout = document.getElementById('btn-logout');\n const loginForm = document.getElementById('login-form');\n const loginModal = document.getElementById('login-modal');\n\n // 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 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 localStorage.clear();\n sessionStorage.clear();\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 sessionStorage.setItem('kantine_authToken', data.key);\n sessionStorage.setItem('kantine_currentUser', employeeId);\n\n // Fetch user name\n try {\n const userResp = await fetch(`${API_BASE}/auth/user/`, {\n headers: apiHeaders(authToken)\n });\n if (userResp.ok) {\n const userData = await userResp.json();\n if (userData.first_name) sessionStorage.setItem('kantine_firstName', userData.first_name);\n if (userData.last_name) sessionStorage.setItem('kantine_lastName', userData.last_name);\n }\n } catch (err) {\n console.error('Failed to fetch user info:', err);\n }\n\n updateAuthUI();\n loginModal.classList.add('hidden');\n fetchOrders();\n loginForm.reset();\n startPolling();\n\n // Reload menu data with auth for full details\n loadMenuDataFromAPI();\n } else {\n loginError.textContent = data.non_field_errors?.[0] || data.error || 'Login fehlgeschlagen';\n loginError.classList.remove('hidden');\n }\n } catch (error) {\n console.error('Login error:', error);\n loginError.textContent = 'Ein Fehler ist aufgetreten';\n loginError.classList.remove('hidden');\n } finally {\n submitBtn.disabled = false;\n submitBtn.textContent = originalText;\n }\n });\n\n // Logout\n btnLogout.addEventListener('click', () => {\n sessionStorage.removeItem('kantine_authToken');\n sessionStorage.removeItem('kantine_currentUser');\n sessionStorage.removeItem('kantine_firstName');\n sessionStorage.removeItem('kantine_lastName');\n authToken = null;\n currentUser = null;\n orderMap = new Map();\n stopPolling();\n updateAuthUI();\n renderVisibleWeeks();\n });\n }\n\n // === Auth UI ===\n function updateAuthUI() {\n // Try to recover session from Bessa's storage if not already logged in\n if (!authToken) {\n try {\n const akita = localStorage.getItem('AkitaStores');\n if (akita) {\n const parsed = JSON.parse(akita);\n if (parsed.auth && parsed.auth.token) {\n console.log('Found existing Bessa session!');\n authToken = parsed.auth.token;\n sessionStorage.setItem('kantine_authToken', authToken);\n\n if (parsed.auth.user) {\n currentUser = parsed.auth.user.id || 'unknown';\n sessionStorage.setItem('kantine_currentUser', currentUser);\n if (parsed.auth.user.firstName) sessionStorage.setItem('kantine_firstName', parsed.auth.user.firstName);\n if (parsed.auth.user.lastName) sessionStorage.setItem('kantine_lastName', parsed.auth.user.lastName);\n }\n }\n }\n } catch (e) {\n console.warn('Failed to parse AkitaStores:', e);\n }\n }\n\n authToken = sessionStorage.getItem('kantine_authToken');\n currentUser = sessionStorage.getItem('kantine_currentUser');\n const firstName = sessionStorage.getItem('kantine_firstName');\n const btnLoginOpen = document.getElementById('btn-login-open');\n const userInfo = document.getElementById('user-info');\n const userIdDisplay = document.getElementById('user-id-display');\n\n if (authToken) {\n btnLoginOpen.classList.add('hidden');\n userInfo.classList.remove('hidden');\n userIdDisplay.textContent = firstName || (currentUser ? `User ${currentUser}` : 'Angemeldet');\n fetchOrders(); // Always fetch fresh orders on auth update\n } else {\n btnLoginOpen.classList.remove('hidden');\n userInfo.classList.add('hidden');\n userIdDisplay.textContent = '';\n }\n\n renderVisibleWeeks();\n }\n\n // === Fetch Orders from Bessa ===\n async function fetchOrders() {\n if (!authToken) return;\n try {\n // Use user/orders endpoint for reliable history\n const response = await fetch(`${API_BASE}/user/orders/?venue=${VENUE_ID}&ordering=-created&limit=50`, {\n headers: apiHeaders(authToken)\n });\n const data = await response.json();\n\n if (response.ok) {\n orderMap = new Map();\n const results = data.results || [];\n\n for (const order of results) {\n // Filter out cancelled orders (State 9)\n // Accepting State 1 (Created?), 5 (Placed?), 8 (Completed)\n // TODO: Verify exact states. Subagent saw 5=Active, 8=Completed, 9=Cancelled.\n if (order.order_state === 9) continue;\n\n // Extract date properly (it comes as ISO string)\n const orderDate = order.date.split('T')[0];\n\n for (const item of (order.items || [])) {\n const key = `${orderDate}_${item.article}`;\n if (!orderMap.has(key)) orderMap.set(key, []);\n orderMap.get(key).push(order.id);\n }\n }\n console.log(`Fetched ${results.length} orders, mapped active ones.`);\n renderVisibleWeeks();\n 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 = `

Fehler 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 += `
\n

${yearGroup.year}

`;\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 += `
\n
\n
\n ${monthGroup.name}\n
\n ${monthGroup.count} Bestellungen • \u20ac${monthGroup.total.toFixed(2)}\n
\n
\n expand_more\n
\n
`;\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 += `
\n
\n ${week.label}\n ${week.count} Bestellungen • \u20ac${week.total.toFixed(2)}\n
`;\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 = 'Storniert';\n } else if (item.state === 8) {\n statusBadge = 'Abgeschlossen';\n } else {\n statusBadge = '\u00dcbertragen';\n }\n\n html += `\n
\n
${dayStr}
\n
\n ${escapeHtml(item.name)}\n
${statusBadge}
\n
\n
\u20ac${item.price.toFixed(2)}
\n
`;\n });\n html += `
`;\n });\n html += `
`; // Close month-content and month-group\n });\n html += `
`; // 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:00:00.000Z`,\n payment_method: 'payroll',\n customer: {\n first_name: userData.first_name,\n last_name: userData.last_name,\n email: userData.email,\n newsletter: false\n },\n preorder: false,\n delivery_fee: 0,\n cash_box_table_name: null,\n take_away: false\n };\n\n const response = await fetch(`${API_BASE}/user/orders/`, {\n method: 'POST',\n headers: apiHeaders(authToken),\n body: JSON.stringify(orderPayload)\n });\n\n if (response.ok || response.status === 201) {\n showToast(`Bestellt: ${name}`, 'success');\n 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} ×`;\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.

${error.message}`,\n 'Zur Original-Seite',\n 'https://web.bessa.app/knapp-kantine'\n );\n } finally {\n loading.classList.add('hidden');\n }\n }\n\n // === Last Updated Display ===\n 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 = `${icon}${message}`;\n container.appendChild(toast);\n requestAnimationFrame(() => toast.classList.add('show'));\n setTimeout(() => {\n toast.classList.remove('show');\n setTimeout(() => toast.remove(), 300);\n }, 3000);\n }\n\n // === Next Week Badge ===\n function updateNextWeekBadge() {\n const btnNextWeek = document.getElementById('btn-next-week');\n let nextWeek = currentWeekNumber + 1;\n let nextYear = currentYear;\n if (nextWeek > 52) { nextWeek = 1; nextYear++; }\n\n const nextWeekData = allWeeks.find(w => w.weekNumber === nextWeek && w.year === nextYear);\n let totalDataCount = 0;\n let orderableCount = 0;\n let daysWithOrders = 0;\n let daysWithOrderableAndNoOrder = 0;\n\n if (nextWeekData && nextWeekData.days) {\n nextWeekData.days.forEach(day => {\n if (day.items && day.items.length > 0) {\n totalDataCount++;\n const isOrderable = day.items.some(item => item.available);\n if (isOrderable) orderableCount++;\n\n let hasOrder = false;\n day.items.forEach(item => {\n const articleId = item.articleId || parseInt(item.id.split('_')[1]);\n const key = `${day.date}_${articleId}`;\n if (orderMap.has(key) && orderMap.get(key).length > 0) hasOrder = true;\n });\n\n if (hasOrder) daysWithOrders++;\n if (isOrderable && !hasOrder) daysWithOrderableAndNoOrder++;\n }\n });\n }\n\n let badge = btnNextWeek.querySelector('.nav-badge');\n if (totalDataCount > 0) {\n if (!badge) {\n badge = document.createElement('span');\n badge.className = 'nav-badge';\n btnNextWeek.appendChild(badge);\n }\n\n // Format: ( Ordered / Orderable / Total )\n badge.title = `${daysWithOrders} bestellt / ${orderableCount} bestellbar / ${totalDataCount} gesamt`;\n badge.innerHTML = `${daysWithOrders}/${orderableCount}/${totalDataCount}`;\n\n // Color Logic\n badge.classList.remove('badge-violet', 'badge-green', 'badge-red', 'badge-blue');\n\n // Refined Logic (v1.7.4):\n // Violet: If we have orders AND there are no DAYS left that are orderable but un-ordered.\n // (i.e. \"I have ordered everything I can\")\n if (daysWithOrders > 0 && daysWithOrderableAndNoOrder === 0) {\n badge.classList.add('badge-violet');\n } else if (daysWithOrderableAndNoOrder > 0) {\n badge.classList.add('badge-green'); // Orderable days exist without order\n } else if (orderableCount === 0) {\n badge.classList.add('badge-red'); // No orderable days at all & no orders\n } else {\n badge.classList.add('badge-blue'); // Default / partial state\n }\n\n // 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 += `(${highlightCount})`;\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 = `shopping_bag Gesamt: ${totalCost.toFixed(2).replace('.', ',')} \u20ac`;\n costDisplay.classList.remove('hidden');\n } else {\n costDisplay.classList.add('hidden');\n }\n }\n\n // === Render Weeks ===\n function renderVisibleWeeks() {\n const menuContainer = document.getElementById('menu-container');\n if (!menuContainer) return;\n menuContainer.innerHTML = '';\n\n let targetWeek = currentWeekNumber;\n let targetYear = currentYear;\n\n if (displayMode === 'next-week') {\n targetWeek++;\n if (targetWeek > 52) { targetWeek = 1; targetYear++; }\n }\n\n // Flatten & filter by week + year\n const allDays = allWeeks.flatMap(w => w.days || []);\n const daysInTargetWeek = allDays.filter(day => {\n const d = new Date(day.date);\n return getISOWeek(d) === targetWeek && getWeekYear(d) === targetYear;\n });\n\n if (daysInTargetWeek.length === 0) {\n menuContainer.innerHTML = `\n
\n

Keine Men\u00fcdaten f\u00fcr KW ${targetWeek} (${targetYear}) verf\u00fcgbar.

\n Versuchen Sie eine andere Woche oder schauen Sie sp\u00e4ter vorbei.\n
`;\n document.getElementById('weekly-cost-display').classList.add('hidden');\n return;\n }\n\n updateWeeklyCost(daysInTargetWeek);\n\n // Update header\n const headerWeekInfo = document.getElementById('header-week-info');\n const weekTitle = displayMode === 'this-week' ? 'Diese Woche' : 'N\u00e4chste Woche';\n headerWeekInfo.innerHTML = `\n
${weekTitle}
\n
Week ${targetWeek} \u2022 ${targetYear}
`;\n\n const grid = document.createElement('div');\n grid.className = 'days-grid';\n\n daysInTargetWeek.sort((a, b) => a.date.localeCompare(b.date));\n\n // Filter weekends\n const workingDays = daysInTargetWeek.filter(d => {\n const date = new Date(d.date);\n const day = date.getDay();\n return day !== 0 && day !== 6;\n });\n\n workingDays.forEach(day => {\n const card = createDayCard(day);\n if (card) grid.appendChild(card);\n });\n\n menuContainer.appendChild(grid);\n setTimeout(() => syncMenuItemHeights(grid), 0);\n }\n\n // === Sync Item Heights ===\n function syncMenuItemHeights(grid) {\n const cards = grid.querySelectorAll('.menu-card');\n if (cards.length === 0) return;\n let maxItems = 0;\n cards.forEach(card => {\n maxItems = Math.max(maxItems, card.querySelectorAll('.menu-item').length);\n });\n for (let i = 0; i < maxItems; i++) {\n let maxHeight = 0;\n const itemsAtPos = [];\n cards.forEach(card => {\n const items = card.querySelectorAll('.menu-item');\n if (items[i]) {\n items[i].style.height = 'auto';\n maxHeight = Math.max(maxHeight, items[i].offsetHeight);\n itemsAtPos.push(items[i]);\n }\n });\n itemsAtPos.forEach(item => { item.style.height = `${maxHeight}px`; });\n }\n }\n\n // === Create Day Card ===\n function createDayCard(day) {\n if (!day.items || day.items.length === 0) return null;\n\n const card = document.createElement('div');\n card.className = 'menu-card';\n\n const now = new Date();\n const cardDate = new Date(day.date);\n\n let isPastCutoff = false;\n if (day.orderCutoff) {\n isPastCutoff = now >= new Date(day.orderCutoff);\n } else {\n const today = new Date();\n today.setHours(0, 0, 0, 0);\n const cd = new Date(day.date);\n cd.setHours(0, 0, 0, 0);\n isPastCutoff = cd < today;\n }\n\n if (isPastCutoff) card.classList.add('past-day');\n\n // Collect ordered menu codes\n const menuBadges = [];\n if (day.items) {\n day.items.forEach(item => {\n const articleId = item.articleId || parseInt(item.id.split('_')[1]);\n const orderKey = `${day.date}_${articleId}`;\n const orders = orderMap.get(orderKey) || [];\n const count = orders.length;\n\n if (count > 0) {\n // Regex for M1, M2, M1F etc.\n const match = item.name.match(/([M][1-9][Ff]?)/);\n if (match) {\n let code = match[1];\n if (count > 1) code += '+';\n menuBadges.push(code);\n }\n }\n });\n }\n\n // Header\n const header = document.createElement('div');\n header.className = 'card-header';\n const dateStr = cardDate.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' });\n\n const badgesHtml = menuBadges.map(code => `${code}`).join('');\n\n // Determine Day Status for Header Color\n // Violet: Has Order\n // Green: No Order but Orderable\n // Red: No Order and Not Orderable (Locked/Sold Out)\n let headerClass = '';\n const hasAnyOrder = day.items && day.items.some(item => {\n const articleId = item.articleId || parseInt(item.id.split('_')[1]);\n const key = `${day.date}_${articleId}`;\n return orderMap.has(key) && orderMap.get(key).length > 0;\n });\n\n const hasOrderable = day.items && day.items.some(item => {\n // Use pre-calculated available flag from loadMenuDataFromAPI calculation\n return item.available;\n });\n\n if (hasAnyOrder) {\n headerClass = 'header-violet';\n } else if (hasOrderable && !isPastCutoff) {\n headerClass = 'header-green';\n } else {\n // Red if not orderable (or past cutoff)\n headerClass = 'header-red';\n }\n\n if (headerClass) header.classList.add(headerClass);\n\n header.innerHTML = `\n
\n ${translateDay(day.weekday)}\n
${badgesHtml}
\n
\n ${dateStr}`;\n card.appendChild(header);\n\n // Body\n const body = document.createElement('div');\n body.className = 'card-body';\n\n const todayDateStr = new Date().toISOString().split('T')[0];\n const isToday = day.date === todayDateStr;\n\n const sortedItems = [...day.items].sort((a, b) => {\n if (isToday) {\n const aId = a.articleId || parseInt(a.id.split('_')[1]);\n const bId = b.articleId || parseInt(b.id.split('_')[1]);\n const aOrdered = orderMap.has(`${day.date}_${aId}`);\n const bOrdered = orderMap.has(`${day.date}_${bId}`);\n\n if (aOrdered && !bOrdered) return -1;\n if (!aOrdered && bOrdered) return 1;\n }\n return a.name.localeCompare(b.name);\n });\n\n sortedItems.forEach(item => {\n const itemEl = document.createElement('div');\n itemEl.className = 'menu-item';\n\n const articleId = item.articleId || parseInt(item.id.split('_')[1]);\n const orderKey = `${day.date}_${articleId}`;\n const orderIds = orderMap.get(orderKey) || [];\n const orderCount = orderIds.length;\n\n // Status badge\n let statusBadge = '';\n if (item.available) {\n statusBadge = item.amountTracking\n ? `Verf\u00fcgbar (${item.availableAmount})`\n : `Verf\u00fcgbar`;\n } else {\n statusBadge = `Ausverkauft`;\n }\n\n // Order badge\n let orderedBadge = '';\n if (orderCount > 0) {\n const countBadge = orderCount > 1 ? `${orderCount}` : '';\n orderedBadge = `check_circle Bestellt${countBadge}`;\n itemEl.classList.add('ordered');\n if (new Date(day.date).toDateString() === now.toDateString()) {\n itemEl.classList.add('today-ordered');\n }\n }\n\n // Flagged styles\n const flagId = `${day.date}_${articleId}`;\n const isFlagged = userFlags.has(flagId);\n if (isFlagged) {\n itemEl.classList.add(item.available ? 'flagged-available' : 'flagged-sold-out');\n }\n\n // 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 = ``;\n }\n\n // Order button\n if (item.available) {\n if (orderCount > 0) {\n orderButton = ``;\n } else {\n orderButton = ``;\n }\n }\n\n // Cancel button\n if (orderCount > 0) {\n const cancelIcon = orderCount === 1 ? 'close' : 'remove';\n const cancelTitle = orderCount === 1 ? 'Bestellung stornieren' : 'Eine Bestellung stornieren';\n cancelButton = ``;\n }\n }\n\n // Build matched-tags HTML (only if tags found)\n let tagsHtml = '';\n if (matchedTags.length > 0) {\n const badges = matchedTags.map(t => `star${escapeHtml(t)}`).join('');\n tagsHtml = `
${badges}
`;\n }\n\n itemEl.innerHTML = `\n
\n ${escapeHtml(item.name)}\n ${item.price.toFixed(2)} \u20ac\n
\n
\n ${orderedBadge}\n ${cancelButton}\n ${orderButton}\n ${flagButton}\n
${statusBadge}
\n
\n ${tagsHtml}\n

${escapeHtml(item.description)}

`;\n\n // Event: Order\n const orderBtn = itemEl.querySelector('.btn-order');\n if (orderBtn) {\n orderBtn.addEventListener('click', (e) => {\n e.stopPropagation();\n const btn = e.currentTarget;\n btn.disabled = true;\n btn.classList.add('loading');\n placeOrder(btn.dataset.date, parseInt(btn.dataset.article), btn.dataset.name, parseFloat(btn.dataset.price), btn.dataset.desc || '')\n .finally(() => { btn.disabled = false; btn.classList.remove('loading'); });\n });\n }\n\n // Event: Cancel\n const cancelBtn = itemEl.querySelector('.btn-cancel');\n if (cancelBtn) {\n cancelBtn.addEventListener('click', (e) => {\n e.stopPropagation();\n const btn = e.currentTarget;\n btn.disabled = true;\n cancelOrder(btn.dataset.date, parseInt(btn.dataset.article), btn.dataset.name)\n .finally(() => { btn.disabled = false; });\n });\n }\n\n // Event: Flag\n const flagBtn = itemEl.querySelector('.btn-flag');\n if (flagBtn) {\n flagBtn.addEventListener('click', (e) => {\n e.stopPropagation();\n const btn = e.currentTarget;\n toggleFlag(btn.dataset.date, parseInt(btn.dataset.article), btn.dataset.name, btn.dataset.cutoff);\n });\n }\n\n body.appendChild(itemEl);\n });\n\n card.appendChild(body);\n return card;\n }\n\n // === 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.4.23';\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.4.23';\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 = '';\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 = '\u2713 Installiert';\n else if (isNew) badge = '\u2b06 Neu!';\n\n let action = '';\n if (!isCurrent) {\n action = `Installieren`;\n }\n\n li.innerHTML = `\n
\n ${v.tag}\n ${badge}\n
\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 = `

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 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 (!sessionStorage.getItem(notifiedKey)) {\n if (Notification.permission === 'granted') {\n new Notification('Kantine: Bestellschluss naht!', {\n body: 'Du hast heute noch nichts bestellt. Nur noch 1 Stunde!',\n icon: '\u23f3'\n });\n } else if (Notification.permission === 'default') {\n Notification.requestPermission();\n }\n sessionStorage.setItem(notifiedKey, 'true');\n }\n } else {\n countdownEl.classList.remove('urgent');\n }\n }\n\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 // === 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

\n signal_wifi_off\n ${title}\n

\n
\n
\n

${htmlContent}

\n
\n \n
\n
\n
\n `;\n document.body.appendChild(modal);\n\n document.getElementById('btn-error-redirect').addEventListener('click', () => {\n window.location.href = url;\n });\n\n requestAnimationFrame(() => {\n modal.classList.remove('hidden');\n });\n}\n"; document.head.appendChild(sc); })(); +javascript:(function(){ if(window.__KANTINE_LOADED){alert('Kantine Wrapper already loaded!');return;} var s=document.createElement('style'); s.textContent=':root { /* Premium Slate/Gray-Blue Palette - Light Mode */ --bg-body: #f1f5f9; /* Slate 100 */ --bg-card: #ffffff; --text-primary: #334155; /* Slate 700 */ --text-secondary: #64748b; --accent-color: #0f172a; /* Slate 900 (High contrast) */ --border-color: #cbd5e1; /* Slate 300 */ --banner-bg: #e2e8f0; --banner-text: #1e293b; --success-color: #059669; --error-color: #dc2626; --card-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.05), 0 2px 4px -2px rgb(0 0 0 / 0.05); --header-bg: rgba(255, 255, 255, 0.9); --header-border: 1px solid rgba(203, 213, 225, 0.6); } [data-theme="dark"] { /* Premium Slate/Gray-Blue Palette - Dark Mode */ --bg-body: #1e293b; /* Deep Slate Gray (Requested) */ --bg-card: #334155; /* Slate 700 */ --text-primary: #f8fafc; /* Slate 50 */ --text-secondary: #cbd5e1; /* Slate 300 */ --accent-color: #60a5fa; /* Blue 400 */ --border-color: #475569; /* Slate 600 */ --banner-bg: #475569; --banner-text: #e2e8f0; --header-bg: rgba(30, 41, 59, 0.9); --header-border: 1px solid rgba(71, 85, 105, 0.6); --card-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.4); } * { box-sizing: border-box; margin: 0; padding: 0; } body { font-family: \'Inter\', system-ui, -apple-system, sans-serif; background-color: var(--bg-body); color: var(--text-primary); transition: background-color 0.3s ease, color 0.3s ease; line-height: 1.5; -webkit-font-smoothing: antialiased; } /* Fix scrolling bug: Reset html/body styles from host page */ html, body { height: auto !important; min-height: 100% !important; overflow-y: auto !important; overflow-x: hidden !important; position: static !important; margin: 0 !important; padding: 0 !important; } /* Header */ .app-header { position: sticky; top: 0; z-index: 100; backdrop-filter: blur(12px); background-color: var(--header-bg); border-bottom: var(--header-border); padding: 1rem 0; } .header-content { width: 100%; /* Full width */ padding: 0 2rem; /* Comfortable padding */ display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; gap: 1rem; } .brand { display: flex; align-items: center; gap: 0.75rem; } .brand-text { display: flex; flex-direction: column; } .brand h1 { font-size: 1.25rem; font-weight: 700; letter-spacing: -0.025em; margin-bottom: 0; } .subtitle { font-size: 0.85rem; color: var(--text-secondary); font-weight: 400; margin-left: 2px; } .logo-icon { font-size: 1.5rem; color: var(--accent-color); } /* Controls */ .controls { display: flex; align-items: center; gap: 1.5rem; justify-self: end; } /* Header Week Info (centered) */ .header-week-info { text-align: center; line-height: 1.3; } .header-center-wrapper { display: flex; flex-direction: row; align-items: center; gap: 1.5rem; justify-content: center; } .header-week-title { font-size: 1.1rem; font-weight: 600; color: var(--text-primary); } .header-week-subtitle { font-size: 0.85rem; color: var(--text-secondary); } .nav-group { display: flex; background-color: var(--bg-card); border: 1px solid var(--border-color); padding: 0.25rem; border-radius: 8px; } .nav-btn { background: none; border: none; padding: 0.5rem 1rem; font-size: 0.875rem; font-weight: 500; color: var(--text-secondary); cursor: pointer; border-radius: 6px; transition: all 0.2s; display: flex; align-items: center; gap: 0.5rem; } .nav-btn:hover { color: var(--text-primary); background-color: rgba(100, 116, 139, 0.1); } .nav-btn.active { background-color: var(--accent-color); color: white; } /* 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 */ .container { width: 100%; /* Full width */ margin: 2rem auto; padding: 0 2rem; min-height: 80vh; } /* Banner */ .banner { background-color: var(--banner-bg); color: var(--banner-text); padding: 0.75rem 1rem; border-radius: 8px; display: flex; align-items: center; gap: 0.5rem; margin-bottom: 2rem; font-size: 0.875rem; font-weight: 500; border: 1px solid var(--border-color); max-width: fit-content; } /* User Badge */ .user-badge { display: flex; align-items: center; gap: 8px; padding: 6px 12px; background: var(--bg-card); /* Changed from --surface */ border: 1px solid var(--border-color); /* Changed from --border */ border-radius: 20px; font-size: 0.9rem; font-weight: 500; } .icon-btn-small { background: none; border: none; padding: 4px; cursor: pointer; color: var(--text-secondary); /* Changed from --text-muted */ display: flex; align-items: center; justify-content: center; border-radius: 50%; transition: all 0.2s; } .icon-btn-small:hover { color: var(--error-color); /* Changed from --danger */ background: rgba(239, 68, 68, 0.1); } /* Modal */ .modal { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0, 0, 0, 0.5); backdrop-filter: blur(4px); display: flex; align-items: center; justify-content: center; z-index: 1000; transition: all 0.3s; } .modal.hidden { opacity: 0; pointer-events: none; } .modal-content { background: var(--bg-card); 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 */ .menu-grid { display: grid; gap: 2rem; } .week-section { margin-bottom: 3rem; } .week-header { margin-bottom: 1.5rem; border-bottom: 1px solid var(--border-color); padding-bottom: 1rem; text-align: center; } .week-title { font-size: 1.75rem; font-weight: 700; color: var(--text-primary); } .week-range { color: var(--text-secondary); font-size: 0.9rem; margin-top: 0.25rem; } .days-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 0.75rem; } /* Card */ .menu-card { background-color: var(--bg-card); border-radius: 12px; border: 1px solid var(--border-color); box-shadow: var(--card-shadow); overflow: hidden; transition: transform 0.2s ease, box-shadow 0.2s ease; display: flex; flex-direction: column; } /* Past Day Styling - Target specific elements so ordered items can remain visible */ .menu-card.past-day .card-header, .menu-card.past-day .menu-item:not(.ordered) { opacity: 0.6; filter: grayscale(0.8); transition: opacity 0.3s, filter 0.3s; } .menu-card.past-day:hover .card-header, .menu-card.past-day:hover .menu-item:not(.ordered) { opacity: 0.8; filter: grayscale(0.4); } /* Enhancements for ordered items */ .menu-card.past-day .menu-item.ordered { /* No opacity/filter here - fully visible */ background: var(--bg-card); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); border: 1px solid #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 { transform: translateY(-2px); box-shadow: 0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1); } .card-header { padding: 1rem 1.25rem; border-bottom: 1px solid var(--border-color); display: flex; justify-content: space-between; align-items: baseline; background-color: rgba(100, 116, 139, 0.05); } .day-name { font-size: 1.125rem; font-weight: 600; } .day-date { font-size: 0.875rem; color: var(--text-secondary); } .card-body { padding: 1.25rem; display: grid; grid-template-rows: auto; /* Each menu item gets its own row */ align-content: start; } .empty-state { color: var(--text-secondary); font-style: italic; text-align: center; padding: 1rem; } /* Menu Items */ .menu-item { margin-bottom: 1.5rem; padding-bottom: 1.5rem; border-bottom: 1px solid var(--border-color); } .menu-item:last-child { margin-bottom: 0; padding-bottom: 0; border-bottom: none; } .item-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 0.5rem; gap: 1rem; } .item-name { font-weight: 600; color: var(--text-primary); font-size: 1rem; } .item-price { font-weight: 700; color: var(--accent-color); white-space: nowrap; } .item-desc { font-size: 0.875rem; color: var(--text-secondary); line-height: 1.6; margin-bottom: 0.75rem; } .badges { display: flex; gap: 0.5rem; margin-left: auto; } .item-status-row { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.75rem; } .badge { display: inline-flex; align-items: center; justify-content: center; height: 24px; font-size: 0.75rem; padding: 0 10px; border-radius: 4px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; line-height: normal; white-space: nowrap; } .badge.available { background-color: rgba(16, 185, 129, 0.1); /* Emerald 500 / 10% */ color: var(--success-color); border: 1px solid rgba(16, 185, 129, 0.2); } .badge.sold-out { background-color: rgba(239, 68, 68, 0.1); /* Red 500 / 10% */ color: var(--error-color); border: 1px solid rgba(239, 68, 68, 0.2); } .badge.ordered { background-color: rgba(139, 92, 246, 0.1); /* Violet 500 / 10% */ color: #8b5cf6; border: 1px solid rgba(139, 92, 246, 0.2); gap: 4px; } .badge.ordered .material-icons-round { font-size: 1rem; } /* Loading */ .loading-state { text-align: center; padding: 4rem; color: var(--text-secondary); } .spinner { width: 40px; height: 40px; border: 3px solid var(--border-color); border-top-color: var(--accent-color); border-radius: 50%; margin: 0 auto 1rem; animation: spin 1s linear infinite; } @keyframes spin { to { transform: rotate(360deg); } } /* Footer */ .app-footer { text-align: center; padding: 2rem; color: var(--text-secondary); font-size: 0.875rem; border-top: 1px solid var(--border-color); margin-top: auto; } /* === Order / Cancel Buttons (inline in status row) === */ .btn-order { display: inline-flex; align-items: center; gap: 4px; padding: 4px 10px; border: none; border-radius: 6px; background: var(--success-color); color: white; font-size: 0.75rem; font-weight: 600; cursor: pointer; transition: all 0.2s ease; font-family: inherit; } .btn-order .material-icons-round { font-size: 16px; } .btn-order:hover:not(:disabled) { filter: brightness(1.15); transform: translateY(-1px); } .btn-order:disabled { opacity: 0.5; cursor: not-allowed; } .btn-order.loading { pointer-events: none; opacity: 0.6; } .btn-order-compact { padding: 2px 4px; gap: 0; } .btn-order-compact .material-icons-round { font-size: 16px; } .btn-cancel { display: inline-flex; align-items: center; justify-content: center; padding: 4px 6px; border: none; border-radius: 6px; background: var(--error-color); color: white; font-size: 0.75rem; cursor: pointer; transition: all 0.2s ease; font-family: inherit; } .btn-cancel .material-icons-round { font-size: 16px; } .btn-cancel:hover:not(:disabled) { filter: brightness(1.15); transform: translateY(-1px); } .btn-cancel:disabled { opacity: 0.5; cursor: not-allowed; } /* Past days: hide action buttons */ .past-day .item-actions { display: none; } /* Order count badge (for multi-orders) */ .order-count-badge { display: inline-flex; align-items: center; justify-content: center; background: rgba(255, 255, 255, 0.3); color: white; font-size: 0.65rem; font-weight: 700; min-width: 16px; height: 16px; padding: 0 4px; border-radius: 8px; margin-left: 4px; line-height: 1; } /* === Toast Notifications === */ #toast-container { position: fixed; bottom: 20px; right: 20px; z-index: 10000; display: flex; flex-direction: column; gap: 8px; pointer-events: none; } .toast { display: flex; align-items: center; gap: 8px; padding: 10px 16px; border-radius: 8px; font-size: 0.85rem; font-weight: 500; font-family: \'Inter\', sans-serif; color: white; backdrop-filter: blur(10px); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); pointer-events: auto; transform: translateX(120%); opacity: 0; transition: transform 0.3s ease, opacity 0.3s ease; } .toast.show { transform: translateX(0); opacity: 1; } .toast .material-icons-round { font-size: 18px; } .toast-success { background: rgba(5, 150, 105, 0.95); } .toast-error { background: rgba(220, 38, 38, 0.95); } .toast-info { background: rgba(59, 130, 246, 0.95); } /* === Mobile Responsiveness === */ @media (max-width: 600px) { .header-content { flex-direction: column; gap: 1rem; padding: 0.75rem; } .week-nav { width: 100%; justify-content: center; } .nav-pills { width: 100%; justify-content: space-between; } .nav-btn { flex: 1; justify-content: center; padding: 0.5rem; font-size: 0.85rem; } .days-grid { grid-template-columns: 1fr; /* Force single column */ } .main-content { padding: 1rem; } .week-title { font-size: 1.5rem; } /* Adjust toast position for mobile */ .toast-container { bottom: 1rem; right: 1rem; left: 1rem; /* Center on mobile */ width: auto; } .menu-card { margin-bottom: 1rem; } } /* === Flagging & Notification Styles === */ .btn-flag { display: inline-flex; align-items: center; justify-content: center; background: transparent; border: 1px solid var(--text-secondary); color: var(--text-secondary); border-radius: 6px; padding: 4px; cursor: pointer; transition: all 0.2s; margin-right: 0.5rem; width: 28px; height: 28px; } .btn-flag:hover { background: rgba(234, 179, 8, 0.1); /* Yellow-500 / 10% */ color: #eab308; border-color: #eab308; } .btn-flag.active { background: rgba(234, 179, 8, 0.1); color: #eab308; border-color: #eab308; } .btn-flag .material-icons-round { font-size: 1.1rem; } /* Flagged & Sold Out (Yellow Glow) */ .menu-item.flagged-sold-out { border: 1px solid #eab308; box-shadow: 0 0 10px rgba(234, 179, 8, 0.2); border-radius: 8px; padding: 1rem; margin: 0 -1rem 1.5rem -1rem; background: var(--bg-card); position: relative; z-index: 5; animation: yellow-pulse 3s infinite; } @keyframes yellow-pulse { 0% { box-shadow: 0 0 8px rgba(234, 179, 8, 0.2); } 50% { box-shadow: 0 0 16px rgba(234, 179, 8, 0.5); } 100% { box-shadow: 0 0 8px rgba(234, 179, 8, 0.2); } } /* Flagged & Available (Green Glow) */ .menu-item.flagged-available { border: 2px solid var(--success-color); box-shadow: 0 0 15px rgba(16, 185, 129, 0.3); border-radius: 8px; padding: 1rem; margin: 0 -1rem 1.5rem -1rem; background: var(--bg-card); position: relative; z-index: 5; animation: green-pulse 3s infinite; } @keyframes green-pulse { 0% { box-shadow: 0 0 10px rgba(16, 185, 129, 0.3); } 50% { box-shadow: 0 0 20px rgba(16, 185, 129, 0.6); } 100% { box-shadow: 0 0 10px rgba(16, 185, 129, 0.3); } } /* Day Header Badges */ .day-header-left { display: flex; align-items: center; gap: 0.75rem; } .menu-code-badge { font-size: 0.75rem; font-weight: 700; color: #8b5cf6; /* Violet 500 */ background-color: rgba(139, 92, 246, 0.15); border: 1px solid rgba(139, 92, 246, 0.3); padding: 2px 6px; border-radius: 6px; line-height: normal; display: inline-block; } /* Detailed Badge Colors */ .nav-badge.badge-violet { background-color: #8b5cf6; } .nav-badge.badge-green { background-color: var(--success-color); } .nav-badge.badge-red { background-color: var(--error-color); } .nav-badge.badge-blue { background-color: var(--accent-color); } /* Day Header Status Colors (User Request) */ .card-header.header-violet { background-color: rgba(139, 92, 246, 0.15); border-bottom: 2px solid #8b5cf6; } .card-header.header-green { background-color: rgba(16, 185, 129, 0.15); border-bottom: 2px solid var(--success-color); } .card-header.header-red { background-color: rgba(239, 68, 68, 0.15); border-bottom: 2px solid var(--error-color); } .card-header.header-violet .day-name, .card-header.header-green .day-name, .card-header.header-red .day-name { font-weight: 700; color: var(--text-primary); /* Ensure text remains standard color */ } /* 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 = sessionStorage.getItem('kantine_authToken');\n let currentUser = sessionStorage.getItem('kantine_currentUser');\n let orderMap = new Map();\n let userFlags = new Set(JSON.parse(localStorage.getItem('kantine_flags') || '[]'));\n let pollIntervalId = null;\n\n // === API Helpers ===\n function apiHeaders(token) {\n return {\n 'Authorization': `Token ${token || GUEST_TOKEN}`,\n 'Accept': 'application/json',\n 'Content-Type': 'application/json',\n 'X-Client-Version': CLIENT_VERSION\n };\n }\n\n // === Inject UI ===\n function injectUI() {\n // Replace entire page content\n document.title = 'Kantine Weekly Menu';\n\n // Inject custom favicon (triangle + fork & knife)\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/svg+xml';\n // Pre-encoded Base64 of the SVG favicon (triangle + fork & knife)\n favicon.href = 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA2NCA2NCI+PGcgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoMiwxMCkiPjxyZWN0IHg9IjEiIHk9IjAiIHdpZHRoPSIxLjgiIGhlaWdodD0iMTYiIHJ4PSIuOSIgZmlsbD0iIzMzMyIvPjxyZWN0IHg9IjQuNiIgeT0iMCIgd2lkdGg9IjEuOCIgaGVpZ2h0PSIxNiIgcng9Ii45IiBmaWxsPSIjMzMzIi8+PHJlY3QgeD0iOC4yIiB5PSIwIiB3aWR0aD0iMS44IiBoZWlnaHQ9IjE2IiByeD0iLjkiIGZpbGw9IiMzMzMiLz48cmVjdCB4PSIxIiB5PSIxNCIgd2lkdGg9IjkiIGhlaWdodD0iMy41IiByeD0iMS41IiBmaWxsPSIjMzMzIi8+PHJlY3QgeD0iMy41IiB5PSIxNi41IiB3aWR0aD0iNCIgaGVpZ2h0PSIyNCIgcng9IjIiIGZpbGw9IiMzMzMiLz48L2c+PHBvbHlnb24gcG9pbnRzPSIzMiw4IDQ3LDQ4IDE3LDQ4IiBmaWxsPSJub25lIiBzdHJva2U9IiMzMzMiIHN0cm9rZS13aWR0aD0iNCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIvPjxnIHRyYW5zZm9ybT0idHJhbnNsYXRlKDUwLDEwKSI+PHBhdGggZD0iTTMsMEMzLDAsMywwLDMsMEwzLDE3TDEwLDE0QzEwLDYsNywwLDMsMFoiIGZpbGw9IiMzMzMiLz48cmVjdCB4PSIxLjUiIHk9IjAiIHdpZHRoPSIyIiBoZWlnaHQ9IjE4IiByeD0iMSIgZmlsbD0iIzMzMyIvPjxyZWN0IHg9IjEuNSIgeT0iMTYuNSIgd2lkdGg9IjguNSIgaGVpZ2h0PSIzLjUiIHJ4PSIxLjIiIGZpbGw9IiMzMzMiLz48cmVjdCB4PSIzLjUiIHk9IjE5IiB3aWR0aD0iNCIgaGVpZ2h0PSIyMiIgcng9IjIiIGZpbGw9IiMzMzMiLz48L2c+PC9zdmc+';\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
\n
\n
\n
\n restaurant_menu\n
\n

Kantinen \u00dcbersicht v1.4.24

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

Login

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

Men\u00fcdaten aktualisieren

\n
\n
\n
\n
\n
\n
\n
0%
\n
\n

Initialisierung...

\n
\n
\n
\n\n
\n
\n
\n

Meine Highlights

\n \n
\n
\n

\n Markiere Men\u00fcs automatisch, wenn sie diese Schlagw\u00f6rter enthalten.\n

\n
\n \n \n
\n
\n
\n
\n
\n\n
\n
\n
\n

Bestellhistorie

\n \n
\n
\n
\n

Lade Historie...

\n
\n
\n
\n
\n
\n
\n
\n \n
\n
\n
\n
\n\n
\n
\n
\n

\ud83d\udce6 Versionen

\n \n
\n
\n
\n Aktuell: v1.4.24\n
\n
\n \n
\n
\n

Lade Versionen...

\n
\n
\n \n bug_report Fehler melden\n \n \n lightbulb Feature vorschlagen\n \n \n
\n
\n
\n
\n\n
\n
\n update\n Gerade aktualisiert\n
\n
\n
\n

Lade Men\u00fcdaten...

\n
\n
\n
\n\n
\n

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

\n
\n
`;\n }\n\n // === Bind Events ===\n function bindEvents() {\n const btnThisWeek = document.getElementById('btn-this-week');\n const btnNextWeek = document.getElementById('btn-next-week');\n const btnRefresh = document.getElementById('btn-refresh');\n const themeToggle = document.getElementById('theme-toggle');\n const btnLoginOpen = document.getElementById('btn-login-open');\n const btnLoginClose = document.getElementById('btn-login-close');\n const btnLogout = document.getElementById('btn-logout');\n const loginForm = document.getElementById('login-form');\n const loginModal = document.getElementById('login-modal');\n\n // 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 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 localStorage.clear();\n sessionStorage.clear();\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 sessionStorage.setItem('kantine_authToken', data.key);\n sessionStorage.setItem('kantine_currentUser', employeeId);\n\n // Fetch user name\n try {\n const userResp = await fetch(`${API_BASE}/auth/user/`, {\n headers: apiHeaders(authToken)\n });\n if (userResp.ok) {\n const userData = await userResp.json();\n if (userData.first_name) sessionStorage.setItem('kantine_firstName', userData.first_name);\n if (userData.last_name) sessionStorage.setItem('kantine_lastName', userData.last_name);\n }\n } catch (err) {\n console.error('Failed to fetch user info:', err);\n }\n\n updateAuthUI();\n loginModal.classList.add('hidden');\n fetchOrders();\n loginForm.reset();\n startPolling();\n\n // Reload menu data with auth for full details\n loadMenuDataFromAPI();\n } else {\n loginError.textContent = data.non_field_errors?.[0] || data.error || 'Login fehlgeschlagen';\n loginError.classList.remove('hidden');\n }\n } catch (error) {\n console.error('Login error:', error);\n loginError.textContent = 'Ein Fehler ist aufgetreten';\n loginError.classList.remove('hidden');\n } finally {\n submitBtn.disabled = false;\n submitBtn.textContent = originalText;\n }\n });\n\n // Logout\n btnLogout.addEventListener('click', () => {\n sessionStorage.removeItem('kantine_authToken');\n sessionStorage.removeItem('kantine_currentUser');\n sessionStorage.removeItem('kantine_firstName');\n sessionStorage.removeItem('kantine_lastName');\n authToken = null;\n currentUser = null;\n orderMap = new Map();\n stopPolling();\n updateAuthUI();\n renderVisibleWeeks();\n });\n }\n\n // === Auth UI ===\n function updateAuthUI() {\n // Try to recover session from Bessa's storage if not already logged in\n if (!authToken) {\n try {\n const akita = localStorage.getItem('AkitaStores');\n if (akita) {\n const parsed = JSON.parse(akita);\n if (parsed.auth && parsed.auth.token) {\n console.log('Found existing Bessa session!');\n authToken = parsed.auth.token;\n sessionStorage.setItem('kantine_authToken', authToken);\n\n if (parsed.auth.user) {\n currentUser = parsed.auth.user.id || 'unknown';\n sessionStorage.setItem('kantine_currentUser', currentUser);\n if (parsed.auth.user.firstName) sessionStorage.setItem('kantine_firstName', parsed.auth.user.firstName);\n if (parsed.auth.user.lastName) sessionStorage.setItem('kantine_lastName', parsed.auth.user.lastName);\n }\n }\n }\n } catch (e) {\n console.warn('Failed to parse AkitaStores:', e);\n }\n }\n\n authToken = sessionStorage.getItem('kantine_authToken');\n currentUser = sessionStorage.getItem('kantine_currentUser');\n const firstName = sessionStorage.getItem('kantine_firstName');\n const btnLoginOpen = document.getElementById('btn-login-open');\n const userInfo = document.getElementById('user-info');\n const userIdDisplay = document.getElementById('user-id-display');\n\n if (authToken) {\n btnLoginOpen.classList.add('hidden');\n userInfo.classList.remove('hidden');\n userIdDisplay.textContent = firstName || (currentUser ? `User ${currentUser}` : 'Angemeldet');\n fetchOrders(); // Always fetch fresh orders on auth update\n } else {\n btnLoginOpen.classList.remove('hidden');\n userInfo.classList.add('hidden');\n userIdDisplay.textContent = '';\n }\n\n renderVisibleWeeks();\n }\n\n // === Fetch Orders from Bessa ===\n async function fetchOrders() {\n if (!authToken) return;\n try {\n // Use user/orders endpoint for reliable history\n const response = await fetch(`${API_BASE}/user/orders/?venue=${VENUE_ID}&ordering=-created&limit=50`, {\n headers: apiHeaders(authToken)\n });\n const data = await response.json();\n\n if (response.ok) {\n orderMap = new Map();\n const results = data.results || [];\n\n for (const order of results) {\n // Filter out cancelled orders (State 9)\n // Accepting State 1 (Created?), 5 (Placed?), 8 (Completed)\n // TODO: Verify exact states. Subagent saw 5=Active, 8=Completed, 9=Cancelled.\n if (order.order_state === 9) continue;\n\n // Extract date properly (it comes as ISO string)\n const orderDate = order.date.split('T')[0];\n\n for (const item of (order.items || [])) {\n const key = `${orderDate}_${item.article}`;\n if (!orderMap.has(key)) orderMap.set(key, []);\n orderMap.get(key).push(order.id);\n }\n }\n console.log(`Fetched ${results.length} orders, mapped active ones.`);\n renderVisibleWeeks();\n 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 = `

Fehler 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 += `
\n

${yearGroup.year}

`;\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 += `
\n
\n
\n ${monthGroup.name}\n
\n ${monthGroup.count} Bestellungen • \u20ac${monthGroup.total.toFixed(2)}\n
\n
\n expand_more\n
\n
`;\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 += `
\n
\n ${week.label}\n ${week.count} Bestellungen • \u20ac${week.total.toFixed(2)}\n
`;\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 = 'Storniert';\n } else if (item.state === 8) {\n statusBadge = 'Abgeschlossen';\n } else {\n statusBadge = '\u00dcbertragen';\n }\n\n html += `\n
\n
${dayStr}
\n
\n ${escapeHtml(item.name)}\n
${statusBadge}
\n
\n
\u20ac${item.price.toFixed(2)}
\n
`;\n });\n html += `
`;\n });\n html += `
`; // Close month-content and month-group\n });\n html += `
`; // 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:00:00.000Z`,\n payment_method: 'payroll',\n customer: {\n first_name: userData.first_name,\n last_name: userData.last_name,\n email: userData.email,\n newsletter: false\n },\n preorder: false,\n delivery_fee: 0,\n cash_box_table_name: null,\n take_away: false\n };\n\n const response = await fetch(`${API_BASE}/user/orders/`, {\n method: 'POST',\n headers: apiHeaders(authToken),\n body: JSON.stringify(orderPayload)\n });\n\n if (response.ok || response.status === 201) {\n showToast(`Bestellt: ${name}`, 'success');\n 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} ×`;\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.

${error.message}`,\n 'Zur Original-Seite',\n 'https://web.bessa.app/knapp-kantine'\n );\n } finally {\n loading.classList.add('hidden');\n }\n }\n\n // === Last Updated Display ===\n 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 = `${icon}${message}`;\n container.appendChild(toast);\n requestAnimationFrame(() => toast.classList.add('show'));\n setTimeout(() => {\n toast.classList.remove('show');\n setTimeout(() => toast.remove(), 300);\n }, 3000);\n }\n\n // === Next Week Badge ===\n function updateNextWeekBadge() {\n const btnNextWeek = document.getElementById('btn-next-week');\n let nextWeek = currentWeekNumber + 1;\n let nextYear = currentYear;\n if (nextWeek > 52) { nextWeek = 1; nextYear++; }\n\n const nextWeekData = allWeeks.find(w => w.weekNumber === nextWeek && w.year === nextYear);\n let totalDataCount = 0;\n let orderableCount = 0;\n let daysWithOrders = 0;\n let daysWithOrderableAndNoOrder = 0;\n\n if (nextWeekData && nextWeekData.days) {\n nextWeekData.days.forEach(day => {\n if (day.items && day.items.length > 0) {\n totalDataCount++;\n const isOrderable = day.items.some(item => item.available);\n if (isOrderable) orderableCount++;\n\n let hasOrder = false;\n day.items.forEach(item => {\n const articleId = item.articleId || parseInt(item.id.split('_')[1]);\n const key = `${day.date}_${articleId}`;\n if (orderMap.has(key) && orderMap.get(key).length > 0) hasOrder = true;\n });\n\n if (hasOrder) daysWithOrders++;\n if (isOrderable && !hasOrder) daysWithOrderableAndNoOrder++;\n }\n });\n }\n\n let badge = btnNextWeek.querySelector('.nav-badge');\n if (totalDataCount > 0) {\n if (!badge) {\n badge = document.createElement('span');\n badge.className = 'nav-badge';\n btnNextWeek.appendChild(badge);\n }\n\n // Format: ( Ordered / Orderable / Total )\n badge.title = `${daysWithOrders} bestellt / ${orderableCount} bestellbar / ${totalDataCount} gesamt`;\n badge.innerHTML = `${daysWithOrders}/${orderableCount}/${totalDataCount}`;\n\n // Color Logic\n badge.classList.remove('badge-violet', 'badge-green', 'badge-red', 'badge-blue');\n\n // Refined Logic (v1.7.4):\n // Violet: If we have orders AND there are no DAYS left that are orderable but un-ordered.\n // (i.e. \"I have ordered everything I can\")\n if (daysWithOrders > 0 && daysWithOrderableAndNoOrder === 0) {\n badge.classList.add('badge-violet');\n } else if (daysWithOrderableAndNoOrder > 0) {\n badge.classList.add('badge-green'); // Orderable days exist without order\n } else if (orderableCount === 0) {\n badge.classList.add('badge-red'); // No orderable days at all & no orders\n } else {\n badge.classList.add('badge-blue'); // Default / partial state\n }\n\n // 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 += `(${highlightCount})`;\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 = `shopping_bag Gesamt: ${totalCost.toFixed(2).replace('.', ',')} \u20ac`;\n costDisplay.classList.remove('hidden');\n } else {\n costDisplay.classList.add('hidden');\n }\n }\n\n // === Render Weeks ===\n function renderVisibleWeeks() {\n const menuContainer = document.getElementById('menu-container');\n if (!menuContainer) return;\n menuContainer.innerHTML = '';\n\n let targetWeek = currentWeekNumber;\n let targetYear = currentYear;\n\n if (displayMode === 'next-week') {\n targetWeek++;\n if (targetWeek > 52) { targetWeek = 1; targetYear++; }\n }\n\n // Flatten & filter by week + year\n const allDays = allWeeks.flatMap(w => w.days || []);\n const daysInTargetWeek = allDays.filter(day => {\n const d = new Date(day.date);\n return getISOWeek(d) === targetWeek && getWeekYear(d) === targetYear;\n });\n\n if (daysInTargetWeek.length === 0) {\n menuContainer.innerHTML = `\n
\n

Keine Men\u00fcdaten f\u00fcr KW ${targetWeek} (${targetYear}) verf\u00fcgbar.

\n Versuchen Sie eine andere Woche oder schauen Sie sp\u00e4ter vorbei.\n
`;\n document.getElementById('weekly-cost-display').classList.add('hidden');\n return;\n }\n\n updateWeeklyCost(daysInTargetWeek);\n\n // Update header\n const headerWeekInfo = document.getElementById('header-week-info');\n const weekTitle = displayMode === 'this-week' ? 'Diese Woche' : 'N\u00e4chste Woche';\n headerWeekInfo.innerHTML = `\n
${weekTitle}
\n
Week ${targetWeek} \u2022 ${targetYear}
`;\n\n const grid = document.createElement('div');\n grid.className = 'days-grid';\n\n daysInTargetWeek.sort((a, b) => a.date.localeCompare(b.date));\n\n // Filter weekends\n const workingDays = daysInTargetWeek.filter(d => {\n const date = new Date(d.date);\n const day = date.getDay();\n return day !== 0 && day !== 6;\n });\n\n workingDays.forEach(day => {\n const card = createDayCard(day);\n if (card) grid.appendChild(card);\n });\n\n menuContainer.appendChild(grid);\n setTimeout(() => syncMenuItemHeights(grid), 0);\n }\n\n // === Sync Item Heights ===\n function syncMenuItemHeights(grid) {\n const cards = grid.querySelectorAll('.menu-card');\n if (cards.length === 0) return;\n let maxItems = 0;\n cards.forEach(card => {\n maxItems = Math.max(maxItems, card.querySelectorAll('.menu-item').length);\n });\n for (let i = 0; i < maxItems; i++) {\n let maxHeight = 0;\n const itemsAtPos = [];\n cards.forEach(card => {\n const items = card.querySelectorAll('.menu-item');\n if (items[i]) {\n items[i].style.height = 'auto';\n maxHeight = Math.max(maxHeight, items[i].offsetHeight);\n itemsAtPos.push(items[i]);\n }\n });\n itemsAtPos.forEach(item => { item.style.height = `${maxHeight}px`; });\n }\n }\n\n // === Create Day Card ===\n function createDayCard(day) {\n if (!day.items || day.items.length === 0) return null;\n\n const card = document.createElement('div');\n card.className = 'menu-card';\n\n const now = new Date();\n const cardDate = new Date(day.date);\n\n let isPastCutoff = false;\n if (day.orderCutoff) {\n isPastCutoff = now >= new Date(day.orderCutoff);\n } else {\n const today = new Date();\n today.setHours(0, 0, 0, 0);\n const cd = new Date(day.date);\n cd.setHours(0, 0, 0, 0);\n isPastCutoff = cd < today;\n }\n\n if (isPastCutoff) card.classList.add('past-day');\n\n // Collect ordered menu codes\n const menuBadges = [];\n if (day.items) {\n day.items.forEach(item => {\n const articleId = item.articleId || parseInt(item.id.split('_')[1]);\n const orderKey = `${day.date}_${articleId}`;\n const orders = orderMap.get(orderKey) || [];\n const count = orders.length;\n\n if (count > 0) {\n // Regex for M1, M2, M1F etc.\n const match = item.name.match(/([M][1-9][Ff]?)/);\n if (match) {\n let code = match[1];\n if (count > 1) code += '+';\n menuBadges.push(code);\n }\n }\n });\n }\n\n // Header\n const header = document.createElement('div');\n header.className = 'card-header';\n const dateStr = cardDate.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' });\n\n const badgesHtml = menuBadges.map(code => `${code}`).join('');\n\n // Determine Day Status for Header Color\n // Violet: Has Order\n // Green: No Order but Orderable\n // Red: No Order and Not Orderable (Locked/Sold Out)\n let headerClass = '';\n const hasAnyOrder = day.items && day.items.some(item => {\n const articleId = item.articleId || parseInt(item.id.split('_')[1]);\n const key = `${day.date}_${articleId}`;\n return orderMap.has(key) && orderMap.get(key).length > 0;\n });\n\n const hasOrderable = day.items && day.items.some(item => {\n // Use pre-calculated available flag from loadMenuDataFromAPI calculation\n return item.available;\n });\n\n if (hasAnyOrder) {\n headerClass = 'header-violet';\n } else if (hasOrderable && !isPastCutoff) {\n headerClass = 'header-green';\n } else {\n // Red if not orderable (or past cutoff)\n headerClass = 'header-red';\n }\n\n if (headerClass) header.classList.add(headerClass);\n\n header.innerHTML = `\n
\n ${translateDay(day.weekday)}\n
${badgesHtml}
\n
\n ${dateStr}`;\n card.appendChild(header);\n\n // Body\n const body = document.createElement('div');\n body.className = 'card-body';\n\n const todayDateStr = new Date().toISOString().split('T')[0];\n const isToday = day.date === todayDateStr;\n\n const sortedItems = [...day.items].sort((a, b) => {\n if (isToday) {\n const aId = a.articleId || parseInt(a.id.split('_')[1]);\n const bId = b.articleId || parseInt(b.id.split('_')[1]);\n const aOrdered = orderMap.has(`${day.date}_${aId}`);\n const bOrdered = orderMap.has(`${day.date}_${bId}`);\n\n if (aOrdered && !bOrdered) return -1;\n if (!aOrdered && bOrdered) return 1;\n }\n return a.name.localeCompare(b.name);\n });\n\n sortedItems.forEach(item => {\n const itemEl = document.createElement('div');\n itemEl.className = 'menu-item';\n\n const articleId = item.articleId || parseInt(item.id.split('_')[1]);\n const orderKey = `${day.date}_${articleId}`;\n const orderIds = orderMap.get(orderKey) || [];\n const orderCount = orderIds.length;\n\n // Status badge\n let statusBadge = '';\n if (item.available) {\n statusBadge = item.amountTracking\n ? `Verf\u00fcgbar (${item.availableAmount})`\n : `Verf\u00fcgbar`;\n } else {\n statusBadge = `Ausverkauft`;\n }\n\n // Order badge\n let orderedBadge = '';\n if (orderCount > 0) {\n const countBadge = orderCount > 1 ? `${orderCount}` : '';\n orderedBadge = `check_circle Bestellt${countBadge}`;\n itemEl.classList.add('ordered');\n if (new Date(day.date).toDateString() === now.toDateString()) {\n itemEl.classList.add('today-ordered');\n }\n }\n\n // Flagged styles\n const flagId = `${day.date}_${articleId}`;\n const isFlagged = userFlags.has(flagId);\n if (isFlagged) {\n itemEl.classList.add(item.available ? 'flagged-available' : 'flagged-sold-out');\n }\n\n // 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 = ``;\n }\n\n // Order button\n if (item.available) {\n if (orderCount > 0) {\n orderButton = ``;\n } else {\n orderButton = ``;\n }\n }\n\n // Cancel button\n if (orderCount > 0) {\n const cancelIcon = orderCount === 1 ? 'close' : 'remove';\n const cancelTitle = orderCount === 1 ? 'Bestellung stornieren' : 'Eine Bestellung stornieren';\n cancelButton = ``;\n }\n }\n\n // Build matched-tags HTML (only if tags found)\n let tagsHtml = '';\n if (matchedTags.length > 0) {\n const badges = matchedTags.map(t => `star${escapeHtml(t)}`).join('');\n tagsHtml = `
${badges}
`;\n }\n\n itemEl.innerHTML = `\n
\n ${escapeHtml(item.name)}\n ${item.price.toFixed(2)} \u20ac\n
\n
\n ${orderedBadge}\n ${cancelButton}\n ${orderButton}\n ${flagButton}\n
${statusBadge}
\n
\n ${tagsHtml}\n

${escapeHtml(item.description)}

`;\n\n // Event: Order\n const orderBtn = itemEl.querySelector('.btn-order');\n if (orderBtn) {\n orderBtn.addEventListener('click', (e) => {\n e.stopPropagation();\n const btn = e.currentTarget;\n btn.disabled = true;\n btn.classList.add('loading');\n placeOrder(btn.dataset.date, parseInt(btn.dataset.article), btn.dataset.name, parseFloat(btn.dataset.price), btn.dataset.desc || '')\n .finally(() => { btn.disabled = false; btn.classList.remove('loading'); });\n });\n }\n\n // Event: Cancel\n const cancelBtn = itemEl.querySelector('.btn-cancel');\n if (cancelBtn) {\n cancelBtn.addEventListener('click', (e) => {\n e.stopPropagation();\n const btn = e.currentTarget;\n btn.disabled = true;\n cancelOrder(btn.dataset.date, parseInt(btn.dataset.article), btn.dataset.name)\n .finally(() => { btn.disabled = false; });\n });\n }\n\n // Event: Flag\n const flagBtn = itemEl.querySelector('.btn-flag');\n if (flagBtn) {\n flagBtn.addEventListener('click', (e) => {\n e.stopPropagation();\n const btn = e.currentTarget;\n toggleFlag(btn.dataset.date, parseInt(btn.dataset.article), btn.dataset.name, btn.dataset.cutoff);\n });\n }\n\n body.appendChild(itemEl);\n });\n\n card.appendChild(body);\n return card;\n }\n\n // === 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.4.24';\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.4.24';\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 = '';\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 = '\u2713 Installiert';\n else if (isNew) badge = '\u2b06 Neu!';\n\n let action = '';\n if (!isCurrent) {\n action = `Installieren`;\n }\n\n li.innerHTML = `\n
\n ${v.tag}\n ${badge}\n
\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 = `

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 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 (!sessionStorage.getItem(notifiedKey)) {\n if (Notification.permission === 'granted') {\n new Notification('Kantine: Bestellschluss naht!', {\n body: 'Du hast heute noch nichts bestellt. Nur noch 1 Stunde!',\n icon: '\u23f3'\n });\n } else if (Notification.permission === 'default') {\n Notification.requestPermission();\n }\n sessionStorage.setItem(notifiedKey, 'true');\n }\n } else {\n countdownEl.classList.remove('urgent');\n }\n }\n\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 // === 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

\n signal_wifi_off\n ${title}\n

\n
\n
\n

${htmlContent}

\n
\n \n
\n
\n
\n `;\n document.body.appendChild(modal);\n\n document.getElementById('btn-error-redirect').addEventListener('click', () => {\n window.location.href = url;\n });\n\n requestAnimationFrame(() => {\n modal.classList.remove('hidden');\n });\n}\n"; document.head.appendChild(sc); })(); diff --git a/dist/install.html b/dist/install.html index f09f98e..58f6918 100755 --- a/dist/install.html +++ b/dist/install.html @@ -2,7 +2,7 @@ - Kantine Wrapper Installer (v1.4.23) + Kantine Wrapper Installer (v1.4.24)