Compare commits

..

21 Commits

Author SHA1 Message Date
Kantine Wrapper
7a82cb06db chore: update build artifacts for v1.4.16 2026-02-24 15:47:13 +01:00
Kantine Wrapper
d89b080da5 feat: add clear local cache button to version menu (v1.4.16) 2026-02-24 15:47:13 +01:00
Kantine Wrapper
d80863a169 chore: update build artifacts for v1.4.15 2026-02-24 15:38:44 +01:00
Kantine Wrapper
ae79c58d30 fix(flags): properly remove expired flags from localstorage (v1.4.15) 2026-02-24 15:38:44 +01:00
Kantine Wrapper
a0ef6e631e chore: update build artifacts for v1.4.14 2026-02-24 15:31:48 +01:00
Kantine Wrapper
d895a5fb7c feat: immediate api refresh on flag, fix timestamp fallback (v1.4.14) 2026-02-24 15:31:48 +01:00
Kantine Wrapper
fd765a74c0 chore: update build artifacts for v1.4.13 2026-02-24 13:24:50 +01:00
Kantine Wrapper
1f184fab8b fix: replace css variables with direct hex colors for alarm bell 2026-02-24 13:24:09 +01:00
Kantine Wrapper
b6c7c66027 chore: update build artifacts for v1.4.12 2026-02-24 13:20:03 +01:00
Kantine Wrapper
33bb87d7f4 fix: enforce hidden state and default color of alarm bell 2026-02-24 13:19:38 +01:00
Kantine Wrapper
d4a8a47ccd chore: update build artifacts for v1.4.11 2026-02-24 13:11:09 +01:00
Kantine Wrapper
8e8c93410b feat: background refresh for version menu 2026-02-24 13:11:02 +01:00
Kantine Wrapper
cca59bcace chore: update build artifacts for v1.4.10 2026-02-24 13:05:53 +01:00
Kantine Wrapper
432bbcb6f2 build: separate release logic into release.sh 2026-02-24 13:05:46 +01:00
Kantine Wrapper
accdccf897 docs: sync REQUIREMENTS.md with latest features 2026-02-24 13:00:16 +01:00
Kantine Wrapper
7fc8c6f1e0 dist files for v1.4.10 built 2026-02-24 12:59:19 +01:00
Kantine Wrapper
0eb14a1869 dist files for v1.4.9 built 2026-02-24 12:56:53 +01:00
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
13 changed files with 705 additions and 171 deletions

View File

@@ -40,7 +40,7 @@ Das System umfasst die Darstellung von Menüplänen in einer Wochenübersicht, d
| FR-033 | Es muss möglich sein, dasselbe Menü mehrfach zu bestellen. Bei Mehrfachbestellungen muss die Anzahl angezeigt werden. | Niedrig | v1.0.1 | | FR-033 | Es muss möglich sein, dasselbe Menü mehrfach zu bestellen. Bei Mehrfachbestellungen muss die Anzahl angezeigt werden. | Niedrig | v1.0.1 |
| **Kostentransparenz & Bestellhistorie** | | | | | **Kostentransparenz & Bestellhistorie** | | | |
| FR-040 | Das System muss die Gesamtkosten aller Bestellungen einer Woche automatisch berechnen und anzeigen. | Mittel | v1.1.0 | | FR-040 | Das System muss die Gesamtkosten aller Bestellungen einer Woche automatisch berechnen und anzeigen. | Mittel | v1.1.0 |
| FR-041 | Das System muss dem Benutzer eine paginierte oder vollständige Bestellhistorie (gruppiert nach Monat und KW) mit Fortschrittsanzeige auf Abruf in einem Modal bereitstellen. | Mittel | v1.4.0 | | FR-041 | Das System muss dem Benutzer eine Bestellhistorie (gruppiert nach Monat und KW) mit Fortschrittsanzeige auf Abruf in einem Modal bereitstellen. Die Historie muss über ein lokales Delta-Caching verfügen, um Ladezeiten zu minimieren. | Mittel | v1.4.0 (Update v1.4.7) |
| **Bestell-Countdown** | | | | | **Bestell-Countdown** | | | |
| FR-050 | Das System muss vor Bestellschluss einen visuell hervorgehobenen Countdown anzeigen. | Mittel | v1.1.0 | | FR-050 | Das System muss vor Bestellschluss einen visuell hervorgehobenen Countdown anzeigen. | Mittel | v1.1.0 |
| **Menü-Flagging & Benachrichtigungen** | | | | | **Menü-Flagging & Benachrichtigungen** | | | |
@@ -59,7 +59,7 @@ Das System umfasst die Darstellung von Menüplänen in einer Wochenübersicht, d
| 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** | | | | | **Header UI & Navigation** | | | |
| FR-090 | Die Hauptnavigation (Wochen-Toggles) muss linksbündig neben dem App-Titel positioniert sein. | Niedrig | v1.5.0 | | 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-091 | Ein dynamisches Alarm-Icon im Header muss den Überwachungsstatus geflaggter Menüs anzeigen (Gelb=Überwachung aktiv aber kein Menü verfügbar, Grün=Mindestens ein Menü verfügbar, Versteckt=keine Flags). Der Tooltip muss den Zeitpunkt der letzten Prüfung als relativen String (z.B. "vor 4 Min.") enthalten. | Mittel | v1.5.0 (Update v1.4.10) |
| FR-092 | 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 | | 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 |
@@ -93,4 +93,4 @@ Das System umfasst die Darstellung von Menüplänen in einer Wochenübersicht, d
* **Datenhaltung**: Clientseitig via `localStorage` (Menü-Cache, Flags, Highlights, Theme) und `sessionStorage` (Auth-Token). * **Datenhaltung**: Clientseitig via `localStorage` (Menü-Cache, Flags, Highlights, Theme) und `sessionStorage` (Auth-Token).
* **Build**: Bash-basiertes Build-Script, das Bookmarklet-URL, Standalone-HTML und Installer-Seite generiert. * **Build**: Bash-basiertes Build-Script, das Bookmarklet-URL, Standalone-HTML und Installer-Seite generiert.
* **Versionierung**: SemVer, verwaltet über GitHub Releases/Tags. * **Versionierung**: SemVer, verwaltet über GitHub Releases/Tags.
* **Tests**: Python-basierte Build-Tests + Node.js-basierte Logik-Tests. * **Tests**: Python-basierte Build-Tests (`python3`) + Node.js-basierte Logik-Tests.

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>
@@ -267,23 +267,4 @@ if [ $TEST_EXIT -ne 0 ]; then
fi fi
echo "✅ All build tests passed." echo "✅ All build tests passed."
# === 5. Commit, tag, and push ===
echo ""
echo "=== Committing & Pushing ==="
git add -A
git commit -m "dist files for $VERSION built" --allow-empty
echo ""
echo "=== Tagging $VERSION ==="
if git rev-parse "$VERSION" >/dev/null 2>&1; then
git tag -f "$VERSION"
echo "🔄 Tag $VERSION moved to current commit."
else
git tag "$VERSION"
echo "✅ Created tag: $VERSION"
fi
git push
git push origin --force tag "$VERSION"
git push github --force tag "$VERSION"
echo "✅ Pushed commit + tag $VERSION"

View File

@@ -1,3 +1,39 @@
## v1.4.16
-**Feature**: Ein Button "Lokalen Cache leeren" wurde zum Versionen-Menü hinzugefügt, um bei hartnäckigen lokalen Fehlern alle Caches und Sessions bereinigen zu können, ohne die Entwicklertools (F12) des Browsers bemühen zu müssen.
## v1.4.15
- 🧹 **Bugfix**: In der Vergangenheit gesetzte Alarme/Flags wurden nicht zuverlässig gelöscht. Dies ist nun behoben, sodass verfallene Menüs nach 10:00 Uhr bzw. an vergangenen Tagen automatisch aus dem Tracker verschwinden.
## v1.4.14
- 🐛 **Bugfix**: Alarmglocke versteckt sich jetzt zuverlässig auch auf Endgeräten mit CSS Konflikten
- 🚀 **Feature**: Sofortige API-Aktualisierung (Refresh) bei Aktivierung eines Menüalarms
-**Optimierung**: "Unbekannt" im letzten Refresh-Zeitpunkt wird abgefangen und zeigt initial "gerade eben"
## v1.4.13 (2026-02-24)
- **Fix**: Die Farben der Glocke funktionieren nun verlässlich, da CSS-Variablen durch direkte Hex-Codes ersetzt wurden.
## v1.4.12 (2026-02-24)
- **Fix**: Das Glocken-Icon sollte nun endgültig versteckt bleiben, wenn keine Benachrichtigungen aktiv sind (CSS-Kollision mit `.hidden` behoben).
## v1.4.11 (2026-02-24)
- **Feature**: Das Versionsmenü prüft nun im Hintergrund direkt beim Öffnen nach neuen Versionen und aktualisiert die Liste automatisch, selbst wenn eine veraltete Liste noch im Cache liegt.
## v1.4.10 (2026-02-24)
- **Fix**: Die Farben der Benachrichtigungs-Glocke wurden korrigiert: Sie ist nun gelb, während man auf ein Menü wartet, und wird grün, sobald eines verfügbar ist.
## v1.4.9 (2026-02-24)
- **Fix**: Das Glocken-Icon für Benachrichtigungen wird nun direkt beim Start (wenn Daten aus dem lokalen Cache geladen werden) korrekt angezeigt.
## 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) ## v1.4.5 (2026-02-24)
- **Fix**: Doppelten Scrollbalken in der Versionen-Liste entfernt. - **Fix**: Doppelten Scrollbalken in der Versionen-Liste entfernt.

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

59
dist/install.html vendored

File diff suppressed because one or more lines are too long

View File

@@ -812,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;
@@ -2021,7 +2021,7 @@ 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.5</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.16</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;"> <div class="nav-group" style="margin-left: 1rem;">
@@ -2163,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.5</span> <strong>Aktuell:</strong> <span id="version-current">v1.4.16</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;">
@@ -2181,6 +2181,9 @@ body {
<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"> <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 <span class="material-icons-round" style="font-size: 1.2em;">lightbulb</span> Feature vorschlagen
</a> </a>
<button id="btn-clear-cache" style="background: none; border: none; padding: 0; color: var(--error-color); text-decoration: none; display: flex; align-items: center; gap: 0.5rem; cursor: pointer; text-align: left; font-size: inherit; font-family: inherit;" title="Löscht alle lokalen Daten & erzwingt einen Neuladen">
<span class="material-icons-round" style="font-size: 1.2em;">delete_forever</span> Lokalen Cache leeren
</button>
</div> </div>
</div> </div>
</div> </div>
@@ -2265,6 +2268,17 @@ body {
}); });
} }
const btnClearCache = document.getElementById('btn-clear-cache');
if (btnClearCache) {
btnClearCache.addEventListener('click', () => {
if (confirm('Möchtest du wirklich alle lokalen Daten (inkl. Login-Session, Cache und Einstellungen) löschen? Die Seite wird danach neu geladen.')) {
localStorage.clear();
sessionStorage.clear();
window.location.reload();
}
});
}
window.addEventListener('click', (e) => { window.addEventListener('click', (e) => {
if (e.target === versionModal) versionModal.classList.add('hidden'); if (e.target === versionModal) versionModal.classList.add('hidden');
}); });
@@ -2520,37 +2534,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 memory cache first // 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) {
// Check local storage cache
const localCache = localStorage.getItem('kantine_history_cache');
if (localCache) {
try { try {
fullOrderHistoryCache = JSON.parse(localCache); localCache = JSON.parse(ls);
renderHistory(fullOrderHistoryCache); fullOrderHistoryCache = localCache;
return;
} catch (e) { } catch (e) {
console.warn('History cache parse error', 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;
// Start background delta sync
if (localCache.length === 0) {
historyContent.innerHTML = ''; historyContent.innerHTML = '';
historyLoading.classList.remove('hidden'); historyLoading.classList.remove('hidden');
progressFill.style.width = '0%'; }
progressText.textContent = 'Lade Bestellhistorie...';
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}`);
@@ -2560,29 +2586,75 @@ body {
totalCount = data.count; totalCount = data.count;
} }
allOrders = allOrders.concat(data.results || []); const results = data.results || [];
for (const order of results) {
// Check if we hit an order that is already in our cache AND has the exact same state/update time
// Bessa returns 'updated' timestamp, we can use it to determine if anything changed
const existingOrderIndex = localCache.findIndex(cached => cached.id === order.id);
if (!requiresFullFetch && existingOrderIndex !== -1) {
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);
}
// Update progress // Update progress
if (!deltaComplete && requiresFullFetch) {
if (totalCount > 0) { if (totalCount > 0) {
const pct = Math.round((allOrders.length / totalCount) * 100); const pct = Math.round((fetchedOrders.length / totalCount) * 100);
progressFill.style.width = `${pct}%`; progressFill.style.width = `${pct}%`;
progressText.textContent = `Lade Bestellung ${allOrders.length} von ${totalCount}...`; progressText.textContent = `Lade Bestellung ${fetchedOrders.length} von ${totalCount}...`;
} else { } else {
progressText.textContent = `Lade Bestellung ${allOrders.length}...`; progressText.textContent = `Lade Bestellung ${fetchedOrders.length}...`;
}
} else if (!deltaComplete) {
progressText.textContent = `${fetchedOrders.length} neue/geänderte Bestellungen gefunden...`;
} }
nextUrl = data.next; nextUrl = deltaComplete ? null : data.next;
} }
fullOrderHistoryCache = allOrders; // Merge fetched orders with cache
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 { try {
localStorage.setItem('kantine_history_cache', JSON.stringify(allOrders)); localStorage.setItem('kantine_history_cache', JSON.stringify(mergedOrders));
} catch (e) { console.warn('History cache write error', e); } } catch (e) {
console.warn('History cache write error', e);
}
// Render the updated history
renderHistory(fullOrderHistoryCache); renderHistory(fullOrderHistoryCache);
}
} catch (error) { } catch (error) {
console.error('Error fetching full history:', 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;">Fehler beim Laden der Historie.</p>`;
} else {
showToast('Hintergrund-Synchronisation fehlgeschlagen', 'error');
}
} finally { } finally {
historyLoading.classList.add('hidden'); historyLoading.classList.add('hidden');
} }
@@ -2784,7 +2856,7 @@ body {
if (response.ok || response.status === 201) { if (response.ok || response.status === 201) {
showToast(`Bestellt: ${name}`, 'success'); showToast(`Bestellt: ${name}`, 'success');
localStorage.removeItem('kantine_history_cache'); 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();
@@ -2814,7 +2886,7 @@ body {
if (response.ok) { if (response.ok) {
showToast(`Storniert: ${name}`, 'success'); showToast(`Storniert: ${name}`, 'success');
localStorage.removeItem('kantine_history_cache'); 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();
@@ -2831,6 +2903,67 @@ body {
localStorage.setItem('kantine_flags', JSON.stringify([...userFlags])); localStorage.setItem('kantine_flags', JSON.stringify([...userFlags]));
} }
async function refreshFlaggedItems() {
if (userFlags.size === 0) return;
const token = authToken || GUEST_TOKEN;
const datesToFetch = new Set();
for (const flagId of userFlags) {
const [dateStr] = flagId.split('_');
datesToFetch.add(dateStr);
}
let updated = false;
for (const dateStr of datesToFetch) {
try {
const resp = await fetch(`${API_BASE}/venues/${VENUE_ID}/menu/${MENU_ID}/${dateStr}/`, {
headers: apiHeaders(token)
});
if (!resp.ok) continue;
const data = await resp.json();
const menuGroups = data.results || [];
let dayItems = [];
for (const group of menuGroups) {
if (group.items && Array.isArray(group.items)) {
dayItems = dayItems.concat(group.items);
}
}
// Update allWeeks in memory
for (let week of allWeeks) {
if (!week.days) continue;
let dayObj = week.days.find(d => d.date === dateStr);
if (dayObj) {
dayObj.items = dayItems.map(item => {
const isUnlimited = item.amount_tracking === false;
const hasStock = parseInt(item.available_amount) > 0;
return {
id: `${dateStr}_${item.id}`,
articleId: item.id,
name: item.name || 'Unknown',
description: item.description || '',
price: parseFloat(item.price) || 0,
available: isUnlimited || hasStock,
availableAmount: parseInt(item.available_amount) || 0,
amountTracking: item.amount_tracking !== false
};
});
updated = true;
}
}
} catch (e) {
console.error('Error refreshing flag date', dateStr, e);
}
}
if (updated) {
saveMenuCache();
updateLastUpdatedTime(new Date().toISOString());
updateAlarmBell();
renderVisibleWeeks();
}
}
function updateAlarmBell() { function updateAlarmBell() {
const bellBtn = document.getElementById('alarm-bell'); const bellBtn = document.getElementById('alarm-bell');
const bellIcon = document.getElementById('alarm-bell-icon'); const bellIcon = document.getElementById('alarm-bell-icon');
@@ -2838,10 +2971,14 @@ body {
if (userFlags.size === 0) { if (userFlags.size === 0) {
bellBtn.classList.add('hidden'); bellBtn.classList.add('hidden');
bellBtn.style.display = 'none';
bellIcon.style.color = 'var(--text-secondary)';
bellIcon.style.textShadow = 'none';
return; return;
} }
bellBtn.classList.remove('hidden'); bellBtn.classList.remove('hidden');
bellBtn.style.display = 'inline-flex';
// Check if any flagged item is available // Check if any flagged item is available
let anyAvailable = false; let anyAvailable = false;
@@ -2860,33 +2997,40 @@ body {
if (anyAvailable) break; if (anyAvailable) break;
} }
const lastUpdatedStr = localStorage.getItem('kantine_last_updated'); let lastUpdatedStr = localStorage.getItem('kantine_last_updated');
let timeStr = 'Unbekannt'; let timeStr = 'gerade eben'; // Fallback instead of Unbekannt
if (lastUpdatedStr) { if (!lastUpdatedStr) {
lastUpdatedStr = new Date().toISOString();
localStorage.setItem('kantine_last_updated', lastUpdatedStr);
}
const lastUpdated = new Date(lastUpdatedStr); const lastUpdated = new Date(lastUpdatedStr);
const diffMs = Date.now() - lastUpdated.getTime(); const diffMs = Date.now() - lastUpdated.getTime();
const diffMins = Math.floor(diffMs / 60000); const diffMins = Math.floor(diffMs / 60000);
if (diffMins < 60) timeStr = `vor ${diffMins} Min.`; if (diffMins < 1) timeStr = 'gerade eben';
else if (diffMins < 60) timeStr = `vor ${diffMins} Min.`;
else timeStr = `vor ${Math.floor(diffMins / 60)} Std.`; else timeStr = `vor ${Math.floor(diffMins / 60)} Std.`;
}
bellBtn.title = `Zuletzt geprüft: ${timeStr}`; bellBtn.title = `Zuletzt geprüft: ${timeStr}`;
if (anyAvailable) { if (anyAvailable) {
bellIcon.style.color = 'var(--success-color)'; bellIcon.style.color = '#10b981'; // green / success
bellIcon.style.textShadow = '0 0 10px rgba(16, 185, 129, 0.4)'; bellIcon.style.textShadow = '0 0 10px rgba(16, 185, 129, 0.4)';
} else { } else {
bellIcon.style.color = 'var(--warning-color)'; bellIcon.style.color = '#f59e0b'; // yellow / warning
bellIcon.style.textShadow = '0 0 10px rgba(245, 158, 11, 0.4)'; bellIcon.style.textShadow = '0 0 10px rgba(245, 158, 11, 0.4)';
} }
} }
function toggleFlag(date, articleId, name, cutoff) { function toggleFlag(date, articleId, name, cutoff) {
const id = `${date}_${articleId}`; const id = `${date}_${articleId}`;
let flagAdded = false;
if (userFlags.has(id)) { if (userFlags.has(id)) {
userFlags.delete(id); userFlags.delete(id);
showToast(`Flag entfernt für ${name}`, 'success'); showToast(`Flag entfernt für ${name}`, 'success');
} else { } else {
userFlags.add(id); userFlags.add(id);
flagAdded = true;
showToast(`Benachrichtigung aktiviert für ${name}`, 'success'); showToast(`Benachrichtigung aktiviert für ${name}`, 'success');
if (Notification.permission === 'default') { if (Notification.permission === 'default') {
Notification.requestPermission(); Notification.requestPermission();
@@ -2895,17 +3039,36 @@ body {
saveFlags(); saveFlags();
updateAlarmBell(); updateAlarmBell();
renderVisibleWeeks(); renderVisibleWeeks();
if (flagAdded) {
refreshFlaggedItems();
}
} }
// FR-019: Auto-remove flags whose cutoff has passed // FR-019: Auto-remove flags whose cutoff has passed
function cleanupExpiredFlags() { function cleanupExpiredFlags() {
const now = new Date(); const now = new Date();
const todayStr = now.toISOString().split('T')[0]; // Format: YYYY-MM-DD
let changed = false; let changed = false;
for (const flagId of [...userFlags]) { for (const flagId of [...userFlags]) {
const [date] = flagId.split('_'); const [dateStr] = flagId.split('_'); // Format usually is YYYY-MM-DD
const cutoff = new Date(date);
// If the flag's date string is entirely in the past (before today)
// or if it's today but past the 10:00 cutoff time
let isExpired = false;
if (dateStr < todayStr) {
isExpired = true;
} else if (dateStr === todayStr) {
const cutoff = new Date(dateStr);
cutoff.setHours(10, 0, 0, 0); // Standard cutoff 10:00 cutoff.setHours(10, 0, 0, 0); // Standard cutoff 10:00
if (now >= cutoff) { if (now >= cutoff) {
isExpired = true;
}
}
if (isExpired) {
userFlags.delete(flagId); userFlags.delete(flagId);
changed = true; changed = true;
} }
@@ -3050,6 +3213,7 @@ body {
console.log(`[Cache] Parsed ${allWeeks.length} weeks:`, allWeeks.map(w => `KW${w.weekNumber}/${w.year} (${(w.days || []).length} days)`)); console.log(`[Cache] Parsed ${allWeeks.length} weeks:`, allWeeks.map(w => `KW${w.weekNumber}/${w.year} (${(w.days || []).length} days)`));
renderVisibleWeeks(); renderVisibleWeeks();
updateNextWeekBadge(); updateNextWeekBadge();
updateAlarmBell();
if (cachedTs) updateLastUpdatedTime(cachedTs); if (cachedTs) updateLastUpdatedTime(cachedTs);
console.log('Loaded menu from cache'); console.log('Loaded menu from cache');
return true; return true;
@@ -3834,7 +3998,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.5'; const currentVersion = 'v1.4.16';
const devMode = localStorage.getItem('kantine_dev_mode') === 'true'; const devMode = localStorage.getItem('kantine_dev_mode') === 'true';
try { try {
@@ -3875,7 +4039,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.5'; const currentVersion = 'v1.4.16';
if (!modal) return; if (!modal) return;
modal.classList.remove('hidden'); modal.classList.remove('hidden');
@@ -3893,19 +4057,8 @@ body {
const dm = devToggle.checked; const dm = devToggle.checked;
container.innerHTML = '<p style="color:var(--text-secondary);">Lade Versionen...</p>'; container.innerHTML = '<p style="color:var(--text-secondary);">Lade Versionen...</p>';
try { function renderVersionsList(versions) {
let versions; if (!versions || !versions.length) {
const cached = JSON.parse(localStorage.getItem('kantine_version_cache') || 'null');
if (!forceRefresh && cached && cached.devMode === dm && (Date.now() - cached.timestamp < 3600000)) {
versions = cached.versions;
} else {
versions = await fetchVersions(dm);
localStorage.setItem('kantine_version_cache', JSON.stringify({
timestamp: Date.now(), devMode: dm, versions
}));
}
if (!versions.length) {
container.innerHTML = '<p style="color:var(--text-secondary);">Keine Versionen gefunden.</p>'; container.innerHTML = '<p style="color:var(--text-secondary);">Keine Versionen gefunden.</p>';
return; return;
} }
@@ -3937,6 +4090,34 @@ body {
`; `;
list.appendChild(li); list.appendChild(li);
}); });
}
try {
// 1. Show cached versions immediately if available
const cachedRaw = localStorage.getItem('kantine_version_cache');
let cached = null;
if (cachedRaw) {
try { cached = JSON.parse(cachedRaw); } catch (e) { }
}
if (cached && cached.devMode === dm && cached.versions) {
renderVersionsList(cached.versions);
}
// 2. Fetch fresh versions in background (or foreground if no cache)
const liveVersions = await fetchVersions(dm);
// Compare with cache to see if we need to re-render
const liveVersionsStr = JSON.stringify(liveVersions);
const cachedVersionsStr = cached ? JSON.stringify(cached.versions) : '';
if (liveVersionsStr !== cachedVersionsStr) {
localStorage.setItem('kantine_version_cache', JSON.stringify({
timestamp: Date.now(), devMode: dm, versions: liveVersions
}));
renderVersionsList(liveVersions);
}
} catch (e) { } catch (e) {
container.innerHTML = `<p style="color:#e94560;">Fehler: ${e.message}</p>`; container.innerHTML = `<p style="color:#e94560;">Fehler: ${e.message}</p>`;
} }

View File

@@ -231,6 +231,9 @@
<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"> <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 <span class="material-icons-round" style="font-size: 1.2em;">lightbulb</span> Feature vorschlagen
</a> </a>
<button id="btn-clear-cache" style="background: none; border: none; padding: 0; color: var(--error-color); text-decoration: none; display: flex; align-items: center; gap: 0.5rem; cursor: pointer; text-align: left; font-size: inherit; font-family: inherit;" title="Löscht alle lokalen Daten & erzwingt einen Neuladen">
<span class="material-icons-round" style="font-size: 1.2em;">delete_forever</span> Lokalen Cache leeren
</button>
</div> </div>
</div> </div>
</div> </div>
@@ -315,6 +318,17 @@
}); });
} }
const btnClearCache = document.getElementById('btn-clear-cache');
if (btnClearCache) {
btnClearCache.addEventListener('click', () => {
if (confirm('Möchtest du wirklich alle lokalen Daten (inkl. Login-Session, Cache und Einstellungen) löschen? Die Seite wird danach neu geladen.')) {
localStorage.clear();
sessionStorage.clear();
window.location.reload();
}
});
}
window.addEventListener('click', (e) => { window.addEventListener('click', (e) => {
if (e.target === versionModal) versionModal.classList.add('hidden'); if (e.target === versionModal) versionModal.classList.add('hidden');
}); });
@@ -570,37 +584,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 memory cache first // 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) {
// Check local storage cache
const localCache = localStorage.getItem('kantine_history_cache');
if (localCache) {
try { try {
fullOrderHistoryCache = JSON.parse(localCache); localCache = JSON.parse(ls);
renderHistory(fullOrderHistoryCache); fullOrderHistoryCache = localCache;
return;
} catch (e) { } catch (e) {
console.warn('History cache parse error', 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;
// Start background delta sync
if (localCache.length === 0) {
historyContent.innerHTML = ''; historyContent.innerHTML = '';
historyLoading.classList.remove('hidden'); historyLoading.classList.remove('hidden');
progressFill.style.width = '0%'; }
progressText.textContent = 'Lade Bestellhistorie...';
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}`);
@@ -610,29 +636,75 @@
totalCount = data.count; totalCount = data.count;
} }
allOrders = allOrders.concat(data.results || []); const results = data.results || [];
for (const order of results) {
// Check if we hit an order that is already in our cache AND has the exact same state/update time
// Bessa returns 'updated' timestamp, we can use it to determine if anything changed
const existingOrderIndex = localCache.findIndex(cached => cached.id === order.id);
if (!requiresFullFetch && existingOrderIndex !== -1) {
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);
}
// Update progress // Update progress
if (!deltaComplete && requiresFullFetch) {
if (totalCount > 0) { if (totalCount > 0) {
const pct = Math.round((allOrders.length / totalCount) * 100); const pct = Math.round((fetchedOrders.length / totalCount) * 100);
progressFill.style.width = `${pct}%`; progressFill.style.width = `${pct}%`;
progressText.textContent = `Lade Bestellung ${allOrders.length} von ${totalCount}...`; progressText.textContent = `Lade Bestellung ${fetchedOrders.length} von ${totalCount}...`;
} else { } else {
progressText.textContent = `Lade Bestellung ${allOrders.length}...`; progressText.textContent = `Lade Bestellung ${fetchedOrders.length}...`;
}
} else if (!deltaComplete) {
progressText.textContent = `${fetchedOrders.length} neue/geänderte Bestellungen gefunden...`;
} }
nextUrl = data.next; nextUrl = deltaComplete ? null : data.next;
} }
fullOrderHistoryCache = allOrders; // Merge fetched orders with cache
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 { try {
localStorage.setItem('kantine_history_cache', JSON.stringify(allOrders)); localStorage.setItem('kantine_history_cache', JSON.stringify(mergedOrders));
} catch (e) { console.warn('History cache write error', e); } } catch (e) {
console.warn('History cache write error', e);
}
// Render the updated history
renderHistory(fullOrderHistoryCache); renderHistory(fullOrderHistoryCache);
}
} catch (error) { } catch (error) {
console.error('Error fetching full history:', 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;">Fehler beim Laden der Historie.</p>`;
} else {
showToast('Hintergrund-Synchronisation fehlgeschlagen', 'error');
}
} finally { } finally {
historyLoading.classList.add('hidden'); historyLoading.classList.add('hidden');
} }
@@ -834,7 +906,7 @@
if (response.ok || response.status === 201) { if (response.ok || response.status === 201) {
showToast(`Bestellt: ${name}`, 'success'); showToast(`Bestellt: ${name}`, 'success');
localStorage.removeItem('kantine_history_cache'); 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();
@@ -864,7 +936,7 @@
if (response.ok) { if (response.ok) {
showToast(`Storniert: ${name}`, 'success'); showToast(`Storniert: ${name}`, 'success');
localStorage.removeItem('kantine_history_cache'); 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();
@@ -881,6 +953,67 @@
localStorage.setItem('kantine_flags', JSON.stringify([...userFlags])); localStorage.setItem('kantine_flags', JSON.stringify([...userFlags]));
} }
async function refreshFlaggedItems() {
if (userFlags.size === 0) return;
const token = authToken || GUEST_TOKEN;
const datesToFetch = new Set();
for (const flagId of userFlags) {
const [dateStr] = flagId.split('_');
datesToFetch.add(dateStr);
}
let updated = false;
for (const dateStr of datesToFetch) {
try {
const resp = await fetch(`${API_BASE}/venues/${VENUE_ID}/menu/${MENU_ID}/${dateStr}/`, {
headers: apiHeaders(token)
});
if (!resp.ok) continue;
const data = await resp.json();
const menuGroups = data.results || [];
let dayItems = [];
for (const group of menuGroups) {
if (group.items && Array.isArray(group.items)) {
dayItems = dayItems.concat(group.items);
}
}
// Update allWeeks in memory
for (let week of allWeeks) {
if (!week.days) continue;
let dayObj = week.days.find(d => d.date === dateStr);
if (dayObj) {
dayObj.items = dayItems.map(item => {
const isUnlimited = item.amount_tracking === false;
const hasStock = parseInt(item.available_amount) > 0;
return {
id: `${dateStr}_${item.id}`,
articleId: item.id,
name: item.name || 'Unknown',
description: item.description || '',
price: parseFloat(item.price) || 0,
available: isUnlimited || hasStock,
availableAmount: parseInt(item.available_amount) || 0,
amountTracking: item.amount_tracking !== false
};
});
updated = true;
}
}
} catch (e) {
console.error('Error refreshing flag date', dateStr, e);
}
}
if (updated) {
saveMenuCache();
updateLastUpdatedTime(new Date().toISOString());
updateAlarmBell();
renderVisibleWeeks();
}
}
function updateAlarmBell() { function updateAlarmBell() {
const bellBtn = document.getElementById('alarm-bell'); const bellBtn = document.getElementById('alarm-bell');
const bellIcon = document.getElementById('alarm-bell-icon'); const bellIcon = document.getElementById('alarm-bell-icon');
@@ -888,10 +1021,14 @@
if (userFlags.size === 0) { if (userFlags.size === 0) {
bellBtn.classList.add('hidden'); bellBtn.classList.add('hidden');
bellBtn.style.display = 'none';
bellIcon.style.color = 'var(--text-secondary)';
bellIcon.style.textShadow = 'none';
return; return;
} }
bellBtn.classList.remove('hidden'); bellBtn.classList.remove('hidden');
bellBtn.style.display = 'inline-flex';
// Check if any flagged item is available // Check if any flagged item is available
let anyAvailable = false; let anyAvailable = false;
@@ -910,33 +1047,40 @@
if (anyAvailable) break; if (anyAvailable) break;
} }
const lastUpdatedStr = localStorage.getItem('kantine_last_updated'); let lastUpdatedStr = localStorage.getItem('kantine_last_updated');
let timeStr = 'Unbekannt'; let timeStr = 'gerade eben'; // Fallback instead of Unbekannt
if (lastUpdatedStr) { if (!lastUpdatedStr) {
lastUpdatedStr = new Date().toISOString();
localStorage.setItem('kantine_last_updated', lastUpdatedStr);
}
const lastUpdated = new Date(lastUpdatedStr); const lastUpdated = new Date(lastUpdatedStr);
const diffMs = Date.now() - lastUpdated.getTime(); const diffMs = Date.now() - lastUpdated.getTime();
const diffMins = Math.floor(diffMs / 60000); const diffMins = Math.floor(diffMs / 60000);
if (diffMins < 60) timeStr = `vor ${diffMins} Min.`; if (diffMins < 1) timeStr = 'gerade eben';
else if (diffMins < 60) timeStr = `vor ${diffMins} Min.`;
else timeStr = `vor ${Math.floor(diffMins / 60)} Std.`; else timeStr = `vor ${Math.floor(diffMins / 60)} Std.`;
}
bellBtn.title = `Zuletzt geprüft: ${timeStr}`; bellBtn.title = `Zuletzt geprüft: ${timeStr}`;
if (anyAvailable) { if (anyAvailable) {
bellIcon.style.color = 'var(--success-color)'; bellIcon.style.color = '#10b981'; // green / success
bellIcon.style.textShadow = '0 0 10px rgba(16, 185, 129, 0.4)'; bellIcon.style.textShadow = '0 0 10px rgba(16, 185, 129, 0.4)';
} else { } else {
bellIcon.style.color = 'var(--warning-color)'; bellIcon.style.color = '#f59e0b'; // yellow / warning
bellIcon.style.textShadow = '0 0 10px rgba(245, 158, 11, 0.4)'; bellIcon.style.textShadow = '0 0 10px rgba(245, 158, 11, 0.4)';
} }
} }
function toggleFlag(date, articleId, name, cutoff) { function toggleFlag(date, articleId, name, cutoff) {
const id = `${date}_${articleId}`; const id = `${date}_${articleId}`;
let flagAdded = false;
if (userFlags.has(id)) { if (userFlags.has(id)) {
userFlags.delete(id); userFlags.delete(id);
showToast(`Flag entfernt für ${name}`, 'success'); showToast(`Flag entfernt für ${name}`, 'success');
} else { } else {
userFlags.add(id); userFlags.add(id);
flagAdded = true;
showToast(`Benachrichtigung aktiviert für ${name}`, 'success'); showToast(`Benachrichtigung aktiviert für ${name}`, 'success');
if (Notification.permission === 'default') { if (Notification.permission === 'default') {
Notification.requestPermission(); Notification.requestPermission();
@@ -945,17 +1089,36 @@
saveFlags(); saveFlags();
updateAlarmBell(); updateAlarmBell();
renderVisibleWeeks(); renderVisibleWeeks();
if (flagAdded) {
refreshFlaggedItems();
}
} }
// FR-019: Auto-remove flags whose cutoff has passed // FR-019: Auto-remove flags whose cutoff has passed
function cleanupExpiredFlags() { function cleanupExpiredFlags() {
const now = new Date(); const now = new Date();
const todayStr = now.toISOString().split('T')[0]; // Format: YYYY-MM-DD
let changed = false; let changed = false;
for (const flagId of [...userFlags]) { for (const flagId of [...userFlags]) {
const [date] = flagId.split('_'); const [dateStr] = flagId.split('_'); // Format usually is YYYY-MM-DD
const cutoff = new Date(date);
// If the flag's date string is entirely in the past (before today)
// or if it's today but past the 10:00 cutoff time
let isExpired = false;
if (dateStr < todayStr) {
isExpired = true;
} else if (dateStr === todayStr) {
const cutoff = new Date(dateStr);
cutoff.setHours(10, 0, 0, 0); // Standard cutoff 10:00 cutoff.setHours(10, 0, 0, 0); // Standard cutoff 10:00
if (now >= cutoff) { if (now >= cutoff) {
isExpired = true;
}
}
if (isExpired) {
userFlags.delete(flagId); userFlags.delete(flagId);
changed = true; changed = true;
} }
@@ -1100,6 +1263,7 @@
console.log(`[Cache] Parsed ${allWeeks.length} weeks:`, allWeeks.map(w => `KW${w.weekNumber}/${w.year} (${(w.days || []).length} days)`)); console.log(`[Cache] Parsed ${allWeeks.length} weeks:`, allWeeks.map(w => `KW${w.weekNumber}/${w.year} (${(w.days || []).length} days)`));
renderVisibleWeeks(); renderVisibleWeeks();
updateNextWeekBadge(); updateNextWeekBadge();
updateAlarmBell();
if (cachedTs) updateLastUpdatedTime(cachedTs); if (cachedTs) updateLastUpdatedTime(cachedTs);
console.log('Loaded menu from cache'); console.log('Loaded menu from cache');
return true; return true;
@@ -1943,19 +2107,8 @@
const dm = devToggle.checked; const dm = devToggle.checked;
container.innerHTML = '<p style="color:var(--text-secondary);">Lade Versionen...</p>'; container.innerHTML = '<p style="color:var(--text-secondary);">Lade Versionen...</p>';
try { function renderVersionsList(versions) {
let versions; if (!versions || !versions.length) {
const cached = JSON.parse(localStorage.getItem('kantine_version_cache') || 'null');
if (!forceRefresh && cached && cached.devMode === dm && (Date.now() - cached.timestamp < 3600000)) {
versions = cached.versions;
} else {
versions = await fetchVersions(dm);
localStorage.setItem('kantine_version_cache', JSON.stringify({
timestamp: Date.now(), devMode: dm, versions
}));
}
if (!versions.length) {
container.innerHTML = '<p style="color:var(--text-secondary);">Keine Versionen gefunden.</p>'; container.innerHTML = '<p style="color:var(--text-secondary);">Keine Versionen gefunden.</p>';
return; return;
} }
@@ -1987,6 +2140,34 @@
`; `;
list.appendChild(li); list.appendChild(li);
}); });
}
try {
// 1. Show cached versions immediately if available
const cachedRaw = localStorage.getItem('kantine_version_cache');
let cached = null;
if (cachedRaw) {
try { cached = JSON.parse(cachedRaw); } catch (e) { }
}
if (cached && cached.devMode === dm && cached.versions) {
renderVersionsList(cached.versions);
}
// 2. Fetch fresh versions in background (or foreground if no cache)
const liveVersions = await fetchVersions(dm);
// Compare with cache to see if we need to re-render
const liveVersionsStr = JSON.stringify(liveVersions);
const cachedVersionsStr = cached ? JSON.stringify(cached.versions) : '';
if (liveVersionsStr !== cachedVersionsStr) {
localStorage.setItem('kantine_version_cache', JSON.stringify({
timestamp: Date.now(), devMode: dm, versions: liveVersions
}));
renderVersionsList(liveVersions);
}
} catch (e) { } catch (e) {
container.innerHTML = `<p style="color:#e94560;">Fehler: ${e.message}</p>`; container.innerHTML = `<p style="color:#e94560;">Fehler: ${e.message}</p>`;
} }

58
release.sh Executable file
View File

@@ -0,0 +1,58 @@
#!/bin/bash
# release.sh - Deploys a new version of the Kantine Wrapper
# Ensure we're in the script directory
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )"
cd "$SCRIPT_DIR"
# Ensure tests have run and artifacts exist
if [ ! -d "$SCRIPT_DIR/dist" ]; then
echo "❌ Error: dist folder missing. Please run build-bookmarklet.sh first"
exit 1
fi
# Get current version
VERSION=$(cat "version.txt" | tr -d '\n\r ')
# Validate that version is set
if [ -z "$VERSION" ]; then
echo "❌ Error: Could not determine version from version.txt"
exit 1
fi
echo "=== Kantine Bookmarklet Releaser ($VERSION) ==="
# Check for uncommitted changes (excluding dist/)
if ! git diff-index --quiet HEAD -- ":(exclude)dist"; then
echo "⚠️ Warning: You have uncommitted changes in the working directory."
echo "Please commit your code changes before running the release script."
exit 1
fi
echo "=== Committing build artifacts ==="
git add "dist/"
git commit -m "chore: update build artifacts for $VERSION" --allow-empty
echo ""
echo "=== Tagging $VERSION ==="
if git rev-parse "$VERSION" >/dev/null 2>&1; then
git tag -f "$VERSION"
echo "🔄 Tag $VERSION moved to current commit."
else
git tag "$VERSION"
echo "✅ Created tag: $VERSION"
fi
echo ""
echo "=== Pushing to remotes ==="
# Determine remote targets: Assume 'origin' for primary, optionally 'github'
git push origin HEAD
git push origin --force tag "$VERSION"
# If a remote named 'github' exists, push tags there too
if git remote | grep -q "^github$"; then
git push github --force tag "$VERSION"
fi
echo "🎉 Successfully released version $VERSION!"
exit 0

View File

@@ -801,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;

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

49
tests/test_dom.js Executable file
View File

@@ -0,0 +1,49 @@
const fs = require('fs');
const jsdom = require('jsdom');
const { JSDOM } = jsdom;
const html = `
<!DOCTYPE html>
<html>
<head>
<style>
.hidden { display: none !important; }
.icon-btn { display: inline-flex; }
</style>
</head>
<body>
<button id="alarm-bell" class="icon-btn hidden">
<span id="alarm-bell-icon" style="color:var(--text-secondary);"></span>
</button>
</body>
</html>
`;
const jsCode = fs.readFileSync('kantine.js', 'utf8').replace('(function () {', '').replace(/}\)\(\);$/, '');
const dom = new JSDOM(html, { runScripts: "dangerously" });
global.window = dom.window;
global.document = window.document;
global.localStorage = { getItem: () => '[]', setItem: () => {} };
global.sessionStorage = { getItem: () => null };
global.showToast = () => {};
global.saveFlags = () => {};
global.renderVisibleWeeks = () => {};
// Mock missing browser features if needed
global.Notification = { permission: 'default', requestPermission: () => {} };
try {
dom.window.eval(jsCode);
console.log("Initial Bell Classes:", window.document.getElementById('alarm-bell').className);
// Add flag
dom.window.eval("userFlags.add('2026-02-24_123'); updateAlarmBell();");
console.log("After Add:", window.document.getElementById('alarm-bell').className);
// Remove flag
dom.window.eval("userFlags.delete('2026-02-24_123'); updateAlarmBell();");
console.log("After Remove:", window.document.getElementById('alarm-bell').className);
} catch (e) {
console.error(e);
}

View File

@@ -1 +1 @@
v1.4.5 v1.4.16