Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
08ee2a2d0f | ||
|
|
314728f6d0 | ||
|
|
ea4e0d151f | ||
|
|
b928b90728 | ||
|
|
9b1f0e2fd3 | ||
|
|
05bc06660c | ||
|
|
20957c3582 | ||
| fe86e68aca | |||
| 4dbd6c930f | |||
| ad4cfaf4ec | |||
| 441198dd8d | |||
| 13a0ae3a93 | |||
| 1f8ebff9fe |
@@ -48,3 +48,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.
|
||||
|
||||
|
||||
117
REQUIREMENTS.md
117
REQUIREMENTS.md
@@ -2,55 +2,90 @@
|
||||
|
||||
## 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** | | | |
|
||||
| FR-040 | Das System muss die Gesamtkosten aller Bestellungen einer Woche automatisch berechnen und anzeigen. | Mittel | v1.1.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.
|
||||
|
||||
@@ -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
|
||||
@@ -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"
|
||||
|
||||
20
changelog.md
20
changelog.md
@@ -1,3 +1,23 @@
|
||||
## 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. 🧪
|
||||
|
||||
|
||||
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
34
dist/install.html
vendored
34
dist/install.html
vendored
File diff suppressed because one or more lines are too long
393
dist/kantine-standalone.html
vendored
393
dist/kantine-standalone.html
vendored
@@ -1423,6 +1423,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>
|
||||
@@ -1633,6 +1755,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 +1807,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.6</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.3.1</small></h1>
|
||||
<div id="last-updated-subtitle" class="subtitle"></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1782,6 +1909,31 @@ body {
|
||||
</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.3.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>
|
||||
@@ -1833,6 +1985,29 @@ body {
|
||||
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)) {
|
||||
@@ -2341,10 +2516,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 +2534,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 +2756,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 ===
|
||||
@@ -3025,29 +3249,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.6';
|
||||
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.3.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 +3328,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.3.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 +3536,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) {
|
||||
|
||||
269
kantine.js
269
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>
|
||||
@@ -168,6 +173,31 @@
|
||||
</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>
|
||||
@@ -219,6 +249,29 @@
|
||||
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)) {
|
||||
@@ -727,10 +780,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 +798,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 +1020,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 ===
|
||||
@@ -1411,29 +1513,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 +1592,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 +1800,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) {
|
||||
|
||||
122
style.css
122
style.css
@@ -1413,3 +1413,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.6
|
||||
v1.3.1
|
||||
|
||||
Reference in New Issue
Block a user