Commit Graph
466 Commits
Author SHA1 Message Date
ScarriffleandClaude Opus 5 8bc32faa7f fix(theme): Farben erben wieder den Standard statt ihn einzufrieren
Fuenf Farbspalten trugen einen fest eingebauten Vorgabewert — primary_color
#4285f4, accent_color #ea4335, today_color #4285f4, month_divider_color und
month_label_color #7090c0. Damit hatte *jeder* Nutzer immer eine eigene
Farbe, und der Fallback settings.X || baseColor(X) kam nie zum Zug. Das
Admin-Versprechen "Gilt fuer alle Nutzer ohne eigene Farbe" war fuer diese
fuenf unerfuellbar, und die Werte widersprachen DEFAULT_COLORS im Frontend,
das laut Kommentar die einzige Quelle sein soll.

Das betraf nicht nur Altbestand: UserSettings wird ohne Farbwerte angelegt,
also bekam auch jeder heute neu angelegte Nutzer das Blau.

- models.py: die fuenf Spalten sind nullable, NULL heisst "erbt".
- settings_router: NULL wird ausgeliefert statt durch einen Standard ersetzt,
  und ein explizites null wird gespeichert (NULLABLE_OVERRIDES).
- main.py: einmalige Migration setzt genau die alten Vorgabewerte auf NULL.
  Bewusst gewaehlte Farben bleiben unangetastet.
- calendar.js: eine erbende Zeile merkt sich das und sendet beim Speichern
  null. "Zurueck" heisst wieder erben, statt den aktuellen Standard in die
  Nutzerzeile zu kopieren — sonst erreichen spaetere Aenderungen des
  Standards den Nutzer nie mehr.

Verifiziert: frische Installation liefert --primary #58B900 statt #4285f4;
zurueckgeschriebenes Alt-Blau wird beim Neustart geleert; eine eigene Farbe
(#FF00AA) ueberlebt die Migration. Suite 129 gruen, davon 5 neue.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-01 11:44:25 +02:00
ScarriffleandClaude Opus 5 aaecfc435d fix(theme): Theme aus den wirksamen statt den rohen Einstellungen anwenden
initCalendar baute mit mergeEffective() korrekt die wirksamen Einstellungen
zusammen, benutzte danach aber die rohe Server-Antwort zum Anwenden:

  applyTheme(settings) statt applyTheme(state.settings)

Fuer jede Einstellung, deren Sync-Flag aus ist, ist der wirksame Wert laut
effectiveValue() der browser-lokale — der wurde damit stillschweigend
ignoriert. Die Einstellungstabelle zeigte also einen Wert an, waehrend die
Oberflaeche einen anderen benutzte.

Derselbe Fehler bei setLang: die Zeile mit dem wirksamen Wert wurde wenige
Zeilen spaeter vom rohen Server-Wert ueberschrieben. Die doppelte Zuweisung
faellt weg, die richtige bleibt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-01 11:34:55 +02:00
ScarriffleandClaude Opus 5 6b7317b4e0 docs: Design-Vertrag fuer Web, iOS und Android
Unterhalb der Farbebene war nie etwas einmal entschieden worden: Android
hat 40 verschiedene Abstandswerte, 9 Schriftgroessen, 9 Radien und vier
verschiedene Seitenraender; das Web streut aehnlich. Deshalb springt der
Textrand beim Screenwechsel und dieselbe Termin-Pille hat vier Radien.

Der Vertrag legt Abstandsleiter, Radien nach Rolle, Schriftskala,
Trefferflaechen, Komponenten-Inventar, Zustaende, Termin-Darstellung und
den gemeinsamen Widget-Zuschnitt fest -- nach dem Vorbild von
SETTINGS_SYNC.md, das denselben Zweck fuer die Einstellungen erfuellt.

Bewusst nicht vereinheitlicht: Navigation, Systemschrift, Bedienelemente
und Gesten. Gleiche Struktur und gleiche Masse, nicht gleiche Pixel.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-14 11:56:59 +02:00
ScarriffleandClaude Opus 5 cd6a4698aa fix(anhaenge): Anhang-Bereich ruhiger und im Stil der Eingabefelder
Die beiden Texte steckten in .form-hint -- eine spaetere Regel fuer den
SSO-Login macht daraus einen umrandeten Kasten in Primaerfarbe. Fuer eine
Warnung ist das richtig, fuer eine Bildunterschrift viel zu laut. Eigene
leise Klasse statt dessen.

"Datei hinzufuegen" sieht jetzt aus wie die Textfelder darueber: gleicher
Grund, Rahmen, Radius und Innenabstand, nur linksbuendig und in der
gedaempften Textfarbe.

Der Leerzustand "Keine Anhaenge" faellt weg -- die Erinnerungsliste macht
es genauso, und die Zeile unter dem Knopf sagt ohnehin, was hier hin kann.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-14 10:47:16 +02:00
ScarriffleandClaude Opus 5 a1f15e33aa feat(android): Anhaenge an Terminen
attachmentCount im Event-Parser, EventAttachment und StagedAttachment als
Modelle, fuenf Retrofit-Endpunkte plus Repository-Wrapper. Die
Event-Endpunkte liefern wie bisher den rohen Body und werden von Hand
geparst, passend zum iOS-Client.

Die Detailansicht zeigt die Liste und speichert eine Datei ueber SAF --
derselbe Zweischritt wie beim ICS-Export, damit weder ein FileProvider im
Manifest noch eine Speicherberechtigung noetig wird. Der Editor staged
neue Dateien und wendet sie erst beim Speichern an; createLocalEvent gibt
dafuer jetzt die neue UID zurueck.

Thumbnails werden mit BitmapFactory dekodiert statt eine Bildbibliothek
aufzunehmen: es sind serverseitig auf 320 px verkleinerte JPEGs, dafuer
lohnt Coil nicht.

strOrNull ist aus dem Companion von CalEvent auf Dateiebene gewandert,
damit EventAttachment es auch nutzen kann.

Bewusst TextOverflow.Ellipsis statt MiddleEllipsis: die Compose-BOM
2023.10.01 kennt letzteres noch nicht.

Nicht kompiliert: auf diesem Rechner gibt es weder JDK noch Android-SDK.
Muss auf einer Maschine mit beidem gebaut werden.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-14 10:39:16 +02:00
ScarriffleandClaude Opus 5 3b649ec310 feat(ios): Anhaenge an Terminen
attachmentCount im Event-Parser, EventAttachment als Modell, API-Methoden
fuer Liste, Upload, Download, Thumbnail und Loeschen. Der Multipart-Body
ist von importICS uebernommen.

Die Detailansicht laedt die Liste beim Oeffnen und teilt eine Datei ueber
das System-Share-Sheet; der Editor staged neue Dateien und wendet sie
erst nach dem Speichern an, weil ein neuer Termin vorher keine UID hat.

EventAttachment liegt bewusst in CalEvent.swift statt in einer eigenen
Datei: eine hier angelegte .swift-Datei waere in keinem Xcode-Target
Mitglied und wuerde schlicht nicht mitkompiliert -- das faellt erst auf
dem Mac auf.

Die URL fuer das Share-Sheet steckt in einem kleinen Identifiable-Wrapper,
weil URL das nicht ist und eine nachtraegliche Konformitaet auf einen
Stdlib-Typ modulweit sichtbar waere.

Nicht kompiliert: auf diesem Rechner gibt es kein Xcode. Muss auf dem Mac
gebaut werden.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-14 10:32:01 +02:00
ScarriffleandClaude Opus 5 cd8aba74b6 docs: Anhaenge, Hintergrund-Jobs und neue Variablen im README
Dokumentiert auch den ehrlichen Haken der ATTACH-Links: sie muessen ohne
Login funktionieren, weil externe Kalender-Clients keinen mitschicken
koennen, und sind damit unbefristet gueltige Capability-URLs. Mit dem
Abschalter, falls das fuer eine Installation nicht tragbar ist.

Nebenbei die seit dem Monorepo-Merge falsche Aussage korrigiert, die
mobilen Apps laegen in eigenen Repos.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-14 10:28:08 +02:00
ScarriffleandClaude Opus 5 e2a7edf421 feat(anhaenge): Anhaenge im Web-Editor und im Detail-Popup
Im Editor werden Dateien gestaget und erst beim Speichern angewendet --
sonst haette ein neuer Termin keine UID, an die der Anhang gehen koennte,
und "Abbrechen" wuerde nicht mehr bedeuten, dass nichts passiert ist.
Schlaegt danach ein Upload fehl, sagt der Toast das, statt Erfolg
vorzutaeuschen.

Das Popup laedt die Liste erst beim Oeffnen, weil das Event-JSON nur die
Anzahl traegt. Thumbnails gehen ueber authentifiziertes fetch auf eine
Blob-URL, weil ein img-Tag den Token nicht mitschicken kann -- die URL
wird nach dem Dekodieren freigegeben, anders als an den beiden
bestehenden Stellen, die sie liegen lassen.

Die fest verdrahtete Popup-Hoehe von 200 px wird jetzt gemessen; mit einer
Anhangliste stimmte die Annahme nicht mehr.

api.js haengt den HTTP-Status an geworfene Fehler, damit die Oberflaeche
409/413/400 uebersetzen kann statt das deutsche Backend-Detail zu zeigen.

Der ASCII-Rueckfallname im Content-Disposition war keiner: isalnum() ist
fuer Umlaute wahr. Faellt beim Herunterladen von "Rechnung Zahnaerztin.pdf"
auf, jetzt mit Test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-14 10:27:11 +02:00
ScarriffleandClaude Opus 5 a024fa54b9 feat(anhaenge): ATTACH-Zeile im erzeugten ICS
Jeder Anhang wird als ATTACH mit FMTTYPE, SIZE und FILENAME ausgegeben,
als URI statt inline base64 -- bei 10 MB je Datei wuerde sonst bei jeder
Synchronisation der ganze Anhang im ICS-Rumpf mitgeschleppt.

Die absolute URL kommt aus dav_util.public_base, das PUBLIC_BASE_URL und
die Forwarded-Header schon behandelt; ohne Basis-URL wird die Zeile
weggelassen, weil eine relative ATTACH-URL einem externen Client nichts
nuetzt. Abschaltbar ueber ATTACHMENT_PUBLIC_LINKS=0.

Der CalDAV-PUT ist geprueft: ein externer Client, der den Termin
zurueckschreibt, laesst die Anhaenge unberuehrt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-14 10:19:14 +02:00
ScarriffleandClaude Opus 5 4b9c09df74 feat(scheduler): Hintergrund-Jobs im Serverprozess
Das Backend hatte bisher keinerlei Hintergrund-Mechanik. Neues Modul mit
einer Job-Registry, angebunden ueber den FastAPI-Lifespan. Der Server
laeuft als genau ein Prozess, ein In-Process-Loop laeuft damit garantiert
nur einmal -- steht als Vorbehalt im Modulkopf, falls spaeter Worker
dazukommen.

Jeder Job laeuft im Thread mit eigener Session, weil SQLAlchemy hier
synchron ist und Datei- oder Netz-I/O sonst den Event-Loop blockiert.
Fehler werden pro Job gefangen.

Zwei Jobs: der Anhang-Sweep alle 24 h und die iCal-Aktualisierung alle
15 min. Letztere treibt nur den vorhandenen _refresh_if_needed an, der das
Intervall je Abo schon respektiert -- neu ist allein, dass Abos auch
aktuell bleiben, wenn niemand die App offen hat. Jedes Abo einzeln
gekapselt, weil der Helfer ohne Cache weiterwirft.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-14 10:14:28 +02:00
ScarriffleandClaude Opus 5 b885e731ee feat(anhaenge): attachment_count im Termin-JSON
Die Terminliste traegt nur die Anzahl, nicht die Anhaenge selbst. Der
Merge-Read laeuft pro Termin durch eine Schleife -- eine Lazy-Relationship
waere dort eine Abfrage je Termin ueber eine ganze Monatsansicht, und ein
wiederkehrender Termin wuerde dieselbe Liste an jeder Instanz wiederholen.
Stattdessen je Kalender eine gruppierte Abfrage; die Liste holen die
Clients beim Oeffnen der Detailansicht.

mask_busy_event setzt die Zahl ausdruecklich auf 0: dass ein als
"Beschaeftigt" maskierter Termin drei Dateien haette, ist auch eine
Information.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-14 10:11:30 +02:00
ScarriffleandClaude Opus 5 0e8a1a6a3d feat(anhaenge): Endpunkte zum Hochladen, Abrufen und Loeschen
Neuer Router unter /api/local mit Upload, Liste, Download, Thumbnail und
Loeschen, dazu permissions.readable_local_event als fehlendes Gegenstueck
zu _writable_event.

Der Lese-Helfer prueft nicht nur den Kalender, sondern auch die
Privatsphaere: ein fremder privater Termin wird im Lesepfad maskiert, also
duerfen auch seine Anhaenge nicht ueber die UID erreichbar sein. Fremde
bekommen durchgaengig 404 statt 403, damit die Existenz nicht durchsickert.

Die Mengenbegrenzung zaehlt nach dem Insert innerhalb derselben
Transaktion -- zaehlen-dann-einfuegen waere ein Wettlauf, bei dem zwei
gleichzeitige Uploads beide 9 sehen.

Ausgeliefert wird immer als Download mit nosniff, nie inline. Dazu die
Capability-URL unter /api/attach/<token> fuer externe CalDAV-Clients,
abschaltbar ueber ATTACHMENT_PUBLIC_LINKS=0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-14 10:08:47 +02:00
ScarriffleandClaude Opus 5 22a469d449 feat(anhaenge): Datenmodell und Ablage fuer Termin-Anhaenge
Neue Tabelle event_attachments samt Ablage-Modul. Dateien liegen unter
DATA_DIR/attachments, geshardet nach einem servergenerierten Namen -- weder
der hochgeladene Dateiname noch die Termin-UID beruehren je einen Pfad, da
die UID bei CalDAV-Terminen aus einem fremden Client stammt.

Der Dateityp wird an den Bytes bestimmt, nie am Content-Type des Clients.
SVG und HTML sind bewusst nicht erlaubt: beide fuehren Skript im Origin der
App aus, wo das Sitzungstoken liegt.

Aufgeraeumt wird an allen fuenf Loeschpfaden (Termin, Kalender, Gruppe,
Benutzer, CalDAV-DELETE), und zwar Zeilen vor dem Commit, Dateien erst
danach -- ein Rollback darf keine Bytes verlieren. Der Sweep als
Sicherheitsnetz ist implementiert, haengt aber noch an keinem Scheduler.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-14 10:04:44 +02:00
ScarriffleandClaude Opus 5 6e981e7aea chore(repo): iOS-App als ios/ ins Hauptrepo uebernehmen
Calendarr-IOS wird mit vollstaendiger Historie als Unterverzeichnis
ios/ eingebunden. Damit liegen Server, Web, Android und iOS in einem
gemeinsamen Repo.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-14 09:20:12 +02:00
ScarriffleandClaude Opus 5 560457945c chore(repo): Android-App als android/ ins Hauptrepo uebernehmen
Calendarr-Android wird mit vollstaendiger Historie als Unterverzeichnis
android/ eingebunden, damit Server, Web und die mobilen Apps in einem
Repo liegen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-14 09:19:55 +02:00
ScarriffleandClaude Opus 5 31d7a0080e Ship the app for iPad, and keep the watch out of the Mac build
Device family becomes 1,2 for the app and the widget extension, and the iPad
gets all four interface orientations. That is not a preference: App Store
Connect rejects an iPad-capable bundle that declares fewer (error 90474),
because iPad multitasking can present the app at any size. The iPhone key stays
portrait-only. Mac Catalyst still resolves to family 2 alone, which is what
84f5c00 set up and what this had to avoid breaking.

Building for Catalyst also turned up a regression from the watch work: the
Embed Watch Content phase carried watchOS content into a macOS build, which
fails outright. It went unnoticed because no Catalyst build was run after the
watch targets were added. Both the copy and its target dependency are now
filtered to iOS.

Verified on all three destinations: iOS carries Watch/CalendarrWatch.app with
its complications, Catalyst carries no Watch folder, and both report the right
device family.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 14:57:02 +02:00
ScarriffleandClaude Opus 5 e5b3e549e8 Catch multi-day events that do not end on a day boundary
Requiring both ends to sit exactly on midnight was too strict. A holiday stored
as the 16th at 00:00 through the 21st at 01:00 slipped through and rendered as
a one-hour slot. Starting on a day boundary and running into a later day is
enough to call it all-day.

The agenda row now names the weekday on both sides when an event crosses a day.
Showing only "00:00 – 01:00" for something that runs from Wednesday to Monday
is true and useless.

The timezone handling above this is deliberately untouched: timed events show
the right time, so the UTC assumption for timestamps without an offset is not
what produced the odd hour.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 14:52:52 +02:00
ScarriffleandClaude Opus 5 465c465af2 Treat a whole-day span as all-day, whatever the payload claims
The previous rule only caught date-only strings. A multi-day event sent with
full timestamps and no allDay flag still arrived as a timed event, so a holiday
running from the 16th to the 21st rendered as a one-hour slot at midnight.

Now an event that starts and ends on a day boundary and covers at least one
whole day is all-day regardless. That is a statement about what the data means,
not about how any one client formats it, which is why it lives in the parser
and not in a view.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 14:49:57 +02:00
ScarriffleandClaude Opus 5 6e91eaaaa2 Read all-day events correctly, and make the date circle say a date
Three things, one of which is not a watch problem at all.

parseDate already treated a date-only string as all-day, but isAllDay came from
the JSON flag alone. An event sent as "2026-09-12" with no flag therefore
landed at midnight with isAllDay false, and every surface that formats a time
put "00:00" in front of it — the widgets as much as the watch. Now derived from
both.

The date complication showed the day number and nothing else, because
.accessoryCircularCapacity renders only currentValueLabel and the month sat in
the label closure, where it is never drawn. It now stacks weekday over day
number, which is what makes it read as a date rather than a number.

The ring only appears while an appointment is running, and then shows how far
through it we are. An empty track around an idle day said nothing, so there is
no Gauge at all in that case. All-day events do not count as running: one would
hold the ring at an arbitrary fraction for twenty-four hours.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 14:10:21 +02:00
ScarriffleandClaude Opus 5 1a1230a796 Put the next appointment on the Infograph bezel
The position asked for is the bezel slot: a circular complication plus a label
that arcs across the top, where the tick marks otherwise are. ClockKit called
that family graphicBezel; in WidgetKit it is an accessoryCircular widget with
.widgetLabel attached. Nothing was attached, so that arc stayed empty while
Apple's Calendar fills it — which is the difference that was visible on the
watch and invisible in every check made here.

All three circular kinds now carry it. Faces without a bezel slot ignore the
label, so there is no cost to always providing it.

cornerLabel becomes eventLabel: the same text serves the corner edge and the
bezel arc, and the old name claimed otherwise.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 20:19:21 +02:00
ScarriffleandClaude Opus 5 2c26be2ab5 Add a corner complication with the appointment's name as the value
Requested shape: the event title as the text, the time small alongside. This
inverts how accessoryCorner is built — the curved bezel is where a corner has
room for long text and the inner area holds three or four characters — so the
title shrinks and truncates. Kept alongside WatchCornerTime, which carries the
same information with the roles the other way round, so both can be compared on
the face rather than argued about.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 18:53:17 +02:00
ScarriffleandClaude Opus 5 938ca7ca98 Render the circular complications as edge-to-edge closed gauges
Date and "events today" filled their slot with AccessoryWidgetBackground and
centred text, which reads as a small element with space around it. Both are now
Gauges in .accessoryCircularCapacity — the WidgetKit equivalent of ClockKit's
ClosedGauge templates, which no longer exist and cannot be reached from a
WidgetKit complication at all.

The rings carry meaning rather than decorating: the date's drains through the
day, and the count's drains as today's appointments are worked through. Both
drain, matching the countdown rather than inventing a second direction. An
empty day leaves the ring empty instead of full, so it does not read as an
untouched one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 18:38:54 +02:00
ScarriffleandClaude Opus 5 b489f10c28 Make the countdown narrow enough for the slot it sits in
Text(style: .timer) renders 1:23:45 once more than an hour out. An
accessoryCorner has room for three or four characters beside its curved label,
and a circular gauge for about the same, so the live timer overlapped what it
sat next to.

Replaced with a compact value — "23m", "2h", "3d" — recomputed per timeline
entry, which is why the timeline now carries a mark every minute for the first
hour. Both corner kinds and the gauge also get lineLimit(1) and a minimum scale
factor, so an unexpectedly wide value shrinks instead of colliding.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 18:33:38 +02:00
ScarriffleandClaude Opus 5 fdae680e02 Match the watch targets' version to the iOS app
A companion watch app has to carry the same CFBundleShortVersionString and
CFBundleVersion as the app it ships inside. The template left the watch at
1.0 (1) against the app's 3.5 (7), and watchOS answers that by refusing the
install without reporting anything: the Install button simply comes back.

Same class of drift as c9bd42a, which unified the app and widget versions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 17:21:56 +02:00
ScarriffleandClaude Opus 5 e7fbe5c092 Give the watch app an icon and the right name
The target template left AppIcon.appiconset with an entry carrying no filename
— an empty slot. watchOS refuses to install an app without a valid icon, which
is why the Watch app's Install button kept coming back after ten seconds with
no error anywhere. Reuses the iOS 1024 icon, which is already alpha-free.

Both watch targets also called themselves "CalendarrWatch", which is what would
have appeared under the icon and in the complication gallery.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 17:16:08 +02:00
ScarriffleandClaude Opus 5 d0cff20be8 Note the watch plan as implemented
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 17:08:44 +02:00
ScarriffleandClaude Opus 5 f9bc1965a5 Add the watch app and its eight complications
The watch receives the phone's payload over WatchConnectivity and writes it
into its own App Group container through the same SnapshotStore, so there is
one wire format and one reader. The agenda, the detail screen and the filter
all read that; the complications read what the app last wrote, since an
extension cannot run a WCSession of its own.

Complication timelines land on event boundaries rather than a schedule, so the
next appointment flips in the exact minute it changes without spending refresh
budget.

CalendarrWatchShared holds what both watch targets need, attached to each the
way Shared/ is attached to the app and the widgets.

recommendations() is implemented because watchOS requires it — iOS has a
default, the watch does not, since the complication gallery has to be
populated. Found by building, not by reading.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 17:08:27 +02:00
ScarriffleandClaude Opus 5 6a7afe0647 Drop -sdk from the plan's build commands
-sdk overrides SDKROOT for the whole dependency graph, so the watch app was
built against the iOS SDK. It still succeeded, and produced a watch app with no
WKApplication key — the kind of failure that only shows up on the device.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 16:56:37 +02:00
ScarriffleandClaude Opus 5 9394a80ea2 Add the watchOS app and complication targets
The companion dropdown in Xcode's target template came up empty, which left
both bundle identifiers starting with a bare dot and
WKCompanionAppBundleIdentifier as an empty string. Both are set explicitly
here, along with the App Group entitlements and watchOS 26 as the floor.

The iOS app had no Embed Watch Content phase, so the watch app would have built
and never shipped. Added, with the matching target dependency: the product now
lands at Calendarr iOS.app/Watch/CalendarrWatch.app with the complications
inside it.

Folders follow what they contain: CalendarrWatch is the app,
CalendarrWatchComplications the extension. Xcode had them the other way round.

Package.resolved is now tracked. It pins AppAuth, whose version drift is what
produced the two compile errors fixed in 75a6a5b.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 16:56:26 +02:00
ScarriffleandClaude Opus 5 ddda0ecd3e Correct the plan's Info.plist step to what actually works
Xcode exposes neither UIBackgroundModes nor BGTaskSchedulerPermittedIdentifiers
as an INFOPLIST_KEY_ build setting, and a partial plist inside a synchronized
group collides with the processed one. Both found by building, not by reading.

Also records that synchronized groups pick up new source files on their own.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 16:41:07 +02:00
ScarriffleandClaude Opus 5 b28c5a0502 Send the trimmed snapshot to the watch over WatchConnectivity
App Groups are per-device, so the watch cannot read the file the phone writes
and has to be handed the data. Three delivery paths: application context for
the latest state, a complication push only when the next appointment actually
changed (the budget is small), and a reply to the watch asking directly.

Sign-out pushes an empty signed-out snapshot. Clearing our own container is not
enough — the watch holds its own copy and would keep rendering the previous
user's calendar.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 16:40:45 +02:00
ScarriffleandClaude Opus 5 ad7a405f32 Refresh the shared snapshot in the background so widgets stop going stale
publishWidgetSnapshot() only ever ran from a user action, so an event created
on the web or on Android did not reach the widgets until the app was opened
again. A BGAppRefreshTask now republishes without the foreground.

The two keys this needs are arrays, which Xcode does not expose as
INFOPLIST_KEY_ build settings — verified by building and inspecting the
result. Hence a partial Calendarr-Info.plist that generation merges into. It
sits at the project root rather than inside the target folder: that folder is
a synchronized group, which would also copy the file as a resource and collide
with the processed one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 16:39:34 +02:00
ScarriffleandClaude Opus 5 c8a36c161f Filter widget snapshots through the shared package rule
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 16:35:23 +02:00
ScarriffleandClaude Opus 5 75a6a5bd79 Fix two OIDC compile errors against the current SDK and AppAuth
UIAccessibilityIsGuidedAccessEnabled() is gone in iOS 26; use the property.

AppAuth declares lastAuthorizationResponse and its request as nonnull, so the
optional chain was rejected. Behaviour is unchanged: nonce is optional either
way, which is the tolerance the comment there already describes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 16:35:23 +02:00
ScarriffleandClaude Opus 5 e3ff2b824c Add the implementation plan for the Apple Watch app
Seventeen tasks. Every piece of testable logic lands in CalendarrCore first,
under TDD, because that is the only target this machine can actually run tests
for; the watch targets hold view and WCSession glue that is build-verified only.

The iOS background refresh is task 7 and commits on its own: it fixes the
home-screen widgets' staleness whether or not the watch app follows.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 16:13:59 +02:00
ScarriffleandClaude Opus 5 214f120859 Add design spec for the Apple Watch app and watch-face complications
The watch is a read-only consumer of the phone's snapshot: WatchConnectivity
carries a trimmed CalendarrSnapshot to the watch, which writes it into its own
App Group container through the same SnapshotStore. One format, one reader.

Also specifies the iOS background refresh the watch depends on, which fixes a
pre-existing staleness in the home-screen widgets as a side effect.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 15:35:43 +02:00
ScarriffleandClaude Opus 5 3d73642f2e fix(login): 2FA-Einrichtungsfeld fuer Passwortmanager ausblenden
Die Seite trug zweimal autocomplete="one-time-code": im Login und im Feld
zum Einrichten von 2FA in den Einstellungen. Fuer einen Passwortmanager ist
damit nicht eindeutig, zu welchem Feld sein Inline-Symbol gehoert.

Das Einrichtungsfeld bekommt data-bwignore (plus die Entsprechungen fuer
1Password und LastPass) — dort tippt man den Code aus der Authenticator-App
beim Anlegen eines neuen Geheimnisses ab, ein Ausfuellen ergibt da ohnehin
keinen Sinn. Die Passwortfelder bleiben unangetastet, die sollen weiter
befuellbar sein.

Ob das die fehlplatzierte Bitwarden-Anzeige behebt, ist offen: hier laesst
sich keine Erweiterung installieren. Der vorherige Versuch ueber das
Fokus-Timing hat es nicht geloest.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 06:41:53 +02:00
ScarriffleandClaude Opus 5 1b09cd5167 fix(login): 2FA-Feld erst nach dem Layout fokussieren
Benutzername und Passwort liegen ab Seitenaufbau im Layout, das 2FA-Feld
taucht erst aus display:none auf — und der Fokus wurde im selben Tick
gesetzt. Passwortmanager oeffnen ihr Inline-Overlay beim Fokus und messen die
Feldposition in genau dem Moment; Bitwarden platzierte sein Symbol dadurch
weit neben dem Feld.

Der Fokus laeuft jetzt ueber setTimeout(0), sodass das Layout vorher steht,
plus ein resize-Event als Anstoss zum Neupositionieren. Bewusst kein
requestAnimationFrame: das feuert in einem nicht sichtbaren Tab nicht und
haette den Fokus ganz verschluckt.

Nicht gegen Bitwarden verifizierbar — hier ist keine Erweiterung installiert.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 06:32:54 +02:00
ScarriffleandClaude Opus 5 a977ee3c2b feat(oidc): verknuepfte SSO-Konten in den Einstellungen
Die Endpunkte gab es schon, die Oberflaeche fehlte — es war schlicht nicht
sichtbar, ob und womit ein Konto verknuepft ist.

Unter Einstellungen > Profil, zwischen App-Passwoertern und Privatsphaere,
steht jetzt "Single Sign-On" mit den verknuepften Konten (Anbieter, E-Mail,
letzte Nutzung) und je einem Knopf zum Aufheben. Ohne Verknuepfung erscheint
ein Hinweis, wie man eine anlegt; ist auf dem Server ueberhaupt kein Provider
konfiguriert, bleibt der ganze Abschnitt unsichtbar.

Bei einem per SSO angelegten Konto ist die letzte Verknuepfung nicht
loesbar — es hat kein brauchbares Passwort und waere danach dauerhaft
unerreichbar. Der Knopf ist in dem Fall deaktiviert und erklaert sich per
Tooltip; der Server lehnt es zusaetzlich ab.

Im Browser durchgespielt: verknuepfen, Eintrag erscheint mit Anbieter und
Datum, aufheben, Bestaetigung, leerer Zustand.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 06:10:43 +02:00
ScarriffleandClaude Opus 5 725b61c8cb fix(auth): Sitzungen ueber die Benutzer-ID statt ueber den Namen aufloesen
Das Sitzungs-Token trug den Benutzernamen als Subjekt, und get_current_user
suchte danach. Eine Umbenennung machte damit jedes Token auf jedem anderen
Geraet stillschweigend ungueltig — der Nutzer flog ohne Hinweis raus.

Tokens tragen jetzt zusaetzlich uid, die unveraenderliche Zeilen-ID, und die
Aufloesung geht darueber. sub bleibt als Anzeigename erhalten. Tokens ohne
uid werden weiter ueber den Namen aufgeloest, damit vor der Aenderung
ausgestellte Sitzungen gueltig bleiben — mit "Angemeldet bleiben" laufen die
180 Tage.

Fuer SSO war die Zuordnung schon vorher richtig: OIDCIdentity haengt an
(provider_key, sub) des Providers, und bei bekanntem sub wird der lokale
Benutzername nicht angefasst. Ein umbenannter Nutzer in Authentik landet also
unveraendert auf demselben Konto. Vier Tests halten beide Richtungen fest:
Umbenennung beim Provider, lokale Umbenennung mit weiterlaufender Sitzung,
lokale Umbenennung mit erhaltener SSO-Verknuepfung, und die Kompatibilitaet
alter Tokens.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 06:01:05 +02:00
ScarriffleandClaude Opus 5 cc4daf4a84 feat(oidc): SSO-Identitaet bewusst mit einem Konto verknuepfen
Bisher war eine SSO-Anmeldung ohne passendes Konto eine Sackgasse: rote
Fehlermeldung, kein Weg weiter. Die Verknuepfung ging nur ueber
LINK_BY_EMAIL, das der E-Mail-Verwaltung des Providers vertrauen muss.

Jetzt endet der Callback bei einer besitzerlosen Identitaet nicht im Fehler,
sondern parkt sie in einem kurzlebigen signierten HttpOnly-Cookie und leitet
auf /?sso_link=1. Der Login-Screen bittet daraufhin einmalig um das
Calendarr-Passwort; nach erfolgreichem Login loest der Client
POST /api/auth/oidc/link-pending ein und die Identitaet haengt an genau dem
Konto, das sich gerade ausgewiesen hat. Beide Haelften sind damit bewiesen —
das ID-Token haben wir selbst geprueft, das Konto durch das Passwort — und
die Vertrauensannahme ueber die E-Mail entfaellt.

Das greift fuer alle drei Faelle, in denen eine gueltige Identitaet keinen
Besitzer hat: kein Konto vorhanden, Selbstregistrierung aus, oder E-Mail
bereits vergeben.

Ausserdem: unbekannte Fehler-Slugs werden jetzt mit angezeigt statt hinter
"Anmeldung fehlgeschlagen" zu verschwinden — genau die Information, die zur
Diagnose fehlte. Und oidc_token_exchange_failed hat eine eigene Meldung, die
auf Client-Typ und Redirect-URI hinweist, weil das der haeufigste
Konfigurationsfehler ist.

Sechs Tests dazu; Ablauf zusaetzlich im Browser gegen einen echten
OIDC-Stub-Provider durchgespielt: Hinweis statt Fehler, Verknuepfung nach
Passwort-Login, danach Anmeldung allein per SSO.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 20:46:34 +02:00
ScarriffleandClaude Opus 5 7b51cab693 feat(auth): SSO-Anmeldung ueber OpenID Connect (AppAuth)
Zusaetzlicher Anmeldeweg neben Benutzername/Passwort. Der Login-Screen holt
die konfigurierten Provider von GET /api/auth/oidc/providers; ohne Provider
oder gegen einen aelteren Server bleibt der Screen unveraendert.

Die App laeuft als Public Client: Authorization Code Flow mit PKCE in einer
ASWebAuthenticationSession (nie ein WKWebView), ohne Client Secret. Das
ID-Token wird an POST /api/auth/oidc/exchange gegen ein Calendarr-Token
getauscht; die Antwort hat dieselbe Form wie der Passwort-Login, sodass der
bestehende Session-Pfad unveraendert weiterlaeuft.

- OIDCAuthenticator kapselt Discovery, PKCE-Request, Code-Tausch und die
  stille Erneuerung. Der AuthState mit dem Refresh-Token liegt in der
  Keychain (kSecAttrAccessibleAfterFirstUnlock) und wird beim Logout aus
  beiden Zugriffsgruppen entfernt.
- OIDCSessionRefresher erneuert die Sitzung beim Start, wenn das
  Calendarr-Token binnen 24 h ablaeuft — ohne Browser, ueber den
  Refresh-Token des Providers.
- Die angefragten Scopes kommen vom Server (mobile_scopes); ein Scope, den
  der Provider nicht kennt, wuerde die ganze Anfrage scheitern lassen.
- Bei aktiviertem gefuehrten Zugriff weicht AppAuth auf SFSafariViewController
  aus, dessen Redirect nur ueber ein registriertes URL-Schema zurueckkaeme.
  Da das App-Target seine Info.plist generiert und CFBundleURLTypes dort
  nicht setzbar ist, wird der Fall vorab sauber abgelehnt statt endlos auf
  einen Callback zu warten, der nicht kommen kann.
- Abbrueche werden mit Fehlerdomaene geprueft (user- und programCanceled) und
  still behandelt; alle uebrigen Fehler tragen Domain, Code und OAuth-Fehler
  ins DEBUG-Log, statt spurlos zu verschwinden.

project.pbxproj: AppAuth-iOS 1.7.5 als SPM-Abhaengigkeit, nur am App-Target.
OTHER_LDFLAGS bekommt -ObjC, weil OIDAuthState+IOS.m ausschliesslich eine
Kategorie enthaelt und ohne Symbol sonst wegoptimiert werden koennte.

Achtung: mangels macOS/Xcode ist dieser Stand nicht kompiliert. Beim ersten
Oeffnen loest Xcode das Paket auf und legt Package.resolved an; die bitte
mit committen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 18:33:59 +02:00
ScarriffleandClaude Opus 5 08f8267dcc fix(auth): SSO-Korrekturen aus dem Review + stiller Token-Refresh
Drei Fehler, die den Flow gebrochen haetten:

- Ein Abbruch im Browser liefert bei AppAuth ein regulaeres Ergebnis mit
  USER_CANCELED_AUTH_FLOW, nicht null. Der bisherige Null-Check lief ins
  Leere und "Zurueck" endete in einer roten Fehlermeldung.
- launch(intent) lag ausserhalb von runCatching. Verlaesst der Nutzer den
  Screen waehrend der Discovery, ist der Result-Launcher abgemeldet und die
  IllegalStateException haette die App beendet.
- Nonce und Provider lagen nur in ViewModel-Feldern. Der Custom Tab laeuft
  in eigener Task, dieser Prozess wird dabei routinemaessig getoetet — die
  Anmeldung waere nach erfolgreichem Login beim IdP gescheitert. Der Nonce
  kommt jetzt aus AuthorizationResponse.request, Provider und Client-ID
  werden vor dem Start persistiert.

Weiter: AuthState.update verlangt genau eines von (Response, Exception) und
haette bei null/null geworfen; onCleared() verwarf den @Singleton-Service
fuer die zweite AuthViewModel-Instanz mit.

Neu ist der geforderte Hintergrund-Refresh: laeuft das Calendarr-Token
binnen 24 h ab, wird beim Start ueber den Provider-Refresh-Token ein frisches
ID-Token geholt und getauscht — ohne Browser. Best effort; schlaegt es fehl,
bleibt das vorhandene Token in Kraft.

Ausserdem: Scopes kommen ausschliesslich vom Server (mobile_scopes), Spinner
pro Provider statt global, fehlender Browser als eigener Fehlerfall, Issuer
wird validiert, keine namenlosen Identitaeten mehr.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 18:33:36 +02:00
ScarriffleandClaude Opus 5 b560ee260c fix(oidc): Nonce-Pruefung selbst kontrollieren statt Authlib zu ueberlassen
Authlib wertet "Aufrufer hat eine Nonce uebergeben, das Token traegt keine"
als Mismatch. Das ist fuer den erneuerten Token der mobilen Clients falsch:
ein per Refresh-Token geholtes ID-Token laesst die nonce-Claim regulaer weg,
womit der Hintergrund-Refresh immer an einem 401 gescheitert waere.

Die Nonce wird daher nicht mehr an Authlib durchgereicht, sondern hier
geprueft: vorhandene Nonce muss passen, und der Browser-Flow verlangt sie
zusaetzlich zwingend (require_nonce) — dort erzeugen wir sie selbst, also
muss der Provider sie zurueckspiegeln. Signatur und uebrige Claims bleiben
unveraendert bei Authlib.

Zwei Tests dazu: erneuertes Token ohne Nonce wird angenommen, Browser-Flow
ohne Nonce abgelehnt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 18:33:19 +02:00
ScarriffleandClaude Opus 5 8b0af0f44a feat(auth): SSO-Anmeldung ueber OpenID Connect (AppAuth)
Zusaetzlicher Anmeldeweg neben Benutzername/Passwort. Der Login-Screen holt
die konfigurierten Provider von GET /api/auth/oidc/providers; ohne Provider
oder gegen einen aelteren Server bleibt der Screen unveraendert.

Die App laeuft als Public Client: Authorization Code Flow mit PKCE in einem
Chrome Custom Tab (nie ein WebView), ohne Client Secret. Das ID-Token wird
anschliessend an POST /api/auth/oidc/exchange gegen ein Calendarr-Token
getauscht; die Antwort hat dieselbe Form wie der Passwort-Login, sodass der
bestehende Session-Pfad unveraendert weiterlaeuft.

- OidcAuthenticator kapselt Discovery, PKCE-Request und Code-Tausch.
- Die angefragten Scopes kommen vom Server (mobile_scopes) — ein Scope, den
  der Provider nicht kennt, wuerde die ganze Anfrage scheitern lassen.
- AuthState mit dem Refresh-Token liegt in den EncryptedSharedPreferences,
  nie im Klartext, und wird beim Logout mitgeloescht.
- Provider und Client-ID werden vor dem Browser-Start persistiert, weil der
  Custom Tab in einer eigenen Task laeuft und dieser Prozess dabei getoetet
  werden kann; der Nonce kommt aus AuthorizationResponse.request, das AppAuth
  ueber den Redirect zurueckreicht.
- Abbruch im Browser meldet AppAuth als regulaeres Ergebnis mit
  USER_CANCELED_AUTH_FLOW und wird still behandelt, nicht als Fehler.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 18:25:15 +02:00
ScarriffleandClaude Opus 5 07235fd4c9 feat(oidc): Mobile-Scopes serverseitig konfigurierbar (offline_access)
Die Apps duerfen keine Scopes erfinden: ein Scope, den der Provider nicht
kennt, laesst ihn die gesamte Authorization-Anfrage ablehnen. Daher legt
jetzt der Server fest, was die nativen Clients anfragen.

OIDC_<KEY>_MOBILE_SCOPES ist frei setzbar; ohne Angabe sind es die
Web-Scopes plus offline_access, damit Authentik ab 2024.2 ueberhaupt ein
Refresh-Token ausstellt. Der Browser-Flow bleibt unveraendert, der braucht
kein offline_access. Ausgeliefert wird der Wert als mobile_scopes in
/api/auth/oidc/providers, aber nur wenn eine MOBILE_CLIENT_ID konfiguriert
ist.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 18:24:59 +02:00
ScarriffleandClaude Opus 5 25fde49cee docs: echte Redirect-URIs der Mobile-Clients in der SSO-Anleitung
Die Platzhalter-URI calendarr://oauth/callback durch die tatsaechlichen
Reverse-DNS-Schemata der beiden Apps ersetzt und vermerkt, dass sie als
Public Clients ohne Secret laufen und ohne MOBILE_CLIENT_ID keinen
SSO-Button zeigen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 18:14:33 +02:00
ScarriffleandClaude Opus 5 31baf4b18c feat(auth): OpenID-Connect-SSO gegen Authentik (Backend + Web)
Zusaetzlicher Anmeldeweg per OIDC neben dem bestehenden Passwort-Login. Ohne
gesetztes OIDC_PROVIDERS aendert sich nichts: /api/auth/oidc/providers meldet
enabled:false und der Login-Screen bleibt unveraendert.

Backend
- oidc_config.py: Provider-Registry rein aus ENV. Ein zweiter Provider ist
  reine Konfiguration (OIDC_PROVIDERS + OIDC_<KEY>_* Block), keine Codeaenderung.
- oidc_client.py: Discovery, JWKS, Token-Tausch, ID-Token-Pruefung ueber
  authlib.jose mit fest gepinnter Signatur-Allowlist (nur asymmetrisch, damit
  alg:none und Algorithmus-Confusion ausgeschlossen sind).
- oidc_identity.py: Zuordnung ueber (provider_key, sub). Ein E-Mail-Treffer auf
  ein bestehendes Konto wird standardmaessig abgelehnt statt verknuepft — sonst
  erbt jeder, der im IdP eine beliebige E-Mail setzen kann, das passende Konto.
  Verknuepfen ist eine explizite, authentifizierte Aktion. Auto-Provisioning
  nur per ENV-Flag, standardmaessig aus, optional mit Domain-Allowlist.
- routers/oidc_router.py: /providers, /start, /callback, /complete, /exchange,
  /link, /identities. PKCE S256; Flow-State als kurzlebiges signiertes JWT in
  einem HttpOnly-Cookie mit jti==state statt eines prozesslokalen Dicts, damit
  der Flow Neustarts und mehrere Worker uebersteht. Das Calendarr-JWT wird ueber
  ein Einmal-Cookie uebergeben, nie ueber URL oder Fragment.
- /exchange als Kontrakt fuer die AppAuth-Clients: die App fuehrt den Flow als
  Public Client selbst aus und tauscht das ID-Token; kein Secret im Request.
- models.py: neue Tabelle oidc_identities, users.auth_source; Migration in
  main.py im dortigen try/except-Stil.

Nebenbefund, mitbehoben: auth.verify_password liess ValueError aus
bcrypt.checkpw durch. Ein nicht wohlgeformter password_hash wurde damit zu
HTTP 500 auf /api/auth/login und bei jedem CalDAV-Basic-Auth. SSO-Nutzer
bekommen einen echten bcrypt-Hash ueber ein zufaelliges, nirgends gespeichertes
Geheimnis; fuer CalDAV nutzen sie App-Passwoerter wie 2FA-Nutzer.

Web
- SSO-Block auf dem Login-Screen, aus /providers befuellt; Buttons sind Links,
  weil /start eine Top-Level-Navigation braucht.
- consumeSsoHandoff() laeuft vor den Setup-/Token-Pruefungen in boot() und
  raeumt die Query per replaceState ab, bevor es awaited.
- 401-Auto-Logout um die SSO-Pfade erweitert, sonst dreht ein fehlgeschlagener
  Handoff eine Reload-Schleife.
- i18n-Keys in de + en (Fallback-Kette deckt die uebrigen acht Sprachen).
- Service Worker laesst /api/auth/oidc/ unangetastet durch.

37 neue Tests (Gesamtsuite 60 gruen): Signaturpruefung mit echten RSA-Tokens,
alg:none, falscher iss/aud/nonce, unbekannte client_id, Provisioning-Policies,
Username-Kollision, Einmaligkeit des Handoff-Cookies, unerreichbarer Provider.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 18:00:46 +02:00
ScarriffleandClaude Opus 5 a30375aa63 feat(mini-cal): ein Punkt je Kalender in dessen Farbe pro Tag
Statt eines einzelnen Punkts in --primary zeigt der Mini-Kalender jetzt fuer
jeden Kalender, der an dem Tag mindestens einen Termin hat, einen Punkt in
dessen Farbe (max. 4, stabil nach erstem Auftreten sortiert). Die Farbquelle
ist dieselbe wie in Monats-/Wochenansicht: ev.color || ev.calendarColor.

Ausserdem laeuft der Mini-Kalender jetzt ueber filterEvents(), damit
ausgeblendete Gruppenmitglieder keinen Punkt mehr erzeugen. Auf dem
"heute"-Kreis bekommen die Punkte einen duennen weissen Ring, damit sie auf
der farbigen Flaeche lesbar bleiben.

Cherry-pick von 7a2085b (master) auf beta; die selected-Day-Regel aus beta
(color-mix) bleibt erhalten.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 13:07:02 +02:00
ScarriffleandClaude Opus 5 d1da8de06a Read and write the widget snapshot through CalendarrCore
The snapshot format was an internal detail shared between the app and its own
widget extension. A second Mac app needs the same data, and the way to give it
that is a documented contract rather than a format it reverse-engineers and then
drifts from. Both targets now link CalendarrCore and go through SnapshotStore.

Shared/WidgetData.swift becomes a thin facade. The typealiases and the flat
colour accessors exist so the ~70 existing call sites across the app and the
widget views compile unchanged; they are a migration convenience, not a design.

The snapshot now carries what a reader outside this app actually needs:

coverageStart / coverageEnd, because the published window is ~7 days back and
~42 ahead. Outside it the snapshot holds no information, which is not the same
as holding no events — and only the writer knows where that edge is. Without it
a consumer renders a convincingly empty March and is simply wrong.

isLoggedIn plus a session record, so a reader can say "sign in to Calendarr"
rather than "open Calendarr once". The events are deleted on sign-out, so the
absence of a cache alone cannot tell those two apart.

writerVersion, purely so a mismatch between the two apps is diagnosable.

The coverage constants move to SnapshotCoverage, so the writer and the code that
reconstructs the window for older files can no longer disagree about it.

CalendarStore needs an explicit `import CalendarrCore` because the app target
builds with SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY, which requires the
defining module to be imported directly rather than picked up transitively.

Verified: builds for iOS and Mac Catalyst; CalendarrKit's 11 tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 17:42:00 +02:00