feat: implement internationalization for UI text, refactor localStorage keys, and add input validation for state setters.
This commit is contained in:
123
src/actions.js
123
src/actions.js
@@ -1,8 +1,9 @@
|
||||
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 { API_BASE, GUEST_TOKEN, VENUE_ID, MENU_ID, POLL_INTERVAL_MS, GITHUB_API, INSTALLER_BASE, CLIENT_VERSION, LS } from './constants.js';
|
||||
import { apiHeaders, githubHeaders } from './api.js';
|
||||
import { renderVisibleWeeks, updateNextWeekBadge, updateAlarmBell } from './ui_helpers.js';
|
||||
import { t } from './i18n.js';
|
||||
|
||||
let fullOrderHistoryCache = null;
|
||||
|
||||
@@ -14,13 +15,13 @@ export function updateAuthUI() {
|
||||
const parsed = JSON.parse(akita);
|
||||
if (parsed.auth && parsed.auth.token) {
|
||||
setAuthToken(parsed.auth.token);
|
||||
localStorage.setItem('kantine_authToken', parsed.auth.token);
|
||||
localStorage.setItem(LS.AUTH_TOKEN, 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);
|
||||
localStorage.setItem(LS.CURRENT_USER, parsed.auth.user.id || 'unknown');
|
||||
if (parsed.auth.user.firstName) localStorage.setItem(LS.FIRST_NAME, parsed.auth.user.firstName);
|
||||
if (parsed.auth.user.lastName) localStorage.setItem(LS.LAST_NAME, parsed.auth.user.lastName);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -29,9 +30,9 @@ export function updateAuthUI() {
|
||||
}
|
||||
}
|
||||
|
||||
setAuthToken(localStorage.getItem('kantine_authToken'));
|
||||
setCurrentUser(localStorage.getItem('kantine_currentUser'));
|
||||
const firstName = localStorage.getItem('kantine_firstName');
|
||||
setAuthToken(localStorage.getItem(LS.AUTH_TOKEN));
|
||||
setCurrentUser(localStorage.getItem(LS.CURRENT_USER));
|
||||
const firstName = localStorage.getItem(LS.FIRST_NAME);
|
||||
const btnLoginOpen = document.getElementById('btn-login-open');
|
||||
const userInfo = document.getElementById('user-info');
|
||||
const userIdDisplay = document.getElementById('user-id-display');
|
||||
@@ -39,7 +40,7 @@ export function updateAuthUI() {
|
||||
if (authToken) {
|
||||
btnLoginOpen.classList.add('hidden');
|
||||
userInfo.classList.remove('hidden');
|
||||
userIdDisplay.textContent = firstName || (currentUser ? `User ${currentUser}` : 'Angemeldet');
|
||||
userIdDisplay.textContent = firstName || (currentUser ? `User ${currentUser}` : t('loggedIn'));
|
||||
fetchOrders();
|
||||
} else {
|
||||
btnLoginOpen.classList.remove('hidden');
|
||||
@@ -91,7 +92,7 @@ export async function fetchFullOrderHistory() {
|
||||
if (fullOrderHistoryCache) {
|
||||
localCache = fullOrderHistoryCache;
|
||||
} else {
|
||||
const ls = localStorage.getItem('kantine_history_cache');
|
||||
const ls = localStorage.getItem(LS.HISTORY_CACHE);
|
||||
if (ls) {
|
||||
try {
|
||||
localCache = JSON.parse(ls);
|
||||
@@ -114,7 +115,7 @@ export async function fetchFullOrderHistory() {
|
||||
}
|
||||
|
||||
progressFill.style.width = '0%';
|
||||
progressText.textContent = localCache.length > 0 ? 'Suche nach neuen Bestellungen...' : 'Lade Bestellhistorie...';
|
||||
progressText.textContent = localCache.length > 0 ? t('historyLoadingDelta') : t('historyLoadingFull');
|
||||
if (localCache.length > 0) historyLoading.classList.remove('hidden');
|
||||
|
||||
let nextUrl = localCache.length > 0
|
||||
@@ -155,12 +156,12 @@ export async function fetchFullOrderHistory() {
|
||||
if (totalCount > 0) {
|
||||
const pct = Math.round((fetchedOrders.length / totalCount) * 100);
|
||||
progressFill.style.width = `${pct}%`;
|
||||
progressText.textContent = `Lade Bestellung ${fetchedOrders.length} von ${totalCount}...`;
|
||||
progressText.textContent = `${t('historyLoadingItem')} ${fetchedOrders.length} ${t('historyLoadingOf')} ${totalCount}...`;
|
||||
} else {
|
||||
progressText.textContent = `Lade Bestellung ${fetchedOrders.length}...`;
|
||||
progressText.textContent = `${t('historyLoadingItem')} ${fetchedOrders.length}...`;
|
||||
}
|
||||
} else if (!deltaComplete) {
|
||||
progressText.textContent = `${fetchedOrders.length} neue/geänderte Bestellungen gefunden...`;
|
||||
progressText.textContent = `${fetchedOrders.length} ${t('historyLoadingNew')}`;
|
||||
}
|
||||
|
||||
nextUrl = deltaComplete ? null : data.next;
|
||||
@@ -176,7 +177,7 @@ export async function fetchFullOrderHistory() {
|
||||
|
||||
fullOrderHistoryCache = mergedOrders;
|
||||
try {
|
||||
localStorage.setItem('kantine_history_cache', JSON.stringify(mergedOrders));
|
||||
localStorage.setItem(LS.HISTORY_CACHE, JSON.stringify(mergedOrders));
|
||||
} catch (e) {
|
||||
console.warn('History cache write error', e);
|
||||
}
|
||||
@@ -185,9 +186,9 @@ export async function fetchFullOrderHistory() {
|
||||
} catch (error) {
|
||||
console.error('Error in history sync:', error);
|
||||
if (localCache.length === 0) {
|
||||
historyContent.innerHTML = `<p style="color:var(--error-color);text-align:center;">Fehler beim Laden der Historie.</p>`;
|
||||
historyContent.innerHTML = `<p style="color:var(--error-color);text-align:center;">${t('historyLoadError')}</p>`;
|
||||
} else {
|
||||
showToast('Hintergrund-Synchronisation fehlgeschlagen', 'error');
|
||||
showToast(t('bgSyncFailed'), 'error');
|
||||
}
|
||||
} finally {
|
||||
historyLoading.classList.add('hidden');
|
||||
@@ -197,7 +198,7 @@ export async function fetchFullOrderHistory() {
|
||||
export function renderHistory(orders) {
|
||||
const content = document.getElementById('history-content');
|
||||
if (!orders || orders.length === 0) {
|
||||
content.innerHTML = '<p style="text-align:center;color:var(--text-secondary);padding:20px;">Keine Bestellungen gefunden.</p>';
|
||||
content.innerHTML = `<p style="text-align:center;color:var(--text-secondary);padding:20px;">${t('noOrders')}</p>`;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -208,7 +209,8 @@ export function renderHistory(orders) {
|
||||
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 uiLocale = langMode === 'en' ? 'en-US' : 'de-AT';
|
||||
const monthName = d.toLocaleString(uiLocale, { month: 'long' });
|
||||
|
||||
const kw = getISOWeek(d);
|
||||
|
||||
@@ -219,7 +221,7 @@ export function renderHistory(orders) {
|
||||
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 };
|
||||
groups[y].months[monthKey].weeks[kw] = { label: langMode === 'en' ? `CW ${kw}` : `KW ${kw}`, items: [], count: 0, total: 0 };
|
||||
}
|
||||
|
||||
const items = order.items || [];
|
||||
@@ -267,7 +269,7 @@ export function renderHistory(orders) {
|
||||
monthHeader.setAttribute('tabindex', '0');
|
||||
monthHeader.setAttribute('role', 'button');
|
||||
monthHeader.setAttribute('aria-expanded', 'false');
|
||||
monthHeader.setAttribute('title', 'Klicken, um die Bestellungen für diesen Monat ein-/auszublenden');
|
||||
monthHeader.setAttribute('title', t('historyMonthToggle'));
|
||||
|
||||
const monthHeaderContent = document.createElement('div');
|
||||
monthHeaderContent.style.display = 'flex';
|
||||
@@ -282,7 +284,7 @@ export function renderHistory(orders) {
|
||||
monthSummary.className = 'history-month-summary';
|
||||
|
||||
const monthSummarySpan = document.createElement('span');
|
||||
monthSummarySpan.innerHTML = `${monthGroup.count} Bestellungen • <strong>€${monthGroup.total.toFixed(2)}</strong>`;
|
||||
monthSummarySpan.innerHTML = `${monthGroup.count} ${t('orders')} • <strong>€${monthGroup.total.toFixed(2)}</strong>`;
|
||||
monthSummary.appendChild(monthSummarySpan);
|
||||
|
||||
monthHeaderContent.appendChild(monthSummary);
|
||||
@@ -326,14 +328,15 @@ export function renderHistory(orders) {
|
||||
weekHeader.appendChild(weekLabel);
|
||||
|
||||
const weekSummary = document.createElement('span');
|
||||
weekSummary.innerHTML = `${week.count} Bestellungen • <strong>€${week.total.toFixed(2)}</strong>`;
|
||||
weekSummary.innerHTML = `${week.count} ${t('orders')} • <strong>€${week.total.toFixed(2)}</strong>`;
|
||||
weekHeader.appendChild(weekSummary);
|
||||
|
||||
weekGroupDiv.appendChild(weekHeader);
|
||||
|
||||
week.items.forEach(item => {
|
||||
const dateObj = new Date(item.date);
|
||||
const dayStr = dateObj.toLocaleDateString('de-AT', { weekday: 'short', day: '2-digit', month: '2-digit' });
|
||||
const uiLocale = langMode === 'en' ? 'en-US' : 'de-AT';
|
||||
const dayStr = dateObj.toLocaleDateString(uiLocale, { weekday: 'short', day: '2-digit', month: '2-digit' });
|
||||
|
||||
const historyItem = document.createElement('div');
|
||||
historyItem.className = 'history-item';
|
||||
@@ -359,11 +362,11 @@ export function renderHistory(orders) {
|
||||
const statusSpan = document.createElement('span');
|
||||
statusSpan.className = 'history-item-status';
|
||||
if (item.state === 9) {
|
||||
statusSpan.textContent = 'Storniert';
|
||||
statusSpan.textContent = t('stateCancelled');
|
||||
} else if (item.state === 8) {
|
||||
statusSpan.textContent = 'Abgeschlossen';
|
||||
statusSpan.textContent = t('stateCompleted');
|
||||
} else {
|
||||
statusSpan.textContent = 'Übertragen';
|
||||
statusSpan.textContent = t('stateTransferred');
|
||||
}
|
||||
statusDiv.appendChild(statusSpan);
|
||||
detailsDiv.appendChild(statusDiv);
|
||||
@@ -450,7 +453,7 @@ export async function placeOrder(date, articleId, name, price, description) {
|
||||
});
|
||||
|
||||
if (response.ok || response.status === 201) {
|
||||
showToast(`Bestellt: ${name}`, 'success');
|
||||
showToast(`${t('orderSuccess')}: ${name}`, 'success');
|
||||
fullOrderHistoryCache = null;
|
||||
await fetchOrders();
|
||||
} else {
|
||||
@@ -478,7 +481,7 @@ export async function cancelOrder(date, articleId, name) {
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
showToast(`Storniert: ${name}`, 'success');
|
||||
showToast(`${t('cancelSuccess')}: ${name}`, 'success');
|
||||
fullOrderHistoryCache = null;
|
||||
await fetchOrders();
|
||||
} else {
|
||||
@@ -498,8 +501,9 @@ export function saveFlags() {
|
||||
export async function refreshFlaggedItems() {
|
||||
if (userFlags.size === 0) return;
|
||||
const token = authToken || GUEST_TOKEN;
|
||||
const datesToFetch = new Set();
|
||||
|
||||
// Collect unique dates that have flagged items
|
||||
const datesToFetch = new Set();
|
||||
for (const flagId of userFlags) {
|
||||
const [dateStr] = flagId.split('_');
|
||||
datesToFetch.add(dateStr);
|
||||
@@ -518,32 +522,37 @@ export async function refreshFlaggedItems() {
|
||||
if (!resp.ok) continue;
|
||||
const data = await resp.json();
|
||||
const menuGroups = data.results || [];
|
||||
let dayItems = [];
|
||||
|
||||
// Build a lookup of fresh API items by article ID
|
||||
const apiItemMap = new Map();
|
||||
for (const group of menuGroups) {
|
||||
if (group.items && Array.isArray(group.items)) {
|
||||
dayItems = dayItems.concat(group.items);
|
||||
for (const item of group.items) {
|
||||
apiItemMap.set(item.id, item);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Only update items that are actually flagged
|
||||
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;
|
||||
const dayObj = week.days.find(d => d.date === dateStr);
|
||||
if (!dayObj || !dayObj.items) continue;
|
||||
|
||||
for (let i = 0; i < dayObj.items.length; i++) {
|
||||
const existing = dayObj.items[i];
|
||||
const flagId = `${dateStr}_${existing.articleId}`;
|
||||
if (!userFlags.has(flagId)) continue;
|
||||
|
||||
const apiItem = apiItemMap.get(existing.articleId);
|
||||
if (apiItem) {
|
||||
const isUnlimited = apiItem.amount_tracking === false;
|
||||
const hasStock = parseInt(apiItem.available_amount) > 0;
|
||||
existing.available = isUnlimited || hasStock;
|
||||
existing.availableAmount = parseInt(apiItem.available_amount) || 0;
|
||||
existing.amountTracking = apiItem.amount_tracking !== false;
|
||||
updated = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -553,12 +562,14 @@ export async function refreshFlaggedItems() {
|
||||
|
||||
if (updated) {
|
||||
saveMenuCache();
|
||||
localStorage.setItem('kantine_flagged_items_last_checked', new Date().toISOString());
|
||||
updateAlarmBell();
|
||||
renderVisibleWeeks();
|
||||
}
|
||||
|
||||
showToast(`${userFlags.size} ${userFlags.size === 1 ? 'Menü' : 'Menüs'} geprüft`, 'info');
|
||||
|
||||
// Always update the check timestamp and bell status
|
||||
localStorage.setItem('kantine_flagged_items_last_checked', new Date().toISOString());
|
||||
updateAlarmBell();
|
||||
renderVisibleWeeks();
|
||||
|
||||
showToast(`${userFlags.size} ${userFlags.size === 1 ? t('menuSingular') : t('menuPlural')} ${t('menuChecked')}`, 'info');
|
||||
} finally {
|
||||
if (bellBtn) bellBtn.classList.remove('refreshing');
|
||||
}
|
||||
@@ -570,11 +581,11 @@ export function toggleFlag(date, articleId, name, cutoff) {
|
||||
let flagAdded = false;
|
||||
if (userFlags.has(id)) {
|
||||
userFlags.delete(id);
|
||||
showToast(`Flag entfernt für ${name}`, 'success');
|
||||
showToast(`${t('flagRemoved')} ${name}`, 'success');
|
||||
} else {
|
||||
userFlags.add(id);
|
||||
flagAdded = true;
|
||||
showToast(`Benachrichtigung aktiviert für ${name}`, 'success');
|
||||
showToast(`${t('flagActivated')} ${name}`, 'success');
|
||||
if (Notification.permission === 'default') {
|
||||
Notification.requestPermission();
|
||||
}
|
||||
|
||||
15
src/api.js
15
src/api.js
@@ -1,5 +1,15 @@
|
||||
/**
|
||||
* API header factories for the Bessa REST API and GitHub API.
|
||||
* All fetch calls in the app route through these helpers to ensure
|
||||
* consistent auth and versioning headers.
|
||||
*/
|
||||
import { API_BASE, GUEST_TOKEN, CLIENT_VERSION } from './constants.js';
|
||||
|
||||
/**
|
||||
* Returns request headers for the Bessa REST API.
|
||||
* @param {string|null} token - Auth token; falls back to GUEST_TOKEN if absent.
|
||||
* @returns {Object} HTTP headers for fetch()
|
||||
*/
|
||||
export function apiHeaders(token) {
|
||||
return {
|
||||
'Authorization': `Token ${token || GUEST_TOKEN}`,
|
||||
@@ -9,6 +19,11 @@ export function apiHeaders(token) {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns request headers for the GitHub REST API v3.
|
||||
* Used for version checks and release listing.
|
||||
* @returns {Object} HTTP headers for fetch()
|
||||
*/
|
||||
export function githubHeaders() {
|
||||
return { 'Accept': 'application/vnd.github.v3+json' };
|
||||
}
|
||||
|
||||
@@ -1,10 +1,54 @@
|
||||
export const API_BASE = 'https://api.bessa.app/v1';
|
||||
export const GUEST_TOKEN = 'c3418725e95a9f90e3645cbc846b4d67c7c66131';
|
||||
export const CLIENT_VERSION = 'v1.6.11';
|
||||
export const VENUE_ID = 591;
|
||||
export const MENU_ID = 7;
|
||||
export const POLL_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes
|
||||
/**
|
||||
* Application-wide constants.
|
||||
* All API endpoints, IDs and timing parameters are centralized here
|
||||
* to make changes easy and avoid magic numbers scattered across the codebase.
|
||||
*/
|
||||
|
||||
/** Base URL for the Bessa REST API (v1). */
|
||||
export const API_BASE = 'https://api.bessa.app/v1';
|
||||
|
||||
/** Guest token for unauthenticated API calls (e.g. browsing the menu). */
|
||||
export const GUEST_TOKEN = 'c3418725e95a9f90e3645cbc846b4d67c7c66131';
|
||||
|
||||
/** The client version injected into every API request header. */
|
||||
export const CLIENT_VERSION = 'v1.6.16';
|
||||
|
||||
/** Bessa venue ID for Knapp-Kantine. */
|
||||
export const VENUE_ID = 591;
|
||||
|
||||
/** Bessa menu ID for the weekly lunch menu. */
|
||||
export const MENU_ID = 7;
|
||||
|
||||
/** Polling interval for flagged-menu availability checks (5 minutes). */
|
||||
export const POLL_INTERVAL_MS = 5 * 60 * 1000;
|
||||
|
||||
/** GitHub repository identifier for update checks and release links. */
|
||||
export const GITHUB_REPO = 'TauNeutrino/kantine-overview';
|
||||
|
||||
/** GitHub REST API base URL for this repository. */
|
||||
export const GITHUB_API = `https://api.github.com/repos/${GITHUB_REPO}`;
|
||||
|
||||
/** Base URL for htmlpreview-hosted installer pages. */
|
||||
export const INSTALLER_BASE = `https://htmlpreview.github.io/?https://github.com/${GITHUB_REPO}/blob`;
|
||||
|
||||
/**
|
||||
* Centralized localStorage key registry.
|
||||
* Always use these constants instead of raw strings to avoid typos and ease renaming.
|
||||
*/
|
||||
export const LS = {
|
||||
AUTH_TOKEN: 'kantine_authToken',
|
||||
CURRENT_USER: 'kantine_currentUser',
|
||||
FIRST_NAME: 'kantine_firstName',
|
||||
LAST_NAME: 'kantine_lastName',
|
||||
LANG: 'kantine_lang',
|
||||
FLAGS: 'kantine_flags',
|
||||
FLAGGED_LAST_CHECKED: 'kantine_flagged_items_last_checked',
|
||||
LAST_CHECKED: 'kantine_last_checked',
|
||||
MENU_CACHE: 'kantine_menuCache',
|
||||
MENU_CACHE_TS: 'kantine_menuCacheTs',
|
||||
HISTORY_CACHE: 'kantine_history_cache',
|
||||
HIGHLIGHT_TAGS: 'kantine_highlightTags',
|
||||
LAST_UPDATED: 'kantine_last_updated',
|
||||
VERSION_CACHE: 'kantine_version_cache',
|
||||
DEV_MODE: 'kantine_dev_mode',
|
||||
};
|
||||
|
||||
120
src/events.js
120
src/events.js
@@ -1,8 +1,103 @@
|
||||
import { displayMode, langMode, authToken, currentUser, orderMap, userFlags, pollIntervalId, setLangMode, setDisplayMode, setAuthToken, setCurrentUser, setOrderMap } from './state.js';
|
||||
import { updateAuthUI, loadMenuDataFromAPI, fetchOrders, startPolling, stopPolling, fetchFullOrderHistory, addHighlightTag, renderTagsList, refreshFlaggedItems } from './actions.js';
|
||||
import { renderVisibleWeeks, openVersionMenu } from './ui_helpers.js';
|
||||
import { API_BASE, GUEST_TOKEN } from './constants.js';
|
||||
import { renderVisibleWeeks, openVersionMenu, updateNextWeekBadge, updateAlarmBell } from './ui_helpers.js';
|
||||
import { API_BASE, GUEST_TOKEN, LS } from './constants.js';
|
||||
import { apiHeaders } from './api.js';
|
||||
import { t } from './i18n.js';
|
||||
|
||||
/**
|
||||
* Updates all static UI labels/tooltips to match the current language.
|
||||
* Called when the user switches the language toggle.
|
||||
*/
|
||||
function updateUILanguage() {
|
||||
// Navigation buttons
|
||||
const btnThisWeek = document.getElementById('btn-this-week');
|
||||
const btnNextWeek = document.getElementById('btn-next-week');
|
||||
if (btnThisWeek) {
|
||||
btnThisWeek.textContent = t('thisWeek');
|
||||
btnThisWeek.title = t('thisWeekTooltip');
|
||||
}
|
||||
if (btnNextWeek) {
|
||||
btnNextWeek.textContent = t('nextWeek');
|
||||
// Tooltip will be re-set by updateNextWeekBadge()
|
||||
}
|
||||
|
||||
// Header title
|
||||
const appTitle = document.querySelector('.header-left h1');
|
||||
if (appTitle) {
|
||||
const versionTag = appTitle.querySelector('.version-tag');
|
||||
const updateIcon = appTitle.querySelector('.update-icon');
|
||||
appTitle.textContent = t('appTitle') + ' ';
|
||||
if (versionTag) appTitle.appendChild(versionTag);
|
||||
if (updateIcon) appTitle.appendChild(updateIcon);
|
||||
}
|
||||
|
||||
// Action button tooltips
|
||||
const btnRefresh = document.getElementById('btn-refresh');
|
||||
if (btnRefresh) btnRefresh.setAttribute('aria-label', t('refresh'));
|
||||
if (btnRefresh) btnRefresh.title = t('refresh');
|
||||
|
||||
const btnHistory = document.getElementById('btn-history');
|
||||
if (btnHistory) btnHistory.setAttribute('aria-label', t('history'));
|
||||
if (btnHistory) btnHistory.title = t('history');
|
||||
|
||||
const btnHighlights = document.getElementById('btn-highlights');
|
||||
if (btnHighlights) btnHighlights.setAttribute('aria-label', t('highlights'));
|
||||
if (btnHighlights) btnHighlights.title = t('highlights');
|
||||
|
||||
const themeToggle = document.getElementById('theme-toggle');
|
||||
if (themeToggle) themeToggle.title = t('themeTooltip');
|
||||
|
||||
// Login/Logout
|
||||
const btnLoginOpen = document.getElementById('btn-login-open');
|
||||
if (btnLoginOpen) {
|
||||
btnLoginOpen.title = t('loginTooltip');
|
||||
const loginText = btnLoginOpen.querySelector('span:last-child');
|
||||
if (loginText && !loginText.classList.contains('material-icons-round')) {
|
||||
loginText.textContent = t('login');
|
||||
}
|
||||
}
|
||||
|
||||
const btnLogout = document.getElementById('btn-logout');
|
||||
if (btnLogout) btnLogout.title = t('logoutTooltip');
|
||||
|
||||
// Language toggle tooltip
|
||||
const langToggle = document.getElementById('lang-toggle');
|
||||
if (langToggle) langToggle.title = t('langTooltip');
|
||||
|
||||
// Modal headers
|
||||
const highlightsHeader = document.querySelector('#highlights-modal .modal-header h2');
|
||||
if (highlightsHeader) highlightsHeader.textContent = t('highlightsTitle');
|
||||
const highlightsDesc = document.querySelector('#highlights-modal .modal-body > p');
|
||||
if (highlightsDesc) highlightsDesc.textContent = t('highlightsDesc');
|
||||
const tagInput = document.getElementById('tag-input');
|
||||
if (tagInput) {
|
||||
tagInput.placeholder = t('tagInputPlaceholder');
|
||||
tagInput.title = t('tagInputTooltip');
|
||||
}
|
||||
const btnAddTag = document.getElementById('btn-add-tag');
|
||||
if (btnAddTag) {
|
||||
btnAddTag.textContent = t('addTag');
|
||||
btnAddTag.title = t('addTagTooltip');
|
||||
}
|
||||
|
||||
const historyHeader = document.querySelector('#history-modal .modal-header h2');
|
||||
if (historyHeader) historyHeader.textContent = t('historyTitle');
|
||||
|
||||
const loginHeader = document.querySelector('#login-modal .modal-header h2');
|
||||
if (loginHeader) loginHeader.textContent = t('loginTitle');
|
||||
|
||||
// Alarm bell
|
||||
const alarmBell = document.getElementById('alarm-bell');
|
||||
if (alarmBell && userFlags.size === 0) {
|
||||
alarmBell.title = t('alarmTooltipNone');
|
||||
}
|
||||
|
||||
// Re-render dynamic parts that may use t()
|
||||
renderVisibleWeeks();
|
||||
updateNextWeekBadge();
|
||||
updateAlarmBell();
|
||||
}
|
||||
|
||||
export function bindEvents() {
|
||||
const btnThisWeek = document.getElementById('btn-this-week');
|
||||
@@ -28,15 +123,16 @@ export function bindEvents() {
|
||||
document.querySelectorAll('.lang-btn').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
setLangMode(btn.dataset.lang);
|
||||
localStorage.setItem('kantine_lang', btn.dataset.lang);
|
||||
localStorage.setItem(LS.LANG, btn.dataset.lang);
|
||||
document.querySelectorAll('.lang-btn').forEach(b => b.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
renderVisibleWeeks();
|
||||
updateUILanguage();
|
||||
});
|
||||
});
|
||||
|
||||
if (btnHighlights) {
|
||||
btnHighlights.addEventListener('click', () => {
|
||||
renderTagsList();
|
||||
highlightsModal.classList.remove('hidden');
|
||||
});
|
||||
}
|
||||
@@ -207,8 +303,8 @@ export function bindEvents() {
|
||||
if (response.ok) {
|
||||
setAuthToken(data.key);
|
||||
setCurrentUser(employeeId);
|
||||
localStorage.setItem('kantine_authToken', data.key);
|
||||
localStorage.setItem('kantine_currentUser', employeeId);
|
||||
localStorage.setItem(LS.AUTH_TOKEN, data.key);
|
||||
localStorage.setItem(LS.CURRENT_USER, employeeId);
|
||||
|
||||
try {
|
||||
const userResp = await fetch(`${API_BASE}/auth/user/`, {
|
||||
@@ -216,8 +312,8 @@ export function bindEvents() {
|
||||
});
|
||||
if (userResp.ok) {
|
||||
const userData = await userResp.json();
|
||||
if (userData.first_name) localStorage.setItem('kantine_firstName', userData.first_name);
|
||||
if (userData.last_name) localStorage.setItem('kantine_lastName', userData.last_name);
|
||||
if (userData.first_name) localStorage.setItem(LS.FIRST_NAME, userData.first_name);
|
||||
if (userData.last_name) localStorage.setItem(LS.LAST_NAME, userData.last_name);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch user info:', err);
|
||||
@@ -244,10 +340,10 @@ export function bindEvents() {
|
||||
});
|
||||
|
||||
btnLogout.addEventListener('click', () => {
|
||||
localStorage.removeItem('kantine_authToken');
|
||||
localStorage.removeItem('kantine_currentUser');
|
||||
localStorage.removeItem('kantine_firstName');
|
||||
localStorage.removeItem('kantine_lastName');
|
||||
localStorage.removeItem(LS.AUTH_TOKEN);
|
||||
localStorage.removeItem(LS.CURRENT_USER);
|
||||
localStorage.removeItem(LS.FIRST_NAME);
|
||||
localStorage.removeItem(LS.LAST_NAME);
|
||||
setAuthToken(null);
|
||||
setCurrentUser(null);
|
||||
setOrderMap(new Map());
|
||||
|
||||
302
src/i18n.js
Normal file
302
src/i18n.js
Normal file
@@ -0,0 +1,302 @@
|
||||
/**
|
||||
* Internationalization (i18n) module for the Kantine Wrapper UI.
|
||||
* Provides translations for all static UI text based on the current language mode.
|
||||
* German (de) is the default; English (en) is fully supported.
|
||||
* When langMode is 'all', German labels are used for the GUI.
|
||||
*/
|
||||
import { langMode } from './state.js';
|
||||
|
||||
const TRANSLATIONS = {
|
||||
de: {
|
||||
// Navigation
|
||||
thisWeek: 'Diese Woche',
|
||||
nextWeek: 'Nächste Woche',
|
||||
nextWeekTooltipDefault: 'Menü nächster Woche anzeigen',
|
||||
thisWeekTooltip: 'Menü dieser Woche anzeigen',
|
||||
|
||||
// Header
|
||||
appTitle: 'Kantinen Übersicht',
|
||||
updatedAt: 'Aktualisiert',
|
||||
langTooltip: 'Sprache der Menübeschreibung',
|
||||
weekLabel: 'Woche',
|
||||
|
||||
// Action buttons
|
||||
refresh: 'Menüdaten neu laden',
|
||||
history: 'Bestellhistorie',
|
||||
highlights: 'Persönliche Highlights verwalten',
|
||||
themeTooltip: 'Erscheinungsbild (Hell/Dunkel) wechseln',
|
||||
login: 'Anmelden',
|
||||
loginTooltip: 'Mit Bessa.app Account anmelden',
|
||||
logout: 'Abmelden',
|
||||
logoutTooltip: 'Von Bessa.app abmelden',
|
||||
|
||||
// Login modal
|
||||
loginTitle: 'Login',
|
||||
employeeId: 'Mitarbeiternummer',
|
||||
employeeIdPlaceholder: 'z.B. 2041',
|
||||
employeeIdHelp: 'Deine offizielle Knapp Mitarbeiternummer.',
|
||||
password: 'Passwort',
|
||||
passwordPlaceholder: 'Bessa Passwort',
|
||||
passwordHelp: 'Das Passwort für deinen Bessa Account.',
|
||||
loginButton: 'Einloggen',
|
||||
loggingIn: 'Wird eingeloggt...',
|
||||
|
||||
// Highlights modal
|
||||
highlightsTitle: 'Meine Highlights',
|
||||
highlightsDesc: 'Markiere Menüs automatisch, wenn sie diese Schlagwörter enthalten.',
|
||||
tagInputPlaceholder: 'z.B. Schnitzel, Vegetarisch...',
|
||||
tagInputTooltip: 'Neues Schlagwort zum Hervorheben eingeben',
|
||||
addTag: 'Hinzufügen',
|
||||
addTagTooltip: 'Schlagwort zur Liste hinzufügen',
|
||||
removeTagTooltip: 'Schlagwort entfernen',
|
||||
|
||||
// History modal
|
||||
historyTitle: 'Bestellhistorie',
|
||||
loadingHistory: 'Lade Historie...',
|
||||
noOrders: 'Keine Bestellungen gefunden.',
|
||||
orders: 'Bestellungen',
|
||||
historyMonthToggle: 'Klicken, um die Bestellungen für diesen Monat ein-/auszublenden',
|
||||
|
||||
// Menu item labels
|
||||
available: 'Verfügbar',
|
||||
soldOut: 'Ausverkauft',
|
||||
ordered: 'Bestellt',
|
||||
orderButton: 'Bestellen',
|
||||
orderAgainTooltip: 'nochmal bestellen',
|
||||
orderTooltip: 'bestellen',
|
||||
cancelOrder: 'Bestellung stornieren',
|
||||
cancelOneOrder: 'Eine Bestellung stornieren',
|
||||
flagActivate: 'Benachrichtigen wenn verfügbar',
|
||||
flagDeactivate: 'Benachrichtigung deaktivieren',
|
||||
|
||||
// Alarm bell
|
||||
alarmTooltipNone: 'Keine beobachteten Menüs',
|
||||
alarmLastChecked: 'Zuletzt geprüft',
|
||||
|
||||
// Version modal
|
||||
versionsTitle: '📦 Versionen',
|
||||
currentVersion: 'Aktuell',
|
||||
devModeLabel: 'Dev-Mode (alle Tags anzeigen)',
|
||||
loadingVersions: 'Lade Versionen...',
|
||||
noVersions: 'Keine Versionen gefunden.',
|
||||
installed: '✓ Installiert',
|
||||
newVersion: '⬆ Neu!',
|
||||
installLink: 'Installieren',
|
||||
reportBug: 'Fehler melden',
|
||||
reportBugTooltip: 'Melde einen Fehler auf GitHub',
|
||||
featureRequest: 'Feature vorschlagen',
|
||||
featureRequestTooltip: 'Schlage ein neues Feature auf GitHub vor',
|
||||
clearCache: 'Lokalen Cache leeren',
|
||||
clearCacheTooltip: 'Löscht alle lokalen Daten & erzwingt einen Neuladen',
|
||||
clearCacheConfirm: 'Möchtest du wirklich alle lokalen Daten (inkl. Login-Session, Cache und Einstellungen) löschen? Die Seite wird danach neu geladen.',
|
||||
versionMenuTooltip: 'Klick für Versionsmenü',
|
||||
|
||||
// Progress modal
|
||||
progressTitle: 'Menüdaten aktualisieren',
|
||||
progressInit: 'Initialisierung...',
|
||||
|
||||
// Empty state
|
||||
noMenuData: 'Keine Menüdaten für KW',
|
||||
noMenuDataHint: 'Versuchen Sie eine andere Woche oder schauen Sie später vorbei.',
|
||||
|
||||
// Weekly cost
|
||||
costLabel: 'Gesamt',
|
||||
|
||||
// Countdown
|
||||
orderDeadline: 'Bestellschluss',
|
||||
|
||||
// Toast messages
|
||||
flagRemoved: 'Flag entfernt für',
|
||||
flagActivated: 'Benachrichtigung aktiviert für',
|
||||
menuChecked: 'geprüft',
|
||||
menuSingular: 'Menü',
|
||||
menuPlural: 'Menüs',
|
||||
newMenuDataAvailable: 'Neue Menüdaten für nächste Woche verfügbar!',
|
||||
orderSuccess: 'Bestellt',
|
||||
cancelSuccess: 'Storniert',
|
||||
bgSyncFailed: 'Hintergrund-Synchronisation fehlgeschlagen',
|
||||
historyLoadError: 'Fehler beim Laden der Historie.',
|
||||
historyLoadingFull: 'Lade Bestellhistorie...',
|
||||
historyLoadingDelta: 'Suche nach neuen Bestellungen...',
|
||||
historyLoadingItem: 'Lade Bestellung',
|
||||
historyLoadingOf: 'von',
|
||||
historyLoadingNew: 'neue/geänderte Bestellungen gefunden...',
|
||||
|
||||
// Badge tooltip parts
|
||||
badgeOrdered: 'bestellt',
|
||||
badgeOrderable: 'bestellbar',
|
||||
badgeTotal: 'gesamt',
|
||||
badgeHighlights: 'Highlights gefunden',
|
||||
|
||||
// History item states
|
||||
stateCancelled: 'Storniert',
|
||||
stateCompleted: 'Abgeschlossen',
|
||||
stateTransferred: 'Übertragen',
|
||||
|
||||
// Close button
|
||||
close: 'Schließen',
|
||||
|
||||
// Error modal
|
||||
noConnection: 'Keine Verbindung',
|
||||
toOriginalPage: 'Zur Original-Seite',
|
||||
|
||||
// Misc
|
||||
loggedIn: 'Angemeldet',
|
||||
},
|
||||
en: {
|
||||
// Navigation
|
||||
thisWeek: 'This Week',
|
||||
nextWeek: 'Next Week',
|
||||
nextWeekTooltipDefault: 'Show next week\'s menu',
|
||||
thisWeekTooltip: 'Show this week\'s menu',
|
||||
|
||||
// Header
|
||||
appTitle: 'Canteen Overview',
|
||||
updatedAt: 'Updated',
|
||||
langTooltip: 'Menu description language',
|
||||
weekLabel: 'Week',
|
||||
|
||||
// Action buttons
|
||||
refresh: 'Reload menu data',
|
||||
history: 'Order history',
|
||||
highlights: 'Manage personal highlights',
|
||||
themeTooltip: 'Toggle appearance (Light/Dark)',
|
||||
login: 'Sign in',
|
||||
loginTooltip: 'Sign in with Bessa.app account',
|
||||
logout: 'Sign out',
|
||||
logoutTooltip: 'Sign out from Bessa.app',
|
||||
|
||||
// Login modal
|
||||
loginTitle: 'Login',
|
||||
employeeId: 'Employee ID',
|
||||
employeeIdPlaceholder: 'e.g. 2041',
|
||||
employeeIdHelp: 'Your official Knapp employee number.',
|
||||
password: 'Password',
|
||||
passwordPlaceholder: 'Bessa password',
|
||||
passwordHelp: 'The password for your Bessa account.',
|
||||
loginButton: 'Log in',
|
||||
loggingIn: 'Logging in...',
|
||||
|
||||
// Highlights modal
|
||||
highlightsTitle: 'My Highlights',
|
||||
highlightsDesc: 'Automatically highlight menus containing these keywords.',
|
||||
tagInputPlaceholder: 'e.g. Schnitzel, Vegetarian...',
|
||||
tagInputTooltip: 'Enter new keyword to highlight',
|
||||
addTag: 'Add',
|
||||
addTagTooltip: 'Add keyword to list',
|
||||
removeTagTooltip: 'Remove keyword',
|
||||
|
||||
// History modal
|
||||
historyTitle: 'Order History',
|
||||
loadingHistory: 'Loading history...',
|
||||
noOrders: 'No orders found.',
|
||||
orders: 'Orders',
|
||||
historyMonthToggle: 'Click to expand/collapse orders for this month',
|
||||
|
||||
// Menu item labels
|
||||
available: 'Available',
|
||||
soldOut: 'Sold out',
|
||||
ordered: 'Ordered',
|
||||
orderButton: 'Order',
|
||||
orderAgainTooltip: 'order again',
|
||||
orderTooltip: 'order',
|
||||
cancelOrder: 'Cancel order',
|
||||
cancelOneOrder: 'Cancel one order',
|
||||
flagActivate: 'Notify when available',
|
||||
flagDeactivate: 'Deactivate notification',
|
||||
|
||||
// Alarm bell
|
||||
alarmTooltipNone: 'No flagged menus',
|
||||
alarmLastChecked: 'Last checked',
|
||||
|
||||
// Version modal
|
||||
versionsTitle: '📦 Versions',
|
||||
currentVersion: 'Current',
|
||||
devModeLabel: 'Dev mode (show all tags)',
|
||||
loadingVersions: 'Loading versions...',
|
||||
noVersions: 'No versions found.',
|
||||
installed: '✓ Installed',
|
||||
newVersion: '⬆ New!',
|
||||
installLink: 'Install',
|
||||
reportBug: 'Report a bug',
|
||||
reportBugTooltip: 'Report a bug on GitHub',
|
||||
featureRequest: 'Request a feature',
|
||||
featureRequestTooltip: 'Suggest a new feature on GitHub',
|
||||
clearCache: 'Clear local cache',
|
||||
clearCacheTooltip: 'Deletes all local data & forces a reload',
|
||||
clearCacheConfirm: 'Do you really want to delete all local data (including login session, cache, and settings)? The page will reload afterwards.',
|
||||
versionMenuTooltip: 'Click for version menu',
|
||||
|
||||
// Progress modal
|
||||
progressTitle: 'Updating menu data',
|
||||
progressInit: 'Initializing...',
|
||||
|
||||
// Empty state
|
||||
noMenuData: 'No menu data for CW',
|
||||
noMenuDataHint: 'Try another week or check back later.',
|
||||
|
||||
// Weekly cost
|
||||
costLabel: 'Total',
|
||||
|
||||
// Countdown
|
||||
orderDeadline: 'Order deadline',
|
||||
|
||||
// Toast messages
|
||||
flagRemoved: 'Flag removed for',
|
||||
flagActivated: 'Notification activated for',
|
||||
menuChecked: 'checked',
|
||||
menuSingular: 'menu',
|
||||
menuPlural: 'menus',
|
||||
newMenuDataAvailable: 'New menu data available for next week!',
|
||||
orderSuccess: 'Ordered',
|
||||
cancelSuccess: 'Cancelled',
|
||||
bgSyncFailed: 'Background synchronisation failed',
|
||||
historyLoadError: 'Error loading history.',
|
||||
historyLoadingFull: 'Loading order history...',
|
||||
historyLoadingDelta: 'Checking for new orders...',
|
||||
historyLoadingItem: 'Loading order',
|
||||
historyLoadingOf: 'of',
|
||||
historyLoadingNew: 'new/updated orders found...',
|
||||
|
||||
// Badge tooltip parts
|
||||
badgeOrdered: 'ordered',
|
||||
badgeOrderable: 'orderable',
|
||||
badgeTotal: 'total',
|
||||
badgeHighlights: 'highlights found',
|
||||
|
||||
// History item states
|
||||
stateCancelled: 'Cancelled',
|
||||
stateCompleted: 'Completed',
|
||||
stateTransferred: 'Transferred',
|
||||
|
||||
// Close button
|
||||
close: 'Close',
|
||||
|
||||
// Error modal
|
||||
noConnection: 'No connection',
|
||||
toOriginalPage: 'Go to original page',
|
||||
|
||||
// Misc
|
||||
loggedIn: 'Logged in',
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the translated string for the given key.
|
||||
* Uses the current langMode (en = English, anything else = German).
|
||||
* Falls back to German if a key is missing in the target language.
|
||||
* @param {string} key - Translation key
|
||||
* @returns {string} Translated text
|
||||
*/
|
||||
export function t(key) {
|
||||
const lang = langMode === 'en' ? 'en' : 'de';
|
||||
return TRANSLATIONS[lang][key] || TRANSLATIONS['de'][key] || key;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the effective UI language code ('en' or 'de').
|
||||
* 'all' mode uses German for the GUI.
|
||||
*/
|
||||
export function getUILang() {
|
||||
return langMode === 'en' ? 'en' : 'de';
|
||||
}
|
||||
31
src/state.js
31
src/state.js
@@ -1,25 +1,42 @@
|
||||
import { getISOWeek } from './utils.js';
|
||||
import { LS } from './constants.js';
|
||||
|
||||
export let allWeeks = [];
|
||||
export let currentWeekNumber = getISOWeek(new Date());
|
||||
export let currentYear = new Date().getFullYear();
|
||||
export let displayMode = 'this-week';
|
||||
export let authToken = localStorage.getItem('kantine_authToken');
|
||||
export let currentUser = localStorage.getItem('kantine_currentUser');
|
||||
export let authToken = localStorage.getItem(LS.AUTH_TOKEN);
|
||||
export let currentUser = localStorage.getItem(LS.CURRENT_USER);
|
||||
export let orderMap = new Map();
|
||||
export let userFlags = new Set(JSON.parse(localStorage.getItem('kantine_flags') || '[]'));
|
||||
export let userFlags = new Set(JSON.parse(localStorage.getItem(LS.FLAGS) || '[]'));
|
||||
export let pollIntervalId = null;
|
||||
export let langMode = localStorage.getItem('kantine_lang') || 'de';
|
||||
export let highlightTags = JSON.parse(localStorage.getItem('kantine_highlightTags') || '[]');
|
||||
export let langMode = localStorage.getItem(LS.LANG) || 'de';
|
||||
export let highlightTags = JSON.parse(localStorage.getItem(LS.HIGHLIGHT_TAGS) || '[]');
|
||||
|
||||
export function setAllWeeks(weeks) { allWeeks = weeks; }
|
||||
export function setCurrentWeekNumber(week) { currentWeekNumber = week; }
|
||||
export function setCurrentYear(year) { currentYear = year; }
|
||||
export function setDisplayMode(mode) { displayMode = mode; }
|
||||
export function setAuthToken(token) { authToken = token; }
|
||||
export function setCurrentUser(user) { currentUser = user; }
|
||||
export function setOrderMap(map) { orderMap = map; }
|
||||
export function setUserFlags(flags) { userFlags = flags; }
|
||||
export function setPollIntervalId(id) { pollIntervalId = id; }
|
||||
export function setLangMode(lang) { langMode = lang; }
|
||||
export function setHighlightTags(tags) { highlightTags = tags; }
|
||||
|
||||
/** Only 'this-week' and 'next-week' are valid display modes. */
|
||||
export function setDisplayMode(mode) {
|
||||
if (mode !== 'this-week' && mode !== 'next-week') {
|
||||
console.warn(`[state] Invalid displayMode: "${mode}". Ignoring.`);
|
||||
return;
|
||||
}
|
||||
displayMode = mode;
|
||||
}
|
||||
|
||||
/** Only 'de', 'en', and 'all' are valid language modes. */
|
||||
export function setLangMode(lang) {
|
||||
if (!['de', 'en', 'all'].includes(lang)) {
|
||||
console.warn(`[state] Invalid langMode: "${lang}". Ignoring.`);
|
||||
return;
|
||||
}
|
||||
langMode = lang;
|
||||
}
|
||||
|
||||
10
src/ui.js
10
src/ui.js
@@ -1,5 +1,15 @@
|
||||
/**
|
||||
* UI injection module.
|
||||
* Renders the full Kantine Wrapper HTML skeleton into the current page,
|
||||
* including fonts, icon stylesheet, favicon, and all modal/panel containers.
|
||||
* Must be called before bindEvents() and any state-rendering logic.
|
||||
*/
|
||||
import { langMode } from './state.js';
|
||||
|
||||
/**
|
||||
* Injects the full application HTML into the current tab.
|
||||
* Idempotent in conjunction with the __KANTINE_LOADED guard in index.js.
|
||||
*/
|
||||
export function injectUI() {
|
||||
document.title = 'Kantine Weekly Menu';
|
||||
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
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, getLocalizedText } 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 { API_BASE, GUEST_TOKEN, VENUE_ID, MENU_ID, POLL_INTERVAL_MS, GITHUB_API, INSTALLER_BASE, CLIENT_VERSION, LS } from './constants.js';
|
||||
import { apiHeaders, githubHeaders } from './api.js';
|
||||
import { placeOrder, cancelOrder, toggleFlag, showToast, checkHighlight, loadMenuDataFromAPI } from './actions.js';
|
||||
import { t } from './i18n.js';
|
||||
|
||||
/**
|
||||
* Updates the "Next Week" button tooltip and glow state.
|
||||
* Tooltip shows order status summary and highlight count.
|
||||
* Glow activates only if Mon-Thu have orderable menus without orders (Friday exempt).
|
||||
*/
|
||||
export function updateNextWeekBadge() {
|
||||
const btnNextWeek = document.getElementById('btn-next-week');
|
||||
let nextWeek = currentWeekNumber + 1;
|
||||
@@ -14,7 +20,7 @@ export function updateNextWeekBadge() {
|
||||
let totalDataCount = 0;
|
||||
let orderableCount = 0;
|
||||
let daysWithOrders = 0;
|
||||
let daysWithOrderableAndNoOrder = 0;
|
||||
let monThuOrderableNoOrder = 0;
|
||||
|
||||
if (nextWeekData && nextWeekData.days) {
|
||||
nextWeekData.days.forEach(day => {
|
||||
@@ -31,34 +37,22 @@ export function updateNextWeekBadge() {
|
||||
});
|
||||
|
||||
if (hasOrder) daysWithOrders++;
|
||||
if (isOrderable && !hasOrder) daysWithOrderableAndNoOrder++;
|
||||
|
||||
// Feature 5: Only Mon(1)-Thu(4) count for glow logic, Friday(5) is exempt
|
||||
const dayOfWeek = new Date(day.date).getDay();
|
||||
if (dayOfWeek >= 1 && dayOfWeek <= 4 && isOrderable && !hasOrder) {
|
||||
monThuOrderableNoOrder++;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let badge = btnNextWeek.querySelector('.nav-badge');
|
||||
// Remove any old visible badge element (Feature 3: numbers hidden)
|
||||
const existingBadge = btnNextWeek.querySelector('.nav-badge');
|
||||
if (existingBadge) existingBadge.remove();
|
||||
|
||||
if (totalDataCount > 0) {
|
||||
if (!badge) {
|
||||
badge = document.createElement('span');
|
||||
badge.className = 'nav-badge';
|
||||
btnNextWeek.appendChild(badge);
|
||||
}
|
||||
|
||||
badge.title = `${daysWithOrders} bestellt / ${orderableCount} bestellbar / ${totalDataCount} gesamt`;
|
||||
badge.innerHTML = `<span class="ordered">${daysWithOrders}</span><span class="separator">/</span><span class="orderable">${orderableCount}</span><span class="separator">/</span><span class="total">${totalDataCount}</span>`;
|
||||
|
||||
badge.classList.remove('badge-violet', 'badge-green', 'badge-red', 'badge-blue');
|
||||
|
||||
if (daysWithOrders > 0 && daysWithOrderableAndNoOrder === 0) {
|
||||
badge.classList.add('badge-violet');
|
||||
} else if (daysWithOrderableAndNoOrder > 0) {
|
||||
badge.classList.add('badge-green');
|
||||
} else if (orderableCount === 0) {
|
||||
badge.classList.add('badge-red');
|
||||
} else {
|
||||
badge.classList.add('badge-blue');
|
||||
}
|
||||
|
||||
// Count highlight menus in next week
|
||||
let highlightCount = 0;
|
||||
if (nextWeekData && nextWeekData.days) {
|
||||
nextWeekData.days.forEach(day => {
|
||||
@@ -72,25 +66,27 @@ export function updateNextWeekBadge() {
|
||||
});
|
||||
}
|
||||
|
||||
// Feature 3: All info goes to button tooltip instead of visible badge
|
||||
let tooltipParts = [`${daysWithOrders} ${t('badgeOrdered')} / ${orderableCount} ${t('badgeOrderable')} / ${totalDataCount} ${t('badgeTotal')}`];
|
||||
if (highlightCount > 0) {
|
||||
badge.insertAdjacentHTML('beforeend', `<span class="highlight-count" title="${highlightCount} Highlight Menüs">(${highlightCount})</span>`);
|
||||
badge.title += ` • ${highlightCount} Highlights gefunden`;
|
||||
badge.classList.add('has-highlights');
|
||||
tooltipParts.push(`${highlightCount} ${t('badgeHighlights')}`);
|
||||
}
|
||||
btnNextWeek.title = tooltipParts.join(' • ');
|
||||
|
||||
if (daysWithOrders === 0) {
|
||||
// Feature 5: Glow only if Mon-Thu have orderable days without existing orders
|
||||
if (monThuOrderableNoOrder > 0) {
|
||||
btnNextWeek.classList.add('new-week-available');
|
||||
const storageKey = `kantine_notified_nextweek_${nextYear}_${nextWeek}`;
|
||||
if (!localStorage.getItem(storageKey)) {
|
||||
localStorage.setItem(storageKey, 'true');
|
||||
showToast('Neue Menüdaten für nächste Woche verfügbar!', 'info');
|
||||
showToast(t('newMenuDataAvailable'), 'info');
|
||||
}
|
||||
} else {
|
||||
btnNextWeek.classList.remove('new-week-available');
|
||||
}
|
||||
|
||||
} else if (badge) {
|
||||
badge.remove();
|
||||
} else {
|
||||
btnNextWeek.title = t('nextWeekTooltipDefault');
|
||||
btnNextWeek.classList.remove('new-week-available');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,7 +107,7 @@ export function updateWeeklyCost(days) {
|
||||
|
||||
const costDisplay = document.getElementById('weekly-cost-display');
|
||||
if (totalCost > 0) {
|
||||
costDisplay.innerHTML = `<span class="material-icons-round">shopping_bag</span> <span>Gesamt: ${totalCost.toFixed(2).replace('.', ',')} €</span>`;
|
||||
costDisplay.innerHTML = `<span class="material-icons-round">shopping_bag</span> <span>${t('costLabel')}: ${totalCost.toFixed(2).replace('.', ',')} €</span>`;
|
||||
costDisplay.classList.remove('hidden');
|
||||
} else {
|
||||
costDisplay.classList.add('hidden');
|
||||
@@ -140,8 +136,8 @@ export function renderVisibleWeeks() {
|
||||
if (daysInTargetWeek.length === 0) {
|
||||
menuContainer.innerHTML = `
|
||||
<div class="empty-state">
|
||||
<p>Keine Menüdaten für KW ${targetWeek} (${targetYear}) verfügbar.</p>
|
||||
<small>Versuchen Sie eine andere Woche oder schauen Sie später vorbei.</small>
|
||||
<p>${t('noMenuData')} ${targetWeek} (${targetYear}).</p>
|
||||
<small>${t('noMenuDataHint')}</small>
|
||||
</div>`;
|
||||
document.getElementById('weekly-cost-display').classList.add('hidden');
|
||||
return;
|
||||
@@ -150,10 +146,10 @@ export function renderVisibleWeeks() {
|
||||
updateWeeklyCost(daysInTargetWeek);
|
||||
|
||||
const headerWeekInfo = document.getElementById('header-week-info');
|
||||
const weekTitle = displayMode === 'this-week' ? 'Diese Woche' : 'Nächste Woche';
|
||||
const weekTitle = displayMode === 'this-week' ? t('thisWeek') : t('nextWeek');
|
||||
headerWeekInfo.innerHTML = `
|
||||
<div class="header-week-title">${weekTitle}</div>
|
||||
<div class="header-week-subtitle">Week ${targetWeek} • ${targetYear}</div>`;
|
||||
<div class="header-week-subtitle">${t('weekLabel')} ${targetWeek} • ${targetYear}</div>`;
|
||||
|
||||
const grid = document.createElement('div');
|
||||
grid.className = 'days-grid';
|
||||
@@ -302,16 +298,16 @@ export function createDayCard(day) {
|
||||
let statusBadge = '';
|
||||
if (item.available) {
|
||||
statusBadge = item.amountTracking
|
||||
? `<span class="badge available">Verfügbar (${item.availableAmount})</span>`
|
||||
: `<span class="badge available">Verfügbar</span>`;
|
||||
? `<span class="badge available">${t('available')} (${item.availableAmount})</span>`
|
||||
: `<span class="badge available">${t('available')}</span>`;
|
||||
} else {
|
||||
statusBadge = `<span class="badge sold-out">Ausverkauft</span>`;
|
||||
statusBadge = `<span class="badge sold-out">${t('soldOut')}</span>`;
|
||||
}
|
||||
|
||||
let orderedBadge = '';
|
||||
if (orderCount > 0) {
|
||||
const countBadge = orderCount > 1 ? `<span class="order-count-badge">${orderCount}</span>` : '';
|
||||
orderedBadge = `<span class="badge ordered"><span class="material-icons-round">check_circle</span> Bestellt${countBadge}</span>`;
|
||||
orderedBadge = `<span class="badge ordered"><span class="material-icons-round">check_circle</span> ${t('ordered')}${countBadge}</span>`;
|
||||
itemEl.classList.add('ordered');
|
||||
if (new Date(day.date).toDateString() === now.toDateString()) {
|
||||
itemEl.classList.add('today-ordered');
|
||||
@@ -336,22 +332,22 @@ export function createDayCard(day) {
|
||||
if (authToken && !isPastCutoff) {
|
||||
const flagIcon = isFlagged ? 'notifications_active' : 'notifications_none';
|
||||
const flagClass = isFlagged ? 'btn-flag active' : 'btn-flag';
|
||||
const flagTitle = isFlagged ? 'Benachrichtigung deaktivieren' : 'Benachrichtigen wenn verfügbar';
|
||||
const flagTitle = isFlagged ? t('flagDeactivate') : t('flagActivate');
|
||||
if (!item.available || isFlagged) {
|
||||
flagButton = `<button class="${flagClass}" data-date="${day.date}" data-article="${articleId}" data-name="${escapeHtml(item.name)}" data-cutoff="${day.orderCutoff}" title="${flagTitle}"><span class="material-icons-round">${flagIcon}</span></button>`;
|
||||
}
|
||||
|
||||
if (item.available) {
|
||||
if (orderCount > 0) {
|
||||
orderButton = `<button class="btn-order btn-order-compact" data-date="${day.date}" data-article="${articleId}" data-name="${escapeHtml(item.name)}" data-price="${item.price}" data-desc="${escapeHtml(item.description || '')}" title="${escapeHtml(item.name)} nochmal bestellen"><span class="material-icons-round">add</span></button>`;
|
||||
orderButton = `<button class="btn-order btn-order-compact" data-date="${day.date}" data-article="${articleId}" data-name="${escapeHtml(item.name)}" data-price="${item.price}" data-desc="${escapeHtml(item.description || '')}" title="${escapeHtml(item.name)} – ${t('orderAgainTooltip')}"><span class="material-icons-round">add</span></button>`;
|
||||
} else {
|
||||
orderButton = `<button class="btn-order" data-date="${day.date}" data-article="${articleId}" data-name="${escapeHtml(item.name)}" data-price="${item.price}" data-desc="${escapeHtml(item.description || '')}" title="${escapeHtml(item.name)} bestellen"><span class="material-icons-round">add_shopping_cart</span> Bestellen</button>`;
|
||||
orderButton = `<button class="btn-order" data-date="${day.date}" data-article="${articleId}" data-name="${escapeHtml(item.name)}" data-price="${item.price}" data-desc="${escapeHtml(item.description || '')}" title="${escapeHtml(item.name)} – ${t('orderTooltip')}"><span class="material-icons-round">add_shopping_cart</span> ${t('orderButton')}</button>`;
|
||||
}
|
||||
}
|
||||
|
||||
if (orderCount > 0) {
|
||||
const cancelIcon = orderCount === 1 ? 'close' : 'remove';
|
||||
const cancelTitle = orderCount === 1 ? 'Bestellung stornieren' : 'Eine Bestellung stornieren';
|
||||
const cancelTitle = orderCount === 1 ? t('cancelOrder') : t('cancelOneOrder');
|
||||
cancelButton = `<button class="btn-cancel" data-date="${day.date}" data-article="${articleId}" data-name="${escapeHtml(item.name)}" title="${cancelTitle}"><span class="material-icons-round">${cancelIcon}</span></button>`;
|
||||
}
|
||||
}
|
||||
@@ -443,13 +439,13 @@ export async function fetchVersions(devMode) {
|
||||
|
||||
export async function checkForUpdates() {
|
||||
const currentVersion = '{{VERSION}}';
|
||||
const devMode = localStorage.getItem('kantine_dev_mode') === 'true';
|
||||
const devMode = localStorage.getItem(LS.DEV_MODE) === 'true';
|
||||
|
||||
try {
|
||||
const versions = await fetchVersions(devMode);
|
||||
if (!versions.length) return;
|
||||
|
||||
localStorage.setItem('kantine_version_cache', JSON.stringify({
|
||||
localStorage.setItem(LS.VERSION_CACHE, JSON.stringify({
|
||||
timestamp: Date.now(), devMode, versions
|
||||
}));
|
||||
|
||||
@@ -485,7 +481,7 @@ export function openVersionMenu() {
|
||||
const cur = document.getElementById('version-current');
|
||||
if (cur) cur.textContent = currentVersion;
|
||||
|
||||
const devMode = localStorage.getItem('kantine_dev_mode') === 'true';
|
||||
const devMode = localStorage.getItem(LS.DEV_MODE) === 'true';
|
||||
devToggle.checked = devMode;
|
||||
|
||||
async function loadVersions(forceRefresh) {
|
||||
@@ -528,7 +524,7 @@ export function openVersionMenu() {
|
||||
}
|
||||
|
||||
try {
|
||||
const cachedRaw = localStorage.getItem('kantine_version_cache');
|
||||
const cachedRaw = localStorage.getItem(LS.VERSION_CACHE);
|
||||
let cached = null;
|
||||
if (cachedRaw) {
|
||||
try { cached = JSON.parse(cachedRaw); } catch (e) { }
|
||||
@@ -544,7 +540,7 @@ export function openVersionMenu() {
|
||||
const cachedVersionsStr = cached ? JSON.stringify(cached.versions) : '';
|
||||
|
||||
if (liveVersionsStr !== cachedVersionsStr) {
|
||||
localStorage.setItem('kantine_version_cache', JSON.stringify({
|
||||
localStorage.setItem(LS.VERSION_CACHE, JSON.stringify({
|
||||
timestamp: Date.now(), devMode: dm, versions: liveVersions
|
||||
}));
|
||||
renderVersionsList(liveVersions);
|
||||
@@ -558,8 +554,8 @@ export function openVersionMenu() {
|
||||
loadVersions(false);
|
||||
|
||||
devToggle.onchange = () => {
|
||||
localStorage.setItem('kantine_dev_mode', devToggle.checked);
|
||||
localStorage.removeItem('kantine_version_cache');
|
||||
localStorage.setItem(LS.DEV_MODE, devToggle.checked);
|
||||
localStorage.removeItem(LS.VERSION_CACHE);
|
||||
loadVersions(true);
|
||||
};
|
||||
}
|
||||
@@ -615,7 +611,7 @@ export function updateCountdown() {
|
||||
headerCenter.insertBefore(countdownEl, headerCenter.firstChild);
|
||||
}
|
||||
|
||||
countdownEl.innerHTML = `<span>Bestellschluss:</span> <strong>${diffHrs}h ${diffMins}m</strong>`;
|
||||
countdownEl.innerHTML = `<span>${t('orderDeadline')}:</span> <strong>${diffHrs}h ${diffMins}m</strong>`;
|
||||
|
||||
if (diff < 3600000) {
|
||||
countdownEl.classList.add('urgent');
|
||||
@@ -729,8 +725,8 @@ export function updateAlarmBell() {
|
||||
if (anyAvailable) break;
|
||||
}
|
||||
|
||||
const lastCheckedStr = localStorage.getItem('kantine_last_checked');
|
||||
const flaggedLastCheckedStr = localStorage.getItem('kantine_flagged_items_last_checked');
|
||||
const lastCheckedStr = localStorage.getItem(LS.LAST_CHECKED);
|
||||
const flaggedLastCheckedStr = localStorage.getItem(LS.FLAGGED_LAST_CHECKED);
|
||||
|
||||
let latestTime = 0;
|
||||
if (lastCheckedStr) latestTime = Math.max(latestTime, new Date(lastCheckedStr).getTime());
|
||||
@@ -739,13 +735,13 @@ export function updateAlarmBell() {
|
||||
let timeStr = 'gerade eben';
|
||||
if (latestTime === 0) {
|
||||
const now = new Date().toISOString();
|
||||
localStorage.setItem('kantine_last_checked', now);
|
||||
localStorage.setItem(LS.LAST_CHECKED, now);
|
||||
latestTime = new Date(now).getTime();
|
||||
}
|
||||
|
||||
timeStr = getRelativeTime(new Date(latestTime));
|
||||
|
||||
bellBtn.title = `Zuletzt geprüft: ${timeStr}`;
|
||||
bellBtn.title = `${t('alarmLastChecked')}: ${timeStr}`;
|
||||
|
||||
if (anyAvailable) {
|
||||
bellIcon.style.color = '#10b981';
|
||||
|
||||
@@ -14,7 +14,14 @@ export function getWeekYear(d) {
|
||||
return date.getFullYear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Translates an English day name to the UI language.
|
||||
* Returns German by default; returns English when langMode is 'en'.
|
||||
* @param {string} englishDay - Day name in English (e.g. 'Monday')
|
||||
* @returns {string} Translated day name
|
||||
*/
|
||||
export function translateDay(englishDay) {
|
||||
if (langMode === 'en') return englishDay;
|
||||
const map = { Monday: 'Montag', Tuesday: 'Dienstag', Wednesday: 'Mittwoch', Thursday: 'Donnerstag', Friday: 'Freitag', Saturday: 'Samstag', Sunday: 'Sonntag' };
|
||||
return map[englishDay] || englishDay;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user