import { authToken, currentUser, orderMap, userFlags, pollIntervalId, highlightTags, allWeeks, currentWeekNumber, currentYear, displayMode, langMode, setAuthToken, setCurrentUser, setOrderMap, setUserFlags, setPollIntervalId, setHighlightTags, setAllWeeks, setCurrentWeekNumber, setCurrentYear } from './state.js'; import { getISOWeek, getWeekYear, translateDay, escapeHtml, getRelativeTime, isNewer } from './utils.js'; import { API_BASE, GUEST_TOKEN, VENUE_ID, MENU_ID, POLL_INTERVAL_MS, GITHUB_API, INSTALLER_BASE, CLIENT_VERSION } from './constants.js'; import { apiHeaders, githubHeaders } from './api.js'; import { renderVisibleWeeks, updateNextWeekBadge, updateAlarmBell } from './ui_helpers.js'; let fullOrderHistoryCache = null; export function updateAuthUI() { if (!authToken) { try { const akita = localStorage.getItem('AkitaStores'); if (akita) { const parsed = JSON.parse(akita); if (parsed.auth && parsed.auth.token) { setAuthToken(parsed.auth.token); localStorage.setItem('kantine_authToken', parsed.auth.token); if (parsed.auth.user) { setCurrentUser(parsed.auth.user.id || 'unknown'); localStorage.setItem('kantine_currentUser', parsed.auth.user.id || 'unknown'); if (parsed.auth.user.firstName) localStorage.setItem('kantine_firstName', parsed.auth.user.firstName); if (parsed.auth.user.lastName) localStorage.setItem('kantine_lastName', parsed.auth.user.lastName); } } } } catch (e) { console.warn('Failed to parse AkitaStores:', e); } } setAuthToken(localStorage.getItem('kantine_authToken')); setCurrentUser(localStorage.getItem('kantine_currentUser')); const firstName = localStorage.getItem('kantine_firstName'); const btnLoginOpen = document.getElementById('btn-login-open'); const userInfo = document.getElementById('user-info'); const userIdDisplay = document.getElementById('user-id-display'); if (authToken) { btnLoginOpen.classList.add('hidden'); userInfo.classList.remove('hidden'); userIdDisplay.textContent = firstName || (currentUser ? `User ${currentUser}` : 'Angemeldet'); fetchOrders(); } else { btnLoginOpen.classList.remove('hidden'); userInfo.classList.add('hidden'); userIdDisplay.textContent = ''; } renderVisibleWeeks(); } export async function fetchOrders() { if (!authToken) return; try { const response = await fetch(`${API_BASE}/user/orders/?venue=${VENUE_ID}&ordering=-created&limit=50`, { headers: apiHeaders(authToken) }); const data = await response.json(); if (response.ok) { const newOrderMap = new Map(); const results = data.results || []; for (const order of results) { if (order.order_state === 9) continue; const orderDate = order.date.split('T')[0]; for (const item of (order.items || [])) { const key = `${orderDate}_${item.article}`; if (!newOrderMap.has(key)) newOrderMap.set(key, []); newOrderMap.get(key).push(order.id); } } setOrderMap(newOrderMap); renderVisibleWeeks(); updateNextWeekBadge(); } } catch (error) { console.error('Error fetching orders:', error); } } export async function fetchFullOrderHistory() { const historyLoading = document.getElementById('history-loading'); const historyContent = document.getElementById('history-content'); const progressFill = document.getElementById('history-progress-fill'); const progressText = document.getElementById('history-progress-text'); let localCache = []; if (fullOrderHistoryCache) { localCache = fullOrderHistoryCache; } else { const ls = localStorage.getItem('kantine_history_cache'); if (ls) { try { localCache = JSON.parse(ls); fullOrderHistoryCache = localCache; } catch (e) { console.warn('History cache parse error', e); } } } if (localCache.length > 0) { renderHistory(localCache); } if (!authToken) return; if (localCache.length === 0) { historyContent.innerHTML = ''; historyLoading.classList.remove('hidden'); } progressFill.style.width = '0%'; progressText.textContent = localCache.length > 0 ? 'Suche nach neuen Bestellungen...' : 'Lade Bestellhistorie...'; if (localCache.length > 0) historyLoading.classList.remove('hidden'); let nextUrl = localCache.length > 0 ? `${API_BASE}/user/orders/?venue=${VENUE_ID}&ordering=-created&limit=5` : `${API_BASE}/user/orders/?venue=${VENUE_ID}&ordering=-created&limit=50`; let fetchedOrders = []; let totalCount = 0; let requiresFullFetch = localCache.length === 0; let deltaComplete = false; try { while (nextUrl && !deltaComplete) { const response = await fetch(nextUrl, { headers: apiHeaders(authToken) }); if (!response.ok) throw new Error(`Fetch failed: ${response.status}`); const data = await response.json(); if (data.count && totalCount === 0) { totalCount = data.count; } const results = data.results || []; for (const order of results) { const existingOrderIndex = localCache.findIndex(cached => cached.id === order.id); if (!requiresFullFetch && existingOrderIndex !== -1) { const existingOrder = localCache[existingOrderIndex]; if (existingOrder.updated === order.updated && existingOrder.order_state === order.order_state) { deltaComplete = true; break; } } fetchedOrders.push(order); } if (!deltaComplete && requiresFullFetch) { if (totalCount > 0) { const pct = Math.round((fetchedOrders.length / totalCount) * 100); progressFill.style.width = `${pct}%`; progressText.textContent = `Lade Bestellung ${fetchedOrders.length} von ${totalCount}...`; } else { progressText.textContent = `Lade Bestellung ${fetchedOrders.length}...`; } } else if (!deltaComplete) { progressText.textContent = `${fetchedOrders.length} neue/geänderte Bestellungen gefunden...`; } nextUrl = deltaComplete ? null : data.next; } if (fetchedOrders.length > 0) { const cacheMap = new Map(localCache.map(o => [o.id, o])); for (const order of fetchedOrders) { cacheMap.set(order.id, order); } const mergedOrders = Array.from(cacheMap.values()); mergedOrders.sort((a, b) => new Date(b.created) - new Date(a.created)); fullOrderHistoryCache = mergedOrders; try { localStorage.setItem('kantine_history_cache', JSON.stringify(mergedOrders)); } catch (e) { console.warn('History cache write error', e); } renderHistory(fullOrderHistoryCache); } } catch (error) { console.error('Error in history sync:', error); if (localCache.length === 0) { historyContent.innerHTML = `

Fehler beim Laden der Historie.

`; } else { showToast('Hintergrund-Synchronisation fehlgeschlagen', 'error'); } } finally { historyLoading.classList.add('hidden'); } } export function renderHistory(orders) { const content = document.getElementById('history-content'); if (!orders || orders.length === 0) { content.innerHTML = '

Keine Bestellungen gefunden.

'; return; } 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' }); 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 }); 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; } }); }); const sortedYears = Object.keys(groups).sort((a, b) => b - a); let html = ''; sortedYears.forEach(yKey => { const yearGroup = groups[yKey]; html += `

${yearGroup.year}

`; const sortedMonths = Object.keys(yearGroup.months).sort((a, b) => b.localeCompare(a)); sortedMonths.forEach(mKey => { const monthGroup = yearGroup.months[mKey]; html += `
`; const sortedKWs = Object.keys(monthGroup.weeks).sort((a, b) => parseInt(b) - parseInt(a)); sortedKWs.forEach(kw => { const week = monthGroup.weeks[kw]; html += `
${week.label} ${week.count} Bestellungen • €${week.total.toFixed(2)}
`; week.items.forEach(item => { const dateObj = new Date(item.date); const dayStr = dateObj.toLocaleDateString('de-AT', { weekday: 'short', day: '2-digit', month: '2-digit' }); let statusBadge = ''; if (item.state === 9) { statusBadge = 'Storniert'; } else if (item.state === 8) { statusBadge = 'Abgeschlossen'; } else { statusBadge = 'Übertragen'; } html += `
${dayStr}
${escapeHtml(item.name)}
${statusBadge}
€${item.price.toFixed(2)}
`; }); html += `
`; }); html += `
`; }); html += `
`; }); content.innerHTML = html; const monthHeaders = content.querySelectorAll('.history-month-header'); monthHeaders.forEach(header => { header.addEventListener('click', () => { const parentGroup = header.parentElement; const isOpen = parentGroup.classList.contains('open'); if (isOpen) { parentGroup.classList.remove('open'); header.setAttribute('aria-expanded', 'false'); } else { parentGroup.classList.add('open'); header.setAttribute('aria-expanded', 'true'); } }); }); } export async function placeOrder(date, articleId, name, price, description) { if (!authToken) return; try { const userResp = await fetch(`${API_BASE}/auth/user/`, { headers: apiHeaders(authToken) }); if (!userResp.ok) { showToast('Fehler: Benutzerdaten konnten nicht geladen werden', 'error'); return; } const userData = await userResp.json(); const now = new Date().toISOString(); const orderPayload = { uuid: crypto.randomUUID(), created: now, updated: now, order_type: 7, items: [{ article: articleId, course_group: null, modifiers: [], uuid: crypto.randomUUID(), name: name, description: description || '', price: String(parseFloat(price)), amount: 1, vat: '10.00', comment: '' }], table: null, total: parseFloat(price), tip: 0, currency: 'EUR', venue: VENUE_ID, states: [], order_state: 1, date: `${date}T10:30:00Z`, payment_method: 'payroll', customer: { first_name: userData.first_name, last_name: userData.last_name, email: userData.email, newsletter: false }, preorder: true, delivery_fee: 0, cash_box_table_name: null, take_away: false }; const response = await fetch(`${API_BASE}/user/orders/`, { method: 'POST', headers: apiHeaders(authToken), body: JSON.stringify(orderPayload) }); if (response.ok || response.status === 201) { showToast(`Bestellt: ${name}`, 'success'); fullOrderHistoryCache = null; await fetchOrders(); } else { const data = await response.json(); showToast(`Fehler: ${data.detail || data.non_field_errors?.[0] || 'Bestellung fehlgeschlagen'}`, 'error'); } } catch (error) { console.error('Order error:', error); showToast('Netzwerkfehler bei Bestellung', 'error'); } } export async function cancelOrder(date, articleId, name) { if (!authToken) return; const key = `${date}_${articleId}`; const orderIds = orderMap.get(key); if (!orderIds || orderIds.length === 0) return; const orderId = orderIds[orderIds.length - 1]; try { const response = await fetch(`${API_BASE}/user/orders/${orderId}/cancel/`, { method: 'PATCH', headers: apiHeaders(authToken), body: JSON.stringify({}) }); if (response.ok) { showToast(`Storniert: ${name}`, 'success'); fullOrderHistoryCache = null; await fetchOrders(); } else { const data = await response.json(); showToast(`Fehler: ${data.detail || 'Stornierung fehlgeschlagen'}`, 'error'); } } catch (error) { console.error('Cancel error:', error); showToast('Netzwerkfehler bei Stornierung', 'error'); } } export function saveFlags() { localStorage.setItem('kantine_flags', JSON.stringify([...userFlags])); } export async function refreshFlaggedItems() { if (userFlags.size === 0) return; const token = authToken || GUEST_TOKEN; const datesToFetch = new Set(); for (const flagId of userFlags) { const [dateStr] = flagId.split('_'); datesToFetch.add(dateStr); } let updated = false; for (const dateStr of datesToFetch) { try { const resp = await fetch(`${API_BASE}/venues/${VENUE_ID}/menu/${MENU_ID}/${dateStr}/`, { headers: apiHeaders(token) }); if (!resp.ok) continue; const data = await resp.json(); const menuGroups = data.results || []; let dayItems = []; for (const group of menuGroups) { if (group.items && Array.isArray(group.items)) { dayItems = dayItems.concat(group.items); } } for (let week of allWeeks) { if (!week.days) continue; let dayObj = week.days.find(d => d.date === dateStr); if (dayObj) { dayObj.items = dayItems.map(item => { const isUnlimited = item.amount_tracking === false; const hasStock = parseInt(item.available_amount) > 0; return { id: `${dateStr}_${item.id}`, articleId: item.id, name: item.name || 'Unknown', description: item.description || '', price: parseFloat(item.price) || 0, available: isUnlimited || hasStock, availableAmount: parseInt(item.available_amount) || 0, amountTracking: item.amount_tracking !== false }; }); updated = true; } } } catch (e) { console.error('Error refreshing flag date', dateStr, e); } } if (updated) { saveMenuCache(); updateLastUpdatedTime(new Date().toISOString()); localStorage.setItem('kantine_flagged_items_last_checked', new Date().toISOString()); updateAlarmBell(); renderVisibleWeeks(); } } export function toggleFlag(date, articleId, name, cutoff) { const id = `${date}_${articleId}`; let flagAdded = false; if (userFlags.has(id)) { userFlags.delete(id); showToast(`Flag entfernt für ${name}`, 'success'); } else { userFlags.add(id); flagAdded = true; showToast(`Benachrichtigung aktiviert für ${name}`, 'success'); if (Notification.permission === 'default') { Notification.requestPermission(); } } saveFlags(); updateAlarmBell(); renderVisibleWeeks(); if (flagAdded) { refreshFlaggedItems(); } } export function cleanupExpiredFlags() { const now = new Date(); const todayStr = now.toISOString().split('T')[0]; let changed = false; for (const flagId of [...userFlags]) { const [dateStr] = flagId.split('_'); let isExpired = false; if (dateStr < todayStr) { isExpired = true; } else if (dateStr === todayStr) { const cutoff = new Date(dateStr); cutoff.setHours(10, 0, 0, 0); if (now >= cutoff) { isExpired = true; } } if (isExpired) { userFlags.delete(flagId); changed = true; } } if (changed) saveFlags(); } export function startPolling() { if (pollIntervalId) return; if (!authToken) return; setPollIntervalId(setInterval(() => pollFlaggedItems(), POLL_INTERVAL_MS)); } export function stopPolling() { if (pollIntervalId) { clearInterval(pollIntervalId); setPollIntervalId(null); } } export async function pollFlaggedItems() { if (userFlags.size === 0 || !authToken) return; for (const flagId of userFlags) { const [date, articleIdStr] = flagId.split('_'); const articleId = parseInt(articleIdStr); try { const response = await fetch(`${API_BASE}/venues/${VENUE_ID}/menu/${MENU_ID}/${date}/`, { headers: apiHeaders(authToken) }); if (!response.ok) continue; const data = await response.json(); const groups = data.results || []; let foundItem = null; for (const group of groups) { if (group.items) { foundItem = group.items.find(i => i.id === articleId || i.article === articleId); if (foundItem) break; } } if (foundItem) { const isAvailable = (foundItem.amount_tracking === false) || (parseInt(foundItem.available_amount) > 0); if (isAvailable) { const itemName = foundItem.name || 'Unbekannt'; showToast(`${itemName} ist jetzt verfügbar!`, 'success'); if (Notification.permission === 'granted') { new Notification('Kantine Wrapper', { body: `${itemName} ist jetzt verfügbar!`, icon: '🍽️' }); } loadMenuDataFromAPI(); } } } catch (err) { console.error(`Poll error for ${flagId}:`, err); await new Promise(r => setTimeout(r, 200)); } } localStorage.setItem('kantine_flagged_items_last_checked', new Date().toISOString()); updateAlarmBell(); } export function saveHighlightTags() { localStorage.setItem('kantine_highlightTags', JSON.stringify(highlightTags)); renderVisibleWeeks(); updateNextWeekBadge(); } export function addHighlightTag(tag) { tag = tag.trim().toLowerCase(); if (tag && !highlightTags.includes(tag)) { const newTags = [...highlightTags, tag]; setHighlightTags(newTags); saveHighlightTags(); return true; } return false; } export function removeHighlightTag(tag) { const newTags = highlightTags.filter(t => t !== tag); setHighlightTags(newTags); saveHighlightTags(); } export function renderTagsList() { const list = document.getElementById('tags-list'); list.innerHTML = ''; highlightTags.forEach(tag => { const badge = document.createElement('span'); badge.className = 'tag-badge'; badge.innerHTML = `${tag} ×`; list.appendChild(badge); }); list.querySelectorAll('.tag-remove').forEach(btn => { btn.addEventListener('click', (e) => { removeHighlightTag(e.target.dataset.tag); renderTagsList(); }); }); } export function checkHighlight(text) { if (!text) return []; text = text.toLowerCase(); return highlightTags.filter(tag => text.includes(tag)); } const CACHE_KEY = 'kantine_menuCache'; const CACHE_TS_KEY = 'kantine_menuCacheTs'; export function saveMenuCache() { try { localStorage.setItem(CACHE_KEY, JSON.stringify(allWeeks)); localStorage.setItem(CACHE_TS_KEY, new Date().toISOString()); } catch (e) { console.warn('Failed to cache menu data:', e); } } export function loadMenuCache() { try { const cached = localStorage.getItem(CACHE_KEY); const cachedTs = localStorage.getItem(CACHE_TS_KEY); if (cached) { setAllWeeks(JSON.parse(cached)); setCurrentWeekNumber(getISOWeek(new Date())); setCurrentYear(new Date().getFullYear()); renderVisibleWeeks(); updateNextWeekBadge(); updateAlarmBell(); if (cachedTs) updateLastUpdatedTime(cachedTs); try { const uniqueMenus = new Set(); allWeeks.forEach(w => { (w.days || []).forEach(d => { (d.items || []).forEach(item => { let text = (item.description || '').replace(/\s+/g, ' ').trim(); if (text && text.includes(' / ')) { uniqueMenus.add(text); } }); }); }); } catch (e) { } return true; } } catch (e) { console.warn('Failed to load cached menu:', e); } return false; } export function isCacheFresh() { const cachedTs = localStorage.getItem(CACHE_TS_KEY); if (!cachedTs) { return false; } const ageMs = Date.now() - new Date(cachedTs).getTime(); if (ageMs > 60 * 60 * 1000) { return false; } const thisWeek = getISOWeek(new Date()); const thisYear = getWeekYear(new Date()); const hasCurrentWeek = allWeeks.some(w => w.weekNumber === thisWeek && w.year === thisYear && w.days && w.days.length > 0); return hasCurrentWeek; } export async function loadMenuDataFromAPI() { const loading = document.getElementById('loading'); const progressModal = document.getElementById('progress-modal'); const progressFill = document.getElementById('progress-fill'); const progressPercent = document.getElementById('progress-percent'); const progressMessage = document.getElementById('progress-message'); loading.classList.remove('hidden'); const token = authToken || GUEST_TOKEN; try { progressModal.classList.remove('hidden'); progressMessage.textContent = 'Hole verfügbare Daten...'; progressFill.style.width = '0%'; progressPercent.textContent = '0%'; const datesResponse = await fetch(`${API_BASE}/venues/${VENUE_ID}/menu/dates/`, { headers: apiHeaders(token) }); if (!datesResponse.ok) throw new Error(`Failed to fetch dates: ${datesResponse.status}`); const datesData = await datesResponse.json(); let availableDates = datesData.results || []; const cutoff = new Date(); cutoff.setDate(cutoff.getDate() - 7); const cutoffStr = cutoff.toISOString().split('T')[0]; availableDates = availableDates .filter(d => d.date >= cutoffStr) .sort((a, b) => a.date.localeCompare(b.date)) .slice(0, 30); const totalDates = availableDates.length; progressMessage.textContent = `${totalDates} Tage gefunden. Lade Details...`; const allDays = []; let completed = 0; const BATCH_SIZE = 5; for (let i = 0; i < totalDates; i += BATCH_SIZE) { const batch = availableDates.slice(i, i + BATCH_SIZE); const results = await Promise.all(batch.map(async (dateObj) => { const dateStr = dateObj.date; let dayData = null; try { const detailResp = await fetch(`${API_BASE}/venues/${VENUE_ID}/menu/${MENU_ID}/${dateStr}/`, { headers: apiHeaders(token) }); if (detailResp.ok) { const detailData = await detailResp.json(); const menuGroups = detailData.results || []; let dayItems = []; for (const group of menuGroups) { if (group.items && Array.isArray(group.items)) { dayItems = dayItems.concat(group.items); } } if (dayItems.length > 0) { dayData = { date: dateStr, menu_items: dayItems, orders: dateObj.orders || [] }; } } } catch (err) { console.error(`Failed to fetch details for ${dateStr}:`, err); } finally { completed++; const pct = Math.round((completed / totalDates) * 100); progressFill.style.width = `${pct}%`; progressPercent.textContent = `${pct}%`; progressMessage.textContent = `Lade Menü für ${dateStr}...`; } return dayData; })); for (const result of results) { if (result) { allDays.push(result); } } } const weeksMap = new Map(); if (allWeeks && allWeeks.length > 0) { allWeeks.forEach(w => { const key = `${w.year}-${w.weekNumber}`; try { weeksMap.set(key, { year: w.year, weekNumber: w.weekNumber, days: w.days ? w.days.map(d => ({ ...d, items: d.items ? [...d.items] : [] })) : [] }); } catch (e) { console.warn('Error hydrating week:', e); } }); } for (const day of allDays) { const d = new Date(day.date); const weekNum = getISOWeek(d); const year = getWeekYear(d); const key = `${year}-${weekNum}`; if (!weeksMap.has(key)) { weeksMap.set(key, { year, weekNumber: weekNum, days: [] }); } const weekObj = weeksMap.get(key); const weekday = d.toLocaleDateString('en-US', { weekday: 'long' }); const orderCutoffDate = new Date(day.date); orderCutoffDate.setHours(10, 0, 0, 0); const newDayObj = { date: day.date, weekday: weekday, orderCutoff: orderCutoffDate.toISOString(), items: day.menu_items.map(item => { const isUnlimited = item.amount_tracking === false; const hasStock = parseInt(item.available_amount) > 0; return { id: `${day.date}_${item.id}`, articleId: item.id, name: item.name || 'Unknown', description: item.description || '', price: parseFloat(item.price) || 0, available: isUnlimited || hasStock, availableAmount: parseInt(item.available_amount) || 0, amountTracking: item.amount_tracking !== false }; }) }; const existingIndex = weekObj.days.findIndex(existing => existing.date === day.date); if (existingIndex >= 0) { weekObj.days[existingIndex] = newDayObj; } else { weekObj.days.push(newDayObj); } } const newAllWeeks = Array.from(weeksMap.values()).sort((a, b) => { if (a.year !== b.year) return a.year - b.year; return a.weekNumber - b.weekNumber; }); newAllWeeks.forEach(w => { if (w.days) w.days.sort((a, b) => a.date.localeCompare(b.date)); }); setAllWeeks(newAllWeeks); saveMenuCache(); updateLastUpdatedTime(new Date().toISOString()); setCurrentWeekNumber(getISOWeek(new Date())); setCurrentYear(new Date().getFullYear()); updateAuthUI(); renderVisibleWeeks(); updateNextWeekBadge(); updateAlarmBell(); progressMessage.textContent = 'Fertig!'; setTimeout(() => progressModal.classList.add('hidden'), 500); } catch (error) { console.error('Error fetching menu:', error); progressModal.classList.add('hidden'); import('./ui_helpers.js').then(uiHelpers => { uiHelpers.showErrorModal( 'Keine Verbindung', `Die Menüdaten konnten nicht geladen werden. Möglicherweise besteht keine Verbindung zur API oder zur Bessa-Webseite.

${escapeHtml(error.message)}`, 'Zur Original-Seite', 'https://web.bessa.app/knapp-kantine' ); }); } finally { loading.classList.add('hidden'); } } let lastUpdatedTimestamp = null; let lastUpdatedIntervalId = null; export function updateLastUpdatedTime(isoTimestamp) { const subtitle = document.getElementById('last-updated-subtitle'); if (!isoTimestamp) return; lastUpdatedTimestamp = isoTimestamp; localStorage.setItem('kantine_last_updated', isoTimestamp); localStorage.setItem('kantine_last_checked', isoTimestamp); try { const date = new Date(isoTimestamp); const timeStr = date.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' }); const dateStr = date.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' }); const ago = getRelativeTime(date); subtitle.textContent = `Aktualisiert: ${dateStr} ${timeStr} (${ago})`; } catch (e) { subtitle.textContent = ''; } if (!lastUpdatedIntervalId) { lastUpdatedIntervalId = setInterval(() => { if (lastUpdatedTimestamp) { updateLastUpdatedTime(lastUpdatedTimestamp); updateAlarmBell(); } }, 60 * 1000); } } export function showToast(message, type = 'info') { let container = document.getElementById('toast-container'); if (!container) { container = document.createElement('div'); container.id = 'toast-container'; document.body.appendChild(container); } const toast = document.createElement('div'); toast.className = `toast toast-${type}`; const icon = type === 'success' ? 'check_circle' : type === 'error' ? 'error' : 'info'; toast.innerHTML = `${icon}${escapeHtml(message)}`; container.appendChild(toast); requestAnimationFrame(() => toast.classList.add('show')); setTimeout(() => { toast.classList.remove('show'); setTimeout(() => toast.remove(), 300); }, 3000); }