Files
kantinen-wrapper/dist/kantine-standalone.html
2026-02-23 08:24:51 +01:00

4049 lines
131 KiB
HTML
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Kantine Weekly Menu (Standalone)</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<link href="https://fonts.googleapis.com/icon?family=Material+Icons+Round" rel="stylesheet">
<style>
: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;
}
/* 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 var(--accent-color);
border-radius: 8px;
padding: 1rem;
margin: 0 -1rem 1.5rem -1rem;
position: relative;
z-index: 10;
}
.menu-item.today-ordered {
border: 2px solid var(--accent-color);
box-shadow: 0 0 20px rgba(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;
max-height: 350px;
overflow-y: auto;
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;
} </style>
</head>
<body>
<script>
/**
* Mock data for standalone HTML testing.
* Intercepts fetch() calls to api.bessa.app and returns realistic dummy data.
* Injected BEFORE kantine.js in standalone builds only.
*/
(function () {
'use strict';
// Generate dates for this week and next week (Mon-Fri)
function getWeekDates(weekOffset) {
const dates = [];
const now = new Date();
const dayOfWeek = now.getDay(); // 0=Sun, 1=Mon
const monday = new Date(now);
monday.setDate(now.getDate() - (dayOfWeek === 0 ? 6 : dayOfWeek - 1) + (weekOffset * 7));
for (let i = 0; i < 5; i++) {
const d = new Date(monday);
d.setDate(monday.getDate() + i);
dates.push(d.toISOString().split('T')[0]);
}
return dates;
}
const thisWeekDates = getWeekDates(0);
const nextWeekDates = getWeekDates(1);
const allDates = [...thisWeekDates, ...nextWeekDates];
// Realistic German canteen menu items per day
const menuPool = [
[
{ id: 101, name: 'Wiener Schnitzel mit Kartoffelsalat', description: 'Paniertes Schweineschnitzel mit hausgemachtem Kartoffelsalat', price: '6.90', available_amount: '15', amount_tracking: true },
{ id: 102, name: 'Gemüse-Curry mit Basmatireis', description: 'Veganes Curry mit saisonalem Gemüse und Kokosmilch', price: '5.50', available_amount: '0', amount_tracking: true },
{ id: 103, name: 'Rindergulasch mit Spätzle', description: 'Geschmortes Rindfleisch in Paprikasauce mit Eierspätzle', price: '7.20', available_amount: '8', amount_tracking: true },
{ id: 104, name: 'Tagessuppe: Tomatencremesuppe', description: 'Cremige Tomatensuppe mit Croutons', price: '3.20', available_amount: '0', amount_tracking: false },
],
[
{ id: 201, name: 'Hähnchenbrust mit Pilzrahmsauce', description: 'Gebratene Hähnchenbrust mit Champignon-Rahmsauce und Reis', price: '6.50', available_amount: '12', amount_tracking: true },
{ id: 202, name: 'Vegetarische Lasagne', description: 'Lasagne mit Spinat, Ricotta und Tomatensauce', price: '5.80', available_amount: '10', amount_tracking: true },
{ id: 203, name: 'Bratwurst mit Sauerkraut', description: 'Thüringer Bratwurst mit Sauerkraut und Kartoffelpüree', price: '5.90', available_amount: '0', amount_tracking: true },
{ id: 204, name: 'Caesar Salad mit Hähnchen', description: 'Römersalat mit gegrilltem Hähnchen, Parmesan und Croutons', price: '6.10', available_amount: '0', amount_tracking: false },
],
[
{ id: 301, name: 'Spaghetti Bolognese', description: 'Klassische Bolognese mit frischen Spaghetti', price: '5.20', available_amount: '20', amount_tracking: true },
{ id: 302, name: 'Gebratener Lachs mit Dillsauce', description: 'Lachsfilet auf Blattspinat mit Senf-Dill-Sauce', price: '8.50', available_amount: '5', amount_tracking: true },
{ id: 303, name: 'Kartoffelgratin mit Salat', description: 'Überbackene Kartoffeln mit Sahne und Käse, dazu gemischter Salat', price: '5.00', available_amount: '0', amount_tracking: false },
{ id: 304, name: 'Chili con Carne', description: 'Pikantes Chili mit Hackfleisch, Bohnen und Reis', price: '5.80', available_amount: '9', amount_tracking: true },
],
[
{ id: 401, name: 'Schweinebraten mit Knödel', description: 'Bayerischer Schweinebraten mit Semmelknödel und Bratensauce', price: '7.00', available_amount: '7', amount_tracking: true },
{ id: 402, name: 'Falafel-Bowl mit Hummus', description: 'Knusprige Falafel mit Hummus, Tabouleh und Fladenbrot', price: '5.90', available_amount: '0', amount_tracking: false },
{ id: 403, name: 'Putengeschnetzeltes mit Nudeln', description: 'Putenstreifen in Champignon-Sahnesauce mit Bandnudeln', price: '6.30', available_amount: '11', amount_tracking: true },
{ id: 404, name: 'Tagessuppe: Erbsensuppe', description: 'Deftige Erbsensuppe mit Wiener Würstchen', price: '3.50', available_amount: '0', amount_tracking: false },
],
[
{ id: 501, name: 'Backfisch mit Remoulade', description: 'Paniertes Seelachsfilet mit Remouladensauce und Bratkartoffeln', price: '6.80', available_amount: '6', amount_tracking: true },
{ id: 502, name: 'Käsespätzle mit Röstzwiebeln', description: 'Allgäuer Käsespätzle mit karamellisierten Zwiebeln und Salat', price: '5.50', available_amount: '14', amount_tracking: true },
{ id: 503, name: 'Schnitzel Wiener Art mit Pommes', description: 'Paniertes Hähnchenschnitzel mit knusprigen Pommes Frites', price: '6.20', available_amount: '0', amount_tracking: true },
{ id: 504, name: 'Griechischer Bauernsalat', description: 'Frischer Salat mit Feta, Oliven, Gurke und Tomaten', price: '5.30', available_amount: '0', amount_tracking: false },
],
];
// Build mock responses for each date
const dateResponses = {};
allDates.forEach((date, i) => {
const menuIndex = i % menuPool.length;
dateResponses[date] = {
results: [{
id: 1,
name: 'Mittagsmenü',
items: menuPool[menuIndex].map(item => ({
...item,
// Ensure unique IDs per date
id: item.id + (i * 1000)
}))
}]
};
});
// Mock some orders for today (to show "Bestellt" badges)
const todayStr = new Date().toISOString().split('T')[0];
const todayMenu = dateResponses[todayStr];
const mockOrders = [];
let nextOrderId = 9001;
if (todayMenu) {
const firstItem = todayMenu.results[0].items[0];
mockOrders.push({
id: nextOrderId++,
article: firstItem.id,
article_name: firstItem.name,
date: todayStr,
venue: 591,
status: 'confirmed',
created: new Date().toISOString()
});
}
// Pre-seed a mock auth session so flag/order buttons render
sessionStorage.setItem('kantine_authToken', 'mock-token-for-testing');
sessionStorage.setItem('kantine_currentUser', '12345');
sessionStorage.setItem('kantine_firstName', 'Test');
sessionStorage.setItem('kantine_lastName', 'User');
// Intercept fetch
const originalFetch = window.fetch;
window.fetch = function (url, options) {
const urlStr = typeof url === 'string' ? url : url.toString();
// Menu dates endpoint
if (urlStr.includes('/menu/dates/')) {
console.log('[MOCK] Returning mock dates data');
return Promise.resolve(new Response(JSON.stringify({
results: allDates.map(date => ({ date, orders: [] }))
}), { status: 200, headers: { 'Content-Type': 'application/json' } }));
}
// Menu detail for a specific date
const dateMatch = urlStr.match(/\/menu\/\d+\/(\d{4}-\d{2}-\d{2})\//);
if (dateMatch) {
const date = dateMatch[1];
const data = dateResponses[date] || { results: [] };
console.log(`[MOCK] Returning mock menu for ${date}`);
return Promise.resolve(new Response(JSON.stringify(data), {
status: 200, headers: { 'Content-Type': 'application/json' }
}));
}
// Orders endpoint
if (urlStr.includes('/user/orders/') && (!options || options.method === 'GET' || !options.method)) {
console.log('[MOCK] Returning mock orders');
// Formatter for history mapping
const mappedOrders = mockOrders.map(o => ({
id: o.id,
date: `${o.date}T10:00:00Z`,
order_state: o.status === 'cancelled' ? 9 : 5,
total: o.price || '6.50',
items: [{
article: o.article,
name: o.article_name,
price: o.price || '6.50',
amount: 1
}]
}));
// Handle lazy load / pagination if requesting full history
if (urlStr.includes('limit=50')) {
return Promise.resolve(new Response(JSON.stringify({
count: mappedOrders.length,
next: null,
results: mappedOrders
}), { status: 200, headers: { 'Content-Type': 'application/json' } }));
}
return Promise.resolve(new Response(JSON.stringify({
results: mappedOrders
}), { status: 200, headers: { 'Content-Type': 'application/json' } }));
}
// Auth user endpoint
if (urlStr.includes('/auth/user/')) {
console.log('[MOCK] Returning mock user');
return Promise.resolve(new Response(JSON.stringify({
pk: 12345,
username: 'testuser',
email: 'test@example.com',
first_name: 'Test',
last_name: 'User'
}), { status: 200, headers: { 'Content-Type': 'application/json' } }));
}
// Order create (POST to /user/orders/)
if (urlStr.includes('/user/orders/') && options && options.method === 'POST') {
const body = JSON.parse(options.body || '{}');
const newOrder = {
id: nextOrderId++,
article: body.article,
article_name: 'Mock Order',
date: body.date,
venue: 591,
status: 'confirmed',
created: new Date().toISOString()
};
mockOrders.push(newOrder);
console.log('[MOCK] Created order:', newOrder);
return Promise.resolve(new Response(JSON.stringify(newOrder), {
status: 201, headers: { 'Content-Type': 'application/json' }
}));
}
// Order cancel (POST to /user/orders/{id}/cancel/)
const cancelMatch = urlStr.match(/\/user\/orders\/(\d+)\/cancel\//);
if (cancelMatch) {
const orderId = parseInt(cancelMatch[1]);
const idx = mockOrders.findIndex(o => o.id === orderId);
if (idx >= 0) mockOrders.splice(idx, 1);
console.log('[MOCK] Cancelled order:', orderId);
return Promise.resolve(new Response('{}', {
status: 200, headers: { 'Content-Type': 'application/json' }
}));
}
// Fallback to real fetch for other URLs (fonts, etc.)
return originalFetch.apply(this, arguments);
};
console.log('[MOCK] 🧪 Mock data active using dummy canteen menus for UI testing');
})();
/**
* Kantine Wrapper Client-Only Bookmarklet
* Replaces Bessa page content with enhanced weekly menu view.
* All API calls go directly to api.bessa.app (same origin).
* Data stored in localStorage (flags, theme, auth).
*/
(function () {
'use strict';
// Prevent double injection
if (window.__KANTINE_LOADED) return;
window.__KANTINE_LOADED = true;
// === Constants ===
const API_BASE = 'https://api.bessa.app/v1';
const GUEST_TOKEN = 'c3418725e95a9f90e3645cbc846b4d67c7c66131';
const CLIENT_VERSION = '1.7.0_prod/2026-01-26';
const VENUE_ID = 591;
const MENU_ID = 7;
const POLL_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes
// === GitHub Release Management ===
const GITHUB_REPO = 'TauNeutrino/kantine-overview';
const GITHUB_API = `https://api.github.com/repos/${GITHUB_REPO}`;
const INSTALLER_BASE = `https://htmlpreview.github.io/?https://github.com/${GITHUB_REPO}/blob`;
// === State ===
let allWeeks = [];
let currentWeekNumber = getISOWeek(new Date());
let currentYear = new Date().getFullYear();
let displayMode = 'this-week';
let authToken = sessionStorage.getItem('kantine_authToken');
let currentUser = sessionStorage.getItem('kantine_currentUser');
let orderMap = new Map();
let userFlags = new Set(JSON.parse(localStorage.getItem('kantine_flags') || '[]'));
let pollIntervalId = null;
// === API Helpers ===
function apiHeaders(token) {
return {
'Authorization': `Token ${token || GUEST_TOKEN}`,
'Accept': 'application/json',
'Content-Type': 'application/json',
'X-Client-Version': CLIENT_VERSION
};
}
// === Inject UI ===
function injectUI() {
// Replace entire page content
document.title = 'Kantine Weekly Menu';
// Inject Google Fonts if not already present
if (!document.querySelector('link[href*="fonts.googleapis.com/css2?family=Inter"]')) {
const fontLink = document.createElement('link');
fontLink.rel = 'stylesheet';
fontLink.href = 'https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap';
document.head.appendChild(fontLink);
}
if (!document.querySelector('link[href*="Material+Icons+Round"]')) {
const iconLink = document.createElement('link');
iconLink.rel = 'stylesheet';
iconLink.href = 'https://fonts.googleapis.com/icon?family=Material+Icons+Round';
document.head.appendChild(iconLink);
}
document.body.innerHTML = `
<div id="kantine-wrapper">
<header class="app-header">
<div class="header-content">
<div class="brand">
<span class="material-icons-round logo-icon">restaurant_menu</span>
<div class="header-left">
<h1>Kantinen Übersicht <small class="version-tag" style="font-size: 0.6em; opacity: 0.7; font-weight: 400; cursor: pointer;" title="Klick für Versionsmenü">v1.4.2</small></h1>
<div id="last-updated-subtitle" class="subtitle"></div>
</div>
</div>
<div class="header-center-wrapper">
<div id="header-week-info" class="header-week-info"></div>
<div id="weekly-cost-display" class="weekly-cost hidden"></div>
</div>
<div class="controls">
<button id="btn-refresh" class="icon-btn" aria-label="Menüdaten aktualisieren" title="Menüdaten neu laden">
<span class="material-icons-round">refresh</span>
</button>
<button id="btn-history" class="icon-btn" aria-label="Bestellhistorie" title="Bestellhistorie">
<span class="material-icons-round">receipt_long</span>
</button>
<button id="btn-highlights" class="icon-btn" aria-label="Persönliche Highlights verwalten" title="Persönliche Highlights verwalten">
<span class="material-icons-round">label</span>
</button>
<div class="nav-group">
<button id="btn-this-week" class="nav-btn active">Diese Woche</button>
<button id="btn-next-week" class="nav-btn">Nächste Woche</button>
</div>
<button id="theme-toggle" class="icon-btn" aria-label="Toggle Theme">
<span class="material-icons-round theme-icon">light_mode</span>
</button>
<button id="btn-login-open" class="user-badge-btn icon-btn-small">
<span class="material-icons-round">login</span>
<span>Anmelden</span>
</button>
<div id="user-info" class="user-badge hidden">
<span class="material-icons-round">person</span>
<span id="user-id-display"></span>
<button id="btn-logout" class="icon-btn-small" aria-label="Logout">
<span class="material-icons-round">logout</span>
</button>
</div>
</div>
</div>
</header>
<div id="login-modal" class="modal hidden">
<div class="modal-content">
<div class="modal-header">
<h2>Login</h2>
<button id="btn-login-close" class="icon-btn" aria-label="Close">
<span class="material-icons-round">close</span>
</button>
</div>
<form id="login-form">
<div class="form-group">
<label for="employee-id">Mitarbeiternummer</label>
<input type="text" id="employee-id" name="employee-id" placeholder="z.B. 2041" required>
<small class="help-text">Deine offizielle Knapp Mitarbeiternummer.</small>
</div>
<div class="form-group">
<label for="password">Passwort</label>
<input type="password" id="password" name="password" placeholder="Bessa Passwort" required>
<small class="help-text">Das Passwort für deinen Bessa Account.</small>
</div>
<div id="login-error" class="error-msg hidden"></div>
<div class="modal-actions">
<button type="submit" class="btn-primary wide">Einloggen</button>
</div>
</form>
</div>
</div>
<div id="progress-modal" class="modal hidden">
<div class="modal-content">
<div class="modal-header">
<h2>Menüdaten aktualisieren</h2>
</div>
<div class="modal-body" style="padding: 20px;">
<div class="progress-container">
<div class="progress-bar">
<div id="progress-fill" class="progress-fill"></div>
</div>
<div id="progress-percent" class="progress-percent">0%</div>
</div>
<p id="progress-message" class="progress-message">Initialisierung...</p>
</div>
</div>
</div>
<div id="highlights-modal" class="modal hidden">
<div class="modal-content">
<div class="modal-header">
<h2>Meine Highlights</h2>
<button id="btn-highlights-close" class="icon-btn" aria-label="Close">
<span class="material-icons-round">close</span>
</button>
</div>
<div class="modal-body">
<p style="margin-bottom: 1rem; color: var(--text-secondary);">
Markiere Menüs automatisch, wenn sie diese Schlagwörter enthalten.
</p>
<div class="input-group">
<input type="text" id="tag-input" placeholder="z.B. Schnitzel, Vegetarisch...">
<button id="btn-add-tag" class="btn-primary">Hinzufügen</button>
</div>
<div id="tags-list"></div>
</div>
</div>
</div>
<div id="history-modal" class="modal hidden">
<div class="modal-content history-modal-content">
<div class="modal-header">
<h2>Bestellhistorie</h2>
<button id="btn-history-close" class="icon-btn" aria-label="Close">
<span class="material-icons-round">close</span>
</button>
</div>
<div class="modal-body">
<div id="history-loading" class="hidden">
<p id="history-progress-text" style="text-align: center; margin-bottom: 1rem; color: var(--text-secondary);">Lade Historie...</p>
<div class="progress-container">
<div class="progress-bar">
<div id="history-progress-fill" class="progress-fill"></div>
</div>
</div>
</div>
<div id="history-content">
<!-- Dynamically populated -->
</div>
</div>
</div>
</div>
<div id="version-modal" class="modal hidden">
<div class="modal-content">
<div class="modal-header">
<h2>📦 Versionen</h2>
<button id="btn-version-close" class="icon-btn" aria-label="Close">
<span class="material-icons-round">close</span>
</button>
</div>
<div class="modal-body">
<div style="margin-bottom: 1rem;">
<strong>Aktuell:</strong> <span id="version-current">v1.4.2</span>
</div>
<div class="dev-toggle">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer;">
<input type="checkbox" id="dev-mode-toggle">
<span>Dev-Mode (alle Tags anzeigen)</span>
</label>
</div>
<div id="version-list-container" style="margin-top:1rem;">
<p style="color:var(--text-secondary);">Lade Versionen...</p>
</div>
</div>
</div>
</div>
<main class="container">
<div id="last-updated-banner" class="banner hidden">
<span class="material-icons-round">update</span>
<span id="last-updated-text">Gerade aktualisiert</span>
</div>
<div id="loading" class="loading-state">
<div class="spinner"></div>
<p>Lade Menüdaten...</p>
</div>
<div id="menu-container" class="menu-grid"></div>
</main>
<footer class="app-footer">
<p>Bessa Knapp-Kantine Wrapper &bull; <span id="current-year">${new Date().getFullYear()}</span></p>
</footer>
</div>`;
}
// === Bind Events ===
function bindEvents() {
const btnThisWeek = document.getElementById('btn-this-week');
const btnNextWeek = document.getElementById('btn-next-week');
const btnRefresh = document.getElementById('btn-refresh');
const themeToggle = document.getElementById('theme-toggle');
const btnLoginOpen = document.getElementById('btn-login-open');
const btnLoginClose = document.getElementById('btn-login-close');
const btnLogout = document.getElementById('btn-logout');
const loginForm = document.getElementById('login-form');
const loginModal = document.getElementById('login-modal');
// Highlights Modal
const btnHighlights = document.getElementById('btn-highlights');
const highlightsModal = document.getElementById('highlights-modal');
const btnHighlightsClose = document.getElementById('btn-highlights-close');
const btnAddTag = document.getElementById('btn-add-tag');
const tagInput = document.getElementById('tag-input');
// History Modal
const btnHistory = document.getElementById('btn-history');
const historyModal = document.getElementById('history-modal');
const btnHistoryClose = document.getElementById('btn-history-close');
btnHistory.addEventListener('click', () => {
if (!authToken) {
loginModal.classList.remove('hidden');
return;
}
historyModal.classList.remove('hidden');
fetchFullOrderHistory();
});
btnHistoryClose.addEventListener('click', () => {
historyModal.classList.add('hidden');
});
window.addEventListener('click', (e) => {
if (e.target === historyModal) historyModal.classList.add('hidden');
if (e.target === highlightsModal) highlightsModal.classList.add('hidden');
});
// Version Menu
const versionTag = document.querySelector('.version-tag');
const versionModal = document.getElementById('version-modal');
const btnVersionClose = document.getElementById('btn-version-close');
if (versionTag) {
versionTag.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
openVersionMenu();
});
}
if (btnVersionClose) {
btnVersionClose.addEventListener('click', () => {
versionModal.classList.add('hidden');
});
}
window.addEventListener('click', (e) => {
if (e.target === versionModal) versionModal.classList.add('hidden');
});
btnAddTag.addEventListener('click', () => {
const tag = tagInput.value;
if (addHighlightTag(tag)) {
tagInput.value = '';
renderTagsList();
}
});
tagInput.addEventListener('keypress', (e) => {
if (e.key === 'Enter') {
btnAddTag.click();
}
});
// Theme
const savedTheme = localStorage.getItem('theme');
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const themeIcon = themeToggle.querySelector('.theme-icon');
if (savedTheme === 'dark' || (!savedTheme && prefersDark)) {
document.documentElement.setAttribute('data-theme', 'dark');
themeIcon.textContent = 'dark_mode';
} else {
document.documentElement.setAttribute('data-theme', 'light');
themeIcon.textContent = 'light_mode';
}
themeToggle.addEventListener('click', () => {
const current = document.documentElement.getAttribute('data-theme');
const next = current === 'dark' ? 'light' : 'dark';
document.documentElement.setAttribute('data-theme', next);
localStorage.setItem('theme', next);
themeIcon.textContent = next === 'dark' ? 'dark_mode' : 'light_mode';
});
// Navigation
btnThisWeek.addEventListener('click', () => {
if (displayMode !== 'this-week') {
displayMode = 'this-week';
btnThisWeek.classList.add('active');
btnNextWeek.classList.remove('active');
renderVisibleWeeks();
}
});
btnNextWeek.addEventListener('click', () => {
if (displayMode !== 'next-week') {
displayMode = 'next-week';
btnNextWeek.classList.add('active');
btnThisWeek.classList.remove('active');
renderVisibleWeeks();
}
});
// Refresh fetch fresh data from Bessa API
btnRefresh.addEventListener('click', () => {
if (!authToken) {
loginModal.classList.remove('hidden');
return;
}
loadMenuDataFromAPI();
});
// Login Modal
btnLoginOpen.addEventListener('click', () => {
loginModal.classList.remove('hidden');
document.getElementById('login-error').classList.add('hidden');
loginForm.reset();
});
btnLoginClose.addEventListener('click', () => {
loginModal.classList.add('hidden');
});
window.addEventListener('click', (e) => {
if (e.target === loginModal) loginModal.classList.add('hidden');
});
// Login Form Submit
loginForm.addEventListener('submit', async (e) => {
e.preventDefault();
const employeeId = document.getElementById('employee-id').value.trim();
const password = document.getElementById('password').value;
const loginError = document.getElementById('login-error');
const submitBtn = loginForm.querySelector('button[type="submit"]');
const originalText = submitBtn.textContent;
submitBtn.disabled = true;
submitBtn.textContent = 'Wird eingeloggt...';
try {
const email = `knapp-${employeeId}@bessa.app`;
const response = await fetch(`${API_BASE}/auth/login/`, {
method: 'POST',
headers: apiHeaders(GUEST_TOKEN),
body: JSON.stringify({ email, password })
});
const data = await response.json();
if (response.ok) {
authToken = data.key;
currentUser = employeeId;
sessionStorage.setItem('kantine_authToken', data.key);
sessionStorage.setItem('kantine_currentUser', employeeId);
// Fetch user name
try {
const userResp = await fetch(`${API_BASE}/auth/user/`, {
headers: apiHeaders(authToken)
});
if (userResp.ok) {
const userData = await userResp.json();
if (userData.first_name) sessionStorage.setItem('kantine_firstName', userData.first_name);
if (userData.last_name) sessionStorage.setItem('kantine_lastName', userData.last_name);
}
} catch (err) {
console.error('Failed to fetch user info:', err);
}
updateAuthUI();
loginModal.classList.add('hidden');
fetchOrders();
loginForm.reset();
startPolling();
// Reload menu data with auth for full details
loadMenuDataFromAPI();
} else {
loginError.textContent = data.non_field_errors?.[0] || data.error || 'Login fehlgeschlagen';
loginError.classList.remove('hidden');
}
} catch (error) {
console.error('Login error:', error);
loginError.textContent = 'Ein Fehler ist aufgetreten';
loginError.classList.remove('hidden');
} finally {
submitBtn.disabled = false;
submitBtn.textContent = originalText;
}
});
// Logout
btnLogout.addEventListener('click', () => {
sessionStorage.removeItem('kantine_authToken');
sessionStorage.removeItem('kantine_currentUser');
sessionStorage.removeItem('kantine_firstName');
sessionStorage.removeItem('kantine_lastName');
authToken = null;
currentUser = null;
orderMap = new Map();
stopPolling();
updateAuthUI();
renderVisibleWeeks();
});
}
// === Auth UI ===
function updateAuthUI() {
// Try to recover session from Bessa's storage if not already logged in
if (!authToken) {
try {
const akita = localStorage.getItem('AkitaStores');
if (akita) {
const parsed = JSON.parse(akita);
if (parsed.auth && parsed.auth.token) {
console.log('Found existing Bessa session!');
authToken = parsed.auth.token;
sessionStorage.setItem('kantine_authToken', authToken);
if (parsed.auth.user) {
currentUser = parsed.auth.user.id || 'unknown';
sessionStorage.setItem('kantine_currentUser', currentUser);
if (parsed.auth.user.firstName) sessionStorage.setItem('kantine_firstName', parsed.auth.user.firstName);
if (parsed.auth.user.lastName) sessionStorage.setItem('kantine_lastName', parsed.auth.user.lastName);
}
}
}
} catch (e) {
console.warn('Failed to parse AkitaStores:', e);
}
}
authToken = sessionStorage.getItem('kantine_authToken');
currentUser = sessionStorage.getItem('kantine_currentUser');
const firstName = sessionStorage.getItem('kantine_firstName');
const btnLoginOpen = document.getElementById('btn-login-open');
const userInfo = document.getElementById('user-info');
const userIdDisplay = document.getElementById('user-id-display');
if (authToken) {
btnLoginOpen.classList.add('hidden');
userInfo.classList.remove('hidden');
userIdDisplay.textContent = firstName || (currentUser ? `User ${currentUser}` : 'Angemeldet');
fetchOrders(); // Always fetch fresh orders on auth update
} else {
btnLoginOpen.classList.remove('hidden');
userInfo.classList.add('hidden');
userIdDisplay.textContent = '';
}
renderVisibleWeeks();
}
// === Fetch Orders from Bessa ===
async function fetchOrders() {
if (!authToken) return;
try {
// Use user/orders endpoint for reliable history
const response = await fetch(`${API_BASE}/user/orders/?venue=${VENUE_ID}&ordering=-created&limit=50`, {
headers: apiHeaders(authToken)
});
const data = await response.json();
if (response.ok) {
orderMap = new Map();
const results = data.results || [];
for (const order of results) {
// Filter out cancelled orders (State 9)
// Accepting State 1 (Created?), 5 (Placed?), 8 (Completed)
// TODO: Verify exact states. Subagent saw 5=Active, 8=Completed, 9=Cancelled.
if (order.order_state === 9) continue;
// Extract date properly (it comes as ISO string)
const orderDate = order.date.split('T')[0];
for (const item of (order.items || [])) {
const key = `${orderDate}_${item.article}`;
if (!orderMap.has(key)) orderMap.set(key, []);
orderMap.get(key).push(order.id);
}
}
console.log(`Fetched ${results.length} orders, mapped active ones.`);
renderVisibleWeeks();
}
} catch (error) {
console.error('Error fetching orders:', error);
}
}
// === History Modal Flow ===
let fullOrderHistoryCache = null;
async function fetchFullOrderHistory() {
const historyLoading = document.getElementById('history-loading');
const historyContent = document.getElementById('history-content');
const progressFill = document.getElementById('history-progress-fill');
const progressText = document.getElementById('history-progress-text');
if (fullOrderHistoryCache) {
renderHistory(fullOrderHistoryCache);
return;
}
if (!authToken) return;
historyContent.innerHTML = '';
historyLoading.classList.remove('hidden');
progressFill.style.width = '0%';
progressText.textContent = 'Lade Bestellhistorie...';
let nextUrl = `${API_BASE}/user/orders/?venue=${VENUE_ID}&ordering=-created&limit=50`;
let allOrders = [];
let totalCount = 0;
try {
while (nextUrl) {
const response = await fetch(nextUrl, { headers: apiHeaders(authToken) });
if (!response.ok) throw new Error(`Fetch failed: ${response.status}`);
const data = await response.json();
if (data.count && totalCount === 0) {
totalCount = data.count;
}
allOrders = allOrders.concat(data.results || []);
// Update progress
if (totalCount > 0) {
const pct = Math.round((allOrders.length / totalCount) * 100);
progressFill.style.width = `${pct}%`;
progressText.textContent = `Lade Bestellung ${allOrders.length} von ${totalCount}...`;
} else {
progressText.textContent = `Lade Bestellung ${allOrders.length}...`;
}
nextUrl = data.next;
}
fullOrderHistoryCache = allOrders;
renderHistory(fullOrderHistoryCache);
} catch (error) {
console.error('Error fetching full history:', error);
historyContent.innerHTML = `<p style="color:var(--error-color);text-align:center;">Fehler beim Laden der Historie.</p>`;
} finally {
historyLoading.classList.add('hidden');
}
}
function renderHistory(orders) {
const content = document.getElementById('history-content');
if (!orders || orders.length === 0) {
content.innerHTML = '<p style="text-align:center;color:var(--text-secondary);padding:20px;">Keine Bestellungen gefunden.</p>';
return;
}
// Group by Year -> Month -> Week Number (KW)
const groups = {};
orders.forEach(order => {
const d = new Date(order.date);
const y = d.getFullYear();
const m = d.getMonth();
const monthKey = `${y}-${m.toString().padStart(2, '0')}`;
const monthName = d.toLocaleString('de-AT', { month: 'long' }); // Only month name
const kw = getISOWeek(d);
if (!groups[y]) {
groups[y] = { year: y, months: {} };
}
if (!groups[y].months[monthKey]) {
groups[y].months[monthKey] = { name: monthName, year: y, monthIndex: m, count: 0, total: 0, weeks: {} };
}
if (!groups[y].months[monthKey].weeks[kw]) {
groups[y].months[monthKey].weeks[kw] = { label: `KW ${kw}`, items: [], count: 0, total: 0 };
}
const items = order.items || [];
items.forEach(item => {
const itemPrice = parseFloat(item.price || order.total || 0);
groups[y].months[monthKey].weeks[kw].items.push({
date: order.date,
name: item.name || 'Menü',
price: itemPrice,
state: order.order_state // 9 is cancelled, 5 is active, 8 is completed
});
if (order.order_state !== 9) {
groups[y].months[monthKey].weeks[kw].count++;
groups[y].months[monthKey].weeks[kw].total += itemPrice;
groups[y].months[monthKey].count++;
groups[y].months[monthKey].total += itemPrice;
}
});
});
// Generate HTML
const sortedYears = Object.keys(groups).sort((a, b) => b - a);
let html = '';
sortedYears.forEach(yKey => {
const yearGroup = groups[yKey];
html += `<div class="history-year-group">
<h2 class="history-year-header">${yearGroup.year}</h2>`;
const sortedMonths = Object.keys(yearGroup.months).sort((a, b) => b.localeCompare(a));
sortedMonths.forEach(mKey => {
const monthGroup = yearGroup.months[mKey];
html += `<div class="history-month-group">
<div class="history-month-header" tabindex="0" role="button" aria-expanded="false">
<div style="display:flex; flex-direction:column; gap:4px;">
<span>${monthGroup.name}</span>
<div class="history-month-summary">
<span>${monthGroup.count} Bestellungen &bull; <strong>€${monthGroup.total.toFixed(2)}</strong></span>
</div>
</div>
<span class="material-icons-round">expand_more</span>
</div>
<div class="history-month-content">`;
const sortedKWs = Object.keys(monthGroup.weeks).sort((a, b) => parseInt(b) - parseInt(a));
sortedKWs.forEach(kw => {
const week = monthGroup.weeks[kw];
html += `<div class="history-week-group">
<div class="history-week-header">
<strong>${week.label}</strong>
<span>${week.count} Bestellungen &bull; <strong>€${week.total.toFixed(2)}</strong></span>
</div>`;
week.items.forEach(item => {
const dateObj = new Date(item.date);
const dayStr = dateObj.toLocaleDateString('de-AT', { weekday: 'short', day: '2-digit', month: '2-digit' });
let statusBadge = '';
if (item.state === 9) {
statusBadge = '<span class="history-item-status">Storniert</span>';
} else if (item.state === 8) {
statusBadge = '<span class="history-item-status">Abgeschlossen</span>';
} else {
statusBadge = '<span class="history-item-status">Übertragen</span>';
}
html += `
<div class="history-item ${item.state === 9 ? 'history-item-cancelled' : ''}">
<div style="font-size: 0.85rem; color: var(--text-secondary);">${dayStr}</div>
<div class="history-item-details">
<span class="history-item-name">${escapeHtml(item.name)}</span>
<div>${statusBadge}</div>
</div>
<div class="history-item-price ${item.state === 9 ? 'history-item-price-cancelled' : ''}">€${item.price.toFixed(2)}</div>
</div>`;
});
html += `</div>`;
});
html += `</div></div>`; // Close month-content and month-group
});
html += `</div>`; // Close year-group
});
content.innerHTML = html;
// Bind Accordion Click Events via JS
const monthHeaders = content.querySelectorAll('.history-month-header');
monthHeaders.forEach(header => {
header.addEventListener('click', () => {
const parentGroup = header.parentElement;
const isOpen = parentGroup.classList.contains('open');
// Toggle current
if (isOpen) {
parentGroup.classList.remove('open');
header.setAttribute('aria-expanded', 'false');
} else {
parentGroup.classList.add('open');
header.setAttribute('aria-expanded', 'true');
}
});
});
}
// === Place Order ===
async function placeOrder(date, articleId, name, price, description) {
if (!authToken) return;
try {
// Get user data for customer object
const userResp = await fetch(`${API_BASE}/auth/user/`, {
headers: apiHeaders(authToken)
});
if (!userResp.ok) {
showToast('Fehler: Benutzerdaten konnten nicht geladen werden', 'error');
return;
}
const userData = await userResp.json();
const now = new Date().toISOString();
const orderPayload = {
uuid: crypto.randomUUID(),
created: now,
updated: now,
order_type: 7,
items: [{
article: articleId,
course_group: null,
modifiers: [],
uuid: crypto.randomUUID(),
name: name,
description: description || '',
price: String(parseFloat(price)),
amount: 1,
vat: '10.00',
comment: ''
}],
table: null,
total: parseFloat(price),
tip: 0,
currency: 'EUR',
venue: VENUE_ID,
states: [],
order_state: 1,
date: `${date}T10:00:00.000Z`,
payment_method: 'payroll',
customer: {
first_name: userData.first_name,
last_name: userData.last_name,
email: userData.email,
newsletter: false
},
preorder: false,
delivery_fee: 0,
cash_box_table_name: null,
take_away: false
};
const response = await fetch(`${API_BASE}/user/orders/`, {
method: 'POST',
headers: apiHeaders(authToken),
body: JSON.stringify(orderPayload)
});
if (response.ok || response.status === 201) {
showToast(`Bestellt: ${name}`, 'success');
await fetchOrders();
} else {
const data = await response.json();
showToast(`Fehler: ${data.detail || data.non_field_errors?.[0] || 'Bestellung fehlgeschlagen'}`, 'error');
}
} catch (error) {
console.error('Order error:', error);
showToast('Netzwerkfehler bei Bestellung', 'error');
}
}
// === Cancel Order ===
async function cancelOrder(date, articleId, name) {
if (!authToken) return;
const key = `${date}_${articleId}`;
const orderIds = orderMap.get(key);
if (!orderIds || orderIds.length === 0) return;
// LIFO: cancel most recent
const orderId = orderIds[orderIds.length - 1];
try {
const response = await fetch(`${API_BASE}/user/orders/${orderId}/cancel/`, {
method: 'PATCH',
headers: apiHeaders(authToken),
body: JSON.stringify({})
});
if (response.ok) {
showToast(`Storniert: ${name}`, 'success');
await fetchOrders();
} else {
const data = await response.json();
showToast(`Fehler: ${data.detail || 'Stornierung fehlgeschlagen'}`, 'error');
}
} catch (error) {
console.error('Cancel error:', error);
showToast('Netzwerkfehler bei Stornierung', 'error');
}
}
// === Flag Management (localStorage) ===
function saveFlags() {
localStorage.setItem('kantine_flags', JSON.stringify([...userFlags]));
}
function toggleFlag(date, articleId, name, cutoff) {
const id = `${date}_${articleId}`;
if (userFlags.has(id)) {
userFlags.delete(id);
showToast(`Flag entfernt für ${name}`, 'success');
} else {
userFlags.add(id);
showToast(`Benachrichtigung aktiviert für ${name}`, 'success');
if (Notification.permission === 'default') {
Notification.requestPermission();
}
}
saveFlags();
renderVisibleWeeks();
}
// FR-019: Auto-remove flags whose cutoff has passed
function cleanupExpiredFlags() {
const now = new Date();
let changed = false;
for (const flagId of [...userFlags]) {
const [date] = flagId.split('_');
const cutoff = new Date(date);
cutoff.setHours(10, 0, 0, 0); // Standard cutoff 10:00
if (now >= cutoff) {
userFlags.delete(flagId);
changed = true;
}
}
if (changed) saveFlags();
}
// === Polling (Client-Side) ===
function startPolling() {
if (pollIntervalId) return;
if (!authToken) return;
pollIntervalId = setInterval(() => pollFlaggedItems(), POLL_INTERVAL_MS);
console.log('Polling started (every 5 min)');
}
function stopPolling() {
if (pollIntervalId) {
clearInterval(pollIntervalId);
pollIntervalId = null;
console.log('Polling stopped');
}
}
async function pollFlaggedItems() {
if (userFlags.size === 0 || !authToken) return;
console.log(`Polling ${userFlags.size} flagged items...`);
for (const flagId of userFlags) {
const [date, articleIdStr] = flagId.split('_');
const articleId = parseInt(articleIdStr);
try {
const response = await fetch(`${API_BASE}/venues/${VENUE_ID}/menu/${MENU_ID}/${date}/`, {
headers: apiHeaders(authToken)
});
if (!response.ok) continue;
const data = await response.json();
const groups = data.results || [];
let foundItem = null;
for (const group of groups) {
if (group.items) {
foundItem = group.items.find(i => i.id === articleId || i.article === articleId);
if (foundItem) break;
}
}
if (foundItem) {
const isAvailable = (foundItem.amount_tracking === false) || (parseInt(foundItem.available_amount) > 0);
if (isAvailable) {
const itemName = foundItem.name || 'Unbekannt';
showToast(`${itemName} ist jetzt verfügbar!`, 'success');
if (Notification.permission === 'granted') {
new Notification('Kantine Wrapper', {
body: `${itemName} ist jetzt verfügbar!`,
icon: '🍽️'
});
}
// Refresh menu data to update UI
loadMenuDataFromAPI();
}
}
} catch (err) {
console.error(`Poll error for ${flagId}:`, err);
// Small delay between checks
await new Promise(r => setTimeout(r, 200));
}
}
}
// === Highlight Management ===
let highlightTags = JSON.parse(localStorage.getItem('kantine_highlightTags') || '[]');
function saveHighlightTags() {
localStorage.setItem('kantine_highlightTags', JSON.stringify(highlightTags));
renderVisibleWeeks(); // Refresh UI to apply changes
updateNextWeekBadge();
}
function addHighlightTag(tag) {
tag = tag.trim().toLowerCase();
if (tag && !highlightTags.includes(tag)) {
highlightTags.push(tag);
saveHighlightTags();
return true;
}
return false;
}
function removeHighlightTag(tag) {
highlightTags = highlightTags.filter(t => t !== tag);
saveHighlightTags();
}
function renderTagsList() {
const list = document.getElementById('tags-list');
list.innerHTML = '';
highlightTags.forEach(tag => {
const badge = document.createElement('span');
badge.className = 'tag-badge';
badge.innerHTML = `${tag} <span class="tag-remove" data-tag="${tag}">&times;</span>`;
list.appendChild(badge);
});
// Bind remove events
list.querySelectorAll('.tag-remove').forEach(btn => {
btn.addEventListener('click', (e) => {
removeHighlightTag(e.target.dataset.tag);
renderTagsList();
});
});
}
function checkHighlight(text) {
if (!text) return [];
text = text.toLowerCase();
return highlightTags.filter(tag => text.includes(tag));
}
// === Local Menu Cache (localStorage) ===
const CACHE_KEY = 'kantine_menuCache';
const CACHE_TS_KEY = 'kantine_menuCacheTs';
function saveMenuCache() {
try {
localStorage.setItem(CACHE_KEY, JSON.stringify(allWeeks));
localStorage.setItem(CACHE_TS_KEY, new Date().toISOString());
} catch (e) {
console.warn('Failed to cache menu data:', e);
}
}
function loadMenuCache() {
try {
const cached = localStorage.getItem(CACHE_KEY);
const cachedTs = localStorage.getItem(CACHE_TS_KEY);
console.log(`[Cache] localStorage: key=${!!cached} (${cached ? cached.length : 0} chars), ts=${cachedTs}`);
if (cached) {
allWeeks = JSON.parse(cached);
currentWeekNumber = getISOWeek(new Date());
currentYear = new Date().getFullYear();
console.log(`[Cache] Parsed ${allWeeks.length} weeks:`, allWeeks.map(w => `KW${w.weekNumber}/${w.year} (${(w.days || []).length} days)`));
renderVisibleWeeks();
updateNextWeekBadge();
if (cachedTs) updateLastUpdatedTime(cachedTs);
console.log('Loaded menu from cache');
return true;
}
} catch (e) {
console.warn('Failed to load cached menu:', e);
}
return false;
}
// FR-024: Check if cache is fresh enough to skip API refresh
function isCacheFresh() {
const cachedTs = localStorage.getItem(CACHE_TS_KEY);
if (!cachedTs) {
console.log('[Cache] No timestamp found');
return false;
}
// Condition 1: Cache < 1 hour old
const ageMs = Date.now() - new Date(cachedTs).getTime();
const ageMin = Math.round(ageMs / 60000);
if (ageMs > 60 * 60 * 1000) {
console.log(`[Cache] Stale: ${ageMin}min old (max 60)`);
return false;
}
// Condition 2: Data for current week exists
const thisWeek = getISOWeek(new Date());
const thisYear = getWeekYear(new Date());
const hasCurrentWeek = allWeeks.some(w => w.weekNumber === thisWeek && w.year === thisYear && w.days && w.days.length > 0);
console.log(`[Cache] Age: ${ageMin}min, looking for KW${thisWeek}/${thisYear}, found: ${hasCurrentWeek}`);
return hasCurrentWeek;
}
// === Menu Data Fetching (Direct from Bessa API) ===
async function loadMenuDataFromAPI() {
const loading = document.getElementById('loading');
const progressModal = document.getElementById('progress-modal');
const progressFill = document.getElementById('progress-fill');
const progressPercent = document.getElementById('progress-percent');
const progressMessage = document.getElementById('progress-message');
loading.classList.remove('hidden');
const token = authToken || GUEST_TOKEN;
try {
// Show progress modal
progressModal.classList.remove('hidden');
progressMessage.textContent = 'Hole verfügbare Daten...';
progressFill.style.width = '0%';
progressPercent.textContent = '0%';
// 1. Fetch available dates
const datesResponse = await fetch(`${API_BASE}/venues/${VENUE_ID}/menu/dates/`, {
headers: apiHeaders(token)
});
if (!datesResponse.ok) throw new Error(`Failed to fetch dates: ${datesResponse.status}`);
const datesData = await datesResponse.json();
let availableDates = datesData.results || [];
// Filter last 7 days + future, limit 30
const cutoff = new Date();
cutoff.setDate(cutoff.getDate() - 7);
const cutoffStr = cutoff.toISOString().split('T')[0];
availableDates = availableDates
.filter(d => d.date >= cutoffStr)
.sort((a, b) => a.date.localeCompare(b.date))
.slice(0, 30);
const totalDates = availableDates.length;
progressMessage.textContent = `${totalDates} Tage gefunden. Lade Details...`;
// 2. Fetch details for each date
const allDays = [];
let completed = 0;
for (const dateObj of availableDates) {
const dateStr = dateObj.date;
const pct = Math.round(((completed + 1) / totalDates) * 100);
progressFill.style.width = `${pct}%`;
progressPercent.textContent = `${pct}%`;
progressMessage.textContent = `Lade Menü für ${dateStr}...`;
try {
const detailResp = await fetch(`${API_BASE}/venues/${VENUE_ID}/menu/${MENU_ID}/${dateStr}/`, {
headers: apiHeaders(token)
});
if (detailResp.ok) {
const detailData = await detailResp.json();
// Debug: log raw API response for first date
if (completed === 0) {
console.log('[Kantine Debug] Raw API response for', dateStr, ':', JSON.stringify(detailData).substring(0, 2000));
}
const menuGroups = detailData.results || [];
let dayItems = [];
for (const group of menuGroups) {
if (group.items && Array.isArray(group.items)) {
dayItems = dayItems.concat(group.items);
}
}
if (dayItems.length > 0) {
// Debug: log first item structure
if (completed === 0) {
console.log('[Kantine Debug] First item keys:', Object.keys(dayItems[0]));
console.log('[Kantine Debug] First item:', JSON.stringify(dayItems[0]).substring(0, 500));
}
allDays.push({
date: dateStr,
menu_items: dayItems,
orders: dateObj.orders || []
});
}
}
} catch (err) {
console.error(`Failed to fetch details for ${dateStr}:`, err);
}
completed++;
// Small delay to avoid rate limiting
await new Promise(r => setTimeout(r, 100));
}
// 3. Group by ISO week (Merge with existing to preserve past days)
const weeksMap = new Map();
// Hydrate from existing cache (preserve past data)
if (allWeeks && allWeeks.length > 0) {
allWeeks.forEach(w => {
const key = `${w.year}-${w.weekNumber}`;
try {
weeksMap.set(key, {
year: w.year,
weekNumber: w.weekNumber,
days: w.days ? w.days.map(d => ({ ...d, items: d.items ? [...d.items] : [] })) : []
});
} catch (e) { console.warn('Error hydrating week:', e); }
});
}
for (const day of allDays) {
const d = new Date(day.date);
const weekNum = getISOWeek(d);
const year = getWeekYear(d);
const key = `${year}-${weekNum}`;
if (!weeksMap.has(key)) {
weeksMap.set(key, { year, weekNumber: weekNum, days: [] });
}
const weekObj = weeksMap.get(key);
const weekday = d.toLocaleDateString('en-US', { weekday: 'long' });
const orderCutoffDate = new Date(day.date);
orderCutoffDate.setHours(10, 0, 0, 0);
const newDayObj = {
date: day.date,
weekday: weekday,
orderCutoff: orderCutoffDate.toISOString(),
items: day.menu_items.map(item => {
const isUnlimited = item.amount_tracking === false;
const hasStock = parseInt(item.available_amount) > 0;
return {
id: `${day.date}_${item.id}`,
articleId: item.id,
name: item.name || 'Unknown',
description: item.description || '',
price: parseFloat(item.price) || 0,
available: isUnlimited || hasStock,
availableAmount: parseInt(item.available_amount) || 0,
amountTracking: item.amount_tracking !== false
};
})
};
// Merge: Overwrite if exists, push if new
const existingIndex = weekObj.days.findIndex(existing => existing.date === day.date);
if (existingIndex >= 0) {
weekObj.days[existingIndex] = newDayObj;
} else {
weekObj.days.push(newDayObj);
}
}
// Sort weeks and days
allWeeks = Array.from(weeksMap.values()).sort((a, b) => {
if (a.year !== b.year) return a.year - b.year;
return a.weekNumber - b.weekNumber;
});
allWeeks.forEach(w => {
if (w.days) w.days.sort((a, b) => a.date.localeCompare(b.date));
});
// Save to localStorage cache
saveMenuCache();
// Update timestamp
updateLastUpdatedTime(new Date().toISOString());
currentWeekNumber = getISOWeek(new Date());
currentYear = new Date().getFullYear();
updateAuthUI(); // This will trigger fetchOrders if logged in
renderVisibleWeeks();
updateNextWeekBadge();
progressMessage.textContent = 'Fertig!';
setTimeout(() => progressModal.classList.add('hidden'), 500);
} catch (error) {
console.error('Error fetching menu:', error);
progressModal.classList.add('hidden');
showErrorModal(
'Keine Verbindung',
`Die Menüdaten konnten nicht geladen werden. Möglicherweise besteht keine Verbindung zur API oder zur Bessa-Webseite.<br><br><small style="color:var(--text-secondary)">${error.message}</small>`,
'Zur Original-Seite',
'https://web.bessa.app/knapp-kantine'
);
} finally {
loading.classList.add('hidden');
}
}
// === Last Updated Display ===
let lastUpdatedTimestamp = null;
let lastUpdatedIntervalId = null;
function updateLastUpdatedTime(isoTimestamp) {
const subtitle = document.getElementById('last-updated-subtitle');
if (!isoTimestamp) return;
lastUpdatedTimestamp = isoTimestamp;
try {
const date = new Date(isoTimestamp);
const timeStr = date.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
const dateStr = date.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' });
const ago = getRelativeTime(date);
subtitle.textContent = `Aktualisiert: ${dateStr} ${timeStr} (${ago})`;
} catch (e) {
subtitle.textContent = '';
}
// Auto-refresh relative time every minute
if (!lastUpdatedIntervalId) {
lastUpdatedIntervalId = setInterval(() => {
if (lastUpdatedTimestamp) updateLastUpdatedTime(lastUpdatedTimestamp);
}, 60 * 1000);
}
}
function getRelativeTime(date) {
const diffMs = Date.now() - date.getTime();
const diffMin = Math.floor(diffMs / 60000);
if (diffMin < 1) return 'gerade eben';
if (diffMin === 1) return 'vor 1 min.';
if (diffMin < 60) return `vor ${diffMin} min.`;
const diffH = Math.floor(diffMin / 60);
if (diffH === 1) return 'vor 1 Std.';
return `vor ${diffH} Std.`;
}
// === Toast Notification ===
function showToast(message, type = 'info') {
let container = document.getElementById('toast-container');
if (!container) {
container = document.createElement('div');
container.id = 'toast-container';
document.body.appendChild(container);
}
const toast = document.createElement('div');
toast.className = `toast toast-${type}`;
const icon = type === 'success' ? 'check_circle' : type === 'error' ? 'error' : 'info';
toast.innerHTML = `<span class="material-icons-round">${icon}</span><span>${message}</span>`;
container.appendChild(toast);
requestAnimationFrame(() => toast.classList.add('show'));
setTimeout(() => {
toast.classList.remove('show');
setTimeout(() => toast.remove(), 300);
}, 3000);
}
// === Next Week Badge ===
function updateNextWeekBadge() {
const btnNextWeek = document.getElementById('btn-next-week');
let nextWeek = currentWeekNumber + 1;
let nextYear = currentYear;
if (nextWeek > 52) { nextWeek = 1; nextYear++; }
const nextWeekData = allWeeks.find(w => w.weekNumber === nextWeek && w.year === nextYear);
let totalDataCount = 0;
let orderableCount = 0;
let daysWithOrders = 0;
let daysWithOrderableAndNoOrder = 0;
if (nextWeekData && nextWeekData.days) {
nextWeekData.days.forEach(day => {
if (day.items && day.items.length > 0) {
totalDataCount++;
const isOrderable = day.items.some(item => item.available);
if (isOrderable) orderableCount++;
let hasOrder = false;
day.items.forEach(item => {
const articleId = item.articleId || parseInt(item.id.split('_')[1]);
const key = `${day.date}_${articleId}`;
if (orderMap.has(key) && orderMap.get(key).length > 0) hasOrder = true;
});
if (hasOrder) daysWithOrders++;
if (isOrderable && !hasOrder) daysWithOrderableAndNoOrder++;
}
});
}
let badge = btnNextWeek.querySelector('.nav-badge');
if (totalDataCount > 0) {
if (!badge) {
badge = document.createElement('span');
badge.className = 'nav-badge';
btnNextWeek.appendChild(badge);
}
// Format: ( Ordered / Orderable / Total )
badge.title = `${daysWithOrders} bestellt / ${orderableCount} bestellbar / ${totalDataCount} gesamt`;
badge.innerHTML = `<span class="ordered">${daysWithOrders}</span><span class="separator">/</span><span class="orderable">${orderableCount}</span><span class="separator">/</span><span class="total">${totalDataCount}</span>`;
// Color Logic
badge.classList.remove('badge-violet', 'badge-green', 'badge-red', 'badge-blue');
// Refined Logic (v1.7.4):
// Violet: If we have orders AND there are no DAYS left that are orderable but un-ordered.
// (i.e. "I have ordered everything I can")
if (daysWithOrders > 0 && daysWithOrderableAndNoOrder === 0) {
badge.classList.add('badge-violet');
} else if (daysWithOrderableAndNoOrder > 0) {
badge.classList.add('badge-green'); // Orderable days exist without order
} else if (orderableCount === 0) {
badge.classList.add('badge-red'); // No orderable days at all & no orders
} else {
badge.classList.add('badge-blue'); // Default / partial state
}
// Advanced Feature: Highlight Count
let highlightCount = 0;
if (nextWeekData && nextWeekData.days) {
nextWeekData.days.forEach(day => {
day.items.forEach(item => {
const nameMatches = checkHighlight(item.name);
const descMatches = checkHighlight(item.description);
if (nameMatches.length > 0 || descMatches.length > 0) {
highlightCount++;
}
});
});
}
if (highlightCount > 0) {
// Append blue count
badge.innerHTML += `<span class="highlight-count" title="${highlightCount} Highlight Menüs">(${highlightCount})</span>`;
badge.title += `${highlightCount} Highlights gefunden`;
badge.classList.add('has-highlights');
}
} else if (badge) {
badge.remove();
}
}
// === Weekly Cost ===
function updateWeeklyCost(days) {
let totalCost = 0;
if (days && days.length > 0) {
days.forEach(day => {
if (day.items) {
day.items.forEach(item => {
const articleId = item.articleId || parseInt(item.id.split('_')[1]);
const key = `${day.date}_${articleId}`;
const orders = orderMap.get(key) || [];
if (orders.length > 0) totalCost += item.price * orders.length;
});
}
});
}
const costDisplay = document.getElementById('weekly-cost-display');
if (totalCost > 0) {
costDisplay.innerHTML = `<span class="material-icons-round">shopping_bag</span> <span>Gesamt: ${totalCost.toFixed(2).replace('.', ',')} €</span>`;
costDisplay.classList.remove('hidden');
} else {
costDisplay.classList.add('hidden');
}
}
// === Render Weeks ===
function renderVisibleWeeks() {
const menuContainer = document.getElementById('menu-container');
if (!menuContainer) return;
menuContainer.innerHTML = '';
let targetWeek = currentWeekNumber;
let targetYear = currentYear;
if (displayMode === 'next-week') {
targetWeek++;
if (targetWeek > 52) { targetWeek = 1; targetYear++; }
}
// Flatten & filter by week + year
const allDays = allWeeks.flatMap(w => w.days || []);
const daysInTargetWeek = allDays.filter(day => {
const d = new Date(day.date);
return getISOWeek(d) === targetWeek && getWeekYear(d) === targetYear;
});
if (daysInTargetWeek.length === 0) {
menuContainer.innerHTML = `
<div class="empty-state">
<p>Keine Menüdaten für KW ${targetWeek} (${targetYear}) verfügbar.</p>
<small>Versuchen Sie eine andere Woche oder schauen Sie später vorbei.</small>
</div>`;
document.getElementById('weekly-cost-display').classList.add('hidden');
return;
}
updateWeeklyCost(daysInTargetWeek);
// Update header
const headerWeekInfo = document.getElementById('header-week-info');
const weekTitle = displayMode === 'this-week' ? 'Diese Woche' : 'Nächste Woche';
headerWeekInfo.innerHTML = `
<div class="header-week-title">${weekTitle}</div>
<div class="header-week-subtitle">Week ${targetWeek}${targetYear}</div>`;
const grid = document.createElement('div');
grid.className = 'days-grid';
daysInTargetWeek.sort((a, b) => a.date.localeCompare(b.date));
// Filter weekends
const workingDays = daysInTargetWeek.filter(d => {
const date = new Date(d.date);
const day = date.getDay();
return day !== 0 && day !== 6;
});
workingDays.forEach(day => {
const card = createDayCard(day);
if (card) grid.appendChild(card);
});
menuContainer.appendChild(grid);
setTimeout(() => syncMenuItemHeights(grid), 0);
}
// === Sync Item Heights ===
function syncMenuItemHeights(grid) {
const cards = grid.querySelectorAll('.menu-card');
if (cards.length === 0) return;
let maxItems = 0;
cards.forEach(card => {
maxItems = Math.max(maxItems, card.querySelectorAll('.menu-item').length);
});
for (let i = 0; i < maxItems; i++) {
let maxHeight = 0;
const itemsAtPos = [];
cards.forEach(card => {
const items = card.querySelectorAll('.menu-item');
if (items[i]) {
items[i].style.height = 'auto';
maxHeight = Math.max(maxHeight, items[i].offsetHeight);
itemsAtPos.push(items[i]);
}
});
itemsAtPos.forEach(item => { item.style.height = `${maxHeight}px`; });
}
}
// === Create Day Card ===
function createDayCard(day) {
if (!day.items || day.items.length === 0) return null;
const card = document.createElement('div');
card.className = 'menu-card';
const now = new Date();
const cardDate = new Date(day.date);
let isPastCutoff = false;
if (day.orderCutoff) {
isPastCutoff = now >= new Date(day.orderCutoff);
} else {
const today = new Date();
today.setHours(0, 0, 0, 0);
const cd = new Date(day.date);
cd.setHours(0, 0, 0, 0);
isPastCutoff = cd < today;
}
if (isPastCutoff) card.classList.add('past-day');
// Collect ordered menu codes
const menuBadges = [];
if (day.items) {
day.items.forEach(item => {
const articleId = item.articleId || parseInt(item.id.split('_')[1]);
const orderKey = `${day.date}_${articleId}`;
const orders = orderMap.get(orderKey) || [];
const count = orders.length;
if (count > 0) {
// Regex for M1, M2, M1F etc.
const match = item.name.match(/([M][1-9][Ff]?)/);
if (match) {
let code = match[1];
if (count > 1) code += '+';
menuBadges.push(code);
}
}
});
}
// Header
const header = document.createElement('div');
header.className = 'card-header';
const dateStr = cardDate.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' });
const badgesHtml = menuBadges.map(code => `<span class="menu-code-badge">${code}</span>`).join('');
// Determine Day Status for Header Color
// Violet: Has Order
// Green: No Order but Orderable
// Red: No Order and Not Orderable (Locked/Sold Out)
let headerClass = '';
const hasAnyOrder = day.items && day.items.some(item => {
const articleId = item.articleId || parseInt(item.id.split('_')[1]);
const key = `${day.date}_${articleId}`;
return orderMap.has(key) && orderMap.get(key).length > 0;
});
const hasOrderable = day.items && day.items.some(item => {
// Use pre-calculated available flag from loadMenuDataFromAPI calculation
return item.available;
});
if (hasAnyOrder) {
headerClass = 'header-violet';
} else if (hasOrderable && !isPastCutoff) {
headerClass = 'header-green';
} else {
// Red if not orderable (or past cutoff)
headerClass = 'header-red';
}
if (headerClass) header.classList.add(headerClass);
header.innerHTML = `
<div class="day-header-left">
<span class="day-name">${translateDay(day.weekday)}</span>
<div class="day-badges">${badgesHtml}</div>
</div>
<span class="day-date">${dateStr}</span>`;
card.appendChild(header);
// Body
const body = document.createElement('div');
body.className = 'card-body';
const todayDateStr = new Date().toISOString().split('T')[0];
const isToday = day.date === todayDateStr;
const sortedItems = [...day.items].sort((a, b) => {
if (isToday) {
const aId = a.articleId || parseInt(a.id.split('_')[1]);
const bId = b.articleId || parseInt(b.id.split('_')[1]);
const aOrdered = orderMap.has(`${day.date}_${aId}`);
const bOrdered = orderMap.has(`${day.date}_${bId}`);
if (aOrdered && !bOrdered) return -1;
if (!aOrdered && bOrdered) return 1;
}
return a.name.localeCompare(b.name);
});
sortedItems.forEach(item => {
const itemEl = document.createElement('div');
itemEl.className = 'menu-item';
const articleId = item.articleId || parseInt(item.id.split('_')[1]);
const orderKey = `${day.date}_${articleId}`;
const orderIds = orderMap.get(orderKey) || [];
const orderCount = orderIds.length;
// Status badge
let statusBadge = '';
if (item.available) {
statusBadge = item.amountTracking
? `<span class="badge available">Verfügbar (${item.availableAmount})</span>`
: `<span class="badge available">Verfügbar</span>`;
} else {
statusBadge = `<span class="badge sold-out">Ausverkauft</span>`;
}
// Order badge
let orderedBadge = '';
if (orderCount > 0) {
const countBadge = orderCount > 1 ? `<span class="order-count-badge">${orderCount}</span>` : '';
orderedBadge = `<span class="badge ordered"><span class="material-icons-round">check_circle</span> Bestellt${countBadge}</span>`;
itemEl.classList.add('ordered');
if (new Date(day.date).toDateString() === now.toDateString()) {
itemEl.classList.add('today-ordered');
}
}
// Flagged styles
const flagId = `${day.date}_${articleId}`;
const isFlagged = userFlags.has(flagId);
if (isFlagged) {
itemEl.classList.add(item.available ? 'flagged-available' : 'flagged-sold-out');
}
// Highlight matching menu items based on user tags
const matchedTags = [...new Set([...checkHighlight(item.name), ...checkHighlight(item.description)])];
if (matchedTags.length > 0) {
itemEl.classList.add('highlight-glow');
}
// Action buttons
let orderButton = '';
let cancelButton = '';
let flagButton = '';
if (authToken && !isPastCutoff) {
// Flag button
const flagIcon = isFlagged ? 'notifications_active' : 'notifications_none';
const flagClass = isFlagged ? 'btn-flag active' : 'btn-flag';
const flagTitle = isFlagged ? 'Benachrichtigung deaktivieren' : 'Benachrichtigen wenn verfügbar';
if (!item.available || isFlagged) {
flagButton = `<button class="${flagClass}" data-date="${day.date}" data-article="${articleId}" data-name="${escapeHtml(item.name)}" data-cutoff="${day.orderCutoff}" title="${flagTitle}"><span class="material-icons-round">${flagIcon}</span></button>`;
}
// Order button
if (item.available) {
if (orderCount > 0) {
orderButton = `<button class="btn-order btn-order-compact" data-date="${day.date}" data-article="${articleId}" data-name="${escapeHtml(item.name)}" data-price="${item.price}" data-desc="${escapeHtml(item.description || '')}" title="${escapeHtml(item.name)} nochmal bestellen"><span class="material-icons-round">add</span></button>`;
} else {
orderButton = `<button class="btn-order" data-date="${day.date}" data-article="${articleId}" data-name="${escapeHtml(item.name)}" data-price="${item.price}" data-desc="${escapeHtml(item.description || '')}" title="${escapeHtml(item.name)} bestellen"><span class="material-icons-round">add_shopping_cart</span> Bestellen</button>`;
}
}
// Cancel button
if (orderCount > 0) {
const cancelIcon = orderCount === 1 ? 'close' : 'remove';
const cancelTitle = orderCount === 1 ? 'Bestellung stornieren' : 'Eine Bestellung stornieren';
cancelButton = `<button class="btn-cancel" data-date="${day.date}" data-article="${articleId}" data-name="${escapeHtml(item.name)}" title="${cancelTitle}"><span class="material-icons-round">${cancelIcon}</span></button>`;
}
}
// Build matched-tags HTML (only if tags found)
let tagsHtml = '';
if (matchedTags.length > 0) {
const badges = matchedTags.map(t => `<span class="tag-badge-small"><span class="material-icons-round" style="font-size:10px;margin-right:2px">star</span>${escapeHtml(t)}</span>`).join('');
tagsHtml = `<div class="matched-tags">${badges}</div>`;
}
itemEl.innerHTML = `
<div class="item-header">
<span class="item-name">${escapeHtml(item.name)}</span>
<span class="item-price">${item.price.toFixed(2)} €</span>
</div>
<div class="item-status-row">
${orderedBadge}
${cancelButton}
${orderButton}
${flagButton}
<div class="badges">${statusBadge}</div>
</div>
${tagsHtml}
<p class="item-desc">${escapeHtml(item.description)}</p>`;
// Event: Order
const orderBtn = itemEl.querySelector('.btn-order');
if (orderBtn) {
orderBtn.addEventListener('click', (e) => {
e.stopPropagation();
const btn = e.currentTarget;
btn.disabled = true;
btn.classList.add('loading');
placeOrder(btn.dataset.date, parseInt(btn.dataset.article), btn.dataset.name, parseFloat(btn.dataset.price), btn.dataset.desc || '')
.finally(() => { btn.disabled = false; btn.classList.remove('loading'); });
});
}
// Event: Cancel
const cancelBtn = itemEl.querySelector('.btn-cancel');
if (cancelBtn) {
cancelBtn.addEventListener('click', (e) => {
e.stopPropagation();
const btn = e.currentTarget;
btn.disabled = true;
cancelOrder(btn.dataset.date, parseInt(btn.dataset.article), btn.dataset.name)
.finally(() => { btn.disabled = false; });
});
}
// Event: Flag
const flagBtn = itemEl.querySelector('.btn-flag');
if (flagBtn) {
flagBtn.addEventListener('click', (e) => {
e.stopPropagation();
const btn = e.currentTarget;
toggleFlag(btn.dataset.date, parseInt(btn.dataset.article), btn.dataset.name, btn.dataset.cutoff);
});
}
body.appendChild(itemEl);
});
card.appendChild(body);
return card;
}
// === GitHub Release Management ===
// Semver comparison: returns true if remote > local
function isNewer(remote, local) {
if (!remote || !local) return false;
const r = remote.replace(/^v/, '').split('.').map(Number);
const l = local.replace(/^v/, '').split('.').map(Number);
for (let i = 0; i < Math.max(r.length, l.length); i++) {
if ((r[i] || 0) > (l[i] || 0)) return true;
if ((r[i] || 0) < (l[i] || 0)) return false;
}
return false;
}
// GitHub API headers
function githubHeaders() {
return { 'Accept': 'application/vnd.github.v3+json' };
}
// Fetch versions from GitHub (releases or tags)
async function fetchVersions(devMode) {
const endpoint = devMode
? `${GITHUB_API}/tags?per_page=20`
: `${GITHUB_API}/releases?per_page=20`;
const resp = await fetch(endpoint, { headers: githubHeaders() });
if (!resp.ok) {
if (resp.status === 403) {
throw new Error('API Rate Limit erreicht (403). Bitte später erneut versuchen.');
}
throw new Error(`GitHub API ${resp.status}`);
}
const data = await resp.json();
// Normalize to common format: { tag, name, url, body }
return data.map(item => {
const tag = devMode ? item.name : item.tag_name;
return {
tag,
name: devMode ? tag : (item.name || tag),
url: `${INSTALLER_BASE}/${tag}/dist/install.html`,
body: item.body || ''
};
});
}
// Periodic update check (runs on init + every hour)
async function checkForUpdates() {
const currentVersion = 'v1.4.2';
const devMode = localStorage.getItem('kantine_dev_mode') === 'true';
try {
const versions = await fetchVersions(devMode);
if (!versions.length) return;
// Cache for version menu
localStorage.setItem('kantine_version_cache', JSON.stringify({
timestamp: Date.now(), devMode, versions
}));
const latest = versions[0].tag;
console.log(`[Kantine] Version Check: Local [${currentVersion}] vs Latest [${latest}] (${devMode ? 'dev' : 'stable'})`);
if (!isNewer(latest, currentVersion)) return;
console.log(`[Kantine] Update verfügbar: ${latest}`);
// Show 🆕 icon in header (only once)
const headerTitle = document.querySelector('.header-left h1');
if (headerTitle && !headerTitle.querySelector('.update-icon')) {
const icon = document.createElement('a');
icon.className = 'update-icon';
icon.href = versions[0].url;
icon.target = '_blank';
icon.innerHTML = '🆕';
icon.title = `Update: ${latest} — Klick zum Installieren`;
icon.style.cssText = 'margin-left:8px;font-size:1em;text-decoration:none;cursor:pointer;vertical-align:middle;';
headerTitle.appendChild(icon);
}
} catch (e) {
console.warn('[Kantine] Version check failed:', e);
}
}
// Open Version Menu modal
function openVersionMenu() {
const modal = document.getElementById('version-modal');
const container = document.getElementById('version-list-container');
const devToggle = document.getElementById('dev-mode-toggle');
const currentVersion = 'v1.4.2';
if (!modal) return;
modal.classList.remove('hidden');
// Set current version display
const cur = document.getElementById('version-current');
if (cur) cur.textContent = currentVersion;
// Init dev toggle
const devMode = localStorage.getItem('kantine_dev_mode') === 'true';
devToggle.checked = devMode;
// Load versions (from cache or fresh)
async function loadVersions(forceRefresh) {
const dm = devToggle.checked;
container.innerHTML = '<p style="color:var(--text-secondary);">Lade Versionen...</p>';
try {
let versions;
const cached = JSON.parse(localStorage.getItem('kantine_version_cache') || 'null');
if (!forceRefresh && cached && cached.devMode === dm && (Date.now() - cached.timestamp < 3600000)) {
versions = cached.versions;
} else {
versions = await fetchVersions(dm);
localStorage.setItem('kantine_version_cache', JSON.stringify({
timestamp: Date.now(), devMode: dm, versions
}));
}
if (!versions.length) {
container.innerHTML = '<p style="color:var(--text-secondary);">Keine Versionen gefunden.</p>';
return;
}
container.innerHTML = '<ul class="version-list"></ul>';
const list = container.querySelector('.version-list');
versions.forEach(v => {
const isCurrent = v.tag === currentVersion;
const isNew = isNewer(v.tag, currentVersion);
const li = document.createElement('li');
li.className = 'version-item' + (isCurrent ? ' current' : '');
let badge = '';
if (isCurrent) badge = '<span class="badge-current">✓ Installiert</span>';
else if (isNew) badge = '<span class="badge-new">⬆ Neu!</span>';
let action = '';
if (!isCurrent) {
action = `<a href="${v.url}" target="_blank" class="install-link" title="${v.tag} installieren">Installieren</a>`;
}
li.innerHTML = `
<div class="version-info">
<strong>${v.tag}</strong>
${badge}
</div>
${action}
`;
list.appendChild(li);
});
} catch (e) {
container.innerHTML = `<p style="color:#e94560;">Fehler: ${e.message}</p>`;
}
}
loadVersions(false);
// Dev toggle handler
devToggle.onchange = () => {
localStorage.setItem('kantine_dev_mode', devToggle.checked);
// Clear cache to force refresh when mode changes
localStorage.removeItem('kantine_version_cache');
loadVersions(true);
};
}
// === Order Countdown ===
function updateCountdown() {
const now = new Date();
const currentDay = now.getDay();
// Skip weekends (0=Sun, 6=Sat)
if (currentDay === 0 || currentDay === 6) {
removeCountdown();
return;
}
const todayStr = now.toISOString().split('T')[0];
// 1. Check if we already ordered for today
let hasOrder = false;
// Optimization: Check orderMap for today's date
// Keys are "YYYY-MM-DD_ArticleID"
for (const key of orderMap.keys()) {
if (key.startsWith(todayStr)) {
hasOrder = true;
break;
}
}
if (hasOrder) {
removeCountdown();
return;
}
// 2. Calculate time to cutoff (10:00 AM)
const cutoff = new Date();
cutoff.setHours(10, 0, 0, 0);
const diff = cutoff - now;
// If passed cutoff or more than 3 hours away (e.g. 07:00), maybe don't show?
// User req: "heute noch keine bestellung... countdown erscheinen"
// Let's show it if within valid order window (e.g. 00:00 - 10:00)
if (diff <= 0) {
removeCountdown();
return;
}
// 3. Render Countdown
const diffHrs = Math.floor(diff / 3600000);
const diffMins = Math.floor((diff % 3600000) / 60000);
const headerCenter = document.querySelector('.header-center-wrapper');
if (!headerCenter) return;
let countdownEl = document.getElementById('order-countdown');
if (!countdownEl) {
countdownEl = document.createElement('div');
countdownEl.id = 'order-countdown';
// Insert before cost display or append
headerCenter.insertBefore(countdownEl, headerCenter.firstChild);
}
countdownEl.innerHTML = `<span>Bestellschluss:</span> <strong>${diffHrs}h ${diffMins}m</strong>`;
// Red Alert if < 1 hour
if (diff < 3600000) { // 1 hour
countdownEl.classList.add('urgent');
// Notification logic (One time)
const notifiedKey = `kantine_notified_${todayStr}`;
if (!sessionStorage.getItem(notifiedKey)) {
if (Notification.permission === 'granted') {
new Notification('Kantine: Bestellschluss naht!', {
body: 'Du hast heute noch nichts bestellt. Nur noch 1 Stunde!',
icon: '⏳'
});
} else if (Notification.permission === 'default') {
Notification.requestPermission();
}
sessionStorage.setItem(notifiedKey, 'true');
}
} else {
countdownEl.classList.remove('urgent');
}
}
function removeCountdown() {
const el = document.getElementById('order-countdown');
if (el) el.remove();
}
// Update countdown every minute
setInterval(updateCountdown, 60000);
// Also update on load
setTimeout(updateCountdown, 1000);
// === Helpers ===
function getISOWeek(date) {
const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()));
const dayNum = d.getUTCDay() || 7;
d.setUTCDate(d.getUTCDate() + 4 - dayNum);
const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
return Math.ceil(((d - yearStart) / 86400000 + 1) / 7);
}
function getWeekYear(d) {
const date = new Date(d.getTime());
date.setDate(date.getDate() + 3 - (date.getDay() + 6) % 7);
return date.getFullYear();
}
function translateDay(englishDay) {
const map = { Monday: 'Montag', Tuesday: 'Dienstag', Wednesday: 'Mittwoch', Thursday: 'Donnerstag', Friday: 'Freitag', Saturday: 'Samstag', Sunday: 'Sonntag' };
return map[englishDay] || englishDay;
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text || '';
return div.innerHTML;
}
// === Bootstrap ===
injectUI();
bindEvents();
updateAuthUI();
cleanupExpiredFlags();
// Load cached data first for instant UI, refresh only if stale (FR-024)
const hadCache = loadMenuCache();
if (hadCache) {
document.getElementById('loading').classList.add('hidden');
if (!isCacheFresh()) {
console.log('Cache stale or incomplete refreshing from API');
loadMenuDataFromAPI();
} else {
console.log('Cache fresh & complete skipping API refresh');
}
} else {
loadMenuDataFromAPI();
}
// Auto-start polling if already logged in
if (authToken) {
startPolling();
}
// Check for updates (now + every hour)
checkForUpdates();
setInterval(checkForUpdates, 60 * 60 * 1000);
console.log('Kantine Wrapper loaded ✅');
})();
// === Error Modal ===
function showErrorModal(title, htmlContent, btnText, url) {
const modalId = 'error-modal';
let modal = document.getElementById(modalId);
if (modal) modal.remove();
modal = document.createElement('div');
modal.id = modalId;
modal.className = 'modal hidden';
modal.innerHTML = `
<div class="modal-content">
<div class="modal-header">
<h2 style="color: var(--error-color); display: flex; align-items: center; gap: 10px;">
<span class="material-icons-round">signal_wifi_off</span>
${title}
</h2>
</div>
<div style="padding: 20px;">
<p style="margin-bottom: 15px; color: var(--text-primary);">${htmlContent}</p>
<div style="margin-top: 20px; display: flex; justify-content: center;">
<button id="btn-error-redirect" style="
background-color: var(--accent-color);
color: white;
padding: 12px 24px;
border-radius: 8px;
border: none;
font-weight: 600;
cursor: pointer;
display: flex;
align-items: center;
gap: 8px;
width: 100%;
justify-content: center;
transition: transform 0.1s;
">
${btnText}
<span class="material-icons-round">open_in_new</span>
</button>
</div>
</div>
</div>
`;
document.body.appendChild(modal);
document.getElementById('btn-error-redirect').addEventListener('click', () => {
window.location.href = url;
});
requestAnimationFrame(() => {
modal.classList.remove('hidden');
});
}
</script>
</body>
</html>