Lade Menüdaten...
/** * 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 = 'v1.6.10'; 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 = localStorage.getItem('kantine_authToken'); let currentUser = localStorage.getItem('kantine_currentUser'); 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) { 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 custom favicon (triangle + fork & knife PNG) if (document.querySelectorAll) { document.querySelectorAll('link[rel*="icon"]').forEach(el => el.remove()); } const favicon = document.createElement('link'); favicon.rel = 'icon'; favicon.type = 'image/png'; favicon.href = '{{FAVICON_DATA_URI}}'; document.head.appendChild(favicon); // 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 = `
Lade Menüdaten...
Fehler beim Laden der Historie.
`; } else { showToast('Hintergrund-Synchronisation fehlgeschlagen', 'error'); } } finally { historyLoading.classList.add('hidden'); } } function renderHistory(orders) { const content = document.getElementById('history-content'); if (!orders || orders.length === 0) { content.innerHTML = 'Keine Bestellungen gefunden.
'; 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 += `Keine Menüdaten für KW ${targetWeek} (${targetYear}) verfügbar.
Versuchen Sie eine andere Woche oder schauen Sie später vorbei.${escapeHtml(getLocalizedText(item.description))}
`; // 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 = '{{VERSION}}'; 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 = '{{VERSION}}'; 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 = 'Lade Versionen...
'; function renderVersionsList(versions) { if (!versions || !versions.length) { container.innerHTML = 'Keine Versionen gefunden.
'; return; } container.innerHTML = 'Fehler: ${e.message}
`; } } 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() { // Only show order alarms for logged-in users if (!authToken || !currentUser) { removeCountdown(); return; } 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 = `Bestellschluss: ${diffHrs}h ${diffMins}m`; // Red Alert if < 1 hour if (diff < 3600000) { // 1 hour countdownEl.classList.add('urgent'); // Notification logic (One time) const notifiedKey = `kantine_notified_${todayStr}`; if (!localStorage.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(); } localStorage.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; } // === Language Filter (FR-100) === // DE stems for fallback language detection const DE_STEMS = [ 'apfel', 'achtung', 'aubergine', 'auflauf', 'beere', 'blumenkohl', 'bohne', 'braten', 'brokkoli', 'brot', 'brust', 'brötchen', 'butter', 'chili', 'dessert', 'dip', 'eier', 'eintopf', 'eis', 'erbse', 'erdbeer', 'essig', 'filet', 'fisch', 'fisole', 'fleckerl', 'fleisch', 'flügel', 'frucht', 'für', 'gebraten', 'gemüse', 'gewürz', 'gratin', 'grieß', 'gulasch', 'gurke', 'himbeer', 'honig', 'huhn', 'hähnchen', 'jambalaya', 'joghurt', 'karotte', 'kartoffel', 'keule', 'kirsch', 'knacker', 'knoblauch', 'knödel', 'kompott', 'kraut', 'kräuter', 'kuchen', 'käse', 'kürbis', 'lauch', 'mandel', 'milch', 'mild', 'mit', 'mohn', 'most', 'möhre', 'natur', 'nockerl', 'nudel', 'nuss', 'nuß', 'obst', 'oder', 'olive', 'paprika', 'pfanne', 'pfannkuchen', 'pfeffer', 'pikant', 'pilz', 'plunder', 'püree', 'ragout', 'rahm', 'reis', 'rind', 'sahne', 'salami', 'salat', 'salz', 'sauer', 'scharf', 'schinken', 'schnitte', 'schnitzel', 'schoko', 'schupf', 'schwein', 'sellerie', 'senf', 'sosse', 'soße', 'spargel', 'spätzle', 'speck', 'spieß', 'spinat', 'steak', 'suppe', 'süß', 'tofu', 'tomate', 'topfen', 'torte', 'trüffel', 'und', 'vanille', 'vogerl', 'vom', 'wien', 'wurst', 'zucchini', 'zum', 'zur', 'zwiebel', 'öl' ]; const EN_STEMS = [ 'almond', 'and', 'apple', 'asparagus', 'bacon', 'baked', 'ball', 'bean', 'beef', 'berry', 'bread', 'breast', 'broccoli', 'bun', 'butter', 'cabbage', 'cake', 'caper', 'carrot', 'casserole', 'cauliflower', 'celery', 'cheese', 'cherry', 'chicken', 'chili', 'choco', 'chocolate', 'cider', 'cilantro', 'coffee', 'compote', 'cream', 'cucumber', 'curd', 'danish', 'dessert', 'dip', 'dumpling', 'egg', 'eggplant', 'filet', 'fish', 'for', 'fried', 'from', 'fruit', 'garlic', 'goulash', 'gratin', 'ham', 'herb', 'honey', 'hot', 'ice', 'jambalaya', 'leek', 'leg', 'mash', 'meat', 'mexican', 'mild', 'milk', 'mint', 'mushroom', 'mustard', 'noodle', 'nut', 'oat', 'oil', 'olive', 'onion', 'or', 'oven', 'pan', 'pancake', 'pea', 'pepper', 'plain', 'plate', 'poppy', 'pork', 'potato', 'pumpkin', 'radish', 'ragout', 'raspberry', 'rice', 'roast', 'roll', 'salad', 'salami', 'salt', 'sauce', 'sausage', 'shrimp', 'skewer', 'slice', 'soup', 'sour', 'spice', 'spicy', 'spinach', 'steak', 'stew', 'strawberr', 'strawberry', 'strudel', 'sweet', 'tart', 'thyme', 'to', 'tofu', 'tomat', 'tomato', 'truffle', 'trukey', 'turkey', 'vanilla', 'vegan', 'vegetable', 'vinegar', 'wedge', 'wing', 'with', 'wok', 'yogurt', 'zucchini' ]; /** * 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; // Formatting: add • for new lines, avoiding dots before slashes let formattedRaw = text.replace(/(?:\(|(?:\/|\s|^))([A-Z,]+)\)\s*(?=\S)(?!\s*\/)/g, '($1)\n• '); if (!formattedRaw.startsWith('• ')) { formattedRaw = '• ' + formattedRaw; } // 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" 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); const rightFirstWord = right[0]; let capitalBonus = 0; // Nouns are capitalized in German if (/^[A-ZÄÖÜ]/.test(rightFirstWord)) { capitalBonus = 1.0; } const score = (leftScore.en - leftScore.de) + (rightScore.de - rightScore.en) + capitalBonus; // Mandatory check: The assumed English part must actually look reasonably like English (or at least more so than the right part) const leftLooksEnglish = (leftScore.en > leftScore.de) || (leftScore.en > 0); const rightLooksGerman = (rightScore.de + capitalBonus) > rightScore.en; if (leftLooksEnglish && rightLooksGerman && score > maxScore) { maxScore = score; bestK = k; } } if (bestK !== -1) { return { enPart: words.slice(0, bestK).join(' '), nextDe: words.slice(bestK).join(' ') }; } return { enPart: fragment, nextDe: '' }; } // Match courses: Any text followed by an allergen marker "(...)" but NOT if followed by a slash. const allergenRegex = /(.*?)(?:\(|(?:\/|\s|^))([A-Z,]+)\)\s*(?!\s*[/])/g; let match; const rawCourses = []; let lastScanIndex = 0; while ((match = allergenRegex.exec(text)) !== null) { if (match.index > lastScanIndex) { rawCourses.push(text.substring(lastScanIndex, match.index).trim()); } rawCourses.push(match[0].trim()); lastScanIndex = allergenRegex.lastIndex; } if (lastScanIndex < text.length) { rawCourses.push(text.substring(lastScanIndex).trim()); } if (rawCourses.length === 0 && text.trim() !== '') { rawCourses.push(text.trim()); } const deParts = []; const enParts = []; // 2. Process each course individually for (let course of rawCourses) { let courseMatch = course.match(/(.*?)(?:\(|(?:\/|\s|^))([A-Z,]+)\)\s*$/); let courseText = course; let allergenTxt = ""; let allergenCode = ""; if (courseMatch) { courseText = courseMatch[1].trim(); allergenCode = courseMatch[2]; allergenTxt = ` (${allergenCode})`; } // A) Split by slash if present const slashParts = courseText.split(/\s*\/\s*(?![A-Z,]+$)/); if (slashParts.length >= 2) { // Potential DE / EN pair const deCandidate = slashParts[0].trim(); let enCandidate = slashParts.slice(1).join(' / ').trim(); // Check for nested German in English part (e.g. "Pumpkin cream Achtung...") const nestedSplit = heuristicSplitEnDe(enCandidate); if (nestedSplit.nextDe) { // Transition back to German found! deParts.push(deCandidate + allergenTxt); enParts.push(nestedSplit.enPart + allergenTxt); // Push the nested German part as a new standalone course (fallback to itself) const nestedDe = nestedSplit.nextDe + allergenTxt; deParts.push(nestedDe); enParts.push(nestedDe); } else { // Happy path: standard DE / EN // Avoid double allergens if they were on both sides already const enFinal = enCandidate + allergenTxt; const deFinal = deCandidate.includes(allergenTxt.trim()) ? deCandidate : (deCandidate + allergenTxt); deParts.push(deFinal); enParts.push(enFinal); } } else { // B) No slash found: Either missing translation or "EN DE" mixed const heuristicSplit = heuristicSplitEnDe(courseText); if (heuristicSplit.nextDe) { enParts.push(heuristicSplit.enPart + allergenTxt); deParts.push(heuristicSplit.nextDe + allergenTxt); } else { // Fallback: Use same chunk for both deParts.push(courseText + allergenTxt); enParts.push(courseText + allergenTxt); } } } let deJoined = deParts.join('\n• '); if (deParts.length > 0 && !deJoined.startsWith('• ')) deJoined = '• ' + deJoined; let enJoined = enParts.join('\n• '); if (enParts.length > 0 && !enJoined.startsWith('• ')) enJoined = '• ' + enJoined; return { de: deJoined, en: enJoined, 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(); 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 = ` `; document.body.appendChild(modal); document.getElementById('btn-error-redirect').addEventListener('click', () => { window.location.href = url; }); requestAnimationFrame(() => { modal.classList.remove('hidden'); }); }