Compare commits

...

78 Commits

Author SHA1 Message Date
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
28 changed files with 4727 additions and 410 deletions

View File

@@ -4,7 +4,7 @@ from datetime import date, datetime, timedelta, timezone
from typing import Dict, List, Optional from typing import Dict, List, Optional
import caldav import caldav
from icalendar import Calendar, Event from icalendar import Calendar, Event, vDatetime, vRecur
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -105,6 +105,10 @@ def _parse_ics(raw: str, event_url: str) -> List[Dict]:
location = str(component.get("LOCATION", "") or "") location = str(component.get("LOCATION", "") or "")
description = str(component.get("DESCRIPTION", "") or "") description = str(component.get("DESCRIPTION", "") or "")
color = str(component.get("X-CALENDARR-COLOR", "") or "") color = str(component.get("X-CALENDARR-COLOR", "") or "")
rrule_prop = component.get("RRULE")
rrule_str = rrule_prop.to_ical().decode("utf-8") if rrule_prop else None
recurrence_id = component.get("RECURRENCE-ID")
is_recurring = rrule_str is not None or recurrence_id is not None
dtstart_prop = component.get("DTSTART") dtstart_prop = component.get("DTSTART")
dtend_prop = component.get("DTEND") dtend_prop = component.get("DTEND")
@@ -154,6 +158,8 @@ def _parse_ics(raw: str, event_url: str) -> List[Dict]:
"location": location, "location": location,
"description": description, "description": description,
"color": color or None, "color": color or None,
"rrule": rrule_str,
"recurring": is_recurring,
} }
) )
except Exception as exc: except Exception as exc:
@@ -201,6 +207,8 @@ def create_event(
event.add("description", data["description"]) event.add("description", data["description"])
if data.get("color"): if data.get("color"):
event.add("x-calendarr-color", data["color"]) event.add("x-calendarr-color", data["color"])
if data.get("rrule"):
event.add("rrule", _parse_rrule_str(data["rrule"]))
cal.add_component(event) cal.add_component(event)
cal_obj.save_event(cal.to_ical().decode("utf-8")) cal_obj.save_event(cal.to_ical().decode("utf-8"))
@@ -208,10 +216,16 @@ def create_event(
def update_event( def update_event(
url: str, username: str, password: str, event_url: str, data: Dict url: str, username: str, password: str, event_url: str, data: Dict,
calendar_url: str = None,
): ):
client = _client(url, username, password) client = _client(url, username, password)
resource = client.event(url=event_url) if calendar_url:
cal_obj = client.calendar(url=calendar_url)
resource = caldav.Event(client=client, url=event_url, parent=cal_obj)
else:
resource = caldav.Event(client=client, url=event_url)
resource.load()
raw = resource.data raw = resource.data
cal = Calendar.from_ical(raw) cal = Calendar.from_ical(raw)
@@ -226,26 +240,54 @@ def update_event(
continue continue
if "title" in data or "summary" in data: if "title" in data or "summary" in data:
component["SUMMARY"] = data.get("title", data.get("summary", "")) if "SUMMARY" in component:
del component["SUMMARY"]
component.add("summary", data.get("title", data.get("summary", "")))
if "start" in data: if "start" in data:
if "DTSTART" in component:
del component["DTSTART"]
if data.get("allDay"): if data.get("allDay"):
component["DTSTART"] = date.fromisoformat(data["start"][:10]) component.add("dtstart", date.fromisoformat(data["start"][:10]))
else: else:
component["DTSTART"] = _parse_dt(data["start"]) component.add("dtstart", _parse_dt(data["start"]))
if "end" in data: if "end" in data:
if "DTEND" in component:
del component["DTEND"]
if data.get("allDay"): if data.get("allDay"):
component["DTEND"] = date.fromisoformat(data["end"][:10]) component.add("dtend", date.fromisoformat(data["end"][:10]))
else: else:
component["DTEND"] = _parse_dt(data["end"]) component.add("dtend", _parse_dt(data["end"]))
if "location" in data: if "location" in data:
component["LOCATION"] = data["location"] if "LOCATION" in component:
del component["LOCATION"]
component.add("location", data["location"])
if "description" in data: if "description" in data:
component["DESCRIPTION"] = data["description"] if "DESCRIPTION" in component:
del component["DESCRIPTION"]
component.add("description", data["description"])
if "color" in data: if "color" in data:
component["X-CALENDARR-COLOR"] = data["color"] if "X-CALENDARR-COLOR" in component:
del component["X-CALENDARR-COLOR"]
component.add("x-calendarr-color", data["color"])
if "rrule" in data:
if "RRULE" in component:
del component["RRULE"]
if data["rrule"]:
component.add("rrule", _parse_rrule_str(data["rrule"]))
if "exdate" in data and data["exdate"]:
# Parse YYYYMMDD string into a proper EXDATE
exdate_str = data["exdate"]
# Determine if event uses dates or datetimes
dtstart_prop = component.get("DTSTART")
if dtstart_prop and isinstance(dtstart_prop.dt, date) and not isinstance(dtstart_prop.dt, datetime):
exdate_val = date(int(exdate_str[:4]), int(exdate_str[4:6]), int(exdate_str[6:8]))
else:
exdate_val = datetime(int(exdate_str[:4]), int(exdate_str[4:6]), int(exdate_str[6:8]), tzinfo=timezone.utc)
component.add("exdate", [exdate_val])
new_cal.add_component(component) new_cal.add_component(component)
@@ -253,12 +295,31 @@ def update_event(
resource.save() resource.save()
def delete_event(url: str, username: str, password: str, event_url: str): def delete_event(url: str, username: str, password: str, event_url: str,
calendar_url: str = None):
client = _client(url, username, password) client = _client(url, username, password)
resource = client.event(url=event_url) if calendar_url:
cal_obj = client.calendar(url=calendar_url)
resource = caldav.Event(client=client, url=event_url, parent=cal_obj)
else:
resource = caldav.Event(client=client, url=event_url)
resource.delete() resource.delete()
def _parse_rrule_str(rrule_str: str) -> vRecur:
"""Parse an RRULE string like 'FREQ=WEEKLY;BYDAY=MO,WE' into a vRecur."""
params = {}
for part in rrule_str.split(";"):
if "=" not in part:
continue
key, val = part.split("=", 1)
if "," in val:
params[key] = val.split(",")
else:
params[key] = val
return vRecur(params)
def _parse_dt(s: str) -> datetime: def _parse_dt(s: str) -> datetime:
s = s.replace("Z", "+00:00") s = s.replace("Z", "+00:00")
dt = datetime.fromisoformat(s) dt = datetime.fromisoformat(s)

View File

@@ -4,15 +4,20 @@ 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
from routers import auth_router, caldav_router, google_router, ical_router, local_router, profile_router, settings_router, users_router from routers import auth_router, caldav_router, google_router, homeassistant_router, ical_router, local_router, profile_router, settings_router, users_router
logging.basicConfig(level=logging.INFO) logging.basicConfig(level=logging.INFO)
@@ -63,11 +68,84 @@ def _migrate():
conn.commit() conn.commit()
except Exception: except Exception:
pass pass
try:
conn.execute(text("ALTER TABLE homeassistant_accounts ADD COLUMN auth_method VARCHAR(20) DEFAULT 'token'"))
conn.commit()
except Exception:
pass
try:
conn.execute(text("ALTER TABLE homeassistant_accounts ADD COLUMN refresh_token TEXT"))
conn.commit()
except Exception:
pass
try:
conn.execute(text("ALTER TABLE homeassistant_accounts ADD COLUMN token_expiry DATETIME"))
conn.commit()
except Exception:
pass
try:
conn.execute(text("ALTER TABLE homeassistant_accounts ADD COLUMN client_id VARCHAR(500)"))
conn.commit()
except Exception:
pass
try:
conn.execute(text("ALTER TABLE local_events ADD COLUMN rrule TEXT"))
conn.commit()
logging.info("Migration: added rrule to local_events")
except Exception:
pass
try:
conn.execute(text("ALTER TABLE local_events ADD COLUMN exdate TEXT"))
conn.commit()
logging.info("Migration: added exdate to local_events")
except Exception:
pass
try:
conn.execute(text("ALTER TABLE user_settings ADD COLUMN month_divider_color VARCHAR(7) DEFAULT '#7090c0'"))
conn.commit()
except Exception:
pass
try:
conn.execute(text("ALTER TABLE user_settings ADD COLUMN month_label_color VARCHAR(7) DEFAULT '#7090c0'"))
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"])
@@ -76,11 +154,35 @@ app.include_router(profile_router.router, prefix="/api/profile", tags=["profile"
app.include_router(local_router.router, prefix="/api/local", tags=["local"]) app.include_router(local_router.router, prefix="/api/local", tags=["local"])
app.include_router(ical_router.router, prefix="/api/ical", tags=["ical"]) app.include_router(ical_router.router, prefix="/api/ical", tags=["ical"])
app.include_router(google_router.router, prefix="/api/google", tags=["google"]) app.include_router(google_router.router, prefix="/api/google", tags=["google"])
app.include_router(homeassistant_router.router, prefix="/api/homeassistant", tags=["homeassistant"])
FRONTEND_DIR = Path(__file__).parent.parent / "frontend" FRONTEND_DIR = Path(__file__).parent.parent / "frontend"
app.mount("/static", StaticFiles(directory=str(FRONTEND_DIR)), name="static") app.mount("/static", StaticFiles(directory=str(FRONTEND_DIR)), name="static")
# ── PWA assets that must live at root scope ──────────────
@app.get("/manifest.json")
async def pwa_manifest():
return FileResponse(str(FRONTEND_DIR / "manifest.json"), media_type="application/manifest+json")
@app.get("/sw.js")
async def pwa_service_worker():
return FileResponse(
str(FRONTEND_DIR / "sw.js"),
media_type="application/javascript",
headers={"Service-Worker-Allowed": "/", "Cache-Control": "no-cache"},
)
@app.get("/icons/{icon_name}")
async def pwa_icon(icon_name: str):
icon_path = FRONTEND_DIR / "icons" / icon_name
if not icon_path.exists() or not icon_path.is_file():
raise HTTPException(status_code=404, detail="Icon not found")
return FileResponse(str(icon_path))
@app.get("/{full_path:path}") @app.get("/{full_path:path}")
async def spa_fallback(full_path: str): async def spa_fallback(full_path: str):
if full_path.startswith("api/"): if full_path.startswith("api/"):

View File

@@ -30,6 +30,9 @@ class User(Base):
google_accounts = relationship( google_accounts = relationship(
"GoogleAccount", back_populates="user", cascade="all, delete-orphan" "GoogleAccount", back_populates="user", cascade="all, delete-orphan"
) )
homeassistant_accounts = relationship(
"HomeAssistantAccount", back_populates="user", cascade="all, delete-orphan"
)
class CalDAVAccount(Base): class CalDAVAccount(Base):
@@ -79,6 +82,8 @@ class UserSettings(Base):
line_contrast = Column(Integer, default=3) line_contrast = Column(Integer, default=3)
hour_height = Column(Integer, default=60) hour_height = Column(Integer, default=60)
language = Column(String(5), default="de") language = Column(String(5), default="de")
month_divider_color = Column(String(7), default="#7090c0")
month_label_color = Column(String(7), default="#7090c0")
user = relationship("User", back_populates="settings") user = relationship("User", back_populates="settings")
@@ -109,6 +114,8 @@ class LocalEvent(Base):
location = Column(String(500), nullable=True) location = Column(String(500), nullable=True)
description = Column(Text, nullable=True) description = Column(Text, nullable=True)
color = Column(String(7), nullable=True) color = Column(String(7), nullable=True)
rrule = Column(Text, nullable=True)
exdate = Column(Text, nullable=True) # Comma-separated YYYYMMDD dates to exclude
calendar = relationship("LocalCalendar", back_populates="events") calendar = relationship("LocalCalendar", back_populates="events")
@@ -176,3 +183,36 @@ class GoogleCalendar(Base):
sidebar_hidden = Column(Boolean, default=False) sidebar_hidden = Column(Boolean, default=False)
account = relationship("GoogleAccount", back_populates="calendars") account = relationship("GoogleAccount", back_populates="calendars")
class HomeAssistantAccount(Base):
__tablename__ = "homeassistant_accounts"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
name = Column(String(100), nullable=False)
url = Column(String(500), nullable=False)
token = Column(Text, nullable=False)
auth_method = Column(String(20), default="token")
refresh_token = Column(Text, nullable=True)
token_expiry = Column(DateTime, nullable=True)
client_id = Column(String(500), nullable=True)
user = relationship("User", back_populates="homeassistant_accounts")
calendars = relationship(
"HomeAssistantCalendar", back_populates="account", cascade="all, delete-orphan"
)
class HomeAssistantCalendar(Base):
__tablename__ = "homeassistant_calendars"
id = Column(Integer, primary_key=True, index=True)
account_id = Column(Integer, ForeignKey("homeassistant_accounts.id"), nullable=False)
entity_id = Column(String(255), nullable=False)
name = Column(String(255), nullable=False)
color = Column(String(7), nullable=True)
enabled = Column(Boolean, default=True)
sidebar_hidden = Column(Boolean, default=False)
account = relationship("HomeAssistantAccount", back_populates="calendars")

View File

@@ -1,3 +1,4 @@
from datetime import timedelta
from typing import Optional from typing import Optional
import pyotp import pyotp
@@ -11,6 +12,9 @@ import models
from auth import create_access_token, get_current_user, get_password_hash, verify_password from auth import create_access_token, get_current_user, get_password_hash, verify_password
from database import get_db from database import get_db
# When "Angemeldet bleiben" is ticked the token lives for half a year.
REMEMBER_ME_EXPIRY = timedelta(days=180)
router = APIRouter() router = APIRouter()
@@ -24,6 +28,7 @@ class LoginRequest(BaseModel):
username: str username: str
password: str password: str
totp_code: Optional[str] = None totp_code: Optional[str] = None
remember_me: Optional[bool] = False
def _user_dict(user: models.User) -> dict: def _user_dict(user: models.User) -> dict:
@@ -98,7 +103,8 @@ def login_json(req: LoginRequest, db: Session = Depends(get_db)):
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
detail="Ungültiger 2FA-Code", detail="Ungültiger 2FA-Code",
) )
token = create_access_token({"sub": user.username}) expires = REMEMBER_ME_EXPIRY if req.remember_me else None
token = create_access_token({"sub": user.username}, expires_delta=expires)
return {"access_token": token, "token_type": "bearer", "user": _user_dict(user)} return {"access_token": token, "token_type": "bearer", "user": _user_dict(user)}

View File

@@ -1,9 +1,13 @@
import logging import logging
from datetime import datetime as dt_datetime, date as dt_date, timedelta, timezone as dt_timezone
from typing import Optional from typing import Optional
from urllib.parse import urlparse
from dateutil.rrule import rrulestr
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from sqlalchemy import or_
import caldav_client import caldav_client
import models import models
@@ -41,6 +45,7 @@ class EventCreate(BaseModel):
location: Optional[str] = None location: Optional[str] = None
description: Optional[str] = None description: Optional[str] = None
color: Optional[str] = None color: Optional[str] = None
rrule: Optional[str] = None
class EventUpdate(BaseModel): class EventUpdate(BaseModel):
@@ -51,6 +56,8 @@ class EventUpdate(BaseModel):
location: Optional[str] = None location: Optional[str] = None
description: Optional[str] = None description: Optional[str] = None
color: Optional[str] = None color: Optional[str] = None
rrule: Optional[str] = None
exdate: Optional[str] = None
def _account_dict(a: models.CalDAVAccount) -> dict: def _account_dict(a: models.CalDAVAccount) -> dict:
@@ -75,16 +82,137 @@ def _account_dict(a: models.CalDAVAccount) -> dict:
} }
def _expand_recurring_local(ev, local_cal, range_start, range_end):
"""Expand a recurring LocalEvent into individual occurrences within the date range."""
results = []
# Parse excluded dates
excluded = set()
if ev.exdate:
for d in ev.exdate.split(","):
d = d.strip()
if d:
excluded.add(d)
try:
ev_start_str = ev.start.replace("Z", "+00:00")
ev_end_str = ev.end.replace("Z", "+00:00")
if ev.all_day:
ev_start = dt_date.fromisoformat(ev_start_str[:10])
ev_end = dt_date.fromisoformat(ev_end_str[:10])
duration = ev_end - ev_start
rule = rrulestr(f"RRULE:{ev.rrule}", dtstart=dt_datetime.combine(ev_start, dt_datetime.min.time()))
r_start = dt_datetime.combine(range_start if isinstance(range_start, dt_date) else range_start.date(), dt_datetime.min.time())
r_end = dt_datetime.combine(range_end if isinstance(range_end, dt_date) else range_end.date(), dt_datetime.min.time())
occurrences = rule.between(r_start - timedelta(days=1), r_end + timedelta(days=1), inc=True)
for occ in occurrences:
occ_start = occ.date()
occ_key = occ_start.strftime("%Y%m%d")
if occ_key in excluded:
continue
occ_end = occ_start + duration
results.append({
"id": ev.uid,
"url": f"local://{ev.uid}",
"title": ev.title,
"start": occ_start.isoformat(),
"end": occ_end.isoformat(),
"allDay": True,
"location": ev.location or "",
"description": ev.description or "",
"color": ev.color,
"rrule": ev.rrule,
"calendar_id": f"local-{local_cal.id}",
"calendar_name": local_cal.name,
"calendarColor": local_cal.color,
"source": "local",
})
else:
ev_start = dt_datetime.fromisoformat(ev_start_str)
ev_end = dt_datetime.fromisoformat(ev_end_str)
if ev_start.tzinfo is None:
ev_start = ev_start.replace(tzinfo=dt_timezone.utc)
if ev_end.tzinfo is None:
ev_end = ev_end.replace(tzinfo=dt_timezone.utc)
duration = ev_end - ev_start
rule = rrulestr(f"RRULE:{ev.rrule}", dtstart=ev_start)
r_start = range_start if isinstance(range_start, dt_datetime) else dt_datetime.combine(range_start, dt_datetime.min.time(), tzinfo=dt_timezone.utc)
r_end = range_end if isinstance(range_end, dt_datetime) else dt_datetime.combine(range_end, dt_datetime.min.time(), tzinfo=dt_timezone.utc)
if r_start.tzinfo is None:
r_start = r_start.replace(tzinfo=dt_timezone.utc)
if r_end.tzinfo is None:
r_end = r_end.replace(tzinfo=dt_timezone.utc)
occurrences = rule.between(r_start - timedelta(days=1), r_end + timedelta(days=1), inc=True)
for occ in occurrences:
occ_key = occ.strftime("%Y%m%d")
if occ_key in excluded:
continue
occ_end = occ + duration
results.append({
"id": ev.uid,
"url": f"local://{ev.uid}",
"title": ev.title,
"start": occ.isoformat(),
"end": occ_end.isoformat(),
"allDay": False,
"location": ev.location or "",
"description": ev.description or "",
"color": ev.color,
"rrule": ev.rrule,
"calendar_id": f"local-{local_cal.id}",
"calendar_name": local_cal.name,
"calendarColor": local_cal.color,
"source": "local",
})
except Exception as exc:
logger.warning("Error expanding recurring event %s: %s", ev.uid, exc)
# Fall back to single event
results.append({
"id": ev.uid, "url": f"local://{ev.uid}", "title": ev.title,
"start": ev.start, "end": ev.end, "allDay": ev.all_day,
"location": ev.location or "", "description": ev.description or "",
"color": ev.color, "rrule": ev.rrule,
"calendar_id": f"local-{local_cal.id}", "calendar_name": local_cal.name,
"calendarColor": local_cal.color, "source": "local",
})
return results
def _normalize_url(url: str) -> str:
"""Normalize URL for comparison: lowercase scheme/host, strip trailing slash."""
parsed = urlparse(url)
scheme = parsed.scheme.lower()
host = (parsed.hostname or '').lower()
port = parsed.port
if (scheme == 'https' and port == 443) or (scheme == 'http' and port == 80):
port = None
netloc = f"{host}:{port}" if port else host
path = parsed.path.rstrip('/')
return f"{scheme}://{netloc}{path}"
def _find_account_for_event_url( def _find_account_for_event_url(
event_url: str, accounts: list[models.CalDAVAccount] event_url: str, accounts: list[models.CalDAVAccount]
) -> Optional[models.CalDAVAccount]: ) -> Optional[models.CalDAVAccount]:
norm_event = _normalize_url(event_url)
# Primary: match against normalized account URL
for acc in accounts: for acc in accounts:
if event_url.startswith(acc.url): if norm_event.startswith(_normalize_url(acc.url)):
return acc return acc
# fallback: check calendar urls # Fallback: match against normalized calendar URLs
for acc in accounts: for acc in accounts:
for cal in acc.calendars: for cal in acc.calendars:
if event_url.startswith(cal.cal_id): if norm_event.startswith(_normalize_url(cal.cal_id)):
return acc
# Second fallback: path-only matching
event_path = urlparse(event_url).path.rstrip('/')
for acc in accounts:
acc_path = urlparse(acc.url).path.rstrip('/')
if acc_path and event_path.startswith(acc_path):
return acc
for acc in accounts:
for cal in acc.calendars:
cal_path = urlparse(cal.cal_id).path.rstrip('/')
if cal_path and event_path.startswith(cal_path):
return acc return acc
return None return None
@@ -266,7 +394,7 @@ def get_events(
for account in accounts: for account in accounts:
for calendar in account.calendars: for calendar in account.calendars:
if not calendar.enabled: if not calendar.enabled or calendar.sidebar_hidden:
continue continue
try: try:
events = caldav_client.fetch_events( events = caldav_client.fetch_events(
@@ -282,6 +410,7 @@ def get_events(
ev["calendar_id"] = calendar.id ev["calendar_id"] = calendar.id
ev["calendar_name"] = calendar.name ev["calendar_name"] = calendar.name
ev["calendarColor"] = cal_color ev["calendarColor"] = cal_color
ev["source"] = "caldav"
all_events.append(ev) all_events.append(ev)
except Exception as exc: except Exception as exc:
logger.error( logger.error(
@@ -302,27 +431,35 @@ def get_events(
db.query(models.LocalEvent) db.query(models.LocalEvent)
.filter( .filter(
models.LocalEvent.calendar_id == local_cal.id, models.LocalEvent.calendar_id == local_cal.id,
models.LocalEvent.start < end, or_(
models.LocalEvent.end > start, # Non-recurring events in range
(models.LocalEvent.rrule == None) & (models.LocalEvent.start < end) & (models.LocalEvent.end > start),
# Recurring events: always include so we can expand
models.LocalEvent.rrule != None,
),
) )
.all() .all()
) )
for ev in local_events: for ev in local_events:
all_events.append({ if ev.rrule:
"id": ev.uid, all_events.extend(_expand_recurring_local(ev, local_cal, start_dt, end_dt))
"url": f"local://{ev.uid}", else:
"title": ev.title, all_events.append({
"start": ev.start, "id": ev.uid,
"end": ev.end, "url": f"local://{ev.uid}",
"allDay": ev.all_day, "title": ev.title,
"location": ev.location or "", "start": ev.start,
"description": ev.description or "", "end": ev.end,
"color": ev.color, "allDay": ev.all_day,
"calendar_id": f"local-{local_cal.id}", "location": ev.location or "",
"calendar_name": local_cal.name, "description": ev.description or "",
"calendarColor": local_cal.color, "color": ev.color,
"source": "local", "rrule": None,
}) "calendar_id": f"local-{local_cal.id}",
"calendar_name": local_cal.name,
"calendarColor": local_cal.color,
"source": "local",
})
# ── iCal subscription events ────────────────────────── # ── iCal subscription events ──────────────────────────
ical_subs = ( ical_subs = (
@@ -347,13 +484,28 @@ def get_events(
.filter(models.GoogleAccount.user_id == current_user.id) .filter(models.GoogleAccount.user_id == current_user.id)
.all() .all()
) )
google_errors = []
for g_acc in google_accounts: for g_acc in google_accounts:
try: try:
all_events.extend(get_google_events(g_acc, start_dt, end_dt, db)) all_events.extend(get_google_events(g_acc, start_dt, end_dt, db))
except Exception as exc: except Exception as exc:
logger.error("Error fetching Google Calendar for %s: %s", g_acc.email, exc) logger.error("Error fetching Google Calendar for %s: %s", g_acc.email, exc)
google_errors.append({"email": g_acc.email})
return all_events # ── Home Assistant events ─────────────────────────────
from routers.homeassistant_router import get_ha_events
ha_accounts = (
db.query(models.HomeAssistantAccount)
.filter(models.HomeAssistantAccount.user_id == current_user.id)
.all()
)
for ha_acc in ha_accounts:
try:
all_events.extend(get_ha_events(ha_acc, start_dt, end_dt, db))
except Exception as exc:
logger.error("Error fetching HA events for %s: %s", ha_acc.name, exc)
return {"events": all_events, "errors": google_errors}
@router.post("/events") @router.post("/events")
@@ -388,6 +540,7 @@ def create_event(
"location": data.location, "location": data.location,
"description": data.description, "description": data.description,
"color": data.color, "color": data.color,
"rrule": data.rrule,
}, },
) )
return {"uid": uid, "calendar_id": data.calendar_id} return {"uid": uid, "calendar_id": data.calendar_id}
@@ -399,6 +552,7 @@ def create_event(
def update_event( def update_event(
event_id: str, event_id: str,
event_url: str = Query(...), event_url: str = Query(...),
calendar_id: Optional[int] = Query(None),
data: EventUpdate = None, data: EventUpdate = None,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user), current_user: models.User = Depends(get_current_user),
@@ -408,7 +562,26 @@ def update_event(
.filter(models.CalDAVAccount.user_id == current_user.id) .filter(models.CalDAVAccount.user_id == current_user.id)
.all() .all()
) )
account = _find_account_for_event_url(event_url, accounts) account = None
cal_url = None
if calendar_id is not None:
cal = (
db.query(models.Calendar)
.join(models.CalDAVAccount)
.filter(models.Calendar.id == calendar_id, models.CalDAVAccount.user_id == current_user.id)
.first()
)
if cal:
account = next((a for a in accounts if a.id == cal.account_id), None)
cal_url = cal.cal_id
if not account:
account = _find_account_for_event_url(event_url, accounts)
# Try to find the calendar URL for the account
if account and not cal_url:
for c in account.calendars:
if event_url.startswith(c.cal_id) or event_url.startswith(_normalize_url(c.cal_id)):
cal_url = c.cal_id
break
if not account: if not account:
raise HTTPException(404, "Event not found or not authorized") raise HTTPException(404, "Event not found or not authorized")
try: try:
@@ -418,6 +591,7 @@ def update_event(
account.password, account.password,
event_url, event_url,
data.model_dump(exclude_none=True) if data else {}, data.model_dump(exclude_none=True) if data else {},
calendar_url=cal_url,
) )
return {"ok": True} return {"ok": True}
except Exception as exc: except Exception as exc:
@@ -428,6 +602,7 @@ def update_event(
def delete_event( def delete_event(
event_id: str, event_id: str,
event_url: str = Query(...), event_url: str = Query(...),
calendar_id: Optional[int] = Query(None),
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user), current_user: models.User = Depends(get_current_user),
): ):
@@ -436,12 +611,31 @@ def delete_event(
.filter(models.CalDAVAccount.user_id == current_user.id) .filter(models.CalDAVAccount.user_id == current_user.id)
.all() .all()
) )
account = _find_account_for_event_url(event_url, accounts) account = None
cal_url = None
if calendar_id is not None:
cal = (
db.query(models.Calendar)
.join(models.CalDAVAccount)
.filter(models.Calendar.id == calendar_id, models.CalDAVAccount.user_id == current_user.id)
.first()
)
if cal:
account = next((a for a in accounts if a.id == cal.account_id), None)
cal_url = cal.cal_id
if not account:
account = _find_account_for_event_url(event_url, accounts)
if account and not cal_url:
for c in account.calendars:
if event_url.startswith(c.cal_id) or event_url.startswith(_normalize_url(c.cal_id)):
cal_url = c.cal_id
break
if not account: if not account:
raise HTTPException(404, "Event not found or not authorized") raise HTTPException(404, "Event not found or not authorized")
try: try:
caldav_client.delete_event( caldav_client.delete_event(
account.url, account.username, account.password, event_url account.url, account.username, account.password, event_url,
calendar_url=cal_url,
) )
return {"ok": True} return {"ok": True}
except Exception as exc: except Exception as exc:

View File

@@ -32,6 +32,11 @@ SKIP_GOOGLE_CALENDAR_IDS = {
} }
def _is_system_calendar(cal_id: str) -> bool:
"""Return True for virtual/system calendars that should be hidden."""
return cal_id in SKIP_GOOGLE_CALENDAR_IDS or "weeknum" in cal_id.lower()
def _google_configured() -> bool: def _google_configured() -> bool:
return bool(GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET) return bool(GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET)
@@ -139,7 +144,7 @@ def _account_dict(a: models.GoogleAccount) -> dict:
"sidebar_hidden": bool(c.sidebar_hidden), "sidebar_hidden": bool(c.sidebar_hidden),
} }
for c in a.calendars for c in a.calendars
if c.cal_id not in SKIP_GOOGLE_CALENDAR_IDS if not _is_system_calendar(c.cal_id)
], ],
} }
@@ -149,12 +154,16 @@ def _sync_google_calendars(account: models.GoogleAccount, db: Session):
try: try:
token = _refresh_access_token(account, db) token = _refresh_access_token(account, db)
cal_list = _google_api(token, "/users/me/calendarList") cal_list = _google_api(token, "/users/me/calendarList")
existing = {c.cal_id: c for c in account.calendars} # Remove any previously stored system calendars (e.g. locale-specific weeknum variants)
for c in list(account.calendars):
if _is_system_calendar(c.cal_id):
db.delete(c)
existing = {c.cal_id: c for c in account.calendars if not _is_system_calendar(c.cal_id)}
for cal in cal_list.get("items", []): for cal in cal_list.get("items", []):
if cal.get("deleted"): if cal.get("deleted"):
continue continue
cal_id = cal["id"] cal_id = cal["id"]
if cal_id in SKIP_GOOGLE_CALENDAR_IDS: if _is_system_calendar(cal_id):
continue continue
if cal_id not in existing: if cal_id not in existing:
db.add(models.GoogleCalendar( db.add(models.GoogleCalendar(
@@ -373,16 +382,17 @@ def get_google_events(account: models.GoogleAccount, start_dt: datetime, end_dt:
"""Fetch events from all enabled Google calendars for an account.""" """Fetch events from all enabled Google calendars for an account."""
try: try:
token = _refresh_access_token(account, db) token = _refresh_access_token(account, db)
except Exception: except Exception as exc:
return [] logger.error("Token refresh failed for Google account %s: %s", account.email, exc)
raise
all_events = [] all_events = []
try: for gcal in account.calendars:
for gcal in account.calendars: if not gcal.enabled or gcal.sidebar_hidden:
if not gcal.enabled: continue
continue if _is_system_calendar(gcal.cal_id):
if gcal.cal_id in SKIP_GOOGLE_CALENDAR_IDS: continue
continue try:
events_resp = _google_api(token, f"/calendars/{gcal.cal_id}/events", params={ events_resp = _google_api(token, f"/calendars/{gcal.cal_id}/events", params={
"timeMin": start_dt.isoformat(), "timeMin": start_dt.isoformat(),
"timeMax": end_dt.isoformat(), "timeMax": end_dt.isoformat(),
@@ -393,8 +403,8 @@ def get_google_events(account: models.GoogleAccount, start_dt: datetime, end_dt:
if ev.get("status") == "cancelled": if ev.get("status") == "cancelled":
continue continue
all_events.append(_parse_google_event(ev, gcal.id, gcal.name, gcal.color or "#4285f4")) all_events.append(_parse_google_event(ev, gcal.id, gcal.name, gcal.color or "#4285f4"))
except Exception as exc: except Exception as exc:
logger.error("Error fetching Google Calendar for %s: %s", account.email, exc) logger.error("Error fetching events for calendar %s (%s): %s", gcal.name, gcal.cal_id, exc)
return all_events return all_events

View File

@@ -0,0 +1,735 @@
import json
import logging
import secrets
import ssl
import time
from datetime import datetime, timezone
from typing import Optional
from urllib.parse import urlencode
import requests as http_requests
import websocket as ws_client
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from fastapi.responses import RedirectResponse
from pydantic import BaseModel
from sqlalchemy.orm import Session
import models
from auth import get_current_user
from database import get_db
logger = logging.getLogger(__name__)
router = APIRouter()
HA_DEFAULT_COLOR = "#03a9f4"
# In-memory store for pending OAuth states (short-lived, ~10 min TTL)
_pending_oauth: dict[str, dict] = {}
def _cleanup_pending():
now = time.time()
for k in [k for k, v in _pending_oauth.items() if v["expires"] < now]:
_pending_oauth.pop(k, None)
# ── Auth helpers ──────────────────────────────────────────
def _ha_refresh(url: str, refresh_token: str, client_id: str) -> tuple:
"""Refresh grant → (access_token, expires_in)"""
resp = http_requests.post(
f"{url.rstrip('/')}/auth/token",
data={
"grant_type": "refresh_token",
"refresh_token": refresh_token,
"client_id": client_id,
},
timeout=10,
verify=False,
)
resp.raise_for_status()
data = resp.json()
return data["access_token"], data.get("expires_in", 1800)
def _get_valid_token(account: models.HomeAssistantAccount, db: Session) -> str:
"""Return a valid access token, refreshing if necessary."""
if account.auth_method != "oauth":
return account.token # Long-Lived Token läuft nicht ab
now = datetime.now(timezone.utc)
if account.token_expiry and account.token_expiry.replace(tzinfo=timezone.utc) > now:
return account.token
# Needs refresh
try:
access_token, expires_in = _ha_refresh(
account.url, account.refresh_token, account.client_id or ""
)
except Exception as exc:
logger.error("HA token refresh failed for %s: %s", account.name, exc)
raise HTTPException(401, "Home Assistant Token abgelaufen, bitte Konto neu verbinden")
account.token = access_token
account.token_expiry = datetime.fromtimestamp(now.timestamp() + expires_in, tz=timezone.utc)
db.commit()
return access_token
# ── HA API helpers ────────────────────────────────────────
def _ha_get_calendars(url: str, token: str) -> list:
try:
resp = http_requests.get(
f"{url.rstrip('/')}/api/calendars",
headers={"Authorization": f"Bearer {token}"},
timeout=10,
verify=False,
)
resp.raise_for_status()
return resp.json()
except http_requests.exceptions.ConnectionError:
raise HTTPException(503, "Home Assistant nicht erreichbar")
except http_requests.exceptions.Timeout:
raise HTTPException(503, "Home Assistant antwortet nicht (Timeout)")
except http_requests.exceptions.HTTPError as e:
if e.response is not None and e.response.status_code == 401:
raise HTTPException(400, "Ungültiger Access Token")
raise HTTPException(502, f"Home Assistant Fehler: {e}")
def _ha_get_events(url: str, token: str, entity_id: str, start_dt: datetime, end_dt: datetime) -> list:
try:
resp = http_requests.get(
f"{url.rstrip('/')}/api/calendars/{entity_id}",
headers={"Authorization": f"Bearer {token}"},
params={"start": start_dt.isoformat(), "end": end_dt.isoformat()},
timeout=15,
verify=False,
)
resp.raise_for_status()
return resp.json()
except http_requests.exceptions.ConnectionError:
raise http_requests.exceptions.ConnectionError(f"HA nicht erreichbar für {entity_id}")
except http_requests.exceptions.Timeout:
raise http_requests.exceptions.Timeout(f"HA Timeout für {entity_id}")
def _ha_format_dt(s: str) -> str:
"""Convert ISO datetime to HA format with timezone, no milliseconds.
HA's cv.datetime accepts ISO 8601. Keep the timezone offset so HA
interprets the time correctly regardless of HA's local timezone.
"""
# Frontend sends "2026-05-07T15:00:00.000Z"; normalize Z to +00:00
if s.endswith("Z"):
s = s[:-1] + "+00:00"
try:
dt = datetime.fromisoformat(s)
except ValueError:
# Strip fractional seconds if fromisoformat can't handle them
import re
s2 = re.sub(r"\.\d+", "", s)
dt = datetime.fromisoformat(s2)
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt.isoformat(timespec="seconds")
def _ha_build_event_body(entity_id: str, data: dict) -> dict:
"""Build a service-call body for create_event / update_event."""
body = {"entity_id": entity_id}
if data.get("title"):
body["summary"] = data["title"]
if data.get("description"):
body["description"] = data["description"]
if data.get("location"):
body["location"] = data["location"]
if data.get("start") and data.get("end"):
if data.get("allDay"):
body["start_date"] = data["start"][:10]
body["end_date"] = data["end"][:10]
else:
body["start_date_time"] = _ha_format_dt(data["start"])
body["end_date_time"] = _ha_format_dt(data["end"])
return body
def _ha_ws_call(url: str, token: str, command: dict) -> dict:
"""Send a single WebSocket command to HA and return the result.
Used for calendar/event/delete and calendar/event/update which are
only exposed via WebSocket, not as service calls.
"""
# Convert http(s):// → ws(s)://
if url.startswith("https://"):
ws_url = "wss://" + url[len("https://"):].rstrip("/") + "/api/websocket"
sslopt = {"cert_reqs": ssl.CERT_NONE}
elif url.startswith("http://"):
ws_url = "ws://" + url[len("http://"):].rstrip("/") + "/api/websocket"
sslopt = None
else:
raise Exception(f"Ungültige HA-URL: {url}")
sock = ws_client.create_connection(ws_url, timeout=15, sslopt=sslopt)
try:
# 1. auth_required
msg = json.loads(sock.recv())
if msg.get("type") != "auth_required":
raise Exception(f"Unerwartete WS-Antwort: {msg}")
# 2. send auth
sock.send(json.dumps({"type": "auth", "access_token": token}))
msg = json.loads(sock.recv())
if msg.get("type") != "auth_ok":
raise Exception(f"WS Auth fehlgeschlagen: {msg.get('message', msg)}")
# 3. send command
cmd = {"id": 1, **command}
logger.info("HA WS command: %s", cmd)
sock.send(json.dumps(cmd))
# 4. receive result (might receive other messages first, ignore them)
for _ in range(10):
msg = json.loads(sock.recv())
if msg.get("id") == 1:
if msg.get("success"):
return msg.get("result", {})
err = msg.get("error", {})
raise Exception(f"{err.get('code', 'error')}: {err.get('message', 'Unbekannter Fehler')}")
raise Exception("Keine Antwort von HA WebSocket")
finally:
try:
sock.close()
except Exception:
pass
def _ha_ws_event_payload(data: dict) -> dict:
"""Build the 'event' payload for calendar/event/create|update WS commands."""
payload = {}
if data.get("title"):
payload["summary"] = data["title"]
if data.get("description"):
payload["description"] = data["description"]
if data.get("location"):
payload["location"] = data["location"]
if data.get("start") and data.get("end"):
if data.get("allDay"):
payload["dtstart"] = data["start"][:10]
payload["dtend"] = data["end"][:10]
else:
payload["dtstart"] = _ha_format_dt(data["start"])
payload["dtend"] = _ha_format_dt(data["end"])
return payload
def _ha_create_event(url: str, token: str, entity_id: str, data: dict) -> dict:
"""Create a new event. Tries WebSocket command first, falls back to service call."""
# Try WebSocket calendar/event/create
try:
return _ha_ws_call(url, token, {
"type": "calendar/event/create",
"entity_id": entity_id,
"event": _ha_ws_event_payload(data),
})
except Exception as exc:
logger.info("HA WS create failed (%s), falling back to service call", exc)
# Fallback: service call
base = url.rstrip("/")
headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
body = _ha_build_event_body(entity_id, data)
logger.info("HA create_event body: %s", body)
resp = http_requests.post(
f"{base}/api/services/calendar/create_event",
headers=headers, json=body, timeout=15, verify=False,
)
if not resp.ok:
try:
detail = resp.json().get("message", resp.text[:500])
except Exception:
detail = resp.text[:500] if resp.text else f"HTTP {resp.status_code}"
raise Exception(f"HA create_event ({resp.status_code}): {detail}")
return {}
def _ha_update_event(url: str, token: str, entity_id: str, uid: str, data: dict):
"""Update an event. Tries calendar/event/update first; if the integration
doesn't support update (e.g. Google Calendar via HA), falls back to
delete+create so the user still sees their changes."""
try:
return _ha_ws_call(url, token, {
"type": "calendar/event/update",
"entity_id": entity_id,
"uid": uid,
"event": _ha_ws_event_payload(data),
})
except Exception as exc:
msg = str(exc).lower()
if "not_supported" not in msg and "does not support" not in msg:
raise
logger.info("HA update not supported for %s, falling back to delete+create", entity_id)
# Fallback: delete the old event, then create a new one
try:
_ha_ws_call(url, token, {
"type": "calendar/event/delete",
"entity_id": entity_id,
"uid": uid,
})
except Exception as exc:
logger.warning("HA delete during update fallback failed: %s", exc)
# Continue anyway — try the create
return _ha_ws_call(url, token, {
"type": "calendar/event/create",
"entity_id": entity_id,
"event": _ha_ws_event_payload(data),
})
def _ha_delete_event(url: str, token: str, entity_id: str, uid: str):
"""Delete an event via WebSocket command (the only reliable path)."""
return _ha_ws_call(url, token, {
"type": "calendar/event/delete",
"entity_id": entity_id,
"uid": uid,
})
def _parse_ha_event(ev: dict, cal_db_id: int, cal_name: str, cal_color: str) -> dict:
start = ev.get("start", {})
end = ev.get("end", {})
all_day = "date" in start and "dateTime" not in start
return {
"id": ev.get("uid") or f"ha-{cal_db_id}-{ev.get('summary', '')}",
"url": f"homeassistant://{cal_db_id}/{ev.get('uid', '')}",
"title": ev.get("summary", "(Kein Titel)"),
"start": start.get("dateTime") or start.get("date", ""),
"end": end.get("dateTime") or end.get("date", ""),
"allDay": all_day,
"location": ev.get("location", ""),
"description": ev.get("description", ""),
"color": None,
"calendar_id": f"homeassistant-{cal_db_id}",
"calendar_name": cal_name,
"calendarColor": cal_color,
"source": "homeassistant",
}
def get_ha_events(account: models.HomeAssistantAccount, start_dt: datetime, end_dt: datetime, db: Session) -> list:
all_events = []
try:
token = _get_valid_token(account, db)
except Exception as exc:
logger.error("HA token error for %s: %s", account.name, exc)
raise
for cal in account.calendars:
if not cal.enabled or cal.sidebar_hidden:
continue
try:
raw = _ha_get_events(account.url, token, cal.entity_id, start_dt, end_dt)
color = cal.color or HA_DEFAULT_COLOR
for ev in raw:
all_events.append(_parse_ha_event(ev, cal.id, cal.name, color))
except Exception as exc:
logger.error("HA event fetch error %s (%s): %s", cal.entity_id, account.name, exc)
return all_events
# ── Serialization ─────────────────────────────────────────
def _account_dict(a: models.HomeAssistantAccount) -> dict:
return {
"id": a.id,
"name": a.name,
"url": a.url,
"auth_method": a.auth_method or "token",
"calendars": [
{
"id": c.id,
"name": c.name,
"entity_id": c.entity_id,
"color": c.color or HA_DEFAULT_COLOR,
"enabled": c.enabled,
"sidebar_hidden": bool(c.sidebar_hidden),
}
for c in a.calendars
],
}
# ── Pydantic models ───────────────────────────────────────
class HAAccountCreate(BaseModel):
name: str
url: str
token: str
class HAOAuthStart(BaseModel):
name: str
url: str
client_id: str
redirect_uri: str
class HACalendarUpdate(BaseModel):
enabled: Optional[bool] = None
color: Optional[str] = None
name: Optional[str] = None
sidebar_hidden: Optional[bool] = None
# ── Endpoints ─────────────────────────────────────────────
@router.get("/accounts")
def list_accounts(
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user),
):
accounts = (
db.query(models.HomeAssistantAccount)
.filter(models.HomeAssistantAccount.user_id == current_user.id)
.all()
)
return [_account_dict(a) for a in accounts]
@router.post("/accounts")
def add_account(
data: HAAccountCreate,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user),
):
"""Create a HA account from a Long-Lived Access Token."""
remote_cals = _ha_get_calendars(data.url, data.token)
account = models.HomeAssistantAccount(
user_id=current_user.id,
name=data.name,
url=data.url,
token=data.token,
auth_method="token",
refresh_token=None,
token_expiry=None,
)
db.add(account)
db.flush()
for cal in remote_cals:
entity_id = cal.get("entity_id", "")
if not entity_id:
continue
db.add(models.HomeAssistantCalendar(
account_id=account.id,
entity_id=entity_id,
name=cal.get("name") or entity_id,
color=None,
enabled=True,
))
db.commit()
db.refresh(account)
return _account_dict(account)
@router.post("/auth-url")
def oauth_start(
data: HAOAuthStart,
current_user: models.User = Depends(get_current_user),
):
"""Start the OAuth flow: store pending state, return HA authorization URL."""
_cleanup_pending()
state_token = secrets.token_urlsafe(32)
_pending_oauth[state_token] = {
"user_id": current_user.id,
"ha_url": data.url.rstrip('/'),
"name": data.name,
"client_id": data.client_id,
"redirect_uri": data.redirect_uri,
"expires": time.time() + 600,
}
params = {
"client_id": data.client_id,
"redirect_uri": data.redirect_uri,
"state": state_token,
"response_type": "code",
}
return {"url": f"{data.url.rstrip('/')}/auth/authorize?{urlencode(params)}"}
@router.get("/callback")
def oauth_callback(
request: Request,
code: str = Query(""),
state: str = Query(""),
error: str = Query(""),
db: Session = Depends(get_db),
):
"""Callback from Home Assistant after user authorization."""
if error or not code:
return RedirectResponse(url=f"/?ha_error={error or 'no_code'}", status_code=302)
pending = _pending_oauth.pop(state, None)
if not pending or pending["expires"] < time.time():
return RedirectResponse(url="/?ha_error=state_expired", status_code=302)
ha_url = pending["ha_url"]
client_id = pending["client_id"]
# Exchange code for tokens
try:
resp = http_requests.post(
f"{ha_url}/auth/token",
data={
"grant_type": "authorization_code",
"code": code,
"client_id": client_id,
},
timeout=15,
verify=False,
)
except Exception as exc:
logger.error("HA token exchange connection error: %s", exc)
return RedirectResponse(url="/?ha_error=ha_unreachable", status_code=302)
if resp.status_code != 200:
logger.error("HA token exchange failed (%s): %s", resp.status_code, resp.text)
return RedirectResponse(url="/?ha_error=token_exchange_failed", status_code=302)
tokens = resp.json()
access_token = tokens["access_token"]
refresh_token = tokens.get("refresh_token", "")
expires_in = tokens.get("expires_in", 1800)
now = datetime.now(timezone.utc)
try:
remote_cals = _ha_get_calendars(ha_url, access_token)
except HTTPException as exc:
logger.error("HA calendar fetch failed after OAuth: %s", exc.detail)
return RedirectResponse(url="/?ha_error=calendars_failed", status_code=302)
account = models.HomeAssistantAccount(
user_id=pending["user_id"],
name=pending["name"],
url=ha_url,
token=access_token,
auth_method="oauth",
refresh_token=refresh_token,
token_expiry=datetime.fromtimestamp(now.timestamp() + expires_in, tz=timezone.utc),
client_id=client_id,
)
db.add(account)
db.flush()
for cal in remote_cals:
entity_id = cal.get("entity_id", "")
if not entity_id:
continue
db.add(models.HomeAssistantCalendar(
account_id=account.id,
entity_id=entity_id,
name=cal.get("name") or entity_id,
color=None,
enabled=True,
))
db.commit()
return RedirectResponse(url="/?ha_connected=1", status_code=302)
@router.delete("/accounts/{account_id}")
def delete_account(
account_id: int,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user),
):
acc = (
db.query(models.HomeAssistantAccount)
.filter(
models.HomeAssistantAccount.id == account_id,
models.HomeAssistantAccount.user_id == current_user.id,
)
.first()
)
if not acc:
raise HTTPException(404, "Account not found")
db.delete(acc)
db.commit()
return {"ok": True}
@router.post("/accounts/{account_id}/sync")
def sync_account(
account_id: int,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user),
):
acc = (
db.query(models.HomeAssistantAccount)
.filter(
models.HomeAssistantAccount.id == account_id,
models.HomeAssistantAccount.user_id == current_user.id,
)
.first()
)
if not acc:
raise HTTPException(404, "Account not found")
token = _get_valid_token(acc, db)
remote_cals = _ha_get_calendars(acc.url, token)
existing = {c.entity_id: c for c in acc.calendars}
for cal in remote_cals:
entity_id = cal.get("entity_id", "")
if not entity_id:
continue
if entity_id not in existing:
db.add(models.HomeAssistantCalendar(
account_id=acc.id,
entity_id=entity_id,
name=cal.get("name") or entity_id,
color=None,
enabled=True,
))
else:
existing[entity_id].name = cal.get("name") or entity_id
db.commit()
db.refresh(acc)
return _account_dict(acc)
@router.put("/calendars/{calendar_id}")
def update_calendar(
calendar_id: int,
data: HACalendarUpdate,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user),
):
cal = (
db.query(models.HomeAssistantCalendar)
.join(models.HomeAssistantAccount)
.filter(
models.HomeAssistantCalendar.id == calendar_id,
models.HomeAssistantAccount.user_id == current_user.id,
)
.first()
)
if not cal:
raise HTTPException(404, "Calendar not found")
if data.enabled is not None:
cal.enabled = data.enabled
if data.color is not None:
cal.color = data.color
if data.name is not None:
cal.name = data.name
if data.sidebar_hidden is not None:
cal.sidebar_hidden = data.sidebar_hidden
db.commit()
return {"ok": True}
# ── Event CRUD ───────────────────────────────────────────
class HAEventUpdate(BaseModel):
title: Optional[str] = None
start: Optional[str] = None
end: Optional[str] = None
allDay: Optional[bool] = None
location: Optional[str] = None
description: Optional[str] = None
class HAEventCreate(BaseModel):
calendar_id: int
title: str
start: str
end: str
allDay: bool = False
location: Optional[str] = None
description: Optional[str] = None
@router.post("/events")
def create_event(
data: HAEventCreate,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user),
):
cal = (
db.query(models.HomeAssistantCalendar)
.join(models.HomeAssistantAccount)
.filter(
models.HomeAssistantCalendar.id == data.calendar_id,
models.HomeAssistantAccount.user_id == current_user.id,
)
.first()
)
if not cal:
raise HTTPException(404, "Calendar not found")
account = cal.account
token = _get_valid_token(account, db)
try:
_ha_create_event(
account.url, token, cal.entity_id,
data.model_dump(exclude_none=True),
)
return {"ok": True}
except Exception as exc:
raise HTTPException(500, f"HA event create failed: {exc}")
@router.put("/events/{calendar_id}/{uid}")
def update_event(
calendar_id: int,
uid: str,
data: HAEventUpdate,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user),
):
cal = (
db.query(models.HomeAssistantCalendar)
.join(models.HomeAssistantAccount)
.filter(
models.HomeAssistantCalendar.id == calendar_id,
models.HomeAssistantAccount.user_id == current_user.id,
)
.first()
)
if not cal:
raise HTTPException(404, "Calendar not found")
account = cal.account
token = _get_valid_token(account, db)
try:
_ha_update_event(
account.url, token, cal.entity_id, uid,
data.model_dump(exclude_none=True),
)
return {"ok": True}
except Exception as exc:
raise HTTPException(500, f"HA event update failed: {exc}")
@router.delete("/events/{calendar_id}/{uid}")
def delete_event(
calendar_id: int,
uid: str,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user),
):
cal = (
db.query(models.HomeAssistantCalendar)
.join(models.HomeAssistantAccount)
.filter(
models.HomeAssistantCalendar.id == calendar_id,
models.HomeAssistantAccount.user_id == current_user.id,
)
.first()
)
if not cal:
raise HTTPException(404, "Calendar not found")
account = cal.account
token = _get_valid_token(account, db)
try:
_ha_delete_event(account.url, token, cal.entity_id, uid)
return {"ok": True}
except Exception as exc:
raise HTTPException(500, f"HA event delete failed: {exc}")

View File

@@ -32,6 +32,7 @@ class EventCreate(BaseModel):
location: Optional[str] = None location: Optional[str] = None
description: Optional[str] = None description: Optional[str] = None
color: Optional[str] = None color: Optional[str] = None
rrule: Optional[str] = None
class EventUpdate(BaseModel): class EventUpdate(BaseModel):
@@ -42,6 +43,8 @@ class EventUpdate(BaseModel):
location: Optional[str] = None location: Optional[str] = None
description: Optional[str] = None description: Optional[str] = None
color: Optional[str] = None color: Optional[str] = None
rrule: Optional[str] = None
exdate: Optional[str] = None
def _cal_dict(cal: models.LocalCalendar) -> dict: def _cal_dict(cal: models.LocalCalendar) -> dict:
@@ -64,6 +67,8 @@ def _event_dict(ev: models.LocalEvent, cal: models.LocalCalendar) -> dict:
"location": ev.location or "", "location": ev.location or "",
"description": ev.description or "", "description": ev.description or "",
"color": ev.color, "color": ev.color,
"rrule": ev.rrule,
"exdate": ev.exdate,
"calendar_id": f"local-{cal.id}", "calendar_id": f"local-{cal.id}",
"calendar_name": cal.name, "calendar_name": cal.name,
"calendarColor": cal.color, "calendarColor": cal.color,
@@ -180,6 +185,7 @@ def create_event(
location=data.location, location=data.location,
description=data.description, description=data.description,
color=data.color, color=data.color,
rrule=data.rrule,
) )
db.add(ev) db.add(ev)
db.commit() db.commit()
@@ -219,6 +225,14 @@ def update_event(
ev.description = data.description ev.description = data.description
if data.color is not None: if data.color is not None:
ev.color = data.color ev.color = data.color
if data.rrule is not None:
ev.rrule = data.rrule if data.rrule else None
if data.exdate is not None:
existing = ev.exdate or ""
dates = [d for d in existing.split(",") if d]
if data.exdate not in dates:
dates.append(data.exdate)
ev.exdate = ",".join(dates)
db.commit() db.commit()
return {"ok": True} return {"ok": True}

View File

@@ -22,6 +22,8 @@ class SettingsUpdate(BaseModel):
line_contrast: Optional[int] = None line_contrast: Optional[int] = None
hour_height: Optional[int] = None hour_height: Optional[int] = None
language: Optional[str] = None language: Optional[str] = None
month_divider_color: Optional[str] = None
month_label_color: Optional[str] = None
def _settings_dict(s: models.UserSettings) -> dict: def _settings_dict(s: models.UserSettings) -> dict:
@@ -36,6 +38,8 @@ def _settings_dict(s: models.UserSettings) -> dict:
"line_contrast": s.line_contrast or 3, "line_contrast": s.line_contrast or 3,
"hour_height": s.hour_height or 60, "hour_height": s.hour_height or 60,
"language": s.language or "de", "language": s.language or "de",
"month_divider_color": s.month_divider_color or "#7090c0",
"month_label_color": s.month_label_color or "#7090c0",
} }

File diff suppressed because it is too large Load Diff

BIN
frontend/icons/icon-192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

BIN
frontend/icons/icon-512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

9
frontend/icons/icon.svg Normal file
View File

@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<rect width="512" height="512" fill="#4285f4"/>
<g fill="none" stroke="#ffffff" stroke-width="23" stroke-linecap="round" stroke-linejoin="round">
<rect x="102" y="154" width="307" height="266" rx="26"/>
<line x1="102" y1="234" x2="409" y2="234"/>
<line x1="170" y1="103" x2="170" y2="180"/>
<line x1="342" y1="103" x2="342" y2="180"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 432 B

View File

@@ -2,9 +2,16 @@
<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" /> <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" />
<title>Calendarr</title> <!-- APP_VERSION: update here + version.js on every release -->
<title>Calendarr v17</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" />
<meta name="theme-color" content="#4285f4" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Calendarr" />
<link rel="apple-touch-icon" href="/icons/icon-192.png" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/cropperjs/1.6.2/cropper.min.css" /> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/cropperjs/1.6.2/cropper.min.css" />
<link rel="stylesheet" href="/static/css/app.css" /> <link rel="stylesheet" href="/static/css/app.css" />
</head> </head>
@@ -66,11 +73,14 @@
<label>2FA-Code</label> <label>2FA-Code</label>
<input type="text" id="login-totp" placeholder="6-stelliger Code" maxlength="6" inputmode="numeric" autocomplete="one-time-code" /> <input type="text" id="login-totp" placeholder="6-stelliger Code" maxlength="6" inputmode="numeric" autocomplete="one-time-code" />
</div> </div>
<label class="toggle-label" style="margin-bottom:14px">
<input type="checkbox" id="login-remember" /> Angemeldet bleiben
</label>
<div id="login-error" class="form-error hidden"></div> <div id="login-error" class="form-error hidden"></div>
<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</button> <button class="impressum-link" onclick="openImpressum()">©&nbsp;2026&nbsp;Scarriffleservices&nbsp;·&nbsp;v17</button>
</div> </div>
<!-- ─── MAIN APP ──────────────────────────────────────────── --> <!-- ─── MAIN APP ──────────────────────────────────────────── -->
@@ -99,11 +109,25 @@
</div> </div>
<div class="topbar-right"> <div class="topbar-right">
<div class="view-switcher"> <div class="view-switcher">
<button class="view-btn" data-view="quarter" data-i18n="view_quarter">Quartal</button>
<button class="view-btn" data-view="month" data-i18n="view_month">Monat</button> <button class="view-btn" data-view="month" data-i18n="view_month">Monat</button>
<button class="view-btn" data-view="week" data-i18n="view_week">Woche</button> <button class="view-btn" data-view="week" data-i18n="view_week">Woche</button>
<button class="view-btn" data-view="day" data-i18n="view_day">Tag</button> <button class="view-btn" data-view="day" data-i18n="view_day">Tag</button>
<button class="view-btn" data-view="agenda" data-i18n="view_agenda">Termine</button> <button class="view-btn" data-view="agenda" data-i18n="view_agenda">Termine</button>
</div> </div>
<div class="view-mobile-wrapper">
<button class="icon-btn" id="btn-view-mobile" title="Ansicht wechseln" aria-label="Ansicht wechseln">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M3 13h2v-2H3v2zm0 4h2v-2H3v2zm0-8h2V7H3v2zm4 4h14v-2H7v2zm0 4h14v-2H7v2zM7 7v2h14V7H7z"/></svg>
</button>
<div id="view-mobile-dropdown" class="user-dropdown hidden" style="right:0;top:50px;min-width:180px">
<button class="dropdown-item" data-mobile-view="quarter" data-i18n="view_quarter">Quartal</button>
<button class="dropdown-item" data-mobile-view="month" data-i18n="view_month">Monat</button>
<button class="dropdown-item" data-mobile-view="week" data-i18n="view_week">Woche</button>
<button class="dropdown-item" data-mobile-view="day" data-i18n="view_day">Tag</button>
<button class="dropdown-item" data-mobile-view="agenda" data-i18n="view_agenda">Termine</button>
<button class="dropdown-item" id="btn-today-mobile" style="border-top:1px solid var(--border)" data-i18n="btn_today">Heute</button>
</div>
</div>
<button class="icon-btn" id="btn-settings" data-i18n-title="settings_title" title="Einstellungen"> <button class="icon-btn" id="btn-settings" data-i18n-title="settings_title" title="Einstellungen">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M19.14,12.94c0.04-0.3,0.06-0.61,0.06-0.94c0-0.32-0.02-0.64-0.07-0.94l2.03-1.58c0.18-0.14,0.23-0.41,0.12-0.61 l-1.92-3.32c-0.12-0.22-0.37-0.29-0.59-0.22l-2.39,0.96c-0.5-0.38-1.03-0.7-1.62-0.94L14.4,2.81c-0.04-0.24-0.24-0.41-0.48-0.41 h-3.84c-0.24,0-0.43,0.17-0.47,0.41L9.25,5.35C8.66,5.59,8.12,5.92,7.63,6.29L5.24,5.33c-0.22-0.08-0.47,0-0.59,0.22L2.74,8.87 C2.62,9.08,2.66,9.34,2.86,9.48l2.03,1.58C4.84,11.36,4.8,11.69,4.8,12s0.02,0.64,0.07,0.94l-2.03,1.58 c-0.18,0.14-0.23,0.41-0.12,0.61l1.92,3.32c0.12,0.22,0.37,0.29,0.59,0.22l2.39-0.96c0.5,0.38,1.03,0.7,1.62,0.94l0.36,2.54 c0.05,0.24,0.24,0.41,0.48,0.41h3.84c0.24,0,0.44-0.17,0.47-0.41l0.36-2.54c0.59-0.24,1.13-0.56,1.62-0.94l2.39,0.96 c0.22,0.08,0.47,0,0.59-0.22l1.92-3.32c0.12-0.22,0.07-0.47-0.12-0.61L19.14,12.94z M12,15.6c-1.98,0-3.6-1.62-3.6-3.6 s1.62-3.6,3.6-3.6s3.6,1.62,3.6,3.6S13.98,15.6,12,15.6z"/></svg> <svg viewBox="0 0 24 24" fill="currentColor"><path d="M19.14,12.94c0.04-0.3,0.06-0.61,0.06-0.94c0-0.32-0.02-0.64-0.07-0.94l2.03-1.58c0.18-0.14,0.23-0.41,0.12-0.61 l-1.92-3.32c-0.12-0.22-0.37-0.29-0.59-0.22l-2.39,0.96c-0.5-0.38-1.03-0.7-1.62-0.94L14.4,2.81c-0.04-0.24-0.24-0.41-0.48-0.41 h-3.84c-0.24,0-0.43,0.17-0.47,0.41L9.25,5.35C8.66,5.59,8.12,5.92,7.63,6.29L5.24,5.33c-0.22-0.08-0.47,0-0.59,0.22L2.74,8.87 C2.62,9.08,2.66,9.34,2.86,9.48l2.03,1.58C4.84,11.36,4.8,11.69,4.8,12s0.02,0.64,0.07,0.94l-2.03,1.58 c-0.18,0.14-0.23,0.41-0.12,0.61l1.92,3.32c0.12,0.22,0.37,0.29,0.59,0.22l2.39-0.96c0.5,0.38,1.03,0.7,1.62,0.94l0.36,2.54 c0.05,0.24,0.24,0.41,0.48,0.41h3.84c0.24,0,0.44-0.17,0.47-0.41l0.36-2.54c0.59-0.24,1.13-0.56,1.62-0.94l2.39,0.96 c0.22,0.08,0.47,0,0.59-0.22l1.92-3.32c0.12-0.22,0.07-0.47-0.12-0.61L19.14,12.94z M12,15.6c-1.98,0-3.6-1.62-3.6-3.6 s1.62-3.6,3.6-3.6s3.6,1.62,3.6,3.6S13.98,15.6,12,15.6z"/></svg>
</button> </button>
@@ -115,8 +139,12 @@
<svg viewBox="0 0 24 24" fill="currentColor" width="16" height="16"><path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/></svg> <svg viewBox="0 0 24 24" fill="currentColor" width="16" height="16"><path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/></svg>
<span data-i18n="btn_profile">Profil</span> <span data-i18n="btn_profile">Profil</span>
</button> </button>
<button class="dropdown-item dropdown-item-mobile-only" id="btn-settings-from-user">
<svg viewBox="0 0 24 24" fill="currentColor" width="16" height="16"><path d="M19.14,12.94c0.04-0.3,0.06-0.61,0.06-0.94c0-0.32-0.02-0.64-0.07-0.94l2.03-1.58c0.18-0.14,0.23-0.41,0.12-0.61 l-1.92-3.32c-0.12-0.22-0.37-0.29-0.59-0.22l-2.39,0.96c-0.5-0.38-1.03-0.7-1.62-0.94L14.4,2.81c-0.04-0.24-0.24-0.41-0.48-0.41 h-3.84c-0.24,0-0.43,0.17-0.47,0.41L9.25,5.35C8.66,5.59,8.12,5.92,7.63,6.29L5.24,5.33c-0.22-0.08-0.47,0-0.59,0.22L2.74,8.87 C2.62,9.08,2.66,9.34,2.86,9.48l2.03,1.58C4.84,11.36,4.8,11.69,4.8,12s0.02,0.64,0.07,0.94l-2.03,1.58 c-0.18,0.14-0.23,0.41-0.12,0.61l1.92,3.32c0.12,0.22,0.37,0.29,0.59,0.22l2.39-0.96c0.5,0.38,1.03,0.7,1.62,0.94l0.36,2.54 c0.05,0.24,0.24,0.41,0.48,0.41h3.84c0.24,0,0.44-0.17,0.47-0.41l0.36-2.54c0.59-0.24,1.13-0.56,1.62-0.94l2.39,0.96 c0.22,0.08,0.47,0,0.59-0.22l1.92-3.32c0.12-0.22,0.07-0.47-0.12-0.61L19.14,12.94z M12,15.6c-1.98,0-3.6-1.62-3.6-3.6 s1.62-3.6,3.6-3.6s3.6,1.62,3.6,3.6S13.98,15.6,12,15.6z"/></svg>
<span data-i18n="settings_title">Einstellungen</span>
</button>
<button class="dropdown-item" id="btn-logout"> <button class="dropdown-item" id="btn-logout">
<svg viewBox="0 0 24 24" fill="currentColor" width="16" height="16"><path d="M17 7l-1.41 1.41L18.17 11H8v2h10.17l-2.58 2.58L17 17l5-5zM4 5h8V3H4c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h8v-2H4V5z"/></svg> <svg viewBox="0 0 24 24" fill="currentColor" width="16" height="16"><path d="M17 7l-1.41 1.41L18.17 11H8v2h10.17l-2.58 2.58L17 17l5-5zM4 5h8V3H4c-1.1 0-2 .9-2 2v24c0 1.1.9 2 2 2h8v-2H4V5z"/></svg>
<span data-i18n="btn_logout">Abmelden</span> <span data-i18n="btn_logout">Abmelden</span>
</button> </button>
</div> </div>
@@ -164,14 +192,16 @@
<button data-action="caldav">CalDAV-Konto</button> <button data-action="caldav">CalDAV-Konto</button>
<button data-action="ical">iCal-URL abonnieren</button> <button data-action="ical">iCal-URL abonnieren</button>
<button data-action="google">Google Kalender</button> <button data-action="google">Google Kalender</button>
<button data-action="homeassistant">Home Assistant</button>
</div> </div>
</div> </div>
</div> </div>
<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</button> <button class="sidebar-copyright" onclick="openImpressum()">©&nbsp;2026&nbsp;Scarriffleservices&nbsp;·&nbsp;v17</button>
</aside> </aside>
<div id="sidebar-backdrop" class="sidebar-backdrop"></div>
<!-- MAIN VIEW --> <!-- MAIN VIEW -->
<main class="main-view" id="main-view"> <main class="main-view" id="main-view">
@@ -202,21 +232,87 @@
<div class="form-row" id="ev-time-row"> <div class="form-row" id="ev-time-row">
<div class="form-group half"> <div class="form-group half">
<label>Start</label> <label>Start</label>
<input type="datetime-local" id="ev-start" /> <input type="hidden" id="ev-start" />
<div class="dt-display" id="ev-start-display" tabindex="0" role="button">
<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 10h5v17H7z"/></svg>
</div>
</div> </div>
<div class="form-group half"> <div class="form-group half">
<label>Ende</label> <label>Ende</label>
<input type="datetime-local" id="ev-end" /> <input type="hidden" id="ev-end" />
<div class="dt-display" id="ev-end-display" tabindex="0" role="button">
<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 10h5v17H7z"/></svg>
</div>
</div> </div>
</div> </div>
<div class="form-row" id="ev-date-row" style="display:none"> <div class="form-row" id="ev-date-row" style="display:none">
<div class="form-group half"> <div class="form-group half">
<label>Start</label> <label>Start</label>
<input type="date" id="ev-start-date" /> <input type="hidden" id="ev-start-date" />
<div class="dt-display" id="ev-start-date-display" tabindex="0" role="button">
<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 10h5v17H7z"/></svg>
</div>
</div> </div>
<div class="form-group half"> <div class="form-group half">
<label>Ende</label> <label>Ende</label>
<input type="date" id="ev-end-date" /> <input type="hidden" id="ev-end-date" />
<div class="dt-display" id="ev-end-date-display" tabindex="0" role="button">
<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 10h5v17H7z"/></svg>
</div>
</div>
</div>
<div class="form-group">
<label id="ev-rec-label">Wiederholung</label>
<select id="ev-recurrence">
<option value="">Keine</option>
<option value="FREQ=DAILY">Täglich</option>
<option value="FREQ=WEEKLY">Wöchentlich</option>
<option value="FREQ=MONTHLY">Monatlich</option>
<option value="FREQ=YEARLY">Jährlich</option>
<option value="custom">Benutzerdefiniert…</option>
</select>
</div>
<div id="ev-recurrence-custom" class="form-group hidden">
<div class="form-row" style="gap:8px;align-items:center">
<label style="white-space:nowrap" id="ev-rec-every-label">Alle</label>
<input type="number" id="ev-rec-interval" value="1" min="1" max="99" style="width:60px" />
<select id="ev-rec-freq">
<option value="DAILY">Tage</option>
<option value="WEEKLY">Wochen</option>
<option value="MONTHLY">Monate</option>
</select>
</div>
<div id="ev-rec-weekdays" class="rec-weekdays hidden">
<button type="button" class="rec-day-btn" data-day="MO">Mo</button>
<button type="button" class="rec-day-btn" data-day="TU">Di</button>
<button type="button" class="rec-day-btn" data-day="WE">Mi</button>
<button type="button" class="rec-day-btn" data-day="TH">Do</button>
<button type="button" class="rec-day-btn" data-day="FR">Fr</button>
<button type="button" class="rec-day-btn" data-day="SA">Sa</button>
<button type="button" class="rec-day-btn" data-day="SU">So</button>
</div>
<div class="form-row" style="gap:8px;align-items:center;margin-top:8px">
<label id="ev-rec-ends-label">Endet</label>
<select id="ev-rec-end-type">
<option value="never">Nie</option>
<option value="count">Nach Anzahl</option>
<option value="until">Am Datum</option>
</select>
</div>
<div id="ev-rec-end-count" class="hidden" style="margin-top:4px">
<input type="number" id="ev-rec-count" value="10" min="1" max="999" style="width:80px" />
<span id="ev-rec-occ-label"> Termine</span>
</div>
<div id="ev-rec-end-until" class="hidden" style="margin-top:4px">
<input type="hidden" id="ev-rec-until" />
<div class="dt-display" id="ev-rec-until-display" tabindex="0" role="button">
<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 10h5v17H7z"/></svg>
</div>
</div> </div>
</div> </div>
<div class="form-group"> <div class="form-group">
@@ -248,18 +344,51 @@
</div> </div>
</div> </div>
<!-- Delete Confirm Modal -->
<div id="modal-delete-confirm" class="modal-overlay hidden">
<div class="modal-card" style="max-width:400px">
<div class="modal-header">
<h3 id="delete-confirm-title">Termin löschen</h3>
<button class="icon-btn modal-close" data-modal="modal-delete-confirm">&times;</button>
</div>
<div class="modal-body">
<p id="delete-confirm-text"></p>
<div id="delete-series-options" class="hidden" style="margin-top:12px">
<label class="toggle-label" style="display:block;margin-bottom:8px">
<input type="radio" name="delete-scope" value="single" checked /> Nur diesen Termin
</label>
<label class="toggle-label" style="display:block">
<input type="radio" name="delete-scope" value="all" /> Alle Serienelemente
</label>
</div>
</div>
<div class="modal-footer">
<div style="flex:1"></div>
<button class="btn btn-ghost" data-modal="modal-delete-confirm">Abbrechen</button>
<button class="btn btn-danger" id="delete-confirm-ok">Löschen</button>
</div>
</div>
</div>
<!-- Event Detail Popup --> <!-- Event Detail Popup -->
<div id="popup-event" class="event-popup hidden"> <div id="popup-event" class="event-popup hidden">
<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">
</button> <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 class="icon-btn popup-action" id="popup-delete" title="Löschen"> </button>
<svg 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 class="popup-icon-btn" id="popup-copy" title="Kopieren" aria-label="Kopieren">
</button> <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 class="icon-btn popup-close" id="popup-close">&times;</button> </button>
<button class="popup-icon-btn popup-icon-btn-danger" id="popup-delete" title="Löschen" aria-label="Löschen">
<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 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>
@@ -267,6 +396,7 @@
<div class="popup-description" id="popup-description"></div> <div class="popup-description" id="popup-description"></div>
<div class="popup-calendar" id="popup-calendar"></div> <div class="popup-calendar" id="popup-calendar"></div>
</div> </div>
<div id="popup-copy-menu" class="popup-copy-menu hidden"></div>
</div> </div>
<!-- Add CalDAV Account Modal --> <!-- Add CalDAV Account Modal -->
@@ -378,6 +508,49 @@
</div> </div>
</div> </div>
<!-- Home Assistant Account Modal -->
<div id="modal-ha-account" class="modal-overlay hidden">
<div class="modal-card" style="max-width:480px">
<div class="modal-header">
<h3>Home Assistant verbinden</h3>
<button class="icon-btn modal-close" data-modal="modal-ha-account">&times;</button>
</div>
<div class="modal-body">
<div class="form-group">
<label>Anzeigename</label>
<input type="text" id="ha-account-name" placeholder="z.B. Mein Home Assistant" />
</div>
<div class="form-group">
<label>Home Assistant URL</label>
<input type="url" id="ha-account-url" placeholder="http://homeassistant.local:8123" />
</div>
<div style="margin-bottom:16px">
<div style="font-size:12px;font-weight:500;text-transform:uppercase;letter-spacing:.5px;color:var(--text-2);margin-bottom:8px">Anmeldemethode</div>
<div style="display:flex;gap:20px">
<label style="display:flex;align-items:center;gap:6px;cursor:pointer;color:var(--text-1);font-size:14px">
<input type="radio" name="ha-auth-method" value="oauth" id="ha-auth-oauth" checked style="width:auto;accent-color:var(--primary)" /> Mit Home Assistant anmelden
</label>
<label style="display:flex;align-items:center;gap:6px;cursor:pointer;color:var(--text-1);font-size:14px">
<input type="radio" name="ha-auth-method" value="token" id="ha-auth-token" style="width:auto;accent-color:var(--primary)" /> Long-Lived Token
</label>
</div>
</div>
<div id="ha-oauth-info" style="margin-bottom:16px;padding:10px 12px;background:var(--bg-app);border-radius:var(--radius-sm);font-size:13px;color:var(--text-2);line-height:1.5">
Du wirst zur Login-Seite deiner Home Assistant Instanz weitergeleitet und nach erfolgreichem Login wieder zurück zu Calendarr.
</div>
<div class="form-group hidden" id="ha-token-group">
<label>Long-Lived Access Token</label>
<input type="password" id="ha-account-token" placeholder="Token aus Profil → Sicherheit" autocomplete="off" />
</div>
<div id="ha-account-error" class="form-error hidden"></div>
</div>
<div class="modal-footer">
<button class="btn btn-ghost" data-modal="modal-ha-account">Abbrechen</button>
<button class="btn btn-primary" id="ha-account-save">Mit Home Assistant anmelden</button>
</div>
</div>
</div>
<!-- Settings Page --> <!-- Settings Page -->
<div id="modal-settings" class="modal-overlay hidden"> <div id="modal-settings" class="modal-overlay hidden">
<div class="settings-page-card"> <div class="settings-page-card">
@@ -385,13 +558,17 @@
<button class="icon-btn modal-close" data-modal="modal-settings" style="margin-right:8px"> <button class="icon-btn modal-close" data-modal="modal-settings" style="margin-right:8px">
<svg viewBox="0 0 24 24" fill="currentColor" width="20" height="20"><path d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z"/></svg> <svg viewBox="0 0 24 24" fill="currentColor" width="20" height="20"><path d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z"/></svg>
</button> </button>
<button class="icon-btn settings-nav-toggle" id="settings-nav-toggle" title="Menü" aria-label="Menü">
<svg viewBox="0 0 24 24" fill="currentColor" width="20" height="20"><path d="M3 18h18v-2H3v2zm0-5h18v-2H3v2zm0-7v2h18V6H3z"/></svg>
</button>
<h3 data-i18n="settings_title">Einstellungen</h3> <h3 data-i18n="settings_title">Einstellungen</h3>
<button class="btn btn-primary" id="settings-save" style="margin-left:auto" data-i18n="save">Speichern</button> <button class="btn btn-primary" id="settings-save" style="margin-left:auto" data-i18n="save">Speichern</button>
</div> </div>
<div class="settings-page-body"> <div class="settings-page-body">
<div class="settings-nav-backdrop" id="settings-nav-backdrop"></div>
<nav class="settings-nav"> <nav class="settings-nav">
<button class="settings-nav-btn active" data-panel="general" data-i18n="settings_nav_appearance">Darstellung</button> <button class="settings-nav-btn active" data-panel="general" data-i18n="settings_nav_appearance">Darstellung</button>
<button class="settings-nav-btn" data-panel="google" data-i18n="settings_nav_google">Google Konten</button> <button class="settings-nav-btn" data-panel="accounts" data-i18n="settings_nav_accounts">Konten</button>
<button class="settings-nav-btn hidden" data-panel="users" id="settings-nav-users" data-i18n="settings_nav_users">Benutzerverwaltung</button> <button class="settings-nav-btn hidden" data-panel="users" id="settings-nav-users" data-i18n="settings_nav_users">Benutzerverwaltung</button>
</nav> </nav>
@@ -430,6 +607,20 @@
<div class="ev-color-preview" id="cfg-today-preview" data-i18n-title="color_pick" title="Farbe wählen"></div> <div class="ev-color-preview" id="cfg-today-preview" data-i18n-title="color_pick" title="Farbe wählen"></div>
</div> </div>
</div> </div>
<div class="form-group">
<label data-i18n="settings_month_divider_color">Monatswechsel-Linie</label>
<div class="ev-color-row">
<input type="text" id="cfg-month-divider-hex" class="ev-color-hex" maxlength="7" spellcheck="false" />
<div class="ev-color-preview" id="cfg-month-divider-preview" data-i18n-title="color_pick" title="Farbe wählen"></div>
</div>
</div>
<div class="form-group">
<label data-i18n="settings_month_label_color">Monatskürzel</label>
<div class="ev-color-row">
<input type="text" id="cfg-month-label-hex" class="ev-color-hex" maxlength="7" spellcheck="false" />
<div class="ev-color-preview" id="cfg-month-label-preview" data-i18n-title="color_pick" title="Farbe wählen"></div>
</div>
</div>
<h4 class="panel-title" style="margin-top:24px" data-i18n="settings_text_contrast">Schriftkontrast</h4> <h4 class="panel-title" style="margin-top:24px" data-i18n="settings_text_contrast">Schriftkontrast</h4>
<p class="panel-desc" data-i18n="settings_text_contrast_desc">Helligkeit der Beschriftungen und Texte</p> <p class="panel-desc" data-i18n="settings_text_contrast_desc">Helligkeit der Beschriftungen und Texte</p>
@@ -456,6 +647,7 @@
<option value="month" data-i18n="view_month">Monat</option> <option value="month" data-i18n="view_month">Monat</option>
<option value="week" data-i18n="view_week">Woche</option> <option value="week" data-i18n="view_week">Woche</option>
<option value="day" data-i18n="view_day">Tag</option> <option value="day" data-i18n="view_day">Tag</option>
<option value="quarter" data-i18n="view_quarter">Quartal</option>
<option value="agenda" data-i18n="view_agenda">Termine</option> <option value="agenda" data-i18n="view_agenda">Termine</option>
</select> </select>
</div> </div>
@@ -476,20 +668,44 @@
<h4 class="panel-title" style="margin-top:24px" data-i18n="settings_hour_height">Stundenhöhe (Wochen- &amp; Tagesansicht)</h4> <h4 class="panel-title" style="margin-top:24px" data-i18n="settings_hour_height">Stundenhöhe (Wochen- &amp; Tagesansicht)</h4>
<p class="panel-desc" data-i18n="settings_hour_height_desc">Wie viel Platz eine Stunde in der Zeitrasteransicht einnimmt</p> <p class="panel-desc" data-i18n="settings_hour_height_desc">Wie viel Platz eine Stunde in der Zeitrasteransicht einnimmt</p>
<div class="contrast-selector" id="cfg-hour-height" data-setting="hour_height"> <div class="contrast-selector" id="cfg-hour-height" data-setting="hour_height">
<button class="contrast-btn" data-val="40"><span class="hour-preview">━━</span><span class="contrast-lbl" data-i18n="hour_compact">Kompakt</span></button> <button class="contrast-btn" data-val="28"><span class="hour-preview">━━</span><span class="contrast-lbl" data-i18n="hour_compact">Kompakt</span></button>
<button class="contrast-btn" data-val="60"><span class="hour-preview">━━━</span><span class="contrast-lbl" data-i18n="hour_normal">Normal</span></button> <button class="contrast-btn" data-val="44"><span class="hour-preview">━━━</span><span class="contrast-lbl" data-i18n="hour_normal">Normal</span></button>
<button class="contrast-btn" data-val="80"><span class="hour-preview">━━━━</span><span class="contrast-lbl" data-i18n="hour_comfort">Komfort</span></button> <button class="contrast-btn" data-val="60"><span class="hour-preview">━━━━</span><span class="contrast-lbl" data-i18n="hour_comfort">Komfort</span></button>
<button class="contrast-btn" data-val="100"><span class="hour-preview">━━━━━</span><span class="contrast-lbl" data-i18n="hour_large">Gross</span></button> <button class="contrast-btn" data-val="80"><span class="hour-preview">━━━━━</span><span class="contrast-lbl" data-i18n="hour_large">Gross</span></button>
</div> </div>
<h4 class="panel-title" style="margin-top:24px" data-i18n="settings_hidden_cals">Ausgeblendete Kalender</h4> <h4 class="panel-title" style="margin-top:24px" data-i18n="settings_hidden_cals">Ausgeblendete Kalender</h4>
<div id="hidden-cals-list"><span style="font-size:13px;color:var(--text-3)" data-i18n="settings_no_hidden_cals">Keine ausgeblendeten Kalender</span></div> <div id="hidden-cals-list"><span style="font-size:13px;color:var(--text-3)" data-i18n="settings_no_hidden_cals">Keine ausgeblendeten Kalender</span></div>
</div> </div>
<!-- Google Konten --> <!-- Konten (CalDAV, Lokal, iCal, Google) -->
<div class="settings-panel" id="settings-panel-google"> <div class="settings-panel" id="settings-panel-accounts">
<h4 class="panel-title" data-i18n="settings_nav_google">Google Konten</h4> <h4 class="panel-title" data-i18n="settings_nav_accounts">Konten</h4>
<div id="google-accounts-list"><span style="font-size:13px;color:var(--text-3)" data-i18n="settings_no_google">Keine Google-Konten verbunden</span></div>
<div class="accounts-section">
<div class="accounts-section-heading" data-i18n="settings_accounts_caldav">CalDAV-Konten</div>
<div id="accounts-caldav-list"><span class="accounts-section-empty" data-i18n="settings_no_caldav_accounts">Keine CalDAV-Konten</span></div>
</div>
<div class="accounts-section">
<div class="accounts-section-heading" data-i18n="settings_accounts_local">Lokale Kalender</div>
<div id="accounts-local-list"><span class="accounts-section-empty" data-i18n="settings_no_local_cals">Keine lokalen Kalender</span></div>
</div>
<div class="accounts-section">
<div class="accounts-section-heading" data-i18n="settings_accounts_ical">iCal-Abonnements</div>
<div id="accounts-ical-list"><span class="accounts-section-empty" data-i18n="settings_no_ical_subs">Keine Abonnements</span></div>
</div>
<div class="accounts-section">
<div class="accounts-section-heading" data-i18n="settings_accounts_google">Google-Konten</div>
<div id="google-accounts-list"><span class="accounts-section-empty" data-i18n="settings_no_google_accounts">Keine Google-Konten</span></div>
</div>
<div class="accounts-section">
<div class="accounts-section-heading">Home Assistant</div>
<div id="accounts-ha-list"><span class="accounts-section-empty">Keine HA-Konten</span></div>
</div>
</div> </div>
<!-- Benutzerverwaltung --> <!-- Benutzerverwaltung -->
@@ -591,7 +807,7 @@
<div class="totp-secret-row"> <div class="totp-secret-row">
<code id="2fa-secret-code"></code> <code id="2fa-secret-code"></code>
<button class="btn btn-ghost btn-sm" id="2fa-copy-secret" title="Kopieren"> <button class="btn btn-ghost btn-sm" id="2fa-copy-secret" title="Kopieren">
<svg viewBox="0 0 24 24" fill="currentColor" width="16" height="16"><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> <svg viewBox="0 0 24 24" fill="currentColor" width="16" height="16"><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>
</button> </button>
</div> </div>
</div> </div>
@@ -646,6 +862,11 @@
</div> </div>
<!-- Toast --> <!-- Toast -->
<!-- Floating action button (mobile only — create event) -->
<button id="btn-create-fab" class="create-fab" title="Termin erstellen" aria-label="Termin erstellen">
<svg viewBox="0 0 24 24" fill="currentColor" width="28" height="28"><path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/></svg>
</button>
<div id="toast" class="toast hidden"></div> <div id="toast" class="toast hidden"></div>
<!-- Impressum Modal --> <!-- Impressum Modal -->
@@ -660,13 +881,14 @@
Software &amp; Webentwicklung</p> Software &amp; Webentwicklung</p>
<p>Diese Software wurde von Scarriffleservices mit grösster Sorgfalt entwickelt und bereitgestellt. Alle Rechte vorbehalten &copy; 2026 Scarriffleservices.</p> <p>Diese Software wurde von Scarriffleservices mit grösster Sorgfalt entwickelt und bereitgestellt. Alle Rechte vorbehalten &copy; 2026 Scarriffleservices.</p>
<p><strong>Datenspeicherung</strong><br> <p><strong>Datenspeicherung</strong><br>
Alle Anwendungsdaten werden ausschliesslich in der Schweiz gespeichert und verarbeitet. Bei Nutzung der Google Kalender-Anbindung werden Daten über die Google API abgerufen, welche von Google auf deren Infrastruktur ausserhalb der Schweiz verarbeitet werden. Für diese Daten gelten die Datenschutzbestimmungen von Google.</p> Alle Anwendungsdaten werden auf dem Server gespeichert und verarbeitet, auf dem diese Calendarr-Instanz betrieben wird. Der Speicherort hängt damit vom Betreiber des jeweiligen Servers ab. Bei Nutzung der Google&nbsp;Kalender-Anbindung werden Daten über die Google&nbsp;API ausgetauscht; für diese Daten gelten die Datenschutzbestimmungen von Google. Bei Nutzung der Home&nbsp;Assistant-Anbindung werden Daten mit der jeweiligen Home&nbsp;Assistant-Instanz ausgetauscht. Home&nbsp;Assistant ist ein Projekt der Open&nbsp;Home&nbsp;Foundation.</p>
<p><strong>Haftungsausschluss</strong><br> <p><strong>Haftungsausschluss</strong><br>
Trotz sorgfältiger Erstellung wird keine Haftung für die Richtigkeit, Vollständigkeit oder Aktualität der bereitgestellten Inhalte übernommen. Die Nutzung erfolgt auf eigene Verantwortung.</p> Trotz sorgfältiger Erstellung wird keine Haftung für die Richtigkeit, Vollständigkeit oder Aktualität der bereitgestellten Inhalte übernommen. Die Nutzung erfolgt auf eigene Verantwortung.</p>
<p><strong>Kontakt</strong><br> <p><strong>Kontakt</strong><br>
<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"> <div class="modal-footer" style="justify-content:space-between;align-items:center">
<span style="font-size:12px;color:var(--text-3)">Calendarr v17</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

@@ -64,8 +64,8 @@ export const api = {
delete: (path) => request('DELETE', path), delete: (path) => request('DELETE', path),
upload: (path, form) => uploadRequest(path, form), upload: (path, form) => uploadRequest(path, form),
login: (username, password, totp_code = null) => login: (username, password, totp_code = null, remember_me = false) =>
request('POST', '/auth/login', { username, password, totp_code }), request('POST', '/auth/login', { username, password, totp_code, remember_me }),
setupRequired: () => request('GET', '/auth/setup-required'), setupRequired: () => request('GET', '/auth/setup-required'),
setup: (data) => request('POST', '/auth/setup', data), setup: (data) => request('POST', '/auth/setup', data),

View File

@@ -141,11 +141,12 @@ function bindLoginForm() {
const username = document.getElementById('login-username').value.trim(); const username = document.getElementById('login-username').value.trim();
const password = document.getElementById('login-password').value; const password = document.getElementById('login-password').value;
const totpCode = document.getElementById('login-totp')?.value.trim() || null; const totpCode = document.getElementById('login-totp')?.value.trim() || null;
const remember = document.getElementById('login-remember')?.checked || false;
const errEl = document.getElementById('login-error'); const errEl = document.getElementById('login-error');
errEl.classList.add('hidden'); errEl.classList.add('hidden');
try { try {
const res = await api.login(username, password, totpCode); const res = await api.login(username, password, totpCode, remember);
localStorage.setItem('token', res.access_token); localStorage.setItem('token', res.access_token);
localStorage.setItem('user', JSON.stringify(res.user)); localStorage.setItem('user', JSON.stringify(res.user));
await launchApp(); await launchApp();
@@ -188,3 +189,12 @@ function loadAvatarImage(avatarEl, username) {
// ── Start ───────────────────────────────────────────────── // ── Start ─────────────────────────────────────────────────
boot(); boot();
// ── Service Worker registration (PWA) ─────────────────────
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js', { scope: '/' }).catch(err => {
console.warn('SW registration failed:', err);
});
});
}

File diff suppressed because it is too large Load Diff

View File

@@ -128,11 +128,16 @@ export function openColorPicker(anchorEl, currentColor = '#4285f4') {
function updateUI() { function updateUI() {
const [r, g, b] = hsvToRgb(h, s, v); const [r, g, b] = hsvToRgb(h, s, v);
const hex = rgbToHex(r, g, b); const hex = rgbToHex(r, g, b);
// SV cursor position // Use rendered rects to position cursor relative to the picker container
svCursor.style.left = (s * svCanvas.width) + 'px'; const svRect = svCanvas.getBoundingClientRect();
svCursor.style.top = ((1 - v) * svCanvas.height) + 'px'; const pickerRect = picker.getBoundingClientRect();
// Hue thumb position const hueRect = hueCanvas.getBoundingClientRect();
hueThumb.style.left = (h / 360 * hueCanvas.width) + 'px'; const hueTrackRect = hueTrack.getBoundingClientRect();
// SV cursor: offset canvas position within picker + position within canvas
svCursor.style.left = (svRect.left - pickerRect.left + s * svRect.width) + 'px';
svCursor.style.top = (svRect.top - pickerRect.top + (1 - v) * svRect.height) + 'px';
// Hue thumb: offset canvas position within track + position within canvas
hueThumb.style.left = (hueRect.left - hueTrackRect.left + (h / 360) * hueRect.width) + 'px';
// Preview + hex // Preview + hex
preview.style.background = hex; preview.style.background = hex;
hexInput.value = hex.toUpperCase(); hexInput.value = hex.toUpperCase();

271
frontend/js/date-picker.js Normal file
View File

@@ -0,0 +1,271 @@
/**
* Custom dark date/time picker
* openDatePicker(anchor, value, mode) → Promise<string|null>
* anchor : DOM element to position near
* value : ISO string ("YYYY-MM-DDTHH:MM" | "YYYY-MM-DD") or ""
* mode : 'datetime' | 'date'
*/
import { t } from './i18n.js';
const ITEM_H = 40; // px per scroll item
const VISIBLE = 3; // visible items in time scroller
export function openDatePicker(anchor, value, mode = 'datetime') {
return new Promise(resolve => {
// ── Parse initial value ───────────────────────────────
let selDate = new Date();
selDate.setHours(0, 0, 0, 0);
let selHour = selDate.getHours();
let selMin = 0;
if (value) {
try {
const raw = mode === 'datetime'
? value.replace(' ', 'T')
: value + 'T00:00:00';
const d = new Date(raw);
if (!isNaN(d)) {
selDate = new Date(d.getFullYear(), d.getMonth(), d.getDate());
if (mode === 'datetime') { selHour = d.getHours(); selMin = d.getMinutes(); }
}
} catch (_) {}
}
let viewYear = selDate.getFullYear();
let viewMonth = selDate.getMonth();
// ── Build DOM ─────────────────────────────────────────
const overlay = document.createElement('div');
overlay.className = 'dtp-overlay';
document.body.appendChild(overlay);
const card = document.createElement('div');
card.className = 'dtp-card';
overlay.appendChild(card);
function done(result) {
overlay.remove();
resolve(result);
}
// Click outside → cancel
overlay.addEventListener('mousedown', e => {
if (e.target === overlay) done(null);
});
// ── Calendar builder ──────────────────────────────────
function buildCalendar() {
const months = t('months');
const dowKeys = t('dow_monday'); // always Monday-first in calendar
const firstDay = new Date(viewYear, viewMonth, 1);
const gridStart = new Date(firstDay);
let dow = firstDay.getDay();
dow = dow === 0 ? 6 : dow - 1; // 0=Mon…6=Sun
gridStart.setDate(gridStart.getDate() - dow);
const cells = [];
const iter = new Date(gridStart);
for (let i = 0; i < 42; i++) {
cells.push(new Date(iter));
iter.setDate(iter.getDate() + 1);
}
const today = new Date(); today.setHours(0, 0, 0, 0);
const dowHtml = dowKeys.map(d => `<div class="dtp-dow">${d}</div>`).join('');
const daysHtml = cells.map(cell => {
const isOther = cell.getMonth() !== viewMonth;
const isToday = cell.getTime() === today.getTime();
const isSelected = cell.getTime() === selDate.getTime();
let cls = 'dtp-day';
if (isOther) cls += ' other';
if (isToday) cls += ' today';
if (isSelected) cls += ' selected';
return `<div class="${cls}" data-ts="${cell.getTime()}">${cell.getDate()}</div>`;
}).join('');
return `<div class="dtp-cal-header">
<button class="dtp-nav-btn" id="dtp-prev">&#8249;</button>
<span class="dtp-month-label">${months[viewMonth]} ${viewYear}</span>
<button class="dtp-nav-btn" id="dtp-next">&#8250;</button>
</div>
<div class="dtp-grid">
${dowHtml}
${daysHtml}
</div>`;
}
// ── Time scroll builder ───────────────────────────────
function buildTime() {
if (mode !== 'datetime') return '';
const hItems = Array.from({ length: 24 }, (_, i) =>
`<div class="dtp-ti" data-val="${i}">${String(i).padStart(2,'0')}</div>`
).join('');
const mItems = Array.from({ length: 60 }, (_, i) =>
`<div class="dtp-ti" data-val="${i}">${String(i).padStart(2,'0')}</div>`
).join('');
return `<div class="dtp-time-row">
<div class="dtp-tc-wrap">
<div class="dtp-tc" id="dtp-h">${hItems}</div>
</div>
<div class="dtp-colon">:</div>
<div class="dtp-tc-wrap">
<div class="dtp-tc" id="dtp-m">${mItems}</div>
</div>
</div>`;
}
// ── Render ────────────────────────────────────────────
function render() {
card.innerHTML =
buildCalendar() +
buildTime() +
`<div class="dtp-actions">
<button class="btn btn-ghost btn-sm" id="dtp-cancel">${t('cancel')}</button>
<button class="btn btn-primary btn-sm" id="dtp-ok">${t('save')}</button>
</div>`;
bindEvents();
if (mode === 'datetime') initScrollers();
positionCard();
}
// ── Event bindings ────────────────────────────────────
function bindEvents() {
card.querySelector('#dtp-prev').onclick = () => {
viewMonth--;
if (viewMonth < 0) { viewMonth = 11; viewYear--; }
render();
};
card.querySelector('#dtp-next').onclick = () => {
viewMonth++;
if (viewMonth > 11) { viewMonth = 0; viewYear++; }
render();
};
// Day click
card.querySelectorAll('.dtp-day').forEach(el => {
el.addEventListener('click', () => {
selDate = new Date(parseInt(el.dataset.ts));
if (el.classList.contains('other')) {
viewYear = selDate.getFullYear();
viewMonth = selDate.getMonth();
}
render();
});
});
card.querySelector('#dtp-cancel').onclick = () => done(null);
card.querySelector('#dtp-ok').onclick = () => done(buildResult());
}
// ── Time scroll initialisation ────────────────────────
function initScrollers() {
const hCol = card.querySelector('#dtp-h');
const mCol = card.querySelector('#dtp-m');
if (!hCol || !mCol) return;
// Scroll to selected value (padding-top = ITEM_H, so scrollTop = val * ITEM_H)
hCol.scrollTop = selHour * ITEM_H;
mCol.scrollTop = selMin * ITEM_H;
highlightItems(hCol, selHour);
highlightItems(mCol, selMin);
let hTimer, mTimer;
hCol.addEventListener('scroll', () => {
clearTimeout(hTimer);
hTimer = setTimeout(() => {
selHour = Math.max(0, Math.min(23, Math.round(hCol.scrollTop / ITEM_H)));
hCol.scrollTop = selHour * ITEM_H;
highlightItems(hCol, selHour);
}, 80);
});
mCol.addEventListener('scroll', () => {
clearTimeout(mTimer);
mTimer = setTimeout(() => {
selMin = Math.max(0, Math.min(59, Math.round(mCol.scrollTop / ITEM_H)));
mCol.scrollTop = selMin * ITEM_H;
highlightItems(mCol, selMin);
}, 80);
});
// Click item to select
hCol.querySelectorAll('.dtp-ti').forEach((el, i) => {
el.addEventListener('click', () => {
selHour = i;
hCol.scrollTo({ top: i * ITEM_H, behavior: 'smooth' });
highlightItems(hCol, i);
});
});
mCol.querySelectorAll('.dtp-ti').forEach((el, i) => {
el.addEventListener('click', () => {
selMin = i;
mCol.scrollTo({ top: i * ITEM_H, behavior: 'smooth' });
highlightItems(mCol, i);
});
});
}
function highlightItems(col, val) {
col.querySelectorAll('.dtp-ti').forEach((el, i) => {
el.classList.toggle('selected', i === val);
});
}
// ── Result builder ────────────────────────────────────
function buildResult() {
const y = selDate.getFullYear();
const mo = String(selDate.getMonth() + 1).padStart(2, '0');
const dd = String(selDate.getDate()).padStart(2, '0');
if (mode === 'date') return `${y}-${mo}-${dd}`;
const h = String(selHour).padStart(2, '0');
const mi = String(selMin).padStart(2, '0');
return `${y}-${mo}-${dd}T${h}:${mi}`;
}
// ── Positioning ───────────────────────────────────────
function positionCard() {
const r = anchor.getBoundingClientRect();
const cw = card.offsetWidth || 280;
const ch = card.offsetHeight || 420;
let left = r.left;
let top = r.bottom + 6;
if (left + cw > window.innerWidth - 8) left = window.innerWidth - cw - 8;
if (top + ch > window.innerHeight - 8) top = r.top - ch - 6;
if (left < 8) left = 8;
if (top < 8) top = 8;
card.style.left = left + 'px';
card.style.top = top + 'px';
}
render();
});
}
/**
* Format an ISO value for display in the UI
* mode: 'datetime' | 'date'
* lang: 'de' | 'en'
*/
export function formatDtDisplay(isoStr, mode, lang = 'de') {
if (!isoStr) return '—';
try {
const d = mode === 'datetime'
? new Date(isoStr.replace(' ', 'T'))
: new Date(isoStr + 'T00:00:00');
if (isNaN(d)) return isoStr;
const locale = lang === 'en' ? 'en-GB' : 'de-CH';
if (mode === 'datetime') {
return d.toLocaleString(locale, {
day: '2-digit', month: '2-digit', year: 'numeric',
hour: '2-digit', minute: '2-digit', hour12: false,
});
}
return d.toLocaleDateString(locale, {
day: '2-digit', month: '2-digit', year: 'numeric',
});
} catch (_) { return isoStr; }
}

View File

@@ -24,7 +24,7 @@ const translations = {
// Topbar // Topbar
btn_today: 'Heute', btn_today: 'Heute',
view_month: 'Monat', view_week: 'Woche', view_day: 'Tag', view_agenda: 'Termine', view_month: 'Monat', view_week: 'Woche', view_day: 'Tag', view_quarter: 'Quartal', view_agenda: 'Termine',
btn_profile: 'Profil', btn_logout: 'Abmelden', btn_profile: 'Profil', btn_logout: 'Abmelden',
// Sidebar // Sidebar
@@ -65,6 +65,8 @@ const translations = {
settings_colors: 'Farben', settings_colors: 'Farben',
settings_primary_color: 'Primärfarbe', settings_accent_color: 'Akzentfarbe', settings_primary_color: 'Primärfarbe', settings_accent_color: 'Akzentfarbe',
settings_today_color: 'Heutige-Tag-Farbe', settings_today_color: 'Heutige-Tag-Farbe',
settings_month_divider_color: 'Monatswechsel-Linie',
settings_month_label_color: 'Monatskürzel-Farbe',
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',
@@ -86,6 +88,17 @@ const translations = {
settings_hidden_cals: 'Ausgeblendete Kalender', settings_hidden_cals: 'Ausgeblendete Kalender',
settings_no_hidden_cals: 'Keine ausgeblendeten Kalender', settings_no_hidden_cals: 'Keine ausgeblendeten Kalender',
settings_no_google: 'Keine Google-Konten verbunden', settings_no_google: 'Keine Google-Konten verbunden',
settings_nav_accounts: 'Konten',
settings_accounts_caldav: 'CalDAV-Konten',
settings_accounts_local: 'Lokale Kalender',
settings_accounts_ical: 'iCal-Abonnements',
settings_accounts_google: 'Google-Konten',
settings_no_caldav_accounts: 'Keine CalDAV-Konten',
settings_no_local_cals: 'Keine lokalen Kalender',
settings_no_ical_subs: 'Keine Abonnements',
settings_no_google_accounts: 'Keine Google-Konten',
confirm_caldav_disconnect: 'CalDAV-Konto wirklich trennen?',
caldav_disconnected: 'CalDAV-Konto getrennt',
// User management // User management
users_add: 'Benutzer hinzufügen', users_is_admin: 'Administrator', users_add: 'Benutzer hinzufügen', users_is_admin: 'Administrator',
@@ -117,7 +130,7 @@ const translations = {
impressum_desc: 'Software & Webentwicklung', impressum_desc: 'Software & Webentwicklung',
impressum_about: 'Diese Software wurde von Scarriffleservices mit grösster Sorgfalt entwickelt und bereitgestellt. Alle Rechte vorbehalten\u00a0© 2026 Scarriffleservices.', impressum_about: 'Diese Software wurde von Scarriffleservices mit grösster Sorgfalt entwickelt und bereitgestellt. Alle Rechte vorbehalten\u00a0© 2026 Scarriffleservices.',
impressum_data_title: 'Datenspeicherung', impressum_data_title: 'Datenspeicherung',
impressum_data_text: 'Alle Anwendungsdaten werden ausschliesslich in der Schweiz gespeichert und verarbeitet. Bei Nutzung der Google Kalender-Anbindung werden Daten über die Google API abgerufen, welche von Google auf deren Infrastruktur ausserhalb der Schweiz verarbeitet werden. Für diese Daten gelten die Datenschutzbestimmungen von Google.', impressum_data_text: 'Alle Anwendungsdaten werden auf dem Server gespeichert und verarbeitet, auf dem diese Calendarr-Instanz betrieben wird. Der Speicherort hängt damit vom Betreiber des jeweiligen Servers ab. Bei Nutzung der Google Kalender-Anbindung werden Daten über die Google API ausgetauscht; für diese Daten gelten die Datenschutzbestimmungen von Google. Bei Nutzung der Home Assistant-Anbindung werden Daten mit der jeweiligen Home Assistant-Instanz ausgetauscht. Home Assistant ist ein Projekt der Open Home Foundation.',
impressum_disclaimer_title: 'Haftungsausschluss', impressum_disclaimer_title: 'Haftungsausschluss',
impressum_disclaimer_text: 'Trotz sorgfältiger Erstellung wird keine Haftung für die Richtigkeit, Vollständigkeit oder Aktualität der bereitgestellten Inhalte übernommen. Die Nutzung erfolgt auf eigene Verantwortung.', impressum_disclaimer_text: 'Trotz sorgfältiger Erstellung wird keine Haftung für die Richtigkeit, Vollständigkeit oder Aktualität der bereitgestellten Inhalte übernommen. Die Nutzung erfolgt auf eigene Verantwortung.',
impressum_contact: 'Kontakt', impressum_contact: 'Kontakt',
@@ -131,8 +144,23 @@ const translations = {
error_enter_title: 'Bitte Titel eingeben', error_enter_title: 'Bitte Titel eingeben',
error_enter_date: 'Bitte Datum eingeben', error_enter_date: 'Bitte Datum eingeben',
error_enter_start: 'Bitte Start-Zeit eingeben', error_enter_start: 'Bitte Start-Zeit eingeben',
error_end_before_start: 'Ende kann nicht vor dem Start liegen',
ctx_create_event: 'Neuen Termin erstellen',
event_readonly: 'Abonnierte Termine können nicht bearbeitet werden',
ha_create_not_supported: 'Termine können in Home Assistant Kalendern nicht direkt erstellt werden',
rec_label: 'Wiederholung',
rec_none: 'Keine', rec_daily: 'Täglich', rec_weekly: 'Wöchentlich',
rec_monthly: 'Monatlich', rec_yearly: 'Jährlich', rec_custom: 'Benutzerdefiniert…',
rec_every: 'Alle', rec_days: 'Tage', rec_weeks: 'Wochen', rec_months: 'Monate',
rec_ends: 'Endet', rec_never: 'Nie', rec_after_count: 'Nach Anzahl',
rec_on_date: 'Am Datum', rec_occurrences: 'Termine',
copy_to_calendar: 'Kopieren nach…', event_copied: 'Termin kopiert', copy: 'Kopieren',
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?',
confirm_delete_title: 'Termin löschen',
delete_single: 'Nur diesen Termin',
delete_all_series: 'Alle Termine der Serie',
event_deleted: 'Termin gelöscht', event_deleted: 'Termin gelöscht',
error_fill_all: 'Bitte alle Felder ausfüllen', error_fill_all: 'Bitte alle Felder ausfüllen',
account_added: 'Konto "{name}" hinzugefügt', account_added: 'Konto "{name}" hinzugefügt',
@@ -207,7 +235,7 @@ const translations = {
// Topbar // Topbar
btn_today: 'Today', btn_today: 'Today',
view_month: 'Month', view_week: 'Week', view_day: 'Day', view_agenda: 'Events', view_month: 'Month', view_week: 'Week', view_day: 'Day', view_quarter: 'Quarter', view_agenda: 'Events',
btn_profile: 'Profile', btn_logout: 'Log out', btn_profile: 'Profile', btn_logout: 'Log out',
// Sidebar // Sidebar
@@ -248,6 +276,8 @@ const translations = {
settings_colors: 'Colors', settings_colors: 'Colors',
settings_primary_color: 'Primary color', settings_accent_color: 'Accent color', settings_primary_color: 'Primary color', settings_accent_color: 'Accent color',
settings_today_color: 'Today highlight color', settings_today_color: 'Today highlight color',
settings_month_divider_color: 'Month divider line',
settings_month_label_color: 'Month label color',
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',
@@ -269,6 +299,17 @@ const translations = {
settings_hidden_cals: 'Hidden calendars', settings_hidden_cals: 'Hidden calendars',
settings_no_hidden_cals: 'No hidden calendars', settings_no_hidden_cals: 'No hidden calendars',
settings_no_google: 'No Google accounts connected', settings_no_google: 'No Google accounts connected',
settings_nav_accounts: 'Accounts',
settings_accounts_caldav: 'CalDAV Accounts',
settings_accounts_local: 'Local Calendars',
settings_accounts_ical: 'iCal Subscriptions',
settings_accounts_google: 'Google Accounts',
settings_no_caldav_accounts: 'No CalDAV accounts',
settings_no_local_cals: 'No local calendars',
settings_no_ical_subs: 'No subscriptions',
settings_no_google_accounts: 'No Google accounts',
confirm_caldav_disconnect: 'Really disconnect CalDAV account?',
caldav_disconnected: 'CalDAV account disconnected',
// User management // User management
users_add: 'Add user', users_is_admin: 'Administrator', users_add: 'Add user', users_is_admin: 'Administrator',
@@ -300,7 +341,7 @@ const translations = {
impressum_desc: 'Software & Web Development', impressum_desc: 'Software & Web Development',
impressum_about: 'This software was developed and provided by Scarriffleservices with the utmost care. All rights reserved\u00a0© 2026 Scarriffleservices.', impressum_about: 'This software was developed and provided by Scarriffleservices with the utmost care. All rights reserved\u00a0© 2026 Scarriffleservices.',
impressum_data_title: 'Data Storage', impressum_data_title: 'Data Storage',
impressum_data_text: 'All application data is stored and processed exclusively in Switzerland. When using the Google Calendar integration, data is retrieved via the Google API, which Google processes on their infrastructure outside Switzerland. Google\'s privacy policy applies to this data.', impressum_data_text: 'All application data is stored and processed on the server hosting this Calendarr instance. The storage location therefore depends on whoever operates that server. When using the Google Calendar integration, data is exchanged via the Google API; Google\'s privacy policy applies to this data. When using the Home Assistant integration, data is exchanged with the respective Home Assistant instance. Home Assistant is a project of the Open Home Foundation.',
impressum_disclaimer_title: 'Disclaimer', impressum_disclaimer_title: 'Disclaimer',
impressum_disclaimer_text: 'Despite careful preparation, no liability is assumed for the accuracy, completeness or timeliness of the content provided. Use is at your own risk.', impressum_disclaimer_text: 'Despite careful preparation, no liability is assumed for the accuracy, completeness or timeliness of the content provided. Use is at your own risk.',
impressum_contact: 'Contact', impressum_contact: 'Contact',
@@ -314,8 +355,23 @@ const translations = {
error_enter_title: 'Please enter a title', error_enter_title: 'Please enter a title',
error_enter_date: 'Please enter a date', error_enter_date: 'Please enter a date',
error_enter_start: 'Please enter a start time', error_enter_start: 'Please enter a start time',
error_end_before_start: 'End cannot be before start',
ctx_create_event: 'Create new event',
event_readonly: 'Subscribed events cannot be edited',
ha_create_not_supported: 'Events cannot be created directly in Home Assistant calendars',
rec_label: 'Recurrence',
rec_none: 'None', rec_daily: 'Daily', rec_weekly: 'Weekly',
rec_monthly: 'Monthly', rec_yearly: 'Yearly', rec_custom: 'Custom…',
rec_every: 'Every', rec_days: 'days', rec_weeks: 'weeks', rec_months: 'months',
rec_ends: 'Ends', rec_never: 'Never', rec_after_count: 'After count',
rec_on_date: 'On date', rec_occurrences: 'occurrences',
copy_to_calendar: 'Copy to…', event_copied: 'Event copied', copy: 'Copy',
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}"?',
confirm_delete_title: 'Delete event',
delete_single: 'Only this occurrence',
delete_all_series: 'All events in series',
event_deleted: 'Event deleted', event_deleted: 'Event deleted',
error_fill_all: 'Please fill in all fields', error_fill_all: 'Please fill in all fields',
account_added: 'Account "{name}" added', account_added: 'Account "{name}" added',
@@ -386,15 +442,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

@@ -92,8 +92,11 @@ export function applyTheme(settings) {
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);
const hh = settings.hour_height || 60; const hh = settings.hour_height || 44;
root.style.setProperty('--hour-h', hh + 'px'); root.style.setProperty('--hour-h', hh + 'px');
root.style.setProperty('--month-divider-color', settings.month_divider_color || '#7090c0');
root.style.setProperty('--month-label-color', settings.month_label_color || '#7090c0');
} }
function hexToRgba(hex, alpha) { function hexToRgba(hex, alpha) {

2
frontend/js/version.js Normal file
View File

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

View File

@@ -1,119 +1,231 @@
import { formatDate, isSameDay, isToday, isPast, dayOfWeek, getISOWeekNumber } from '../utils.js'; import { isToday, isPast, isSameDay, dayOfWeek, weekStart, getISOWeekNumber } from '../utils.js';
import { t } from '../i18n.js'; import { t } from '../i18n.js';
export function renderMonth(container, currentDate, events, onDayClick, onEventClick, weekStartDay = 'monday') { const LANE_H = 20; // px per lane (event height 18px + 2px gap)
const year = currentDate.getFullYear(); const DAY_H = 30; // day-number row height
const month = currentDate.getMonth(); const NUM_ROWS = 5; // rolling view: always 5 weeks
export function renderMonth(container, currentDate, events, onDayClick, onEventClick, weekStartDay = 'monday', selectedDate = null) {
// Dynamic lane limit: how many events fit in the actual row height
const containerH = container.clientHeight || 600;
const headerH = 34; // month-header DOW row
const rowH = (containerH - headerH) / NUM_ROWS;
const MAX_LANES = Math.max(1, Math.floor((rowH - DAY_H) / LANE_H) - 1);
// "Primary month" = currentDate's month (used for muting other-month days)
const primaryMonth = currentDate.getMonth();
const DOW = weekStartDay === 'sunday' ? t('dow_sunday') : t('dow_monday'); const DOW = weekStartDay === 'sunday' ? t('dow_sunday') : t('dow_monday');
const firstDay = new Date(year, month, 1); // Rolling grid: start at the week that contains currentDate
const lastDay = new Date(year, month + 1, 0); const gridStart = weekStart(currentDate, weekStartDay);
// Start grid on the correct weekday
const gridStart = new Date(firstDay);
const offset = dayOfWeek(firstDay, weekStartDay);
gridStart.setDate(gridStart.getDate() - offset);
// Build NUM_ROWS × 7 cells
const cells = []; const cells = [];
const d = new Date(gridStart); const d = new Date(gridStart);
for (let i = 0; i < 42; i++) { for (let i = 0; i < NUM_ROWS * 7; i++) {
cells.push(new Date(d)); cells.push(new Date(d));
d.setDate(d.getDate() + 1); d.setDate(d.getDate() + 1);
} }
// Build event map keyed by date string // Normalize each event's date range once
const evMap = {}; const normed = events.map(ev => {
events.forEach(ev => { const s = new Date(ev.start); s.setHours(0, 0, 0, 0);
const s = new Date(ev.start); const e = new Date(ev.end); e.setHours(0, 0, 0, 0);
const e = ev.allDay ? new Date(ev.end) : new Date(ev.end); if (ev.allDay && e > s) e.setDate(e.getDate() - 1); // exclusive → inclusive
// Spread multi-day events across cells return { ev, ns: s, ne: e };
const cur = new Date(s);
cur.setHours(0, 0, 0, 0);
const endNorm = new Date(e);
endNorm.setHours(0, 0, 0, 0);
if (ev.allDay && endNorm > cur) endNorm.setDate(endNorm.getDate() - 1);
while (cur <= endNorm) {
const key = dateKey(cur);
if (!evMap[key]) evMap[key] = [];
evMap[key].push(ev);
cur.setDate(cur.getDate() + 1);
}
}); });
// Header: KW-Spalte + Wochentage // Header
const headerHtml = `<div class="month-kw-header">KW</div>` + const headerHtml =
`<div class="month-kw-header">KW</div>` +
DOW.map(d => `<div class="month-dow">${d}</div>`).join(''); DOW.map(d => `<div class="month-dow">${d}</div>`).join('');
// Build rows (6 weeks × 7 days) // Build rows
let cellsHtml = ''; let bodyHtml = '';
for (let row = 0; row < 6; row++) { for (let row = 0; row < NUM_ROWS; row++) {
// KW cell for the first day of this row const rowCells = cells.slice(row * 7, row * 7 + 7);
const rowFirstDay = cells[row * 7]; const rowStart = new Date(rowCells[0]); rowStart.setHours(0, 0, 0, 0);
const kw = getISOWeekNumber(rowFirstDay); const rowEnd = new Date(rowCells[6]); rowEnd.setHours(0, 0, 0, 0);
cellsHtml += `<div class="month-kw-cell">${kw}</div>`; const kw = getISOWeekNumber(rowCells[0]);
for (let col = 0; col < 7; col++) { // Collect events overlapping this row
const cell = cells[row * 7 + col]; const rowItems = [];
const key = dateKey(cell); normed.forEach(({ ev, ns, ne }) => {
const cellEvs = (evMap[key] || []).slice().sort((a, b) => { if (ne < rowStart || ns > rowEnd) return;
if (a.allDay && !b.allDay) return -1; const colStart = Math.max(0, daysBetween(rowStart, ns));
if (!a.allDay && b.allDay) return 1; const colEnd = Math.min(6, daysBetween(rowStart, ne));
return new Date(a.start) - new Date(b.start); if (colEnd < colStart) return;
rowItems.push({
ev,
colStart,
span: colEnd - colStart + 1,
continuesLeft: ns < rowStart,
continuesRight: ne > rowEnd,
}); });
});
const isOther = cell.getMonth() !== month; // Sort: all-day first, then span desc, then start time
const todayClass = isToday(cell) ? 'today' : ''; rowItems.sort((a, b) => {
const otherClass = isOther ? 'other-month' : ''; if (a.ev.allDay && !b.ev.allDay) return -1;
const numClass = isToday(cell) ? 'today' : ''; if (!a.ev.allDay && b.ev.allDay) return 1;
if (b.span !== a.span) return b.span - a.span;
return new Date(a.ev.start) - new Date(b.ev.start);
});
const MAX_VISIBLE = 3; // Assign lanes (greedy interval packing)
const visible = cellEvs.slice(0, MAX_VISIBLE); const lanes = [];
const hiddenCount = cellEvs.length - MAX_VISIBLE; rowItems.forEach(item => {
let laneIdx = lanes.findIndex(l => item.colStart >= l.colEnd);
if (laneIdx === -1) { laneIdx = lanes.length; lanes.push({ colEnd: 0 }); }
item.lane = laneIdx;
lanes[laneIdx].colEnd = item.colStart + item.span;
});
const evHtml = visible.map(ev => { // Track overflow per column
const color = ev.color || ev.calendarColor || '#4285f4'; const overflowByCol = {};
const pastClass = isPast(ev) ? 'past' : ''; rowItems.forEach(item => {
const title = ev.allDay ? ev.title : `${fmtTime(new Date(ev.start))} ${ev.title}`; if (item.lane >= MAX_LANES) {
return `<div class="month-event ${pastClass}" data-id="${ev.id}" data-url="${escAttr(ev.url)}" for (let c = item.colStart; c < item.colStart + item.span; c++) {
style="background:${color};color:#fff" overflowByCol[c] = (overflowByCol[c] || 0) + 1;
title="${escAttr(ev.title)}">${escHtml(title)}</div>`; }
}).join(''); }
});
const moreHtml = hiddenCount > 0 // Render event spans HTML (placed in overlay)
? `<div class="month-more" data-date="${key}">${t('more_events', {n: hiddenCount})}</div>` let eventsHtml = '';
rowItems.forEach(item => {
if (item.lane >= MAX_LANES) return;
const { ev, colStart, span, continuesLeft, continuesRight } = item;
const leftPct = (colStart / 7) * 100;
const widthPct = (span / 7) * 100 - 0.4;
const topPx = item.lane * LANE_H + 2;
const color = ev.color || ev.calendarColor || '#4285f4';
const pastCls = isPast(ev) ? 'past' : '';
const cL = continuesLeft ? 'continues-left' : '';
const cR = continuesRight ? 'continues-right' : '';
const titleEsc = escHtml(ev.title);
const labelHtml = ev.allDay
? titleEsc
: `<span class="month-event-time">${escHtml(fmtTime(new Date(ev.start)))}</span> ${titleEsc}`;
eventsHtml += `<div class="month-span-event ${pastCls} ${cL} ${cR}"
data-id="${ev.id}" data-url="${escAttr(ev.url)}"
style="left:${leftPct.toFixed(3)}%;width:${widthPct.toFixed(3)}%;top:${topPx}px;background:${color}"
title="${escAttr(ev.title)}">${labelHtml}</div>`;
});
// "+N more" per column
Object.entries(overflowByCol).forEach(([col, count]) => {
const c = parseInt(col);
eventsHtml += `<div class="month-more"
data-date="${dateKey(rowCells[c])}"
style="left:${((c / 7) * 100).toFixed(3)}%;width:${(100 / 7).toFixed(3)}%;bottom:2px">${t('more_events', { n: count })}</div>`;
});
// Detect a month boundary in this row. monthChangeIdx is the index of
// the first-of-month cell, or -1 if the row doesn't span a month change.
let monthChangeIdx = -1;
rowCells.forEach((cell, idx) => {
if (cell.getDate() === 1 && idx > 0) monthChangeIdx = idx;
});
// Full-height column divs (click targets + borders)
const monthsShort = t('months_short');
let colsHtml = '';
rowCells.forEach((cell, idx) => {
const key = dateKey(cell);
const isOther = cell.getMonth() !== primaryMonth;
const todayCls = isToday(cell) ? 'today' : '';
const otherCls = isOther ? 'other-month' : '';
const selDate = selectedDate || currentDate;
const selectedCls = isSameDay(cell, selDate) ? 'month-selected' : '';
const numCls = isToday(cell) ? 'today' : '';
// First-of-month marker: show month abbreviation, push day number below
const isFirstOfMonth = cell.getDate() === 1;
const firstCls = isFirstOfMonth ? 'first-of-month' : '';
// Step-shaped boundary at month change: bottom under the previous-month
// tail, vertical at the change, top across the new-month head.
const dividerClasses = [];
if (isFirstOfMonth && idx > 0) dividerClasses.push('month-divider-left');
if (monthChangeIdx > 0 && idx >= monthChangeIdx) dividerClasses.push('month-divider-top');
if (monthChangeIdx > 0 && idx < monthChangeIdx) dividerClasses.push('month-divider-bottom');
const dividerCls = dividerClasses.join(' ');
const monthLabel = isFirstOfMonth
? `<div class="month-marker">${monthsShort[cell.getMonth()]}</div>`
: ''; : '';
colsHtml += `<div class="month-col ${todayCls} ${otherCls} ${selectedCls} ${firstCls} ${dividerCls}" data-date="${key}">
cellsHtml += `<div class="month-cell ${todayClass} ${otherClass}" data-date="${key}"> ${monthLabel}
<div class="cell-day ${numClass}">${cell.getDate()}</div> <div class="cell-day ${numCls}">${cell.getDate()}</div>
${evHtml}${moreHtml}
</div>`; </div>`;
} });
// If the row starts on the 1st of a new month, draw a full-width divider above the row
const rowDividerCls = rowCells[0].getDate() === 1 ? 'month-divider-top' : '';
// If any cell in the row is first-of-month, push events overlay down so the day
// number isn't hidden by spanning event bars
const hasMonthMarker = rowCells.some(c => c.getDate() === 1);
const rowMarkerCls = hasMonthMarker ? 'has-month-marker' : '';
bodyHtml += `<div class="month-row ${rowDividerCls} ${rowMarkerCls}">
<div class="month-kw-cell">${kw}</div>
<div class="month-row-right">
${colsHtml}
<div class="month-events-overlay">${eventsHtml}</div>
</div>
</div>`;
} }
container.innerHTML = `<div class="month-view"> container.innerHTML = `<div class="month-view">
<div class="month-header">${headerHtml}</div> <div class="month-header">${headerHtml}</div>
<div class="month-grid">${cellsHtml}</div> <div class="month-body">${bodyHtml}</div>
</div>`; </div>`;
// Events // Click handlers via event delegation on the body
container.querySelectorAll('.month-cell').forEach(cell => { const body = container.querySelector('.month-body');
cell.addEventListener('click', e => {
const evEl = e.target.closest('.month-event'); // Single click: select day (or handle event / more clicks)
if (evEl) { body.addEventListener('click', e => {
e.stopPropagation(); // Span event click
const ev = events.find(ev => ev.id === evEl.dataset.id && ev.url === evEl.dataset.url); const spanEl = e.target.closest('.month-span-event');
if (ev) onEventClick(ev, evEl); if (spanEl) {
return; e.stopPropagation();
} const ev = events.find(ev => ev.id === spanEl.dataset.id && ev.url === spanEl.dataset.url);
const moreEl = e.target.closest('.month-more'); if (ev) onEventClick(ev, spanEl);
if (moreEl) { return;
e.stopPropagation(); }
onDayClick(new Date(moreEl.dataset.date + 'T00:00:00')); // "+N more" → navigate to day view
return; const moreEl = e.target.closest('.month-more');
} if (moreEl) {
onDayClick(new Date(cell.dataset.date + 'T00:00:00')); e.stopPropagation();
}); onDayClick(new Date(moreEl.dataset.date + 'T00:00:00'), 'navigate');
return;
}
// Column click → select day
const colEl = e.target.closest('.month-col');
if (colEl) {
onDayClick(new Date(colEl.dataset.date + 'T00:00:00'), 'select');
}
}); });
// Double click: navigate to day view
body.addEventListener('dblclick', e => {
const colEl = e.target.closest('.month-col');
if (colEl && !e.target.closest('.month-span-event')) {
onDayClick(new Date(colEl.dataset.date + 'T00:00:00'), 'navigate');
}
});
// Right click: context menu
body.addEventListener('contextmenu', e => {
const colEl = e.target.closest('.month-col');
if (colEl) {
e.preventDefault();
onDayClick(new Date(colEl.dataset.date + 'T00:00:00'), 'context', e);
}
});
}
// ── Helpers ───────────────────────────────────────────────
function daysBetween(a, b) {
return Math.round((b - a) / 86400000);
} }
function dateKey(d) { function dateKey(d) {
@@ -127,6 +239,7 @@ function fmtTime(d) {
function escHtml(s) { function escHtml(s) {
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
} }
function escAttr(s) { function escAttr(s) {
return String(s).replace(/"/g,'&quot;').replace(/'/g,'&#39;'); return String(s).replace(/"/g,'&quot;').replace(/'/g,'&#39;');
} }

View File

@@ -0,0 +1,112 @@
import { isToday, isPast, dayOfWeek } from '../utils.js';
import { t } from '../i18n.js';
export function renderQuarter(container, currentDate, events, onDayClick, onEventClick, weekStartDay = 'monday') {
const year = currentDate.getFullYear();
// Quarter: Q1=0, Q2=1, Q3=2, Q4=3
const quarter = Math.floor(currentDate.getMonth() / 3);
const firstMonthOfQ = quarter * 3;
// Build event map keyed by date string
const evMap = {};
events.forEach(ev => {
const s = new Date(ev.start);
const e = new Date(ev.end);
const cur = new Date(s);
cur.setHours(0, 0, 0, 0);
const endNorm = new Date(e);
endNorm.setHours(0, 0, 0, 0);
if (ev.allDay && endNorm > cur) endNorm.setDate(endNorm.getDate() - 1);
while (cur <= endNorm) {
const key = dateKey(cur);
if (!evMap[key]) evMap[key] = [];
evMap[key].push(ev);
cur.setDate(cur.getDate() + 1);
}
});
const DOW = weekStartDay === 'sunday' ? t('dow_sunday') : t('dow_monday');
const MONTHS = t('months');
const monthsHtml = [0, 1, 2].map(offset => {
const month = firstMonthOfQ + offset;
const firstDay = new Date(year, month, 1);
const lastDay = new Date(year, month + 1, 0);
// Start grid on correct weekday
const gridStart = new Date(firstDay);
const startOffset = dayOfWeek(firstDay, weekStartDay);
gridStart.setDate(gridStart.getDate() - startOffset);
const cells = [];
const d = new Date(gridStart);
for (let i = 0; i < 42; i++) {
cells.push(new Date(d));
d.setDate(d.getDate() + 1);
}
// DOW header
const dowHeader = DOW.map(d => `<div class="qtr-dow">${d}</div>`).join('');
// Rows
let rowsHtml = '';
for (let row = 0; row < 6; row++) {
for (let col = 0; col < 7; col++) {
const cell = cells[row * 7 + col];
const key = dateKey(cell);
const cellEvs = evMap[key] || [];
const isOther = cell.getMonth() !== month;
const todayCls = isToday(cell) ? 'today' : '';
const otherCls = isOther ? 'other-month' : '';
// Up to 3 event dots
const dots = cellEvs.slice(0, 3).map(ev => {
const color = ev.color || ev.calendarColor || '#4285f4';
const pastCls = isPast(ev) ? 'past' : '';
return `<span class="qtr-dot ${pastCls}" style="background:${color}" title="${escAttr(ev.title)}" data-id="${ev.id}" data-url="${escAttr(ev.url || '')}"></span>`;
}).join('');
const moreDot = cellEvs.length > 3
? `<span class="qtr-dot-more">+${cellEvs.length - 3}</span>`
: '';
rowsHtml += `<div class="qtr-cell ${todayCls} ${otherCls}" data-date="${key}">
<div class="qtr-day-num">${cell.getDate()}</div>
<div class="qtr-dots">${dots}${moreDot}</div>
</div>`;
}
}
return `<div class="qtr-month">
<div class="qtr-month-name">${MONTHS[month]}</div>
<div class="qtr-month-grid">
<div class="qtr-header">${dowHeader}</div>
<div class="qtr-cells">${rowsHtml}</div>
</div>
</div>`;
}).join('');
container.innerHTML = `<div class="quarter-view">${monthsHtml}</div>`;
// Click handlers
container.querySelectorAll('.qtr-cell').forEach(cell => {
cell.addEventListener('click', e => {
// Check if a dot was clicked
const dot = e.target.closest('.qtr-dot');
if (dot) {
e.stopPropagation();
const ev = events.find(ev => ev.id === dot.dataset.id && ev.url === dot.dataset.url);
if (ev) { onEventClick(ev, dot); return; }
}
onDayClick(new Date(cell.dataset.date + 'T00:00:00'));
});
});
}
function dateKey(d) {
return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`;
}
function escAttr(s) {
return String(s).replace(/"/g,'&quot;').replace(/'/g,'&#39;');
}

View File

@@ -16,8 +16,19 @@ export function renderWeek(container, currentDate, events, onSlotClick, onEventC
} }
// Separate all-day and timed events // Separate all-day and timed events
const allDayEvs = events.filter(ev => ev.allDay); const allDayEvs = events.filter(ev => ev.allDay);
const timedEvs = events.filter(ev => !ev.allDay); const timedEvs = events.filter(ev => !ev.allDay);
// Multi-day timed events: timed but spanning more than one calendar day
const multiDayTimedEvs = timedEvs.filter(ev => !isSameDay(new Date(ev.start), new Date(ev.end)));
// Returns true if event overlaps any part of the given day
function spansDay(ev, day) {
const evStart = new Date(ev.start);
const evEnd = new Date(ev.end);
const dayStart = new Date(day); dayStart.setHours(0, 0, 0, 0);
const dayEnd = new Date(day); dayEnd.setHours(24, 0, 0, 0);
return evStart < dayEnd && evEnd > dayStart;
}
// ── KW Badge ────────────────────────────────────────── // ── KW Badge ──────────────────────────────────────────
const kwNum = getISOWeekNumber(days[0]); const kwNum = getISOWeekNumber(days[0]);
@@ -34,30 +45,61 @@ export function renderWeek(container, currentDate, events, onSlotClick, onEventC
</div>`; </div>`;
}).join(''); }).join('');
// ── All-day row ─────────────────────────────────────── // ── All-day row (spanning bars, same logic as month view) ──
const alldayCols = days.map(day => { const ALLDAY_LANE_H = 22;
const key = dayKey(day); const allDayAndMulti = [...allDayEvs, ...multiDayTimedEvs];
const dayEvs = allDayEvs.filter(ev => { const alldayLayout = layoutWeekAllDay(allDayAndMulti, days);
const s = new Date(ev.start); s.setHours(0,0,0,0); // Items that span more than one column → used for column background tint
const e = new Date(ev.end); e.setHours(0,0,0,0); const multiDayLayoutItems = alldayLayout.filter(item => item.colEnd > item.colStart);
const d = new Date(day); d.setHours(0,0,0,0); const maxAlldayLane = alldayLayout.length ? alldayLayout.reduce((m, it) => Math.max(m, it.lane), 0) : -1;
return d >= s && d < e || isSameDay(d, s); const alldayRowH = maxAlldayLane < 0 ? 28 : (maxAlldayLane + 1) * ALLDAY_LANE_H + 6;
});
const inner = dayEvs.map(ev => { const alldaySpanHtml = alldayLayout.map(({ ev, colStart, colEnd, lane }) => {
const color = ev.color || ev.calendarColor || '#4285f4'; const isMultiTimed = multiDayTimedEvs.includes(ev);
return `<div class="allday-event" style="background:${color};color:#fff" const n = days.length;
data-id="${ev.id}" data-url="${escAttr(ev.url)}" title="${escAttr(ev.title)}">${escHtml(ev.title)}</div>`; const left = (colStart / n) * 100;
}).join(''); const width = ((colEnd - colStart + 1) / n) * 100;
return `<div class="allday-col" data-date="${key}">${inner}</div>`; const top = lane * ALLDAY_LANE_H + 2;
const color = ev.color || ev.calendarColor || '#4285f4';
const pastCls = isPast(ev) ? 'past' : '';
const multiCls = isMultiTimed ? 'multiday-timed' : '';
// 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 label = isMultiTimed && isSameDay(new Date(ev.start), days[colStart])
? `${fmtTime(new Date(ev.start))} ${ev.title}`
: ev.title;
return `<div class="allday-span ${pastCls} ${multiCls} ${cL} ${cR}"
style="left:calc(${left.toFixed(2)}% + 1px);width:calc(${width.toFixed(2)}% - 2px);top:${top}px;background:${color};color:#fff"
data-id="${ev.id}" data-url="${escAttr(ev.url)}" title="${escAttr(ev.title)}">${escHtml(label)}</div>`;
}).join(''); }).join('');
const alldayBgCols = days.map(day =>
`<div class="allday-col-bg" data-date="${dayKey(day)}"></div>`
).join('');
const alldayCols = `<div class="allday-cols-wrap" style="height:${alldayRowH}px">
${alldayBgCols}
<div class="allday-spans-layer">${alldaySpanHtml}</div>
</div>`;
// ── Time column labels ──────────────────────────────── // ── Time column labels ────────────────────────────────
const timeLabels = Array.from({length: 24}, (_, h) => const timeLabels = Array.from({length: 24}, (_, h) =>
`<div class="time-label">${h === 0 ? '' : `${String(h).padStart(2,'0')}:00`}</div>` `<div class="time-label">${h === 0 ? '' : `${String(h).padStart(2,'0')}:00`}</div>`
).join(''); ).join('');
// ── Day columns ─────────────────────────────────────── // ── Day columns ───────────────────────────────────────
const dayCols = days.map(day => { const dayCols = days.map((day, dayIdx) => {
const key = dayKey(day); const key = dayKey(day);
const dayEvs = timedEvs.filter(ev => { const dayEvs = timedEvs.filter(ev => {
const s = new Date(ev.start); const s = new Date(ev.start);
@@ -74,7 +116,9 @@ export function renderWeek(container, currentDate, events, onSlotClick, onEventC
const s = new Date(ev.start); const s = new Date(ev.start);
const e = new Date(ev.end); const e = new Date(ev.end);
const top = s.getHours() * hourH + s.getMinutes() * hourH / 60; const top = s.getHours() * hourH + s.getMinutes() * hourH / 60;
const height = Math.max(20, (e - s) / 60000 * hourH / 60); const dayEnd = new Date(s); dayEnd.setHours(24, 0, 0, 0);
const clampedEnd = e > dayEnd ? dayEnd : e;
const height = Math.max(20, (clampedEnd - s) / 60000 * hourH / 60);
const left = (col / cols) * 100; const left = (col / cols) * 100;
const width = (1 / cols) * 100 - 0.5; const width = (1 / cols) * 100 - 0.5;
const color = ev.color || ev.calendarColor || '#4285f4'; const color = ev.color || ev.calendarColor || '#4285f4';
@@ -90,7 +134,30 @@ export function renderWeek(container, currentDate, events, onSlotClick, onEventC
</div>`; </div>`;
}).join(''); }).join('');
// Background tint: reuse alldayLayout (proven correct) — colEnd > colStart = multi-day
const dayTintEvs = multiDayLayoutItems
.filter(item => dayIdx >= item.colStart && dayIdx <= item.colEnd)
.map(item => item.ev);
const tintHtml = (() => {
if (!dayTintEvs.length) return '';
const colors = dayTintEvs.map(ev => ev.color || ev.calendarColor || '#4285f4');
let bg;
if (colors.length === 1) {
bg = colors[0] + '26';
} else {
// Vertical gradient bands for multiple overlapping multi-day events
const stops = colors.flatMap((c, i) => {
const p1 = ((i / colors.length) * 100).toFixed(1);
const p2 = (((i + 1) / colors.length) * 100).toFixed(1);
return [`${c}26 ${p1}%`, `${c}26 ${p2}%`];
}).join(',');
bg = `linear-gradient(to bottom,${stops})`;
}
return `<div class="col-span-tint" style="background:${bg}"></div>`;
})();
return `<div class="week-day-col" data-date="${key}" style="height:${hourH * 24}px"> return `<div class="week-day-col" data-date="${key}" style="height:${hourH * 24}px">
${tintHtml}
${hourLines} ${hourLines}
${evHtml} ${evHtml}
</div>`; </div>`;
@@ -99,23 +166,25 @@ export function renderWeek(container, currentDate, events, onSlotClick, onEventC
const viewClass = isSingleDay ? 'day-view' : 'week-view'; const viewClass = isSingleDay ? 'day-view' : 'week-view';
container.innerHTML = `<div class="${viewClass}"> container.innerHTML = `<div class="${viewClass}">
<div class="week-header-row"> <div class="week-head-sticky">
<div class="week-time-gutter">${kwBadge}</div> <div class="week-header-row">
${headerCols} <div class="week-time-gutter">${kwBadge}</div>
${headerCols}
</div>
<div class="week-allday-row">
<div class="allday-gutter">${t('allday')}</div>
${alldayCols}
</div>
</div> </div>
<div class="week-allday-row"> <div class="week-time-area">
<div class="allday-gutter">${t('allday')}</div>
<div class="allday-cols">${alldayCols}</div>
</div>
<div class="week-body">
<div class="week-time-col">${timeLabels}</div> <div class="week-time-col">${timeLabels}</div>
<div class="week-days-col">${dayCols}</div> <div class="week-days-col">${dayCols}</div>
</div> </div>
</div>`; </div>`;
// Scroll to ~8:00 // Scroll to ~8:00
const body = container.querySelector('.week-body'); const scrollEl = container.querySelector(`.${viewClass}`);
if (body) body.scrollTop = 8 * hourH - 20; if (scrollEl) scrollEl.scrollTop = 8 * hourH - 20;
// Render current-time line // Render current-time line
renderNowLine(container, days, hourH); renderNowLine(container, days, hourH);
@@ -125,7 +194,7 @@ export function renderWeek(container, currentDate, events, onSlotClick, onEventC
col.addEventListener('click', e => { col.addEventListener('click', e => {
if (e.target.closest('.timed-event')) return; if (e.target.closest('.timed-event')) return;
const rect = col.getBoundingClientRect(); const rect = col.getBoundingClientRect();
const y = e.clientY - rect.top + (container.querySelector('.week-body')?.scrollTop || 0); const y = e.clientY - rect.top + (scrollEl?.scrollTop || 0);
const h = Math.floor(y / hourH); const h = Math.floor(y / hourH);
const m = Math.round(((y % hourH) / hourH * 60) / 15) * 15; const m = Math.round(((y % hourH) / hourH * 60) / 15) * 15;
const date = new Date(col.dataset.date + 'T00:00:00'); const date = new Date(col.dataset.date + 'T00:00:00');
@@ -150,8 +219,8 @@ export function renderWeek(container, currentDate, events, onSlotClick, onEventC
}); });
}); });
// Click: all-day event // Click: all-day span
container.querySelectorAll('.allday-event').forEach(el => { container.querySelectorAll('.allday-span').forEach(el => {
el.addEventListener('click', e => { el.addEventListener('click', e => {
e.stopPropagation(); e.stopPropagation();
const ev = events.find(ev => ev.id === el.dataset.id && ev.url === el.dataset.url); const ev = events.find(ev => ev.id === el.dataset.id && ev.url === el.dataset.url);
@@ -175,6 +244,56 @@ function renderNowLine(container, days, hourH = 60) {
setTimeout(() => renderNowLine(container, days, hourH), 60000); setTimeout(() => renderNowLine(container, days, hourH), 60000);
} }
function layoutWeekAllDay(evs, days) {
const items = [];
evs.forEach(ev => {
// 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) {
if (colStart === -1) colStart = i;
colEnd = i;
}
});
if (colStart === -1) return;
items.push({ ev, colStart, colEnd });
});
// Sort: longer spans first, then by start column
items.sort((a, b) =>
(b.colEnd - b.colStart) - (a.colEnd - a.colStart) || a.colStart - b.colStart
);
// Greedy lane assignment
const laneEnds = [];
items.forEach(item => {
let lane = laneEnds.findIndex(end => item.colStart > end);
if (lane === -1) { lane = laneEnds.length; laneEnds.push(-1); }
item.lane = lane;
laneEnds[lane] = item.colEnd;
});
return items;
}
function layoutEvents(events) { function layoutEvents(events) {
if (!events.length) return []; if (!events.length) return [];

30
frontend/manifest.json Normal file
View File

@@ -0,0 +1,30 @@
{
"name": "Calendarr",
"short_name": "Calendarr",
"start_url": "/",
"scope": "/",
"display": "standalone",
"orientation": "any",
"background_color": "#0e0e14",
"theme_color": "#4285f4",
"icons": [
{
"src": "/icons/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "/icons/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
},
{
"src": "/icons/icon.svg",
"sizes": "any",
"type": "image/svg+xml",
"purpose": "any"
}
]
}

76
frontend/sw.js Normal file
View File

@@ -0,0 +1,76 @@
// Calendarr Service Worker — minimal-cache strategy
//
// 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-v17';
const OFFLINE_SHELL = ['/', '/index.html'];
self.addEventListener('install', event => {
event.waitUntil(
caches.open(CACHE_VERSION).then(cache =>
Promise.all(OFFLINE_SHELL.map(url =>
cache.add(url).catch(err => console.warn('[SW] skip', url, err))
))
).then(() => self.skipWaiting())
);
});
self.addEventListener('activate', event => {
event.waitUntil(
caches.keys().then(keys =>
Promise.all(keys.filter(k => k !== CACHE_VERSION).map(k => caches.delete(k)))
).then(() => self.clients.claim())
);
});
self.addEventListener('fetch', event => {
const req = event.request;
if (req.method !== 'GET') return;
const url = new URL(req.url);
// 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/')) {
event.respondWith(
fetch(req).catch(() =>
new Response(JSON.stringify({ offline: true }), {
status: 503,
headers: { 'Content-Type': 'application/json' },
})
)
);
return;
}
// Everything else: network-first. The browser's HTTP cache (driven by
// the server's Cache-Control headers) already throttles re-fetches —
// the SW just makes sure offline still works for the entry HTML.
event.respondWith(
fetch(req).then(resp => {
// Keep a fresh copy of navigation requests / index.html for offline
const isNavigation = req.mode === 'navigate'
|| url.pathname === '/'
|| url.pathname === '/index.html';
if (isNavigation && resp && resp.status === 200) {
const clone = resp.clone();
caches.open(CACHE_VERSION).then(c => c.put(req, clone)).catch(() => {});
}
return resp;
}).catch(() => {
// Offline fallback: only the HTML shell is served from cache, so the
// 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 });
})
);
});

View File

@@ -10,3 +10,5 @@ requests==2.32.3
pyotp==2.9.0 pyotp==2.9.0
qrcode[pil]==8.0 qrcode[pil]==8.0
Pillow==11.0.0 Pillow==11.0.0
python-dateutil==2.9.0
websocket-client==1.8.0