Compare commits
19 Commits
1af1668c02
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 71cb2e8475 | |||
| 18c4961402 | |||
|
|
25816b7fb5 | ||
| d002e6a900 | |||
| c58b54faf6 | |||
| bff7ce3874 | |||
| 6864838999 | |||
| 6f2a34ae0e | |||
| 7008b40987 | |||
| 3500790b5d | |||
| 3d185140cc | |||
| 35e59a15a0 | |||
| 57d9e2e2b2 | |||
| 93e110639c | |||
| 689d185635 | |||
| 8bddff10cd | |||
| d5f1a51dc6 | |||
| 2f7381484a | |||
| 89100e2c9c |
@@ -1,6 +0,0 @@
|
||||
# Bessa Credentials (NEVER commit real credentials!)
|
||||
BESSA_EMPLOYEE_NUMBER=YOUR_EMPLOYEE_NUMBER
|
||||
BESSA_PASSWORD=YOUR_PASSWORD
|
||||
|
||||
# Optional: Headless mode (set to false for debugging)
|
||||
PUPPETEER_HEADLESS=true
|
||||
@@ -8,9 +8,8 @@ Ein intelligentes Bookmarklet für die Mitarbeiter-Kantine der Bessa App. Dieses
|
||||
* **Bestellstatus:** Farbige Indikatoren für bestellte Menüs.
|
||||
* **Kostenkontrolle:** Summiert automatisch den Gesamtpreis der Woche.
|
||||
* **Session Reuse:** Nutzt automatisch eine bestehende Login-Session (Loggt dich automatisch ein).
|
||||
* **Bestellhistorie:** Zeigt zuverlässig alle aktiven und abgeschlossenen Bestellungen an (über `/user/orders/`).
|
||||
* **Lokaler Cache:** Lädt Menüdaten blitzschnell aus dem Browser-Speicher.
|
||||
* **Scroll-Fix:** Garantiert Scrollbarkeit auch auf restriktiven Seiten.
|
||||
* **API Fallback:** Prüft die Verbindung und bietet bei Fehlern einen Direktlink zur Originalseite.
|
||||
* **Menu Badges:** Zeigt Menü-Codes (M1, M2+) direkt im Header.
|
||||
|
||||
## 📦 Installation
|
||||
|
||||
|
||||
55
RESEARCH.md
55
RESEARCH.md
@@ -1,55 +0,0 @@
|
||||
# Bessa API Authentication Research
|
||||
|
||||
This document describes the authentication flow for the Bessa Web App (`web.bessa.app/knapp-kantine`).
|
||||
|
||||
## Overview
|
||||
|
||||
The authentication process follows a multi-step flow involving a guest token and user credentials.
|
||||
|
||||
### 1. Initial Guest Session
|
||||
When the page first loads, it initializes a guest session. This session is associated with a guest token.
|
||||
|
||||
* **Identified Guest Token:** `c3418725e95a9f90e3645cbc846b4d67c7c66131`
|
||||
* **Usage:** Mandatory for the login request itself.
|
||||
|
||||
### 2. User Login
|
||||
The login request is sent to the `/auth/login/` endpoint.
|
||||
|
||||
* **Endpoint:** `POST https://api.bessa.app/v1/auth/login/`
|
||||
* **Headers:**
|
||||
* `Authorization`: `Token <Guest_Token>`
|
||||
* `Content-Type`: `application/json`
|
||||
* `Accept`: `application/json`
|
||||
* `X-Client-Version`: `1.7.0_prod/2026-01-26` (Example)
|
||||
* **Request Body:**
|
||||
```json
|
||||
{
|
||||
"email": "knapp-<EMPLOYEE_NUMBER>@bessa.app",
|
||||
"password": "<PASSWORD>"
|
||||
}
|
||||
```
|
||||
> [!NOTE]
|
||||
> The employee number entered in the UI is automatically transformed into an email format: `knapp-<number>@bessa.app`.
|
||||
|
||||
### 3. Authentication Result
|
||||
A successful login returns a session key.
|
||||
|
||||
* **Response (200 OK):**
|
||||
```json
|
||||
{
|
||||
"key": "dba7d86e83c7f462fd8af96521dea41c4facd8a5"
|
||||
}
|
||||
```
|
||||
* **Usage:** This `key` MUST be used in the `Authorization` header for all subsequent API requests.
|
||||
* **Header Format:** `Authorization: Token dba7d86e83c7f462fd8af96521dea41c4facd8a5`
|
||||
|
||||
### 4. Token Persistence
|
||||
* The token is stored in the browser's `localStorage` under the key `AkitaStores`.
|
||||
* Path: `AkitaStores.auth.token`
|
||||
|
||||
## Implementation Considerations
|
||||
|
||||
For the wrapper implementation:
|
||||
1. **In-Memory Storage**: The token should be handled purely in-memory (e.g., in the user session) to ensure security and follow privacy guidelines.
|
||||
2. **No Persistence**: Credentials or tokens should never be written to disk in a production environment.
|
||||
3. **Automatic Email Transformation**: The login handler should automatically prepend `knapp-` and append `@bessa.app` to the provided employee number to mimic the official app's behavior.
|
||||
@@ -5,22 +5,31 @@ set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
DIST_DIR="$SCRIPT_DIR/dist"
|
||||
CSS_FILE="$SCRIPT_DIR/public/style.css"
|
||||
CSS_FILE="$SCRIPT_DIR/style.css"
|
||||
JS_FILE="$SCRIPT_DIR/kantine.js"
|
||||
|
||||
# === VERSION ===
|
||||
if [ -f "$SCRIPT_DIR/version.txt" ]; then
|
||||
VERSION=$(cat "$SCRIPT_DIR/version.txt" | tr -d '\n')
|
||||
else
|
||||
echo "ERROR: version.txt not found"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "$DIST_DIR"
|
||||
|
||||
echo "=== Kantine Bookmarklet Builder ==="
|
||||
echo "=== Kantine Bookmarklet Builder ($VERSION) ==="
|
||||
|
||||
# Check files exist
|
||||
if [ ! -f "$CSS_FILE" ]; then echo "ERROR: $CSS_FILE not found"; exit 1; fi
|
||||
if [ ! -f "$JS_FILE" ]; then echo "ERROR: $JS_FILE not found"; exit 1; fi
|
||||
|
||||
CSS_CONTENT=$(cat "$CSS_FILE")
|
||||
JS_CONTENT=$(cat "$JS_FILE")
|
||||
# Inject version into JS
|
||||
JS_CONTENT=$(cat "$JS_FILE" | sed "s/{{VERSION}}/$VERSION/g")
|
||||
|
||||
# === 1. Build standalone HTML (for local testing/dev) ===
|
||||
cat > "$DIST_DIR/kantine-standalone.html" << 'HTMLEOF'
|
||||
cat > "$DIST_DIR/kantine-standalone.html" << HTMLEOF
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
@@ -37,7 +46,7 @@ HTMLEOF
|
||||
# Inject CSS
|
||||
cat "$CSS_FILE" >> "$DIST_DIR/kantine-standalone.html"
|
||||
|
||||
cat >> "$DIST_DIR/kantine-standalone.html" << 'HTMLEOF'
|
||||
cat >> "$DIST_DIR/kantine-standalone.html" << HTMLEOF
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -45,9 +54,9 @@ cat >> "$DIST_DIR/kantine-standalone.html" << 'HTMLEOF'
|
||||
HTMLEOF
|
||||
|
||||
# Inject JS
|
||||
cat "$JS_FILE" >> "$DIST_DIR/kantine-standalone.html"
|
||||
echo "$JS_CONTENT" >> "$DIST_DIR/kantine-standalone.html"
|
||||
|
||||
cat >> "$DIST_DIR/kantine-standalone.html" << 'HTMLEOF'
|
||||
cat >> "$DIST_DIR/kantine-standalone.html" << HTMLEOF
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -69,7 +78,7 @@ var s=document.createElement('style');
|
||||
s.textContent='${CSS_ESCAPED}';
|
||||
document.head.appendChild(s);
|
||||
var sc=document.createElement('script');
|
||||
sc.textContent=$(cat "$JS_FILE" | python3 -c "import sys,json; print(json.dumps(sys.stdin.read()))" 2>/dev/null || cat "$JS_FILE" | sed 's/\\/\\\\/g' | sed "s/'/\\\\'/g" | sed 's/"/\\\\"/g' | tr '\n' ' ' | sed 's/^/"/' | sed 's/$/"/');
|
||||
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/$/"/');
|
||||
document.head.appendChild(sc);
|
||||
})();
|
||||
PAYLOADEOF
|
||||
@@ -81,31 +90,45 @@ echo "javascript:${BOOKMARKLET_RAW}" > "$DIST_DIR/bookmarklet.txt"
|
||||
echo "✅ Bookmarklet URL: $DIST_DIR/bookmarklet.txt"
|
||||
|
||||
# === 3. Create an easy-to-use HTML installer page ===
|
||||
cat > "$DIST_DIR/install.html" << 'INSTALLEOF'
|
||||
cat > "$DIST_DIR/install.html" << INSTALLEOF
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Kantine Wrapper Installer</title>
|
||||
<title>Kantine Wrapper Installer ($VERSION)</title>
|
||||
<style>
|
||||
body { font-family: 'Inter', sans-serif; max-width: 600px; margin: 40px auto; padding: 20px; background: #1a1a2e; color: #eee; }
|
||||
h1 { color: #e94560; }
|
||||
h1 { color: #029AA8; } /* Knapp Teal */
|
||||
.instructions { background: #16213e; padding: 20px; border-radius: 12px; margin: 20px 0; }
|
||||
.instructions ol li { margin: 10px 0; }
|
||||
a.bookmarklet { display: inline-block; background: #e94560; color: white; padding: 12px 24px; border-radius: 8px; text-decoration: none; font-weight: 600; font-size: 18px; cursor: grab; }
|
||||
a.bookmarklet:hover { background: #c73652; }
|
||||
a.bookmarklet { display: inline-block; background: #029AA8; color: white; padding: 12px 24px; border-radius: 8px; text-decoration: none; font-weight: 600; font-size: 18px; cursor: grab; }
|
||||
a.bookmarklet:hover { background: #006269; }
|
||||
code { background: #0f3460; padding: 2px 6px; border-radius: 4px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>🍽️ Kantine Wrapper</h1>
|
||||
<h1>🍽️ Kantine Wrapper <span style="font-size:0.5em; opacity:0.6; font-weight:400; vertical-align:middle; margin-left:10px;">$VERSION</span></h1>
|
||||
<div class="instructions">
|
||||
<h2>Installation</h2>
|
||||
<ol>
|
||||
<li>Ziehe den Button unten in deine <strong>Lesezeichen-Leiste</strong> (Drag & Drop)</li>
|
||||
<li>Navigiere zu <a href="https://web.bessa.app/knapp-kantine" style="color:#e94560">web.bessa.app/knapp-kantine</a></li>
|
||||
<li>Klicke auf das Lesezeichen <code>Kantine Wrapper</code></li>
|
||||
<li>Navigiere zu <a href="https://web.bessa.app/knapp-kantine" style="color:#029AA8">web.bessa.app/knapp-kantine</a></li>
|
||||
<li>Klicke auf das Lesezeichen <code>Kantine $VERSION</code></li>
|
||||
</ol>
|
||||
|
||||
<h2>✨ Features</h2>
|
||||
<ul>
|
||||
<li>📅 <strong>Wochenübersicht:</strong> Die ganze Woche auf einen Blick.</li>
|
||||
<li>💰 <strong>Kostenkontrolle:</strong> Automatische Berechnung der Wochensumme.</li>
|
||||
<li>🔑 <strong>Auto-Login:</strong> Nutzt deine bestehende Session.</li>
|
||||
<li>🏷️ <strong>Badges & Status:</strong> Menü-Codes (M1, M2) und Bestellstatus direkt sichtbar.</li>
|
||||
<li>🛡️ <strong>Offline-Support:</strong> Speichert Menüdaten lokal.</li>
|
||||
</ul>
|
||||
|
||||
<div style="margin-top: 30px; padding: 15px; background: rgba(233, 69, 96, 0.1); border: 1px solid rgba(233, 69, 96, 0.3); border-radius: 8px; font-size: 0.85em; color: #ddd;">
|
||||
<strong>⚠️ Haftungsausschluss:</strong><br>
|
||||
Die Verwendung dieses Bookmarklets erfolgt auf eigene Verantwortung. Der Entwickler übernimmt keine Haftung für Schäden, Datenverlust oder ungewollte Bestellungen, die durch die Nutzung dieser Software entstehen.
|
||||
</div>
|
||||
</div>
|
||||
<p>👇 Diesen Button in die Lesezeichen-Leiste ziehen:</p>
|
||||
<p><a class="bookmarklet" id="bookmarklet-link" href="#">⏳ Wird generiert...</a></p>
|
||||
@@ -114,7 +137,7 @@ INSTALLEOF
|
||||
|
||||
# Embed the bookmarklet URL inline
|
||||
echo "document.getElementById('bookmarklet-link').href = " >> "$DIST_DIR/install.html"
|
||||
cat "$JS_FILE" | python3 -c "
|
||||
echo "$JS_CONTENT" | python3 -c "
|
||||
import sys, json
|
||||
js = sys.stdin.read()
|
||||
css = open('$CSS_FILE').read().replace('\\n', ' ').replace(' ', ' ')
|
||||
@@ -122,8 +145,8 @@ bmk = '''javascript:(function(){if(window.__KANTINE_LOADED){alert(\"Already load
|
||||
print(json.dumps(bmk) + ';')
|
||||
" 2>/dev/null >> "$DIST_DIR/install.html" || echo "'javascript:alert(\"Build error\")'" >> "$DIST_DIR/install.html"
|
||||
|
||||
cat >> "$DIST_DIR/install.html" << 'INSTALLEOF'
|
||||
document.getElementById('bookmarklet-link').textContent = '🍽️ Kantine Wrapper';
|
||||
cat >> "$DIST_DIR/install.html" << INSTALLEOF
|
||||
document.getElementById('bookmarklet-link').textContent = 'Kantine $VERSION';
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
150
build-bookmarklet_old.sh
Executable file
150
build-bookmarklet_old.sh
Executable file
@@ -0,0 +1,150 @@
|
||||
#!/bin/bash
|
||||
# Build script for Kantine Bookmarklet
|
||||
# Creates a self-contained bookmarklet URL and standalone HTML file
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
DIST_DIR="$SCRIPT_DIR/dist"
|
||||
CSS_FILE="$SCRIPT_DIR/style.css"
|
||||
JS_FILE="$SCRIPT_DIR/kantine.js"
|
||||
|
||||
mkdir -p "$DIST_DIR"
|
||||
|
||||
echo "=== Kantine Bookmarklet Builder ==="
|
||||
|
||||
# Check files exist
|
||||
if [ ! -f "$CSS_FILE" ]; then echo "ERROR: $CSS_FILE not found"; exit 1; fi
|
||||
if [ ! -f "$JS_FILE" ]; then echo "ERROR: $JS_FILE not found"; exit 1; fi
|
||||
|
||||
CSS_CONTENT=$(cat "$CSS_FILE")
|
||||
JS_CONTENT=$(cat "$JS_FILE")
|
||||
|
||||
# === 1. Build standalone HTML (for local testing/dev) ===
|
||||
cat > "$DIST_DIR/kantine-standalone.html" << 'HTMLEOF'
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Kantine Weekly Menu (Standalone)</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||
<link href="https://fonts.googleapis.com/icon?family=Material+Icons+Round" rel="stylesheet">
|
||||
<style>
|
||||
HTMLEOF
|
||||
|
||||
# Inject CSS
|
||||
cat "$CSS_FILE" >> "$DIST_DIR/kantine-standalone.html"
|
||||
|
||||
cat >> "$DIST_DIR/kantine-standalone.html" << 'HTMLEOF'
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<script>
|
||||
HTMLEOF
|
||||
|
||||
# Inject JS
|
||||
cat "$JS_FILE" >> "$DIST_DIR/kantine-standalone.html"
|
||||
|
||||
cat >> "$DIST_DIR/kantine-standalone.html" << 'HTMLEOF'
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
HTMLEOF
|
||||
|
||||
echo "✅ Standalone HTML: $DIST_DIR/kantine-standalone.html"
|
||||
|
||||
# === 2. Build bookmarklet (JavaScript URL) ===
|
||||
# The bookmarklet injects CSS + JS into the current page
|
||||
|
||||
# Escape CSS for embedding in JS string
|
||||
CSS_ESCAPED=$(echo "$CSS_CONTENT" | sed "s/'/\\\\'/g" | tr '\n' ' ' | sed 's/ */ /g')
|
||||
|
||||
# Build bookmarklet payload
|
||||
cat > "$DIST_DIR/bookmarklet-payload.js" << PAYLOADEOF
|
||||
(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 sc=document.createElement('script');
|
||||
sc.textContent=$(cat "$JS_FILE" | python3 -c "import sys,json; print(json.dumps(sys.stdin.read()))" 2>/dev/null || cat "$JS_FILE" | sed 's/\\/\\\\/g' | sed "s/'/\\\\'/g" | sed 's/"/\\\\"/g' | tr '\n' ' ' | sed 's/^/"/' | sed 's/$/"/');
|
||||
document.head.appendChild(sc);
|
||||
})();
|
||||
PAYLOADEOF
|
||||
|
||||
# URL-encode for bookmark
|
||||
BOOKMARKLET_RAW=$(cat "$DIST_DIR/bookmarklet-payload.js" | tr '\n' ' ' | sed 's/ */ /g')
|
||||
echo "javascript:${BOOKMARKLET_RAW}" > "$DIST_DIR/bookmarklet.txt"
|
||||
|
||||
echo "✅ Bookmarklet URL: $DIST_DIR/bookmarklet.txt"
|
||||
|
||||
# === 3. Create an easy-to-use HTML installer page ===
|
||||
cat > "$DIST_DIR/install.html" << 'INSTALLEOF'
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Kantine Wrapper Installer</title>
|
||||
<style>
|
||||
body { font-family: 'Inter', sans-serif; max-width: 600px; margin: 40px auto; padding: 20px; background: #1a1a2e; color: #eee; }
|
||||
h1 { color: #e94560; }
|
||||
.instructions { background: #16213e; padding: 20px; border-radius: 12px; margin: 20px 0; }
|
||||
.instructions ol li { margin: 10px 0; }
|
||||
a.bookmarklet { display: inline-block; background: #e94560; color: white; padding: 12px 24px; border-radius: 8px; text-decoration: none; font-weight: 600; font-size: 18px; cursor: grab; }
|
||||
a.bookmarklet:hover { background: #c73652; }
|
||||
code { background: #0f3460; padding: 2px 6px; border-radius: 4px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>🍽️ Kantine Wrapper</h1>
|
||||
<div class="instructions">
|
||||
<h2>Installation</h2>
|
||||
<ol>
|
||||
<li>Ziehe den Button unten in deine <strong>Lesezeichen-Leiste</strong> (Drag & Drop)</li>
|
||||
<li>Navigiere zu <a href="https://web.bessa.app/knapp-kantine" style="color:#e94560">web.bessa.app/knapp-kantine</a></li>
|
||||
<li>Klicke auf das Lesezeichen <code>Kantine Wrapper</code></li>
|
||||
</ol>
|
||||
|
||||
<h2>✨ Features</h2>
|
||||
<ul>
|
||||
<li>📅 <strong>Wochenübersicht:</strong> Die ganze Woche auf einen Blick.</li>
|
||||
<li>💰 <strong>Kostenkontrolle:</strong> Automatische Berechnung der Wochensumme.</li>
|
||||
<li>🔑 <strong>Auto-Login:</strong> Nutzt deine bestehende Session.</li>
|
||||
<li>🏷️ <strong>Badges & Status:</strong> Menü-Codes (M1, M2) und Bestellstatus direkt sichtbar.</li>
|
||||
<li>🛡️ <strong>Offline-Support:</strong> Speichert Menüdaten lokal.</li>
|
||||
</ul>
|
||||
|
||||
<div style="margin-top: 30px; padding: 15px; background: rgba(233, 69, 96, 0.1); border: 1px solid rgba(233, 69, 96, 0.3); border-radius: 8px; font-size: 0.85em; color: #ddd;">
|
||||
<strong>⚠️ Haftungsausschluss:</strong><br>
|
||||
Die Verwendung dieses Bookmarklets erfolgt auf eigene Verantwortung. Der Entwickler übernimmt keine Haftung für Schäden, Datenverlust oder ungewollte Bestellungen, die durch die Nutzung dieser Software entstehen.
|
||||
</div>
|
||||
</div>
|
||||
<p>👇 Diesen Button in die Lesezeichen-Leiste ziehen:</p>
|
||||
<p><a class="bookmarklet" id="bookmarklet-link" href="#">⏳ Wird generiert...</a></p>
|
||||
<script>
|
||||
INSTALLEOF
|
||||
|
||||
# Embed the bookmarklet URL inline
|
||||
echo "document.getElementById('bookmarklet-link').href = " >> "$DIST_DIR/install.html"
|
||||
cat "$JS_FILE" | python3 -c "
|
||||
import sys, json
|
||||
js = sys.stdin.read()
|
||||
css = open('$CSS_FILE').read().replace('\\n', ' ').replace(' ', ' ')
|
||||
bmk = '''javascript:(function(){if(window.__KANTINE_LOADED){alert(\"Already loaded\");return;}var s=document.createElement(\"style\");s.textContent=''' + json.dumps(css) + ''';document.head.appendChild(s);var sc=document.createElement(\"script\");sc.textContent=''' + json.dumps(js) + ''';document.head.appendChild(sc);})();'''
|
||||
print(json.dumps(bmk) + ';')
|
||||
" 2>/dev/null >> "$DIST_DIR/install.html" || echo "'javascript:alert(\"Build error\")'" >> "$DIST_DIR/install.html"
|
||||
|
||||
cat >> "$DIST_DIR/install.html" << 'INSTALLEOF'
|
||||
document.getElementById('bookmarklet-link').textContent = '🍽️ Kantine Wrapper';
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
INSTALLEOF
|
||||
|
||||
echo "✅ Installer page: $DIST_DIR/install.html"
|
||||
echo ""
|
||||
echo "=== Build Complete ==="
|
||||
echo "Files in $DIST_DIR:"
|
||||
ls -la "$DIST_DIR/"
|
||||
4
dist/bookmarklet-payload.js
vendored
4
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
32
dist/install.html
vendored
32
dist/install.html
vendored
File diff suppressed because one or more lines are too long
349
dist/kantine-standalone.html
vendored
349
dist/kantine-standalone.html
vendored
@@ -1115,7 +1115,98 @@ body {
|
||||
100% {
|
||||
box-shadow: 0 0 10px rgba(16, 185, 129, 0.3);
|
||||
}
|
||||
} </style>
|
||||
}
|
||||
|
||||
/* Day Header Badges */
|
||||
.day-header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.menu-code-badge {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
color: #8b5cf6;
|
||||
/* Violet 500 */
|
||||
background-color: rgba(139, 92, 246, 0.15);
|
||||
border: 1px solid rgba(139, 92, 246, 0.3);
|
||||
padding: 2px 6px;
|
||||
border-radius: 6px;
|
||||
line-height: normal;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
/* Detailed Badge Colors */
|
||||
.nav-badge.badge-violet {
|
||||
background-color: #8b5cf6;
|
||||
}
|
||||
|
||||
.nav-badge.badge-green {
|
||||
background-color: var(--success-color);
|
||||
}
|
||||
|
||||
.nav-badge.badge-red {
|
||||
background-color: var(--error-color);
|
||||
}
|
||||
|
||||
.nav-badge.badge-blue {
|
||||
background-color: var(--accent-color);
|
||||
}
|
||||
|
||||
/* Day Header Status Colors (User Request) */
|
||||
.card-header.header-violet {
|
||||
background-color: rgba(139, 92, 246, 0.15);
|
||||
border-bottom: 2px solid #8b5cf6;
|
||||
}
|
||||
|
||||
.card-header.header-green {
|
||||
background-color: rgba(16, 185, 129, 0.15);
|
||||
border-bottom: 2px solid var(--success-color);
|
||||
}
|
||||
|
||||
.card-header.header-red {
|
||||
background-color: rgba(239, 68, 68, 0.15);
|
||||
border-bottom: 2px solid var(--error-color);
|
||||
}
|
||||
|
||||
.card-header.header-violet .day-name,
|
||||
.card-header.header-green .day-name,
|
||||
.card-header.header-red .day-name {
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
/* Ensure text remains standard color */
|
||||
}
|
||||
/* Update Icon */
|
||||
.update-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-left: 8px;
|
||||
background-color: rgba(16, 185, 129, 0.2); /* Green tint */
|
||||
color: var(--success-color);
|
||||
border-radius: 50%;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
transition: all 0.2s;
|
||||
text-decoration: none;
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
.update-icon:hover {
|
||||
background-color: var(--success-color);
|
||||
color: white;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% { box-shadow: 0 0 0 0 rgba(16, 185, 129, 0.4); }
|
||||
70% { box-shadow: 0 0 0 6px rgba(16, 185, 129, 0); }
|
||||
100% { box-shadow: 0 0 0 0 rgba(16, 185, 129, 0); }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<script>
|
||||
@@ -1186,8 +1277,8 @@ body {
|
||||
<div class="header-content">
|
||||
<div class="brand">
|
||||
<span class="material-icons-round logo-icon">restaurant_menu</span>
|
||||
<div class="brand-text">
|
||||
<h1>Kantinen Übersicht</h1>
|
||||
<div class="header-left">
|
||||
<h1>Kantinen Übersicht <small style="font-size: 0.6em; opacity: 0.7; font-weight: 400;">v1.0.3</small></h1>
|
||||
<div id="last-updated-subtitle" class="subtitle"></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1851,8 +1942,23 @@ body {
|
||||
await new Promise(r => setTimeout(r, 100));
|
||||
}
|
||||
|
||||
// 3. Group by ISO week
|
||||
// 3. Group by ISO week (Merge with existing to preserve past days)
|
||||
const weeksMap = new Map();
|
||||
|
||||
// Hydrate from existing cache (preserve past data)
|
||||
if (allWeeks && allWeeks.length > 0) {
|
||||
allWeeks.forEach(w => {
|
||||
const key = `${w.year}-${w.weekNumber}`;
|
||||
try {
|
||||
weeksMap.set(key, {
|
||||
year: w.year,
|
||||
weekNumber: w.weekNumber,
|
||||
days: w.days ? w.days.map(d => ({ ...d, items: d.items ? [...d.items] : [] })) : []
|
||||
});
|
||||
} catch (e) { console.warn('Error hydrating week:', e); }
|
||||
});
|
||||
}
|
||||
|
||||
for (const day of allDays) {
|
||||
const d = new Date(day.date);
|
||||
const weekNum = getISOWeek(d);
|
||||
@@ -1863,11 +1969,12 @@ body {
|
||||
weeksMap.set(key, { year, weekNumber: weekNum, days: [] });
|
||||
}
|
||||
|
||||
const weekObj = weeksMap.get(key);
|
||||
const weekday = d.toLocaleDateString('en-US', { weekday: 'long' });
|
||||
const orderCutoffDate = new Date(day.date);
|
||||
orderCutoffDate.setHours(10, 0, 0, 0);
|
||||
|
||||
weeksMap.get(key).days.push({
|
||||
const newDayObj = {
|
||||
date: day.date,
|
||||
weekday: weekday,
|
||||
orderCutoff: orderCutoffDate.toISOString(),
|
||||
@@ -1885,13 +1992,25 @@ body {
|
||||
amountTracking: item.amount_tracking !== false
|
||||
};
|
||||
})
|
||||
});
|
||||
};
|
||||
|
||||
// Merge: Overwrite if exists, push if new
|
||||
const existingIndex = weekObj.days.findIndex(existing => existing.date === day.date);
|
||||
if (existingIndex >= 0) {
|
||||
weekObj.days[existingIndex] = newDayObj;
|
||||
} else {
|
||||
weekObj.days.push(newDayObj);
|
||||
}
|
||||
}
|
||||
|
||||
// Sort weeks and days
|
||||
allWeeks = Array.from(weeksMap.values()).sort((a, b) => {
|
||||
if (a.year !== b.year) return a.year - b.year;
|
||||
return a.weekNumber - b.weekNumber;
|
||||
});
|
||||
allWeeks.forEach(w => {
|
||||
if (w.days) w.days.sort((a, b) => a.date.localeCompare(b.date));
|
||||
});
|
||||
|
||||
// Save to localStorage cache
|
||||
saveMenuCache();
|
||||
@@ -1913,8 +2032,14 @@ body {
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error fetching menu:', error);
|
||||
document.getElementById('menu-container').innerHTML = `<p class="error">Fehler beim Laden der Menüdaten: ${error.message}</p>`;
|
||||
progressModal.classList.add('hidden');
|
||||
|
||||
showErrorModal(
|
||||
'Keine Verbindung',
|
||||
`Die Menüdaten konnten nicht geladen werden. Möglicherweise besteht keine Verbindung zur API oder zur Bessa-Webseite.<br><br><small style="color:var(--text-secondary)">${error.message}</small>`,
|
||||
'Zur Original-Seite',
|
||||
'https://web.bessa.app/knapp-kantine'
|
||||
);
|
||||
} finally {
|
||||
loading.classList.add('hidden');
|
||||
}
|
||||
@@ -1964,12 +2089,25 @@ body {
|
||||
const nextWeekData = allWeeks.find(w => w.weekNumber === nextWeek && w.year === nextYear);
|
||||
let totalDataCount = 0;
|
||||
let orderableCount = 0;
|
||||
let daysWithOrders = 0;
|
||||
let daysWithOrderableAndNoOrder = 0;
|
||||
|
||||
if (nextWeekData && nextWeekData.days) {
|
||||
nextWeekData.days.forEach(day => {
|
||||
if (day.items && day.items.length > 0) {
|
||||
totalDataCount++;
|
||||
if (day.items.some(item => item.available)) orderableCount++;
|
||||
const isOrderable = day.items.some(item => item.available);
|
||||
if (isOrderable) orderableCount++;
|
||||
|
||||
let hasOrder = false;
|
||||
day.items.forEach(item => {
|
||||
const articleId = item.articleId || parseInt(item.id.split('_')[1]);
|
||||
const key = `${day.date}_${articleId}`;
|
||||
if (orderMap.has(key) && orderMap.get(key).length > 0) hasOrder = true;
|
||||
});
|
||||
|
||||
if (hasOrder) daysWithOrders++;
|
||||
if (isOrderable && !hasOrder) daysWithOrderableAndNoOrder++;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1981,8 +2119,27 @@ body {
|
||||
badge.className = 'nav-badge';
|
||||
btnNextWeek.appendChild(badge);
|
||||
}
|
||||
badge.title = `${orderableCount} Tage bestellbar / ${totalDataCount} Tage mit Menüdaten`;
|
||||
badge.innerHTML = `<span class="orderable">${orderableCount}</span><span class="separator">/</span><span class="total">${totalDataCount}</span>`;
|
||||
|
||||
// Format: ( Ordered / Orderable / Total )
|
||||
badge.title = `${daysWithOrders} bestellt / ${orderableCount} bestellbar / ${totalDataCount} gesamt`;
|
||||
badge.innerHTML = `<span class="ordered">${daysWithOrders}</span><span class="separator">/</span><span class="orderable">${orderableCount}</span><span class="separator">/</span><span class="total">${totalDataCount}</span>`;
|
||||
|
||||
// Color Logic
|
||||
badge.classList.remove('badge-violet', 'badge-green', 'badge-red', 'badge-blue');
|
||||
|
||||
// Refined Logic (v1.7.4):
|
||||
// Violet: If we have orders AND there are no DAYS left that are orderable but un-ordered.
|
||||
// (i.e. "I have ordered everything I can")
|
||||
if (daysWithOrders > 0 && daysWithOrderableAndNoOrder === 0) {
|
||||
badge.classList.add('badge-violet');
|
||||
} else if (daysWithOrderableAndNoOrder > 0) {
|
||||
badge.classList.add('badge-green'); // Orderable days exist without order
|
||||
} else if (orderableCount === 0) {
|
||||
badge.classList.add('badge-red'); // No orderable days at all & no orders
|
||||
} else {
|
||||
badge.classList.add('badge-blue'); // Default / partial state
|
||||
}
|
||||
|
||||
} else if (badge) {
|
||||
badge.remove();
|
||||
}
|
||||
@@ -2120,12 +2277,66 @@ body {
|
||||
|
||||
if (isPastCutoff) card.classList.add('past-day');
|
||||
|
||||
// Collect ordered menu codes
|
||||
const menuBadges = [];
|
||||
if (day.items) {
|
||||
day.items.forEach(item => {
|
||||
const articleId = item.articleId || parseInt(item.id.split('_')[1]);
|
||||
const orderKey = `${day.date}_${articleId}`;
|
||||
const orders = orderMap.get(orderKey) || [];
|
||||
const count = orders.length;
|
||||
|
||||
if (count > 0) {
|
||||
// Regex for M1, M2, M1F etc.
|
||||
const match = item.name.match(/([M][1-9][Ff]?)/);
|
||||
if (match) {
|
||||
let code = match[1];
|
||||
if (count > 1) code += '+';
|
||||
menuBadges.push(code);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Header
|
||||
const header = document.createElement('div');
|
||||
header.className = 'card-header';
|
||||
const dateStr = cardDate.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' });
|
||||
|
||||
const badgesHtml = menuBadges.map(code => `<span class="menu-code-badge">${code}</span>`).join('');
|
||||
|
||||
// Determine Day Status for Header Color
|
||||
// Violet: Has Order
|
||||
// Green: No Order but Orderable
|
||||
// Red: No Order and Not Orderable (Locked/Sold Out)
|
||||
let headerClass = '';
|
||||
const hasAnyOrder = day.items && day.items.some(item => {
|
||||
const articleId = item.articleId || parseInt(item.id.split('_')[1]);
|
||||
const key = `${day.date}_${articleId}`;
|
||||
return orderMap.has(key) && orderMap.get(key).length > 0;
|
||||
});
|
||||
|
||||
const hasOrderable = day.items && day.items.some(item => {
|
||||
// Use pre-calculated available flag from loadMenuDataFromAPI calculation
|
||||
return item.available;
|
||||
});
|
||||
|
||||
if (hasAnyOrder) {
|
||||
headerClass = 'header-violet';
|
||||
} else if (hasOrderable && !isPastCutoff) {
|
||||
headerClass = 'header-green';
|
||||
} else {
|
||||
// Red if not orderable (or past cutoff)
|
||||
headerClass = 'header-red';
|
||||
}
|
||||
|
||||
if (headerClass) header.classList.add(headerClass);
|
||||
|
||||
header.innerHTML = `
|
||||
<span class="day-name">${translateDay(day.weekday)}</span>
|
||||
<div class="day-header-left">
|
||||
<span class="day-name">${translateDay(day.weekday)}</span>
|
||||
<div class="day-badges">${badgesHtml}</div>
|
||||
</div>
|
||||
<span class="day-date">${dateStr}</span>`;
|
||||
card.appendChild(header);
|
||||
|
||||
@@ -2133,7 +2344,21 @@ body {
|
||||
const body = document.createElement('div');
|
||||
body.className = 'card-body';
|
||||
|
||||
const sortedItems = [...day.items].sort((a, b) => a.name.localeCompare(b.name));
|
||||
const todayDateStr = new Date().toISOString().split('T')[0];
|
||||
const isToday = day.date === todayDateStr;
|
||||
|
||||
const sortedItems = [...day.items].sort((a, b) => {
|
||||
if (isToday) {
|
||||
const aId = a.articleId || parseInt(a.id.split('_')[1]);
|
||||
const bId = b.articleId || parseInt(b.id.split('_')[1]);
|
||||
const aOrdered = orderMap.has(`${day.date}_${aId}`);
|
||||
const bOrdered = orderMap.has(`${day.date}_${bId}`);
|
||||
|
||||
if (aOrdered && !bOrdered) return -1;
|
||||
if (!aOrdered && bOrdered) return 1;
|
||||
}
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
|
||||
sortedItems.forEach(item => {
|
||||
const itemEl = document.createElement('div');
|
||||
@@ -2259,6 +2484,50 @@ body {
|
||||
return card;
|
||||
}
|
||||
|
||||
// === Version Check ===
|
||||
async function checkForUpdates() {
|
||||
const CurrentVersion = 'v1.0.3'; // Injected by build script
|
||||
const VersionUrl = 'https://raw.githubusercontent.com/TauNeutrino/kantine-overview/main/version.txt';
|
||||
// Use htmlpreview.github.io to render the HTML directly in browser
|
||||
const InstallerUrl = 'https://htmlpreview.github.io/?https://github.com/TauNeutrino/kantine-overview/blob/main/dist/install.html';
|
||||
|
||||
console.log(`[Kantine] Checking for updates... (Current: ${CurrentVersion})`);
|
||||
|
||||
try {
|
||||
const response = await fetch(VersionUrl, { cache: 'no-cache' });
|
||||
if (!response.ok) return;
|
||||
|
||||
const remoteVersion = (await response.text()).trim();
|
||||
console.log(`[Kantine] Remote version: ${remoteVersion}`);
|
||||
|
||||
if (remoteVersion && remoteVersion !== CurrentVersion) {
|
||||
// Simple semantic version check or string inequality
|
||||
// Assuming format v1.0.0
|
||||
showUpdateIcon(remoteVersion, InstallerUrl);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[Kantine] Version check failed:', error);
|
||||
}
|
||||
}
|
||||
|
||||
function showUpdateIcon(newVersion, url) {
|
||||
const headerTitle = document.querySelector('.header-left h1');
|
||||
if (!headerTitle) return;
|
||||
|
||||
// Check if already added
|
||||
if (headerTitle.querySelector('.update-icon')) return;
|
||||
|
||||
const icon = document.createElement('a');
|
||||
icon.className = 'update-icon';
|
||||
icon.href = url;
|
||||
icon.target = '_blank';
|
||||
icon.innerHTML = '🆕'; // User requested icon
|
||||
icon.title = `Neue Version verfügbar (${newVersion}). Klick für download`;
|
||||
|
||||
headerTitle.appendChild(icon);
|
||||
showToast(`Update verfügbar: ${newVersion}`, 'info');
|
||||
}
|
||||
|
||||
// === Helpers ===
|
||||
function getISOWeek(date) {
|
||||
const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()));
|
||||
@@ -2304,8 +2573,64 @@ body {
|
||||
startPolling();
|
||||
}
|
||||
|
||||
// Check for updates
|
||||
checkForUpdates();
|
||||
|
||||
console.log('Kantine Wrapper loaded ✅');
|
||||
})();
|
||||
|
||||
// === Error Modal ===
|
||||
function showErrorModal(title, htmlContent, btnText, url) {
|
||||
const modalId = 'error-modal';
|
||||
let modal = document.getElementById(modalId);
|
||||
if (modal) modal.remove();
|
||||
|
||||
modal = document.createElement('div');
|
||||
modal.id = modalId;
|
||||
modal.className = 'modal hidden';
|
||||
modal.innerHTML = `
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h2 style="color: var(--error-color); display: flex; align-items: center; gap: 10px;">
|
||||
<span class="material-icons-round">signal_wifi_off</span>
|
||||
${title}
|
||||
</h2>
|
||||
</div>
|
||||
<div style="padding: 20px;">
|
||||
<p style="margin-bottom: 15px; color: var(--text-primary);">${htmlContent}</p>
|
||||
<div style="margin-top: 20px; display: flex; justify-content: center;">
|
||||
<button id="btn-error-redirect" style="
|
||||
background-color: var(--accent-color);
|
||||
color: white;
|
||||
padding: 12px 24px;
|
||||
border-radius: 8px;
|
||||
border: none;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
transition: transform 0.1s;
|
||||
">
|
||||
${btnText}
|
||||
<span class="material-icons-round">open_in_new</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(modal);
|
||||
|
||||
document.getElementById('btn-error-redirect').addEventListener('click', () => {
|
||||
window.location.href = url;
|
||||
});
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
modal.classList.remove('hidden');
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
256
kantine.js
256
kantine.js
@@ -65,8 +65,8 @@
|
||||
<div class="header-content">
|
||||
<div class="brand">
|
||||
<span class="material-icons-round logo-icon">restaurant_menu</span>
|
||||
<div class="brand-text">
|
||||
<h1>Kantinen Übersicht</h1>
|
||||
<div class="header-left">
|
||||
<h1>Kantinen Übersicht <small style="font-size: 0.6em; opacity: 0.7; font-weight: 400;">{{VERSION}}</small></h1>
|
||||
<div id="last-updated-subtitle" class="subtitle"></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -730,8 +730,23 @@
|
||||
await new Promise(r => setTimeout(r, 100));
|
||||
}
|
||||
|
||||
// 3. Group by ISO week
|
||||
// 3. Group by ISO week (Merge with existing to preserve past days)
|
||||
const weeksMap = new Map();
|
||||
|
||||
// Hydrate from existing cache (preserve past data)
|
||||
if (allWeeks && allWeeks.length > 0) {
|
||||
allWeeks.forEach(w => {
|
||||
const key = `${w.year}-${w.weekNumber}`;
|
||||
try {
|
||||
weeksMap.set(key, {
|
||||
year: w.year,
|
||||
weekNumber: w.weekNumber,
|
||||
days: w.days ? w.days.map(d => ({ ...d, items: d.items ? [...d.items] : [] })) : []
|
||||
});
|
||||
} catch (e) { console.warn('Error hydrating week:', e); }
|
||||
});
|
||||
}
|
||||
|
||||
for (const day of allDays) {
|
||||
const d = new Date(day.date);
|
||||
const weekNum = getISOWeek(d);
|
||||
@@ -742,11 +757,12 @@
|
||||
weeksMap.set(key, { year, weekNumber: weekNum, days: [] });
|
||||
}
|
||||
|
||||
const weekObj = weeksMap.get(key);
|
||||
const weekday = d.toLocaleDateString('en-US', { weekday: 'long' });
|
||||
const orderCutoffDate = new Date(day.date);
|
||||
orderCutoffDate.setHours(10, 0, 0, 0);
|
||||
|
||||
weeksMap.get(key).days.push({
|
||||
const newDayObj = {
|
||||
date: day.date,
|
||||
weekday: weekday,
|
||||
orderCutoff: orderCutoffDate.toISOString(),
|
||||
@@ -764,13 +780,25 @@
|
||||
amountTracking: item.amount_tracking !== false
|
||||
};
|
||||
})
|
||||
});
|
||||
};
|
||||
|
||||
// Merge: Overwrite if exists, push if new
|
||||
const existingIndex = weekObj.days.findIndex(existing => existing.date === day.date);
|
||||
if (existingIndex >= 0) {
|
||||
weekObj.days[existingIndex] = newDayObj;
|
||||
} else {
|
||||
weekObj.days.push(newDayObj);
|
||||
}
|
||||
}
|
||||
|
||||
// Sort weeks and days
|
||||
allWeeks = Array.from(weeksMap.values()).sort((a, b) => {
|
||||
if (a.year !== b.year) return a.year - b.year;
|
||||
return a.weekNumber - b.weekNumber;
|
||||
});
|
||||
allWeeks.forEach(w => {
|
||||
if (w.days) w.days.sort((a, b) => a.date.localeCompare(b.date));
|
||||
});
|
||||
|
||||
// Save to localStorage cache
|
||||
saveMenuCache();
|
||||
@@ -792,8 +820,14 @@
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error fetching menu:', error);
|
||||
document.getElementById('menu-container').innerHTML = `<p class="error">Fehler beim Laden der Menüdaten: ${error.message}</p>`;
|
||||
progressModal.classList.add('hidden');
|
||||
|
||||
showErrorModal(
|
||||
'Keine Verbindung',
|
||||
`Die Menüdaten konnten nicht geladen werden. Möglicherweise besteht keine Verbindung zur API oder zur Bessa-Webseite.<br><br><small style="color:var(--text-secondary)">${error.message}</small>`,
|
||||
'Zur Original-Seite',
|
||||
'https://web.bessa.app/knapp-kantine'
|
||||
);
|
||||
} finally {
|
||||
loading.classList.add('hidden');
|
||||
}
|
||||
@@ -843,12 +877,25 @@
|
||||
const nextWeekData = allWeeks.find(w => w.weekNumber === nextWeek && w.year === nextYear);
|
||||
let totalDataCount = 0;
|
||||
let orderableCount = 0;
|
||||
let daysWithOrders = 0;
|
||||
let daysWithOrderableAndNoOrder = 0;
|
||||
|
||||
if (nextWeekData && nextWeekData.days) {
|
||||
nextWeekData.days.forEach(day => {
|
||||
if (day.items && day.items.length > 0) {
|
||||
totalDataCount++;
|
||||
if (day.items.some(item => item.available)) orderableCount++;
|
||||
const isOrderable = day.items.some(item => item.available);
|
||||
if (isOrderable) orderableCount++;
|
||||
|
||||
let hasOrder = false;
|
||||
day.items.forEach(item => {
|
||||
const articleId = item.articleId || parseInt(item.id.split('_')[1]);
|
||||
const key = `${day.date}_${articleId}`;
|
||||
if (orderMap.has(key) && orderMap.get(key).length > 0) hasOrder = true;
|
||||
});
|
||||
|
||||
if (hasOrder) daysWithOrders++;
|
||||
if (isOrderable && !hasOrder) daysWithOrderableAndNoOrder++;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -860,8 +907,27 @@
|
||||
badge.className = 'nav-badge';
|
||||
btnNextWeek.appendChild(badge);
|
||||
}
|
||||
badge.title = `${orderableCount} Tage bestellbar / ${totalDataCount} Tage mit Menüdaten`;
|
||||
badge.innerHTML = `<span class="orderable">${orderableCount}</span><span class="separator">/</span><span class="total">${totalDataCount}</span>`;
|
||||
|
||||
// Format: ( Ordered / Orderable / Total )
|
||||
badge.title = `${daysWithOrders} bestellt / ${orderableCount} bestellbar / ${totalDataCount} gesamt`;
|
||||
badge.innerHTML = `<span class="ordered">${daysWithOrders}</span><span class="separator">/</span><span class="orderable">${orderableCount}</span><span class="separator">/</span><span class="total">${totalDataCount}</span>`;
|
||||
|
||||
// Color Logic
|
||||
badge.classList.remove('badge-violet', 'badge-green', 'badge-red', 'badge-blue');
|
||||
|
||||
// Refined Logic (v1.7.4):
|
||||
// Violet: If we have orders AND there are no DAYS left that are orderable but un-ordered.
|
||||
// (i.e. "I have ordered everything I can")
|
||||
if (daysWithOrders > 0 && daysWithOrderableAndNoOrder === 0) {
|
||||
badge.classList.add('badge-violet');
|
||||
} else if (daysWithOrderableAndNoOrder > 0) {
|
||||
badge.classList.add('badge-green'); // Orderable days exist without order
|
||||
} else if (orderableCount === 0) {
|
||||
badge.classList.add('badge-red'); // No orderable days at all & no orders
|
||||
} else {
|
||||
badge.classList.add('badge-blue'); // Default / partial state
|
||||
}
|
||||
|
||||
} else if (badge) {
|
||||
badge.remove();
|
||||
}
|
||||
@@ -999,12 +1065,66 @@
|
||||
|
||||
if (isPastCutoff) card.classList.add('past-day');
|
||||
|
||||
// Collect ordered menu codes
|
||||
const menuBadges = [];
|
||||
if (day.items) {
|
||||
day.items.forEach(item => {
|
||||
const articleId = item.articleId || parseInt(item.id.split('_')[1]);
|
||||
const orderKey = `${day.date}_${articleId}`;
|
||||
const orders = orderMap.get(orderKey) || [];
|
||||
const count = orders.length;
|
||||
|
||||
if (count > 0) {
|
||||
// Regex for M1, M2, M1F etc.
|
||||
const match = item.name.match(/([M][1-9][Ff]?)/);
|
||||
if (match) {
|
||||
let code = match[1];
|
||||
if (count > 1) code += '+';
|
||||
menuBadges.push(code);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Header
|
||||
const header = document.createElement('div');
|
||||
header.className = 'card-header';
|
||||
const dateStr = cardDate.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' });
|
||||
|
||||
const badgesHtml = menuBadges.map(code => `<span class="menu-code-badge">${code}</span>`).join('');
|
||||
|
||||
// Determine Day Status for Header Color
|
||||
// Violet: Has Order
|
||||
// Green: No Order but Orderable
|
||||
// Red: No Order and Not Orderable (Locked/Sold Out)
|
||||
let headerClass = '';
|
||||
const hasAnyOrder = day.items && day.items.some(item => {
|
||||
const articleId = item.articleId || parseInt(item.id.split('_')[1]);
|
||||
const key = `${day.date}_${articleId}`;
|
||||
return orderMap.has(key) && orderMap.get(key).length > 0;
|
||||
});
|
||||
|
||||
const hasOrderable = day.items && day.items.some(item => {
|
||||
// Use pre-calculated available flag from loadMenuDataFromAPI calculation
|
||||
return item.available;
|
||||
});
|
||||
|
||||
if (hasAnyOrder) {
|
||||
headerClass = 'header-violet';
|
||||
} else if (hasOrderable && !isPastCutoff) {
|
||||
headerClass = 'header-green';
|
||||
} else {
|
||||
// Red if not orderable (or past cutoff)
|
||||
headerClass = 'header-red';
|
||||
}
|
||||
|
||||
if (headerClass) header.classList.add(headerClass);
|
||||
|
||||
header.innerHTML = `
|
||||
<span class="day-name">${translateDay(day.weekday)}</span>
|
||||
<div class="day-header-left">
|
||||
<span class="day-name">${translateDay(day.weekday)}</span>
|
||||
<div class="day-badges">${badgesHtml}</div>
|
||||
</div>
|
||||
<span class="day-date">${dateStr}</span>`;
|
||||
card.appendChild(header);
|
||||
|
||||
@@ -1012,7 +1132,21 @@
|
||||
const body = document.createElement('div');
|
||||
body.className = 'card-body';
|
||||
|
||||
const sortedItems = [...day.items].sort((a, b) => a.name.localeCompare(b.name));
|
||||
const todayDateStr = new Date().toISOString().split('T')[0];
|
||||
const isToday = day.date === todayDateStr;
|
||||
|
||||
const sortedItems = [...day.items].sort((a, b) => {
|
||||
if (isToday) {
|
||||
const aId = a.articleId || parseInt(a.id.split('_')[1]);
|
||||
const bId = b.articleId || parseInt(b.id.split('_')[1]);
|
||||
const aOrdered = orderMap.has(`${day.date}_${aId}`);
|
||||
const bOrdered = orderMap.has(`${day.date}_${bId}`);
|
||||
|
||||
if (aOrdered && !bOrdered) return -1;
|
||||
if (!aOrdered && bOrdered) return 1;
|
||||
}
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
|
||||
sortedItems.forEach(item => {
|
||||
const itemEl = document.createElement('div');
|
||||
@@ -1138,6 +1272,50 @@
|
||||
return card;
|
||||
}
|
||||
|
||||
// === Version Check ===
|
||||
async function checkForUpdates() {
|
||||
const CurrentVersion = '{{VERSION}}'; // Injected by build script
|
||||
const VersionUrl = 'https://raw.githubusercontent.com/TauNeutrino/kantine-overview/main/version.txt';
|
||||
// Use htmlpreview.github.io to render the HTML directly in browser
|
||||
const InstallerUrl = 'https://htmlpreview.github.io/?https://github.com/TauNeutrino/kantine-overview/blob/main/dist/install.html';
|
||||
|
||||
console.log(`[Kantine] Checking for updates... (Current: ${CurrentVersion})`);
|
||||
|
||||
try {
|
||||
const response = await fetch(VersionUrl, { cache: 'no-cache' });
|
||||
if (!response.ok) return;
|
||||
|
||||
const remoteVersion = (await response.text()).trim();
|
||||
console.log(`[Kantine] Remote version: ${remoteVersion}`);
|
||||
|
||||
if (remoteVersion && remoteVersion !== CurrentVersion) {
|
||||
// Simple semantic version check or string inequality
|
||||
// Assuming format v1.0.0
|
||||
showUpdateIcon(remoteVersion, InstallerUrl);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[Kantine] Version check failed:', error);
|
||||
}
|
||||
}
|
||||
|
||||
function showUpdateIcon(newVersion, url) {
|
||||
const headerTitle = document.querySelector('.header-left h1');
|
||||
if (!headerTitle) return;
|
||||
|
||||
// Check if already added
|
||||
if (headerTitle.querySelector('.update-icon')) return;
|
||||
|
||||
const icon = document.createElement('a');
|
||||
icon.className = 'update-icon';
|
||||
icon.href = url;
|
||||
icon.target = '_blank';
|
||||
icon.innerHTML = '🆕'; // User requested icon
|
||||
icon.title = `Neue Version verfügbar (${newVersion}). Klick für download`;
|
||||
|
||||
headerTitle.appendChild(icon);
|
||||
showToast(`Update verfügbar: ${newVersion}`, 'info');
|
||||
}
|
||||
|
||||
// === Helpers ===
|
||||
function getISOWeek(date) {
|
||||
const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()));
|
||||
@@ -1183,5 +1361,61 @@
|
||||
startPolling();
|
||||
}
|
||||
|
||||
// Check for updates
|
||||
checkForUpdates();
|
||||
|
||||
console.log('Kantine Wrapper loaded ✅');
|
||||
})();
|
||||
|
||||
// === Error Modal ===
|
||||
function showErrorModal(title, htmlContent, btnText, url) {
|
||||
const modalId = 'error-modal';
|
||||
let modal = document.getElementById(modalId);
|
||||
if (modal) modal.remove();
|
||||
|
||||
modal = document.createElement('div');
|
||||
modal.id = modalId;
|
||||
modal.className = 'modal hidden';
|
||||
modal.innerHTML = `
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h2 style="color: var(--error-color); display: flex; align-items: center; gap: 10px;">
|
||||
<span class="material-icons-round">signal_wifi_off</span>
|
||||
${title}
|
||||
</h2>
|
||||
</div>
|
||||
<div style="padding: 20px;">
|
||||
<p style="margin-bottom: 15px; color: var(--text-primary);">${htmlContent}</p>
|
||||
<div style="margin-top: 20px; display: flex; justify-content: center;">
|
||||
<button id="btn-error-redirect" style="
|
||||
background-color: var(--accent-color);
|
||||
color: white;
|
||||
padding: 12px 24px;
|
||||
border-radius: 8px;
|
||||
border: none;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
transition: transform 0.1s;
|
||||
">
|
||||
${btnText}
|
||||
<span class="material-icons-round">open_in_new</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(modal);
|
||||
|
||||
document.getElementById('btn-error-redirect').addEventListener('click', () => {
|
||||
window.location.href = url;
|
||||
});
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
modal.classList.remove('hidden');
|
||||
});
|
||||
}
|
||||
|
||||
1148
public/app.js
1148
public/app.js
File diff suppressed because it is too large
Load Diff
@@ -1,125 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Kantine Weekly Menu</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||
<link href="https://fonts.googleapis.com/icon?family=Material+Icons+Round" rel="stylesheet">
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<header class="app-header">
|
||||
<div class="header-content">
|
||||
<div class="brand">
|
||||
<span class="material-icons-round logo-icon">restaurant_menu</span>
|
||||
<div class="brand-text">
|
||||
<h1>Kantinen Übersicht</h1>
|
||||
<div id="last-updated-subtitle" class="subtitle"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="header-week-info" class="header-week-info">
|
||||
<!-- Populated by JavaScript -->
|
||||
</div>
|
||||
<div id="weekly-cost-display" class="weekly-cost hidden">
|
||||
<!-- Populated by JS -->
|
||||
</div>
|
||||
<div class="controls">
|
||||
<button id="btn-refresh" class="icon-btn" aria-label="Menüdaten aktualisieren"
|
||||
title="Menüdaten aktualisieren">
|
||||
<span class="material-icons-round">refresh</span>
|
||||
</button>
|
||||
<div class="nav-group">
|
||||
<button id="btn-this-week" class="nav-btn active">Diese Woche</button>
|
||||
<button id="btn-next-week" class="nav-btn">Nächste Woche</button>
|
||||
</div>
|
||||
<button id="theme-toggle" class="icon-btn" aria-label="Toggle Theme">
|
||||
<span class="material-icons-round theme-icon">light_mode</span>
|
||||
</button>
|
||||
<button id="btn-login-open" class="user-badge-btn icon-btn-small">
|
||||
<span class="material-icons-round">login</span>
|
||||
<span>Anmelden</span>
|
||||
</button>
|
||||
<div id="user-info" class="user-badge hidden">
|
||||
<span class="material-icons-round">person</span>
|
||||
<span id="user-id-display"></span>
|
||||
<button id="btn-logout" class="icon-btn-small" aria-label="Logout">
|
||||
<span class="material-icons-round">logout</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div id="login-modal" class="modal hidden">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h2>Login</h2>
|
||||
<button id="btn-login-close" class="icon-btn" aria-label="Close">
|
||||
<span class="material-icons-round">close</span>
|
||||
</button>
|
||||
</div>
|
||||
<form id="login-form">
|
||||
<div class="form-group">
|
||||
<label for="employee-id">Mitarbeiternummer</label>
|
||||
<input type="text" id="employee-id" name="employee-id" placeholder="z.B. 2041" required>
|
||||
<small class="help-text">Deine offizielle Knapp Mitarbeiternummer.</small>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="password">Passwort</label>
|
||||
<input type="password" id="password" name="password" placeholder="Bessa Passwort" required>
|
||||
<small class="help-text">Das Passwort für deinen Bessa Account.</small>
|
||||
</div>
|
||||
<div id="login-error" class="error-msg hidden"></div>
|
||||
<div class="modal-actions">
|
||||
<button type="submit" class="btn-primary wide">Einloggen</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="progress-modal" class="modal hidden">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h2>Menüdaten aktualisieren</h2>
|
||||
</div>
|
||||
<div class="modal-body" style="padding: 20px;">
|
||||
<div class="progress-container">
|
||||
<div class="progress-bar">
|
||||
<div id="progress-fill" class="progress-fill"></div>
|
||||
</div>
|
||||
<div id="progress-percent" class="progress-percent">0%</div>
|
||||
</div>
|
||||
<p id="progress-message" class="progress-message">Initialisierung...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<main class="container">
|
||||
<div id="last-updated-banner" class="banner hidden">
|
||||
<span class="material-icons-round">update</span>
|
||||
<span id="last-updated-text">Gerade aktualisiert</span>
|
||||
</div>
|
||||
|
||||
<div id="loading" class="loading-state">
|
||||
<div class="spinner"></div>
|
||||
<p>Lade Menüdaten...</p>
|
||||
</div>
|
||||
|
||||
<div id="menu-container" class="menu-grid">
|
||||
<!-- Dynamic Content -->
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer class="app-footer">
|
||||
<p>Bessa Knapp-Kantine Wrapper • <span id="current-year">2026</span></p>
|
||||
</footer>
|
||||
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -1104,4 +1104,94 @@ body {
|
||||
100% {
|
||||
box-shadow: 0 0 10px rgba(16, 185, 129, 0.3);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Day Header Badges */
|
||||
.day-header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.menu-code-badge {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
color: #8b5cf6;
|
||||
/* Violet 500 */
|
||||
background-color: rgba(139, 92, 246, 0.15);
|
||||
border: 1px solid rgba(139, 92, 246, 0.3);
|
||||
padding: 2px 6px;
|
||||
border-radius: 6px;
|
||||
line-height: normal;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
/* Detailed Badge Colors */
|
||||
.nav-badge.badge-violet {
|
||||
background-color: #8b5cf6;
|
||||
}
|
||||
|
||||
.nav-badge.badge-green {
|
||||
background-color: var(--success-color);
|
||||
}
|
||||
|
||||
.nav-badge.badge-red {
|
||||
background-color: var(--error-color);
|
||||
}
|
||||
|
||||
.nav-badge.badge-blue {
|
||||
background-color: var(--accent-color);
|
||||
}
|
||||
|
||||
/* Day Header Status Colors (User Request) */
|
||||
.card-header.header-violet {
|
||||
background-color: rgba(139, 92, 246, 0.15);
|
||||
border-bottom: 2px solid #8b5cf6;
|
||||
}
|
||||
|
||||
.card-header.header-green {
|
||||
background-color: rgba(16, 185, 129, 0.15);
|
||||
border-bottom: 2px solid var(--success-color);
|
||||
}
|
||||
|
||||
.card-header.header-red {
|
||||
background-color: rgba(239, 68, 68, 0.15);
|
||||
border-bottom: 2px solid var(--error-color);
|
||||
}
|
||||
|
||||
.card-header.header-violet .day-name,
|
||||
.card-header.header-green .day-name,
|
||||
.card-header.header-red .day-name {
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
/* Ensure text remains standard color */
|
||||
}
|
||||
/* Update Icon */
|
||||
.update-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-left: 8px;
|
||||
background-color: rgba(16, 185, 129, 0.2); /* Green tint */
|
||||
color: var(--success-color);
|
||||
border-radius: 50%;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
transition: all 0.2s;
|
||||
text-decoration: none;
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
.update-icon:hover {
|
||||
background-color: var(--success-color);
|
||||
color: white;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% { box-shadow: 0 0 0 0 rgba(16, 185, 129, 0.4); }
|
||||
70% { box-shadow: 0 0 0 6px rgba(16, 185, 129, 0); }
|
||||
100% { box-shadow: 0 0 0 0 rgba(16, 185, 129, 0); }
|
||||
}
|
||||
1
version.txt
Executable file
1
version.txt
Executable file
@@ -0,0 +1 @@
|
||||
v1.0.3
|
||||
Reference in New Issue
Block a user