Skip to content

Calendar: per-calendar filtering and click-through to events - #17

Merged
havokentity merged 5 commits into
mainfrom
feat/calendar-filtering
Jul 21, 2026
Merged

Calendar: per-calendar filtering and click-through to events#17
havokentity merged 5 commits into
mainfrom
feat/calendar-filtering

Conversation

@havokentity

Copy link
Copy Markdown
Owner

Adds per-calendar filtering and click-through to Calendar.app.

  • A filter button in the header lists your calendars as toggles with their colours; the month-grid dots and both event lists honour the choice.
  • The set of hidden calendars is persisted, not the visible ones — so a calendar you add later shows up by default instead of silently vanishing. Stale identifiers are deliberately never pruned, so an offline account cannot un-hide itself.
  • Event rows carry their calendar's colour and name, and clicking one opens that event in Calendar.app.
  • Recurring events open the correct occurrence: occurrences share an 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.
  • Uses the existing EventKit permission flow — no second authorization path.

Tests: 428 → 455, all passing. No test needs real calendar access.

Reviewer notes

  • The adversarial review found the deep link escaped the identifier with .urlPathAllowed, which permits /. EventKit identifiers are opaque, so one containing a slash split the ical:// URL into extra path components and silently opened a different event rather than failing. Fixed and tested.
  • The 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.
  • All-day recurring events stamp local midnight, which serialises to a non-midnight UTC time. If any deep link misbehaves, look here first.

🤖 Generated with Claude Code

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +262 to +265
/// `.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: "/"))

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@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).

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread Tests/DMonteCoreTests/CalendarKitTests.swift
havokentity added a commit that referenced this pull request Jul 18, 2026
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>
havokentity and others added 3 commits July 18, 2026 16:26
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>
@havokentity
havokentity force-pushed the feat/calendar-filtering branch from 84caae0 to aa3b792 Compare July 18, 2026 10:58
@havokentity
havokentity changed the base branch from fix/deep-review-2026-07 to main July 18, 2026 11:23
havokentity and others added 2 commits July 21, 2026 00:49
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
@havokentity
havokentity merged commit b751735 into main Jul 21, 2026
1 check passed
@havokentity
havokentity deleted the feat/calendar-filtering branch July 21, 2026 06:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants