Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f5b3635773 | ||
|
|
bff8669cd7 | ||
|
|
008462e304 | ||
|
|
9237e911d2 | ||
|
|
5bb0e01136 | ||
|
|
f19827ae91 | ||
|
|
4c55e34bc1 | ||
|
|
08ee2a2d0f | ||
|
|
314728f6d0 | ||
|
|
ea4e0d151f | ||
|
|
b928b90728 | ||
|
|
9b1f0e2fd3 | ||
|
|
05bc06660c | ||
|
|
20957c3582 | ||
| fe86e68aca | |||
| 4dbd6c930f | |||
| ad4cfaf4ec | |||
| 441198dd8d | |||
| 13a0ae3a93 | |||
| 1f8ebff9fe | |||
| 8e299c82ca | |||
| 5ae43e92de |
@@ -26,16 +26,15 @@ trigger: always_on
|
||||
- **Interaction**: Be proactive, concise, and helpful. Focus on code value.
|
||||
|
||||
## 4. Development Standards
|
||||
**Tech Stack:**
|
||||
- **Container**: Docker-based application.
|
||||
- **Config**: Configurable port.
|
||||
|
||||
**Coding Style:**
|
||||
- **Typing**: Strict typing where applicable.
|
||||
- **Comments**: Concise, English.
|
||||
- **Frontend/UX**:
|
||||
- Priority on Usability.
|
||||
- **MANDATORY**: Tooltips/Help texts for all interactions.
|
||||
- **Versioning**:
|
||||
- version.txt has to be increased for any implemented features or fixed bugs)
|
||||
- a change summary has to be documented in changelog.md
|
||||
|
||||
## 5. Agentic Workflow & Artifacts
|
||||
**Core Philosophy**: Plan first, act second.
|
||||
@@ -48,3 +47,10 @@ trigger: always_on
|
||||
## 6. Workspace Scopes
|
||||
- **Browser**: Allowed for documentation and safe browsing. No automated logins without permission.
|
||||
- **Terminal**: No `rm -rf`. Run tests (`pytest` etc.) after logic changes.
|
||||
|
||||
## 7. Requirements-Konsistenz 📋
|
||||
Alle umgesetzten Anforderungen müssen mit `REQUIREMENTS.md` übereinstimmen.
|
||||
1. **Vor der Umsetzung prüfen**: Passt die neue Anforderung zu den bestehenden Requirements?
|
||||
2. **Fehlende Requirements**: Wenn eine umzusetzende Anforderung in `REQUIREMENTS.md` fehlt, muss dies im **Implementation Plan** explizit dokumentiert und freigegeben werden.
|
||||
3. **Widersprüche**: Wenn eine neue Anforderung im Widerspruch zu bestehenden Requirements steht, muss dies im **Implementation Plan** als ⚠️ Warning hervorgehoben werden.
|
||||
4. **Nach Freigabe**: Bei Genehmigung des Implementation Plans muss `REQUIREMENTS.md` entsprechend erweitert oder korrigiert werden (neue ID, Version, Beschreibung). Obsolet gewordene Requirements werden nicht aus `REQUIREMENTS.md` gelöscht sondern nur als durchgestrichen markiert und ein Kommentar mit dem Grund hinzugefügt.
|
||||
@@ -1,4 +1,4 @@
|
||||
# Kantine Wrapper Bookmarklet (v1.2.0)
|
||||
# Kantine Wrapper Bookmarklet
|
||||
|
||||
Ein intelligentes Bookmarklet für die Mitarbeiter-Kantine der Bessa App. Dieses Skript erweitert die Standardansicht um eine **Wochenübersicht**, Kostenkontrolle und verbesserte Usability.
|
||||
|
||||
|
||||
118
REQUIREMENTS.md
118
REQUIREMENTS.md
@@ -2,55 +2,91 @@
|
||||
|
||||
## 1. Einleitung
|
||||
### 1.1 Zweck des Systems
|
||||
Das System dient als Wrapper und Erweiterung für das Bessa Web-Shop-Portal der Knapp-Kantine (https://web.bessa.app/knapp-kantine). Es ermöglicht den Benutzern, Menüpläne einzusehen, Buchungen automatisiert vorzunehmen und Menüdaten persistent für den öffentlichen Zugriff bereitzustellen, ohne dass jeder Betrachter eigene Zugangsdaten benötigt.
|
||||
Das System dient als Erweiterung für das Bessa Web-Shop-Portal der Knapp-Kantine (https://web.bessa.app/knapp-kantine). Es verbessert die Benutzererfahrung durch eine übersichtliche Wochenansicht, vereinfachte Bestellvorgänge, Kostentransparenz und proaktive Benachrichtigungen.
|
||||
|
||||
### 1.2 High-Level-Scope
|
||||
Das System umfasst einen automatisierten Scraper, eine API zur Datenbereitstellung, einen persistenten Dateispeicher für erfasste Menüs und ein Frontend-Dashboard zur Visualisierung der Kantinen-Wochenpläne.
|
||||
Das System umfasst die Darstellung von Menüplänen in einer Wochenübersicht, die Verwaltung von Bestellungen und Stornierungen, ein Benachrichtigungssystem für Verfügbarkeitsänderungen, personalisierte Menü-Highlights sowie ein automatisches Update-Management.
|
||||
|
||||
## 2. Funktionale Anforderungen
|
||||
|
||||
| ID | Anforderung (Satzschablone nach Chris Rupp) | Priorität |
|
||||
|:---|:---|:---|
|
||||
| **Auth & Sessions** | | |
|
||||
| FR-001 | Das System muss dem Benutzer die Möglichkeit bieten, sich mit Mitarbeiternummer und Passwort am Bessa-Backend anzumelden. | Hoch |
|
||||
| FR-002 | Sobald ein Benutzer erfolgreich angemeldet ist, muss das System eine Session-ID erzeugen und die resultierenden Cookies verschlüsselt für 2 Stunden vorhalten. | Hoch |
|
||||
| FR-003 | Das System muss die Zugangsdaten (Mitarbeiternummer/Passwort) unmittelbar nach der Verwendung durch den Scraper verwerfen und darf diese nicht dauerhaft speichern. | Hoch |
|
||||
| **Scraper & Datenextraktion** | | |
|
||||
| FR-004 | Das System muss in der Lage sein, den wöchentlichen Menüplan (Montag bis Freitag) automatisiert von der Bessa-Webseite zu extrahieren. | Hoch |
|
||||
| FR-005 | Wenn ein Tag bereits eine Buchung enthält, muss das System den Navigationspfad so anpassen, dass dennoch alle verfügbaren Menüoptionen (M1F, M2F, etc.) extrahiert werden können. | Mittel |
|
||||
| FR-006 | Das System muss für jedes extrahierte Gericht den Namen, die Beschreibung, den Preis und den Status (verfügbar/nicht verfügbar/ bestellt) erfassen. | Hoch |
|
||||
| **Datenhaltung & Zugriff** | | |
|
||||
| FR-007 | Das System muss erfolgreich gescrapte Menüpläne in einer persistenten JSON-Datei (`data/menus.json`) speichern. | Hoch |
|
||||
| FR-008 | Das System muss unauthentifizierten Benutzern den Zugriff auf bereits im Speicher befindliche Menüdaten ermöglichen (Public Access). | Mittel |
|
||||
| FR-009 | Falls keine Daten im persistenten Speicher vorhanden sind, muss das System einen nicht authentifizierten Benutzer die Möglichkeit bieten sich anzumelden, um den Speicher zu initialisieren. | Hoch |
|
||||
| **Dashboard & UI** | | |
|
||||
| FR-010 | Das System muss dem Benutzer eine intuitive Wochenansicht des Menüplans im Browser darstellen. | Mittel |
|
||||
| FR-011 | Wenn ein Scraper-Vorgang aktiv ist, muss das System den Status (Fortschritt, aktuelle Aktion) in Echtzeit visualisieren. | Niedrig |
|
||||
| **Buchungsfunktion** | | |
|
||||
| FR-012 | Wenn der Benutzer authentifiziert ist, soll das System eine Bestellung für ein Menü ermöglichen. | Mittel |
|
||||
| FR-013 | Wenn der Benutzer authentifiziert ist, soll das System ein bereits bestelltes Menü zu stornieren. | Mittel |
|
||||
| **Menu Flagging & Notifications** | | |
|
||||
| FR-014 | Das System muss authentifizierten Benutzern ermöglichen, nicht bestellbare Menüs (deren Cutoff noch nicht erreicht ist) zu markieren ("flaggen"). | Mittel |
|
||||
| FR-015 | Das System soll die Statusprüfung für geflaggte Menüs auf verbundene Clients verteilen (Distributed Polling), um die Last zu minimieren. Der Server orchestriert, welcher Client wann welche Menüs prüft. | Mittel |
|
||||
| FR-016 | Bei Statusänderung auf "verfügbar" muss das System den Benutzer benachrichtigen (Systembenachrichtigung). | Mittel |
|
||||
| FR-017 | Geflaggte und ausverkaufte Menüs müssen im UI mit einem gelben Glow hervorgehoben werden. | Mittel |
|
||||
| FR-018 | Geflaggte und verfügbare Menüs müssen im UI mit einem grünen Glow hervorgehoben werden. | Mittel |
|
||||
| FR-019 | Wenn die Bestell-Cutoff-Zeit erreicht ist, muss das System das Flag automatisch entfernen. | Mittel |
|
||||
| ID | Anforderung (Satzschablone nach Chris Rupp) | Priorität | Seit |
|
||||
|:---|:---|:---|:---|
|
||||
| **Authentifizierung & Zugang** | | | |
|
||||
| FR-001 | Das System muss dem Benutzer die Möglichkeit bieten, sich mit Mitarbeiternummer und Passwort anzumelden. | Hoch | v1.0.1 |
|
||||
| FR-002 | Das System muss eine bestehende Bessa-Session erkennen und automatisch wiederverwenden, um eine erneute Anmeldung zu vermeiden. | Hoch | v1.0.1 |
|
||||
| FR-003 | Das System darf keine Zugangsdaten dauerhaft speichern. Die Authentifizierung muss sitzungsbasiert sein. | Hoch | v1.0.1 |
|
||||
| FR-004 | Dem Benutzer muss angezeigt werden, ob und als wer er angemeldet ist (Vorname, Name oder ID). | Mittel | v1.0.1 |
|
||||
| FR-005 | Nicht authentifizierte Benutzer müssen die Menüdaten einsehen können (eingeschränkter Lesezugriff). | Mittel | v1.0.1 |
|
||||
| FR-006 | Das System muss eine explizite Logout-Funktion bereitstellen, die alle sitzungsbezogenen Daten entfernt. | Mittel | v1.0.1 |
|
||||
| **Menüanzeige** | | | |
|
||||
| FR-010 | Das System muss dem Benutzer alle verfügbaren Tagesmenüs einer Woche gleichzeitig in einer Übersicht darstellen. | Hoch | v1.0.1 |
|
||||
| FR-011 | Das System muss dem Benutzer die Navigation zwischen der aktuellen und der kommenden Woche ermöglichen. | Mittel | v1.0.1 |
|
||||
| FR-012 | Für jedes Gericht müssen Name, Beschreibung, Preis und Verfügbarkeitsstatus angezeigt werden. | Hoch | v1.0.1 |
|
||||
| FR-013 | Bereits bestellte Menüs müssen visuell von nicht bestellten unterscheidbar sein (farbliche Markierung, Badge). | Mittel | v1.0.1 |
|
||||
| FR-014 | Die Tageskarten-Header müssen den Bestellstatus des Tages farblich signalisieren (bestellt / bestellbar / nicht bestellbar). | Niedrig | v1.0.1 |
|
||||
| FR-015 | Bestellte Menü-Codes (z.B. M1, M2) müssen als Badges im Tageskarten-Header angezeigt werden. | Niedrig | v1.0.1 |
|
||||
| FR-016 | Am heutigen Tag müssen bestellte Menüs an erster Stelle sortiert werden. | Niedrig | v1.0.1 |
|
||||
| FR-017 | Wenn keine Menüdaten für eine Woche vorliegen, muss ein aussagekräftiger Leertext angezeigt werden. | Niedrig | v1.0.1 |
|
||||
| **Daten-Aktualisierung** | | | |
|
||||
| FR-020 | Wenn Menüdaten geladen werden, muss der Fortschritt dem Benutzer in Echtzeit angezeigt werden (Fortschrittsbalken, Statustext). | Niedrig | v1.0.1 |
|
||||
| FR-021 | Das System muss bereits geladene Menüdaten zwischenspeichern, um bei erneutem Aufruf sofort eine Übersicht anzeigen zu können. | Mittel | v1.0.1 |
|
||||
| FR-022 | Das System muss dem Benutzer die Möglichkeit bieten, die Menüdaten manuell neu zu laden. | Niedrig | v1.0.1 |
|
||||
| FR-023 | Der Zeitpunkt der letzten Aktualisierung muss für den Benutzer sichtbar sein. | Niedrig | v1.0.1 |
|
||||
| FR-024 | Das System darf beim Start keinen automatischen API-Refresh durchführen, wenn der Cache frisch (< 1 Stunde) und Daten für die aktuelle Kalenderwoche vorhanden sind. | Mittel | v1.3.1 |
|
||||
| **Bestellfunktion** | | | |
|
||||
| FR-030 | Authentifizierte Benutzer müssen ein verfügbares Menü direkt aus der Übersicht bestellen können. | Hoch | v1.0.1 |
|
||||
| FR-031 | Authentifizierte Benutzer müssen eine bestehende Bestellung direkt aus der Übersicht stornieren können. | Hoch | v1.0.1 |
|
||||
| FR-032 | Nach Bestellschluss (Cutoff-Zeit) dürfen keine neuen Bestellungen oder Stornierungen für diesen Tag möglich sein. | Hoch | v1.0.1 |
|
||||
| FR-033 | Es muss möglich sein, dasselbe Menü mehrfach zu bestellen. Bei Mehrfachbestellungen muss die Anzahl angezeigt werden. | Niedrig | v1.0.1 |
|
||||
| **Kostentransparenz & Bestellhistorie** | | | |
|
||||
| FR-040 | Das System muss die Gesamtkosten aller Bestellungen einer Woche automatisch berechnen und anzeigen. | Mittel | v1.1.0 |
|
||||
| FR-041 | Das System muss dem Benutzer eine paginierte oder vollständige Bestellhistorie (gruppiert nach Monat und KW) mit Fortschrittsanzeige auf Abruf in einem Modal bereitstellen. | Mittel | v1.4.0 |
|
||||
| **Bestell-Countdown** | | | |
|
||||
| FR-050 | Das System muss vor Bestellschluss einen visuell hervorgehobenen Countdown anzeigen. | Mittel | v1.1.0 |
|
||||
| **Menü-Flagging & Benachrichtigungen** | | | |
|
||||
| FR-060 | Authentifizierte Benutzer müssen ausverkaufte Menüs zur Beobachtung markieren können ("flaggen"). | Mittel | v1.0.1 |
|
||||
| FR-061 | Das System muss geflaggte Menüs periodisch auf Verfügbarkeitsänderungen prüfen. | Mittel | v1.0.1 |
|
||||
| FR-062 | Bei Statusänderung eines geflaggten Menüs auf „verfügbar" muss der Benutzer benachrichtigt werden (In-App + Systembenachrichtigung). | Mittel | v1.0.1 |
|
||||
| FR-063 | Geflaggte Menüs müssen im UI visuell hervorgehoben werden (gelber Glow bei ausverkauft, grüner Glow bei verfügbar). | Mittel | v1.0.1 |
|
||||
| FR-064 | Wenn die Bestell-Cutoff-Zeit erreicht ist, muss das System ein Flag automatisch entfernen. | Mittel | v1.0.1 |
|
||||
| **Personalisierung: Smart Highlights** | | | |
|
||||
| FR-070 | Benutzer müssen Schlagwörter (Tags) definieren können, nach denen Menüs automatisch visuell hervorgehoben werden. | Mittel | v1.1.0 |
|
||||
| FR-071 | Die Hervorhebung muss anhand von Menüname und Beschreibung erfolgen (Substring-Matching, case-insensitive). | Niedrig | v1.1.0 |
|
||||
| FR-072 | Hervorgehobene Menüs müssen ein Tag-Badge mit dem matchenden Schlagwort anzeigen. | Niedrig | v1.2.4 |
|
||||
| FR-073 | Die Nächste-Woche-Navigation muss die Anzahl der dort gefundenen Highlights als Badge anzeigen. | Niedrig | v1.2.5 |
|
||||
| **Darstellung & Theming** | | | |
|
||||
| FR-080 | Das System muss einen hellen und einen dunklen Darstellungsmodus (Light/Dark Theme) unterstützen. | Niedrig | v1.0.1 |
|
||||
| FR-081 | Die Theme-Präferenz des Benutzers muss persistent gespeichert werden. | 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 |
|
||||
| **Benutzer-Feedback** | | | |
|
||||
| FR-090 | Alle benutzerrelevanten Aktionen (Bestellung, Stornierung, Fehler) müssen durch nicht-blockierende Benachrichtigungen (Toasts) bestätigt werden. | Mittel | v1.0.1 |
|
||||
| FR-091 | Bei einem Verbindungsfehler muss ein Fehlerdialog mit Fallback-Link zur Originalseite angezeigt werden. | Mittel | v1.0.1 |
|
||||
| **Nächste-Woche-Badge** | | | |
|
||||
| FR-100 | Die Navigation zur nächsten Woche muss ein Badge anzeigen, das den Überblick über den Bestellstatus der kommenden Woche visualisiert (bestellt / bestellbar / gesamt). | Niedrig | v1.0.1 |
|
||||
| **Update-Management** | | | |
|
||||
| FR-110 | Das System muss periodisch prüfen, ob eine neuere Version verfügbar ist. | Niedrig | v1.0.3 |
|
||||
| FR-111 | Bei Verfügbarkeit einer neueren Version muss ein diskreter Indikator im Header angezeigt werden. | Niedrig | v1.0.3 |
|
||||
| FR-112 | Benutzer müssen eine Versionsliste mit Installationslinks einsehen können (Versionsmenü). | Niedrig | v1.3.0 |
|
||||
| FR-113 | Es muss möglich sein, zu einer älteren Version zurückzukehren (Downgrade). | Niedrig | v1.3.0 |
|
||||
| FR-114 | Ein Dev-Mode muss es ermöglichen, zwischen stabilen Releases und Entwicklungs-Tags umzuschalten. | Niedrig | v1.3.0 |
|
||||
|
||||
## 3. Nicht-funktionale Anforderungen
|
||||
|
||||
| Kategorie (ISO 25010) | ID | Anforderung | Zielwert/Metrik |
|
||||
|:---|:---|:---|:---|
|
||||
| **Performance** | NFR-001 | Antwortzeit der API für gecachte Daten | < 200 ms (95. Perzentil) |
|
||||
| **Performance** | NFR-007 | Polling-Effizienz & Token-Nutzung | Kein System-Token verfügbar. Polling muss über authentifizierte User-Clients erfolgen. Der Server muss die Anfragen so verteilen, dass redundante Abfragen vermieden werden. |
|
||||
| **Performance** | NFR-002 | Dauer eines vollständigen Scrape-Vorgangs (exkl. Navigation) | < 30 Sekunden pro Woche |
|
||||
| **Sicherheit** | NFR-003 | Speicherung von Zugangsdaten | 0 (keine dauerhafte Speicherung von Passwörtern) |
|
||||
| **Sicherheit** | NFR-004 | Session-Sicherheit | HttpOnly Cookies für die Kommunikation zwischen Frontend und Backend |
|
||||
| **Wartbarkeit** | NFR-005 | Testabdeckung der Scraper-Logik | Alle Kern-Selektoren müssen durch Debug-HTML-Dumps verifizierbar sein |
|
||||
| **Benutzbarkeit** | NFR-006 | Mobile Responsiveness | Dashboard muss auf Viewports ab 320px Breite fehlerfrei nutzbar sein |
|
||||
| **Performance** | NFR-001 | Die Darstellung bereits gecachter Daten muss ohne spürbare Verzögerung erfolgen. | < 200 ms (UI-Rendering) |
|
||||
| **Performance** | NFR-002 | Das Polling für geflaggte Menüs darf die reguläre Nutzung nicht beeinträchtigen. | Intervall ≥ 5 Minuten |
|
||||
| **Sicherheit** | NFR-003 | Es dürfen keine Zugangsdaten dauerhaft gespeichert werden. | 0 (keine persistente Speicherung von Passwörtern) |
|
||||
| **Sicherheit** | NFR-004 | Auth-Tokens müssen sitzungsbasiert gespeichert werden und bei Schließen des Browsers verfallen. | sessionStorage |
|
||||
| **Benutzbarkeit** | NFR-005 | Die Oberfläche muss auf mobilen Geräten fehlerfrei nutzbar sein. | Viewports ab 320px Breite |
|
||||
| **Benutzbarkeit** | NFR-006 | Alle interaktiven Elemente müssen Tooltips oder Hilfetexte bieten. | 100% Coverage |
|
||||
| **Benutzbarkeit** | NFR-007 | Die Benutzeroberfläche muss vollständig in deutscher Sprache sein. | Vollständige Lokalisierung |
|
||||
| **Wartbarkeit** | NFR-008 | Die Build-Artefakte müssen durch automatisierte Tests validiert werden. | Build-Tests + Logik-Tests |
|
||||
|
||||
## 4. Technische Randbedingungen
|
||||
* **Architektur**: Node.js Backend mit Express (API + Static Serving) und Vanilla JS Frontend.
|
||||
* **Engine**: Direkte API-Integration (Reverse Engineering der Bessa API) für maximale Performance und Zuverlässigkeit.
|
||||
* **Datenspeicher**: Dateibasierter JSON-Store für persistente Daten + In-Memory Caching.
|
||||
* **Schnittstellen**: REST API (`/api/bookings`, `/api/status`, `/api/order`).
|
||||
* **Runtime**: Node.js Umgebung, Docker-ready.
|
||||
* **Deployment**: Das System wird als Bookmarklet ausgeliefert, das auf der Bessa-Webseite ausgeführt wird.
|
||||
* **Datenquelle**: Direkte Integration mit der Bessa REST-API (`api.bessa.app/v1`).
|
||||
* **Datenhaltung**: Clientseitig via `localStorage` (Menü-Cache, Flags, Highlights, Theme) und `sessionStorage` (Auth-Token).
|
||||
* **Build**: Bash-basiertes Build-Script, das Bookmarklet-URL, Standalone-HTML und Installer-Seite generiert.
|
||||
* **Versionierung**: SemVer, verwaltet über GitHub Releases/Tags.
|
||||
* **Tests**: Python-basierte Build-Tests + Node.js-basierte Logik-Tests.
|
||||
|
||||
1
bessa_orders_debug.json
Executable file
1
bessa_orders_debug.json
Executable file
@@ -0,0 +1 @@
|
||||
{"next":null,"previous":null,"results":[]}
|
||||
@@ -25,8 +25,9 @@ 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")
|
||||
|
||||
# Inject version into JS
|
||||
JS_CONTENT=$(cat "$JS_FILE" | sed "s/{{VERSION}}/$VERSION/g")
|
||||
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
|
||||
@@ -128,7 +129,7 @@ cat > "$DIST_DIR/install.html" << INSTALLEOF
|
||||
<!-- 1. BUTTON (Top Priority) -->
|
||||
<div class="card" style="text-align: center; border: 2px solid #029AA8;">
|
||||
<p style="margin-bottom:15px; font-weight:bold;">👇 Diesen Button in die Lesezeichen-Leiste ziehen:</p>
|
||||
<p><a class="bookmarklet" id="bookmarklet-link" href="#">⏳ Wird generiert...</a></p>
|
||||
<p><a class="bookmarklet" id="bookmarklet-link" href="#" onclick="event.preventDefault(); return false;" title="Nicht klicken! Ziehe mich in deine Lesezeichen-Leiste.">⏳ Wird generiert...</a></p>
|
||||
</div>
|
||||
|
||||
<!-- 2. INSTRUCTIONS -->
|
||||
@@ -265,3 +266,24 @@ if [ $TEST_EXIT -ne 0 ]; then
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ All build tests passed."
|
||||
|
||||
# === 5. Commit, tag, and push ===
|
||||
echo ""
|
||||
echo "=== Committing & Pushing ==="
|
||||
git add -A
|
||||
git commit -m "dist files for $VERSION built" --allow-empty
|
||||
|
||||
echo ""
|
||||
echo "=== Tagging $VERSION ==="
|
||||
if git rev-parse "$VERSION" >/dev/null 2>&1; then
|
||||
git tag -f "$VERSION"
|
||||
echo "🔄 Tag $VERSION moved to current commit."
|
||||
else
|
||||
git tag "$VERSION"
|
||||
echo "✅ Created tag: $VERSION"
|
||||
fi
|
||||
|
||||
git push
|
||||
git push origin --force tag "$VERSION"
|
||||
git push github --force tag "$VERSION"
|
||||
echo "✅ Pushed commit + tag $VERSION"
|
||||
|
||||
32
changelog.md
32
changelog.md
@@ -1,3 +1,35 @@
|
||||
## v1.4.1 (2026-02-22)
|
||||
- **UX Verbesserungen**: Bestellhistorie gruppiert nach Jahren und Monaten mittels einklappbarem Akkordeon. Monatssummen integriert und Stati farblich abgehoben (Offen, Abgeschlossen, Storniert).
|
||||
|
||||
## v1.4.0 (2026-02-22)
|
||||
- **Feature**: Bestellhistorie per Knopfdruck abrufbar. Übersichtliche Darstellung, gruppiert nach Monaten und Kalenderwochen, inklusive Stornos. 📜✨
|
||||
|
||||
## v1.3.2 (2026-02-19)
|
||||
- **Fix**: Falsche Anzahl an Highlight-Menüs im "Nächste Woche"-Badge korrigiert (zählte alle Menüs statt nur Highlights). 🐛
|
||||
|
||||
## v1.3.1 (2026-02-17)
|
||||
- **Feature**: Smart Cache – API-Refresh beim Start wird übersprungen wenn Daten für die aktuelle KW vorhanden und Cache < 1h alt ist. ⚡
|
||||
|
||||
## v1.3.0 (2026-02-16)
|
||||
- **Feature**: GitHub Release Management 📦
|
||||
- Version-Menü: Klick auf Versionsnummer zeigt alle verfügbaren Versionen
|
||||
- Dev-Mode Toggle: Zwischen Releases (stabil) und Tags (dev) wechseln
|
||||
- Downgrade-Support: Jede Version hat einen eigenen Installer-Link
|
||||
- Update-Check nutzt jetzt die GitHub API statt `version.txt`
|
||||
- GitHub PAT für höheres API-Rate-Limit (5000/h)
|
||||
- SemVer-Check: Update-Icon nur bei wirklich neuerer Version
|
||||
|
||||
## v1.2.9 (2026-02-16)
|
||||
|
||||
## v1.2.8 (2026-02-16)
|
||||
- **Debug**: Weiteres Logging (Fetch-Status, Start-Log) zur Fehlersuche. 🔎
|
||||
|
||||
## v1.2.7 (2026-02-16)
|
||||
- **Debug**: Verbose Logging für Update-Check eingebaut. 🐞
|
||||
|
||||
## v1.2.6 (2026-02-16)
|
||||
- **Test**: Version Bump zum Testen der Live-Update-Erkennung. 🧪
|
||||
|
||||
## v1.2.5 (2026-02-16)
|
||||
- **Refactor**: Update-Erkennung komplett überarbeitet (stündlicher Check, diskretes 🆕 Icon im Header, kein Banner mehr). 🔄
|
||||
- **Cleanup**: Ungenutzter CSS-Code und Netzwerk-Traffic reduziert. 🧹
|
||||
|
||||
10
cors_server.py
Executable file
10
cors_server.py
Executable file
@@ -0,0 +1,10 @@
|
||||
import http.server
|
||||
import socketserver
|
||||
|
||||
class CORSRequestHandler(http.server.SimpleHTTPRequestHandler):
|
||||
def end_headers(self):
|
||||
self.send_header('Access-Control-Allow-Origin', '*')
|
||||
super().end_headers()
|
||||
|
||||
with socketserver.TCPServer(("127.0.0.1", 8080), CORSRequestHandler) as httpd:
|
||||
httpd.serve_forever()
|
||||
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
52
dist/install.html
vendored
52
dist/install.html
vendored
File diff suppressed because one or more lines are too long
835
dist/kantine-standalone.html
vendored
835
dist/kantine-standalone.html
vendored
@@ -448,7 +448,6 @@ body {
|
||||
|
||||
.modal-content {
|
||||
background: var(--bg-card);
|
||||
/* Changed from --surface */
|
||||
width: 90%;
|
||||
max-width: 400px;
|
||||
border-radius: 16px;
|
||||
@@ -457,6 +456,174 @@ body {
|
||||
animation: modalSlide 0.3s ease-out;
|
||||
}
|
||||
|
||||
/* History Modal specific */
|
||||
.history-modal-content {
|
||||
max-width: 600px;
|
||||
max-height: 85vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.history-modal-content .modal-body {
|
||||
overflow-y: auto;
|
||||
padding: 0;
|
||||
/* Padding is handled by inner elements */
|
||||
}
|
||||
|
||||
/* History Styles */
|
||||
.history-year-group {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.history-year-header {
|
||||
background: var(--bg-card);
|
||||
padding: 12px 20px;
|
||||
margin: 0;
|
||||
font-size: 1.2rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
border-bottom: 2px solid var(--border-color);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 12;
|
||||
}
|
||||
|
||||
.history-month-group {
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.history-month-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 14px 20px;
|
||||
margin: 0;
|
||||
font-size: 1.05rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-body);
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.history-month-header:hover {
|
||||
background: var(--border-color);
|
||||
/* Slight hover effect */
|
||||
}
|
||||
|
||||
.history-month-summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
font-size: 0.95rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.history-month-content {
|
||||
display: none;
|
||||
/* Collapsed by default */
|
||||
background: var(--bg-card);
|
||||
}
|
||||
|
||||
.history-month-group.open .history-month-content {
|
||||
display: block;
|
||||
/* Expanded when open class is present */
|
||||
}
|
||||
|
||||
.history-month-group.open .history-month-header .material-icons-round {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.history-month-header .material-icons-round {
|
||||
transition: transform 0.3s;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.history-week-group {
|
||||
padding: 12px 20px;
|
||||
border-bottom: 1px dashed var(--border-color);
|
||||
}
|
||||
|
||||
.history-week-group:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.history-week-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.history-week-summary {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
background: rgba(100, 116, 139, 0.1);
|
||||
padding: 4px 10px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.history-items {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.history-item {
|
||||
display: grid;
|
||||
grid-template-columns: 50px 1fr auto;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 12px;
|
||||
background: var(--bg-body);
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.history-item-date {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.history-item-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.history-item-name {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.history-item-price {
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.history-item-status {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.history-item-cancelled {
|
||||
opacity: 0.5;
|
||||
filter: grayscale(1);
|
||||
}
|
||||
|
||||
.history-item-price-cancelled {
|
||||
text-decoration: line-through;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
@keyframes modalSlide {
|
||||
from {
|
||||
transform: translateY(20px);
|
||||
@@ -1423,6 +1590,128 @@ body {
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 1.1em;
|
||||
color: var(--accent-color);
|
||||
}
|
||||
|
||||
/* === Version Menu === */
|
||||
.version-tag {
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s ease, text-decoration 0.2s ease;
|
||||
}
|
||||
|
||||
.version-tag:hover {
|
||||
opacity: 1 !important;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.version-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
max-height: 350px;
|
||||
overflow-y: auto;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.version-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 10px 14px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 4px;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.version-item:hover {
|
||||
background: rgba(100, 116, 139, 0.08);
|
||||
}
|
||||
|
||||
.version-item.current {
|
||||
background: rgba(2, 154, 168, 0.1);
|
||||
border: 1px solid rgba(2, 154, 168, 0.25);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .version-item:hover {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .version-item.current {
|
||||
background: rgba(96, 165, 250, 0.12);
|
||||
border: 1px solid rgba(96, 165, 250, 0.25);
|
||||
}
|
||||
|
||||
.version-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.badge-current {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--success-color);
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
background: rgba(5, 150, 105, 0.1);
|
||||
}
|
||||
|
||||
.badge-new {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: #029aa8;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
background: rgba(2, 154, 168, 0.1);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .badge-new {
|
||||
color: #60a5fa;
|
||||
background: rgba(96, 165, 250, 0.12);
|
||||
}
|
||||
|
||||
.install-link {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
padding: 4px 12px;
|
||||
border-radius: 6px;
|
||||
background: rgba(2, 154, 168, 0.1);
|
||||
color: #029aa8;
|
||||
text-decoration: none;
|
||||
border: 1px solid rgba(2, 154, 168, 0.25);
|
||||
transition: all 0.2s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.install-link:hover {
|
||||
background: rgba(2, 154, 168, 0.2);
|
||||
border-color: rgba(2, 154, 168, 0.4);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .install-link {
|
||||
color: #60a5fa;
|
||||
background: rgba(96, 165, 250, 0.12);
|
||||
border: 1px solid rgba(96, 165, 250, 0.25);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .install-link:hover {
|
||||
background: rgba(96, 165, 250, 0.2);
|
||||
border-color: rgba(96, 165, 250, 0.4);
|
||||
}
|
||||
|
||||
.dev-toggle {
|
||||
padding: 10px 14px;
|
||||
border-radius: 8px;
|
||||
background: rgba(100, 116, 139, 0.05);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.dev-toggle input[type="checkbox"] {
|
||||
accent-color: #029aa8;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .dev-toggle input[type="checkbox"] {
|
||||
accent-color: #60a5fa;
|
||||
} </style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -1557,8 +1846,31 @@ body {
|
||||
// Orders endpoint
|
||||
if (urlStr.includes('/user/orders/') && (!options || options.method === 'GET' || !options.method)) {
|
||||
console.log('[MOCK] Returning mock orders');
|
||||
// Formatter for history mapping
|
||||
const mappedOrders = mockOrders.map(o => ({
|
||||
id: o.id,
|
||||
date: `${o.date}T10:00:00Z`,
|
||||
order_state: o.status === 'cancelled' ? 9 : 5,
|
||||
total: o.price || '6.50',
|
||||
items: [{
|
||||
article: o.article,
|
||||
name: o.article_name,
|
||||
price: o.price || '6.50',
|
||||
amount: 1
|
||||
}]
|
||||
}));
|
||||
|
||||
// Handle lazy load / pagination if requesting full history
|
||||
if (urlStr.includes('limit=50')) {
|
||||
return Promise.resolve(new Response(JSON.stringify({
|
||||
results: mockOrders
|
||||
count: mappedOrders.length,
|
||||
next: null,
|
||||
results: mappedOrders
|
||||
}), { status: 200, headers: { 'Content-Type': 'application/json' } }));
|
||||
}
|
||||
|
||||
return Promise.resolve(new Response(JSON.stringify({
|
||||
results: mappedOrders
|
||||
}), { status: 200, headers: { 'Content-Type': 'application/json' } }));
|
||||
}
|
||||
|
||||
@@ -1633,6 +1945,11 @@ body {
|
||||
const MENU_ID = 7;
|
||||
const POLL_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
// === GitHub Release Management ===
|
||||
const GITHUB_REPO = 'TauNeutrino/kantine-overview';
|
||||
const GITHUB_API = `https://api.github.com/repos/${GITHUB_REPO}`;
|
||||
const INSTALLER_BASE = `https://htmlpreview.github.io/?https://github.com/${GITHUB_REPO}/blob`;
|
||||
|
||||
// === State ===
|
||||
let allWeeks = [];
|
||||
let currentWeekNumber = getISOWeek(new Date());
|
||||
@@ -1680,7 +1997,7 @@ body {
|
||||
<div class="brand">
|
||||
<span class="material-icons-round logo-icon">restaurant_menu</span>
|
||||
<div class="header-left">
|
||||
<h1>Kantinen Übersicht <small style="font-size: 0.6em; opacity: 0.7; font-weight: 400;">v1.2.5</small></h1>
|
||||
<h1>Kantinen Übersicht <small class="version-tag" style="font-size: 0.6em; opacity: 0.7; font-weight: 400; cursor: pointer;" title="Klick für Versionsmenü">v1.4.1</small></h1>
|
||||
<div id="last-updated-subtitle" class="subtitle"></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1692,6 +2009,9 @@ body {
|
||||
<button id="btn-refresh" class="icon-btn" aria-label="Menüdaten aktualisieren" title="Menüdaten neu laden">
|
||||
<span class="material-icons-round">refresh</span>
|
||||
</button>
|
||||
<button id="btn-history" class="icon-btn" aria-label="Bestellhistorie" title="Bestellhistorie">
|
||||
<span class="material-icons-round">receipt_long</span>
|
||||
</button>
|
||||
<button id="btn-highlights" class="icon-btn" aria-label="Persönliche Highlights verwalten" title="Persönliche Highlights verwalten">
|
||||
<span class="material-icons-round">label</span>
|
||||
</button>
|
||||
@@ -1782,6 +2102,55 @@ body {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="history-modal" class="modal hidden">
|
||||
<div class="modal-content history-modal-content">
|
||||
<div class="modal-header">
|
||||
<h2>Bestellhistorie</h2>
|
||||
<button id="btn-history-close" class="icon-btn" aria-label="Close">
|
||||
<span class="material-icons-round">close</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div id="history-loading" class="hidden">
|
||||
<p id="history-progress-text" style="text-align: center; margin-bottom: 1rem; color: var(--text-secondary);">Lade Historie...</p>
|
||||
<div class="progress-container">
|
||||
<div class="progress-bar">
|
||||
<div id="history-progress-fill" class="progress-fill"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="history-content">
|
||||
<!-- Dynamically populated -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="version-modal" class="modal hidden">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h2>📦 Versionen</h2>
|
||||
<button id="btn-version-close" class="icon-btn" aria-label="Close">
|
||||
<span class="material-icons-round">close</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div style="margin-bottom: 1rem;">
|
||||
<strong>Aktuell:</strong> <span id="version-current">v1.4.1</span>
|
||||
</div>
|
||||
<div class="dev-toggle">
|
||||
<label style="display:flex;align-items:center;gap:8px;cursor:pointer;">
|
||||
<input type="checkbox" id="dev-mode-toggle">
|
||||
<span>Dev-Mode (alle Tags anzeigen)</span>
|
||||
</label>
|
||||
</div>
|
||||
<div id="version-list-container" style="margin-top:1rem;">
|
||||
<p style="color:var(--text-secondary);">Lade Versionen...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<main class="container">
|
||||
<div id="last-updated-banner" class="banner hidden">
|
||||
<span class="material-icons-round">update</span>
|
||||
@@ -1819,20 +2188,52 @@ body {
|
||||
const btnAddTag = document.getElementById('btn-add-tag');
|
||||
const tagInput = document.getElementById('tag-input');
|
||||
|
||||
btnHighlights.addEventListener('click', () => {
|
||||
highlightsModal.classList.remove('hidden');
|
||||
renderTagsList();
|
||||
tagInput.focus();
|
||||
// History Modal
|
||||
const btnHistory = document.getElementById('btn-history');
|
||||
const historyModal = document.getElementById('history-modal');
|
||||
const btnHistoryClose = document.getElementById('btn-history-close');
|
||||
|
||||
btnHistory.addEventListener('click', () => {
|
||||
if (!authToken) {
|
||||
loginModal.classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
historyModal.classList.remove('hidden');
|
||||
fetchFullOrderHistory();
|
||||
});
|
||||
|
||||
btnHighlightsClose.addEventListener('click', () => {
|
||||
highlightsModal.classList.add('hidden');
|
||||
btnHistoryClose.addEventListener('click', () => {
|
||||
historyModal.classList.add('hidden');
|
||||
});
|
||||
|
||||
window.addEventListener('click', (e) => {
|
||||
if (e.target === historyModal) historyModal.classList.add('hidden');
|
||||
if (e.target === highlightsModal) highlightsModal.classList.add('hidden');
|
||||
});
|
||||
|
||||
// Version Menu
|
||||
const versionTag = document.querySelector('.version-tag');
|
||||
const versionModal = document.getElementById('version-modal');
|
||||
const btnVersionClose = document.getElementById('btn-version-close');
|
||||
|
||||
if (versionTag) {
|
||||
versionTag.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
openVersionMenu();
|
||||
});
|
||||
}
|
||||
|
||||
if (btnVersionClose) {
|
||||
btnVersionClose.addEventListener('click', () => {
|
||||
versionModal.classList.add('hidden');
|
||||
});
|
||||
}
|
||||
|
||||
window.addEventListener('click', (e) => {
|
||||
if (e.target === versionModal) versionModal.classList.add('hidden');
|
||||
});
|
||||
|
||||
btnAddTag.addEventListener('click', () => {
|
||||
const tag = tagInput.value;
|
||||
if (addHighlightTag(tag)) {
|
||||
@@ -2074,6 +2475,202 @@ body {
|
||||
}
|
||||
}
|
||||
|
||||
// === History Modal Flow ===
|
||||
let fullOrderHistoryCache = null;
|
||||
|
||||
async function fetchFullOrderHistory() {
|
||||
const historyLoading = document.getElementById('history-loading');
|
||||
const historyContent = document.getElementById('history-content');
|
||||
const progressFill = document.getElementById('history-progress-fill');
|
||||
const progressText = document.getElementById('history-progress-text');
|
||||
|
||||
if (fullOrderHistoryCache) {
|
||||
renderHistory(fullOrderHistoryCache);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!authToken) return;
|
||||
|
||||
historyContent.innerHTML = '';
|
||||
historyLoading.classList.remove('hidden');
|
||||
progressFill.style.width = '0%';
|
||||
progressText.textContent = 'Lade Bestellhistorie...';
|
||||
|
||||
let nextUrl = `${API_BASE}/user/orders/?venue=${VENUE_ID}&ordering=-created&limit=50`;
|
||||
let allOrders = [];
|
||||
let totalCount = 0;
|
||||
|
||||
try {
|
||||
while (nextUrl) {
|
||||
const response = await fetch(nextUrl, { headers: apiHeaders(authToken) });
|
||||
if (!response.ok) throw new Error(`Fetch failed: ${response.status}`);
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.count && totalCount === 0) {
|
||||
totalCount = data.count;
|
||||
}
|
||||
|
||||
allOrders = allOrders.concat(data.results || []);
|
||||
|
||||
// Update progress
|
||||
if (totalCount > 0) {
|
||||
const pct = Math.round((allOrders.length / totalCount) * 100);
|
||||
progressFill.style.width = `${pct}%`;
|
||||
progressText.textContent = `Lade Bestellung ${allOrders.length} von ${totalCount}...`;
|
||||
} else {
|
||||
progressText.textContent = `Lade Bestellung ${allOrders.length}...`;
|
||||
}
|
||||
|
||||
nextUrl = data.next;
|
||||
}
|
||||
|
||||
fullOrderHistoryCache = allOrders;
|
||||
renderHistory(fullOrderHistoryCache);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error fetching full history:', error);
|
||||
historyContent.innerHTML = `<p style="color:var(--error-color);text-align:center;">Fehler beim Laden der Historie.</p>`;
|
||||
} finally {
|
||||
historyLoading.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
function renderHistory(orders) {
|
||||
const content = document.getElementById('history-content');
|
||||
if (!orders || orders.length === 0) {
|
||||
content.innerHTML = '<p style="text-align:center;color:var(--text-secondary);padding:20px;">Keine Bestellungen gefunden.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Group by Year -> Month -> Week Number (KW)
|
||||
const groups = {};
|
||||
|
||||
orders.forEach(order => {
|
||||
const d = new Date(order.date);
|
||||
const y = d.getFullYear();
|
||||
const m = d.getMonth();
|
||||
const monthKey = `${y}-${m.toString().padStart(2, '0')}`;
|
||||
const monthName = d.toLocaleString('de-AT', { month: 'long' }); // Only month name
|
||||
|
||||
const kw = getISOWeek(d);
|
||||
|
||||
if (!groups[y]) {
|
||||
groups[y] = { year: y, months: {} };
|
||||
}
|
||||
if (!groups[y].months[monthKey]) {
|
||||
groups[y].months[monthKey] = { name: monthName, year: y, monthIndex: m, count: 0, total: 0, weeks: {} };
|
||||
}
|
||||
if (!groups[y].months[monthKey].weeks[kw]) {
|
||||
groups[y].months[monthKey].weeks[kw] = { label: `KW ${kw}`, items: [], count: 0, total: 0 };
|
||||
}
|
||||
|
||||
const items = order.items || [];
|
||||
items.forEach(item => {
|
||||
const itemPrice = parseFloat(item.price || order.total || 0);
|
||||
groups[y].months[monthKey].weeks[kw].items.push({
|
||||
date: order.date,
|
||||
name: item.name || 'Menü',
|
||||
price: itemPrice,
|
||||
state: order.order_state // 9 is cancelled, 5 is active, 8 is completed
|
||||
});
|
||||
|
||||
if (order.order_state !== 9) {
|
||||
groups[y].months[monthKey].weeks[kw].count++;
|
||||
groups[y].months[monthKey].weeks[kw].total += itemPrice;
|
||||
groups[y].months[monthKey].count++;
|
||||
groups[y].months[monthKey].total += itemPrice;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Generate HTML
|
||||
const sortedYears = Object.keys(groups).sort((a, b) => b - a);
|
||||
let html = '';
|
||||
|
||||
sortedYears.forEach(yKey => {
|
||||
const yearGroup = groups[yKey];
|
||||
html += `<div class="history-year-group">
|
||||
<h2 class="history-year-header">${yearGroup.year}</h2>`;
|
||||
|
||||
const sortedMonths = Object.keys(yearGroup.months).sort((a, b) => b.localeCompare(a));
|
||||
|
||||
sortedMonths.forEach(mKey => {
|
||||
const monthGroup = yearGroup.months[mKey];
|
||||
|
||||
html += `<div class="history-month-group">
|
||||
<div class="history-month-header" tabindex="0" role="button" aria-expanded="false">
|
||||
<div style="display:flex; flex-direction:column; gap:4px;">
|
||||
<span>${monthGroup.name}</span>
|
||||
<div class="history-month-summary">
|
||||
<span>${monthGroup.count} Bestellungen • <strong>€${monthGroup.total.toFixed(2)}</strong></span>
|
||||
</div>
|
||||
</div>
|
||||
<span class="material-icons-round">expand_more</span>
|
||||
</div>
|
||||
<div class="history-month-content">`;
|
||||
|
||||
const sortedKWs = Object.keys(monthGroup.weeks).sort((a, b) => parseInt(b) - parseInt(a));
|
||||
|
||||
sortedKWs.forEach(kw => {
|
||||
const week = monthGroup.weeks[kw];
|
||||
html += `<div class="history-week-group">
|
||||
<div class="history-week-header">
|
||||
<strong>${week.label}</strong>
|
||||
<span>${week.count} Bestellungen • <strong>€${week.total.toFixed(2)}</strong></span>
|
||||
</div>`;
|
||||
|
||||
week.items.forEach(item => {
|
||||
const dateObj = new Date(item.date);
|
||||
const dayStr = dateObj.toLocaleDateString('de-AT', { weekday: 'short', day: '2-digit', month: '2-digit' });
|
||||
|
||||
let statusBadge = '';
|
||||
if (item.state === 9) {
|
||||
statusBadge = '<span class="history-item-status">Storniert</span>';
|
||||
} else if (item.state === 8) {
|
||||
statusBadge = '<span class="history-item-status">Abgeschlossen</span>';
|
||||
} else {
|
||||
statusBadge = '<span class="history-item-status">Übertragen</span>';
|
||||
}
|
||||
|
||||
html += `
|
||||
<div class="history-item ${item.state === 9 ? 'history-item-cancelled' : ''}">
|
||||
<div style="font-size: 0.85rem; color: var(--text-secondary);">${dayStr}</div>
|
||||
<div class="history-item-details">
|
||||
<span class="history-item-name">${escapeHtml(item.name)}</span>
|
||||
<div>${statusBadge}</div>
|
||||
</div>
|
||||
<div class="history-item-price ${item.state === 9 ? 'history-item-price-cancelled' : ''}">€${item.price.toFixed(2)}</div>
|
||||
</div>`;
|
||||
});
|
||||
html += `</div>`;
|
||||
});
|
||||
html += `</div></div>`; // Close month-content and month-group
|
||||
});
|
||||
html += `</div>`; // Close year-group
|
||||
});
|
||||
|
||||
content.innerHTML = html;
|
||||
|
||||
// Bind Accordion Click Events via JS
|
||||
const monthHeaders = content.querySelectorAll('.history-month-header');
|
||||
monthHeaders.forEach(header => {
|
||||
header.addEventListener('click', () => {
|
||||
const parentGroup = header.parentElement;
|
||||
const isOpen = parentGroup.classList.contains('open');
|
||||
|
||||
// Toggle current
|
||||
if (isOpen) {
|
||||
parentGroup.classList.remove('open');
|
||||
header.setAttribute('aria-expanded', 'false');
|
||||
} else {
|
||||
parentGroup.classList.add('open');
|
||||
header.setAttribute('aria-expanded', 'true');
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// === Place Order ===
|
||||
async function placeOrder(date, articleId, name, price, description) {
|
||||
if (!authToken) return;
|
||||
@@ -2341,10 +2938,12 @@ body {
|
||||
try {
|
||||
const cached = localStorage.getItem(CACHE_KEY);
|
||||
const cachedTs = localStorage.getItem(CACHE_TS_KEY);
|
||||
console.log(`[Cache] localStorage: key=${!!cached} (${cached ? cached.length : 0} chars), ts=${cachedTs}`);
|
||||
if (cached) {
|
||||
allWeeks = JSON.parse(cached);
|
||||
currentWeekNumber = getISOWeek(new Date());
|
||||
currentYear = new Date().getFullYear();
|
||||
console.log(`[Cache] Parsed ${allWeeks.length} weeks:`, allWeeks.map(w => `KW${w.weekNumber}/${w.year} (${(w.days || []).length} days)`));
|
||||
renderVisibleWeeks();
|
||||
updateNextWeekBadge();
|
||||
if (cachedTs) updateLastUpdatedTime(cachedTs);
|
||||
@@ -2357,6 +2956,31 @@ body {
|
||||
return false;
|
||||
}
|
||||
|
||||
// FR-024: Check if cache is fresh enough to skip API refresh
|
||||
function isCacheFresh() {
|
||||
const cachedTs = localStorage.getItem(CACHE_TS_KEY);
|
||||
if (!cachedTs) {
|
||||
console.log('[Cache] No timestamp found');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Condition 1: Cache < 1 hour old
|
||||
const ageMs = Date.now() - new Date(cachedTs).getTime();
|
||||
const ageMin = Math.round(ageMs / 60000);
|
||||
if (ageMs > 60 * 60 * 1000) {
|
||||
console.log(`[Cache] Stale: ${ageMin}min old (max 60)`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Condition 2: Data for current week exists
|
||||
const thisWeek = getISOWeek(new Date());
|
||||
const thisYear = getWeekYear(new Date());
|
||||
const hasCurrentWeek = allWeeks.some(w => w.weekNumber === thisWeek && w.year === thisYear && w.days && w.days.length > 0);
|
||||
|
||||
console.log(`[Cache] Age: ${ageMin}min, looking for KW${thisWeek}/${thisYear}, found: ${hasCurrentWeek}`);
|
||||
return hasCurrentWeek;
|
||||
}
|
||||
|
||||
// === Menu Data Fetching (Direct from Bessa API) ===
|
||||
async function loadMenuDataFromAPI() {
|
||||
const loading = document.getElementById('loading');
|
||||
@@ -2554,17 +3178,39 @@ body {
|
||||
}
|
||||
|
||||
// === Last Updated Display ===
|
||||
let lastUpdatedTimestamp = null;
|
||||
let lastUpdatedIntervalId = null;
|
||||
|
||||
function updateLastUpdatedTime(isoTimestamp) {
|
||||
const subtitle = document.getElementById('last-updated-subtitle');
|
||||
if (!isoTimestamp) return;
|
||||
lastUpdatedTimestamp = isoTimestamp;
|
||||
try {
|
||||
const date = new Date(isoTimestamp);
|
||||
const timeStr = date.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
|
||||
const dateStr = date.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' });
|
||||
subtitle.textContent = `Aktualisiert: ${dateStr} ${timeStr}`;
|
||||
const ago = getRelativeTime(date);
|
||||
subtitle.textContent = `Aktualisiert: ${dateStr} ${timeStr} (${ago})`;
|
||||
} catch (e) {
|
||||
subtitle.textContent = '';
|
||||
}
|
||||
// Auto-refresh relative time every minute
|
||||
if (!lastUpdatedIntervalId) {
|
||||
lastUpdatedIntervalId = setInterval(() => {
|
||||
if (lastUpdatedTimestamp) updateLastUpdatedTime(lastUpdatedTimestamp);
|
||||
}, 60 * 1000);
|
||||
}
|
||||
}
|
||||
|
||||
function getRelativeTime(date) {
|
||||
const diffMs = Date.now() - date.getTime();
|
||||
const diffMin = Math.floor(diffMs / 60000);
|
||||
if (diffMin < 1) return 'gerade eben';
|
||||
if (diffMin === 1) return 'vor 1 min.';
|
||||
if (diffMin < 60) return `vor ${diffMin} min.`;
|
||||
const diffH = Math.floor(diffMin / 60);
|
||||
if (diffH === 1) return 'vor 1 Std.';
|
||||
return `vor ${diffH} Std.`;
|
||||
}
|
||||
|
||||
// === Toast Notification ===
|
||||
@@ -2653,7 +3299,9 @@ body {
|
||||
if (nextWeekData && nextWeekData.days) {
|
||||
nextWeekData.days.forEach(day => {
|
||||
day.items.forEach(item => {
|
||||
if (checkHighlight(item.name) || checkHighlight(item.description)) {
|
||||
const nameMatches = checkHighlight(item.name);
|
||||
const descMatches = checkHighlight(item.description);
|
||||
if (nameMatches.length > 0 || descMatches.length > 0) {
|
||||
highlightCount++;
|
||||
}
|
||||
});
|
||||
@@ -3025,29 +3673,77 @@ body {
|
||||
return card;
|
||||
}
|
||||
|
||||
// === Version Check (periodic, every hour) ===
|
||||
// === GitHub Release Management ===
|
||||
|
||||
// Semver comparison: returns true if remote > local
|
||||
function isNewer(remote, local) {
|
||||
if (!remote || !local) return false;
|
||||
const r = remote.replace(/^v/, '').split('.').map(Number);
|
||||
const l = local.replace(/^v/, '').split('.').map(Number);
|
||||
for (let i = 0; i < Math.max(r.length, l.length); i++) {
|
||||
if ((r[i] || 0) > (l[i] || 0)) return true;
|
||||
if ((r[i] || 0) < (l[i] || 0)) return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// GitHub API headers
|
||||
function githubHeaders() {
|
||||
return { 'Accept': 'application/vnd.github.v3+json' };
|
||||
}
|
||||
|
||||
// Fetch versions from GitHub (releases or tags)
|
||||
async function fetchVersions(devMode) {
|
||||
const endpoint = devMode
|
||||
? `${GITHUB_API}/tags?per_page=20`
|
||||
: `${GITHUB_API}/releases?per_page=20`;
|
||||
|
||||
const resp = await fetch(endpoint, { headers: githubHeaders() });
|
||||
if (!resp.ok) throw new Error(`GitHub API ${resp.status}`);
|
||||
const data = await resp.json();
|
||||
|
||||
// Normalize to common format: { tag, name, url, body }
|
||||
return data.map(item => {
|
||||
const tag = devMode ? item.name : item.tag_name;
|
||||
return {
|
||||
tag,
|
||||
name: devMode ? tag : (item.name || tag),
|
||||
url: `${INSTALLER_BASE}/${tag}/dist/install.html`,
|
||||
body: item.body || ''
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// Periodic update check (runs on init + every hour)
|
||||
async function checkForUpdates() {
|
||||
const currentVersion = 'v1.2.5';
|
||||
const versionUrl = 'https://raw.githubusercontent.com/TauNeutrino/kantine-overview/main/version.txt';
|
||||
const installerUrl = 'https://htmlpreview.github.io/?https://github.com/TauNeutrino/kantine-overview/blob/main/dist/install.html';
|
||||
const currentVersion = 'v1.4.1';
|
||||
const devMode = localStorage.getItem('kantine_dev_mode') === 'true';
|
||||
|
||||
try {
|
||||
const resp = await fetch(versionUrl, { cache: 'no-cache' });
|
||||
if (!resp.ok) return;
|
||||
const remoteVersion = (await resp.text()).trim();
|
||||
if (!remoteVersion || remoteVersion === currentVersion) return;
|
||||
const versions = await fetchVersions(devMode);
|
||||
if (!versions.length) return;
|
||||
|
||||
console.log(`[Kantine] Update verfügbar: ${remoteVersion} (aktuell: ${currentVersion})`);
|
||||
// Cache for version menu
|
||||
localStorage.setItem('kantine_version_cache', JSON.stringify({
|
||||
timestamp: Date.now(), devMode, versions
|
||||
}));
|
||||
|
||||
const latest = versions[0].tag;
|
||||
console.log(`[Kantine] Version Check: Local [${currentVersion}] vs Latest [${latest}] (${devMode ? 'dev' : 'stable'})`);
|
||||
|
||||
if (!isNewer(latest, currentVersion)) return;
|
||||
|
||||
console.log(`[Kantine] Update verfügbar: ${latest}`);
|
||||
|
||||
// Show 🆕 icon in header (only once)
|
||||
const headerTitle = document.querySelector('.header-left h1');
|
||||
if (headerTitle && !headerTitle.querySelector('.update-icon')) {
|
||||
const icon = document.createElement('a');
|
||||
icon.className = 'update-icon';
|
||||
icon.href = installerUrl;
|
||||
icon.href = versions[0].url;
|
||||
icon.target = '_blank';
|
||||
icon.innerHTML = '🆕';
|
||||
icon.title = `Update verfügbar: ${remoteVersion} — Klick zum Installieren`;
|
||||
icon.title = `Update: ${latest} — Klick zum Installieren`;
|
||||
icon.style.cssText = 'margin-left:8px;font-size:1em;text-decoration:none;cursor:pointer;vertical-align:middle;';
|
||||
headerTitle.appendChild(icon);
|
||||
}
|
||||
@@ -3056,6 +3752,89 @@ body {
|
||||
}
|
||||
}
|
||||
|
||||
// Open Version Menu modal
|
||||
function openVersionMenu() {
|
||||
const modal = document.getElementById('version-modal');
|
||||
const container = document.getElementById('version-list-container');
|
||||
const devToggle = document.getElementById('dev-mode-toggle');
|
||||
const currentVersion = 'v1.4.1';
|
||||
|
||||
if (!modal) return;
|
||||
modal.classList.remove('hidden');
|
||||
|
||||
// Set current version display
|
||||
const cur = document.getElementById('version-current');
|
||||
if (cur) cur.textContent = currentVersion;
|
||||
|
||||
// Init dev toggle
|
||||
const devMode = localStorage.getItem('kantine_dev_mode') === 'true';
|
||||
devToggle.checked = devMode;
|
||||
|
||||
// Load versions (from cache or fresh)
|
||||
async function loadVersions(forceRefresh) {
|
||||
const dm = devToggle.checked;
|
||||
container.innerHTML = '<p style="color:var(--text-secondary);">Lade Versionen...</p>';
|
||||
|
||||
try {
|
||||
let versions;
|
||||
const cached = JSON.parse(localStorage.getItem('kantine_version_cache') || 'null');
|
||||
if (!forceRefresh && cached && cached.devMode === dm && (Date.now() - cached.timestamp < 3600000)) {
|
||||
versions = cached.versions;
|
||||
} else {
|
||||
versions = await fetchVersions(dm);
|
||||
localStorage.setItem('kantine_version_cache', JSON.stringify({
|
||||
timestamp: Date.now(), devMode: dm, versions
|
||||
}));
|
||||
}
|
||||
|
||||
if (!versions.length) {
|
||||
container.innerHTML = '<p style="color:var(--text-secondary);">Keine Versionen gefunden.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = '<ul class="version-list"></ul>';
|
||||
const list = container.querySelector('.version-list');
|
||||
|
||||
versions.forEach(v => {
|
||||
const isCurrent = v.tag === currentVersion;
|
||||
const isNew = isNewer(v.tag, currentVersion);
|
||||
const li = document.createElement('li');
|
||||
li.className = 'version-item' + (isCurrent ? ' current' : '');
|
||||
|
||||
let badge = '';
|
||||
if (isCurrent) badge = '<span class="badge-current">✓ Installiert</span>';
|
||||
else if (isNew) badge = '<span class="badge-new">⬆ Neu!</span>';
|
||||
|
||||
let action = '';
|
||||
if (!isCurrent) {
|
||||
action = `<a href="${v.url}" target="_blank" class="install-link" title="${v.tag} installieren">Installieren</a>`;
|
||||
}
|
||||
|
||||
li.innerHTML = `
|
||||
<div class="version-info">
|
||||
<strong>${v.tag}</strong>
|
||||
${badge}
|
||||
</div>
|
||||
${action}
|
||||
`;
|
||||
list.appendChild(li);
|
||||
});
|
||||
} catch (e) {
|
||||
container.innerHTML = `<p style="color:#e94560;">Fehler: ${e.message}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
loadVersions(false);
|
||||
|
||||
// Dev toggle handler
|
||||
devToggle.onchange = () => {
|
||||
localStorage.setItem('kantine_dev_mode', devToggle.checked);
|
||||
// Clear cache to force refresh when mode changes
|
||||
localStorage.removeItem('kantine_version_cache');
|
||||
loadVersions(true);
|
||||
};
|
||||
}
|
||||
|
||||
// === Order Countdown ===
|
||||
function updateCountdown() {
|
||||
const now = new Date();
|
||||
@@ -3181,13 +3960,19 @@ body {
|
||||
updateAuthUI();
|
||||
cleanupExpiredFlags();
|
||||
|
||||
// Load cached data first for instant UI, then refresh from API
|
||||
// Load cached data first for instant UI, refresh only if stale (FR-024)
|
||||
const hadCache = loadMenuCache();
|
||||
if (hadCache) {
|
||||
// Hide loading spinner since cache is shown
|
||||
document.getElementById('loading').classList.add('hidden');
|
||||
}
|
||||
if (!isCacheFresh()) {
|
||||
console.log('Cache stale or incomplete – refreshing from API');
|
||||
loadMenuDataFromAPI();
|
||||
} else {
|
||||
console.log('Cache fresh & complete – skipping API refresh');
|
||||
}
|
||||
} else {
|
||||
loadMenuDataFromAPI();
|
||||
}
|
||||
|
||||
// Auto-start polling if already logged in
|
||||
if (authToken) {
|
||||
|
||||
517
kantine.js
517
kantine.js
@@ -19,6 +19,11 @@
|
||||
const MENU_ID = 7;
|
||||
const POLL_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
// === GitHub Release Management ===
|
||||
const GITHUB_REPO = 'TauNeutrino/kantine-overview';
|
||||
const GITHUB_API = `https://api.github.com/repos/${GITHUB_REPO}`;
|
||||
const INSTALLER_BASE = `https://htmlpreview.github.io/?https://github.com/${GITHUB_REPO}/blob`;
|
||||
|
||||
// === State ===
|
||||
let allWeeks = [];
|
||||
let currentWeekNumber = getISOWeek(new Date());
|
||||
@@ -66,7 +71,7 @@
|
||||
<div class="brand">
|
||||
<span class="material-icons-round logo-icon">restaurant_menu</span>
|
||||
<div class="header-left">
|
||||
<h1>Kantinen Übersicht <small style="font-size: 0.6em; opacity: 0.7; font-weight: 400;">{{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>
|
||||
</div>
|
||||
@@ -78,6 +83,9 @@
|
||||
<button id="btn-refresh" class="icon-btn" aria-label="Menüdaten aktualisieren" title="Menüdaten neu laden">
|
||||
<span class="material-icons-round">refresh</span>
|
||||
</button>
|
||||
<button id="btn-history" class="icon-btn" aria-label="Bestellhistorie" title="Bestellhistorie">
|
||||
<span class="material-icons-round">receipt_long</span>
|
||||
</button>
|
||||
<button id="btn-highlights" class="icon-btn" aria-label="Persönliche Highlights verwalten" title="Persönliche Highlights verwalten">
|
||||
<span class="material-icons-round">label</span>
|
||||
</button>
|
||||
@@ -168,6 +176,55 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="history-modal" class="modal hidden">
|
||||
<div class="modal-content history-modal-content">
|
||||
<div class="modal-header">
|
||||
<h2>Bestellhistorie</h2>
|
||||
<button id="btn-history-close" class="icon-btn" aria-label="Close">
|
||||
<span class="material-icons-round">close</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div id="history-loading" class="hidden">
|
||||
<p id="history-progress-text" style="text-align: center; margin-bottom: 1rem; color: var(--text-secondary);">Lade Historie...</p>
|
||||
<div class="progress-container">
|
||||
<div class="progress-bar">
|
||||
<div id="history-progress-fill" class="progress-fill"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="history-content">
|
||||
<!-- Dynamically populated -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="version-modal" class="modal hidden">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h2>📦 Versionen</h2>
|
||||
<button id="btn-version-close" class="icon-btn" aria-label="Close">
|
||||
<span class="material-icons-round">close</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div style="margin-bottom: 1rem;">
|
||||
<strong>Aktuell:</strong> <span id="version-current">{{VERSION}}</span>
|
||||
</div>
|
||||
<div class="dev-toggle">
|
||||
<label style="display:flex;align-items:center;gap:8px;cursor:pointer;">
|
||||
<input type="checkbox" id="dev-mode-toggle">
|
||||
<span>Dev-Mode (alle Tags anzeigen)</span>
|
||||
</label>
|
||||
</div>
|
||||
<div id="version-list-container" style="margin-top:1rem;">
|
||||
<p style="color:var(--text-secondary);">Lade Versionen...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<main class="container">
|
||||
<div id="last-updated-banner" class="banner hidden">
|
||||
<span class="material-icons-round">update</span>
|
||||
@@ -205,20 +262,52 @@
|
||||
const btnAddTag = document.getElementById('btn-add-tag');
|
||||
const tagInput = document.getElementById('tag-input');
|
||||
|
||||
btnHighlights.addEventListener('click', () => {
|
||||
highlightsModal.classList.remove('hidden');
|
||||
renderTagsList();
|
||||
tagInput.focus();
|
||||
// History Modal
|
||||
const btnHistory = document.getElementById('btn-history');
|
||||
const historyModal = document.getElementById('history-modal');
|
||||
const btnHistoryClose = document.getElementById('btn-history-close');
|
||||
|
||||
btnHistory.addEventListener('click', () => {
|
||||
if (!authToken) {
|
||||
loginModal.classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
historyModal.classList.remove('hidden');
|
||||
fetchFullOrderHistory();
|
||||
});
|
||||
|
||||
btnHighlightsClose.addEventListener('click', () => {
|
||||
highlightsModal.classList.add('hidden');
|
||||
btnHistoryClose.addEventListener('click', () => {
|
||||
historyModal.classList.add('hidden');
|
||||
});
|
||||
|
||||
window.addEventListener('click', (e) => {
|
||||
if (e.target === historyModal) historyModal.classList.add('hidden');
|
||||
if (e.target === highlightsModal) highlightsModal.classList.add('hidden');
|
||||
});
|
||||
|
||||
// Version Menu
|
||||
const versionTag = document.querySelector('.version-tag');
|
||||
const versionModal = document.getElementById('version-modal');
|
||||
const btnVersionClose = document.getElementById('btn-version-close');
|
||||
|
||||
if (versionTag) {
|
||||
versionTag.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
openVersionMenu();
|
||||
});
|
||||
}
|
||||
|
||||
if (btnVersionClose) {
|
||||
btnVersionClose.addEventListener('click', () => {
|
||||
versionModal.classList.add('hidden');
|
||||
});
|
||||
}
|
||||
|
||||
window.addEventListener('click', (e) => {
|
||||
if (e.target === versionModal) versionModal.classList.add('hidden');
|
||||
});
|
||||
|
||||
btnAddTag.addEventListener('click', () => {
|
||||
const tag = tagInput.value;
|
||||
if (addHighlightTag(tag)) {
|
||||
@@ -460,6 +549,202 @@
|
||||
}
|
||||
}
|
||||
|
||||
// === History Modal Flow ===
|
||||
let fullOrderHistoryCache = null;
|
||||
|
||||
async function fetchFullOrderHistory() {
|
||||
const historyLoading = document.getElementById('history-loading');
|
||||
const historyContent = document.getElementById('history-content');
|
||||
const progressFill = document.getElementById('history-progress-fill');
|
||||
const progressText = document.getElementById('history-progress-text');
|
||||
|
||||
if (fullOrderHistoryCache) {
|
||||
renderHistory(fullOrderHistoryCache);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!authToken) return;
|
||||
|
||||
historyContent.innerHTML = '';
|
||||
historyLoading.classList.remove('hidden');
|
||||
progressFill.style.width = '0%';
|
||||
progressText.textContent = 'Lade Bestellhistorie...';
|
||||
|
||||
let nextUrl = `${API_BASE}/user/orders/?venue=${VENUE_ID}&ordering=-created&limit=50`;
|
||||
let allOrders = [];
|
||||
let totalCount = 0;
|
||||
|
||||
try {
|
||||
while (nextUrl) {
|
||||
const response = await fetch(nextUrl, { headers: apiHeaders(authToken) });
|
||||
if (!response.ok) throw new Error(`Fetch failed: ${response.status}`);
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.count && totalCount === 0) {
|
||||
totalCount = data.count;
|
||||
}
|
||||
|
||||
allOrders = allOrders.concat(data.results || []);
|
||||
|
||||
// Update progress
|
||||
if (totalCount > 0) {
|
||||
const pct = Math.round((allOrders.length / totalCount) * 100);
|
||||
progressFill.style.width = `${pct}%`;
|
||||
progressText.textContent = `Lade Bestellung ${allOrders.length} von ${totalCount}...`;
|
||||
} else {
|
||||
progressText.textContent = `Lade Bestellung ${allOrders.length}...`;
|
||||
}
|
||||
|
||||
nextUrl = data.next;
|
||||
}
|
||||
|
||||
fullOrderHistoryCache = allOrders;
|
||||
renderHistory(fullOrderHistoryCache);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error fetching full history:', error);
|
||||
historyContent.innerHTML = `<p style="color:var(--error-color);text-align:center;">Fehler beim Laden der Historie.</p>`;
|
||||
} finally {
|
||||
historyLoading.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
function renderHistory(orders) {
|
||||
const content = document.getElementById('history-content');
|
||||
if (!orders || orders.length === 0) {
|
||||
content.innerHTML = '<p style="text-align:center;color:var(--text-secondary);padding:20px;">Keine Bestellungen gefunden.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Group by Year -> Month -> Week Number (KW)
|
||||
const groups = {};
|
||||
|
||||
orders.forEach(order => {
|
||||
const d = new Date(order.date);
|
||||
const y = d.getFullYear();
|
||||
const m = d.getMonth();
|
||||
const monthKey = `${y}-${m.toString().padStart(2, '0')}`;
|
||||
const monthName = d.toLocaleString('de-AT', { month: 'long' }); // Only month name
|
||||
|
||||
const kw = getISOWeek(d);
|
||||
|
||||
if (!groups[y]) {
|
||||
groups[y] = { year: y, months: {} };
|
||||
}
|
||||
if (!groups[y].months[monthKey]) {
|
||||
groups[y].months[monthKey] = { name: monthName, year: y, monthIndex: m, count: 0, total: 0, weeks: {} };
|
||||
}
|
||||
if (!groups[y].months[monthKey].weeks[kw]) {
|
||||
groups[y].months[monthKey].weeks[kw] = { label: `KW ${kw}`, items: [], count: 0, total: 0 };
|
||||
}
|
||||
|
||||
const items = order.items || [];
|
||||
items.forEach(item => {
|
||||
const itemPrice = parseFloat(item.price || order.total || 0);
|
||||
groups[y].months[monthKey].weeks[kw].items.push({
|
||||
date: order.date,
|
||||
name: item.name || 'Menü',
|
||||
price: itemPrice,
|
||||
state: order.order_state // 9 is cancelled, 5 is active, 8 is completed
|
||||
});
|
||||
|
||||
if (order.order_state !== 9) {
|
||||
groups[y].months[monthKey].weeks[kw].count++;
|
||||
groups[y].months[monthKey].weeks[kw].total += itemPrice;
|
||||
groups[y].months[monthKey].count++;
|
||||
groups[y].months[monthKey].total += itemPrice;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Generate HTML
|
||||
const sortedYears = Object.keys(groups).sort((a, b) => b - a);
|
||||
let html = '';
|
||||
|
||||
sortedYears.forEach(yKey => {
|
||||
const yearGroup = groups[yKey];
|
||||
html += `<div class="history-year-group">
|
||||
<h2 class="history-year-header">${yearGroup.year}</h2>`;
|
||||
|
||||
const sortedMonths = Object.keys(yearGroup.months).sort((a, b) => b.localeCompare(a));
|
||||
|
||||
sortedMonths.forEach(mKey => {
|
||||
const monthGroup = yearGroup.months[mKey];
|
||||
|
||||
html += `<div class="history-month-group">
|
||||
<div class="history-month-header" tabindex="0" role="button" aria-expanded="false">
|
||||
<div style="display:flex; flex-direction:column; gap:4px;">
|
||||
<span>${monthGroup.name}</span>
|
||||
<div class="history-month-summary">
|
||||
<span>${monthGroup.count} Bestellungen • <strong>€${monthGroup.total.toFixed(2)}</strong></span>
|
||||
</div>
|
||||
</div>
|
||||
<span class="material-icons-round">expand_more</span>
|
||||
</div>
|
||||
<div class="history-month-content">`;
|
||||
|
||||
const sortedKWs = Object.keys(monthGroup.weeks).sort((a, b) => parseInt(b) - parseInt(a));
|
||||
|
||||
sortedKWs.forEach(kw => {
|
||||
const week = monthGroup.weeks[kw];
|
||||
html += `<div class="history-week-group">
|
||||
<div class="history-week-header">
|
||||
<strong>${week.label}</strong>
|
||||
<span>${week.count} Bestellungen • <strong>€${week.total.toFixed(2)}</strong></span>
|
||||
</div>`;
|
||||
|
||||
week.items.forEach(item => {
|
||||
const dateObj = new Date(item.date);
|
||||
const dayStr = dateObj.toLocaleDateString('de-AT', { weekday: 'short', day: '2-digit', month: '2-digit' });
|
||||
|
||||
let statusBadge = '';
|
||||
if (item.state === 9) {
|
||||
statusBadge = '<span class="history-item-status">Storniert</span>';
|
||||
} else if (item.state === 8) {
|
||||
statusBadge = '<span class="history-item-status">Abgeschlossen</span>';
|
||||
} else {
|
||||
statusBadge = '<span class="history-item-status">Übertragen</span>';
|
||||
}
|
||||
|
||||
html += `
|
||||
<div class="history-item ${item.state === 9 ? 'history-item-cancelled' : ''}">
|
||||
<div style="font-size: 0.85rem; color: var(--text-secondary);">${dayStr}</div>
|
||||
<div class="history-item-details">
|
||||
<span class="history-item-name">${escapeHtml(item.name)}</span>
|
||||
<div>${statusBadge}</div>
|
||||
</div>
|
||||
<div class="history-item-price ${item.state === 9 ? 'history-item-price-cancelled' : ''}">€${item.price.toFixed(2)}</div>
|
||||
</div>`;
|
||||
});
|
||||
html += `</div>`;
|
||||
});
|
||||
html += `</div></div>`; // Close month-content and month-group
|
||||
});
|
||||
html += `</div>`; // Close year-group
|
||||
});
|
||||
|
||||
content.innerHTML = html;
|
||||
|
||||
// Bind Accordion Click Events via JS
|
||||
const monthHeaders = content.querySelectorAll('.history-month-header');
|
||||
monthHeaders.forEach(header => {
|
||||
header.addEventListener('click', () => {
|
||||
const parentGroup = header.parentElement;
|
||||
const isOpen = parentGroup.classList.contains('open');
|
||||
|
||||
// Toggle current
|
||||
if (isOpen) {
|
||||
parentGroup.classList.remove('open');
|
||||
header.setAttribute('aria-expanded', 'false');
|
||||
} else {
|
||||
parentGroup.classList.add('open');
|
||||
header.setAttribute('aria-expanded', 'true');
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// === Place Order ===
|
||||
async function placeOrder(date, articleId, name, price, description) {
|
||||
if (!authToken) return;
|
||||
@@ -727,10 +1012,12 @@
|
||||
try {
|
||||
const cached = localStorage.getItem(CACHE_KEY);
|
||||
const cachedTs = localStorage.getItem(CACHE_TS_KEY);
|
||||
console.log(`[Cache] localStorage: key=${!!cached} (${cached ? cached.length : 0} chars), ts=${cachedTs}`);
|
||||
if (cached) {
|
||||
allWeeks = JSON.parse(cached);
|
||||
currentWeekNumber = getISOWeek(new Date());
|
||||
currentYear = new Date().getFullYear();
|
||||
console.log(`[Cache] Parsed ${allWeeks.length} weeks:`, allWeeks.map(w => `KW${w.weekNumber}/${w.year} (${(w.days || []).length} days)`));
|
||||
renderVisibleWeeks();
|
||||
updateNextWeekBadge();
|
||||
if (cachedTs) updateLastUpdatedTime(cachedTs);
|
||||
@@ -743,6 +1030,31 @@
|
||||
return false;
|
||||
}
|
||||
|
||||
// FR-024: Check if cache is fresh enough to skip API refresh
|
||||
function isCacheFresh() {
|
||||
const cachedTs = localStorage.getItem(CACHE_TS_KEY);
|
||||
if (!cachedTs) {
|
||||
console.log('[Cache] No timestamp found');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Condition 1: Cache < 1 hour old
|
||||
const ageMs = Date.now() - new Date(cachedTs).getTime();
|
||||
const ageMin = Math.round(ageMs / 60000);
|
||||
if (ageMs > 60 * 60 * 1000) {
|
||||
console.log(`[Cache] Stale: ${ageMin}min old (max 60)`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Condition 2: Data for current week exists
|
||||
const thisWeek = getISOWeek(new Date());
|
||||
const thisYear = getWeekYear(new Date());
|
||||
const hasCurrentWeek = allWeeks.some(w => w.weekNumber === thisWeek && w.year === thisYear && w.days && w.days.length > 0);
|
||||
|
||||
console.log(`[Cache] Age: ${ageMin}min, looking for KW${thisWeek}/${thisYear}, found: ${hasCurrentWeek}`);
|
||||
return hasCurrentWeek;
|
||||
}
|
||||
|
||||
// === Menu Data Fetching (Direct from Bessa API) ===
|
||||
async function loadMenuDataFromAPI() {
|
||||
const loading = document.getElementById('loading');
|
||||
@@ -940,17 +1252,39 @@
|
||||
}
|
||||
|
||||
// === Last Updated Display ===
|
||||
let lastUpdatedTimestamp = null;
|
||||
let lastUpdatedIntervalId = null;
|
||||
|
||||
function updateLastUpdatedTime(isoTimestamp) {
|
||||
const subtitle = document.getElementById('last-updated-subtitle');
|
||||
if (!isoTimestamp) return;
|
||||
lastUpdatedTimestamp = isoTimestamp;
|
||||
try {
|
||||
const date = new Date(isoTimestamp);
|
||||
const timeStr = date.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
|
||||
const dateStr = date.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' });
|
||||
subtitle.textContent = `Aktualisiert: ${dateStr} ${timeStr}`;
|
||||
const ago = getRelativeTime(date);
|
||||
subtitle.textContent = `Aktualisiert: ${dateStr} ${timeStr} (${ago})`;
|
||||
} catch (e) {
|
||||
subtitle.textContent = '';
|
||||
}
|
||||
// Auto-refresh relative time every minute
|
||||
if (!lastUpdatedIntervalId) {
|
||||
lastUpdatedIntervalId = setInterval(() => {
|
||||
if (lastUpdatedTimestamp) updateLastUpdatedTime(lastUpdatedTimestamp);
|
||||
}, 60 * 1000);
|
||||
}
|
||||
}
|
||||
|
||||
function getRelativeTime(date) {
|
||||
const diffMs = Date.now() - date.getTime();
|
||||
const diffMin = Math.floor(diffMs / 60000);
|
||||
if (diffMin < 1) return 'gerade eben';
|
||||
if (diffMin === 1) return 'vor 1 min.';
|
||||
if (diffMin < 60) return `vor ${diffMin} min.`;
|
||||
const diffH = Math.floor(diffMin / 60);
|
||||
if (diffH === 1) return 'vor 1 Std.';
|
||||
return `vor ${diffH} Std.`;
|
||||
}
|
||||
|
||||
// === Toast Notification ===
|
||||
@@ -1039,7 +1373,9 @@
|
||||
if (nextWeekData && nextWeekData.days) {
|
||||
nextWeekData.days.forEach(day => {
|
||||
day.items.forEach(item => {
|
||||
if (checkHighlight(item.name) || checkHighlight(item.description)) {
|
||||
const nameMatches = checkHighlight(item.name);
|
||||
const descMatches = checkHighlight(item.description);
|
||||
if (nameMatches.length > 0 || descMatches.length > 0) {
|
||||
highlightCount++;
|
||||
}
|
||||
});
|
||||
@@ -1411,29 +1747,77 @@
|
||||
return card;
|
||||
}
|
||||
|
||||
// === Version Check (periodic, every hour) ===
|
||||
// === GitHub Release Management ===
|
||||
|
||||
// Semver comparison: returns true if remote > local
|
||||
function isNewer(remote, local) {
|
||||
if (!remote || !local) return false;
|
||||
const r = remote.replace(/^v/, '').split('.').map(Number);
|
||||
const l = local.replace(/^v/, '').split('.').map(Number);
|
||||
for (let i = 0; i < Math.max(r.length, l.length); i++) {
|
||||
if ((r[i] || 0) > (l[i] || 0)) return true;
|
||||
if ((r[i] || 0) < (l[i] || 0)) return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// GitHub API headers
|
||||
function githubHeaders() {
|
||||
return { 'Accept': 'application/vnd.github.v3+json' };
|
||||
}
|
||||
|
||||
// Fetch versions from GitHub (releases or tags)
|
||||
async function fetchVersions(devMode) {
|
||||
const endpoint = devMode
|
||||
? `${GITHUB_API}/tags?per_page=20`
|
||||
: `${GITHUB_API}/releases?per_page=20`;
|
||||
|
||||
const resp = await fetch(endpoint, { headers: githubHeaders() });
|
||||
if (!resp.ok) throw new Error(`GitHub API ${resp.status}`);
|
||||
const data = await resp.json();
|
||||
|
||||
// Normalize to common format: { tag, name, url, body }
|
||||
return data.map(item => {
|
||||
const tag = devMode ? item.name : item.tag_name;
|
||||
return {
|
||||
tag,
|
||||
name: devMode ? tag : (item.name || tag),
|
||||
url: `${INSTALLER_BASE}/${tag}/dist/install.html`,
|
||||
body: item.body || ''
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// Periodic update check (runs on init + every hour)
|
||||
async function checkForUpdates() {
|
||||
const currentVersion = '{{VERSION}}';
|
||||
const versionUrl = 'https://raw.githubusercontent.com/TauNeutrino/kantine-overview/main/version.txt';
|
||||
const installerUrl = 'https://htmlpreview.github.io/?https://github.com/TauNeutrino/kantine-overview/blob/main/dist/install.html';
|
||||
const devMode = localStorage.getItem('kantine_dev_mode') === 'true';
|
||||
|
||||
try {
|
||||
const resp = await fetch(versionUrl, { cache: 'no-cache' });
|
||||
if (!resp.ok) return;
|
||||
const remoteVersion = (await resp.text()).trim();
|
||||
if (!remoteVersion || remoteVersion === currentVersion) return;
|
||||
const versions = await fetchVersions(devMode);
|
||||
if (!versions.length) return;
|
||||
|
||||
console.log(`[Kantine] Update verfügbar: ${remoteVersion} (aktuell: ${currentVersion})`);
|
||||
// Cache for version menu
|
||||
localStorage.setItem('kantine_version_cache', JSON.stringify({
|
||||
timestamp: Date.now(), devMode, versions
|
||||
}));
|
||||
|
||||
const latest = versions[0].tag;
|
||||
console.log(`[Kantine] Version Check: Local [${currentVersion}] vs Latest [${latest}] (${devMode ? 'dev' : 'stable'})`);
|
||||
|
||||
if (!isNewer(latest, currentVersion)) return;
|
||||
|
||||
console.log(`[Kantine] Update verfügbar: ${latest}`);
|
||||
|
||||
// Show 🆕 icon in header (only once)
|
||||
const headerTitle = document.querySelector('.header-left h1');
|
||||
if (headerTitle && !headerTitle.querySelector('.update-icon')) {
|
||||
const icon = document.createElement('a');
|
||||
icon.className = 'update-icon';
|
||||
icon.href = installerUrl;
|
||||
icon.href = versions[0].url;
|
||||
icon.target = '_blank';
|
||||
icon.innerHTML = '🆕';
|
||||
icon.title = `Update verfügbar: ${remoteVersion} — Klick zum Installieren`;
|
||||
icon.title = `Update: ${latest} — Klick zum Installieren`;
|
||||
icon.style.cssText = 'margin-left:8px;font-size:1em;text-decoration:none;cursor:pointer;vertical-align:middle;';
|
||||
headerTitle.appendChild(icon);
|
||||
}
|
||||
@@ -1442,6 +1826,89 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Open Version Menu modal
|
||||
function openVersionMenu() {
|
||||
const modal = document.getElementById('version-modal');
|
||||
const container = document.getElementById('version-list-container');
|
||||
const devToggle = document.getElementById('dev-mode-toggle');
|
||||
const currentVersion = '{{VERSION}}';
|
||||
|
||||
if (!modal) return;
|
||||
modal.classList.remove('hidden');
|
||||
|
||||
// Set current version display
|
||||
const cur = document.getElementById('version-current');
|
||||
if (cur) cur.textContent = currentVersion;
|
||||
|
||||
// Init dev toggle
|
||||
const devMode = localStorage.getItem('kantine_dev_mode') === 'true';
|
||||
devToggle.checked = devMode;
|
||||
|
||||
// Load versions (from cache or fresh)
|
||||
async function loadVersions(forceRefresh) {
|
||||
const dm = devToggle.checked;
|
||||
container.innerHTML = '<p style="color:var(--text-secondary);">Lade Versionen...</p>';
|
||||
|
||||
try {
|
||||
let versions;
|
||||
const cached = JSON.parse(localStorage.getItem('kantine_version_cache') || 'null');
|
||||
if (!forceRefresh && cached && cached.devMode === dm && (Date.now() - cached.timestamp < 3600000)) {
|
||||
versions = cached.versions;
|
||||
} else {
|
||||
versions = await fetchVersions(dm);
|
||||
localStorage.setItem('kantine_version_cache', JSON.stringify({
|
||||
timestamp: Date.now(), devMode: dm, versions
|
||||
}));
|
||||
}
|
||||
|
||||
if (!versions.length) {
|
||||
container.innerHTML = '<p style="color:var(--text-secondary);">Keine Versionen gefunden.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = '<ul class="version-list"></ul>';
|
||||
const list = container.querySelector('.version-list');
|
||||
|
||||
versions.forEach(v => {
|
||||
const isCurrent = v.tag === currentVersion;
|
||||
const isNew = isNewer(v.tag, currentVersion);
|
||||
const li = document.createElement('li');
|
||||
li.className = 'version-item' + (isCurrent ? ' current' : '');
|
||||
|
||||
let badge = '';
|
||||
if (isCurrent) badge = '<span class="badge-current">✓ Installiert</span>';
|
||||
else if (isNew) badge = '<span class="badge-new">⬆ Neu!</span>';
|
||||
|
||||
let action = '';
|
||||
if (!isCurrent) {
|
||||
action = `<a href="${v.url}" target="_blank" class="install-link" title="${v.tag} installieren">Installieren</a>`;
|
||||
}
|
||||
|
||||
li.innerHTML = `
|
||||
<div class="version-info">
|
||||
<strong>${v.tag}</strong>
|
||||
${badge}
|
||||
</div>
|
||||
${action}
|
||||
`;
|
||||
list.appendChild(li);
|
||||
});
|
||||
} catch (e) {
|
||||
container.innerHTML = `<p style="color:#e94560;">Fehler: ${e.message}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
loadVersions(false);
|
||||
|
||||
// Dev toggle handler
|
||||
devToggle.onchange = () => {
|
||||
localStorage.setItem('kantine_dev_mode', devToggle.checked);
|
||||
// Clear cache to force refresh when mode changes
|
||||
localStorage.removeItem('kantine_version_cache');
|
||||
loadVersions(true);
|
||||
};
|
||||
}
|
||||
|
||||
// === Order Countdown ===
|
||||
function updateCountdown() {
|
||||
const now = new Date();
|
||||
@@ -1567,13 +2034,19 @@
|
||||
updateAuthUI();
|
||||
cleanupExpiredFlags();
|
||||
|
||||
// Load cached data first for instant UI, then refresh from API
|
||||
// Load cached data first for instant UI, refresh only if stale (FR-024)
|
||||
const hadCache = loadMenuCache();
|
||||
if (hadCache) {
|
||||
// Hide loading spinner since cache is shown
|
||||
document.getElementById('loading').classList.add('hidden');
|
||||
}
|
||||
if (!isCacheFresh()) {
|
||||
console.log('Cache stale or incomplete – refreshing from API');
|
||||
loadMenuDataFromAPI();
|
||||
} else {
|
||||
console.log('Cache fresh & complete – skipping API refresh');
|
||||
}
|
||||
} else {
|
||||
loadMenuDataFromAPI();
|
||||
}
|
||||
|
||||
// Auto-start polling if already logged in
|
||||
if (authToken) {
|
||||
|
||||
25
mock-data.js
25
mock-data.js
@@ -128,8 +128,31 @@
|
||||
// Orders endpoint
|
||||
if (urlStr.includes('/user/orders/') && (!options || options.method === 'GET' || !options.method)) {
|
||||
console.log('[MOCK] Returning mock orders');
|
||||
// Formatter for history mapping
|
||||
const mappedOrders = mockOrders.map(o => ({
|
||||
id: o.id,
|
||||
date: `${o.date}T10:00:00Z`,
|
||||
order_state: o.status === 'cancelled' ? 9 : 5,
|
||||
total: o.price || '6.50',
|
||||
items: [{
|
||||
article: o.article,
|
||||
name: o.article_name,
|
||||
price: o.price || '6.50',
|
||||
amount: 1
|
||||
}]
|
||||
}));
|
||||
|
||||
// Handle lazy load / pagination if requesting full history
|
||||
if (urlStr.includes('limit=50')) {
|
||||
return Promise.resolve(new Response(JSON.stringify({
|
||||
results: mockOrders
|
||||
count: mappedOrders.length,
|
||||
next: null,
|
||||
results: mappedOrders
|
||||
}), { status: 200, headers: { 'Content-Type': 'application/json' } }));
|
||||
}
|
||||
|
||||
return Promise.resolve(new Response(JSON.stringify({
|
||||
results: mappedOrders
|
||||
}), { status: 200, headers: { 'Content-Type': 'application/json' } }));
|
||||
}
|
||||
|
||||
|
||||
291
style.css
291
style.css
@@ -437,7 +437,6 @@ body {
|
||||
|
||||
.modal-content {
|
||||
background: var(--bg-card);
|
||||
/* Changed from --surface */
|
||||
width: 90%;
|
||||
max-width: 400px;
|
||||
border-radius: 16px;
|
||||
@@ -446,6 +445,174 @@ body {
|
||||
animation: modalSlide 0.3s ease-out;
|
||||
}
|
||||
|
||||
/* History Modal specific */
|
||||
.history-modal-content {
|
||||
max-width: 600px;
|
||||
max-height: 85vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.history-modal-content .modal-body {
|
||||
overflow-y: auto;
|
||||
padding: 0;
|
||||
/* Padding is handled by inner elements */
|
||||
}
|
||||
|
||||
/* History Styles */
|
||||
.history-year-group {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.history-year-header {
|
||||
background: var(--bg-card);
|
||||
padding: 12px 20px;
|
||||
margin: 0;
|
||||
font-size: 1.2rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
border-bottom: 2px solid var(--border-color);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 12;
|
||||
}
|
||||
|
||||
.history-month-group {
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.history-month-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 14px 20px;
|
||||
margin: 0;
|
||||
font-size: 1.05rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-body);
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.history-month-header:hover {
|
||||
background: var(--border-color);
|
||||
/* Slight hover effect */
|
||||
}
|
||||
|
||||
.history-month-summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
font-size: 0.95rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.history-month-content {
|
||||
display: none;
|
||||
/* Collapsed by default */
|
||||
background: var(--bg-card);
|
||||
}
|
||||
|
||||
.history-month-group.open .history-month-content {
|
||||
display: block;
|
||||
/* Expanded when open class is present */
|
||||
}
|
||||
|
||||
.history-month-group.open .history-month-header .material-icons-round {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.history-month-header .material-icons-round {
|
||||
transition: transform 0.3s;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.history-week-group {
|
||||
padding: 12px 20px;
|
||||
border-bottom: 1px dashed var(--border-color);
|
||||
}
|
||||
|
||||
.history-week-group:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.history-week-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.history-week-summary {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
background: rgba(100, 116, 139, 0.1);
|
||||
padding: 4px 10px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.history-items {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.history-item {
|
||||
display: grid;
|
||||
grid-template-columns: 50px 1fr auto;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 12px;
|
||||
background: var(--bg-body);
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.history-item-date {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.history-item-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.history-item-name {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.history-item-price {
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.history-item-status {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.history-item-cancelled {
|
||||
opacity: 0.5;
|
||||
filter: grayscale(1);
|
||||
}
|
||||
|
||||
.history-item-price-cancelled {
|
||||
text-decoration: line-through;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
@keyframes modalSlide {
|
||||
from {
|
||||
transform: translateY(20px);
|
||||
@@ -1413,3 +1580,125 @@ body {
|
||||
font-size: 1.1em;
|
||||
color: var(--accent-color);
|
||||
}
|
||||
|
||||
/* === Version Menu === */
|
||||
.version-tag {
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s ease, text-decoration 0.2s ease;
|
||||
}
|
||||
|
||||
.version-tag:hover {
|
||||
opacity: 1 !important;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.version-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
max-height: 350px;
|
||||
overflow-y: auto;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.version-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 10px 14px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 4px;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.version-item:hover {
|
||||
background: rgba(100, 116, 139, 0.08);
|
||||
}
|
||||
|
||||
.version-item.current {
|
||||
background: rgba(2, 154, 168, 0.1);
|
||||
border: 1px solid rgba(2, 154, 168, 0.25);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .version-item:hover {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .version-item.current {
|
||||
background: rgba(96, 165, 250, 0.12);
|
||||
border: 1px solid rgba(96, 165, 250, 0.25);
|
||||
}
|
||||
|
||||
.version-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.badge-current {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--success-color);
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
background: rgba(5, 150, 105, 0.1);
|
||||
}
|
||||
|
||||
.badge-new {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: #029aa8;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
background: rgba(2, 154, 168, 0.1);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .badge-new {
|
||||
color: #60a5fa;
|
||||
background: rgba(96, 165, 250, 0.12);
|
||||
}
|
||||
|
||||
.install-link {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
padding: 4px 12px;
|
||||
border-radius: 6px;
|
||||
background: rgba(2, 154, 168, 0.1);
|
||||
color: #029aa8;
|
||||
text-decoration: none;
|
||||
border: 1px solid rgba(2, 154, 168, 0.25);
|
||||
transition: all 0.2s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.install-link:hover {
|
||||
background: rgba(2, 154, 168, 0.2);
|
||||
border-color: rgba(2, 154, 168, 0.4);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .install-link {
|
||||
color: #60a5fa;
|
||||
background: rgba(96, 165, 250, 0.12);
|
||||
border: 1px solid rgba(96, 165, 250, 0.25);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .install-link:hover {
|
||||
background: rgba(96, 165, 250, 0.2);
|
||||
border-color: rgba(96, 165, 250, 0.4);
|
||||
}
|
||||
|
||||
.dev-toggle {
|
||||
padding: 10px 14px;
|
||||
border-radius: 8px;
|
||||
background: rgba(100, 116, 139, 0.05);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.dev-toggle input[type="checkbox"] {
|
||||
accent-color: #029aa8;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .dev-toggle input[type="checkbox"] {
|
||||
accent-color: #60a5fa;
|
||||
}
|
||||
@@ -101,6 +101,24 @@ try {
|
||||
console.log("✅ Static Analysis Passed: 'appendChild(icon)' found.");
|
||||
}
|
||||
|
||||
// Check for GitHub Release Management functions
|
||||
const checks = [
|
||||
[/GITHUB_API/, 'GITHUB_API constant'],
|
||||
[/function\s+fetchVersions/, 'fetchVersions function'],
|
||||
[/function\s+isNewer/, 'isNewer function'],
|
||||
[/function\s+openVersionMenu/, 'openVersionMenu function'],
|
||||
[/kantine_dev_mode/, 'dev-mode localStorage key'],
|
||||
[/function\s+isCacheFresh/, 'isCacheFresh function']
|
||||
];
|
||||
|
||||
for (const [regex, label] of checks) {
|
||||
if (!regex.test(code)) {
|
||||
console.error(`❌ Static Analysis Failed: '${label}' not found.`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
console.log("✅ Static Analysis Passed: All GitHub Release Management functions found.");
|
||||
|
||||
// Check dynamic logic usage
|
||||
// Note: Since we mock fetch to fail for menu data, the app might perform error handling.
|
||||
// We just want to ensure it doesn't CRASH (exit code) and that our specific feature logic ran.
|
||||
|
||||
@@ -1 +1 @@
|
||||
v1.2.5
|
||||
v1.4.1
|
||||
|
||||
Reference in New Issue
Block a user