Compare commits

...

10 Commits

Author SHA1 Message Date
Kantine Wrapper
c841954c5d dist files for v1.4.8 built 2026-02-24 12:44:07 +01:00
Kantine Wrapper
320c4066f3 dist files for v1.4.7 built 2026-02-24 12:43:35 +01:00
Kantine Wrapper
cda74e65db dist files for v1.4.7 built 2026-02-24 12:32:44 +01:00
Kantine Wrapper
d1a19b043d dist files for v1.4.6 built 2026-02-24 11:11:15 +01:00
Kantine Wrapper
8c4de96432 dist files for v1.4.5 built 2026-02-24 10:52:53 +01:00
Kantine Wrapper
ce7d8a3de5 dist files for v1.4.4 built 2026-02-24 10:46:06 +01:00
Kantine Wrapper
0309f488bd dist files for v1.4.4 built 2026-02-24 10:46:00 +01:00
Kantine Wrapper
d82762430f dist files for v1.4.3 built 2026-02-24 10:26:59 +01:00
Kantine Wrapper
54e5ada03d dist files for v1.4.3 built 2026-02-24 08:37:52 +01:00
Kantine Wrapper
136fe7d355 dist files for v1.4.2 built 2026-02-23 08:24:51 +01:00
12 changed files with 500 additions and 90 deletions

View File

@@ -54,10 +54,13 @@ Das System umfasst die Darstellung von Menüplänen in einer Wochenübersicht, d
| FR-071 | Die Hervorhebung muss anhand von Menüname und Beschreibung erfolgen (Substring-Matching, case-insensitive). | Niedrig | v1.1.0 | | FR-071 | Die Hervorhebung muss anhand von Menüname und Beschreibung erfolgen (Substring-Matching, case-insensitive). | Niedrig | v1.1.0 |
| FR-072 | Hervorgehobene Menüs müssen ein Tag-Badge mit dem matchenden Schlagwort anzeigen. | Niedrig | v1.2.4 | | FR-072 | Hervorgehobene Menüs müssen ein Tag-Badge mit dem matchenden Schlagwort anzeigen. | Niedrig | v1.2.4 |
| FR-073 | Die Nächste-Woche-Navigation muss die Anzahl der dort gefundenen Highlights als Badge anzeigen. | Niedrig | v1.2.5 | | FR-073 | Die Nächste-Woche-Navigation muss die Anzahl der dort gefundenen Highlights als Badge anzeigen. | Niedrig | v1.2.5 |
| **Darstellung & Theming** | | | |
| FR-080 | Das System muss einen hellen und einen dunklen Darstellungsmodus (Light/Dark Theme) unterstützen. | Niedrig | v1.0.1 | | FR-080 | Das System muss einen hellen und einen dunklen Darstellungsmodus (Light/Dark Theme) unterstützen. | Niedrig | v1.0.1 |
| FR-081 | Die Theme-Präferenz des Benutzers muss persistent gespeichert werden. | Niedrig | v1.0.1 | | FR-081 | Die Theme-Präferenz des Benutzers muss persistent gespeichert werden. | Niedrig | v1.0.1 |
| FR-082 | Das System muss beim erstmaligen Laden die Betriebssystem-Präferenz für das Farbschema berücksichtigen. | Niedrig | v1.0.1 | | FR-082 | Das System muss beim erstmaligen Laden die Betriebssystem-Präferenz für das Farbschema berücksichtigen. | Niedrig | v1.0.1 |
| **Header UI & Navigation** | | | |
| FR-090 | Die Hauptnavigation (Wochen-Toggles) muss linksbündig neben dem App-Titel positioniert sein. | Niedrig | v1.5.0 |
| FR-091 | Ein dynamisches Alarm-Icon im Header muss den Überwachungsstatus geflaggter Menüs anzeigen (Gelb=Überwachung aktiv, Grün=Menü verfügbar, Versteckt=keine Flags). Der Tooltip muss den Zeitpunkt der letzten Prüfung als relativen String (z.B. "vor 4 Min.") enthalten. | Mittel | v1.5.0 |
| FR-092 | Sobald über den Daten-Refresh erstmals Menüdaten für die Nächste Woche geladen werden, muss der entsprechende Navigation-Button animiert und farblich (Gelb) hervorgehoben werden. Zusätzlich muss einmalig ein Hinweis eingeblendet werden. Bei Klick auf den Button muss die Hervorhebung erlöschen. | Mittel | v1.6.0 |
| **Benutzer-Feedback** | | | | | **Benutzer-Feedback** | | | |
| FR-090 | Alle benutzerrelevanten Aktionen (Bestellung, Stornierung, Fehler) müssen durch nicht-blockierende Benachrichtigungen (Toasts) bestätigt werden. | Mittel | v1.0.1 | | FR-090 | Alle benutzerrelevanten Aktionen (Bestellung, Stornierung, Fehler) müssen durch nicht-blockierende Benachrichtigungen (Toasts) bestätigt werden. | Mittel | v1.0.1 |
| FR-091 | Bei einem Verbindungsfehler muss ein Fehlerdialog mit Fallback-Link zur Originalseite angezeigt werden. | Mittel | v1.0.1 | | FR-091 | Bei einem Verbindungsfehler muss ein Fehlerdialog mit Fallback-Link zur Originalseite angezeigt werden. | Mittel | v1.0.1 |
@@ -69,6 +72,7 @@ Das System umfasst die Darstellung von Menüplänen in einer Wochenübersicht, d
| FR-112 | Benutzer müssen eine Versionsliste mit Installationslinks einsehen können (Versionsmenü). | Niedrig | v1.3.0 | | FR-112 | Benutzer müssen eine Versionsliste mit Installationslinks einsehen können (Versionsmenü). | Niedrig | v1.3.0 |
| FR-113 | Es muss möglich sein, zu einer älteren Version zurückzukehren (Downgrade). | Niedrig | v1.3.0 | | FR-113 | Es muss möglich sein, zu einer älteren Version zurückzukehren (Downgrade). | Niedrig | v1.3.0 |
| FR-114 | Ein Dev-Mode muss es ermöglichen, zwischen stabilen Releases und Entwicklungs-Tags umzuschalten. | Niedrig | v1.3.0 | | FR-114 | Ein Dev-Mode muss es ermöglichen, zwischen stabilen Releases und Entwicklungs-Tags umzuschalten. | Niedrig | v1.3.0 |
| FR-115 | Das Versionsmenü muss Links zur Erstellung von Feature-Requests und Bug-Reports auf GitHub enthalten. | Niedrig | v1.4.4 |
## 3. Nicht-funktionale Anforderungen ## 3. Nicht-funktionale Anforderungen

Binary file not shown.

View File

@@ -171,7 +171,7 @@ cat > "$DIST_DIR/install.html" << INSTALLEOF
</div> </div>
<div style="text-align: center; margin-top: 40px; color: #5c6b7f; font-size: 0.8rem;"> <div style="text-align: center; margin-top: 40px; color: #5c6b7f; font-size: 0.8rem;">
<p>Powered by <strong>Kaufi-Kitchen</strong> 👨‍🍳</p> <p>Powered by <strong>Kaufis-Kitchen</strong> 👨‍🍳</p>
</div> </div>

View File

@@ -1,3 +1,26 @@
## v1.4.8 (2026-02-24)
- **Fix**: Die Benachrichtigungs-Glocke wird nun korrekt in Gelb dargestellt, wenn beobachtete Menüs verfügbar sind.
- **Tools**: Fehler in Testskript behoben, der den CI/CD Build verlangsamt hat.
## v1.4.7 (2026-02-24)
- **Performance**: Die Bestellhistorie nutzt nun einen inkrementellen Delta-Cache anstatt immer alle Seiten von der API herunterzuladen, was die Ladezeiten für Vielbesteller enorm reduziert.
## v1.4.6 (2026-02-24)
- **Fix**: Die Umrandung für bereits bestellte Menüs der vergangenen Tage ist nun ebenfalls einheitlich violett statt blau.
## v1.4.5 (2026-02-24)
- **Fix**: Doppelten Scrollbalken in der Versionen-Liste entfernt.
## v1.4.4 (2026-02-24)
- **Feature**: Das Versionsmenü enthält nun direkte Links zu GitHub, um Fehler zu melden oder neue Features vorzuschlagen.
## v1.4.3 (2026-02-24)
- **Fix**: Der Rahmen des "Heute Bestellt" Menüs ist nun konsequent violett (passend zum Glow-Effekt).
## v1.4.2 (2026-02-23)
- **Fix**: Das "Heute Bestellt" Menü leuchtet nun stimmig im Design-Violett statt Blau.
- **Fix**: Abfangen des GitHub API Rate Limit (403) im Versionsdialog mit einer freundlicheren Fehlermeldung, da der User-Agent im Browser nicht manuell gesetzt werden darf.
## v1.4.1 (2026-02-22) ## v1.4.1 (2026-02-22)
- **UX Verbesserungen**: Bestellhistorie gruppiert nach Jahren und Monaten mittels einklappbarem Akkordeon. Monatssummen integriert und Stati farblich abgehoben (Offen, Abgeschlossen, Storniert). - **UX Verbesserungen**: Bestellhistorie gruppiert nach Jahren und Monaten mittels einklappbarem Akkordeon. Monatssummen integriert und Stati farblich abgehoben (Offen, Abgeschlossen, Storniert).

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

44
dist/install.html vendored

File diff suppressed because one or more lines are too long

View File

@@ -197,6 +197,32 @@ body {
color: white; color: white;
} }
/* Notification state for Next Week */
.nav-btn.new-week-available {
animation: goldPulse 2s infinite;
border-color: #f59e0b;
color: var(--accent-color);
}
.nav-btn.new-week-available.active {
color: white;
}
@keyframes goldPulse {
0% {
box-shadow: 0 0 0 0 rgba(245, 158, 11, 0.7);
}
70% {
box-shadow: 0 0 0 10px rgba(245, 158, 11, 0);
}
100% {
box-shadow: 0 0 0 0 rgba(245, 158, 11, 0);
}
}
/* Badge for nav buttons (day count indicator) */ /* Badge for nav buttons (day count indicator) */
.nav-badge { .nav-badge {
background-color: var(--error-color); background-color: var(--error-color);
@@ -786,7 +812,7 @@ body {
/* No opacity/filter here - fully visible */ /* No opacity/filter here - fully visible */
background: var(--bg-card); background: var(--bg-card);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
border: 1px solid var(--accent-color); border: 1px solid #8b5cf6;
border-radius: 8px; border-radius: 8px;
padding: 1rem; padding: 1rem;
margin: 0 -1rem 1.5rem -1rem; margin: 0 -1rem 1.5rem -1rem;
@@ -795,8 +821,8 @@ body {
} }
.menu-item.today-ordered { .menu-item.today-ordered {
border: 2px solid var(--accent-color); border: 2px solid #8b5cf6;
box-shadow: 0 0 20px rgba(96, 165, 250, 0.4); box-shadow: 0 0 20px rgba(139, 92, 246, 0.4);
border-radius: 8px; border-radius: 8px;
padding: 1rem; padding: 1rem;
margin: 0 -1rem 1.5rem -1rem; margin: 0 -1rem 1.5rem -1rem;
@@ -808,15 +834,15 @@ body {
@keyframes pulse-glow { @keyframes pulse-glow {
0% { 0% {
box-shadow: 0 0 15px rgba(96, 165, 250, 0.3); box-shadow: 0 0 15px rgba(139, 92, 246, 0.3);
} }
50% { 50% {
box-shadow: 0 0 25px rgba(96, 165, 250, 0.6); box-shadow: 0 0 25px rgba(139, 92, 246, 0.6);
} }
100% { 100% {
box-shadow: 0 0 15px rgba(96, 165, 250, 0.3); box-shadow: 0 0 15px rgba(139, 92, 246, 0.3);
} }
} }
@@ -1606,8 +1632,6 @@ body {
.version-list { .version-list {
list-style: none; list-style: none;
padding: 0; padding: 0;
max-height: 350px;
overflow-y: auto;
margin: 0; margin: 0;
} }
@@ -1997,9 +2021,16 @@ body {
<div class="brand"> <div class="brand">
<span class="material-icons-round logo-icon">restaurant_menu</span> <span class="material-icons-round logo-icon">restaurant_menu</span>
<div class="header-left"> <div class="header-left">
<h1>Kantinen Übersicht <small class="version-tag" style="font-size: 0.6em; opacity: 0.7; font-weight: 400; cursor: pointer;" title="Klick für Versionsmenü">v1.4.1</small></h1> <h1>Kantinen Übersicht <small class="version-tag" style="font-size: 0.6em; opacity: 0.7; font-weight: 400; cursor: pointer;" title="Klick für Versionsmenü">v1.4.8</small></h1>
<div id="last-updated-subtitle" class="subtitle"></div> <div id="last-updated-subtitle" class="subtitle"></div>
</div> </div>
<div class="nav-group" style="margin-left: 1rem;">
<button id="btn-this-week" class="nav-btn active">Diese Woche</button>
<button id="btn-next-week" class="nav-btn">Nächste Woche</button>
</div>
<button id="alarm-bell" class="icon-btn hidden" aria-label="Benachrichtigungen" title="Keine beobachteten Menüs" style="margin-left: -0.5rem;">
<span class="material-icons-round" id="alarm-bell-icon" style="color:var(--text-secondary); transition: color 0.3s;">notifications</span>
</button>
</div> </div>
<div class="header-center-wrapper"> <div class="header-center-wrapper">
<div id="header-week-info" class="header-week-info"></div> <div id="header-week-info" class="header-week-info"></div>
@@ -2015,10 +2046,6 @@ body {
<button id="btn-highlights" class="icon-btn" aria-label="Persönliche Highlights verwalten" title="Persönliche Highlights verwalten"> <button id="btn-highlights" class="icon-btn" aria-label="Persönliche Highlights verwalten" title="Persönliche Highlights verwalten">
<span class="material-icons-round">label</span> <span class="material-icons-round">label</span>
</button> </button>
<div class="nav-group">
<button id="btn-this-week" class="nav-btn active">Diese Woche</button>
<button id="btn-next-week" class="nav-btn">Nächste Woche</button>
</div>
<button id="theme-toggle" class="icon-btn" aria-label="Toggle Theme"> <button id="theme-toggle" class="icon-btn" aria-label="Toggle Theme">
<span class="material-icons-round theme-icon">light_mode</span> <span class="material-icons-round theme-icon">light_mode</span>
</button> </button>
@@ -2136,7 +2163,7 @@ body {
</div> </div>
<div class="modal-body"> <div class="modal-body">
<div style="margin-bottom: 1rem;"> <div style="margin-bottom: 1rem;">
<strong>Aktuell:</strong> <span id="version-current">v1.4.1</span> <strong>Aktuell:</strong> <span id="version-current">v1.4.8</span>
</div> </div>
<div class="dev-toggle"> <div class="dev-toggle">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer;"> <label style="display:flex;align-items:center;gap:8px;cursor:pointer;">
@@ -2144,9 +2171,17 @@ body {
<span>Dev-Mode (alle Tags anzeigen)</span> <span>Dev-Mode (alle Tags anzeigen)</span>
</label> </label>
</div> </div>
<div id="version-list-container" style="margin-top:1rem;"> <div id="version-list-container" style="margin-top:1rem; max-height: 250px; overflow-y: auto;">
<p style="color:var(--text-secondary);">Lade Versionen...</p> <p style="color:var(--text-secondary);">Lade Versionen...</p>
</div> </div>
<div style="margin-top: 1.5rem; padding-top: 1rem; border-top: 1px solid var(--border-color); display: flex; flex-direction: column; gap: 0.75rem; font-size: 0.9em;">
<a href="https://github.com/TauNeutrino/kantine-overview/issues" target="_blank" rel="noopener noreferrer" style="color: var(--primary-color); text-decoration: none; display: flex; align-items: center; gap: 0.5rem;" title="Melde einen Fehler auf GitHub">
<span class="material-icons-round" style="font-size: 1.2em;">bug_report</span> Fehler melden
</a>
<a href="https://github.com/TauNeutrino/kantine-overview/discussions/categories/ideas" target="_blank" rel="noopener noreferrer" style="color: var(--primary-color); text-decoration: none; display: flex; align-items: center; gap: 0.5rem;" title="Schlage ein neues Feature auf GitHub vor">
<span class="material-icons-round" style="font-size: 1.2em;">lightbulb</span> Feature vorschlagen
</a>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -2280,6 +2315,7 @@ body {
}); });
btnNextWeek.addEventListener('click', () => { btnNextWeek.addEventListener('click', () => {
btnNextWeek.classList.remove('new-week-available');
if (displayMode !== 'next-week') { if (displayMode !== 'next-week') {
displayMode = 'next-week'; displayMode = 'next-week';
btnNextWeek.classList.add('active'); btnNextWeek.classList.add('active');
@@ -2484,24 +2520,49 @@ body {
const progressFill = document.getElementById('history-progress-fill'); const progressFill = document.getElementById('history-progress-fill');
const progressText = document.getElementById('history-progress-text'); const progressText = document.getElementById('history-progress-text');
// Check local storage cache (we still use memory cache if available)
let localCache = [];
if (fullOrderHistoryCache) { if (fullOrderHistoryCache) {
renderHistory(fullOrderHistoryCache); localCache = fullOrderHistoryCache;
return; } 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);
}
}
}
// Show cached version immediately if we have one
if (localCache.length > 0) {
renderHistory(localCache);
} }
if (!authToken) return; if (!authToken) return;
historyContent.innerHTML = ''; // Start background delta sync
historyLoading.classList.remove('hidden'); if (localCache.length === 0) {
progressFill.style.width = '0%'; historyContent.innerHTML = '';
progressText.textContent = 'Lade Bestellhistorie...'; historyLoading.classList.remove('hidden');
}
let nextUrl = `${API_BASE}/user/orders/?venue=${VENUE_ID}&ordering=-created&limit=50`; progressFill.style.width = '0%';
let allOrders = []; 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 totalCount = 0;
let requiresFullFetch = localCache.length === 0;
let deltaComplete = false;
try { try {
while (nextUrl) { while (nextUrl && !deltaComplete) {
const response = await fetch(nextUrl, { headers: apiHeaders(authToken) }); const response = await fetch(nextUrl, { headers: apiHeaders(authToken) });
if (!response.ok) throw new Error(`Fetch failed: ${response.status}`); if (!response.ok) throw new Error(`Fetch failed: ${response.status}`);
@@ -2511,26 +2572,75 @@ body {
totalCount = data.count; totalCount = data.count;
} }
allOrders = allOrders.concat(data.results || []); const results = data.results || [];
// Update progress for (const order of results) {
if (totalCount > 0) { // Check if we hit an order that is already in our cache AND has the exact same state/update time
const pct = Math.round((allOrders.length / totalCount) * 100); // Bessa returns 'updated' timestamp, we can use it to determine if anything changed
progressFill.style.width = `${pct}%`; const existingOrderIndex = localCache.findIndex(cached => cached.id === order.id);
progressText.textContent = `Lade Bestellung ${allOrders.length} von ${totalCount}...`;
} else { if (!requiresFullFetch && existingOrderIndex !== -1) {
progressText.textContent = `Lade Bestellung ${allOrders.length}...`; const existingOrder = localCache[existingOrderIndex];
// If order exists and wasn't updated since our cache, we've reached the point
// where everything older is already correctly cached.
// order.updated is an ISO string like "2025-02-18T10:30:15.123456Z"
if (existingOrder.updated === order.updated && existingOrder.order_state === order.order_state) {
deltaComplete = true;
break;
}
}
fetchedOrders.push(order);
} }
nextUrl = data.next; // Update progress
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;
} }
fullOrderHistoryCache = allOrders; // Merge fetched orders with cache
renderHistory(fullOrderHistoryCache); if (fetchedOrders.length > 0) {
// We have new/updated orders. We need to merge them into the cache.
// 1. Create a map of the existing cache for quick ID lookup
const cacheMap = new Map(localCache.map(o => [o.id, o]));
// 2. Update/Insert the newly fetched orders
for (const order of fetchedOrders) {
cacheMap.set(order.id, order); // Overwrites existing, or adds new
}
// 3. Convert back to array and sort by created date (descending)
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);
}
// Render the updated history
renderHistory(fullOrderHistoryCache);
}
} catch (error) { } catch (error) {
console.error('Error fetching full history:', error); console.error('Error in history sync:', error);
historyContent.innerHTML = `<p style="color:var(--error-color);text-align:center;">Fehler beim Laden der Historie.</p>`; if (localCache.length === 0) {
historyContent.innerHTML = `<p style="color:var(--error-color);text-align:center;">Fehler beim Laden der Historie.</p>`;
} else {
showToast('Hintergrund-Synchronisation fehlgeschlagen', 'error');
}
} finally { } finally {
historyLoading.classList.add('hidden'); historyLoading.classList.add('hidden');
} }
@@ -2732,6 +2842,7 @@ body {
if (response.ok || response.status === 201) { if (response.ok || response.status === 201) {
showToast(`Bestellt: ${name}`, 'success'); showToast(`Bestellt: ${name}`, 'success');
fullOrderHistoryCache = null; // Clear memory cache so next history open triggers delta sync
await fetchOrders(); await fetchOrders();
} else { } else {
const data = await response.json(); const data = await response.json();
@@ -2761,6 +2872,7 @@ body {
if (response.ok) { if (response.ok) {
showToast(`Storniert: ${name}`, 'success'); showToast(`Storniert: ${name}`, 'success');
fullOrderHistoryCache = null; // Clear memory cache so next history open triggers delta sync
await fetchOrders(); await fetchOrders();
} else { } else {
const data = await response.json(); const data = await response.json();
@@ -2777,6 +2889,55 @@ body {
localStorage.setItem('kantine_flags', JSON.stringify([...userFlags])); localStorage.setItem('kantine_flags', JSON.stringify([...userFlags]));
} }
function updateAlarmBell() {
const bellBtn = document.getElementById('alarm-bell');
const bellIcon = document.getElementById('alarm-bell-icon');
if (!bellBtn || !bellIcon) return;
if (userFlags.size === 0) {
bellBtn.classList.add('hidden');
return;
}
bellBtn.classList.remove('hidden');
// Check if any flagged item is available
let anyAvailable = false;
for (const wk of allWeeks) {
if (!wk.days) continue;
for (const d of wk.days) {
if (!d.items) continue;
for (const item of d.items) {
if (item.available && userFlags.has(item.id)) {
anyAvailable = true;
break;
}
}
if (anyAvailable) break;
}
if (anyAvailable) break;
}
const lastUpdatedStr = localStorage.getItem('kantine_last_updated');
let timeStr = 'Unbekannt';
if (lastUpdatedStr) {
const lastUpdated = new Date(lastUpdatedStr);
const diffMs = Date.now() - lastUpdated.getTime();
const diffMins = Math.floor(diffMs / 60000);
if (diffMins < 60) timeStr = `vor ${diffMins} Min.`;
else timeStr = `vor ${Math.floor(diffMins / 60)} Std.`;
}
bellBtn.title = `Zuletzt geprüft: ${timeStr}`;
if (anyAvailable) {
bellIcon.style.color = 'var(--warning-color)';
bellIcon.style.textShadow = '0 0 10px rgba(245, 158, 11, 0.4)';
} else {
bellIcon.style.color = 'var(--text-secondary)';
bellIcon.style.textShadow = 'none';
}
}
function toggleFlag(date, articleId, name, cutoff) { function toggleFlag(date, articleId, name, cutoff) {
const id = `${date}_${articleId}`; const id = `${date}_${articleId}`;
if (userFlags.has(id)) { if (userFlags.has(id)) {
@@ -2790,6 +2951,7 @@ body {
} }
} }
saveFlags(); saveFlags();
updateAlarmBell();
renderVisibleWeeks(); renderVisibleWeeks();
} }
@@ -3158,6 +3320,7 @@ body {
updateAuthUI(); // This will trigger fetchOrders if logged in updateAuthUI(); // This will trigger fetchOrders if logged in
renderVisibleWeeks(); renderVisibleWeeks();
updateNextWeekBadge(); updateNextWeekBadge();
updateAlarmBell();
progressMessage.textContent = 'Fertig!'; progressMessage.textContent = 'Fertig!';
setTimeout(() => progressModal.classList.add('hidden'), 500); setTimeout(() => progressModal.classList.add('hidden'), 500);
@@ -3315,6 +3478,14 @@ body {
badge.classList.add('has-highlights'); badge.classList.add('has-highlights');
} }
// FR-092: Highlight Next Week Button when new data arrives
const storageKey = `kantine_notified_nextweek_${nextYear}_${nextWeek}`;
if (!localStorage.getItem(storageKey)) {
localStorage.setItem(storageKey, 'true');
btnNextWeek.classList.add('new-week-available');
showToast('Neue Menüdaten für nächste Woche verfügbar!', 'info');
}
} else if (badge) { } else if (badge) {
badge.remove(); badge.remove();
} }
@@ -3699,7 +3870,12 @@ body {
: `${GITHUB_API}/releases?per_page=20`; : `${GITHUB_API}/releases?per_page=20`;
const resp = await fetch(endpoint, { headers: githubHeaders() }); const resp = await fetch(endpoint, { headers: githubHeaders() });
if (!resp.ok) throw new Error(`GitHub API ${resp.status}`); 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(); const data = await resp.json();
// Normalize to common format: { tag, name, url, body } // Normalize to common format: { tag, name, url, body }
@@ -3716,7 +3892,7 @@ body {
// Periodic update check (runs on init + every hour) // Periodic update check (runs on init + every hour)
async function checkForUpdates() { async function checkForUpdates() {
const currentVersion = 'v1.4.1'; const currentVersion = 'v1.4.8';
const devMode = localStorage.getItem('kantine_dev_mode') === 'true'; const devMode = localStorage.getItem('kantine_dev_mode') === 'true';
try { try {
@@ -3757,7 +3933,7 @@ body {
const modal = document.getElementById('version-modal'); const modal = document.getElementById('version-modal');
const container = document.getElementById('version-list-container'); const container = document.getElementById('version-list-container');
const devToggle = document.getElementById('dev-mode-toggle'); const devToggle = document.getElementById('dev-mode-toggle');
const currentVersion = 'v1.4.1'; const currentVersion = 'v1.4.8';
if (!modal) return; if (!modal) return;
modal.classList.remove('hidden'); modal.classList.remove('hidden');

View File

@@ -74,6 +74,13 @@
<h1>Kantinen Übersicht <small class="version-tag" style="font-size: 0.6em; opacity: 0.7; font-weight: 400; cursor: pointer;" title="Klick für Versionsmenü">{{VERSION}}</small></h1> <h1>Kantinen Übersicht <small class="version-tag" style="font-size: 0.6em; opacity: 0.7; font-weight: 400; cursor: pointer;" title="Klick für Versionsmenü">{{VERSION}}</small></h1>
<div id="last-updated-subtitle" class="subtitle"></div> <div id="last-updated-subtitle" class="subtitle"></div>
</div> </div>
<div class="nav-group" style="margin-left: 1rem;">
<button id="btn-this-week" class="nav-btn active">Diese Woche</button>
<button id="btn-next-week" class="nav-btn">Nächste Woche</button>
</div>
<button id="alarm-bell" class="icon-btn hidden" aria-label="Benachrichtigungen" title="Keine beobachteten Menüs" style="margin-left: -0.5rem;">
<span class="material-icons-round" id="alarm-bell-icon" style="color:var(--text-secondary); transition: color 0.3s;">notifications</span>
</button>
</div> </div>
<div class="header-center-wrapper"> <div class="header-center-wrapper">
<div id="header-week-info" class="header-week-info"></div> <div id="header-week-info" class="header-week-info"></div>
@@ -89,10 +96,6 @@
<button id="btn-highlights" class="icon-btn" aria-label="Persönliche Highlights verwalten" title="Persönliche Highlights verwalten"> <button id="btn-highlights" class="icon-btn" aria-label="Persönliche Highlights verwalten" title="Persönliche Highlights verwalten">
<span class="material-icons-round">label</span> <span class="material-icons-round">label</span>
</button> </button>
<div class="nav-group">
<button id="btn-this-week" class="nav-btn active">Diese Woche</button>
<button id="btn-next-week" class="nav-btn">Nächste Woche</button>
</div>
<button id="theme-toggle" class="icon-btn" aria-label="Toggle Theme"> <button id="theme-toggle" class="icon-btn" aria-label="Toggle Theme">
<span class="material-icons-round theme-icon">light_mode</span> <span class="material-icons-round theme-icon">light_mode</span>
</button> </button>
@@ -218,9 +221,17 @@
<span>Dev-Mode (alle Tags anzeigen)</span> <span>Dev-Mode (alle Tags anzeigen)</span>
</label> </label>
</div> </div>
<div id="version-list-container" style="margin-top:1rem;"> <div id="version-list-container" style="margin-top:1rem; max-height: 250px; overflow-y: auto;">
<p style="color:var(--text-secondary);">Lade Versionen...</p> <p style="color:var(--text-secondary);">Lade Versionen...</p>
</div> </div>
<div style="margin-top: 1.5rem; padding-top: 1rem; border-top: 1px solid var(--border-color); display: flex; flex-direction: column; gap: 0.75rem; font-size: 0.9em;">
<a href="https://github.com/TauNeutrino/kantine-overview/issues" target="_blank" rel="noopener noreferrer" style="color: var(--primary-color); text-decoration: none; display: flex; align-items: center; gap: 0.5rem;" title="Melde einen Fehler auf GitHub">
<span class="material-icons-round" style="font-size: 1.2em;">bug_report</span> Fehler melden
</a>
<a href="https://github.com/TauNeutrino/kantine-overview/discussions/categories/ideas" target="_blank" rel="noopener noreferrer" style="color: var(--primary-color); text-decoration: none; display: flex; align-items: center; gap: 0.5rem;" title="Schlage ein neues Feature auf GitHub vor">
<span class="material-icons-round" style="font-size: 1.2em;">lightbulb</span> Feature vorschlagen
</a>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -354,6 +365,7 @@
}); });
btnNextWeek.addEventListener('click', () => { btnNextWeek.addEventListener('click', () => {
btnNextWeek.classList.remove('new-week-available');
if (displayMode !== 'next-week') { if (displayMode !== 'next-week') {
displayMode = 'next-week'; displayMode = 'next-week';
btnNextWeek.classList.add('active'); btnNextWeek.classList.add('active');
@@ -558,24 +570,49 @@
const progressFill = document.getElementById('history-progress-fill'); const progressFill = document.getElementById('history-progress-fill');
const progressText = document.getElementById('history-progress-text'); const progressText = document.getElementById('history-progress-text');
// Check local storage cache (we still use memory cache if available)
let localCache = [];
if (fullOrderHistoryCache) { if (fullOrderHistoryCache) {
renderHistory(fullOrderHistoryCache); localCache = fullOrderHistoryCache;
return; } 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);
}
}
}
// Show cached version immediately if we have one
if (localCache.length > 0) {
renderHistory(localCache);
} }
if (!authToken) return; if (!authToken) return;
historyContent.innerHTML = ''; // Start background delta sync
historyLoading.classList.remove('hidden'); if (localCache.length === 0) {
progressFill.style.width = '0%'; historyContent.innerHTML = '';
progressText.textContent = 'Lade Bestellhistorie...'; historyLoading.classList.remove('hidden');
}
let nextUrl = `${API_BASE}/user/orders/?venue=${VENUE_ID}&ordering=-created&limit=50`; progressFill.style.width = '0%';
let allOrders = []; 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 totalCount = 0;
let requiresFullFetch = localCache.length === 0;
let deltaComplete = false;
try { try {
while (nextUrl) { while (nextUrl && !deltaComplete) {
const response = await fetch(nextUrl, { headers: apiHeaders(authToken) }); const response = await fetch(nextUrl, { headers: apiHeaders(authToken) });
if (!response.ok) throw new Error(`Fetch failed: ${response.status}`); if (!response.ok) throw new Error(`Fetch failed: ${response.status}`);
@@ -585,26 +622,75 @@
totalCount = data.count; totalCount = data.count;
} }
allOrders = allOrders.concat(data.results || []); const results = data.results || [];
// Update progress for (const order of results) {
if (totalCount > 0) { // Check if we hit an order that is already in our cache AND has the exact same state/update time
const pct = Math.round((allOrders.length / totalCount) * 100); // Bessa returns 'updated' timestamp, we can use it to determine if anything changed
progressFill.style.width = `${pct}%`; const existingOrderIndex = localCache.findIndex(cached => cached.id === order.id);
progressText.textContent = `Lade Bestellung ${allOrders.length} von ${totalCount}...`;
} else { if (!requiresFullFetch && existingOrderIndex !== -1) {
progressText.textContent = `Lade Bestellung ${allOrders.length}...`; const existingOrder = localCache[existingOrderIndex];
// If order exists and wasn't updated since our cache, we've reached the point
// where everything older is already correctly cached.
// order.updated is an ISO string like "2025-02-18T10:30:15.123456Z"
if (existingOrder.updated === order.updated && existingOrder.order_state === order.order_state) {
deltaComplete = true;
break;
}
}
fetchedOrders.push(order);
} }
nextUrl = data.next; // Update progress
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;
} }
fullOrderHistoryCache = allOrders; // Merge fetched orders with cache
renderHistory(fullOrderHistoryCache); if (fetchedOrders.length > 0) {
// We have new/updated orders. We need to merge them into the cache.
// 1. Create a map of the existing cache for quick ID lookup
const cacheMap = new Map(localCache.map(o => [o.id, o]));
// 2. Update/Insert the newly fetched orders
for (const order of fetchedOrders) {
cacheMap.set(order.id, order); // Overwrites existing, or adds new
}
// 3. Convert back to array and sort by created date (descending)
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);
}
// Render the updated history
renderHistory(fullOrderHistoryCache);
}
} catch (error) { } catch (error) {
console.error('Error fetching full history:', error); console.error('Error in history sync:', error);
historyContent.innerHTML = `<p style="color:var(--error-color);text-align:center;">Fehler beim Laden der Historie.</p>`; if (localCache.length === 0) {
historyContent.innerHTML = `<p style="color:var(--error-color);text-align:center;">Fehler beim Laden der Historie.</p>`;
} else {
showToast('Hintergrund-Synchronisation fehlgeschlagen', 'error');
}
} finally { } finally {
historyLoading.classList.add('hidden'); historyLoading.classList.add('hidden');
} }
@@ -806,6 +892,7 @@
if (response.ok || response.status === 201) { if (response.ok || response.status === 201) {
showToast(`Bestellt: ${name}`, 'success'); showToast(`Bestellt: ${name}`, 'success');
fullOrderHistoryCache = null; // Clear memory cache so next history open triggers delta sync
await fetchOrders(); await fetchOrders();
} else { } else {
const data = await response.json(); const data = await response.json();
@@ -835,6 +922,7 @@
if (response.ok) { if (response.ok) {
showToast(`Storniert: ${name}`, 'success'); showToast(`Storniert: ${name}`, 'success');
fullOrderHistoryCache = null; // Clear memory cache so next history open triggers delta sync
await fetchOrders(); await fetchOrders();
} else { } else {
const data = await response.json(); const data = await response.json();
@@ -851,6 +939,55 @@
localStorage.setItem('kantine_flags', JSON.stringify([...userFlags])); localStorage.setItem('kantine_flags', JSON.stringify([...userFlags]));
} }
function updateAlarmBell() {
const bellBtn = document.getElementById('alarm-bell');
const bellIcon = document.getElementById('alarm-bell-icon');
if (!bellBtn || !bellIcon) return;
if (userFlags.size === 0) {
bellBtn.classList.add('hidden');
return;
}
bellBtn.classList.remove('hidden');
// Check if any flagged item is available
let anyAvailable = false;
for (const wk of allWeeks) {
if (!wk.days) continue;
for (const d of wk.days) {
if (!d.items) continue;
for (const item of d.items) {
if (item.available && userFlags.has(item.id)) {
anyAvailable = true;
break;
}
}
if (anyAvailable) break;
}
if (anyAvailable) break;
}
const lastUpdatedStr = localStorage.getItem('kantine_last_updated');
let timeStr = 'Unbekannt';
if (lastUpdatedStr) {
const lastUpdated = new Date(lastUpdatedStr);
const diffMs = Date.now() - lastUpdated.getTime();
const diffMins = Math.floor(diffMs / 60000);
if (diffMins < 60) timeStr = `vor ${diffMins} Min.`;
else timeStr = `vor ${Math.floor(diffMins / 60)} Std.`;
}
bellBtn.title = `Zuletzt geprüft: ${timeStr}`;
if (anyAvailable) {
bellIcon.style.color = 'var(--warning-color)';
bellIcon.style.textShadow = '0 0 10px rgba(245, 158, 11, 0.4)';
} else {
bellIcon.style.color = 'var(--text-secondary)';
bellIcon.style.textShadow = 'none';
}
}
function toggleFlag(date, articleId, name, cutoff) { function toggleFlag(date, articleId, name, cutoff) {
const id = `${date}_${articleId}`; const id = `${date}_${articleId}`;
if (userFlags.has(id)) { if (userFlags.has(id)) {
@@ -864,6 +1001,7 @@
} }
} }
saveFlags(); saveFlags();
updateAlarmBell();
renderVisibleWeeks(); renderVisibleWeeks();
} }
@@ -1232,6 +1370,7 @@
updateAuthUI(); // This will trigger fetchOrders if logged in updateAuthUI(); // This will trigger fetchOrders if logged in
renderVisibleWeeks(); renderVisibleWeeks();
updateNextWeekBadge(); updateNextWeekBadge();
updateAlarmBell();
progressMessage.textContent = 'Fertig!'; progressMessage.textContent = 'Fertig!';
setTimeout(() => progressModal.classList.add('hidden'), 500); setTimeout(() => progressModal.classList.add('hidden'), 500);
@@ -1389,6 +1528,14 @@
badge.classList.add('has-highlights'); badge.classList.add('has-highlights');
} }
// FR-092: Highlight Next Week Button when new data arrives
const storageKey = `kantine_notified_nextweek_${nextYear}_${nextWeek}`;
if (!localStorage.getItem(storageKey)) {
localStorage.setItem(storageKey, 'true');
btnNextWeek.classList.add('new-week-available');
showToast('Neue Menüdaten für nächste Woche verfügbar!', 'info');
}
} else if (badge) { } else if (badge) {
badge.remove(); badge.remove();
} }
@@ -1773,7 +1920,12 @@
: `${GITHUB_API}/releases?per_page=20`; : `${GITHUB_API}/releases?per_page=20`;
const resp = await fetch(endpoint, { headers: githubHeaders() }); const resp = await fetch(endpoint, { headers: githubHeaders() });
if (!resp.ok) throw new Error(`GitHub API ${resp.status}`); 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(); const data = await resp.json();
// Normalize to common format: { tag, name, url, body } // Normalize to common format: { tag, name, url, body }

View File

@@ -186,6 +186,32 @@ body {
color: white; color: white;
} }
/* Notification state for Next Week */
.nav-btn.new-week-available {
animation: goldPulse 2s infinite;
border-color: #f59e0b;
color: var(--accent-color);
}
.nav-btn.new-week-available.active {
color: white;
}
@keyframes goldPulse {
0% {
box-shadow: 0 0 0 0 rgba(245, 158, 11, 0.7);
}
70% {
box-shadow: 0 0 0 10px rgba(245, 158, 11, 0);
}
100% {
box-shadow: 0 0 0 0 rgba(245, 158, 11, 0);
}
}
/* Badge for nav buttons (day count indicator) */ /* Badge for nav buttons (day count indicator) */
.nav-badge { .nav-badge {
background-color: var(--error-color); background-color: var(--error-color);
@@ -775,7 +801,7 @@ body {
/* No opacity/filter here - fully visible */ /* No opacity/filter here - fully visible */
background: var(--bg-card); background: var(--bg-card);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
border: 1px solid var(--accent-color); border: 1px solid #8b5cf6;
border-radius: 8px; border-radius: 8px;
padding: 1rem; padding: 1rem;
margin: 0 -1rem 1.5rem -1rem; margin: 0 -1rem 1.5rem -1rem;
@@ -784,8 +810,8 @@ body {
} }
.menu-item.today-ordered { .menu-item.today-ordered {
border: 2px solid var(--accent-color); border: 2px solid #8b5cf6;
box-shadow: 0 0 20px rgba(96, 165, 250, 0.4); box-shadow: 0 0 20px rgba(139, 92, 246, 0.4);
border-radius: 8px; border-radius: 8px;
padding: 1rem; padding: 1rem;
margin: 0 -1rem 1.5rem -1rem; margin: 0 -1rem 1.5rem -1rem;
@@ -797,15 +823,15 @@ body {
@keyframes pulse-glow { @keyframes pulse-glow {
0% { 0% {
box-shadow: 0 0 15px rgba(96, 165, 250, 0.3); box-shadow: 0 0 15px rgba(139, 92, 246, 0.3);
} }
50% { 50% {
box-shadow: 0 0 25px rgba(96, 165, 250, 0.6); box-shadow: 0 0 25px rgba(139, 92, 246, 0.6);
} }
100% { 100% {
box-shadow: 0 0 15px rgba(96, 165, 250, 0.3); box-shadow: 0 0 15px rgba(139, 92, 246, 0.3);
} }
} }
@@ -1595,8 +1621,6 @@ body {
.version-list { .version-list {
list-style: none; list-style: none;
padding: 0; padding: 0;
max-height: 350px;
overflow-y: auto;
margin: 0; margin: 0;
} }

View File

@@ -108,7 +108,8 @@ try {
[/function\s+isNewer/, 'isNewer function'], [/function\s+isNewer/, 'isNewer function'],
[/function\s+openVersionMenu/, 'openVersionMenu function'], [/function\s+openVersionMenu/, 'openVersionMenu function'],
[/kantine_dev_mode/, 'dev-mode localStorage key'], [/kantine_dev_mode/, 'dev-mode localStorage key'],
[/function\s+isCacheFresh/, 'isCacheFresh function'] [/function\s+isCacheFresh/, 'isCacheFresh function'],
[/limit=5/, 'Delta fetch limit parameter']
]; ];
for (const [regex, label] of checks) { for (const [regex, label] of checks) {

View File

@@ -1 +1 @@
v1.4.1 v1.4.8