Records the UID_2445 (vs SYNC_DATA1) identity decision, the sync-time age snapshot, and the seeded reminder default; checks off the #14/#15 task lists. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
10 KiB
Design: Contact special-dates calendars (+ per-calendar multiple reminders)
Status: implemented on feat/contact-special-dates (#14 prerequisite merged in)
Date: 2026-06-30 (implemented 2026-07-01)
Tracking: Codeberg #15 (feature), Codeberg #14 (prerequisite)
Implementation notes / deviations from this design:
- Event identity key is a deterministic
Events.UID_2445(contact-<type>:<lookupKey>@calendula), notSYNC_DATA1. On a LOCAL calendar the provider drops_synccolumns unless written through a sync-adapter URI;UID_2445is written/read through the normal event URIs the app already uses (and doubles as a stable iCal UID). The managed calendar still carries aCAL_SYNC2marker for re-adoption (calendar-row writes already use the sync-adapter URI). - Age is a sync-time snapshot. A
FREQ=YEARLYrow has one static title, so{age}reflects the upcoming birthday at the last sync (refreshed daily / on foreground), not a per-occurrence value. - New managed calendars seed a per-calendar all-day reminder default of on-the-day + one week before, so birthdays get lead time out of the box.
- Title template is user-editable from day one (
{name},{age}).
This document captures the full design for surfacing contact birthdays — and, by extension, anniversaries and custom dates — as auto-updating local calendars in Calendula. It also specifies #14 (per-calendar multiple default reminders), which is a hard prerequisite and ships first.
The two pieces are deliberately split across two branches / two shipments:
feat/per-calendar-multi-reminders— #14, ships first.feat/contact-special-dates— #15, builds on #14.
They may release together depending on timing, but #14 is independently useful and is the foundation the special-dates calendars lean on.
Why this is worth doing carefully
Calendula's identity is privacy: no INTERNET permission, and historically
no READ_CONTACTS — guest-picking uses a one-shot ACTION_PICK so the app
never holds the contacts permission (EventEditScreen.kt:446-460).
This feature declares READ_CONTACTS for the first time. That is a
deliberate, owner-approved shift, mitigated by:
- The permission is opt-in and feature-gated — requested only when the user enables the feature, never at startup.
- Everything stays offline. Contacts are read locally and mirrored into a local calendar; nothing leaves the device.
- The mirror is one-way (contacts → calendar). Calendula never writes to Contacts.
Store listing / About copy will need to explain the optional permission.
Prerequisite — #14: per-calendar multiple default reminders
Today
- Events already support multiple reminders —
EventForm.reminders: List<Int>(EventForm.kt:24), each written as a separateCalendarContract.Remindersrow (CalendarDataSource.kt:620-630), each firing independently. The runtime/notification path is done. - The defaults layer is single-valued, though:
- Global:
defaultReminderMinutes/defaultAllDayReminderMinutes— singleInt(SettingsPrefs.kt:287,302). - Per-calendar override:
perCalendarReminderOverride: Map<Long, Int?>and the all-day variant (SettingsPrefs.kt:350-381), resolved byresolveDefaultReminder()(SettingsPrefs.kt:509-524). - UI: expandable per-calendar override section
(
SettingsScreen.kt:879-953);ReminderDefaultPicker(:956+).
- Global:
The change
Widen the defaults, not the runtime, from one reminder to a list:
- Resolution model
Int→List<Int>in global defaults + per-calendar override +resolveDefaultReminder(). The DataStore string format already parsesid=value;id=value(SettingsPrefs.kt:576-577);valuebecomes a comma-separated list. Keep theInherit / None / Minutesoverride states (CalendarReminderOverride,SettingsPrefs.kt:493-500) —Minutescarries a list. ReminderDefaultPicker→ multi-select. Mirror the event-form multi-reminder UI rather than inventing new interaction.- New events seed
EventForm.remindersfrom the resolved list instead of a single value.
No new permissions, no provider changes, no scheduling changes. Type widening plus one picker upgrade.
Why it gates #15
Each special-dates calendar is a normal local calendar, so it inherits the per-calendar reminder default for free. A birthdays calendar is useless without lead time (the reporter literally asked for "a week before and on the day") — that requires multiple defaults, i.e. #14.
#15: Contact special-dates calendars
Core model: one-way mirror, one local calendar per type
Contacts → separate local calendars per type:
- "Birthdays"
- "Anniversaries"
- "Custom dates" (everything else /
TYPE_OTHER/TYPE_CUSTOM)
Separate-per-type is the key architectural decision: each is a normal local
calendar (createLocalCalendar(), CalendarDataSource.kt:238-260), so the
existing per-calendar infra applies for free:
- Color → existing per-calendar color.
- Show/hide → existing calendar visibility.
- Default reminders → per-calendar override from #14.
This shrinks the new surface dramatically: anniversaries and custom dates are
near copy-paste of birthdays (same engine, different ContactsContract data
kind, one extra local calendar each), and the settings sub-page does not
need to reinvent color/visibility/reminders.
Events: field-level managed, not read-only
Calendula manages a few fields; the user owns the rest.
- Managed (overwritten on every sync, disabled in the editor for these
events): title, start date, recurrence (
FREQ=YEARLY), existence. - User-owned (seeded once on insert, never touched again by sync): location, reminders, notes, busy/free.
The rule that makes this safe: sync performs a targeted column update (managed columns only), never delete-and-reinsert. Reminders live in separate rows, so once seeded they are simply never re-touched. A user can move a birthday reminder to "2 weeks before" or add a location and neither is clobbered.
In the editor, when an event belongs to a managed calendar: disable the title/date/recurrence fields; allow the rest.
Reconciliation identity
Stamp each event with the contact's LOOKUP_KEY in Events.SYNC_DATA1 (ours,
since it's a local calendar). Sync becomes an idempotent diff keyed on it:
- contact added → insert
- birthday/date changed → targeted update of managed columns
- contact or date removed → delete the event
Getting this wrong = duplicate birthdays on every sync, so the identity key is non-negotiable.
Data source
ContactsContract.Data rows of Event.CONTENT_ITEM_TYPE, split by
Event.TYPE:
TYPE_BIRTHDAY→ Birthdays calendarTYPE_ANNIVERSARY→ Anniversaries calendarTYPE_OTHER/TYPE_CUSTOM(+ label) → Custom dates calendar
Dates: year-less + age
Many contacts store --MM-DD (no year). Handle both:
- Year present: anchor
DTSTARTat that year;FREQ=YEARLY. We can show age ("Jane turns 30") when the show-age option is on. - Year absent:
FREQ=YEARLYfrom an arbitrary anchor year; no age shown.
Recurrence support is already there: FREQ=YEARLY + UNTIL/COUNT/INTERVAL
(Recurrence.kt:32-174).
Auto-update cadence
Birthdays change rarely, so nothing live/expensive:
- Sync on enable, on app foreground, and a daily WorkManager job (catches new/edited/deleted contacts even if the app isn't opened).
- No
ContentObserver— needs the process alive and buys almost nothing for once-a-year events.
Permission + lifecycle
- Enable: request
READ_CONTACTS(feature-gated, contextual — mirror the contextualWRITE_CALENDARpattern atEventEditScreen.kt:216-231). On grant, create the enabled calendars and run the first sync. - Disable: delete the managed calendars + their events (with a confirm).
- Permission revoked in system settings: detect on next sync; surface that the feature is stalled rather than failing silently.
Configurable settings sub-page
Enabling the feature reveals a dedicated sub-page (not inline rows). Because color/visibility/reminders already live in normal calendar settings, the sub-page only carries the genuinely new knobs:
- Master enable toggle.
- Per-type toggles: Birthdays / Anniversaries / Custom dates.
- Title/display template, e.g.
"{name}'s birthday"(new translatable string). - Show-age toggle (only meaningful when the year is known).
- (Possibly) sync-cadence / "sync now".
- Pointer to the normal calendar settings for color, visibility, reminders.
The owner wants this broadly configurable — keep the sub-page the home for display/behaviour options, and lean on existing per-calendar settings for the rest.
Open questions / deferred
- Title template: free-form vs a small set of presets. Start with one good localized default; revisit configurability.
- Whether "Custom dates" is one bucket or split further by label. Start: one bucket.
- Contact scope: all contacts vs per-account/group filtering. Start: all; filtering is a later refinement.
Task outline
#14 — per-calendar multiple default reminders (feat/per-calendar-multi-reminders)
- Widen global + per-calendar reminder defaults
Int→List<Int>(SettingsPrefs.kt), keepInherit/None/Minutes(list). resolveDefaultReminder()returns a list.ReminderDefaultPicker→ multi-select (mirror event-form UI).- Seed new events'
EventForm.remindersfrom the resolved list. - Migration / parse compatibility for existing single-value stored prefs.
- Tests: resolution, parse round-trip, all-day variant.
#15 — contact special-dates (feat/contact-special-dates)
- Declare
READ_CONTACTS; feature-gated contextual request on enable. - Per-type local calendars (create on enable, delete on disable+confirm).
- Contacts reader:
Eventrows byTYPE, year-less handling. - Sync engine: idempotent diff keyed on the deterministic
UID_2445(see notes; notSYNC_DATA1); targeted managed-column updates; seed user-owned fields on insert only. FREQ=YEARLYevent generation + age (when year known; sync-time snapshot).- Auto-update: on enable, on foreground, daily WorkManager.
- Editor: disable managed fields for managed-calendar events.
- Revoked-permission detection + stalled-state surface.
- Settings sub-page: enable, per-type toggles, title template, show-age.
- Translatable strings (titles, sub-page).
- About / store copy for the optional
READ_CONTACTSpermission.