Compare commits
23 Commits
f5f6dddba3
...
v1.6.10
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bb5dab64cd | ||
|
|
cbf03ea497 | ||
|
|
6b8ac5ca1d | ||
|
|
2964eba88b | ||
|
|
089e5375b4 | ||
|
|
e514f42dbe | ||
|
|
f7a9b061ea | ||
|
|
fe75347682 | ||
|
|
5c5255058c | ||
|
|
e16398ef74 | ||
|
|
6a57e2716f | ||
|
|
d383808f4f | ||
|
|
498b400033 | ||
|
|
884a17e7d3 | ||
|
|
ad660799ec | ||
|
|
a5fe50bbf0 | ||
|
|
a98faec51e | ||
|
|
212bf3b015 | ||
|
|
f29ecd4b79 | ||
|
|
1be6e44d7f | ||
|
|
49b0ab17ac | ||
|
|
55e738a554 | ||
|
|
45adfa9d5d |
@@ -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 |
|
| 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** | | | |
|
| **Header UI & Navigation** | | | |
|
||||||
| FR-090 | Die Hauptnavigation (Wochen-Toggles) muss linksbündig neben dem App-Titel positioniert sein. | Niedrig | v1.5.0 |
|
| FR-090 | Die Hauptnavigation (Wochen-Toggles) muss linksbündig neben dem App-Titel positioniert sein. | Niedrig | v1.5.0 |
|
||||||
| FR-091 | Ein dynamisches Alarm-Icon im Header muss den Überwachungsstatus geflaggter Menüs anzeigen (Gelb=Überwachung aktiv aber kein Menü verfügbar, Grün=Mindestens ein Menü verfügbar, Versteckt=keine Flags). Der Tooltip muss den Zeitpunkt der letzten Prüfung als relativen String (z.B. "vor 4 Min.") enthalten. | Mittel | v1.5.0 (Update v1.4.10) |
|
| FR-091 | Ein dynamisches Alarm-Icon im Header muss den Überwachungsstatus geflaggter Menüs anzeigen (Gelb=Überwachung aktiv aber kein Menü verfügbar, Grün=Mindestens ein Menü verfügbar, Versteckt=keine Flags). Der Tooltip muss den Zeitpunkt der letzten Prüfung als relativen String (z.B. "vor 4 Min.") enthalten. | Mittel | v1.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) |
|
| 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** | | | |
|
| **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-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** | | | |
|
| **Benutzer-Feedback** | | | |
|
||||||
| FR-095 | Alle benutzerrelevanten Aktionen (Bestellung, Stornierung, Fehler) müssen durch nicht-blockierende Benachrichtigungen (Toasts) bestätigt werden. | Mittel | v1.0.1 |
|
| FR-095 | Alle benutzerrelevanten Aktionen (Bestellung, Stornierung, Fehler) müssen durch nicht-blockierende Benachrichtigungen (Toasts) bestätigt werden. | Mittel | v1.0.1 |
|
||||||
| FR-096 | Bei einem Verbindungsfehler muss ein Fehlerdialog mit Fallback-Link zur Originalseite angezeigt werden. | Mittel | v1.0.1 |
|
| FR-096 | Bei einem Verbindungsfehler muss ein Fehlerdialog mit Fallback-Link zur Originalseite angezeigt werden. | Mittel | v1.0.1 |
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -99,15 +99,20 @@ echo "✅ Standalone HTML: $DIST_DIR/kantine-standalone.html"
|
|||||||
# Escape CSS for embedding in JS string
|
# Escape CSS for embedding in JS string
|
||||||
CSS_ESCAPED=$(echo "$CSS_CONTENT" | sed "s/'/\\\\'/g" | tr '\n' ' ' | sed 's/ */ /g')
|
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
|
cat > "$DIST_DIR/bookmarklet-payload.js" << PAYLOADEOF
|
||||||
(function(){
|
javascript:(function(){
|
||||||
if(window.__KANTINE_LOADED){alert('Kantine Wrapper already loaded!');return;}
|
if(window.__KANTINE_LOADED){alert('Kantine Wrapper already loaded!');return;}
|
||||||
var s=document.createElement('style');
|
var s=document.createElement('style');s.textContent='${CSS_ESCAPED}';document.head.appendChild(s);
|
||||||
s.textContent='${CSS_ESCAPED}';
|
// Inject JS logic
|
||||||
document.head.appendChild(s);
|
|
||||||
var sc=document.createElement('script');
|
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);
|
document.head.appendChild(sc);
|
||||||
})();
|
})();
|
||||||
PAYLOADEOF
|
PAYLOADEOF
|
||||||
@@ -161,7 +166,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>
|
||||||
@@ -239,12 +244,11 @@ fi
|
|||||||
|
|
||||||
# Embed the bookmarklet URL inline
|
# Embed the bookmarklet URL inline
|
||||||
echo "document.getElementById('bookmarklet-link').href = " >> "$DIST_DIR/install.html"
|
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
|
import sys, json, urllib.parse
|
||||||
|
|
||||||
# 1. Read JS and Replace VERSION + Favicon
|
# 1. Read JS and Replace VERSION + Favicon
|
||||||
js_template = sys.stdin.read()
|
js = sys.stdin.read()
|
||||||
js = js_template.replace('{{VERSION}}', '$VERSION').replace('{{FAVICON_DATA_URI}}', '$FAVICON_URL')
|
|
||||||
|
|
||||||
# 2. Prepare CSS for injection via createElement('style')
|
# 2. Prepare CSS for injection via createElement('style')
|
||||||
css = open('$CSS_FILE').read().replace('\n', ' ').replace(' ', ' ')
|
css = open('$CSS_FILE').read().replace('\n', ' ').replace(' ', ' ')
|
||||||
@@ -303,26 +307,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."
|
||||||
|
|||||||
29
changelog.md
29
changelog.md
@@ -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)
|
## 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.
|
- ✨ **Chore**: Slogan im Footer aktualisiert ("Jetzt Bessa Einfach! • Knapp-Kantine Wrapper • 2026 by Kaufis-Kitchen") und Footer-Höhe für mehr Platzierung optimiert.
|
||||||
|
|
||||||
|
|||||||
9
dist/bookmarklet-payload.js
vendored
9
dist/bookmarklet-payload.js
vendored
File diff suppressed because one or more lines are too long
2
dist/bookmarklet.txt
vendored
2
dist/bookmarklet.txt
vendored
File diff suppressed because one or more lines are too long
54
dist/install.html
vendored
54
dist/install.html
vendored
File diff suppressed because one or more lines are too long
253
dist/kantine-standalone.html
vendored
253
dist/kantine-standalone.html
vendored
File diff suppressed because one or more lines are too long
BIN
favicon.png
BIN
favicon.png
Binary file not shown.
|
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 3.6 KiB |
29
favicon.svg
29
favicon.svg
@@ -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 |
218
kantine.js
218
kantine.js
@@ -14,7 +14,7 @@
|
|||||||
// === Constants ===
|
// === Constants ===
|
||||||
const API_BASE = 'https://api.bessa.app/v1';
|
const API_BASE = 'https://api.bessa.app/v1';
|
||||||
const GUEST_TOKEN = 'c3418725e95a9f90e3645cbc846b4d67c7c66131';
|
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 VENUE_ID = 591;
|
||||||
const MENU_ID = 7;
|
const MENU_ID = 7;
|
||||||
const POLL_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes
|
const POLL_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes
|
||||||
@@ -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: 2em; width: 2em; 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>Jetzt Bessa Einfach! • Knapp-Kantine Wrapper • <span id="current-year">${new Date().getFullYear()}</span> by Kaufis-Kitchen</p>
|
<p>Jetzt Bessa Einfach! • Knapp-Kantine Wrapper • <span id="current-year">${new Date().getFullYear()}</span> by Kaufi 😃👍 mit Hilfe von KI 🤖</p>
|
||||||
</footer>
|
</footer>
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
@@ -691,7 +691,7 @@
|
|||||||
const existingOrder = localCache[existingOrderIndex];
|
const existingOrder = localCache[existingOrderIndex];
|
||||||
// If order exists and wasn't updated since our cache, we've reached the point
|
// If order exists and wasn't updated since our cache, we've reached the point
|
||||||
// where everything older is already correctly cached.
|
// 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) {
|
if (existingOrder.updated === order.updated && existingOrder.order_state === order.order_state) {
|
||||||
deltaComplete = true;
|
deltaComplete = true;
|
||||||
break;
|
break;
|
||||||
@@ -1099,11 +1099,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
const lastUpdated = new Date(lastUpdatedStr);
|
const lastUpdated = new Date(lastUpdatedStr);
|
||||||
const diffMs = Date.now() - lastUpdated.getTime();
|
timeStr = getRelativeTime(lastUpdated);
|
||||||
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.`;
|
|
||||||
|
|
||||||
bellBtn.title = `Zuletzt geprüft: ${timeStr}`;
|
bellBtn.title = `Zuletzt geprüft: ${timeStr}`;
|
||||||
|
|
||||||
@@ -1231,6 +1227,8 @@
|
|||||||
await new Promise(r => setTimeout(r, 200));
|
await new Promise(r => setTimeout(r, 200));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Update timestamp after successful polling cycle
|
||||||
|
updateLastUpdatedTime(new Date().toISOString());
|
||||||
}
|
}
|
||||||
|
|
||||||
// === Highlight Management ===
|
// === Highlight Management ===
|
||||||
@@ -1316,7 +1314,7 @@
|
|||||||
allWeeks.forEach(w => {
|
allWeeks.forEach(w => {
|
||||||
(w.days || []).forEach(d => {
|
(w.days || []).forEach(d => {
|
||||||
(d.items || []).forEach(item => {
|
(d.items || []).forEach(item => {
|
||||||
let text = (item.name || '').replace(/\s+/g, ' ').trim();
|
let text = (item.description || '').replace(/\s+/g, ' ').trim();
|
||||||
if (text && text.includes(' / ')) {
|
if (text && text.includes(' / ')) {
|
||||||
uniqueMenus.add(text);
|
uniqueMenus.add(text);
|
||||||
}
|
}
|
||||||
@@ -1567,6 +1565,7 @@
|
|||||||
const subtitle = document.getElementById('last-updated-subtitle');
|
const subtitle = document.getElementById('last-updated-subtitle');
|
||||||
if (!isoTimestamp) return;
|
if (!isoTimestamp) return;
|
||||||
lastUpdatedTimestamp = isoTimestamp;
|
lastUpdatedTimestamp = isoTimestamp;
|
||||||
|
localStorage.setItem('kantine_last_updated', isoTimestamp); // Persist for session-over-tab consistency
|
||||||
try {
|
try {
|
||||||
const date = new Date(isoTimestamp);
|
const date = new Date(isoTimestamp);
|
||||||
const timeStr = date.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
|
const timeStr = date.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
|
||||||
@@ -1579,7 +1578,10 @@
|
|||||||
// Auto-refresh relative time every minute
|
// Auto-refresh relative time every minute
|
||||||
if (!lastUpdatedIntervalId) {
|
if (!lastUpdatedIntervalId) {
|
||||||
lastUpdatedIntervalId = setInterval(() => {
|
lastUpdatedIntervalId = setInterval(() => {
|
||||||
if (lastUpdatedTimestamp) updateLastUpdatedTime(lastUpdatedTimestamp);
|
if (lastUpdatedTimestamp) {
|
||||||
|
updateLastUpdatedTime(lastUpdatedTimestamp);
|
||||||
|
updateAlarmBell(); // Ensure bell icon title (tooltip) also refreshes
|
||||||
|
}
|
||||||
}, 60 * 1000);
|
}, 60 * 1000);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2380,29 +2382,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', 'achtung', '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'
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -2416,7 +2424,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, 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
|
// Utility to compute DE/EN score for a subset of words
|
||||||
function scoreBlock(wordArray) {
|
function scoreBlock(wordArray) {
|
||||||
@@ -2446,7 +2458,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: '' };
|
||||||
@@ -2461,22 +2472,21 @@
|
|||||||
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) {
|
// Mandatory check: The assumed English part must actually look reasonably like English (or at least more so than the right part)
|
||||||
maxScore = finalScore;
|
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;
|
bestK = k;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2490,77 +2500,93 @@
|
|||||||
return { enPart: fragment, nextDe: '' };
|
return { enPart: fragment, nextDe: '' };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if text contains the bilingual separator ' / '
|
// Match courses: Any text followed by an allergen marker "(...)" but NOT if followed by a slash.
|
||||||
if (!text.includes(' / ')) {
|
const allergenRegex = /(.*?)(?:\(|(?:\/|\s|^))([A-Z,]+)\)\s*(?!\s*[/])/g;
|
||||||
// Fallback: detect language via keyword scoring
|
let match;
|
||||||
const words = text.toLowerCase().split(/\s+/);
|
const rawCourses = [];
|
||||||
const score = scoreBlock(words);
|
let lastScanIndex = 0;
|
||||||
|
|
||||||
// No split possible – return full text for detected language, empty for other
|
while ((match = allergenRegex.exec(text)) !== null) {
|
||||||
if (score.en > score.de) {
|
if (match.index > lastScanIndex) {
|
||||||
return { de: '', en: formattedRaw, raw: formattedRaw };
|
rawCourses.push(text.substring(lastScanIndex, match.index).trim());
|
||||||
}
|
}
|
||||||
return { de: formattedRaw, en: '', raw: formattedRaw };
|
rawCourses.push(match[0].trim());
|
||||||
|
lastScanIndex = allergenRegex.lastIndex;
|
||||||
}
|
}
|
||||||
|
if (lastScanIndex < text.length) {
|
||||||
// Split by ' / ' – produces alternating DE/EN fragments
|
rawCourses.push(text.substring(lastScanIndex).trim());
|
||||||
const parts = text.split(' / ');
|
}
|
||||||
// Sanity check: max 3 courses means max 3 slashes → max 4 parts
|
if (rawCourses.length === 0 && text.trim() !== '') {
|
||||||
if (parts.length > 4) {
|
rawCourses.push(text.trim());
|
||||||
// Too many slashes – possibly not bilingual, return as-is
|
|
||||||
return { de: formattedRaw, en: '', raw: formattedRaw };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const deParts = [];
|
const deParts = [];
|
||||||
const enParts = [];
|
const enParts = [];
|
||||||
|
|
||||||
// First fragment is always DE (course 1)
|
// 2. Process each course individually
|
||||||
deParts.push(parts[0].trim());
|
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"
|
if (courseMatch) {
|
||||||
// Allergen pattern: (LETTERS_AND_SPACES) at the boundary
|
courseText = courseMatch[1].trim();
|
||||||
const allergenRegex = /\(([A-Z ]+)\)\s*/;
|
allergenCode = courseMatch[2];
|
||||||
|
allergenTxt = ` (${allergenCode})`;
|
||||||
for (let i = 1; i < parts.length; i++) {
|
|
||||||
const fragment = parts[i].trim();
|
|
||||||
const match = fragment.match(allergenRegex);
|
|
||||||
|
|
||||||
if (match) {
|
|
||||||
// Split: everything before allergen + allergen = EN, after = next DE
|
|
||||||
const allergenEnd = match.index + match[0].length;
|
|
||||||
const enPart = fragment.substring(0, match.index).trim();
|
|
||||||
const allergenCode = match[1];
|
|
||||||
const nextDe = fragment.substring(allergenEnd).trim();
|
|
||||||
|
|
||||||
enParts.push(enPart + '(' + allergenCode + ')');
|
|
||||||
// Also append allergen to the last DE part
|
|
||||||
if (deParts.length > 0) {
|
|
||||||
deParts[deParts.length - 1] = deParts[deParts.length - 1] + '(' + allergenCode + ')';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (nextDe) {
|
// A) Split by slash if present
|
||||||
deParts.push(nextDe);
|
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 {
|
} else {
|
||||||
// No allergen code found!
|
// B) No slash found: Either missing translation or "EN DE" mixed
|
||||||
// If this is the last fragment, it contains only the English text of the final course.
|
const heuristicSplit = heuristicSplitEnDe(courseText);
|
||||||
// It should not be split again.
|
if (heuristicSplit.nextDe) {
|
||||||
if (i === parts.length - 1) {
|
enParts.push(heuristicSplit.enPart + allergenTxt);
|
||||||
enParts.push(fragment);
|
deParts.push(heuristicSplit.nextDe + allergenTxt);
|
||||||
} else {
|
} else {
|
||||||
// We use the heuristic to find the hidden split-point.
|
// Fallback: Use same chunk for both
|
||||||
const split = heuristicSplitEnDe(fragment);
|
deParts.push(courseText + allergenTxt);
|
||||||
enParts.push(split.enPart);
|
enParts.push(courseText + allergenTxt);
|
||||||
if (split.nextDe) {
|
|
||||||
deParts.push(split.nextDe);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
25
style.css
25
style.css
@@ -875,42 +875,31 @@ body {
|
|||||||
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ def main():
|
|||||||
|
|
||||||
# Check for placeholder leftovers
|
# Check for placeholder leftovers
|
||||||
if not check_content(BOOKMARKLET_TXT,
|
if not check_content(BOOKMARKLET_TXT,
|
||||||
must_contain=["document.createElement('style')", "M1", "M2"],
|
must_contain=["document.createElement('style')"],
|
||||||
must_not_contain=["{{VERSION}}", "{{CSS_ESCAPED}}"]):
|
must_not_contain=["{{VERSION}}", "{{CSS_ESCAPED}}"]):
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -90,7 +102,8 @@ const sandbox = {
|
|||||||
try {
|
try {
|
||||||
vm.createContext(sandbox);
|
vm.createContext(sandbox);
|
||||||
// Execute the code
|
// 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
|
// Regex Check: update icon appended to header
|
||||||
@@ -121,6 +134,7 @@ try {
|
|||||||
}
|
}
|
||||||
console.log("✅ Static Analysis Passed: All GitHub Release Management functions found.");
|
console.log("✅ Static Analysis Passed: All GitHub Release Management functions found.");
|
||||||
|
|
||||||
|
|
||||||
// Check dynamic logic usage
|
// Check dynamic logic usage
|
||||||
// Note: Since we mock fetch to fail for menu data, the app might perform error handling.
|
// 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.
|
// 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).");
|
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.");
|
console.log("✅ Syntax Check Passed: Code executed in sandbox.");
|
||||||
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
v1.6.3
|
v1.6.10
|
||||||
|
|||||||
Reference in New Issue
Block a user