Compare commits

..

80 Commits

Author SHA1 Message Date
Scarriffle
8f9eafe561 feat(settings): Schriftfarbe, Linienfarbe und Hintergrundfarbe per Color-Picker
Die bisherigen Stufen-Wähler ("Dunkel/Mittel/Hell/Maximum" und
"Kaum/Subtil/Normal/Stark") für Schrift- bzw. Linienkontrast sind durch
echte Hex-Color-Picker ersetzt. Zusätzlich kann jetzt auch die
Hintergrundfarbe der Seite frei gewählt werden.

Wenn ein Override gesetzt ist:
- text_color → setzt --text-1 direkt, --text-2/--text-3 werden
  daraus per shadeHex(-0.25 / -0.55) abgeleitet, damit der Hue passt
- line_color → setzt --border, --border-light wird leicht abgedunkelt
- bg_color → setzt --bg-app, daraus werden Topbar/Sidebar/Surface/
  Hover/Active per shadeHex(+0.10…+0.40) konsistent hochskaliert

Per "Reset"-Knopf wird der Override geleert und die alte Stufen-Logik
(falls noch vorhanden) bzw. der Default-Theme greift wieder.

Backend:
- 3 neue nullable VARCHAR(7)-Spalten in user_settings (text_color,
  line_color, bg_color) inkl. Migrationen in main.py
- settings_router nutzt model_dump(exclude_unset=True) und respektiert
  explizite null-Werte nur für diese 3 Override-Felder, damit Reset
  funktioniert

Auch enthalten: Auflösen der Merge-Konflikte in sw.js, index.html,
version.js (HEAD-Stand v17 behalten) und Bump auf v18.
2026-05-19 09:49:45 +02:00
Scarriffle
d3fa591bef merge: beta into master 2026-05-19 09:35:15 +02:00
Scarriffle
254adfa12a ui: Event-Popup-Aktionen modernisiert — kompakte Icon-Toolbar im Header
Das Popup hatte vorher Text+Icon-Buttons in einem Footer mit
verschwendeter vertikaler Höhe. Jetzt:

- Color-Dot + Titel links (volle Breite, kann sauber umbrechen)
- Kompakte 30px-Icon-Toolbar rechts oben: Bearbeiten / Kopieren /
  Löschen / Schließen
- Icons im Ruhezustand transparent (nur SVG sichtbar, sehr dezent)
- Auf Hover: runder farbiger Hintergrund. Edit/Copy in Primärfarbe,
  Delete in Akzentrot, Close in neutralem bg-hover
- Klick gibt mit Scale-Down (.9) taktilen Feedback
- Popup-Breite leicht erhöht (340 → 360 px) damit Titel + Toolbar
  bequem nebeneinander passen
- Trash- und Copy-SVG-Pfade auf den 24x24-viewBox normalisiert
  (waren vorher zu lang)

Version v16 → v17.
2026-05-11 09:10:07 +02:00
Scarriffle
dc1cb4b57d fix: Popup-Action-Icons riesig, "copy" als Text — Cache-Robustheit
Wenn der Browser noch die alte CSS bzw. i18n.js aus dem Cache hatte,
lief das neu strukturierte Popup ins Leere:
- SVGs ohne CSS-Width-Constraint nahmen die Browser-Standardgröße
  (300×150) an → riesige Icons, Layout brach in Vertikalstapel
- Der Key "copy" fehlte in der alten i18n.js → "Kopieren" wurde durch
  den Roh-Key "copy" ersetzt

Robust gemacht:
- SVGs der Action-Buttons bekommen jetzt direkt im HTML width="16"
  height="16" — funktioniert auch ohne dass die zugehörige CSS-Regel
  geladen wurde
- applyLang() in i18n.js fällt bei fehlendem Schlüssel auf den
  HTML-Default-Text zurück, anstatt den Key als Text einzuschreiben
  (gleiches Prinzip für data-i18n, -i18n-ph, -i18n-title)

Version v15 → v16.
2026-05-11 08:54:20 +02:00
Scarriffle
baa7e4c064 ui: Event-Popup neu strukturiert — Titel volle Breite, Actions im Footer
Vorher haben Bearbeiten/Kopieren/Löschen/Schließen im Header über die
Hälfte der Breite gefressen, sodass der Titel auf 2-3 Zeilen
zusammenschrumpfen musste.

Neues Layout:
- Schließen-X klein in der oberen rechten Ecke (absolut positioniert)
- Header zeigt nur Color-Dot + Titel — voller Platz fürs Lesen
- Drei beschriftete Aktions-Buttons (Bearbeiten / Kopieren / Löschen)
  als gleichbreite Reihe im Footer
- Hover-Tint folgt der Primärfarbe; Löschen tönt zur Akzentfarbe
- Popup-Breite leicht erhöht (300 → 340 px) für mehr Atemraum
- Mobile bekommt die Action-Buttons etwas kompakter

IDs der Buttons unverändert (popup-edit/copy/delete/close), bestehende
JS-Handler funktionieren weiter.

Version v14 → v15.
2026-05-11 08:24:48 +02:00
Scarriffle
1d6acceafc ui: Event-Popup-Aktionsbuttons polieren
Die drei Aktions-Icons (Bearbeiten, Kopieren, Löschen) und der
Schließen-X im Termin-Popup hatten bisher nur den schlichten
icon-btn-Hover (graue Fläche). Jetzt im selben modernen Stil wie die
neuen Pill-Buttons:

- Bearbeiten/Kopieren/Löschen: Hover bekommt Primärfarben-Tint
  (color-mix-Hintergrund + farbige Schrift) plus dezenten farbigen
  Schatten
- Schließen-X: Hover zeigt die Akzentfarbe (rot), passend zur
  destruktiven Geste
- Klick fühlt sich mit kurzem Scale-Down (.92) taktiler an

Version v13 → v14.
2026-05-11 08:08:12 +02:00
Scarriffle
9013f57d02 feat(ui): Buttons im modernen Pill-Stil + Plus-Icon fixen
Großes Frontend-Update für alle Buttons. Der Stil orientiert sich an
modernen App-Designs (Pill mit dezentem Schatten, sanft "abhebender"
Hover-Effekt), die Farbe folgt der gewählten Primärfarbe des Users
dynamisch via color-mix().

- .btn: fully rounded (border-radius: 999px), grösseres Padding,
  smooth Transitions für Schatten/Transform/Brightness
- .btn-primary: Primärfarbe als Hintergrund + dezenter farbiger
  Schatten; Hover hebt um 1px, Schatten wird kräftiger, leichte
  Aufhellung
- .btn-secondary: dezenter Border, auf Hover wird er primär-farben
- .btn-ghost / .btn-danger entsprechend angepasst
- .btn-fab (Sidebar "Erstellen"): jetzt in Primärfarbe statt grau,
  passt zum FAB unten rechts auf Mobile und zur Marken-Sprache
- .icon-btn: kleines Scale-Down beim Drücken, Focus-Ring sichtbar
  für Tastatur-Nutzer
- Form-Inputs: 8px Radius, sanfter Hover-Border, beim Focus jetzt
  Primärfarben-Ring (color-mix-Glow)

Fix: kaputtes Plus-SVG am Kalender-Hinzufügen-Button — Vertikalbalken
war zu lang (v12 statt v6), jetzt symmetrisch.

Version v12 → v13.
2026-05-11 07:56:13 +02:00
Scarriffle
199a65e2a5 fix: Caching auf max 2 h reduzieren
Bisher konnten alte JS-/CSS-Dateien durch Service-Worker- und Browser-
Cache hartnäckig hängen bleiben, obwohl auf dem Server schon eine neue
Version lag. Strategie jetzt:

Backend (main.py)
- Neue HTTP-Middleware setzt explizite Cache-Control-Header:
  * /, /index.html, /manifest.json, /sw.js, /static/js/version.js
    bekommen no-cache, no-store, must-revalidate
  * /static/* und /icons/* bekommen public, max-age=7200,
    must-revalidate (2 h)
  * SPA-Fallback-Antworten ebenfalls no-cache
  * /api/* bleibt unangetastet

Service Worker (sw.js)
- Wechsel von Cache-First zu Network-First für alles
- Cache wird nur noch für die index.html-Offline-Hülle vorgehalten,
  nicht mehr für JS/CSS — Browser-HTTP-Cache übernimmt das mit den
  2-h-Headern vom Server
- Bei Netzwerkfehler bleibt nur die HTML-Shell offline verfügbar

Version v11 → v12 (auch SW-Cache-Key).
2026-05-11 07:44:25 +02:00
Scarriffle
3152c744a0 feat: URL-State – Reload erhält View und Datum statt auf heute zu springen
Aktuelle View und Datum werden als URL-Hash gespiegelt
(#date=YYYY-MM-DD&view=<view>). Beim Init liest initCalendar() den Hash
und überschreibt damit die Defaults (settings.default_view + today).
fetchAndRender() schreibt nach jedem Render den aktuellen State zurück
(replaceState, damit prev/next-Clicks keinen History-Müll erzeugen).

Browser-Back/Forward funktioniert via hashchange-Listener.

Edge case: HA-OAuth-Callback erhält jetzt den Hash beim URL-Cleanup
(window.location.pathname + window.location.hash statt nur pathname).

Komplett Frontend-only — kein Backend-Touch.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-10 13:15:28 +02:00
Scarriffle
ba86092cc8 fix: Wochenansicht – ganztägige Termine spannen sich nicht mehr über zwei Tage
Bei der Layout-Berechnung für ganztägige Termine wurden Start/Ende als
UTC-Zeitstempel mit der Lokal-Zeit der Tagesgrenze verglichen — das
führte in Zeitzonen mit positivem UTC-Offset (z.B. CET) dazu, dass
das exklusive DTEND zwei Stunden in den nächsten Tag hineinragte und
die UI den Termin auf zwei Tagen darstellte.

Fix: Für ganztägige Events normalisieren wir auf reine Datumswerte
(setHours(0,0,0,0)) und ziehen einen Tag vom End-Datum ab, sodass die
Vergleiche dieselbe inklusive Semantik wie die Monatsansicht nutzen.
Timed Events behalten die ursprüngliche strict-overlap Logik.

Auch die continues-left/right Marker arbeiten jetzt mit den
normalisierten Daten.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-10 11:01:13 +02:00
Scarriffle
50c19c7999 fix: Impressum – Open Home Foundation statt Home Assistant Foundation 2026-05-09 18:03:47 +02:00
Scarriffle
05e55b3326 fix: Impressum – Datenspeicherungsabschnitt korrigiert
- Speicherort hängt vom Server-Betreiber ab, nicht hardcoded "Schweiz"
- Home Assistant-Anbindung erwähnt mit Hinweis auf Datenaustausch
- Trademark-Hinweis: Home Assistant ist eingetragene Marke der
  Home Assistant Foundation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-09 17:55:24 +02:00
Guido Schmit
74ebf6465d fix: Monatskürzel ~4px (≈1mm) nach rechts verschoben 2026-05-09 17:17:36 +02:00
Guido Schmit
87ebc22d17 fix: Stufenförmige Monatsgrenze – auch waagerechte Linie unter Vormonats-Tagen
Die Trennlinie hat jetzt eine 'Stufen'-Form: unten unter den letzten
Tagen des Vormonats in derselben Zeile, dann links runter zum 1. des
neuen Monats, dann oben über die ersten Tage des neuen Monats.

So ist die Monatsgrenze visuell vollständig umrandet.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-09 17:12:57 +02:00
Guido Schmit
496d4e5745 fix: Monatswechsel-Markierung – Linien über Events, mehr Abstand, immer waagerecht
Vier Korrekturen:
- Linie verschwand hinter Events: Pseudo-Elemente mit z-index 4 statt
  box-shadow inset, damit Trennlinien immer über den Event-Bars liegen
- Waagerechte Linie auch bei Monatswechsel mitten in einer Zeile (vorher
  nur wenn der Monat am Zeilenanfang begann)
- "1" verschwand hinter Events: cell-day und month-marker bekommen
  z-index 3 + position relative, plus Events-Overlay wird in Zeilen mit
  Monatsmarker um ~26px nach unten geschoben
- Mehr Abstand zwischen Monatskürzel und Trennlinie (padding-top 8px,
  margin-bottom am marker positiv statt negativ)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-09 17:08:17 +02:00
Guido Schmit
006c1f994c feat: Monatswechsel-Markierung in Monatsansicht
In der rolling Monatsansicht wird jetzt am Monatswechsel:
- eine dickere Trennlinie gezeichnet (links bei Wechsel mitten in Zeile,
  oben bei Zeilenstart)
- das 3-Buchstaben-Monatskürzel (z.B. JUL, AUG) groß über der "1"
  angezeigt

Beide Farben (Linie und Kürzel) sind in den Einstellungen unter
"Farben" individuell anpassbar (Default: #7090c0).

Backend: neue UserSettings-Felder month_divider_color und month_label_color
mit Migration. Frontend: applyTheme setzt entsprechende CSS-Variablen.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-09 16:49:52 +02:00
Scarriffle
15b6c90b11 fix(pwa): Layout berücksichtigt iOS-Safe-Area auch im Hauptbereich
Bisher bekam nur .topbar Safe-Area-Padding, aber .content-wrapper
rechnete weiter starr mit --topbar-h. Im PWA-Standalone-Modus auf
iPhones mit Notch lief der Kalender dadurch oben in die Status-Bar
und unten in den Home-Indicator hinein — die Wochentag-Header und
Tagesnummern der ersten Zeile waren verdeckt, die letzte Zeile
zu kurz.

- .content-wrapper: margin-top und height berechnen jetzt safe-top
  und safe-bottom mit ein
- .sidebar (Mobile-Overlay): top startet ebenfalls unterhalb der
  vergrösserten Topbar

Version v10 → v11.
2026-05-07 20:01:48 +02:00
Scarriffle
e52299fc08 fix: Plus-Icon symmetrisch + Service-Worker Network-First für HTML
- Erstellen-Button in der Sidebar: SVG-Path war asymmetrisch
  (M19 13h-6v9...) — Vertikalbalken hing nach unten heraus. Jetzt
  Standard-Plus mit gleich langen Armen.
- Service Worker holt index.html und version.js ab sofort per
  Network-First — neue Releases greifen damit beim nächsten
  Seiten-Reload, ohne dass der SW manuell deregistriert werden muss.
  Statics bleiben Cache-First für Offline-Tauglichkeit; auf
  Aktivierung wird der alte Cache komplett gelöscht.

Version v9 → v10.
2026-05-07 19:49:48 +02:00
Scarriffle
e7247d2ee1 fix(mobile): zweizeiliger Titel, kompaktes Event-Popup, keine Uhrzeit in Monatszelle
- Titel im Topbar wird auf Mobile auf 2 Zeilen aufgeteilt: Hauptlabel
  (z.B. "Mai – Jun") oben, Jahr ("2026") darunter in kleinerer Schrift.
  Auf Desktop bleibt es einzeilig durch margin-left auf der Year-Span.
- Event-Popup: 44px-Mindestgröße der Icon-Buttons greift hier nicht
  mehr — Buttons bleiben kompakt 32px, weniger Gap, schmaleres Popup
  (max 92vw / 340px), sodass das Schließen-X nicht aus dem Rand
  herausragt.
- Monatsansicht auf Mobile: Startuhrzeit ("00:00 Lemgo") wird
  versteckt, nur der Titel ist sichtbar. Auf Desktop wie bisher mit
  Uhrzeit-Präfix. Die Info bleibt im Termin-Popup verfügbar.

Version v8 → v9.
2026-05-07 19:40:20 +02:00
Scarriffle
15388e5806 feat(mobile): Heute-Button im Topbar + runder FAB für Termin-Erstellen
- "Heute"-Button auf Mobile wieder im Topbar-Center sichtbar
  (kompakter mit weniger Padding) statt nur im View-Popup.
- Neuer runder Floating-Action-Button unten rechts auf Mobile mit
  Plus-Icon, öffnet das "Termin erstellen"-Modal — Google-Calendar-
  artige Bedienung.
- Der "Erstellen"-Button in der Sidebar wird auf Mobile ausgeblendet,
  weil der FAB ihn ersetzt. Auf Desktop bleibt alles wie bisher.
- iOS-Safe-Area unten respektiert (Home-Indicator).

Version v7 → v8.
2026-05-07 19:31:17 +02:00
Scarriffle
85d427f9b2 perf: Event-Cache von ±8 Wochen auf ±10 Monate erweitern
Damit lädt beim Swipen durch Monate erst nach ~10 Monaten in beide
Richtungen erneut Daten nach. Vorher reichte der Cache nur ±2 Monate,
sodass nach 2-3 Wischen ein Spinner kam.

- CACHE_BUF      56  → 300 Tage (initial ±10 Monate)
- PREFETCH_EXT   56  → 180 Tage (Verlängerung bei Edge ~6 Monate)
- PREFETCH_EDGE  28  →  90 Tage (Trigger ~3 Monate vor Cache-Rand)

Version v6 → v7.
2026-05-07 19:23:35 +02:00
Scarriffle
2f8fed0600 feat(auth): "Angemeldet bleiben"-Checkbox auf Login-Screen
Wenn aktiviert, bekommt der JWT-Token statt der üblichen 7 Tage eine
Lebensdauer von 180 Tagen. Der Token liegt wie bisher in localStorage,
bleibt also bis zum manuellen Löschen / Cookie-Reset gültig.

- backend/routers/auth_router.py: LoginRequest.remember_me, längere
  expires_delta beim Token-Erstellen
- index.html: Checkbox unter dem 2FA-Feld
- api.js: login() reicht remember_me als 4. Parameter durch
- app.js: Wert aus #login-remember lesen und mitschicken
- Version v5 → v6
2026-05-07 19:17:26 +02:00
Scarriffle
264c47fefd fix(mobile): Monatstitel sichtbar, KW-Bubble unten, Termine mit Text, Long-Press, Settings-Hamburger
- View-Switcher auf Mobile in Popup-Menü ausgelagert (neuer Icon-Button
  rechts in der Topbar). Dadurch wird in der Topbar Platz frei für
  prev/next + Monatstitel ("Mai 2026" usw.).
- Topbar-Settings-Icon auf Mobile ausgeblendet, dafür neuer
  "Einstellungen"-Eintrag im User-Dropdown. "Heute" wandert ins
  View-Popup.
- KW-Bubble: von oben-links nach unten-links verschoben — überlappt
  jetzt nicht mehr die Tagesnummer.
- Termine in der Monatsansicht zeigen wieder ihren Text (kleinere
  14px-Höhe, 9px Schrift) statt nur farbiger Punkte.
- Long-Press auf einen Tag öffnet das Kontextmenü "Termin erstellen"
  (synthetisches contextmenu-Event nach 500 ms ohne Bewegung). Der
  nachfolgende synthetische Click wird unterdrückt.
- Settings-Modal: Sidebar (Darstellung/Konten/Benutzerverwaltung) auf
  Mobile als slide-in Overlay mit Hamburger-Toggle. Auf Desktop bleibt
  sie immer sichtbar.
- Version v4 → v5 (auch SW-Cache)
2026-05-07 19:08:20 +02:00
Scarriffle
fdf9af09cd fix(mobile): Zoom blocken, Long-Press, KW-Bubble, Swipe-Nav, Safe-Area
- Viewport: maximum-scale=1, user-scalable=no — kein Pinch-Zoom mehr
- Profil-Dropdown öffnet wieder: overflow:hidden auf .topbar-right
  in der Mobile-Media-Query entfernt (hatte das absolut positionierte
  Dropdown abgeschnitten)
- Long-Press auf Kalenderzellen markiert keinen Text mehr:
  user-select/touch-callout/tap-highlight in der ganzen Mobile-UI aus
- Long-Press auf Avatar zeigt nicht "Bild speichern":
  -webkit-touch-callout:none + pointer-events:none auf <img>
- Kalenderwochen erscheinen als kleine Bubble oben links in jeder
  Zeile statt als eigene 38px-Spalte
- Status-Bar-Overlap im Settings-Modal behoben: safe-area-inset-top
  auf .settings-page-header und Modal-Header in der Mobile-Media-Query
- Swipe links/rechts auf #view-container navigiert prev/next
  (≥60 px, überwiegend horizontal, < 700 ms)
- Version v3 → v4 (auch SW-Cache)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 18:52:51 +02:00
Scarriffle
528d63d7dd feat: PWA-Unterstützung und Mobile-Responsiveness
Macht Calendarr installierbar (Manifest + Service Worker) und auf
Smartphones bedienbar — additive Änderungen, kein Refactoring der
bestehenden Logik, Theme/Variablen unverändert.

PWA:
- frontend/manifest.json (theme #4285f4, bg #0e0e14, name/icons/scope)
- frontend/sw.js (cache-first für Statics, network-first für /api/*)
- frontend/icons/icon-192.png + icon-512.png + icon.svg
- backend/main.py: Routen für /manifest.json, /sw.js, /icons/* damit
  diese Pfade nicht vom SPA-Fallback abgefangen werden
- index.html: manifest-Link, theme-color, apple-touch-icon, apple-* Meta
- app.js: Service-Worker-Registrierung am Ende

Mobile (≤ 768px, additiv am Ende von app.css):
- Sidebar als Overlay mit body.sidebar-open + Backdrop-Element
- View-Switcher horizontal scrollbar wenn er nicht passt
- Monatsansicht zeigt nur farbige Punkte statt Titel
- Wochenansicht reduziert auf Tagesspalte (heute) wenn heute in der
  Woche ist (via :has()), sonst Standard-7-Spalten
- Modale auf voller Breite/Höhe
- Tap-Targets ≥ 44px (icon-btn, btn)
- Kein horizontaler Page-Overflow
- iOS-Safe-Area für Notch/Home-Indicator

Version v2 → v3.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 10:35:53 +02:00
Guido Schmit
23a18b0a20 fix: HA Update-Fallback auf delete+create wenn Integration nicht unterstützt
HA's Google-Calendar-Integration unterstützt kein calendar/event/update
und gibt 'not_supported: Calendar does not support event update' zurück.
In dem Fall wird jetzt automatisch der Termin gelöscht und neu erstellt
(beide Operationen werden von der Integration unterstützt). Der Termin
bekommt dabei eine neue UID, aber für den User sieht es wie ein Update aus.
2026-05-05 18:19:00 +02:00
Guido Schmit
e7174770f3 fix: Ganztägig-Termine zeigen End-Datum jetzt korrekt inklusive
iCal speichert DTEND exklusiv (Tag NACH dem letzten Tag). Bisher
führte das dazu, dass ein Termin mit Ende=18.08 nur bis zum 17.08
angezeigt wurde, obwohl der User 18.08 als letzten Tag erwartete.
Fix: Im Date-Picker arbeiten wir jetzt mit inklusiven End-Daten
('endet am 18.08' = 18.08 ist letzter Tag) und konvertieren beim
Speichern auf exklusiv (DTEND=19.08). Beim Laden umgekehrt: -1 Tag
fürs Anzeigen im Picker.
Betrifft: openEditEventModal, openCopyEditModal, Save-Handler.
2026-05-05 18:16:56 +02:00
Guido Schmit
c12f30cbbf feat: 'Vor dem Kopieren bearbeiten' Checkbox im Kopieren-Popup
Über der Kalenderliste im Kopieren-Menü gibt es jetzt eine Checkbox
'Vor dem Kopieren bearbeiten'. Wenn aktiviert und ein Ziel-Kalender
geklickt wird, öffnet sich der Termin-erstellen-Dialog mit allen
Daten des Quell-Termins vorausgefüllt (Titel, Datum, Ort, Beschreibung,
Farbe, Wiederholung) und dem Ziel-Kalender vorausgewählt.
2026-05-05 18:11:33 +02:00
Guido Schmit
da74e8dc78 fix: Quell-Kalender wird beim Kopieren ausgeblendet
buildWritableCalendars excluded jetzt den Kalender, in dem das Event
bereits ist – so kann man nicht mehr in denselben Kalender kopieren.
2026-05-05 17:54:15 +02:00
Guido Schmit
b1b08072e7 fix: Termine in unchecked Kalendern erstellen + HA-Kalender als Kopier-Ziel
- populateCalendarSelect: filtert jetzt nach !sidebar_hidden statt
  enabled. Unchecked (versteckte) Kalender bleiben so im
  Termin-erstellen-Dropdown verfügbar
- buildWritableCalendars: HA-Kalender werden als Kopier-Ziele aufgeführt
- copyEventToCalendar: routet HA-Ziele über /homeassistant/events
  Endpoint (vorher fielen sie in den CalDAV-Fallback)
2026-05-05 17:46:12 +02:00
Guido Schmit
b961cf94ef fix: CalDAV-Events bekommen source-Feld – Kalenderfarbe-Patch wirkt sofort
CalDAV-Events hatten bisher kein source-Feld gesetzt. applyCalendarColor
filtert aber via ev.source !== 'caldav', sodass der Patch nie auf
CalDAV-Events angewendet wurde – die Farbe blieb sichtbar bis F5.
Jetzt wird source: 'caldav' beim Anreichern der Events gesetzt.
2026-04-29 20:27:31 +02:00
Guido Schmit
dce9890bfa fix: Termin-Änderungen (Farbe, Titel, etc.) sofort ohne Reload anzeigen
Nach dem Speichern eines Termins wird das gecachte Event-Objekt
direkt in-place gepatcht und die View neu gerendert. Vorher war die
neue Farbe erst nach F5 sichtbar, weil zwar fetchAndRender(true)
aufgerufen wurde, aber der Render-Pfad das Update nicht zuverlässig
übernommen hat.
2026-04-29 20:22:34 +02:00
Guido Schmit
98870ccfb3 feat: HA-Events über WebSocket API (calendar/event/delete und update)
Manche HA-Integrationen registrieren nur den WebSocket-Handler, keinen
Service-Call. Die HA-Web-UI nutzt deshalb den WebSocket-Pfad. Calendarr
macht das jetzt auch:
- _ha_ws_call: minimaler WebSocket-Client für eine einzelne Command
- create: erst WS, dann Service-Call als Fallback
- update: nur WS (Service-Call existiert oft nicht)
- delete: nur WS (Service-Call existiert oft nicht)
Neue Dependency: websocket-client==1.8.0
2026-04-29 20:01:12 +02:00
Guido Schmit
1f010078d3 fix: HA Delete – mehrere Body-Formate ausprobieren
HA's Service-Call-Schema akzeptiert je nach Version verschiedene
Body-Shapes für entity_id. Wir probieren jetzt der Reihe nach:
1. entity_id als String
2. entity_id als Liste
3. target-Wrapper
Wenn alle fehlschlagen, klare Anweisung zum HA-Developer-Tools-Test.
2026-04-29 19:58:56 +02:00
Guido Schmit
80cde5aaee fix: HA Delete – Fallback auf REST DELETE und klarere Fehlermeldung
calendar.delete_event schlägt mit 400 fehl, wenn die HA-Integration
das Feature nicht unterstützt (z.B. Google-Calendar via HA hat nur
CREATE_EVENT, kein DELETE/UPDATE).
- Versucht erst Service-Call, dann REST DELETE als Fallback
- Bei 400 wird der User aufgeklärt, dass die Integration vermutlich
  kein Löschen unterstützt
2026-04-29 19:55:04 +02:00
Guido Schmit
0ea0a530f6 fix: HA Datetime-Format mit Timezone, leere Strings filtern, Debug-Logs
- _ha_format_dt: Parst ISO-Datetime zu datetime-Objekt, emittiert
  ohne Millisekunden, MIT Timezone-Offset. Vorher landeten Termine
  am falschen Datum, weil das Frontend UTC schickt aber wir die
  Timezone gestrippt haben → HA hat als lokale Zeit interpretiert
- Leere Strings werden nicht mehr in den Body aufgenommen (HA
  Validator könnte diese ablehnen)
- Logging in create/delete/update für besseres Debugging der HA-Calls
2026-04-29 19:50:52 +02:00
Guido Schmit
7047f55cf7 feat: HA-Termine erstellen über calendar.create_event Service
- POST /api/homeassistant/events Endpoint mit calendar.create_event
- Frontend: HA-Termine erstellen statt 'nicht unterstützt' Toast
- Datetime-Format an HA-Konvention angepasst:
  'YYYY-MM-DD HH:MM:SS' (Space-Separator, ohne Timezone)
- _ha_format_dt Helper für ISO → HA Datetime-Konvertierung
2026-04-29 19:07:02 +02:00
Guido Schmit
d859e969d0 fix: HA-Event-Update Fallback auf delete+create
calendar.update_event existiert erst ab HA 2024.6. Wenn der Service
nicht verfügbar ist (400), wird stattdessen delete_event + create_event
verwendet. Funktioniert mit HA 2022.5+.
2026-04-29 18:54:33 +02:00
Guido Schmit
f970276b91 fix: HA update_event – bessere Fehlermeldung mit JSON-Response-Details
Liest message-Feld aus HA JSON-Response und loggt den Request-Body
für Debugging
2026-04-29 18:52:32 +02:00
Guido Schmit
4964dcf7f3 fix: HA Service-Call Parameter-Format korrigiert
- update_event: start_date_time/end_date_time statt dtstart/dtend
- Ganztägig: start_date/end_date statt dtstart/dtend
- Datetime-Werte als ISO-String mit Timezone statt space-separated
- Bessere Fehlermeldungen: HA-Response-Body wird im Error angezeigt
2026-04-29 18:50:00 +02:00
Guido Schmit
86fa07d18c fix: HA-Events über Service-Call API statt nicht-existierender REST-Endpoints
PUT/DELETE /api/calendars/{entity_id}/{uid} existieren nicht in HA.
Stattdessen: POST /api/services/calendar/update_event und
POST /api/services/calendar/delete_event (HA 2023.x+)
2026-04-29 18:45:24 +02:00
Guido Schmit
59f53b5524 fix: HA-Event-Update URL-Encoding und Popup-Überlappung bei Lösch-Dialog
- HA Update/Delete: UID wird URL-encoded (@ → %40), Delete mit
  Fallback auf Service-Call API für ältere HA-Versionen
- Lösch-Dialog: Event-Popup wird geschlossen BEVOR der Bestätigungsdialog
  erscheint, kein Überlappen mehr
2026-04-29 18:38:43 +02:00
Guido Schmit
e5265b3694 fix: HA-Events bearbeitbar, Selected≠Today Styling, Serien-Löschung
- HA-Events: Update/Delete-Endpoints via HA REST API implementiert
- HA read-only Guard entfernt, stattdessen korrekte API-Anbindung
- Selected-Day: Outline-Ring statt gefüllter Kreis (Today bleibt gefüllt)
- Serien-Löschung: RECURRENCE-ID aus CalDAV-Events erkennen, damit
  expandierte Serientermine als recurring markiert werden und der
  Lösch-Dialog Einzel-/Serienlöschung anbietet
2026-04-29 18:31:58 +02:00
Guido Schmit
3e204d3355 fix: CalDAV-Update DTSTART-Fehler und HA-Events read-only
- caldav_client: del+add statt direkter Zuweisung bei VEVENT-Properties
  (behebt "DTSTART MUST appear exactly once" Validierungsfehler)
- HA-Events als read-only behandeln (kein Bearbeiten/Löschen im Popup)
- [object Object] Toast behoben: HA-Events fallen nicht mehr in CalDAV-Pfad
2026-04-29 18:21:56 +02:00
Guido Schmit
1638c9f631 fix: Runde-2-Fixes – Monatsauswahl, CalDAV-Update, Lösch-Dialog, EXDATE
- Monatsansicht: selectedDate von currentDate getrennt, Klick verschiebt View nicht mehr
- Selected-Day Styling: weißer Text auf Primary-Hintergrund statt nur Textfarbe
- Kontextmenü: --bg-surface statt fehlendem --bg-card
- CalDAV Update/Delete: parent Calendar-Objekt übergeben (behebt NoneType-Fehler)
- HA-Kalender im Kalender-Selektor ergänzt
- Browser-confirm() durch styled Modal-Dialog ersetzt mit Serie/Einzeln-Option
- EXDATE-Support: einzelne Vorkommen wiederkehrender Termine löschen (lokal + CalDAV)
- Fehlende i18n-Keys für Lösch-Dialog ergänzt (DE + EN)
2026-04-29 18:13:12 +02:00
Guido Schmit
013fb3dbc2 feat: Datum-Validierung, Monatsauswahl, CalDAV-Fix, wiederkehrende Termine
- End-Datum passt sich automatisch an wenn Start geändert wird (Duration bleibt erhalten)
- Erstellen-Button nutzt den aktuell angesehenen Tag statt immer heute
- Monatsansicht: Einzelklick = Tag auswählen, Doppelklick = Tagesansicht, Rechtsklick = Kontextmenü
- CalDAV URL-Matching robuster (Normalisierung, Path-Fallback, calendar_id Parameter)
- iCal-Abo-Termine sind nicht mehr bearbeitbar (Read-Only-Schutz)
- Wiederkehrende Termine mit RRULE-Support (täglich/wöchentlich/monatlich/jährlich/benutzerdefiniert)
2026-04-29 17:49:03 +02:00
Scarriffle
9a59911156 feat(ha): OAuth Authorization-Code-Flow statt kaputtem Password-Grant
Home Assistant unterstützt keinen Password-Grant — deshalb kam immer
"Ungültige Anmeldedaten", egal was eingegeben wurde. Jetzt wird der
Nutzer nach demselben Muster wie bei Google zur HA-Login-Seite
weitergeleitet, meldet sich dort an und kommt zurück zu Calendarr.
Änderungen:
- Neuer POST /api/homeassistant/auth-url und GET /callback Endpoint
- Account speichert client_id für spätere Token-Refreshes
- Modal: "Benutzername/Passwort" → "Mit Home Assistant anmelden"
- Frontend behandelt ?ha_connected=1 / ?ha_error=... nach Rückkehr
- Version v1 → v2
2026-04-24 12:57:38 +02:00
Scarriffle
e81bcfa269 fix: Versionsanzeige direkt im HTML statt per JS
Vorher wurde die Version erst in initCalendar() gesetzt – wenn JS
vorher fehlschlug, blieb der Text leer. Jetzt steht v1 direkt im
HTML (Titel, Login-Button, Sidebar-Button, Impressum-Modal).
Für künftige Releases: v1 → v2 in index.html + version.js ersetzen.
2026-04-24 11:47:55 +02:00
Scarriffle
f82b7cf739 feat: Versionsanzeige bei Copyright-Links und im Impressum
Neue version.js als Single Point of Truth (APP_VERSION).
Sidebar, Login-Screen und Impressum-Modal zeigen die aktuelle
Version an — ab jetzt bei jeder Änderung v2, v3 ... hochzählen.
Startet bei v1.
2026-04-24 11:36:43 +02:00
Scarriffle
a41e76b1bf fix: HA Passwort-Auth loggt nicht mehr aus, Radio-Layout korrigiert
- Backend gibt 400 statt 401 bei falschen HA-Credentials zurück, damit
  der globale api.js-Logout-Handler nicht ausgelöst wird
- Null-Guard im JS nach api.post verhindert den "calendars of null"-Crash
- Radio-Buttons für Anmeldemethode nicht mehr in form-group, damit
  input[type=radio] kein width:100% bekommt und sauber nebeneinander liegt
2026-04-24 11:26:50 +02:00
Scarriffle
7c55a6043d feat: Home Assistant Benutzername/Passwort-Authentifizierung
Ergänzt die HA-Integration um Password-Grant OAuth2: Nutzer können sich
nun wahlweise mit einem Long-Lived Token oder mit Benutzername/Passwort
anmelden. Access Tokens werden automatisch per Refresh-Token erneuert.
2026-04-21 11:02:32 +02:00
Scarriffle
4ffcd2628e fix: Color-Picker-Cursor korrekt auf Palette ausgerichtet
Der Cursor war relativ zum .gcp-Container positioniert, aber ohne den
Offset des Canvas innerhalb des Containers (Padding). Jetzt wird die
Canvas-Position via getBoundingClientRect() eingerechnet, sodass der
Cursor exakt auf der Farbpalette bleibt.
2026-04-13 09:22:42 +02:00
Scarriffle
e172386850 fix: Color-Picker-Cursor erreicht jetzt den rechten und unteren Rand
updateUI verwendete svCanvas.width (HTML-Attribut, 220px) statt der
tatsächlich gerenderten Breite. Wenn CSS den Canvas größer rendert,
stoppte der Cursor vor dem rechten Rand. Jetzt wird getBoundingClientRect()
verwendet, konsistent mit handleSV.
2026-04-13 09:18:03 +02:00
Scarriffle
e70433a61c fix: Ausgeblendete Kalender sofort aus Event-Cache entfernen
Beim Ausblenden eines Kalenders (sidebar_hidden) wurde fetchAndRender()
ohne force=true aufgerufen, wodurch der Cache nie invalidiert wurde und
die Events weiterhin angezeigt wurden. Jetzt wird der Cache sofort
gefiltert (wie beim Checkbox-Deaktivieren), ohne einen neuen Netzwerkaufruf.
2026-04-13 09:03:40 +02:00
Scarriffle
f9f305b213 feat: Home Assistant Kalender-Integration + Bugfix ausgeblendete Kalender
- Neue Integration: Home Assistant als Kalenderquelle via REST-API
  (GET /api/calendars + GET /api/calendars/{entity_id})
- Authentifizierung per Long-Lived Access Token
- Neues Modal zum Verbinden (Name, URL, Token) mit Fehlerbehandlung
- Kalender einzeln aktivierbar/deaktivierbar, Farbe änderbar
- Ausgeblendete HA-Kalender in Einstellungen wiederherstellbar
- Sync- und Trennen-Buttons in den Einstellungen
- Bugfix: CalDAV- und Google-Kalender mit sidebar_hidden=true
  liefern nun keine Events mehr im Kalender
2026-04-13 08:46:43 +02:00
Guido Schmit
7f123de148 fix: Tint für mehrtägige Ganztags-Events korrekt via alldayLayout
Der bisherige multiDayAllDayEvs-Filter hatte einen Timezone-Fehler
bei der Datumsberechnung (UTC-Parsing vs. lokale Zeit in UTC+2).
Neue Lösung: das bereits korrekt arbeitende alldayLayout wird direkt
als Quelle verwendet. Items mit colEnd > colStart sind mehrtägig —
die Spaltenindizes aus dem Layout ergeben den Tint-Bereich exakt.
2026-04-08 22:24:05 +02:00
Guido Schmit
a362ab21ae perf/fix: Kalender-Toggle ohne Ladescreen + Mehrfach-Tint als Verlauf
Ausblenden: Events werden sofort client-seitig aus dem Cache gefiltert
(calendar_id-Match), kein Netzwerkaufruf für die Ansicht nötig.
Einblenden: fetchAndRender(force, silent=true) überspringt showLoading(),
die aktuelle Ansicht bleibt sichtbar und wird nach dem Fetch aktualisiert.
Mehrere mehrtägige Events am selben Tag erzeugen jetzt einen vertikalen
Farbverlauf (linear-gradient) statt gestapelter Ebenen, bei denen nur
die letzte Farbe sichtbar war.
2026-04-08 22:14:08 +02:00
Guido Schmit
15c540bd25 fix: Kalender-Toggle sofort wirksam + Tint für mehrtägige Ganztags-Events
- fetchAndRender(true) beim Ein-/Ausblenden eines Kalenders erzwingt
  einen Neu-Abruf statt Cache-Treffer, damit die Änderung sofort sichtbar ist
- Tint-Berechnung in der Wochenansicht berücksichtigt jetzt auch
  mehrtägige Ganztags-Events (z.B. Urlaub), nicht nur mehrtägige
  Termin-Events — exclusive Enddaten werden dabei korrekt normalisiert
2026-04-08 21:59:41 +02:00
Guido Schmit
f2da15784b fix: Wochenkalender-Filter und per-Kalender Fehlerbehandlung
Der Wochenkalender von Google hat locale-spezifische IDs
(z.B. de.german#weeknum@...) die nicht im alten exakten Set-Filter
gefangen wurden. Dadurch wurde er in die DB gespeichert und
verursachte beim Event-Abruf einen API-Fehler.
Da der try/except die gesamte Kalender-Schleife umschloss, wurden
bei einem einzigen fehlerhaften Kalender alle anderen Events ebenfalls
verloren — Ursache für keine Termine trotz korrektem Token.
- _is_system_calendar(): prüft jetzt auch 'weeknum' als Substring
- _sync_google_calendars(): bereinigt bereits gespeicherte System-Kalender
- get_google_events(): try/except ist jetzt pro Kalender, nicht global
2026-04-08 21:49:24 +02:00
Guido Schmit
62e7fa8be1 fix: Google-Token-Fehler wird sichtbar gemacht und dem User gemeldet
Wenn der Access-Token eines Google-Accounts abläuft und der Refresh
fehlschlägt, wurde die leere Terminliste bisher still zurückgegeben
(kein Log, keine UI-Meldung). Jetzt wird der Fehler geloggt, an den
Aufrufer weitergegeben und als Toast-Meldung im Frontend angezeigt
("Token abgelaufen – bitte Konto trennen und neu verbinden").
Das Events-Endpoint gibt nun {events, errors} statt ein reines Array
zurück; das Frontend extrahiert die Events entsprechend.
2026-04-08 21:40:13 +02:00
307ee3c6a9 Fix: Scroll auf week-view verlegen – Header und Zeitraster immer gleich breit 2026-04-08 15:19:16 +02:00
0cce4fc721 Fix: Kopfzeilen-Breite per JS an Scrollbar-Breite anpassen 2026-04-08 15:15:37 +02:00
ecdf8917d6 Fix: overflow-y:scroll statt auto für konsistente Spaltenbreite 2026-04-08 15:13:00 +02:00
77462263e1 Fix: Spaltenbreite Zeitraster und Kopfzeile durch scrollbar-gutter angleichen 2026-04-08 15:11:13 +02:00
4a2f094a40 Fix: Ganztägig-Zeile sticky + korrekte Ausrichtung in Wochen-/Tagesansicht 2026-04-08 15:08:45 +02:00
4156bc4413 Feature: Dynamische Monatsansicht-Lanes + spanning All-Day-Balken in Wochenansicht
month.js: MAX_LANES wird jetzt aus der tatsächlichen Container-Höhe berechnet (kein hartes Limit von 3 mehr).
week.js: All-Day-Zeile verwendet jetzt dieselbe Overlay-Logik wie die Monatsansicht – Termine spannen als einzelner Balken über mehrere Tage.
2026-04-08 14:57:57 +02:00
f98ff69a9b Feature: Mehrtägige Termine in Wochen-/Tagesansicht vollständig anzeigen
Timed-Events die mehrere Tage überspannen werden neu in der Ganztags-Zeile für jeden betroffenen Tag als Bar angezeigt (am Starttag mit Uhrzeit). Die Tagesspalten erhalten einen 15%-Farbhintergrund (col-span-tint) um die Abdeckung zu visualisieren.
2026-04-08 14:47:11 +02:00
eea150373e Fix: CalDAV delete/update, Copy-Menü-Reset, Timezone beim Kopieren
- caldav_client: client.event() → caldav.Event() mit resource.load() für update/delete (DAVClient hat keine event()-Methode)
- Popup: Copy-Menü wird beim Öffnen eines neuen Events immer zurückgesetzt
- copyEventToCalendar: start/end via new Date().toISOString() normalisiert → verhindert 2h-Verschiebung bei Terminen ohne Timezone-Info
2026-04-08 14:43:34 +02:00
5dcde0a3ef Feature: Enddatum im Popup + Kopieren-nach-Kalender-Button
Enddatum wird im Event-Popup angezeigt wenn Termin über Mitternacht geht. Neuer Kopieren-Button (📋) im Popup öffnet Kalender-Auswahl und dupliziert den Termin in den gewählten Kalender (CalDAV / Lokal / Google).
2026-04-08 14:34:01 +02:00
d29cbb8450 Fix: Mehrtägige Events auf Tagesende begrenzt, Stundenhöhen weiter reduziert
Timed-Events in Wochen-/Tagesansicht werden jetzt auf Mitternacht (24:00) des Starttages gekürzt – keine kilometerhohen Balken mehr bei tagesübergreifenden Terminen. Stundenhöhen: 36/54/72/90 → 28/44/60/80px; Kompakt (28px) zeigt 24h = 672px.
2026-04-08 14:27:24 +02:00
dea15191d8 Fix: Scroll-Navigation nur für Monat/Quartal, Stundenhöhen reduziert
Wheel-Scroll ändert Zeitraum jetzt nur noch in Monats- und Quartalsansicht. In Wochen-, Tag- und Terminansicht scrollt die Seite normal. Stundenhöhen: 40/60/80/100 → 36/54/72/90px; Kompakt (36px) zeigt 24h auf 1080p ohne Scrollen.
2026-04-08 14:19:13 +02:00
e9a307a20d Fix: Quartalsansicht – zufällige Today-Markierungen behoben, Button nach links verschoben
Selected-Klasse aus der Quartalsansicht entfernt (war visuell identisch mit Today). Button-Reihenfolge: Quartal > Monat > Woche > Tag > Termine.
2026-04-08 14:15:48 +02:00
e8b5bb3a40 Feature: Quartalsansicht hinzugefügt
Neue Ansicht zeigt 3 Monate eines Quartals nebeneinander mit farbigen Event-Dots, Quartal-Navigation und Titelanzeige (z.B. Q2 2026). Klick auf Tag wechselt in Tagesansicht. Zweisprachig (DE/EN).
2026-04-08 14:11:00 +02:00
Guido Schmit
3846af527a fix: Kalenderfarbe wird sofort ohne Reload aktualisiert
Statt nach Farbänderung den Cache zu invalidieren und neu zu laden,
wird calendarColor direkt in-place auf allen gecachten Events gepatcht
und dann nur renderView() aufgerufen. Kein Netzwerk-Request, sofortige
Darstellung der neuen Farbe.
2026-04-07 22:18:10 +02:00
Guido Schmit
59751349b7 perf: Sliding-window Cache — Hintergrund-Prefetch bei Cache-Randnähe
Wenn die aktuelle Ansicht weniger als 4 Wochen vom Cache-Rand entfernt
ist, werden im Hintergrund 8 weitere Wochen in diese Richtung geladen
und in den Cache gemergt. Der Cache wächst damit automatisch mit der
Navigation mit — kein sichtbarer Ladevorgang auch bei langen Sprüngen.
2026-04-07 22:09:11 +02:00
Guido Schmit
bc93474f49 perf: Event cache mit ±8-Wochen-Puffer für schnelle Navigation
Beim ersten Laden wird ein Fenster von ±8 Wochen um die aktuelle
Ansicht geholt. Wochenweise Navigation trifft danach den Cache
sofort (kein Spinner, kein Netzwerk). Nach echten Datenänderungen
(Event speichern/löschen, Sync, Konto-Änderungen) wird der Cache
invalidiert und neu geladen.
2026-04-07 22:05:03 +02:00
Guido Schmit
62ac0162eb feat: Rolling 5-week month view with week-by-week scroll
Month view now shows 5 weeks starting from the week containing
currentDate (not fixed to month boundaries), enabling views like
"mid-April to mid-May". Prev/Next buttons jump 4 weeks; mouse
wheel scrolls 1 week at a time with 500ms debounce.
2026-04-07 21:58:51 +02:00
Guido Schmit
46f6765087 fix: Month scroll navigates by full month, not by week
Scrolling in month view was moving currentDate by 7 days, but the
grid always renders the complete month — so 4 scrolls were needed
before any visual change. Now each scroll step advances/retreats
by exactly one month (same as the prev/next buttons).
2026-04-07 21:51:15 +02:00
Guido Schmit
e2f98520e2 fix: Month grid lines, scroll throttle, custom dark date/time picker
- Month view: Replaced day-strip+events-area with full-height column
  divs (.month-col) so borders extend the full row height and clicking
  anywhere in a day column (including below events) navigates to day view.
  Events overlay uses pointer-events:none (pass-through) while span bars
  and +N-more labels stay pointer-events:all.
- Scroll navigation: Changed wheel handler from 80ms debounce to 500ms
  leading-edge throttle — one navigation per trackpad gesture.
- Custom date/time picker (date-picker.js): Dark calendar grid with
  prev/next navigation, today/selected highlighting, and a CSS
  scroll-snap time scroller (hours 0-23, minutes 0-59) matching the
  app's primary color. Language-aware (month names, day headers via t()).
- Event modal datetime inputs replaced with hidden inputs + .dt-display
  click targets that open the custom picker. setDtValue() helper keeps
  hidden input and display label in sync.
2026-04-07 21:44:44 +02:00
Guido Schmit
94cbe4e7fb feat: Spanning event bars, wheel nav, dark datetime picker, segmented settings UI
- Month view: Multi-day events render as continuous Google Calendar-style
  spanning bars across days/weeks using a greedy lane-packing algorithm.
  Timed multi-day events no longer repeat per day.
- Mouse wheel / trackpad scrolls week-by-week in month view, day/week in
  other views (debounced, prevents default page scroll).
- datetime-local/date inputs now use color-scheme:dark so the native
  browser picker opens in dark mode; calendar icon styled to match.
- Contrast/hour-height selectors redesigned as connected segmented pill
  controls instead of individual tiles.
- Hidden calendars list gains proper padding and separator lines.
- "Google Konten" settings panel renamed "Konten" and expanded to show
  CalDAV, local calendars, iCal subscriptions, and Google accounts in
  one unified panel with sync/disconnect actions.
- New i18n keys added for accounts panel in both de and en.
2026-04-07 21:20:42 +02:00
11 changed files with 624 additions and 224 deletions

View File

@@ -4,11 +4,16 @@ import sys
from pathlib import Path from pathlib import Path
import uvicorn import uvicorn
from fastapi import FastAPI, HTTPException from fastapi import FastAPI, HTTPException, Request
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from sqlalchemy import text from sqlalchemy import text
# How long the browser may keep static assets before revalidating.
STATIC_MAX_AGE_SECONDS = 2 * 60 * 60 # 2 hours
NO_CACHE = "no-cache, no-store, must-revalidate"
STATIC_CACHE = f"public, max-age={STATIC_MAX_AGE_SECONDS}, must-revalidate"
sys.path.insert(0, str(Path(__file__).parent)) sys.path.insert(0, str(Path(__file__).parent))
from database import Base, engine from database import Base, engine
@@ -109,10 +114,56 @@ def _migrate():
except Exception: except Exception:
pass pass
try:
conn.execute(text("ALTER TABLE user_settings ADD COLUMN text_color VARCHAR(7)"))
conn.commit()
except Exception:
pass
try:
conn.execute(text("ALTER TABLE user_settings ADD COLUMN line_color VARCHAR(7)"))
conn.commit()
except Exception:
pass
try:
conn.execute(text("ALTER TABLE user_settings ADD COLUMN bg_color VARCHAR(7)"))
conn.commit()
except Exception:
pass
_migrate() _migrate()
app = FastAPI(title="Calendarr", docs_url=None, redoc_url=None) app = FastAPI(title="Calendarr", docs_url=None, redoc_url=None)
@app.middleware("http")
async def add_cache_headers(request: Request, call_next):
"""Force ≤ 2h browser cache for static assets and disable cache for the
entry HTML / SW / version file. API responses are left alone (handlers
decide their own caching)."""
response = await call_next(request)
path = request.url.path
# Never cache: entry HTML, manifest, service worker, version marker
if (
path in ("/", "/index.html", "/manifest.json", "/sw.js")
or path == "/static/js/version.js"
):
response.headers["Cache-Control"] = NO_CACHE
response.headers["Pragma"] = "no-cache"
response.headers["Expires"] = "0"
# 2h cache for the rest of the frontend (JS/CSS/icons/etc.)
elif path.startswith("/static/") or path.startswith("/icons/"):
response.headers["Cache-Control"] = STATIC_CACHE
# SPA fallback (everything else that isn't an API route) returns HTML;
# don't let the browser cache that either.
elif not path.startswith("/api/"):
response.headers["Cache-Control"] = NO_CACHE
return response
app.include_router(auth_router.router, prefix="/api/auth", tags=["auth"]) app.include_router(auth_router.router, prefix="/api/auth", tags=["auth"])
app.include_router(users_router.router, prefix="/api/users", tags=["users"]) app.include_router(users_router.router, prefix="/api/users", tags=["users"])
app.include_router(caldav_router.router, prefix="/api/caldav", tags=["caldav"]) app.include_router(caldav_router.router, prefix="/api/caldav", tags=["caldav"])

View File

@@ -84,6 +84,9 @@ class UserSettings(Base):
language = Column(String(5), default="de") language = Column(String(5), default="de")
month_divider_color = Column(String(7), default="#7090c0") month_divider_color = Column(String(7), default="#7090c0")
month_label_color = Column(String(7), default="#7090c0") month_label_color = Column(String(7), default="#7090c0")
text_color = Column(String(7), nullable=True) # Override für --text-1 (NULL = nutze text_contrast)
line_color = Column(String(7), nullable=True) # Override für --border (NULL = nutze line_contrast)
bg_color = Column(String(7), nullable=True) # Override für --bg-app (NULL = Default)
user = relationship("User", back_populates="settings") user = relationship("User", back_populates="settings")

View File

@@ -24,6 +24,9 @@ class SettingsUpdate(BaseModel):
language: Optional[str] = None language: Optional[str] = None
month_divider_color: Optional[str] = None month_divider_color: Optional[str] = None
month_label_color: Optional[str] = None month_label_color: Optional[str] = None
text_color: Optional[str] = None
line_color: Optional[str] = None
bg_color: Optional[str] = None
def _settings_dict(s: models.UserSettings) -> dict: def _settings_dict(s: models.UserSettings) -> dict:
@@ -40,6 +43,9 @@ def _settings_dict(s: models.UserSettings) -> dict:
"language": s.language or "de", "language": s.language or "de",
"month_divider_color": s.month_divider_color or "#7090c0", "month_divider_color": s.month_divider_color or "#7090c0",
"month_label_color": s.month_label_color or "#7090c0", "month_label_color": s.month_label_color or "#7090c0",
"text_color": s.text_color,
"line_color": s.line_color,
"bg_color": s.bg_color,
} }
@@ -76,7 +82,15 @@ def update_settings(
settings = models.UserSettings(user_id=current_user.id) settings = models.UserSettings(user_id=current_user.id)
db.add(settings) db.add(settings)
for field, value in data.model_dump(exclude_none=True).items(): # For these three override colours, an explicit null is meaningful
# ("reset to default") and must be persisted as NULL. All other fields
# keep the previous behaviour where a null/missing value is ignored.
NULLABLE_OVERRIDES = {"text_color", "line_color", "bg_color"}
update_data = data.model_dump(exclude_unset=True)
for field, value in update_data.items():
if field in NULLABLE_OVERRIDES:
setattr(settings, field, value or None)
elif value is not None:
setattr(settings, field, value) setattr(settings, field, value)
db.commit() db.commit()

View File

@@ -9,50 +9,38 @@
--accent: #ea4335; --accent: #ea4335;
--today-color: #4285f4; --today-color: #4285f4;
/* Layered surfaces — subtly cool-tinted, slightly more depth between layers --bg-app: #0e0e14;
so panels read via elevation instead of hard table-like 1px borders */ --bg-topbar: #18181f;
--bg-app: #0c0c13; --bg-sidebar: #18181f;
--bg-topbar: #14141d; --bg-surface: #22222c;
--bg-sidebar: #14141d; --bg-hover: #2a2a38;
--bg-surface: #1d1d2a; --bg-active: #32324a;
--bg-2: #16161f; /* used by quarter month cards */
--bg-card: #1d1d2a; /* used by recurrence day buttons */
--bg-hover: #262636;
--bg-active: #31314f;
--text-1: #e8e8f0; --text-1: #e8e8f0;
--text-2: #9090aa; --text-2: #9090aa;
--text-3: #55556a; --text-3: #55556a;
--border: #2e2e40; --border: #2e2e40;
--border-light: #242438; --border-light: #242438;
--scrollbar: #36364a; --scrollbar: #30303c;
--topbar-h: 64px; --topbar-h: 64px;
--sidebar-w: 256px; --sidebar-w: 256px;
--shadow-sm: 0 1px 3px rgba(0,0,0,.30); --shadow: 0 2px 12px rgba(0,0,0,.45);
--shadow: 0 6px 20px rgba(0,0,0,.40); --shadow-lg: 0 8px 28px rgba(0,0,0,.55);
--shadow-lg: 0 16px 44px rgba(0,0,0,.55); --radius: 8px;
--radius-lg: 20px; --radius-sm: 4px;
--radius: 14px; --transition: .15s ease;
--radius-sm: 9px;
--transition: .18s cubic-bezier(.4, 0, .2, 1);
--ring: 0 0 0 3px var(--primary-dim);
} }
/* ── Reset ──────────────────────────────────────────────── */ /* ── Reset ──────────────────────────────────────────────── */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html, body { height: 100%; overflow: hidden; } html, body { height: 100%; overflow: hidden; }
body { body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, font-family: 'Google Sans', 'Roboto', system-ui, sans-serif;
'Helvetica Neue', system-ui, sans-serif;
background: var(--bg-app); background: var(--bg-app);
color: var(--text-1); color: var(--text-1);
font-size: 14px; font-size: 14px;
line-height: 1.5; line-height: 1.5;
letter-spacing: .1px;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
} }
input, select, textarea, button { font-family: inherit; font-size: inherit; color: inherit; } input, select, textarea, button { font-family: inherit; font-size: inherit; color: inherit; }
button { cursor: pointer; border: none; background: none; } button { cursor: pointer; border: none; background: none; }
@@ -67,59 +55,129 @@ a { color: var(--primary); text-decoration: none; }
.flex-col { display: flex; flex-direction: column; } .flex-col { display: flex; flex-direction: column; }
.gap-8 { gap: 8px; } .gap-8 { gap: 8px; }
/* ── Buttons ────────────────────────────────────────────── */ /* ── Buttons ──────────────────────────────────────────────
Modern pill style: fully rounded, subtle coloured shadow on the
prominent variants, lift on hover, snap back on press. The
primary-coloured glow follows --primary via color-mix(), so it adapts
when the user changes the theme colour in settings. */
.btn { .btn {
display: inline-flex; align-items: center; gap: 6px; display: inline-flex; align-items: center; justify-content: center;
padding: 8px 16px; border-radius: 20px; gap: 8px;
font-weight: 500; padding: 10px 22px;
transition: background var(--transition), color var(--transition), border-radius: 999px;
box-shadow var(--transition), transform var(--transition), font-weight: 500; font-size: 14px;
filter var(--transition); letter-spacing: .1px;
white-space: nowrap; white-space: nowrap;
user-select: none;
-webkit-tap-highlight-color: transparent;
transition:
background var(--transition),
color var(--transition),
border-color var(--transition),
box-shadow .18s ease,
transform .12s ease,
filter var(--transition);
} }
.btn:active { transform: translateY(1px); } .btn:active { transform: translateY(0) scale(.985); transition-duration: .05s; }
.btn:focus-visible { outline: none; box-shadow: var(--ring); } .btn:focus-visible {
outline: 2px solid var(--primary);
outline-offset: 2px;
}
.btn-primary { .btn-primary {
background: var(--primary); background: var(--primary);
background: linear-gradient(135deg, var(--primary), color-mix(in srgb, var(--primary) 80%, #14141d));
color: #fff; color: #fff;
box-shadow: 0 2px 10px var(--primary-dim); box-shadow: 0 2px 8px rgba(66,133,244,.28);
box-shadow: 0 2px 8px color-mix(in srgb, var(--primary) 30%, transparent);
} }
.btn-primary:hover { filter: brightness(1.10); box-shadow: 0 5px 18px var(--primary-dim); } .btn-primary:hover {
filter: brightness(1.08);
transform: translateY(-1px);
box-shadow: 0 6px 18px rgba(66,133,244,.42);
box-shadow: 0 6px 18px color-mix(in srgb, var(--primary) 45%, transparent);
}
.btn-secondary { .btn-secondary {
background: var(--bg-surface); background: var(--bg-surface);
color: var(--text-1); color: var(--text-1);
border: 1px solid var(--border); border: 1px solid var(--border);
} }
.btn-secondary:hover { background: var(--bg-hover); } .btn-secondary:hover {
.btn-ghost { color: var(--primary); } background: var(--bg-hover);
.btn-ghost:hover { background: var(--primary-dim); } border-color: var(--primary);
.btn-danger { background: var(--accent); color: #fff; } transform: translateY(-1px);
.btn-danger:hover { filter: brightness(1.1); } }
.btn-full { width: 100%; justify-content: center; }
.btn-ghost {
color: var(--primary);
background: transparent;
}
.btn-ghost:hover {
background: var(--primary-dim);
transform: translateY(-1px);
}
.btn-danger {
background: var(--accent);
color: #fff;
box-shadow: 0 2px 8px rgba(234,67,53,.28);
box-shadow: 0 2px 8px color-mix(in srgb, var(--accent) 30%, transparent);
}
.btn-danger:hover {
filter: brightness(1.08);
transform: translateY(-1px);
box-shadow: 0 6px 18px rgba(234,67,53,.42);
box-shadow: 0 6px 18px color-mix(in srgb, var(--accent) 45%, transparent);
}
.btn-full { width: 100%; }
/* The big sidebar "Erstellen" button: same pill aesthetic, primary tinted,
lives in the calm dark sidebar so the shadow is a touch stronger. */
.btn-fab { .btn-fab {
display: flex; align-items: center; gap: 10px; display: flex; align-items: center; gap: 10px;
padding: 12px 20px; border-radius: 24px; padding: 12px 22px;
background: var(--bg-surface); border-radius: 999px;
color: var(--text-1); background: var(--primary);
color: #fff;
font-weight: 600; font-weight: 600;
box-shadow: var(--shadow);
margin: 16px 12px 8px; margin: 16px 12px 8px;
transition: background var(--transition), box-shadow var(--transition); box-shadow: 0 4px 14px rgba(66,133,244,.32);
box-shadow: 0 4px 14px color-mix(in srgb, var(--primary) 35%, transparent);
transition:
background var(--transition),
box-shadow .18s ease,
transform .12s ease,
filter var(--transition);
} }
.btn-fab:hover { background: var(--bg-hover); box-shadow: var(--shadow-lg); } .btn-fab:hover {
filter: brightness(1.08);
transform: translateY(-1px);
box-shadow: 0 8px 22px rgba(66,133,244,.5);
box-shadow: 0 8px 22px color-mix(in srgb, var(--primary) 50%, transparent);
}
.btn-fab:active { transform: translateY(0) scale(.985); }
/* Circular icon buttons (topbar nav, modal close, etc.) */
.icon-btn { .icon-btn {
display: inline-flex; align-items: center; justify-content: center; display: inline-flex; align-items: center; justify-content: center;
width: 40px; height: 40px; border-radius: 50%; width: 40px; height: 40px;
border-radius: 50%;
color: var(--text-2); color: var(--text-2);
transition: background var(--transition), color var(--transition), transform var(--transition);
flex-shrink: 0; flex-shrink: 0;
-webkit-tap-highlight-color: transparent;
transition:
background var(--transition),
color var(--transition),
transform .1s ease;
} }
.icon-btn svg { width: 20px; height: 20px; fill: currentColor; } .icon-btn svg { width: 20px; height: 20px; fill: currentColor; }
.icon-btn:hover { background: var(--bg-hover); color: var(--text-1); } .icon-btn:hover { background: var(--bg-hover); color: var(--text-1); }
.icon-btn:active { transform: scale(.92); } .icon-btn:active { transform: scale(.92); }
.icon-btn:focus-visible { outline: none; box-shadow: var(--ring); } .icon-btn:focus-visible {
outline: 2px solid var(--primary);
outline-offset: 2px;
}
/* ── Auth Screens ───────────────────────────────────────── */ /* ── Auth Screens ───────────────────────────────────────── */
.auth-screen { .auth-screen {
@@ -158,20 +216,26 @@ a { color: var(--primary); text-decoration: none; }
display: flex; flex-direction: column; gap: 6px; display: flex; flex-direction: column; gap: 6px;
margin-bottom: 16px; margin-bottom: 16px;
} }
.form-group label { color: var(--text-2); font-size: 13px; font-weight: 500; letter-spacing: 0; } .form-group label { color: var(--text-2); font-size: 12px; font-weight: 500; text-transform: uppercase; letter-spacing: .5px; }
.form-group input, .form-group select, .form-group textarea { .form-group input, .form-group select, .form-group textarea {
background: var(--bg-app); background: var(--bg-app);
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: var(--radius-sm); border-radius: 8px;
padding: 10px 12px; padding: 11px 14px;
color: var(--text-1); color: var(--text-1);
outline: none; outline: none;
transition: border-color var(--transition), box-shadow var(--transition); transition: border-color var(--transition), box-shadow var(--transition);
width: 100%; width: 100%;
} }
.form-group input:hover:not(:focus),
.form-group select:hover:not(:focus),
.form-group textarea:hover:not(:focus) {
border-color: var(--text-3);
}
.form-group input:focus, .form-group select:focus, .form-group textarea:focus { .form-group input:focus, .form-group select:focus, .form-group textarea:focus {
border-color: var(--primary); border-color: var(--primary);
box-shadow: var(--ring); box-shadow: 0 0 0 3px rgba(66,133,244,.18);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 18%, transparent);
} }
.form-group textarea { resize: vertical; } .form-group textarea { resize: vertical; }
@@ -312,8 +376,7 @@ a { color: var(--primary); text-decoration: none; }
position: fixed; top: 0; left: 0; right: 0; z-index: 100; position: fixed; top: 0; left: 0; right: 0; z-index: 100;
height: var(--topbar-h); height: var(--topbar-h);
background: var(--bg-topbar); background: var(--bg-topbar);
border-bottom: 1px solid var(--border-light); border-bottom: 1px solid var(--border);
box-shadow: var(--shadow-sm);
display: flex; align-items: center; display: flex; align-items: center;
padding: 0 8px; padding: 0 8px;
gap: 8px; gap: 8px;
@@ -326,16 +389,15 @@ a { color: var(--primary); text-decoration: none; }
.view-title { font-size: 20px; font-weight: 400; color: var(--text-1); white-space: nowrap; padding-left: 8px; } .view-title { font-size: 20px; font-weight: 400; color: var(--text-1); white-space: nowrap; padding-left: 8px; }
.topbar-right { display: flex; align-items: center; gap: 4px; } .topbar-right { display: flex; align-items: center; gap: 4px; }
.view-switcher { display: flex; gap: 2px; padding: 3px; background: var(--bg-surface); border-radius: 22px; border: 1px solid var(--border-light); } .view-switcher { display: flex; background: var(--bg-surface); border-radius: 20px; overflow: hidden; border: 1px solid var(--border); }
.view-btn { .view-btn {
padding: 6px 14px; font-size: 13px; font-weight: 500; padding: 6px 14px; font-size: 13px; font-weight: 500;
color: var(--text-2); color: var(--text-2);
transition: background var(--transition), color var(--transition), box-shadow var(--transition); transition: background var(--transition), color var(--transition);
border-radius: 18px; border-radius: 20px;
} }
.view-btn:hover { background: var(--bg-hover); color: var(--text-1); } .view-btn:hover { background: var(--bg-hover); color: var(--text-1); }
.view-btn:focus-visible { outline: none; box-shadow: var(--ring); } .view-btn.active { background: var(--primary-dim); color: var(--primary); }
.view-btn.active { background: var(--primary-dim); color: var(--primary); font-weight: 600; box-shadow: inset 0 0 0 1px var(--primary-dim); }
.user-menu-wrapper { position: relative; } .user-menu-wrapper { position: relative; }
.user-avatar { .user-avatar {
@@ -378,7 +440,7 @@ a { color: var(--primary); text-decoration: none; }
.sidebar { .sidebar {
width: var(--sidebar-w); width: var(--sidebar-w);
background: var(--bg-sidebar); background: var(--bg-sidebar);
border-right: 1px solid var(--border-light); border-right: 1px solid var(--border);
flex-shrink: 0; flex-shrink: 0;
overflow-y: auto; overflow-y: auto;
overflow-x: hidden; overflow-x: hidden;
@@ -495,7 +557,8 @@ a { color: var(--primary); text-decoration: none; }
} }
.month-kw-cell { .month-kw-cell {
position: absolute; left: 0; top: 0; bottom: 0; width: 38px; position: absolute; left: 0; top: 0; bottom: 0; width: 38px;
display: flex; align-items: center; justify-content: center; display: flex; align-items: flex-start; justify-content: center;
padding-top: 6px;
font-size: 13px; color: var(--text-3); font-weight: 700; font-size: 13px; color: var(--text-3); font-weight: 700;
border-right: 1px solid var(--border-light); border-right: 1px solid var(--border-light);
cursor: default; user-select: none; z-index: 1; cursor: default; user-select: none; z-index: 1;
@@ -509,7 +572,7 @@ a { color: var(--primary); text-decoration: none; }
} }
.month-col:last-child { border-right: none; } .month-col:last-child { border-right: none; }
.month-col:hover { background: var(--bg-hover); } .month-col:hover { background: var(--bg-hover); }
.month-col.today { background: color-mix(in srgb, var(--today-color) 9%, transparent); } .month-col.today { background: rgba(66,133,244,.08); }
.month-col.month-selected { background: var(--primary-dim); } .month-col.month-selected { background: var(--primary-dim); }
.month-col.month-selected .cell-day { border: 2px solid var(--primary); color: var(--primary); font-weight: 700; } .month-col.month-selected .cell-day { border: 2px solid var(--primary); color: var(--primary); font-weight: 700; }
.month-col.month-selected .cell-day.today { background: var(--today-color); color: #fff; border: none; } .month-col.month-selected .cell-day.today { background: var(--today-color); color: #fff; border: none; }
@@ -623,7 +686,8 @@ a { color: var(--primary); text-decoration: none; }
/* KW badge in week view header gutter */ /* KW badge in week view header gutter */
.week-kw-badge { .week-kw-badge {
font-size: 14px; font-weight: 700; color: var(--text-3); font-size: 14px; font-weight: 700; color: var(--text-3);
display: flex; align-items: center; justify-content: center; display: flex; align-items: flex-end; justify-content: center;
padding-bottom: 6px;
text-transform: uppercase; letter-spacing: .3px; text-transform: uppercase; letter-spacing: .3px;
user-select: none; user-select: none;
} }
@@ -663,7 +727,7 @@ a { color: var(--primary); text-decoration: none; }
position: absolute; position: absolute;
height: 18px; line-height: 18px; height: 18px; line-height: 18px;
font-size: 11px; font-weight: 500; font-size: 11px; font-weight: 500;
padding: 0 6px; border-radius: 6px; padding: 0 6px; border-radius: 3px;
cursor: pointer; pointer-events: all; cursor: pointer; pointer-events: all;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
} }
@@ -714,7 +778,7 @@ a { color: var(--primary); text-decoration: none; }
/* Timed events */ /* Timed events */
.timed-event { .timed-event {
position: absolute; border-radius: 7px; position: absolute; border-radius: 4px;
padding: 2px 4px; cursor: pointer; overflow: hidden; padding: 2px 4px; cursor: pointer; overflow: hidden;
font-size: 11px; font-weight: 500; font-size: 11px; font-weight: 500;
border-left: 3px solid rgba(0,0,0,.25); border-left: 3px solid rgba(0,0,0,.25);
@@ -746,19 +810,16 @@ a { color: var(--primary); text-decoration: none; }
flex-direction: column; flex-direction: column;
min-width: 0; min-width: 0;
background: var(--bg-2); background: var(--bg-2);
border: 1px solid var(--border-light); border: 1px solid var(--border);
border-radius: var(--radius); border-radius: 8px;
overflow: hidden; overflow: hidden;
box-shadow: var(--shadow-sm);
transition: box-shadow var(--transition), transform var(--transition);
} }
.qtr-month:hover { box-shadow: var(--shadow); transform: translateY(-2px); }
.qtr-month-name { .qtr-month-name {
font-size: 13px; font-size: 13px;
font-weight: 600; font-weight: 600;
color: var(--text-1); color: var(--text-1);
padding: 10px 12px 8px; padding: 10px 12px 8px;
border-bottom: 1px solid var(--border-light); border-bottom: 1px solid var(--border);
letter-spacing: .3px; letter-spacing: .3px;
} }
.qtr-month-grid { padding: 6px 8px 8px; } .qtr-month-grid { padding: 6px 8px 8px; }
@@ -861,34 +922,24 @@ a { color: var(--primary); text-decoration: none; }
/* ── Modals ─────────────────────────────────────────────── */ /* ── Modals ─────────────────────────────────────────────── */
.modal-overlay { .modal-overlay {
position: fixed; inset: 0; z-index: 500; position: fixed; inset: 0; z-index: 500;
background: rgba(8,8,14,.55); background: rgba(0,0,0,.6);
-webkit-backdrop-filter: blur(5px);
backdrop-filter: blur(5px);
display: flex; align-items: center; justify-content: center; display: flex; align-items: center; justify-content: center;
padding: 16px; padding: 16px;
} }
.modal-card { .modal-card {
background: var(--bg-surface); background: var(--bg-surface);
border: 1px solid var(--border-light); border: 1px solid var(--border);
border-radius: var(--radius); border-radius: var(--radius);
width: 100%; width: 100%;
max-height: 90vh; overflow-y: auto; max-height: 90vh; overflow-y: auto;
box-shadow: var(--shadow-lg); box-shadow: var(--shadow-lg);
animation: card-in .2s cubic-bezier(.4, 0, .2, 1);
}
@keyframes card-in {
from { opacity: 0; transform: translateY(8px) scale(.985); }
to { opacity: 1; transform: none; }
}
@media (prefers-reduced-motion: reduce) {
.modal-card { animation: none; }
} }
.modal-header { .modal-header {
display: flex; align-items: center; display: flex; align-items: center;
padding: 16px 20px; border-bottom: 1px solid var(--border-light); padding: 16px 20px; border-bottom: 1px solid var(--border);
gap: 8px; gap: 8px;
} }
.modal-header h3 { font-size: 18px; font-weight: 600; flex: 1; letter-spacing: -.2px; } .modal-header h3 { font-size: 18px; font-weight: 500; flex: 1; }
.modal-close { font-size: 24px; } .modal-close { font-size: 24px; }
.modal-body { padding: 20px; } .modal-body { padding: 20px; }
.modal-body p { margin: 0 0 14px; font-size: 14px; color: var(--text-1); } .modal-body p { margin: 0 0 14px; font-size: 14px; color: var(--text-1); }
@@ -897,7 +948,7 @@ a { color: var(--primary); text-decoration: none; }
.modal-body a:hover { text-decoration: underline; } .modal-body a:hover { text-decoration: underline; }
.modal-footer { .modal-footer {
display: flex; align-items: center; gap: 8px; display: flex; align-items: center; gap: 8px;
padding: 14px 20px; border-top: 1px solid var(--border-light); padding: 12px 20px; border-top: 1px solid var(--border);
} }
/* ── Recurrence UI ─────────────────────────────────────── */ /* ── Recurrence UI ─────────────────────────────────────── */
@@ -924,26 +975,79 @@ a { color: var(--primary); text-decoration: none; }
} }
.ctx-item:hover { background: var(--bg-hover); } .ctx-item:hover { background: var(--bg-hover); }
<<<<<<< HEAD
/* ── Event Popup ──────────────────────────────────────────
Layout: Color-Dot + Title links, kleine Icon-Toolbar rechts oben.
Icons sind im Ruhezustand transparent (nur das SVG selbst sichtbar),
bekommen erst beim Hover einen runden farbigen Hintergrund. Wirkt
modern und lässt dem Titel die meiste Breite. */
=======
/* ── Event Popup ────────────────────────────────────────── */ /* ── Event Popup ────────────────────────────────────────── */
>>>>>>> e744b1829e99db6b80922f75542ced329138e474
.event-popup { .event-popup {
position: fixed; z-index: 600; position: fixed; z-index: 600;
background: var(--bg-surface); background: var(--bg-surface);
border: 1px solid var(--border-light); border: 1px solid var(--border);
border-radius: var(--radius); border-radius: var(--radius);
width: 340px; width: 360px;
box-shadow: var(--shadow-lg); box-shadow: var(--shadow-lg);
overflow: hidden;
animation: card-in .16s cubic-bezier(.4, 0, .2, 1);
} }
@media (prefers-reduced-motion: reduce) { .event-popup { animation: none; } }
.popup-header { .popup-header {
display: flex; align-items: flex-start; gap: 10px; display: flex; align-items: flex-start; gap: 10px;
padding: 14px 12px 12px 16px; border-bottom: 1px solid var(--border-light); padding: 12px 10px 12px 16px;
border-bottom: 1px solid var(--border);
} }
.popup-color-dot { width: 11px; height: 11px; border-radius: 50%; flex-shrink: 0; margin-top: 5px; } .popup-color-dot {
.popup-header h4 { flex: 1; min-width: 0; font-size: 15px; font-weight: 600; line-height: 1.35; overflow-wrap: anywhere; padding-top: 1px; } width: 11px; height: 11px; border-radius: 50%;
.popup-action, .popup-close { width: 30px; height: 30px; font-size: 16px; flex-shrink: 0; } flex-shrink: 0;
.popup-close { margin-left: 2px; } margin-top: 6px;
}
.popup-header h4 {
flex: 1;
font-size: 14px; font-weight: 500;
line-height: 1.4;
word-break: break-word;
padding-top: 2px;
}
.popup-toolbar {
display: flex;
gap: 2px;
flex-shrink: 0;
margin-left: 4px;
}
.popup-icon-btn {
width: 30px; height: 30px;
border-radius: 50%;
display: inline-flex; align-items: center; justify-content: center;
background: transparent;
border: none;
cursor: pointer;
color: var(--text-3);
-webkit-tap-highlight-color: transparent;
transition:
background var(--transition),
color var(--transition),
transform .1s ease;
}
.popup-icon-btn svg { width: 15px; height: 15px; fill: currentColor; flex-shrink: 0; }
.popup-icon-btn:hover {
background: rgba(66,133,244,.16);
background: color-mix(in srgb, var(--primary) 16%, transparent);
color: var(--primary);
}
.popup-icon-btn-danger:hover {
background: rgba(234,67,53,.16);
background: color-mix(in srgb, var(--accent) 16%, transparent);
color: var(--accent);
}
.popup-icon-btn-close:hover {
background: var(--bg-hover);
color: var(--text-1);
}
.popup-icon-btn:active { transform: scale(.9); }
.popup-body { padding: 12px 16px; } .popup-body { padding: 12px 16px; }
.popup-time, .popup-location, .popup-calendar { font-size: 13px; color: var(--text-2); margin-bottom: 6px; } .popup-time, .popup-location, .popup-calendar { font-size: 13px; color: var(--text-2); margin-bottom: 6px; }
.popup-description { font-size: 13px; color: var(--text-1); margin-bottom: 6px; white-space: pre-wrap; } .popup-description { font-size: 13px; color: var(--text-1); margin-bottom: 6px; white-space: pre-wrap; }
@@ -1057,7 +1161,7 @@ a { color: var(--primary); text-decoration: none; }
.settings-section h4 { font-size: 14px; font-weight: 600; color: var(--text-1); margin-bottom: 16px; display: flex; align-items: center; gap: 8px; } .settings-section h4 { font-size: 14px; font-weight: 600; color: var(--text-1); margin-bottom: 16px; display: flex; align-items: center; gap: 8px; }
.badge-admin { .badge-admin {
font-size: 10px; padding: 2px 6px; font-size: 10px; padding: 2px 6px;
background: var(--primary-dim); color: var(--primary); background: rgba(66,133,244,.2); color: var(--primary);
border-radius: 10px; font-weight: 600; border-radius: 10px; font-weight: 600;
} }
.users-list-item { .users-list-item {
@@ -1185,7 +1289,7 @@ a { color: var(--primary); text-decoration: none; }
.month-span-event { .month-span-event {
position: absolute; position: absolute;
height: 18px; line-height: 18px; height: 18px; line-height: 18px;
border-radius: 6px; padding: 0 6px; border-radius: 3px; padding: 0 6px;
font-size: 11px; font-weight: 500; font-size: 11px; font-weight: 500;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
cursor: pointer; color: #fff; cursor: pointer; color: #fff;
@@ -1597,6 +1701,14 @@ a { color: var(--primary); text-decoration: none; }
.topbar-left { gap: 0; } .topbar-left { gap: 0; }
.topbar-right { gap: 0; } .topbar-right { gap: 0; }
<<<<<<< HEAD
/* Event-Popup auf Mobile: an Viewport-Breite anpassen */
.event-popup { width: min(94vw, 380px); max-width: 94vw; }
.popup-header { padding: 10px 8px 10px 14px; }
.popup-header h4 { font-size: 13.5px; }
.popup-icon-btn { width: 32px; height: 32px; }
.popup-icon-btn svg { width: 16px; height: 16px; }
=======
/* Event-Popup: Buttons kompakt halten, kein 44px-Override ───── */ /* Event-Popup: Buttons kompakt halten, kein 44px-Override ───── */
.event-popup .icon-btn { .event-popup .icon-btn {
min-width: 32px !important; min-width: 32px !important;
@@ -1606,6 +1718,7 @@ a { color: var(--primary); text-decoration: none; }
} }
.event-popup .popup-header { gap: 2px; padding: 10px 12px; } .event-popup .popup-header { gap: 2px; padding: 10px 12px; }
.event-popup { width: min(92vw, 340px); max-width: 92vw; } .event-popup { width: min(92vw, 340px); max-width: 92vw; }
>>>>>>> e744b1829e99db6b80922f75542ced329138e474
/* Monatsansicht: Startzeit ausblenden — nur Titel anzeigen ──── */ /* Monatsansicht: Startzeit ausblenden — nur Titel anzeigen ──── */
.month-event-time { display: none; } .month-event-time { display: none; }

View File

@@ -1,10 +1,10 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="de"> <html lang="de">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no, viewport-fit=cover" /> <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no, viewport-fit=cover" />
<!-- APP_VERSION: update here + version.js on every release --> <!-- APP_VERSION: update here + version.js on every release -->
<title>Calendarr v11</title> <title>Calendarr v18</title>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/static/favicon.svg" />
<link rel="manifest" href="/manifest.json" /> <link rel="manifest" href="/manifest.json" />
<meta name="theme-color" content="#4285f4" /> <meta name="theme-color" content="#4285f4" />
@@ -80,7 +80,7 @@
<button type="submit" class="btn btn-primary btn-full">Anmelden</button> <button type="submit" class="btn btn-primary btn-full">Anmelden</button>
</form> </form>
</div> </div>
<button class="impressum-link" onclick="openImpressum()">©&nbsp;2026&nbsp;Scarriffleservices&nbsp;·&nbsp;v11</button> <button class="impressum-link" onclick="openImpressum()">©&nbsp;2026&nbsp;Scarriffleservices&nbsp;·&nbsp;v18</button>
</div> </div>
<!-- ─── MAIN APP ──────────────────────────────────────────── --> <!-- ─── MAIN APP ──────────────────────────────────────────── -->
@@ -199,7 +199,7 @@
<div id="cal-list-items"></div> <div id="cal-list-items"></div>
</div> </div>
</div> </div>
<button class="sidebar-copyright" onclick="openImpressum()">©&nbsp;2026&nbsp;Scarriffleservices&nbsp;·&nbsp;v11</button> <button class="sidebar-copyright" onclick="openImpressum()">©&nbsp;2026&nbsp;Scarriffleservices&nbsp;·&nbsp;v18</button>
</aside> </aside>
<div id="sidebar-backdrop" class="sidebar-backdrop"></div> <div id="sidebar-backdrop" class="sidebar-backdrop"></div>
@@ -235,7 +235,7 @@
<input type="hidden" id="ev-start" /> <input type="hidden" id="ev-start" />
<div class="dt-display" id="ev-start-display" tabindex="0" role="button"> <div class="dt-display" id="ev-start-display" tabindex="0" role="button">
<span class="dt-display-text"></span> <span class="dt-display-text"></span>
<svg class="dt-display-icon" viewBox="0 0 24 24" fill="currentColor" width="16" height="16"><path d="M19 3h-1V1h-2v2H8V1H6v2H5c-1.1 0-2 .9-2 2v24a2 2 0 002 2h14a2 2 0 002-2V5c0-1.1-.9-2-2-2zm0 16H5V8h14v21zM7 10h5v11H7z"/></svg> <svg class="dt-display-icon" viewBox="0 0 24 24" fill="currentColor" width="16" height="16"><path d="M19 3h-1V1h-2v2H8V1H6v2H5c-1.1 0-2 .9-2 2v24a2 2 0 002 2h14a2 2 0 002-2V5c0-1.1-.9-2-2-2zm0 16H5V8h14v21zM7 10h5v17H7z"/></svg>
</div> </div>
</div> </div>
<div class="form-group half"> <div class="form-group half">
@@ -243,7 +243,7 @@
<input type="hidden" id="ev-end" /> <input type="hidden" id="ev-end" />
<div class="dt-display" id="ev-end-display" tabindex="0" role="button"> <div class="dt-display" id="ev-end-display" tabindex="0" role="button">
<span class="dt-display-text"></span> <span class="dt-display-text"></span>
<svg class="dt-display-icon" viewBox="0 0 24 24" fill="currentColor" width="16" height="16"><path d="M19 3h-1V1h-2v2H8V1H6v2H5c-1.1 0-2 .9-2 2v24a2 2 0 002 2h14a2 2 0 002-2V5c0-1.1-.9-2-2-2zm0 16H5V8h14v21zM7 10h5v11H7z"/></svg> <svg class="dt-display-icon" viewBox="0 0 24 24" fill="currentColor" width="16" height="16"><path d="M19 3h-1V1h-2v2H8V1H6v2H5c-1.1 0-2 .9-2 2v24a2 2 0 002 2h14a2 2 0 002-2V5c0-1.1-.9-2-2-2zm0 16H5V8h14v21zM7 10h5v17H7z"/></svg>
</div> </div>
</div> </div>
</div> </div>
@@ -253,7 +253,7 @@
<input type="hidden" id="ev-start-date" /> <input type="hidden" id="ev-start-date" />
<div class="dt-display" id="ev-start-date-display" tabindex="0" role="button"> <div class="dt-display" id="ev-start-date-display" tabindex="0" role="button">
<span class="dt-display-text"></span> <span class="dt-display-text"></span>
<svg class="dt-display-icon" viewBox="0 0 24 24" fill="currentColor" width="16" height="16"><path d="M19 3h-1V1h-2v2H8V1H6v2H5c-1.1 0-2 .9-2 2v24a2 2 0 002 2h14a2 2 0 002-2V5c0-1.1-.9-2-2-2zm0 16H5V8h14v21zM7 10h5v11H7z"/></svg> <svg class="dt-display-icon" viewBox="0 0 24 24" fill="currentColor" width="16" height="16"><path d="M19 3h-1V1h-2v2H8V1H6v2H5c-1.1 0-2 .9-2 2v24a2 2 0 002 2h14a2 2 0 002-2V5c0-1.1-.9-2-2-2zm0 16H5V8h14v21zM7 10h5v17H7z"/></svg>
</div> </div>
</div> </div>
<div class="form-group half"> <div class="form-group half">
@@ -261,7 +261,7 @@
<input type="hidden" id="ev-end-date" /> <input type="hidden" id="ev-end-date" />
<div class="dt-display" id="ev-end-date-display" tabindex="0" role="button"> <div class="dt-display" id="ev-end-date-display" tabindex="0" role="button">
<span class="dt-display-text"></span> <span class="dt-display-text"></span>
<svg class="dt-display-icon" viewBox="0 0 24 24" fill="currentColor" width="16" height="16"><path d="M19 3h-1V1h-2v2H8V1H6v2H5c-1.1 0-2 .9-2 2v24a2 2 0 002 2h14a2 2 0 002-2V5c0-1.1-.9-2-2-2zm0 16H5V8h14v21zM7 10h5v11H7z"/></svg> <svg class="dt-display-icon" viewBox="0 0 24 24" fill="currentColor" width="16" height="16"><path d="M19 3h-1V1h-2v2H8V1H6v2H5c-1.1 0-2 .9-2 2v24a2 2 0 002 2h14a2 2 0 002-2V5c0-1.1-.9-2-2-2zm0 16H5V8h14v21zM7 10h5v17H7z"/></svg>
</div> </div>
</div> </div>
</div> </div>
@@ -311,7 +311,7 @@
<input type="hidden" id="ev-rec-until" /> <input type="hidden" id="ev-rec-until" />
<div class="dt-display" id="ev-rec-until-display" tabindex="0" role="button"> <div class="dt-display" id="ev-rec-until-display" tabindex="0" role="button">
<span class="dt-display-text"></span> <span class="dt-display-text"></span>
<svg class="dt-display-icon" viewBox="0 0 24 24" fill="currentColor" width="16" height="16"><path d="M19 3h-1V1h-2v2H8V1H6v2H5c-1.1 0-2 .9-2 2v24a2 2 0 002 2h14a2 2 0 002-2V5c0-1.1-.9-2-2-2zm0 16H5V8h14v21zM7 10h5v11H7z"/></svg> <svg class="dt-display-icon" viewBox="0 0 24 24" fill="currentColor" width="16" height="16"><path d="M19 3h-1V1h-2v2H8V1H6v2H5c-1.1 0-2 .9-2 2v24a2 2 0 002 2h14a2 2 0 002-2V5c0-1.1-.9-2-2-2zm0 16H5V8h14v21zM7 10h5v17H7z"/></svg>
</div> </div>
</div> </div>
</div> </div>
@@ -375,16 +375,20 @@
<div class="popup-header"> <div class="popup-header">
<div class="popup-color-dot" id="popup-color-dot"></div> <div class="popup-color-dot" id="popup-color-dot"></div>
<h4 id="popup-title"></h4> <h4 id="popup-title"></h4>
<button class="icon-btn popup-action" id="popup-edit" title="Bearbeiten"> <div class="popup-toolbar">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34c-.39-.39-1.02-.39-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"/></svg> <button class="popup-icon-btn" id="popup-edit" title="Bearbeiten" aria-label="Bearbeiten">
<svg width="15" height="15" viewBox="0 0 24 24" fill="currentColor"><path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34c-.39-.39-1.02-.39-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"/></svg>
</button> </button>
<button class="icon-btn popup-action" id="popup-copy" title="Kopieren nach…"> <button class="popup-icon-btn" id="popup-copy" title="Kopieren" aria-label="Kopieren">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M16 1H4c-1.1 0-2 .9-2 2v24h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v24c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v24z"/></svg> <svg width="15" height="15" viewBox="0 0 24 24" fill="currentColor"><path d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z"/></svg>
</button> </button>
<button class="icon-btn popup-action" id="popup-delete" title="Löschen"> <button class="popup-icon-btn popup-icon-btn-danger" id="popup-delete" title="Löschen" aria-label="Löschen">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v22zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/></svg> <svg width="15" height="15" viewBox="0 0 24 24" fill="currentColor"><path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/></svg>
</button> </button>
<button class="icon-btn popup-close" id="popup-close">&times;</button> <button class="popup-icon-btn popup-icon-btn-close" id="popup-close" title="Schließen" aria-label="Schließen">
<svg width="15" height="15" viewBox="0 0 24 24" fill="currentColor"><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg>
</button>
</div>
</div> </div>
<div class="popup-body"> <div class="popup-body">
<div class="popup-time" id="popup-time"></div> <div class="popup-time" id="popup-time"></div>
@@ -618,22 +622,29 @@
</div> </div>
</div> </div>
<h4 class="panel-title" style="margin-top:24px" data-i18n="settings_text_contrast">Schriftkontrast</h4> <div class="form-group">
<p class="panel-desc" data-i18n="settings_text_contrast_desc">Helligkeit der Beschriftungen und Texte</p> <label data-i18n="settings_text_color">Schriftfarbe</label>
<div class="contrast-selector" id="cfg-text-contrast" data-setting="text_contrast"> <div class="ev-color-row">
<button class="contrast-btn" data-val="1"><span style="color:#606070">Aa</span><span class="contrast-lbl" data-i18n="contrast_dark">Dunkel</span></button> <input type="text" id="cfg-text-color-hex" class="ev-color-hex" maxlength="7" spellcheck="false" placeholder="auto" />
<button class="contrast-btn" data-val="2"><span style="color:#9090a8">Aa</span><span class="contrast-lbl" data-i18n="contrast_medium">Mittel</span></button> <div class="ev-color-preview" id="cfg-text-color-preview" data-i18n-title="color_pick" title="Farbe wählen"></div>
<button class="contrast-btn" data-val="3"><span style="color:#c8c8d8">Aa</span><span class="contrast-lbl" data-i18n="contrast_light">Hell</span></button> <button type="button" class="btn btn-ghost btn-sm" id="cfg-text-color-reset" data-i18n="reset" title="Zurücksetzen">Reset</button>
<button class="contrast-btn" data-val="4"><span style="color:#ffffff">Aa</span><span class="contrast-lbl" data-i18n="contrast_max">Maximum</span></button> </div>
</div>
<div class="form-group">
<label data-i18n="settings_line_color">Linienfarbe</label>
<div class="ev-color-row">
<input type="text" id="cfg-line-color-hex" class="ev-color-hex" maxlength="7" spellcheck="false" placeholder="auto" />
<div class="ev-color-preview" id="cfg-line-color-preview" data-i18n-title="color_pick" title="Farbe wählen"></div>
<button type="button" class="btn btn-ghost btn-sm" id="cfg-line-color-reset" data-i18n="reset" title="Zurücksetzen">Reset</button>
</div>
</div>
<div class="form-group">
<label data-i18n="settings_bg_color">Hintergrundfarbe</label>
<div class="ev-color-row">
<input type="text" id="cfg-bg-color-hex" class="ev-color-hex" maxlength="7" spellcheck="false" placeholder="auto" />
<div class="ev-color-preview" id="cfg-bg-color-preview" data-i18n-title="color_pick" title="Farbe wählen"></div>
<button type="button" class="btn btn-ghost btn-sm" id="cfg-bg-color-reset" data-i18n="reset" title="Zurücksetzen">Reset</button>
</div> </div>
<h4 class="panel-title" style="margin-top:24px" data-i18n="settings_line_contrast">Linienkontrast</h4>
<p class="panel-desc" data-i18n="settings_line_contrast_desc">Sichtbarkeit von Trennlinien und Rahmen</p>
<div class="contrast-selector" id="cfg-line-contrast" data-setting="line_contrast">
<button class="contrast-btn" data-val="1"><span class="line-preview" style="border-color:#1e1e2c"></span><span class="contrast-lbl" data-i18n="line_barely">Kaum</span></button>
<button class="contrast-btn" data-val="2"><span class="line-preview" style="border-color:#2a2a3c"></span><span class="contrast-lbl" data-i18n="line_subtle">Subtil</span></button>
<button class="contrast-btn" data-val="3"><span class="line-preview" style="border-color:#3a3a52"></span><span class="contrast-lbl" data-i18n="line_normal">Normal</span></button>
<button class="contrast-btn" data-val="4"><span class="line-preview" style="border-color:#5a5a78"></span><span class="contrast-lbl" data-i18n="line_strong">Stark</span></button>
</div> </div>
<h4 class="panel-title" style="margin-top:24px" data-i18n="settings_calendar_view">Kalenderansicht</h4> <h4 class="panel-title" style="margin-top:24px" data-i18n="settings_calendar_view">Kalenderansicht</h4>
@@ -884,7 +895,7 @@
<a href="mailto:scarriffleservices@gmail.com">scarriffleservices@gmail.com</a></p> <a href="mailto:scarriffleservices@gmail.com">scarriffleservices@gmail.com</a></p>
</div> </div>
<div class="modal-footer" style="justify-content:space-between;align-items:center"> <div class="modal-footer" style="justify-content:space-between;align-items:center">
<span style="font-size:12px;color:var(--text-3)">Calendarr v11</span> <span style="font-size:12px;color:var(--text-3)">Calendarr v18</span>
<button class="btn btn-ghost" onclick="closeImpressum()">Schliessen</button> <button class="btn btn-ghost" onclick="closeImpressum()">Schliessen</button>
</div> </div>
</div> </div>

View File

@@ -40,6 +40,38 @@ let state = {
selectedEventColor: '', // '' = use calendar color selectedEventColor: '', // '' = use calendar color
}; };
// ── URL state ────────────────────────────────────────────
// View + Date werden in der URL als #date=YYYY-MM-DD&view=<view> gespiegelt,
// damit Reload/PWA-restore den letzten Stand wiederherstellen statt auf
// heute zu springen.
const VALID_VIEWS = ['month', 'week', 'day', 'quarter', 'agenda'];
function readUrlState() {
const hash = window.location.hash.replace(/^#/, '');
if (!hash) return {};
const params = new URLSearchParams(hash);
const out = {};
const view = params.get('view');
if (view && VALID_VIEWS.includes(view)) out.view = view;
const date = params.get('date');
if (date && /^\d{4}-\d{2}-\d{2}$/.test(date)) {
const d = new Date(date + 'T00:00:00');
if (!isNaN(d.getTime())) out.date = d;
}
return out;
}
function writeUrlState() {
const d = state.currentDate;
const dateStr = `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`;
const newHash = `date=${dateStr}&view=${state.currentView}`;
if (window.location.hash.replace(/^#/,'') !== newHash) {
// replaceState statt pushState: prev/next-Klicks sollen nicht jeden
// einzelnen Tag in den Browser-History-Stack drücken
window.history.replaceState(null, '', '#' + newHash);
}
}
// ── Public init ─────────────────────────────────────────── // ── Public init ───────────────────────────────────────────
export async function initCalendar() { export async function initCalendar() {
const [settings, accounts, localCalendars, icalSubscriptions, googleAccounts, haAccounts] = await Promise.all([ const [settings, accounts, localCalendars, icalSubscriptions, googleAccounts, haAccounts] = await Promise.all([
@@ -61,6 +93,11 @@ export async function initCalendar() {
state.dimPast = settings.dim_past_events; state.dimPast = settings.dim_past_events;
weekStartDay = settings.week_start_day || 'monday'; weekStartDay = settings.week_start_day || 'monday';
// URL state takes precedence over defaults (settings + today)
const urlState = readUrlState();
if (urlState.date) state.currentDate = urlState.date;
if (urlState.view) state.currentView = urlState.view;
setLang(settings.language || 'de'); setLang(settings.language || 'de');
applyTheme(settings); applyTheme(settings);
updateViewButtons(); updateViewButtons();
@@ -78,6 +115,25 @@ export async function initCalendar() {
bindProfileModal(); bindProfileModal();
bindSwipeNavigation(); bindSwipeNavigation();
handleHAOAuthReturn(); handleHAOAuthReturn();
<<<<<<< HEAD
// Browser-Back/Forward: URL-Hash → State synchronisieren
window.addEventListener('hashchange', () => {
const u = readUrlState();
let changed = false;
if (u.view && u.view !== state.currentView) {
state.currentView = u.view;
updateViewButtons();
changed = true;
}
if (u.date && !isSameDay(u.date, state.currentDate)) {
state.currentDate = u.date;
changed = true;
}
if (changed) fetchAndRender();
});
=======
>>>>>>> e744b1829e99db6b80922f75542ced329138e474
} }
function handleHAOAuthReturn() { function handleHAOAuthReturn() {
@@ -91,7 +147,11 @@ function handleHAOAuthReturn() {
}; };
if (params.has('ha_connected')) { if (params.has('ha_connected')) {
showToast('Home Assistant verbunden'); showToast('Home Assistant verbunden');
<<<<<<< HEAD
window.history.replaceState({}, '', window.location.pathname + window.location.hash);
=======
window.history.replaceState({}, '', window.location.pathname); window.history.replaceState({}, '', window.location.pathname);
>>>>>>> e744b1829e99db6b80922f75542ced329138e474
fetchAndRender(true); fetchAndRender(true);
api.get('/homeassistant/accounts').then(accs => { api.get('/homeassistant/accounts').then(accs => {
state.haAccounts = accs || []; state.haAccounts = accs || [];
@@ -101,7 +161,11 @@ function handleHAOAuthReturn() {
} else if (params.has('ha_error')) { } else if (params.has('ha_error')) {
const code = params.get('ha_error'); const code = params.get('ha_error');
showToast(errMap[code] || `HA-Anmeldung fehlgeschlagen: ${code}`, true); showToast(errMap[code] || `HA-Anmeldung fehlgeschlagen: ${code}`, true);
<<<<<<< HEAD
window.history.replaceState({}, '', window.location.pathname + window.location.hash);
=======
window.history.replaceState({}, '', window.location.pathname); window.history.replaceState({}, '', window.location.pathname);
>>>>>>> e744b1829e99db6b80922f75542ced329138e474
} }
} }
@@ -196,6 +260,10 @@ async function fetchAndRender(force = false, silent = false) {
renderView(); renderView();
updateTitle(); updateTitle();
renderMiniCal(); renderMiniCal();
<<<<<<< HEAD
writeUrlState();
=======
>>>>>>> e744b1829e99db6b80922f75542ced329138e474
prefetchIfNeeded(start, end); // extend cache in background if approaching an edge prefetchIfNeeded(start, end); // extend cache in background if approaching an edge
return; return;
} }
@@ -224,6 +292,7 @@ async function fetchAndRender(force = false, silent = false) {
renderView(); renderView();
updateTitle(); updateTitle();
renderMiniCal(); renderMiniCal();
writeUrlState();
} }
function getViewRange() { function getViewRange() {
@@ -1976,6 +2045,24 @@ function openSettingsModal() {
document.getElementById(id + '-hex').value = val.toUpperCase(); document.getElementById(id + '-hex').value = val.toUpperCase();
document.getElementById(id + '-preview').style.background = val; document.getElementById(id + '-preview').style.background = val;
}); });
// Optional colour overrides — empty hex input means "auto"
[
{ id: 'cfg-text-color', val: s.text_color },
{ id: 'cfg-line-color', val: s.line_color },
{ id: 'cfg-bg-color', val: s.bg_color },
].forEach(({ id, val }) => {
const hex = document.getElementById(id + '-hex');
const prev = document.getElementById(id + '-preview');
if (!hex || !prev) return;
if (val) {
hex.value = String(val).toUpperCase();
prev.style.background = val;
} else {
hex.value = '';
prev.style.background = 'transparent';
}
});
document.getElementById('cfg-dim-past').checked = !!s.dim_past_events; document.getElementById('cfg-dim-past').checked = !!s.dim_past_events;
document.getElementById('cfg-language').value = getLang(); document.getElementById('cfg-language').value = getLang();
@@ -2307,6 +2394,32 @@ function bindSettingsModal() {
}); });
}); });
// Optional override colours (text / line / background) — empty = use default
[
{ prefix: 'cfg-text-color', defaultColor: '#c8c8d8' },
{ prefix: 'cfg-line-color', defaultColor: '#3a3a52' },
{ prefix: 'cfg-bg-color', defaultColor: '#0e0e14' },
].forEach(({ prefix, defaultColor }) => {
const preview = document.getElementById(prefix + '-preview');
const hex = document.getElementById(prefix + '-hex');
const reset = document.getElementById(prefix + '-reset');
if (!preview || !hex || !reset) return;
preview.addEventListener('click', async () => {
const picked = await openColorPicker(preview, hex.value || defaultColor);
if (picked) { hex.value = picked.toUpperCase(); preview.style.background = picked; }
});
hex.addEventListener('change', () => {
let val = hex.value.trim();
if (!val) { preview.style.background = 'transparent'; return; }
if (!val.startsWith('#')) val = '#' + val;
if (/^#[0-9a-fA-F]{6}$/.test(val)) { hex.value = val.toUpperCase(); preview.style.background = val; }
});
reset.addEventListener('click', () => {
hex.value = '';
preview.style.background = 'transparent';
});
});
// Panel navigation // Panel navigation
document.querySelectorAll('.settings-nav-btn').forEach(btn => { document.querySelectorAll('.settings-nav-btn').forEach(btn => {
btn.addEventListener('click', () => activateSettingsPanel(btn.dataset.panel)); btn.addEventListener('click', () => activateSettingsPanel(btn.dataset.panel));
@@ -2346,6 +2459,11 @@ function bindSettingsModal() {
const btn = document.querySelector(`#${id} .contrast-btn.active`); const btn = document.querySelector(`#${id} .contrast-btn.active`);
return btn ? Number(btn.dataset.val) : null; return btn ? Number(btn.dataset.val) : null;
}; };
// Optional override colours: empty input → null (use default)
const colourOrNull = (id) => {
const v = (document.getElementById(id).value || '').trim();
return /^#[0-9a-fA-F]{6}$/.test(v) ? v : null;
};
const settings = { const settings = {
default_view: document.getElementById('cfg-default-view').value, default_view: document.getElementById('cfg-default-view').value,
week_start_day: document.getElementById('cfg-week-start').value, week_start_day: document.getElementById('cfg-week-start').value,
@@ -2354,9 +2472,10 @@ function bindSettingsModal() {
today_color: document.getElementById('cfg-today-hex').value, today_color: document.getElementById('cfg-today-hex').value,
month_divider_color: document.getElementById('cfg-month-divider-hex').value, month_divider_color: document.getElementById('cfg-month-divider-hex').value,
month_label_color: document.getElementById('cfg-month-label-hex').value, month_label_color: document.getElementById('cfg-month-label-hex').value,
text_color: colourOrNull('cfg-text-color-hex'),
line_color: colourOrNull('cfg-line-color-hex'),
bg_color: colourOrNull('cfg-bg-color-hex'),
dim_past_events: document.getElementById('cfg-dim-past').checked, dim_past_events: document.getElementById('cfg-dim-past').checked,
text_contrast: getActive('cfg-text-contrast') || 3,
line_contrast: getActive('cfg-line-contrast') || 3,
hour_height: getActive('cfg-hour-height') || 44, hour_height: getActive('cfg-hour-height') || 44,
language: document.getElementById('cfg-language').value, language: document.getElementById('cfg-language').value,
}; };

View File

@@ -67,6 +67,10 @@ const translations = {
settings_today_color: 'Heutige-Tag-Farbe', settings_today_color: 'Heutige-Tag-Farbe',
settings_month_divider_color: 'Monatswechsel-Linie', settings_month_divider_color: 'Monatswechsel-Linie',
settings_month_label_color: 'Monatskürzel-Farbe', settings_month_label_color: 'Monatskürzel-Farbe',
settings_text_color: 'Schriftfarbe',
settings_line_color: 'Linienfarbe',
settings_bg_color: 'Hintergrundfarbe',
reset: 'Reset',
settings_text_contrast: 'Schriftkontrast', settings_text_contrast: 'Schriftkontrast',
settings_text_contrast_desc: 'Helligkeit der Beschriftungen und Texte', settings_text_contrast_desc: 'Helligkeit der Beschriftungen und Texte',
contrast_dark: 'Dunkel', contrast_medium: 'Mittel', contrast_dark: 'Dunkel', contrast_medium: 'Mittel',
@@ -154,7 +158,11 @@ const translations = {
rec_every: 'Alle', rec_days: 'Tage', rec_weeks: 'Wochen', rec_months: 'Monate', rec_every: 'Alle', rec_days: 'Tage', rec_weeks: 'Wochen', rec_months: 'Monate',
rec_ends: 'Endet', rec_never: 'Nie', rec_after_count: 'Nach Anzahl', rec_ends: 'Endet', rec_never: 'Nie', rec_after_count: 'Nach Anzahl',
rec_on_date: 'Am Datum', rec_occurrences: 'Termine', rec_on_date: 'Am Datum', rec_occurrences: 'Termine',
<<<<<<< HEAD
copy_to_calendar: 'Kopieren nach…', event_copied: 'Termin kopiert', copy: 'Kopieren',
=======
copy_to_calendar: 'Kopieren nach…', event_copied: 'Termin kopiert', copy_to_calendar: 'Kopieren nach…', event_copied: 'Termin kopiert',
>>>>>>> e744b1829e99db6b80922f75542ced329138e474
edit_before_copy: 'Vor dem Kopieren bearbeiten', edit_before_copy: 'Vor dem Kopieren bearbeiten',
event_updated: 'Termin aktualisiert', event_created: 'Termin erstellt', event_updated: 'Termin aktualisiert', event_created: 'Termin erstellt',
confirm_delete_event: '"{title}" wirklich löschen?', confirm_delete_event: '"{title}" wirklich löschen?',
@@ -278,6 +286,10 @@ const translations = {
settings_today_color: 'Today highlight color', settings_today_color: 'Today highlight color',
settings_month_divider_color: 'Month divider line', settings_month_divider_color: 'Month divider line',
settings_month_label_color: 'Month label color', settings_month_label_color: 'Month label color',
settings_text_color: 'Text color',
settings_line_color: 'Line color',
settings_bg_color: 'Background color',
reset: 'Reset',
settings_text_contrast: 'Text contrast', settings_text_contrast: 'Text contrast',
settings_text_contrast_desc: 'Brightness of labels and text', settings_text_contrast_desc: 'Brightness of labels and text',
contrast_dark: 'Dark', contrast_medium: 'Medium', contrast_dark: 'Dark', contrast_medium: 'Medium',
@@ -365,7 +377,11 @@ const translations = {
rec_every: 'Every', rec_days: 'days', rec_weeks: 'weeks', rec_months: 'months', rec_every: 'Every', rec_days: 'days', rec_weeks: 'weeks', rec_months: 'months',
rec_ends: 'Ends', rec_never: 'Never', rec_after_count: 'After count', rec_ends: 'Ends', rec_never: 'Never', rec_after_count: 'After count',
rec_on_date: 'On date', rec_occurrences: 'occurrences', rec_on_date: 'On date', rec_occurrences: 'occurrences',
<<<<<<< HEAD
copy_to_calendar: 'Copy to…', event_copied: 'Event copied', copy: 'Copy',
=======
copy_to_calendar: 'Copy to…', event_copied: 'Event copied', copy_to_calendar: 'Copy to…', event_copied: 'Event copied',
>>>>>>> e744b1829e99db6b80922f75542ced329138e474
edit_before_copy: 'Edit before copying', edit_before_copy: 'Edit before copying',
event_updated: 'Event updated', event_created: 'Event created', event_updated: 'Event updated', event_created: 'Event created',
confirm_delete_event: 'Really delete "{title}"?', confirm_delete_event: 'Really delete "{title}"?',
@@ -442,15 +458,26 @@ export function t(key, vars = {}) {
return val.replace(/\{(\w+)\}/g, (_, k) => vars[k] ?? ''); return val.replace(/\{(\w+)\}/g, (_, k) => vars[k] ?? '');
} }
// Look up a translation but return null if the key is undefined in both
// the current language and German. Lets callers fall back to the existing
// HTML default rather than displaying the raw key.
function tOrNull(key) {
const dict = translations[currentLang] ?? translations.de;
const val = dict[key] ?? translations.de[key];
return typeof val === 'string' ? val : null;
}
export function applyLang() { export function applyLang() {
document.querySelectorAll('[data-i18n]').forEach(el => { document.querySelectorAll('[data-i18n]').forEach(el => {
const v = t(el.dataset.i18n); const v = tOrNull(el.dataset.i18n);
if (typeof v === 'string') el.textContent = v; if (v != null) el.textContent = v;
}); });
document.querySelectorAll('[data-i18n-ph]').forEach(el => { document.querySelectorAll('[data-i18n-ph]').forEach(el => {
el.placeholder = t(el.dataset.i18nPh); const v = tOrNull(el.dataset.i18nPh);
if (v != null) el.placeholder = v;
}); });
document.querySelectorAll('[data-i18n-title]').forEach(el => { document.querySelectorAll('[data-i18n-title]').forEach(el => {
el.title = t(el.dataset.i18nTitle); const v = tOrNull(el.dataset.i18nTitle);
if (v != null) el.title = v;
}); });
} }

View File

@@ -83,14 +83,47 @@ export function applyTheme(settings) {
root.style.setProperty('--accent', settings.accent_color || '#ea4335'); root.style.setProperty('--accent', settings.accent_color || '#ea4335');
root.style.setProperty('--today-color', settings.today_color || '#4285f4'); root.style.setProperty('--today-color', settings.today_color || '#4285f4');
// Text colour: a custom hex (settings.text_color) wins over the legacy
// 14 contrast step. We derive --text-2/--text-3 by darkening the
// chosen colour so the secondary/tertiary text stays in the same hue.
if (settings.text_color) {
root.style.setProperty('--text-1', settings.text_color);
root.style.setProperty('--text-2', shadeHex(settings.text_color, -0.25));
root.style.setProperty('--text-3', shadeHex(settings.text_color, -0.55));
} else {
const tc = TEXT_CONTRAST[settings.text_contrast || 3]; const tc = TEXT_CONTRAST[settings.text_contrast || 3];
root.style.setProperty('--text-1', tc.t1); root.style.setProperty('--text-1', tc.t1);
root.style.setProperty('--text-2', tc.t2); root.style.setProperty('--text-2', tc.t2);
root.style.setProperty('--text-3', tc.t3); root.style.setProperty('--text-3', tc.t3);
}
// Line colour: custom hex overrides the legacy contrast step.
if (settings.line_color) {
root.style.setProperty('--border', settings.line_color);
root.style.setProperty('--border-light', shadeHex(settings.line_color, -0.25));
} else {
const lc = LINE_CONTRAST[settings.line_contrast || 3]; const lc = LINE_CONTRAST[settings.line_contrast || 3];
root.style.setProperty('--border', lc.border); root.style.setProperty('--border', lc.border);
root.style.setProperty('--border-light', lc.light); root.style.setProperty('--border-light', lc.light);
}
// Background colour: optional. If set, also tint the topbar/sidebar
// and surface variants so the whole UI stays coherent.
if (settings.bg_color) {
root.style.setProperty('--bg-app', settings.bg_color);
root.style.setProperty('--bg-topbar', shadeHex(settings.bg_color, 0.10));
root.style.setProperty('--bg-sidebar', shadeHex(settings.bg_color, 0.10));
root.style.setProperty('--bg-surface', shadeHex(settings.bg_color, 0.18));
root.style.setProperty('--bg-hover', shadeHex(settings.bg_color, 0.26));
root.style.setProperty('--bg-active', shadeHex(settings.bg_color, 0.40));
} else {
root.style.removeProperty('--bg-app');
root.style.removeProperty('--bg-topbar');
root.style.removeProperty('--bg-sidebar');
root.style.removeProperty('--bg-surface');
root.style.removeProperty('--bg-hover');
root.style.removeProperty('--bg-active');
}
const hh = settings.hour_height || 44; const hh = settings.hour_height || 44;
root.style.setProperty('--hour-h', hh + 'px'); root.style.setProperty('--hour-h', hh + 'px');
@@ -105,3 +138,24 @@ function hexToRgba(hex, alpha) {
const b = parseInt(hex.slice(5,7), 16); const b = parseInt(hex.slice(5,7), 16);
return `rgba(${r},${g},${b},${alpha})`; return `rgba(${r},${g},${b},${alpha})`;
} }
// Brighten (positive amount) or darken (negative) a hex colour.
// Used to derive supporting shades (sidebar bg, hover bg, secondary text…)
// from a single user-picked colour so the whole UI stays in the same family.
function shadeHex(hex, amount) {
let r = parseInt(hex.slice(1,3), 16);
let g = parseInt(hex.slice(3,5), 16);
let b = parseInt(hex.slice(5,7), 16);
if (amount >= 0) {
r = Math.round(r + (255 - r) * amount);
g = Math.round(g + (255 - g) * amount);
b = Math.round(b + (255 - b) * amount);
} else {
const a = 1 + amount; // amount is negative: e.g. -0.25 → keep 75%
r = Math.round(r * a);
g = Math.round(g * a);
b = Math.round(b * a);
}
const h = n => Math.max(0, Math.min(255, n)).toString(16).padStart(2, '0');
return '#' + h(r) + h(g) + h(b);
}

View File

@@ -1,2 +1,2 @@
// Increment APP_VERSION with every code change // Increment APP_VERSION with every code change
export const APP_VERSION = 'v11'; export const APP_VERSION = 'v18';

View File

@@ -63,8 +63,24 @@ export function renderWeek(container, currentDate, events, onSlotClick, onEventC
const color = ev.color || ev.calendarColor || '#4285f4'; const color = ev.color || ev.calendarColor || '#4285f4';
const pastCls = isPast(ev) ? 'past' : ''; const pastCls = isPast(ev) ? 'past' : '';
const multiCls = isMultiTimed ? 'multiday-timed' : ''; const multiCls = isMultiTimed ? 'multiday-timed' : '';
<<<<<<< HEAD
// continues-left/right: compute on date-only basis for all-day events
let evStart = new Date(ev.start);
let evEnd = new Date(ev.end);
if (ev.allDay) {
evStart.setHours(0, 0, 0, 0);
evEnd.setHours(0, 0, 0, 0);
if (evEnd > evStart) evEnd.setDate(evEnd.getDate() - 1);
}
const firstDay = new Date(days[0]); firstDay.setHours(0, 0, 0, 0);
const lastDayMidnight = new Date(days[n-1]); lastDayMidnight.setHours(24, 0, 0, 0);
const lastDay = new Date(days[n-1]); lastDay.setHours(0, 0, 0, 0);
const cL = evStart < firstDay ? 'continues-left' : '';
const cR = (ev.allDay ? evEnd > lastDay : evEnd > lastDayMidnight) ? 'continues-right' : '';
=======
const cL = new Date(ev.start) < new Date(days[0]) ? 'continues-left' : ''; const cL = new Date(ev.start) < new Date(days[0]) ? 'continues-left' : '';
const cR = new Date(ev.end) > (() => { const d = new Date(days[n-1]); d.setHours(24,0,0,0); return d; })() ? 'continues-right' : ''; const cR = new Date(ev.end) > (() => { const d = new Date(days[n-1]); d.setHours(24,0,0,0); return d; })() ? 'continues-right' : '';
>>>>>>> e744b1829e99db6b80922f75542ced329138e474
const label = isMultiTimed && isSameDay(new Date(ev.start), days[colStart]) const label = isMultiTimed && isSameDay(new Date(ev.start), days[colStart])
? `${fmtTime(new Date(ev.start))} ${ev.title}` ? `${fmtTime(new Date(ev.start))} ${ev.title}`
: ev.title; : ev.title;
@@ -236,11 +252,36 @@ function renderNowLine(container, days, hourH = 60) {
function layoutWeekAllDay(evs, days) { function layoutWeekAllDay(evs, days) {
const items = []; const items = [];
evs.forEach(ev => { evs.forEach(ev => {
<<<<<<< HEAD
// For all-day events, normalize to date-only with inclusive end-day
// (iCal stores exclusive end → subtract 1). For timed events, keep
// the original strict-overlap logic so events ending exactly at
// midnight don't bleed into the next day.
let ns, ne;
if (ev.allDay) {
ns = new Date(ev.start); ns.setHours(0, 0, 0, 0);
ne = new Date(ev.end); ne.setHours(0, 0, 0, 0);
if (ne > ns) ne.setDate(ne.getDate() - 1);
}
let colStart = -1, colEnd = -1;
days.forEach((day, i) => {
const ds = new Date(day); ds.setHours(0, 0, 0, 0);
let matches;
if (ev.allDay) {
matches = ds >= ns && ds <= ne;
} else {
const de = new Date(day); de.setHours(24, 0, 0, 0);
matches = new Date(ev.start) < de && new Date(ev.end) > ds;
}
if (matches) {
=======
let colStart = -1, colEnd = -1; let colStart = -1, colEnd = -1;
days.forEach((day, i) => { days.forEach((day, i) => {
const ds = new Date(day); ds.setHours(0, 0, 0, 0); const ds = new Date(day); ds.setHours(0, 0, 0, 0);
const de = new Date(day); de.setHours(24, 0, 0, 0); const de = new Date(day); de.setHours(24, 0, 0, 0);
if (new Date(ev.start) < de && new Date(ev.end) > ds) { if (new Date(ev.start) < de && new Date(ev.end) > ds) {
>>>>>>> e744b1829e99db6b80922f75542ced329138e474
if (colStart === -1) colStart = i; if (colStart === -1) colStart = i;
colEnd = i; colEnd = i;
} }

View File

@@ -1,39 +1,21 @@
// Calendarr Service Worker // Calendarr Service Worker — minimal-cache strategy
// Cache-first for static assets, network-first for /api/* (graceful offline) //
// Strategy: network-first for everything. The cache is only used as a
// last-resort fallback when offline (so the app shell still opens). This
// means every online request hits the network and respects the
// server's Cache-Control headers (≤ 2h for static assets, no-cache for
// the entry HTML / version files). New releases take effect on the next
// reload, no manual SW unregister required.
const CACHE_VERSION = 'calendarr-v11'; const CACHE_VERSION = 'calendarr-v18';
const STATIC_ASSETS = [ const OFFLINE_SHELL = ['/', '/index.html'];
'/',
'/index.html',
'/manifest.json',
'/static/css/app.css',
'/static/favicon.svg',
'/static/js/app.js',
'/static/js/api.js',
'/static/js/calendar.js',
'/static/js/color-picker.js',
'/static/js/date-picker.js',
'/static/js/i18n.js',
'/static/js/utils.js',
'/static/js/version.js',
'/static/js/views/agenda.js',
'/static/js/views/month.js',
'/static/js/views/quarter.js',
'/static/js/views/week.js',
'/icons/icon-192.png',
'/icons/icon-512.png',
'/icons/icon.svg',
];
self.addEventListener('install', event => { self.addEventListener('install', event => {
event.waitUntil( event.waitUntil(
caches.open(CACHE_VERSION).then(cache => caches.open(CACHE_VERSION).then(cache =>
// Use addAll with a fallback so a single missing file doesn't abort install Promise.all(OFFLINE_SHELL.map(url =>
Promise.all(
STATIC_ASSETS.map(url =>
cache.add(url).catch(err => console.warn('[SW] skip', url, err)) cache.add(url).catch(err => console.warn('[SW] skip', url, err))
) ))
)
).then(() => self.skipWaiting()) ).then(() => self.skipWaiting())
); );
}); });
@@ -52,7 +34,8 @@ self.addEventListener('fetch', event => {
const url = new URL(req.url); const url = new URL(req.url);
// Network-first for API routes — fail silently if offline // API routes: always go to the network, no offline fallback (we'd just
// be returning stale account/event data otherwise).
if (url.pathname.startsWith('/api/')) { if (url.pathname.startsWith('/api/')) {
event.respondWith( event.respondWith(
fetch(req).catch(() => fetch(req).catch(() =>
@@ -65,45 +48,29 @@ self.addEventListener('fetch', event => {
return; return;
} }
// Network-first for navigation (HTML) and the version-defining files — // Everything else: network-first. The browser's HTTP cache (driven by
// ensures users always get the freshest entry point so new releases // the server's Cache-Control headers) already throttles re-fetches —
// take effect on the next reload without a manual SW unregister. // the SW just makes sure offline still works for the entry HTML.
const isHtml = req.mode === 'navigate'
|| url.pathname === '/'
|| url.pathname === '/index.html';
const isVersionFile = url.pathname === '/static/js/version.js';
if (isHtml || isVersionFile) {
event.respondWith( event.respondWith(
fetch(req).then(resp => { fetch(req).then(resp => {
if (resp && resp.status === 200) { // Keep a fresh copy of navigation requests / index.html for offline
const clone = resp.clone(); const isNavigation = req.mode === 'navigate'
caches.open(CACHE_VERSION).then(c => c.put(req, clone)).catch(() => {}); || url.pathname === '/'
} || url.pathname === '/index.html';
return resp; if (isNavigation && resp && resp.status === 200) {
}).catch(() =>
caches.match(req).then(c => c || caches.match('/index.html'))
)
);
return;
}
// Cache-first for everything else (static)
event.respondWith(
caches.match(req).then(cached => {
if (cached) return cached;
return fetch(req).then(resp => {
// Only cache successful, basic-origin responses
if (resp && resp.status === 200 && resp.type === 'basic') {
const clone = resp.clone(); const clone = resp.clone();
caches.open(CACHE_VERSION).then(c => c.put(req, clone)).catch(() => {}); caches.open(CACHE_VERSION).then(c => c.put(req, clone)).catch(() => {});
} }
return resp; return resp;
}).catch(() => { }).catch(() => {
// Offline fallback for navigation requests // Offline fallback: only the HTML shell is served from cache, so the
if (req.mode === 'navigate') return caches.match('/index.html'); // app at least renders and can show its own offline UI.
if (req.mode === 'navigate'
|| url.pathname === '/'
|| url.pathname === '/index.html') {
return caches.match(req).then(c => c || caches.match('/index.html'));
}
return new Response('', { status: 503 }); return new Response('', { status: 503 });
});
}) })
); );
}); });