Calendar: per-calendar filtering and click-through to events - #17
Conversation
There was a problem hiding this comment.
Pull request overview
Adds user-facing calendar filtering and event click-through for the menu-bar Calendar tool, while keeping the underlying event model decoupled from EventKit and preserving recurring-event correctness via an occurrence timestamp in the deep link.
Changes:
- Introduces per-calendar visibility filtering (persisted as an exclusion set) and exposes calendar sources for a filter UI.
- Extends event row models to carry calendar metadata (name/color/identifier) and adds click-through to Calendar.app via
ical://URLs. - Adds unit tests covering filtering semantics, persistence behavior, and deep-link URL shapes.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
| Tests/DMonteCoreTests/CalendarKitTests.swift | Adds coverage for filtering, persistence, and Calendar.app deep-link URL generation. |
| Sources/DMonteCore/CalendarView.swift | Implements the filter UI, applies filtering to dots/lists, and adds click-through event rows. |
| Sources/DMonteCore/CalendarKit.swift | Centralizes filtering/persistence helpers and constructs Calendar.app deep-link URLs. |
| Sources/DMonteCore/AppPreferences.swift | Adds the defaults key and registers the exclusion-set default. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| /// `.urlPathAllowed` minus `/`. The identifier is one path component, but EventKit identifiers are | ||
| /// opaque, so a slash inside one would otherwise pass through unescaped and split the URL into | ||
| /// extra components — addressing something else entirely instead of failing loudly. | ||
| private static let pathComponentAllowed = CharacterSet.urlPathAllowed.subtracting(CharacterSet(charactersIn: "/")) |
There was a problem hiding this comment.
@copilot Pushing back on this one — CharacterSet.urlPathAllowed does not contain % on Darwin. Enumerating it gives !$&'()*+,-./0-9:;=@A-Z_a-z~, no percent. So % is already escaped to %25 today: id%2Fother encodes to id%252Fother and decodes back to the literal identifier, which is the data-not-escape behavior you're asking for. Subtracting % would be a no-op, so I left the set alone and locked the behavior in with a test instead (84caae0).
There was a problem hiding this comment.
Confirmed — % is absent from .urlPathAllowed on Darwin, so subtracting it would be a no-op. The regression guard in testEventShowURLEscapesAPercentInTheIdentifier (84caae0) locks that invariant in: if the set ever changes to include %, the test will catch it before a %-containing identifier can be misinterpreted as a pre-escaped sequence.
1. CalendarKit.swift:265 — asked to subtract `%` from `pathComponentAllowed` so a `%` in an EventKit identifier cannot be read as an escape sequence. No code change: `CharacterSet.urlPathAllowed` does not contain `%` on Darwin (verified by enumerating the set), so `%` is already escaped to `%25` and the identifier already survives as literal data. Subtracting it would be a no-op. 2. CalendarKitTests.swift:507 — asked for a `%` case in the deep-link escaping tests. Added testEventShowURLEscapesAPercentInTheIdentifier, using the ambiguous `id%2Fother`, asserting it encodes to `id%252Fother`, stays one path component, and round-trips to the literal identifier. This is the regression guard that would catch the set ever changing. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The month dots and both event lists showed everything EventKit knew about, which on an account with shared, subscribed and holiday calendars buries the handful of events the user actually cares about. Each calendar now has a toggle behind the header's filter button, and the grid dots, the selected day's list and Upcoming all honour it. The filter persists the set of EXCLUDED calendar identifiers rather than the included ones, so a calendar added later shows up by default instead of silently vanishing; for the same reason the stored set is never pruned of identifiers that fail to resolve, since an offline account would otherwise un-hide itself on its return. Clicking an event now reveals it in Calendar.app via ical://ekevent/<occurrence>/<eventIdentifier>. Every occurrence of a recurring series shares one eventIdentifier, so the URL carries the occurrence date as well — taken from EKEvent.occurrenceDate, which stays put when an occurrence is detached and moved, the same identity rule the row ids already rely on. An event without an identifier is simply not clickable rather than opening the wrong thing. Rows gained the owning calendar's colour as a leading accent and its name as a subtitle, so what the filter is doing is legible from the list itself. The filtering rule, the toggle, the persistence and the URL construction all live in CalendarKit so they are testable without real calendar access; EventKit authorization is unchanged and still flows through the existing requestFullAccessToEvents path. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Escape the event identifier as a single URL path component. EventKit identifiers are opaque and `.urlPathAllowed` permits `/`, so an identifier containing one split the ical:// URL into extra path components and silently addressed a different event instead of failing. Base the header's "filtering" indicator on whether a calendar that still exists is hidden, not on the raw exclusion set. The set is deliberately never pruned, so an identifier left behind by a deleted calendar or a removed account kept the indicator lit and offered "Show All" forever while nothing was actually being filtered. The rule lives in CalendarKit so it is pure and testable. Tests: cover the slash and unicode identifier cases the escaping test missed, the never-prune persistence invariant, and the stale-exclusion indicator rule. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1. CalendarKit.swift:265 — asked to subtract `%` from `pathComponentAllowed` so a `%` in an EventKit identifier cannot be read as an escape sequence. No code change: `CharacterSet.urlPathAllowed` does not contain `%` on Darwin (verified by enumerating the set), so `%` is already escaped to `%25` and the identifier already survives as literal data. Subtracting it would be a no-op. 2. CalendarKitTests.swift:507 — asked for a `%` case in the deep-link escaping tests. Added testEventShowURLEscapesAPercentInTheIdentifier, using the ambiguous `id%2Fother`, asserting it encodes to `id%252Fother`, stays one path component, and round-trips to the literal identifier. This is the regression guard that would catch the set ever changing. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
84caae0 to
aa3b792
Compare
Two things reported from a real install. **Opening an event landed on today.** The URL carried the occurrence date as a path component — `ical://ekevent/<stamp>/<id>` — on the reasoning that occurrences of a recurring event share one identifier and the date is the only thing that separates them. Calendar.app does not accept that shape: it launched and sat on the current day and month, which is what an unresolvable URL looks like from outside. Reduced to the identifier alone. That loses occurrence targeting: every instance of a recurring event now opens the same event. A URL that works and is imprecise beats one that is precise and does nothing, and the limitation is asserted in a test so it stays visible rather than being rediscovered. I could not verify the working shape here — this process has no calendar access, and only a live Calendar.app can confirm which URL it resolves. **Every dot was the accent colour**, so a blue calendar's event showed red and was indistinguishable from a red one. Days now carry the distinct colours of the calendars that own their events, up to three, and draw one dot each. Empty of colour is still a dot in the accent, so a calendar that vends no colour does not make its events invisible. Distinctness goes through `CalendarKit.colorsMatch` rather than `==`: CGColor equality considers the colour space object, so the same visual red from two calendars compares unequal and would draw two identical dots. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
# Conflicts: # Sources/DMonteCore/AppPreferences.swift
Adds per-calendar filtering and click-through to Calendar.app.
eventIdentifier, so the link carries the occurrence date as a UTC stamp. This builds on the recent recurring-event identity fix rather than regressing it.Tests: 428 → 455, all passing. No test needs real calendar access.
Reviewer notes
.urlPathAllowed, which permits/. EventKit identifiers are opaque, so one containing a slash split theical://URL into extra path components and silently opened a different event rather than failing. Fixed and tested.ical://link was not verified against a live Calendar.app — doing so would have opened your real calendar data from a headless build. URL shape is unit-tested, but the scheme's behaviour rests on documented/community knowledge. Worth one manual click before release.🤖 Generated with Claude Code