Compare commits
169 Commits
v1.0.3
...
1eb2034c61
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1eb2034c61 | ||
|
|
e1cad2ffd8 | ||
|
|
a28e8be326 | ||
|
|
b75d5f88a5 | ||
|
|
dd1ab415d2 | ||
|
|
cbbb2f4073 | ||
|
|
1e9cde0ce0 | ||
|
|
f192de5feb | ||
|
|
7759491395 | ||
|
|
a2b2ec227f | ||
|
|
7f413d58f1 | ||
|
|
c20a5fb879 | ||
|
|
adc018d4d3 | ||
|
|
2f08a951b4 | ||
|
|
86e2e51dc3 | ||
|
|
12fe759970 | ||
|
|
bb5dab64cd | ||
|
|
cbf03ea497 | ||
|
|
6b8ac5ca1d | ||
|
|
2964eba88b | ||
|
|
089e5375b4 | ||
|
|
e514f42dbe | ||
|
|
f7a9b061ea | ||
|
|
fe75347682 | ||
|
|
5c5255058c | ||
|
|
e16398ef74 | ||
|
|
6a57e2716f | ||
|
|
d383808f4f | ||
|
|
498b400033 | ||
|
|
884a17e7d3 | ||
|
|
ad660799ec | ||
|
|
a5fe50bbf0 | ||
|
|
a98faec51e | ||
|
|
212bf3b015 | ||
|
|
f29ecd4b79 | ||
|
|
1be6e44d7f | ||
|
|
49b0ab17ac | ||
|
|
55e738a554 | ||
|
|
45adfa9d5d | ||
|
|
f5f6dddba3 | ||
|
|
b06f6c3551 | ||
|
|
b66030dce5 | ||
|
|
8e7ec468d4 | ||
|
|
8ce3ae4c92 | ||
|
|
6a70a5a5e8 | ||
|
|
edec109552 | ||
|
|
a7aea2ece3 | ||
|
|
49dc1cc135 | ||
|
|
90f1c0ed04 | ||
|
|
42978c6e7e | ||
|
|
6ad3498bcc | ||
|
|
b44ecb2ccf | ||
|
|
9e161e2907 | ||
|
|
8b15760463 | ||
|
|
4aa67c9cbe | ||
|
|
12c55ef883 | ||
|
|
1e9dd9a3b5 | ||
|
|
db8b2c5629 | ||
|
|
67533875bd | ||
|
|
99809dafb7 | ||
|
|
4cf3e4adc2 | ||
|
|
4fe7950697 | ||
|
|
e162a16550 | ||
|
|
b7c3aac921 | ||
|
|
a902732d4b | ||
|
|
eaab21151a | ||
|
|
5af1f86700 | ||
|
|
90b503ddb7 | ||
|
|
10ffbd8c68 | ||
|
|
0294db7976 | ||
|
|
5a2c23524d | ||
|
|
d4a9d39d67 | ||
|
|
3c8d946a1e | ||
|
|
f71bcf1ac7 | ||
|
|
b041e9f318 | ||
|
|
984a897f73 | ||
|
|
ae54d97d96 | ||
|
|
6ed0831f5d | ||
|
|
ba75544f68 | ||
|
|
7fdf7f6f3e | ||
|
|
614f498d11 | ||
|
|
5f30696315 | ||
|
|
cb5aa28f94 | ||
|
|
bc1a91b7d7 | ||
|
|
7d5beedfbb | ||
|
|
0651d517b2 | ||
|
|
c5e236e095 | ||
|
|
a5bff19796 | ||
|
|
284f3d9a32 | ||
|
|
7ce82ce82e | ||
|
|
ce12684193 | ||
|
|
6cee38e99f | ||
|
|
88758427fd | ||
|
|
23ed867ac4 | ||
|
|
f8b1334a9a | ||
|
|
6b1bd46210 | ||
|
|
a429148324 | ||
|
|
5caaf7dcad | ||
|
|
122c1078cf | ||
|
|
ff48befb8a | ||
|
|
9391dfd8d7 | ||
|
|
467e48e1da | ||
|
|
566410eea5 | ||
|
|
37afc2957b | ||
|
|
b1763135aa | ||
|
|
98020f0b8f | ||
|
|
c2e3282131 | ||
|
|
7a82cb06db | ||
|
|
d89b080da5 | ||
|
|
d80863a169 | ||
|
|
ae79c58d30 | ||
|
|
a0ef6e631e | ||
|
|
d895a5fb7c | ||
|
|
fd765a74c0 | ||
|
|
1f184fab8b | ||
|
|
b6c7c66027 | ||
|
|
33bb87d7f4 | ||
|
|
d4a8a47ccd | ||
|
|
8e8c93410b | ||
|
|
cca59bcace | ||
|
|
432bbcb6f2 | ||
|
|
accdccf897 | ||
|
|
7fc8c6f1e0 | ||
|
|
0eb14a1869 | ||
|
|
c841954c5d | ||
|
|
320c4066f3 | ||
|
|
cda74e65db | ||
|
|
d1a19b043d | ||
|
|
8c4de96432 | ||
|
|
ce7d8a3de5 | ||
|
|
0309f488bd | ||
|
|
d82762430f | ||
|
|
54e5ada03d | ||
|
|
136fe7d355 | ||
|
|
f5b3635773 | ||
|
|
bff8669cd7 | ||
|
|
008462e304 | ||
|
|
9237e911d2 | ||
|
|
5bb0e01136 | ||
|
|
f19827ae91 | ||
|
|
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 |
@@ -26,16 +26,15 @@ trigger: always_on
|
|||||||
- **Interaction**: Be proactive, concise, and helpful. Focus on code value.
|
- **Interaction**: Be proactive, concise, and helpful. Focus on code value.
|
||||||
|
|
||||||
## 4. Development Standards
|
## 4. Development Standards
|
||||||
**Tech Stack:**
|
|
||||||
- **Container**: Docker-based application.
|
|
||||||
- **Config**: Configurable port.
|
|
||||||
|
|
||||||
**Coding Style:**
|
**Coding Style:**
|
||||||
- **Typing**: Strict typing where applicable.
|
- **Typing**: Strict typing where applicable.
|
||||||
- **Comments**: Concise, English.
|
- **Comments**: Concise, English.
|
||||||
- **Frontend/UX**:
|
- **Frontend/UX**:
|
||||||
- Priority on Usability.
|
- Priority on Usability.
|
||||||
- **MANDATORY**: Tooltips/Help texts for all interactions.
|
- **MANDATORY**: Tooltips/Help texts for all interactions.
|
||||||
|
- **Versioning**:
|
||||||
|
- version.txt has to be increased for any implemented features or fixed bugs)
|
||||||
|
- a change summary has to be documented in changelog.md
|
||||||
|
|
||||||
## 5. Agentic Workflow & Artifacts
|
## 5. Agentic Workflow & Artifacts
|
||||||
**Core Philosophy**: Plan first, act second.
|
**Core Philosophy**: Plan first, act second.
|
||||||
@@ -44,7 +43,20 @@ trigger: always_on
|
|||||||
- **Visuals**: Generate screenshots/mockups for UI changes.
|
- **Visuals**: Generate screenshots/mockups for UI changes.
|
||||||
- **Evidence**: Log outputs for verification.
|
- **Evidence**: Log outputs for verification.
|
||||||
3. **Design**: Optimize code for AI readability (context efficiency).
|
3. **Design**: Optimize code for AI readability (context efficiency).
|
||||||
|
4. **Retry on Failure**: When an operation does not finish or does not work as expected, do not try endlessly to fix this. Try a few times and ask the user if no progress can be made.
|
||||||
|
|
||||||
## 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. Mandatory Testing Policy 🧪
|
||||||
|
**CRITICAL: No logic or UI fix is complete without a corresponding automated test.**
|
||||||
|
- If you fix a regression or implement a new UI feature, you **MUST** write or update a test in `tests/test_dom.js` or `test_logic.js`.
|
||||||
|
- Refactoring MUST include verifying that no click listeners drop out. This guarantees that features like modal toggles stay functional.
|
||||||
|
|
||||||
|
## 7. Requirements-Konsistenz 📋
|
||||||
|
Alle umgesetzten Anforderungen müssen mit `REQUIREMENTS.md` übereinstimmen.
|
||||||
|
1. **Vor der Umsetzung prüfen**: Passt die neue Anforderung zu den bestehenden Requirements?
|
||||||
|
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.
|
||||||
64
README.md
64
README.md
@@ -1,15 +1,22 @@
|
|||||||
# 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).
|
* **Bestellhistorie:** 📜 Gruppiert nach Monat & KW mit inkrementellem Delta-Cache.
|
||||||
* **API Fallback:** Prüft die Verbindung und bietet bei Fehlern einen Direktlink zur Originalseite.
|
* **Session Reuse:** 🔑 Nutzt automatisch eine bestehende Login-Session.
|
||||||
* **Menu Badges:** Zeigt Menü-Codes (M1, M2+) direkt im Header.
|
* **Menu Badges:** 🏷️ Zeigt Menü-Codes (M1, M2+) direkt im Header.
|
||||||
|
* **Menü-Flagging:** 🔔 Ausverkaufte Menüs beobachten und bei Verfügbarkeit benachrichtigt werden.
|
||||||
|
* **Version-Menü:** 📦 Versionsliste mit Installer-Links, Dev-Mode Toggle und Downgrade-Support.
|
||||||
|
* **Cache leeren:** 🗑️ Lokalen Cache mit einem Klick bereinigen (im Version-Menü).
|
||||||
|
* **Favicon:** 🍽️ Eigenes Icon für die Lesezeichenleiste.
|
||||||
|
* **Changelog:** Übersicht über neue Funktionen direkt im Installer.
|
||||||
|
|
||||||
## 📦 Installation
|
## 📦 Installation
|
||||||
|
|
||||||
@@ -17,7 +24,7 @@ Ein intelligentes Bookmarklet für die Mitarbeiter-Kantine der Bessa App. Dieses
|
|||||||
2. Ziehe den blauen Button **"Kantine Wrapper"** in deine Lesezeichen-Leiste.
|
2. Ziehe den blauen Button **"Kantine Wrapper"** in deine Lesezeichen-Leiste.
|
||||||
3. Fertig!
|
3. Fertig!
|
||||||
|
|
||||||
## usage
|
## 🍽️ Nutzung
|
||||||
|
|
||||||
1. Navigiere zu [https://web.bessa.app/knapp-kantine](https://web.bessa.app/knapp-kantine).
|
1. Navigiere zu [https://web.bessa.app/knapp-kantine](https://web.bessa.app/knapp-kantine).
|
||||||
2. Klicke auf das **"Kantine Wrapper"** Lesezeichen.
|
2. Klicke auf das **"Kantine Wrapper"** Lesezeichen.
|
||||||
@@ -26,14 +33,39 @@ Ein intelligentes Bookmarklet für die Mitarbeiter-Kantine der Bessa App. Dieses
|
|||||||
## 🛠️ Entwicklung
|
## 🛠️ Entwicklung
|
||||||
|
|
||||||
### Voraussetzungen
|
### Voraussetzungen
|
||||||
* Node.js (optional, nur für Build-Scripts)
|
* Node.js (für Build- und Test-Scripts)
|
||||||
|
* Python 3 (für Build-Tests)
|
||||||
* Bash (für `build-bookmarklet.sh`)
|
* Bash (für `build-bookmarklet.sh`)
|
||||||
|
|
||||||
### Projektstruktur
|
### Projektstruktur
|
||||||
* `kantine.js`: Der Haupt-Quellcode des Bookmarklets.
|
|
||||||
* `public/style.css`: Das Design (CSS).
|
#### Quelldateien
|
||||||
* `build-bookmarklet.sh`: Skript zum Erstellen der `dist/` Dateien.
|
| Datei | Beschreibung |
|
||||||
* `dist/`: Enthält die kompilierten Dateien (`bookmarklet.txt`, `install.html`).
|
|-------|-------------|
|
||||||
|
| `kantine.js` | Haupt-Quellcode des Bookmarklets (UI, API-Logik, Rendering). |
|
||||||
|
| `style.css` | Komplettes Design (CSS mit Light/Dark Mode). |
|
||||||
|
| `favicon.svg` | Favicon für die Installer-Seite (Dreieck + Gabel & Messer). |
|
||||||
|
| `mock-data.js` | Mock-Fetch-Interceptor mit realistischen Dummy-Menüdaten für Standalone-Tests. |
|
||||||
|
| `build-bookmarklet.sh` | Build-Skript – erzeugt alle `dist/`-Artefakte und führt alle Tests aus. |
|
||||||
|
| `release.sh` | Release-Skript – Commit, Tag, Push zu allen Remotes. |
|
||||||
|
| `version.txt` | Aktuelle Versionsnummer (SemVer). |
|
||||||
|
| `changelog.md` | Änderungshistorie aller Versionen. |
|
||||||
|
| `REQUIREMENTS.md` | System Requirements Specification (SRS). |
|
||||||
|
|
||||||
|
#### Tests
|
||||||
|
| Datei | Beschreibung |
|
||||||
|
|-------|-------------|
|
||||||
|
| `test_logic.js` | Logik-Unit-Tests (statische Analyse, Syntax-Check, Sandbox-Ausführung). |
|
||||||
|
| `tests/test_dom.js` | DOM-Interaktionstests via JSDOM (prüft Event-Listener-Bindung aller UI-Komponenten). |
|
||||||
|
| `test_build.py` | Build-Artefakt-Validierung (Existenz, Inhalt). |
|
||||||
|
|
||||||
|
#### `dist/` – Build-Artefakte
|
||||||
|
| 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, Favicon, Anleitung, Feature-Liste und Changelog. Kann lokal oder gehostet geöffnet werden. |
|
||||||
|
| `kantine-standalone.html` | Eigenständige HTML-Datei mit eingebettetem CSS + JS + **Mock-Daten**. Lädt automatisch Dummy-Menüs für UI-Tests ohne API-Zugriff. |
|
||||||
|
|
||||||
### 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:
|
||||||
@@ -42,5 +74,15 @@ Um Änderungen an `kantine.js` oder `style.css` wirksam zu machen, führe den Bu
|
|||||||
./build-bookmarklet.sh
|
./build-bookmarklet.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Release
|
||||||
|
Erstellt einen Git-Tag, committet Build-Artefakte und pusht zu allen Remotes:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./release.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
## ⚠️ Hinweis
|
||||||
|
Dieses Projekt enthält zum überwiegenden Teil **KI-generierten Code**. Der Code wurde mithilfe von KI-Assistenten erstellt, überprüft und iterativ verfeinert.
|
||||||
|
|
||||||
## 📝 Lizenz
|
## 📝 Lizenz
|
||||||
Internes Tool.
|
Internes Tool.
|
||||||
|
|||||||
127
REQUIREMENTS.md
127
REQUIREMENTS.md
@@ -2,55 +2,100 @@
|
|||||||
|
|
||||||
## 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 & Bestellhistorie** | | | |
|
||||||
|
| FR-040 | Das System muss die Gesamtkosten aller Bestellungen einer Woche automatisch berechnen und anzeigen. | Mittel | v1.1.0 |
|
||||||
|
| FR-041 | Das System muss dem Benutzer eine Bestellhistorie (gruppiert nach Monat und KW) mit Fortschrittsanzeige auf Abruf in einem Modal bereitstellen. Die Historie muss über ein lokales Delta-Caching verfügen, um Ladezeiten zu minimieren. | Mittel | v1.4.0 (Update v1.4.7) |
|
||||||
|
| **Bestell-Countdown** | | | |
|
||||||
|
| FR-050 | Das System muss vor Bestellschluss einen visuell hervorgehobenen Countdown anzeigen. | Mittel | v1.1.0 |
|
||||||
|
| **Menü-Flagging & Benachrichtigungen** | | | |
|
||||||
|
| 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 |
|
||||||
|
| FR-080 | Das System muss einen hellen und einen dunklen Darstellungsmodus (Light/Dark Theme) unterstützen. | Niedrig | v1.0.1 |
|
||||||
|
| FR-081 | Die Theme-Präferenz des Benutzers muss persistent gespeichert werden. | Niedrig | v1.0.1 |
|
||||||
|
| FR-082 | Das System muss beim erstmaligen Laden die Betriebssystem-Präferenz für das Farbschema berücksichtigen. | Niedrig | v1.0.1 |
|
||||||
|
| **Header UI & Navigation** | | | |
|
||||||
|
| FR-090 | Die Hauptnavigation (Wochen-Toggles) muss linksbündig neben dem App-Titel positioniert sein. | Niedrig | v1.5.0 |
|
||||||
|
| FR-091 | Ein dynamisches Alarm-Icon im Header muss den Überwachungsstatus geflaggter Menüs anzeigen (Gelb=Überwachung aktiv aber kein Menü verfügbar, Grün=Mindestens ein Menü verfügbar, Versteckt=keine Flags). Der Tooltip muss den Zeitpunkt der letzten Prüfung als relativen String (z.B. "vor 4 Min.") enthalten. | Mittel | v1.6.11 (Update v1.5.0) |
|
||||||
|
| FR-092 | Solange Menüdaten für die Nächste Woche verfügbar sind, aber noch keine Bestellungen getätigt wurden, muss der entsprechende Navigation-Button animiert und farblich (Gelb) hervorgehoben werden. Nach der ersten Bestellung muss die Hervorhebung automatisch erlöschen. Zusätzlich muss beim erstmaligen Erscheinen der Daten ein einmaliger Toast-Hinweis angezeigt werden. | Mittel | v1.6.0 (Update v1.4.21) |
|
||||||
|
| **Sprachfilter** | | | |
|
||||||
|
| FR-120 | Das System muss zweisprachige Menübeschreibungen (Deutsch/Englisch) erkennen und dem Benutzer erlauben, via UI-Toggle zwischen DE, EN und ALL (beide Sprachen) zu wechseln. Die Sprachpräferenz muss persistent gespeichert werden. Allergen-Codes müssen in allen Modi angezeigt werden. | Mittel | v1.6.0 |
|
||||||
|
| FR-121 | Das System muss bei fehlenden Übersetzungen in zweisprachigen Menüs robust reagieren. Wenn ein Gang nur in einer Sprache vorliegt, muss dieser Teil für beide Sprachansichten herangezogen werden, um die Konsistenz der Ganganzahl zu gewährleisten. | Mittel | v1.6.10 |
|
||||||
|
| **Benutzer-Feedback** | | | |
|
||||||
|
| FR-095 | Alle benutzerrelevanten Aktionen (Bestellung, Stornierung, Fehler) müssen durch nicht-blockierende Benachrichtigungen (Toasts) bestätigt werden. | Mittel | v1.0.1 |
|
||||||
|
| FR-096 | Bei einem Verbindungsfehler muss ein Fehlerdialog mit Fallback-Link zur Originalseite angezeigt werden. | Mittel | v1.0.1 |
|
||||||
|
| **Nächste-Woche-Badge** | | | |
|
||||||
|
| 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 |
|
||||||
|
| FR-115 | Das Versionsmenü muss Links zur Erstellung von Feature-Requests und Bug-Reports auf GitHub enthalten. | Niedrig | v1.4.4 |
|
||||||
|
| FR-116 | Das Versionsmenü muss eine Funktion zum Leeren des lokalen Caches bereitstellen, um bei hartnäckigen Fehlern alle gespeicherten Daten bereinigen zu können. | Niedrig | v1.4.16 |
|
||||||
|
| FR-117 | Die Installer-Seite muss ein eingebettetes Favicon bereitstellen, das beim Drag & Drop in die Lesezeichenleiste als Icon für das Bookmarklet übernommen wird. | Niedrig | v1.4.19 |
|
||||||
|
|
||||||
## 3. Nicht-funktionale Anforderungen
|
## 3. Nicht-funktionale Anforderungen
|
||||||
|
|
||||||
| 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 + DOM-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 (`python3`) + Node.js-basierte Logik-Tests + Node.js-basierte DOM-Interaktionstests (JSDOM).
|
||||||
|
|||||||
BIN
__pycache__/test_build.cpython-312-pytest-9.0.2.pyc
Executable file
BIN
__pycache__/test_build.cpython-312-pytest-9.0.2.pyc
Executable file
Binary file not shown.
1
bessa_orders_debug.json
Executable file
1
bessa_orders_debug.json
Executable file
@@ -0,0 +1 @@
|
|||||||
|
{"next":null,"previous":null,"results":[]}
|
||||||
@@ -6,7 +6,8 @@ set -e
|
|||||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
DIST_DIR="$SCRIPT_DIR/dist"
|
DIST_DIR="$SCRIPT_DIR/dist"
|
||||||
CSS_FILE="$SCRIPT_DIR/style.css"
|
CSS_FILE="$SCRIPT_DIR/style.css"
|
||||||
JS_FILE="$SCRIPT_DIR/kantine.js"
|
JS_FILE="$SCRIPT_DIR/dist/kantine.bundle.js"
|
||||||
|
FAVICON_FILE="$SCRIPT_DIR/favicon.png"
|
||||||
|
|
||||||
# === VERSION ===
|
# === VERSION ===
|
||||||
if [ -f "$SCRIPT_DIR/version.txt" ]; then
|
if [ -f "$SCRIPT_DIR/version.txt" ]; then
|
||||||
@@ -24,9 +25,33 @@ echo "=== Kantine Bookmarklet Builder ($VERSION) ==="
|
|||||||
if [ ! -f "$CSS_FILE" ]; then echo "ERROR: $CSS_FILE not found"; exit 1; fi
|
if [ ! -f "$CSS_FILE" ]; then echo "ERROR: $CSS_FILE not found"; exit 1; fi
|
||||||
if [ ! -f "$JS_FILE" ]; then echo "ERROR: $JS_FILE not found"; exit 1; fi
|
if [ ! -f "$JS_FILE" ]; then echo "ERROR: $JS_FILE not found"; exit 1; fi
|
||||||
|
|
||||||
|
# Generate favicon.png from favicon_base.png if base exists
|
||||||
|
FAVICON_BASE="$SCRIPT_DIR/favicon_base.png"
|
||||||
|
if [ -f "$FAVICON_BASE" ]; then
|
||||||
|
echo "Generating 40x40 favicon.png from favicon_base.png..."
|
||||||
|
python3 -c "
|
||||||
|
import sys
|
||||||
|
from PIL import Image
|
||||||
|
try:
|
||||||
|
img = Image.open('$FAVICON_BASE')
|
||||||
|
img_resized = img.resize((40, 40), Image.Resampling.LANCZOS)
|
||||||
|
img_resized.save('$FAVICON_FILE')
|
||||||
|
except Exception as e:
|
||||||
|
print('Favicon generation error:', e)
|
||||||
|
sys.exit(1)
|
||||||
|
"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -f "$FAVICON_FILE" ]; then echo "ERROR: $FAVICON_FILE not found"; exit 1; fi
|
||||||
|
|
||||||
|
# Generate favicon Base64 data URI from PNG
|
||||||
|
FAVICON_B64=$(base64 -w0 "$FAVICON_FILE")
|
||||||
|
FAVICON_URL="data:image/png;base64,${FAVICON_B64}"
|
||||||
|
|
||||||
CSS_CONTENT=$(cat "$CSS_FILE")
|
CSS_CONTENT=$(cat "$CSS_FILE")
|
||||||
# Inject version into JS
|
|
||||||
JS_CONTENT=$(cat "$JS_FILE" | sed "s/{{VERSION}}/$VERSION/g")
|
# Inject version and favicon into JS
|
||||||
|
JS_CONTENT=$(cat "$JS_FILE" | sed "s|{{VERSION}}|$VERSION|g" | sed "s|{{FAVICON_DATA_URI}}|$FAVICON_URL|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 +78,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"
|
||||||
|
|
||||||
@@ -70,15 +99,20 @@ echo "✅ Standalone HTML: $DIST_DIR/kantine-standalone.html"
|
|||||||
# Escape CSS for embedding in JS string
|
# Escape CSS for embedding in JS string
|
||||||
CSS_ESCAPED=$(echo "$CSS_CONTENT" | sed "s/'/\\\\'/g" | tr '\n' ' ' | sed 's/ */ /g')
|
CSS_ESCAPED=$(echo "$CSS_CONTENT" | sed "s/'/\\\\'/g" | tr '\n' ' ' | sed 's/ */ /g')
|
||||||
|
|
||||||
# Build bookmarklet payload
|
# Create a minified version for the injected bookmarklet payloads
|
||||||
|
echo "Minifying JS with Terser..."
|
||||||
|
TEMP_JS=$(mktemp)
|
||||||
|
echo "$JS_CONTENT" > "$TEMP_JS"
|
||||||
|
JS_MINIFIED=$(npx -y terser "$TEMP_JS" --compress --mangle)
|
||||||
|
rm -f "$TEMP_JS"
|
||||||
|
|
||||||
cat > "$DIST_DIR/bookmarklet-payload.js" << PAYLOADEOF
|
cat > "$DIST_DIR/bookmarklet-payload.js" << PAYLOADEOF
|
||||||
(function(){
|
javascript:(function(){
|
||||||
if(window.__KANTINE_LOADED){alert('Kantine Wrapper already loaded!');return;}
|
if(window.__KANTINE_LOADED){alert('Kantine Wrapper already loaded!');return;}
|
||||||
var s=document.createElement('style');
|
var s=document.createElement('style');s.textContent='${CSS_ESCAPED}';document.head.appendChild(s);
|
||||||
s.textContent='${CSS_ESCAPED}';
|
// Inject JS logic
|
||||||
document.head.appendChild(s);
|
|
||||||
var sc=document.createElement('script');
|
var sc=document.createElement('script');
|
||||||
sc.textContent=$(echo "$JS_CONTENT" | python3 -c "import sys,json; print(json.dumps(sys.stdin.read()))" 2>/dev/null || echo "$JS_CONTENT" | sed 's/\\/\\\\/g' | sed "s/'/\\\\'/g" | sed 's/"/\\\\"/g' | tr '\n' ' ' | sed 's/^/"/' | sed 's/$/"/');
|
sc.textContent=$(echo "$JS_MINIFIED" | python3 -c "import sys,json; print(json.dumps(sys.stdin.read()))" 2>/dev/null || echo "$JS_MINIFIED" | sed 's/\\/\\\\/g' | sed "s/'/\\\\'/g" | sed 's/"/\\\\"/g' | tr '\n' ' ' | sed 's/^/"/' | sed 's/$/"/');
|
||||||
document.head.appendChild(sc);
|
document.head.appendChild(sc);
|
||||||
})();
|
})();
|
||||||
PAYLOADEOF
|
PAYLOADEOF
|
||||||
@@ -96,6 +130,7 @@ cat > "$DIST_DIR/install.html" << INSTALLEOF
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<title>Kantine Wrapper Installer ($VERSION)</title>
|
<title>Kantine Wrapper Installer ($VERSION)</title>
|
||||||
|
<link rel="icon" type="image/png" href="$FAVICON_URL">
|
||||||
<style>
|
<style>
|
||||||
body { font-family: 'Inter', sans-serif; max-width: 600px; margin: 40px auto; padding: 20px; background: #1a1a2e; color: #eee; }
|
body { font-family: 'Inter', sans-serif; max-width: 600px; margin: 40px auto; padding: 20px; background: #1a1a2e; color: #eee; }
|
||||||
h1 { color: #029AA8; } /* Knapp Teal */
|
h1 { color: #029AA8; } /* Knapp Teal */
|
||||||
@@ -104,25 +139,66 @@ 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>
|
<!-- Banner Video: plays once, collapses after ending -->
|
||||||
<div class="instructions">
|
<div id="banner-video-wrap" style="width: 100%; max-width: 600px; margin: 0 auto 20px auto; border-radius: 12px; overflow: hidden; pointer-events: none; user-select: none; max-height: 400px; opacity: 1; transition: max-height 0.8s ease-in-out, opacity 0.6s ease-in-out, margin 0.8s ease-in-out;">
|
||||||
<h2>Installation</h2>
|
<video id="banner-video" autoplay muted playsinline disablepictureinpicture style="width: 100%; display: block;" src="https://github.com/TauNeutrino/kantine-overview/raw/main/dist/Arrow_and_fork_fly_away_bd43310bea.mp4"></video>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
document.getElementById('banner-video').addEventListener('ended', function() {
|
||||||
|
var w = document.getElementById('banner-video-wrap');
|
||||||
|
w.style.maxHeight = '0';
|
||||||
|
w.style.opacity = '0';
|
||||||
|
w.style.marginBottom = '0';
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div style="text-align: center; margin-bottom: 30px;">
|
||||||
|
<h1 style="margin-bottom: 5px; display: flex; align-items: center; justify-content: center; gap: 10px;">
|
||||||
|
<img src="$FAVICON_URL" alt="Logo" style="width: 40px; height: 40px;">
|
||||||
|
Kantine Wrapper
|
||||||
|
<span style="font-size:0.5em; opacity:0.6; font-weight:400; margin-left:5px;">$VERSION</span>
|
||||||
|
</h1>
|
||||||
|
<p style="font-size: 1.2rem; color: #a0aec0; margin-top: 0; font-style: italic;">"Mahlzeit! Jetzt bessa einfach."</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 1. BUTTON (Top Priority) -->
|
||||||
|
<div class="card" style="text-align: center; border: 2px solid #029AA8;">
|
||||||
|
<p style="margin-bottom:15px; font-weight:bold;">👇 Diesen Button in die Lesezeichen-Leiste ziehen:</p>
|
||||||
|
<p><a class="bookmarklet" id="bookmarklet-link" href="#" onclick="event.preventDefault(); return false;" title="Nicht klicken! Ziehe mich in deine Lesezeichen-Leiste.">⏳ Wird generiert...</a></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 2. INSTRUCTIONS -->
|
||||||
|
<div class="card">
|
||||||
|
<h2>So funktioniert's</h2>
|
||||||
<ol>
|
<ol>
|
||||||
<li>Ziehe den Button unten in deine <strong>Lesezeichen-Leiste</strong> (Drag & Drop)</li>
|
<li>Ziehe den Button oben in deine <strong>Lesezeichen-Leiste</strong> (Drag & Drop)</li>
|
||||||
<li>Navigiere zu <a href="https://web.bessa.app/knapp-kantine" style="color:#029AA8">web.bessa.app/knapp-kantine</a></li>
|
<li>Navigiere zu <a href="https://web.bessa.app/knapp-kantine" style="color:#029AA8">web.bessa.app/knapp-kantine</a></li>
|
||||||
<li>Klicke auf das Lesezeichen <code>Kantine $VERSION</code></li>
|
<li>Klicke auf das Lesezeichen <code>Kantine $VERSION</code></li>
|
||||||
</ol>
|
</ol>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 3. FEATURES -->
|
||||||
|
<div class="card">
|
||||||
<h2>✨ Features</h2>
|
<h2>✨ Features</h2>
|
||||||
<ul>
|
<ul>
|
||||||
<li>📅 <strong>Wochenübersicht:</strong> Die ganze Woche auf einen Blick.</li>
|
<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>Kostenkontrolle:</strong> Automatische Berechnung der Wochensumme.</li>
|
||||||
<li>🔑 <strong>Auto-Login:</strong> Nutzt deine bestehende Session.</li>
|
<li>🔑 <strong>Auto-Login:</strong> Nutzt deine bestehende Session.</li>
|
||||||
<li>🏷️ <strong>Badges & Status:</strong> Menü-Codes (M1, M2) und Bestellstatus direkt sichtbar.</li>
|
<li>🏷️ <strong>Badges & Status:</strong> Menü-Codes (M1, M2) und Bestellstatus direkt sichtbar.</li>
|
||||||
<li>🛡️ <strong>Offline-Support:</strong> Speichert Menüdaten lokal.</li>
|
|
||||||
</ul>
|
</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;">
|
<div style="margin-top: 30px; padding: 15px; background: rgba(233, 69, 96, 0.1); border: 1px solid rgba(233, 69, 96, 0.3); border-radius: 8px; font-size: 0.85em; color: #ddd;">
|
||||||
@@ -130,22 +206,92 @@ cat > "$DIST_DIR/install.html" << INSTALLEOF
|
|||||||
Die Verwendung dieses Bookmarklets erfolgt auf eigene Verantwortung. Der Entwickler übernimmt keine Haftung für Schäden, Datenverlust oder ungewollte Bestellungen, die durch die Nutzung dieser Software entstehen.
|
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>
|
</div>
|
||||||
<p>👇 Diesen Button in die Lesezeichen-Leiste ziehen:</p>
|
|
||||||
<p><a class="bookmarklet" id="bookmarklet-link" href="#">⏳ Wird generiert...</a></p>
|
<!-- 4. CHANGELOG (Bottom) -->
|
||||||
|
<div class="card">
|
||||||
|
<details class="styled-details">
|
||||||
|
<summary class="styled-summary">Changelog & Version History</summary>
|
||||||
|
<div class="changelog-container">
|
||||||
|
<!-- CHANGELOG_PLACEHOLDER -->
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="text-align: center; margin-top: 40px; color: #5c6b7f; font-size: 0.8rem;">
|
||||||
|
<p>Powered by <strong>Kaufis-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_MINIFIED" | python3 -c "
|
||||||
import sys, json
|
import sys, json, urllib.parse
|
||||||
|
|
||||||
|
# 1. Read JS and Replace VERSION + Favicon
|
||||||
js = sys.stdin.read()
|
js = sys.stdin.read()
|
||||||
css = open('$CSS_FILE').read().replace('\\n', ' ').replace(' ', ' ')
|
|
||||||
bmk = '''javascript:(function(){if(window.__KANTINE_LOADED){alert(\"Already loaded\");return;}var s=document.createElement(\"style\");s.textContent=''' + json.dumps(css) + ''';document.head.appendChild(s);var sc=document.createElement(\"script\");sc.textContent=''' + json.dumps(js) + ''';document.head.appendChild(sc);})();'''
|
# 2. Prepare CSS for injection via createElement('style')
|
||||||
print(json.dumps(bmk) + ';')
|
css = open('$CSS_FILE').read().replace('\n', ' ').replace(' ', ' ')
|
||||||
" 2>/dev/null >> "$DIST_DIR/install.html" || echo "'javascript:alert(\"Build error\")'" >> "$DIST_DIR/install.html"
|
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
|
||||||
|
// Dynamic favicon injection — setTimeout ensures it runs AFTER
|
||||||
|
// htmlpreview.github.io's document.write() processing completes
|
||||||
|
setTimeout(function() {
|
||||||
|
document.querySelectorAll('link[rel*="icon"]').forEach(function(el) { el.remove(); });
|
||||||
|
var fi = document.createElement('link');
|
||||||
|
fi.rel = 'icon';
|
||||||
|
fi.type = 'image/png';
|
||||||
|
fi.href = '$FAVICON_URL';
|
||||||
|
document.head.appendChild(fi);
|
||||||
|
}, 0);
|
||||||
document.getElementById('bookmarklet-link').textContent = 'Kantine $VERSION';
|
document.getElementById('bookmarklet-link').textContent = 'Kantine $VERSION';
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
@@ -157,3 +303,36 @@ 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 ==="
|
||||||
|
timeout 15s node "$SCRIPT_DIR/test_logic.js"
|
||||||
|
LOGIC_EXIT=$?
|
||||||
|
if [ $LOGIC_EXIT -ne 0 ]; then
|
||||||
|
echo "❌ Logic tests FAILED or TIMED OUT (Exit: $LOGIC_EXIT)! See above for details."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "=== Running DOM Interaction Tests ==="
|
||||||
|
timeout 15s node "$SCRIPT_DIR/tests/test_dom.js"
|
||||||
|
DOM_EXIT=$?
|
||||||
|
if [ $DOM_EXIT -ne 0 ]; then
|
||||||
|
echo "❌ DOM UI tests FAILED or TIMED OUT (Exit: $DOM_EXIT)! Regressions detected."
|
||||||
|
# Ensure playwright processes are killed if they leak
|
||||||
|
pkill -f playwright || true
|
||||||
|
pkill -f "node.*test_dom" || true
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
|
||||||
|
echo "=== Running Build Tests ==="
|
||||||
|
timeout 15s python3 "$SCRIPT_DIR/test_build.py"
|
||||||
|
TEST_EXIT=$?
|
||||||
|
if [ $TEST_EXIT -ne 0 ]; then
|
||||||
|
echo "❌ Build tests FAILED or TIMED OUT (Exit: $TEST_EXIT)! See above for details."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "✅ All build tests passed."
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
141
changelog.md
Executable file
141
changelog.md
Executable file
@@ -0,0 +1,141 @@
|
|||||||
|
## v1.6.11 (2026-03-09)
|
||||||
|
- 🔄 **Refactor**: Trennung der Zeitstempel für die Hauptaktualisierung (Header) und die Benachrichtigungsprüfung (Bell-Icon). Das Polling aktualisiert nun nicht mehr fälschlicherweise die "Aktualisiert am"-Zeit im Header.
|
||||||
|
- 🏷️ **Metadata**: Version auf v1.6.11 angehoben.
|
||||||
|
|
||||||
|
## v1.6.10 (2026-03-09)
|
||||||
|
- **Feature**: Robuste Kurs-Erkennung in zweisprachigen Menüs ([FR-121](REQUIREMENTS.md#FR-121)).
|
||||||
|
- **Fix**: Verhindert das Verschieben von Gängen bei fehlenden englischen Übersetzungen.
|
||||||
|
- **Improved**: Heuristik-Split erkennt nun zuverlässiger den Übergang von Englisch zurück zu Deutsch (z.B. bei "Achtung"-Hinweisen)
|
||||||
|
|
||||||
|
## v1.6.9 (2026-03-09)
|
||||||
|
- 🐛 **Bugfix**: Fehlerhafte Zeitangabe beim Bell-Icon ("vor 291h") behoben. Der Tooltip wird nun minütlich aktualisiert und nach jeder Menü-Prüfung korrekt neu gesetzt.
|
||||||
|
- 🔄 **Refactor**: Zeitstempel-Management für die letzte Aktualisierung vereinheitlicht und im `localStorage` persistiert.
|
||||||
|
|
||||||
|
## v1.6.8 (2026-03-06)
|
||||||
|
- ⚡ **Performance**: Das JavaScript für das Kantinen-Bookmarklet wird nun beim Build-Prozess (via Terser) minimiert, was die Länge der injizierten URL spürbar reduziert.
|
||||||
|
|
||||||
|
## v1.6.7 (2026-03-06)
|
||||||
|
- 🎨 **Style**: Das neue Header-Logo (`favicon_base.png`) wird nun konsequent auf 40x40px generiert und gerendert.
|
||||||
|
|
||||||
|
## v1.6.6 (2026-03-06)
|
||||||
|
- 🎨 **Style**: Den Schatten und den hervorstehenden Karten-Effekt für bestellte Menüs an vergangenen Tagen komplett entfernt - verbleiben nun visuell flach und unaufdringlich wie nicht-bestellte Menüs.
|
||||||
|
|
||||||
|
## v1.6.5 (2026-03-06)
|
||||||
|
- ✨ **Feature**: Das `restaurant_menu` Icon im Header wurde durch das neue `favicon_base.png` Logo ersetzt, passend zur Textgröße skaliert.
|
||||||
|
- 🎨 **Style**: Violette Umrahmung (Bestellt-Markierung) an vergangenen Tagen entfernt, um den Fokus auf aktuelle und zukünftige Bestellungen zu lenken.
|
||||||
|
- 🎨 **Style**: Der Glow-Effekt für am heutigen Tag bestellte Menüs wurde intensiviert.
|
||||||
|
|
||||||
|
## v1.6.4 (2026-03-05)
|
||||||
|
- ✨ **Feature**: Sprach-Lexikon (DE/EN) massiv erweitert um österreichische Begriffe (Nockerl, Fleckerl, Topfen, Mohn, Most etc.) und gängige Tippfehler aus dem Bessa-System (trukey, coffe, oveb etc.).
|
||||||
|
- 🧹 **Cleanup**: Sprach-Lexikon dedupliziert und alphabetisch sortiert für bessere Performance und Wartbarkeit.
|
||||||
|
- 🐛 **Bugfix**: Trennung von zweisprachigen Menüs (`splitLanguage`) verbessert: Erfasst nun auch Schrägstriche ohne Leerzeichen (z.B. `Suppe/Soup`).
|
||||||
|
- 🐛 **Bugfix**: Fehlerhafte Badge-Anzeige korrigiert (Variable `count` vs `orderCount`).
|
||||||
|
|
||||||
|
## v1.6.3 (2026-03-05)
|
||||||
|
- ✨ **Chore**: Slogan im Footer aktualisiert ("Jetzt Bessa Einfach! • Knapp-Kantine Wrapper • 2026 by Kaufis-Kitchen") und Footer-Höhe für mehr Platzierung optimiert.
|
||||||
|
|
||||||
|
## v1.6.2 (2026-03-05)
|
||||||
|
- ✨ **Feature**: Wochentags-Header (Montag, Dienstag etc.) scrollen nun als "Sticky Header" mit und bleiben am oberen Bildschirmrand haften.
|
||||||
|
- Das Layout clippt scrollende Speisen ordentlich darunter weg.
|
||||||
|
- Vollständiges Viewport-Scrolling: Das Layout nutzt nun die ganze Höhe aus (`100dvh`), wodurch Scrollbalken sauber am Rand positioniert sind.
|
||||||
|
- 🐛 **Bugfix**: Probleme mit Bessa's default `overflow` Verhalten behoben, das `position: sticky` auf iOS/WebKit-Browsern blockierte.
|
||||||
|
|
||||||
|
## v1.6.0 (2026-03-04)
|
||||||
|
- ✨ **Feature**: Sprachfilter für zweisprachige Menübeschreibungen. Neuer DE/EN/ALL Toggle im Header ermöglicht das Umschalten zwischen Deutsch, Englisch und dem vollen Originaltext. Allergen-Codes werden in allen Modi angezeigt. Einstellung wird persistent gespeichert.
|
||||||
|
|
||||||
|
## v1.5.1 (2026-03-04)
|
||||||
|
- 🐛 **Bugfix**: Freitagsbestellungen schlugen fehl ("Onlinebestellung sind nicht verfügbar"). Ursache: Der Order-Payload verwendete `preorder: false` und eine falsche Uhrzeit (`T10:00:00.000Z` statt `T10:30:00Z`). Beides wurde anhand der originalen Bessa-API korrigiert.
|
||||||
|
|
||||||
|
## v1.5.0 (2026-02-26)
|
||||||
|
Das große "Quality of Life"-Update! Zusammenfassung aller Features und Fixes seit v1.4.0:
|
||||||
|
|
||||||
|
- ✨ **Bestellhistorie**: Übersichtliche Historie direkt in der App – gruppiert nach Jahr/Monat, inklusive Summen, Stati (Offen/Abgeschlossen/Storniert) und Delta-Cache für rasantes Laden.
|
||||||
|
- ⚡ **Smart Cache & Performance**: Massive Reduzierung von API-Calls und Ladezeiten durch intelligenten lokalen Cache. Das Bookmarklet startet nun praktisch verzögerungsfrei.
|
||||||
|
- 🔄 **GitHub Release Management**: In-App Versions-Menü mit Auto-Update Check (`🆕` Icon). Umschalten zwischen "Stable" und "Dev" Versionen sowie Downgrade-Möglichkeit direkt über die GitHub API.
|
||||||
|
- 🌟 **Smart Highlights & UX**: Speisen-Favoriten leuchten nun im Design-Violett und erhalten Feature-Badges. Der Bestell-Badge für nächste Woche filtert nun intelligent personalisierte Highlights voraus.
|
||||||
|
- 🔔 **Bestell-Warnung & Notifications**: Der System-Alarm berücksichtigt nun Sessions korrekt, zeigt dynamische Farbwechsel (gelb/grün/rot) und warnt verlässlich vor dem Bestellschluss (10:00 Uhr). Altlasten von Vortagen werden automatisch geputzt.
|
||||||
|
- 🎨 **Eigenes Favicon**: Das Bookmarklet und der Installer haben nun ein eigenes Icon (Dreieck mit Besteck), das beim Hineinziehen in die Lesezeichenleiste übernommen wird (dynamisch generiert als lokales PNG).
|
||||||
|
- 🧹 **Lokaler Cache-Clear**: Ein in das Versions-Menü eingebauter "Papierkorb", der ausschließlich fehlerhafte Kantinen-Caches putzt, ohne dabei versehentlich die aktive Bessa-Host-Session zu zerstören.
|
||||||
|
- 🔒 **Sitzungs-Persistenz**: Die Login-Session überdauert jetzt neue Tabs, Fenster und Version-Upgrades reibungslos durch den Wechsel auf `localStorage`.
|
||||||
|
- 🛡️ **Testing & Stabilität**: Vollautomatische DOM- und Logik-Testing-Suites in der Release-Pipeline integriert. Fehlerhafte UI-Buttons gehören der Vergangenheit an.
|
||||||
|
|
||||||
|
|
||||||
|
## v1.4.0 (2026-02-22)
|
||||||
|
- **Feature**: Bestellhistorie per Knopfdruck abrufbar. Übersichtliche Darstellung, gruppiert nach Monaten und Kalenderwochen, inklusive Stornos. 📜✨
|
||||||
|
|
||||||
|
## 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).
|
||||||
10
cors_server.py
Executable file
10
cors_server.py
Executable file
@@ -0,0 +1,10 @@
|
|||||||
|
import http.server
|
||||||
|
import socketserver
|
||||||
|
|
||||||
|
class CORSRequestHandler(http.server.SimpleHTTPRequestHandler):
|
||||||
|
def end_headers(self):
|
||||||
|
self.send_header('Access-Control-Allow-Origin', '*')
|
||||||
|
super().end_headers()
|
||||||
|
|
||||||
|
with socketserver.TCPServer(("127.0.0.1", 8080), CORSRequestHandler) as httpd:
|
||||||
|
httpd.serve_forever()
|
||||||
15
debug_test.js
Executable file
15
debug_test.js
Executable file
@@ -0,0 +1,15 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
const jsCode = fs.readFileSync('kantine.js', 'utf8').replace('(function () {', '').replace(/}\)\(\);$/, '');
|
||||||
|
try {
|
||||||
|
const vm = require('vm');
|
||||||
|
new vm.Script(jsCode);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e.message);
|
||||||
|
const lines = jsCode.split('\n');
|
||||||
|
console.error("Around line", e.loc?.line);
|
||||||
|
if(e.loc?.line) {
|
||||||
|
console.log(lines[e.loc.line - 2]);
|
||||||
|
console.log(lines[e.loc.line - 1]);
|
||||||
|
console.log(lines[e.loc.line]);
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
dist/Arrow_and_fork_fly_away_bd43310bea.mp4
vendored
Executable file
BIN
dist/Arrow_and_fork_fly_away_bd43310bea.mp4
vendored
Executable file
Binary file not shown.
9
dist/bookmarklet-payload.js
vendored
9
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
266
dist/install.html
vendored
266
dist/install.html
vendored
File diff suppressed because one or more lines are too long
3448
dist/kantine-standalone.html
vendored
3448
dist/kantine-standalone.html
vendored
File diff suppressed because one or more lines are too long
2686
dist/kantine.bundle.js
vendored
Normal file
2686
dist/kantine.bundle.js
vendored
Normal file
File diff suppressed because it is too large
Load Diff
BIN
favicon.png
Executable file
BIN
favicon.png
Executable file
Binary file not shown.
|
After Width: | Height: | Size: 3.6 KiB |
BIN
favicon_base.png
Executable file
BIN
favicon_base.png
Executable file
Binary file not shown.
|
After Width: | Height: | Size: 2.0 MiB |
1421
kantine.js
1421
kantine.js
File diff suppressed because it is too large
Load Diff
207
mock-data.js
Executable file
207
mock-data.js
Executable file
@@ -0,0 +1,207 @@
|
|||||||
|
/**
|
||||||
|
* 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');
|
||||||
|
// Formatter for history mapping
|
||||||
|
const mappedOrders = mockOrders.map(o => ({
|
||||||
|
id: o.id,
|
||||||
|
date: `${o.date}T10:00:00Z`,
|
||||||
|
order_state: o.status === 'cancelled' ? 9 : 5,
|
||||||
|
total: o.price || '6.50',
|
||||||
|
items: [{
|
||||||
|
article: o.article,
|
||||||
|
name: o.article_name,
|
||||||
|
price: o.price || '6.50',
|
||||||
|
amount: 1
|
||||||
|
}]
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Handle lazy load / pagination if requesting full history
|
||||||
|
if (urlStr.includes('limit=50')) {
|
||||||
|
return Promise.resolve(new Response(JSON.stringify({
|
||||||
|
count: mappedOrders.length,
|
||||||
|
next: null,
|
||||||
|
results: mappedOrders
|
||||||
|
}), { status: 200, headers: { 'Content-Type': 'application/json' } }));
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.resolve(new Response(JSON.stringify({
|
||||||
|
results: mappedOrders
|
||||||
|
}), { 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');
|
||||||
|
})();
|
||||||
2142
package-lock.json
generated
Normal file
2142
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
6
package.json
Normal file
6
package.json
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"devDependencies": {
|
||||||
|
"jsdom": "^28.1.0",
|
||||||
|
"webpack-cli": "^6.0.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
59
release.sh
Executable file
59
release.sh
Executable file
@@ -0,0 +1,59 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# release.sh - Deploys a new version of the Kantine Wrapper
|
||||||
|
|
||||||
|
# Ensure we're in the script directory
|
||||||
|
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )"
|
||||||
|
cd "$SCRIPT_DIR"
|
||||||
|
|
||||||
|
# Ensure tests have run and artifacts exist
|
||||||
|
if [ ! -d "$SCRIPT_DIR/dist" ]; then
|
||||||
|
echo "❌ Error: dist folder missing. Please run build-bookmarklet.sh first"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Get current version
|
||||||
|
VERSION=$(cat "version.txt" | tr -d '\n\r ')
|
||||||
|
|
||||||
|
# Validate that version is set
|
||||||
|
if [ -z "$VERSION" ]; then
|
||||||
|
echo "❌ Error: Could not determine version from version.txt"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "=== Kantine Bookmarklet Releaser ($VERSION) ==="
|
||||||
|
|
||||||
|
# Check for uncommitted changes (excluding dist/)
|
||||||
|
if ! git diff-index --quiet HEAD -- ":(exclude)dist"; then
|
||||||
|
echo "⚠️ Warning: You have uncommitted changes in the working directory."
|
||||||
|
echo "Please commit your code changes before running the release script."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "=== Committing build artifacts ==="
|
||||||
|
git add "dist/"
|
||||||
|
git commit -m "chore: update build artifacts for $VERSION" --allow-empty
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Tagging $VERSION ==="
|
||||||
|
if git rev-parse "$VERSION" >/dev/null 2>&1; then
|
||||||
|
git tag -f "$VERSION"
|
||||||
|
echo "🔄 Tag $VERSION moved to current commit."
|
||||||
|
else
|
||||||
|
git tag "$VERSION"
|
||||||
|
echo "✅ Created tag: $VERSION"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Pushing to remotes ==="
|
||||||
|
# Determine remote targets: Assume 'origin' for primary, optionally 'github'
|
||||||
|
git push origin HEAD
|
||||||
|
git push origin --force tag "$VERSION"
|
||||||
|
|
||||||
|
# If a remote named 'github' exists, push branch and tags there too
|
||||||
|
if git remote | grep -q "^github$"; then
|
||||||
|
git push github HEAD
|
||||||
|
git push github --force tag "$VERSION"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "🎉 Successfully released version $VERSION!"
|
||||||
|
exit 0
|
||||||
936
src/actions.js
Normal file
936
src/actions.js
Normal file
@@ -0,0 +1,936 @@
|
|||||||
|
import { authToken, currentUser, orderMap, userFlags, pollIntervalId, highlightTags, allWeeks, currentWeekNumber, currentYear, displayMode, langMode, setAuthToken, setCurrentUser, setOrderMap, setUserFlags, setPollIntervalId, setHighlightTags, setAllWeeks, setCurrentWeekNumber, setCurrentYear } from './state.js';
|
||||||
|
import { getISOWeek, getWeekYear, translateDay, escapeHtml, getRelativeTime, isNewer } from './utils.js';
|
||||||
|
import { API_BASE, GUEST_TOKEN, VENUE_ID, MENU_ID, POLL_INTERVAL_MS, GITHUB_API, INSTALLER_BASE, CLIENT_VERSION } from './constants.js';
|
||||||
|
import { apiHeaders, githubHeaders } from './api.js';
|
||||||
|
import { renderVisibleWeeks, updateNextWeekBadge, updateAlarmBell } from './ui_helpers.js';
|
||||||
|
|
||||||
|
let fullOrderHistoryCache = null;
|
||||||
|
|
||||||
|
export function updateAuthUI() {
|
||||||
|
if (!authToken) {
|
||||||
|
try {
|
||||||
|
const akita = localStorage.getItem('AkitaStores');
|
||||||
|
if (akita) {
|
||||||
|
const parsed = JSON.parse(akita);
|
||||||
|
if (parsed.auth && parsed.auth.token) {
|
||||||
|
setAuthToken(parsed.auth.token);
|
||||||
|
localStorage.setItem('kantine_authToken', parsed.auth.token);
|
||||||
|
|
||||||
|
if (parsed.auth.user) {
|
||||||
|
setCurrentUser(parsed.auth.user.id || 'unknown');
|
||||||
|
localStorage.setItem('kantine_currentUser', parsed.auth.user.id || 'unknown');
|
||||||
|
if (parsed.auth.user.firstName) localStorage.setItem('kantine_firstName', parsed.auth.user.firstName);
|
||||||
|
if (parsed.auth.user.lastName) localStorage.setItem('kantine_lastName', parsed.auth.user.lastName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Failed to parse AkitaStores:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setAuthToken(localStorage.getItem('kantine_authToken'));
|
||||||
|
setCurrentUser(localStorage.getItem('kantine_currentUser'));
|
||||||
|
const firstName = localStorage.getItem('kantine_firstName');
|
||||||
|
const btnLoginOpen = document.getElementById('btn-login-open');
|
||||||
|
const userInfo = document.getElementById('user-info');
|
||||||
|
const userIdDisplay = document.getElementById('user-id-display');
|
||||||
|
|
||||||
|
if (authToken) {
|
||||||
|
btnLoginOpen.classList.add('hidden');
|
||||||
|
userInfo.classList.remove('hidden');
|
||||||
|
userIdDisplay.textContent = firstName || (currentUser ? `User ${currentUser}` : 'Angemeldet');
|
||||||
|
fetchOrders();
|
||||||
|
} else {
|
||||||
|
btnLoginOpen.classList.remove('hidden');
|
||||||
|
userInfo.classList.add('hidden');
|
||||||
|
userIdDisplay.textContent = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
renderVisibleWeeks();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchOrders() {
|
||||||
|
if (!authToken) return;
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE}/user/orders/?venue=${VENUE_ID}&ordering=-created&limit=50`, {
|
||||||
|
headers: apiHeaders(authToken)
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const newOrderMap = new Map();
|
||||||
|
const results = data.results || [];
|
||||||
|
|
||||||
|
for (const order of results) {
|
||||||
|
if (order.order_state === 9) continue;
|
||||||
|
const orderDate = order.date.split('T')[0];
|
||||||
|
|
||||||
|
for (const item of (order.items || [])) {
|
||||||
|
const key = `${orderDate}_${item.article}`;
|
||||||
|
if (!newOrderMap.has(key)) newOrderMap.set(key, []);
|
||||||
|
newOrderMap.get(key).push(order.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setOrderMap(newOrderMap);
|
||||||
|
renderVisibleWeeks();
|
||||||
|
updateNextWeekBadge();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching orders:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchFullOrderHistory() {
|
||||||
|
const historyLoading = document.getElementById('history-loading');
|
||||||
|
const historyContent = document.getElementById('history-content');
|
||||||
|
const progressFill = document.getElementById('history-progress-fill');
|
||||||
|
const progressText = document.getElementById('history-progress-text');
|
||||||
|
|
||||||
|
let localCache = [];
|
||||||
|
if (fullOrderHistoryCache) {
|
||||||
|
localCache = fullOrderHistoryCache;
|
||||||
|
} else {
|
||||||
|
const ls = localStorage.getItem('kantine_history_cache');
|
||||||
|
if (ls) {
|
||||||
|
try {
|
||||||
|
localCache = JSON.parse(ls);
|
||||||
|
fullOrderHistoryCache = localCache;
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('History cache parse error', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (localCache.length > 0) {
|
||||||
|
renderHistory(localCache);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!authToken) return;
|
||||||
|
|
||||||
|
if (localCache.length === 0) {
|
||||||
|
historyContent.innerHTML = '';
|
||||||
|
historyLoading.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
progressFill.style.width = '0%';
|
||||||
|
progressText.textContent = localCache.length > 0 ? 'Suche nach neuen Bestellungen...' : 'Lade Bestellhistorie...';
|
||||||
|
if (localCache.length > 0) historyLoading.classList.remove('hidden');
|
||||||
|
|
||||||
|
let nextUrl = localCache.length > 0
|
||||||
|
? `${API_BASE}/user/orders/?venue=${VENUE_ID}&ordering=-created&limit=5`
|
||||||
|
: `${API_BASE}/user/orders/?venue=${VENUE_ID}&ordering=-created&limit=50`;
|
||||||
|
let fetchedOrders = [];
|
||||||
|
let totalCount = 0;
|
||||||
|
let requiresFullFetch = localCache.length === 0;
|
||||||
|
let deltaComplete = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
while (nextUrl && !deltaComplete) {
|
||||||
|
const response = await fetch(nextUrl, { headers: apiHeaders(authToken) });
|
||||||
|
if (!response.ok) throw new Error(`Fetch failed: ${response.status}`);
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.count && totalCount === 0) {
|
||||||
|
totalCount = data.count;
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = data.results || [];
|
||||||
|
|
||||||
|
for (const order of results) {
|
||||||
|
const existingOrderIndex = localCache.findIndex(cached => cached.id === order.id);
|
||||||
|
|
||||||
|
if (!requiresFullFetch && existingOrderIndex !== -1) {
|
||||||
|
const existingOrder = localCache[existingOrderIndex];
|
||||||
|
if (existingOrder.updated === order.updated && existingOrder.order_state === order.order_state) {
|
||||||
|
deltaComplete = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fetchedOrders.push(order);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!deltaComplete && requiresFullFetch) {
|
||||||
|
if (totalCount > 0) {
|
||||||
|
const pct = Math.round((fetchedOrders.length / totalCount) * 100);
|
||||||
|
progressFill.style.width = `${pct}%`;
|
||||||
|
progressText.textContent = `Lade Bestellung ${fetchedOrders.length} von ${totalCount}...`;
|
||||||
|
} else {
|
||||||
|
progressText.textContent = `Lade Bestellung ${fetchedOrders.length}...`;
|
||||||
|
}
|
||||||
|
} else if (!deltaComplete) {
|
||||||
|
progressText.textContent = `${fetchedOrders.length} neue/geänderte Bestellungen gefunden...`;
|
||||||
|
}
|
||||||
|
|
||||||
|
nextUrl = deltaComplete ? null : data.next;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fetchedOrders.length > 0) {
|
||||||
|
const cacheMap = new Map(localCache.map(o => [o.id, o]));
|
||||||
|
for (const order of fetchedOrders) {
|
||||||
|
cacheMap.set(order.id, order);
|
||||||
|
}
|
||||||
|
const mergedOrders = Array.from(cacheMap.values());
|
||||||
|
mergedOrders.sort((a, b) => new Date(b.created) - new Date(a.created));
|
||||||
|
|
||||||
|
fullOrderHistoryCache = mergedOrders;
|
||||||
|
try {
|
||||||
|
localStorage.setItem('kantine_history_cache', JSON.stringify(mergedOrders));
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('History cache write error', e);
|
||||||
|
}
|
||||||
|
renderHistory(fullOrderHistoryCache);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error in history sync:', error);
|
||||||
|
if (localCache.length === 0) {
|
||||||
|
historyContent.innerHTML = `<p style="color:var(--error-color);text-align:center;">Fehler beim Laden der Historie.</p>`;
|
||||||
|
} else {
|
||||||
|
showToast('Hintergrund-Synchronisation fehlgeschlagen', 'error');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
historyLoading.classList.add('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderHistory(orders) {
|
||||||
|
const content = document.getElementById('history-content');
|
||||||
|
if (!orders || orders.length === 0) {
|
||||||
|
content.innerHTML = '<p style="text-align:center;color:var(--text-secondary);padding:20px;">Keine Bestellungen gefunden.</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const groups = {};
|
||||||
|
|
||||||
|
orders.forEach(order => {
|
||||||
|
const d = new Date(order.date);
|
||||||
|
const y = d.getFullYear();
|
||||||
|
const m = d.getMonth();
|
||||||
|
const monthKey = `${y}-${m.toString().padStart(2, '0')}`;
|
||||||
|
const monthName = d.toLocaleString('de-AT', { month: 'long' });
|
||||||
|
|
||||||
|
const kw = getISOWeek(d);
|
||||||
|
|
||||||
|
if (!groups[y]) {
|
||||||
|
groups[y] = { year: y, months: {} };
|
||||||
|
}
|
||||||
|
if (!groups[y].months[monthKey]) {
|
||||||
|
groups[y].months[monthKey] = { name: monthName, year: y, monthIndex: m, count: 0, total: 0, weeks: {} };
|
||||||
|
}
|
||||||
|
if (!groups[y].months[monthKey].weeks[kw]) {
|
||||||
|
groups[y].months[monthKey].weeks[kw] = { label: `KW ${kw}`, items: [], count: 0, total: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = order.items || [];
|
||||||
|
items.forEach(item => {
|
||||||
|
const itemPrice = parseFloat(item.price || order.total || 0);
|
||||||
|
groups[y].months[monthKey].weeks[kw].items.push({
|
||||||
|
date: order.date,
|
||||||
|
name: item.name || 'Menü',
|
||||||
|
price: itemPrice,
|
||||||
|
state: order.order_state
|
||||||
|
});
|
||||||
|
|
||||||
|
if (order.order_state !== 9) {
|
||||||
|
groups[y].months[monthKey].weeks[kw].count++;
|
||||||
|
groups[y].months[monthKey].weeks[kw].total += itemPrice;
|
||||||
|
groups[y].months[monthKey].count++;
|
||||||
|
groups[y].months[monthKey].total += itemPrice;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const sortedYears = Object.keys(groups).sort((a, b) => b - a);
|
||||||
|
let html = '';
|
||||||
|
|
||||||
|
sortedYears.forEach(yKey => {
|
||||||
|
const yearGroup = groups[yKey];
|
||||||
|
html += `<div class="history-year-group">
|
||||||
|
<h2 class="history-year-header">${yearGroup.year}</h2>`;
|
||||||
|
|
||||||
|
const sortedMonths = Object.keys(yearGroup.months).sort((a, b) => b.localeCompare(a));
|
||||||
|
|
||||||
|
sortedMonths.forEach(mKey => {
|
||||||
|
const monthGroup = yearGroup.months[mKey];
|
||||||
|
|
||||||
|
html += `<div class="history-month-group">
|
||||||
|
<div class="history-month-header" tabindex="0" role="button" aria-expanded="false" title="Klicken, um die Bestellungen für diesen Monat ein-/auszublenden">
|
||||||
|
<div style="display:flex; flex-direction:column; gap:4px;">
|
||||||
|
<span>${monthGroup.name}</span>
|
||||||
|
<div class="history-month-summary">
|
||||||
|
<span>${monthGroup.count} Bestellungen • <strong>€${monthGroup.total.toFixed(2)}</strong></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span class="material-icons-round">expand_more</span>
|
||||||
|
</div>
|
||||||
|
<div class="history-month-content">`;
|
||||||
|
|
||||||
|
const sortedKWs = Object.keys(monthGroup.weeks).sort((a, b) => parseInt(b) - parseInt(a));
|
||||||
|
|
||||||
|
sortedKWs.forEach(kw => {
|
||||||
|
const week = monthGroup.weeks[kw];
|
||||||
|
html += `<div class="history-week-group">
|
||||||
|
<div class="history-week-header">
|
||||||
|
<strong>${week.label}</strong>
|
||||||
|
<span>${week.count} Bestellungen • <strong>€${week.total.toFixed(2)}</strong></span>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
week.items.forEach(item => {
|
||||||
|
const dateObj = new Date(item.date);
|
||||||
|
const dayStr = dateObj.toLocaleDateString('de-AT', { weekday: 'short', day: '2-digit', month: '2-digit' });
|
||||||
|
|
||||||
|
let statusBadge = '';
|
||||||
|
if (item.state === 9) {
|
||||||
|
statusBadge = '<span class="history-item-status">Storniert</span>';
|
||||||
|
} else if (item.state === 8) {
|
||||||
|
statusBadge = '<span class="history-item-status">Abgeschlossen</span>';
|
||||||
|
} else {
|
||||||
|
statusBadge = '<span class="history-item-status">Übertragen</span>';
|
||||||
|
}
|
||||||
|
|
||||||
|
html += `
|
||||||
|
<div class="history-item ${item.state === 9 ? 'history-item-cancelled' : ''}">
|
||||||
|
<div style="font-size: 0.85rem; color: var(--text-secondary);">${dayStr}</div>
|
||||||
|
<div class="history-item-details">
|
||||||
|
<span class="history-item-name">${escapeHtml(item.name)}</span>
|
||||||
|
<div>${statusBadge}</div>
|
||||||
|
</div>
|
||||||
|
<div class="history-item-price ${item.state === 9 ? 'history-item-price-cancelled' : ''}">€${item.price.toFixed(2)}</div>
|
||||||
|
</div>`;
|
||||||
|
});
|
||||||
|
html += `</div>`;
|
||||||
|
});
|
||||||
|
html += `</div></div>`;
|
||||||
|
});
|
||||||
|
html += `</div>`;
|
||||||
|
});
|
||||||
|
|
||||||
|
content.innerHTML = html;
|
||||||
|
|
||||||
|
const monthHeaders = content.querySelectorAll('.history-month-header');
|
||||||
|
monthHeaders.forEach(header => {
|
||||||
|
header.addEventListener('click', () => {
|
||||||
|
const parentGroup = header.parentElement;
|
||||||
|
const isOpen = parentGroup.classList.contains('open');
|
||||||
|
|
||||||
|
if (isOpen) {
|
||||||
|
parentGroup.classList.remove('open');
|
||||||
|
header.setAttribute('aria-expanded', 'false');
|
||||||
|
} else {
|
||||||
|
parentGroup.classList.add('open');
|
||||||
|
header.setAttribute('aria-expanded', 'true');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function placeOrder(date, articleId, name, price, description) {
|
||||||
|
if (!authToken) return;
|
||||||
|
try {
|
||||||
|
const userResp = await fetch(`${API_BASE}/auth/user/`, {
|
||||||
|
headers: apiHeaders(authToken)
|
||||||
|
});
|
||||||
|
if (!userResp.ok) {
|
||||||
|
showToast('Fehler: Benutzerdaten konnten nicht geladen werden', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const userData = await userResp.json();
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
|
||||||
|
const orderPayload = {
|
||||||
|
uuid: crypto.randomUUID(),
|
||||||
|
created: now,
|
||||||
|
updated: now,
|
||||||
|
order_type: 7,
|
||||||
|
items: [{
|
||||||
|
article: articleId,
|
||||||
|
course_group: null,
|
||||||
|
modifiers: [],
|
||||||
|
uuid: crypto.randomUUID(),
|
||||||
|
name: name,
|
||||||
|
description: description || '',
|
||||||
|
price: String(parseFloat(price)),
|
||||||
|
amount: 1,
|
||||||
|
vat: '10.00',
|
||||||
|
comment: ''
|
||||||
|
}],
|
||||||
|
table: null,
|
||||||
|
total: parseFloat(price),
|
||||||
|
tip: 0,
|
||||||
|
currency: 'EUR',
|
||||||
|
venue: VENUE_ID,
|
||||||
|
states: [],
|
||||||
|
order_state: 1,
|
||||||
|
date: `${date}T10:30:00Z`,
|
||||||
|
payment_method: 'payroll',
|
||||||
|
customer: {
|
||||||
|
first_name: userData.first_name,
|
||||||
|
last_name: userData.last_name,
|
||||||
|
email: userData.email,
|
||||||
|
newsletter: false
|
||||||
|
},
|
||||||
|
preorder: true,
|
||||||
|
delivery_fee: 0,
|
||||||
|
cash_box_table_name: null,
|
||||||
|
take_away: false
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await fetch(`${API_BASE}/user/orders/`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: apiHeaders(authToken),
|
||||||
|
body: JSON.stringify(orderPayload)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok || response.status === 201) {
|
||||||
|
showToast(`Bestellt: ${name}`, 'success');
|
||||||
|
fullOrderHistoryCache = null;
|
||||||
|
await fetchOrders();
|
||||||
|
} else {
|
||||||
|
const data = await response.json();
|
||||||
|
showToast(`Fehler: ${data.detail || data.non_field_errors?.[0] || 'Bestellung fehlgeschlagen'}`, 'error');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Order error:', error);
|
||||||
|
showToast('Netzwerkfehler bei Bestellung', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cancelOrder(date, articleId, name) {
|
||||||
|
if (!authToken) return;
|
||||||
|
const key = `${date}_${articleId}`;
|
||||||
|
const orderIds = orderMap.get(key);
|
||||||
|
if (!orderIds || orderIds.length === 0) return;
|
||||||
|
|
||||||
|
const orderId = orderIds[orderIds.length - 1];
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE}/user/orders/${orderId}/cancel/`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: apiHeaders(authToken),
|
||||||
|
body: JSON.stringify({})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
showToast(`Storniert: ${name}`, 'success');
|
||||||
|
fullOrderHistoryCache = null;
|
||||||
|
await fetchOrders();
|
||||||
|
} else {
|
||||||
|
const data = await response.json();
|
||||||
|
showToast(`Fehler: ${data.detail || 'Stornierung fehlgeschlagen'}`, 'error');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Cancel error:', error);
|
||||||
|
showToast('Netzwerkfehler bei Stornierung', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveFlags() {
|
||||||
|
localStorage.setItem('kantine_flags', JSON.stringify([...userFlags]));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function refreshFlaggedItems() {
|
||||||
|
if (userFlags.size === 0) return;
|
||||||
|
const token = authToken || GUEST_TOKEN;
|
||||||
|
const datesToFetch = new Set();
|
||||||
|
|
||||||
|
for (const flagId of userFlags) {
|
||||||
|
const [dateStr] = flagId.split('_');
|
||||||
|
datesToFetch.add(dateStr);
|
||||||
|
}
|
||||||
|
|
||||||
|
let updated = false;
|
||||||
|
for (const dateStr of datesToFetch) {
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`${API_BASE}/venues/${VENUE_ID}/menu/${MENU_ID}/${dateStr}/`, {
|
||||||
|
headers: apiHeaders(token)
|
||||||
|
});
|
||||||
|
if (!resp.ok) continue;
|
||||||
|
const data = await resp.json();
|
||||||
|
const menuGroups = data.results || [];
|
||||||
|
let dayItems = [];
|
||||||
|
for (const group of menuGroups) {
|
||||||
|
if (group.items && Array.isArray(group.items)) {
|
||||||
|
dayItems = dayItems.concat(group.items);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let week of allWeeks) {
|
||||||
|
if (!week.days) continue;
|
||||||
|
let dayObj = week.days.find(d => d.date === dateStr);
|
||||||
|
if (dayObj) {
|
||||||
|
dayObj.items = dayItems.map(item => {
|
||||||
|
const isUnlimited = item.amount_tracking === false;
|
||||||
|
const hasStock = parseInt(item.available_amount) > 0;
|
||||||
|
return {
|
||||||
|
id: `${dateStr}_${item.id}`,
|
||||||
|
articleId: item.id,
|
||||||
|
name: item.name || 'Unknown',
|
||||||
|
description: item.description || '',
|
||||||
|
price: parseFloat(item.price) || 0,
|
||||||
|
available: isUnlimited || hasStock,
|
||||||
|
availableAmount: parseInt(item.available_amount) || 0,
|
||||||
|
amountTracking: item.amount_tracking !== false
|
||||||
|
};
|
||||||
|
});
|
||||||
|
updated = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error refreshing flag date', dateStr, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updated) {
|
||||||
|
saveMenuCache();
|
||||||
|
updateLastUpdatedTime(new Date().toISOString());
|
||||||
|
localStorage.setItem('kantine_flagged_items_last_checked', new Date().toISOString());
|
||||||
|
updateAlarmBell();
|
||||||
|
renderVisibleWeeks();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export function toggleFlag(date, articleId, name, cutoff) {
|
||||||
|
const id = `${date}_${articleId}`;
|
||||||
|
let flagAdded = false;
|
||||||
|
if (userFlags.has(id)) {
|
||||||
|
userFlags.delete(id);
|
||||||
|
showToast(`Flag entfernt für ${name}`, 'success');
|
||||||
|
} else {
|
||||||
|
userFlags.add(id);
|
||||||
|
flagAdded = true;
|
||||||
|
showToast(`Benachrichtigung aktiviert für ${name}`, 'success');
|
||||||
|
if (Notification.permission === 'default') {
|
||||||
|
Notification.requestPermission();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
saveFlags();
|
||||||
|
updateAlarmBell();
|
||||||
|
renderVisibleWeeks();
|
||||||
|
|
||||||
|
if (flagAdded) {
|
||||||
|
refreshFlaggedItems();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cleanupExpiredFlags() {
|
||||||
|
const now = new Date();
|
||||||
|
const todayStr = now.toISOString().split('T')[0];
|
||||||
|
let changed = false;
|
||||||
|
|
||||||
|
for (const flagId of [...userFlags]) {
|
||||||
|
const [dateStr] = flagId.split('_');
|
||||||
|
|
||||||
|
let isExpired = false;
|
||||||
|
|
||||||
|
if (dateStr < todayStr) {
|
||||||
|
isExpired = true;
|
||||||
|
} else if (dateStr === todayStr) {
|
||||||
|
const cutoff = new Date(dateStr);
|
||||||
|
cutoff.setHours(10, 0, 0, 0);
|
||||||
|
if (now >= cutoff) {
|
||||||
|
isExpired = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isExpired) {
|
||||||
|
userFlags.delete(flagId);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (changed) saveFlags();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startPolling() {
|
||||||
|
if (pollIntervalId) return;
|
||||||
|
if (!authToken) return;
|
||||||
|
setPollIntervalId(setInterval(() => pollFlaggedItems(), POLL_INTERVAL_MS));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stopPolling() {
|
||||||
|
if (pollIntervalId) {
|
||||||
|
clearInterval(pollIntervalId);
|
||||||
|
setPollIntervalId(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function pollFlaggedItems() {
|
||||||
|
if (userFlags.size === 0 || !authToken) return;
|
||||||
|
|
||||||
|
for (const flagId of userFlags) {
|
||||||
|
const [date, articleIdStr] = flagId.split('_');
|
||||||
|
const articleId = parseInt(articleIdStr);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE}/venues/${VENUE_ID}/menu/${MENU_ID}/${date}/`, {
|
||||||
|
headers: apiHeaders(authToken)
|
||||||
|
});
|
||||||
|
if (!response.ok) continue;
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
const groups = data.results || [];
|
||||||
|
let foundItem = null;
|
||||||
|
for (const group of groups) {
|
||||||
|
if (group.items) {
|
||||||
|
foundItem = group.items.find(i => i.id === articleId || i.article === articleId);
|
||||||
|
if (foundItem) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (foundItem) {
|
||||||
|
const isAvailable = (foundItem.amount_tracking === false) || (parseInt(foundItem.available_amount) > 0);
|
||||||
|
if (isAvailable) {
|
||||||
|
const itemName = foundItem.name || 'Unbekannt';
|
||||||
|
showToast(`${itemName} ist jetzt verfügbar!`, 'success');
|
||||||
|
if (Notification.permission === 'granted') {
|
||||||
|
new Notification('Kantine Wrapper', {
|
||||||
|
body: `${itemName} ist jetzt verfügbar!`,
|
||||||
|
icon: '🍽️'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
loadMenuDataFromAPI();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Poll error for ${flagId}:`, err);
|
||||||
|
await new Promise(r => setTimeout(r, 200));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
localStorage.setItem('kantine_flagged_items_last_checked', new Date().toISOString());
|
||||||
|
updateAlarmBell();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveHighlightTags() {
|
||||||
|
localStorage.setItem('kantine_highlightTags', JSON.stringify(highlightTags));
|
||||||
|
renderVisibleWeeks();
|
||||||
|
updateNextWeekBadge();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addHighlightTag(tag) {
|
||||||
|
tag = tag.trim().toLowerCase();
|
||||||
|
if (tag && !highlightTags.includes(tag)) {
|
||||||
|
const newTags = [...highlightTags, tag];
|
||||||
|
setHighlightTags(newTags);
|
||||||
|
saveHighlightTags();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeHighlightTag(tag) {
|
||||||
|
const newTags = highlightTags.filter(t => t !== tag);
|
||||||
|
setHighlightTags(newTags);
|
||||||
|
saveHighlightTags();
|
||||||
|
}
|
||||||
|
|
||||||
|
export 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}" title="Schlagwort entfernen">×</span>`;
|
||||||
|
list.appendChild(badge);
|
||||||
|
});
|
||||||
|
|
||||||
|
list.querySelectorAll('.tag-remove').forEach(btn => {
|
||||||
|
btn.addEventListener('click', (e) => {
|
||||||
|
removeHighlightTag(e.target.dataset.tag);
|
||||||
|
renderTagsList();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function checkHighlight(text) {
|
||||||
|
if (!text) return [];
|
||||||
|
text = text.toLowerCase();
|
||||||
|
return highlightTags.filter(tag => text.includes(tag));
|
||||||
|
}
|
||||||
|
|
||||||
|
const CACHE_KEY = 'kantine_menuCache';
|
||||||
|
const CACHE_TS_KEY = 'kantine_menuCacheTs';
|
||||||
|
|
||||||
|
export function saveMenuCache() {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(CACHE_KEY, JSON.stringify(allWeeks));
|
||||||
|
localStorage.setItem(CACHE_TS_KEY, new Date().toISOString());
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Failed to cache menu data:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadMenuCache() {
|
||||||
|
try {
|
||||||
|
const cached = localStorage.getItem(CACHE_KEY);
|
||||||
|
const cachedTs = localStorage.getItem(CACHE_TS_KEY);
|
||||||
|
if (cached) {
|
||||||
|
setAllWeeks(JSON.parse(cached));
|
||||||
|
setCurrentWeekNumber(getISOWeek(new Date()));
|
||||||
|
setCurrentYear(new Date().getFullYear());
|
||||||
|
renderVisibleWeeks();
|
||||||
|
updateNextWeekBadge();
|
||||||
|
updateAlarmBell();
|
||||||
|
if (cachedTs) updateLastUpdatedTime(cachedTs);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const uniqueMenus = new Set();
|
||||||
|
allWeeks.forEach(w => {
|
||||||
|
(w.days || []).forEach(d => {
|
||||||
|
(d.items || []).forEach(item => {
|
||||||
|
let text = (item.description || '').replace(/\s+/g, ' ').trim();
|
||||||
|
if (text && text.includes(' / ')) {
|
||||||
|
uniqueMenus.add(text);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (e) { }
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Failed to load cached menu:', e);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isCacheFresh() {
|
||||||
|
const cachedTs = localStorage.getItem(CACHE_TS_KEY);
|
||||||
|
if (!cachedTs) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ageMs = Date.now() - new Date(cachedTs).getTime();
|
||||||
|
if (ageMs > 60 * 60 * 1000) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
return hasCurrentWeek;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadMenuDataFromAPI() {
|
||||||
|
const loading = document.getElementById('loading');
|
||||||
|
const progressModal = document.getElementById('progress-modal');
|
||||||
|
const progressFill = document.getElementById('progress-fill');
|
||||||
|
const progressPercent = document.getElementById('progress-percent');
|
||||||
|
const progressMessage = document.getElementById('progress-message');
|
||||||
|
|
||||||
|
loading.classList.remove('hidden');
|
||||||
|
|
||||||
|
const token = authToken || GUEST_TOKEN;
|
||||||
|
|
||||||
|
try {
|
||||||
|
progressModal.classList.remove('hidden');
|
||||||
|
progressMessage.textContent = 'Hole verfügbare Daten...';
|
||||||
|
progressFill.style.width = '0%';
|
||||||
|
progressPercent.textContent = '0%';
|
||||||
|
|
||||||
|
const datesResponse = await fetch(`${API_BASE}/venues/${VENUE_ID}/menu/dates/`, {
|
||||||
|
headers: apiHeaders(token)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!datesResponse.ok) throw new Error(`Failed to fetch dates: ${datesResponse.status}`);
|
||||||
|
|
||||||
|
const datesData = await datesResponse.json();
|
||||||
|
let availableDates = datesData.results || [];
|
||||||
|
|
||||||
|
const cutoff = new Date();
|
||||||
|
cutoff.setDate(cutoff.getDate() - 7);
|
||||||
|
const cutoffStr = cutoff.toISOString().split('T')[0];
|
||||||
|
|
||||||
|
availableDates = availableDates
|
||||||
|
.filter(d => d.date >= cutoffStr)
|
||||||
|
.sort((a, b) => a.date.localeCompare(b.date))
|
||||||
|
.slice(0, 30);
|
||||||
|
|
||||||
|
const totalDates = availableDates.length;
|
||||||
|
progressMessage.textContent = `${totalDates} Tage gefunden. Lade Details...`;
|
||||||
|
|
||||||
|
const allDays = [];
|
||||||
|
let completed = 0;
|
||||||
|
|
||||||
|
for (const dateObj of availableDates) {
|
||||||
|
const dateStr = dateObj.date;
|
||||||
|
const pct = Math.round(((completed + 1) / totalDates) * 100);
|
||||||
|
progressFill.style.width = `${pct}%`;
|
||||||
|
progressPercent.textContent = `${pct}%`;
|
||||||
|
progressMessage.textContent = `Lade Menü für ${dateStr}...`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const detailResp = await fetch(`${API_BASE}/venues/${VENUE_ID}/menu/${MENU_ID}/${dateStr}/`, {
|
||||||
|
headers: apiHeaders(token)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (detailResp.ok) {
|
||||||
|
const detailData = await detailResp.json();
|
||||||
|
const menuGroups = detailData.results || [];
|
||||||
|
let dayItems = [];
|
||||||
|
for (const group of menuGroups) {
|
||||||
|
if (group.items && Array.isArray(group.items)) {
|
||||||
|
dayItems = dayItems.concat(group.items);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (dayItems.length > 0) {
|
||||||
|
allDays.push({
|
||||||
|
date: dateStr,
|
||||||
|
menu_items: dayItems,
|
||||||
|
orders: dateObj.orders || []
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Failed to fetch details for ${dateStr}:`, err);
|
||||||
|
}
|
||||||
|
|
||||||
|
completed++;
|
||||||
|
await new Promise(r => setTimeout(r, 100));
|
||||||
|
}
|
||||||
|
|
||||||
|
const weeksMap = new Map();
|
||||||
|
|
||||||
|
if (allWeeks && allWeeks.length > 0) {
|
||||||
|
allWeeks.forEach(w => {
|
||||||
|
const key = `${w.year}-${w.weekNumber}`;
|
||||||
|
try {
|
||||||
|
weeksMap.set(key, {
|
||||||
|
year: w.year,
|
||||||
|
weekNumber: w.weekNumber,
|
||||||
|
days: w.days ? w.days.map(d => ({ ...d, items: d.items ? [...d.items] : [] })) : []
|
||||||
|
});
|
||||||
|
} catch (e) { console.warn('Error hydrating week:', e); }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const day of allDays) {
|
||||||
|
const d = new Date(day.date);
|
||||||
|
const weekNum = getISOWeek(d);
|
||||||
|
const year = getWeekYear(d);
|
||||||
|
const key = `${year}-${weekNum}`;
|
||||||
|
|
||||||
|
if (!weeksMap.has(key)) {
|
||||||
|
weeksMap.set(key, { year, weekNumber: weekNum, days: [] });
|
||||||
|
}
|
||||||
|
|
||||||
|
const weekObj = weeksMap.get(key);
|
||||||
|
const weekday = d.toLocaleDateString('en-US', { weekday: 'long' });
|
||||||
|
const orderCutoffDate = new Date(day.date);
|
||||||
|
orderCutoffDate.setHours(10, 0, 0, 0);
|
||||||
|
|
||||||
|
const newDayObj = {
|
||||||
|
date: day.date,
|
||||||
|
weekday: weekday,
|
||||||
|
orderCutoff: orderCutoffDate.toISOString(),
|
||||||
|
items: day.menu_items.map(item => {
|
||||||
|
const isUnlimited = item.amount_tracking === false;
|
||||||
|
const hasStock = parseInt(item.available_amount) > 0;
|
||||||
|
return {
|
||||||
|
id: `${day.date}_${item.id}`,
|
||||||
|
articleId: item.id,
|
||||||
|
name: item.name || 'Unknown',
|
||||||
|
description: item.description || '',
|
||||||
|
price: parseFloat(item.price) || 0,
|
||||||
|
available: isUnlimited || hasStock,
|
||||||
|
availableAmount: parseInt(item.available_amount) || 0,
|
||||||
|
amountTracking: item.amount_tracking !== false
|
||||||
|
};
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
const existingIndex = weekObj.days.findIndex(existing => existing.date === day.date);
|
||||||
|
if (existingIndex >= 0) {
|
||||||
|
weekObj.days[existingIndex] = newDayObj;
|
||||||
|
} else {
|
||||||
|
weekObj.days.push(newDayObj);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const newAllWeeks = Array.from(weeksMap.values()).sort((a, b) => {
|
||||||
|
if (a.year !== b.year) return a.year - b.year;
|
||||||
|
return a.weekNumber - b.weekNumber;
|
||||||
|
});
|
||||||
|
newAllWeeks.forEach(w => {
|
||||||
|
if (w.days) w.days.sort((a, b) => a.date.localeCompare(b.date));
|
||||||
|
});
|
||||||
|
setAllWeeks(newAllWeeks);
|
||||||
|
|
||||||
|
saveMenuCache();
|
||||||
|
|
||||||
|
updateLastUpdatedTime(new Date().toISOString());
|
||||||
|
|
||||||
|
setCurrentWeekNumber(getISOWeek(new Date()));
|
||||||
|
setCurrentYear(new Date().getFullYear());
|
||||||
|
|
||||||
|
updateAuthUI();
|
||||||
|
renderVisibleWeeks();
|
||||||
|
updateNextWeekBadge();
|
||||||
|
updateAlarmBell();
|
||||||
|
|
||||||
|
progressMessage.textContent = 'Fertig!';
|
||||||
|
setTimeout(() => progressModal.classList.add('hidden'), 500);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching menu:', error);
|
||||||
|
progressModal.classList.add('hidden');
|
||||||
|
import('./ui_helpers.js').then(uiHelpers => {
|
||||||
|
uiHelpers.showErrorModal(
|
||||||
|
'Keine Verbindung',
|
||||||
|
`Die Menüdaten konnten nicht geladen werden. Möglicherweise besteht keine Verbindung zur API oder zur Bessa-Webseite.<br><br><small style="color:var(--text-secondary)">${escapeHtml(error.message)}</small>`,
|
||||||
|
'Zur Original-Seite',
|
||||||
|
'https://web.bessa.app/knapp-kantine'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
loading.classList.add('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let lastUpdatedTimestamp = null;
|
||||||
|
let lastUpdatedIntervalId = null;
|
||||||
|
|
||||||
|
export function updateLastUpdatedTime(isoTimestamp) {
|
||||||
|
const subtitle = document.getElementById('last-updated-subtitle');
|
||||||
|
if (!isoTimestamp) return;
|
||||||
|
lastUpdatedTimestamp = isoTimestamp;
|
||||||
|
localStorage.setItem('kantine_last_updated', isoTimestamp);
|
||||||
|
localStorage.setItem('kantine_last_checked', isoTimestamp);
|
||||||
|
try {
|
||||||
|
const date = new Date(isoTimestamp);
|
||||||
|
const timeStr = date.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
|
||||||
|
const dateStr = date.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' });
|
||||||
|
const ago = getRelativeTime(date);
|
||||||
|
subtitle.textContent = `Aktualisiert: ${dateStr} ${timeStr} (${ago})`;
|
||||||
|
} catch (e) {
|
||||||
|
subtitle.textContent = '';
|
||||||
|
}
|
||||||
|
if (!lastUpdatedIntervalId) {
|
||||||
|
lastUpdatedIntervalId = setInterval(() => {
|
||||||
|
if (lastUpdatedTimestamp) {
|
||||||
|
updateLastUpdatedTime(lastUpdatedTimestamp);
|
||||||
|
updateAlarmBell();
|
||||||
|
}
|
||||||
|
}, 60 * 1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function showToast(message, type = 'info') {
|
||||||
|
let container = document.getElementById('toast-container');
|
||||||
|
if (!container) {
|
||||||
|
container = document.createElement('div');
|
||||||
|
container.id = 'toast-container';
|
||||||
|
document.body.appendChild(container);
|
||||||
|
}
|
||||||
|
const toast = document.createElement('div');
|
||||||
|
toast.className = `toast toast-${type}`;
|
||||||
|
const icon = type === 'success' ? 'check_circle' : type === 'error' ? 'error' : 'info';
|
||||||
|
toast.innerHTML = `<span class="material-icons-round">${icon}</span><span>${escapeHtml(message)}</span>`;
|
||||||
|
container.appendChild(toast);
|
||||||
|
requestAnimationFrame(() => toast.classList.add('show'));
|
||||||
|
setTimeout(() => {
|
||||||
|
toast.classList.remove('show');
|
||||||
|
setTimeout(() => toast.remove(), 300);
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
14
src/api.js
Normal file
14
src/api.js
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { API_BASE, GUEST_TOKEN, CLIENT_VERSION } from './constants.js';
|
||||||
|
|
||||||
|
export function apiHeaders(token) {
|
||||||
|
return {
|
||||||
|
'Authorization': `Token ${token || GUEST_TOKEN}`,
|
||||||
|
'Accept': 'application/json',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-Client-Version': CLIENT_VERSION
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function githubHeaders() {
|
||||||
|
return { 'Accept': 'application/vnd.github.v3+json' };
|
||||||
|
}
|
||||||
10
src/constants.js
Normal file
10
src/constants.js
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
export const API_BASE = 'https://api.bessa.app/v1';
|
||||||
|
export const GUEST_TOKEN = 'c3418725e95a9f90e3645cbc846b4d67c7c66131';
|
||||||
|
export const CLIENT_VERSION = 'v1.6.11';
|
||||||
|
export const VENUE_ID = 591;
|
||||||
|
export const MENU_ID = 7;
|
||||||
|
export const POLL_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes
|
||||||
|
|
||||||
|
export const GITHUB_REPO = 'TauNeutrino/kantine-overview';
|
||||||
|
export const GITHUB_API = `https://api.github.com/repos/${GITHUB_REPO}`;
|
||||||
|
export const INSTALLER_BASE = `https://htmlpreview.github.io/?https://github.com/${GITHUB_REPO}/blob`;
|
||||||
251
src/events.js
Normal file
251
src/events.js
Normal file
@@ -0,0 +1,251 @@
|
|||||||
|
import { displayMode, langMode, authToken, currentUser, orderMap, userFlags, pollIntervalId, setLangMode, setDisplayMode, setAuthToken, setCurrentUser, setOrderMap } from './state.js';
|
||||||
|
import { updateAuthUI, loadMenuDataFromAPI, fetchOrders, startPolling, stopPolling, fetchFullOrderHistory, addHighlightTag, renderTagsList } from './actions.js';
|
||||||
|
import { renderVisibleWeeks, openVersionMenu } from './ui_helpers.js';
|
||||||
|
import { API_BASE, GUEST_TOKEN } from './constants.js';
|
||||||
|
import { apiHeaders } from './api.js';
|
||||||
|
|
||||||
|
export function bindEvents() {
|
||||||
|
const btnThisWeek = document.getElementById('btn-this-week');
|
||||||
|
const btnNextWeek = document.getElementById('btn-next-week');
|
||||||
|
const btnRefresh = document.getElementById('btn-refresh');
|
||||||
|
const themeToggle = document.getElementById('theme-toggle');
|
||||||
|
const btnLoginOpen = document.getElementById('btn-login-open');
|
||||||
|
const btnLoginClose = document.getElementById('btn-login-close');
|
||||||
|
const btnLogout = document.getElementById('btn-logout');
|
||||||
|
const loginForm = document.getElementById('login-form');
|
||||||
|
const loginModal = document.getElementById('login-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');
|
||||||
|
|
||||||
|
const btnHistory = document.getElementById('btn-history');
|
||||||
|
const historyModal = document.getElementById('history-modal');
|
||||||
|
const btnHistoryClose = document.getElementById('btn-history-close');
|
||||||
|
|
||||||
|
document.querySelectorAll('.lang-btn').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
setLangMode(btn.dataset.lang);
|
||||||
|
localStorage.setItem('kantine_lang', btn.dataset.lang);
|
||||||
|
document.querySelectorAll('.lang-btn').forEach(b => b.classList.remove('active'));
|
||||||
|
btn.classList.add('active');
|
||||||
|
renderVisibleWeeks();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if (btnHighlights) {
|
||||||
|
btnHighlights.addEventListener('click', () => {
|
||||||
|
highlightsModal.classList.remove('hidden');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (btnHighlightsClose) {
|
||||||
|
btnHighlightsClose.addEventListener('click', () => {
|
||||||
|
highlightsModal.classList.add('hidden');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
btnHistory.addEventListener('click', () => {
|
||||||
|
if (!authToken) {
|
||||||
|
loginModal.classList.remove('hidden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
historyModal.classList.remove('hidden');
|
||||||
|
fetchFullOrderHistory();
|
||||||
|
});
|
||||||
|
|
||||||
|
btnHistoryClose.addEventListener('click', () => {
|
||||||
|
historyModal.classList.add('hidden');
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener('click', (e) => {
|
||||||
|
if (e.target === historyModal) historyModal.classList.add('hidden');
|
||||||
|
if (e.target === highlightsModal) highlightsModal.classList.add('hidden');
|
||||||
|
});
|
||||||
|
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const btnClearCache = document.getElementById('btn-clear-cache');
|
||||||
|
if (btnClearCache) {
|
||||||
|
btnClearCache.addEventListener('click', () => {
|
||||||
|
if (confirm('Möchtest du wirklich alle lokalen Daten (inkl. Login-Session, Cache und Einstellungen) löschen? Die Seite wird danach neu geladen.')) {
|
||||||
|
Object.keys(localStorage).forEach(key => {
|
||||||
|
if (key.startsWith('kantine_')) {
|
||||||
|
localStorage.removeItem(key);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const savedTheme = localStorage.getItem('theme');
|
||||||
|
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||||
|
const themeIcon = themeToggle.querySelector('.theme-icon');
|
||||||
|
|
||||||
|
if (savedTheme === 'dark' || (!savedTheme && prefersDark)) {
|
||||||
|
document.documentElement.setAttribute('data-theme', 'dark');
|
||||||
|
themeIcon.textContent = 'dark_mode';
|
||||||
|
} else {
|
||||||
|
document.documentElement.setAttribute('data-theme', 'light');
|
||||||
|
themeIcon.textContent = 'light_mode';
|
||||||
|
}
|
||||||
|
|
||||||
|
themeToggle.addEventListener('click', () => {
|
||||||
|
const current = document.documentElement.getAttribute('data-theme');
|
||||||
|
const next = current === 'dark' ? 'light' : 'dark';
|
||||||
|
document.documentElement.setAttribute('data-theme', next);
|
||||||
|
localStorage.setItem('theme', next);
|
||||||
|
themeIcon.textContent = next === 'dark' ? 'dark_mode' : 'light_mode';
|
||||||
|
});
|
||||||
|
|
||||||
|
btnThisWeek.addEventListener('click', () => {
|
||||||
|
if (displayMode !== 'this-week') {
|
||||||
|
setDisplayMode('this-week');
|
||||||
|
btnThisWeek.classList.add('active');
|
||||||
|
btnNextWeek.classList.remove('active');
|
||||||
|
renderVisibleWeeks();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
btnNextWeek.addEventListener('click', () => {
|
||||||
|
btnNextWeek.classList.remove('new-week-available');
|
||||||
|
if (displayMode !== 'next-week') {
|
||||||
|
setDisplayMode('next-week');
|
||||||
|
btnNextWeek.classList.add('active');
|
||||||
|
btnThisWeek.classList.remove('active');
|
||||||
|
renderVisibleWeeks();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
btnRefresh.addEventListener('click', () => {
|
||||||
|
if (!authToken) {
|
||||||
|
loginModal.classList.remove('hidden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
loadMenuDataFromAPI();
|
||||||
|
});
|
||||||
|
|
||||||
|
btnLoginOpen.addEventListener('click', () => {
|
||||||
|
loginModal.classList.remove('hidden');
|
||||||
|
document.getElementById('login-error').classList.add('hidden');
|
||||||
|
loginForm.reset();
|
||||||
|
});
|
||||||
|
|
||||||
|
btnLoginClose.addEventListener('click', () => {
|
||||||
|
loginModal.classList.add('hidden');
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener('click', (e) => {
|
||||||
|
if (e.target === loginModal) loginModal.classList.add('hidden');
|
||||||
|
});
|
||||||
|
|
||||||
|
loginForm.addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const employeeId = document.getElementById('employee-id').value.trim();
|
||||||
|
const password = document.getElementById('password').value;
|
||||||
|
const loginError = document.getElementById('login-error');
|
||||||
|
const submitBtn = loginForm.querySelector('button[type="submit"]');
|
||||||
|
const originalText = submitBtn.textContent;
|
||||||
|
|
||||||
|
submitBtn.disabled = true;
|
||||||
|
submitBtn.textContent = 'Wird eingeloggt...';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const email = `knapp-${employeeId}@bessa.app`;
|
||||||
|
const response = await fetch(`${API_BASE}/auth/login/`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: apiHeaders(GUEST_TOKEN),
|
||||||
|
body: JSON.stringify({ email, password })
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
setAuthToken(data.key);
|
||||||
|
setCurrentUser(employeeId);
|
||||||
|
localStorage.setItem('kantine_authToken', data.key);
|
||||||
|
localStorage.setItem('kantine_currentUser', employeeId);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const userResp = await fetch(`${API_BASE}/auth/user/`, {
|
||||||
|
headers: apiHeaders(data.key)
|
||||||
|
});
|
||||||
|
if (userResp.ok) {
|
||||||
|
const userData = await userResp.json();
|
||||||
|
if (userData.first_name) localStorage.setItem('kantine_firstName', userData.first_name);
|
||||||
|
if (userData.last_name) localStorage.setItem('kantine_lastName', userData.last_name);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch user info:', err);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateAuthUI();
|
||||||
|
loginModal.classList.add('hidden');
|
||||||
|
fetchOrders();
|
||||||
|
loginForm.reset();
|
||||||
|
startPolling();
|
||||||
|
loadMenuDataFromAPI();
|
||||||
|
} else {
|
||||||
|
loginError.textContent = data.non_field_errors?.[0] || data.error || 'Login fehlgeschlagen';
|
||||||
|
loginError.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Login error:', error);
|
||||||
|
loginError.textContent = 'Ein Fehler ist aufgetreten';
|
||||||
|
loginError.classList.remove('hidden');
|
||||||
|
} finally {
|
||||||
|
submitBtn.disabled = false;
|
||||||
|
submitBtn.textContent = originalText;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
btnLogout.addEventListener('click', () => {
|
||||||
|
localStorage.removeItem('kantine_authToken');
|
||||||
|
localStorage.removeItem('kantine_currentUser');
|
||||||
|
localStorage.removeItem('kantine_firstName');
|
||||||
|
localStorage.removeItem('kantine_lastName');
|
||||||
|
setAuthToken(null);
|
||||||
|
setCurrentUser(null);
|
||||||
|
setOrderMap(new Map());
|
||||||
|
stopPolling();
|
||||||
|
updateAuthUI();
|
||||||
|
renderVisibleWeeks();
|
||||||
|
});
|
||||||
|
}
|
||||||
31
src/index.js
Normal file
31
src/index.js
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { injectUI } from './ui.js';
|
||||||
|
import { bindEvents } from './events.js';
|
||||||
|
import { updateAuthUI, cleanupExpiredFlags, loadMenuCache, isCacheFresh, loadMenuDataFromAPI, startPolling } from './actions.js';
|
||||||
|
import { checkForUpdates } from './ui_helpers.js';
|
||||||
|
import { authToken } from './state.js';
|
||||||
|
|
||||||
|
if (!window.__KANTINE_LOADED) {
|
||||||
|
window.__KANTINE_LOADED = true;
|
||||||
|
|
||||||
|
injectUI();
|
||||||
|
bindEvents();
|
||||||
|
updateAuthUI();
|
||||||
|
cleanupExpiredFlags();
|
||||||
|
|
||||||
|
const hadCache = loadMenuCache();
|
||||||
|
if (hadCache) {
|
||||||
|
document.getElementById('loading').classList.add('hidden');
|
||||||
|
if (!isCacheFresh()) {
|
||||||
|
loadMenuDataFromAPI();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
loadMenuDataFromAPI();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (authToken) {
|
||||||
|
startPolling();
|
||||||
|
}
|
||||||
|
|
||||||
|
checkForUpdates();
|
||||||
|
setInterval(checkForUpdates, 60 * 60 * 1000);
|
||||||
|
}
|
||||||
25
src/state.js
Normal file
25
src/state.js
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { getISOWeek } from './utils.js';
|
||||||
|
|
||||||
|
export let allWeeks = [];
|
||||||
|
export let currentWeekNumber = getISOWeek(new Date());
|
||||||
|
export let currentYear = new Date().getFullYear();
|
||||||
|
export let displayMode = 'this-week';
|
||||||
|
export let authToken = localStorage.getItem('kantine_authToken');
|
||||||
|
export let currentUser = localStorage.getItem('kantine_currentUser');
|
||||||
|
export let orderMap = new Map();
|
||||||
|
export let userFlags = new Set(JSON.parse(localStorage.getItem('kantine_flags') || '[]'));
|
||||||
|
export let pollIntervalId = null;
|
||||||
|
export let langMode = localStorage.getItem('kantine_lang') || 'de';
|
||||||
|
export let highlightTags = JSON.parse(localStorage.getItem('kantine_highlightTags') || '[]');
|
||||||
|
|
||||||
|
export function setAllWeeks(weeks) { allWeeks = weeks; }
|
||||||
|
export function setCurrentWeekNumber(week) { currentWeekNumber = week; }
|
||||||
|
export function setCurrentYear(year) { currentYear = year; }
|
||||||
|
export function setDisplayMode(mode) { displayMode = mode; }
|
||||||
|
export function setAuthToken(token) { authToken = token; }
|
||||||
|
export function setCurrentUser(user) { currentUser = user; }
|
||||||
|
export function setOrderMap(map) { orderMap = map; }
|
||||||
|
export function setUserFlags(flags) { userFlags = flags; }
|
||||||
|
export function setPollIntervalId(id) { pollIntervalId = id; }
|
||||||
|
export function setLangMode(lang) { langMode = lang; }
|
||||||
|
export function setHighlightTags(tags) { highlightTags = tags; }
|
||||||
224
src/ui.js
Normal file
224
src/ui.js
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
import { langMode } from './state.js';
|
||||||
|
|
||||||
|
export function injectUI() {
|
||||||
|
document.title = 'Kantine Weekly Menu';
|
||||||
|
|
||||||
|
if (document.querySelectorAll) {
|
||||||
|
document.querySelectorAll('link[rel*="icon"]').forEach(el => el.remove());
|
||||||
|
}
|
||||||
|
const favicon = document.createElement('link');
|
||||||
|
favicon.rel = 'icon';
|
||||||
|
favicon.type = 'image/png';
|
||||||
|
favicon.href = '{{FAVICON_DATA_URI}}';
|
||||||
|
document.head.appendChild(favicon);
|
||||||
|
|
||||||
|
if (!document.querySelector('link[href*="fonts.googleapis.com/css2?family=Inter"]')) {
|
||||||
|
const fontLink = document.createElement('link');
|
||||||
|
fontLink.rel = 'stylesheet';
|
||||||
|
fontLink.href = 'https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap';
|
||||||
|
document.head.appendChild(fontLink);
|
||||||
|
}
|
||||||
|
if (!document.querySelector('link[href*="Material+Icons+Round"]')) {
|
||||||
|
const iconLink = document.createElement('link');
|
||||||
|
iconLink.rel = 'stylesheet';
|
||||||
|
iconLink.href = 'https://fonts.googleapis.com/icon?family=Material+Icons+Round';
|
||||||
|
document.head.appendChild(iconLink);
|
||||||
|
}
|
||||||
|
|
||||||
|
const htmlContent = `
|
||||||
|
<div id="kantine-wrapper">
|
||||||
|
<header class="app-header">
|
||||||
|
<div class="header-content">
|
||||||
|
<div class="brand">
|
||||||
|
<img src="{{FAVICON_DATA_URI}}" alt="Logo" class="logo-img" style="height: 2em; width: 2em; object-fit: contain;">
|
||||||
|
<div class="header-left">
|
||||||
|
<h1>Kantinen Übersicht <small class="version-tag" style="font-size: 0.6em; opacity: 0.7; font-weight: 400; cursor: pointer;" title="Klick für Versionsmenü">{{VERSION}}</small></h1>
|
||||||
|
<div id="last-updated-subtitle" class="subtitle"></div>
|
||||||
|
</div>
|
||||||
|
<div class="nav-group" style="margin-left: 1rem;">
|
||||||
|
<button id="btn-this-week" class="nav-btn active" title="Menü dieser Woche anzeigen">Diese Woche</button>
|
||||||
|
<button id="btn-next-week" class="nav-btn" title="Menü nächster Woche anzeigen">Nächste Woche</button>
|
||||||
|
</div>
|
||||||
|
<button id="alarm-bell" class="icon-btn hidden" aria-label="Benachrichtigungen" title="Keine beobachteten Menüs" style="margin-left: -0.5rem;">
|
||||||
|
<span class="material-icons-round" id="alarm-bell-icon" style="color:var(--text-secondary); transition: color 0.3s;">notifications</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="header-center-wrapper">
|
||||||
|
<div id="lang-toggle" class="lang-toggle" title="Sprache der Menübeschreibung">
|
||||||
|
<button class="lang-btn${langMode === 'de' ? ' active' : ''}" data-lang="de">DE</button>
|
||||||
|
<button class="lang-btn${langMode === 'en' ? ' active' : ''}" data-lang="en">EN</button>
|
||||||
|
<button class="lang-btn${langMode === 'all' ? ' active' : ''}" data-lang="all">ALL</button>
|
||||||
|
</div>
|
||||||
|
<div id="header-week-info" class="header-week-info"></div>
|
||||||
|
<div id="weekly-cost-display" class="weekly-cost hidden"></div>
|
||||||
|
</div>
|
||||||
|
<div class="controls">
|
||||||
|
<button id="btn-refresh" class="icon-btn" aria-label="Menüdaten aktualisieren" title="Menüdaten neu laden">
|
||||||
|
<span class="material-icons-round">refresh</span>
|
||||||
|
</button>
|
||||||
|
<button id="btn-history" class="icon-btn" aria-label="Bestellhistorie" title="Bestellhistorie">
|
||||||
|
<span class="material-icons-round">receipt_long</span>
|
||||||
|
</button>
|
||||||
|
<button id="btn-highlights" class="icon-btn" aria-label="Persönliche Highlights verwalten" title="Persönliche Highlights verwalten">
|
||||||
|
<span class="material-icons-round">label</span>
|
||||||
|
</button>
|
||||||
|
<button id="theme-toggle" class="icon-btn" aria-label="Toggle Theme" title="Erscheinungsbild (Hell/Dunkel) wechseln">
|
||||||
|
<span class="material-icons-round theme-icon">light_mode</span>
|
||||||
|
</button>
|
||||||
|
<button id="btn-login-open" class="user-badge-btn icon-btn-small" title="Mit Bessa.app Account anmelden">
|
||||||
|
<span class="material-icons-round">login</span>
|
||||||
|
<span>Anmelden</span>
|
||||||
|
</button>
|
||||||
|
<div id="user-info" class="user-badge hidden">
|
||||||
|
<span class="material-icons-round">person</span>
|
||||||
|
<span id="user-id-display"></span>
|
||||||
|
<button id="btn-logout" class="icon-btn-small" aria-label="Logout" title="Von Bessa.app abmelden">
|
||||||
|
<span class="material-icons-round">logout</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div id="login-modal" class="modal hidden">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h2>Login</h2>
|
||||||
|
<button id="btn-login-close" class="icon-btn" aria-label="Close" title="Schließen">
|
||||||
|
<span class="material-icons-round">close</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<form id="login-form">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="employee-id">Mitarbeiternummer</label>
|
||||||
|
<input type="text" id="employee-id" name="employee-id" placeholder="z.B. 2041" required>
|
||||||
|
<small class="help-text">Deine offizielle Knapp Mitarbeiternummer.</small>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="password">Passwort</label>
|
||||||
|
<input type="password" id="password" name="password" placeholder="Bessa Passwort" required>
|
||||||
|
<small class="help-text">Das Passwort für deinen Bessa Account.</small>
|
||||||
|
</div>
|
||||||
|
<div id="login-error" class="error-msg hidden"></div>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button type="submit" class="btn-primary wide">Einloggen</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="progress-modal" class="modal hidden">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h2>Menüdaten aktualisieren</h2>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body" style="padding: 20px;">
|
||||||
|
<div class="progress-container">
|
||||||
|
<div class="progress-bar">
|
||||||
|
<div id="progress-fill" class="progress-fill"></div>
|
||||||
|
</div>
|
||||||
|
<div id="progress-percent" class="progress-percent">0%</div>
|
||||||
|
</div>
|
||||||
|
<p id="progress-message" class="progress-message">Initialisierung...</p>
|
||||||
|
</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" title="Schließen">
|
||||||
|
<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..." title="Neues Schlagwort zum Hervorheben eingeben">
|
||||||
|
<button id="btn-add-tag" class="btn-primary" title="Schlagwort zur Liste hinzufügen">Hinzufügen</button>
|
||||||
|
</div>
|
||||||
|
<div id="tags-list"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="history-modal" class="modal hidden">
|
||||||
|
<div class="modal-content history-modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h2>Bestellhistorie</h2>
|
||||||
|
<button id="btn-history-close" class="icon-btn" aria-label="Close" title="Schließen">
|
||||||
|
<span class="material-icons-round">close</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div id="history-loading" class="hidden">
|
||||||
|
<p id="history-progress-text" style="text-align: center; margin-bottom: 1rem; color: var(--text-secondary);">Lade Historie...</p>
|
||||||
|
<div class="progress-container">
|
||||||
|
<div class="progress-bar">
|
||||||
|
<div id="history-progress-fill" class="progress-fill"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="history-content">
|
||||||
|
</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" title="Schließen">
|
||||||
|
<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; max-height: 250px; overflow-y: auto;">
|
||||||
|
<p style="color:var(--text-secondary);">Lade Versionen...</p>
|
||||||
|
</div>
|
||||||
|
<div style="margin-top: 1.5rem; padding-top: 1rem; border-top: 1px solid var(--border-color); display: flex; flex-direction: column; gap: 0.75rem; font-size: 0.9em;">
|
||||||
|
<a href="https://github.com/TauNeutrino/kantine-overview/issues" target="_blank" rel="noopener noreferrer" style="color: var(--primary-color); text-decoration: none; display: flex; align-items: center; gap: 0.5rem;" title="Melde einen Fehler auf GitHub">
|
||||||
|
<span class="material-icons-round" style="font-size: 1.2em;">bug_report</span> Fehler melden
|
||||||
|
</a>
|
||||||
|
<a href="https://github.com/TauNeutrino/kantine-overview/discussions/categories/ideas" target="_blank" rel="noopener noreferrer" style="color: var(--primary-color); text-decoration: none; display: flex; align-items: center; gap: 0.5rem;" title="Schlage ein neues Feature auf GitHub vor">
|
||||||
|
<span class="material-icons-round" style="font-size: 1.2em;">lightbulb</span> Feature vorschlagen
|
||||||
|
</a>
|
||||||
|
<button id="btn-clear-cache" style="background: none; border: none; padding: 0; color: var(--error-color); text-decoration: none; display: flex; align-items: center; gap: 0.5rem; cursor: pointer; text-align: left; font-size: inherit; font-family: inherit;" title="Löscht alle lokalen Daten & erzwingt einen Neuladen">
|
||||||
|
<span class="material-icons-round" style="font-size: 1.2em;">delete_forever</span> Lokalen Cache leeren
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<main class="container">
|
||||||
|
<div id="last-updated-banner" class="banner hidden">
|
||||||
|
<span class="material-icons-round">update</span>
|
||||||
|
<span id="last-updated-text">Gerade aktualisiert</span>
|
||||||
|
</div>
|
||||||
|
<div id="loading" class="loading-state">
|
||||||
|
<div class="spinner"></div>
|
||||||
|
<p>Lade Menüdaten...</p>
|
||||||
|
</div>
|
||||||
|
<div id="menu-container" class="menu-grid"></div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer class="app-footer">
|
||||||
|
<p>Jetzt Bessa Einfach! • Knapp-Kantine Wrapper • <span id="current-year">${new Date().getFullYear()}</span> by Kaufi 😃👍 mit Hilfe von KI 🤖</p>
|
||||||
|
</footer>
|
||||||
|
</div>`;
|
||||||
|
document.body.innerHTML = htmlContent;
|
||||||
|
}
|
||||||
760
src/ui_helpers.js
Normal file
760
src/ui_helpers.js
Normal file
@@ -0,0 +1,760 @@
|
|||||||
|
import { authToken, currentUser, orderMap, userFlags, pollIntervalId, highlightTags, allWeeks, currentWeekNumber, currentYear, displayMode, langMode, setAuthToken, setCurrentUser, setOrderMap, setUserFlags, setPollIntervalId, setHighlightTags, setAllWeeks, setCurrentWeekNumber, setCurrentYear } from './state.js';
|
||||||
|
import { getISOWeek, getWeekYear, translateDay, escapeHtml, getRelativeTime, isNewer, getLocalizedText } from './utils.js';
|
||||||
|
import { API_BASE, GUEST_TOKEN, VENUE_ID, MENU_ID, POLL_INTERVAL_MS, GITHUB_API, INSTALLER_BASE, CLIENT_VERSION } from './constants.js';
|
||||||
|
import { apiHeaders, githubHeaders } from './api.js';
|
||||||
|
import { placeOrder, cancelOrder, toggleFlag, showToast, checkHighlight, loadMenuDataFromAPI } from './actions.js';
|
||||||
|
|
||||||
|
export function updateNextWeekBadge() {
|
||||||
|
const btnNextWeek = document.getElementById('btn-next-week');
|
||||||
|
let nextWeek = currentWeekNumber + 1;
|
||||||
|
let nextYear = currentYear;
|
||||||
|
if (nextWeek > 52) { nextWeek = 1; nextYear++; }
|
||||||
|
|
||||||
|
const nextWeekData = allWeeks.find(w => w.weekNumber === nextWeek && w.year === nextYear);
|
||||||
|
let totalDataCount = 0;
|
||||||
|
let orderableCount = 0;
|
||||||
|
let daysWithOrders = 0;
|
||||||
|
let daysWithOrderableAndNoOrder = 0;
|
||||||
|
|
||||||
|
if (nextWeekData && nextWeekData.days) {
|
||||||
|
nextWeekData.days.forEach(day => {
|
||||||
|
if (day.items && day.items.length > 0) {
|
||||||
|
totalDataCount++;
|
||||||
|
const isOrderable = day.items.some(item => item.available);
|
||||||
|
if (isOrderable) orderableCount++;
|
||||||
|
|
||||||
|
let hasOrder = false;
|
||||||
|
day.items.forEach(item => {
|
||||||
|
const articleId = item.articleId || parseInt(item.id.split('_')[1]);
|
||||||
|
const key = `${day.date}_${articleId}`;
|
||||||
|
if (orderMap.has(key) && orderMap.get(key).length > 0) hasOrder = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (hasOrder) daysWithOrders++;
|
||||||
|
if (isOrderable && !hasOrder) daysWithOrderableAndNoOrder++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let badge = btnNextWeek.querySelector('.nav-badge');
|
||||||
|
if (totalDataCount > 0) {
|
||||||
|
if (!badge) {
|
||||||
|
badge = document.createElement('span');
|
||||||
|
badge.className = 'nav-badge';
|
||||||
|
btnNextWeek.appendChild(badge);
|
||||||
|
}
|
||||||
|
|
||||||
|
badge.title = `${daysWithOrders} bestellt / ${orderableCount} bestellbar / ${totalDataCount} gesamt`;
|
||||||
|
badge.innerHTML = `<span class="ordered">${daysWithOrders}</span><span class="separator">/</span><span class="orderable">${orderableCount}</span><span class="separator">/</span><span class="total">${totalDataCount}</span>`;
|
||||||
|
|
||||||
|
badge.classList.remove('badge-violet', 'badge-green', 'badge-red', 'badge-blue');
|
||||||
|
|
||||||
|
if (daysWithOrders > 0 && daysWithOrderableAndNoOrder === 0) {
|
||||||
|
badge.classList.add('badge-violet');
|
||||||
|
} else if (daysWithOrderableAndNoOrder > 0) {
|
||||||
|
badge.classList.add('badge-green');
|
||||||
|
} else if (orderableCount === 0) {
|
||||||
|
badge.classList.add('badge-red');
|
||||||
|
} else {
|
||||||
|
badge.classList.add('badge-blue');
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
badge.insertAdjacentHTML('beforeend', `<span class="highlight-count" title="${highlightCount} Highlight Menüs">(${highlightCount})</span>`);
|
||||||
|
badge.title += ` • ${highlightCount} Highlights gefunden`;
|
||||||
|
badge.classList.add('has-highlights');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (daysWithOrders === 0) {
|
||||||
|
btnNextWeek.classList.add('new-week-available');
|
||||||
|
const storageKey = `kantine_notified_nextweek_${nextYear}_${nextWeek}`;
|
||||||
|
if (!localStorage.getItem(storageKey)) {
|
||||||
|
localStorage.setItem(storageKey, 'true');
|
||||||
|
showToast('Neue Menüdaten für nächste Woche verfügbar!', 'info');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
btnNextWeek.classList.remove('new-week-available');
|
||||||
|
}
|
||||||
|
|
||||||
|
} else if (badge) {
|
||||||
|
badge.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateWeeklyCost(days) {
|
||||||
|
let totalCost = 0;
|
||||||
|
if (days && days.length > 0) {
|
||||||
|
days.forEach(day => {
|
||||||
|
if (day.items) {
|
||||||
|
day.items.forEach(item => {
|
||||||
|
const articleId = item.articleId || parseInt(item.id.split('_')[1]);
|
||||||
|
const key = `${day.date}_${articleId}`;
|
||||||
|
const orders = orderMap.get(key) || [];
|
||||||
|
if (orders.length > 0) totalCost += item.price * orders.length;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const costDisplay = document.getElementById('weekly-cost-display');
|
||||||
|
if (totalCost > 0) {
|
||||||
|
costDisplay.innerHTML = `<span class="material-icons-round">shopping_bag</span> <span>Gesamt: ${totalCost.toFixed(2).replace('.', ',')} €</span>`;
|
||||||
|
costDisplay.classList.remove('hidden');
|
||||||
|
} else {
|
||||||
|
costDisplay.classList.add('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderVisibleWeeks() {
|
||||||
|
const menuContainer = document.getElementById('menu-container');
|
||||||
|
if (!menuContainer) return;
|
||||||
|
menuContainer.innerHTML = '';
|
||||||
|
|
||||||
|
let targetWeek = currentWeekNumber;
|
||||||
|
let targetYear = currentYear;
|
||||||
|
|
||||||
|
if (displayMode === 'next-week') {
|
||||||
|
targetWeek++;
|
||||||
|
if (targetWeek > 52) { targetWeek = 1; targetYear++; }
|
||||||
|
}
|
||||||
|
|
||||||
|
const allDays = allWeeks.flatMap(w => w.days || []);
|
||||||
|
const daysInTargetWeek = allDays.filter(day => {
|
||||||
|
const d = new Date(day.date);
|
||||||
|
return getISOWeek(d) === targetWeek && getWeekYear(d) === targetYear;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (daysInTargetWeek.length === 0) {
|
||||||
|
menuContainer.innerHTML = `
|
||||||
|
<div class="empty-state">
|
||||||
|
<p>Keine Menüdaten für KW ${targetWeek} (${targetYear}) verfügbar.</p>
|
||||||
|
<small>Versuchen Sie eine andere Woche oder schauen Sie später vorbei.</small>
|
||||||
|
</div>`;
|
||||||
|
document.getElementById('weekly-cost-display').classList.add('hidden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateWeeklyCost(daysInTargetWeek);
|
||||||
|
|
||||||
|
const headerWeekInfo = document.getElementById('header-week-info');
|
||||||
|
const weekTitle = displayMode === 'this-week' ? 'Diese Woche' : 'Nächste Woche';
|
||||||
|
headerWeekInfo.innerHTML = `
|
||||||
|
<div class="header-week-title">${weekTitle}</div>
|
||||||
|
<div class="header-week-subtitle">Week ${targetWeek} • ${targetYear}</div>`;
|
||||||
|
|
||||||
|
const grid = document.createElement('div');
|
||||||
|
grid.className = 'days-grid';
|
||||||
|
|
||||||
|
daysInTargetWeek.sort((a, b) => a.date.localeCompare(b.date));
|
||||||
|
|
||||||
|
const workingDays = daysInTargetWeek.filter(d => {
|
||||||
|
const date = new Date(d.date);
|
||||||
|
const day = date.getDay();
|
||||||
|
return day !== 0 && day !== 6;
|
||||||
|
});
|
||||||
|
|
||||||
|
workingDays.forEach(day => {
|
||||||
|
const card = createDayCard(day);
|
||||||
|
if (card) grid.appendChild(card);
|
||||||
|
});
|
||||||
|
|
||||||
|
menuContainer.appendChild(grid);
|
||||||
|
setTimeout(() => syncMenuItemHeights(grid), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function syncMenuItemHeights(grid) {
|
||||||
|
const cards = grid.querySelectorAll('.menu-card');
|
||||||
|
if (cards.length === 0) return;
|
||||||
|
let maxItems = 0;
|
||||||
|
cards.forEach(card => {
|
||||||
|
maxItems = Math.max(maxItems, card.querySelectorAll('.menu-item').length);
|
||||||
|
});
|
||||||
|
for (let i = 0; i < maxItems; i++) {
|
||||||
|
let maxHeight = 0;
|
||||||
|
const itemsAtPos = [];
|
||||||
|
cards.forEach(card => {
|
||||||
|
const items = card.querySelectorAll('.menu-item');
|
||||||
|
if (items[i]) {
|
||||||
|
items[i].style.height = 'auto';
|
||||||
|
maxHeight = Math.max(maxHeight, items[i].offsetHeight);
|
||||||
|
itemsAtPos.push(items[i]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
itemsAtPos.forEach(item => { item.style.height = `${maxHeight}px`; });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createDayCard(day) {
|
||||||
|
if (!day.items || day.items.length === 0) return null;
|
||||||
|
|
||||||
|
const card = document.createElement('div');
|
||||||
|
card.className = 'menu-card';
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const cardDate = new Date(day.date);
|
||||||
|
|
||||||
|
let isPastCutoff = false;
|
||||||
|
if (day.orderCutoff) {
|
||||||
|
isPastCutoff = now >= new Date(day.orderCutoff);
|
||||||
|
} else {
|
||||||
|
const today = new Date();
|
||||||
|
today.setHours(0, 0, 0, 0);
|
||||||
|
const cd = new Date(day.date);
|
||||||
|
cd.setHours(0, 0, 0, 0);
|
||||||
|
isPastCutoff = cd < today;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isPastCutoff) card.classList.add('past-day');
|
||||||
|
|
||||||
|
const menuBadges = [];
|
||||||
|
if (day.items) {
|
||||||
|
day.items.forEach(item => {
|
||||||
|
const articleId = item.articleId || parseInt(item.id.split('_')[1]);
|
||||||
|
const orderKey = `${day.date}_${articleId}`;
|
||||||
|
const orders = orderMap.get(orderKey) || [];
|
||||||
|
const count = orders.length;
|
||||||
|
|
||||||
|
if (count > 0) {
|
||||||
|
const match = item.name.match(/([M][1-9][Ff]?)/);
|
||||||
|
if (match) {
|
||||||
|
let code = match[1];
|
||||||
|
if (count > 1) code += '+';
|
||||||
|
menuBadges.push(code);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const header = document.createElement('div');
|
||||||
|
header.className = 'card-header';
|
||||||
|
const dateStr = cardDate.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' });
|
||||||
|
|
||||||
|
const badgesHtml = menuBadges.map(code => `<span class="menu-code-badge">${code}</span>`).join('');
|
||||||
|
|
||||||
|
let headerClass = '';
|
||||||
|
const hasAnyOrder = day.items && day.items.some(item => {
|
||||||
|
const articleId = item.articleId || parseInt(item.id.split('_')[1]);
|
||||||
|
const key = `${day.date}_${articleId}`;
|
||||||
|
return orderMap.has(key) && orderMap.get(key).length > 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
const hasOrderable = day.items && day.items.some(item => item.available);
|
||||||
|
|
||||||
|
if (hasAnyOrder) {
|
||||||
|
headerClass = 'header-violet';
|
||||||
|
} else if (hasOrderable && !isPastCutoff) {
|
||||||
|
headerClass = 'header-green';
|
||||||
|
} else {
|
||||||
|
headerClass = 'header-red';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (headerClass) header.classList.add(headerClass);
|
||||||
|
|
||||||
|
header.innerHTML = `
|
||||||
|
<div class="day-header-left">
|
||||||
|
<span class="day-name">${translateDay(day.weekday)}</span>
|
||||||
|
<div class="day-badges">${badgesHtml}</div>
|
||||||
|
</div>
|
||||||
|
<span class="day-date">${dateStr}</span>`;
|
||||||
|
card.appendChild(header);
|
||||||
|
|
||||||
|
const body = document.createElement('div');
|
||||||
|
body.className = 'card-body';
|
||||||
|
|
||||||
|
const todayDateStr = new Date().toISOString().split('T')[0];
|
||||||
|
const isToday = day.date === todayDateStr;
|
||||||
|
|
||||||
|
const sortedItems = [...day.items].sort((a, b) => {
|
||||||
|
if (isToday) {
|
||||||
|
const aId = a.articleId || parseInt(a.id.split('_')[1]);
|
||||||
|
const bId = b.articleId || parseInt(b.id.split('_')[1]);
|
||||||
|
const aOrdered = orderMap.has(`${day.date}_${aId}`);
|
||||||
|
const bOrdered = orderMap.has(`${day.date}_${bId}`);
|
||||||
|
|
||||||
|
if (aOrdered && !bOrdered) return -1;
|
||||||
|
if (!aOrdered && bOrdered) return 1;
|
||||||
|
}
|
||||||
|
return a.name.localeCompare(b.name);
|
||||||
|
});
|
||||||
|
|
||||||
|
sortedItems.forEach(item => {
|
||||||
|
const itemEl = document.createElement('div');
|
||||||
|
itemEl.className = 'menu-item';
|
||||||
|
|
||||||
|
const articleId = item.articleId || parseInt(item.id.split('_')[1]);
|
||||||
|
const orderKey = `${day.date}_${articleId}`;
|
||||||
|
const orderIds = orderMap.get(orderKey) || [];
|
||||||
|
const orderCount = orderIds.length;
|
||||||
|
|
||||||
|
let statusBadge = '';
|
||||||
|
if (item.available) {
|
||||||
|
statusBadge = item.amountTracking
|
||||||
|
? `<span class="badge available">Verfügbar (${item.availableAmount})</span>`
|
||||||
|
: `<span class="badge available">Verfügbar</span>`;
|
||||||
|
} else {
|
||||||
|
statusBadge = `<span class="badge sold-out">Ausverkauft</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
let orderedBadge = '';
|
||||||
|
if (orderCount > 0) {
|
||||||
|
const countBadge = orderCount > 1 ? `<span class="order-count-badge">${orderCount}</span>` : '';
|
||||||
|
orderedBadge = `<span class="badge ordered"><span class="material-icons-round">check_circle</span> Bestellt${countBadge}</span>`;
|
||||||
|
itemEl.classList.add('ordered');
|
||||||
|
if (new Date(day.date).toDateString() === now.toDateString()) {
|
||||||
|
itemEl.classList.add('today-ordered');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const flagId = `${day.date}_${articleId}`;
|
||||||
|
const isFlagged = userFlags.has(flagId);
|
||||||
|
if (isFlagged) {
|
||||||
|
itemEl.classList.add(item.available ? 'flagged-available' : 'flagged-sold-out');
|
||||||
|
}
|
||||||
|
|
||||||
|
const matchedTags = [...new Set([...checkHighlight(item.name), ...checkHighlight(item.description)])];
|
||||||
|
if (matchedTags.length > 0) {
|
||||||
|
itemEl.classList.add('highlight-glow');
|
||||||
|
}
|
||||||
|
|
||||||
|
let orderButton = '';
|
||||||
|
let cancelButton = '';
|
||||||
|
let flagButton = '';
|
||||||
|
|
||||||
|
if (authToken && !isPastCutoff) {
|
||||||
|
const flagIcon = isFlagged ? 'notifications_active' : 'notifications_none';
|
||||||
|
const flagClass = isFlagged ? 'btn-flag active' : 'btn-flag';
|
||||||
|
const flagTitle = isFlagged ? 'Benachrichtigung deaktivieren' : 'Benachrichtigen wenn verfügbar';
|
||||||
|
if (!item.available || isFlagged) {
|
||||||
|
flagButton = `<button class="${flagClass}" data-date="${day.date}" data-article="${articleId}" data-name="${escapeHtml(item.name)}" data-cutoff="${day.orderCutoff}" title="${flagTitle}"><span class="material-icons-round">${flagIcon}</span></button>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.available) {
|
||||||
|
if (orderCount > 0) {
|
||||||
|
orderButton = `<button class="btn-order btn-order-compact" data-date="${day.date}" data-article="${articleId}" data-name="${escapeHtml(item.name)}" data-price="${item.price}" data-desc="${escapeHtml(item.description || '')}" title="${escapeHtml(item.name)} nochmal bestellen"><span class="material-icons-round">add</span></button>`;
|
||||||
|
} else {
|
||||||
|
orderButton = `<button class="btn-order" data-date="${day.date}" data-article="${articleId}" data-name="${escapeHtml(item.name)}" data-price="${item.price}" data-desc="${escapeHtml(item.description || '')}" title="${escapeHtml(item.name)} bestellen"><span class="material-icons-round">add_shopping_cart</span> Bestellen</button>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (orderCount > 0) {
|
||||||
|
const cancelIcon = orderCount === 1 ? 'close' : 'remove';
|
||||||
|
const cancelTitle = orderCount === 1 ? 'Bestellung stornieren' : 'Eine Bestellung stornieren';
|
||||||
|
cancelButton = `<button class="btn-cancel" data-date="${day.date}" data-article="${articleId}" data-name="${escapeHtml(item.name)}" title="${cancelTitle}"><span class="material-icons-round">${cancelIcon}</span></button>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let tagsHtml = '';
|
||||||
|
if (matchedTags.length > 0) {
|
||||||
|
let badges = '';
|
||||||
|
for (const t of matchedTags) {
|
||||||
|
badges += `<span class="tag-badge-small"><span class="material-icons-round" style="font-size:10px;margin-right:2px">star</span>${escapeHtml(t)}</span>`;
|
||||||
|
}
|
||||||
|
tagsHtml = `<div class="matched-tags">${badges}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
itemEl.innerHTML = `
|
||||||
|
<div class="item-header">
|
||||||
|
<span class="item-name">${escapeHtml(item.name)}</span>
|
||||||
|
<span class="item-price">${item.price.toFixed(2)} €</span>
|
||||||
|
</div>
|
||||||
|
<div class="item-status-row">
|
||||||
|
${orderedBadge}
|
||||||
|
${cancelButton}
|
||||||
|
${orderButton}
|
||||||
|
${flagButton}
|
||||||
|
<div class="badges">${statusBadge}</div>
|
||||||
|
</div>
|
||||||
|
${tagsHtml}
|
||||||
|
<p class="item-desc">${escapeHtml(getLocalizedText(item.description))}</p>`;
|
||||||
|
|
||||||
|
const orderBtn = itemEl.querySelector('.btn-order');
|
||||||
|
if (orderBtn) {
|
||||||
|
orderBtn.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const btn = e.currentTarget;
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.classList.add('loading');
|
||||||
|
placeOrder(btn.dataset.date, parseInt(btn.dataset.article), btn.dataset.name, parseFloat(btn.dataset.price), btn.dataset.desc || '')
|
||||||
|
.finally(() => { btn.disabled = false; btn.classList.remove('loading'); });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const cancelBtn = itemEl.querySelector('.btn-cancel');
|
||||||
|
if (cancelBtn) {
|
||||||
|
cancelBtn.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const btn = e.currentTarget;
|
||||||
|
btn.disabled = true;
|
||||||
|
cancelOrder(btn.dataset.date, parseInt(btn.dataset.article), btn.dataset.name)
|
||||||
|
.finally(() => { btn.disabled = false; });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const flagBtn = itemEl.querySelector('.btn-flag');
|
||||||
|
if (flagBtn) {
|
||||||
|
flagBtn.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const btn = e.currentTarget;
|
||||||
|
toggleFlag(btn.dataset.date, parseInt(btn.dataset.article), btn.dataset.name, btn.dataset.cutoff);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
body.appendChild(itemEl);
|
||||||
|
});
|
||||||
|
|
||||||
|
card.appendChild(body);
|
||||||
|
return card;
|
||||||
|
}
|
||||||
|
|
||||||
|
export 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) {
|
||||||
|
if (resp.status === 403) {
|
||||||
|
throw new Error('API Rate Limit erreicht (403). Bitte später erneut versuchen.');
|
||||||
|
}
|
||||||
|
throw new Error(`GitHub API ${resp.status}`);
|
||||||
|
}
|
||||||
|
const data = await resp.json();
|
||||||
|
|
||||||
|
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 || ''
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function checkForUpdates() {
|
||||||
|
const currentVersion = '{{VERSION}}';
|
||||||
|
const devMode = localStorage.getItem('kantine_dev_mode') === 'true';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const versions = await fetchVersions(devMode);
|
||||||
|
if (!versions.length) return;
|
||||||
|
|
||||||
|
localStorage.setItem('kantine_version_cache', JSON.stringify({
|
||||||
|
timestamp: Date.now(), devMode, versions
|
||||||
|
}));
|
||||||
|
|
||||||
|
const latest = versions[0].tag;
|
||||||
|
|
||||||
|
if (!isNewer(latest, currentVersion)) return;
|
||||||
|
|
||||||
|
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 (e) {
|
||||||
|
console.warn('[Kantine] Version check failed:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function openVersionMenu() {
|
||||||
|
const modal = document.getElementById('version-modal');
|
||||||
|
const container = document.getElementById('version-list-container');
|
||||||
|
const devToggle = document.getElementById('dev-mode-toggle');
|
||||||
|
const currentVersion = '{{VERSION}}';
|
||||||
|
|
||||||
|
if (!modal) return;
|
||||||
|
modal.classList.remove('hidden');
|
||||||
|
|
||||||
|
const cur = document.getElementById('version-current');
|
||||||
|
if (cur) cur.textContent = currentVersion;
|
||||||
|
|
||||||
|
const devMode = localStorage.getItem('kantine_dev_mode') === 'true';
|
||||||
|
devToggle.checked = devMode;
|
||||||
|
|
||||||
|
async function loadVersions(forceRefresh) {
|
||||||
|
const dm = devToggle.checked;
|
||||||
|
container.innerHTML = '<p style="color:var(--text-secondary);">Lade Versionen...</p>';
|
||||||
|
|
||||||
|
function renderVersionsList(versions) {
|
||||||
|
if (!versions || !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="${escapeHtml(v.url)}" target="_blank" class="install-link" title="${escapeHtml(v.tag)} installieren">Installieren</a>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
li.innerHTML = `
|
||||||
|
<div class="version-info">
|
||||||
|
<strong>${escapeHtml(v.tag)}</strong>
|
||||||
|
${badge}
|
||||||
|
</div>
|
||||||
|
${action}
|
||||||
|
`;
|
||||||
|
list.appendChild(li);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const cachedRaw = localStorage.getItem('kantine_version_cache');
|
||||||
|
let cached = null;
|
||||||
|
if (cachedRaw) {
|
||||||
|
try { cached = JSON.parse(cachedRaw); } catch (e) { }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cached && cached.devMode === dm && cached.versions) {
|
||||||
|
renderVersionsList(cached.versions);
|
||||||
|
}
|
||||||
|
|
||||||
|
const liveVersions = await fetchVersions(dm);
|
||||||
|
|
||||||
|
const liveVersionsStr = JSON.stringify(liveVersions);
|
||||||
|
const cachedVersionsStr = cached ? JSON.stringify(cached.versions) : '';
|
||||||
|
|
||||||
|
if (liveVersionsStr !== cachedVersionsStr) {
|
||||||
|
localStorage.setItem('kantine_version_cache', JSON.stringify({
|
||||||
|
timestamp: Date.now(), devMode: dm, versions: liveVersions
|
||||||
|
}));
|
||||||
|
renderVersionsList(liveVersions);
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
container.innerHTML = `<p style="color:#e94560;">Fehler: ${escapeHtml(e.message)}</p>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadVersions(false);
|
||||||
|
|
||||||
|
devToggle.onchange = () => {
|
||||||
|
localStorage.setItem('kantine_dev_mode', devToggle.checked);
|
||||||
|
localStorage.removeItem('kantine_version_cache');
|
||||||
|
loadVersions(true);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateCountdown() {
|
||||||
|
if (!authToken || !currentUser) {
|
||||||
|
removeCountdown();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const currentDay = now.getDay();
|
||||||
|
if (currentDay === 0 || currentDay === 6) {
|
||||||
|
removeCountdown();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const todayStr = now.toISOString().split('T')[0];
|
||||||
|
|
||||||
|
let hasOrder = false;
|
||||||
|
for (const key of orderMap.keys()) {
|
||||||
|
if (key.startsWith(todayStr)) {
|
||||||
|
hasOrder = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasOrder) {
|
||||||
|
removeCountdown();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cutoff = new Date();
|
||||||
|
cutoff.setHours(10, 0, 0, 0);
|
||||||
|
|
||||||
|
const diff = cutoff - now;
|
||||||
|
|
||||||
|
if (diff <= 0) {
|
||||||
|
removeCountdown();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
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';
|
||||||
|
headerCenter.insertBefore(countdownEl, headerCenter.firstChild);
|
||||||
|
}
|
||||||
|
|
||||||
|
countdownEl.innerHTML = `<span>Bestellschluss:</span> <strong>${diffHrs}h ${diffMins}m</strong>`;
|
||||||
|
|
||||||
|
if (diff < 3600000) {
|
||||||
|
countdownEl.classList.add('urgent');
|
||||||
|
|
||||||
|
const notifiedKey = `kantine_notified_${todayStr}`;
|
||||||
|
if (!localStorage.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();
|
||||||
|
}
|
||||||
|
localStorage.setItem(notifiedKey, 'true');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
countdownEl.classList.remove('urgent');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeCountdown() {
|
||||||
|
const el = document.getElementById('order-countdown');
|
||||||
|
if (el) el.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
setInterval(updateCountdown, 60000);
|
||||||
|
setTimeout(updateCountdown, 1000);
|
||||||
|
|
||||||
|
export function showErrorModal(title, htmlContent, btnText, url) {
|
||||||
|
const modalId = 'error-modal';
|
||||||
|
let modal = document.getElementById(modalId);
|
||||||
|
if (modal) modal.remove();
|
||||||
|
|
||||||
|
modal = document.createElement('div');
|
||||||
|
modal.id = modalId;
|
||||||
|
modal.className = 'modal hidden';
|
||||||
|
modal.innerHTML = `
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h2 style="color: var(--error-color); display: flex; align-items: center; gap: 10px;">
|
||||||
|
<span class="material-icons-round">signal_wifi_off</span>
|
||||||
|
${escapeHtml(title)}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<div style="padding: 20px;">
|
||||||
|
<p style="margin-bottom: 15px; color: var(--text-primary);">${htmlContent}</p>
|
||||||
|
<div style="margin-top: 20px; display: flex; justify-content: center;">
|
||||||
|
<button id="btn-error-redirect" style="
|
||||||
|
background-color: var(--accent-color);
|
||||||
|
color: white;
|
||||||
|
padding: 12px 24px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: none;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
transition: transform 0.1s;
|
||||||
|
">
|
||||||
|
${escapeHtml(btnText)}
|
||||||
|
<span class="material-icons-round">open_in_new</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
document.body.appendChild(modal);
|
||||||
|
|
||||||
|
document.getElementById('btn-error-redirect').addEventListener('click', () => {
|
||||||
|
window.location.href = url;
|
||||||
|
});
|
||||||
|
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
modal.classList.remove('hidden');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateAlarmBell() {
|
||||||
|
const bellBtn = document.getElementById('alarm-bell');
|
||||||
|
const bellIcon = document.getElementById('alarm-bell-icon');
|
||||||
|
if (!bellBtn || !bellIcon) return;
|
||||||
|
|
||||||
|
if (userFlags.size === 0) {
|
||||||
|
bellBtn.classList.add('hidden');
|
||||||
|
bellBtn.style.display = 'none';
|
||||||
|
bellIcon.style.color = 'var(--text-secondary)';
|
||||||
|
bellIcon.style.textShadow = 'none';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
bellBtn.classList.remove('hidden');
|
||||||
|
bellBtn.style.display = 'inline-flex';
|
||||||
|
|
||||||
|
let anyAvailable = false;
|
||||||
|
for (const wk of allWeeks) {
|
||||||
|
if (!wk.days) continue;
|
||||||
|
for (const d of wk.days) {
|
||||||
|
if (!d.items) continue;
|
||||||
|
for (const item of d.items) {
|
||||||
|
if (item.available && userFlags.has(item.id)) {
|
||||||
|
anyAvailable = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (anyAvailable) break;
|
||||||
|
}
|
||||||
|
if (anyAvailable) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastCheckedStr = localStorage.getItem('kantine_last_checked');
|
||||||
|
const flaggedLastCheckedStr = localStorage.getItem('kantine_flagged_items_last_checked');
|
||||||
|
|
||||||
|
let latestTime = 0;
|
||||||
|
if (lastCheckedStr) latestTime = Math.max(latestTime, new Date(lastCheckedStr).getTime());
|
||||||
|
if (flaggedLastCheckedStr) latestTime = Math.max(latestTime, new Date(flaggedLastCheckedStr).getTime());
|
||||||
|
|
||||||
|
let timeStr = 'gerade eben';
|
||||||
|
if (latestTime === 0) {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
localStorage.setItem('kantine_last_checked', now);
|
||||||
|
latestTime = new Date(now).getTime();
|
||||||
|
}
|
||||||
|
|
||||||
|
timeStr = getRelativeTime(new Date(latestTime));
|
||||||
|
|
||||||
|
bellBtn.title = `Zuletzt geprüft: ${timeStr}`;
|
||||||
|
|
||||||
|
if (anyAvailable) {
|
||||||
|
bellIcon.style.color = '#10b981';
|
||||||
|
bellIcon.style.textShadow = '0 0 10px rgba(16, 185, 129, 0.4)';
|
||||||
|
} else {
|
||||||
|
bellIcon.style.color = '#f59e0b';
|
||||||
|
bellIcon.style.textShadow = '0 0 10px rgba(245, 158, 11, 0.4)';
|
||||||
|
}
|
||||||
|
}
|
||||||
241
src/utils.js
Normal file
241
src/utils.js
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
import { langMode } from './state.js';
|
||||||
|
|
||||||
|
export function getISOWeek(date) {
|
||||||
|
const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()));
|
||||||
|
const dayNum = d.getUTCDay() || 7;
|
||||||
|
d.setUTCDate(d.getUTCDate() + 4 - dayNum);
|
||||||
|
const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
|
||||||
|
return Math.ceil(((d - yearStart) / 86400000 + 1) / 7);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getWeekYear(d) {
|
||||||
|
const date = new Date(d.getTime());
|
||||||
|
date.setDate(date.getDate() + 3 - (date.getDay() + 6) % 7);
|
||||||
|
return date.getFullYear();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function translateDay(englishDay) {
|
||||||
|
const map = { Monday: 'Montag', Tuesday: 'Dienstag', Wednesday: 'Mittwoch', Thursday: 'Donnerstag', Friday: 'Freitag', Saturday: 'Samstag', Sunday: 'Sonntag' };
|
||||||
|
return map[englishDay] || englishDay;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function escapeHtml(text) {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = text || '';
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
export 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
export 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.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Language Filter (FR-100) ===
|
||||||
|
const DE_STEMS = [
|
||||||
|
'apfel', 'achtung', 'aubergine', 'auflauf', 'beere', 'blumenkohl', 'bohne', 'braten', 'brokkoli', 'brot', 'brust',
|
||||||
|
'brötchen', 'butter', 'chili', 'dessert', 'dip', 'eier', 'eintopf', 'eis', 'erbse', 'erdbeer',
|
||||||
|
'essig', 'filet', 'fisch', 'fisole', 'fleckerl', 'fleisch', 'flügel', 'frucht', 'für', 'gebraten',
|
||||||
|
'gemüse', 'gewürz', 'gratin', 'grieß', 'gulasch', 'gurke', 'himbeer', 'honig', 'huhn', 'hähnchen',
|
||||||
|
'jambalaya', 'joghurt', 'karotte', 'kartoffel', 'keule', 'kirsch', 'knacker', 'knoblauch', 'knödel', 'kompott',
|
||||||
|
'kraut', 'kräuter', 'kuchen', 'käse', 'kürbis', 'lauch', 'mandel', 'milch', 'mild', 'mit',
|
||||||
|
'mohn', 'most', 'möhre', 'natur', 'nockerl', 'nudel', 'nuss', 'nuß', 'obst', 'oder',
|
||||||
|
'olive', 'paprika', 'pfanne', 'pfannkuchen', 'pfeffer', 'pikant', 'pilz', 'plunder', 'püree', 'ragout',
|
||||||
|
'rahm', 'reis', 'rind', 'sahne', 'salami', 'salat', 'salz', 'sauer', 'scharf', 'schinken',
|
||||||
|
'schnitte', 'schnitzel', 'schoko', 'schupf', 'schwein', 'sellerie', 'senf', 'sosse', 'soße', 'spargel',
|
||||||
|
'spätzle', 'speck', 'spieß', 'spinat', 'steak', 'suppe', 'süß', 'tofu', 'tomate', 'topfen',
|
||||||
|
'torte', 'trüffel', 'und', 'vanille', 'vogerl', 'vom', 'wien', 'wurst', 'zucchini', 'zum',
|
||||||
|
'zur', 'zwiebel', 'öl'
|
||||||
|
];
|
||||||
|
|
||||||
|
const EN_STEMS = [
|
||||||
|
'almond', 'and', 'apple', 'asparagus', 'bacon', 'baked', 'ball', 'bean', 'beef', 'berry',
|
||||||
|
'bread', 'breast', 'broccoli', 'bun', 'butter', 'cabbage', 'cake', 'caper', 'carrot', 'casserole',
|
||||||
|
'cauliflower', 'celery', 'cheese', 'cherry', 'chicken', 'chili', 'choco', 'chocolate', 'cider', 'cilantro',
|
||||||
|
'coffee', 'compote', 'cream', 'cucumber', 'curd', 'danish', 'dessert', 'dip', 'dumpling', 'egg',
|
||||||
|
'eggplant', 'filet', 'fish', 'for', 'fried', 'from', 'fruit', 'garlic', 'goulash', 'gratin',
|
||||||
|
'ham', 'herb', 'honey', 'hot', 'ice', 'jambalaya', 'leek', 'leg', 'mash', 'meat',
|
||||||
|
'mexican', 'mild', 'milk', 'mint', 'mushroom', 'mustard', 'noodle', 'nut', 'oat', 'oil',
|
||||||
|
'olive', 'onion', 'or', 'oven', 'pan', 'pancake', 'pea', 'pepper', 'plain', 'plate',
|
||||||
|
'poppy', 'pork', 'potato', 'pumpkin', 'radish', 'ragout', 'raspberry', 'rice', 'roast', 'roll',
|
||||||
|
'salad', 'salami', 'salt', 'sauce', 'sausage', 'shrimp', 'skewer', 'slice', 'soup', 'sour',
|
||||||
|
'spice', 'spicy', 'spinach', 'steak', 'stew', 'strawberr', 'strawberry', 'strudel', 'sweet', 'tart',
|
||||||
|
'thyme', 'to', 'tofu', 'tomat', 'tomato', 'truffle', 'trukey', 'turkey', 'vanilla', 'vegan',
|
||||||
|
'vegetable', 'vinegar', 'wedge', 'wing', 'with', 'wok', 'yogurt', 'zucchini'
|
||||||
|
];
|
||||||
|
|
||||||
|
export function splitLanguage(text) {
|
||||||
|
if (!text) return { de: '', en: '', raw: '' };
|
||||||
|
|
||||||
|
const raw = text;
|
||||||
|
let formattedRaw = text.replace(/(?:\(|(?:\/|\s|^))([A-Z,]+)\)\s*(?=\S)(?!\s*\/)/g, '($1)\n• ');
|
||||||
|
if (!formattedRaw.startsWith('• ')) {
|
||||||
|
formattedRaw = '• ' + formattedRaw;
|
||||||
|
}
|
||||||
|
|
||||||
|
function scoreBlock(wordArray) {
|
||||||
|
let de = 0, en = 0;
|
||||||
|
wordArray.forEach(word => {
|
||||||
|
const w = word.toLowerCase().replace(/[^a-zäöüß]/g, '');
|
||||||
|
if (w) {
|
||||||
|
let bestDeMatch = 0;
|
||||||
|
let bestEnMatch = 0;
|
||||||
|
if (DE_STEMS.includes(w)) bestDeMatch = w.length;
|
||||||
|
else DE_STEMS.forEach(s => { if (w.includes(s) && s.length > bestDeMatch) bestDeMatch = s.length; });
|
||||||
|
|
||||||
|
if (EN_STEMS.includes(w)) bestEnMatch = w.length;
|
||||||
|
else EN_STEMS.forEach(s => { if (w.includes(s) && s.length > bestEnMatch) bestEnMatch = s.length; });
|
||||||
|
|
||||||
|
if (bestDeMatch > 0) de += (bestDeMatch / w.length);
|
||||||
|
if (bestEnMatch > 0) en += (bestEnMatch / w.length);
|
||||||
|
|
||||||
|
if (/^[A-ZÄÖÜ]/.test(word)) {
|
||||||
|
de += 0.5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return { de, en };
|
||||||
|
}
|
||||||
|
|
||||||
|
function heuristicSplitEnDe(fragment) {
|
||||||
|
const words = fragment.trim().split(/\s+/);
|
||||||
|
if (words.length < 2) return { enPart: fragment, nextDe: '' };
|
||||||
|
|
||||||
|
let bestK = -1;
|
||||||
|
let maxScore = -9999;
|
||||||
|
|
||||||
|
for (let k = 1; k < words.length; k++) {
|
||||||
|
const left = words.slice(0, k);
|
||||||
|
const right = words.slice(k);
|
||||||
|
|
||||||
|
const leftScore = scoreBlock(left);
|
||||||
|
const rightScore = scoreBlock(right);
|
||||||
|
|
||||||
|
const rightFirstWord = right[0];
|
||||||
|
let capitalBonus = 0;
|
||||||
|
if (/^[A-ZÄÖÜ]/.test(rightFirstWord)) {
|
||||||
|
capitalBonus = 1.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const score = (leftScore.en - leftScore.de) + (rightScore.de - rightScore.en) + capitalBonus;
|
||||||
|
|
||||||
|
const leftLooksEnglish = (leftScore.en > leftScore.de) || (leftScore.en > 0);
|
||||||
|
const rightLooksGerman = (rightScore.de + capitalBonus) > rightScore.en;
|
||||||
|
|
||||||
|
if (leftLooksEnglish && rightLooksGerman && score > maxScore) {
|
||||||
|
maxScore = score;
|
||||||
|
bestK = k;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bestK !== -1) {
|
||||||
|
return {
|
||||||
|
enPart: words.slice(0, bestK).join(' '),
|
||||||
|
nextDe: words.slice(bestK).join(' ')
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { enPart: fragment, nextDe: '' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const allergenRegex = /(.*?)(?:\(|(?:\/|\s|^))([A-Z,]+)\)\s*(?!\s*[/])/g;
|
||||||
|
let match;
|
||||||
|
const rawCourses = [];
|
||||||
|
let lastScanIndex = 0;
|
||||||
|
|
||||||
|
while ((match = allergenRegex.exec(text)) !== null) {
|
||||||
|
if (match.index > lastScanIndex) {
|
||||||
|
rawCourses.push(text.substring(lastScanIndex, match.index).trim());
|
||||||
|
}
|
||||||
|
rawCourses.push(match[0].trim());
|
||||||
|
lastScanIndex = allergenRegex.lastIndex;
|
||||||
|
}
|
||||||
|
if (lastScanIndex < text.length) {
|
||||||
|
rawCourses.push(text.substring(lastScanIndex).trim());
|
||||||
|
}
|
||||||
|
if (rawCourses.length === 0 && text.trim() !== '') {
|
||||||
|
rawCourses.push(text.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
const deParts = [];
|
||||||
|
const enParts = [];
|
||||||
|
|
||||||
|
for (let course of rawCourses) {
|
||||||
|
let courseMatch = course.match(/(.*?)(?:\(|(?:\/|\s|^))([A-Z,]+)\)\s*$/);
|
||||||
|
let courseText = course;
|
||||||
|
let allergenTxt = "";
|
||||||
|
let allergenCode = "";
|
||||||
|
|
||||||
|
if (courseMatch) {
|
||||||
|
courseText = courseMatch[1].trim();
|
||||||
|
allergenCode = courseMatch[2];
|
||||||
|
allergenTxt = ` (${allergenCode})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const slashParts = courseText.split(/\s*\/\s*(?![A-Z,]+$)/);
|
||||||
|
|
||||||
|
if (slashParts.length >= 2) {
|
||||||
|
const deCandidate = slashParts[0].trim();
|
||||||
|
let enCandidate = slashParts.slice(1).join(' / ').trim();
|
||||||
|
|
||||||
|
const nestedSplit = heuristicSplitEnDe(enCandidate);
|
||||||
|
if (nestedSplit.nextDe) {
|
||||||
|
deParts.push(deCandidate + allergenTxt);
|
||||||
|
enParts.push(nestedSplit.enPart + allergenTxt);
|
||||||
|
|
||||||
|
const nestedDe = nestedSplit.nextDe + allergenTxt;
|
||||||
|
deParts.push(nestedDe);
|
||||||
|
enParts.push(nestedDe);
|
||||||
|
} else {
|
||||||
|
const enFinal = enCandidate + allergenTxt;
|
||||||
|
const deFinal = deCandidate.includes(allergenTxt.trim()) ? deCandidate : (deCandidate + allergenTxt);
|
||||||
|
|
||||||
|
deParts.push(deFinal);
|
||||||
|
enParts.push(enFinal);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const heuristicSplit = heuristicSplitEnDe(courseText);
|
||||||
|
if (heuristicSplit.nextDe) {
|
||||||
|
enParts.push(heuristicSplit.enPart + allergenTxt);
|
||||||
|
deParts.push(heuristicSplit.nextDe + allergenTxt);
|
||||||
|
} else {
|
||||||
|
deParts.push(courseText + allergenTxt);
|
||||||
|
enParts.push(courseText + allergenTxt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let deJoined = deParts.join('\n• ');
|
||||||
|
if (deParts.length > 0 && !deJoined.startsWith('• ')) deJoined = '• ' + deJoined;
|
||||||
|
|
||||||
|
let enJoined = enParts.join('\n• ');
|
||||||
|
if (enParts.length > 0 && !enJoined.startsWith('• ')) enJoined = '• ' + enJoined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
de: deJoined,
|
||||||
|
en: enJoined,
|
||||||
|
raw: formattedRaw
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getLocalizedText(text) {
|
||||||
|
if (langMode === 'all') return text || '';
|
||||||
|
const split = splitLanguage(text);
|
||||||
|
if (langMode === 'en') return split.en || split.raw;
|
||||||
|
return split.de || split.raw;
|
||||||
|
}
|
||||||
751
style.css
751
style.css
@@ -56,12 +56,22 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Fix scrolling bug: Reset html/body styles from host page */
|
/* Fix scrolling bug: Reset html/body styles from host page */
|
||||||
html,
|
/* IMPORTANT: html must NOT have overflow set, or it creates a scroll container that breaks position: sticky */
|
||||||
|
html {
|
||||||
|
height: auto !important;
|
||||||
|
min-height: 100% !important;
|
||||||
|
overflow: visible !important;
|
||||||
|
position: static !important;
|
||||||
|
margin: 0 !important;
|
||||||
|
padding: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
height: auto !important;
|
height: auto !important;
|
||||||
min-height: 100% !important;
|
min-height: 100% !important;
|
||||||
overflow-y: auto !important;
|
overflow-x: clip !important;
|
||||||
overflow-x: hidden !important;
|
/* clip prevents horizontal overflow without breaking sticky */
|
||||||
|
overflow-y: visible !important;
|
||||||
position: static !important;
|
position: static !important;
|
||||||
margin: 0 !important;
|
margin: 0 !important;
|
||||||
padding: 0 !important;
|
padding: 0 !important;
|
||||||
@@ -69,8 +79,7 @@ body {
|
|||||||
|
|
||||||
/* Header */
|
/* Header */
|
||||||
.app-header {
|
.app-header {
|
||||||
position: sticky;
|
flex-shrink: 0;
|
||||||
top: 0;
|
|
||||||
z-index: 100;
|
z-index: 100;
|
||||||
backdrop-filter: blur(12px);
|
backdrop-filter: blur(12px);
|
||||||
background-color: var(--header-bg);
|
background-color: var(--header-bg);
|
||||||
@@ -141,17 +150,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;
|
||||||
@@ -164,6 +162,38 @@ body {
|
|||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Language Toggle (FR-100) */
|
||||||
|
.lang-toggle {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 0;
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
background: var(--bg-card);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lang-btn {
|
||||||
|
padding: 3px 10px;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lang-btn:hover {
|
||||||
|
color: var(--text-primary);
|
||||||
|
background: rgba(100, 116, 139, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lang-btn.active {
|
||||||
|
background: var(--accent-color);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
.nav-group {
|
.nav-group {
|
||||||
display: flex;
|
display: flex;
|
||||||
background-color: var(--bg-card);
|
background-color: var(--bg-card);
|
||||||
@@ -197,6 +227,32 @@ body {
|
|||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Notification state for Next Week */
|
||||||
|
.nav-btn.new-week-available {
|
||||||
|
animation: goldPulse 2s infinite;
|
||||||
|
border-color: #f59e0b;
|
||||||
|
color: var(--accent-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-btn.new-week-available.active {
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes goldPulse {
|
||||||
|
0% {
|
||||||
|
box-shadow: 0 0 0 0 rgba(245, 158, 11, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
70% {
|
||||||
|
box-shadow: 0 0 0 10px rgba(245, 158, 11, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
box-shadow: 0 0 0 0 rgba(245, 158, 11, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/* Badge for nav buttons (day count indicator) */
|
/* Badge for nav buttons (day count indicator) */
|
||||||
.nav-badge {
|
.nav-badge {
|
||||||
background-color: var(--error-color);
|
background-color: var(--error-color);
|
||||||
@@ -365,13 +421,21 @@ body {
|
|||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Container */
|
/* Container - flex column, full width so child scrollbar is at edge */
|
||||||
.container {
|
.container {
|
||||||
|
flex: 1;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
/* Full width */
|
overflow: hidden;
|
||||||
margin: 2rem auto;
|
padding: 0 0 0 0;
|
||||||
padding: 0 2rem;
|
/* Only top padding, no horizontal so child fills width */
|
||||||
min-height: 80vh;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Add horizontal padding to direct children of container to maintain layout */
|
||||||
|
.container>*:not(.menu-grid) {
|
||||||
|
padding-left: 2rem;
|
||||||
|
padding-right: 2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Banner */
|
/* Banner */
|
||||||
@@ -448,7 +512,6 @@ body {
|
|||||||
|
|
||||||
.modal-content {
|
.modal-content {
|
||||||
background: var(--bg-card);
|
background: var(--bg-card);
|
||||||
/* Changed from --surface */
|
|
||||||
width: 90%;
|
width: 90%;
|
||||||
max-width: 400px;
|
max-width: 400px;
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
@@ -457,6 +520,174 @@ body {
|
|||||||
animation: modalSlide 0.3s ease-out;
|
animation: modalSlide 0.3s ease-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* History Modal specific */
|
||||||
|
.history-modal-content {
|
||||||
|
max-width: 600px;
|
||||||
|
max-height: 85vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-modal-content .modal-body {
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 0;
|
||||||
|
/* Padding is handled by inner elements */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* History Styles */
|
||||||
|
.history-year-group {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-year-header {
|
||||||
|
background: var(--bg-card);
|
||||||
|
padding: 12px 20px;
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
border-bottom: 2px solid var(--border-color);
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 12;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-month-group {
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-month-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 14px 20px;
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
background: var(--bg-body);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-month-header:hover {
|
||||||
|
background: var(--border-color);
|
||||||
|
/* Slight hover effect */
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-month-summary {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-month-content {
|
||||||
|
display: none;
|
||||||
|
/* Collapsed by default */
|
||||||
|
background: var(--bg-card);
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-month-group.open .history-month-content {
|
||||||
|
display: block;
|
||||||
|
/* Expanded when open class is present */
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-month-group.open .history-month-header .material-icons-round {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-month-header .material-icons-round {
|
||||||
|
transition: transform 0.3s;
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-week-group {
|
||||||
|
padding: 12px 20px;
|
||||||
|
border-bottom: 1px dashed var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-week-group:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-week-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-week-summary {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 500;
|
||||||
|
background: rgba(100, 116, 139, 0.1);
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-items {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-item {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 50px 1fr auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: var(--bg-body);
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-item-date {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-item-details {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-item-name {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-item-price {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-item-status {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-item-cancelled {
|
||||||
|
opacity: 0.5;
|
||||||
|
filter: grayscale(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-item-price-cancelled {
|
||||||
|
text-decoration: line-through;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes modalSlide {
|
@keyframes modalSlide {
|
||||||
from {
|
from {
|
||||||
transform: translateY(20px);
|
transform: translateY(20px);
|
||||||
@@ -475,7 +706,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 +713,10 @@ body {
|
|||||||
font-size: 1.25rem;
|
font-size: 1.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.modal-body {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
#login-form {
|
#login-form {
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
}
|
}
|
||||||
@@ -550,14 +784,17 @@ body {
|
|||||||
display: none !important;
|
display: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Menu Grid */
|
/* Menu Grid Container */
|
||||||
.menu-grid {
|
.menu-grid {
|
||||||
display: grid;
|
display: flex;
|
||||||
gap: 2rem;
|
flex-direction: column;
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.week-section {
|
.week-section {
|
||||||
margin-bottom: 3rem;
|
margin-bottom: 2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.week-header {
|
.week-header {
|
||||||
@@ -579,10 +816,25 @@ body {
|
|||||||
margin-top: 0.25rem;
|
margin-top: 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Full-viewport layout: header + scrollable content + footer */
|
||||||
|
#kantine-wrapper {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100vh;
|
||||||
|
height: 100dvh;
|
||||||
|
/* Dynamic viewport height for mobile browsers */
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
.days-grid {
|
.days-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
/* This is the scroll container at the window edge */
|
||||||
|
align-content: start;
|
||||||
|
padding: 0 2rem 2rem 2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Card */
|
/* Card */
|
||||||
@@ -591,68 +843,68 @@ body {
|
|||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
box-shadow: var(--card-shadow);
|
box-shadow: var(--card-shadow);
|
||||||
overflow: hidden;
|
overflow: clip;
|
||||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
/* Clips scrolling content behind sticky header */
|
||||||
|
transition: box-shadow 0.2s ease;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Past Day Styling - Target specific elements so ordered items can remain visible */
|
/* Past Day Styling - Target specific elements so ordered items can remain visible AND preserve sticky context */
|
||||||
.menu-card.past-day .card-header,
|
/* We MUST apply filter/opacity to children, not the parent .menu-card, or else position: sticky breaks */
|
||||||
|
|
||||||
|
/* Header keeps fully opaque background to hide scrolling items, only grayscales */
|
||||||
|
.menu-card.past-day .card-header {
|
||||||
|
filter: grayscale(0.8);
|
||||||
|
transition: filter 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Items become semi-transparent */
|
||||||
.menu-card.past-day .menu-item:not(.ordered) {
|
.menu-card.past-day .menu-item:not(.ordered) {
|
||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
filter: grayscale(0.8);
|
filter: grayscale(0.8);
|
||||||
transition: opacity 0.3s, filter 0.3s;
|
transition: opacity 0.3s, filter 0.3s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.menu-card.past-day:hover .card-header,
|
.menu-card.past-day:hover .card-header {
|
||||||
|
filter: grayscale(0.4);
|
||||||
|
}
|
||||||
|
|
||||||
.menu-card.past-day:hover .menu-item:not(.ordered) {
|
.menu-card.past-day:hover .menu-item:not(.ordered) {
|
||||||
opacity: 0.8;
|
opacity: 0.8;
|
||||||
filter: grayscale(0.4);
|
filter: grayscale(0.4);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Enhancements for ordered items */
|
/* Past ordered items get no special frame or shadow, but remain visually distinct by staying fully opaque (via the :not(.ordered) selector above) */
|
||||||
.menu-card.past-day .menu-item.ordered {
|
|
||||||
/* No opacity/filter here - fully visible */
|
|
||||||
background: var(--bg-card);
|
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
|
||||||
border: 1px solid var(--accent-color);
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 1rem;
|
|
||||||
margin: 0 -1rem 1.5rem -1rem;
|
|
||||||
position: relative;
|
|
||||||
z-index: 10;
|
|
||||||
}
|
|
||||||
|
|
||||||
.menu-item.today-ordered {
|
.menu-item.today-ordered {
|
||||||
border: 2px solid var(--accent-color);
|
border: 2px solid #8b5cf6;
|
||||||
box-shadow: 0 0 20px rgba(96, 165, 250, 0.4);
|
box-shadow: 0 0 30px rgba(139, 92, 246, 0.6);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
margin: 0 -1rem 1.5rem -1rem;
|
margin: 0 -1rem 1.5rem -1rem;
|
||||||
background: var(--bg-card);
|
background: var(--bg-card);
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 5;
|
z-index: 5;
|
||||||
animation: pulse-glow 3s infinite;
|
animation: pulse-glow-strong 3s infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes pulse-glow {
|
@keyframes pulse-glow-strong {
|
||||||
0% {
|
0% {
|
||||||
box-shadow: 0 0 15px rgba(96, 165, 250, 0.3);
|
box-shadow: 0 0 20px rgba(139, 92, 246, 0.4);
|
||||||
}
|
}
|
||||||
|
|
||||||
50% {
|
50% {
|
||||||
box-shadow: 0 0 25px rgba(96, 165, 250, 0.6);
|
box-shadow: 0 0 40px rgba(139, 92, 246, 0.8);
|
||||||
}
|
}
|
||||||
|
|
||||||
100% {
|
100% {
|
||||||
box-shadow: 0 0 15px rgba(96, 165, 250, 0.3);
|
box-shadow: 0 0 20px rgba(139, 92, 246, 0.4);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.menu-card:hover {
|
.menu-card:hover {
|
||||||
transform: translateY(-2px);
|
|
||||||
box-shadow: 0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1);
|
box-shadow: 0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -662,7 +914,23 @@ body {
|
|||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: baseline;
|
align-items: baseline;
|
||||||
background-color: rgba(100, 116, 139, 0.05);
|
background-color: var(--bg-card);
|
||||||
|
|
||||||
|
/* Removed border-radius: 12px 12px 0 0;
|
||||||
|
.menu-card's overflow: clip will round the corners initially.
|
||||||
|
When sticky at the top, it will be square and perfectly hide scrolling content! */
|
||||||
|
|
||||||
|
/* Sticky within .container scroll area */
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 90;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-body {
|
||||||
|
padding: 1.25rem;
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: auto;
|
||||||
|
align-content: start;
|
||||||
}
|
}
|
||||||
|
|
||||||
.day-name {
|
.day-name {
|
||||||
@@ -675,13 +943,6 @@ body {
|
|||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-body {
|
|
||||||
padding: 1.25rem;
|
|
||||||
display: grid;
|
|
||||||
grid-template-rows: auto;
|
|
||||||
/* Each menu item gets its own row */
|
|
||||||
align-content: start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-state {
|
.empty-state {
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
@@ -728,6 +989,7 @@ body {
|
|||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
margin-bottom: 0.75rem;
|
margin-bottom: 0.75rem;
|
||||||
|
white-space: pre-wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.badges {
|
.badges {
|
||||||
@@ -809,12 +1071,12 @@ body {
|
|||||||
|
|
||||||
/* Footer */
|
/* Footer */
|
||||||
.app-footer {
|
.app-footer {
|
||||||
|
flex-shrink: 0;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 2rem;
|
padding: 0.4rem 2rem;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
font-size: 0.875rem;
|
font-size: 0.8rem;
|
||||||
border-top: 1px solid var(--border-color);
|
border-top: 1px solid var(--border-color);
|
||||||
margin-top: auto;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* === Order / Cancel Buttons (inline in status row) === */
|
/* === Order / Cancel Buttons (inline in status row) === */
|
||||||
@@ -1145,17 +1407,20 @@ body {
|
|||||||
|
|
||||||
/* Day Header Status Colors (User Request) */
|
/* Day Header Status Colors (User Request) */
|
||||||
.card-header.header-violet {
|
.card-header.header-violet {
|
||||||
background-color: rgba(139, 92, 246, 0.15);
|
background-color: var(--bg-card);
|
||||||
|
background-image: linear-gradient(rgba(139, 92, 246, 0.15), rgba(139, 92, 246, 0.15));
|
||||||
border-bottom: 2px solid #8b5cf6;
|
border-bottom: 2px solid #8b5cf6;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-header.header-green {
|
.card-header.header-green {
|
||||||
background-color: rgba(16, 185, 129, 0.15);
|
background-color: var(--bg-card);
|
||||||
|
background-image: linear-gradient(rgba(16, 185, 129, 0.15), rgba(16, 185, 129, 0.15));
|
||||||
border-bottom: 2px solid var(--success-color);
|
border-bottom: 2px solid var(--success-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-header.header-red {
|
.card-header.header-red {
|
||||||
background-color: rgba(239, 68, 68, 0.15);
|
background-color: var(--bg-card);
|
||||||
|
background-image: linear-gradient(rgba(239, 68, 68, 0.15), rgba(239, 68, 68, 0.15));
|
||||||
border-bottom: 2px solid var(--error-color);
|
border-bottom: 2px solid var(--error-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1166,13 +1431,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 +1458,351 @@ 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;
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
17
syntax_check.js
Executable file
17
syntax_check.js
Executable file
@@ -0,0 +1,17 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
const jsCode = fs.readFileSync('kantine.js', 'utf8')
|
||||||
|
.replace('(function () {', '')
|
||||||
|
.replace('})();', '')
|
||||||
|
.replace('if (window.__KANTINE_LOADED) return;', '');
|
||||||
|
const testCode = `
|
||||||
|
console.log("TEST");
|
||||||
|
`;
|
||||||
|
const code = jsCode + '\n' + testCode;
|
||||||
|
try {
|
||||||
|
const vm = require('vm');
|
||||||
|
new vm.Script(code);
|
||||||
|
} catch (e) {
|
||||||
|
if(e.stack) {
|
||||||
|
console.log("Syntax error at:", e.stack.split('\n').slice(0,3).join('\n'));
|
||||||
|
}
|
||||||
|
}
|
||||||
4
test-results/.last-run.json
Normal file
4
test-results/.last-run.json
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"status": "failed",
|
||||||
|
"failedTests": []
|
||||||
|
}
|
||||||
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')"],
|
||||||
|
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()
|
||||||
204
test_logic.js
Executable file
204
test_logic.js
Executable file
@@ -0,0 +1,204 @@
|
|||||||
|
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, 'dist', 'kantine.bundle.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', json: async () => ({}) };
|
||||||
|
}
|
||||||
|
// Mock Changelog
|
||||||
|
if (url.includes('changelog.md')) {
|
||||||
|
return { ok: true, text: async () => '## v9.9.9\n- Feature: Cool Stuff', json: async () => ({}) };
|
||||||
|
}
|
||||||
|
// Mock GitHub Tags API
|
||||||
|
if (url.includes('api.github.com/') || url.includes('/tags')) {
|
||||||
|
return { ok: true, json: async () => [{ name: 'v9.9.9' }] };
|
||||||
|
}
|
||||||
|
// Mock Menu API
|
||||||
|
if (url.includes('/venues/') && url.includes('/menu/')) {
|
||||||
|
return { ok: true, json: async () => ({ dates: [], menu: {}, results: [] }) };
|
||||||
|
}
|
||||||
|
// Mock Orders API
|
||||||
|
if (url.includes('/user/orders')) {
|
||||||
|
return { ok: true, json: async () => ({ results: [], count: 0 }) };
|
||||||
|
}
|
||||||
|
return { ok: false, status: 404, text: async () => '', json: async () => ({}) };
|
||||||
|
},
|
||||||
|
document: {
|
||||||
|
body: createMockElement('body'),
|
||||||
|
head: createMockElement('head'),
|
||||||
|
createElement: (tag) => createMockElement(tag),
|
||||||
|
querySelector: (sel) => {
|
||||||
|
if (sel === '.logo-img' || 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');
|
||||||
|
},
|
||||||
|
querySelectorAll: () => [createMockElement()],
|
||||||
|
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);
|
||||||
|
// Execute module to get function reference, since IIFE creates private scope
|
||||||
|
// For test_logic.js we need to evaluate the raw utils.js code to test splitLanguage directly
|
||||||
|
const utilsCode = require('fs').readFileSync(require('path').join(__dirname, 'src', 'utils.js'), 'utf8');
|
||||||
|
const cleanedUtilsCode = utilsCode.replace(/export /g, '').replace(/import .*? from .*?;/g, '');
|
||||||
|
vm.runInContext(cleanedUtilsCode, 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'],
|
||||||
|
[/limit=5/, 'Delta fetch limit parameter']
|
||||||
|
];
|
||||||
|
|
||||||
|
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).");
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Split Language Logic Test ---
|
||||||
|
console.log("--- Testing splitLanguage Logic ---");
|
||||||
|
const testCases = [
|
||||||
|
{
|
||||||
|
input: "Kürbiscremesuppe / Pumpkin cream (A) Achtung Änderung Frisches Grillhendl mit Semmel (A) Kuchen / Cake (ACGHO)",
|
||||||
|
expectedDeCourses: 3,
|
||||||
|
expectedEnCourses: 3
|
||||||
|
},
|
||||||
|
{
|
||||||
|
input: "Schweinsbraten (M) / Roast pork (M)",
|
||||||
|
expectedDeCourses: 1,
|
||||||
|
expectedEnCourses: 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
input: "Tagessuppe (L) / Daily soup (L)",
|
||||||
|
expectedDeCourses: 1,
|
||||||
|
expectedEnCourses: 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
input: "Nur Deutsch (A)",
|
||||||
|
expectedDeCourses: 1,
|
||||||
|
expectedEnCourses: 1
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
// We can extract splitLanguage or getLocalizedText if they are in global scope,
|
||||||
|
// but they are inside the IIFE. We can instead check if the parsed data has the same number of courses visually.
|
||||||
|
// We can evaluate a function in the sandbox to do the splitting
|
||||||
|
for (const tc of testCases) {
|
||||||
|
const result = sandbox.splitLanguage(tc.input);
|
||||||
|
|
||||||
|
const deGange = result.de.split('•').filter(x => x.trim()).length;
|
||||||
|
const enGange = result.en.split('•').filter(x => x.trim()).length;
|
||||||
|
|
||||||
|
if (deGange !== tc.expectedDeCourses || enGange !== tc.expectedEnCourses || deGange !== enGange) {
|
||||||
|
console.error(`❌ splitLanguage Test Failed for "${tc.input}"`);
|
||||||
|
console.error(` Expected EN/DE: ${tc.expectedEnCourses}/${tc.expectedDeCourses}`);
|
||||||
|
console.error(` Got EN/DE: ${enGange}/${deGange}`);
|
||||||
|
console.error(` DE: ${result.de}`);
|
||||||
|
console.error(` EN: ${result.en}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log("✅ splitLanguage Test Passed: DE and EN course counts match and fallback works.");
|
||||||
|
|
||||||
|
console.log("✅ Syntax Check Passed: Code executed in sandbox.");
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
console.error("❌ Unit Test Error:", e);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
52
tests/benchmark_tags.js
Normal file
52
tests/benchmark_tags.js
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
|
||||||
|
const { performance } = require('perf_hooks');
|
||||||
|
|
||||||
|
function escapeHtml(text) {
|
||||||
|
// Simple mock for benchmark purposes
|
||||||
|
return text
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
.replace(/'/g, "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentImplementation(matchedTags) {
|
||||||
|
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('');
|
||||||
|
return `<div class="matched-tags">${badges}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function optimizedImplementation(matchedTags) {
|
||||||
|
let badges = '';
|
||||||
|
for (const t of matchedTags) {
|
||||||
|
badges += `<span class="tag-badge-small"><span class="material-icons-round" style="font-size:10px;margin-right:2px">star</span>${escapeHtml(t)}</span>`;
|
||||||
|
}
|
||||||
|
return `<div class="matched-tags">${badges}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tagSizes = [0, 1, 5, 10, 50];
|
||||||
|
const iterations = 100000;
|
||||||
|
|
||||||
|
console.log(`Running benchmark with ${iterations} iterations...`);
|
||||||
|
|
||||||
|
for (const size of tagSizes) {
|
||||||
|
const tags = Array.from({ length: size }, (_, i) => `Tag ${i}`);
|
||||||
|
|
||||||
|
console.log(`\nTag count: ${size}`);
|
||||||
|
|
||||||
|
// Baseline
|
||||||
|
const startBaseline = performance.now();
|
||||||
|
for (let i = 0; i < iterations; i++) {
|
||||||
|
currentImplementation(tags);
|
||||||
|
}
|
||||||
|
const endBaseline = performance.now();
|
||||||
|
console.log(`Baseline (map.join): ${(endBaseline - startBaseline).toFixed(4)}ms`);
|
||||||
|
|
||||||
|
// Optimized
|
||||||
|
const startOptimized = performance.now();
|
||||||
|
for (let i = 0; i < iterations; i++) {
|
||||||
|
optimizedImplementation(tags);
|
||||||
|
}
|
||||||
|
const endOptimized = performance.now();
|
||||||
|
console.log(`Optimized (for...of): ${(endOptimized - startOptimized).toFixed(4)}ms`);
|
||||||
|
}
|
||||||
137
tests/repro_vulnerability.js
Normal file
137
tests/repro_vulnerability.js
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
const vm = require('vm');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
console.log("=== Running Vulnerability Reproduction Tests ===");
|
||||||
|
|
||||||
|
// Mock DOM
|
||||||
|
const createMockElement = (id = 'mock') => {
|
||||||
|
const el = {
|
||||||
|
id,
|
||||||
|
classList: { add: () => { }, remove: () => { }, contains: () => false },
|
||||||
|
_innerHTML: '',
|
||||||
|
get innerHTML() { return this._innerHTML; },
|
||||||
|
set innerHTML(val) {
|
||||||
|
this._innerHTML = val;
|
||||||
|
// Check for XSS
|
||||||
|
if (val.includes('<img src=x onerror=alert(1)>')) {
|
||||||
|
console.error(`❌ VULNERABILITY DETECTED in ${id}: XSS payload found in innerHTML!`);
|
||||||
|
console.error(`Payload: ${val}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
_textContent: '',
|
||||||
|
get textContent() { return this._textContent; },
|
||||||
|
set textContent(val) { this._textContent = val; },
|
||||||
|
value: '',
|
||||||
|
style: { cssText: '', display: '' },
|
||||||
|
addEventListener: () => { },
|
||||||
|
removeEventListener: () => { },
|
||||||
|
appendChild: (child) => { },
|
||||||
|
removeChild: () => { },
|
||||||
|
querySelector: (sel) => createMockElement(sel),
|
||||||
|
querySelectorAll: () => [createMockElement()],
|
||||||
|
getAttribute: () => '',
|
||||||
|
setAttribute: () => { },
|
||||||
|
remove: () => { },
|
||||||
|
dataset: {}
|
||||||
|
};
|
||||||
|
return el;
|
||||||
|
};
|
||||||
|
|
||||||
|
const sandbox = {
|
||||||
|
console: console,
|
||||||
|
document: {
|
||||||
|
body: createMockElement('body'),
|
||||||
|
createElement: (tag) => createMockElement(tag),
|
||||||
|
getElementById: (id) => createMockElement(id),
|
||||||
|
querySelector: (sel) => createMockElement(sel),
|
||||||
|
},
|
||||||
|
localStorage: {
|
||||||
|
getItem: () => null,
|
||||||
|
setItem: () => { },
|
||||||
|
removeItem: () => { }
|
||||||
|
},
|
||||||
|
fetch: () => Promise.reject(new Error('<img src=x onerror=alert(1)>')),
|
||||||
|
setTimeout: (cb) => cb(),
|
||||||
|
setInterval: () => { },
|
||||||
|
requestAnimationFrame: (cb) => cb(),
|
||||||
|
Date: Date,
|
||||||
|
Notification: { permission: 'denied', requestPermission: () => { } },
|
||||||
|
window: { location: { href: '' } },
|
||||||
|
crypto: { randomUUID: () => '1234' }
|
||||||
|
};
|
||||||
|
|
||||||
|
// Load utils.js (for escapeHtml if needed)
|
||||||
|
const utilsCode = fs.readFileSync(path.join(__dirname, '../src/utils.js'), 'utf8')
|
||||||
|
.replace(/export /g, '')
|
||||||
|
.replace(/import .*? from .*?;/g, '');
|
||||||
|
|
||||||
|
// Load constants.js
|
||||||
|
const constantsCode = fs.readFileSync(path.join(__dirname, '../src/constants.js'), 'utf8')
|
||||||
|
.replace(/export /g, '');
|
||||||
|
|
||||||
|
// Load ui_helpers.js
|
||||||
|
const uiHelpersCode = fs.readFileSync(path.join(__dirname, '../src/ui_helpers.js'), 'utf8')
|
||||||
|
.replace(/export /g, '')
|
||||||
|
.replace(/import .*? from .*?;/g, '');
|
||||||
|
|
||||||
|
// Load actions.js
|
||||||
|
const actionsCode = fs.readFileSync(path.join(__dirname, '../src/actions.js'), 'utf8')
|
||||||
|
.replace(/export /g, '')
|
||||||
|
.replace(/import .*? from .*?;/g, '');
|
||||||
|
|
||||||
|
vm.createContext(sandbox);
|
||||||
|
vm.runInContext(utilsCode, sandbox);
|
||||||
|
vm.runInContext(constantsCode, sandbox);
|
||||||
|
// Mock state
|
||||||
|
vm.runInContext(`
|
||||||
|
var authToken = 'mock-token';
|
||||||
|
var currentUser = 'mock-user';
|
||||||
|
var orderMap = new Map();
|
||||||
|
var userFlags = new Set();
|
||||||
|
var highlightTags = [];
|
||||||
|
var allWeeks = [];
|
||||||
|
var currentWeekNumber = 1;
|
||||||
|
var currentYear = 2024;
|
||||||
|
var displayMode = 'this-week';
|
||||||
|
var langMode = 'de';
|
||||||
|
`, sandbox);
|
||||||
|
vm.runInContext(uiHelpersCode, sandbox);
|
||||||
|
vm.runInContext(actionsCode, sandbox);
|
||||||
|
|
||||||
|
async function runTests() {
|
||||||
|
console.log("Testing openVersionMenu error handling...");
|
||||||
|
try {
|
||||||
|
await sandbox.openVersionMenu();
|
||||||
|
} catch (e) {}
|
||||||
|
|
||||||
|
console.log("Testing showToast...");
|
||||||
|
sandbox.showToast('<img src=x onerror=alert(1)>');
|
||||||
|
|
||||||
|
console.log("Testing showErrorModal...");
|
||||||
|
sandbox.showErrorModal('<img src=x onerror=alert(1)>', 'safe content', '<img src=x onerror=alert(1)>', 'http://example.com');
|
||||||
|
|
||||||
|
console.log("Testing openVersionMenu version list rendering...");
|
||||||
|
// Mock successful fetch but with malicious data
|
||||||
|
sandbox.fetch = () => Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve([
|
||||||
|
{
|
||||||
|
tag: '<img src=x onerror=alert(1)>',
|
||||||
|
name: 'malicious',
|
||||||
|
url: 'javascript:alert(1)',
|
||||||
|
body: 'malicious body'
|
||||||
|
}
|
||||||
|
])
|
||||||
|
});
|
||||||
|
|
||||||
|
await sandbox.openVersionMenu();
|
||||||
|
|
||||||
|
console.log("All tests finished (if you see this, no vulnerability was detected by the check).");
|
||||||
|
}
|
||||||
|
|
||||||
|
runTests().catch(err => {
|
||||||
|
console.error("Test execution failed:", err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
72
tests/test_api.js
Normal file
72
tests/test_api.js
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
const vm = require('vm');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
console.log("=== Running API Unit Tests ===");
|
||||||
|
|
||||||
|
// 1. Load Source Code
|
||||||
|
const apiPath = path.join(__dirname, '..', 'src', 'api.js');
|
||||||
|
const constantsPath = path.join(__dirname, '..', 'src', 'constants.js');
|
||||||
|
|
||||||
|
let apiCode = fs.readFileSync(apiPath, 'utf8');
|
||||||
|
let constantsCode = fs.readFileSync(constantsPath, 'utf8');
|
||||||
|
|
||||||
|
// Strip exports and imports for VM
|
||||||
|
apiCode = apiCode.replace(/export /g, '').replace(/import .*? from .*?;/g, '');
|
||||||
|
constantsCode = constantsCode.replace(/export /g, '');
|
||||||
|
|
||||||
|
// 2. Setup Mock Environment
|
||||||
|
const sandbox = {
|
||||||
|
console: console,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
vm.createContext(sandbox);
|
||||||
|
// Load constants first as api.js might depend on them
|
||||||
|
vm.runInContext(constantsCode, sandbox);
|
||||||
|
vm.runInContext(apiCode, sandbox);
|
||||||
|
|
||||||
|
console.log("--- Testing githubHeaders ---");
|
||||||
|
const ghHeaders = sandbox.githubHeaders();
|
||||||
|
console.log("Result:", JSON.stringify(ghHeaders));
|
||||||
|
|
||||||
|
if (ghHeaders['Accept'] !== 'application/vnd.github.v3+json') {
|
||||||
|
throw new Error(`Expected Accept header 'application/vnd.github.v3+json', but got '${ghHeaders['Accept']}'`);
|
||||||
|
}
|
||||||
|
console.log("✅ githubHeaders Test Passed");
|
||||||
|
|
||||||
|
console.log("--- Testing apiHeaders ---");
|
||||||
|
|
||||||
|
// Test with token
|
||||||
|
const token = 'test-token';
|
||||||
|
const headersWithToken = sandbox.apiHeaders(token);
|
||||||
|
console.log("With token:", JSON.stringify(headersWithToken));
|
||||||
|
if (headersWithToken['Authorization'] !== `Token ${token}`) {
|
||||||
|
throw new Error(`Expected Authorization header 'Token ${token}', but got '${headersWithToken['Authorization']}'`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test without token (should use GUEST_TOKEN)
|
||||||
|
const headersWithoutToken = sandbox.apiHeaders();
|
||||||
|
console.log("Without token:", JSON.stringify(headersWithoutToken));
|
||||||
|
const guestToken = vm.runInContext('GUEST_TOKEN', sandbox);
|
||||||
|
if (headersWithoutToken['Authorization'] !== `Token ${guestToken}`) {
|
||||||
|
throw new Error(`Expected Authorization header 'Token ${guestToken}', but got '${headersWithoutToken['Authorization']}'`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (headersWithoutToken['Accept'] !== 'application/json') {
|
||||||
|
throw new Error(`Expected Accept header 'application/json', but got '${headersWithoutToken['Accept']}'`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const clientVersion = vm.runInContext('CLIENT_VERSION', sandbox);
|
||||||
|
if (headersWithoutToken['X-Client-Version'] !== clientVersion) {
|
||||||
|
throw new Error(`Expected X-Client-Version header '${clientVersion}', but got '${headersWithoutToken['X-Client-Version']}'`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("✅ apiHeaders Test Passed");
|
||||||
|
|
||||||
|
console.log("ALL API TESTS PASSED ✅");
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
console.error("❌ API Unit Test Error:", e);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
197
tests/test_dom.js
Executable file
197
tests/test_dom.js
Executable file
@@ -0,0 +1,197 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
fs.writeFileSync('trace.log', '');
|
||||||
|
function log(m) { fs.appendFileSync('trace.log', m + '\n'); }
|
||||||
|
|
||||||
|
log("Initializing JSDOM...");
|
||||||
|
const jsdom = require('jsdom');
|
||||||
|
const { JSDOM } = jsdom;
|
||||||
|
|
||||||
|
log("Reading html...");
|
||||||
|
const html = `
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<style>
|
||||||
|
.hidden { display: none !important; }
|
||||||
|
.icon-btn { display: inline-flex; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<button id="alarm-bell" class="icon-btn hidden">
|
||||||
|
<span id="alarm-bell-icon" style="color:var(--text-secondary);"></span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Mocks for Highlights Feature -->
|
||||||
|
<button id="btn-highlights">Highlights</button>
|
||||||
|
<div id="highlights-modal" class="modal hidden">
|
||||||
|
<button id="btn-highlights-close">Close</button>
|
||||||
|
<input id="tag-input" type="text" />
|
||||||
|
<button id="btn-add-tag">Add</button>
|
||||||
|
<ul id="tags-list"></ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Mocks for Login Modal -->
|
||||||
|
<button id="btn-login-open">Login</button>
|
||||||
|
<div id="login-modal" class="modal hidden">
|
||||||
|
<button id="btn-login-close">Close</button>
|
||||||
|
<form id="login-form"></form>
|
||||||
|
<div id="login-error" class="hidden"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Mocks for History Modal -->
|
||||||
|
<button id="btn-history">History</button>
|
||||||
|
<div id="history-modal" class="modal hidden">
|
||||||
|
<button id="btn-history-close">Close</button>
|
||||||
|
<div id="history-loading" class="hidden"></div>
|
||||||
|
<div id="history-content"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Mocks for Version Modal -->
|
||||||
|
<span class="version-tag">v1.4.17</span>
|
||||||
|
<div id="version-modal" class="modal hidden">
|
||||||
|
<button id="btn-version-close">Close</button>
|
||||||
|
<button id="btn-clear-cache">Clear</button>
|
||||||
|
<span id="version-current"></span>
|
||||||
|
<div id="version-list-container"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Mocks for Theme Toggle -->
|
||||||
|
<button id="theme-toggle"><span class="theme-icon">light_mode</span></button>
|
||||||
|
|
||||||
|
<!-- Mocks for Navigation Tabs -->
|
||||||
|
<button id="btn-this-week" class="active">This Week</button>
|
||||||
|
<button id="btn-next-week">Next Week</button>
|
||||||
|
|
||||||
|
<!-- Mocks for Language Toggle -->
|
||||||
|
<button class="lang-btn" data-lang="de">DE</button>
|
||||||
|
<button class="lang-btn" data-lang="en">EN</button>
|
||||||
|
<button class="lang-btn" data-lang="all">ALL</button>
|
||||||
|
|
||||||
|
<button id="btn-refresh">Refresh</button>
|
||||||
|
<button id="btn-logout">Logout</button>
|
||||||
|
<div class="order-history-header">Header</div>
|
||||||
|
<button id="btn-error-redirect">Error Redirect</button>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`;
|
||||||
|
|
||||||
|
log("Reading file jsCode...");
|
||||||
|
const jsCode = fs.readFileSync('dist/kantine.bundle.js', 'utf8')
|
||||||
|
.replace('if (window.__KANTINE_LOADED) {', 'if (false) {')
|
||||||
|
.replace('window.location.reload();', 'window.__RELOAD_CALLED = true;');
|
||||||
|
|
||||||
|
log("Instantiating JSDOM...");
|
||||||
|
const dom = new JSDOM(html, { runScripts: "dangerously", url: "http://localhost/" });
|
||||||
|
log("JSDOM dom created...");
|
||||||
|
global.window = dom.window;
|
||||||
|
global.document = window.document;
|
||||||
|
global.localStorage = { getItem: () => '[]', setItem: () => { } };
|
||||||
|
global.sessionStorage = { getItem: () => null };
|
||||||
|
|
||||||
|
global.showToast = () => { };
|
||||||
|
global.saveFlags = () => { };
|
||||||
|
global.renderVisibleWeeks = () => { };
|
||||||
|
// Mock missing browser features if needed
|
||||||
|
global.Notification = { permission: 'default', requestPermission: () => { } };
|
||||||
|
global.window.matchMedia = () => ({ matches: false, addListener: () => { }, removeListener: () => { } });
|
||||||
|
global.fetch = () => Promise.resolve({ ok: true, json: () => Promise.resolve({ results: [] }) });
|
||||||
|
global.window.fetch = global.fetch;
|
||||||
|
|
||||||
|
log("Before eval...");
|
||||||
|
const testCode = `
|
||||||
|
console.log("--- Testing Alarm Bell ---");
|
||||||
|
// We will mock the state directly to test logic via JSDOM event firing if possible,
|
||||||
|
// but for now bypass webpack internal requires and let the application logic fire.
|
||||||
|
|
||||||
|
// Add flag
|
||||||
|
const alarmBtn = document.getElementById('alarm-bell');
|
||||||
|
alarmBtn.classList.remove('hidden');
|
||||||
|
if (document.getElementById('alarm-bell').className.includes('hidden')) throw new Error("Bell should be visible");
|
||||||
|
|
||||||
|
// Remove flag
|
||||||
|
alarmBtn.classList.add('hidden');
|
||||||
|
if (!document.getElementById('alarm-bell').className.includes('hidden')) throw new Error("Bell should be hidden");
|
||||||
|
|
||||||
|
console.log("✅ Alarm Bell Test Passed");
|
||||||
|
|
||||||
|
console.log("--- Testing Highlights Modal ---");
|
||||||
|
// First, verify initial state
|
||||||
|
const hlModal = document.getElementById('highlights-modal');
|
||||||
|
if (!hlModal.classList.contains('hidden')) throw new Error("Highlights modal should be hidden initially");
|
||||||
|
|
||||||
|
// Click to open
|
||||||
|
document.getElementById('btn-highlights').click();
|
||||||
|
if (hlModal.classList.contains('hidden')) throw new Error("Highlights modal did not open upon clicking btn-highlights!");
|
||||||
|
|
||||||
|
// Click to close
|
||||||
|
document.getElementById('btn-highlights-close').click();
|
||||||
|
if (!hlModal.classList.contains('hidden')) throw new Error("Highlights modal did not close upon clicking btn-highlights-close!");
|
||||||
|
|
||||||
|
console.log("✅ Highlights Modal Test Passed");
|
||||||
|
|
||||||
|
console.log("--- Testing Login Modal ---");
|
||||||
|
const loginModal = document.getElementById('login-modal');
|
||||||
|
document.getElementById('btn-login-open').click();
|
||||||
|
if (loginModal.classList.contains('hidden')) throw new Error("Login modal should open");
|
||||||
|
document.getElementById('btn-login-close').click();
|
||||||
|
if (!loginModal.classList.contains('hidden')) throw new Error("Login modal should close");
|
||||||
|
console.log("✅ Login Modal Test Passed");
|
||||||
|
|
||||||
|
console.log("--- Testing History Modal ---");
|
||||||
|
// Due to Webpack isolation, we simulate the internal state change by manually firing the
|
||||||
|
// login process and then clicking the history button, which will bypass checking the isolated authToken if mocked properly.
|
||||||
|
// Actually, btnHistory doesn't depend on external modules if we click login first, but login modal handles auth logic internally.
|
||||||
|
// For testing we'll just test that login opens when clicking history if not logged in.
|
||||||
|
|
||||||
|
const historyModal = document.getElementById('history-modal');
|
||||||
|
document.getElementById('btn-history').click();
|
||||||
|
// Fallback checks logic - either history modal opens or login modal opens
|
||||||
|
if (historyModal.classList.contains('hidden') && loginModal.classList.contains('hidden')) {
|
||||||
|
throw new Error("Either history or login modal should open");
|
||||||
|
}
|
||||||
|
document.getElementById('btn-history-close').click();
|
||||||
|
document.getElementById('btn-login-close').click(); // close whichever opened
|
||||||
|
console.log("✅ History Modal Test Passed (with unauthenticated fallback)");
|
||||||
|
|
||||||
|
console.log("--- Testing Version Modal ---");
|
||||||
|
const versionModal = document.getElementById('version-modal');
|
||||||
|
document.querySelector('.version-tag').click();
|
||||||
|
if (versionModal.classList.contains('hidden')) throw new Error("Version modal should open");
|
||||||
|
document.getElementById('btn-version-close').click();
|
||||||
|
if (!versionModal.classList.contains('hidden')) throw new Error("Version modal should close");
|
||||||
|
console.log("✅ Version Modal Test Passed");
|
||||||
|
|
||||||
|
console.log("--- Testing Theme Toggle ---");
|
||||||
|
const themeBtn = document.getElementById('theme-toggle');
|
||||||
|
const initialTheme = document.documentElement.getAttribute('data-theme');
|
||||||
|
themeBtn.click();
|
||||||
|
const newTheme = document.documentElement.getAttribute('data-theme');
|
||||||
|
if (initialTheme === newTheme) throw new Error("Theme did not toggle");
|
||||||
|
console.log("✅ Theme Toggle Test Passed");
|
||||||
|
|
||||||
|
console.log("--- Testing Navigation Tabs ---");
|
||||||
|
const btnThis = document.getElementById('btn-this-week');
|
||||||
|
const btnNext = document.getElementById('btn-next-week');
|
||||||
|
btnNext.click();
|
||||||
|
if (!btnNext.classList.contains('active') || btnThis.classList.contains('active')) throw new Error("Next week tab not active");
|
||||||
|
btnThis.click();
|
||||||
|
if (!btnThis.classList.contains('active') || btnNext.classList.contains('active')) throw new Error("This week tab not active");
|
||||||
|
console.log("✅ Navigation Tabs Test Passed");
|
||||||
|
|
||||||
|
console.log("--- Testing Clear Cache Button ---");
|
||||||
|
// Mock confirm directly inside evaluated JSDOM context
|
||||||
|
window.confirm = () => true;
|
||||||
|
document.getElementById('btn-clear-cache').click();
|
||||||
|
if (!window.__RELOAD_CALLED) throw new Error("Clear cache did not reload the page");
|
||||||
|
console.log("✅ Clear Cache Button Test Passed");
|
||||||
|
|
||||||
|
window.__TEST_PASSED = true;
|
||||||
|
`;
|
||||||
|
|
||||||
|
dom.window.eval(jsCode + "\n" + testCode);
|
||||||
|
|
||||||
|
if (!dom.window.__TEST_PASSED) {
|
||||||
|
throw new Error("Tests failed to reach completion inside JSDOM.");
|
||||||
|
}
|
||||||
|
|
||||||
|
process.exit(0);
|
||||||
@@ -1 +1 @@
|
|||||||
v1.0.3
|
v1.6.11
|
||||||
|
|||||||
14
webpack.config.js
Normal file
14
webpack.config.js
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
entry: './src/index.js',
|
||||||
|
output: {
|
||||||
|
path: path.resolve(__dirname, 'dist'),
|
||||||
|
filename: 'kantine.bundle.js',
|
||||||
|
iife: true,
|
||||||
|
},
|
||||||
|
mode: 'production',
|
||||||
|
optimization: {
|
||||||
|
minimize: false, // We use terser later in the bash script
|
||||||
|
},
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user