Compare commits

...

13 Commits

Author SHA1 Message Date
Kantine Wrapper
49dc1cc135 chore: update build artifacts for v1.6.0 2026-03-04 14:45:25 +01:00
Kantine Wrapper
90f1c0ed04 feat: Add descriptive German tooltips to various UI elements for improved usability 2026-03-04 14:45:13 +01:00
Kantine Wrapper
42978c6e7e chore: update build artifacts for v1.6.0 2026-03-04 13:29:25 +01:00
Kantine Wrapper
6ad3498bcc cleanup test file 2026-03-04 13:27:13 +01:00
Kantine Wrapper
b44ecb2ccf v1.6.0: Language Filter 2026-03-04 13:26:46 +01:00
Kantine Wrapper
9e161e2907 chore: update build artifacts for v1.6.0 2026-03-04 13:11:43 +01:00
Kantine Wrapper
8b15760463 feat: Introduce language filter with DE/EN/ALL toggle for menu descriptions and update to version 1.6.0. 2026-03-04 13:11:34 +01:00
Kantine Wrapper
4aa67c9cbe chore: update build artifacts for v1.5.1 2026-03-04 11:42:04 +01:00
Kantine Wrapper
12c55ef883 feat: Add a collapsing video banner to the installation page. 2026-03-04 11:41:58 +01:00
Kantine Wrapper
1e9dd9a3b5 chore: update build artifacts for v1.5.1 2026-03-04 11:39:19 +01:00
Kantine Wrapper
db8b2c5629 fix: Correct Friday order payload preorder and time, and update version to v1.5.1. 2026-03-04 11:39:09 +01:00
Kantine Wrapper
67533875bd chore: update build artifacts for v1.5.1 2026-03-04 11:33:37 +01:00
Kantine Wrapper
99809dafb7 fix: correct preorder flag and date format for Friday orders (v1.5.1) 2026-03-04 11:33:37 +01:00
13 changed files with 584 additions and 46 deletions

View File

@@ -61,6 +61,8 @@ Das System umfasst die Darstellung von Menüplänen in einer Wochenübersicht, d
| FR-090 | Die Hauptnavigation (Wochen-Toggles) muss linksbündig neben dem App-Titel positioniert sein. | Niedrig | v1.5.0 | | FR-090 | Die Hauptnavigation (Wochen-Toggles) muss linksbündig neben dem App-Titel positioniert sein. | Niedrig | v1.5.0 |
| FR-091 | Ein dynamisches Alarm-Icon im Header muss den Überwachungsstatus geflaggter Menüs anzeigen (Gelb=Überwachung aktiv aber kein Menü verfügbar, Grün=Mindestens ein Menü verfügbar, Versteckt=keine Flags). Der Tooltip muss den Zeitpunkt der letzten Prüfung als relativen String (z.B. "vor 4 Min.") enthalten. | Mittel | v1.5.0 (Update v1.4.10) | | FR-091 | Ein dynamisches Alarm-Icon im Header muss den Überwachungsstatus geflaggter Menüs anzeigen (Gelb=Überwachung aktiv aber kein Menü verfügbar, Grün=Mindestens ein Menü verfügbar, Versteckt=keine Flags). Der Tooltip muss den Zeitpunkt der letzten Prüfung als relativen String (z.B. "vor 4 Min.") enthalten. | Mittel | v1.5.0 (Update v1.4.10) |
| FR-092 | Solange Menüdaten für die Nächste Woche verfügbar sind, aber noch keine Bestellungen getätigt wurden, muss der entsprechende Navigation-Button animiert und farblich (Gelb) hervorgehoben werden. Nach der ersten Bestellung muss die Hervorhebung automatisch erlöschen. Zusätzlich muss beim erstmaligen Erscheinen der Daten ein einmaliger Toast-Hinweis angezeigt werden. | Mittel | v1.6.0 (Update v1.4.21) | | FR-092 | Solange Menüdaten für die Nächste Woche verfügbar sind, aber noch keine Bestellungen getätigt wurden, muss der entsprechende Navigation-Button animiert und farblich (Gelb) hervorgehoben werden. Nach der ersten Bestellung muss die Hervorhebung automatisch erlöschen. Zusätzlich muss beim erstmaligen Erscheinen der Daten ein einmaliger Toast-Hinweis angezeigt werden. | Mittel | v1.6.0 (Update v1.4.21) |
| **Sprachfilter** | | | |
| FR-120 | Das System muss zweisprachige Menübeschreibungen (Deutsch/Englisch) erkennen und dem Benutzer erlauben, via UI-Toggle zwischen DE, EN und ALL (beide Sprachen) zu wechseln. Die Sprachpräferenz muss persistent gespeichert werden. Allergen-Codes müssen in allen Modi angezeigt werden. | Mittel | v1.6.0 |
| **Benutzer-Feedback** | | | | | **Benutzer-Feedback** | | | |
| FR-095 | Alle benutzerrelevanten Aktionen (Bestellung, Stornierung, Fehler) müssen durch nicht-blockierende Benachrichtigungen (Toasts) bestätigt werden. | Mittel | v1.0.1 | | FR-095 | Alle benutzerrelevanten Aktionen (Bestellung, Stornierung, Fehler) müssen durch nicht-blockierende Benachrichtigungen (Toasts) bestätigt werden. | Mittel | v1.0.1 |
| FR-096 | Bei einem Verbindungsfehler muss ein Fehlerdialog mit Fallback-Link zur Originalseite angezeigt werden. | Mittel | v1.0.1 | | FR-096 | Bei einem Verbindungsfehler muss ein Fehlerdialog mit Fallback-Link zur Originalseite angezeigt werden. | Mittel | v1.0.1 |

View File

@@ -146,6 +146,19 @@ cat > "$DIST_DIR/install.html" << INSTALLEOF
</style> </style>
</head> </head>
<body> <body>
<!-- Banner Video: plays once, collapses after ending -->
<div id="banner-video-wrap" style="width: 100%; max-width: 600px; margin: 0 auto 20px auto; border-radius: 12px; overflow: hidden; pointer-events: none; user-select: none; max-height: 400px; opacity: 1; transition: max-height 0.8s ease-in-out, opacity 0.6s ease-in-out, margin 0.8s ease-in-out;">
<video id="banner-video" autoplay muted playsinline disablepictureinpicture style="width: 100%; display: block;" src="https://github.com/TauNeutrino/kantine-overview/raw/main/dist/Arrow_and_fork_fly_away_bd43310bea.mp4"></video>
</div>
<script>
document.getElementById('banner-video').addEventListener('ended', function() {
var w = document.getElementById('banner-video-wrap');
w.style.maxHeight = '0';
w.style.opacity = '0';
w.style.marginBottom = '0';
});
</script>
<div style="text-align: center; margin-bottom: 30px;"> <div style="text-align: center; margin-bottom: 30px;">
<h1 style="margin-bottom: 5px; display: flex; align-items: center; justify-content: center; gap: 10px;"> <h1 style="margin-bottom: 5px; display: flex; align-items: center; justify-content: center; gap: 10px;">
<img src="$FAVICON_URL" alt="Logo" style="width: 38px; height: 38px;"> <img src="$FAVICON_URL" alt="Logo" style="width: 38px; height: 38px;">

View File

@@ -1,3 +1,9 @@
## v1.6.0 (2026-03-04)
-**Feature**: Sprachfilter für zweisprachige Menübeschreibungen. Neuer DE/EN/ALL Toggle im Header ermöglicht das Umschalten zwischen Deutsch, Englisch und dem vollen Originaltext. Allergen-Codes werden in allen Modi angezeigt. Einstellung wird persistent gespeichert.
## v1.5.1 (2026-03-04)
- 🐛 **Bugfix**: Freitagsbestellungen schlugen fehl ("Onlinebestellung sind nicht verfügbar"). Ursache: Der Order-Payload verwendete `preorder: false` und eine falsche Uhrzeit (`T10:00:00.000Z` statt `T10:30:00Z`). Beides wurde anhand der originalen Bessa-API korrigiert.
## v1.5.0 (2026-02-26) ## v1.5.0 (2026-02-26)
Das große "Quality of Life"-Update! Zusammenfassung aller Features und Fixes seit v1.4.0: Das große "Quality of Life"-Update! Zusammenfassung aller Features und Fixes seit v1.4.0:

BIN
dist/Arrow_and_fork_fly_away_bd43310bea.mp4 vendored Executable file

Binary file not shown.

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

31
dist/install.html vendored

File diff suppressed because one or more lines are too long

View File

@@ -164,6 +164,38 @@ body {
color: var(--text-secondary); color: var(--text-secondary);
} }
/* Language Toggle (FR-100) */
.lang-toggle {
display: inline-flex;
gap: 0;
border-radius: 6px;
overflow: hidden;
border: 1px solid var(--border-color);
background: var(--bg-card);
}
.lang-btn {
padding: 3px 10px;
font-size: 0.7rem;
font-weight: 600;
letter-spacing: 0.03em;
background: transparent;
color: var(--text-secondary);
border: none;
cursor: pointer;
transition: all 0.2s;
}
.lang-btn:hover {
color: var(--text-primary);
background: rgba(100, 116, 139, 0.1);
}
.lang-btn.active {
background: var(--accent-color);
color: white;
}
.nav-group { .nav-group {
display: flex; display: flex;
background-color: var(--bg-card); background-color: var(--bg-card);
@@ -924,6 +956,7 @@ body {
color: var(--text-secondary); color: var(--text-secondary);
line-height: 1.6; line-height: 1.6;
margin-bottom: 0.75rem; margin-bottom: 0.75rem;
white-space: pre-wrap;
} }
.badges { .badges {
@@ -1984,6 +2017,7 @@ body {
let orderMap = new Map(); let orderMap = new Map();
let userFlags = new Set(JSON.parse(localStorage.getItem('kantine_flags') || '[]')); let userFlags = new Set(JSON.parse(localStorage.getItem('kantine_flags') || '[]'));
let pollIntervalId = null; let pollIntervalId = null;
let langMode = localStorage.getItem('kantine_lang') || 'de';
// === API Helpers === // === API Helpers ===
function apiHeaders(token) { function apiHeaders(token) {
@@ -2031,18 +2065,23 @@ body {
<div class="brand"> <div class="brand">
<span class="material-icons-round logo-icon">restaurant_menu</span> <span class="material-icons-round logo-icon">restaurant_menu</span>
<div class="header-left"> <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.5.0</small></h1> <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.6.0</small></h1>
<div id="last-updated-subtitle" class="subtitle"></div> <div id="last-updated-subtitle" class="subtitle"></div>
</div> </div>
<div class="nav-group" style="margin-left: 1rem;"> <div class="nav-group" style="margin-left: 1rem;">
<button id="btn-this-week" class="nav-btn active">Diese Woche</button> <button id="btn-this-week" class="nav-btn active" title="Menü dieser Woche anzeigen">Diese Woche</button>
<button id="btn-next-week" class="nav-btn">Nächste Woche</button> <button id="btn-next-week" class="nav-btn" title="Menü nächster Woche anzeigen">Nächste Woche</button>
</div> </div>
<button id="alarm-bell" class="icon-btn hidden" aria-label="Benachrichtigungen" title="Keine beobachteten Menüs" style="margin-left: -0.5rem;"> <button id="alarm-bell" class="icon-btn hidden" aria-label="Benachrichtigungen" title="Keine beobachteten Menüs" style="margin-left: -0.5rem;">
<span class="material-icons-round" id="alarm-bell-icon" style="color:var(--text-secondary); transition: color 0.3s;">notifications</span> <span class="material-icons-round" id="alarm-bell-icon" style="color:var(--text-secondary); transition: color 0.3s;">notifications</span>
</button> </button>
</div> </div>
<div class="header-center-wrapper"> <div class="header-center-wrapper">
<div id="lang-toggle" class="lang-toggle" title="Sprache der Menübeschreibung">
<button class="lang-btn${langMode === 'de' ? ' active' : ''}" data-lang="de">DE</button>
<button class="lang-btn${langMode === 'en' ? ' active' : ''}" data-lang="en">EN</button>
<button class="lang-btn${langMode === 'all' ? ' active' : ''}" data-lang="all">ALL</button>
</div>
<div id="header-week-info" class="header-week-info"></div> <div id="header-week-info" class="header-week-info"></div>
<div id="weekly-cost-display" class="weekly-cost hidden"></div> <div id="weekly-cost-display" class="weekly-cost hidden"></div>
</div> </div>
@@ -2056,17 +2095,17 @@ body {
<button id="btn-highlights" class="icon-btn" aria-label="Persönliche Highlights verwalten" title="Persönliche Highlights verwalten"> <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> <span class="material-icons-round">label</span>
</button> </button>
<button id="theme-toggle" class="icon-btn" aria-label="Toggle Theme"> <button id="theme-toggle" class="icon-btn" aria-label="Toggle Theme" title="Erscheinungsbild (Hell/Dunkel) wechseln">
<span class="material-icons-round theme-icon">light_mode</span> <span class="material-icons-round theme-icon">light_mode</span>
</button> </button>
<button id="btn-login-open" class="user-badge-btn icon-btn-small"> <button id="btn-login-open" class="user-badge-btn icon-btn-small" title="Mit Bessa.app Account anmelden">
<span class="material-icons-round">login</span> <span class="material-icons-round">login</span>
<span>Anmelden</span> <span>Anmelden</span>
</button> </button>
<div id="user-info" class="user-badge hidden"> <div id="user-info" class="user-badge hidden">
<span class="material-icons-round">person</span> <span class="material-icons-round">person</span>
<span id="user-id-display"></span> <span id="user-id-display"></span>
<button id="btn-logout" class="icon-btn-small" aria-label="Logout"> <button id="btn-logout" class="icon-btn-small" aria-label="Logout" title="Von Bessa.app abmelden">
<span class="material-icons-round">logout</span> <span class="material-icons-round">logout</span>
</button> </button>
</div> </div>
@@ -2078,7 +2117,7 @@ body {
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
<h2>Login</h2> <h2>Login</h2>
<button id="btn-login-close" class="icon-btn" aria-label="Close"> <button id="btn-login-close" class="icon-btn" aria-label="Close" title="Schließen">
<span class="material-icons-round">close</span> <span class="material-icons-round">close</span>
</button> </button>
</div> </div>
@@ -2122,7 +2161,7 @@ body {
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
<h2>Meine Highlights</h2> <h2>Meine Highlights</h2>
<button id="btn-highlights-close" class="icon-btn" aria-label="Close"> <button id="btn-highlights-close" class="icon-btn" aria-label="Close" title="Schließen">
<span class="material-icons-round">close</span> <span class="material-icons-round">close</span>
</button> </button>
</div> </div>
@@ -2131,8 +2170,8 @@ body {
Markiere Menüs automatisch, wenn sie diese Schlagwörter enthalten. Markiere Menüs automatisch, wenn sie diese Schlagwörter enthalten.
</p> </p>
<div class="input-group"> <div class="input-group">
<input type="text" id="tag-input" placeholder="z.B. Schnitzel, Vegetarisch..."> <input type="text" id="tag-input" placeholder="z.B. Schnitzel, Vegetarisch..." title="Neues Schlagwort zum Hervorheben eingeben">
<button id="btn-add-tag" class="btn-primary">Hinzufügen</button> <button id="btn-add-tag" class="btn-primary" title="Schlagwort zur Liste hinzufügen">Hinzufügen</button>
</div> </div>
<div id="tags-list"></div> <div id="tags-list"></div>
</div> </div>
@@ -2143,7 +2182,7 @@ body {
<div class="modal-content history-modal-content"> <div class="modal-content history-modal-content">
<div class="modal-header"> <div class="modal-header">
<h2>Bestellhistorie</h2> <h2>Bestellhistorie</h2>
<button id="btn-history-close" class="icon-btn" aria-label="Close"> <button id="btn-history-close" class="icon-btn" aria-label="Close" title="Schließen">
<span class="material-icons-round">close</span> <span class="material-icons-round">close</span>
</button> </button>
</div> </div>
@@ -2167,13 +2206,13 @@ body {
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
<h2>📦 Versionen</h2> <h2>📦 Versionen</h2>
<button id="btn-version-close" class="icon-btn" aria-label="Close"> <button id="btn-version-close" class="icon-btn" aria-label="Close" title="Schließen">
<span class="material-icons-round">close</span> <span class="material-icons-round">close</span>
</button> </button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<div style="margin-bottom: 1rem;"> <div style="margin-bottom: 1rem;">
<strong>Aktuell:</strong> <span id="version-current">v1.5.0</span> <strong>Aktuell:</strong> <span id="version-current">v1.6.0</span>
</div> </div>
<div class="dev-toggle"> <div class="dev-toggle">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer;"> <label style="display:flex;align-items:center;gap:8px;cursor:pointer;">
@@ -2241,6 +2280,17 @@ body {
const historyModal = document.getElementById('history-modal'); const historyModal = document.getElementById('history-modal');
const btnHistoryClose = document.getElementById('btn-history-close'); const btnHistoryClose = document.getElementById('btn-history-close');
// Language Toggle
document.querySelectorAll('.lang-btn').forEach(btn => {
btn.addEventListener('click', () => {
langMode = btn.dataset.lang;
localStorage.setItem('kantine_lang', langMode);
document.querySelectorAll('.lang-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
renderVisibleWeeks();
});
});
if (btnHighlights) { if (btnHighlights) {
btnHighlights.addEventListener('click', () => { btnHighlights.addEventListener('click', () => {
highlightsModal.classList.remove('hidden'); highlightsModal.classList.remove('hidden');
@@ -2750,7 +2800,7 @@ body {
const monthGroup = yearGroup.months[mKey]; const monthGroup = yearGroup.months[mKey];
html += `<div class="history-month-group"> html += `<div class="history-month-group">
<div class="history-month-header" tabindex="0" role="button" aria-expanded="false"> <div class="history-month-header" tabindex="0" role="button" aria-expanded="false" title="Klicken, um die Bestellungen für diesen Monat ein-/auszublenden">
<div style="display:flex; flex-direction:column; gap:4px;"> <div style="display:flex; flex-direction:column; gap:4px;">
<span>${monthGroup.name}</span> <span>${monthGroup.name}</span>
<div class="history-month-summary"> <div class="history-month-summary">
@@ -2861,7 +2911,7 @@ body {
venue: VENUE_ID, venue: VENUE_ID,
states: [], states: [],
order_state: 1, order_state: 1,
date: `${date}T10:00:00.000Z`, date: `${date}T10:30:00Z`,
payment_method: 'payroll', payment_method: 'payroll',
customer: { customer: {
first_name: userData.first_name, first_name: userData.first_name,
@@ -2869,7 +2919,7 @@ body {
email: userData.email, email: userData.email,
newsletter: false newsletter: false
}, },
preorder: false, preorder: true,
delivery_fee: 0, delivery_fee: 0,
cash_box_table_name: null, cash_box_table_name: null,
take_away: false take_away: false
@@ -3196,7 +3246,7 @@ body {
highlightTags.forEach(tag => { highlightTags.forEach(tag => {
const badge = document.createElement('span'); const badge = document.createElement('span');
badge.className = 'tag-badge'; badge.className = 'tag-badge';
badge.innerHTML = `${tag} <span class="tag-remove" data-tag="${tag}">&times;</span>`; badge.innerHTML = `${tag} <span class="tag-remove" data-tag="${tag}" title="Schlagwort entfernen">&times;</span>`;
list.appendChild(badge); list.appendChild(badge);
}); });
@@ -3938,7 +3988,7 @@ body {
<div class="badges">${statusBadge}</div> <div class="badges">${statusBadge}</div>
</div> </div>
${tagsHtml} ${tagsHtml}
<p class="item-desc">${escapeHtml(item.description)}</p>`; <p class="item-desc">${escapeHtml(getLocalizedText(item.description))}</p>`;
// Event: Order // Event: Order
const orderBtn = itemEl.querySelector('.btn-order'); const orderBtn = itemEl.querySelector('.btn-order');
@@ -4030,7 +4080,7 @@ body {
// Periodic update check (runs on init + every hour) // Periodic update check (runs on init + every hour)
async function checkForUpdates() { async function checkForUpdates() {
const currentVersion = 'v1.5.0'; const currentVersion = 'v1.6.0';
const devMode = localStorage.getItem('kantine_dev_mode') === 'true'; const devMode = localStorage.getItem('kantine_dev_mode') === 'true';
try { try {
@@ -4071,7 +4121,7 @@ body {
const modal = document.getElementById('version-modal'); const modal = document.getElementById('version-modal');
const container = document.getElementById('version-list-container'); const container = document.getElementById('version-list-container');
const devToggle = document.getElementById('dev-mode-toggle'); const devToggle = document.getElementById('dev-mode-toggle');
const currentVersion = 'v1.5.0'; const currentVersion = 'v1.6.0';
if (!modal) return; if (!modal) return;
modal.classList.remove('hidden'); modal.classList.remove('hidden');
@@ -4291,6 +4341,201 @@ body {
return div.innerHTML; return div.innerHTML;
} }
// === Language Filter (FR-100) ===
// DE stems for fallback language detection
const DE_STEMS = [
'mit', 'und', 'oder', 'für', 'vom', 'zum', 'zur', 'gebraten', 'kartoffel', 'gemüse', 'suppe',
'kuchen', 'schwein', 'rind', 'hähnchen', 'huhn', 'fisch', 'nudel', 'soße', 'sosse', 'wurst',
'kürbis', 'braten', 'sahne', 'apfel', 'käse', 'fleisch', 'pilz', 'kirsch', 'joghurt', 'spätzle',
'knödel', 'kraut', 'schnitzel', 'püree', 'rahm', 'erdbeer', 'schoko', 'vanille', 'tomate',
'gurke', 'salat', 'zwiebel', 'paprika', 'reis', 'bohne', 'erbse', 'karotte', 'möhre', 'lauch',
'knoblauch', 'chili', 'gewürz', 'kräuter', 'pfeffer', 'salz', 'butter', 'milch', 'eier',
'pfanne', 'auflauf', 'gratin', 'ragout', 'gulasch', 'eintopf', 'filet', 'steak', 'brust',
'salami', 'schinken', 'speck', 'brokkoli', 'blumenkohl', 'zucchini', 'aubergine',
'spinat', 'spargel', 'olive', 'mandel', 'nuss', 'honig', 'senf', 'essig', 'öl', 'brot',
'brötchen', 'pfannkuchen', 'eis', 'torte', 'dessert', 'kompott', 'obst', 'frucht', 'beere',
'plunder', 'dip', 'tofu', 'jambalaya'
];
const EN_STEMS = [
'with', 'and', 'or', 'for', 'from', 'to', 'fried', 'potato', 'vegetable', 'soup', 'cake',
'pork', 'beef', 'chicken', 'fish', 'noodle', 'sauce', 'sausage', 'pumpkin', 'roast',
'cream', 'apple', 'cheese', 'meat', 'mushroom', 'cherry', 'yogurt', 'wedge', 'sweet',
'sour', 'dumpling', 'cabbage', 'mash', 'strawberr', 'choco', 'vanilla', 'tomat', 'cucumber',
'salad', 'onion', 'pepper', 'rice', 'bean', 'pea', 'carrot', 'leek', 'garlic', 'chili',
'spice', 'herb', 'salt', 'butter', 'milk', 'egg', 'pan', 'casserole', 'gratin', 'ragout',
'goulash', 'stew', 'filet', 'steak', 'breast', 'salami', 'ham', 'bacon', 'broccoli',
'cauliflower', 'zucchini', 'eggplant', 'spinach', 'asparagus', 'olive', 'almond', 'nut',
'honey', 'mustard', 'vinegar', 'oil', 'bread', 'bun', 'pancake', 'ice', 'tart', 'dessert',
'compote', 'fruit', 'berry', 'dip', 'danish', 'tofu', 'jambalaya'
];
/**
* Splits bilingual menu text into DE and EN parts.
* Pattern per course: [DE] / [EN](ALLERGENS)
* Max 3 courses per menu item (sanity check).
* @param {string} text - The bilingual description text
* @returns {{ de: string, en: string, raw: string }}
*/
function splitLanguage(text) {
if (!text) return { de: '', en: '', raw: '' };
const raw = text;
const formattedRaw = '• ' + text.replace(/\(([A-Z ]+)\)\s*(?=\S)/g, '($1)\n• ');
// Utility to compute DE/EN score for a subset of words
function scoreBlock(wordArray) {
let de = 0, en = 0;
wordArray.forEach(word => {
const w = word.toLowerCase().replace(/[^a-zäöüß]/g, '');
if (w) {
let bestDeMatch = 0;
let bestEnMatch = 0;
// Full match is better than partial string match
if (DE_STEMS.includes(w)) bestDeMatch = w.length;
else DE_STEMS.forEach(s => { if (w.includes(s) && s.length > bestDeMatch) bestDeMatch = s.length; });
if (EN_STEMS.includes(w)) bestEnMatch = w.length;
else EN_STEMS.forEach(s => { if (w.includes(s) && s.length > bestEnMatch) bestEnMatch = s.length; });
if (bestDeMatch > 0) de += (bestDeMatch / w.length);
if (bestEnMatch > 0) en += (bestEnMatch / w.length);
// Capitalized noun heuristic matches German text styles typically
if (/^[A-ZÄÖÜ]/.test(word)) {
de += 0.5;
}
}
});
return { de, en };
}
// Heuristic sliding window to split a fragment containing "EN DE"
// E.g., "Bratwurst with pumpkin Kirschjoghurt" => enPart: "Bratwurst with pumpkin", dePart: "Kirschjoghurt"
function heuristicSplitEnDe(fragment) {
const words = fragment.trim().split(/\s+/);
if (words.length < 2) return { enPart: fragment, nextDe: '' };
let bestK = -1;
let maxScore = -9999;
for (let k = 1; k < words.length; k++) {
const left = words.slice(0, k);
const right = words.slice(k);
const leftScore = scoreBlock(left);
const rightScore = scoreBlock(right);
// left should be EN, right should be DE
// Metric = (EN votes in left - DE votes in left) + (DE votes in right - EN votes in right)
const score = (leftScore.en - leftScore.de) + (rightScore.de - rightScore.en);
// Extra penalty if the split puts a low-case word as the first word of the right (DE) part
// because a new German sentence usually starts with a capital noun.
const rightFirstWord = right[0];
let capitalBonus = 0;
if (/^[A-ZÄÖÜ]/.test(rightFirstWord)) {
capitalBonus = 2.0;
}
const finalScore = score + capitalBonus;
if (finalScore > maxScore) {
maxScore = finalScore;
bestK = k;
}
}
if (bestK !== -1) {
return {
enPart: words.slice(0, bestK).join(' '),
nextDe: words.slice(bestK).join(' ')
};
}
return { enPart: fragment, nextDe: '' };
}
// Check if text contains the bilingual separator ' / '
if (!text.includes(' / ')) {
// Fallback: detect language via keyword scoring
const words = text.toLowerCase().split(/\s+/);
const score = scoreBlock(words);
// No split possible return full text for detected language, empty for other
if (score.en > score.de) {
return { de: '', en: formattedRaw, raw: formattedRaw };
}
return { de: formattedRaw, en: '', raw: formattedRaw };
}
// Split by ' / ' produces alternating DE/EN fragments
const parts = text.split(' / ');
// Sanity check: max 3 courses means max 3 slashes → max 4 parts
if (parts.length > 4) {
// Too many slashes possibly not bilingual, return as-is
return { de: formattedRaw, en: '', raw: formattedRaw };
}
const deParts = [];
const enParts = [];
// First fragment is always DE (course 1)
deParts.push(parts[0].trim());
// Process remaining fragments: each contains "EN(ALLERGENS) next_DE"
// Allergen pattern: (LETTERS_AND_SPACES) at the boundary
const allergenRegex = /\(([A-Z ]+)\)\s*/;
for (let i = 1; i < parts.length; i++) {
const fragment = parts[i].trim();
const match = fragment.match(allergenRegex);
if (match) {
// Split: everything before allergen + allergen = EN, after = next DE
const allergenEnd = match.index + match[0].length;
const enPart = fragment.substring(0, match.index).trim();
const allergenCode = match[1];
const nextDe = fragment.substring(allergenEnd).trim();
enParts.push(enPart + '(' + allergenCode + ')');
// Also append allergen to the last DE part
if (deParts.length > 0) {
deParts[deParts.length - 1] = deParts[deParts.length - 1] + '(' + allergenCode + ')';
}
if (nextDe) {
deParts.push(nextDe);
}
} else {
// No allergen code found!
// If it's not the last part (or even if it is, but we highly suspect merged languages),
// we use the heuristic to find the hidden split-point.
const split = heuristicSplitEnDe(fragment);
enParts.push(split.enPart);
if (split.nextDe) {
deParts.push(split.nextDe);
}
}
}
return {
de: deParts.map(p => '• ' + p).join('\n'),
en: enParts.map(p => '• ' + p).join('\n'),
raw: formattedRaw
};
}
/**
* Returns text filtered by the current language mode.
* @param {string} text - The bilingual text
* @returns {string}
*/
function getLocalizedText(text) {
if (langMode === 'all') return text || '';
const split = splitLanguage(text);
if (langMode === 'en') return split.en || split.raw;
return split.de || split.raw; // 'de' is default
}
// === Bootstrap === // === Bootstrap ===
injectUI(); injectUI();
bindEvents(); bindEvents();

View File

@@ -34,6 +34,7 @@
let orderMap = new Map(); let orderMap = new Map();
let userFlags = new Set(JSON.parse(localStorage.getItem('kantine_flags') || '[]')); let userFlags = new Set(JSON.parse(localStorage.getItem('kantine_flags') || '[]'));
let pollIntervalId = null; let pollIntervalId = null;
let langMode = localStorage.getItem('kantine_lang') || 'de';
// === API Helpers === // === API Helpers ===
function apiHeaders(token) { function apiHeaders(token) {
@@ -85,14 +86,19 @@
<div id="last-updated-subtitle" class="subtitle"></div> <div id="last-updated-subtitle" class="subtitle"></div>
</div> </div>
<div class="nav-group" style="margin-left: 1rem;"> <div class="nav-group" style="margin-left: 1rem;">
<button id="btn-this-week" class="nav-btn active">Diese Woche</button> <button id="btn-this-week" class="nav-btn active" title="Menü dieser Woche anzeigen">Diese Woche</button>
<button id="btn-next-week" class="nav-btn">Nächste Woche</button> <button id="btn-next-week" class="nav-btn" title="Menü nächster Woche anzeigen">Nächste Woche</button>
</div> </div>
<button id="alarm-bell" class="icon-btn hidden" aria-label="Benachrichtigungen" title="Keine beobachteten Menüs" style="margin-left: -0.5rem;"> <button id="alarm-bell" class="icon-btn hidden" aria-label="Benachrichtigungen" title="Keine beobachteten Menüs" style="margin-left: -0.5rem;">
<span class="material-icons-round" id="alarm-bell-icon" style="color:var(--text-secondary); transition: color 0.3s;">notifications</span> <span class="material-icons-round" id="alarm-bell-icon" style="color:var(--text-secondary); transition: color 0.3s;">notifications</span>
</button> </button>
</div> </div>
<div class="header-center-wrapper"> <div class="header-center-wrapper">
<div id="lang-toggle" class="lang-toggle" title="Sprache der Menübeschreibung">
<button class="lang-btn${langMode === 'de' ? ' active' : ''}" data-lang="de">DE</button>
<button class="lang-btn${langMode === 'en' ? ' active' : ''}" data-lang="en">EN</button>
<button class="lang-btn${langMode === 'all' ? ' active' : ''}" data-lang="all">ALL</button>
</div>
<div id="header-week-info" class="header-week-info"></div> <div id="header-week-info" class="header-week-info"></div>
<div id="weekly-cost-display" class="weekly-cost hidden"></div> <div id="weekly-cost-display" class="weekly-cost hidden"></div>
</div> </div>
@@ -106,17 +112,17 @@
<button id="btn-highlights" class="icon-btn" aria-label="Persönliche Highlights verwalten" title="Persönliche Highlights verwalten"> <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> <span class="material-icons-round">label</span>
</button> </button>
<button id="theme-toggle" class="icon-btn" aria-label="Toggle Theme"> <button id="theme-toggle" class="icon-btn" aria-label="Toggle Theme" title="Erscheinungsbild (Hell/Dunkel) wechseln">
<span class="material-icons-round theme-icon">light_mode</span> <span class="material-icons-round theme-icon">light_mode</span>
</button> </button>
<button id="btn-login-open" class="user-badge-btn icon-btn-small"> <button id="btn-login-open" class="user-badge-btn icon-btn-small" title="Mit Bessa.app Account anmelden">
<span class="material-icons-round">login</span> <span class="material-icons-round">login</span>
<span>Anmelden</span> <span>Anmelden</span>
</button> </button>
<div id="user-info" class="user-badge hidden"> <div id="user-info" class="user-badge hidden">
<span class="material-icons-round">person</span> <span class="material-icons-round">person</span>
<span id="user-id-display"></span> <span id="user-id-display"></span>
<button id="btn-logout" class="icon-btn-small" aria-label="Logout"> <button id="btn-logout" class="icon-btn-small" aria-label="Logout" title="Von Bessa.app abmelden">
<span class="material-icons-round">logout</span> <span class="material-icons-round">logout</span>
</button> </button>
</div> </div>
@@ -128,7 +134,7 @@
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
<h2>Login</h2> <h2>Login</h2>
<button id="btn-login-close" class="icon-btn" aria-label="Close"> <button id="btn-login-close" class="icon-btn" aria-label="Close" title="Schließen">
<span class="material-icons-round">close</span> <span class="material-icons-round">close</span>
</button> </button>
</div> </div>
@@ -172,7 +178,7 @@
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
<h2>Meine Highlights</h2> <h2>Meine Highlights</h2>
<button id="btn-highlights-close" class="icon-btn" aria-label="Close"> <button id="btn-highlights-close" class="icon-btn" aria-label="Close" title="Schließen">
<span class="material-icons-round">close</span> <span class="material-icons-round">close</span>
</button> </button>
</div> </div>
@@ -181,8 +187,8 @@
Markiere Menüs automatisch, wenn sie diese Schlagwörter enthalten. Markiere Menüs automatisch, wenn sie diese Schlagwörter enthalten.
</p> </p>
<div class="input-group"> <div class="input-group">
<input type="text" id="tag-input" placeholder="z.B. Schnitzel, Vegetarisch..."> <input type="text" id="tag-input" placeholder="z.B. Schnitzel, Vegetarisch..." title="Neues Schlagwort zum Hervorheben eingeben">
<button id="btn-add-tag" class="btn-primary">Hinzufügen</button> <button id="btn-add-tag" class="btn-primary" title="Schlagwort zur Liste hinzufügen">Hinzufügen</button>
</div> </div>
<div id="tags-list"></div> <div id="tags-list"></div>
</div> </div>
@@ -193,7 +199,7 @@
<div class="modal-content history-modal-content"> <div class="modal-content history-modal-content">
<div class="modal-header"> <div class="modal-header">
<h2>Bestellhistorie</h2> <h2>Bestellhistorie</h2>
<button id="btn-history-close" class="icon-btn" aria-label="Close"> <button id="btn-history-close" class="icon-btn" aria-label="Close" title="Schließen">
<span class="material-icons-round">close</span> <span class="material-icons-round">close</span>
</button> </button>
</div> </div>
@@ -217,7 +223,7 @@
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
<h2>📦 Versionen</h2> <h2>📦 Versionen</h2>
<button id="btn-version-close" class="icon-btn" aria-label="Close"> <button id="btn-version-close" class="icon-btn" aria-label="Close" title="Schließen">
<span class="material-icons-round">close</span> <span class="material-icons-round">close</span>
</button> </button>
</div> </div>
@@ -291,6 +297,17 @@
const historyModal = document.getElementById('history-modal'); const historyModal = document.getElementById('history-modal');
const btnHistoryClose = document.getElementById('btn-history-close'); const btnHistoryClose = document.getElementById('btn-history-close');
// Language Toggle
document.querySelectorAll('.lang-btn').forEach(btn => {
btn.addEventListener('click', () => {
langMode = btn.dataset.lang;
localStorage.setItem('kantine_lang', langMode);
document.querySelectorAll('.lang-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
renderVisibleWeeks();
});
});
if (btnHighlights) { if (btnHighlights) {
btnHighlights.addEventListener('click', () => { btnHighlights.addEventListener('click', () => {
highlightsModal.classList.remove('hidden'); highlightsModal.classList.remove('hidden');
@@ -800,7 +817,7 @@
const monthGroup = yearGroup.months[mKey]; const monthGroup = yearGroup.months[mKey];
html += `<div class="history-month-group"> html += `<div class="history-month-group">
<div class="history-month-header" tabindex="0" role="button" aria-expanded="false"> <div class="history-month-header" tabindex="0" role="button" aria-expanded="false" title="Klicken, um die Bestellungen für diesen Monat ein-/auszublenden">
<div style="display:flex; flex-direction:column; gap:4px;"> <div style="display:flex; flex-direction:column; gap:4px;">
<span>${monthGroup.name}</span> <span>${monthGroup.name}</span>
<div class="history-month-summary"> <div class="history-month-summary">
@@ -911,7 +928,7 @@
venue: VENUE_ID, venue: VENUE_ID,
states: [], states: [],
order_state: 1, order_state: 1,
date: `${date}T10:00:00.000Z`, date: `${date}T10:30:00Z`,
payment_method: 'payroll', payment_method: 'payroll',
customer: { customer: {
first_name: userData.first_name, first_name: userData.first_name,
@@ -919,7 +936,7 @@
email: userData.email, email: userData.email,
newsletter: false newsletter: false
}, },
preorder: false, preorder: true,
delivery_fee: 0, delivery_fee: 0,
cash_box_table_name: null, cash_box_table_name: null,
take_away: false take_away: false
@@ -1246,7 +1263,7 @@
highlightTags.forEach(tag => { highlightTags.forEach(tag => {
const badge = document.createElement('span'); const badge = document.createElement('span');
badge.className = 'tag-badge'; badge.className = 'tag-badge';
badge.innerHTML = `${tag} <span class="tag-remove" data-tag="${tag}">&times;</span>`; badge.innerHTML = `${tag} <span class="tag-remove" data-tag="${tag}" title="Schlagwort entfernen">&times;</span>`;
list.appendChild(badge); list.appendChild(badge);
}); });
@@ -1988,7 +2005,7 @@
<div class="badges">${statusBadge}</div> <div class="badges">${statusBadge}</div>
</div> </div>
${tagsHtml} ${tagsHtml}
<p class="item-desc">${escapeHtml(item.description)}</p>`; <p class="item-desc">${escapeHtml(getLocalizedText(item.description))}</p>`;
// Event: Order // Event: Order
const orderBtn = itemEl.querySelector('.btn-order'); const orderBtn = itemEl.querySelector('.btn-order');
@@ -2341,6 +2358,201 @@
return div.innerHTML; return div.innerHTML;
} }
// === Language Filter (FR-100) ===
// DE stems for fallback language detection
const DE_STEMS = [
'mit', 'und', 'oder', 'für', 'vom', 'zum', 'zur', 'gebraten', 'kartoffel', 'gemüse', 'suppe',
'kuchen', 'schwein', 'rind', 'hähnchen', 'huhn', 'fisch', 'nudel', 'soße', 'sosse', 'wurst',
'kürbis', 'braten', 'sahne', 'apfel', 'käse', 'fleisch', 'pilz', 'kirsch', 'joghurt', 'spätzle',
'knödel', 'kraut', 'schnitzel', 'püree', 'rahm', 'erdbeer', 'schoko', 'vanille', 'tomate',
'gurke', 'salat', 'zwiebel', 'paprika', 'reis', 'bohne', 'erbse', 'karotte', 'möhre', 'lauch',
'knoblauch', 'chili', 'gewürz', 'kräuter', 'pfeffer', 'salz', 'butter', 'milch', 'eier',
'pfanne', 'auflauf', 'gratin', 'ragout', 'gulasch', 'eintopf', 'filet', 'steak', 'brust',
'salami', 'schinken', 'speck', 'brokkoli', 'blumenkohl', 'zucchini', 'aubergine',
'spinat', 'spargel', 'olive', 'mandel', 'nuss', 'honig', 'senf', 'essig', 'öl', 'brot',
'brötchen', 'pfannkuchen', 'eis', 'torte', 'dessert', 'kompott', 'obst', 'frucht', 'beere',
'plunder', 'dip', 'tofu', 'jambalaya'
];
const EN_STEMS = [
'with', 'and', 'or', 'for', 'from', 'to', 'fried', 'potato', 'vegetable', 'soup', 'cake',
'pork', 'beef', 'chicken', 'fish', 'noodle', 'sauce', 'sausage', 'pumpkin', 'roast',
'cream', 'apple', 'cheese', 'meat', 'mushroom', 'cherry', 'yogurt', 'wedge', 'sweet',
'sour', 'dumpling', 'cabbage', 'mash', 'strawberr', 'choco', 'vanilla', 'tomat', 'cucumber',
'salad', 'onion', 'pepper', 'rice', 'bean', 'pea', 'carrot', 'leek', 'garlic', 'chili',
'spice', 'herb', 'salt', 'butter', 'milk', 'egg', 'pan', 'casserole', 'gratin', 'ragout',
'goulash', 'stew', 'filet', 'steak', 'breast', 'salami', 'ham', 'bacon', 'broccoli',
'cauliflower', 'zucchini', 'eggplant', 'spinach', 'asparagus', 'olive', 'almond', 'nut',
'honey', 'mustard', 'vinegar', 'oil', 'bread', 'bun', 'pancake', 'ice', 'tart', 'dessert',
'compote', 'fruit', 'berry', 'dip', 'danish', 'tofu', 'jambalaya'
];
/**
* Splits bilingual menu text into DE and EN parts.
* Pattern per course: [DE] / [EN](ALLERGENS)
* Max 3 courses per menu item (sanity check).
* @param {string} text - The bilingual description text
* @returns {{ de: string, en: string, raw: string }}
*/
function splitLanguage(text) {
if (!text) return { de: '', en: '', raw: '' };
const raw = text;
const formattedRaw = '• ' + text.replace(/\(([A-Z ]+)\)\s*(?=\S)/g, '($1)\n• ');
// Utility to compute DE/EN score for a subset of words
function scoreBlock(wordArray) {
let de = 0, en = 0;
wordArray.forEach(word => {
const w = word.toLowerCase().replace(/[^a-zäöüß]/g, '');
if (w) {
let bestDeMatch = 0;
let bestEnMatch = 0;
// Full match is better than partial string match
if (DE_STEMS.includes(w)) bestDeMatch = w.length;
else DE_STEMS.forEach(s => { if (w.includes(s) && s.length > bestDeMatch) bestDeMatch = s.length; });
if (EN_STEMS.includes(w)) bestEnMatch = w.length;
else EN_STEMS.forEach(s => { if (w.includes(s) && s.length > bestEnMatch) bestEnMatch = s.length; });
if (bestDeMatch > 0) de += (bestDeMatch / w.length);
if (bestEnMatch > 0) en += (bestEnMatch / w.length);
// Capitalized noun heuristic matches German text styles typically
if (/^[A-ZÄÖÜ]/.test(word)) {
de += 0.5;
}
}
});
return { de, en };
}
// Heuristic sliding window to split a fragment containing "EN DE"
// E.g., "Bratwurst with pumpkin Kirschjoghurt" => enPart: "Bratwurst with pumpkin", dePart: "Kirschjoghurt"
function heuristicSplitEnDe(fragment) {
const words = fragment.trim().split(/\s+/);
if (words.length < 2) return { enPart: fragment, nextDe: '' };
let bestK = -1;
let maxScore = -9999;
for (let k = 1; k < words.length; k++) {
const left = words.slice(0, k);
const right = words.slice(k);
const leftScore = scoreBlock(left);
const rightScore = scoreBlock(right);
// left should be EN, right should be DE
// Metric = (EN votes in left - DE votes in left) + (DE votes in right - EN votes in right)
const score = (leftScore.en - leftScore.de) + (rightScore.de - rightScore.en);
// Extra penalty if the split puts a low-case word as the first word of the right (DE) part
// because a new German sentence usually starts with a capital noun.
const rightFirstWord = right[0];
let capitalBonus = 0;
if (/^[A-ZÄÖÜ]/.test(rightFirstWord)) {
capitalBonus = 2.0;
}
const finalScore = score + capitalBonus;
if (finalScore > maxScore) {
maxScore = finalScore;
bestK = k;
}
}
if (bestK !== -1) {
return {
enPart: words.slice(0, bestK).join(' '),
nextDe: words.slice(bestK).join(' ')
};
}
return { enPart: fragment, nextDe: '' };
}
// Check if text contains the bilingual separator ' / '
if (!text.includes(' / ')) {
// Fallback: detect language via keyword scoring
const words = text.toLowerCase().split(/\s+/);
const score = scoreBlock(words);
// No split possible return full text for detected language, empty for other
if (score.en > score.de) {
return { de: '', en: formattedRaw, raw: formattedRaw };
}
return { de: formattedRaw, en: '', raw: formattedRaw };
}
// Split by ' / ' produces alternating DE/EN fragments
const parts = text.split(' / ');
// Sanity check: max 3 courses means max 3 slashes → max 4 parts
if (parts.length > 4) {
// Too many slashes possibly not bilingual, return as-is
return { de: formattedRaw, en: '', raw: formattedRaw };
}
const deParts = [];
const enParts = [];
// First fragment is always DE (course 1)
deParts.push(parts[0].trim());
// Process remaining fragments: each contains "EN(ALLERGENS) next_DE"
// Allergen pattern: (LETTERS_AND_SPACES) at the boundary
const allergenRegex = /\(([A-Z ]+)\)\s*/;
for (let i = 1; i < parts.length; i++) {
const fragment = parts[i].trim();
const match = fragment.match(allergenRegex);
if (match) {
// Split: everything before allergen + allergen = EN, after = next DE
const allergenEnd = match.index + match[0].length;
const enPart = fragment.substring(0, match.index).trim();
const allergenCode = match[1];
const nextDe = fragment.substring(allergenEnd).trim();
enParts.push(enPart + '(' + allergenCode + ')');
// Also append allergen to the last DE part
if (deParts.length > 0) {
deParts[deParts.length - 1] = deParts[deParts.length - 1] + '(' + allergenCode + ')';
}
if (nextDe) {
deParts.push(nextDe);
}
} else {
// No allergen code found!
// If it's not the last part (or even if it is, but we highly suspect merged languages),
// we use the heuristic to find the hidden split-point.
const split = heuristicSplitEnDe(fragment);
enParts.push(split.enPart);
if (split.nextDe) {
deParts.push(split.nextDe);
}
}
}
return {
de: deParts.map(p => '• ' + p).join('\n'),
en: enParts.map(p => '• ' + p).join('\n'),
raw: formattedRaw
};
}
/**
* Returns text filtered by the current language mode.
* @param {string} text - The bilingual text
* @returns {string}
*/
function getLocalizedText(text) {
if (langMode === 'all') return text || '';
const split = splitLanguage(text);
if (langMode === 'en') return split.en || split.raw;
return split.de || split.raw; // 'de' is default
}
// === Bootstrap === // === Bootstrap ===
injectUI(); injectUI();
bindEvents(); bindEvents();

View File

@@ -153,6 +153,38 @@ body {
color: var(--text-secondary); color: var(--text-secondary);
} }
/* Language Toggle (FR-100) */
.lang-toggle {
display: inline-flex;
gap: 0;
border-radius: 6px;
overflow: hidden;
border: 1px solid var(--border-color);
background: var(--bg-card);
}
.lang-btn {
padding: 3px 10px;
font-size: 0.7rem;
font-weight: 600;
letter-spacing: 0.03em;
background: transparent;
color: var(--text-secondary);
border: none;
cursor: pointer;
transition: all 0.2s;
}
.lang-btn:hover {
color: var(--text-primary);
background: rgba(100, 116, 139, 0.1);
}
.lang-btn.active {
background: var(--accent-color);
color: white;
}
.nav-group { .nav-group {
display: flex; display: flex;
background-color: var(--bg-card); background-color: var(--bg-card);
@@ -913,6 +945,7 @@ body {
color: var(--text-secondary); color: var(--text-secondary);
line-height: 1.6; line-height: 1.6;
margin-bottom: 0.75rem; margin-bottom: 0.75rem;
white-space: pre-wrap;
} }
.badges { .badges {

View File

@@ -62,6 +62,7 @@ const sandbox = {
} }
return createMockElement('query-result'); return createMockElement('query-result');
}, },
querySelectorAll: () => [createMockElement()],
getElementById: (id) => createMockElement(id), getElementById: (id) => createMockElement(id),
documentElement: { documentElement: {
setAttribute: () => { }, setAttribute: () => { },

View File

@@ -62,6 +62,11 @@ const html = `
<button id="btn-this-week" class="active">This Week</button> <button id="btn-this-week" class="active">This Week</button>
<button id="btn-next-week">Next Week</button> <button id="btn-next-week">Next Week</button>
<!-- Mocks for Language Toggle -->
<button class="lang-btn" data-lang="de">DE</button>
<button class="lang-btn" data-lang="en">EN</button>
<button class="lang-btn" data-lang="all">ALL</button>
<button id="btn-refresh">Refresh</button> <button id="btn-refresh">Refresh</button>
<button id="btn-logout">Logout</button> <button id="btn-logout">Logout</button>
<div class="order-history-header">Header</div> <div class="order-history-header">Header</div>

View File

@@ -1 +1 @@
v1.5.0 v1.6.0