Compare commits

...

18 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
12 changed files with 464 additions and 279 deletions
+1
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.
+10 -6
View File
@@ -303,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."
+23
View File
@@ -1,3 +1,26 @@
## v1.6.6 (2026-03-06)
- 🎨 **Style**: Den Schatten und den hervorstehenden Karten-Effekt für bestellte Menüs an vergangenen Tagen komplett entfernt - verbleiben nun visuell flach und unaufdringlich wie nicht-bestellte Menüs.
## v1.6.5 (2026-03-06)
-**Feature**: Das `restaurant_menu` Icon im Header wurde durch das neue `favicon_base.png` Logo ersetzt, passend zur Textgröße skaliert.
- 🎨 **Style**: Violette Umrahmung (Bestellt-Markierung) an vergangenen Tagen entfernt, um den Fokus auf aktuelle und zukünftige Bestellungen zu lenken.
- 🎨 **Style**: Der Glow-Effekt für am heutigen Tag bestellte Menüs wurde intensiviert.
## v1.6.4 (2026-03-05)
-**Feature**: Sprach-Lexikon (DE/EN) massiv erweitert um österreichische Begriffe (Nockerl, Fleckerl, Topfen, Mohn, Most etc.) und gängige Tippfehler aus dem Bessa-System (trukey, coffe, oveb etc.).
- 🧹 **Cleanup**: Sprach-Lexikon dedupliziert und alphabetisch sortiert für bessere Performance und Wartbarkeit.
- 🐛 **Bugfix**: Trennung von zweisprachigen Menüs (`splitLanguage`) verbessert: Erfasst nun auch Schrägstriche ohne Leerzeichen (z.B. `Suppe/Soup`).
- 🐛 **Bugfix**: Fehlerhafte Badge-Anzeige korrigiert (Variable `count` vs `orderCount`).
## v1.6.3 (2026-03-05)
-**Chore**: Slogan im Footer aktualisiert ("Jetzt Bessa Einfach! • Knapp-Kantine Wrapper • 2026 by Kaufis-Kitchen") und Footer-Höhe für mehr Platzierung optimiert.
## v1.6.2 (2026-03-05)
-**Feature**: Wochentags-Header (Montag, Dienstag etc.) scrollen nun als "Sticky Header" mit und bleiben am oberen Bildschirmrand haften.
- Das Layout clippt scrollende Speisen ordentlich darunter weg.
- Vollständiges Viewport-Scrolling: Das Layout nutzt nun die ganze Höhe aus (`100dvh`), wodurch Scrollbalken sauber am Rand positioniert sind.
- 🐛 **Bugfix**: Probleme mit Bessa's default `overflow` Verhalten behoben, das `position: sticky` auf iOS/WebKit-Browsern blockierte.
## v1.6.0 (2026-03-04)
-**Feature**: Sprachfilter für zweisprachige Menübeschreibungen. Neuer DE/EN/ALL Toggle im Header ermöglicht das Umschalten zwischen Deutsch, Englisch und dem vollen Originaltext. Allergen-Codes werden in allen Modi angezeigt. Einstellung wird persistent gespeichert.
+2 -2
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+34 -6
View File
File diff suppressed because one or more lines are too long
+190 -117
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);
@@ -423,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 */
@@ -778,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 {
@@ -807,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 */
@@ -819,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);
}
@@ -890,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 {
@@ -903,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);
@@ -1038,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) === */
@@ -1374,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);
}
@@ -2063,9 +2110,9 @@ 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.6.0</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;">
@@ -2212,7 +2259,7 @@ body {
</div>
<div class="modal-body">
<div style="margin-bottom: 1rem;">
<strong>Aktuell:</strong> <span id="version-current">v1.6.0</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;">
@@ -2251,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>`;
}
@@ -3292,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;
}
@@ -4080,7 +4146,7 @@ body {
// Periodic update check (runs on init + every hour)
async function checkForUpdates() {
const currentVersion = 'v1.6.0';
const currentVersion = 'v1.6.6';
const devMode = localStorage.getItem('kantine_dev_mode') === 'true';
try {
@@ -4121,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.6.0';
const currentVersion = 'v1.6.6';
if (!modal) return;
modal.classList.remove('hidden');
@@ -4344,29 +4410,35 @@ body {
// === 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'
'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 = [
'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'
'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'
];
/**
@@ -4380,7 +4452,11 @@ body {
if (!text) return { de: '', en: '', raw: '' };
const raw = text;
const formattedRaw = '• ' + text.replace(/\(([A-Z ]+)\)\s*(?=\S)/g, '($1)\n• ');
// 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) {
@@ -4410,7 +4486,6 @@ body {
}
// 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: '' };
@@ -4425,22 +4500,20 @@ body {
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;
// Nouns are capitalized in German
if (/^[A-ZÄÖÜ]/.test(rightFirstWord)) {
capitalBonus = 2.0;
capitalBonus = 1.0;
}
const finalScore = score + capitalBonus;
const score = (leftScore.en - leftScore.de) + (rightScore.de - rightScore.en) + capitalBonus;
if (finalScore > maxScore) {
maxScore = finalScore;
// 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;
}
}
@@ -4454,50 +4527,34 @@ body {
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);
// 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,]+\))/);
// 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)
// Part 0 is ALWAYS German (beginning of the menu item)
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*/;
// 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) {
// 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 + ')';
}
@@ -4506,9 +4563,7 @@ body {
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.
// No allergen code found! Need to heuristically split "EN DE"
const split = heuristicSplitEnDe(fragment);
enParts.push(split.enPart);
if (split.nextDe) {
@@ -4517,9 +4572,27 @@ body {
}
}
// 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: deParts.map(p => '• ' + p).join('\n'),
en: enParts.map(p => '• ' + p).join('\n'),
de: deJoined,
en: enJoined,
raw: formattedRaw
};
}
-29
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

+87 -61
View File
@@ -80,7 +80,7 @@
<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>
@@ -268,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>`;
}
@@ -1309,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;
}
@@ -2361,29 +2380,35 @@
// === 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'
'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 = [
'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'
'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'
];
/**
@@ -2397,7 +2422,11 @@
if (!text) return { de: '', en: '', raw: '' };
const raw = text;
const formattedRaw = '• ' + text.replace(/\(([A-Z ]+)\)\s*(?=\S)/g, '($1)\n• ');
// 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) {
@@ -2427,7 +2456,6 @@
}
// 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: '' };
@@ -2442,22 +2470,20 @@
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;
// Nouns are capitalized in German
if (/^[A-ZÄÖÜ]/.test(rightFirstWord)) {
capitalBonus = 2.0;
capitalBonus = 1.0;
}
const finalScore = score + capitalBonus;
const score = (leftScore.en - leftScore.de) + (rightScore.de - rightScore.en) + capitalBonus;
if (finalScore > maxScore) {
maxScore = finalScore;
// 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;
}
}
@@ -2471,50 +2497,34 @@
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);
// 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,]+\))/);
// 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)
// Part 0 is ALWAYS German (beginning of the menu item)
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*/;
// 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) {
// 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 + ')';
}
@@ -2523,9 +2533,7 @@
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.
// No allergen code found! Need to heuristically split "EN DE"
const split = heuristicSplitEnDe(fragment);
enParts.push(split.enPart);
if (split.nextDe) {
@@ -2534,9 +2542,27 @@
}
}
// 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: deParts.map(p => '• ' + p).join('\n'),
en: enParts.map(p => '• ' + p).join('\n'),
de: deJoined,
en: enJoined,
raw: formattedRaw
};
}
+99 -52
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);
@@ -412,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 */
@@ -767,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 {
@@ -796,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 */
@@ -808,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);
}
@@ -879,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 {
@@ -892,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);
@@ -1027,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) === */
@@ -1363,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);
}
+16 -4
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
+1 -1
View File
@@ -1 +1 @@
v1.6.0
v1.6.6