Compare commits

..

35 Commits

Author SHA1 Message Date
Kantine Wrapper
23ed867ac4 chore: update build artifacts for v1.4.23 2026-02-26 08:25:14 +01:00
Kantine Wrapper
f8b1334a9a fix: inject favicon into page when bookmarklet runs (v1.4.23) 2026-02-26 08:25:07 +01:00
Kantine Wrapper
6b1bd46210 chore: update build artifacts for v1.4.22 2026-02-26 08:19:40 +01:00
Kantine Wrapper
a429148324 docs: full documentation audit - README + REQUIREMENTS (v1.4.22) 2026-02-26 08:19:34 +01:00
Kantine Wrapper
5caaf7dcad chore: update build artifacts for v1.4.21 2026-02-25 15:00:04 +01:00
Kantine Wrapper
122c1078cf feat: dynamic glow on next-week button until first order (v1.4.21) 2026-02-25 14:59:59 +01:00
Kantine Wrapper
ff48befb8a chore: update build artifacts for v1.4.20 2026-02-25 14:57:50 +01:00
Kantine Wrapper
9391dfd8d7 fix: update next-week badge counter after order/cancel (v1.4.20) 2026-02-25 14:57:44 +01:00
Kantine Wrapper
467e48e1da chore: update build artifacts for v1.4.19 2026-02-24 21:20:34 +01:00
Kantine Wrapper
566410eea5 feat: custom favicon for bookmarklet (triangle + fork & knife) v1.4.19 2026-02-24 21:20:30 +01:00
Kantine Wrapper
37afc2957b chore: update build artifacts for v1.4.18 2026-02-24 20:50:20 +01:00
Kantine Wrapper
b1763135aa test(ui): massively expand DOM testing suite to cover all Modals and Actions (v1.4.18) 2026-02-24 20:50:19 +01:00
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
18 changed files with 1085 additions and 196 deletions

View File

@@ -48,6 +48,11 @@ trigger: always_on
- **Browser**: Allowed for documentation and safe browsing. No automated logins without permission. - **Browser**: Allowed for documentation and safe browsing. No automated logins without permission.
- **Terminal**: No `rm -rf`. Run tests (`pytest` etc.) after logic changes. - **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 📋 ## 7. Requirements-Konsistenz 📋
Alle umgesetzten Anforderungen müssen mit `REQUIREMENTS.md` übereinstimmen. Alle umgesetzten Anforderungen müssen mit `REQUIREMENTS.md` übereinstimmen.
1. **Vor der Umsetzung prüfen**: Passt die neue Anforderung zu den bestehenden Requirements? 1. **Vor der Umsetzung prüfen**: Passt die neue Anforderung zu den bestehenden Requirements?

View File

@@ -8,9 +8,14 @@ Ein intelligentes Bookmarklet für die Mitarbeiter-Kantine der Bessa App. Dieses
* **Bestell-Countdown:** ⏳ Roter Alarm 1h vor Bestellschluss. * **Bestell-Countdown:** ⏳ Roter Alarm 1h vor Bestellschluss.
* **Smart Highlights:** 🌟 Markiere deine Favoriten (z.B. "Schnitzel", "Vegetarisch"). * **Smart Highlights:** 🌟 Markiere deine Favoriten (z.B. "Schnitzel", "Vegetarisch").
* **Bestellstatus:** Farbige Indikatoren für bestellte Menüs. * **Bestellstatus:** Farbige Indikatoren für bestellte Menüs.
* **Kostenkontrolle:** Summiert automatisch den Gesamtpreis der Woche. * **Kostenkontrolle:** 💰 Summiert automatisch den Gesamtpreis der Woche.
* **Session Reuse:** Nutzt automatisch eine bestehende Login-Session (Loggt dich automatisch ein). * **Bestellhistorie:** 📜 Gruppiert nach Monat & KW mit inkrementellem Delta-Cache.
* **Menu Badges:** Zeigt Menü-Codes (M1, M2+) direkt im Header. * **Session Reuse:** 🔑 Nutzt automatisch eine bestehende Login-Session.
* **Menu Badges:** 🏷️ Zeigt Menü-Codes (M1, M2+) direkt im Header.
* **Menü-Flagging:** 🔔 Ausverkaufte Menüs beobachten und bei Verfügbarkeit benachrichtigt werden.
* **Version-Menü:** 📦 Versionsliste mit Installer-Links, Dev-Mode Toggle und Downgrade-Support.
* **Cache leeren:** 🗑️ Lokalen Cache mit einem Klick bereinigen (im Version-Menü).
* **Favicon:** 🍽️ Eigenes Icon für die Lesezeichenleiste.
* **Changelog:** Übersicht über neue Funktionen direkt im Installer. * **Changelog:** Übersicht über neue Funktionen direkt im Installer.
## 📦 Installation ## 📦 Installation
@@ -19,7 +24,7 @@ Ein intelligentes Bookmarklet für die Mitarbeiter-Kantine der Bessa App. Dieses
2. Ziehe den blauen Button **"Kantine Wrapper"** in deine Lesezeichen-Leiste. 2. Ziehe den blauen Button **"Kantine Wrapper"** in deine Lesezeichen-Leiste.
3. Fertig! 3. Fertig!
## usage ## 🍽️ Nutzung
1. Navigiere zu [https://web.bessa.app/knapp-kantine](https://web.bessa.app/knapp-kantine). 1. Navigiere zu [https://web.bessa.app/knapp-kantine](https://web.bessa.app/knapp-kantine).
2. Klicke auf das **"Kantine Wrapper"** Lesezeichen. 2. Klicke auf das **"Kantine Wrapper"** Lesezeichen.
@@ -28,24 +33,38 @@ Ein intelligentes Bookmarklet für die Mitarbeiter-Kantine der Bessa App. Dieses
## 🛠️ Entwicklung ## 🛠️ Entwicklung
### Voraussetzungen ### Voraussetzungen
* Node.js (optional, nur für Build-Scripts) * Node.js (für Build- und Test-Scripts)
* Python 3 (für Build-Tests)
* Bash (für `build-bookmarklet.sh`) * Bash (für `build-bookmarklet.sh`)
### Projektstruktur ### Projektstruktur
#### Quelldateien #### Quelldateien
* `kantine.js`: Der Haupt-Quellcode des Bookmarklets (UI, API-Logik, Rendering). | Datei | Beschreibung |
* `style.css`: Das komplette Design (CSS mit Light/Dark Mode). |-------|-------------|
* `mock-data.js`: Mock-Fetch-Interceptor mit realistischen Dummy-Menüdaten für Standalone-Tests. | `kantine.js` | Haupt-Quellcode des Bookmarklets (UI, API-Logik, Rendering). |
* `build-bookmarklet.sh`: Build-Skript erzeugt alle `dist/`-Artefakte. | `style.css` | Komplettes Design (CSS mit Light/Dark Mode). |
* `test_build.py`: Automatische Build-Tests, laufen am Ende jedes Builds. | `favicon.svg` | Favicon für die Installer-Seite (Dreieck + Gabel & Messer). |
| `mock-data.js` | Mock-Fetch-Interceptor mit realistischen Dummy-Menüdaten für Standalone-Tests. |
| `build-bookmarklet.sh` | Build-Skript erzeugt alle `dist/`-Artefakte und führt alle Tests aus. |
| `release.sh` | Release-Skript Commit, Tag, Push zu allen Remotes. |
| `version.txt` | Aktuelle Versionsnummer (SemVer). |
| `changelog.md` | Änderungshistorie aller Versionen. |
| `REQUIREMENTS.md` | System Requirements Specification (SRS). |
#### Tests
| Datei | Beschreibung |
|-------|-------------|
| `test_logic.js` | Logik-Unit-Tests (statische Analyse, Syntax-Check, Sandbox-Ausführung). |
| `tests/test_dom.js` | DOM-Interaktionstests via JSDOM (prüft Event-Listener-Bindung aller UI-Komponenten). |
| `test_build.py` | Build-Artefakt-Validierung (Existenz, Inhalt). |
#### `dist/` Build-Artefakte #### `dist/` Build-Artefakte
| Datei | Beschreibung | | Datei | Beschreibung |
|-------|-------------| |-------|-------------|
| `bookmarklet.txt` | Die rohe Bookmarklet-URL (`javascript:...`). Enthält CSS + JS als selbstextrahierendes IIFE. Kann direkt als Lesezeichen-URL eingefügt werden. | | `bookmarklet.txt` | Die rohe Bookmarklet-URL (`javascript:...`). Enthält CSS + JS als selbstextrahierendes IIFE. Kann direkt als Lesezeichen-URL eingefügt werden. |
| `bookmarklet-payload.js` | Der entpackte Bookmarklet-Payload (JS). Erstellt `<style>` + `<script>` Elemente und injiziert sie in die Seite. Nützlich zum Debuggen. | | `bookmarklet-payload.js` | Der entpackte Bookmarklet-Payload (JS). Erstellt `<style>` + `<script>` Elemente und injiziert sie in die Seite. Nützlich zum Debuggen. |
| `install.html` | Installer-Seite mit Drag & Drop Button, Anleitung, Feature-Liste und Changelog. Kann lokal oder gehostet geöffnet werden. | | `install.html` | Installer-Seite mit Drag & Drop Button, Favicon, Anleitung, Feature-Liste und Changelog. Kann lokal oder gehostet geöffnet werden. |
| `kantine-standalone.html` | Eigenständige HTML-Datei mit eingebettetem CSS + JS + **Mock-Daten**. Lädt automatisch Dummy-Menüs für UI-Tests ohne API-Zugriff. | | `kantine-standalone.html` | Eigenständige HTML-Datei mit eingebettetem CSS + JS + **Mock-Daten**. Lädt automatisch Dummy-Menüs für UI-Tests ohne API-Zugriff. |
### Build ### Build
@@ -55,5 +74,15 @@ Um Änderungen an `kantine.js` oder `style.css` wirksam zu machen, führe den Bu
./build-bookmarklet.sh ./build-bookmarklet.sh
``` ```
### Release
Erstellt einen Git-Tag, committet Build-Artefakte und pusht zu allen Remotes:
```bash
./release.sh
```
## ⚠️ Hinweis
Dieses Projekt enthält zum überwiegenden Teil **KI-generierten Code**. Der Code wurde mithilfe von KI-Assistenten erstellt, überprüft und iterativ verfeinert.
## 📝 Lizenz ## 📝 Lizenz
Internes Tool. Internes Tool.

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,11 +59,11 @@ 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 | Solange Menüdaten für die Nächste Woche verfügbar sind, aber noch keine Bestellungen getätigt wurden, muss der entsprechende Navigation-Button animiert und farblich (Gelb) hervorgehoben werden. Nach der ersten Bestellung muss die Hervorhebung automatisch erlöschen. Zusätzlich muss beim erstmaligen Erscheinen der Daten ein einmaliger Toast-Hinweis angezeigt werden. | Mittel | v1.6.0 (Update v1.4.21) |
| **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-095 | Alle benutzerrelevanten Aktionen (Bestellung, Stornierung, Fehler) müssen durch nicht-blockierende Benachrichtigungen (Toasts) bestätigt werden. | Mittel | v1.0.1 |
| FR-091 | Bei einem Verbindungsfehler muss ein Fehlerdialog mit Fallback-Link zur Originalseite angezeigt werden. | Mittel | v1.0.1 | | FR-096 | Bei einem Verbindungsfehler muss ein Fehlerdialog mit Fallback-Link zur Originalseite angezeigt werden. | Mittel | v1.0.1 |
| **Nächste-Woche-Badge** | | | | | **Nächste-Woche-Badge** | | | |
| FR-100 | Die Navigation zur nächsten Woche muss ein Badge anzeigen, das den Überblick über den Bestellstatus der kommenden Woche visualisiert (bestellt / bestellbar / gesamt). | Niedrig | v1.0.1 | | FR-100 | Die Navigation zur nächsten Woche muss ein Badge anzeigen, das den Überblick über den Bestellstatus der kommenden Woche visualisiert (bestellt / bestellbar / gesamt). | Niedrig | v1.0.1 |
| **Update-Management** | | | | | **Update-Management** | | | |
@@ -73,6 +73,8 @@ Das System umfasst die Darstellung von Menüplänen in einer Wochenübersicht, d
| FR-113 | Es muss möglich sein, zu einer älteren Version zurückzukehren (Downgrade). | Niedrig | v1.3.0 | | FR-113 | Es muss möglich sein, zu einer älteren Version zurückzukehren (Downgrade). | Niedrig | v1.3.0 |
| FR-114 | Ein Dev-Mode muss es ermöglichen, zwischen stabilen Releases und Entwicklungs-Tags umzuschalten. | Niedrig | v1.3.0 | | FR-114 | Ein Dev-Mode muss es ermöglichen, zwischen stabilen Releases und Entwicklungs-Tags umzuschalten. | Niedrig | v1.3.0 |
| FR-115 | Das Versionsmenü muss Links zur Erstellung von Feature-Requests und Bug-Reports auf GitHub enthalten. | Niedrig | v1.4.4 | | FR-115 | Das Versionsmenü muss Links zur Erstellung von Feature-Requests und Bug-Reports auf GitHub enthalten. | Niedrig | v1.4.4 |
| FR-116 | Das Versionsmenü muss eine Funktion zum Leeren des lokalen Caches bereitstellen, um bei hartnäckigen Fehlern alle gespeicherten Daten bereinigen zu können. | Niedrig | v1.4.16 |
| FR-117 | Die Installer-Seite muss ein eingebettetes Favicon bereitstellen, das beim Drag & Drop in die Lesezeichenleiste als Icon für das Bookmarklet übernommen wird. | Niedrig | v1.4.19 |
## 3. Nicht-funktionale Anforderungen ## 3. Nicht-funktionale Anforderungen
@@ -85,7 +87,7 @@ Das System umfasst die Darstellung von Menüplänen in einer Wochenübersicht, d
| **Benutzbarkeit** | NFR-005 | Die Oberfläche muss auf mobilen Geräten fehlerfrei nutzbar sein. | Viewports ab 320px Breite | | **Benutzbarkeit** | NFR-005 | Die Oberfläche muss auf mobilen Geräten fehlerfrei nutzbar sein. | Viewports ab 320px Breite |
| **Benutzbarkeit** | NFR-006 | Alle interaktiven Elemente müssen Tooltips oder Hilfetexte bieten. | 100% Coverage | | **Benutzbarkeit** | NFR-006 | Alle interaktiven Elemente müssen Tooltips oder Hilfetexte bieten. | 100% Coverage |
| **Benutzbarkeit** | NFR-007 | Die Benutzeroberfläche muss vollständig in deutscher Sprache sein. | Vollständige Lokalisierung | | **Benutzbarkeit** | NFR-007 | Die Benutzeroberfläche muss vollständig in deutscher Sprache sein. | Vollständige Lokalisierung |
| **Wartbarkeit** | NFR-008 | Die Build-Artefakte müssen durch automatisierte Tests validiert werden. | Build-Tests + Logik-Tests | | **Wartbarkeit** | NFR-008 | Die Build-Artefakte müssen durch automatisierte Tests validiert werden. | Build-Tests + Logik-Tests + DOM-Tests |
## 4. Technische Randbedingungen ## 4. Technische Randbedingungen
* **Deployment**: Das System wird als Bookmarklet ausgeliefert, das auf der Bessa-Webseite ausgeführt wird. * **Deployment**: Das System wird als Bookmarklet ausgeliefert, das auf der Bessa-Webseite ausgeführt wird.
@@ -93,4 +95,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 + Node.js-basierte DOM-Interaktionstests (JSDOM).

View File

@@ -7,6 +7,7 @@ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
DIST_DIR="$SCRIPT_DIR/dist" DIST_DIR="$SCRIPT_DIR/dist"
CSS_FILE="$SCRIPT_DIR/style.css" CSS_FILE="$SCRIPT_DIR/style.css"
JS_FILE="$SCRIPT_DIR/kantine.js" JS_FILE="$SCRIPT_DIR/kantine.js"
FAVICON_FILE="$SCRIPT_DIR/favicon.svg"
# === VERSION === # === VERSION ===
if [ -f "$SCRIPT_DIR/version.txt" ]; then if [ -f "$SCRIPT_DIR/version.txt" ]; then
@@ -23,6 +24,11 @@ echo "=== Kantine Bookmarklet Builder ($VERSION) ==="
# Check files exist # Check files exist
if [ ! -f "$CSS_FILE" ]; then echo "ERROR: $CSS_FILE not found"; exit 1; fi if [ ! -f "$CSS_FILE" ]; then echo "ERROR: $CSS_FILE not found"; exit 1; fi
if [ ! -f "$JS_FILE" ]; then echo "ERROR: $JS_FILE not found"; exit 1; fi if [ ! -f "$JS_FILE" ]; then echo "ERROR: $JS_FILE not found"; exit 1; fi
if [ ! -f "$FAVICON_FILE" ]; then echo "ERROR: $FAVICON_FILE not found"; exit 1; fi
# Generate favicon Base64 data URI
FAVICON_B64=$(base64 -w0 "$FAVICON_FILE")
FAVICON_URI="data:image/svg+xml;base64,${FAVICON_B64}"
CSS_CONTENT=$(cat "$CSS_FILE") CSS_CONTENT=$(cat "$CSS_FILE")
@@ -101,6 +107,7 @@ cat > "$DIST_DIR/install.html" << INSTALLEOF
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<title>Kantine Wrapper Installer ($VERSION)</title> <title>Kantine Wrapper Installer ($VERSION)</title>
<link rel="icon" type="image/svg+xml" href="$FAVICON_URI">
<style> <style>
body { font-family: 'Inter', sans-serif; max-width: 600px; margin: 40px auto; padding: 20px; background: #1a1a2e; color: #eee; } body { font-family: 'Inter', sans-serif; max-width: 600px; margin: 40px auto; padding: 20px; background: #1a1a2e; color: #eee; }
h1 { color: #029AA8; } /* Knapp Teal */ h1 { color: #029AA8; } /* Knapp Teal */
@@ -171,7 +178,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>
@@ -258,6 +265,14 @@ if [ $LOGIC_EXIT -ne 0 ]; then
exit 1 exit 1
fi 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 ===" echo "=== Running Build Tests ==="
python3 "$SCRIPT_DIR/test_build.py" python3 "$SCRIPT_DIR/test_build.py"
TEST_EXIT=$? TEST_EXIT=$?
@@ -267,23 +282,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,61 @@
## v1.4.23
- 🐛 **Bugfix**: Favicon wird jetzt beim Ausführen des Bookmarklets in die Seite injiziert (ersetzt das Bessa-Favicon im Tab). Chrome cached das Icon und übernimmt es anschließend auch in die Lesezeichenleiste.
## v1.4.22
- 📝 **Docs**: Vollständiger Dokumentations-Audit: README.md um fehlende Dateien (favicon.svg, release.sh, Tests) und Features (Bestellhistorie, Version-Menü, Cache leeren, Favicon) ergänzt. REQUIREMENTS.md: Doppelte IDs (FR-090/091) behoben, FR-092 an dynamische Glow-Logik angepasst, FR-116 (Cache leeren) und FR-117 (Favicon) hinzugefügt, NFR-008 um DOM-Tests erweitert.
## v1.4.21
-**UX**: Der Glow-Effekt des „Nächste Woche"-Buttons bleibt nun aktiv, solange Menüdaten vorhanden aber noch keine Bestellungen getätigt wurden. Verschwindet automatisch nach der ersten Bestellung.
## v1.4.20
- 🐛 **Bugfix**: Der Badge-Counter im „Nächste Woche"-Tab wird jetzt sofort nach einer Bestellung oder Stornierung aktualisiert.
## v1.4.19
- 🎨 **Feature**: Eigenes Favicon für die Installer-Seite hinzugefügt (Dreieck + Gabel & Messer). Wird beim Drag & Drop in die Lesezeichenleiste als Icon übernommen.
## v1.4.18
- 🧪 **Testing**: Die automatische DOM-Testing Suite (`test_dom.js`) wurde massiv ausgebaut. Sie prüft nun neben der Alarmglocke und den Highlights auch systematisch alle anderen UI-Komponenten (Login-Modal, History-Modal, Versionen-Modal, Theme-Toggle, und Navigation Tabs) auf korrekte Event-Listener-Bindungen, um Regressionen (tote Buttons) endgültig auszuschließen.
## 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) ## v1.4.5 (2026-02-24)
- **Fix**: Doppelten Scrollbalken in der Versionen-Liste entfernt. - **Fix**: Doppelten Scrollbalken in der Versionen-Liste entfernt.

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

91
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;
@@ -2000,6 +2000,16 @@ body {
// Replace entire page content // Replace entire page content
document.title = 'Kantine Weekly Menu'; document.title = 'Kantine Weekly Menu';
// Inject custom favicon (triangle + fork & knife)
if (document.querySelectorAll) {
document.querySelectorAll('link[rel*="icon"]').forEach(el => el.remove());
}
const favicon = document.createElement('link');
favicon.rel = 'icon';
favicon.type = 'image/svg+xml';
favicon.href = 'data:image/svg+xml,' + encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><g transform="translate(2,10)"><rect x="1" y="0" width="1.8" height="16" rx=".9" fill="%23333"/><rect x="4.6" y="0" width="1.8" height="16" rx=".9" fill="%23333"/><rect x="8.2" y="0" width="1.8" height="16" rx=".9" fill="%23333"/><rect x="1" y="14" width="9" height="3.5" rx="1.5" fill="%23333"/><rect x="3.5" y="16.5" width="4" height="24" rx="2" fill="%23333"/></g><polygon points="32,8 47,48 17,48" fill="none" stroke="%23333" stroke-width="4" stroke-linejoin="round"/><g transform="translate(50,10)"><path d="M3,0C3,0,3,0,3,0L3,17L10,14C10,6,7,0,3,0Z" fill="%23333"/><rect x="1.5" y="0" width="2" height="18" rx="1" fill="%23333"/><rect x="1.5" y="16.5" width="8.5" height="3.5" rx="1.2" fill="%23333"/><rect x="3.5" y="19" width="4" height="22" rx="2" fill="%23333"/></g></svg>');
document.head.appendChild(favicon);
// Inject Google Fonts if not already present // Inject Google Fonts if not already present
if (!document.querySelector('link[href*="fonts.googleapis.com/css2?family=Inter"]')) { if (!document.querySelector('link[href*="fonts.googleapis.com/css2?family=Inter"]')) {
const fontLink = document.createElement('link'); const fontLink = document.createElement('link');
@@ -2021,7 +2031,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.23</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 +2173,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.23</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 +2191,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>
@@ -2228,6 +2241,18 @@ body {
const historyModal = document.getElementById('history-modal'); const historyModal = document.getElementById('history-modal');
const btnHistoryClose = document.getElementById('btn-history-close'); 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', () => { btnHistory.addEventListener('click', () => {
if (!authToken) { if (!authToken) {
loginModal.classList.remove('hidden'); loginModal.classList.remove('hidden');
@@ -2265,6 +2290,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');
}); });
@@ -2505,6 +2541,7 @@ body {
} }
console.log(`Fetched ${results.length} orders, mapped active ones.`); console.log(`Fetched ${results.length} orders, mapped active ones.`);
renderVisibleWeeks(); renderVisibleWeeks();
updateNextWeekBadge();
} }
} catch (error) { } catch (error) {
console.error('Error fetching orders:', error); console.error('Error fetching orders:', error);
@@ -2520,37 +2557,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) {
try {
localCache = JSON.parse(ls);
fullOrderHistoryCache = localCache;
} catch (e) {
console.warn('History cache parse error', e);
}
}
} }
// Check local storage cache // Show cached version immediately if we have one
const localCache = localStorage.getItem('kantine_history_cache'); if (localCache.length > 0) {
if (localCache) { renderHistory(localCache);
try {
fullOrderHistoryCache = JSON.parse(localCache);
renderHistory(fullOrderHistoryCache);
return;
} catch (e) {
console.warn('History cache parse error', e);
}
} }
if (!authToken) return; if (!authToken) return;
historyContent.innerHTML = ''; // Start background delta sync
historyLoading.classList.remove('hidden'); if (localCache.length === 0) {
progressFill.style.width = '0%'; historyContent.innerHTML = '';
progressText.textContent = 'Lade Bestellhistorie...'; historyLoading.classList.remove('hidden');
}
let nextUrl = `${API_BASE}/user/orders/?venue=${VENUE_ID}&ordering=-created&limit=50`; progressFill.style.width = '0%';
let allOrders = []; progressText.textContent = localCache.length > 0 ? 'Suche nach neuen Bestellungen...' : 'Lade Bestellhistorie...';
if (localCache.length > 0) historyLoading.classList.remove('hidden');
let nextUrl = localCache.length > 0
? `${API_BASE}/user/orders/?venue=${VENUE_ID}&ordering=-created&limit=5`
: `${API_BASE}/user/orders/?venue=${VENUE_ID}&ordering=-created&limit=50`;
let fetchedOrders = [];
let totalCount = 0; let totalCount = 0;
let requiresFullFetch = localCache.length === 0;
let deltaComplete = false;
try { try {
while (nextUrl) { while (nextUrl && !deltaComplete) {
const response = await fetch(nextUrl, { headers: apiHeaders(authToken) }); const response = await fetch(nextUrl, { headers: apiHeaders(authToken) });
if (!response.ok) throw new Error(`Fetch failed: ${response.status}`); if (!response.ok) throw new Error(`Fetch failed: ${response.status}`);
@@ -2560,29 +2609,75 @@ body {
totalCount = data.count; totalCount = data.count;
} }
allOrders = allOrders.concat(data.results || []); const results = data.results || [];
// Update progress for (const order of results) {
if (totalCount > 0) { // Check if we hit an order that is already in our cache AND has the exact same state/update time
const pct = Math.round((allOrders.length / totalCount) * 100); // Bessa returns 'updated' timestamp, we can use it to determine if anything changed
progressFill.style.width = `${pct}%`; const existingOrderIndex = localCache.findIndex(cached => cached.id === order.id);
progressText.textContent = `Lade Bestellung ${allOrders.length} von ${totalCount}...`;
} else { if (!requiresFullFetch && existingOrderIndex !== -1) {
progressText.textContent = `Lade Bestellung ${allOrders.length}...`; const existingOrder = localCache[existingOrderIndex];
// If order exists and wasn't updated since our cache, we've reached the point
// where everything older is already correctly cached.
// order.updated is an ISO string like "2025-02-18T10:30:15.123456Z"
if (existingOrder.updated === order.updated && existingOrder.order_state === order.order_state) {
deltaComplete = true;
break;
}
}
fetchedOrders.push(order);
} }
nextUrl = data.next; // Update progress
if (!deltaComplete && requiresFullFetch) {
if (totalCount > 0) {
const pct = Math.round((fetchedOrders.length / totalCount) * 100);
progressFill.style.width = `${pct}%`;
progressText.textContent = `Lade Bestellung ${fetchedOrders.length} von ${totalCount}...`;
} else {
progressText.textContent = `Lade Bestellung ${fetchedOrders.length}...`;
}
} else if (!deltaComplete) {
progressText.textContent = `${fetchedOrders.length} neue/geänderte Bestellungen gefunden...`;
}
nextUrl = deltaComplete ? null : data.next;
} }
fullOrderHistoryCache = allOrders; // Merge fetched orders with cache
try { if (fetchedOrders.length > 0) {
localStorage.setItem('kantine_history_cache', JSON.stringify(allOrders)); // We have new/updated orders. We need to merge them into the cache.
} catch (e) { console.warn('History cache write error', e); } // 1. Create a map of the existing cache for quick ID lookup
renderHistory(fullOrderHistoryCache); const cacheMap = new Map(localCache.map(o => [o.id, o]));
// 2. Update/Insert the newly fetched orders
for (const order of fetchedOrders) {
cacheMap.set(order.id, order); // Overwrites existing, or adds new
}
// 3. Convert back to array and sort by created date (descending)
const mergedOrders = Array.from(cacheMap.values());
mergedOrders.sort((a, b) => new Date(b.created) - new Date(a.created));
fullOrderHistoryCache = mergedOrders;
try {
localStorage.setItem('kantine_history_cache', JSON.stringify(mergedOrders));
} catch (e) {
console.warn('History cache write error', e);
}
// Render the updated history
renderHistory(fullOrderHistoryCache);
}
} catch (error) { } catch (error) {
console.error('Error fetching full history:', error); console.error('Error in history sync:', error);
historyContent.innerHTML = `<p style="color:var(--error-color);text-align:center;">Fehler beim Laden der Historie.</p>`; if (localCache.length === 0) {
historyContent.innerHTML = `<p style="color:var(--error-color);text-align:center;">Fehler beim Laden der Historie.</p>`;
} else {
showToast('Hintergrund-Synchronisation fehlgeschlagen', 'error');
}
} finally { } finally {
historyLoading.classList.add('hidden'); historyLoading.classList.add('hidden');
} }
@@ -2784,7 +2879,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 +2909,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 +2926,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 +2994,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 +3020,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) {
const lastUpdated = new Date(lastUpdatedStr); lastUpdatedStr = new Date().toISOString();
const diffMs = Date.now() - lastUpdated.getTime(); localStorage.setItem('kantine_last_updated', lastUpdatedStr);
const diffMins = Math.floor(diffMs / 60000);
if (diffMins < 60) timeStr = `vor ${diffMins} Min.`;
else timeStr = `vor ${Math.floor(diffMins / 60)} Std.`;
} }
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}`; 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 +3062,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);
cutoff.setHours(10, 0, 0, 0); // Standard cutoff 10:00 // If the flag's date string is entirely in the past (before today)
if (now >= cutoff) { // 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); userFlags.delete(flagId);
changed = true; changed = true;
} }
@@ -3050,6 +3236,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;
@@ -3420,12 +3607,17 @@ body {
badge.classList.add('has-highlights'); badge.classList.add('has-highlights');
} }
// FR-092: Highlight Next Week Button when new data arrives // FR-092: Glow Next Week button while data exists but no orders placed
const storageKey = `kantine_notified_nextweek_${nextYear}_${nextWeek}`; if (daysWithOrders === 0) {
if (!localStorage.getItem(storageKey)) {
localStorage.setItem(storageKey, 'true');
btnNextWeek.classList.add('new-week-available'); btnNextWeek.classList.add('new-week-available');
showToast('Neue Menüdaten für nächste Woche verfügbar!', 'info'); // One-time toast notification when new data first arrives
const storageKey = `kantine_notified_nextweek_${nextYear}_${nextWeek}`;
if (!localStorage.getItem(storageKey)) {
localStorage.setItem(storageKey, 'true');
showToast('Neue Menüdaten für nächste Woche verfügbar!', 'info');
}
} else {
btnNextWeek.classList.remove('new-week-available');
} }
} else if (badge) { } else if (badge) {
@@ -3834,7 +4026,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.23';
const devMode = localStorage.getItem('kantine_dev_mode') === 'true'; const devMode = localStorage.getItem('kantine_dev_mode') === 'true';
try { try {
@@ -3875,7 +4067,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.23';
if (!modal) return; if (!modal) return;
modal.classList.remove('hidden'); modal.classList.remove('hidden');
@@ -3893,19 +4085,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 +4118,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>`;
} }

29
favicon.svg Executable file
View File

@@ -0,0 +1,29 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
<!-- Fork (left, with gap to triangle) -->
<g transform="translate(2, 10)">
<!-- Tines -->
<rect x="1" y="0" width="1.8" height="16" rx="0.9" fill="#333"/>
<rect x="4.6" y="0" width="1.8" height="16" rx="0.9" fill="#333"/>
<rect x="8.2" y="0" width="1.8" height="16" rx="0.9" fill="#333"/>
<!-- Connector -->
<rect x="1" y="14" width="9" height="3.5" rx="1.5" fill="#333"/>
<!-- Handle -->
<rect x="3.5" y="16.5" width="4" height="24" rx="2" fill="#333"/>
</g>
<!-- Triangle (center, equilateral aspect ratio ~1:0.866) -->
<!-- Equilateral: base=28, height=24.25 => keeps proper ratio -->
<polygon points="32,8 47,48 17,48" fill="none" stroke="#333" stroke-width="4" stroke-linejoin="round"/>
<!-- Knife (right, with gap to triangle) -->
<g transform="translate(50, 10)">
<!-- Blade (slight curve) -->
<path d="M3,0 C3,0 3,0 3,0 L3,17 L10,14 C10,6 7,0 3,0 Z" fill="#333"/>
<!-- Spine -->
<rect x="1.5" y="0" width="2" height="18" rx="1" fill="#333"/>
<!-- Bolster -->
<rect x="1.5" y="16.5" width="8.5" height="3.5" rx="1.2" fill="#333"/>
<!-- Handle -->
<rect x="3.5" y="19" width="4" height="22" rx="2" fill="#333"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -50,6 +50,16 @@
// Replace entire page content // Replace entire page content
document.title = 'Kantine Weekly Menu'; document.title = 'Kantine Weekly Menu';
// Inject custom favicon (triangle + fork & knife)
if (document.querySelectorAll) {
document.querySelectorAll('link[rel*="icon"]').forEach(el => el.remove());
}
const favicon = document.createElement('link');
favicon.rel = 'icon';
favicon.type = 'image/svg+xml';
favicon.href = 'data:image/svg+xml,' + encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><g transform="translate(2,10)"><rect x="1" y="0" width="1.8" height="16" rx=".9" fill="%23333"/><rect x="4.6" y="0" width="1.8" height="16" rx=".9" fill="%23333"/><rect x="8.2" y="0" width="1.8" height="16" rx=".9" fill="%23333"/><rect x="1" y="14" width="9" height="3.5" rx="1.5" fill="%23333"/><rect x="3.5" y="16.5" width="4" height="24" rx="2" fill="%23333"/></g><polygon points="32,8 47,48 17,48" fill="none" stroke="%23333" stroke-width="4" stroke-linejoin="round"/><g transform="translate(50,10)"><path d="M3,0C3,0,3,0,3,0L3,17L10,14C10,6,7,0,3,0Z" fill="%23333"/><rect x="1.5" y="0" width="2" height="18" rx="1" fill="%23333"/><rect x="1.5" y="16.5" width="8.5" height="3.5" rx="1.2" fill="%23333"/><rect x="3.5" y="19" width="4" height="22" rx="2" fill="%23333"/></g></svg>');
document.head.appendChild(favicon);
// Inject Google Fonts if not already present // Inject Google Fonts if not already present
if (!document.querySelector('link[href*="fonts.googleapis.com/css2?family=Inter"]')) { if (!document.querySelector('link[href*="fonts.googleapis.com/css2?family=Inter"]')) {
const fontLink = document.createElement('link'); const fontLink = document.createElement('link');
@@ -231,6 +241,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>
@@ -278,6 +291,18 @@
const historyModal = document.getElementById('history-modal'); const historyModal = document.getElementById('history-modal');
const btnHistoryClose = document.getElementById('btn-history-close'); 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', () => { btnHistory.addEventListener('click', () => {
if (!authToken) { if (!authToken) {
loginModal.classList.remove('hidden'); loginModal.classList.remove('hidden');
@@ -315,6 +340,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');
}); });
@@ -555,6 +591,7 @@
} }
console.log(`Fetched ${results.length} orders, mapped active ones.`); console.log(`Fetched ${results.length} orders, mapped active ones.`);
renderVisibleWeeks(); renderVisibleWeeks();
updateNextWeekBadge();
} }
} catch (error) { } catch (error) {
console.error('Error fetching orders:', error); console.error('Error fetching orders:', error);
@@ -570,37 +607,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) {
try {
localCache = JSON.parse(ls);
fullOrderHistoryCache = localCache;
} catch (e) {
console.warn('History cache parse error', e);
}
}
} }
// Check local storage cache // Show cached version immediately if we have one
const localCache = localStorage.getItem('kantine_history_cache'); if (localCache.length > 0) {
if (localCache) { renderHistory(localCache);
try {
fullOrderHistoryCache = JSON.parse(localCache);
renderHistory(fullOrderHistoryCache);
return;
} catch (e) {
console.warn('History cache parse error', e);
}
} }
if (!authToken) return; if (!authToken) return;
historyContent.innerHTML = ''; // Start background delta sync
historyLoading.classList.remove('hidden'); if (localCache.length === 0) {
progressFill.style.width = '0%'; historyContent.innerHTML = '';
progressText.textContent = 'Lade Bestellhistorie...'; historyLoading.classList.remove('hidden');
}
let nextUrl = `${API_BASE}/user/orders/?venue=${VENUE_ID}&ordering=-created&limit=50`; progressFill.style.width = '0%';
let allOrders = []; progressText.textContent = localCache.length > 0 ? 'Suche nach neuen Bestellungen...' : 'Lade Bestellhistorie...';
if (localCache.length > 0) historyLoading.classList.remove('hidden');
let nextUrl = localCache.length > 0
? `${API_BASE}/user/orders/?venue=${VENUE_ID}&ordering=-created&limit=5`
: `${API_BASE}/user/orders/?venue=${VENUE_ID}&ordering=-created&limit=50`;
let fetchedOrders = [];
let totalCount = 0; let totalCount = 0;
let requiresFullFetch = localCache.length === 0;
let deltaComplete = false;
try { try {
while (nextUrl) { while (nextUrl && !deltaComplete) {
const response = await fetch(nextUrl, { headers: apiHeaders(authToken) }); const response = await fetch(nextUrl, { headers: apiHeaders(authToken) });
if (!response.ok) throw new Error(`Fetch failed: ${response.status}`); if (!response.ok) throw new Error(`Fetch failed: ${response.status}`);
@@ -610,29 +659,75 @@
totalCount = data.count; totalCount = data.count;
} }
allOrders = allOrders.concat(data.results || []); const results = data.results || [];
// Update progress for (const order of results) {
if (totalCount > 0) { // Check if we hit an order that is already in our cache AND has the exact same state/update time
const pct = Math.round((allOrders.length / totalCount) * 100); // Bessa returns 'updated' timestamp, we can use it to determine if anything changed
progressFill.style.width = `${pct}%`; const existingOrderIndex = localCache.findIndex(cached => cached.id === order.id);
progressText.textContent = `Lade Bestellung ${allOrders.length} von ${totalCount}...`;
} else { if (!requiresFullFetch && existingOrderIndex !== -1) {
progressText.textContent = `Lade Bestellung ${allOrders.length}...`; const existingOrder = localCache[existingOrderIndex];
// If order exists and wasn't updated since our cache, we've reached the point
// where everything older is already correctly cached.
// order.updated is an ISO string like "2025-02-18T10:30:15.123456Z"
if (existingOrder.updated === order.updated && existingOrder.order_state === order.order_state) {
deltaComplete = true;
break;
}
}
fetchedOrders.push(order);
} }
nextUrl = data.next; // Update progress
if (!deltaComplete && requiresFullFetch) {
if (totalCount > 0) {
const pct = Math.round((fetchedOrders.length / totalCount) * 100);
progressFill.style.width = `${pct}%`;
progressText.textContent = `Lade Bestellung ${fetchedOrders.length} von ${totalCount}...`;
} else {
progressText.textContent = `Lade Bestellung ${fetchedOrders.length}...`;
}
} else if (!deltaComplete) {
progressText.textContent = `${fetchedOrders.length} neue/geänderte Bestellungen gefunden...`;
}
nextUrl = deltaComplete ? null : data.next;
} }
fullOrderHistoryCache = allOrders; // Merge fetched orders with cache
try { if (fetchedOrders.length > 0) {
localStorage.setItem('kantine_history_cache', JSON.stringify(allOrders)); // We have new/updated orders. We need to merge them into the cache.
} catch (e) { console.warn('History cache write error', e); } // 1. Create a map of the existing cache for quick ID lookup
renderHistory(fullOrderHistoryCache); const cacheMap = new Map(localCache.map(o => [o.id, o]));
// 2. Update/Insert the newly fetched orders
for (const order of fetchedOrders) {
cacheMap.set(order.id, order); // Overwrites existing, or adds new
}
// 3. Convert back to array and sort by created date (descending)
const mergedOrders = Array.from(cacheMap.values());
mergedOrders.sort((a, b) => new Date(b.created) - new Date(a.created));
fullOrderHistoryCache = mergedOrders;
try {
localStorage.setItem('kantine_history_cache', JSON.stringify(mergedOrders));
} catch (e) {
console.warn('History cache write error', e);
}
// Render the updated history
renderHistory(fullOrderHistoryCache);
}
} catch (error) { } catch (error) {
console.error('Error fetching full history:', error); console.error('Error in history sync:', error);
historyContent.innerHTML = `<p style="color:var(--error-color);text-align:center;">Fehler beim Laden der Historie.</p>`; if (localCache.length === 0) {
historyContent.innerHTML = `<p style="color:var(--error-color);text-align:center;">Fehler beim Laden der Historie.</p>`;
} else {
showToast('Hintergrund-Synchronisation fehlgeschlagen', 'error');
}
} finally { } finally {
historyLoading.classList.add('hidden'); historyLoading.classList.add('hidden');
} }
@@ -834,7 +929,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 +959,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 +976,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 +1044,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 +1070,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) {
const lastUpdated = new Date(lastUpdatedStr); lastUpdatedStr = new Date().toISOString();
const diffMs = Date.now() - lastUpdated.getTime(); localStorage.setItem('kantine_last_updated', lastUpdatedStr);
const diffMins = Math.floor(diffMs / 60000);
if (diffMins < 60) timeStr = `vor ${diffMins} Min.`;
else timeStr = `vor ${Math.floor(diffMins / 60)} Std.`;
} }
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}`; 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 +1112,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);
cutoff.setHours(10, 0, 0, 0); // Standard cutoff 10:00 // If the flag's date string is entirely in the past (before today)
if (now >= cutoff) { // 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); userFlags.delete(flagId);
changed = true; changed = true;
} }
@@ -1100,6 +1286,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;
@@ -1470,12 +1657,17 @@
badge.classList.add('has-highlights'); badge.classList.add('has-highlights');
} }
// FR-092: Highlight Next Week Button when new data arrives // FR-092: Glow Next Week button while data exists but no orders placed
const storageKey = `kantine_notified_nextweek_${nextYear}_${nextWeek}`; if (daysWithOrders === 0) {
if (!localStorage.getItem(storageKey)) {
localStorage.setItem(storageKey, 'true');
btnNextWeek.classList.add('new-week-available'); btnNextWeek.classList.add('new-week-available');
showToast('Neue Menüdaten für nächste Woche verfügbar!', 'info'); // One-time toast notification when new data first arrives
const storageKey = `kantine_notified_nextweek_${nextYear}_${nextWeek}`;
if (!localStorage.getItem(storageKey)) {
localStorage.setItem(storageKey, 'true');
showToast('Neue Menüdaten für nächste Woche verfügbar!', 'info');
}
} else {
btnNextWeek.classList.remove('new-week-available');
} }
} else if (badge) { } else if (badge) {
@@ -1943,19 +2135,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 +2168,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;

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

184
tests/test_dom.js Executable file
View File

@@ -0,0 +1,184 @@
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>
<!-- Mocks for Login Modal -->
<button id="btn-login-open">Login</button>
<div id="login-modal" class="modal hidden">
<button id="btn-login-close">Close</button>
<form id="login-form"></form>
<div id="login-error" class="hidden"></div>
</div>
<!-- Mocks for History Modal -->
<button id="btn-history">History</button>
<div id="history-modal" class="modal hidden">
<button id="btn-history-close">Close</button>
<div id="history-loading" class="hidden"></div>
<div id="history-content"></div>
</div>
<!-- Mocks for Version Modal -->
<span class="version-tag">v1.4.17</span>
<div id="version-modal" class="modal hidden">
<button id="btn-version-close">Close</button>
<button id="btn-clear-cache">Clear</button>
<span id="version-current"></span>
<div id="version-list-container"></div>
</div>
<!-- Mocks for Theme Toggle -->
<button id="theme-toggle"><span class="theme-icon">light_mode</span></button>
<!-- Mocks for Navigation Tabs -->
<button id="btn-this-week" class="active">This Week</button>
<button id="btn-next-week">Next Week</button>
<button id="btn-refresh">Refresh</button>
<button id="btn-logout">Logout</button>
<div class="order-history-header">Header</div>
<button id="btn-error-redirect">Error Redirect</button>
</body>
</html>
`;
log("Reading file jsCode...");
const jsCode = fs.readFileSync('kantine.js', 'utf8')
.replace('(function () {', '')
.replace('})();', '')
.replace('if (window.__KANTINE_LOADED) return;', '')
.replace('window.location.reload();', 'window.__RELOAD_CALLED = true;');
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");
// 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");
console.log("--- Testing Login Modal ---");
const loginModal = document.getElementById('login-modal');
document.getElementById('btn-login-open').click();
if (loginModal.classList.contains('hidden')) throw new Error("Login modal should open");
document.getElementById('btn-login-close').click();
if (!loginModal.classList.contains('hidden')) throw new Error("Login modal should close");
console.log("✅ Login Modal Test Passed");
console.log("--- Testing History Modal ---");
// We need authToken to be truthy to open history modal
authToken = "fake_token";
const historyModal = document.getElementById('history-modal');
document.getElementById('btn-history').click();
if (historyModal.classList.contains('hidden')) throw new Error("History modal should open");
document.getElementById('btn-history-close').click();
if (!historyModal.classList.contains('hidden')) throw new Error("History modal should close");
console.log("✅ History Modal Test Passed");
console.log("--- Testing Version Modal ---");
const versionModal = document.getElementById('version-modal');
document.querySelector('.version-tag').click();
if (versionModal.classList.contains('hidden')) throw new Error("Version modal should open");
document.getElementById('btn-version-close').click();
if (!versionModal.classList.contains('hidden')) throw new Error("Version modal should close");
console.log("✅ Version Modal Test Passed");
console.log("--- Testing Theme Toggle ---");
const themeBtn = document.getElementById('theme-toggle');
const initialTheme = document.documentElement.getAttribute('data-theme');
themeBtn.click();
const newTheme = document.documentElement.getAttribute('data-theme');
if (initialTheme === newTheme) throw new Error("Theme did not toggle");
console.log("✅ Theme Toggle Test Passed");
console.log("--- Testing Navigation Tabs ---");
const btnThis = document.getElementById('btn-this-week');
const btnNext = document.getElementById('btn-next-week');
btnNext.click();
if (!btnNext.classList.contains('active') || btnThis.classList.contains('active')) throw new Error("Next week tab not active");
btnThis.click();
if (!btnThis.classList.contains('active') || btnNext.classList.contains('active')) throw new Error("This week tab not active");
console.log("✅ Navigation Tabs Test Passed");
console.log("--- Testing Clear Cache Button ---");
// Mock confirm directly inside evaluated JSDOM context
window.confirm = () => true;
document.getElementById('btn-clear-cache').click();
if (!window.__RELOAD_CALLED) throw new Error("Clear cache did not reload the page");
console.log("✅ Clear Cache Button 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.5 v1.4.23