feat(calendar): Phase 2 M5 — data-model completeness + recurrence exceptions - #288
Merged
Conversation
Extends calendar_events and calendar_external_events with the richer Google Calendar payload surface: attendees, reminders, visibility, colorId, and conferenceData. Adds parentEventId + originalStartTime to calendar_events so single-instance edits of a recurring series can round-trip as child exceptions. Migration 0027 hand-written per the post-0020 convention; typed JSON shapes (CalendarAttendee, CalendarReminders, CalendarConferenceData, CalendarVisibility) live alongside the schema so repositories and mappers stay type-safe. Tests: repository round-trip coverage for every new column, plus a pre-M5 null-default guard to confirm existing rows are unaffected.
…isibility, colorId, conferenceData, exception pointers (M5 T2) Extends the Google client's Zod schema + mapRemoteEvent to surface the rich Calendar v3 payload fields directly on GoogleCalendarRemoteEvent. Mappers null-coalesce each at the boundary so Memry rows store the canonical "nullable TEXT/JSON" shape. Exception instances (Google returns recurringEventId + originalStartTime on child events) now promote cleanly to parentEventId + originalStartTime on the local row, so the renderer can recognise "exception of <series>". Introduces CalendarEventChanges as the shared return type for both mapGoogleEventToCalendarEventChanges and (in a follow-up task) the local-side change builder used when pushing to Google. Tests: focused mappers.test.ts covering round-trip fidelity for every new field, null-coalescing when Google omits them, and recurringEventId/originalStartTime promotion.
…E (M5 T3) Extends mapCalendarEventToGoogleInput + toGoogleEventPayload so every attendee, reminder override, visibility flag, colorId, and conference pointer that lives locally flows to Google. Introduces buildExdateLine so recurrenceExceptions is emitted in canonical iCal form — UTC Z when the event lives in UTC, TZID=... otherwise — matching Google's own round-tripped output. Tightens recurrenceExceptions to string[] across the Drizzle schema and contract Zod schemas so the mapper sees ISO datetimes directly. Pre-production, so no compat shim — the two legacy fixtures in the contract tests move to canonical ISO form. conferenceData stays read-only on the write path (M5 default: mirror existing Meet links, don't create them). Tests: 11 mapper tests covering every rich field round-trip, EXDATE formatting in both UTC and named-zone variants, plus the edge case of exceptions-without-rrule.
…n (M5 T4)
Confirms the two exception-handling flows compose end-to-end:
1. Push: a local calendar_events row with parentEventId +
originalStartTime flows through mapCalendarEventToGoogleInput →
toGoogleEventPayload, so Google receives recurringEventId +
originalStartTime and creates a child instance rather than
mutating the master series.
2. Pull: applyGoogleCalendarWriteback persists the same pointers on
the local row when Google reports an exception — the renderer
can then resolve "exception of <series>".
Also extends promoteExternalEvent to carry attendees, reminders,
visibility, colorId, and conferenceData from the external mirror to
the newly-created calendar_events row, so promoting an imported
Google event no longer drops the rich payload.
Tests: two sync-service integration scenarios (push + pull) and a
focused promote-external-event assertion.
Extends CALENDAR_EVENT_SYNCABLE_FIELDS with attendees, reminders,
visibility, colorId, conferenceData. The existing LWW-by-tick-sum
merge handles each as an atomic JSON value — per-element merging
(e.g. attendee dedup) is out of M5 scope.
Threads the same fields through:
- CalendarEventSyncPayloadSchema (cross-device CRDT sync payload)
- calendar-event-handler apply-paths (insert, passthrough update,
merge branch) so remote edits land on the local row
- buildPushPayload so outbound sync carries them
Tests: new constant assertion covers the 14 syncable fields; new
merge test demonstrates tick-sum resolution for a concurrent
attendees-list edit (device B's later write wins).
Introduces CalendarEventMetadata — a compact read-only panel for the
richer Google Calendar fields that M5 just started persisting:
- Attendees list with response-status badges (Accepted/Declined/
Tentative/Pending) and an "Optional" marker.
- Reminders summary (per-override "N min · popup/email", or
"Default reminders" when useDefault + no overrides).
- Visibility tag (omitted when 'default' or null).
- "Join meeting" button when conferenceData has a video entry point.
Wired into CalendarEventPopover behind a new optional readOnlyMetadata
prop so create-mode stays untouched. CalendarPage fetches the full
event record via calendarService.getEvent(id) on open and threads the
metadata through CalendarShell. The IPC CalendarEventRecord now carries
attendees/reminders/visibility/colorId/conferenceData/parentEventId/
originalStartTime with typed shapes on the contract.
Tests: 8 component assertions covering full render, optional-flag
marker, reminders override + default cases, visibility conditional,
Join-meeting link, and empty-state (renders nothing).
No editing UI in M5 by design — editable controls are a follow-up
phase once we know the UX for large attendee lists.
…ract shift (M5 T7) - sync-service.ts: cast CalendarEventChanges through unknown before Record<string, unknown> since the interface now has fixed typed keys. - Regenerate generated-rpc.ts + generated-ipc-invoke-map.ts to pick up the tightened recurrenceExceptions: string[] and the new rich fields on CalendarEventRecord.
…y exceptions, null propagation, external sync)
Four issues flagged by Codex post-T7 review, each with a failing test
reproduction before the fix.
[P1] EXDATE stamps now carry the zone's wall time, not UTC's.
buildExdateLine() was formatting non-UTC exceptions by stripping
punctuation from the stored UTC ISO, so a 13:00 UTC exception on an
America/New_York series was emitted as
EXDATE;TZID=America/New_York:20260510T130000
telling Google to skip the 13:00 ET instance (which doesn't exist on
the series) instead of the intended 09:00 ET. Now we format via
Intl.DateTimeFormat against the target zone. DST spring-forward test
pins the behaviour across boundaries.
[P2a] All-day recurring exceptions emit originalStartTime as { date }.
client.ts toGoogleEventPayload was always sending { dateTime, timeZone }.
Google identifies all-day exception instances by date-only; time-based
shapes silently miss the target instance. Read-side was already
normalising { date } → midnight-UTC ISO, so the write side now
re-extracts .slice(0, 10) when isAllDay.
[P2b] calendar-event-handler applies explicit null on remote payloads.
The passthrough apply-path used
attendees: (data.attendees ?? existing.attendees ?? null)
which collapses "remote cleared this field" into "remote omitted this
field". If Google removed attendees on device A, device B kept the
stale list. Now uses Object.prototype.hasOwnProperty to distinguish
present-null from absent, clearing local state when the remote is
explicit.
[P2c] Cross-device external-event sync carries the rich fields.
CalendarExternalEventSyncPayloadSchema + the handler's applyUpsert /
insert / buildPushPayload paths were not updated alongside T4's
external-row writes, so promoteExternalEvent() on a second device
would read a mirror row with null attendees/reminders/visibility/
colorId/conferenceData. Round-trip test in a new
calendar-external-event-handler.test.ts pins all five fields
end-to-end.
Counts: 747 tests pass (up from 742), typecheck:node/web clean, lint
unchanged (0 errors, 1463 pre-existing warnings).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Closes G7 (recurring single-instance edits) and G8 (attendees / reminders / visibility / colorId / conferenceData) from the Google Calendar Phase 2 design.
Branches off
main— orthogonal to M4 push-channel work in #281 and #287 (push vs. poll is the when; M5 is the what).Seven tasks, each RED→GREEN→commit, plus a follow-up round of fixes after a Codex review:
0027_calendar_rich_fields.sqlcalendar_events, 5 oncalendar_external_events, typed JSON via Drizzle$typeZorTZID=formrecurringEventId + originalStartTimefor exception rows; pull populatesparentEventId + originalStartTimeon writeback;promoteExternalEventcarries rich fields acrossCALENDAR_EVENT_SYNCABLE_FIELDSandCalendarEventSyncPayloadSchema; handler insert + apply + push paths all thread themCalendarEventMetadatashows attendees (with response-status badges + optional marker), reminders summary, visibility tag, "Join meeting" buttondb:pushon a fresh SQLitefbb76507)Post-review fixes
TZID=stamp kept UTC wall time instead of converting into the event's zone — Google would skip the wrong occurrence on non-UTC seriesIntl.DateTimeFormatconversion + defensivepadStartfor single-digit secondsoriginalStartTime: { dateTime, timeZone }— Google matches all-day exceptions by{ date }onlyevent.isAllDay ? { date } : { dateTime, timeZone }intoGoogleEventPayload??for rich fields — remotenullwas silently treated as "field omitted", so cleared-on-device-A attendees never cleared on device BObject.prototype.hasOwnProperty.call(data, key)to distinguish present-null from absentpromoteExternalEventon a second device would read a null-heavy mirror even though device A had populated itCalendarExternalEventSyncPayloadSchema+applyUpsert/ insert /buildPushPayloadpathscalendar-external-event-handler.test.tsround-tripDefaults locked upfront (no re-ask):
Zform whentimezone === 'UTC',EXDATE;TZID=<tz>:<zoned wall time>otherwise.recurrenceExceptionstightened fromArray<Record<string, unknown>>tostring[]across Drizzle + contracts (pre-production, no compat shim).Out of scope (follow-ups):
conferenceDataVersion=1+createRequest; M6+).Test plan
pnpm --filter @memry/desktop exec vitest run src/main/calendar src/main/sync— 747/747 passing (+5 from Codex fixes)pnpm typecheck:node && pnpm typecheck:web— cleanpnpm lint— 0 errors (1463 pre-existing warnings, no new ones)pnpm db:pushagainst a fresh SQLite — all 12 new columns materialise on both tables{ date }(not{ dateTime }) onoriginalStartTimeresponseStatus=declinedrenders with the red badge;optional: trueshows the Optional pill🤖 Generated with Claude Code