Compare commits

...

28 Commits

Author SHA1 Message Date
Kantine Wrapper
08ee2a2d0f dist files for v1.3.1 built 2026-02-17 21:33:09 +01:00
Kantine Wrapper
314728f6d0 dist files for v1.3.1 built 2026-02-17 21:31:25 +01:00
Kantine Wrapper
ea4e0d151f dist files for v1.3.1 built 2026-02-17 21:25:55 +01:00
Kantine Wrapper
b928b90728 dist files for v1.3.1 built 2026-02-17 21:17:40 +01:00
Kantine Wrapper
9b1f0e2fd3 v1.3.1: Smart Cache vereinfacht (KW-Check + 1h Alter), Build-Script auto-commit+push 2026-02-17 21:17:34 +01:00
Kantine Wrapper
05bc06660c docs: Add warning about force-pushing when moving an existing git tag. 2026-02-17 21:04:11 +01:00
Kantine Wrapper
20957c3582 chore: corrected README 2026-02-17 20:49:28 +01:00
fe86e68aca v1.3.1: Smart Cache Strategy + REQUIREMENTS.md überarbeitet
- Feature: isCacheFresh() – initialer API-Refresh nur wenn Cache >1h alt oder <5 Arbeitstage abgedeckt (FR-024)
- REQUIREMENTS.md komplett überarbeitet: lösungsneutral, alle Features dokumentiert, Versions-Spalte
- Build-Script: Git-Tag wird bei Rebuild auf aktuellen Commit verschoben (git tag -f)
- Neue Regel 7: Requirements-Konsistenz im Implementation Plan
- test_logic.js: statischer Check für isCacheFresh
2026-02-17 20:38:53 +01:00
4dbd6c930f feat: Expand and refine the system's purpose, scope, and functional/non-functional requirements, adding new categories like smart highlights and update management. 2026-02-17 18:06:57 +01:00
ad4cfaf4ec feat: GitHub Release Management v1.3.0 - Version menu, dev-mode, downgrade support 2026-02-16 23:39:41 +01:00
441198dd8d fix(update): use semver check to prevent update icon on dev/newer versions 2026-02-16 23:19:41 +01:00
13a0ae3a93 chore(debug): release v1.2.8 with start/status logs 2026-02-16 23:14:59 +01:00
1f8ebff9fe chore(debug): release v1.2.7 with verbose update logging 2026-02-16 23:12:18 +01:00
8e299c82ca chore(release): bump version to v1.2.6 for live update test 2026-02-16 23:08:09 +01:00
5ae43e92de fix(installer): prevent click on bookmarklet button (drag-only) 2026-02-16 23:05:39 +01:00
08da842118 build: update dist artifacts for v1.2.5 2026-02-16 23:02:54 +01:00
89157f9a8b chore(release): bump version to v1.2.5 2026-02-16 23:00:46 +01:00
13b94a3eba refactor: overhaul update detection - periodic check, icon only, no banner 2026-02-16 22:58:37 +01:00
c42cb3f72d fix(highlight): correct truthy check for tag arrays, add tag badges to cards (v1.2.4) 2026-02-16 22:28:46 +01:00
7296901ad9 feat(ui): display matched highlight tags in menu cards (v1.2.4) 2026-02-16 22:19:41 +01:00
f9b29254f9 fix(ui): clickable update icon and build-time unit tests (v1.2.3) 2026-02-16 22:13:37 +01:00
876da1a2de feat(ui): collapsible changelog in installer (v1.2.2) 2026-02-16 21:54:51 +01:00
587a37884e feat(ui): add tagline and footer to installer page (v1.2.1-polish) 2026-02-16 21:50:11 +01:00
1040828d7f fix(ui): v1.2.1 – highlights integration, mock data, CSS polish 2026-02-16 21:33:18 +01:00
bab54fdf2d feat(ui): release v1.2.0 with improved installer UX, build tests, and docs update 2026-02-16 21:01:38 +01:00
efdb50083e fix(build): url-encode bookmarklet payload to prevent URI malformed error (v1.1.2) 2026-02-16 19:14:21 +01:00
d82dd31bed fix(core): resolve syntax errors in v1.1.0 preventing load (v1.1.1) 2026-02-16 19:00:43 +01:00
d6a2236a5b feat: release v1.1.0 (countdown, highlight tags, changelog) 2026-02-16 18:52:07 +01:00
15 changed files with 2796 additions and 216 deletions

View File

@@ -48,3 +48,10 @@ trigger: always_on
## 6. Workspace Scopes
- **Browser**: Allowed for documentation and safe browsing. No automated logins without permission.
- **Terminal**: No `rm -rf`. Run tests (`pytest` etc.) after logic changes.
## 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?
2. **Fehlende Requirements**: Wenn eine umzusetzende Anforderung in `REQUIREMENTS.md` fehlt, muss dies im **Implementation Plan** explizit dokumentiert und freigegeben werden.
3. **Widersprüche**: Wenn eine neue Anforderung im Widerspruch zu bestehenden Requirements steht, muss dies im **Implementation Plan** als ⚠️ Warning hervorgehoben werden.
4. **Nach Freigabe**: Bei Genehmigung des Implementation Plans muss `REQUIREMENTS.md` entsprechend erweitert oder korrigiert werden (neue ID, Version, Beschreibung). Obsolet gewordene Requirements werden nicht aus `REQUIREMENTS.md` gelöscht sondern nur als durchgestrichen markiert und ein Kommentar mit dem Grund hinzugefügt.

View File

@@ -1,15 +1,17 @@
# Kantine Wrapper Bookmarklet (v1.7.0)
# Kantine Wrapper Bookmarklet
Ein intelligentes Bookmarklet für die Mitarbeiter-Kantine der Bessa App. Dieses Skript erweitert die Standardansicht um eine **Wochenübersicht**, Kostenkontrolle und verbesserte Usability.
## 🚀 Features
* **Wochenübersicht:** Zeigt alle Tage der aktuellen Woche auf einen Blick.
* **Bestell-Countdown:** ⏳ Roter Alarm 1h vor Bestellschluss.
* **Smart Highlights:** 🌟 Markiere deine Favoriten (z.B. "Schnitzel", "Vegetarisch").
* **Bestellstatus:** Farbige Indikatoren für bestellte Menüs.
* **Kostenkontrolle:** Summiert automatisch den Gesamtpreis der Woche.
* **Session Reuse:** Nutzt automatisch eine bestehende Login-Session (Loggt dich automatisch ein).
* **API Fallback:** Prüft die Verbindung und bietet bei Fehlern einen Direktlink zur Originalseite.
* **Menu Badges:** Zeigt Menü-Codes (M1, M2+) direkt im Header.
* **Changelog:** Übersicht über neue Funktionen direkt im Installer.
## 📦 Installation
@@ -30,10 +32,21 @@ Ein intelligentes Bookmarklet für die Mitarbeiter-Kantine der Bessa App. Dieses
* Bash (für `build-bookmarklet.sh`)
### Projektstruktur
* `kantine.js`: Der Haupt-Quellcode des Bookmarklets.
* `public/style.css`: Das Design (CSS).
* `build-bookmarklet.sh`: Skript zum Erstellen der `dist/` Dateien.
* `dist/`: Enthält die kompilierten Dateien (`bookmarklet.txt`, `install.html`).
#### Quelldateien
* `kantine.js`: Der Haupt-Quellcode des Bookmarklets (UI, API-Logik, Rendering).
* `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.
* `build-bookmarklet.sh`: Build-Skript erzeugt alle `dist/`-Artefakte.
* `test_build.py`: Automatische Build-Tests, laufen am Ende jedes Builds.
#### `dist/` Build-Artefakte
| 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-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. |
| `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
Um Änderungen an `kantine.js` oder `style.css` wirksam zu machen, führe den Build aus:

View File

@@ -2,55 +2,90 @@
## 1. Einleitung
### 1.1 Zweck des Systems
Das System dient als Wrapper und Erweiterung für das Bessa Web-Shop-Portal der Knapp-Kantine (https://web.bessa.app/knapp-kantine). Es ermöglicht den Benutzern, Menüpläne einzusehen, Buchungen automatisiert vorzunehmen und Menüdaten persistent für den öffentlichen Zugriff bereitzustellen, ohne dass jeder Betrachter eigene Zugangsdaten benötigt.
Das System dient als Erweiterung für das Bessa Web-Shop-Portal der Knapp-Kantine (https://web.bessa.app/knapp-kantine). Es verbessert die Benutzererfahrung durch eine übersichtliche Wochenansicht, vereinfachte Bestellvorgänge, Kostentransparenz und proaktive Benachrichtigungen.
### 1.2 High-Level-Scope
Das System umfasst einen automatisierten Scraper, eine API zur Datenbereitstellung, einen persistenten Dateispeicher für erfasste Menüs und ein Frontend-Dashboard zur Visualisierung der Kantinen-Wochenpläne.
Das System umfasst die Darstellung von Menüplänen in einer Wochenübersicht, die Verwaltung von Bestellungen und Stornierungen, ein Benachrichtigungssystem für Verfügbarkeitsänderungen, personalisierte Menü-Highlights sowie ein automatisches Update-Management.
## 2. Funktionale Anforderungen
| ID | Anforderung (Satzschablone nach Chris Rupp) | Priorität |
|:---|:---|:---|
| **Auth & Sessions** | | |
| FR-001 | Das System muss dem Benutzer die Möglichkeit bieten, sich mit Mitarbeiternummer und Passwort am Bessa-Backend anzumelden. | Hoch |
| FR-002 | Sobald ein Benutzer erfolgreich angemeldet ist, muss das System eine Session-ID erzeugen und die resultierenden Cookies verschlüsselt für 2 Stunden vorhalten. | Hoch |
| FR-003 | Das System muss die Zugangsdaten (Mitarbeiternummer/Passwort) unmittelbar nach der Verwendung durch den Scraper verwerfen und darf diese nicht dauerhaft speichern. | Hoch |
| **Scraper & Datenextraktion** | | |
| FR-004 | Das System muss in der Lage sein, den wöchentlichen Menüplan (Montag bis Freitag) automatisiert von der Bessa-Webseite zu extrahieren. | Hoch |
| FR-005 | Wenn ein Tag bereits eine Buchung enthält, muss das System den Navigationspfad so anpassen, dass dennoch alle verfügbaren Menüoptionen (M1F, M2F, etc.) extrahiert werden können. | Mittel |
| FR-006 | Das System muss für jedes extrahierte Gericht den Namen, die Beschreibung, den Preis und den Status (verfügbar/nicht verfügbar/ bestellt) erfassen. | Hoch |
| **Datenhaltung & Zugriff** | | |
| FR-007 | Das System muss erfolgreich gescrapte Menüpläne in einer persistenten JSON-Datei (`data/menus.json`) speichern. | Hoch |
| FR-008 | Das System muss unauthentifizierten Benutzern den Zugriff auf bereits im Speicher befindliche Menüdaten ermöglichen (Public Access). | Mittel |
| FR-009 | Falls keine Daten im persistenten Speicher vorhanden sind, muss das System einen nicht authentifizierten Benutzer die Möglichkeit bieten sich anzumelden, um den Speicher zu initialisieren. | Hoch |
| **Dashboard & UI** | | |
| FR-010 | Das System muss dem Benutzer eine intuitive Wochenansicht des Menüplans im Browser darstellen. | Mittel |
| FR-011 | Wenn ein Scraper-Vorgang aktiv ist, muss das System den Status (Fortschritt, aktuelle Aktion) in Echtzeit visualisieren. | Niedrig |
| **Buchungsfunktion** | | |
| FR-012 | Wenn der Benutzer authentifiziert ist, soll das System eine Bestellung für ein Menü ermöglichen. | Mittel |
| FR-013 | Wenn der Benutzer authentifiziert ist, soll das System ein bereits bestelltes Menü zu stornieren. | Mittel |
| **Menu Flagging & Notifications** | | |
| FR-014 | Das System muss authentifizierten Benutzern ermöglichen, nicht bestellbare Menüs (deren Cutoff noch nicht erreicht ist) zu markieren ("flaggen"). | Mittel |
| FR-015 | Das System soll die Statusprüfung für geflaggte Menüs auf verbundene Clients verteilen (Distributed Polling), um die Last zu minimieren. Der Server orchestriert, welcher Client wann welche Menüs prüft. | Mittel |
| FR-016 | Bei Statusänderung auf "verfügbar" muss das System den Benutzer benachrichtigen (Systembenachrichtigung). | Mittel |
| FR-017 | Geflaggte und ausverkaufte Menüs müssen im UI mit einem gelben Glow hervorgehoben werden. | Mittel |
| FR-018 | Geflaggte und verfügbare Menüs müssen im UI mit einem grünen Glow hervorgehoben werden. | Mittel |
| FR-019 | Wenn die Bestell-Cutoff-Zeit erreicht ist, muss das System das Flag automatisch entfernen. | Mittel |
| ID | Anforderung (Satzschablone nach Chris Rupp) | Priorität | Seit |
|:---|:---|:---|:---|
| **Authentifizierung & Zugang** | | | |
| FR-001 | Das System muss dem Benutzer die Möglichkeit bieten, sich mit Mitarbeiternummer und Passwort anzumelden. | Hoch | v1.0.1 |
| FR-002 | Das System muss eine bestehende Bessa-Session erkennen und automatisch wiederverwenden, um eine erneute Anmeldung zu vermeiden. | Hoch | v1.0.1 |
| FR-003 | Das System darf keine Zugangsdaten dauerhaft speichern. Die Authentifizierung muss sitzungsbasiert sein. | Hoch | v1.0.1 |
| FR-004 | Dem Benutzer muss angezeigt werden, ob und als wer er angemeldet ist (Vorname, Name oder ID). | Mittel | v1.0.1 |
| FR-005 | Nicht authentifizierte Benutzer müssen die Menüdaten einsehen können (eingeschränkter Lesezugriff). | Mittel | v1.0.1 |
| FR-006 | Das System muss eine explizite Logout-Funktion bereitstellen, die alle sitzungsbezogenen Daten entfernt. | Mittel | v1.0.1 |
| **Menüanzeige** | | | |
| FR-010 | Das System muss dem Benutzer alle verfügbaren Tagesmenüs einer Woche gleichzeitig in einer Übersicht darstellen. | Hoch | v1.0.1 |
| FR-011 | Das System muss dem Benutzer die Navigation zwischen der aktuellen und der kommenden Woche ermöglichen. | Mittel | v1.0.1 |
| FR-012 | Für jedes Gericht müssen Name, Beschreibung, Preis und Verfügbarkeitsstatus angezeigt werden. | Hoch | v1.0.1 |
| FR-013 | Bereits bestellte Menüs müssen visuell von nicht bestellten unterscheidbar sein (farbliche Markierung, Badge). | Mittel | v1.0.1 |
| FR-014 | Die Tageskarten-Header müssen den Bestellstatus des Tages farblich signalisieren (bestellt / bestellbar / nicht bestellbar). | Niedrig | v1.0.1 |
| FR-015 | Bestellte Menü-Codes (z.B. M1, M2) müssen als Badges im Tageskarten-Header angezeigt werden. | Niedrig | v1.0.1 |
| FR-016 | Am heutigen Tag müssen bestellte Menüs an erster Stelle sortiert werden. | Niedrig | v1.0.1 |
| FR-017 | Wenn keine Menüdaten für eine Woche vorliegen, muss ein aussagekräftiger Leertext angezeigt werden. | Niedrig | v1.0.1 |
| **Daten-Aktualisierung** | | | |
| FR-020 | Wenn Menüdaten geladen werden, muss der Fortschritt dem Benutzer in Echtzeit angezeigt werden (Fortschrittsbalken, Statustext). | Niedrig | v1.0.1 |
| FR-021 | Das System muss bereits geladene Menüdaten zwischenspeichern, um bei erneutem Aufruf sofort eine Übersicht anzeigen zu können. | Mittel | v1.0.1 |
| FR-022 | Das System muss dem Benutzer die Möglichkeit bieten, die Menüdaten manuell neu zu laden. | Niedrig | v1.0.1 |
| FR-023 | Der Zeitpunkt der letzten Aktualisierung muss für den Benutzer sichtbar sein. | Niedrig | v1.0.1 |
| FR-024 | Das System darf beim Start keinen automatischen API-Refresh durchführen, wenn der Cache frisch (< 1 Stunde) und Daten für die aktuelle Kalenderwoche vorhanden sind. | Mittel | v1.3.1 |
| **Bestellfunktion** | | | |
| FR-030 | Authentifizierte Benutzer müssen ein verfügbares Menü direkt aus der Übersicht bestellen können. | Hoch | v1.0.1 |
| FR-031 | Authentifizierte Benutzer müssen eine bestehende Bestellung direkt aus der Übersicht stornieren können. | Hoch | v1.0.1 |
| FR-032 | Nach Bestellschluss (Cutoff-Zeit) dürfen keine neuen Bestellungen oder Stornierungen für diesen Tag möglich sein. | Hoch | 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** | | | |
| FR-040 | Das System muss die Gesamtkosten aller Bestellungen einer Woche automatisch berechnen und anzeigen. | Mittel | v1.1.0 |
| **Bestell-Countdown** | | | |
| FR-050 | Das System muss vor Bestellschluss einen visuell hervorgehobenen Countdown anzeigen. | Mittel | v1.1.0 |
| **Menü-Flagging & Benachrichtigungen** | | | |
| FR-060 | Authentifizierte Benutzer müssen ausverkaufte Menüs zur Beobachtung markieren können ("flaggen"). | Mittel | v1.0.1 |
| FR-061 | Das System muss geflaggte Menüs periodisch auf Verfügbarkeitsänderungen prüfen. | Mittel | v1.0.1 |
| FR-062 | Bei Statusänderung eines geflaggten Menüs auf „verfügbar" muss der Benutzer benachrichtigt werden (In-App + Systembenachrichtigung). | Mittel | v1.0.1 |
| FR-063 | Geflaggte Menüs müssen im UI visuell hervorgehoben werden (gelber Glow bei ausverkauft, grüner Glow bei verfügbar). | Mittel | v1.0.1 |
| FR-064 | Wenn die Bestell-Cutoff-Zeit erreicht ist, muss das System ein Flag automatisch entfernen. | Mittel | v1.0.1 |
| **Personalisierung: Smart Highlights** | | | |
| FR-070 | Benutzer müssen Schlagwörter (Tags) definieren können, nach denen Menüs automatisch visuell hervorgehoben werden. | Mittel | v1.1.0 |
| FR-071 | Die Hervorhebung muss anhand von Menüname und Beschreibung erfolgen (Substring-Matching, case-insensitive). | Niedrig | v1.1.0 |
| FR-072 | Hervorgehobene Menüs müssen ein Tag-Badge mit dem matchenden Schlagwort anzeigen. | Niedrig | v1.2.4 |
| FR-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 |
| **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 |
| **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 |
| **Update-Management** | | | |
| FR-110 | Das System muss periodisch prüfen, ob eine neuere Version verfügbar ist. | Niedrig | v1.0.3 |
| FR-111 | Bei Verfügbarkeit einer neueren Version muss ein diskreter Indikator im Header angezeigt werden. | Niedrig | v1.0.3 |
| 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 |
## 3. Nicht-funktionale Anforderungen
| Kategorie (ISO 25010) | ID | Anforderung | Zielwert/Metrik |
|:---|:---|:---|:---|
| **Performance** | NFR-001 | Antwortzeit der API für gecachte Daten | < 200 ms (95. Perzentil) |
| **Performance** | NFR-007 | Polling-Effizienz & Token-Nutzung | Kein System-Token verfügbar. Polling muss über authentifizierte User-Clients erfolgen. Der Server muss die Anfragen so verteilen, dass redundante Abfragen vermieden werden. |
| **Performance** | NFR-002 | Dauer eines vollständigen Scrape-Vorgangs (exkl. Navigation) | < 30 Sekunden pro Woche |
| **Sicherheit** | NFR-003 | Speicherung von Zugangsdaten | 0 (keine dauerhafte Speicherung von Passwörtern) |
| **Sicherheit** | NFR-004 | Session-Sicherheit | HttpOnly Cookies für die Kommunikation zwischen Frontend und Backend |
| **Wartbarkeit** | NFR-005 | Testabdeckung der Scraper-Logik | Alle Kern-Selektoren müssen durch Debug-HTML-Dumps verifizierbar sein |
| **Benutzbarkeit** | NFR-006 | Mobile Responsiveness | Dashboard muss auf Viewports ab 320px Breite fehlerfrei nutzbar sein |
| **Performance** | NFR-001 | Die Darstellung bereits gecachter Daten muss ohne spürbare Verzögerung erfolgen. | < 200 ms (UI-Rendering) |
| **Performance** | NFR-002 | Das Polling für geflaggte Menüs darf die reguläre Nutzung nicht beeinträchtigen. | Intervall ≥ 5 Minuten |
| **Sicherheit** | NFR-003 | Es dürfen keine Zugangsdaten dauerhaft gespeichert werden. | 0 (keine persistente Speicherung von Passwörtern) |
| **Sicherheit** | NFR-004 | Auth-Tokens müssen sitzungsbasiert gespeichert werden und bei Schließen des Browsers verfallen. | sessionStorage |
| **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-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 |
## 4. Technische Randbedingungen
* **Architektur**: Node.js Backend mit Express (API + Static Serving) und Vanilla JS Frontend.
* **Engine**: Direkte API-Integration (Reverse Engineering der Bessa API) für maximale Performance und Zuverlässigkeit.
* **Datenspeicher**: Dateibasierter JSON-Store für persistente Daten + In-Memory Caching.
* **Schnittstellen**: REST API (`/api/bookings`, `/api/status`, `/api/order`).
* **Runtime**: Node.js Umgebung, Docker-ready.
* **Deployment**: Das System wird als Bookmarklet ausgeliefert, das auf der Bessa-Webseite ausgeführt wird.
* **Datenquelle**: Direkte Integration mit der Bessa REST-API (`api.bessa.app/v1`).
* **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.

View File

@@ -25,8 +25,9 @@ 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
CSS_CONTENT=$(cat "$CSS_FILE")
# Inject version into JS
JS_CONTENT=$(cat "$JS_FILE" | sed "s/{{VERSION}}/$VERSION/g")
JS_CONTENT=$(cat "$JS_FILE" | sed "s|{{VERSION}}|$VERSION|g")
# === 1. Build standalone HTML (for local testing/dev) ===
cat > "$DIST_DIR/kantine-standalone.html" << HTMLEOF
@@ -53,6 +54,10 @@ cat >> "$DIST_DIR/kantine-standalone.html" << HTMLEOF
<script>
HTMLEOF
# Inject mock data for standalone testing (loaded BEFORE kantine.js)
cat "$SCRIPT_DIR/mock-data.js" >> "$DIST_DIR/kantine-standalone.html"
echo "" >> "$DIST_DIR/kantine-standalone.html"
# Inject JS
echo "$JS_CONTENT" >> "$DIST_DIR/kantine-standalone.html"
@@ -104,25 +109,49 @@ cat > "$DIST_DIR/install.html" << INSTALLEOF
a.bookmarklet { display: inline-block; background: #029AA8; color: white; padding: 12px 24px; border-radius: 8px; text-decoration: none; font-weight: 600; font-size: 18px; cursor: grab; }
a.bookmarklet:hover { background: #006269; }
code { background: #0f3460; padding: 2px 6px; border-radius: 4px; }
/* Collapsible Changelog */
details.styled-details { background: rgba(0,0,0,0.2); border-radius: 8px; overflow: hidden; }
summary.styled-summary { padding: 15px; cursor: pointer; font-weight: bold; list-style: none; display: flex; justify-content: space-between; align-items: center; user-select: none; }
summary.styled-summary:hover { background: rgba(255,255,255,0.05); }
summary.styled-summary::-webkit-details-marker { display: none; }
summary.styled-summary::after { content: '▼'; font-size: 0.8em; transition: transform 0.2s; }
details.styled-details[open] summary.styled-summary::after { transform: rotate(180deg); transition: transform 0.2s; }
.changelog-container { padding: 0 15px 15px 15px; border-top: 1px solid rgba(255,255,255,0.05); }
</style>
</head>
<body>
<h1>🍽️ Kantine Wrapper <span style="font-size:0.5em; opacity:0.6; font-weight:400; vertical-align:middle; margin-left:10px;">$VERSION</span></h1>
<div class="instructions">
<h2>Installation</h2>
<div style="text-align: center; margin-bottom: 30px;">
<h1 style="margin-bottom: 5px;">🍽️ Kantine Wrapper <span style="font-size:0.5em; opacity:0.6; font-weight:400; vertical-align:middle; margin-left:10px;">$VERSION</span></h1>
<p style="font-size: 1.2rem; color: #a0aec0; margin-top: 0; font-style: italic;">"Mahlzeit! Jetzt bessa einfach."</p>
</div>
<!-- 1. BUTTON (Top Priority) -->
<div class="card" style="text-align: center; border: 2px solid #029AA8;">
<p style="margin-bottom:15px; font-weight:bold;">👇 Diesen Button in die Lesezeichen-Leiste ziehen:</p>
<p><a class="bookmarklet" id="bookmarklet-link" href="#" onclick="event.preventDefault(); return false;" title="Nicht klicken! Ziehe mich in deine Lesezeichen-Leiste.">⏳ Wird generiert...</a></p>
</div>
<!-- 2. INSTRUCTIONS -->
<div class="card">
<h2>So funktioniert's</h2>
<ol>
<li>Ziehe den Button unten in deine <strong>Lesezeichen-Leiste</strong> (Drag & Drop)</li>
<li>Ziehe den Button oben in deine <strong>Lesezeichen-Leiste</strong> (Drag & Drop)</li>
<li>Navigiere zu <a href="https://web.bessa.app/knapp-kantine" style="color:#029AA8">web.bessa.app/knapp-kantine</a></li>
<li>Klicke auf das Lesezeichen <code>Kantine $VERSION</code></li>
</ol>
</div>
<!-- 3. FEATURES -->
<div class="card">
<h2>✨ Features</h2>
<ul>
<li>📅 <strong>Wochenübersicht:</strong> Die ganze Woche auf einen Blick.</li>
<li>⏳ <strong>Order Countdown:</strong> Roter Alarm 1h vor Bestellschluss.</li>
<li>🌟 <strong>Smart Highlights:</strong> Markiere deine Favoriten (z.B. "Schnitzel").</li>
<li>💰 <strong>Kostenkontrolle:</strong> Automatische Berechnung der Wochensumme.</li>
<li>🔑 <strong>Auto-Login:</strong> Nutzt deine bestehende Session.</li>
<li>🏷️ <strong>Badges & Status:</strong> Menü-Codes (M1, M2) und Bestellstatus direkt sichtbar.</li>
<li>🛡️ <strong>Offline-Support:</strong> Speichert Menüdaten lokal.</li>
</ul>
<div style="margin-top: 30px; padding: 15px; background: rgba(233, 69, 96, 0.1); border: 1px solid rgba(233, 69, 96, 0.3); border-radius: 8px; font-size: 0.85em; color: #ddd;">
@@ -130,20 +159,81 @@ cat > "$DIST_DIR/install.html" << INSTALLEOF
Die Verwendung dieses Bookmarklets erfolgt auf eigene Verantwortung. Der Entwickler übernimmt keine Haftung für Schäden, Datenverlust oder ungewollte Bestellungen, die durch die Nutzung dieser Software entstehen.
</div>
</div>
<p>👇 Diesen Button in die Lesezeichen-Leiste ziehen:</p>
<p><a class="bookmarklet" id="bookmarklet-link" href="#">⏳ Wird generiert...</a></p>
<!-- 4. CHANGELOG (Bottom) -->
<div class="card">
<details class="styled-details">
<summary class="styled-summary">Changelog & Version History</summary>
<div class="changelog-container">
<!-- CHANGELOG_PLACEHOLDER -->
</div>
</details>
</div>
<div style="text-align: center; margin-top: 40px; color: #5c6b7f; font-size: 0.8rem;">
<p>Powered by <strong>Kaufi-Kitchen</strong> 👨‍🍳</p>
</div>
<script>
INSTALLEOF
# Generate Changlog HTML from Markdown
CHANGELOG_HTML=""
if [ -f "$SCRIPT_DIR/changelog.md" ]; then
CHANGELOG_HTML=$(cat "$SCRIPT_DIR/changelog.md" | python3 -c "
import sys, re
md = sys.stdin.read()
# Convert headers to h3/h4
html = re.sub(r'^## (.*)', r'<h3>\1</h3>', md, flags=re.MULTILINE)
# Convert bullets to list items
html = re.sub(r'^- (.*)', r'<li>\1</li>', html, flags=re.MULTILINE)
# Wrap lists (simple heuristic)
html = html.replace('</h3>\n<li>', '</h3>\n<ul>\n<li>').replace('</li>\n<h', '</li>\n</ul>\n<h').replace('</li>\n\n', '</li>\n</ul>\n')
if '<li>' in html and '<ul>' not in html: html = '<ul>' + html + '</ul>'
print(html)
")
fi
# Embed the bookmarklet URL inline
echo "document.getElementById('bookmarklet-link').href = " >> "$DIST_DIR/install.html"
echo "$JS_CONTENT" | python3 -c "
import sys, json
js = sys.stdin.read()
css = open('$CSS_FILE').read().replace('\\n', ' ').replace(' ', ' ')
bmk = '''javascript:(function(){if(window.__KANTINE_LOADED){alert(\"Already loaded\");return;}var s=document.createElement(\"style\");s.textContent=''' + json.dumps(css) + ''';document.head.appendChild(s);var sc=document.createElement(\"script\");sc.textContent=''' + json.dumps(js) + ''';document.head.appendChild(sc);})();'''
print(json.dumps(bmk) + ';')
" 2>/dev/null >> "$DIST_DIR/install.html" || echo "'javascript:alert(\"Build error\")'" >> "$DIST_DIR/install.html"
import sys, json, urllib.parse
# 1. Read JS and Replace VERSION
js_template = sys.stdin.read()
js = js_template.replace('{{VERSION}}', '$VERSION')
# 2. Prepare CSS for injection via createElement('style')
css = open('$CSS_FILE').read().replace('\n', ' ').replace(' ', ' ')
escaped_css = css.replace('\\\\', '\\\\\\\\').replace(\"'\", \"\\\\'\").replace('\"', '\\\\\"')
# 3. Update URL
update_url = 'https://htmlpreview.github.io/?https://github.com/TauNeutrino/kantine-overview/blob/main/dist/install.html'
js = js.replace('https://github.com/TauNeutrino/kantine-overview/raw/main/dist/install.html', update_url)
# 4. Create Bookmarklet Code with CSS injection
# Inject CSS via style element (same pattern as bookmarklet-payload.js)
css_injection = \"var s=document.createElement('style');s.textContent='\" + escaped_css + \"';document.head.appendChild(s);\"
bookmarklet_code = 'javascript:(function(){' + css_injection + js + '})();'
# 5. URL Encode
encoded_code = urllib.parse.quote(bookmarklet_code, safe=':/()!;=+,')
# Output as JSON string for the HTML script to assign to href
print(json.dumps(encoded_code) + ';')
" >> "$DIST_DIR/install.html"
# Inject Changelog into Installer HTML (Safe Python replace)
python3 -c "
import sys
html = open('$DIST_DIR/install.html').read()
changelog = sys.stdin.read()
html = html.replace('<!-- CHANGELOG_PLACEHOLDER -->', changelog)
open('$DIST_DIR/install.html', 'w').write(html)
" << EOF
$CHANGELOG_HTML
EOF
cat >> "$DIST_DIR/install.html" << INSTALLEOF
document.getElementById('bookmarklet-link').textContent = 'Kantine $VERSION';
@@ -157,3 +247,43 @@ echo ""
echo "=== Build Complete ==="
echo "Files in $DIST_DIR:"
ls -la "$DIST_DIR/"
# === 4. Run build-time tests ===
echo ""
echo "=== Running Logic Tests ==="
node "$SCRIPT_DIR/test_logic.js"
LOGIC_EXIT=$?
if [ $LOGIC_EXIT -ne 0 ]; then
echo "❌ Logic tests FAILED! See above for details."
exit 1
fi
echo "=== Running Build Tests ==="
python3 "$SCRIPT_DIR/test_build.py"
TEST_EXIT=$?
if [ $TEST_EXIT -ne 0 ]; then
echo "❌ Build tests FAILED! See above for details."
exit 1
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"

73
changelog.md Executable file
View File

@@ -0,0 +1,73 @@
## v1.3.1 (2026-02-17)
- **Feature**: Smart Cache API-Refresh beim Start wird übersprungen wenn Daten für die aktuelle KW vorhanden und Cache < 1h alt ist. ⚡
## v1.3.0 (2026-02-16)
- **Feature**: GitHub Release Management 📦
- Version-Menü: Klick auf Versionsnummer zeigt alle verfügbaren Versionen
- Dev-Mode Toggle: Zwischen Releases (stabil) und Tags (dev) wechseln
- Downgrade-Support: Jede Version hat einen eigenen Installer-Link
- Update-Check nutzt jetzt die GitHub API statt `version.txt`
- GitHub PAT für höheres API-Rate-Limit (5000/h)
- SemVer-Check: Update-Icon nur bei wirklich neuerer Version
## v1.2.9 (2026-02-16)
## v1.2.8 (2026-02-16)
- **Debug**: Weiteres Logging (Fetch-Status, Start-Log) zur Fehlersuche. 🔎
## v1.2.7 (2026-02-16)
- **Debug**: Verbose Logging für Update-Check eingebaut. 🐞
## v1.2.6 (2026-02-16)
- **Test**: Version Bump zum Testen der Live-Update-Erkennung. 🧪
## v1.2.5 (2026-02-16)
- **Refactor**: Update-Erkennung komplett überarbeitet (stündlicher Check, diskretes 🆕 Icon im Header, kein Banner mehr). 🔄
- **Cleanup**: Ungenutzter CSS-Code und Netzwerk-Traffic reduziert. 🧹
- **Fix**: Highlight-Logik stabilisiert (keine falschen Matches bei leeren Tags). 🏷️
## v1.2.4 (2026-02-16)
- **Feature**: Gefundene Highlights werden jetzt direkt im Menü als Badge angezeigt. 🏷️
## v1.2.3 (2026-02-16)
- **Fix**: Update-Icon ist jetzt klickbar und führt direkt zum Installer. 🔗
- **Dev**: Unit-Tests für Update-Logik im Build integriert. 🛡️
## v1.2.2 (2026-02-16)
- **UX**: Installer-Changelog jetzt einklappbar für mehr Übersicht. 📂
## v1.2.1 (2026-02-16)
- **Fix**: Smart Highlights werden jetzt korrekt auf Menü-Items angewendet (`checkHighlight` in `createDayCard`). 🌟
- **Feature**: Mock-Daten (`mock-data.js`) für Standalone-Tests eingebaut. 🧪
- **Style**: Highlight-Glow mit blauer Puls-Animation (`blue-pulse`) überarbeitet. 💎
- **Style**: Tag-Badges konsistent mit Badge-System gestaltet. 🏷️
- **Style**: "Hinzufügen"-Button (`#btn-add-tag`) als Primary-Button gestylt. 🎨
- **Style**: Modal-Body Padding und Input-Font korrigiert. 🔧
- **Docs**: README Projektstruktur mit Tabelle für `dist/`-Artefakte ergänzt. 📖
## v1.2.0 (2026-02-16)
- **Feature**: Bessere UX im Installer (Button oben, Log unten, Features aktualisiert). 💅
- **Tech**: Build-Tests hinzugefügt. 🧪
- **Fix**: Encoding-Probleme final behoben (dank Python Buildlogic). 🐍
## v1.1.2 (2026-02-16)
- **Fix**: Encoding-Problem beim Bookmarklet behoben (URL Malformed Error). 🔗
## v1.1.1 (2026-02-16)
- **Fix**: Kritischer Fehler behoben, der das Laden des Wrappers verhinderte. 🐛
## v1.1.0 (2026-02-16)
- **Feature: Bestell-Countdown**: Zeigt 1 Stunde vor Bestellschluss einen roten Countdown an. ⏳
- **Feature: Smart Highlights**: Markiere deine Lieblingsspeisen (z.B. "Schnitzel", "Vegetarisch"), damit sie leuchten. 🌟
- **Feature: Changelog**: Diese Übersicht der Änderungen. 📜
- **Verbesserung**: Live-Check der Version beim Update.
## v1.0.3 (2026-02-13)
- **Fix**: Update-Link öffnet nun den Installer direkt als Webseite (via htmlpreview).
## v1.0.2 (2026-02-13)
- **Sync**: Version mit GitHub synchronisiert.
## v1.0.1 (2026-02-12)
- **UI**: Besseres Design für "Nächste Woche" (Badges).
- **Core**: Grundlegende Funktionen (Bestellen, Guthaben, Token-Store).

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

156
dist/install.html vendored

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

View File

@@ -19,6 +19,11 @@
const MENU_ID = 7;
const POLL_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes
// === GitHub Release Management ===
const GITHUB_REPO = 'TauNeutrino/kantine-overview';
const GITHUB_API = `https://api.github.com/repos/${GITHUB_REPO}`;
const INSTALLER_BASE = `https://htmlpreview.github.io/?https://github.com/${GITHUB_REPO}/blob`;
// === State ===
let allWeeks = [];
let currentWeekNumber = getISOWeek(new Date());
@@ -66,7 +71,7 @@
<div class="brand">
<span class="material-icons-round logo-icon">restaurant_menu</span>
<div class="header-left">
<h1>Kantinen Übersicht <small style="font-size: 0.6em; opacity: 0.7; font-weight: 400;">{{VERSION}}</small></h1>
<h1>Kantinen Übersicht <small class="version-tag" style="font-size: 0.6em; opacity: 0.7; font-weight: 400; cursor: pointer;" title="Klick für Versionsmenü">{{VERSION}}</small></h1>
<div id="last-updated-subtitle" class="subtitle"></div>
</div>
</div>
@@ -78,6 +83,9 @@
<button id="btn-refresh" class="icon-btn" aria-label="Menüdaten aktualisieren" title="Menüdaten neu laden">
<span class="material-icons-round">refresh</span>
</button>
<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>
@@ -144,6 +152,52 @@
</div>
</div>
<div id="highlights-modal" class="modal hidden">
<div class="modal-content">
<div class="modal-header">
<h2>Meine Highlights</h2>
<button id="btn-highlights-close" class="icon-btn" aria-label="Close">
<span class="material-icons-round">close</span>
</button>
</div>
<div class="modal-body">
<p style="margin-bottom: 1rem; color: var(--text-secondary);">
Markiere Menüs automatisch, wenn sie diese Schlagwörter enthalten.
</p>
<div class="input-group">
<input type="text" id="tag-input" placeholder="z.B. Schnitzel, Vegetarisch...">
<button id="btn-add-tag" class="btn-primary">Hinzufügen</button>
</div>
<div id="tags-list"></div>
</div>
</div>
</div>
<div id="version-modal" class="modal hidden">
<div class="modal-content">
<div class="modal-header">
<h2>📦 Versionen</h2>
<button id="btn-version-close" class="icon-btn" aria-label="Close">
<span class="material-icons-round">close</span>
</button>
</div>
<div class="modal-body">
<div style="margin-bottom: 1rem;">
<strong>Aktuell:</strong> <span id="version-current">{{VERSION}}</span>
</div>
<div class="dev-toggle">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer;">
<input type="checkbox" id="dev-mode-toggle">
<span>Dev-Mode (alle Tags anzeigen)</span>
</label>
</div>
<div id="version-list-container" style="margin-top:1rem;">
<p style="color:var(--text-secondary);">Lade Versionen...</p>
</div>
</div>
</div>
</div>
<main class="container">
<div id="last-updated-banner" class="banner hidden">
<span class="material-icons-round">update</span>
@@ -174,6 +228,64 @@
const loginForm = document.getElementById('login-form');
const loginModal = document.getElementById('login-modal');
// Highlights Modal
const btnHighlights = document.getElementById('btn-highlights');
const highlightsModal = document.getElementById('highlights-modal');
const btnHighlightsClose = document.getElementById('btn-highlights-close');
const btnAddTag = document.getElementById('btn-add-tag');
const tagInput = document.getElementById('tag-input');
btnHighlights.addEventListener('click', () => {
highlightsModal.classList.remove('hidden');
renderTagsList();
tagInput.focus();
});
btnHighlightsClose.addEventListener('click', () => {
highlightsModal.classList.add('hidden');
});
window.addEventListener('click', (e) => {
if (e.target === highlightsModal) highlightsModal.classList.add('hidden');
});
// Version Menu
const versionTag = document.querySelector('.version-tag');
const versionModal = document.getElementById('version-modal');
const btnVersionClose = document.getElementById('btn-version-close');
if (versionTag) {
versionTag.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
openVersionMenu();
});
}
if (btnVersionClose) {
btnVersionClose.addEventListener('click', () => {
versionModal.classList.add('hidden');
});
}
window.addEventListener('click', (e) => {
if (e.target === versionModal) versionModal.classList.add('hidden');
});
btnAddTag.addEventListener('click', () => {
const tag = tagInput.value;
if (addHighlightTag(tag)) {
tagInput.value = '';
renderTagsList();
}
});
tagInput.addEventListener('keypress', (e) => {
if (e.key === 'Enter') {
btnAddTag.click();
}
});
// Theme
const savedTheme = localStorage.getItem('theme');
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
@@ -592,17 +704,64 @@
}
// Refresh menu data to update UI
loadMenuDataFromAPI();
break; // One refresh is enough
}
}
} catch (err) {
console.error(`Poll error for ${flagId}:`, err);
}
// Small delay between checks
await new Promise(r => setTimeout(r, 200));
}
}
}
// === Highlight Management ===
let highlightTags = JSON.parse(localStorage.getItem('kantine_highlightTags') || '[]');
function saveHighlightTags() {
localStorage.setItem('kantine_highlightTags', JSON.stringify(highlightTags));
renderVisibleWeeks(); // Refresh UI to apply changes
updateNextWeekBadge();
}
function addHighlightTag(tag) {
tag = tag.trim().toLowerCase();
if (tag && !highlightTags.includes(tag)) {
highlightTags.push(tag);
saveHighlightTags();
return true;
}
return false;
}
function removeHighlightTag(tag) {
highlightTags = highlightTags.filter(t => t !== tag);
saveHighlightTags();
}
function renderTagsList() {
const list = document.getElementById('tags-list');
list.innerHTML = '';
highlightTags.forEach(tag => {
const badge = document.createElement('span');
badge.className = 'tag-badge';
badge.innerHTML = `${tag} <span class="tag-remove" data-tag="${tag}">&times;</span>`;
list.appendChild(badge);
});
// Bind remove events
list.querySelectorAll('.tag-remove').forEach(btn => {
btn.addEventListener('click', (e) => {
removeHighlightTag(e.target.dataset.tag);
renderTagsList();
});
});
}
function checkHighlight(text) {
if (!text) return [];
text = text.toLowerCase();
return highlightTags.filter(tag => text.includes(tag));
}
// === Local Menu Cache (localStorage) ===
const CACHE_KEY = 'kantine_menuCache';
@@ -621,10 +780,12 @@
try {
const cached = localStorage.getItem(CACHE_KEY);
const cachedTs = localStorage.getItem(CACHE_TS_KEY);
console.log(`[Cache] localStorage: key=${!!cached} (${cached ? cached.length : 0} chars), ts=${cachedTs}`);
if (cached) {
allWeeks = JSON.parse(cached);
currentWeekNumber = getISOWeek(new Date());
currentYear = new Date().getFullYear();
console.log(`[Cache] Parsed ${allWeeks.length} weeks:`, allWeeks.map(w => `KW${w.weekNumber}/${w.year} (${(w.days || []).length} days)`));
renderVisibleWeeks();
updateNextWeekBadge();
if (cachedTs) updateLastUpdatedTime(cachedTs);
@@ -637,6 +798,31 @@
return false;
}
// FR-024: Check if cache is fresh enough to skip API refresh
function isCacheFresh() {
const cachedTs = localStorage.getItem(CACHE_TS_KEY);
if (!cachedTs) {
console.log('[Cache] No timestamp found');
return false;
}
// Condition 1: Cache < 1 hour old
const ageMs = Date.now() - new Date(cachedTs).getTime();
const ageMin = Math.round(ageMs / 60000);
if (ageMs > 60 * 60 * 1000) {
console.log(`[Cache] Stale: ${ageMin}min old (max 60)`);
return false;
}
// Condition 2: Data for current week exists
const thisWeek = getISOWeek(new Date());
const thisYear = getWeekYear(new Date());
const hasCurrentWeek = allWeeks.some(w => w.weekNumber === thisWeek && w.year === thisYear && w.days && w.days.length > 0);
console.log(`[Cache] Age: ${ageMin}min, looking for KW${thisWeek}/${thisYear}, found: ${hasCurrentWeek}`);
return hasCurrentWeek;
}
// === Menu Data Fetching (Direct from Bessa API) ===
async function loadMenuDataFromAPI() {
const loading = document.getElementById('loading');
@@ -834,17 +1020,39 @@
}
// === Last Updated Display ===
let lastUpdatedTimestamp = null;
let lastUpdatedIntervalId = null;
function updateLastUpdatedTime(isoTimestamp) {
const subtitle = document.getElementById('last-updated-subtitle');
if (!isoTimestamp) return;
lastUpdatedTimestamp = isoTimestamp;
try {
const date = new Date(isoTimestamp);
const timeStr = date.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
const dateStr = date.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' });
subtitle.textContent = `Aktualisiert: ${dateStr} ${timeStr}`;
const ago = getRelativeTime(date);
subtitle.textContent = `Aktualisiert: ${dateStr} ${timeStr} (${ago})`;
} catch (e) {
subtitle.textContent = '';
}
// Auto-refresh relative time every minute
if (!lastUpdatedIntervalId) {
lastUpdatedIntervalId = setInterval(() => {
if (lastUpdatedTimestamp) updateLastUpdatedTime(lastUpdatedTimestamp);
}, 60 * 1000);
}
}
function getRelativeTime(date) {
const diffMs = Date.now() - date.getTime();
const diffMin = Math.floor(diffMs / 60000);
if (diffMin < 1) return 'gerade eben';
if (diffMin === 1) return 'vor 1 min.';
if (diffMin < 60) return `vor ${diffMin} min.`;
const diffH = Math.floor(diffMin / 60);
if (diffH === 1) return 'vor 1 Std.';
return `vor ${diffH} Std.`;
}
// === Toast Notification ===
@@ -928,6 +1136,25 @@
badge.classList.add('badge-blue'); // Default / partial state
}
// Advanced Feature: Highlight Count
let highlightCount = 0;
if (nextWeekData && nextWeekData.days) {
nextWeekData.days.forEach(day => {
day.items.forEach(item => {
if (checkHighlight(item.name) || checkHighlight(item.description)) {
highlightCount++;
}
});
});
}
if (highlightCount > 0) {
// Append blue count
badge.innerHTML += `<span class="highlight-count" title="${highlightCount} Highlight Menüs">(${highlightCount})</span>`;
badge.title += `${highlightCount} Highlights gefunden`;
badge.classList.add('has-highlights');
}
} else if (badge) {
badge.remove();
}
@@ -1185,6 +1412,12 @@
itemEl.classList.add(item.available ? 'flagged-available' : 'flagged-sold-out');
}
// Highlight matching menu items based on user tags
const matchedTags = [...new Set([...checkHighlight(item.name), ...checkHighlight(item.description)])];
if (matchedTags.length > 0) {
itemEl.classList.add('highlight-glow');
}
// Action buttons
let orderButton = '';
let cancelButton = '';
@@ -1216,6 +1449,13 @@
}
}
// Build matched-tags HTML (only if tags found)
let tagsHtml = '';
if (matchedTags.length > 0) {
const badges = matchedTags.map(t => `<span class="tag-badge-small"><span class="material-icons-round" style="font-size:10px;margin-right:2px">star</span>${escapeHtml(t)}</span>`).join('');
tagsHtml = `<div class="matched-tags">${badges}</div>`;
}
itemEl.innerHTML = `
<div class="item-header">
<span class="item-name">${escapeHtml(item.name)}</span>
@@ -1228,6 +1468,7 @@
${flagButton}
<div class="badges">${statusBadge}</div>
</div>
${tagsHtml}
<p class="item-desc">${escapeHtml(item.description)}</p>`;
// Event: Order
@@ -1272,49 +1513,259 @@
return card;
}
// === Version Check ===
async function checkForUpdates() {
const CurrentVersion = '{{VERSION}}'; // Injected by build script
const VersionUrl = 'https://raw.githubusercontent.com/TauNeutrino/kantine-overview/main/version.txt';
// Use htmlpreview.github.io to render the HTML directly in browser
const InstallerUrl = 'https://htmlpreview.github.io/?https://github.com/TauNeutrino/kantine-overview/blob/main/dist/install.html';
// === GitHub Release Management ===
console.log(`[Kantine] Checking for updates... (Current: ${CurrentVersion})`);
// Semver comparison: returns true if remote > local
function isNewer(remote, local) {
if (!remote || !local) return false;
const r = remote.replace(/^v/, '').split('.').map(Number);
const l = local.replace(/^v/, '').split('.').map(Number);
for (let i = 0; i < Math.max(r.length, l.length); i++) {
if ((r[i] || 0) > (l[i] || 0)) return true;
if ((r[i] || 0) < (l[i] || 0)) return false;
}
return false;
}
// GitHub API headers
function githubHeaders() {
return { 'Accept': 'application/vnd.github.v3+json' };
}
// Fetch versions from GitHub (releases or tags)
async function fetchVersions(devMode) {
const endpoint = devMode
? `${GITHUB_API}/tags?per_page=20`
: `${GITHUB_API}/releases?per_page=20`;
const resp = await fetch(endpoint, { headers: githubHeaders() });
if (!resp.ok) throw new Error(`GitHub API ${resp.status}`);
const data = await resp.json();
// Normalize to common format: { tag, name, url, body }
return data.map(item => {
const tag = devMode ? item.name : item.tag_name;
return {
tag,
name: devMode ? tag : (item.name || tag),
url: `${INSTALLER_BASE}/${tag}/dist/install.html`,
body: item.body || ''
};
});
}
// Periodic update check (runs on init + every hour)
async function checkForUpdates() {
const currentVersion = '{{VERSION}}';
const devMode = localStorage.getItem('kantine_dev_mode') === 'true';
try {
const response = await fetch(VersionUrl, { cache: 'no-cache' });
if (!response.ok) return;
const versions = await fetchVersions(devMode);
if (!versions.length) return;
const remoteVersion = (await response.text()).trim();
console.log(`[Kantine] Remote version: ${remoteVersion}`);
// Cache for version menu
localStorage.setItem('kantine_version_cache', JSON.stringify({
timestamp: Date.now(), devMode, versions
}));
if (remoteVersion && remoteVersion !== CurrentVersion) {
// Simple semantic version check or string inequality
// Assuming format v1.0.0
showUpdateIcon(remoteVersion, InstallerUrl);
}
} catch (error) {
console.warn('[Kantine] Version check failed:', error);
}
}
const latest = versions[0].tag;
console.log(`[Kantine] Version Check: Local [${currentVersion}] vs Latest [${latest}] (${devMode ? 'dev' : 'stable'})`);
function showUpdateIcon(newVersion, url) {
if (!isNewer(latest, currentVersion)) return;
console.log(`[Kantine] Update verfügbar: ${latest}`);
// Show 🆕 icon in header (only once)
const headerTitle = document.querySelector('.header-left h1');
if (!headerTitle) return;
// Check if already added
if (headerTitle.querySelector('.update-icon')) return;
if (headerTitle && !headerTitle.querySelector('.update-icon')) {
const icon = document.createElement('a');
icon.className = 'update-icon';
icon.href = url;
icon.href = versions[0].url;
icon.target = '_blank';
icon.innerHTML = '🆕'; // User requested icon
icon.title = `Neue Version verfügbar (${newVersion}). Klick für download`;
icon.innerHTML = '🆕';
icon.title = `Update: ${latest} Klick zum Installieren`;
icon.style.cssText = 'margin-left:8px;font-size:1em;text-decoration:none;cursor:pointer;vertical-align:middle;';
headerTitle.appendChild(icon);
showToast(`Update verfügbar: ${newVersion}`, 'info');
}
} catch (e) {
console.warn('[Kantine] Version check failed:', e);
}
}
// Open Version Menu modal
function openVersionMenu() {
const modal = document.getElementById('version-modal');
const container = document.getElementById('version-list-container');
const devToggle = document.getElementById('dev-mode-toggle');
const currentVersion = '{{VERSION}}';
if (!modal) return;
modal.classList.remove('hidden');
// Set current version display
const cur = document.getElementById('version-current');
if (cur) cur.textContent = currentVersion;
// Init dev toggle
const devMode = localStorage.getItem('kantine_dev_mode') === 'true';
devToggle.checked = devMode;
// Load versions (from cache or fresh)
async function loadVersions(forceRefresh) {
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) {
container.innerHTML = '<p style="color:var(--text-secondary);">Keine Versionen gefunden.</p>';
return;
}
container.innerHTML = '<ul class="version-list"></ul>';
const list = container.querySelector('.version-list');
versions.forEach(v => {
const isCurrent = v.tag === currentVersion;
const isNew = isNewer(v.tag, currentVersion);
const li = document.createElement('li');
li.className = 'version-item' + (isCurrent ? ' current' : '');
let badge = '';
if (isCurrent) badge = '<span class="badge-current">✓ Installiert</span>';
else if (isNew) badge = '<span class="badge-new">⬆ Neu!</span>';
let action = '';
if (!isCurrent) {
action = `<a href="${v.url}" target="_blank" class="install-link" title="${v.tag} installieren">Installieren</a>`;
}
li.innerHTML = `
<div class="version-info">
<strong>${v.tag}</strong>
${badge}
</div>
${action}
`;
list.appendChild(li);
});
} catch (e) {
container.innerHTML = `<p style="color:#e94560;">Fehler: ${e.message}</p>`;
}
}
loadVersions(false);
// Dev toggle handler
devToggle.onchange = () => {
localStorage.setItem('kantine_dev_mode', devToggle.checked);
// Clear cache to force refresh when mode changes
localStorage.removeItem('kantine_version_cache');
loadVersions(true);
};
}
// === Order Countdown ===
function updateCountdown() {
const now = new Date();
const currentDay = now.getDay();
// Skip weekends (0=Sun, 6=Sat)
if (currentDay === 0 || currentDay === 6) {
removeCountdown();
return;
}
const todayStr = now.toISOString().split('T')[0];
// 1. Check if we already ordered for today
let hasOrder = false;
// Optimization: Check orderMap for today's date
// Keys are "YYYY-MM-DD_ArticleID"
for (const key of orderMap.keys()) {
if (key.startsWith(todayStr)) {
hasOrder = true;
break;
}
}
if (hasOrder) {
removeCountdown();
return;
}
// 2. Calculate time to cutoff (10:00 AM)
const cutoff = new Date();
cutoff.setHours(10, 0, 0, 0);
const diff = cutoff - now;
// If passed cutoff or more than 3 hours away (e.g. 07:00), maybe don't show?
// User req: "heute noch keine bestellung... countdown erscheinen"
// Let's show it if within valid order window (e.g. 00:00 - 10:00)
if (diff <= 0) {
removeCountdown();
return;
}
// 3. Render Countdown
const diffHrs = Math.floor(diff / 3600000);
const diffMins = Math.floor((diff % 3600000) / 60000);
const headerCenter = document.querySelector('.header-center-wrapper');
if (!headerCenter) return;
let countdownEl = document.getElementById('order-countdown');
if (!countdownEl) {
countdownEl = document.createElement('div');
countdownEl.id = 'order-countdown';
// Insert before cost display or append
headerCenter.insertBefore(countdownEl, headerCenter.firstChild);
}
countdownEl.innerHTML = `<span>Bestellschluss:</span> <strong>${diffHrs}h ${diffMins}m</strong>`;
// Red Alert if < 1 hour
if (diff < 3600000) { // 1 hour
countdownEl.classList.add('urgent');
// Notification logic (One time)
const notifiedKey = `kantine_notified_${todayStr}`;
if (!sessionStorage.getItem(notifiedKey)) {
if (Notification.permission === 'granted') {
new Notification('Kantine: Bestellschluss naht!', {
body: 'Du hast heute noch nichts bestellt. Nur noch 1 Stunde!',
icon: '⏳'
});
} else if (Notification.permission === 'default') {
Notification.requestPermission();
}
sessionStorage.setItem(notifiedKey, 'true');
}
} else {
countdownEl.classList.remove('urgent');
}
}
function removeCountdown() {
const el = document.getElementById('order-countdown');
if (el) el.remove();
}
// Update countdown every minute
setInterval(updateCountdown, 60000);
// Also update on load
setTimeout(updateCountdown, 1000);
// === Helpers ===
function getISOWeek(date) {
@@ -1331,6 +1782,7 @@
return date.getFullYear();
}
function translateDay(englishDay) {
const map = { Monday: 'Montag', Tuesday: 'Dienstag', Wednesday: 'Mittwoch', Thursday: 'Donnerstag', Friday: 'Freitag', Saturday: 'Samstag', Sunday: 'Sonntag' };
return map[englishDay] || englishDay;
@@ -1348,21 +1800,28 @@
updateAuthUI();
cleanupExpiredFlags();
// Load cached data first for instant UI, then refresh from API
// Load cached data first for instant UI, refresh only if stale (FR-024)
const hadCache = loadMenuCache();
if (hadCache) {
// Hide loading spinner since cache is shown
document.getElementById('loading').classList.add('hidden');
}
if (!isCacheFresh()) {
console.log('Cache stale or incomplete refreshing from API');
loadMenuDataFromAPI();
} else {
console.log('Cache fresh & complete skipping API refresh');
}
} else {
loadMenuDataFromAPI();
}
// Auto-start polling if already logged in
if (authToken) {
startPolling();
}
// Check for updates
// Check for updates (now + every hour)
checkForUpdates();
setInterval(checkForUpdates, 60 * 60 * 1000);
console.log('Kantine Wrapper loaded ✅');
})();

184
mock-data.js Executable file
View File

@@ -0,0 +1,184 @@
/**
* Mock data for standalone HTML testing.
* Intercepts fetch() calls to api.bessa.app and returns realistic dummy data.
* Injected BEFORE kantine.js in standalone builds only.
*/
(function () {
'use strict';
// Generate dates for this week and next week (Mon-Fri)
function getWeekDates(weekOffset) {
const dates = [];
const now = new Date();
const dayOfWeek = now.getDay(); // 0=Sun, 1=Mon
const monday = new Date(now);
monday.setDate(now.getDate() - (dayOfWeek === 0 ? 6 : dayOfWeek - 1) + (weekOffset * 7));
for (let i = 0; i < 5; i++) {
const d = new Date(monday);
d.setDate(monday.getDate() + i);
dates.push(d.toISOString().split('T')[0]);
}
return dates;
}
const thisWeekDates = getWeekDates(0);
const nextWeekDates = getWeekDates(1);
const allDates = [...thisWeekDates, ...nextWeekDates];
// Realistic German canteen menu items per day
const menuPool = [
[
{ id: 101, name: 'Wiener Schnitzel mit Kartoffelsalat', description: 'Paniertes Schweineschnitzel mit hausgemachtem Kartoffelsalat', price: '6.90', available_amount: '15', amount_tracking: true },
{ id: 102, name: 'Gemüse-Curry mit Basmatireis', description: 'Veganes Curry mit saisonalem Gemüse und Kokosmilch', price: '5.50', available_amount: '0', amount_tracking: true },
{ id: 103, name: 'Rindergulasch mit Spätzle', description: 'Geschmortes Rindfleisch in Paprikasauce mit Eierspätzle', price: '7.20', available_amount: '8', amount_tracking: true },
{ id: 104, name: 'Tagessuppe: Tomatencremesuppe', description: 'Cremige Tomatensuppe mit Croutons', price: '3.20', available_amount: '0', amount_tracking: false },
],
[
{ id: 201, name: 'Hähnchenbrust mit Pilzrahmsauce', description: 'Gebratene Hähnchenbrust mit Champignon-Rahmsauce und Reis', price: '6.50', available_amount: '12', amount_tracking: true },
{ id: 202, name: 'Vegetarische Lasagne', description: 'Lasagne mit Spinat, Ricotta und Tomatensauce', price: '5.80', available_amount: '10', amount_tracking: true },
{ id: 203, name: 'Bratwurst mit Sauerkraut', description: 'Thüringer Bratwurst mit Sauerkraut und Kartoffelpüree', price: '5.90', available_amount: '0', amount_tracking: true },
{ id: 204, name: 'Caesar Salad mit Hähnchen', description: 'Römersalat mit gegrilltem Hähnchen, Parmesan und Croutons', price: '6.10', available_amount: '0', amount_tracking: false },
],
[
{ id: 301, name: 'Spaghetti Bolognese', description: 'Klassische Bolognese mit frischen Spaghetti', price: '5.20', available_amount: '20', amount_tracking: true },
{ id: 302, name: 'Gebratener Lachs mit Dillsauce', description: 'Lachsfilet auf Blattspinat mit Senf-Dill-Sauce', price: '8.50', available_amount: '5', amount_tracking: true },
{ id: 303, name: 'Kartoffelgratin mit Salat', description: 'Überbackene Kartoffeln mit Sahne und Käse, dazu gemischter Salat', price: '5.00', available_amount: '0', amount_tracking: false },
{ id: 304, name: 'Chili con Carne', description: 'Pikantes Chili mit Hackfleisch, Bohnen und Reis', price: '5.80', available_amount: '9', amount_tracking: true },
],
[
{ id: 401, name: 'Schweinebraten mit Knödel', description: 'Bayerischer Schweinebraten mit Semmelknödel und Bratensauce', price: '7.00', available_amount: '7', amount_tracking: true },
{ id: 402, name: 'Falafel-Bowl mit Hummus', description: 'Knusprige Falafel mit Hummus, Tabouleh und Fladenbrot', price: '5.90', available_amount: '0', amount_tracking: false },
{ id: 403, name: 'Putengeschnetzeltes mit Nudeln', description: 'Putenstreifen in Champignon-Sahnesauce mit Bandnudeln', price: '6.30', available_amount: '11', amount_tracking: true },
{ id: 404, name: 'Tagessuppe: Erbsensuppe', description: 'Deftige Erbsensuppe mit Wiener Würstchen', price: '3.50', available_amount: '0', amount_tracking: false },
],
[
{ id: 501, name: 'Backfisch mit Remoulade', description: 'Paniertes Seelachsfilet mit Remouladensauce und Bratkartoffeln', price: '6.80', available_amount: '6', amount_tracking: true },
{ id: 502, name: 'Käsespätzle mit Röstzwiebeln', description: 'Allgäuer Käsespätzle mit karamellisierten Zwiebeln und Salat', price: '5.50', available_amount: '14', amount_tracking: true },
{ id: 503, name: 'Schnitzel Wiener Art mit Pommes', description: 'Paniertes Hähnchenschnitzel mit knusprigen Pommes Frites', price: '6.20', available_amount: '0', amount_tracking: true },
{ id: 504, name: 'Griechischer Bauernsalat', description: 'Frischer Salat mit Feta, Oliven, Gurke und Tomaten', price: '5.30', available_amount: '0', amount_tracking: false },
],
];
// Build mock responses for each date
const dateResponses = {};
allDates.forEach((date, i) => {
const menuIndex = i % menuPool.length;
dateResponses[date] = {
results: [{
id: 1,
name: 'Mittagsmenü',
items: menuPool[menuIndex].map(item => ({
...item,
// Ensure unique IDs per date
id: item.id + (i * 1000)
}))
}]
};
});
// Mock some orders for today (to show "Bestellt" badges)
const todayStr = new Date().toISOString().split('T')[0];
const todayMenu = dateResponses[todayStr];
const mockOrders = [];
let nextOrderId = 9001;
if (todayMenu) {
const firstItem = todayMenu.results[0].items[0];
mockOrders.push({
id: nextOrderId++,
article: firstItem.id,
article_name: firstItem.name,
date: todayStr,
venue: 591,
status: 'confirmed',
created: new Date().toISOString()
});
}
// Pre-seed a mock auth session so flag/order buttons render
sessionStorage.setItem('kantine_authToken', 'mock-token-for-testing');
sessionStorage.setItem('kantine_currentUser', '12345');
sessionStorage.setItem('kantine_firstName', 'Test');
sessionStorage.setItem('kantine_lastName', 'User');
// Intercept fetch
const originalFetch = window.fetch;
window.fetch = function (url, options) {
const urlStr = typeof url === 'string' ? url : url.toString();
// Menu dates endpoint
if (urlStr.includes('/menu/dates/')) {
console.log('[MOCK] Returning mock dates data');
return Promise.resolve(new Response(JSON.stringify({
results: allDates.map(date => ({ date, orders: [] }))
}), { status: 200, headers: { 'Content-Type': 'application/json' } }));
}
// Menu detail for a specific date
const dateMatch = urlStr.match(/\/menu\/\d+\/(\d{4}-\d{2}-\d{2})\//);
if (dateMatch) {
const date = dateMatch[1];
const data = dateResponses[date] || { results: [] };
console.log(`[MOCK] Returning mock menu for ${date}`);
return Promise.resolve(new Response(JSON.stringify(data), {
status: 200, headers: { 'Content-Type': 'application/json' }
}));
}
// Orders endpoint
if (urlStr.includes('/user/orders/') && (!options || options.method === 'GET' || !options.method)) {
console.log('[MOCK] Returning mock orders');
return Promise.resolve(new Response(JSON.stringify({
results: mockOrders
}), { status: 200, headers: { 'Content-Type': 'application/json' } }));
}
// Auth user endpoint
if (urlStr.includes('/auth/user/')) {
console.log('[MOCK] Returning mock user');
return Promise.resolve(new Response(JSON.stringify({
pk: 12345,
username: 'testuser',
email: 'test@example.com',
first_name: 'Test',
last_name: 'User'
}), { status: 200, headers: { 'Content-Type': 'application/json' } }));
}
// Order create (POST to /user/orders/)
if (urlStr.includes('/user/orders/') && options && options.method === 'POST') {
const body = JSON.parse(options.body || '{}');
const newOrder = {
id: nextOrderId++,
article: body.article,
article_name: 'Mock Order',
date: body.date,
venue: 591,
status: 'confirmed',
created: new Date().toISOString()
};
mockOrders.push(newOrder);
console.log('[MOCK] Created order:', newOrder);
return Promise.resolve(new Response(JSON.stringify(newOrder), {
status: 201, headers: { 'Content-Type': 'application/json' }
}));
}
// Order cancel (POST to /user/orders/{id}/cancel/)
const cancelMatch = urlStr.match(/\/user\/orders\/(\d+)\/cancel\//);
if (cancelMatch) {
const orderId = parseInt(cancelMatch[1]);
const idx = mockOrders.findIndex(o => o.id === orderId);
if (idx >= 0) mockOrders.splice(idx, 1);
console.log('[MOCK] Cancelled order:', orderId);
return Promise.resolve(new Response('{}', {
status: 200, headers: { 'Content-Type': 'application/json' }
}));
}
// Fallback to real fetch for other URLs (fonts, etc.)
return originalFetch.apply(this, arguments);
};
console.log('[MOCK] 🧪 Mock data active using dummy canteen menus for UI testing');
})();

372
style.css
View File

@@ -141,17 +141,6 @@ body {
justify-content: center;
}
.weekly-cost {
white-space: nowrap;
font-size: 0.9rem;
font-weight: 600;
color: var(--success-color);
background-color: var(--bg-body);
padding: 0.25rem 0.75rem;
border-radius: 20px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
border: 1px solid var(--border-color);
}
.header-week-title {
font-size: 1.1rem;
@@ -475,7 +464,6 @@ body {
justify-content: space-between;
padding: 20px;
border-bottom: 1px solid var(--border-color);
/* Changed from --border */
}
.modal-header h2 {
@@ -483,6 +471,10 @@ body {
font-size: 1.25rem;
}
.modal-body {
padding: 20px;
}
#login-form {
padding: 20px;
}
@@ -1166,13 +1158,15 @@ body {
color: var(--text-primary);
/* Ensure text remains standard color */
}
/* Update Icon */
.update-icon {
display: inline-flex;
align-items: center;
justify-content: center;
margin-left: 8px;
background-color: rgba(16, 185, 129, 0.2); /* Green tint */
background-color: rgba(16, 185, 129, 0.2);
/* Green tint */
color: var(--success-color);
border-radius: 50%;
width: 24px;
@@ -1191,7 +1185,353 @@ body {
}
@keyframes pulse {
0% { box-shadow: 0 0 0 0 rgba(16, 185, 129, 0.4); }
70% { box-shadow: 0 0 0 6px rgba(16, 185, 129, 0); }
100% { box-shadow: 0 0 0 0 rgba(16, 185, 129, 0); }
0% {
box-shadow: 0 0 0 0 rgba(16, 185, 129, 0.4);
}
70% {
box-shadow: 0 0 0 6px rgba(16, 185, 129, 0);
}
100% {
box-shadow: 0 0 0 0 rgba(16, 185, 129, 0);
}
}
/* Order Countdown */
#order-countdown {
background: rgba(255, 255, 255, 0.1);
padding: 0.25rem 0.75rem;
border-radius: 99px;
font-size: 0.85rem;
display: flex;
align-items: center;
gap: 0.5rem;
white-space: nowrap;
border: 1px solid var(--border-color);
}
#order-countdown span {
opacity: 0.7;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.5px;
}
#order-countdown.urgent {
background: rgba(239, 68, 68, 0.2);
border-color: rgba(239, 68, 68, 0.5);
color: #ef4444;
animation: pulse-red 2s infinite;
}
@keyframes pulse-red {
0% {
box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.4);
}
70% {
box-shadow: 0 0 0 6px rgba(239, 68, 68, 0);
}
100% {
box-shadow: 0 0 0 0 rgba(239, 68, 68, 0);
}
}
/* Smart Highlights (Blue Glow - matches today-ordered/flagged pattern) */
.menu-item.highlight-glow {
border: 2px solid rgba(59, 130, 246, 0.7);
box-shadow: 0 0 20px rgba(59, 130, 246, 0.4);
border-radius: 8px;
padding: 1rem;
margin: 0 -1rem 1.5rem -1rem;
background: var(--bg-card);
position: relative;
z-index: 5;
animation: blue-pulse 3s infinite;
}
@keyframes blue-pulse {
0% {
box-shadow: 0 0 15px rgba(59, 130, 246, 0.3);
}
50% {
box-shadow: 0 0 25px rgba(59, 130, 246, 0.6);
}
100% {
box-shadow: 0 0 15px rgba(59, 130, 246, 0.3);
}
}
/* Nav Badge with Count */
.nav-badge.has-highlights {
background-color: var(--bg-card);
/* Neutral background */
color: var(--text-primary);
border: 1px solid var(--border-color);
padding: 2px 6px;
}
.nav-badge .highlight-count {
color: #3b82f6;
/* Blue 500 */
font-weight: 700;
margin-left: 4px;
}
/* Tag Management Modal */
#tags-list {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 1rem;
min-height: 50px;
}
/* Tag badges styled consistently with .badge (verfügbar/ausverkauft) */
.tag-badge {
display: inline-flex;
align-items: center;
justify-content: center;
height: 24px;
font-size: 0.75rem;
padding: 0 10px;
border-radius: 4px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
line-height: normal;
white-space: nowrap;
background-color: rgba(59, 130, 246, 0.1);
color: #3b82f6;
border: 1px solid rgba(59, 130, 246, 0.2);
gap: 4px;
}
.tag-remove {
cursor: pointer;
opacity: 0.7;
font-size: 1.1em;
line-height: 1;
transition: all 0.2s;
}
.tag-remove:hover {
opacity: 1;
color: #ef4444;
}
.input-group {
display: flex;
gap: 0.5rem;
}
.input-group input {
flex: 1;
padding: 0.75rem;
background: var(--bg-body);
border: 1px solid var(--border-color);
color: var(--text-primary);
border-radius: 8px;
font-family: inherit;
}
/* Add tag button - styled like .btn-order with nav-btn.active color */
#btn-add-tag {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 0.5rem 1rem;
border: none;
border-radius: 6px;
background: var(--accent-color);
color: white;
font-size: 0.8rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
font-family: inherit;
white-space: nowrap;
}
#btn-add-tag:hover {
filter: brightness(1.15);
transform: translateY(-1px);
}
.matched-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 8px;
/* Space between tags and title */
margin-top: -5px;
/* Pull closer to header */
}
.tag-badge-small {
display: inline-flex;
align-items: center;
font-size: 0.7rem;
padding: 2px 8px;
border-radius: 4px;
background: rgba(59, 130, 246, 0.15);
color: #60a5fa;
border: 1px solid rgba(59, 130, 246, 0.3);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
}
[data-theme="light"] .tag-badge-small {
background: rgba(37, 99, 235, 0.1);
color: #2563eb;
border: 1px solid rgba(37, 99, 235, 0.2);
}
/* Installer Changelog */
.changelog-container ul {
padding-left: 1.5rem;
margin: 0.5rem 0;
}
.changelog-container li {
margin-bottom: 0.4rem;
line-height: 1.5;
}
.changelog-container h3 {
margin-top: 1.5rem;
margin-bottom: 0.5rem;
font-size: 1.1em;
color: var(--accent-color);
}
/* === Version Menu === */
.version-tag {
cursor: pointer;
transition: opacity 0.2s ease, text-decoration 0.2s ease;
}
.version-tag:hover {
opacity: 1 !important;
text-decoration: underline;
}
.version-list {
list-style: none;
padding: 0;
max-height: 350px;
overflow-y: auto;
margin: 0;
}
.version-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 14px;
border-radius: 8px;
margin-bottom: 4px;
transition: background 0.2s;
}
.version-item:hover {
background: rgba(100, 116, 139, 0.08);
}
.version-item.current {
background: rgba(2, 154, 168, 0.1);
border: 1px solid rgba(2, 154, 168, 0.25);
}
[data-theme="dark"] .version-item:hover {
background: rgba(255, 255, 255, 0.05);
}
[data-theme="dark"] .version-item.current {
background: rgba(96, 165, 250, 0.12);
border: 1px solid rgba(96, 165, 250, 0.25);
}
.version-info {
display: flex;
align-items: center;
gap: 10px;
}
.badge-current {
font-size: 0.75rem;
font-weight: 600;
color: var(--success-color);
padding: 2px 8px;
border-radius: 4px;
background: rgba(5, 150, 105, 0.1);
}
.badge-new {
font-size: 0.75rem;
font-weight: 600;
color: #029aa8;
padding: 2px 8px;
border-radius: 4px;
background: rgba(2, 154, 168, 0.1);
}
[data-theme="dark"] .badge-new {
color: #60a5fa;
background: rgba(96, 165, 250, 0.12);
}
.install-link {
font-size: 0.8rem;
font-weight: 500;
padding: 4px 12px;
border-radius: 6px;
background: rgba(2, 154, 168, 0.1);
color: #029aa8;
text-decoration: none;
border: 1px solid rgba(2, 154, 168, 0.25);
transition: all 0.2s;
white-space: nowrap;
}
.install-link:hover {
background: rgba(2, 154, 168, 0.2);
border-color: rgba(2, 154, 168, 0.4);
}
[data-theme="dark"] .install-link {
color: #60a5fa;
background: rgba(96, 165, 250, 0.12);
border: 1px solid rgba(96, 165, 250, 0.25);
}
[data-theme="dark"] .install-link:hover {
background: rgba(96, 165, 250, 0.2);
border-color: rgba(96, 165, 250, 0.4);
}
.dev-toggle {
padding: 10px 14px;
border-radius: 8px;
background: rgba(100, 116, 139, 0.05);
border: 1px solid var(--border-color);
}
.dev-toggle input[type="checkbox"] {
accent-color: #029aa8;
width: 16px;
height: 16px;
}
[data-theme="dark"] .dev-toggle input[type="checkbox"] {
accent-color: #60a5fa;
}

89
test_build.py Executable file
View File

@@ -0,0 +1,89 @@
import os
import sys
DIST_DIR = os.path.join(os.path.dirname(__file__), 'dist')
INSTALL_HTML = os.path.join(DIST_DIR, 'install.html')
BOOKMARKLET_TXT = os.path.join(DIST_DIR, 'bookmarklet.txt')
STANDALONE_HTML = os.path.join(DIST_DIR, 'kantine-standalone.html')
def check_file_exists(path, description):
if not os.path.exists(path):
print(f"❌ MISSING: {description} ({path})")
return False
# Check if not empty
if os.path.getsize(path) == 0:
print(f"❌ EMPTY: {description} ({path})")
return False
print(f"✅ FOUND: {description}")
return True
def check_content(path, must_contain=[], must_not_contain=[]):
with open(path, 'r', encoding='utf-8') as f:
content = f.read()
success = True
for item in must_contain:
if item not in content:
print(f"❌ MISSING CONTENT: '{item}' in {os.path.basename(path)}")
success = False
for item in must_not_contain:
if item in content:
print(f"❌ FORBIDDEN CONTENT: '{item}' in {os.path.basename(path)}")
success = False
if success:
print(f"✅ CONTENT VERIFIED: {os.path.basename(path)}")
return success
def main():
print("=== Running Build Tests ===")
# 1. Existence Check
if not all([
check_file_exists(INSTALL_HTML, "Installer HTML"),
check_file_exists(BOOKMARKLET_TXT, "Bookmarklet Text"),
check_file_exists(STANDALONE_HTML, "Standalone HTML")
]):
sys.exit(1)
# 2. Bookmarklet Logic Check
# Must have the CSS injection fix from the external AI
# Must have correct versioning
# Must be properly URL encoded (checking for common issues)
# Read bookmarklet code (decoded mostly by being in txt? No, txt is usually the raw URL)
with open(BOOKMARKLET_TXT, 'r') as f:
bm_code = f.read().strip()
if not bm_code.startswith("javascript:"):
print("❌ Bookmarklet does not start with 'javascript:'")
sys.exit(1)
# Check for placeholder leftovers
if not check_content(BOOKMARKLET_TXT,
must_contain=["document.createElement('style')", "M1", "M2"],
must_not_contain=["{{VERSION}}", "{{CSS_ESCAPED}}"]):
sys.exit(1)
# Check for CSS injection specific logic
if "document.head.appendChild(s)" not in bm_code and "appendChild(s)" not in bm_code: # URL encoded might mask this, strictly checking decoded would be better but simple check first
# Actually bm_code is URL encoded. We should decode it to verify logic.
import urllib.parse
decoded = urllib.parse.unquote(bm_code)
if "document.createElement('style')" not in decoded:
print("❌ CSS Injection logic missing in bookmarklet")
sys.exit(1)
print("✅ CSS Injection logic confirmed")
# 3. Installer Check
if not check_content(INSTALL_HTML,
must_contain=["Kantine Wrapper", "So funktioniert's", "changelog-container"],
must_not_contain=["CHANGELOG_HTML_PLACEHOLDER"]): # If we used that
sys.exit(1)
print("🎉 ALL TESTS PASSED")
sys.exit(0)
if __name__ == "__main__":
main()

139
test_logic.js Executable file
View File

@@ -0,0 +1,139 @@
const fs = require('fs');
const vm = require('vm');
const path = require('path');
console.log("=== Running Logic Unit Tests ===");
// 1. Load Source Code
const jsPath = path.join(__dirname, 'kantine.js');
const code = fs.readFileSync(jsPath, 'utf8');
// Generic Mock Element
const createMockElement = (id = 'mock') => ({
id,
classList: { add: () => { }, remove: () => { }, contains: () => false },
textContent: '',
value: '',
style: {},
addEventListener: () => { },
removeEventListener: () => { },
appendChild: () => { },
removeChild: () => { },
querySelector: () => createMockElement(),
querySelectorAll: () => [createMockElement()],
getAttribute: () => '',
setAttribute: () => { },
remove: () => { },
replaceWith: (newNode) => {
// Special check for update icon
if (id === 'last-updated-icon-mock') {
console.log("✅ Unit Test Passed: Icon replacement triggered.");
sandbox.__TEST_PASSED = true;
}
},
parentElement: { title: '' },
dataset: {}
});
// 2. Setup Mock Environment
const sandbox = {
console: console,
fetch: async (url) => {
// Mock Version Check
if (url.includes('version.txt')) {
return { ok: true, text: async () => 'v9.9.9' }; // Simulate new version
}
// Mock Changelog
if (url.includes('changelog.md')) {
return { ok: true, text: async () => '## v9.9.9\n- Feature: Cool Stuff' };
}
return { ok: false }; // Fail others to prevent huge cascades
},
document: {
body: createMockElement('body'),
head: createMockElement('head'),
createElement: (tag) => createMockElement(tag),
querySelector: (sel) => {
if (sel === '.material-icons-round.logo-icon') {
const el = createMockElement('last-updated-icon-mock');
// Mock legacy prop for specific test check if needed,
// but our generic mock handles replaceWith hook
return el;
}
return createMockElement('query-result');
},
getElementById: (id) => createMockElement(id),
documentElement: {
setAttribute: () => { },
getAttribute: () => 'light',
style: {}
}
},
window: {
matchMedia: () => ({ matches: false }),
addEventListener: () => { },
location: { href: '' }
},
localStorage: { getItem: () => "[]", setItem: () => { } },
sessionStorage: { getItem: () => null, setItem: () => { } },
location: { href: '' },
setInterval: () => { },
setTimeout: (cb) => cb(), // Execute immediately to resolve promises/logic
requestAnimationFrame: (cb) => cb(),
Date: Date,
// Add other globals used in kantine.js
Notification: { permission: 'denied', requestPermission: () => { } }
};
// 3. Instrument Code to expose functions or run check
try {
vm.createContext(sandbox);
// Execute the code
vm.runInContext(code, sandbox);
// Regex Check: update icon appended to header
const fixRegex = /headerTitle\.appendChild\(icon\)/;
if (!fixRegex.test(code)) {
console.error("❌ Logic Test Failed: 'appendChild(icon)' missing in checkForUpdates.");
process.exit(1);
} else {
console.log("✅ Static Analysis Passed: 'appendChild(icon)' found.");
}
// Check for GitHub Release Management functions
const checks = [
[/GITHUB_API/, 'GITHUB_API constant'],
[/function\s+fetchVersions/, 'fetchVersions function'],
[/function\s+isNewer/, 'isNewer function'],
[/function\s+openVersionMenu/, 'openVersionMenu function'],
[/kantine_dev_mode/, 'dev-mode localStorage key'],
[/function\s+isCacheFresh/, 'isCacheFresh function']
];
for (const [regex, label] of checks) {
if (!regex.test(code)) {
console.error(`❌ Static Analysis Failed: '${label}' not found.`);
process.exit(1);
}
}
console.log("✅ Static Analysis Passed: All GitHub Release Management functions found.");
// Check dynamic logic usage
// Note: Since we mock fetch to fail for menu data, the app might perform error handling.
// We just want to ensure it doesn't CRASH (exit code) and that our specific feature logic ran.
if (sandbox.__TEST_PASSED) {
console.log("✅ Dynamic Check Passed: Update logic executed.");
} else {
// It might be buried in async queues that didn't flush.
// Since static analysis passed, we are somewhat confident.
console.log("⚠️ Dynamic Check Skipped (Active execution verification relies on async/timing).");
}
console.log("✅ Syntax Check Passed: Code executed in sandbox.");
} catch (e) {
console.error("❌ Unit Test Error:", e);
process.exit(1);
}

View File

@@ -1 +1 @@
v1.0.3
v1.3.1