Compare commits

..

52 Commits

Author SHA1 Message Date
Kantine Wrapper
884a17e7d3 chore: update build artifacts for v1.6.6 2026-03-06 08:41:06 +01:00
Kantine Wrapper
ad660799ec style: flatten past ordered items 2026-03-06 08:41:06 +01:00
Kantine Wrapper
a5fe50bbf0 chore: update build artifacts for v1.6.5 2026-03-06 08:39:09 +01:00
Kantine Wrapper
a98faec51e chore: release v1.6.5 2026-03-06 08:39:09 +01:00
Kantine Wrapper
212bf3b015 chore: update build artifacts for v1.6.4 2026-03-06 08:38:24 +01:00
Kantine Wrapper
f29ecd4b79 style: remove past ordered borders and swap header icon 2026-03-06 08:38:17 +01:00
Kantine Wrapper
1be6e44d7f chore: update build artifacts for v1.6.4 2026-03-05 16:38:09 +01:00
Kantine Wrapper
49b0ab17ac chore: refine language heuristics and dictionary 2026-03-05 16:37:55 +01:00
Kantine Wrapper
55e738a554 chore: update build artifacts for v1.6.3 2026-03-05 12:47:56 +01:00
Kantine Wrapper
45adfa9d5d fix: Correct menu item text extraction from name to description and introduce a language splitting utility. 2026-03-05 12:47:51 +01:00
Kantine Wrapper
f5f6dddba3 chore: update build artifacts for v1.6.3 2026-03-05 12:43:49 +01:00
Kantine Wrapper
b06f6c3551 chore: inject temp menu logger 2026-03-05 12:43:25 +01:00
Kantine Wrapper
b66030dce5 fix: prevent heuristic split on final English-only fragment 2026-03-05 11:56:55 +01:00
Kantine Wrapper
8e7ec468d4 chore: update build artifacts for v1.6.3 2026-03-05 11:46:57 +01:00
Kantine Wrapper
8ce3ae4c92 feat: Update footer slogan, optimize footer height and container padding, and bump version to v1.6.3. 2026-03-05 11:46:51 +01:00
Kantine Wrapper
6a70a5a5e8 feat: sticky headers (v1.6.2) 2026-03-05 11:34:19 +01:00
Kantine Wrapper
edec109552 chore: update build artifacts for v1.6.1 2026-03-04 14:46:17 +01:00
Kantine Wrapper
a7aea2ece3 chore: version bump 2026-03-04 14:46:11 +01:00
Kantine Wrapper
49dc1cc135 chore: update build artifacts for v1.6.0 2026-03-04 14:45:25 +01:00
Kantine Wrapper
90f1c0ed04 feat: Add descriptive German tooltips to various UI elements for improved usability 2026-03-04 14:45:13 +01:00
Kantine Wrapper
42978c6e7e chore: update build artifacts for v1.6.0 2026-03-04 13:29:25 +01:00
Kantine Wrapper
6ad3498bcc cleanup test file 2026-03-04 13:27:13 +01:00
Kantine Wrapper
b44ecb2ccf v1.6.0: Language Filter 2026-03-04 13:26:46 +01:00
Kantine Wrapper
9e161e2907 chore: update build artifacts for v1.6.0 2026-03-04 13:11:43 +01:00
Kantine Wrapper
8b15760463 feat: Introduce language filter with DE/EN/ALL toggle for menu descriptions and update to version 1.6.0. 2026-03-04 13:11:34 +01:00
Kantine Wrapper
4aa67c9cbe chore: update build artifacts for v1.5.1 2026-03-04 11:42:04 +01:00
Kantine Wrapper
12c55ef883 feat: Add a collapsing video banner to the installation page. 2026-03-04 11:41:58 +01:00
Kantine Wrapper
1e9dd9a3b5 chore: update build artifacts for v1.5.1 2026-03-04 11:39:19 +01:00
Kantine Wrapper
db8b2c5629 fix: Correct Friday order payload preorder and time, and update version to v1.5.1. 2026-03-04 11:39:09 +01:00
Kantine Wrapper
67533875bd chore: update build artifacts for v1.5.1 2026-03-04 11:33:37 +01:00
Kantine Wrapper
99809dafb7 fix: correct preorder flag and date format for Friday orders (v1.5.1) 2026-03-04 11:33:37 +01:00
Kantine Wrapper
4cf3e4adc2 chore: update build artifacts for v1.5.0 2026-02-26 17:37:34 +01:00
Kantine Wrapper
4fe7950697 style: update favicon_base.png 2026-02-26 17:37:34 +01:00
Kantine Wrapper
e162a16550 build: autogenerate favicon.png and update installer assets for v1.5.0 2026-02-26 17:36:58 +01:00
Kantine Wrapper
b7c3aac921 chore: update build artifacts for v1.5.0 2026-02-26 17:13:51 +01:00
Kantine Wrapper
a902732d4b style: replace logo unicode char with dynamic favicon image in installer 2026-02-26 17:13:51 +01:00
Kantine Wrapper
eaab21151a chore: update build artifacts for v1.5.0 2026-02-26 17:10:33 +01:00
Kantine Wrapper
5af1f86700 feat: Update favicon and add theming support to the bookmarklet. 2026-02-26 17:10:27 +01:00
Kantine Wrapper
90b503ddb7 chore: update build artifacts for v1.5.0 2026-02-26 12:43:13 +01:00
Kantine Wrapper
10ffbd8c68 build: automatically generate favicon.png from favicon_base.png if present 2026-02-26 12:43:03 +01:00
Kantine Wrapper
0294db7976 chore: update build artifacts for v1.5.0 2026-02-26 12:39:20 +01:00
Kantine Wrapper
5a2c23524d chore: update build artifacts for v1.5.0 2026-02-26 12:35:04 +01:00
Kantine Wrapper
d4a9d39d67 feat: implement history modal with comprehensive styling, general UI improvements, and favicon update. 2026-02-26 12:34:50 +01:00
Kantine Wrapper
3c8d946a1e style: update favicon to new design for v1.5.0 release 2026-02-26 11:20:49 +01:00
Kantine Wrapper
f71bcf1ac7 chore: update build artifacts for v1.5.0 2026-02-26 10:39:49 +01:00
Kantine Wrapper
b041e9f318 chore: bump version to 1.5.0 and rollup changelog 2026-02-26 10:39:49 +01:00
Kantine Wrapper
984a897f73 chore: update build artifacts for v1.4.31 2026-02-26 10:35:28 +01:00
Kantine Wrapper
ae54d97d96 fix: target localStorage cleanup to kantine_ prefix to prevent host session loss (v1.4.31) 2026-02-26 10:35:28 +01:00
Kantine Wrapper
6ed0831f5d chore: update build artifacts for v1.4.30 2026-02-26 10:18:50 +01:00
Kantine Wrapper
ba75544f68 fix: session loss and order alarm rendering across unauthenticated sessions (v1.4.30) 2026-02-26 10:18:50 +01:00
Kantine Wrapper
7fdf7f6f3e chore: update build artifacts for v1.4.29 2026-02-26 10:01:39 +01:00
Kantine Wrapper
614f498d11 fix: defer favicon injection with setTimeout for htmlpreview compat (v1.4.29) 2026-02-26 10:01:17 +01:00
17 changed files with 1029 additions and 432 deletions

View File

@@ -43,6 +43,7 @@ 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.

View File

@@ -61,6 +61,8 @@ Das System umfasst die Darstellung von Menüplänen in einer Wochenübersicht, d
| 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 | 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-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 |

View File

@@ -24,6 +24,24 @@ echo "=== Kantine Bookmarklet Builder ($VERSION) ==="
# Check files exist
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
@@ -128,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>
@@ -243,13 +278,16 @@ $CHANGELOG_HTML
EOF
cat >> "$DIST_DIR/install.html" << INSTALLEOF
// Dynamic favicon injection (overrides proxy defaults like htmlpreview.github.io)
// 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>
@@ -265,26 +303,30 @@ ls -la "$DIST_DIR/"
# === 4. Run build-time tests ===
echo ""
echo "=== Running Logic Tests ==="
node "$SCRIPT_DIR/test_logic.js"
timeout 15s node "$SCRIPT_DIR/test_logic.js"
LOGIC_EXIT=$?
if [ $LOGIC_EXIT -ne 0 ]; then
echo "❌ Logic tests FAILED! See above for details."
echo "❌ Logic tests FAILED or TIMED OUT (Exit: $LOGIC_EXIT)! See above for details."
exit 1
fi
echo "=== Running DOM Interaction Tests ==="
node "$SCRIPT_DIR/tests/test_dom.js"
timeout 15s node "$SCRIPT_DIR/tests/test_dom.js"
DOM_EXIT=$?
if [ $DOM_EXIT -ne 0 ]; then
echo "❌ DOM UI tests FAILED! Regressions detected."
echo "❌ DOM UI tests FAILED or TIMED OUT (Exit: $DOM_EXIT)! Regressions detected."
# Ensure playwright processes are killed if they leak
pkill -f playwright || true
pkill -f "node.*test_dom" || true
exit 1
fi
echo "=== Running Build Tests ==="
python3 "$SCRIPT_DIR/test_build.py"
timeout 15s python3 "$SCRIPT_DIR/test_build.py"
TEST_EXIT=$?
if [ $TEST_EXIT -ne 0 ]; then
echo "❌ Build tests FAILED! See above for details."
echo "❌ Build tests FAILED or TIMED OUT (Exit: $TEST_EXIT)! See above for details."
exit 1
fi
echo "✅ All build tests passed."

View File

@@ -1,91 +1,45 @@
## v1.4.28
- 🎨 **Favicon**: Eigenes Favicon-Design aus `favicon_base.png` (2048x2048) auf 32x32 skaliert. Wird beim Build automatisch als PNG-Data-URI in Bookmarklet und Installer injiziert.
## v1.6.6 (2026-03-06)
- 🎨 **Style**: Den Schatten und den hervorstehenden Karten-Effekt für bestellte Menüs an vergangenen Tagen komplett entfernt - verbleiben nun visuell flach und unaufdringlich wie nicht-bestellte Menüs.
## v1.4.27
- 🔧 **Build**: Favicon wird jetzt sauber aus `favicon.png` per Build-Script als PNG-Base64-Data-URI generiert und über `{{FAVICON_DATA_URI}}` Platzhalter in `kantine.js` + `install.html` injiziert. Funktioniert auf allen Browsern und Proxy-Diensten.
## v1.6.5 (2026-03-06)
- **Feature**: Das `restaurant_menu` Icon im Header wurde durch das neue `favicon_base.png` Logo ersetzt, passend zur Textgröße skaliert.
- 🎨 **Style**: Violette Umrahmung (Bestellt-Markierung) an vergangenen Tagen entfernt, um den Fokus auf aktuelle und zukünftige Bestellungen zu lenken.
- 🎨 **Style**: Der Glow-Effekt für am heutigen Tag bestellte Menüs wurde intensiviert.
## v1.4.26
- 🎨 **Favicon**: Von SVG-Base64 auf PNG-Datei (`favicon.png`) umgestellt, verlinkt via raw.githubusercontent.com. Funktioniert zuverlässig auf allen Browsern und Proxy-Diensten (htmlpreview.github.io).
## v1.6.4 (2026-03-05)
- **Feature**: Sprach-Lexikon (DE/EN) massiv erweitert um österreichische Begriffe (Nockerl, Fleckerl, Topfen, Mohn, Most etc.) und gängige Tippfehler aus dem Bessa-System (trukey, coffe, oveb etc.).
- 🧹 **Cleanup**: Sprach-Lexikon dedupliziert und alphabetisch sortiert für bessere Performance und Wartbarkeit.
- 🐛 **Bugfix**: Trennung von zweisprachigen Menüs (`splitLanguage`) verbessert: Erfasst nun auch Schrägstriche ohne Leerzeichen (z.B. `Suppe/Soup`).
- 🐛 **Bugfix**: Fehlerhafte Badge-Anzeige korrigiert (Variable `count` vs `orderCount`).
## v1.4.25
- 🐛 **Bugfix**: Favicon wird in `install.html` jetzt zusätzlich per JavaScript injiziert, um Proxy-Dienste wie htmlpreview.github.io zu überschreiben. Release-Script pusht nun auch `main` Branch zu GitHub.
## 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.24
- 🐛 **Bugfix**: Favicon-Encoding korrigiert `encodeURIComponent` verursachte doppeltes Encoding der Farbcodes (`%23``%2523`). Auf Base64 umgestellt, funktioniert nun auch unter Chrome/Windows.
## 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.23
- 🐛 **Bugfix**: Favicon wird jetzt beim Ausführen des Bookmarklets in die Seite injiziert (ersetzt das Bessa-Favicon im Tab). Chrome cached das Icon und übernimmt es anschließend auch in die Lesezeichenleiste.
## v1.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.22
- 📝 **Docs**: Vollständiger Dokumentations-Audit: README.md um fehlende Dateien (favicon.svg, release.sh, Tests) und Features (Bestellhistorie, Version-Menü, Cache leeren, Favicon) ergänzt. REQUIREMENTS.md: Doppelte IDs (FR-090/091) behoben, FR-092 an dynamische Glow-Logik angepasst, FR-116 (Cache leeren) und FR-117 (Favicon) hinzugefügt, NFR-008 um DOM-Tests erweitert.
## v1.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.21
-**UX**: Der Glow-Effekt des „Nächste Woche"-Buttons bleibt nun aktiv, solange Menüdaten vorhanden aber noch keine Bestellungen getätigt wurden. Verschwindet automatisch nach der ersten Bestellung.
## v1.5.0 (2026-02-26)
Das große "Quality of Life"-Update! Zusammenfassung aller Features und Fixes seit v1.4.0:
## v1.4.20
- 🐛 **Bugfix**: Der Badge-Counter im „Nächste Woche"-Tab wird jetzt sofort nach einer Bestellung oder Stornierung aktualisiert.
-**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.19
- 🎨 **Feature**: Eigenes Favicon für die Installer-Seite hinzugefügt (Dreieck + Gabel & Messer). Wird beim Drag & Drop in die Lesezeichenleiste als Icon übernommen.
## v1.4.18
- 🧪 **Testing**: Die automatische DOM-Testing Suite (`test_dom.js`) wurde massiv ausgebaut. Sie prüft nun neben der Alarmglocke und den Highlights auch systematisch alle anderen UI-Komponenten (Login-Modal, History-Modal, Versionen-Modal, Theme-Toggle, und Navigation Tabs) auf korrekte Event-Listener-Bindungen, um Regressionen (tote Buttons) endgültig auszuschließen.
## v1.4.17
- 🐛 **Bugfix**: Regression behoben: Der "Persönliche Highlights" (Stern-Button) Dialog öffnet sich nun wieder korrekt.
- 🧪 **Testing**: Es wurde ein initialer UI-Testing-Hook (`test_dom.js` mit `jsdom`) in die Build-Pipeline integriert, um kritische DOM Event-Listener Regressionen (wie den Highlights-Button und die Alarmglocke) automatisch zu preventen.
## v1.4.16
-**Feature**: Ein Button "Lokalen Cache leeren" wurde zum Versionen-Menü hinzugefügt, um bei hartnäckigen lokalen Fehlern alle Caches und Sessions bereinigen zu können, ohne die Entwicklertools (F12) des Browsers bemühen zu müssen.
## v1.4.15
- 🧹 **Bugfix**: In der Vergangenheit gesetzte Alarme/Flags wurden nicht zuverlässig gelöscht. Dies ist nun behoben, sodass verfallene Menüs nach 10:00 Uhr bzw. an vergangenen Tagen automatisch aus dem Tracker verschwinden.
## v1.4.14
- 🐛 **Bugfix**: Alarmglocke versteckt sich jetzt zuverlässig auch auf Endgeräten mit CSS Konflikten
- 🚀 **Feature**: Sofortige API-Aktualisierung (Refresh) bei Aktivierung eines Menüalarms
-**Optimierung**: "Unbekannt" im letzten Refresh-Zeitpunkt wird abgefangen und zeigt initial "gerade eben"
## v1.4.13 (2026-02-24)
- **Fix**: Die Farben der Glocke funktionieren nun verlässlich, da CSS-Variablen durch direkte Hex-Codes ersetzt wurden.
## v1.4.12 (2026-02-24)
- **Fix**: Das Glocken-Icon sollte nun endgültig versteckt bleiben, wenn keine Benachrichtigungen aktiv sind (CSS-Kollision mit `.hidden` behoben).
## v1.4.11 (2026-02-24)
- **Feature**: Das Versionsmenü prüft nun im Hintergrund direkt beim Öffnen nach neuen Versionen und aktualisiert die Liste automatisch, selbst wenn eine veraltete Liste noch im Cache liegt.
## v1.4.10 (2026-02-24)
- **Fix**: Die Farben der Benachrichtigungs-Glocke wurden korrigiert: Sie ist nun gelb, während man auf ein Menü wartet, und wird grün, sobald eines verfügbar ist.
## v1.4.9 (2026-02-24)
- **Fix**: Das Glocken-Icon für Benachrichtigungen wird nun direkt beim Start (wenn Daten aus dem lokalen Cache geladen werden) korrekt angezeigt.
## v1.4.8 (2026-02-24)
- **Fix**: Die Benachrichtigungs-Glocke wird nun korrekt in Gelb dargestellt, wenn beobachtete Menüs verfügbar sind.
- **Tools**: Fehler in Testskript behoben, der den CI/CD Build verlangsamt hat.
## v1.4.7 (2026-02-24)
- **Performance**: Die Bestellhistorie nutzt nun einen inkrementellen Delta-Cache anstatt immer alle Seiten von der API herunterzuladen, was die Ladezeiten für Vielbesteller enorm reduziert.
## v1.4.6 (2026-02-24)
- **Fix**: Die Umrandung für bereits bestellte Menüs der vergangenen Tage ist nun ebenfalls einheitlich violett statt blau.
## v1.4.5 (2026-02-24)
- **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. 📜✨

BIN
dist/Arrow_and_fork_fly_away_bd43310bea.mp4 vendored Executable file

Binary file not shown.

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

173
dist/install.html vendored

File diff suppressed because one or more lines are too long

View File

@@ -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,68 +854,68 @@ 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);
}
/* Enhancements for ordered items */
.menu-card.past-day .menu-item.ordered {
/* No opacity/filter here - fully visible */
background: var(--bg-card);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
border: 1px solid #8b5cf6;
border-radius: 8px;
padding: 1rem;
margin: 0 -1rem 1.5rem -1rem;
position: relative;
z-index: 10;
}
/* Past ordered items get no special frame or shadow, but remain visually distinct by staying fully opaque (via the :not(.ordered) selector above) */
.menu-item.today-ordered {
border: 2px solid #8b5cf6;
box-shadow: 0 0 20px rgba(139, 92, 246, 0.4);
box-shadow: 0 0 30px rgba(139, 92, 246, 0.6);
border-radius: 8px;
padding: 1rem;
margin: 0 -1rem 1.5rem -1rem;
background: var(--bg-card);
position: relative;
z-index: 5;
animation: pulse-glow 3s infinite;
animation: pulse-glow-strong 3s infinite;
}
@keyframes pulse-glow {
@keyframes pulse-glow-strong {
0% {
box-shadow: 0 0 15px rgba(139, 92, 246, 0.3);
box-shadow: 0 0 20px rgba(139, 92, 246, 0.4);
}
50% {
box-shadow: 0 0 25px rgba(139, 92, 246, 0.6);
box-shadow: 0 0 40px rgba(139, 92, 246, 0.8);
}
100% {
box-shadow: 0 0 15px rgba(139, 92, 246, 0.3);
box-shadow: 0 0 20px rgba(139, 92, 246, 0.4);
}
}
.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 +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 {
@@ -871,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);
@@ -924,6 +1000,7 @@ body {
color: var(--text-secondary);
line-height: 1.6;
margin-bottom: 0.75rem;
white-space: pre-wrap;
}
.badges {
@@ -1005,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) === */
@@ -1341,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);
}
@@ -1979,11 +2059,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) {
@@ -2007,7 +2088,7 @@ body {
const favicon = document.createElement('link');
favicon.rel = 'icon';
favicon.type = 'image/png';
favicon.href = 'data:image/png;base64,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';
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
@@ -2029,20 +2110,25 @@ body {
<header class="app-header">
<div class="header-content">
<div class="brand">
<span class="material-icons-round logo-icon">restaurant_menu</span>
<img src="data:image/png;base64,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" alt="Logo" class="logo-img" style="height: 1.2em; width: 1.2em; object-fit: contain;">
<div class="header-left">
<h1>Kantinen Übersicht <small class="version-tag" style="font-size: 0.6em; opacity: 0.7; font-weight: 400; cursor: pointer;" title="Klick für Versionsmenü">v1.4.28</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.6</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>
@@ -2056,17 +2142,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>
@@ -2078,7 +2164,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>
@@ -2122,7 +2208,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>
@@ -2131,8 +2217,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>
@@ -2143,7 +2229,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>
@@ -2167,13 +2253,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.28</span>
<strong>Aktuell:</strong> <span id="version-current">v1.6.6</span>
</div>
<div class="dev-toggle">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer;">
@@ -2212,7 +2298,7 @@ body {
</main>
<footer class="app-footer">
<p>Bessa Knapp-Kantine Wrapper &bull; <span id="current-year">${new Date().getFullYear()}</span></p>
<p>Jetzt Bessa Einfach! &bull; Knapp-Kantine Wrapper &bull; <span id="current-year">${new Date().getFullYear()}</span> by Kaufis-Kitchen</p>
</footer>
</div>`;
}
@@ -2241,6 +2327,17 @@ 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');
@@ -2294,8 +2391,12 @@ body {
if (btnClearCache) {
btnClearCache.addEventListener('click', () => {
if (confirm('Möchtest du wirklich alle lokalen Daten (inkl. Login-Session, Cache und Einstellungen) löschen? Die Seite wird danach neu geladen.')) {
localStorage.clear();
sessionStorage.clear();
// 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();
}
});
@@ -2409,8 +2510,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 {
@@ -2419,8 +2520,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);
@@ -2450,10 +2551,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();
@@ -2474,13 +2575,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);
}
}
}
@@ -2489,9 +2590,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');
@@ -2746,7 +2847,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">
@@ -2857,7 +2958,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,
@@ -2865,7 +2966,7 @@ body {
email: userData.email,
newsletter: false
},
preorder: false,
preorder: true,
delivery_fee: 0,
cash_box_table_name: null,
take_away: false
@@ -3192,7 +3293,7 @@ body {
highlightTags.forEach(tag => {
const badge = document.createElement('span');
badge.className = 'tag-badge';
badge.innerHTML = `${tag} <span class="tag-remove" data-tag="${tag}">&times;</span>`;
badge.innerHTML = `${tag} <span class="tag-remove" data-tag="${tag}" title="Schlagwort entfernen">&times;</span>`;
list.appendChild(badge);
});
@@ -3238,6 +3339,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;
}
@@ -3934,7 +4054,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');
@@ -4026,7 +4146,7 @@ body {
// Periodic update check (runs on init + every hour)
async function checkForUpdates() {
const currentVersion = 'v1.4.28';
const currentVersion = 'v1.6.6';
const devMode = localStorage.getItem('kantine_dev_mode') === 'true';
try {
@@ -4067,7 +4187,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.28';
const currentVersion = 'v1.6.6';
if (!modal) return;
modal.classList.remove('hidden');
@@ -4164,6 +4284,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)
@@ -4228,7 +4354,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!',
@@ -4237,7 +4363,7 @@ body {
} else if (Notification.permission === 'default') {
Notification.requestPermission();
}
sessionStorage.setItem(notifiedKey, 'true');
localStorage.setItem(notifiedKey, 'true');
}
} else {
countdownEl.classList.remove('urgent');
@@ -4281,6 +4407,208 @@ body {
return div.innerHTML;
}
// === Language Filter (FR-100) ===
// DE stems for fallback language detection
const DE_STEMS = [
'apfel', 'aubergine', 'auflauf', 'beere', 'blumenkohl', 'bohne', 'braten', 'brokkoli', 'brot', 'brust',
'brötchen', 'butter', 'chili', 'dessert', 'dip', 'eier', 'eintopf', 'eis', 'erbse', 'erdbeer',
'essig', 'filet', 'fisch', 'fisole', 'fleckerl', 'fleisch', 'flügel', 'frucht', 'für', 'gebraten',
'gemüse', 'gewürz', 'gratin', 'grieß', 'gulasch', 'gurke', 'himbeer', 'honig', 'huhn', 'hähnchen',
'jambalaya', 'joghurt', 'karotte', 'kartoffel', 'keule', 'kirsch', 'knacker', 'knoblauch', 'knödel', 'kompott',
'kraut', 'kräuter', 'kuchen', 'käse', 'kürbis', 'lauch', 'mandel', 'milch', 'mild', 'mit',
'mohn', 'most', 'möhre', 'natur', 'nockerl', 'nudel', 'nuss', 'nuß', 'obst', 'oder',
'olive', 'paprika', 'pfanne', 'pfannkuchen', 'pfeffer', 'pikant', 'pilz', 'plunder', 'püree', 'ragout',
'rahm', 'reis', 'rind', 'sahne', 'salami', 'salat', 'salz', 'sauer', 'scharf', 'schinken',
'schnitte', 'schnitzel', 'schoko', 'schupf', 'schwein', 'sellerie', 'senf', 'sosse', 'soße', 'spargel',
'spätzle', 'speck', 'spieß', 'spinat', 'steak', 'suppe', 'süß', 'tofu', 'tomate', 'topfen',
'torte', 'trüffel', 'und', 'vanille', 'vogerl', 'vom', 'wien', 'wurst', 'zucchini', 'zum',
'zur', 'zwiebel', 'öl'
];
const EN_STEMS = [
'almond', 'and', 'apple', 'asparagus', 'bacon', 'baked', 'ball', 'bean', 'beef', 'berry',
'bread', 'breast', 'broccoli', 'bun', 'butter', 'cabbage', 'cake', 'caper', 'carrot', 'casserole',
'cauliflower', 'celery', 'cheese', 'cherry', 'chicken', 'chili', 'choco', 'chocolate', 'cider', 'cilantro',
'coffee', 'compote', 'cream', 'cucumber', 'curd', 'danish', 'dessert', 'dip', 'dumpling', 'egg',
'eggplant', 'filet', 'fish', 'for', 'fried', 'from', 'fruit', 'garlic', 'goulash', 'gratin',
'ham', 'herb', 'honey', 'hot', 'ice', 'jambalaya', 'leek', 'leg', 'mash', 'meat',
'mexican', 'mild', 'milk', 'mint', 'mushroom', 'mustard', 'noodle', 'nut', 'oat', 'oil',
'olive', 'onion', 'or', 'oven', 'pan', 'pancake', 'pea', 'pepper', 'plain', 'plate',
'poppy', 'pork', 'potato', 'pumpkin', 'radish', 'ragout', 'raspberry', 'rice', 'roast', 'roll',
'salad', 'salami', 'salt', 'sauce', 'sausage', 'shrimp', 'skewer', 'slice', 'soup', 'sour',
'spice', 'spicy', 'spinach', 'steak', 'stew', 'strawberr', 'strawberry', 'strudel', 'sweet', 'tart',
'thyme', 'to', 'tofu', 'tomat', 'tomato', 'truffle', 'trukey', 'turkey', 'vanilla', 'vegan',
'vegetable', 'vinegar', 'wedge', 'wing', 'with', 'wok', 'yogurt', 'zucchini'
];
/**
* 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;
// Formatting: add • for new lines, using the forgiving regex
let formattedRaw = text.replace(/(?:\(|(?:\/|\s|^))([A-Z,]+)\)\s*(?=\S)/g, '($1)\n• ');
if (!formattedRaw.startsWith('• ')) {
formattedRaw = '• ' + formattedRaw;
}
// 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"
function heuristicSplitEnDe(fragment) {
const words = fragment.trim().split(/\s+/);
if (words.length < 2) return { enPart: fragment, nextDe: '' };
let bestK = -1;
let maxScore = -9999;
for (let k = 1; k < words.length; k++) {
const left = words.slice(0, k);
const right = words.slice(k);
const leftScore = scoreBlock(left);
const rightScore = scoreBlock(right);
const rightFirstWord = right[0];
let capitalBonus = 0;
// Nouns are capitalized in German
if (/^[A-ZÄÖÜ]/.test(rightFirstWord)) {
capitalBonus = 1.0;
}
const score = (leftScore.en - leftScore.de) + (rightScore.de - rightScore.en) + capitalBonus;
// Strict condition! The assumed German part must actually look German
const rightLooksGerman = (rightScore.de + capitalBonus) > rightScore.en;
if (rightLooksGerman && score > maxScore) {
maxScore = score;
bestK = k;
}
}
if (bestK !== -1) {
return {
enPart: words.slice(0, bestK).join(' '),
nextDe: words.slice(bestK).join(' ')
};
}
return { enPart: fragment, nextDe: '' };
}
// NEW LOGIC: We no longer split by slash if the slash is part of a missing-parenthesis allergen like /ACGL)
const parts = text.split(/\s*\/\s*(?![A-Z,]+\))/);
// Sanity check: max 3 courses means max 3 slashes → max 4 parts
if (parts.length > 4) {
return { de: formattedRaw, en: '', raw: formattedRaw };
}
const deParts = [];
const enParts = [];
// Part 0 is ALWAYS German (beginning of the menu item)
deParts.push(parts[0].trim());
// Matches e.g., "(GLM)" OR "/GLM)" OR " GLM)" with trailing spaces
const allergenRegex = /(?:\(|(?:\/|\s|^))([A-Z,]+)\)\s*/;
for (let i = 1; i < parts.length; i++) {
const fragment = parts[i].trim();
const match = fragment.match(allergenRegex);
if (match) {
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 + ')');
if (deParts.length > 0) {
deParts[deParts.length - 1] = deParts[deParts.length - 1] + '(' + allergenCode + ')';
}
if (nextDe) {
deParts.push(nextDe);
}
} else {
// No allergen code found! Need to heuristically split "EN DE"
const split = heuristicSplitEnDe(fragment);
enParts.push(split.enPart);
if (split.nextDe) {
deParts.push(split.nextDe);
}
}
}
// FIX FOR SINGLE-LANGUAGE COURSES OR MISSING EN
if (parts.length === 1 && enParts.length === 0) {
enParts.push(deParts[0]);
}
// Mirror untranslated DE courses to EN (e.g. Dessert)
if (deParts.length > enParts.length) {
for (let i = enParts.length; i < deParts.length; i++) {
enParts.push(deParts[i]);
}
}
let deJoined = deParts.join('\n• ');
if (deParts.length > 0 && !deJoined.startsWith('• ')) deJoined = '• ' + deJoined;
let enJoined = enParts.join('\n• ');
if (enParts.length > 0 && !enJoined.startsWith('• ')) enJoined = '• ' + enJoined;
return {
de: deJoined,
en: enJoined,
raw: formattedRaw
};
}
/**
* 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();

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

View File

@@ -1,29 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 MiB

After

Width:  |  Height:  |  Size: 2.0 MiB

View File

@@ -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) {
@@ -79,20 +80,25 @@
<header class="app-header">
<div class="header-content">
<div class="brand">
<span class="material-icons-round logo-icon">restaurant_menu</span>
<img src="{{FAVICON_DATA_URI}}" alt="Logo" class="logo-img" style="height: 1.2em; width: 1.2em; object-fit: contain;">
<div class="header-left">
<h1>Kantinen Übersicht <small class="version-tag" style="font-size: 0.6em; opacity: 0.7; font-weight: 400; cursor: pointer;" title="Klick für Versionsmenü">{{VERSION}}</small></h1>
<div id="last-updated-subtitle" class="subtitle"></div>
</div>
<div class="nav-group" style="margin-left: 1rem;">
<button id="btn-this-week" class="nav-btn active">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>
@@ -106,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>
@@ -128,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>
@@ -172,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>
@@ -181,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>
@@ -193,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>
@@ -217,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>
@@ -262,7 +268,7 @@
</main>
<footer class="app-footer">
<p>Bessa Knapp-Kantine Wrapper &bull; <span id="current-year">${new Date().getFullYear()}</span></p>
<p>Jetzt Bessa Einfach! &bull; Knapp-Kantine Wrapper &bull; <span id="current-year">${new Date().getFullYear()}</span> by Kaufis-Kitchen</p>
</footer>
</div>`;
}
@@ -291,6 +297,17 @@
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');
@@ -344,8 +361,12 @@
if (btnClearCache) {
btnClearCache.addEventListener('click', () => {
if (confirm('Möchtest du wirklich alle lokalen Daten (inkl. Login-Session, Cache und Einstellungen) löschen? Die Seite wird danach neu geladen.')) {
localStorage.clear();
sessionStorage.clear();
// 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();
}
});
@@ -459,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 {
@@ -469,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);
@@ -500,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();
@@ -524,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);
}
}
}
@@ -539,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');
@@ -796,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">
@@ -907,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,
@@ -915,7 +936,7 @@
email: userData.email,
newsletter: false
},
preorder: false,
preorder: true,
delivery_fee: 0,
cash_box_table_name: null,
take_away: false
@@ -1242,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}">&times;</span>`;
badge.innerHTML = `${tag} <span class="tag-remove" data-tag="${tag}" title="Schlagwort entfernen">&times;</span>`;
list.appendChild(badge);
});
@@ -1288,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;
}
@@ -1984,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');
@@ -2214,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)
@@ -2278,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!',
@@ -2287,7 +2333,7 @@
} else if (Notification.permission === 'default') {
Notification.requestPermission();
}
sessionStorage.setItem(notifiedKey, 'true');
localStorage.setItem(notifiedKey, 'true');
}
} else {
countdownEl.classList.remove('urgent');
@@ -2331,6 +2377,208 @@
return div.innerHTML;
}
// === Language Filter (FR-100) ===
// DE stems for fallback language detection
const DE_STEMS = [
'apfel', 'aubergine', 'auflauf', 'beere', 'blumenkohl', 'bohne', 'braten', 'brokkoli', 'brot', 'brust',
'brötchen', 'butter', 'chili', 'dessert', 'dip', 'eier', 'eintopf', 'eis', 'erbse', 'erdbeer',
'essig', 'filet', 'fisch', 'fisole', 'fleckerl', 'fleisch', 'flügel', 'frucht', 'für', 'gebraten',
'gemüse', 'gewürz', 'gratin', 'grieß', 'gulasch', 'gurke', 'himbeer', 'honig', 'huhn', 'hähnchen',
'jambalaya', 'joghurt', 'karotte', 'kartoffel', 'keule', 'kirsch', 'knacker', 'knoblauch', 'knödel', 'kompott',
'kraut', 'kräuter', 'kuchen', 'käse', 'kürbis', 'lauch', 'mandel', 'milch', 'mild', 'mit',
'mohn', 'most', 'möhre', 'natur', 'nockerl', 'nudel', 'nuss', 'nuß', 'obst', 'oder',
'olive', 'paprika', 'pfanne', 'pfannkuchen', 'pfeffer', 'pikant', 'pilz', 'plunder', 'püree', 'ragout',
'rahm', 'reis', 'rind', 'sahne', 'salami', 'salat', 'salz', 'sauer', 'scharf', 'schinken',
'schnitte', 'schnitzel', 'schoko', 'schupf', 'schwein', 'sellerie', 'senf', 'sosse', 'soße', 'spargel',
'spätzle', 'speck', 'spieß', 'spinat', 'steak', 'suppe', 'süß', 'tofu', 'tomate', 'topfen',
'torte', 'trüffel', 'und', 'vanille', 'vogerl', 'vom', 'wien', 'wurst', 'zucchini', 'zum',
'zur', 'zwiebel', 'öl'
];
const EN_STEMS = [
'almond', 'and', 'apple', 'asparagus', 'bacon', 'baked', 'ball', 'bean', 'beef', 'berry',
'bread', 'breast', 'broccoli', 'bun', 'butter', 'cabbage', 'cake', 'caper', 'carrot', 'casserole',
'cauliflower', 'celery', 'cheese', 'cherry', 'chicken', 'chili', 'choco', 'chocolate', 'cider', 'cilantro',
'coffee', 'compote', 'cream', 'cucumber', 'curd', 'danish', 'dessert', 'dip', 'dumpling', 'egg',
'eggplant', 'filet', 'fish', 'for', 'fried', 'from', 'fruit', 'garlic', 'goulash', 'gratin',
'ham', 'herb', 'honey', 'hot', 'ice', 'jambalaya', 'leek', 'leg', 'mash', 'meat',
'mexican', 'mild', 'milk', 'mint', 'mushroom', 'mustard', 'noodle', 'nut', 'oat', 'oil',
'olive', 'onion', 'or', 'oven', 'pan', 'pancake', 'pea', 'pepper', 'plain', 'plate',
'poppy', 'pork', 'potato', 'pumpkin', 'radish', 'ragout', 'raspberry', 'rice', 'roast', 'roll',
'salad', 'salami', 'salt', 'sauce', 'sausage', 'shrimp', 'skewer', 'slice', 'soup', 'sour',
'spice', 'spicy', 'spinach', 'steak', 'stew', 'strawberr', 'strawberry', 'strudel', 'sweet', 'tart',
'thyme', 'to', 'tofu', 'tomat', 'tomato', 'truffle', 'trukey', 'turkey', 'vanilla', 'vegan',
'vegetable', 'vinegar', 'wedge', 'wing', 'with', 'wok', 'yogurt', 'zucchini'
];
/**
* 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;
// Formatting: add • for new lines, using the forgiving regex
let formattedRaw = text.replace(/(?:\(|(?:\/|\s|^))([A-Z,]+)\)\s*(?=\S)/g, '($1)\n• ');
if (!formattedRaw.startsWith('• ')) {
formattedRaw = '• ' + formattedRaw;
}
// 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"
function heuristicSplitEnDe(fragment) {
const words = fragment.trim().split(/\s+/);
if (words.length < 2) return { enPart: fragment, nextDe: '' };
let bestK = -1;
let maxScore = -9999;
for (let k = 1; k < words.length; k++) {
const left = words.slice(0, k);
const right = words.slice(k);
const leftScore = scoreBlock(left);
const rightScore = scoreBlock(right);
const rightFirstWord = right[0];
let capitalBonus = 0;
// Nouns are capitalized in German
if (/^[A-ZÄÖÜ]/.test(rightFirstWord)) {
capitalBonus = 1.0;
}
const score = (leftScore.en - leftScore.de) + (rightScore.de - rightScore.en) + capitalBonus;
// Strict condition! The assumed German part must actually look German
const rightLooksGerman = (rightScore.de + capitalBonus) > rightScore.en;
if (rightLooksGerman && score > maxScore) {
maxScore = score;
bestK = k;
}
}
if (bestK !== -1) {
return {
enPart: words.slice(0, bestK).join(' '),
nextDe: words.slice(bestK).join(' ')
};
}
return { enPart: fragment, nextDe: '' };
}
// NEW LOGIC: We no longer split by slash if the slash is part of a missing-parenthesis allergen like /ACGL)
const parts = text.split(/\s*\/\s*(?![A-Z,]+\))/);
// Sanity check: max 3 courses means max 3 slashes → max 4 parts
if (parts.length > 4) {
return { de: formattedRaw, en: '', raw: formattedRaw };
}
const deParts = [];
const enParts = [];
// Part 0 is ALWAYS German (beginning of the menu item)
deParts.push(parts[0].trim());
// Matches e.g., "(GLM)" OR "/GLM)" OR " GLM)" with trailing spaces
const allergenRegex = /(?:\(|(?:\/|\s|^))([A-Z,]+)\)\s*/;
for (let i = 1; i < parts.length; i++) {
const fragment = parts[i].trim();
const match = fragment.match(allergenRegex);
if (match) {
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 + ')');
if (deParts.length > 0) {
deParts[deParts.length - 1] = deParts[deParts.length - 1] + '(' + allergenCode + ')';
}
if (nextDe) {
deParts.push(nextDe);
}
} else {
// No allergen code found! Need to heuristically split "EN DE"
const split = heuristicSplitEnDe(fragment);
enParts.push(split.enPart);
if (split.nextDe) {
deParts.push(split.nextDe);
}
}
}
// FIX FOR SINGLE-LANGUAGE COURSES OR MISSING EN
if (parts.length === 1 && enParts.length === 0) {
enParts.push(deParts[0]);
}
// Mirror untranslated DE courses to EN (e.g. Dessert)
if (deParts.length > enParts.length) {
for (let i = enParts.length; i < deParts.length; i++) {
enParts.push(deParts[i]);
}
}
let deJoined = deParts.join('\n• ');
if (deParts.length > 0 && !deJoined.startsWith('• ')) deJoined = '• ' + deJoined;
let enJoined = enParts.join('\n• ');
if (enParts.length > 0 && !enJoined.startsWith('• ')) enJoined = '• ' + enJoined;
return {
de: deJoined,
en: enJoined,
raw: formattedRaw
};
}
/**
* 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();

184
style.css
View File

@@ -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,68 +843,68 @@ 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);
}
/* Enhancements for ordered items */
.menu-card.past-day .menu-item.ordered {
/* No opacity/filter here - fully visible */
background: var(--bg-card);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
border: 1px solid #8b5cf6;
border-radius: 8px;
padding: 1rem;
margin: 0 -1rem 1.5rem -1rem;
position: relative;
z-index: 10;
}
/* Past ordered items get no special frame or shadow, but remain visually distinct by staying fully opaque (via the :not(.ordered) selector above) */
.menu-item.today-ordered {
border: 2px solid #8b5cf6;
box-shadow: 0 0 20px rgba(139, 92, 246, 0.4);
box-shadow: 0 0 30px rgba(139, 92, 246, 0.6);
border-radius: 8px;
padding: 1rem;
margin: 0 -1rem 1.5rem -1rem;
background: var(--bg-card);
position: relative;
z-index: 5;
animation: pulse-glow 3s infinite;
animation: pulse-glow-strong 3s infinite;
}
@keyframes pulse-glow {
@keyframes pulse-glow-strong {
0% {
box-shadow: 0 0 15px rgba(139, 92, 246, 0.3);
box-shadow: 0 0 20px rgba(139, 92, 246, 0.4);
}
50% {
box-shadow: 0 0 25px rgba(139, 92, 246, 0.6);
box-shadow: 0 0 40px rgba(139, 92, 246, 0.8);
}
100% {
box-shadow: 0 0 15px rgba(139, 92, 246, 0.3);
box-shadow: 0 0 20px rgba(139, 92, 246, 0.4);
}
}
.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 +914,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 +943,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 +989,7 @@ body {
color: var(--text-secondary);
line-height: 1.6;
margin-bottom: 0.75rem;
white-space: pre-wrap;
}
.badges {
@@ -994,12 +1071,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 +1407,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);
}

View File

@@ -41,20 +41,32 @@ const sandbox = {
fetch: async (url) => {
// Mock Version Check
if (url.includes('version.txt')) {
return { ok: true, text: async () => 'v9.9.9' }; // Simulate new version
return { ok: true, text: async () => 'v9.9.9', json: async () => ({}) };
}
// Mock Changelog
if (url.includes('changelog.md')) {
return { ok: true, text: async () => '## v9.9.9\n- Feature: Cool Stuff' };
return { ok: true, text: async () => '## v9.9.9\n- Feature: Cool Stuff', json: async () => ({}) };
}
return { ok: false }; // Fail others to prevent huge cascades
// Mock GitHub Tags API
if (url.includes('api.github.com/') || url.includes('/tags')) {
return { ok: true, json: async () => [{ name: 'v9.9.9' }] };
}
// Mock Menu API
if (url.includes('/food-menu/menu/')) {
return { ok: true, json: async () => ({ dates: [], menu: {} }) };
}
// Mock Orders API
if (url.includes('/user/orders')) {
return { ok: true, json: async () => ({ results: [], count: 0 }) };
}
return { ok: false, status: 404, text: async () => '', json: async () => ({}) };
},
document: {
body: createMockElement('body'),
head: createMockElement('head'),
createElement: (tag) => createMockElement(tag),
querySelector: (sel) => {
if (sel === '.material-icons-round.logo-icon') {
if (sel === '.logo-img' || sel === '.material-icons-round.logo-icon') {
const el = createMockElement('last-updated-icon-mock');
// Mock legacy prop for specific test check if needed,
// but our generic mock handles replaceWith hook
@@ -62,6 +74,7 @@ const sandbox = {
}
return createMockElement('query-result');
},
querySelectorAll: () => [createMockElement()],
getElementById: (id) => createMockElement(id),
documentElement: {
setAttribute: () => { },

View File

@@ -62,6 +62,11 @@ const html = `
<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>

View File

@@ -1 +1 @@
v1.4.28
v1.6.6