Compare commits

...

20 Commits

Author SHA1 Message Date
Kantine Wrapper
d383808f4f chore: update build artifacts for v1.6.7 2026-03-06 08:46:46 +01:00
Kantine Wrapper
498b400033 style: scale header logo to 40x40px 2026-03-06 08:46:46 +01:00
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
13 changed files with 478 additions and 286 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

@@ -28,13 +28,13 @@ if [ ! -f "$JS_FILE" ]; then echo "ERROR: $JS_FILE not found"; exit 1; fi
# Generate favicon.png from favicon_base.png if base exists # Generate favicon.png from favicon_base.png if base exists
FAVICON_BASE="$SCRIPT_DIR/favicon_base.png" FAVICON_BASE="$SCRIPT_DIR/favicon_base.png"
if [ -f "$FAVICON_BASE" ]; then if [ -f "$FAVICON_BASE" ]; then
echo "Generating 32x32 favicon.png from favicon_base.png..." echo "Generating 40x40 favicon.png from favicon_base.png..."
python3 -c " python3 -c "
import sys import sys
from PIL import Image from PIL import Image
try: try:
img = Image.open('$FAVICON_BASE') img = Image.open('$FAVICON_BASE')
img_resized = img.resize((32, 32), Image.Resampling.LANCZOS) img_resized = img.resize((40, 40), Image.Resampling.LANCZOS)
img_resized.save('$FAVICON_FILE') img_resized.save('$FAVICON_FILE')
except Exception as e: except Exception as e:
print('Favicon generation error:', e) print('Favicon generation error:', e)
@@ -161,7 +161,7 @@ cat > "$DIST_DIR/install.html" << INSTALLEOF
<div style="text-align: center; margin-bottom: 30px;"> <div style="text-align: center; margin-bottom: 30px;">
<h1 style="margin-bottom: 5px; display: flex; align-items: center; justify-content: center; gap: 10px;"> <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;"> <img src="$FAVICON_URL" alt="Logo" style="width: 40px; height: 40px;">
Kantine Wrapper Kantine Wrapper
<span style="font-size:0.5em; opacity:0.6; font-weight:400; margin-left:5px;">$VERSION</span> <span style="font-size:0.5em; opacity:0.6; font-weight:400; margin-left:5px;">$VERSION</span>
</h1> </h1>
@@ -303,26 +303,30 @@ ls -la "$DIST_DIR/"
# === 4. Run build-time tests === # === 4. Run build-time tests ===
echo "" echo ""
echo "=== Running Logic Tests ===" echo "=== Running Logic Tests ==="
node "$SCRIPT_DIR/test_logic.js" timeout 15s node "$SCRIPT_DIR/test_logic.js"
LOGIC_EXIT=$? LOGIC_EXIT=$?
if [ $LOGIC_EXIT -ne 0 ]; then 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 exit 1
fi fi
echo "=== Running DOM Interaction Tests ===" echo "=== Running DOM Interaction Tests ==="
node "$SCRIPT_DIR/tests/test_dom.js" timeout 15s node "$SCRIPT_DIR/tests/test_dom.js"
DOM_EXIT=$? DOM_EXIT=$?
if [ $DOM_EXIT -ne 0 ]; then 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 exit 1
fi fi
echo "=== Running Build Tests ===" echo "=== Running Build Tests ==="
python3 "$SCRIPT_DIR/test_build.py" timeout 15s python3 "$SCRIPT_DIR/test_build.py"
TEST_EXIT=$? TEST_EXIT=$?
if [ $TEST_EXIT -ne 0 ]; then 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 exit 1
fi fi
echo "✅ All build tests passed." echo "✅ All build tests passed."

View File

@@ -1,3 +1,29 @@
## v1.6.7 (2026-03-06)
- 🎨 **Style**: Das neue Header-Logo (`favicon_base.png`) wird nun konsequent auf 40x40px generiert und gerendert.
## v1.6.6 (2026-03-06)
- 🎨 **Style**: Den Schatten und den hervorstehenden Karten-Effekt für bestellte Menüs an vergangenen Tagen komplett entfernt - verbleiben nun visuell flach und unaufdringlich wie nicht-bestellte Menüs.
## v1.6.5 (2026-03-06)
-**Feature**: Das `restaurant_menu` Icon im Header wurde durch das neue `favicon_base.png` Logo ersetzt, passend zur Textgröße skaliert.
- 🎨 **Style**: Violette Umrahmung (Bestellt-Markierung) an vergangenen Tagen entfernt, um den Fokus auf aktuelle und zukünftige Bestellungen zu lenken.
- 🎨 **Style**: Der Glow-Effekt für am heutigen Tag bestellte Menüs wurde intensiviert.
## v1.6.4 (2026-03-05)
-**Feature**: Sprach-Lexikon (DE/EN) massiv erweitert um österreichische Begriffe (Nockerl, Fleckerl, Topfen, Mohn, Most etc.) und gängige Tippfehler aus dem Bessa-System (trukey, coffe, oveb etc.).
- 🧹 **Cleanup**: Sprach-Lexikon dedupliziert und alphabetisch sortiert für bessere Performance und Wartbarkeit.
- 🐛 **Bugfix**: Trennung von zweisprachigen Menüs (`splitLanguage`) verbessert: Erfasst nun auch Schrägstriche ohne Leerzeichen (z.B. `Suppe/Soup`).
- 🐛 **Bugfix**: Fehlerhafte Badge-Anzeige korrigiert (Variable `count` vs `orderCount`).
## v1.6.3 (2026-03-05)
-**Chore**: Slogan im Footer aktualisiert ("Jetzt Bessa Einfach! • Knapp-Kantine Wrapper • 2026 by Kaufis-Kitchen") und Footer-Höhe für mehr Platzierung optimiert.
## v1.6.2 (2026-03-05)
-**Feature**: Wochentags-Header (Montag, Dienstag etc.) scrollen nun als "Sticky Header" mit und bleiben am oberen Bildschirmrand haften.
- Das Layout clippt scrollende Speisen ordentlich darunter weg.
- Vollständiges Viewport-Scrolling: Das Layout nutzt nun die ganze Höhe aus (`100dvh`), wodurch Scrollbalken sauber am Rand positioniert sind.
- 🐛 **Bugfix**: Probleme mit Bessa's default `overflow` Verhalten behoben, das `position: sticky` auf iOS/WebKit-Browsern blockierte.
## v1.6.0 (2026-03-04) ## 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. -**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.

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

50
dist/install.html vendored

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

View File

@@ -1,29 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
<!-- Fork (left, with gap to triangle) -->
<g transform="translate(2, 10)">
<!-- Tines -->
<rect x="1" y="0" width="1.8" height="16" rx="0.9" fill="#333"/>
<rect x="4.6" y="0" width="1.8" height="16" rx="0.9" fill="#333"/>
<rect x="8.2" y="0" width="1.8" height="16" rx="0.9" fill="#333"/>
<!-- Connector -->
<rect x="1" y="14" width="9" height="3.5" rx="1.5" fill="#333"/>
<!-- Handle -->
<rect x="3.5" y="16.5" width="4" height="24" rx="2" fill="#333"/>
</g>
<!-- Triangle (center, equilateral aspect ratio ~1:0.866) -->
<!-- Equilateral: base=28, height=24.25 => keeps proper ratio -->
<polygon points="32,8 47,48 17,48" fill="none" stroke="#333" stroke-width="4" stroke-linejoin="round"/>
<!-- Knife (right, with gap to triangle) -->
<g transform="translate(50, 10)">
<!-- Blade (slight curve) -->
<path d="M3,0 C3,0 3,0 3,0 L3,17 L10,14 C10,6 7,0 3,0 Z" fill="#333"/>
<!-- Spine -->
<rect x="1.5" y="0" width="2" height="18" rx="1" fill="#333"/>
<!-- Bolster -->
<rect x="1.5" y="16.5" width="8.5" height="3.5" rx="1.2" fill="#333"/>
<!-- Handle -->
<rect x="3.5" y="19" width="4" height="22" rx="2" fill="#333"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -80,7 +80,7 @@
<header class="app-header"> <header class="app-header">
<div class="header-content"> <div class="header-content">
<div class="brand"> <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: 40px; width: 40px; object-fit: contain;">
<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ü">{{VERSION}}</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ü">{{VERSION}}</small></h1>
<div id="last-updated-subtitle" class="subtitle"></div> <div id="last-updated-subtitle" class="subtitle"></div>
@@ -268,7 +268,7 @@
</main> </main>
<footer class="app-footer"> <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> </footer>
</div>`; </div>`;
} }
@@ -1309,6 +1309,25 @@
updateNextWeekBadge(); updateNextWeekBadge();
updateAlarmBell(); updateAlarmBell();
if (cachedTs) updateLastUpdatedTime(cachedTs); 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'); console.log('Loaded menu from cache');
return true; return true;
} }
@@ -2361,29 +2380,35 @@
// === Language Filter (FR-100) === // === Language Filter (FR-100) ===
// DE stems for fallback language detection // DE stems for fallback language detection
const DE_STEMS = [ const DE_STEMS = [
'mit', 'und', 'oder', 'für', 'vom', 'zum', 'zur', 'gebraten', 'kartoffel', 'gemüse', 'suppe', 'apfel', 'aubergine', 'auflauf', 'beere', 'blumenkohl', 'bohne', 'braten', 'brokkoli', 'brot', 'brust',
'kuchen', 'schwein', 'rind', 'hähnchen', 'huhn', 'fisch', 'nudel', 'soße', 'sosse', 'wurst', 'brötchen', 'butter', 'chili', 'dessert', 'dip', 'eier', 'eintopf', 'eis', 'erbse', 'erdbeer',
'kürbis', 'braten', 'sahne', 'apfel', 'käse', 'fleisch', 'pilz', 'kirsch', 'joghurt', 'spätzle', 'essig', 'filet', 'fisch', 'fisole', 'fleckerl', 'fleisch', 'flügel', 'frucht', 'für', 'gebraten',
'knödel', 'kraut', 'schnitzel', 'püree', 'rahm', 'erdbeer', 'schoko', 'vanille', 'tomate', 'gemüse', 'gewürz', 'gratin', 'grieß', 'gulasch', 'gurke', 'himbeer', 'honig', 'huhn', 'hähnchen',
'gurke', 'salat', 'zwiebel', 'paprika', 'reis', 'bohne', 'erbse', 'karotte', 'möhre', 'lauch', 'jambalaya', 'joghurt', 'karotte', 'kartoffel', 'keule', 'kirsch', 'knacker', 'knoblauch', 'knödel', 'kompott',
'knoblauch', 'chili', 'gewürz', 'kräuter', 'pfeffer', 'salz', 'butter', 'milch', 'eier', 'kraut', 'kräuter', 'kuchen', 'käse', 'kürbis', 'lauch', 'mandel', 'milch', 'mild', 'mit',
'pfanne', 'auflauf', 'gratin', 'ragout', 'gulasch', 'eintopf', 'filet', 'steak', 'brust', 'mohn', 'most', 'möhre', 'natur', 'nockerl', 'nudel', 'nuss', 'nuß', 'obst', 'oder',
'salami', 'schinken', 'speck', 'brokkoli', 'blumenkohl', 'zucchini', 'aubergine', 'olive', 'paprika', 'pfanne', 'pfannkuchen', 'pfeffer', 'pikant', 'pilz', 'plunder', 'püree', 'ragout',
'spinat', 'spargel', 'olive', 'mandel', 'nuss', 'honig', 'senf', 'essig', 'öl', 'brot', 'rahm', 'reis', 'rind', 'sahne', 'salami', 'salat', 'salz', 'sauer', 'scharf', 'schinken',
'brötchen', 'pfannkuchen', 'eis', 'torte', 'dessert', 'kompott', 'obst', 'frucht', 'beere', 'schnitte', 'schnitzel', 'schoko', 'schupf', 'schwein', 'sellerie', 'senf', 'sosse', 'soße', 'spargel',
'plunder', 'dip', 'tofu', 'jambalaya' '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 = [ const EN_STEMS = [
'with', 'and', 'or', 'for', 'from', 'to', 'fried', 'potato', 'vegetable', 'soup', 'cake', 'almond', 'and', 'apple', 'asparagus', 'bacon', 'baked', 'ball', 'bean', 'beef', 'berry',
'pork', 'beef', 'chicken', 'fish', 'noodle', 'sauce', 'sausage', 'pumpkin', 'roast', 'bread', 'breast', 'broccoli', 'bun', 'butter', 'cabbage', 'cake', 'caper', 'carrot', 'casserole',
'cream', 'apple', 'cheese', 'meat', 'mushroom', 'cherry', 'yogurt', 'wedge', 'sweet', 'cauliflower', 'celery', 'cheese', 'cherry', 'chicken', 'chili', 'choco', 'chocolate', 'cider', 'cilantro',
'sour', 'dumpling', 'cabbage', 'mash', 'strawberr', 'choco', 'vanilla', 'tomat', 'cucumber', 'coffee', 'compote', 'cream', 'cucumber', 'curd', 'danish', 'dessert', 'dip', 'dumpling', 'egg',
'salad', 'onion', 'pepper', 'rice', 'bean', 'pea', 'carrot', 'leek', 'garlic', 'chili', 'eggplant', 'filet', 'fish', 'for', 'fried', 'from', 'fruit', 'garlic', 'goulash', 'gratin',
'spice', 'herb', 'salt', 'butter', 'milk', 'egg', 'pan', 'casserole', 'gratin', 'ragout', 'ham', 'herb', 'honey', 'hot', 'ice', 'jambalaya', 'leek', 'leg', 'mash', 'meat',
'goulash', 'stew', 'filet', 'steak', 'breast', 'salami', 'ham', 'bacon', 'broccoli', 'mexican', 'mild', 'milk', 'mint', 'mushroom', 'mustard', 'noodle', 'nut', 'oat', 'oil',
'cauliflower', 'zucchini', 'eggplant', 'spinach', 'asparagus', 'olive', 'almond', 'nut', 'olive', 'onion', 'or', 'oven', 'pan', 'pancake', 'pea', 'pepper', 'plain', 'plate',
'honey', 'mustard', 'vinegar', 'oil', 'bread', 'bun', 'pancake', 'ice', 'tart', 'dessert', 'poppy', 'pork', 'potato', 'pumpkin', 'radish', 'ragout', 'raspberry', 'rice', 'roast', 'roll',
'compote', 'fruit', 'berry', 'dip', 'danish', 'tofu', 'jambalaya' '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: '' }; if (!text) return { de: '', en: '', raw: '' };
const raw = text; 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 // Utility to compute DE/EN score for a subset of words
function scoreBlock(wordArray) { function scoreBlock(wordArray) {
@@ -2427,7 +2456,6 @@
} }
// Heuristic sliding window to split a fragment containing "EN DE" // 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) { function heuristicSplitEnDe(fragment) {
const words = fragment.trim().split(/\s+/); const words = fragment.trim().split(/\s+/);
if (words.length < 2) return { enPart: fragment, nextDe: '' }; if (words.length < 2) return { enPart: fragment, nextDe: '' };
@@ -2442,22 +2470,20 @@
const leftScore = scoreBlock(left); const leftScore = scoreBlock(left);
const rightScore = scoreBlock(right); 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]; const rightFirstWord = right[0];
let capitalBonus = 0; let capitalBonus = 0;
// Nouns are capitalized in German
if (/^[A-ZÄÖÜ]/.test(rightFirstWord)) { 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) { // Strict condition! The assumed German part must actually look German
maxScore = finalScore; const rightLooksGerman = (rightScore.de + capitalBonus) > rightScore.en;
if (rightLooksGerman && score > maxScore) {
maxScore = score;
bestK = k; bestK = k;
} }
} }
@@ -2471,50 +2497,34 @@
return { enPart: fragment, nextDe: '' }; return { enPart: fragment, nextDe: '' };
} }
// Check if text contains the bilingual separator ' / ' // NEW LOGIC: We no longer split by slash if the slash is part of a missing-parenthesis allergen like /ACGL)
if (!text.includes(' / ')) { const parts = text.split(/\s*\/\s*(?![A-Z,]+\))/);
// 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 // Sanity check: max 3 courses means max 3 slashes → max 4 parts
if (parts.length > 4) { if (parts.length > 4) {
// Too many slashes possibly not bilingual, return as-is
return { de: formattedRaw, en: '', raw: formattedRaw }; return { de: formattedRaw, en: '', raw: formattedRaw };
} }
const deParts = []; const deParts = [];
const enParts = []; const enParts = [];
// First fragment is always DE (course 1) // Part 0 is ALWAYS German (beginning of the menu item)
deParts.push(parts[0].trim()); deParts.push(parts[0].trim());
// Process remaining fragments: each contains "EN(ALLERGENS) next_DE" // Matches e.g., "(GLM)" OR "/GLM)" OR " GLM)" with trailing spaces
// Allergen pattern: (LETTERS_AND_SPACES) at the boundary const allergenRegex = /(?:\(|(?:\/|\s|^))([A-Z,]+)\)\s*/;
const allergenRegex = /\(([A-Z ]+)\)\s*/;
for (let i = 1; i < parts.length; i++) { for (let i = 1; i < parts.length; i++) {
const fragment = parts[i].trim(); const fragment = parts[i].trim();
const match = fragment.match(allergenRegex); const match = fragment.match(allergenRegex);
if (match) { if (match) {
// Split: everything before allergen + allergen = EN, after = next DE
const allergenEnd = match.index + match[0].length; const allergenEnd = match.index + match[0].length;
const enPart = fragment.substring(0, match.index).trim(); const enPart = fragment.substring(0, match.index).trim();
const allergenCode = match[1]; const allergenCode = match[1];
const nextDe = fragment.substring(allergenEnd).trim(); const nextDe = fragment.substring(allergenEnd).trim();
enParts.push(enPart + '(' + allergenCode + ')'); enParts.push(enPart + '(' + allergenCode + ')');
// Also append allergen to the last DE part
if (deParts.length > 0) { if (deParts.length > 0) {
deParts[deParts.length - 1] = deParts[deParts.length - 1] + '(' + allergenCode + ')'; deParts[deParts.length - 1] = deParts[deParts.length - 1] + '(' + allergenCode + ')';
} }
@@ -2523,9 +2533,7 @@
deParts.push(nextDe); deParts.push(nextDe);
} }
} else { } else {
// No allergen code found! // No allergen code found! Need to heuristically split "EN DE"
// 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); const split = heuristicSplitEnDe(fragment);
enParts.push(split.enPart); enParts.push(split.enPart);
if (split.nextDe) { 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 { return {
de: deParts.map(p => '• ' + p).join('\n'), de: deJoined,
en: enParts.map(p => '• ' + p).join('\n'), en: enJoined,
raw: formattedRaw raw: formattedRaw
}; };
} }

151
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);
@@ -412,13 +421,21 @@ body {
font-size: 18px; font-size: 18px;
} }
/* Container */ /* Container - flex column, full width so child scrollbar is at edge */
.container { .container {
flex: 1;
width: 100%; width: 100%;
/* Full width */ overflow: hidden;
margin: 2rem auto; padding: 0 0 0 0;
padding: 0 2rem; /* Only top padding, no horizontal so child fills width */
min-height: 80vh; display: flex;
flex-direction: column;
}
/* Add horizontal padding to direct children of container to maintain layout */
.container>*:not(.menu-grid) {
padding-left: 2rem;
padding-right: 2rem;
} }
/* Banner */ /* Banner */
@@ -767,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 {
@@ -796,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 */
@@ -808,68 +843,68 @@ body {
border-radius: 12px; border-radius: 12px;
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
box-shadow: var(--card-shadow); box-shadow: var(--card-shadow);
overflow: hidden; overflow: clip;
transition: transform 0.2s ease, box-shadow 0.2s ease; /* Clips scrolling content behind sticky header */
transition: box-shadow 0.2s ease;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
/* Past Day Styling - Target specific elements so ordered items can remain visible */ /* Past Day Styling - Target specific elements so ordered items can remain visible AND preserve sticky context */
.menu-card.past-day .card-header, /* We MUST apply filter/opacity to children, not the parent .menu-card, or else position: sticky breaks */
/* Header keeps fully opaque background to hide scrolling items, only grayscales */
.menu-card.past-day .card-header {
filter: grayscale(0.8);
transition: filter 0.3s;
}
/* Items become semi-transparent */
.menu-card.past-day .menu-item:not(.ordered) { .menu-card.past-day .menu-item:not(.ordered) {
opacity: 0.6; opacity: 0.6;
filter: grayscale(0.8); filter: grayscale(0.8);
transition: opacity 0.3s, filter 0.3s; transition: opacity 0.3s, filter 0.3s;
} }
.menu-card.past-day:hover .card-header, .menu-card.past-day:hover .card-header {
filter: grayscale(0.4);
}
.menu-card.past-day:hover .menu-item:not(.ordered) { .menu-card.past-day:hover .menu-item:not(.ordered) {
opacity: 0.8; opacity: 0.8;
filter: grayscale(0.4); filter: grayscale(0.4);
} }
/* Enhancements for ordered items */ /* Past ordered items get no special frame or shadow, but remain visually distinct by staying fully opaque (via the :not(.ordered) selector above) */
.menu-card.past-day .menu-item.ordered {
/* No opacity/filter here - fully visible */
background: var(--bg-card);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
border: 1px solid #8b5cf6;
border-radius: 8px;
padding: 1rem;
margin: 0 -1rem 1.5rem -1rem;
position: relative;
z-index: 10;
}
.menu-item.today-ordered { .menu-item.today-ordered {
border: 2px solid #8b5cf6; 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; border-radius: 8px;
padding: 1rem; padding: 1rem;
margin: 0 -1rem 1.5rem -1rem; margin: 0 -1rem 1.5rem -1rem;
background: var(--bg-card); background: var(--bg-card);
position: relative; position: relative;
z-index: 5; z-index: 5;
animation: pulse-glow 3s infinite; animation: pulse-glow-strong 3s infinite;
} }
@keyframes pulse-glow { @keyframes pulse-glow-strong {
0% { 0% {
box-shadow: 0 0 15px rgba(139, 92, 246, 0.3); box-shadow: 0 0 20px rgba(139, 92, 246, 0.4);
} }
50% { 50% {
box-shadow: 0 0 25px rgba(139, 92, 246, 0.6); box-shadow: 0 0 40px rgba(139, 92, 246, 0.8);
} }
100% { 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 { .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);
} }
@@ -879,7 +914,23 @@ body {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: baseline; align-items: baseline;
background-color: rgba(100, 116, 139, 0.05); background-color: var(--bg-card);
/* Removed border-radius: 12px 12px 0 0;
.menu-card's overflow: clip will round the corners initially.
When sticky at the top, it will be square and perfectly hide scrolling content! */
/* Sticky within .container scroll area */
position: sticky;
top: 0;
z-index: 90;
}
.card-body {
padding: 1.25rem;
display: grid;
grid-template-rows: auto;
align-content: start;
} }
.day-name { .day-name {
@@ -892,13 +943,6 @@ body {
color: var(--text-secondary); color: var(--text-secondary);
} }
.card-body {
padding: 1.25rem;
display: grid;
grid-template-rows: auto;
/* Each menu item gets its own row */
align-content: start;
}
.empty-state { .empty-state {
color: var(--text-secondary); color: var(--text-secondary);
@@ -1027,12 +1071,12 @@ body {
/* Footer */ /* Footer */
.app-footer { .app-footer {
flex-shrink: 0;
text-align: center; text-align: center;
padding: 2rem; padding: 0.4rem 2rem;
color: var(--text-secondary); color: var(--text-secondary);
font-size: 0.875rem; font-size: 0.8rem;
border-top: 1px solid var(--border-color); border-top: 1px solid var(--border-color);
margin-top: auto;
} }
/* === Order / Cancel Buttons (inline in status row) === */ /* === Order / Cancel Buttons (inline in status row) === */
@@ -1363,17 +1407,20 @@ body {
/* Day Header Status Colors (User Request) */ /* Day Header Status Colors (User Request) */
.card-header.header-violet { .card-header.header-violet {
background-color: rgba(139, 92, 246, 0.15); background-color: var(--bg-card);
background-image: linear-gradient(rgba(139, 92, 246, 0.15), rgba(139, 92, 246, 0.15));
border-bottom: 2px solid #8b5cf6; border-bottom: 2px solid #8b5cf6;
} }
.card-header.header-green { .card-header.header-green {
background-color: rgba(16, 185, 129, 0.15); background-color: var(--bg-card);
background-image: linear-gradient(rgba(16, 185, 129, 0.15), rgba(16, 185, 129, 0.15));
border-bottom: 2px solid var(--success-color); border-bottom: 2px solid var(--success-color);
} }
.card-header.header-red { .card-header.header-red {
background-color: rgba(239, 68, 68, 0.15); background-color: var(--bg-card);
background-image: linear-gradient(rgba(239, 68, 68, 0.15), rgba(239, 68, 68, 0.15));
border-bottom: 2px solid var(--error-color); border-bottom: 2px solid var(--error-color);
} }

View File

@@ -41,20 +41,32 @@ const sandbox = {
fetch: async (url) => { fetch: async (url) => {
// Mock Version Check // Mock Version Check
if (url.includes('version.txt')) { 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 // Mock Changelog
if (url.includes('changelog.md')) { 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: { document: {
body: createMockElement('body'), body: createMockElement('body'),
head: createMockElement('head'), head: createMockElement('head'),
createElement: (tag) => createMockElement(tag), createElement: (tag) => createMockElement(tag),
querySelector: (sel) => { 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'); const el = createMockElement('last-updated-icon-mock');
// Mock legacy prop for specific test check if needed, // Mock legacy prop for specific test check if needed,
// but our generic mock handles replaceWith hook // but our generic mock handles replaceWith hook

View File

@@ -1 +1 @@
v1.6.0 v1.6.7