Compare commits

...

39 Commits

Author SHA1 Message Date
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
Kantine Wrapper
5f30696315 chore: update build artifacts for v1.4.28 2026-02-26 09:57:46 +01:00
Kantine Wrapper
cb5aa28f94 feat: custom favicon from user design, resized to 32x32 (v1.4.28) 2026-02-26 09:57:11 +01:00
16 changed files with 905 additions and 356 deletions

View File

@@ -43,6 +43,7 @@ trigger: always_on
- **Visuals**: Generate screenshots/mockups for UI changes. - **Visuals**: Generate screenshots/mockups for UI changes.
- **Evidence**: Log outputs for verification. - **Evidence**: Log outputs for verification.
3. **Design**: Optimize code for AI readability (context efficiency). 3. **Design**: Optimize code for AI readability (context efficiency).
4. **Retry on Failure**: When an operation does not finish or does not work as expected, do not try endlessly to fix this. Try a few times and ask the user if no progress can be made.
## 6. Workspace Scopes ## 6. Workspace Scopes
- **Browser**: Allowed for documentation and safe browsing. No automated logins without permission. - **Browser**: Allowed for documentation and safe browsing. No automated logins without permission.

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-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-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) | | 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** | | | | | **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-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 | | 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 # Check files exist
if [ ! -f "$CSS_FILE" ]; then echo "ERROR: $CSS_FILE not found"; exit 1; fi if [ ! -f "$CSS_FILE" ]; then echo "ERROR: $CSS_FILE not found"; exit 1; fi
if [ ! -f "$JS_FILE" ]; then echo "ERROR: $JS_FILE not found"; exit 1; fi if [ ! -f "$JS_FILE" ]; then echo "ERROR: $JS_FILE not found"; exit 1; fi
# Generate favicon.png from favicon_base.png if base exists
FAVICON_BASE="$SCRIPT_DIR/favicon_base.png"
if [ -f "$FAVICON_BASE" ]; then
echo "Generating 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 if [ ! -f "$FAVICON_FILE" ]; then echo "ERROR: $FAVICON_FILE not found"; exit 1; fi
# Generate favicon Base64 data URI from PNG # Generate favicon Base64 data URI from PNG
@@ -128,8 +146,25 @@ cat > "$DIST_DIR/install.html" << INSTALLEOF
</style> </style>
</head> </head>
<body> <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;"> <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> <p style="font-size: 1.2rem; color: #a0aec0; margin-top: 0; font-style: italic;">"Mahlzeit! Jetzt bessa einfach."</p>
</div> </div>
@@ -243,13 +278,16 @@ $CHANGELOG_HTML
EOF EOF
cat >> "$DIST_DIR/install.html" << INSTALLEOF 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(); }); document.querySelectorAll('link[rel*="icon"]').forEach(function(el) { el.remove(); });
var fi = document.createElement('link'); var fi = document.createElement('link');
fi.rel = 'icon'; fi.rel = 'icon';
fi.type = 'image/png'; fi.type = 'image/png';
fi.href = '$FAVICON_URL'; fi.href = '$FAVICON_URL';
document.head.appendChild(fi); document.head.appendChild(fi);
}, 0);
document.getElementById('bookmarklet-link').textContent = 'Kantine $VERSION'; document.getElementById('bookmarklet-link').textContent = 'Kantine $VERSION';
</script> </script>
</body> </body>

View File

@@ -1,88 +1,28 @@
## v1.4.27 ## v1.6.2 (2026-03-05)
- 🔧 **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. - **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.26 ## v1.6.0 (2026-03-04)
- 🎨 **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). - **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.25 ## v1.5.1 (2026-03-04)
- 🐛 **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. - 🐛 **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.24 ## v1.5.0 (2026-02-26)
- 🐛 **Bugfix**: Favicon-Encoding korrigiert `encodeURIComponent` verursachte doppeltes Encoding der Farbcodes (`%23``%2523`). Auf Base64 umgestellt, funktioniert nun auch unter Chrome/Windows. Das große "Quality of Life"-Update! Zusammenfassung aller Features und Fixes seit v1.4.0:
## v1.4.23 -**Bestellhistorie**: Übersichtliche Historie direkt in der App gruppiert nach Jahr/Monat, inklusive Summen, Stati (Offen/Abgeschlossen/Storniert) und Delta-Cache für rasantes Laden.
- 🐛 **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. - **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.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.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.4.20
- 🐛 **Bugfix**: Der Badge-Counter im „Nächste Woche"-Tab wird jetzt sofort nach einer Bestellung oder Stornierung aktualisiert.
## 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) ## v1.4.0 (2026-02-22)
- **Feature**: Bestellhistorie per Knopfdruck abrufbar. Übersichtliche Darstellung, gruppiert nach Monaten und Kalenderwochen, inklusive Stornos. 📜✨ - **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

164
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 */ /* Fix scrolling bug: Reset html/body styles from host page */
html, /* IMPORTANT: html must NOT have overflow set, or it creates a scroll container that breaks position: sticky */
html {
height: auto !important;
min-height: 100% !important;
overflow: visible !important;
position: static !important;
margin: 0 !important;
padding: 0 !important;
}
body { body {
height: auto !important; height: auto !important;
min-height: 100% !important; min-height: 100% !important;
overflow-y: auto !important; overflow-x: clip !important;
overflow-x: hidden !important; /* clip prevents horizontal overflow without breaking sticky */
overflow-y: visible !important;
position: static !important; position: static !important;
margin: 0 !important; margin: 0 !important;
padding: 0 !important; padding: 0 !important;
@@ -80,8 +90,7 @@ body {
/* Header */ /* Header */
.app-header { .app-header {
position: sticky; flex-shrink: 0;
top: 0;
z-index: 100; z-index: 100;
backdrop-filter: blur(12px); backdrop-filter: blur(12px);
background-color: var(--header-bg); background-color: var(--header-bg);
@@ -164,6 +173,38 @@ body {
color: var(--text-secondary); color: var(--text-secondary);
} }
/* Language Toggle (FR-100) */
.lang-toggle {
display: inline-flex;
gap: 0;
border-radius: 6px;
overflow: hidden;
border: 1px solid var(--border-color);
background: var(--bg-card);
}
.lang-btn {
padding: 3px 10px;
font-size: 0.7rem;
font-weight: 600;
letter-spacing: 0.03em;
background: transparent;
color: var(--text-secondary);
border: none;
cursor: pointer;
transition: all 0.2s;
}
.lang-btn:hover {
color: var(--text-primary);
background: rgba(100, 116, 139, 0.1);
}
.lang-btn.active {
background: var(--accent-color);
color: white;
}
.nav-group { .nav-group {
display: flex; display: flex;
background-color: var(--bg-card); background-color: var(--bg-card);
@@ -391,13 +432,21 @@ body {
font-size: 18px; font-size: 18px;
} }
/* Container */ /* Container - flex column, full width so child scrollbar is at edge */
.container { .container {
flex: 1;
width: 100%; width: 100%;
/* Full width */ overflow: hidden;
margin: 2rem auto; padding: 2rem 0 0 0;
padding: 0 2rem; /* Only top padding, no horizontal so child fills width */
min-height: 80vh; display: flex;
flex-direction: column;
}
/* Add horizontal padding to direct children of container to maintain layout */
.container>*:not(.menu-grid) {
padding-left: 2rem;
padding-right: 2rem;
} }
/* Banner */ /* Banner */
@@ -746,14 +795,17 @@ body {
display: none !important; display: none !important;
} }
/* Menu Grid */ /* Menu Grid Container */
.menu-grid { .menu-grid {
display: grid; display: flex;
gap: 2rem; flex-direction: column;
flex: 1;
overflow: hidden;
gap: 1rem;
} }
.week-section { .week-section {
margin-bottom: 3rem; margin-bottom: 2rem;
} }
.week-header { .week-header {
@@ -775,10 +827,25 @@ body {
margin-top: 0.25rem; margin-top: 0.25rem;
} }
/* Full-viewport layout: header + scrollable content + footer */
#kantine-wrapper {
display: flex;
flex-direction: column;
height: 100vh;
height: 100dvh;
/* Dynamic viewport height for mobile browsers */
overflow: hidden;
}
.days-grid { .days-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 0.75rem; gap: 0.75rem;
flex: 1;
overflow-y: auto;
/* This is the scroll container at the window edge */
align-content: start;
padding: 0 2rem 2rem 2rem;
} }
/* Card */ /* Card */
@@ -787,21 +854,33 @@ body {
border-radius: 12px; border-radius: 12px;
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
box-shadow: var(--card-shadow); box-shadow: var(--card-shadow);
overflow: hidden; overflow: clip;
transition: transform 0.2s ease, box-shadow 0.2s ease; /* Clips scrolling content behind sticky header */
transition: box-shadow 0.2s ease;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
/* Past Day Styling - Target specific elements so ordered items can remain visible */ /* Past Day Styling - Target specific elements so ordered items can remain visible AND preserve sticky context */
.menu-card.past-day .card-header, /* We MUST apply filter/opacity to children, not the parent .menu-card, or else position: sticky breaks */
/* Header keeps fully opaque background to hide scrolling items, only grayscales */
.menu-card.past-day .card-header {
filter: grayscale(0.8);
transition: filter 0.3s;
}
/* Items become semi-transparent */
.menu-card.past-day .menu-item:not(.ordered) { .menu-card.past-day .menu-item:not(.ordered) {
opacity: 0.6; opacity: 0.6;
filter: grayscale(0.8); filter: grayscale(0.8);
transition: opacity 0.3s, filter 0.3s; transition: opacity 0.3s, filter 0.3s;
} }
.menu-card.past-day:hover .card-header, .menu-card.past-day:hover .card-header {
filter: grayscale(0.4);
}
.menu-card.past-day:hover .menu-item:not(.ordered) { .menu-card.past-day:hover .menu-item:not(.ordered) {
opacity: 0.8; opacity: 0.8;
filter: grayscale(0.4); filter: grayscale(0.4);
@@ -848,7 +927,6 @@ body {
.menu-card:hover { .menu-card:hover {
transform: translateY(-2px);
box-shadow: 0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1); box-shadow: 0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1);
} }
@@ -858,7 +936,23 @@ body {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: baseline; align-items: baseline;
background-color: rgba(100, 116, 139, 0.05); background-color: var(--bg-card);
/* Removed border-radius: 12px 12px 0 0;
.menu-card's overflow: clip will round the corners initially.
When sticky at the top, it will be square and perfectly hide scrolling content! */
/* Sticky within .container scroll area */
position: sticky;
top: 0;
z-index: 90;
}
.card-body {
padding: 1.25rem;
display: grid;
grid-template-rows: auto;
align-content: start;
} }
.day-name { .day-name {
@@ -871,13 +965,6 @@ body {
color: var(--text-secondary); color: var(--text-secondary);
} }
.card-body {
padding: 1.25rem;
display: grid;
grid-template-rows: auto;
/* Each menu item gets its own row */
align-content: start;
}
.empty-state { .empty-state {
color: var(--text-secondary); color: var(--text-secondary);
@@ -924,6 +1011,7 @@ body {
color: var(--text-secondary); color: var(--text-secondary);
line-height: 1.6; line-height: 1.6;
margin-bottom: 0.75rem; margin-bottom: 0.75rem;
white-space: pre-wrap;
} }
.badges { .badges {
@@ -1005,12 +1093,12 @@ body {
/* Footer */ /* Footer */
.app-footer { .app-footer {
flex-shrink: 0;
text-align: center; text-align: center;
padding: 2rem; padding: 1rem 2rem;
color: var(--text-secondary); color: var(--text-secondary);
font-size: 0.875rem; font-size: 0.875rem;
border-top: 1px solid var(--border-color); border-top: 1px solid var(--border-color);
margin-top: auto;
} }
/* === Order / Cancel Buttons (inline in status row) === */ /* === Order / Cancel Buttons (inline in status row) === */
@@ -1341,17 +1429,20 @@ body {
/* Day Header Status Colors (User Request) */ /* Day Header Status Colors (User Request) */
.card-header.header-violet { .card-header.header-violet {
background-color: rgba(139, 92, 246, 0.15); background-color: var(--bg-card);
background-image: linear-gradient(rgba(139, 92, 246, 0.15), rgba(139, 92, 246, 0.15));
border-bottom: 2px solid #8b5cf6; border-bottom: 2px solid #8b5cf6;
} }
.card-header.header-green { .card-header.header-green {
background-color: rgba(16, 185, 129, 0.15); background-color: var(--bg-card);
background-image: linear-gradient(rgba(16, 185, 129, 0.15), rgba(16, 185, 129, 0.15));
border-bottom: 2px solid var(--success-color); border-bottom: 2px solid var(--success-color);
} }
.card-header.header-red { .card-header.header-red {
background-color: rgba(239, 68, 68, 0.15); background-color: var(--bg-card);
background-image: linear-gradient(rgba(239, 68, 68, 0.15), rgba(239, 68, 68, 0.15));
border-bottom: 2px solid var(--error-color); border-bottom: 2px solid var(--error-color);
} }
@@ -1979,11 +2070,12 @@ body {
let currentWeekNumber = getISOWeek(new Date()); let currentWeekNumber = getISOWeek(new Date());
let currentYear = new Date().getFullYear(); let currentYear = new Date().getFullYear();
let displayMode = 'this-week'; let displayMode = 'this-week';
let authToken = sessionStorage.getItem('kantine_authToken'); let authToken = localStorage.getItem('kantine_authToken');
let currentUser = sessionStorage.getItem('kantine_currentUser'); let currentUser = localStorage.getItem('kantine_currentUser');
let orderMap = new Map(); let orderMap = new Map();
let userFlags = new Set(JSON.parse(localStorage.getItem('kantine_flags') || '[]')); let userFlags = new Set(JSON.parse(localStorage.getItem('kantine_flags') || '[]'));
let pollIntervalId = null; let pollIntervalId = null;
let langMode = localStorage.getItem('kantine_lang') || 'de';
// === API Helpers === // === API Helpers ===
function apiHeaders(token) { function apiHeaders(token) {
@@ -2007,7 +2099,7 @@ body {
const favicon = document.createElement('link'); const favicon = document.createElement('link');
favicon.rel = 'icon'; favicon.rel = 'icon';
favicon.type = 'image/png'; favicon.type = 'image/png';
favicon.href = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAC9UlEQVR4AcRVO28TQRCe2UtAQgkFShWIkoDogiAdFUo6XoYGIRoePwSw4Q/QUCBRgIA/gLCokBL+QIKBAipwwkt0pAlwvhtmxrvH+Xy39tkxjG5uZmdf3zf7MvCfZSgA59Y2ppfefp8YhsPAACqNj/Nk4ncT4faHk+tf5wYFMTAAIrgOgJOIMDWGv27AgDIQgPNvPs8g4VU3J7IvMVcuY70AKq+aJINlbRyFVUAIOAstUfE1Bm2R9qfXmvfaJf/fCyCvqzIlvKZ1SI94CZ6ozzGt0wKAMXS4nw1aGkActW4LYyCIgmC8ZoKxm+JLLIrCW3Z+4FgwGf68kJQLnNIAeODLMhYx+6dH9m+K8uSPJcZ74Uo6CwBxO1NSWaDlASAEDELYV8GKKcgCIZ4487p50DbLNX0DSDNz7N2I2Sy4OAIgxnARPNI3gFjWXgYiXfuEvYRE01mQcqJElxI/x+kJQI6U7afrmWVv6yCdBY2h2Q1Av8HAXS0X/HoC0IuAOzvLO7/KxdwvyQLXItBxANzFG/M+eMQL4NmxWSTAO64/AT1Qpi6QsVqHoCciU1VY9AKQXsxkWSzvfPKx1zb8M+5EsC+fkBBbpF4AuvMJjkpnZt9QhlLwqLZJZUHH8LT3AtCdj4DaH82K2j5+mgXbLkrfjjaWNoUAFDmB3nrSAYm2xPajmgXbEAkzt6OtsKYQQCznHkFuPYJhBCGIPFnIBZBmL2sPQ4ovC7kA4r/sIyix9rk4+ebkXVSYhS4AHez5xSuz9rkA7IkoykIXgDT7fs597qSpoHH3AubvhQ4AWfaym+uLszW5TMRCCZE+ojIGL4HejnlZ6AAQRWGNGwcyD7/lL8+uby7thMYIqyCCmoUapCQBcKrx6QAC6osn9YbgIT/mKzuhMhZYQZ6j8v7LlC3yY2k9DMIWuwkg9kf00RaFsMcNnkz4fGH+G42NzxKZ5VHq9uTeufrC9EYXAAlIRX1xZnWU+uLQvh8yl9MkAy7wr+3IAfQi9AcAAP//cvfPmgAAAAZJREFUAwAATd1Qkd2fWwAAAABJRU5ErkJggg=='; 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); document.head.appendChild(favicon);
// Inject Google Fonts if not already present // Inject Google Fonts if not already present
@@ -2031,18 +2123,23 @@ body {
<div class="brand"> <div class="brand">
<span class="material-icons-round logo-icon">restaurant_menu</span> <span class="material-icons-round logo-icon">restaurant_menu</span>
<div class="header-left"> <div class="header-left">
<h1>Kantinen Übersicht <small class="version-tag" style="font-size: 0.6em; opacity: 0.7; font-weight: 400; cursor: pointer;" title="Klick für Versionsmenü">v1.4.27</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.2</small></h1>
<div id="last-updated-subtitle" class="subtitle"></div> <div id="last-updated-subtitle" class="subtitle"></div>
</div> </div>
<div class="nav-group" style="margin-left: 1rem;"> <div class="nav-group" style="margin-left: 1rem;">
<button id="btn-this-week" class="nav-btn active">Diese 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">Nächste Woche</button> <button id="btn-next-week" class="nav-btn" title="Menü nächster Woche anzeigen">Nächste Woche</button>
</div> </div>
<button id="alarm-bell" class="icon-btn hidden" aria-label="Benachrichtigungen" title="Keine beobachteten Menüs" style="margin-left: -0.5rem;"> <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> <span class="material-icons-round" id="alarm-bell-icon" style="color:var(--text-secondary); transition: color 0.3s;">notifications</span>
</button> </button>
</div> </div>
<div class="header-center-wrapper"> <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="header-week-info" class="header-week-info"></div>
<div id="weekly-cost-display" class="weekly-cost hidden"></div> <div id="weekly-cost-display" class="weekly-cost hidden"></div>
</div> </div>
@@ -2056,17 +2153,17 @@ body {
<button id="btn-highlights" class="icon-btn" aria-label="Persönliche Highlights verwalten" title="Persönliche Highlights verwalten"> <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> <span class="material-icons-round">label</span>
</button> </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> <span class="material-icons-round theme-icon">light_mode</span>
</button> </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 class="material-icons-round">login</span>
<span>Anmelden</span> <span>Anmelden</span>
</button> </button>
<div id="user-info" class="user-badge hidden"> <div id="user-info" class="user-badge hidden">
<span class="material-icons-round">person</span> <span class="material-icons-round">person</span>
<span id="user-id-display"></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> <span class="material-icons-round">logout</span>
</button> </button>
</div> </div>
@@ -2078,7 +2175,7 @@ body {
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
<h2>Login</h2> <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> <span class="material-icons-round">close</span>
</button> </button>
</div> </div>
@@ -2122,7 +2219,7 @@ body {
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
<h2>Meine Highlights</h2> <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> <span class="material-icons-round">close</span>
</button> </button>
</div> </div>
@@ -2131,8 +2228,8 @@ body {
Markiere Menüs automatisch, wenn sie diese Schlagwörter enthalten. Markiere Menüs automatisch, wenn sie diese Schlagwörter enthalten.
</p> </p>
<div class="input-group"> <div class="input-group">
<input type="text" id="tag-input" placeholder="z.B. Schnitzel, Vegetarisch..."> <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">Hinzufügen</button> <button id="btn-add-tag" class="btn-primary" title="Schlagwort zur Liste hinzufügen">Hinzufügen</button>
</div> </div>
<div id="tags-list"></div> <div id="tags-list"></div>
</div> </div>
@@ -2143,7 +2240,7 @@ body {
<div class="modal-content history-modal-content"> <div class="modal-content history-modal-content">
<div class="modal-header"> <div class="modal-header">
<h2>Bestellhistorie</h2> <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> <span class="material-icons-round">close</span>
</button> </button>
</div> </div>
@@ -2167,13 +2264,13 @@ body {
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
<h2>📦 Versionen</h2> <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> <span class="material-icons-round">close</span>
</button> </button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<div style="margin-bottom: 1rem;"> <div style="margin-bottom: 1rem;">
<strong>Aktuell:</strong> <span id="version-current">v1.4.27</span> <strong>Aktuell:</strong> <span id="version-current">v1.6.2</span>
</div> </div>
<div class="dev-toggle"> <div class="dev-toggle">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer;"> <label style="display:flex;align-items:center;gap:8px;cursor:pointer;">
@@ -2241,6 +2338,17 @@ body {
const historyModal = document.getElementById('history-modal'); const historyModal = document.getElementById('history-modal');
const btnHistoryClose = document.getElementById('btn-history-close'); 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) { if (btnHighlights) {
btnHighlights.addEventListener('click', () => { btnHighlights.addEventListener('click', () => {
highlightsModal.classList.remove('hidden'); highlightsModal.classList.remove('hidden');
@@ -2294,8 +2402,12 @@ body {
if (btnClearCache) { if (btnClearCache) {
btnClearCache.addEventListener('click', () => { 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.')) { 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(); // Only clear our own keys so we don't destroy the host app's (Bessa's) session
sessionStorage.clear(); Object.keys(localStorage).forEach(key => {
if (key.startsWith('kantine_')) {
localStorage.removeItem(key);
}
});
window.location.reload(); window.location.reload();
} }
}); });
@@ -2409,8 +2521,8 @@ body {
if (response.ok) { if (response.ok) {
authToken = data.key; authToken = data.key;
currentUser = employeeId; currentUser = employeeId;
sessionStorage.setItem('kantine_authToken', data.key); localStorage.setItem('kantine_authToken', data.key);
sessionStorage.setItem('kantine_currentUser', employeeId); localStorage.setItem('kantine_currentUser', employeeId);
// Fetch user name // Fetch user name
try { try {
@@ -2419,8 +2531,8 @@ body {
}); });
if (userResp.ok) { if (userResp.ok) {
const userData = await userResp.json(); const userData = await userResp.json();
if (userData.first_name) sessionStorage.setItem('kantine_firstName', userData.first_name); if (userData.first_name) localStorage.setItem('kantine_firstName', userData.first_name);
if (userData.last_name) sessionStorage.setItem('kantine_lastName', userData.last_name); if (userData.last_name) localStorage.setItem('kantine_lastName', userData.last_name);
} }
} catch (err) { } catch (err) {
console.error('Failed to fetch user info:', err); console.error('Failed to fetch user info:', err);
@@ -2450,10 +2562,10 @@ body {
// Logout // Logout
btnLogout.addEventListener('click', () => { btnLogout.addEventListener('click', () => {
sessionStorage.removeItem('kantine_authToken'); localStorage.removeItem('kantine_authToken');
sessionStorage.removeItem('kantine_currentUser'); localStorage.removeItem('kantine_currentUser');
sessionStorage.removeItem('kantine_firstName'); localStorage.removeItem('kantine_firstName');
sessionStorage.removeItem('kantine_lastName'); localStorage.removeItem('kantine_lastName');
authToken = null; authToken = null;
currentUser = null; currentUser = null;
orderMap = new Map(); orderMap = new Map();
@@ -2474,13 +2586,13 @@ body {
if (parsed.auth && parsed.auth.token) { if (parsed.auth && parsed.auth.token) {
console.log('Found existing Bessa session!'); console.log('Found existing Bessa session!');
authToken = parsed.auth.token; authToken = parsed.auth.token;
sessionStorage.setItem('kantine_authToken', authToken); localStorage.setItem('kantine_authToken', authToken);
if (parsed.auth.user) { if (parsed.auth.user) {
currentUser = parsed.auth.user.id || 'unknown'; currentUser = parsed.auth.user.id || 'unknown';
sessionStorage.setItem('kantine_currentUser', currentUser); localStorage.setItem('kantine_currentUser', currentUser);
if (parsed.auth.user.firstName) sessionStorage.setItem('kantine_firstName', parsed.auth.user.firstName); if (parsed.auth.user.firstName) localStorage.setItem('kantine_firstName', parsed.auth.user.firstName);
if (parsed.auth.user.lastName) sessionStorage.setItem('kantine_lastName', parsed.auth.user.lastName); if (parsed.auth.user.lastName) localStorage.setItem('kantine_lastName', parsed.auth.user.lastName);
} }
} }
} }
@@ -2489,9 +2601,9 @@ body {
} }
} }
authToken = sessionStorage.getItem('kantine_authToken'); authToken = localStorage.getItem('kantine_authToken');
currentUser = sessionStorage.getItem('kantine_currentUser'); currentUser = localStorage.getItem('kantine_currentUser');
const firstName = sessionStorage.getItem('kantine_firstName'); const firstName = localStorage.getItem('kantine_firstName');
const btnLoginOpen = document.getElementById('btn-login-open'); const btnLoginOpen = document.getElementById('btn-login-open');
const userInfo = document.getElementById('user-info'); const userInfo = document.getElementById('user-info');
const userIdDisplay = document.getElementById('user-id-display'); const userIdDisplay = document.getElementById('user-id-display');
@@ -2746,7 +2858,7 @@ body {
const monthGroup = yearGroup.months[mKey]; const monthGroup = yearGroup.months[mKey];
html += `<div class="history-month-group"> 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;"> <div style="display:flex; flex-direction:column; gap:4px;">
<span>${monthGroup.name}</span> <span>${monthGroup.name}</span>
<div class="history-month-summary"> <div class="history-month-summary">
@@ -2857,7 +2969,7 @@ body {
venue: VENUE_ID, venue: VENUE_ID,
states: [], states: [],
order_state: 1, order_state: 1,
date: `${date}T10:00:00.000Z`, date: `${date}T10:30:00Z`,
payment_method: 'payroll', payment_method: 'payroll',
customer: { customer: {
first_name: userData.first_name, first_name: userData.first_name,
@@ -2865,7 +2977,7 @@ body {
email: userData.email, email: userData.email,
newsletter: false newsletter: false
}, },
preorder: false, preorder: true,
delivery_fee: 0, delivery_fee: 0,
cash_box_table_name: null, cash_box_table_name: null,
take_away: false take_away: false
@@ -3192,7 +3304,7 @@ body {
highlightTags.forEach(tag => { highlightTags.forEach(tag => {
const badge = document.createElement('span'); const badge = document.createElement('span');
badge.className = 'tag-badge'; 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); list.appendChild(badge);
}); });
@@ -3934,7 +4046,7 @@ body {
<div class="badges">${statusBadge}</div> <div class="badges">${statusBadge}</div>
</div> </div>
${tagsHtml} ${tagsHtml}
<p class="item-desc">${escapeHtml(item.description)}</p>`; <p class="item-desc">${escapeHtml(getLocalizedText(item.description))}</p>`;
// Event: Order // Event: Order
const orderBtn = itemEl.querySelector('.btn-order'); const orderBtn = itemEl.querySelector('.btn-order');
@@ -4026,7 +4138,7 @@ body {
// Periodic update check (runs on init + every hour) // Periodic update check (runs on init + every hour)
async function checkForUpdates() { async function checkForUpdates() {
const currentVersion = 'v1.4.27'; const currentVersion = 'v1.6.2';
const devMode = localStorage.getItem('kantine_dev_mode') === 'true'; const devMode = localStorage.getItem('kantine_dev_mode') === 'true';
try { try {
@@ -4067,7 +4179,7 @@ body {
const modal = document.getElementById('version-modal'); const modal = document.getElementById('version-modal');
const container = document.getElementById('version-list-container'); const container = document.getElementById('version-list-container');
const devToggle = document.getElementById('dev-mode-toggle'); const devToggle = document.getElementById('dev-mode-toggle');
const currentVersion = 'v1.4.27'; const currentVersion = 'v1.6.2';
if (!modal) return; if (!modal) return;
modal.classList.remove('hidden'); modal.classList.remove('hidden');
@@ -4164,6 +4276,12 @@ body {
// === Order Countdown === // === Order Countdown ===
function updateCountdown() { function updateCountdown() {
// Only show order alarms for logged-in users
if (!authToken || !currentUser) {
removeCountdown();
return;
}
const now = new Date(); const now = new Date();
const currentDay = now.getDay(); const currentDay = now.getDay();
// Skip weekends (0=Sun, 6=Sat) // Skip weekends (0=Sun, 6=Sat)
@@ -4228,7 +4346,7 @@ body {
// Notification logic (One time) // Notification logic (One time)
const notifiedKey = `kantine_notified_${todayStr}`; const notifiedKey = `kantine_notified_${todayStr}`;
if (!sessionStorage.getItem(notifiedKey)) { if (!localStorage.getItem(notifiedKey)) {
if (Notification.permission === 'granted') { if (Notification.permission === 'granted') {
new Notification('Kantine: Bestellschluss naht!', { new Notification('Kantine: Bestellschluss naht!', {
body: 'Du hast heute noch nichts bestellt. Nur noch 1 Stunde!', body: 'Du hast heute noch nichts bestellt. Nur noch 1 Stunde!',
@@ -4237,7 +4355,7 @@ body {
} else if (Notification.permission === 'default') { } else if (Notification.permission === 'default') {
Notification.requestPermission(); Notification.requestPermission();
} }
sessionStorage.setItem(notifiedKey, 'true'); localStorage.setItem(notifiedKey, 'true');
} }
} else { } else {
countdownEl.classList.remove('urgent'); countdownEl.classList.remove('urgent');
@@ -4281,6 +4399,201 @@ body {
return div.innerHTML; 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 it's not the last part (or even if it is, but we highly suspect merged languages),
// 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 === // === Bootstrap ===
injectUI(); injectUI();
bindEvents(); bindEvents();

Binary file not shown.

Before

Width:  |  Height:  |  Size: 832 B

After

Width:  |  Height:  |  Size: 2.6 KiB

BIN
favicon_base.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

View File

@@ -29,11 +29,12 @@
let currentWeekNumber = getISOWeek(new Date()); let currentWeekNumber = getISOWeek(new Date());
let currentYear = new Date().getFullYear(); let currentYear = new Date().getFullYear();
let displayMode = 'this-week'; let displayMode = 'this-week';
let authToken = sessionStorage.getItem('kantine_authToken'); let authToken = localStorage.getItem('kantine_authToken');
let currentUser = sessionStorage.getItem('kantine_currentUser'); let currentUser = localStorage.getItem('kantine_currentUser');
let orderMap = new Map(); let orderMap = new Map();
let userFlags = new Set(JSON.parse(localStorage.getItem('kantine_flags') || '[]')); let userFlags = new Set(JSON.parse(localStorage.getItem('kantine_flags') || '[]'));
let pollIntervalId = null; let pollIntervalId = null;
let langMode = localStorage.getItem('kantine_lang') || 'de';
// === API Helpers === // === API Helpers ===
function apiHeaders(token) { function apiHeaders(token) {
@@ -85,14 +86,19 @@
<div id="last-updated-subtitle" class="subtitle"></div> <div id="last-updated-subtitle" class="subtitle"></div>
</div> </div>
<div class="nav-group" style="margin-left: 1rem;"> <div class="nav-group" style="margin-left: 1rem;">
<button id="btn-this-week" class="nav-btn active">Diese 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">Nächste Woche</button> <button id="btn-next-week" class="nav-btn" title="Menü nächster Woche anzeigen">Nächste Woche</button>
</div> </div>
<button id="alarm-bell" class="icon-btn hidden" aria-label="Benachrichtigungen" title="Keine beobachteten Menüs" style="margin-left: -0.5rem;"> <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> <span class="material-icons-round" id="alarm-bell-icon" style="color:var(--text-secondary); transition: color 0.3s;">notifications</span>
</button> </button>
</div> </div>
<div class="header-center-wrapper"> <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="header-week-info" class="header-week-info"></div>
<div id="weekly-cost-display" class="weekly-cost hidden"></div> <div id="weekly-cost-display" class="weekly-cost hidden"></div>
</div> </div>
@@ -106,17 +112,17 @@
<button id="btn-highlights" class="icon-btn" aria-label="Persönliche Highlights verwalten" title="Persönliche Highlights verwalten"> <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> <span class="material-icons-round">label</span>
</button> </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> <span class="material-icons-round theme-icon">light_mode</span>
</button> </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 class="material-icons-round">login</span>
<span>Anmelden</span> <span>Anmelden</span>
</button> </button>
<div id="user-info" class="user-badge hidden"> <div id="user-info" class="user-badge hidden">
<span class="material-icons-round">person</span> <span class="material-icons-round">person</span>
<span id="user-id-display"></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> <span class="material-icons-round">logout</span>
</button> </button>
</div> </div>
@@ -128,7 +134,7 @@
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
<h2>Login</h2> <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> <span class="material-icons-round">close</span>
</button> </button>
</div> </div>
@@ -172,7 +178,7 @@
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
<h2>Meine Highlights</h2> <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> <span class="material-icons-round">close</span>
</button> </button>
</div> </div>
@@ -181,8 +187,8 @@
Markiere Menüs automatisch, wenn sie diese Schlagwörter enthalten. Markiere Menüs automatisch, wenn sie diese Schlagwörter enthalten.
</p> </p>
<div class="input-group"> <div class="input-group">
<input type="text" id="tag-input" placeholder="z.B. Schnitzel, Vegetarisch..."> <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">Hinzufügen</button> <button id="btn-add-tag" class="btn-primary" title="Schlagwort zur Liste hinzufügen">Hinzufügen</button>
</div> </div>
<div id="tags-list"></div> <div id="tags-list"></div>
</div> </div>
@@ -193,7 +199,7 @@
<div class="modal-content history-modal-content"> <div class="modal-content history-modal-content">
<div class="modal-header"> <div class="modal-header">
<h2>Bestellhistorie</h2> <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> <span class="material-icons-round">close</span>
</button> </button>
</div> </div>
@@ -217,7 +223,7 @@
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
<h2>📦 Versionen</h2> <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> <span class="material-icons-round">close</span>
</button> </button>
</div> </div>
@@ -291,6 +297,17 @@
const historyModal = document.getElementById('history-modal'); const historyModal = document.getElementById('history-modal');
const btnHistoryClose = document.getElementById('btn-history-close'); 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) { if (btnHighlights) {
btnHighlights.addEventListener('click', () => { btnHighlights.addEventListener('click', () => {
highlightsModal.classList.remove('hidden'); highlightsModal.classList.remove('hidden');
@@ -344,8 +361,12 @@
if (btnClearCache) { if (btnClearCache) {
btnClearCache.addEventListener('click', () => { 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.')) { 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(); // Only clear our own keys so we don't destroy the host app's (Bessa's) session
sessionStorage.clear(); Object.keys(localStorage).forEach(key => {
if (key.startsWith('kantine_')) {
localStorage.removeItem(key);
}
});
window.location.reload(); window.location.reload();
} }
}); });
@@ -459,8 +480,8 @@
if (response.ok) { if (response.ok) {
authToken = data.key; authToken = data.key;
currentUser = employeeId; currentUser = employeeId;
sessionStorage.setItem('kantine_authToken', data.key); localStorage.setItem('kantine_authToken', data.key);
sessionStorage.setItem('kantine_currentUser', employeeId); localStorage.setItem('kantine_currentUser', employeeId);
// Fetch user name // Fetch user name
try { try {
@@ -469,8 +490,8 @@
}); });
if (userResp.ok) { if (userResp.ok) {
const userData = await userResp.json(); const userData = await userResp.json();
if (userData.first_name) sessionStorage.setItem('kantine_firstName', userData.first_name); if (userData.first_name) localStorage.setItem('kantine_firstName', userData.first_name);
if (userData.last_name) sessionStorage.setItem('kantine_lastName', userData.last_name); if (userData.last_name) localStorage.setItem('kantine_lastName', userData.last_name);
} }
} catch (err) { } catch (err) {
console.error('Failed to fetch user info:', err); console.error('Failed to fetch user info:', err);
@@ -500,10 +521,10 @@
// Logout // Logout
btnLogout.addEventListener('click', () => { btnLogout.addEventListener('click', () => {
sessionStorage.removeItem('kantine_authToken'); localStorage.removeItem('kantine_authToken');
sessionStorage.removeItem('kantine_currentUser'); localStorage.removeItem('kantine_currentUser');
sessionStorage.removeItem('kantine_firstName'); localStorage.removeItem('kantine_firstName');
sessionStorage.removeItem('kantine_lastName'); localStorage.removeItem('kantine_lastName');
authToken = null; authToken = null;
currentUser = null; currentUser = null;
orderMap = new Map(); orderMap = new Map();
@@ -524,13 +545,13 @@
if (parsed.auth && parsed.auth.token) { if (parsed.auth && parsed.auth.token) {
console.log('Found existing Bessa session!'); console.log('Found existing Bessa session!');
authToken = parsed.auth.token; authToken = parsed.auth.token;
sessionStorage.setItem('kantine_authToken', authToken); localStorage.setItem('kantine_authToken', authToken);
if (parsed.auth.user) { if (parsed.auth.user) {
currentUser = parsed.auth.user.id || 'unknown'; currentUser = parsed.auth.user.id || 'unknown';
sessionStorage.setItem('kantine_currentUser', currentUser); localStorage.setItem('kantine_currentUser', currentUser);
if (parsed.auth.user.firstName) sessionStorage.setItem('kantine_firstName', parsed.auth.user.firstName); if (parsed.auth.user.firstName) localStorage.setItem('kantine_firstName', parsed.auth.user.firstName);
if (parsed.auth.user.lastName) sessionStorage.setItem('kantine_lastName', parsed.auth.user.lastName); if (parsed.auth.user.lastName) localStorage.setItem('kantine_lastName', parsed.auth.user.lastName);
} }
} }
} }
@@ -539,9 +560,9 @@
} }
} }
authToken = sessionStorage.getItem('kantine_authToken'); authToken = localStorage.getItem('kantine_authToken');
currentUser = sessionStorage.getItem('kantine_currentUser'); currentUser = localStorage.getItem('kantine_currentUser');
const firstName = sessionStorage.getItem('kantine_firstName'); const firstName = localStorage.getItem('kantine_firstName');
const btnLoginOpen = document.getElementById('btn-login-open'); const btnLoginOpen = document.getElementById('btn-login-open');
const userInfo = document.getElementById('user-info'); const userInfo = document.getElementById('user-info');
const userIdDisplay = document.getElementById('user-id-display'); const userIdDisplay = document.getElementById('user-id-display');
@@ -796,7 +817,7 @@
const monthGroup = yearGroup.months[mKey]; const monthGroup = yearGroup.months[mKey];
html += `<div class="history-month-group"> 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;"> <div style="display:flex; flex-direction:column; gap:4px;">
<span>${monthGroup.name}</span> <span>${monthGroup.name}</span>
<div class="history-month-summary"> <div class="history-month-summary">
@@ -907,7 +928,7 @@
venue: VENUE_ID, venue: VENUE_ID,
states: [], states: [],
order_state: 1, order_state: 1,
date: `${date}T10:00:00.000Z`, date: `${date}T10:30:00Z`,
payment_method: 'payroll', payment_method: 'payroll',
customer: { customer: {
first_name: userData.first_name, first_name: userData.first_name,
@@ -915,7 +936,7 @@
email: userData.email, email: userData.email,
newsletter: false newsletter: false
}, },
preorder: false, preorder: true,
delivery_fee: 0, delivery_fee: 0,
cash_box_table_name: null, cash_box_table_name: null,
take_away: false take_away: false
@@ -1242,7 +1263,7 @@
highlightTags.forEach(tag => { highlightTags.forEach(tag => {
const badge = document.createElement('span'); const badge = document.createElement('span');
badge.className = 'tag-badge'; 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); list.appendChild(badge);
}); });
@@ -1984,7 +2005,7 @@
<div class="badges">${statusBadge}</div> <div class="badges">${statusBadge}</div>
</div> </div>
${tagsHtml} ${tagsHtml}
<p class="item-desc">${escapeHtml(item.description)}</p>`; <p class="item-desc">${escapeHtml(getLocalizedText(item.description))}</p>`;
// Event: Order // Event: Order
const orderBtn = itemEl.querySelector('.btn-order'); const orderBtn = itemEl.querySelector('.btn-order');
@@ -2214,6 +2235,12 @@
// === Order Countdown === // === Order Countdown ===
function updateCountdown() { function updateCountdown() {
// Only show order alarms for logged-in users
if (!authToken || !currentUser) {
removeCountdown();
return;
}
const now = new Date(); const now = new Date();
const currentDay = now.getDay(); const currentDay = now.getDay();
// Skip weekends (0=Sun, 6=Sat) // Skip weekends (0=Sun, 6=Sat)
@@ -2278,7 +2305,7 @@
// Notification logic (One time) // Notification logic (One time)
const notifiedKey = `kantine_notified_${todayStr}`; const notifiedKey = `kantine_notified_${todayStr}`;
if (!sessionStorage.getItem(notifiedKey)) { if (!localStorage.getItem(notifiedKey)) {
if (Notification.permission === 'granted') { if (Notification.permission === 'granted') {
new Notification('Kantine: Bestellschluss naht!', { new Notification('Kantine: Bestellschluss naht!', {
body: 'Du hast heute noch nichts bestellt. Nur noch 1 Stunde!', body: 'Du hast heute noch nichts bestellt. Nur noch 1 Stunde!',
@@ -2287,7 +2314,7 @@
} else if (Notification.permission === 'default') { } else if (Notification.permission === 'default') {
Notification.requestPermission(); Notification.requestPermission();
} }
sessionStorage.setItem(notifiedKey, 'true'); localStorage.setItem(notifiedKey, 'true');
} }
} else { } else {
countdownEl.classList.remove('urgent'); countdownEl.classList.remove('urgent');
@@ -2331,6 +2358,201 @@
return div.innerHTML; 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 it's not the last part (or even if it is, but we highly suspect merged languages),
// 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 === // === Bootstrap ===
injectUI(); injectUI();
bindEvents(); bindEvents();

157
style.css
View File

@@ -56,12 +56,22 @@ body {
} }
/* Fix scrolling bug: Reset html/body styles from host page */ /* Fix scrolling bug: Reset html/body styles from host page */
html, /* IMPORTANT: html must NOT have overflow set, or it creates a scroll container that breaks position: sticky */
html {
height: auto !important;
min-height: 100% !important;
overflow: visible !important;
position: static !important;
margin: 0 !important;
padding: 0 !important;
}
body { body {
height: auto !important; height: auto !important;
min-height: 100% !important; min-height: 100% !important;
overflow-y: auto !important; overflow-x: clip !important;
overflow-x: hidden !important; /* clip prevents horizontal overflow without breaking sticky */
overflow-y: visible !important;
position: static !important; position: static !important;
margin: 0 !important; margin: 0 !important;
padding: 0 !important; padding: 0 !important;
@@ -69,8 +79,7 @@ body {
/* Header */ /* Header */
.app-header { .app-header {
position: sticky; flex-shrink: 0;
top: 0;
z-index: 100; z-index: 100;
backdrop-filter: blur(12px); backdrop-filter: blur(12px);
background-color: var(--header-bg); background-color: var(--header-bg);
@@ -153,6 +162,38 @@ body {
color: var(--text-secondary); color: var(--text-secondary);
} }
/* Language Toggle (FR-100) */
.lang-toggle {
display: inline-flex;
gap: 0;
border-radius: 6px;
overflow: hidden;
border: 1px solid var(--border-color);
background: var(--bg-card);
}
.lang-btn {
padding: 3px 10px;
font-size: 0.7rem;
font-weight: 600;
letter-spacing: 0.03em;
background: transparent;
color: var(--text-secondary);
border: none;
cursor: pointer;
transition: all 0.2s;
}
.lang-btn:hover {
color: var(--text-primary);
background: rgba(100, 116, 139, 0.1);
}
.lang-btn.active {
background: var(--accent-color);
color: white;
}
.nav-group { .nav-group {
display: flex; display: flex;
background-color: var(--bg-card); background-color: var(--bg-card);
@@ -380,13 +421,21 @@ body {
font-size: 18px; font-size: 18px;
} }
/* Container */ /* Container - flex column, full width so child scrollbar is at edge */
.container { .container {
flex: 1;
width: 100%; width: 100%;
/* Full width */ overflow: hidden;
margin: 2rem auto; padding: 2rem 0 0 0;
padding: 0 2rem; /* Only top padding, no horizontal so child fills width */
min-height: 80vh; display: flex;
flex-direction: column;
}
/* Add horizontal padding to direct children of container to maintain layout */
.container>*:not(.menu-grid) {
padding-left: 2rem;
padding-right: 2rem;
} }
/* Banner */ /* Banner */
@@ -735,14 +784,17 @@ body {
display: none !important; display: none !important;
} }
/* Menu Grid */ /* Menu Grid Container */
.menu-grid { .menu-grid {
display: grid; display: flex;
gap: 2rem; flex-direction: column;
flex: 1;
overflow: hidden;
gap: 1rem;
} }
.week-section { .week-section {
margin-bottom: 3rem; margin-bottom: 2rem;
} }
.week-header { .week-header {
@@ -764,10 +816,25 @@ body {
margin-top: 0.25rem; margin-top: 0.25rem;
} }
/* Full-viewport layout: header + scrollable content + footer */
#kantine-wrapper {
display: flex;
flex-direction: column;
height: 100vh;
height: 100dvh;
/* Dynamic viewport height for mobile browsers */
overflow: hidden;
}
.days-grid { .days-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 0.75rem; gap: 0.75rem;
flex: 1;
overflow-y: auto;
/* This is the scroll container at the window edge */
align-content: start;
padding: 0 2rem 2rem 2rem;
} }
/* Card */ /* Card */
@@ -776,21 +843,33 @@ body {
border-radius: 12px; border-radius: 12px;
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
box-shadow: var(--card-shadow); box-shadow: var(--card-shadow);
overflow: hidden; overflow: clip;
transition: transform 0.2s ease, box-shadow 0.2s ease; /* Clips scrolling content behind sticky header */
transition: box-shadow 0.2s ease;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
/* Past Day Styling - Target specific elements so ordered items can remain visible */ /* Past Day Styling - Target specific elements so ordered items can remain visible AND preserve sticky context */
.menu-card.past-day .card-header, /* We MUST apply filter/opacity to children, not the parent .menu-card, or else position: sticky breaks */
/* Header keeps fully opaque background to hide scrolling items, only grayscales */
.menu-card.past-day .card-header {
filter: grayscale(0.8);
transition: filter 0.3s;
}
/* Items become semi-transparent */
.menu-card.past-day .menu-item:not(.ordered) { .menu-card.past-day .menu-item:not(.ordered) {
opacity: 0.6; opacity: 0.6;
filter: grayscale(0.8); filter: grayscale(0.8);
transition: opacity 0.3s, filter 0.3s; transition: opacity 0.3s, filter 0.3s;
} }
.menu-card.past-day:hover .card-header, .menu-card.past-day:hover .card-header {
filter: grayscale(0.4);
}
.menu-card.past-day:hover .menu-item:not(.ordered) { .menu-card.past-day:hover .menu-item:not(.ordered) {
opacity: 0.8; opacity: 0.8;
filter: grayscale(0.4); filter: grayscale(0.4);
@@ -837,7 +916,6 @@ body {
.menu-card:hover { .menu-card:hover {
transform: translateY(-2px);
box-shadow: 0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1); box-shadow: 0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1);
} }
@@ -847,7 +925,23 @@ body {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: baseline; align-items: baseline;
background-color: rgba(100, 116, 139, 0.05); background-color: var(--bg-card);
/* Removed border-radius: 12px 12px 0 0;
.menu-card's overflow: clip will round the corners initially.
When sticky at the top, it will be square and perfectly hide scrolling content! */
/* Sticky within .container scroll area */
position: sticky;
top: 0;
z-index: 90;
}
.card-body {
padding: 1.25rem;
display: grid;
grid-template-rows: auto;
align-content: start;
} }
.day-name { .day-name {
@@ -860,13 +954,6 @@ body {
color: var(--text-secondary); color: var(--text-secondary);
} }
.card-body {
padding: 1.25rem;
display: grid;
grid-template-rows: auto;
/* Each menu item gets its own row */
align-content: start;
}
.empty-state { .empty-state {
color: var(--text-secondary); color: var(--text-secondary);
@@ -913,6 +1000,7 @@ body {
color: var(--text-secondary); color: var(--text-secondary);
line-height: 1.6; line-height: 1.6;
margin-bottom: 0.75rem; margin-bottom: 0.75rem;
white-space: pre-wrap;
} }
.badges { .badges {
@@ -994,12 +1082,12 @@ body {
/* Footer */ /* Footer */
.app-footer { .app-footer {
flex-shrink: 0;
text-align: center; text-align: center;
padding: 2rem; padding: 1rem 2rem;
color: var(--text-secondary); color: var(--text-secondary);
font-size: 0.875rem; font-size: 0.875rem;
border-top: 1px solid var(--border-color); border-top: 1px solid var(--border-color);
margin-top: auto;
} }
/* === Order / Cancel Buttons (inline in status row) === */ /* === Order / Cancel Buttons (inline in status row) === */
@@ -1330,17 +1418,20 @@ body {
/* Day Header Status Colors (User Request) */ /* Day Header Status Colors (User Request) */
.card-header.header-violet { .card-header.header-violet {
background-color: rgba(139, 92, 246, 0.15); background-color: var(--bg-card);
background-image: linear-gradient(rgba(139, 92, 246, 0.15), rgba(139, 92, 246, 0.15));
border-bottom: 2px solid #8b5cf6; border-bottom: 2px solid #8b5cf6;
} }
.card-header.header-green { .card-header.header-green {
background-color: rgba(16, 185, 129, 0.15); background-color: var(--bg-card);
background-image: linear-gradient(rgba(16, 185, 129, 0.15), rgba(16, 185, 129, 0.15));
border-bottom: 2px solid var(--success-color); border-bottom: 2px solid var(--success-color);
} }
.card-header.header-red { .card-header.header-red {
background-color: rgba(239, 68, 68, 0.15); background-color: var(--bg-card);
background-image: linear-gradient(rgba(239, 68, 68, 0.15), rgba(239, 68, 68, 0.15));
border-bottom: 2px solid var(--error-color); border-bottom: 2px solid var(--error-color);
} }

View File

@@ -62,6 +62,7 @@ const sandbox = {
} }
return createMockElement('query-result'); return createMockElement('query-result');
}, },
querySelectorAll: () => [createMockElement()],
getElementById: (id) => createMockElement(id), getElementById: (id) => createMockElement(id),
documentElement: { documentElement: {
setAttribute: () => { }, setAttribute: () => { },

View File

@@ -62,6 +62,11 @@ const html = `
<button id="btn-this-week" class="active">This Week</button> <button id="btn-this-week" class="active">This Week</button>
<button id="btn-next-week">Next 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-refresh">Refresh</button>
<button id="btn-logout">Logout</button> <button id="btn-logout">Logout</button>
<div class="order-history-header">Header</div> <div class="order-history-header">Header</div>

View File

@@ -1 +1 @@
v1.4.27 v1.6.2