Compare commits

..

23 Commits

Author SHA1 Message Date
Kantine Wrapper
bb5dab64cd chore: update build artifacts for v1.6.10 2026-03-09 13:23:04 +01:00
Kantine Wrapper
cbf03ea497 feat: Update to v1.6.10, refactor relative time calculation, persist last updated timestamp, and update DE language stems. 2026-03-09 13:22:55 +01:00
Kantine Wrapper
6b8ac5ca1d chore: update build artifacts for v1.6.8 2026-03-06 10:01:16 +01:00
Kantine Wrapper
2964eba88b chore: dist files update 2026-03-06 10:01:09 +01:00
Kantine Wrapper
089e5375b4 chore: update build artifacts for v1.6.8 2026-03-06 09:55:54 +01:00
Kantine Wrapper
e514f42dbe feat: minify bookmarklet javascript via terser 2026-03-06 09:55:54 +01:00
Kantine Wrapper
f7a9b061ea chore: update build artifacts for v1.6.7 2026-03-06 09:39:33 +01:00
Kantine Wrapper
fe75347682 chore: update dist files 2026-03-06 09:39:26 +01:00
Kantine Wrapper
5c5255058c chore: increase logo image size in the header 2026-03-06 09:38:20 +01:00
Kantine Wrapper
e16398ef74 chore: update build artifacts for v1.6.7 2026-03-06 08:54:48 +01:00
Kantine Wrapper
6a57e2716f chore: footer text erweitert 2026-03-06 08:54:33 +01:00
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
14 changed files with 432 additions and 299 deletions

View File

@@ -59,10 +59,11 @@ Das System umfasst die Darstellung von Menüplänen in einer Wochenübersicht, d
| FR-082 | Das System muss beim erstmaligen Laden die Betriebssystem-Präferenz für das Farbschema berücksichtigen. | Niedrig | v1.0.1 |
| **Header UI & Navigation** | | | |
| FR-090 | Die Hauptnavigation (Wochen-Toggles) muss linksbündig neben dem App-Titel positioniert sein. | Niedrig | v1.5.0 |
| FR-091 | Ein dynamisches Alarm-Icon im Header muss den Überwachungsstatus geflaggter Menüs anzeigen (Gelb=Überwachung aktiv aber kein Menü verfügbar, Grün=Mindestens ein Menü verfügbar, Versteckt=keine Flags). Der Tooltip muss den Zeitpunkt der letzten Prüfung als relativen String (z.B. "vor 4 Min.") enthalten. | Mittel | v1.5.0 (Update v1.4.10) |
| FR-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.6.9 (Update v1.5.0) |
| FR-092 | Solange Menüdaten für die Nächste Woche verfügbar sind, aber noch keine Bestellungen getätigt wurden, muss der entsprechende Navigation-Button animiert und farblich (Gelb) hervorgehoben werden. Nach der ersten Bestellung muss die Hervorhebung automatisch erlöschen. Zusätzlich muss beim erstmaligen Erscheinen der Daten ein einmaliger Toast-Hinweis angezeigt werden. | Mittel | v1.6.0 (Update v1.4.21) |
| **Sprachfilter** | | | |
| FR-120 | Das System muss zweisprachige Menübeschreibungen (Deutsch/Englisch) erkennen und dem Benutzer erlauben, via UI-Toggle zwischen DE, EN und ALL (beide Sprachen) zu wechseln. Die Sprachpräferenz muss persistent gespeichert werden. Allergen-Codes müssen in allen Modi angezeigt werden. | Mittel | v1.6.0 |
| FR-121 | Das System muss bei fehlenden Übersetzungen in zweisprachigen Menüs robust reagieren. Wenn ein Gang nur in einer Sprache vorliegt, muss dieser Teil für beide Sprachansichten herangezogen werden, um die Konsistenz der Ganganzahl zu gewährleisten. | Mittel | v1.7.0 |
| **Benutzer-Feedback** | | | |
| FR-095 | Alle benutzerrelevanten Aktionen (Bestellung, Stornierung, Fehler) müssen durch nicht-blockierende Benachrichtigungen (Toasts) bestätigt werden. | Mittel | v1.0.1 |
| FR-096 | Bei einem Verbindungsfehler muss ein Fehlerdialog mit Fallback-Link zur Originalseite angezeigt werden. | Mittel | v1.0.1 |

View File

@@ -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
FAVICON_BASE="$SCRIPT_DIR/favicon_base.png"
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 "
import sys
from PIL import Image
try:
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')
except Exception as e:
print('Favicon generation error:', e)
@@ -99,15 +99,20 @@ echo "✅ Standalone HTML: $DIST_DIR/kantine-standalone.html"
# Escape CSS for embedding in JS string
CSS_ESCAPED=$(echo "$CSS_CONTENT" | sed "s/'/\\\\'/g" | tr '\n' ' ' | sed 's/ */ /g')
# Build bookmarklet payload
# Create a minified version for the injected bookmarklet payloads
echo "Minifying JS with Terser..."
TEMP_JS=$(mktemp)
echo "$JS_CONTENT" > "$TEMP_JS"
JS_MINIFIED=$(npx -y terser "$TEMP_JS" --compress --mangle)
rm -f "$TEMP_JS"
cat > "$DIST_DIR/bookmarklet-payload.js" << PAYLOADEOF
(function(){
javascript:(function(){
if(window.__KANTINE_LOADED){alert('Kantine Wrapper already loaded!');return;}
var s=document.createElement('style');
s.textContent='${CSS_ESCAPED}';
document.head.appendChild(s);
var s=document.createElement('style');s.textContent='${CSS_ESCAPED}';document.head.appendChild(s);
// Inject JS logic
var sc=document.createElement('script');
sc.textContent=$(echo "$JS_CONTENT" | python3 -c "import sys,json; print(json.dumps(sys.stdin.read()))" 2>/dev/null || echo "$JS_CONTENT" | sed 's/\\/\\\\/g' | sed "s/'/\\\\'/g" | sed 's/"/\\\\"/g' | tr '\n' ' ' | sed 's/^/"/' | sed 's/$/"/');
sc.textContent=$(echo "$JS_MINIFIED" | python3 -c "import sys,json; print(json.dumps(sys.stdin.read()))" 2>/dev/null || echo "$JS_MINIFIED" | sed 's/\\/\\\\/g' | sed "s/'/\\\\'/g" | sed 's/"/\\\\"/g' | tr '\n' ' ' | sed 's/^/"/' | sed 's/$/"/');
document.head.appendChild(sc);
})();
PAYLOADEOF
@@ -161,7 +166,7 @@ cat > "$DIST_DIR/install.html" << INSTALLEOF
<div style="text-align: center; margin-bottom: 30px;">
<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
<span style="font-size:0.5em; opacity:0.6; font-weight:400; margin-left:5px;">$VERSION</span>
</h1>
@@ -239,12 +244,11 @@ fi
# Embed the bookmarklet URL inline
echo "document.getElementById('bookmarklet-link').href = " >> "$DIST_DIR/install.html"
echo "$JS_CONTENT" | python3 -c "
echo "$JS_MINIFIED" | python3 -c "
import sys, json, urllib.parse
# 1. Read JS and Replace VERSION + Favicon
js_template = sys.stdin.read()
js = js_template.replace('{{VERSION}}', '$VERSION').replace('{{FAVICON_DATA_URI}}', '$FAVICON_URL')
js = sys.stdin.read()
# 2. Prepare CSS for injection via createElement('style')
css = open('$CSS_FILE').read().replace('\n', ' ').replace(' ', ' ')
@@ -303,26 +307,30 @@ ls -la "$DIST_DIR/"
# === 4. Run build-time tests ===
echo ""
echo "=== Running Logic Tests ==="
node "$SCRIPT_DIR/test_logic.js"
timeout 15s node "$SCRIPT_DIR/test_logic.js"
LOGIC_EXIT=$?
if [ $LOGIC_EXIT -ne 0 ]; then
echo "❌ Logic tests FAILED! See above for details."
echo "❌ Logic tests FAILED or TIMED OUT (Exit: $LOGIC_EXIT)! See above for details."
exit 1
fi
echo "=== Running DOM Interaction Tests ==="
node "$SCRIPT_DIR/tests/test_dom.js"
timeout 15s node "$SCRIPT_DIR/tests/test_dom.js"
DOM_EXIT=$?
if [ $DOM_EXIT -ne 0 ]; then
echo "❌ DOM UI tests FAILED! Regressions detected."
echo "❌ DOM UI tests FAILED or TIMED OUT (Exit: $DOM_EXIT)! Regressions detected."
# Ensure playwright processes are killed if they leak
pkill -f playwright || true
pkill -f "node.*test_dom" || true
exit 1
fi
echo "=== Running Build Tests ==="
python3 "$SCRIPT_DIR/test_build.py"
timeout 15s python3 "$SCRIPT_DIR/test_build.py"
TEST_EXIT=$?
if [ $TEST_EXIT -ne 0 ]; then
echo "❌ Build tests FAILED! See above for details."
echo "❌ Build tests FAILED or TIMED OUT (Exit: $TEST_EXIT)! See above for details."
exit 1
fi
echo "✅ All build tests passed."

View File

@@ -1,3 +1,32 @@
## v1.6.10 (2026-03-09)
- **Feature**: Robuste Kurs-Erkennung in zweisprachigen Menüs ([FR-121](REQUIREMENTS.md#FR-121)).
- **Fix**: Verhindert das Verschieben von Gängen bei fehlenden englischen Übersetzungen.
- **Improved**: Heuristik-Split erkennt nun zuverlässiger den Übergang von Englisch zurück zu Deutsch (z.B. bei "Achtung"-Hinweisen)
## v1.6.9 (2026-03-09)
- 🐛 **Bugfix**: Fehlerhafte Zeitangabe beim Bell-Icon ("vor 291h") behoben. Der Tooltip wird nun minütlich aktualisiert und nach jeder Menü-Prüfung korrekt neu gesetzt.
- 🔄 **Refactor**: Zeitstempel-Management für die letzte Aktualisierung vereinheitlicht und im `localStorage` persistiert.
## v1.6.8 (2026-03-06)
-**Performance**: Das JavaScript für das Kantinen-Bookmarklet wird nun beim Build-Prozess (via Terser) minimiert, was die Länge der injizierten URL spürbar reduziert.
## 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.

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

52
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

@@ -14,7 +14,7 @@
// === Constants ===
const API_BASE = 'https://api.bessa.app/v1';
const GUEST_TOKEN = 'c3418725e95a9f90e3645cbc846b4d67c7c66131';
const CLIENT_VERSION = '1.7.0_prod/2026-01-26';
const CLIENT_VERSION = 'v1.6.10';
const VENUE_ID = 591;
const MENU_ID = 7;
const POLL_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes
@@ -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: 2em; width: 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>Jetzt Bessa Einfach! &bull; Knapp-Kantine Wrapper &bull; <span id="current-year">${new Date().getFullYear()}</span> by Kaufis-Kitchen</p>
<p>Jetzt Bessa Einfach! &bull; Knapp-Kantine Wrapper &bull; <span id="current-year">${new Date().getFullYear()}</span> by Kaufi 😃👍 mit Hilfe von KI 🤖</p>
</footer>
</div>`;
}
@@ -691,7 +691,7 @@
const existingOrder = localCache[existingOrderIndex];
// If order exists and wasn't updated since our cache, we've reached the point
// where everything older is already correctly cached.
// order.updated is an ISO string like "2025-02-18T10:30:15.123456Z"
// order.updated is an ISO string like "2026-03-09T18:30:15.123456Z"
if (existingOrder.updated === order.updated && existingOrder.order_state === order.order_state) {
deltaComplete = true;
break;
@@ -1099,11 +1099,7 @@
}
const lastUpdated = new Date(lastUpdatedStr);
const diffMs = Date.now() - lastUpdated.getTime();
const diffMins = Math.floor(diffMs / 60000);
if (diffMins < 1) timeStr = 'gerade eben';
else if (diffMins < 60) timeStr = `vor ${diffMins} Min.`;
else timeStr = `vor ${Math.floor(diffMins / 60)} Std.`;
timeStr = getRelativeTime(lastUpdated);
bellBtn.title = `Zuletzt geprüft: ${timeStr}`;
@@ -1231,6 +1227,8 @@
await new Promise(r => setTimeout(r, 200));
}
}
// Update timestamp after successful polling cycle
updateLastUpdatedTime(new Date().toISOString());
}
// === Highlight Management ===
@@ -1316,7 +1314,7 @@
allWeeks.forEach(w => {
(w.days || []).forEach(d => {
(d.items || []).forEach(item => {
let text = (item.name || '').replace(/\s+/g, ' ').trim();
let text = (item.description || '').replace(/\s+/g, ' ').trim();
if (text && text.includes(' / ')) {
uniqueMenus.add(text);
}
@@ -1567,6 +1565,7 @@
const subtitle = document.getElementById('last-updated-subtitle');
if (!isoTimestamp) return;
lastUpdatedTimestamp = isoTimestamp;
localStorage.setItem('kantine_last_updated', isoTimestamp); // Persist for session-over-tab consistency
try {
const date = new Date(isoTimestamp);
const timeStr = date.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
@@ -1579,7 +1578,10 @@
// Auto-refresh relative time every minute
if (!lastUpdatedIntervalId) {
lastUpdatedIntervalId = setInterval(() => {
if (lastUpdatedTimestamp) updateLastUpdatedTime(lastUpdatedTimestamp);
if (lastUpdatedTimestamp) {
updateLastUpdatedTime(lastUpdatedTimestamp);
updateAlarmBell(); // Ensure bell icon title (tooltip) also refreshes
}
}, 60 * 1000);
}
}
@@ -2380,29 +2382,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', 'achtung', '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'
];
/**
@@ -2416,7 +2424,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, avoiding dots before slashes
let formattedRaw = text.replace(/(?:\(|(?:\/|\s|^))([A-Z,]+)\)\s*(?=\S)(?!\s*\/)/g, '($1)\n• ');
if (!formattedRaw.startsWith('• ')) {
formattedRaw = '• ' + formattedRaw;
}
// Utility to compute DE/EN score for a subset of words
function scoreBlock(wordArray) {
@@ -2446,7 +2458,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: '' };
@@ -2461,22 +2472,21 @@
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;
// Mandatory check: The assumed English part must actually look reasonably like English (or at least more so than the right part)
const leftLooksEnglish = (leftScore.en > leftScore.de) || (leftScore.en > 0);
const rightLooksGerman = (rightScore.de + capitalBonus) > rightScore.en;
if (leftLooksEnglish && rightLooksGerman && score > maxScore) {
maxScore = score;
bestK = k;
}
}
@@ -2490,77 +2500,93 @@
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);
// Match courses: Any text followed by an allergen marker "(...)" but NOT if followed by a slash.
const allergenRegex = /(.*?)(?:\(|(?:\/|\s|^))([A-Z,]+)\)\s*(?!\s*[/])/g;
let match;
const rawCourses = [];
let lastScanIndex = 0;
// No split possible return full text for detected language, empty for other
if (score.en > score.de) {
return { de: '', en: formattedRaw, raw: formattedRaw };
while ((match = allergenRegex.exec(text)) !== null) {
if (match.index > lastScanIndex) {
rawCourses.push(text.substring(lastScanIndex, match.index).trim());
}
return { de: formattedRaw, en: '', raw: formattedRaw };
rawCourses.push(match[0].trim());
lastScanIndex = allergenRegex.lastIndex;
}
// 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 };
if (lastScanIndex < text.length) {
rawCourses.push(text.substring(lastScanIndex).trim());
}
if (rawCourses.length === 0 && text.trim() !== '') {
rawCourses.push(text.trim());
}
const deParts = [];
const enParts = [];
// First fragment is always DE (course 1)
deParts.push(parts[0].trim());
// 2. Process each course individually
for (let course of rawCourses) {
let courseMatch = course.match(/(.*?)(?:\(|(?:\/|\s|^))([A-Z,]+)\)\s*$/);
let courseText = course;
let allergenTxt = "";
let allergenCode = "";
// 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 (courseMatch) {
courseText = courseMatch[1].trim();
allergenCode = courseMatch[2];
allergenTxt = ` (${allergenCode})`;
}
if (nextDe) {
deParts.push(nextDe);
// A) Split by slash if present
const slashParts = courseText.split(/\s*\/\s*(?![A-Z,]+$)/);
if (slashParts.length >= 2) {
// Potential DE / EN pair
const deCandidate = slashParts[0].trim();
let enCandidate = slashParts.slice(1).join(' / ').trim();
// Check for nested German in English part (e.g. "Pumpkin cream Achtung...")
const nestedSplit = heuristicSplitEnDe(enCandidate);
if (nestedSplit.nextDe) {
// Transition back to German found!
deParts.push(deCandidate + allergenTxt);
enParts.push(nestedSplit.enPart + allergenTxt);
// Push the nested German part as a new standalone course (fallback to itself)
const nestedDe = nestedSplit.nextDe + allergenTxt;
deParts.push(nestedDe);
enParts.push(nestedDe);
} else {
// Happy path: standard DE / EN
// Avoid double allergens if they were on both sides already
const enFinal = enCandidate + allergenTxt;
const deFinal = deCandidate.includes(allergenTxt.trim()) ? deCandidate : (deCandidate + allergenTxt);
deParts.push(deFinal);
enParts.push(enFinal);
}
} else {
// No allergen code found!
// If this is the last fragment, it contains only the English text of the final course.
// It should not be split again.
if (i === parts.length - 1) {
enParts.push(fragment);
// B) No slash found: Either missing translation or "EN DE" mixed
const heuristicSplit = heuristicSplitEnDe(courseText);
if (heuristicSplit.nextDe) {
enParts.push(heuristicSplit.enPart + allergenTxt);
deParts.push(heuristicSplit.nextDe + allergenTxt);
} else {
// We use the heuristic to find the hidden split-point.
const split = heuristicSplitEnDe(fragment);
enParts.push(split.enPart);
if (split.nextDe) {
deParts.push(split.nextDe);
}
// Fallback: Use same chunk for both
deParts.push(courseText + allergenTxt);
enParts.push(courseText + allergenTxt);
}
}
}
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
};
}

View File

@@ -875,42 +875,31 @@ body {
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);
}
}

View File

@@ -62,7 +62,7 @@ def main():
# Check for placeholder leftovers
if not check_content(BOOKMARKLET_TXT,
must_contain=["document.createElement('style')", "M1", "M2"],
must_contain=["document.createElement('style')"],
must_not_contain=["{{VERSION}}", "{{CSS_ESCAPED}}"]):
sys.exit(1)

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
@@ -90,7 +102,8 @@ const sandbox = {
try {
vm.createContext(sandbox);
// Execute the code
vm.runInContext(code, sandbox);
const instrumentedCode = code.replace(/\n\}\)\(\);/, ' window.splitLanguage = splitLanguage;\n})();');
vm.runInContext(instrumentedCode, sandbox);
// Regex Check: update icon appended to header
@@ -121,6 +134,7 @@ try {
}
console.log("✅ Static Analysis Passed: All GitHub Release Management functions found.");
// Check dynamic logic usage
// Note: Since we mock fetch to fail for menu data, the app might perform error handling.
// We just want to ensure it doesn't CRASH (exit code) and that our specific feature logic ran.
@@ -133,6 +147,51 @@ try {
console.log("⚠️ Dynamic Check Skipped (Active execution verification relies on async/timing).");
}
// --- Split Language Logic Test ---
console.log("--- Testing splitLanguage Logic ---");
const testCases = [
{
input: "Kürbiscremesuppe / Pumpkin cream (A) Achtung Änderung Frisches Grillhendl mit Semmel (A) Kuchen / Cake (ACGHO)",
expectedDeCourses: 3,
expectedEnCourses: 3
},
{
input: "Schweinsbraten (M) / Roast pork (M)",
expectedDeCourses: 1,
expectedEnCourses: 1
},
{
input: "Tagessuppe (L) / Daily soup (L)",
expectedDeCourses: 1,
expectedEnCourses: 1
},
{
input: "Nur Deutsch (A)",
expectedDeCourses: 1,
expectedEnCourses: 1
}
];
// We can extract splitLanguage or getLocalizedText if they are in global scope,
// but they are inside the IIFE. We can instead check if the parsed data has the same number of courses visually.
// We can evaluate a function in the sandbox to do the splitting
for (const tc of testCases) {
const result = sandbox.window.splitLanguage(tc.input);
const deGange = result.de.split('•').filter(x => x.trim()).length;
const enGange = result.en.split('•').filter(x => x.trim()).length;
if (deGange !== tc.expectedDeCourses || enGange !== tc.expectedEnCourses || deGange !== enGange) {
console.error(`❌ splitLanguage Test Failed for "${tc.input}"`);
console.error(` Expected EN/DE: ${tc.expectedEnCourses}/${tc.expectedDeCourses}`);
console.error(` Got EN/DE: ${enGange}/${deGange}`);
console.error(` DE: ${result.de}`);
console.error(` EN: ${result.en}`);
process.exit(1);
}
}
console.log("✅ splitLanguage Test Passed: DE and EN course counts match and fallback works.");
console.log("✅ Syntax Check Passed: Code executed in sandbox.");
} catch (e) {

View File

@@ -1 +1 @@
v1.6.3
v1.6.10