Compare commits

...

28 Commits

Author SHA1 Message Date
Kantine Wrapper
98020f0b8f chore: update build artifacts for v1.4.17 2026-02-24 20:40:55 +01:00
Kantine Wrapper
c2e3282131 fix(ui): restore highlight modal toggle event & add dom test suite (v1.4.17) 2026-02-24 20:40:54 +01:00
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
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
17 changed files with 1048 additions and 135 deletions

View File

@@ -48,6 +48,11 @@ trigger: always_on
- **Browser**: Allowed for documentation and safe browsing. No automated logins without permission.
- **Terminal**: No `rm -rf`. Run tests (`pytest` etc.) after logic changes.
## 7. Mandatory Testing Policy 🧪
**CRITICAL: No logic or UI fix is complete without a corresponding automated test.**
- If you fix a regression or implement a new UI feature, you **MUST** write or update a test in `tests/test_dom.js` or `test_logic.js`.
- Refactoring MUST include verifying that no click listeners drop out. This guarantees that features like modal toggles stay functional.
## 7. Requirements-Konsistenz 📋
Alle umgesetzten Anforderungen müssen mit `REQUIREMENTS.md` übereinstimmen.
1. **Vor der Umsetzung prüfen**: Passt die neue Anforderung zu den bestehenden Requirements?

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 |
| **Kostentransparenz & Bestellhistorie** | | | |
| 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** | | | |
| FR-050 | Das System muss vor Bestellschluss einen visuell hervorgehobenen Countdown anzeigen. | Mittel | v1.1.0 |
| **Menü-Flagging & Benachrichtigungen** | | | |
@@ -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-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 |
| **Darstellung & Theming** | | | |
| 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-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 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 |
| **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-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-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-115 | Das Versionsmenü muss Links zur Erstellung von Feature-Requests und Bug-Reports auf GitHub enthalten. | Niedrig | v1.4.4 |
## 3. Nicht-funktionale Anforderungen
@@ -89,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).
* **Build**: Bash-basiertes Build-Script, das Bookmarklet-URL, Standalone-HTML und Installer-Seite generiert.
* **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.

Binary file not shown.

View File

@@ -171,7 +171,7 @@ cat > "$DIST_DIR/install.html" << INSTALLEOF
</div>
<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>
@@ -258,6 +258,14 @@ if [ $LOGIC_EXIT -ne 0 ]; then
exit 1
fi
echo "=== Running DOM Interaction Tests ==="
node "$SCRIPT_DIR/tests/test_dom.js"
DOM_EXIT=$?
if [ $DOM_EXIT -ne 0 ]; then
echo "❌ DOM UI tests FAILED! Regressions detected."
exit 1
fi
echo "=== Running Build Tests ==="
python3 "$SCRIPT_DIR/test_build.py"
TEST_EXIT=$?
@@ -267,23 +275,4 @@ if [ $TEST_EXIT -ne 0 ]; then
fi
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,52 @@
## v1.4.17
- 🐛 **Bugfix**: Regression behoben: Der "Persönliche Highlights" (Stern-Button) Dialog öffnet sich nun wieder korrekt.
- 🧪 **Testing**: Es wurde ein initialer UI-Testing-Hook (`test_dom.js` mit `jsdom`) in die Build-Pipeline integriert, um kritische DOM Event-Listener Regressionen (wie den Highlights-Button und die Alarmglocke) automatisch zu preventen.
## 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)
- **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.

15
debug_test.js Executable file
View File

@@ -0,0 +1,15 @@
const fs = require('fs');
const jsCode = fs.readFileSync('kantine.js', 'utf8').replace('(function () {', '').replace(/}\)\(\);$/, '');
try {
const vm = require('vm');
new vm.Script(jsCode);
} catch (e) {
console.error(e.message);
const lines = jsCode.split('\n');
console.error("Around line", e.loc?.line);
if(e.loc?.line) {
console.log(lines[e.loc.line - 2]);
console.log(lines[e.loc.line - 1]);
console.log(lines[e.loc.line]);
}
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

78
dist/install.html vendored

File diff suppressed because one or more lines are too long

View File

@@ -197,6 +197,32 @@ body {
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) */
.nav-badge {
background-color: var(--error-color);
@@ -786,7 +812,7 @@ body {
/* No opacity/filter here - fully visible */
background: var(--bg-card);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
border: 1px solid var(--accent-color);
border: 1px solid #8b5cf6;
border-radius: 8px;
padding: 1rem;
margin: 0 -1rem 1.5rem -1rem;
@@ -795,7 +821,7 @@ body {
}
.menu-item.today-ordered {
border: 2px solid var(--accent-color);
border: 2px solid #8b5cf6;
box-shadow: 0 0 20px rgba(139, 92, 246, 0.4);
border-radius: 8px;
padding: 1rem;
@@ -1606,8 +1632,6 @@ body {
.version-list {
list-style: none;
padding: 0;
max-height: 350px;
overflow-y: auto;
margin: 0;
}
@@ -1997,9 +2021,16 @@ body {
<div class="brand">
<span class="material-icons-round logo-icon">restaurant_menu</span>
<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.2</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.17</small></h1>
<div id="last-updated-subtitle" class="subtitle"></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 class="header-center-wrapper">
<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">
<span class="material-icons-round">label</span>
</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">
<span class="material-icons-round theme-icon">light_mode</span>
</button>
@@ -2136,7 +2163,7 @@ body {
</div>
<div class="modal-body">
<div style="margin-bottom: 1rem;">
<strong>Aktuell:</strong> <span id="version-current">v1.4.2</span>
<strong>Aktuell:</strong> <span id="version-current">v1.4.17</span>
</div>
<div class="dev-toggle">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer;">
@@ -2144,9 +2171,20 @@ body {
<span>Dev-Mode (alle Tags anzeigen)</span>
</label>
</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>
</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>
<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>
@@ -2193,6 +2231,18 @@ body {
const historyModal = document.getElementById('history-modal');
const btnHistoryClose = document.getElementById('btn-history-close');
if (btnHighlights) {
btnHighlights.addEventListener('click', () => {
highlightsModal.classList.remove('hidden');
});
}
if (btnHighlightsClose) {
btnHighlightsClose.addEventListener('click', () => {
highlightsModal.classList.add('hidden');
});
}
btnHistory.addEventListener('click', () => {
if (!authToken) {
loginModal.classList.remove('hidden');
@@ -2230,6 +2280,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) => {
if (e.target === versionModal) versionModal.classList.add('hidden');
});
@@ -2280,6 +2341,7 @@ body {
});
btnNextWeek.addEventListener('click', () => {
btnNextWeek.classList.remove('new-week-available');
if (displayMode !== 'next-week') {
displayMode = 'next-week';
btnNextWeek.classList.add('active');
@@ -2484,24 +2546,49 @@ body {
const progressFill = document.getElementById('history-progress-fill');
const progressText = document.getElementById('history-progress-text');
// Check local storage cache (we still use memory cache if available)
let localCache = [];
if (fullOrderHistoryCache) {
renderHistory(fullOrderHistoryCache);
return;
localCache = fullOrderHistoryCache;
} else {
const ls = localStorage.getItem('kantine_history_cache');
if (ls) {
try {
localCache = JSON.parse(ls);
fullOrderHistoryCache = localCache;
} catch (e) {
console.warn('History cache parse error', e);
}
}
}
// Show cached version immediately if we have one
if (localCache.length > 0) {
renderHistory(localCache);
}
if (!authToken) return;
// Start background delta sync
if (localCache.length === 0) {
historyContent.innerHTML = '';
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`;
let allOrders = [];
progressFill.style.width = '0%';
progressText.textContent = localCache.length > 0 ? 'Suche nach neuen Bestellungen...' : 'Lade Bestellhistorie...';
if (localCache.length > 0) historyLoading.classList.remove('hidden');
let nextUrl = localCache.length > 0
? `${API_BASE}/user/orders/?venue=${VENUE_ID}&ordering=-created&limit=5`
: `${API_BASE}/user/orders/?venue=${VENUE_ID}&ordering=-created&limit=50`;
let fetchedOrders = [];
let totalCount = 0;
let requiresFullFetch = localCache.length === 0;
let deltaComplete = false;
try {
while (nextUrl) {
while (nextUrl && !deltaComplete) {
const response = await fetch(nextUrl, { headers: apiHeaders(authToken) });
if (!response.ok) throw new Error(`Fetch failed: ${response.status}`);
@@ -2511,26 +2598,75 @@ body {
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
if (!deltaComplete && requiresFullFetch) {
if (totalCount > 0) {
const pct = Math.round((allOrders.length / totalCount) * 100);
const pct = Math.round((fetchedOrders.length / totalCount) * 100);
progressFill.style.width = `${pct}%`;
progressText.textContent = `Lade Bestellung ${allOrders.length} von ${totalCount}...`;
progressText.textContent = `Lade Bestellung ${fetchedOrders.length} von ${totalCount}...`;
} 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 {
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) {
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>`;
} else {
showToast('Hintergrund-Synchronisation fehlgeschlagen', 'error');
}
} finally {
historyLoading.classList.add('hidden');
}
@@ -2732,6 +2868,7 @@ body {
if (response.ok || response.status === 201) {
showToast(`Bestellt: ${name}`, 'success');
fullOrderHistoryCache = null; // Clear memory cache so next history open triggers delta sync
await fetchOrders();
} else {
const data = await response.json();
@@ -2761,6 +2898,7 @@ body {
if (response.ok) {
showToast(`Storniert: ${name}`, 'success');
fullOrderHistoryCache = null; // Clear memory cache so next history open triggers delta sync
await fetchOrders();
} else {
const data = await response.json();
@@ -2777,31 +2915,172 @@ body {
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() {
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');
bellBtn.style.display = 'none';
bellIcon.style.color = 'var(--text-secondary)';
bellIcon.style.textShadow = 'none';
return;
}
bellBtn.classList.remove('hidden');
bellBtn.style.display = 'inline-flex';
// 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;
}
let lastUpdatedStr = localStorage.getItem('kantine_last_updated');
let timeStr = 'gerade eben'; // Fallback instead of Unbekannt
if (!lastUpdatedStr) {
lastUpdatedStr = new Date().toISOString();
localStorage.setItem('kantine_last_updated', lastUpdatedStr);
}
const lastUpdated = new Date(lastUpdatedStr);
const diffMs = Date.now() - lastUpdated.getTime();
const diffMins = Math.floor(diffMs / 60000);
if (diffMins < 1) timeStr = 'gerade eben';
else 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 = '#10b981'; // green / success
bellIcon.style.textShadow = '0 0 10px rgba(16, 185, 129, 0.4)';
} else {
bellIcon.style.color = '#f59e0b'; // yellow / warning
bellIcon.style.textShadow = '0 0 10px rgba(245, 158, 11, 0.4)';
}
}
function toggleFlag(date, articleId, name, cutoff) {
const id = `${date}_${articleId}`;
let flagAdded = false;
if (userFlags.has(id)) {
userFlags.delete(id);
showToast(`Flag entfernt für ${name}`, 'success');
} else {
userFlags.add(id);
flagAdded = true;
showToast(`Benachrichtigung aktiviert für ${name}`, 'success');
if (Notification.permission === 'default') {
Notification.requestPermission();
}
}
saveFlags();
updateAlarmBell();
renderVisibleWeeks();
if (flagAdded) {
refreshFlaggedItems();
}
}
// FR-019: Auto-remove flags whose cutoff has passed
function cleanupExpiredFlags() {
const now = new Date();
const todayStr = now.toISOString().split('T')[0]; // Format: YYYY-MM-DD
let changed = false;
for (const flagId of [...userFlags]) {
const [date] = flagId.split('_');
const cutoff = new Date(date);
const [dateStr] = flagId.split('_'); // Format usually is YYYY-MM-DD
// 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
if (now >= cutoff) {
isExpired = true;
}
}
if (isExpired) {
userFlags.delete(flagId);
changed = true;
}
@@ -2946,6 +3225,7 @@ body {
console.log(`[Cache] Parsed ${allWeeks.length} weeks:`, allWeeks.map(w => `KW${w.weekNumber}/${w.year} (${(w.days || []).length} days)`));
renderVisibleWeeks();
updateNextWeekBadge();
updateAlarmBell();
if (cachedTs) updateLastUpdatedTime(cachedTs);
console.log('Loaded menu from cache');
return true;
@@ -3158,6 +3438,7 @@ body {
updateAuthUI(); // This will trigger fetchOrders if logged in
renderVisibleWeeks();
updateNextWeekBadge();
updateAlarmBell();
progressMessage.textContent = 'Fertig!';
setTimeout(() => progressModal.classList.add('hidden'), 500);
@@ -3315,6 +3596,14 @@ body {
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) {
badge.remove();
}
@@ -3721,7 +4010,7 @@ body {
// Periodic update check (runs on init + every hour)
async function checkForUpdates() {
const currentVersion = 'v1.4.2';
const currentVersion = 'v1.4.17';
const devMode = localStorage.getItem('kantine_dev_mode') === 'true';
try {
@@ -3762,7 +4051,7 @@ body {
const modal = document.getElementById('version-modal');
const container = document.getElementById('version-list-container');
const devToggle = document.getElementById('dev-mode-toggle');
const currentVersion = 'v1.4.2';
const currentVersion = 'v1.4.17';
if (!modal) return;
modal.classList.remove('hidden');
@@ -3780,19 +4069,8 @@ body {
const dm = devToggle.checked;
container.innerHTML = '<p style="color:var(--text-secondary);">Lade Versionen...</p>';
try {
let versions;
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) {
function renderVersionsList(versions) {
if (!versions || !versions.length) {
container.innerHTML = '<p style="color:var(--text-secondary);">Keine Versionen gefunden.</p>';
return;
}
@@ -3824,6 +4102,34 @@ body {
`;
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) {
container.innerHTML = `<p style="color:#e94560;">Fehler: ${e.message}</p>`;
}

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>
<div id="last-updated-subtitle" class="subtitle"></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 class="header-center-wrapper">
<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">
<span class="material-icons-round">label</span>
</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">
<span class="material-icons-round theme-icon">light_mode</span>
</button>
@@ -218,9 +221,20 @@
<span>Dev-Mode (alle Tags anzeigen)</span>
</label>
</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>
</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>
<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>
@@ -267,6 +281,18 @@
const historyModal = document.getElementById('history-modal');
const btnHistoryClose = document.getElementById('btn-history-close');
if (btnHighlights) {
btnHighlights.addEventListener('click', () => {
highlightsModal.classList.remove('hidden');
});
}
if (btnHighlightsClose) {
btnHighlightsClose.addEventListener('click', () => {
highlightsModal.classList.add('hidden');
});
}
btnHistory.addEventListener('click', () => {
if (!authToken) {
loginModal.classList.remove('hidden');
@@ -304,6 +330,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) => {
if (e.target === versionModal) versionModal.classList.add('hidden');
});
@@ -354,6 +391,7 @@
});
btnNextWeek.addEventListener('click', () => {
btnNextWeek.classList.remove('new-week-available');
if (displayMode !== 'next-week') {
displayMode = 'next-week';
btnNextWeek.classList.add('active');
@@ -558,24 +596,49 @@
const progressFill = document.getElementById('history-progress-fill');
const progressText = document.getElementById('history-progress-text');
// Check local storage cache (we still use memory cache if available)
let localCache = [];
if (fullOrderHistoryCache) {
renderHistory(fullOrderHistoryCache);
return;
localCache = fullOrderHistoryCache;
} else {
const ls = localStorage.getItem('kantine_history_cache');
if (ls) {
try {
localCache = JSON.parse(ls);
fullOrderHistoryCache = localCache;
} catch (e) {
console.warn('History cache parse error', e);
}
}
}
// Show cached version immediately if we have one
if (localCache.length > 0) {
renderHistory(localCache);
}
if (!authToken) return;
// Start background delta sync
if (localCache.length === 0) {
historyContent.innerHTML = '';
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`;
let allOrders = [];
progressFill.style.width = '0%';
progressText.textContent = localCache.length > 0 ? 'Suche nach neuen Bestellungen...' : 'Lade Bestellhistorie...';
if (localCache.length > 0) historyLoading.classList.remove('hidden');
let nextUrl = localCache.length > 0
? `${API_BASE}/user/orders/?venue=${VENUE_ID}&ordering=-created&limit=5`
: `${API_BASE}/user/orders/?venue=${VENUE_ID}&ordering=-created&limit=50`;
let fetchedOrders = [];
let totalCount = 0;
let requiresFullFetch = localCache.length === 0;
let deltaComplete = false;
try {
while (nextUrl) {
while (nextUrl && !deltaComplete) {
const response = await fetch(nextUrl, { headers: apiHeaders(authToken) });
if (!response.ok) throw new Error(`Fetch failed: ${response.status}`);
@@ -585,26 +648,75 @@
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
if (!deltaComplete && requiresFullFetch) {
if (totalCount > 0) {
const pct = Math.round((allOrders.length / totalCount) * 100);
const pct = Math.round((fetchedOrders.length / totalCount) * 100);
progressFill.style.width = `${pct}%`;
progressText.textContent = `Lade Bestellung ${allOrders.length} von ${totalCount}...`;
progressText.textContent = `Lade Bestellung ${fetchedOrders.length} von ${totalCount}...`;
} 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 {
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) {
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>`;
} else {
showToast('Hintergrund-Synchronisation fehlgeschlagen', 'error');
}
} finally {
historyLoading.classList.add('hidden');
}
@@ -806,6 +918,7 @@
if (response.ok || response.status === 201) {
showToast(`Bestellt: ${name}`, 'success');
fullOrderHistoryCache = null; // Clear memory cache so next history open triggers delta sync
await fetchOrders();
} else {
const data = await response.json();
@@ -835,6 +948,7 @@
if (response.ok) {
showToast(`Storniert: ${name}`, 'success');
fullOrderHistoryCache = null; // Clear memory cache so next history open triggers delta sync
await fetchOrders();
} else {
const data = await response.json();
@@ -851,31 +965,172 @@
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() {
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');
bellBtn.style.display = 'none';
bellIcon.style.color = 'var(--text-secondary)';
bellIcon.style.textShadow = 'none';
return;
}
bellBtn.classList.remove('hidden');
bellBtn.style.display = 'inline-flex';
// 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;
}
let lastUpdatedStr = localStorage.getItem('kantine_last_updated');
let timeStr = 'gerade eben'; // Fallback instead of Unbekannt
if (!lastUpdatedStr) {
lastUpdatedStr = new Date().toISOString();
localStorage.setItem('kantine_last_updated', lastUpdatedStr);
}
const lastUpdated = new Date(lastUpdatedStr);
const diffMs = Date.now() - lastUpdated.getTime();
const diffMins = Math.floor(diffMs / 60000);
if (diffMins < 1) timeStr = 'gerade eben';
else 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 = '#10b981'; // green / success
bellIcon.style.textShadow = '0 0 10px rgba(16, 185, 129, 0.4)';
} else {
bellIcon.style.color = '#f59e0b'; // yellow / warning
bellIcon.style.textShadow = '0 0 10px rgba(245, 158, 11, 0.4)';
}
}
function toggleFlag(date, articleId, name, cutoff) {
const id = `${date}_${articleId}`;
let flagAdded = false;
if (userFlags.has(id)) {
userFlags.delete(id);
showToast(`Flag entfernt für ${name}`, 'success');
} else {
userFlags.add(id);
flagAdded = true;
showToast(`Benachrichtigung aktiviert für ${name}`, 'success');
if (Notification.permission === 'default') {
Notification.requestPermission();
}
}
saveFlags();
updateAlarmBell();
renderVisibleWeeks();
if (flagAdded) {
refreshFlaggedItems();
}
}
// FR-019: Auto-remove flags whose cutoff has passed
function cleanupExpiredFlags() {
const now = new Date();
const todayStr = now.toISOString().split('T')[0]; // Format: YYYY-MM-DD
let changed = false;
for (const flagId of [...userFlags]) {
const [date] = flagId.split('_');
const cutoff = new Date(date);
const [dateStr] = flagId.split('_'); // Format usually is YYYY-MM-DD
// 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
if (now >= cutoff) {
isExpired = true;
}
}
if (isExpired) {
userFlags.delete(flagId);
changed = true;
}
@@ -1020,6 +1275,7 @@
console.log(`[Cache] Parsed ${allWeeks.length} weeks:`, allWeeks.map(w => `KW${w.weekNumber}/${w.year} (${(w.days || []).length} days)`));
renderVisibleWeeks();
updateNextWeekBadge();
updateAlarmBell();
if (cachedTs) updateLastUpdatedTime(cachedTs);
console.log('Loaded menu from cache');
return true;
@@ -1232,6 +1488,7 @@
updateAuthUI(); // This will trigger fetchOrders if logged in
renderVisibleWeeks();
updateNextWeekBadge();
updateAlarmBell();
progressMessage.textContent = 'Fertig!';
setTimeout(() => progressModal.classList.add('hidden'), 500);
@@ -1389,6 +1646,14 @@
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) {
badge.remove();
}
@@ -1854,19 +2119,8 @@
const dm = devToggle.checked;
container.innerHTML = '<p style="color:var(--text-secondary);">Lade Versionen...</p>';
try {
let versions;
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) {
function renderVersionsList(versions) {
if (!versions || !versions.length) {
container.innerHTML = '<p style="color:var(--text-secondary);">Keine Versionen gefunden.</p>';
return;
}
@@ -1898,6 +2152,34 @@
`;
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) {
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

@@ -186,6 +186,32 @@ body {
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) */
.nav-badge {
background-color: var(--error-color);
@@ -775,7 +801,7 @@ body {
/* No opacity/filter here - fully visible */
background: var(--bg-card);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
border: 1px solid var(--accent-color);
border: 1px solid #8b5cf6;
border-radius: 8px;
padding: 1rem;
margin: 0 -1rem 1.5rem -1rem;
@@ -784,7 +810,7 @@ body {
}
.menu-item.today-ordered {
border: 2px solid var(--accent-color);
border: 2px solid #8b5cf6;
box-shadow: 0 0 20px rgba(139, 92, 246, 0.4);
border-radius: 8px;
padding: 1rem;
@@ -1595,8 +1621,6 @@ body {
.version-list {
list-style: none;
padding: 0;
max-height: 350px;
overflow-y: auto;
margin: 0;
}

17
syntax_check.js Executable file
View File

@@ -0,0 +1,17 @@
const fs = require('fs');
const jsCode = fs.readFileSync('kantine.js', 'utf8')
.replace('(function () {', '')
.replace('})();', '')
.replace('if (window.__KANTINE_LOADED) return;', '');
const testCode = `
console.log("TEST");
`;
const code = jsCode + '\n' + testCode;
try {
const vm = require('vm');
new vm.Script(code);
} catch (e) {
if(e.stack) {
console.log("Syntax error at:", e.stack.split('\n').slice(0,3).join('\n'));
}
}

View File

@@ -108,7 +108,8 @@ try {
[/function\s+isNewer/, 'isNewer function'],
[/function\s+openVersionMenu/, 'openVersionMenu function'],
[/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) {

99
tests/test_dom.js Executable file
View File

@@ -0,0 +1,99 @@
const fs = require('fs');
fs.writeFileSync('trace.log', '');
function log(m) { fs.appendFileSync('trace.log', m + '\n'); }
log("Initializing JSDOM...");
const jsdom = require('jsdom');
const { JSDOM } = jsdom;
log("Reading html...");
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>
<!-- Mocks for Highlights Feature -->
<button id="btn-highlights">Highlights</button>
<div id="highlights-modal" class="modal hidden">
<button id="btn-highlights-close">Close</button>
<input id="tag-input" type="text" />
<button id="btn-add-tag">Add</button>
<ul id="tags-list"></ul>
</div>
</body>
</html>
`;
log("Reading file jsCode...");
const jsCode = fs.readFileSync('kantine.js', 'utf8')
.replace('(function () {', '')
.replace('})();', '')
.replace('if (window.__KANTINE_LOADED) return;', '');
log("Instantiating JSDOM...");
const dom = new JSDOM(html, { runScripts: "dangerously", url: "http://localhost/" });
log("JSDOM dom created...");
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: () => { } };
global.window.matchMedia = () => ({ matches: false, addListener: () => { }, removeListener: () => { } });
global.fetch = () => Promise.resolve({ ok: true, json: () => Promise.resolve({ results: [] }) });
global.window.fetch = global.fetch;
log("Before eval...");
const testCode = `
console.log("--- Testing Alarm Bell ---");
// Add flag
userFlags.add('2026-02-24_123'); updateAlarmBell();
if (document.getElementById('alarm-bell').className.includes('hidden')) throw new Error("Bell should be visible");
// Remove flag
userFlags.delete('2026-02-24_123'); updateAlarmBell();
if (!document.getElementById('alarm-bell').className.includes('hidden')) throw new Error("Bell should be hidden");
console.log("✅ Alarm Bell Test Passed");
console.log("--- Testing Highlights Modal ---");
// First, verify initial state
const hlModal = document.getElementById('highlights-modal');
if (!hlModal.classList.contains('hidden')) throw new Error("Highlights modal should be hidden initially");
// Call bindEvents manually to attach the listeners since the IIFE is stripped
bindEvents();
// Click to open
document.getElementById('btn-highlights').click();
if (hlModal.classList.contains('hidden')) throw new Error("Highlights modal did not open upon clicking btn-highlights!");
// Click to close
document.getElementById('btn-highlights-close').click();
if (!hlModal.classList.contains('hidden')) throw new Error("Highlights modal did not close upon clicking btn-highlights-close!");
console.log("✅ Highlights Modal Test Passed");
window.__TEST_PASSED = true;
`;
dom.window.eval(jsCode + "\n" + testCode);
if (!dom.window.__TEST_PASSED) {
throw new Error("Tests failed to reach completion inside JSDOM.");
}
process.exit(0);

View File

@@ -1 +1 @@
v1.4.2
v1.4.17