Compare commits
29 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4c55e34bc1 | ||
|
|
08ee2a2d0f | ||
|
|
314728f6d0 | ||
|
|
ea4e0d151f | ||
|
|
b928b90728 | ||
|
|
9b1f0e2fd3 | ||
|
|
05bc06660c | ||
|
|
20957c3582 | ||
| fe86e68aca | |||
| 4dbd6c930f | |||
| ad4cfaf4ec | |||
| 441198dd8d | |||
| 13a0ae3a93 | |||
| 1f8ebff9fe | |||
| 8e299c82ca | |||
| 5ae43e92de | |||
| 08da842118 | |||
| 89157f9a8b | |||
| 13b94a3eba | |||
| c42cb3f72d | |||
| 7296901ad9 | |||
| f9b29254f9 | |||
| 876da1a2de | |||
| 587a37884e | |||
| 1040828d7f | |||
| bab54fdf2d | |||
| efdb50083e | |||
| d82dd31bed | |||
| d6a2236a5b |
@@ -48,3 +48,10 @@ trigger: always_on
|
|||||||
## 6. Workspace Scopes
|
## 6. Workspace Scopes
|
||||||
- **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. 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.
|
||||||
25
README.md
25
README.md
@@ -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.
|
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
|
## 🚀 Features
|
||||||
|
|
||||||
* **Wochenübersicht:** Zeigt alle Tage der aktuellen Woche auf einen Blick.
|
* **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.
|
* **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).
|
* **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.
|
* **Menu Badges:** Zeigt Menü-Codes (M1, M2+) direkt im Header.
|
||||||
|
* **Changelog:** Übersicht über neue Funktionen direkt im Installer.
|
||||||
|
|
||||||
## 📦 Installation
|
## 📦 Installation
|
||||||
|
|
||||||
@@ -30,10 +32,21 @@ Ein intelligentes Bookmarklet für die Mitarbeiter-Kantine der Bessa App. Dieses
|
|||||||
* Bash (für `build-bookmarklet.sh`)
|
* Bash (für `build-bookmarklet.sh`)
|
||||||
|
|
||||||
### Projektstruktur
|
### Projektstruktur
|
||||||
* `kantine.js`: Der Haupt-Quellcode des Bookmarklets.
|
|
||||||
* `public/style.css`: Das Design (CSS).
|
#### Quelldateien
|
||||||
* `build-bookmarklet.sh`: Skript zum Erstellen der `dist/` Dateien.
|
* `kantine.js`: Der Haupt-Quellcode des Bookmarklets (UI, API-Logik, Rendering).
|
||||||
* `dist/`: Enthält die kompilierten Dateien (`bookmarklet.txt`, `install.html`).
|
* `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
|
### Build
|
||||||
Um Änderungen an `kantine.js` oder `style.css` wirksam zu machen, führe den Build aus:
|
Um Änderungen an `kantine.js` oder `style.css` wirksam zu machen, führe den Build aus:
|
||||||
|
|||||||
117
REQUIREMENTS.md
117
REQUIREMENTS.md
@@ -2,55 +2,90 @@
|
|||||||
|
|
||||||
## 1. Einleitung
|
## 1. Einleitung
|
||||||
### 1.1 Zweck des Systems
|
### 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
|
### 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
|
## 2. Funktionale Anforderungen
|
||||||
|
|
||||||
| ID | Anforderung (Satzschablone nach Chris Rupp) | Priorität |
|
| ID | Anforderung (Satzschablone nach Chris Rupp) | Priorität | Seit |
|
||||||
|:---|:---|:---|
|
|:---|:---|:---|:---|
|
||||||
| **Auth & Sessions** | | |
|
| **Authentifizierung & Zugang** | | | |
|
||||||
| FR-001 | Das System muss dem Benutzer die Möglichkeit bieten, sich mit Mitarbeiternummer und Passwort am Bessa-Backend anzumelden. | Hoch |
|
| FR-001 | Das System muss dem Benutzer die Möglichkeit bieten, sich mit Mitarbeiternummer und Passwort anzumelden. | Hoch | v1.0.1 |
|
||||||
| 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-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 muss die Zugangsdaten (Mitarbeiternummer/Passwort) unmittelbar nach der Verwendung durch den Scraper verwerfen und darf diese nicht dauerhaft speichern. | Hoch |
|
| FR-003 | Das System darf keine Zugangsdaten dauerhaft speichern. Die Authentifizierung muss sitzungsbasiert sein. | Hoch | v1.0.1 |
|
||||||
| **Scraper & Datenextraktion** | | |
|
| FR-004 | Dem Benutzer muss angezeigt werden, ob und als wer er angemeldet ist (Vorname, Name oder ID). | Mittel | v1.0.1 |
|
||||||
| 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 | Nicht authentifizierte Benutzer müssen die Menüdaten einsehen können (eingeschränkter Lesezugriff). | Mittel | v1.0.1 |
|
||||||
| 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 eine explizite Logout-Funktion bereitstellen, die alle sitzungsbezogenen Daten entfernt. | Mittel | v1.0.1 |
|
||||||
| 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 |
|
| **Menüanzeige** | | | |
|
||||||
| **Datenhaltung & Zugriff** | | |
|
| FR-010 | Das System muss dem Benutzer alle verfügbaren Tagesmenüs einer Woche gleichzeitig in einer Übersicht darstellen. | Hoch | v1.0.1 |
|
||||||
| FR-007 | Das System muss erfolgreich gescrapte Menüpläne in einer persistenten JSON-Datei (`data/menus.json`) speichern. | Hoch |
|
| FR-011 | Das System muss dem Benutzer die Navigation zwischen der aktuellen und der kommenden Woche ermöglichen. | Mittel | v1.0.1 |
|
||||||
| FR-008 | Das System muss unauthentifizierten Benutzern den Zugriff auf bereits im Speicher befindliche Menüdaten ermöglichen (Public Access). | Mittel |
|
| FR-012 | Für jedes Gericht müssen Name, Beschreibung, Preis und Verfügbarkeitsstatus angezeigt werden. | Hoch | v1.0.1 |
|
||||||
| 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 |
|
| FR-013 | Bereits bestellte Menüs müssen visuell von nicht bestellten unterscheidbar sein (farbliche Markierung, Badge). | Mittel | v1.0.1 |
|
||||||
| **Dashboard & UI** | | |
|
| FR-014 | Die Tageskarten-Header müssen den Bestellstatus des Tages farblich signalisieren (bestellt / bestellbar / nicht bestellbar). | Niedrig | v1.0.1 |
|
||||||
| FR-010 | Das System muss dem Benutzer eine intuitive Wochenansicht des Menüplans im Browser darstellen. | Mittel |
|
| FR-015 | Bestellte Menü-Codes (z.B. M1, M2) müssen als Badges im Tageskarten-Header angezeigt werden. | Niedrig | v1.0.1 |
|
||||||
| FR-011 | Wenn ein Scraper-Vorgang aktiv ist, muss das System den Status (Fortschritt, aktuelle Aktion) in Echtzeit visualisieren. | Niedrig |
|
| FR-016 | Am heutigen Tag müssen bestellte Menüs an erster Stelle sortiert werden. | Niedrig | v1.0.1 |
|
||||||
| **Buchungsfunktion** | | |
|
| FR-017 | Wenn keine Menüdaten für eine Woche vorliegen, muss ein aussagekräftiger Leertext angezeigt werden. | Niedrig | v1.0.1 |
|
||||||
| FR-012 | Wenn der Benutzer authentifiziert ist, soll das System eine Bestellung für ein Menü ermöglichen. | Mittel |
|
| **Daten-Aktualisierung** | | | |
|
||||||
| FR-013 | Wenn der Benutzer authentifiziert ist, soll das System ein bereits bestelltes Menü zu stornieren. | Mittel |
|
| FR-020 | Wenn Menüdaten geladen werden, muss der Fortschritt dem Benutzer in Echtzeit angezeigt werden (Fortschrittsbalken, Statustext). | Niedrig | v1.0.1 |
|
||||||
| **Menu Flagging & Notifications** | | |
|
| 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-014 | Das System muss authentifizierten Benutzern ermöglichen, nicht bestellbare Menüs (deren Cutoff noch nicht erreicht ist) zu markieren ("flaggen"). | Mittel |
|
| FR-022 | Das System muss dem Benutzer die Möglichkeit bieten, die Menüdaten manuell neu zu laden. | Niedrig | v1.0.1 |
|
||||||
| 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-023 | Der Zeitpunkt der letzten Aktualisierung muss für den Benutzer sichtbar sein. | Niedrig | v1.0.1 |
|
||||||
| FR-016 | Bei Statusänderung auf "verfügbar" muss das System den Benutzer benachrichtigen (Systembenachrichtigung). | Mittel |
|
| 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 |
|
||||||
| FR-017 | Geflaggte und ausverkaufte Menüs müssen im UI mit einem gelben Glow hervorgehoben werden. | Mittel |
|
| **Bestellfunktion** | | | |
|
||||||
| FR-018 | Geflaggte und verfügbare Menüs müssen im UI mit einem grünen Glow hervorgehoben werden. | Mittel |
|
| FR-030 | Authentifizierte Benutzer müssen ein verfügbares Menü direkt aus der Übersicht bestellen können. | Hoch | v1.0.1 |
|
||||||
| FR-019 | Wenn die Bestell-Cutoff-Zeit erreicht ist, muss das System das Flag automatisch entfernen. | Mittel |
|
| 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
|
## 3. Nicht-funktionale Anforderungen
|
||||||
|
|
||||||
| Kategorie (ISO 25010) | ID | Anforderung | Zielwert/Metrik |
|
| Kategorie (ISO 25010) | ID | Anforderung | Zielwert/Metrik |
|
||||||
|:---|:---|:---|:---|
|
|:---|:---|:---|:---|
|
||||||
| **Performance** | NFR-001 | Antwortzeit der API für gecachte Daten | < 200 ms (95. Perzentil) |
|
| **Performance** | NFR-001 | Die Darstellung bereits gecachter Daten muss ohne spürbare Verzögerung erfolgen. | < 200 ms (UI-Rendering) |
|
||||||
| **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 | Das Polling für geflaggte Menüs darf die reguläre Nutzung nicht beeinträchtigen. | Intervall ≥ 5 Minuten |
|
||||||
| **Performance** | NFR-002 | Dauer eines vollständigen Scrape-Vorgangs (exkl. Navigation) | < 30 Sekunden pro Woche |
|
| **Sicherheit** | NFR-003 | Es dürfen keine Zugangsdaten dauerhaft gespeichert werden. | 0 (keine persistente Speicherung von Passwörtern) |
|
||||||
| **Sicherheit** | NFR-003 | Speicherung von Zugangsdaten | 0 (keine dauerhafte Speicherung von Passwörtern) |
|
| **Sicherheit** | NFR-004 | Auth-Tokens müssen sitzungsbasiert gespeichert werden und bei Schließen des Browsers verfallen. | sessionStorage |
|
||||||
| **Sicherheit** | NFR-004 | Session-Sicherheit | HttpOnly Cookies für die Kommunikation zwischen Frontend und Backend |
|
| **Benutzbarkeit** | NFR-005 | Die Oberfläche muss auf mobilen Geräten fehlerfrei nutzbar sein. | Viewports ab 320px Breite |
|
||||||
| **Wartbarkeit** | NFR-005 | Testabdeckung der Scraper-Logik | Alle Kern-Selektoren müssen durch Debug-HTML-Dumps verifizierbar sein |
|
| **Benutzbarkeit** | NFR-006 | Alle interaktiven Elemente müssen Tooltips oder Hilfetexte bieten. | 100% Coverage |
|
||||||
| **Benutzbarkeit** | NFR-006 | Mobile Responsiveness | Dashboard muss auf Viewports ab 320px Breite fehlerfrei nutzbar sein |
|
| **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
|
## 4. Technische Randbedingungen
|
||||||
* **Architektur**: Node.js Backend mit Express (API + Static Serving) und Vanilla JS Frontend.
|
* **Deployment**: Das System wird als Bookmarklet ausgeliefert, das auf der Bessa-Webseite ausgeführt wird.
|
||||||
* **Engine**: Direkte API-Integration (Reverse Engineering der Bessa API) für maximale Performance und Zuverlässigkeit.
|
* **Datenquelle**: Direkte Integration mit der Bessa REST-API (`api.bessa.app/v1`).
|
||||||
* **Datenspeicher**: Dateibasierter JSON-Store für persistente Daten + In-Memory Caching.
|
* **Datenhaltung**: Clientseitig via `localStorage` (Menü-Cache, Flags, Highlights, Theme) und `sessionStorage` (Auth-Token).
|
||||||
* **Schnittstellen**: REST API (`/api/bookings`, `/api/status`, `/api/order`).
|
* **Build**: Bash-basiertes Build-Script, das Bookmarklet-URL, Standalone-HTML und Installer-Seite generiert.
|
||||||
* **Runtime**: Node.js Umgebung, Docker-ready.
|
* **Versionierung**: SemVer, verwaltet über GitHub Releases/Tags.
|
||||||
|
* **Tests**: Python-basierte Build-Tests + Node.js-basierte Logik-Tests.
|
||||||
|
|||||||
@@ -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
|
if [ ! -f "$JS_FILE" ]; then echo "ERROR: $JS_FILE not found"; exit 1; fi
|
||||||
|
|
||||||
CSS_CONTENT=$(cat "$CSS_FILE")
|
CSS_CONTENT=$(cat "$CSS_FILE")
|
||||||
|
|
||||||
# Inject version into JS
|
# 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) ===
|
# === 1. Build standalone HTML (for local testing/dev) ===
|
||||||
cat > "$DIST_DIR/kantine-standalone.html" << HTMLEOF
|
cat > "$DIST_DIR/kantine-standalone.html" << HTMLEOF
|
||||||
@@ -53,6 +54,10 @@ cat >> "$DIST_DIR/kantine-standalone.html" << HTMLEOF
|
|||||||
<script>
|
<script>
|
||||||
HTMLEOF
|
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
|
# Inject JS
|
||||||
echo "$JS_CONTENT" >> "$DIST_DIR/kantine-standalone.html"
|
echo "$JS_CONTENT" >> "$DIST_DIR/kantine-standalone.html"
|
||||||
|
|
||||||
@@ -104,46 +109,131 @@ 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 { 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; }
|
a.bookmarklet:hover { background: #006269; }
|
||||||
code { background: #0f3460; padding: 2px 6px; border-radius: 4px; }
|
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>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<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 style="text-align: center; margin-bottom: 30px;">
|
||||||
<div class="instructions">
|
<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>
|
||||||
<h2>Installation</h2>
|
<p style="font-size: 1.2rem; color: #a0aec0; margin-top: 0; font-style: italic;">"Mahlzeit! Jetzt bessa einfach."</p>
|
||||||
<ol>
|
</div>
|
||||||
<li>Ziehe den Button unten 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>
|
<!-- 1. BUTTON (Top Priority) -->
|
||||||
<li>Klicke auf das Lesezeichen <code>Kantine $VERSION</code></li>
|
<div class="card" style="text-align: center; border: 2px solid #029AA8;">
|
||||||
</ol>
|
<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>
|
||||||
|
|
||||||
<h2>✨ Features</h2>
|
<!-- 2. INSTRUCTIONS -->
|
||||||
<ul>
|
<div class="card">
|
||||||
<li>📅 <strong>Wochenübersicht:</strong> Die ganze Woche auf einen Blick.</li>
|
<h2>So funktioniert's</h2>
|
||||||
<li>💰 <strong>Kostenkontrolle:</strong> Automatische Berechnung der Wochensumme.</li>
|
<ol>
|
||||||
<li>🔑 <strong>Auto-Login:</strong> Nutzt deine bestehende Session.</li>
|
<li>Ziehe den Button oben in deine <strong>Lesezeichen-Leiste</strong> (Drag & Drop)</li>
|
||||||
<li>🏷️ <strong>Badges & Status:</strong> Menü-Codes (M1, M2) und Bestellstatus direkt sichtbar.</li>
|
<li>Navigiere zu <a href="https://web.bessa.app/knapp-kantine" style="color:#029AA8">web.bessa.app/knapp-kantine</a></li>
|
||||||
<li>🛡️ <strong>Offline-Support:</strong> Speichert Menüdaten lokal.</li>
|
<li>Klicke auf das Lesezeichen <code>Kantine $VERSION</code></li>
|
||||||
</ul>
|
</ol>
|
||||||
|
</div>
|
||||||
|
|
||||||
<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;">
|
<!-- 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>
|
||||||
|
</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;">
|
||||||
<strong>⚠️ Haftungsausschluss:</strong><br>
|
<strong>⚠️ Haftungsausschluss:</strong><br>
|
||||||
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.
|
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>
|
</div>
|
||||||
<p>👇 Diesen Button in die Lesezeichen-Leiste ziehen:</p>
|
</div>
|
||||||
<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>
|
<script>
|
||||||
INSTALLEOF
|
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
|
# Embed the bookmarklet URL inline
|
||||||
echo "document.getElementById('bookmarklet-link').href = " >> "$DIST_DIR/install.html"
|
echo "document.getElementById('bookmarklet-link').href = " >> "$DIST_DIR/install.html"
|
||||||
echo "$JS_CONTENT" | python3 -c "
|
echo "$JS_CONTENT" | python3 -c "
|
||||||
import sys, json
|
import sys, json, urllib.parse
|
||||||
js = sys.stdin.read()
|
|
||||||
css = open('$CSS_FILE').read().replace('\\n', ' ').replace(' ', ' ')
|
# 1. Read JS and Replace VERSION
|
||||||
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);})();'''
|
js_template = sys.stdin.read()
|
||||||
print(json.dumps(bmk) + ';')
|
js = js_template.replace('{{VERSION}}', '$VERSION')
|
||||||
" 2>/dev/null >> "$DIST_DIR/install.html" || echo "'javascript:alert(\"Build error\")'" >> "$DIST_DIR/install.html"
|
|
||||||
|
# 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
|
cat >> "$DIST_DIR/install.html" << INSTALLEOF
|
||||||
document.getElementById('bookmarklet-link').textContent = 'Kantine $VERSION';
|
document.getElementById('bookmarklet-link').textContent = 'Kantine $VERSION';
|
||||||
@@ -157,3 +247,43 @@ echo ""
|
|||||||
echo "=== Build Complete ==="
|
echo "=== Build Complete ==="
|
||||||
echo "Files in $DIST_DIR:"
|
echo "Files in $DIST_DIR:"
|
||||||
ls -la "$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"
|
||||||
|
|||||||
76
changelog.md
Executable file
76
changelog.md
Executable file
@@ -0,0 +1,76 @@
|
|||||||
|
## v1.3.2 (2026-02-19)
|
||||||
|
- **Fix**: Falsche Anzahl an Highlight-Menüs im "Nächste Woche"-Badge korrigiert (zählte alle Menüs statt nur Highlights). 🐛
|
||||||
|
|
||||||
|
## 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).
|
||||||
4
dist/bookmarklet-payload.js
vendored
4
dist/bookmarklet-payload.js
vendored
File diff suppressed because one or more lines are too long
2
dist/bookmarklet.txt
vendored
2
dist/bookmarklet.txt
vendored
File diff suppressed because one or more lines are too long
178
dist/install.html
vendored
178
dist/install.html
vendored
File diff suppressed because one or more lines are too long
1101
dist/kantine-standalone.html
vendored
1101
dist/kantine-standalone.html
vendored
File diff suppressed because it is too large
Load Diff
543
kantine.js
543
kantine.js
@@ -19,6 +19,11 @@
|
|||||||
const MENU_ID = 7;
|
const MENU_ID = 7;
|
||||||
const POLL_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes
|
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 ===
|
// === State ===
|
||||||
let allWeeks = [];
|
let allWeeks = [];
|
||||||
let currentWeekNumber = getISOWeek(new Date());
|
let currentWeekNumber = getISOWeek(new Date());
|
||||||
@@ -66,7 +71,7 @@
|
|||||||
<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 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 id="last-updated-subtitle" class="subtitle"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -78,6 +83,9 @@
|
|||||||
<button id="btn-refresh" class="icon-btn" aria-label="Menüdaten aktualisieren" title="Menüdaten neu laden">
|
<button id="btn-refresh" class="icon-btn" aria-label="Menüdaten aktualisieren" title="Menüdaten neu laden">
|
||||||
<span class="material-icons-round">refresh</span>
|
<span class="material-icons-round">refresh</span>
|
||||||
</button>
|
</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">
|
<div class="nav-group">
|
||||||
<button id="btn-this-week" class="nav-btn active">Diese Woche</button>
|
<button id="btn-this-week" class="nav-btn active">Diese Woche</button>
|
||||||
<button id="btn-next-week" class="nav-btn">Nächste Woche</button>
|
<button id="btn-next-week" class="nav-btn">Nächste Woche</button>
|
||||||
@@ -144,6 +152,52 @@
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<main class="container">
|
||||||
<div id="last-updated-banner" class="banner hidden">
|
<div id="last-updated-banner" class="banner hidden">
|
||||||
<span class="material-icons-round">update</span>
|
<span class="material-icons-round">update</span>
|
||||||
@@ -174,6 +228,64 @@
|
|||||||
const loginForm = document.getElementById('login-form');
|
const loginForm = document.getElementById('login-form');
|
||||||
const loginModal = document.getElementById('login-modal');
|
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
|
// Theme
|
||||||
const savedTheme = localStorage.getItem('theme');
|
const savedTheme = localStorage.getItem('theme');
|
||||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||||
@@ -592,18 +704,65 @@
|
|||||||
}
|
}
|
||||||
// Refresh menu data to update UI
|
// Refresh menu data to update UI
|
||||||
loadMenuDataFromAPI();
|
loadMenuDataFromAPI();
|
||||||
break; // One refresh is enough
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(`Poll error for ${flagId}:`, err);
|
console.error(`Poll error for ${flagId}:`, err);
|
||||||
|
// Small delay between checks
|
||||||
|
await new Promise(r => setTimeout(r, 200));
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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}">×</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) ===
|
// === Local Menu Cache (localStorage) ===
|
||||||
const CACHE_KEY = 'kantine_menuCache';
|
const CACHE_KEY = 'kantine_menuCache';
|
||||||
const CACHE_TS_KEY = 'kantine_menuCacheTs';
|
const CACHE_TS_KEY = 'kantine_menuCacheTs';
|
||||||
@@ -621,10 +780,12 @@
|
|||||||
try {
|
try {
|
||||||
const cached = localStorage.getItem(CACHE_KEY);
|
const cached = localStorage.getItem(CACHE_KEY);
|
||||||
const cachedTs = localStorage.getItem(CACHE_TS_KEY);
|
const cachedTs = localStorage.getItem(CACHE_TS_KEY);
|
||||||
|
console.log(`[Cache] localStorage: key=${!!cached} (${cached ? cached.length : 0} chars), ts=${cachedTs}`);
|
||||||
if (cached) {
|
if (cached) {
|
||||||
allWeeks = JSON.parse(cached);
|
allWeeks = JSON.parse(cached);
|
||||||
currentWeekNumber = getISOWeek(new Date());
|
currentWeekNumber = getISOWeek(new Date());
|
||||||
currentYear = new Date().getFullYear();
|
currentYear = new Date().getFullYear();
|
||||||
|
console.log(`[Cache] Parsed ${allWeeks.length} weeks:`, allWeeks.map(w => `KW${w.weekNumber}/${w.year} (${(w.days || []).length} days)`));
|
||||||
renderVisibleWeeks();
|
renderVisibleWeeks();
|
||||||
updateNextWeekBadge();
|
updateNextWeekBadge();
|
||||||
if (cachedTs) updateLastUpdatedTime(cachedTs);
|
if (cachedTs) updateLastUpdatedTime(cachedTs);
|
||||||
@@ -637,6 +798,31 @@
|
|||||||
return false;
|
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) ===
|
// === Menu Data Fetching (Direct from Bessa API) ===
|
||||||
async function loadMenuDataFromAPI() {
|
async function loadMenuDataFromAPI() {
|
||||||
const loading = document.getElementById('loading');
|
const loading = document.getElementById('loading');
|
||||||
@@ -834,17 +1020,39 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// === Last Updated Display ===
|
// === Last Updated Display ===
|
||||||
|
let lastUpdatedTimestamp = null;
|
||||||
|
let lastUpdatedIntervalId = null;
|
||||||
|
|
||||||
function updateLastUpdatedTime(isoTimestamp) {
|
function updateLastUpdatedTime(isoTimestamp) {
|
||||||
const subtitle = document.getElementById('last-updated-subtitle');
|
const subtitle = document.getElementById('last-updated-subtitle');
|
||||||
if (!isoTimestamp) return;
|
if (!isoTimestamp) return;
|
||||||
|
lastUpdatedTimestamp = isoTimestamp;
|
||||||
try {
|
try {
|
||||||
const date = new Date(isoTimestamp);
|
const date = new Date(isoTimestamp);
|
||||||
const timeStr = date.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
|
const timeStr = date.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
|
||||||
const dateStr = date.toLocaleDateString('de-DE', { day: '2-digit', month: '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) {
|
} catch (e) {
|
||||||
subtitle.textContent = '';
|
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 ===
|
// === Toast Notification ===
|
||||||
@@ -928,6 +1136,27 @@
|
|||||||
badge.classList.add('badge-blue'); // Default / partial state
|
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 => {
|
||||||
|
const nameMatches = checkHighlight(item.name);
|
||||||
|
const descMatches = checkHighlight(item.description);
|
||||||
|
if (nameMatches.length > 0 || descMatches.length > 0) {
|
||||||
|
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) {
|
} else if (badge) {
|
||||||
badge.remove();
|
badge.remove();
|
||||||
}
|
}
|
||||||
@@ -1185,6 +1414,12 @@
|
|||||||
itemEl.classList.add(item.available ? 'flagged-available' : 'flagged-sold-out');
|
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
|
// Action buttons
|
||||||
let orderButton = '';
|
let orderButton = '';
|
||||||
let cancelButton = '';
|
let cancelButton = '';
|
||||||
@@ -1216,6 +1451,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 = `
|
itemEl.innerHTML = `
|
||||||
<div class="item-header">
|
<div class="item-header">
|
||||||
<span class="item-name">${escapeHtml(item.name)}</span>
|
<span class="item-name">${escapeHtml(item.name)}</span>
|
||||||
@@ -1228,6 +1470,7 @@
|
|||||||
${flagButton}
|
${flagButton}
|
||||||
<div class="badges">${statusBadge}</div>
|
<div class="badges">${statusBadge}</div>
|
||||||
</div>
|
</div>
|
||||||
|
${tagsHtml}
|
||||||
<p class="item-desc">${escapeHtml(item.description)}</p>`;
|
<p class="item-desc">${escapeHtml(item.description)}</p>`;
|
||||||
|
|
||||||
// Event: Order
|
// Event: Order
|
||||||
@@ -1272,51 +1515,261 @@
|
|||||||
return card;
|
return card;
|
||||||
}
|
}
|
||||||
|
|
||||||
// === Version Check ===
|
// === GitHub Release Management ===
|
||||||
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';
|
|
||||||
|
|
||||||
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 {
|
try {
|
||||||
const response = await fetch(VersionUrl, { cache: 'no-cache' });
|
const versions = await fetchVersions(devMode);
|
||||||
if (!response.ok) return;
|
if (!versions.length) return;
|
||||||
|
|
||||||
const remoteVersion = (await response.text()).trim();
|
// Cache for version menu
|
||||||
console.log(`[Kantine] Remote version: ${remoteVersion}`);
|
localStorage.setItem('kantine_version_cache', JSON.stringify({
|
||||||
|
timestamp: Date.now(), devMode, versions
|
||||||
|
}));
|
||||||
|
|
||||||
if (remoteVersion && remoteVersion !== CurrentVersion) {
|
const latest = versions[0].tag;
|
||||||
// Simple semantic version check or string inequality
|
console.log(`[Kantine] Version Check: Local [${currentVersion}] vs Latest [${latest}] (${devMode ? 'dev' : 'stable'})`);
|
||||||
// Assuming format v1.0.0
|
|
||||||
showUpdateIcon(remoteVersion, InstallerUrl);
|
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 && !headerTitle.querySelector('.update-icon')) {
|
||||||
|
const icon = document.createElement('a');
|
||||||
|
icon.className = 'update-icon';
|
||||||
|
icon.href = versions[0].url;
|
||||||
|
icon.target = '_blank';
|
||||||
|
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);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (e) {
|
||||||
console.warn('[Kantine] Version check failed:', error);
|
console.warn('[Kantine] Version check failed:', e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function showUpdateIcon(newVersion, url) {
|
// Open Version Menu modal
|
||||||
const headerTitle = document.querySelector('.header-left h1');
|
function openVersionMenu() {
|
||||||
if (!headerTitle) return;
|
const modal = document.getElementById('version-modal');
|
||||||
|
const container = document.getElementById('version-list-container');
|
||||||
|
const devToggle = document.getElementById('dev-mode-toggle');
|
||||||
|
const currentVersion = '{{VERSION}}';
|
||||||
|
|
||||||
// Check if already added
|
if (!modal) return;
|
||||||
if (headerTitle.querySelector('.update-icon')) return;
|
modal.classList.remove('hidden');
|
||||||
|
|
||||||
const icon = document.createElement('a');
|
// Set current version display
|
||||||
icon.className = 'update-icon';
|
const cur = document.getElementById('version-current');
|
||||||
icon.href = url;
|
if (cur) cur.textContent = currentVersion;
|
||||||
icon.target = '_blank';
|
|
||||||
icon.innerHTML = '🆕'; // User requested icon
|
|
||||||
icon.title = `Neue Version verfügbar (${newVersion}). Klick für download`;
|
|
||||||
|
|
||||||
headerTitle.appendChild(icon);
|
// Init dev toggle
|
||||||
showToast(`Update verfügbar: ${newVersion}`, 'info');
|
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);
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// === Helpers ===
|
// === 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) {
|
function getISOWeek(date) {
|
||||||
const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()));
|
const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()));
|
||||||
const dayNum = d.getUTCDay() || 7;
|
const dayNum = d.getUTCDay() || 7;
|
||||||
@@ -1331,6 +1784,7 @@
|
|||||||
return date.getFullYear();
|
return date.getFullYear();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
function translateDay(englishDay) {
|
function translateDay(englishDay) {
|
||||||
const map = { Monday: 'Montag', Tuesday: 'Dienstag', Wednesday: 'Mittwoch', Thursday: 'Donnerstag', Friday: 'Freitag', Saturday: 'Samstag', Sunday: 'Sonntag' };
|
const map = { Monday: 'Montag', Tuesday: 'Dienstag', Wednesday: 'Mittwoch', Thursday: 'Donnerstag', Friday: 'Freitag', Saturday: 'Samstag', Sunday: 'Sonntag' };
|
||||||
return map[englishDay] || englishDay;
|
return map[englishDay] || englishDay;
|
||||||
@@ -1348,21 +1802,28 @@
|
|||||||
updateAuthUI();
|
updateAuthUI();
|
||||||
cleanupExpiredFlags();
|
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();
|
const hadCache = loadMenuCache();
|
||||||
if (hadCache) {
|
if (hadCache) {
|
||||||
// Hide loading spinner since cache is shown
|
|
||||||
document.getElementById('loading').classList.add('hidden');
|
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();
|
||||||
}
|
}
|
||||||
loadMenuDataFromAPI();
|
|
||||||
|
|
||||||
// Auto-start polling if already logged in
|
// Auto-start polling if already logged in
|
||||||
if (authToken) {
|
if (authToken) {
|
||||||
startPolling();
|
startPolling();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for updates
|
// Check for updates (now + every hour)
|
||||||
checkForUpdates();
|
checkForUpdates();
|
||||||
|
setInterval(checkForUpdates, 60 * 60 * 1000);
|
||||||
|
|
||||||
console.log('Kantine Wrapper loaded ✅');
|
console.log('Kantine Wrapper loaded ✅');
|
||||||
})();
|
})();
|
||||||
|
|||||||
184
mock-data.js
Executable file
184
mock-data.js
Executable 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
372
style.css
@@ -141,17 +141,6 @@ body {
|
|||||||
justify-content: center;
|
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 {
|
.header-week-title {
|
||||||
font-size: 1.1rem;
|
font-size: 1.1rem;
|
||||||
@@ -475,7 +464,6 @@ body {
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
border-bottom: 1px solid var(--border-color);
|
border-bottom: 1px solid var(--border-color);
|
||||||
/* Changed from --border */
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-header h2 {
|
.modal-header h2 {
|
||||||
@@ -483,6 +471,10 @@ body {
|
|||||||
font-size: 1.25rem;
|
font-size: 1.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.modal-body {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
#login-form {
|
#login-form {
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
}
|
}
|
||||||
@@ -1166,13 +1158,15 @@ body {
|
|||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
/* Ensure text remains standard color */
|
/* Ensure text remains standard color */
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Update Icon */
|
/* Update Icon */
|
||||||
.update-icon {
|
.update-icon {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
margin-left: 8px;
|
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);
|
color: var(--success-color);
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
width: 24px;
|
width: 24px;
|
||||||
@@ -1191,7 +1185,353 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@keyframes pulse {
|
@keyframes pulse {
|
||||||
0% { box-shadow: 0 0 0 0 rgba(16, 185, 129, 0.4); }
|
0% {
|
||||||
70% { box-shadow: 0 0 0 6px rgba(16, 185, 129, 0); }
|
box-shadow: 0 0 0 0 rgba(16, 185, 129, 0.4);
|
||||||
100% { box-shadow: 0 0 0 0 rgba(16, 185, 129, 0); }
|
}
|
||||||
|
|
||||||
|
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
89
test_build.py
Executable 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
139
test_logic.js
Executable 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);
|
||||||
|
}
|
||||||
@@ -1 +1 @@
|
|||||||
v1.0.3
|
v1.3.2
|
||||||
|
|||||||
Reference in New Issue
Block a user