Skip to content

feat(calendar): Phase 2 M5 — data-model completeness + recurrence exceptions - #288

Merged
h4yfans merged 8 commits into
mainfrom
feat/calendar-phase2-m5
Apr 19, 2026
Merged

feat(calendar): Phase 2 M5 — data-model completeness + recurrence exceptions#288
h4yfans merged 8 commits into
mainfrom
feat/calendar-phase2-m5

Conversation

@h4yfans

@h4yfans h4yfans commented Apr 19, 2026

Copy link
Copy Markdown
Collaborator

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:

# Task Key changes
T1 Schema migration 0027_calendar_rich_fields.sql 7 new columns on calendar_events, 5 on calendar_external_events, typed JSON via Drizzle $type
T2 Google → Memry mapper Zod parses attendees / reminders / visibility / colorId / conferenceData / recurringEventId / originalStartTime; mapper null-coalesces
T3 Memry → Google mapper + EXDATE Write path carries rich fields; EXDATE emission in UTC Z or TZID= form
T4 Single-instance recurring edits Push emits recurringEventId + originalStartTime for exception rows; pull populates parentEventId + originalStartTime on writeback; promoteExternalEvent carries rich fields across
T5 Field-level merge coverage Rich fields added to CALENDAR_EVENT_SYNCABLE_FIELDS and CalendarEventSyncPayloadSchema; handler insert + apply + push paths all thread them
T6 Renderer read-only panel CalendarEventMetadata shows attendees (with response-status badges + optional marker), reminders summary, visibility tag, "Join meeting" button
T7 Verify vitest calendar + sync suites + full typecheck + lint + db:push on a fresh SQLite
Codex-review fixes (commit fbb76507) 4 issues caught post-T7 — see table below

Post-review fixes

Codex finding Fix Test
P1 EXDATE TZID= stamp kept UTC wall time instead of converting into the event's zone — Google would skip the wrong occurrence on non-UTC series Intl.DateTimeFormat conversion + defensive padStart for single-digit seconds 2 new mapper tests (basic + DST spring-forward boundary)
P2a All-day recurring exceptions always emitted originalStartTime: { dateTime, timeZone } — Google matches all-day exceptions by { date } only event.isAllDay ? { date } : { dateTime, timeZone } in toGoogleEventPayload 2 new client tests (all-day + timed)
P2b Calendar-event handler used ?? for rich fields — remote null was silently treated as "field omitted", so cleared-on-device-A attendees never cleared on device B Object.prototype.hasOwnProperty.call(data, key) to distinguish present-null from absent 1 new handler test exercising the passthrough apply path
P2c External-event sync payload + handler didn't carry the rich fields — promoteExternalEvent on a second device would read a null-heavy mirror even though device A had populated it Extended CalendarExternalEventSyncPayloadSchema + applyUpsert / insert / buildPushPayload paths New calendar-external-event-handler.test.ts round-trip

Defaults locked upfront (no re-ask):

  • Attendees/reminders/visibility/colorId are read+write. ConferenceData is a read-only mirror; Memry does not create Meet links.
  • EXDATE uses UTC Z form when timezone === 'UTC', EXDATE;TZID=<tz>:<zoned wall time> otherwise.
  • recurrenceExceptions tightened from Array<Record<string, unknown>> to string[] across Drizzle + contracts (pre-production, no compat shim).
  • No backward-compat for existing rows: new columns default NULL.

Out of scope (follow-ups):

  • Editable controls for attendees/reminders/visibility in the popover (first pass is read-only by design).
  • Per-element merge for attendee lists / reminder sets (current merge is atomic-whole-value LWW by tick-sum).
  • Memry-side Meet link creation (requires conferenceDataVersion=1 + createRequest; M6+).

Test plan

  • pnpm --filter @memry/desktop exec vitest run src/main/calendar src/main/sync747/747 passing (+5 from Codex fixes)
  • pnpm typecheck:node && pnpm typecheck:webclean
  • pnpm lint0 errors (1463 pre-existing warnings, no new ones)
  • pnpm db:push against a fresh SQLite — all 12 new columns materialise on both tables
  • Manual: create a recurring event in a non-UTC zone, skip one occurrence, verify Google drops the correct instance (the P1 regression that unit tests now pin)
  • Manual: create an all-day recurring event, edit one occurrence, verify Google creates a child event with { date } (not { dateTime }) on originalStartTime
  • Manual: on device A, clear the attendees on a synced event; verify device B clears them too (the P2b regression)
  • Manual: confirm "Join meeting" button opens the conference URL in the default browser
  • Manual: attendee with responseStatus=declined renders with the red badge; optional: true shows the Optional pill

🤖 Generated with Claude Code

h4yfans added 8 commits April 19, 2026 19:58
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).
@h4yfans
h4yfans merged commit 2edd896 into main Apr 19, 2026
7 checks passed
@h4yfans
h4yfans deleted the feat/calendar-phase2-m5 branch April 19, 2026 17:53
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.

1 participant