Compare commits
74 Commits
v1.4.14
...
55e738a554
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
@@ -43,11 +43,17 @@ trigger: always_on
|
||||
- **Visuals**: Generate screenshots/mockups for UI changes.
|
||||
- **Evidence**: Log outputs for verification.
|
||||
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
|
||||
- **Browser**: Allowed for documentation and safe browsing. No automated logins without permission.
|
||||
- **Terminal**: No `rm -rf`. Run tests (`pytest` etc.) after logic changes.
|
||||
|
||||
## 7. Mandatory Testing Policy 🧪
|
||||
**CRITICAL: No logic or UI fix is complete without a corresponding automated test.**
|
||||
- If you fix a regression or implement a new UI feature, you **MUST** write or update a test in `tests/test_dom.js` or `test_logic.js`.
|
||||
- Refactoring MUST include verifying that no click listeners drop out. This guarantees that features like modal toggles stay functional.
|
||||
|
||||
## 7. Requirements-Konsistenz 📋
|
||||
Alle umgesetzten Anforderungen müssen mit `REQUIREMENTS.md` übereinstimmen.
|
||||
1. **Vor der Umsetzung prüfen**: Passt die neue Anforderung zu den bestehenden Requirements?
|
||||
|
||||
51
README.md
51
README.md
@@ -8,9 +8,14 @@ Ein intelligentes Bookmarklet für die Mitarbeiter-Kantine der Bessa App. Dieses
|
||||
* **Bestell-Countdown:** ⏳ Roter Alarm 1h vor Bestellschluss.
|
||||
* **Smart Highlights:** 🌟 Markiere deine Favoriten (z.B. "Schnitzel", "Vegetarisch").
|
||||
* **Bestellstatus:** Farbige Indikatoren für bestellte Menüs.
|
||||
* **Kostenkontrolle:** Summiert automatisch den Gesamtpreis der Woche.
|
||||
* **Session Reuse:** Nutzt automatisch eine bestehende Login-Session (Loggt dich automatisch ein).
|
||||
* **Menu Badges:** Zeigt Menü-Codes (M1, M2+) direkt im Header.
|
||||
* **Kostenkontrolle:** 💰 Summiert automatisch den Gesamtpreis der Woche.
|
||||
* **Bestellhistorie:** 📜 Gruppiert nach Monat & KW mit inkrementellem Delta-Cache.
|
||||
* **Session Reuse:** 🔑 Nutzt automatisch eine bestehende Login-Session.
|
||||
* **Menu Badges:** 🏷️ Zeigt Menü-Codes (M1, M2+) direkt im Header.
|
||||
* **Menü-Flagging:** 🔔 Ausverkaufte Menüs beobachten und bei Verfügbarkeit benachrichtigt werden.
|
||||
* **Version-Menü:** 📦 Versionsliste mit Installer-Links, Dev-Mode Toggle und Downgrade-Support.
|
||||
* **Cache leeren:** 🗑️ Lokalen Cache mit einem Klick bereinigen (im Version-Menü).
|
||||
* **Favicon:** 🍽️ Eigenes Icon für die Lesezeichenleiste.
|
||||
* **Changelog:** Übersicht über neue Funktionen direkt im Installer.
|
||||
|
||||
## 📦 Installation
|
||||
@@ -19,7 +24,7 @@ Ein intelligentes Bookmarklet für die Mitarbeiter-Kantine der Bessa App. Dieses
|
||||
2. Ziehe den blauen Button **"Kantine Wrapper"** in deine Lesezeichen-Leiste.
|
||||
3. Fertig!
|
||||
|
||||
## usage
|
||||
## 🍽️ Nutzung
|
||||
|
||||
1. Navigiere zu [https://web.bessa.app/knapp-kantine](https://web.bessa.app/knapp-kantine).
|
||||
2. Klicke auf das **"Kantine Wrapper"** Lesezeichen.
|
||||
@@ -28,24 +33,38 @@ Ein intelligentes Bookmarklet für die Mitarbeiter-Kantine der Bessa App. Dieses
|
||||
## 🛠️ Entwicklung
|
||||
|
||||
### 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`)
|
||||
|
||||
### Projektstruktur
|
||||
|
||||
#### Quelldateien
|
||||
* `kantine.js`: Der Haupt-Quellcode des Bookmarklets (UI, API-Logik, Rendering).
|
||||
* `style.css`: Das komplette Design (CSS mit Light/Dark Mode).
|
||||
* `mock-data.js`: Mock-Fetch-Interceptor mit realistischen Dummy-Menüdaten für Standalone-Tests.
|
||||
* `build-bookmarklet.sh`: Build-Skript – erzeugt alle `dist/`-Artefakte.
|
||||
* `test_build.py`: Automatische Build-Tests, laufen am Ende jedes Builds.
|
||||
| Datei | Beschreibung |
|
||||
|-------|-------------|
|
||||
| `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, Anleitung, Feature-Liste und Changelog. Kann lokal oder gehostet geöffnet werden. |
|
||||
| `install.html` | Installer-Seite mit Drag & Drop Button, Favicon, Anleitung, Feature-Liste und Changelog. Kann lokal oder gehostet geöffnet werden. |
|
||||
| `kantine-standalone.html` | Eigenständige HTML-Datei mit eingebettetem CSS + JS + **Mock-Daten**. Lädt automatisch Dummy-Menüs für UI-Tests ohne API-Zugriff. |
|
||||
|
||||
### Build
|
||||
@@ -55,5 +74,15 @@ Um Änderungen an `kantine.js` oder `style.css` wirksam zu machen, führe den Bu
|
||||
./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
|
||||
Internes Tool.
|
||||
|
||||
@@ -60,10 +60,12 @@ Das System umfasst die Darstellung von Menüplänen in einer Wochenübersicht, d
|
||||
| **Header UI & Navigation** | | | |
|
||||
| FR-090 | Die Hauptnavigation (Wochen-Toggles) muss linksbündig neben dem App-Titel positioniert sein. | Niedrig | v1.5.0 |
|
||||
| FR-091 | Ein dynamisches Alarm-Icon im Header muss den Überwachungsstatus geflaggter Menüs anzeigen (Gelb=Überwachung aktiv aber kein Menü verfügbar, Grün=Mindestens ein Menü verfügbar, Versteckt=keine Flags). Der Tooltip muss den Zeitpunkt der letzten Prüfung als relativen String (z.B. "vor 4 Min.") enthalten. | Mittel | v1.5.0 (Update v1.4.10) |
|
||||
| FR-092 | Sobald über den Daten-Refresh erstmals Menüdaten für die Nächste Woche geladen werden, muss der entsprechende Navigation-Button animiert und farblich (Gelb) hervorgehoben werden. Zusätzlich muss einmalig ein Hinweis eingeblendet werden. Bei Klick auf den Button muss die Hervorhebung erlöschen. | Mittel | v1.6.0 |
|
||||
| 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 |
|
||||
| **Benutzer-Feedback** | | | |
|
||||
| FR-090 | Alle benutzerrelevanten Aktionen (Bestellung, Stornierung, Fehler) müssen durch nicht-blockierende Benachrichtigungen (Toasts) bestätigt werden. | Mittel | v1.0.1 |
|
||||
| FR-091 | Bei einem Verbindungsfehler muss ein Fehlerdialog mit Fallback-Link zur Originalseite angezeigt werden. | Mittel | v1.0.1 |
|
||||
| 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** | | | |
|
||||
@@ -73,6 +75,8 @@ Das System umfasst die Darstellung von Menüplänen in einer Wochenübersicht, d
|
||||
| FR-113 | Es muss möglich sein, zu einer älteren Version zurückzukehren (Downgrade). | Niedrig | v1.3.0 |
|
||||
| FR-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
|
||||
|
||||
@@ -85,7 +89,7 @@ Das System umfasst die Darstellung von Menüplänen in einer Wochenübersicht, d
|
||||
| **Benutzbarkeit** | NFR-005 | Die Oberfläche muss auf mobilen Geräten fehlerfrei nutzbar sein. | Viewports ab 320px Breite |
|
||||
| **Benutzbarkeit** | NFR-006 | Alle interaktiven Elemente müssen Tooltips oder Hilfetexte bieten. | 100% Coverage |
|
||||
| **Benutzbarkeit** | NFR-007 | Die Benutzeroberfläche muss vollständig in deutscher Sprache sein. | Vollständige Lokalisierung |
|
||||
| **Wartbarkeit** | NFR-008 | Die Build-Artefakte müssen durch automatisierte Tests validiert werden. | Build-Tests + Logik-Tests |
|
||||
| **Wartbarkeit** | NFR-008 | Die Build-Artefakte müssen durch automatisierte Tests validiert werden. | Build-Tests + Logik-Tests + DOM-Tests |
|
||||
|
||||
## 4. Technische Randbedingungen
|
||||
* **Deployment**: Das System wird als Bookmarklet ausgeliefert, das auf der Bessa-Webseite ausgeführt wird.
|
||||
@@ -93,4 +97,4 @@ Das System umfasst die Darstellung von Menüplänen in einer Wochenübersicht, d
|
||||
* **Datenhaltung**: Clientseitig via `localStorage` (Menü-Cache, Flags, Highlights, Theme) und `sessionStorage` (Auth-Token).
|
||||
* **Build**: Bash-basiertes Build-Script, das Bookmarklet-URL, Standalone-HTML und Installer-Seite generiert.
|
||||
* **Versionierung**: SemVer, verwaltet über GitHub Releases/Tags.
|
||||
* **Tests**: Python-basierte Build-Tests (`python3`) + Node.js-basierte Logik-Tests.
|
||||
* **Tests**: Python-basierte Build-Tests (`python3`) + Node.js-basierte Logik-Tests + Node.js-basierte DOM-Interaktionstests (JSDOM).
|
||||
|
||||
@@ -7,6 +7,7 @@ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
DIST_DIR="$SCRIPT_DIR/dist"
|
||||
CSS_FILE="$SCRIPT_DIR/style.css"
|
||||
JS_FILE="$SCRIPT_DIR/kantine.js"
|
||||
FAVICON_FILE="$SCRIPT_DIR/favicon.png"
|
||||
|
||||
# === VERSION ===
|
||||
if [ -f "$SCRIPT_DIR/version.txt" ]; then
|
||||
@@ -24,10 +25,33 @@ echo "=== Kantine Bookmarklet Builder ($VERSION) ==="
|
||||
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
|
||||
|
||||
# Generate favicon.png from favicon_base.png if base exists
|
||||
FAVICON_BASE="$SCRIPT_DIR/favicon_base.png"
|
||||
if [ -f "$FAVICON_BASE" ]; then
|
||||
echo "Generating 32x32 favicon.png from favicon_base.png..."
|
||||
python3 -c "
|
||||
import sys
|
||||
from PIL import Image
|
||||
try:
|
||||
img = Image.open('$FAVICON_BASE')
|
||||
img_resized = img.resize((32, 32), 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")
|
||||
|
||||
# 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) ===
|
||||
cat > "$DIST_DIR/kantine-standalone.html" << HTMLEOF
|
||||
@@ -101,6 +125,7 @@ cat > "$DIST_DIR/install.html" << INSTALLEOF
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Kantine Wrapper Installer ($VERSION)</title>
|
||||
<link rel="icon" type="image/png" href="$FAVICON_URL">
|
||||
<style>
|
||||
body { font-family: 'Inter', sans-serif; max-width: 600px; margin: 40px auto; padding: 20px; background: #1a1a2e; color: #eee; }
|
||||
h1 { color: #029AA8; } /* Knapp Teal */
|
||||
@@ -121,8 +146,25 @@ cat > "$DIST_DIR/install.html" << INSTALLEOF
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<!-- Banner Video: plays once, collapses after ending -->
|
||||
<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;">
|
||||
<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;">🍽️ Kantine Wrapper <span style="font-size:0.5em; opacity:0.6; font-weight:400; vertical-align:middle; margin-left:10px;">$VERSION</span></h1>
|
||||
<h1 style="margin-bottom: 5px; display: flex; align-items: center; justify-content: center; gap: 10px;">
|
||||
<img src="$FAVICON_URL" alt="Logo" style="width: 38px; height: 38px;">
|
||||
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>
|
||||
|
||||
@@ -200,9 +242,9 @@ echo "document.getElementById('bookmarklet-link').href = " >> "$DIST_DIR/install
|
||||
echo "$JS_CONTENT" | python3 -c "
|
||||
import sys, json, urllib.parse
|
||||
|
||||
# 1. Read JS and Replace VERSION
|
||||
# 1. Read JS and Replace VERSION + Favicon
|
||||
js_template = sys.stdin.read()
|
||||
js = js_template.replace('{{VERSION}}', '$VERSION')
|
||||
js = js_template.replace('{{VERSION}}', '$VERSION').replace('{{FAVICON_DATA_URI}}', '$FAVICON_URL')
|
||||
|
||||
# 2. Prepare CSS for injection via createElement('style')
|
||||
css = open('$CSS_FILE').read().replace('\n', ' ').replace(' ', ' ')
|
||||
@@ -236,6 +278,16 @@ $CHANGELOG_HTML
|
||||
EOF
|
||||
|
||||
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';
|
||||
</script>
|
||||
</body>
|
||||
@@ -258,6 +310,14 @@ if [ $LOGIC_EXIT -ne 0 ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "=== Running DOM Interaction Tests ==="
|
||||
node "$SCRIPT_DIR/tests/test_dom.js"
|
||||
DOM_EXIT=$?
|
||||
if [ $DOM_EXIT -ne 0 ]; then
|
||||
echo "❌ DOM UI tests FAILED! Regressions detected."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "=== Running Build Tests ==="
|
||||
python3 "$SCRIPT_DIR/test_build.py"
|
||||
TEST_EXIT=$?
|
||||
|
||||
61
changelog.md
61
changelog.md
@@ -1,48 +1,31 @@
|
||||
## v1.4.14
|
||||
- 🐛 **Bugfix**: Alarmglocke versteckt sich jetzt zuverlässig auch auf Endgeräten mit CSS Konflikten
|
||||
- 🚀 **Feature**: Sofortige API-Aktualisierung (Refresh) bei Aktivierung eines Menüalarms
|
||||
- ⚡ **Optimierung**: "Unbekannt" im letzten Refresh-Zeitpunkt wird abgefangen und zeigt initial "gerade eben"
|
||||
## v1.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.4.13 (2026-02-24)
|
||||
- **Fix**: Die Farben der Glocke funktionieren nun verlässlich, da CSS-Variablen durch direkte Hex-Codes ersetzt wurden.
|
||||
## 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.4.12 (2026-02-24)
|
||||
- **Fix**: Das Glocken-Icon sollte nun endgültig versteckt bleiben, wenn keine Benachrichtigungen aktiv sind (CSS-Kollision mit `.hidden` behoben).
|
||||
## v1.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.4.11 (2026-02-24)
|
||||
- **Feature**: Das Versionsmenü prüft nun im Hintergrund direkt beim Öffnen nach neuen Versionen und aktualisiert die Liste automatisch, selbst wenn eine veraltete Liste noch im Cache liegt.
|
||||
## v1.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.4.10 (2026-02-24)
|
||||
- **Fix**: Die Farben der Benachrichtigungs-Glocke wurden korrigiert: Sie ist nun gelb, während man auf ein Menü wartet, und wird grün, sobald eines verfügbar ist.
|
||||
## v1.5.0 (2026-02-26)
|
||||
Das große "Quality of Life"-Update! Zusammenfassung aller Features und Fixes seit v1.4.0:
|
||||
|
||||
## v1.4.9 (2026-02-24)
|
||||
- **Fix**: Das Glocken-Icon für Benachrichtigungen wird nun direkt beim Start (wenn Daten aus dem lokalen Cache geladen werden) korrekt angezeigt.
|
||||
- ✨ **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.8 (2026-02-24)
|
||||
- **Fix**: Die Benachrichtigungs-Glocke wird nun korrekt in Gelb dargestellt, wenn beobachtete Menüs verfügbar sind.
|
||||
- **Tools**: Fehler in Testskript behoben, der den CI/CD Build verlangsamt hat.
|
||||
|
||||
## v1.4.7 (2026-02-24)
|
||||
- **Performance**: Die Bestellhistorie nutzt nun einen inkrementellen Delta-Cache anstatt immer alle Seiten von der API herunterzuladen, was die Ladezeiten für Vielbesteller enorm reduziert.
|
||||
|
||||
## v1.4.6 (2026-02-24)
|
||||
- **Fix**: Die Umrandung für bereits bestellte Menüs der vergangenen Tage ist nun ebenfalls einheitlich violett statt blau.
|
||||
|
||||
## v1.4.5 (2026-02-24)
|
||||
- **Fix**: Doppelten Scrollbalken in der Versionen-Liste entfernt.
|
||||
|
||||
## v1.4.4 (2026-02-24)
|
||||
- **Feature**: Das Versionsmenü enthält nun direkte Links zu GitHub, um Fehler zu melden oder neue Features vorzuschlagen.
|
||||
|
||||
## v1.4.3 (2026-02-24)
|
||||
- **Fix**: Der Rahmen des "Heute Bestellt" Menüs ist nun konsequent violett (passend zum Glow-Effekt).
|
||||
|
||||
## v1.4.2 (2026-02-23)
|
||||
- **Fix**: Das "Heute Bestellt" Menü leuchtet nun stimmig im Design-Violett statt Blau.
|
||||
- **Fix**: Abfangen des GitHub API Rate Limit (403) im Versionsdialog mit einer freundlicheren Fehlermeldung, da der User-Agent im Browser nicht manuell gesetzt werden darf.
|
||||
|
||||
## v1.4.1 (2026-02-22)
|
||||
- **UX Verbesserungen**: Bestellhistorie gruppiert nach Jahren und Monaten mittels einklappbarem Akkordeon. Monatssummen integriert und Stati farblich abgehoben (Offen, Abgeschlossen, Storniert).
|
||||
|
||||
## v1.4.0 (2026-02-22)
|
||||
- **Feature**: Bestellhistorie per Knopfdruck abrufbar. Übersichtliche Darstellung, gruppiert nach Monaten und Kalenderwochen, inklusive Stornos. 📜✨
|
||||
|
||||
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.
4
dist/bookmarklet-payload.js
vendored
4
dist/bookmarklet-payload.js
vendored
File diff suppressed because one or more lines are too long
2
dist/bookmarklet.txt
vendored
2
dist/bookmarklet.txt
vendored
File diff suppressed because one or more lines are too long
113
dist/install.html
vendored
113
dist/install.html
vendored
File diff suppressed because one or more lines are too long
560
dist/kantine-standalone.html
vendored
560
dist/kantine-standalone.html
vendored
@@ -67,12 +67,22 @@ body {
|
||||
}
|
||||
|
||||
/* 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 {
|
||||
height: auto !important;
|
||||
min-height: 100% !important;
|
||||
overflow-y: auto !important;
|
||||
overflow-x: hidden !important;
|
||||
overflow-x: clip !important;
|
||||
/* clip prevents horizontal overflow without breaking sticky */
|
||||
overflow-y: visible !important;
|
||||
position: static !important;
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
@@ -80,8 +90,7 @@ body {
|
||||
|
||||
/* Header */
|
||||
.app-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
flex-shrink: 0;
|
||||
z-index: 100;
|
||||
backdrop-filter: blur(12px);
|
||||
background-color: var(--header-bg);
|
||||
@@ -164,6 +173,38 @@ body {
|
||||
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 {
|
||||
display: flex;
|
||||
background-color: var(--bg-card);
|
||||
@@ -391,13 +432,21 @@ body {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
/* Container */
|
||||
/* Container - flex column, full width so child scrollbar is at edge */
|
||||
.container {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
/* Full width */
|
||||
margin: 2rem auto;
|
||||
padding: 0 2rem;
|
||||
min-height: 80vh;
|
||||
overflow: hidden;
|
||||
padding: 0 0 0 0;
|
||||
/* Only top padding, no horizontal so child fills width */
|
||||
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 */
|
||||
@@ -746,14 +795,17 @@ body {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Menu Grid */
|
||||
/* Menu Grid Container */
|
||||
.menu-grid {
|
||||
display: grid;
|
||||
gap: 2rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.week-section {
|
||||
margin-bottom: 3rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.week-header {
|
||||
@@ -775,10 +827,25 @@ body {
|
||||
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 {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
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 */
|
||||
@@ -787,21 +854,33 @@ body {
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--border-color);
|
||||
box-shadow: var(--card-shadow);
|
||||
overflow: hidden;
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
overflow: clip;
|
||||
/* Clips scrolling content behind sticky header */
|
||||
transition: box-shadow 0.2s ease;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Past Day Styling - Target specific elements so ordered items can remain visible */
|
||||
.menu-card.past-day .card-header,
|
||||
/* Past Day Styling - Target specific elements so ordered items can remain visible AND preserve sticky context */
|
||||
/* 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) {
|
||||
opacity: 0.6;
|
||||
filter: grayscale(0.8);
|
||||
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) {
|
||||
opacity: 0.8;
|
||||
filter: grayscale(0.4);
|
||||
@@ -848,7 +927,6 @@ body {
|
||||
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
@@ -858,7 +936,23 @@ body {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
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 {
|
||||
@@ -871,13 +965,6 @@ body {
|
||||
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 {
|
||||
color: var(--text-secondary);
|
||||
@@ -924,6 +1011,7 @@ body {
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.6;
|
||||
margin-bottom: 0.75rem;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.badges {
|
||||
@@ -1005,12 +1093,12 @@ body {
|
||||
|
||||
/* Footer */
|
||||
.app-footer {
|
||||
flex-shrink: 0;
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
padding: 0.4rem 2rem;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.875rem;
|
||||
font-size: 0.8rem;
|
||||
border-top: 1px solid var(--border-color);
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
/* === Order / Cancel Buttons (inline in status row) === */
|
||||
@@ -1341,17 +1429,20 @@ body {
|
||||
|
||||
/* Day Header Status Colors (User Request) */
|
||||
.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;
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
@@ -1979,11 +2070,12 @@ body {
|
||||
let currentWeekNumber = getISOWeek(new Date());
|
||||
let currentYear = new Date().getFullYear();
|
||||
let displayMode = 'this-week';
|
||||
let authToken = sessionStorage.getItem('kantine_authToken');
|
||||
let currentUser = sessionStorage.getItem('kantine_currentUser');
|
||||
let authToken = localStorage.getItem('kantine_authToken');
|
||||
let currentUser = localStorage.getItem('kantine_currentUser');
|
||||
let orderMap = new Map();
|
||||
let userFlags = new Set(JSON.parse(localStorage.getItem('kantine_flags') || '[]'));
|
||||
let pollIntervalId = null;
|
||||
let langMode = localStorage.getItem('kantine_lang') || 'de';
|
||||
|
||||
// === API Helpers ===
|
||||
function apiHeaders(token) {
|
||||
@@ -2000,6 +2092,16 @@ body {
|
||||
// Replace entire page content
|
||||
document.title = 'Kantine Weekly Menu';
|
||||
|
||||
// Inject custom favicon (triangle + fork & knife PNG)
|
||||
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 = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAKQUlEQVR4nKVXa3RVxRX+9sw5uY/khjwgECAJiCFUQhMSCFZKAYMWBQSDRVsUrcXLy4hg2yA0XgLKqjzEmPAIFIggahFs1YovRCh2iYXQgkQUyiNAgBBCgCQ3955zZnZ/hNRGoau0e635MXtmP8737Zl9hvB/CBGBmQkA/0vJTJg7l/41792b2hhVVrbONYqK9P8UFwClpaV1S05O/tVVnfhfHLU6u1GRRKSioqJeamhoyM/Ozv5eRUXF11eTUBnr1nVrlpJE6LJpMVwkZAQ0uxw4LotEBJGIEG4zytdsHTjkf7zSuMHgAoBOTk7uVVNTMzEhIYErKyuXGoZxl5ORIaiiQgVVaMgZ21qjLEcTswEpQUQAETQY0A7I9MIJBp8BUHmj0JFpmlxVVbVw6O1DPdu3f+xYljU8ISFhFCoqbPZnm0cmTin3KT2VXBGGA2Zt26wsix0rrHUopNkKh1VjUIXAl1u/6D/Lpk0SLYUmAaiuXbsOE1KMmhsIqN6908WkSX4+c+bMEmZ2Y1WFgt9v1kyZUdZe82TDjFAQpEEEBgQIAgwJAWmwEP85gUCgZW3cOAUixuDBxMzy+PHjiyc89BBycgbAcWz53HMLdIeEDqk+n2+6EEJj1Srm4mLXqUlPlMWCF8HjkSAoAQKhhQoCQRAzAFy7BpgJRDp99eqO58lam9TYMKXiyVknYxMS8n0+X8a8+fOVbdvStm3ExsaKuYG5etq0abMfffSBDWvXpJ0DTXd6li3rfxrOeLZsTSAB1gpEktFS+ay0c20EAgGBuXPlzWtW3NLHq5Xl2MGvvd6D499+PetSbe3P5zxTyEldk8g0TXi9XgCgqVOnck5OTvS6l994QVKRvnn54r7VUFttRjI7Dhgg9ngkiEAEJhCEkC1FdT0G2q9cWhKWxqgr7Tr2iGusKalvCI6dYXq+eDL3ztwt773HVSdOUDgURkxsDPr368dkSNw/+t5LGU89Pv7LXmmrbaW6kG07TEJKQRQlxOZGpe7TDCW8HhndHJpyftrMld9Q0AI7p61Y0e2S6cza1bnH9P4nj6R5L587tckbP3iN2zfsNZeZG7WvQkc3BsWgQT9Cu+honD17Fh9u28ZXai+Ih+fM3lWeGLdMgrqQ5TgMIun1UJxtP1UzefoLHVcWT6snKtUMJiHsb1FAQCAgkoSoDztqSL/qox/ZU568MxS29k0IXvxi4YCBSU92TuKFF6pFVv5kDL/jDnRKTMTwu+/CyuUrRGFJMXb16n6PJtGdwiHNgoTwumW8Zc08O3n6C6o433V28vRl8VpPFB432d9E/U7xceDtMm9xjXUwGAqdCQ/MnXbbof2fV1sh14yMbJ65Yxt53W6sTO6BK1WncaW+HjcP6IfnLp7H36uq4PZ6NRMJckUgxrZm1kyZuZQDAQNFRQqBgERRkZO4fGmBacgLJ/1PrLnu8Xtw+fIElCz6qt/G8mBDY4OTvXEdY0GAfSuWMhbN41ePfc3MzJdYc/r63zEWPcve0iU6omSxQsnCYOLS5wuoxZ/xbf8EIPDJJ+7rFuHgTwLGzqFFzuxlL41Y7zb+FCUNTYLEySuXIaVEY3MQW8eMw5CuKch8ZS2+qruAqEgvLNtmm4hKhwy7+Ef/lMMfHfhiGjc1/Y2ICID+9yRaO+G1LiLaObRIR3ftGvfm4qVlbw29ky0p6FD1aTSFw7jS1AQNwutHvoIhJQr63wohJWzbgaUVFab1xtQ+feNy88beimDwebfb1Xr0v5GW4HS9BISUUjdUV8+KbN++S1b3HnrNLRmUn9UPo29OxaTMbMRFelH+9wqMeWcLHknPwBsjxwChZqy/Zyx6Vp3GocNf88i777YBDOvUKfEBAAotV3mbirsW+gIA5eXlpQJoHjNmtFLMes6sAlaNjdwq208eZ2/JIsai+Tzw9fXcYIX5WH0dMzNveGUDb9v2EdfV1SkAWkhx2u/3t2v1fa2AbeA3DIO3bt26sHOXzm6fz8fETGxI1DYHAQCL9n6Gvgmd8Pbo++CKiMBfTlUh59VyCGpxFXYcuFxuXLhwQdx113DVJz29y7p16+ZKKfW1EP93hQSgEhIScpVSY15+uVzV19dLIkL7+PaoOXYMvz2wD79+/10M2bwRtyV2waYRYxBhmDhUV4cBr5bjQH0dms6eQ2paGvbv34/MzExZVlambNt+PCsrK/NaVLQmQADAzPLMmTOLJzw8AcNy74BlO9i+4xPkP+bHkqqjeHr7B2gXH4/9586i78Z1uKdHKjbcPQqCGedDIQz5/QZ4BuagY4cOePX3ryM9PZ0GDLgVI0eOMPbs2fOSyxVxTc4BQBCRiomJ+UVsbGzm/HnzlNZajn9wPHa9/wFKq09gfdUx+EwXHK0hDBOP3NIHYaUxruct2PLAeESYJhrCYTx16CBW7/sreiYmYviIEdBay5LSUhUZGTkoOrrdI0TUBoXWwuAFCxbEXr58ed7sObN1YmJnspXChAcfQv3tgzDjnT8g0uOBZo2m5mbMSc/ArKwcmEKgtq4OMQe/wozkboAQsJpD8O/8GEn+RxHXLga2UuiW0o0KC3+ja2trny8oKIhHywkQrdAbUkpHGGJx95TuTx2sPOiAyDClgcLP/oxnd/8FUS43lFawtMbvRo1ByqEjeHfHTnjcLrDWSEpJwaTH/Hjl+BE89OYmRLk9aAwFMfsHg/DcbYOhlIJmrfpm9pWHDx9excyTHMeRABQBoP79+6fu2bPnwJY3t5h59+YRAHrmsz9j/qc7EeWJhNIKzY4Df6fOeDwtHX2ys7/D5drychz8fDfifnY/Cnd/iihPJBpDQUzMzELZ7cMhhMDH27erYbm5okePHgOPHj36GQBJpmnCcZzNeWPz8ja/sdkBs1n4+ad49tOd8Hm9sGxbhVnTaw88iB9cDuLpeUUQROjYsRNcLhcu1l/EhdpaJKekwD9pEnr1TEPZPw5h8rtvIUqaaGwOitG9vof1d4xAtNvj5D+RL0pLSvcxcw4REXVJ6ZJXXVW95dezCjDizh9jd7SHCz7+kHyRkVCOYiM6iqYmJiH7YgOUaYKY8eWXX+LEieMIhcKIj49DWq9euKn7TbAsC1caG9HOFYFdMV6sPHUSZtjSV5oaRW5qT17Suy99+NZbeHr2HET7fL+sr69fQvHx8e83h0I3BZua1NifjnNX5o3sdvjUKccd4RJhZuFP7bX7/NKSuC0ffNjmFnO5XSAScBwbju18h5JHfnIf47EJ1obDh9O9zLrBkOIx03Np7cQp50yPW7hcrvpBPxw0EsXFxS5mFsxsMLOrQ9mL272vrOaoNcu48+qXCq9GNZhZXt333wx51UYklL1YGlm+gmNWvhgeuGXDrcws9jKbe/fuNQcPHvzdn+IRG5fHdt2wKr/7qpKRV/u5aOmmNyatNgJA6stlw9JXL/v+dffimybRNlLr26Dtvv92fNs3rj5w2ugNtG2LDGbCjh0StbWMceNUm7UblxabTZskKisZRLqNHsA/AQoXyB/6HdQ3AAAAAElFTkSuQmCC';
|
||||
document.head.appendChild(favicon);
|
||||
|
||||
// Inject Google Fonts if not already present
|
||||
if (!document.querySelector('link[href*="fonts.googleapis.com/css2?family=Inter"]')) {
|
||||
const fontLink = document.createElement('link');
|
||||
@@ -2021,18 +2123,23 @@ body {
|
||||
<div class="brand">
|
||||
<span class="material-icons-round logo-icon">restaurant_menu</span>
|
||||
<div class="header-left">
|
||||
<h1>Kantinen Übersicht <small class="version-tag" style="font-size: 0.6em; opacity: 0.7; font-weight: 400; cursor: pointer;" title="Klick für Versionsmenü">v1.4.14</small></h1>
|
||||
<h1>Kantinen Übersicht <small class="version-tag" style="font-size: 0.6em; opacity: 0.7; font-weight: 400; cursor: pointer;" title="Klick für Versionsmenü">v1.6.3</small></h1>
|
||||
<div id="last-updated-subtitle" class="subtitle"></div>
|
||||
</div>
|
||||
<div class="nav-group" style="margin-left: 1rem;">
|
||||
<button id="btn-this-week" class="nav-btn active">Diese Woche</button>
|
||||
<button id="btn-next-week" class="nav-btn">Nächste Woche</button>
|
||||
<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>
|
||||
@@ -2046,17 +2153,17 @@ body {
|
||||
<button id="btn-highlights" class="icon-btn" aria-label="Persönliche Highlights verwalten" title="Persönliche Highlights verwalten">
|
||||
<span class="material-icons-round">label</span>
|
||||
</button>
|
||||
<button id="theme-toggle" class="icon-btn" aria-label="Toggle Theme">
|
||||
<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">
|
||||
<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">
|
||||
<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>
|
||||
@@ -2068,7 +2175,7 @@ body {
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h2>Login</h2>
|
||||
<button id="btn-login-close" class="icon-btn" aria-label="Close">
|
||||
<button id="btn-login-close" class="icon-btn" aria-label="Close" title="Schließen">
|
||||
<span class="material-icons-round">close</span>
|
||||
</button>
|
||||
</div>
|
||||
@@ -2112,7 +2219,7 @@ body {
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h2>Meine Highlights</h2>
|
||||
<button id="btn-highlights-close" class="icon-btn" aria-label="Close">
|
||||
<button id="btn-highlights-close" class="icon-btn" aria-label="Close" title="Schließen">
|
||||
<span class="material-icons-round">close</span>
|
||||
</button>
|
||||
</div>
|
||||
@@ -2121,8 +2228,8 @@ body {
|
||||
Markiere Menüs automatisch, wenn sie diese Schlagwörter enthalten.
|
||||
</p>
|
||||
<div class="input-group">
|
||||
<input type="text" id="tag-input" placeholder="z.B. Schnitzel, Vegetarisch...">
|
||||
<button id="btn-add-tag" class="btn-primary">Hinzufügen</button>
|
||||
<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>
|
||||
@@ -2133,7 +2240,7 @@ body {
|
||||
<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">
|
||||
<button id="btn-history-close" class="icon-btn" aria-label="Close" title="Schließen">
|
||||
<span class="material-icons-round">close</span>
|
||||
</button>
|
||||
</div>
|
||||
@@ -2157,13 +2264,13 @@ body {
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h2>📦 Versionen</h2>
|
||||
<button id="btn-version-close" class="icon-btn" aria-label="Close">
|
||||
<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">v1.4.14</span>
|
||||
<strong>Aktuell:</strong> <span id="version-current">v1.6.3</span>
|
||||
</div>
|
||||
<div class="dev-toggle">
|
||||
<label style="display:flex;align-items:center;gap:8px;cursor:pointer;">
|
||||
@@ -2181,6 +2288,9 @@ body {
|
||||
<a href="https://github.com/TauNeutrino/kantine-overview/discussions/categories/ideas" target="_blank" rel="noopener noreferrer" style="color: var(--primary-color); text-decoration: none; display: flex; align-items: center; gap: 0.5rem;" title="Schlage ein neues Feature auf GitHub vor">
|
||||
<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>
|
||||
@@ -2199,7 +2309,7 @@ body {
|
||||
</main>
|
||||
|
||||
<footer class="app-footer">
|
||||
<p>Bessa Knapp-Kantine Wrapper • <span id="current-year">${new Date().getFullYear()}</span></p>
|
||||
<p>Jetzt Bessa Einfach! • Knapp-Kantine Wrapper • <span id="current-year">${new Date().getFullYear()}</span> by Kaufis-Kitchen</p>
|
||||
</footer>
|
||||
</div>`;
|
||||
}
|
||||
@@ -2228,6 +2338,29 @@ body {
|
||||
const historyModal = document.getElementById('history-modal');
|
||||
const btnHistoryClose = document.getElementById('btn-history-close');
|
||||
|
||||
// Language Toggle
|
||||
document.querySelectorAll('.lang-btn').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
langMode = btn.dataset.lang;
|
||||
localStorage.setItem('kantine_lang', langMode);
|
||||
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');
|
||||
@@ -2265,6 +2398,21 @@ body {
|
||||
});
|
||||
}
|
||||
|
||||
const btnClearCache = document.getElementById('btn-clear-cache');
|
||||
if (btnClearCache) {
|
||||
btnClearCache.addEventListener('click', () => {
|
||||
if (confirm('Möchtest du wirklich alle lokalen Daten (inkl. Login-Session, Cache und Einstellungen) löschen? Die Seite wird danach neu geladen.')) {
|
||||
// Only clear our own keys so we don't destroy the host app's (Bessa's) session
|
||||
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');
|
||||
});
|
||||
@@ -2373,8 +2521,8 @@ body {
|
||||
if (response.ok) {
|
||||
authToken = data.key;
|
||||
currentUser = employeeId;
|
||||
sessionStorage.setItem('kantine_authToken', data.key);
|
||||
sessionStorage.setItem('kantine_currentUser', employeeId);
|
||||
localStorage.setItem('kantine_authToken', data.key);
|
||||
localStorage.setItem('kantine_currentUser', employeeId);
|
||||
|
||||
// Fetch user name
|
||||
try {
|
||||
@@ -2383,8 +2531,8 @@ body {
|
||||
});
|
||||
if (userResp.ok) {
|
||||
const userData = await userResp.json();
|
||||
if (userData.first_name) sessionStorage.setItem('kantine_firstName', userData.first_name);
|
||||
if (userData.last_name) sessionStorage.setItem('kantine_lastName', userData.last_name);
|
||||
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);
|
||||
@@ -2414,10 +2562,10 @@ body {
|
||||
|
||||
// Logout
|
||||
btnLogout.addEventListener('click', () => {
|
||||
sessionStorage.removeItem('kantine_authToken');
|
||||
sessionStorage.removeItem('kantine_currentUser');
|
||||
sessionStorage.removeItem('kantine_firstName');
|
||||
sessionStorage.removeItem('kantine_lastName');
|
||||
localStorage.removeItem('kantine_authToken');
|
||||
localStorage.removeItem('kantine_currentUser');
|
||||
localStorage.removeItem('kantine_firstName');
|
||||
localStorage.removeItem('kantine_lastName');
|
||||
authToken = null;
|
||||
currentUser = null;
|
||||
orderMap = new Map();
|
||||
@@ -2438,13 +2586,13 @@ body {
|
||||
if (parsed.auth && parsed.auth.token) {
|
||||
console.log('Found existing Bessa session!');
|
||||
authToken = parsed.auth.token;
|
||||
sessionStorage.setItem('kantine_authToken', authToken);
|
||||
localStorage.setItem('kantine_authToken', authToken);
|
||||
|
||||
if (parsed.auth.user) {
|
||||
currentUser = parsed.auth.user.id || 'unknown';
|
||||
sessionStorage.setItem('kantine_currentUser', currentUser);
|
||||
if (parsed.auth.user.firstName) sessionStorage.setItem('kantine_firstName', parsed.auth.user.firstName);
|
||||
if (parsed.auth.user.lastName) sessionStorage.setItem('kantine_lastName', parsed.auth.user.lastName);
|
||||
localStorage.setItem('kantine_currentUser', currentUser);
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2453,9 +2601,9 @@ body {
|
||||
}
|
||||
}
|
||||
|
||||
authToken = sessionStorage.getItem('kantine_authToken');
|
||||
currentUser = sessionStorage.getItem('kantine_currentUser');
|
||||
const firstName = sessionStorage.getItem('kantine_firstName');
|
||||
authToken = localStorage.getItem('kantine_authToken');
|
||||
currentUser = 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');
|
||||
@@ -2505,6 +2653,7 @@ body {
|
||||
}
|
||||
console.log(`Fetched ${results.length} orders, mapped active ones.`);
|
||||
renderVisibleWeeks();
|
||||
updateNextWeekBadge();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching orders:', error);
|
||||
@@ -2709,7 +2858,7 @@ body {
|
||||
const monthGroup = yearGroup.months[mKey];
|
||||
|
||||
html += `<div class="history-month-group">
|
||||
<div class="history-month-header" tabindex="0" role="button" aria-expanded="false">
|
||||
<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">
|
||||
@@ -2820,7 +2969,7 @@ body {
|
||||
venue: VENUE_ID,
|
||||
states: [],
|
||||
order_state: 1,
|
||||
date: `${date}T10:00:00.000Z`,
|
||||
date: `${date}T10:30:00Z`,
|
||||
payment_method: 'payroll',
|
||||
customer: {
|
||||
first_name: userData.first_name,
|
||||
@@ -2828,7 +2977,7 @@ body {
|
||||
email: userData.email,
|
||||
newsletter: false
|
||||
},
|
||||
preorder: false,
|
||||
preorder: true,
|
||||
delivery_fee: 0,
|
||||
cash_box_table_name: null,
|
||||
take_away: false
|
||||
@@ -3034,12 +3183,27 @@ body {
|
||||
// FR-019: Auto-remove flags whose cutoff has passed
|
||||
function cleanupExpiredFlags() {
|
||||
const now = new Date();
|
||||
const todayStr = now.toISOString().split('T')[0]; // Format: YYYY-MM-DD
|
||||
let changed = false;
|
||||
|
||||
for (const flagId of [...userFlags]) {
|
||||
const [date] = flagId.split('_');
|
||||
const cutoff = new Date(date);
|
||||
cutoff.setHours(10, 0, 0, 0); // Standard cutoff 10:00
|
||||
if (now >= cutoff) {
|
||||
const [dateStr] = flagId.split('_'); // Format usually is YYYY-MM-DD
|
||||
|
||||
// If the flag's date string is entirely in the past (before today)
|
||||
// or if it's today but past the 10:00 cutoff time
|
||||
let isExpired = false;
|
||||
|
||||
if (dateStr < todayStr) {
|
||||
isExpired = true;
|
||||
} else if (dateStr === todayStr) {
|
||||
const cutoff = new Date(dateStr);
|
||||
cutoff.setHours(10, 0, 0, 0); // Standard cutoff 10:00
|
||||
if (now >= cutoff) {
|
||||
isExpired = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (isExpired) {
|
||||
userFlags.delete(flagId);
|
||||
changed = true;
|
||||
}
|
||||
@@ -3140,7 +3304,7 @@ body {
|
||||
highlightTags.forEach(tag => {
|
||||
const badge = document.createElement('span');
|
||||
badge.className = 'tag-badge';
|
||||
badge.innerHTML = `${tag} <span class="tag-remove" data-tag="${tag}">×</span>`;
|
||||
badge.innerHTML = `${tag} <span class="tag-remove" data-tag="${tag}" title="Schlagwort entfernen">×</span>`;
|
||||
list.appendChild(badge);
|
||||
});
|
||||
|
||||
@@ -3186,6 +3350,25 @@ body {
|
||||
updateNextWeekBadge();
|
||||
updateAlarmBell();
|
||||
if (cachedTs) updateLastUpdatedTime(cachedTs);
|
||||
|
||||
// --- TEMP DEBUG LOGGER ---
|
||||
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
const res = Array.from(uniqueMenus).join('\n\n');
|
||||
console.log("=== GEFUNDENE MENÜ-TEXTE (" + uniqueMenus.size + ") ===");
|
||||
console.log(res);
|
||||
} catch (e) { }
|
||||
|
||||
console.log('Loaded menu from cache');
|
||||
return true;
|
||||
}
|
||||
@@ -3555,12 +3738,17 @@ body {
|
||||
badge.classList.add('has-highlights');
|
||||
}
|
||||
|
||||
// FR-092: Highlight Next Week Button when new data arrives
|
||||
const storageKey = `kantine_notified_nextweek_${nextYear}_${nextWeek}`;
|
||||
if (!localStorage.getItem(storageKey)) {
|
||||
localStorage.setItem(storageKey, 'true');
|
||||
// FR-092: Glow Next Week button while data exists but no orders placed
|
||||
if (daysWithOrders === 0) {
|
||||
btnNextWeek.classList.add('new-week-available');
|
||||
showToast('Neue Menüdaten für nächste Woche verfügbar!', 'info');
|
||||
// One-time toast notification when new data first arrives
|
||||
const storageKey = `kantine_notified_nextweek_${nextYear}_${nextWeek}`;
|
||||
if (!localStorage.getItem(storageKey)) {
|
||||
localStorage.setItem(storageKey, 'true');
|
||||
showToast('Neue Menüdaten für nächste Woche verfügbar!', 'info');
|
||||
}
|
||||
} else {
|
||||
btnNextWeek.classList.remove('new-week-available');
|
||||
}
|
||||
|
||||
} else if (badge) {
|
||||
@@ -3877,7 +4065,7 @@ body {
|
||||
<div class="badges">${statusBadge}</div>
|
||||
</div>
|
||||
${tagsHtml}
|
||||
<p class="item-desc">${escapeHtml(item.description)}</p>`;
|
||||
<p class="item-desc">${escapeHtml(getLocalizedText(item.description))}</p>`;
|
||||
|
||||
// Event: Order
|
||||
const orderBtn = itemEl.querySelector('.btn-order');
|
||||
@@ -3969,7 +4157,7 @@ body {
|
||||
|
||||
// Periodic update check (runs on init + every hour)
|
||||
async function checkForUpdates() {
|
||||
const currentVersion = 'v1.4.14';
|
||||
const currentVersion = 'v1.6.3';
|
||||
const devMode = localStorage.getItem('kantine_dev_mode') === 'true';
|
||||
|
||||
try {
|
||||
@@ -4010,7 +4198,7 @@ body {
|
||||
const modal = document.getElementById('version-modal');
|
||||
const container = document.getElementById('version-list-container');
|
||||
const devToggle = document.getElementById('dev-mode-toggle');
|
||||
const currentVersion = 'v1.4.14';
|
||||
const currentVersion = 'v1.6.3';
|
||||
|
||||
if (!modal) return;
|
||||
modal.classList.remove('hidden');
|
||||
@@ -4107,6 +4295,12 @@ body {
|
||||
|
||||
// === Order Countdown ===
|
||||
function updateCountdown() {
|
||||
// Only show order alarms for logged-in users
|
||||
if (!authToken || !currentUser) {
|
||||
removeCountdown();
|
||||
return;
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const currentDay = now.getDay();
|
||||
// Skip weekends (0=Sun, 6=Sat)
|
||||
@@ -4171,7 +4365,7 @@ body {
|
||||
|
||||
// Notification logic (One time)
|
||||
const notifiedKey = `kantine_notified_${todayStr}`;
|
||||
if (!sessionStorage.getItem(notifiedKey)) {
|
||||
if (!localStorage.getItem(notifiedKey)) {
|
||||
if (Notification.permission === 'granted') {
|
||||
new Notification('Kantine: Bestellschluss naht!', {
|
||||
body: 'Du hast heute noch nichts bestellt. Nur noch 1 Stunde!',
|
||||
@@ -4180,7 +4374,7 @@ body {
|
||||
} else if (Notification.permission === 'default') {
|
||||
Notification.requestPermission();
|
||||
}
|
||||
sessionStorage.setItem(notifiedKey, 'true');
|
||||
localStorage.setItem(notifiedKey, 'true');
|
||||
}
|
||||
} else {
|
||||
countdownEl.classList.remove('urgent');
|
||||
@@ -4224,6 +4418,206 @@ body {
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
// === Language Filter (FR-100) ===
|
||||
// DE stems for fallback language detection
|
||||
const DE_STEMS = [
|
||||
'mit', 'und', 'oder', 'für', 'vom', 'zum', 'zur', 'gebraten', 'kartoffel', 'gemüse', 'suppe',
|
||||
'kuchen', 'schwein', 'rind', 'hähnchen', 'huhn', 'fisch', 'nudel', 'soße', 'sosse', 'wurst',
|
||||
'kürbis', 'braten', 'sahne', 'apfel', 'käse', 'fleisch', 'pilz', 'kirsch', 'joghurt', 'spätzle',
|
||||
'knödel', 'kraut', 'schnitzel', 'püree', 'rahm', 'erdbeer', 'schoko', 'vanille', 'tomate',
|
||||
'gurke', 'salat', 'zwiebel', 'paprika', 'reis', 'bohne', 'erbse', 'karotte', 'möhre', 'lauch',
|
||||
'knoblauch', 'chili', 'gewürz', 'kräuter', 'pfeffer', 'salz', 'butter', 'milch', 'eier',
|
||||
'pfanne', 'auflauf', 'gratin', 'ragout', 'gulasch', 'eintopf', 'filet', 'steak', 'brust',
|
||||
'salami', 'schinken', 'speck', 'brokkoli', 'blumenkohl', 'zucchini', 'aubergine',
|
||||
'spinat', 'spargel', 'olive', 'mandel', 'nuss', 'honig', 'senf', 'essig', 'öl', 'brot',
|
||||
'brötchen', 'pfannkuchen', 'eis', 'torte', 'dessert', 'kompott', 'obst', 'frucht', 'beere',
|
||||
'plunder', 'dip', 'tofu', 'jambalaya'
|
||||
];
|
||||
const EN_STEMS = [
|
||||
'with', 'and', 'or', 'for', 'from', 'to', 'fried', 'potato', 'vegetable', 'soup', 'cake',
|
||||
'pork', 'beef', 'chicken', 'fish', 'noodle', 'sauce', 'sausage', 'pumpkin', 'roast',
|
||||
'cream', 'apple', 'cheese', 'meat', 'mushroom', 'cherry', 'yogurt', 'wedge', 'sweet',
|
||||
'sour', 'dumpling', 'cabbage', 'mash', 'strawberr', 'choco', 'vanilla', 'tomat', 'cucumber',
|
||||
'salad', 'onion', 'pepper', 'rice', 'bean', 'pea', 'carrot', 'leek', 'garlic', 'chili',
|
||||
'spice', 'herb', 'salt', 'butter', 'milk', 'egg', 'pan', 'casserole', 'gratin', 'ragout',
|
||||
'goulash', 'stew', 'filet', 'steak', 'breast', 'salami', 'ham', 'bacon', 'broccoli',
|
||||
'cauliflower', 'zucchini', 'eggplant', 'spinach', 'asparagus', 'olive', 'almond', 'nut',
|
||||
'honey', 'mustard', 'vinegar', 'oil', 'bread', 'bun', 'pancake', 'ice', 'tart', 'dessert',
|
||||
'compote', 'fruit', 'berry', 'dip', 'danish', 'tofu', 'jambalaya'
|
||||
];
|
||||
|
||||
/**
|
||||
* Splits bilingual menu text into DE and EN parts.
|
||||
* Pattern per course: [DE] / [EN](ALLERGENS)
|
||||
* Max 3 courses per menu item (sanity check).
|
||||
* @param {string} text - The bilingual description text
|
||||
* @returns {{ de: string, en: string, raw: string }}
|
||||
*/
|
||||
function splitLanguage(text) {
|
||||
if (!text) return { de: '', en: '', raw: '' };
|
||||
|
||||
const raw = text;
|
||||
const formattedRaw = '• ' + text.replace(/\(([A-Z ]+)\)\s*(?=\S)/g, '($1)\n• ');
|
||||
|
||||
// Utility to compute DE/EN score for a subset of words
|
||||
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;
|
||||
// Full match is better than partial string match
|
||||
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);
|
||||
|
||||
// Capitalized noun heuristic matches German text styles typically
|
||||
if (/^[A-ZÄÖÜ]/.test(word)) {
|
||||
de += 0.5;
|
||||
}
|
||||
}
|
||||
});
|
||||
return { de, en };
|
||||
}
|
||||
|
||||
// Heuristic sliding window to split a fragment containing "EN DE"
|
||||
// E.g., "Bratwurst with pumpkin Kirschjoghurt" => enPart: "Bratwurst with pumpkin", dePart: "Kirschjoghurt"
|
||||
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);
|
||||
|
||||
// left should be EN, right should be DE
|
||||
// Metric = (EN votes in left - DE votes in left) + (DE votes in right - EN votes in right)
|
||||
const score = (leftScore.en - leftScore.de) + (rightScore.de - rightScore.en);
|
||||
|
||||
// Extra penalty if the split puts a low-case word as the first word of the right (DE) part
|
||||
// because a new German sentence usually starts with a capital noun.
|
||||
const rightFirstWord = right[0];
|
||||
let capitalBonus = 0;
|
||||
if (/^[A-ZÄÖÜ]/.test(rightFirstWord)) {
|
||||
capitalBonus = 2.0;
|
||||
}
|
||||
|
||||
const finalScore = score + capitalBonus;
|
||||
|
||||
if (finalScore > maxScore) {
|
||||
maxScore = finalScore;
|
||||
bestK = k;
|
||||
}
|
||||
}
|
||||
|
||||
if (bestK !== -1) {
|
||||
return {
|
||||
enPart: words.slice(0, bestK).join(' '),
|
||||
nextDe: words.slice(bestK).join(' ')
|
||||
};
|
||||
}
|
||||
return { enPart: fragment, nextDe: '' };
|
||||
}
|
||||
|
||||
// Check if text contains the bilingual separator ' / '
|
||||
if (!text.includes(' / ')) {
|
||||
// Fallback: detect language via keyword scoring
|
||||
const words = text.toLowerCase().split(/\s+/);
|
||||
const score = scoreBlock(words);
|
||||
|
||||
// No split possible – return full text for detected language, empty for other
|
||||
if (score.en > score.de) {
|
||||
return { de: '', en: formattedRaw, raw: formattedRaw };
|
||||
}
|
||||
return { de: formattedRaw, en: '', raw: formattedRaw };
|
||||
}
|
||||
|
||||
// Split by ' / ' – produces alternating DE/EN fragments
|
||||
const parts = text.split(' / ');
|
||||
// Sanity check: max 3 courses means max 3 slashes → max 4 parts
|
||||
if (parts.length > 4) {
|
||||
// Too many slashes – possibly not bilingual, return as-is
|
||||
return { de: formattedRaw, en: '', raw: formattedRaw };
|
||||
}
|
||||
|
||||
const deParts = [];
|
||||
const enParts = [];
|
||||
|
||||
// First fragment is always DE (course 1)
|
||||
deParts.push(parts[0].trim());
|
||||
|
||||
// Process remaining fragments: each contains "EN(ALLERGENS) next_DE"
|
||||
// Allergen pattern: (LETTERS_AND_SPACES) at the boundary
|
||||
const allergenRegex = /\(([A-Z ]+)\)\s*/;
|
||||
|
||||
for (let i = 1; i < parts.length; i++) {
|
||||
const fragment = parts[i].trim();
|
||||
const match = fragment.match(allergenRegex);
|
||||
|
||||
if (match) {
|
||||
// Split: everything before allergen + allergen = EN, after = next DE
|
||||
const allergenEnd = match.index + match[0].length;
|
||||
const enPart = fragment.substring(0, match.index).trim();
|
||||
const allergenCode = match[1];
|
||||
const nextDe = fragment.substring(allergenEnd).trim();
|
||||
|
||||
enParts.push(enPart + '(' + allergenCode + ')');
|
||||
// Also append allergen to the last DE part
|
||||
if (deParts.length > 0) {
|
||||
deParts[deParts.length - 1] = deParts[deParts.length - 1] + '(' + allergenCode + ')';
|
||||
}
|
||||
|
||||
if (nextDe) {
|
||||
deParts.push(nextDe);
|
||||
}
|
||||
} else {
|
||||
// No allergen code found!
|
||||
// If this is the last fragment, it contains only the English text of the final course.
|
||||
// It should not be split again.
|
||||
if (i === parts.length - 1) {
|
||||
enParts.push(fragment);
|
||||
} else {
|
||||
// We use the heuristic to find the hidden split-point.
|
||||
const split = heuristicSplitEnDe(fragment);
|
||||
enParts.push(split.enPart);
|
||||
if (split.nextDe) {
|
||||
deParts.push(split.nextDe);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
de: deParts.map(p => '• ' + p).join('\n'),
|
||||
en: enParts.map(p => '• ' + p).join('\n'),
|
||||
raw: formattedRaw
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns text filtered by the current language mode.
|
||||
* @param {string} text - The bilingual text
|
||||
* @returns {string}
|
||||
*/
|
||||
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; // 'de' is default
|
||||
}
|
||||
|
||||
// === Bootstrap ===
|
||||
injectUI();
|
||||
bindEvents();
|
||||
|
||||
BIN
favicon.png
Executable file
BIN
favicon.png
Executable file
Binary file not shown.
|
After Width: | Height: | Size: 2.6 KiB |
29
favicon.svg
Executable file
29
favicon.svg
Executable file
@@ -0,0 +1,29 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
|
||||
<!-- Fork (left, with gap to triangle) -->
|
||||
<g transform="translate(2, 10)">
|
||||
<!-- Tines -->
|
||||
<rect x="1" y="0" width="1.8" height="16" rx="0.9" fill="#333"/>
|
||||
<rect x="4.6" y="0" width="1.8" height="16" rx="0.9" fill="#333"/>
|
||||
<rect x="8.2" y="0" width="1.8" height="16" rx="0.9" fill="#333"/>
|
||||
<!-- Connector -->
|
||||
<rect x="1" y="14" width="9" height="3.5" rx="1.5" fill="#333"/>
|
||||
<!-- Handle -->
|
||||
<rect x="3.5" y="16.5" width="4" height="24" rx="2" fill="#333"/>
|
||||
</g>
|
||||
|
||||
<!-- Triangle (center, equilateral aspect ratio ~1:0.866) -->
|
||||
<!-- Equilateral: base=28, height=24.25 => keeps proper ratio -->
|
||||
<polygon points="32,8 47,48 17,48" fill="none" stroke="#333" stroke-width="4" stroke-linejoin="round"/>
|
||||
|
||||
<!-- Knife (right, with gap to triangle) -->
|
||||
<g transform="translate(50, 10)">
|
||||
<!-- Blade (slight curve) -->
|
||||
<path d="M3,0 C3,0 3,0 3,0 L3,17 L10,14 C10,6 7,0 3,0 Z" fill="#333"/>
|
||||
<!-- Spine -->
|
||||
<rect x="1.5" y="0" width="2" height="18" rx="1" fill="#333"/>
|
||||
<!-- Bolster -->
|
||||
<rect x="1.5" y="16.5" width="8.5" height="3.5" rx="1.2" fill="#333"/>
|
||||
<!-- Handle -->
|
||||
<rect x="3.5" y="19" width="4" height="22" rx="2" fill="#333"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
BIN
favicon_base.png
Executable file
BIN
favicon_base.png
Executable file
Binary file not shown.
|
After Width: | Height: | Size: 2.0 MiB |
393
kantine.js
393
kantine.js
@@ -29,11 +29,12 @@
|
||||
let currentWeekNumber = getISOWeek(new Date());
|
||||
let currentYear = new Date().getFullYear();
|
||||
let displayMode = 'this-week';
|
||||
let authToken = sessionStorage.getItem('kantine_authToken');
|
||||
let currentUser = sessionStorage.getItem('kantine_currentUser');
|
||||
let authToken = localStorage.getItem('kantine_authToken');
|
||||
let currentUser = localStorage.getItem('kantine_currentUser');
|
||||
let orderMap = new Map();
|
||||
let userFlags = new Set(JSON.parse(localStorage.getItem('kantine_flags') || '[]'));
|
||||
let pollIntervalId = null;
|
||||
let langMode = localStorage.getItem('kantine_lang') || 'de';
|
||||
|
||||
// === API Helpers ===
|
||||
function apiHeaders(token) {
|
||||
@@ -50,6 +51,16 @@
|
||||
// Replace entire page content
|
||||
document.title = 'Kantine Weekly Menu';
|
||||
|
||||
// Inject custom favicon (triangle + fork & knife PNG)
|
||||
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);
|
||||
|
||||
// Inject Google Fonts if not already present
|
||||
if (!document.querySelector('link[href*="fonts.googleapis.com/css2?family=Inter"]')) {
|
||||
const fontLink = document.createElement('link');
|
||||
@@ -75,14 +86,19 @@
|
||||
<div id="last-updated-subtitle" class="subtitle"></div>
|
||||
</div>
|
||||
<div class="nav-group" style="margin-left: 1rem;">
|
||||
<button id="btn-this-week" class="nav-btn active">Diese Woche</button>
|
||||
<button id="btn-next-week" class="nav-btn">Nächste Woche</button>
|
||||
<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>
|
||||
@@ -96,17 +112,17 @@
|
||||
<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">
|
||||
<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">
|
||||
<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">
|
||||
<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>
|
||||
@@ -118,7 +134,7 @@
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h2>Login</h2>
|
||||
<button id="btn-login-close" class="icon-btn" aria-label="Close">
|
||||
<button id="btn-login-close" class="icon-btn" aria-label="Close" title="Schließen">
|
||||
<span class="material-icons-round">close</span>
|
||||
</button>
|
||||
</div>
|
||||
@@ -162,7 +178,7 @@
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h2>Meine Highlights</h2>
|
||||
<button id="btn-highlights-close" class="icon-btn" aria-label="Close">
|
||||
<button id="btn-highlights-close" class="icon-btn" aria-label="Close" title="Schließen">
|
||||
<span class="material-icons-round">close</span>
|
||||
</button>
|
||||
</div>
|
||||
@@ -171,8 +187,8 @@
|
||||
Markiere Menüs automatisch, wenn sie diese Schlagwörter enthalten.
|
||||
</p>
|
||||
<div class="input-group">
|
||||
<input type="text" id="tag-input" placeholder="z.B. Schnitzel, Vegetarisch...">
|
||||
<button id="btn-add-tag" class="btn-primary">Hinzufügen</button>
|
||||
<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>
|
||||
@@ -183,7 +199,7 @@
|
||||
<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">
|
||||
<button id="btn-history-close" class="icon-btn" aria-label="Close" title="Schließen">
|
||||
<span class="material-icons-round">close</span>
|
||||
</button>
|
||||
</div>
|
||||
@@ -207,7 +223,7 @@
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h2>📦 Versionen</h2>
|
||||
<button id="btn-version-close" class="icon-btn" aria-label="Close">
|
||||
<button id="btn-version-close" class="icon-btn" aria-label="Close" title="Schließen">
|
||||
<span class="material-icons-round">close</span>
|
||||
</button>
|
||||
</div>
|
||||
@@ -231,6 +247,9 @@
|
||||
<a href="https://github.com/TauNeutrino/kantine-overview/discussions/categories/ideas" target="_blank" rel="noopener noreferrer" style="color: var(--primary-color); text-decoration: none; display: flex; align-items: center; gap: 0.5rem;" title="Schlage ein neues Feature auf GitHub vor">
|
||||
<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>
|
||||
@@ -249,7 +268,7 @@
|
||||
</main>
|
||||
|
||||
<footer class="app-footer">
|
||||
<p>Bessa Knapp-Kantine Wrapper • <span id="current-year">${new Date().getFullYear()}</span></p>
|
||||
<p>Jetzt Bessa Einfach! • Knapp-Kantine Wrapper • <span id="current-year">${new Date().getFullYear()}</span> by Kaufis-Kitchen</p>
|
||||
</footer>
|
||||
</div>`;
|
||||
}
|
||||
@@ -278,6 +297,29 @@
|
||||
const historyModal = document.getElementById('history-modal');
|
||||
const btnHistoryClose = document.getElementById('btn-history-close');
|
||||
|
||||
// Language Toggle
|
||||
document.querySelectorAll('.lang-btn').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
langMode = btn.dataset.lang;
|
||||
localStorage.setItem('kantine_lang', langMode);
|
||||
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');
|
||||
@@ -315,6 +357,21 @@
|
||||
});
|
||||
}
|
||||
|
||||
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.')) {
|
||||
// Only clear our own keys so we don't destroy the host app's (Bessa's) session
|
||||
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');
|
||||
});
|
||||
@@ -423,8 +480,8 @@
|
||||
if (response.ok) {
|
||||
authToken = data.key;
|
||||
currentUser = employeeId;
|
||||
sessionStorage.setItem('kantine_authToken', data.key);
|
||||
sessionStorage.setItem('kantine_currentUser', employeeId);
|
||||
localStorage.setItem('kantine_authToken', data.key);
|
||||
localStorage.setItem('kantine_currentUser', employeeId);
|
||||
|
||||
// Fetch user name
|
||||
try {
|
||||
@@ -433,8 +490,8 @@
|
||||
});
|
||||
if (userResp.ok) {
|
||||
const userData = await userResp.json();
|
||||
if (userData.first_name) sessionStorage.setItem('kantine_firstName', userData.first_name);
|
||||
if (userData.last_name) sessionStorage.setItem('kantine_lastName', userData.last_name);
|
||||
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);
|
||||
@@ -464,10 +521,10 @@
|
||||
|
||||
// Logout
|
||||
btnLogout.addEventListener('click', () => {
|
||||
sessionStorage.removeItem('kantine_authToken');
|
||||
sessionStorage.removeItem('kantine_currentUser');
|
||||
sessionStorage.removeItem('kantine_firstName');
|
||||
sessionStorage.removeItem('kantine_lastName');
|
||||
localStorage.removeItem('kantine_authToken');
|
||||
localStorage.removeItem('kantine_currentUser');
|
||||
localStorage.removeItem('kantine_firstName');
|
||||
localStorage.removeItem('kantine_lastName');
|
||||
authToken = null;
|
||||
currentUser = null;
|
||||
orderMap = new Map();
|
||||
@@ -488,13 +545,13 @@
|
||||
if (parsed.auth && parsed.auth.token) {
|
||||
console.log('Found existing Bessa session!');
|
||||
authToken = parsed.auth.token;
|
||||
sessionStorage.setItem('kantine_authToken', authToken);
|
||||
localStorage.setItem('kantine_authToken', authToken);
|
||||
|
||||
if (parsed.auth.user) {
|
||||
currentUser = parsed.auth.user.id || 'unknown';
|
||||
sessionStorage.setItem('kantine_currentUser', currentUser);
|
||||
if (parsed.auth.user.firstName) sessionStorage.setItem('kantine_firstName', parsed.auth.user.firstName);
|
||||
if (parsed.auth.user.lastName) sessionStorage.setItem('kantine_lastName', parsed.auth.user.lastName);
|
||||
localStorage.setItem('kantine_currentUser', currentUser);
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -503,9 +560,9 @@
|
||||
}
|
||||
}
|
||||
|
||||
authToken = sessionStorage.getItem('kantine_authToken');
|
||||
currentUser = sessionStorage.getItem('kantine_currentUser');
|
||||
const firstName = sessionStorage.getItem('kantine_firstName');
|
||||
authToken = localStorage.getItem('kantine_authToken');
|
||||
currentUser = 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');
|
||||
@@ -555,6 +612,7 @@
|
||||
}
|
||||
console.log(`Fetched ${results.length} orders, mapped active ones.`);
|
||||
renderVisibleWeeks();
|
||||
updateNextWeekBadge();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching orders:', error);
|
||||
@@ -759,7 +817,7 @@
|
||||
const monthGroup = yearGroup.months[mKey];
|
||||
|
||||
html += `<div class="history-month-group">
|
||||
<div class="history-month-header" tabindex="0" role="button" aria-expanded="false">
|
||||
<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">
|
||||
@@ -870,7 +928,7 @@
|
||||
venue: VENUE_ID,
|
||||
states: [],
|
||||
order_state: 1,
|
||||
date: `${date}T10:00:00.000Z`,
|
||||
date: `${date}T10:30:00Z`,
|
||||
payment_method: 'payroll',
|
||||
customer: {
|
||||
first_name: userData.first_name,
|
||||
@@ -878,7 +936,7 @@
|
||||
email: userData.email,
|
||||
newsletter: false
|
||||
},
|
||||
preorder: false,
|
||||
preorder: true,
|
||||
delivery_fee: 0,
|
||||
cash_box_table_name: null,
|
||||
take_away: false
|
||||
@@ -1084,12 +1142,27 @@
|
||||
// FR-019: Auto-remove flags whose cutoff has passed
|
||||
function cleanupExpiredFlags() {
|
||||
const now = new Date();
|
||||
const todayStr = now.toISOString().split('T')[0]; // Format: YYYY-MM-DD
|
||||
let changed = false;
|
||||
|
||||
for (const flagId of [...userFlags]) {
|
||||
const [date] = flagId.split('_');
|
||||
const cutoff = new Date(date);
|
||||
cutoff.setHours(10, 0, 0, 0); // Standard cutoff 10:00
|
||||
if (now >= cutoff) {
|
||||
const [dateStr] = flagId.split('_'); // Format usually is YYYY-MM-DD
|
||||
|
||||
// If the flag's date string is entirely in the past (before today)
|
||||
// or if it's today but past the 10:00 cutoff time
|
||||
let isExpired = false;
|
||||
|
||||
if (dateStr < todayStr) {
|
||||
isExpired = true;
|
||||
} else if (dateStr === todayStr) {
|
||||
const cutoff = new Date(dateStr);
|
||||
cutoff.setHours(10, 0, 0, 0); // Standard cutoff 10:00
|
||||
if (now >= cutoff) {
|
||||
isExpired = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (isExpired) {
|
||||
userFlags.delete(flagId);
|
||||
changed = true;
|
||||
}
|
||||
@@ -1190,7 +1263,7 @@
|
||||
highlightTags.forEach(tag => {
|
||||
const badge = document.createElement('span');
|
||||
badge.className = 'tag-badge';
|
||||
badge.innerHTML = `${tag} <span class="tag-remove" data-tag="${tag}">×</span>`;
|
||||
badge.innerHTML = `${tag} <span class="tag-remove" data-tag="${tag}" title="Schlagwort entfernen">×</span>`;
|
||||
list.appendChild(badge);
|
||||
});
|
||||
|
||||
@@ -1236,6 +1309,25 @@
|
||||
updateNextWeekBadge();
|
||||
updateAlarmBell();
|
||||
if (cachedTs) updateLastUpdatedTime(cachedTs);
|
||||
|
||||
// --- TEMP DEBUG LOGGER ---
|
||||
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
const res = Array.from(uniqueMenus).join('\n\n');
|
||||
console.log("=== GEFUNDENE MENÜ-TEXTE (" + uniqueMenus.size + ") ===");
|
||||
console.log(res);
|
||||
} catch (e) { }
|
||||
|
||||
console.log('Loaded menu from cache');
|
||||
return true;
|
||||
}
|
||||
@@ -1605,12 +1697,17 @@
|
||||
badge.classList.add('has-highlights');
|
||||
}
|
||||
|
||||
// FR-092: Highlight Next Week Button when new data arrives
|
||||
const storageKey = `kantine_notified_nextweek_${nextYear}_${nextWeek}`;
|
||||
if (!localStorage.getItem(storageKey)) {
|
||||
localStorage.setItem(storageKey, 'true');
|
||||
// FR-092: Glow Next Week button while data exists but no orders placed
|
||||
if (daysWithOrders === 0) {
|
||||
btnNextWeek.classList.add('new-week-available');
|
||||
showToast('Neue Menüdaten für nächste Woche verfügbar!', 'info');
|
||||
// One-time toast notification when new data first arrives
|
||||
const storageKey = `kantine_notified_nextweek_${nextYear}_${nextWeek}`;
|
||||
if (!localStorage.getItem(storageKey)) {
|
||||
localStorage.setItem(storageKey, 'true');
|
||||
showToast('Neue Menüdaten für nächste Woche verfügbar!', 'info');
|
||||
}
|
||||
} else {
|
||||
btnNextWeek.classList.remove('new-week-available');
|
||||
}
|
||||
|
||||
} else if (badge) {
|
||||
@@ -1927,7 +2024,7 @@
|
||||
<div class="badges">${statusBadge}</div>
|
||||
</div>
|
||||
${tagsHtml}
|
||||
<p class="item-desc">${escapeHtml(item.description)}</p>`;
|
||||
<p class="item-desc">${escapeHtml(getLocalizedText(item.description))}</p>`;
|
||||
|
||||
// Event: Order
|
||||
const orderBtn = itemEl.querySelector('.btn-order');
|
||||
@@ -2157,6 +2254,12 @@
|
||||
|
||||
// === Order Countdown ===
|
||||
function updateCountdown() {
|
||||
// Only show order alarms for logged-in users
|
||||
if (!authToken || !currentUser) {
|
||||
removeCountdown();
|
||||
return;
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const currentDay = now.getDay();
|
||||
// Skip weekends (0=Sun, 6=Sat)
|
||||
@@ -2221,7 +2324,7 @@
|
||||
|
||||
// Notification logic (One time)
|
||||
const notifiedKey = `kantine_notified_${todayStr}`;
|
||||
if (!sessionStorage.getItem(notifiedKey)) {
|
||||
if (!localStorage.getItem(notifiedKey)) {
|
||||
if (Notification.permission === 'granted') {
|
||||
new Notification('Kantine: Bestellschluss naht!', {
|
||||
body: 'Du hast heute noch nichts bestellt. Nur noch 1 Stunde!',
|
||||
@@ -2230,7 +2333,7 @@
|
||||
} else if (Notification.permission === 'default') {
|
||||
Notification.requestPermission();
|
||||
}
|
||||
sessionStorage.setItem(notifiedKey, 'true');
|
||||
localStorage.setItem(notifiedKey, 'true');
|
||||
}
|
||||
} else {
|
||||
countdownEl.classList.remove('urgent');
|
||||
@@ -2274,6 +2377,206 @@
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
// === Language Filter (FR-100) ===
|
||||
// DE stems for fallback language detection
|
||||
const DE_STEMS = [
|
||||
'mit', 'und', 'oder', 'für', 'vom', 'zum', 'zur', 'gebraten', 'kartoffel', 'gemüse', 'suppe',
|
||||
'kuchen', 'schwein', 'rind', 'hähnchen', 'huhn', 'fisch', 'nudel', 'soße', 'sosse', 'wurst',
|
||||
'kürbis', 'braten', 'sahne', 'apfel', 'käse', 'fleisch', 'pilz', 'kirsch', 'joghurt', 'spätzle',
|
||||
'knödel', 'kraut', 'schnitzel', 'püree', 'rahm', 'erdbeer', 'schoko', 'vanille', 'tomate',
|
||||
'gurke', 'salat', 'zwiebel', 'paprika', 'reis', 'bohne', 'erbse', 'karotte', 'möhre', 'lauch',
|
||||
'knoblauch', 'chili', 'gewürz', 'kräuter', 'pfeffer', 'salz', 'butter', 'milch', 'eier',
|
||||
'pfanne', 'auflauf', 'gratin', 'ragout', 'gulasch', 'eintopf', 'filet', 'steak', 'brust',
|
||||
'salami', 'schinken', 'speck', 'brokkoli', 'blumenkohl', 'zucchini', 'aubergine',
|
||||
'spinat', 'spargel', 'olive', 'mandel', 'nuss', 'honig', 'senf', 'essig', 'öl', 'brot',
|
||||
'brötchen', 'pfannkuchen', 'eis', 'torte', 'dessert', 'kompott', 'obst', 'frucht', 'beere',
|
||||
'plunder', 'dip', 'tofu', 'jambalaya'
|
||||
];
|
||||
const EN_STEMS = [
|
||||
'with', 'and', 'or', 'for', 'from', 'to', 'fried', 'potato', 'vegetable', 'soup', 'cake',
|
||||
'pork', 'beef', 'chicken', 'fish', 'noodle', 'sauce', 'sausage', 'pumpkin', 'roast',
|
||||
'cream', 'apple', 'cheese', 'meat', 'mushroom', 'cherry', 'yogurt', 'wedge', 'sweet',
|
||||
'sour', 'dumpling', 'cabbage', 'mash', 'strawberr', 'choco', 'vanilla', 'tomat', 'cucumber',
|
||||
'salad', 'onion', 'pepper', 'rice', 'bean', 'pea', 'carrot', 'leek', 'garlic', 'chili',
|
||||
'spice', 'herb', 'salt', 'butter', 'milk', 'egg', 'pan', 'casserole', 'gratin', 'ragout',
|
||||
'goulash', 'stew', 'filet', 'steak', 'breast', 'salami', 'ham', 'bacon', 'broccoli',
|
||||
'cauliflower', 'zucchini', 'eggplant', 'spinach', 'asparagus', 'olive', 'almond', 'nut',
|
||||
'honey', 'mustard', 'vinegar', 'oil', 'bread', 'bun', 'pancake', 'ice', 'tart', 'dessert',
|
||||
'compote', 'fruit', 'berry', 'dip', 'danish', 'tofu', 'jambalaya'
|
||||
];
|
||||
|
||||
/**
|
||||
* Splits bilingual menu text into DE and EN parts.
|
||||
* Pattern per course: [DE] / [EN](ALLERGENS)
|
||||
* Max 3 courses per menu item (sanity check).
|
||||
* @param {string} text - The bilingual description text
|
||||
* @returns {{ de: string, en: string, raw: string }}
|
||||
*/
|
||||
function splitLanguage(text) {
|
||||
if (!text) return { de: '', en: '', raw: '' };
|
||||
|
||||
const raw = text;
|
||||
const formattedRaw = '• ' + text.replace(/\(([A-Z ]+)\)\s*(?=\S)/g, '($1)\n• ');
|
||||
|
||||
// Utility to compute DE/EN score for a subset of words
|
||||
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;
|
||||
// Full match is better than partial string match
|
||||
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);
|
||||
|
||||
// Capitalized noun heuristic matches German text styles typically
|
||||
if (/^[A-ZÄÖÜ]/.test(word)) {
|
||||
de += 0.5;
|
||||
}
|
||||
}
|
||||
});
|
||||
return { de, en };
|
||||
}
|
||||
|
||||
// Heuristic sliding window to split a fragment containing "EN DE"
|
||||
// E.g., "Bratwurst with pumpkin Kirschjoghurt" => enPart: "Bratwurst with pumpkin", dePart: "Kirschjoghurt"
|
||||
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);
|
||||
|
||||
// left should be EN, right should be DE
|
||||
// Metric = (EN votes in left - DE votes in left) + (DE votes in right - EN votes in right)
|
||||
const score = (leftScore.en - leftScore.de) + (rightScore.de - rightScore.en);
|
||||
|
||||
// Extra penalty if the split puts a low-case word as the first word of the right (DE) part
|
||||
// because a new German sentence usually starts with a capital noun.
|
||||
const rightFirstWord = right[0];
|
||||
let capitalBonus = 0;
|
||||
if (/^[A-ZÄÖÜ]/.test(rightFirstWord)) {
|
||||
capitalBonus = 2.0;
|
||||
}
|
||||
|
||||
const finalScore = score + capitalBonus;
|
||||
|
||||
if (finalScore > maxScore) {
|
||||
maxScore = finalScore;
|
||||
bestK = k;
|
||||
}
|
||||
}
|
||||
|
||||
if (bestK !== -1) {
|
||||
return {
|
||||
enPart: words.slice(0, bestK).join(' '),
|
||||
nextDe: words.slice(bestK).join(' ')
|
||||
};
|
||||
}
|
||||
return { enPart: fragment, nextDe: '' };
|
||||
}
|
||||
|
||||
// Check if text contains the bilingual separator ' / '
|
||||
if (!text.includes(' / ')) {
|
||||
// Fallback: detect language via keyword scoring
|
||||
const words = text.toLowerCase().split(/\s+/);
|
||||
const score = scoreBlock(words);
|
||||
|
||||
// No split possible – return full text for detected language, empty for other
|
||||
if (score.en > score.de) {
|
||||
return { de: '', en: formattedRaw, raw: formattedRaw };
|
||||
}
|
||||
return { de: formattedRaw, en: '', raw: formattedRaw };
|
||||
}
|
||||
|
||||
// Split by ' / ' – produces alternating DE/EN fragments
|
||||
const parts = text.split(' / ');
|
||||
// Sanity check: max 3 courses means max 3 slashes → max 4 parts
|
||||
if (parts.length > 4) {
|
||||
// Too many slashes – possibly not bilingual, return as-is
|
||||
return { de: formattedRaw, en: '', raw: formattedRaw };
|
||||
}
|
||||
|
||||
const deParts = [];
|
||||
const enParts = [];
|
||||
|
||||
// First fragment is always DE (course 1)
|
||||
deParts.push(parts[0].trim());
|
||||
|
||||
// Process remaining fragments: each contains "EN(ALLERGENS) next_DE"
|
||||
// Allergen pattern: (LETTERS_AND_SPACES) at the boundary
|
||||
const allergenRegex = /\(([A-Z ]+)\)\s*/;
|
||||
|
||||
for (let i = 1; i < parts.length; i++) {
|
||||
const fragment = parts[i].trim();
|
||||
const match = fragment.match(allergenRegex);
|
||||
|
||||
if (match) {
|
||||
// Split: everything before allergen + allergen = EN, after = next DE
|
||||
const allergenEnd = match.index + match[0].length;
|
||||
const enPart = fragment.substring(0, match.index).trim();
|
||||
const allergenCode = match[1];
|
||||
const nextDe = fragment.substring(allergenEnd).trim();
|
||||
|
||||
enParts.push(enPart + '(' + allergenCode + ')');
|
||||
// Also append allergen to the last DE part
|
||||
if (deParts.length > 0) {
|
||||
deParts[deParts.length - 1] = deParts[deParts.length - 1] + '(' + allergenCode + ')';
|
||||
}
|
||||
|
||||
if (nextDe) {
|
||||
deParts.push(nextDe);
|
||||
}
|
||||
} else {
|
||||
// No allergen code found!
|
||||
// If this is the last fragment, it contains only the English text of the final course.
|
||||
// It should not be split again.
|
||||
if (i === parts.length - 1) {
|
||||
enParts.push(fragment);
|
||||
} else {
|
||||
// We use the heuristic to find the hidden split-point.
|
||||
const split = heuristicSplitEnDe(fragment);
|
||||
enParts.push(split.enPart);
|
||||
if (split.nextDe) {
|
||||
deParts.push(split.nextDe);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
de: deParts.map(p => '• ' + p).join('\n'),
|
||||
en: enParts.map(p => '• ' + p).join('\n'),
|
||||
raw: formattedRaw
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns text filtered by the current language mode.
|
||||
* @param {string} text - The bilingual text
|
||||
* @returns {string}
|
||||
*/
|
||||
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; // 'de' is default
|
||||
}
|
||||
|
||||
// === Bootstrap ===
|
||||
injectUI();
|
||||
bindEvents();
|
||||
|
||||
@@ -49,8 +49,9 @@ echo "=== Pushing to remotes ==="
|
||||
git push origin HEAD
|
||||
git push origin --force tag "$VERSION"
|
||||
|
||||
# If a remote named 'github' exists, push tags there too
|
||||
# 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
|
||||
|
||||
|
||||
159
style.css
159
style.css
@@ -56,12 +56,22 @@ body {
|
||||
}
|
||||
|
||||
/* 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 {
|
||||
height: auto !important;
|
||||
min-height: 100% !important;
|
||||
overflow-y: auto !important;
|
||||
overflow-x: hidden !important;
|
||||
overflow-x: clip !important;
|
||||
/* clip prevents horizontal overflow without breaking sticky */
|
||||
overflow-y: visible !important;
|
||||
position: static !important;
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
@@ -69,8 +79,7 @@ body {
|
||||
|
||||
/* Header */
|
||||
.app-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
flex-shrink: 0;
|
||||
z-index: 100;
|
||||
backdrop-filter: blur(12px);
|
||||
background-color: var(--header-bg);
|
||||
@@ -153,6 +162,38 @@ body {
|
||||
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 {
|
||||
display: flex;
|
||||
background-color: var(--bg-card);
|
||||
@@ -380,13 +421,21 @@ body {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
/* Container */
|
||||
/* Container - flex column, full width so child scrollbar is at edge */
|
||||
.container {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
/* Full width */
|
||||
margin: 2rem auto;
|
||||
padding: 0 2rem;
|
||||
min-height: 80vh;
|
||||
overflow: hidden;
|
||||
padding: 0 0 0 0;
|
||||
/* Only top padding, no horizontal so child fills width */
|
||||
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 */
|
||||
@@ -735,14 +784,17 @@ body {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Menu Grid */
|
||||
/* Menu Grid Container */
|
||||
.menu-grid {
|
||||
display: grid;
|
||||
gap: 2rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.week-section {
|
||||
margin-bottom: 3rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.week-header {
|
||||
@@ -764,10 +816,25 @@ body {
|
||||
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 {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
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 */
|
||||
@@ -776,21 +843,33 @@ body {
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--border-color);
|
||||
box-shadow: var(--card-shadow);
|
||||
overflow: hidden;
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
overflow: clip;
|
||||
/* Clips scrolling content behind sticky header */
|
||||
transition: box-shadow 0.2s ease;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Past Day Styling - Target specific elements so ordered items can remain visible */
|
||||
.menu-card.past-day .card-header,
|
||||
/* Past Day Styling - Target specific elements so ordered items can remain visible AND preserve sticky context */
|
||||
/* 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) {
|
||||
opacity: 0.6;
|
||||
filter: grayscale(0.8);
|
||||
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) {
|
||||
opacity: 0.8;
|
||||
filter: grayscale(0.4);
|
||||
@@ -837,7 +916,6 @@ body {
|
||||
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
@@ -847,7 +925,23 @@ body {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
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 {
|
||||
@@ -860,13 +954,6 @@ body {
|
||||
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 {
|
||||
color: var(--text-secondary);
|
||||
@@ -913,6 +1000,7 @@ body {
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.6;
|
||||
margin-bottom: 0.75rem;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.badges {
|
||||
@@ -994,12 +1082,12 @@ body {
|
||||
|
||||
/* Footer */
|
||||
.app-footer {
|
||||
flex-shrink: 0;
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
padding: 0.4rem 2rem;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.875rem;
|
||||
font-size: 0.8rem;
|
||||
border-top: 1px solid var(--border-color);
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
/* === Order / Cancel Buttons (inline in status row) === */
|
||||
@@ -1330,17 +1418,20 @@ body {
|
||||
|
||||
/* Day Header Status Colors (User Request) */
|
||||
.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;
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
|
||||
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'));
|
||||
}
|
||||
}
|
||||
@@ -62,6 +62,7 @@ const sandbox = {
|
||||
}
|
||||
return createMockElement('query-result');
|
||||
},
|
||||
querySelectorAll: () => [createMockElement()],
|
||||
getElementById: (id) => createMockElement(id),
|
||||
documentElement: {
|
||||
setAttribute: () => { },
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
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>
|
||||
@@ -15,35 +20,170 @@ const html = `
|
||||
<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>
|
||||
`;
|
||||
|
||||
const jsCode = fs.readFileSync('kantine.js', 'utf8').replace('(function () {', '').replace(/}\)\(\);$/, '');
|
||||
log("Reading file jsCode...");
|
||||
const jsCode = fs.readFileSync('kantine.js', 'utf8')
|
||||
.replace('(function () {', '')
|
||||
.replace('})();', '')
|
||||
.replace('if (window.__KANTINE_LOADED) return;', '')
|
||||
.replace('window.location.reload();', 'window.__RELOAD_CALLED = true;');
|
||||
|
||||
const dom = new JSDOM(html, { runScripts: "dangerously" });
|
||||
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.localStorage = { getItem: () => '[]', setItem: () => { } };
|
||||
global.sessionStorage = { getItem: () => null };
|
||||
|
||||
global.showToast = () => {};
|
||||
global.saveFlags = () => {};
|
||||
global.renderVisibleWeeks = () => {};
|
||||
global.showToast = () => { };
|
||||
global.saveFlags = () => { };
|
||||
global.renderVisibleWeeks = () => { };
|
||||
// Mock missing browser features if needed
|
||||
global.Notification = { permission: 'default', requestPermission: () => {} };
|
||||
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;
|
||||
|
||||
try {
|
||||
dom.window.eval(jsCode);
|
||||
console.log("Initial Bell Classes:", window.document.getElementById('alarm-bell').className);
|
||||
log("Before eval...");
|
||||
const testCode = `
|
||||
console.log("--- Testing Alarm Bell ---");
|
||||
// Add flag
|
||||
userFlags.add('2026-02-24_123'); updateAlarmBell();
|
||||
if (document.getElementById('alarm-bell').className.includes('hidden')) throw new Error("Bell should be visible");
|
||||
|
||||
// Add flag
|
||||
dom.window.eval("userFlags.add('2026-02-24_123'); updateAlarmBell();");
|
||||
console.log("After Add:", window.document.getElementById('alarm-bell').className);
|
||||
// Remove flag
|
||||
userFlags.delete('2026-02-24_123'); updateAlarmBell();
|
||||
if (!document.getElementById('alarm-bell').className.includes('hidden')) throw new Error("Bell should be hidden");
|
||||
|
||||
// Remove flag
|
||||
dom.window.eval("userFlags.delete('2026-02-24_123'); updateAlarmBell();");
|
||||
console.log("After Remove:", window.document.getElementById('alarm-bell').className);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
console.log("✅ Alarm Bell Test Passed");
|
||||
|
||||
console.log("--- Testing Highlights Modal ---");
|
||||
// First, verify initial state
|
||||
const hlModal = document.getElementById('highlights-modal');
|
||||
if (!hlModal.classList.contains('hidden')) throw new Error("Highlights modal should be hidden initially");
|
||||
|
||||
// Click to open
|
||||
document.getElementById('btn-highlights').click();
|
||||
if (hlModal.classList.contains('hidden')) throw new Error("Highlights modal did not open upon clicking btn-highlights!");
|
||||
|
||||
// Click to close
|
||||
document.getElementById('btn-highlights-close').click();
|
||||
if (!hlModal.classList.contains('hidden')) throw new Error("Highlights modal did not close upon clicking btn-highlights-close!");
|
||||
|
||||
console.log("✅ Highlights Modal Test Passed");
|
||||
|
||||
console.log("--- Testing Login Modal ---");
|
||||
const loginModal = document.getElementById('login-modal');
|
||||
document.getElementById('btn-login-open').click();
|
||||
if (loginModal.classList.contains('hidden')) throw new Error("Login modal should open");
|
||||
document.getElementById('btn-login-close').click();
|
||||
if (!loginModal.classList.contains('hidden')) throw new Error("Login modal should close");
|
||||
console.log("✅ Login Modal Test Passed");
|
||||
|
||||
console.log("--- Testing History Modal ---");
|
||||
// We need authToken to be truthy to open history modal
|
||||
authToken = "fake_token";
|
||||
const historyModal = document.getElementById('history-modal');
|
||||
document.getElementById('btn-history').click();
|
||||
if (historyModal.classList.contains('hidden')) throw new Error("History modal should open");
|
||||
document.getElementById('btn-history-close').click();
|
||||
if (!historyModal.classList.contains('hidden')) throw new Error("History modal should close");
|
||||
console.log("✅ History Modal Test Passed");
|
||||
|
||||
console.log("--- Testing Version Modal ---");
|
||||
const versionModal = document.getElementById('version-modal');
|
||||
document.querySelector('.version-tag').click();
|
||||
if (versionModal.classList.contains('hidden')) throw new Error("Version modal should open");
|
||||
document.getElementById('btn-version-close').click();
|
||||
if (!versionModal.classList.contains('hidden')) throw new Error("Version modal should close");
|
||||
console.log("✅ Version Modal Test Passed");
|
||||
|
||||
console.log("--- Testing Theme Toggle ---");
|
||||
const themeBtn = document.getElementById('theme-toggle');
|
||||
const initialTheme = document.documentElement.getAttribute('data-theme');
|
||||
themeBtn.click();
|
||||
const newTheme = document.documentElement.getAttribute('data-theme');
|
||||
if (initialTheme === newTheme) throw new Error("Theme did not toggle");
|
||||
console.log("✅ Theme Toggle Test Passed");
|
||||
|
||||
console.log("--- Testing Navigation Tabs ---");
|
||||
const btnThis = document.getElementById('btn-this-week');
|
||||
const btnNext = document.getElementById('btn-next-week');
|
||||
btnNext.click();
|
||||
if (!btnNext.classList.contains('active') || btnThis.classList.contains('active')) throw new Error("Next week tab not active");
|
||||
btnThis.click();
|
||||
if (!btnThis.classList.contains('active') || btnNext.classList.contains('active')) throw new Error("This week tab not active");
|
||||
console.log("✅ Navigation Tabs Test Passed");
|
||||
|
||||
console.log("--- Testing Clear Cache Button ---");
|
||||
// Mock confirm directly inside evaluated JSDOM context
|
||||
window.confirm = () => true;
|
||||
document.getElementById('btn-clear-cache').click();
|
||||
if (!window.__RELOAD_CALLED) throw new Error("Clear cache did not reload the page");
|
||||
console.log("✅ Clear Cache Button Test Passed");
|
||||
|
||||
window.__TEST_PASSED = true;
|
||||
`;
|
||||
|
||||
dom.window.eval(jsCode + "\n" + testCode);
|
||||
|
||||
if (!dom.window.__TEST_PASSED) {
|
||||
throw new Error("Tests failed to reach completion inside JSDOM.");
|
||||
}
|
||||
|
||||
process.exit(0);
|
||||
|
||||
@@ -1 +1 @@
|
||||
v1.4.14
|
||||
v1.6.3
|
||||
|
||||
Reference in New Issue
Block a user