Compare commits

...

28 Commits

Author SHA1 Message Date
Kantine Wrapper
edec109552 chore: update build artifacts for v1.6.1 2026-03-04 14:46:17 +01:00
Kantine Wrapper
a7aea2ece3 chore: version bump 2026-03-04 14:46:11 +01:00
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
Kantine Wrapper
4cf3e4adc2 chore: update build artifacts for v1.5.0 2026-02-26 17:37:34 +01:00
Kantine Wrapper
4fe7950697 style: update favicon_base.png 2026-02-26 17:37:34 +01:00
Kantine Wrapper
e162a16550 build: autogenerate favicon.png and update installer assets for v1.5.0 2026-02-26 17:36:58 +01:00
Kantine Wrapper
b7c3aac921 chore: update build artifacts for v1.5.0 2026-02-26 17:13:51 +01:00
Kantine Wrapper
a902732d4b style: replace logo unicode char with dynamic favicon image in installer 2026-02-26 17:13:51 +01:00
Kantine Wrapper
eaab21151a chore: update build artifacts for v1.5.0 2026-02-26 17:10:33 +01:00
Kantine Wrapper
5af1f86700 feat: Update favicon and add theming support to the bookmarklet. 2026-02-26 17:10:27 +01:00
Kantine Wrapper
90b503ddb7 chore: update build artifacts for v1.5.0 2026-02-26 12:43:13 +01:00
Kantine Wrapper
10ffbd8c68 build: automatically generate favicon.png from favicon_base.png if present 2026-02-26 12:43:03 +01:00
Kantine Wrapper
0294db7976 chore: update build artifacts for v1.5.0 2026-02-26 12:39:20 +01:00
Kantine Wrapper
5a2c23524d chore: update build artifacts for v1.5.0 2026-02-26 12:35:04 +01:00
Kantine Wrapper
d4a9d39d67 feat: implement history modal with comprehensive styling, general UI improvements, and favicon update. 2026-02-26 12:34:50 +01:00
Kantine Wrapper
3c8d946a1e style: update favicon to new design for v1.5.0 release 2026-02-26 11:20:49 +01:00
15 changed files with 614 additions and 50 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-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) |
| **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** | | | |
| 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 |

View File

@@ -24,6 +24,24 @@ echo "=== Kantine Bookmarklet Builder ($VERSION) ==="
# Check files exist
if [ ! -f "$CSS_FILE" ]; then echo "ERROR: $CSS_FILE not found"; exit 1; fi
if [ ! -f "$JS_FILE" ]; then echo "ERROR: $JS_FILE not found"; exit 1; fi
# Generate favicon.png from favicon_base.png if base exists
FAVICON_BASE="$SCRIPT_DIR/favicon_base.png"
if [ -f "$FAVICON_BASE" ]; then
echo "Generating 32x32 favicon.png from favicon_base.png..."
python3 -c "
import sys
from PIL import Image
try:
img = Image.open('$FAVICON_BASE')
img_resized = img.resize((32, 32), Image.Resampling.LANCZOS)
img_resized.save('$FAVICON_FILE')
except Exception as e:
print('Favicon generation error:', e)
sys.exit(1)
"
fi
if [ ! -f "$FAVICON_FILE" ]; then echo "ERROR: $FAVICON_FILE not found"; exit 1; fi
# Generate favicon Base64 data URI from PNG
@@ -128,8 +146,25 @@ cat > "$DIST_DIR/install.html" << INSTALLEOF
</style>
</head>
<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;">
<h1 style="margin-bottom: 5px;">🍽️ Kantine Wrapper <span style="font-size:0.5em; opacity:0.6; font-weight:400; vertical-align:middle; margin-left:10px;">$VERSION</span></h1>
<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;">
Kantine Wrapper
<span style="font-size:0.5em; opacity:0.6; font-weight:400; margin-left:5px;">$VERSION</span>
</h1>
<p style="font-size: 1.2rem; color: #a0aec0; margin-top: 0; font-style: italic;">"Mahlzeit! Jetzt bessa einfach."</p>
</div>

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)
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

39
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);
}
/* Language Toggle (FR-100) */
.lang-toggle {
display: inline-flex;
gap: 0;
border-radius: 6px;
overflow: hidden;
border: 1px solid var(--border-color);
background: var(--bg-card);
}
.lang-btn {
padding: 3px 10px;
font-size: 0.7rem;
font-weight: 600;
letter-spacing: 0.03em;
background: transparent;
color: var(--text-secondary);
border: none;
cursor: pointer;
transition: all 0.2s;
}
.lang-btn:hover {
color: var(--text-primary);
background: rgba(100, 116, 139, 0.1);
}
.lang-btn.active {
background: var(--accent-color);
color: white;
}
.nav-group {
display: flex;
background-color: var(--bg-card);
@@ -924,6 +956,7 @@ body {
color: var(--text-secondary);
line-height: 1.6;
margin-bottom: 0.75rem;
white-space: pre-wrap;
}
.badges {
@@ -1984,6 +2017,7 @@ body {
let orderMap = new Map();
let userFlags = new Set(JSON.parse(localStorage.getItem('kantine_flags') || '[]'));
let pollIntervalId = null;
let langMode = localStorage.getItem('kantine_lang') || 'de';
// === API Helpers ===
function apiHeaders(token) {
@@ -2007,7 +2041,7 @@ body {
const favicon = document.createElement('link');
favicon.rel = 'icon';
favicon.type = 'image/png';
favicon.href = 'data:image/png;base64,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';
favicon.href = 'data:image/png;base64,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';
document.head.appendChild(favicon);
// Inject Google Fonts if not already present
@@ -2031,18 +2065,23 @@ body {
<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.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.1</small></h1>
<div id="last-updated-subtitle" class="subtitle"></div>
</div>
<div class="nav-group" style="margin-left: 1rem;">
<button id="btn-this-week" class="nav-btn active">Diese Woche</button>
<button id="btn-next-week" class="nav-btn">Nächste 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" title="Menü nächster Woche anzeigen">Nächste Woche</button>
</div>
<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>
</button>
</div>
<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="weekly-cost-display" class="weekly-cost hidden"></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">
<span class="material-icons-round">label</span>
</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>
</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>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">
<button id="btn-logout" class="icon-btn-small" aria-label="Logout" title="Von Bessa.app abmelden">
<span class="material-icons-round">logout</span>
</button>
</div>
@@ -2078,7 +2117,7 @@ body {
<div class="modal-content">
<div class="modal-header">
<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>
</button>
</div>
@@ -2122,7 +2161,7 @@ body {
<div class="modal-content">
<div class="modal-header">
<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>
</button>
</div>
@@ -2131,8 +2170,8 @@ body {
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>
<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" title="Schlagwort zur Liste hinzufügen">Hinzufügen</button>
</div>
<div id="tags-list"></div>
</div>
@@ -2143,7 +2182,7 @@ body {
<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">
<button id="btn-history-close" class="icon-btn" aria-label="Close" title="Schließen">
<span class="material-icons-round">close</span>
</button>
</div>
@@ -2167,13 +2206,13 @@ body {
<div class="modal-content">
<div class="modal-header">
<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>
</button>
</div>
<div class="modal-body">
<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.1</span>
</div>
<div class="dev-toggle">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer;">
@@ -2241,6 +2280,17 @@ body {
const historyModal = document.getElementById('history-modal');
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) {
btnHighlights.addEventListener('click', () => {
highlightsModal.classList.remove('hidden');
@@ -2750,7 +2800,7 @@ body {
const monthGroup = yearGroup.months[mKey];
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;">
<span>${monthGroup.name}</span>
<div class="history-month-summary">
@@ -2861,7 +2911,7 @@ body {
venue: VENUE_ID,
states: [],
order_state: 1,
date: `${date}T10:00:00.000Z`,
date: `${date}T10:30:00Z`,
payment_method: 'payroll',
customer: {
first_name: userData.first_name,
@@ -2869,7 +2919,7 @@ body {
email: userData.email,
newsletter: false
},
preorder: false,
preorder: true,
delivery_fee: 0,
cash_box_table_name: null,
take_away: false
@@ -3196,7 +3246,7 @@ body {
highlightTags.forEach(tag => {
const badge = document.createElement('span');
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);
});
@@ -3938,7 +3988,7 @@ body {
<div class="badges">${statusBadge}</div>
</div>
${tagsHtml}
<p class="item-desc">${escapeHtml(item.description)}</p>`;
<p class="item-desc">${escapeHtml(getLocalizedText(item.description))}</p>`;
// Event: Order
const orderBtn = itemEl.querySelector('.btn-order');
@@ -4030,7 +4080,7 @@ body {
// Periodic update check (runs on init + every hour)
async function checkForUpdates() {
const currentVersion = 'v1.5.0';
const currentVersion = 'v1.6.1';
const devMode = localStorage.getItem('kantine_dev_mode') === 'true';
try {
@@ -4071,7 +4121,7 @@ body {
const modal = document.getElementById('version-modal');
const container = document.getElementById('version-list-container');
const devToggle = document.getElementById('dev-mode-toggle');
const currentVersion = 'v1.5.0';
const currentVersion = 'v1.6.1';
if (!modal) return;
modal.classList.remove('hidden');
@@ -4291,6 +4341,201 @@ body {
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 ===
injectUI();
bindEvents();

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 MiB

After

Width:  |  Height:  |  Size: 2.0 MiB

View File

@@ -34,6 +34,7 @@
let orderMap = new Map();
let userFlags = new Set(JSON.parse(localStorage.getItem('kantine_flags') || '[]'));
let pollIntervalId = null;
let langMode = localStorage.getItem('kantine_lang') || 'de';
// === API Helpers ===
function apiHeaders(token) {
@@ -85,14 +86,19 @@
<div id="last-updated-subtitle" class="subtitle"></div>
</div>
<div class="nav-group" style="margin-left: 1rem;">
<button id="btn-this-week" class="nav-btn active">Diese Woche</button>
<button id="btn-next-week" class="nav-btn">Nächste 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" title="Menü nächster Woche anzeigen">Nächste Woche</button>
</div>
<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>
</button>
</div>
<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="weekly-cost-display" class="weekly-cost hidden"></div>
</div>
@@ -106,17 +112,17 @@
<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>
<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>
</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>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">
<button id="btn-logout" class="icon-btn-small" aria-label="Logout" title="Von Bessa.app abmelden">
<span class="material-icons-round">logout</span>
</button>
</div>
@@ -128,7 +134,7 @@
<div class="modal-content">
<div class="modal-header">
<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>
</button>
</div>
@@ -172,7 +178,7 @@
<div class="modal-content">
<div class="modal-header">
<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>
</button>
</div>
@@ -181,8 +187,8 @@
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>
<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" title="Schlagwort zur Liste hinzufügen">Hinzufügen</button>
</div>
<div id="tags-list"></div>
</div>
@@ -193,7 +199,7 @@
<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">
<button id="btn-history-close" class="icon-btn" aria-label="Close" title="Schließen">
<span class="material-icons-round">close</span>
</button>
</div>
@@ -217,7 +223,7 @@
<div class="modal-content">
<div class="modal-header">
<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>
</button>
</div>
@@ -291,6 +297,17 @@
const historyModal = document.getElementById('history-modal');
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) {
btnHighlights.addEventListener('click', () => {
highlightsModal.classList.remove('hidden');
@@ -800,7 +817,7 @@
const monthGroup = yearGroup.months[mKey];
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;">
<span>${monthGroup.name}</span>
<div class="history-month-summary">
@@ -911,7 +928,7 @@
venue: VENUE_ID,
states: [],
order_state: 1,
date: `${date}T10:00:00.000Z`,
date: `${date}T10:30:00Z`,
payment_method: 'payroll',
customer: {
first_name: userData.first_name,
@@ -919,7 +936,7 @@
email: userData.email,
newsletter: false
},
preorder: false,
preorder: true,
delivery_fee: 0,
cash_box_table_name: null,
take_away: false
@@ -1246,7 +1263,7 @@
highlightTags.forEach(tag => {
const badge = document.createElement('span');
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);
});
@@ -1988,7 +2005,7 @@
<div class="badges">${statusBadge}</div>
</div>
${tagsHtml}
<p class="item-desc">${escapeHtml(item.description)}</p>`;
<p class="item-desc">${escapeHtml(getLocalizedText(item.description))}</p>`;
// Event: Order
const orderBtn = itemEl.querySelector('.btn-order');
@@ -2341,6 +2358,201 @@
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 ===
injectUI();
bindEvents();

View File

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

View File

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

View File

@@ -62,6 +62,11 @@ const html = `
<button id="btn-this-week" class="active">This 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-logout">Logout</button>
<div class="order-history-header">Header</div>

View File

@@ -1 +1 @@
v1.5.0
v1.6.1