# Calendar Event Detail — Implementation Plan

**Spec:** docs/specs/2026-05-31-calendar-event-detail.md  ·  **Slug:** calendar-event-detail  ·  **Wave:** 8
**Depends on:** calendar-module, projects-module, marketing-leads-pipeline, foundation-auth-rbac

## Goal
Build the event detail side panel, the create/edit modal, attendee management, recurring-event handling, and calendar↔task/lead linking on top of the `calendar_events` table owned by `calendar-module`. Adds a schema delta (recurrence RRULE, denormalized attendees JSONB, color override, lead link, recurring-series parent link), the full `/api/calendar/events` CRUD surface plus a token-authenticated RSVP endpoint, client-side RRULE expansion, and `.ics` invite emails. Read-only enforcement for task-sourced events is applied server-side, not just in the UI.

## Architecture
This feature extends the existing `calendar_events` table (columns `id, tenant_id, created_by, title, description, start_at, end_at, all_day, location, source, task_id, project_id, customer_id, external_id, external_calendar_id, synced_at, sync_status, created_at, updated_at` — all from `calendar-module`). It adds five columns via `ALTER TABLE`: `recurrence`, `attendees`, `color`, `lead_id` (FK → `leads(id)` from `marketing-leads-pipeline`), `parent_event_id` (self-FK → `calendar_events(id)` ON DELETE CASCADE).

Data flow:
- **API (Hono, apps/zync-api):** five routes under `/api/calendar/events` reuse the `calendar:read` / `calendar:write` permissions defined by `calendar-module` (no new permissions). Routes call repo functions in `@zync/db`. Task-sourced rows (`source='task'`) are immutable through this surface — guarded server-side. Auth via `authMiddleware` + `requirePermission`. The RSVP route is unauthenticated and verifies a stateless signed token via upstream `verifySignedToken` + `timingSafeEqual`.
- **Serialization:** a feature-local `serializeCalendarEvent` → `CalendarEventObject`. NOTE: the upstream `serializeEvent`/`EventObject` (locked sheet) are the webhook-delivery objects from `tenant-public-api`; do NOT reuse those names — they are a different domain object.
- **App (Vite+React, apps/zync-app):** event detail side panel (`role="complementary"`), create/edit modal, attendee editor, recurrence selector, OKLCH color picker. Calendar grid (from `calendar-module`) expands RRULE client-side with `rrule.js` up to 6 months ahead; no per-instance DB rows.
- **Email:** `.ics` builder + Resend send via upstream `sendEmail` for external attendees.

Consumes upstream: `calendar_events`, `leads`, `tasks` (read-only context: `due_date`, `status`/`TaskStatus`), `projects`, `customers`; exports `authMiddleware`, `requirePermission`, `tenantQuery`, `signSignedToken`, `verifySignedToken`, `timingSafeEqual`, `sendEmail`, `createDb`/`createDb`, `Button`, `Dialog`, `Sheet`, `Input`, `Select`, `Form`, `toast`, `useDirection`.

## Tech Stack
- **apps/zync-api** — Hono routes, Zod validation, Drizzle (Neon Postgres via Hyperdrive binding `DB`).
- **apps/zync-app** — React 18 + Vite, TanStack Query, react-day-picker, `rrule` (rrule.js), `@zync/ui` components.
- **packages/db** — Drizzle schema delta + migration + repo functions.
- **packages/types** — shared `CalendarEventObject`, attendee/recurrence Zod schemas + types.
- Email: `ics` package for `.ics` generation, Resend via `sendEmail`.
- Cloudflare bindings: `DB` (Hyperdrive→Neon). Signed-token secret from env (`SIGNED_TOKEN_SECRET`, existing).

## Wave Plan
| Sub-wave | Tasks | Files touched | Parallelizable? |
|----------|-------|---------------|-----------------|
| A — schema | 1 | packages/db schema + migration | No (blocks all) |
| B — shared contracts | 2 | packages/types schemas | After A |
| C — server repo + serializer | 3, 4 | packages/db repo, serializer | After B |
| D — API routes | 5, 6 | apps/zync-api routes (CRUD + RSVP) | After C; 5 and 6 parallel |
| E — email + ics | 7 | apps/zync-api email helper | After B (parallel with D) |
| F — UI: panel/modal/attendees/recurrence/color | 8, 9, 10, 11 | apps/zync-app components | After D; mostly parallel |
| G — client RRULE expansion + grid wiring | 12 | apps/zync-app calendar grid | After 11 |
| H — a11y + RTL pass | 13 | apps/zync-app components | After F, G |

## Tasks

### Task 1: Schema delta on `calendar_events`
**Blocks:** 2, 3, 4, 5, 6, 12  ·  **Blocked by:** —
**Files:**
- Modify: `packages/db/src/schema/calendar.ts`
- Create: `packages/db/migrations/<timestamp>_calendar_event_detail.sql`
**Steps:**
- [ ] Add the five columns to the Drizzle `calendarEvents` table definition (recurrence text, attendees jsonb default `[]`, color text, leadId uuid FK, parentEventId uuid self-FK with cascade).
- [ ] Write the raw SQL migration with the exact DDL below; FKs are UUID→UUID.
- [ ] Add an index on `parent_event_id` (recurring-detach lookups) and on `lead_id`.
- [ ] Run `drizzle-kit` generate/check to confirm the schema matches the migration.
**Schema / Interfaces:**
```sql
ALTER TABLE calendar_events ADD COLUMN recurrence TEXT;
-- RFC 5545 RRULE string, e.g. 'FREQ=WEEKLY;BYDAY=MO'. NULL = no recurrence.
-- Only manual events (source='manual') may carry a non-NULL recurrence (enforced in app layer).

ALTER TABLE calendar_events ADD COLUMN attendees JSONB NOT NULL DEFAULT '[]'::jsonb;
-- Array of { email TEXT, name TEXT?, status TEXT } where status IN ('pending','accepted','declined').

ALTER TABLE calendar_events ADD COLUMN color TEXT;
-- Optional OKLCH color override string, validated server-side.

ALTER TABLE calendar_events ADD COLUMN lead_id UUID REFERENCES leads(id);
-- Link to a CRM lead (kick-off calls, discovery sessions).

ALTER TABLE calendar_events ADD COLUMN parent_event_id UUID REFERENCES calendar_events(id) ON DELETE CASCADE;
-- A detached "this event only" instance of a recurring series points at its master row.

CREATE INDEX idx_calendar_events_parent_event_id ON calendar_events(parent_event_id);
CREATE INDEX idx_calendar_events_lead_id ON calendar_events(lead_id);
```
**Acceptance:**
- [ ] Migration applies cleanly against Neon; columns/indexes present.
- [ ] `lead_id` FK targets `leads(id)`; `parent_event_id` FK targets `calendar_events(id)` with ON DELETE CASCADE; both UUID→UUID.
- [ ] No new table is created; `calendar_events` remains owned by `calendar-module`.

### Task 2: Shared Zod schemas & `CalendarEventObject` type
**Blocks:** 3, 4, 5, 6, 7, 8, 9, 10, 11  ·  **Blocked by:** 1
**Files:**
- Create: `packages/types/src/calendar-event.ts`
- Modify: `packages/types/src/index.ts`
**Steps:**
- [ ] Define `attendeeSchema`, `attendeesSchema` (array), `recurrenceSchema` (RRULE string, regex-validated for `FREQ=`), `oklchColorSchema` (server-side OKLCH validation).
- [ ] Define `createCalendarEventSchema` and `updateCalendarEventSchema` (the latter adds `editScope`).
- [ ] Define `rsvpSchema` and `CalendarEventObject` output type.
- [ ] Export all from the package index.
**Schema / Interfaces:**
```ts
import { z } from 'zod';

export const attendeeStatusSchema = z.enum(['pending', 'accepted', 'declined']);
export type AttendeeStatus = z.infer<typeof attendeeStatusSchema>;

export const attendeeSchema = z.object({
  email: z.string().email(),
  name: z.string().max(200).optional(),
  status: attendeeStatusSchema.default('pending'),
});
export const attendeesSchema = z.array(attendeeSchema).max(100).default([]);
export type Attendee = z.infer<typeof attendeeSchema>;

// RRULE: must start with FREQ=; allow standard RRULE tokens. Full validity checked by rrule.js on read.
export const recurrenceSchema = z
  .string()
  .regex(/^FREQ=(DAILY|WEEKLY|MONTHLY|YEARLY)(;[A-Z]+=[A-Z0-9,\-]+)*$/i, 'Invalid RRULE')
  .max(500);

// OKLCH e.g. 'oklch(0.62 0.19 29)'. Reject anything else (no hex/named colors, no CSS injection).
export const oklchColorSchema = z
  .string()
  .regex(/^oklch\(\s*[\d.]+%?\s+[\d.]+\s+[\d.]+(\s*\/\s*[\d.]+%?)?\s*\)$/i, 'Invalid OKLCH color')
  .max(64);

export const createCalendarEventSchema = z.object({
  title: z.string().min(1).max(500),
  start_at: z.string().datetime(),
  end_at: z.string().datetime(),
  all_day: z.boolean().optional().default(false),
  location: z.string().max(1000).optional(),
  description: z.string().max(10000).optional(),
  customer_id: z.string().uuid().optional(),
  project_id: z.string().uuid().optional(),
  lead_id: z.string().uuid().optional(),
  attendees: attendeesSchema.optional(),
  recurrence: recurrenceSchema.optional(),
  color: oklchColorSchema.optional(),
}).refine((v) => new Date(v.end_at) >= new Date(v.start_at), { message: 'end_at must be >= start_at', path: ['end_at'] });

export const updateCalendarEventSchema = createCalendarEventSchema
  .innerType()           // unwrap the refine to allow .partial()
  .partial()
  .extend({ editScope: z.enum(['this', 'future']).optional() });

export const rsvpSchema = z.object({
  token: z.string().min(1),
  status: z.enum(['accepted', 'declined']),
});

export interface CalendarEventObject {
  id: string;
  title: string;
  description: string | null;
  start_at: string;          // ISO 8601
  end_at: string;            // ISO 8601
  all_day: boolean;
  location: string | null;
  source: 'manual' | 'task' | 'project' | 'google' | 'outlook' | 'calendly' | 'acuity' | 'mocal';
  task_id: string | null;
  project_id: string | null;
  customer_id: string | null;
  lead_id: string | null;
  parent_event_id: string | null;
  recurrence: string | null;
  attendees: Attendee[];
  color: string | null;
  external_id: string | null;
  sync_status: 'local' | 'synced' | 'pending' | 'error';
  read_only: boolean;        // true when source IN ('task','google','outlook','calendly','acuity','mocal')
  created_at: string;
  updated_at: string;
}
```
**Acceptance:**
- [ ] `pnpm --filter @zync/types build` passes; schemas exported from index.
- [ ] OKLCH regex rejects `#fff`, `red`, and `oklch(0.6 0.1 30); color:expression(...)`.

### Task 3: `serializeCalendarEvent` serializer
**Blocks:** 5, 6  ·  **Blocked by:** 2
**Files:**
- Create: `packages/db/src/serializers/calendar-event.ts`
- Modify: `packages/db/src/index.ts`
**Steps:**
- [ ] Implement `serializeCalendarEvent(row)` → `CalendarEventObject`: convert timestamps to ISO, coerce JSONB attendees, compute `read_only`.
- [ ] `read_only = row.source !== 'manual'` (task/project/external rows cannot be edited via this surface).
- [ ] Export from db package index. Do NOT reuse the upstream `serializeEvent`/`EventObject` names.
**Schema / Interfaces:**
```ts
export function serializeCalendarEvent(row: CalendarEventRow): CalendarEventObject;
// read_only: row.source !== 'manual'
```
**Acceptance:**
- [ ] A `source='task'` row serializes with `read_only: true`; a `source='manual'` row with `read_only: false`.
- [ ] Name is `serializeCalendarEvent`, distinct from upstream `serializeEvent`.

### Task 4: DB repo functions for calendar events
**Blocks:** 5, 6  ·  **Blocked by:** 2
**Files:**
- Create: `packages/db/src/repos/calendar-events.ts`
- Modify: `packages/db/src/index.ts`
**Steps:**
- [ ] Implement tenant-scoped queries via upstream `tenantQuery` so every call is filtered by `tenant_id`.
- [ ] `listCalendarEvents({ start, end, userId? })` — events whose `[start_at,end_at]` overlaps the range, plus any event with non-NULL `recurrence` whose series could project into the range (return masters; client expands).
- [ ] `getCalendarEvent(id)`, `createCalendarEvent(input)`, `updateCalendarEvent(id, input)`, `deleteCalendarEvent(id)`.
- [ ] `detachRecurringInstance(masterId, instanceDate, overrides)` — insert a one-off row with `recurrence = NULL`, `parent_event_id = masterId`, `start_at`/`end_at` = instance date; record the skipped instance date so master expansion excludes it (store in `attendees`-adjacent `recurrence_exdates`? — no new column: store EXDATE inside the master `recurrence` RRULE set string, e.g. append `\nEXDATE:<iso>`).
- [ ] `splitRecurringSeries(masterId, splitDate, newInput)` — set the master's `recurrence` to include `UNTIL=<splitDate>`, then create a new master row with the edited fields and new `recurrence`.
**Schema / Interfaces:**
```ts
export async function listCalendarEvents(db: Db, ctx: TenantCtx, q: { start: string; end: string; userId?: string }): Promise<CalendarEventRow[]>;
export async function getCalendarEvent(db: Db, ctx: TenantCtx, id: string): Promise<CalendarEventRow | null>;
export async function createCalendarEvent(db: Db, ctx: TenantCtx, input: CreateCalendarEventInput): Promise<CalendarEventRow>;
export async function updateCalendarEvent(db: Db, ctx: TenantCtx, id: string, input: UpdateCalendarEventInput): Promise<CalendarEventRow>;
export async function deleteCalendarEvent(db: Db, ctx: TenantCtx, id: string): Promise<void>;
export async function detachRecurringInstance(db: Db, ctx: TenantCtx, masterId: string, instanceDate: string, overrides: UpdateCalendarEventInput): Promise<CalendarEventRow>;
export async function splitRecurringSeries(db: Db, ctx: TenantCtx, masterId: string, splitDate: string, newInput: UpdateCalendarEventInput): Promise<CalendarEventRow>;
```
**Acceptance:**
- [ ] Every repo function is tenant-scoped (no cross-tenant read/write possible).
- [ ] `detachRecurringInstance` produces a row with `parent_event_id` set and `recurrence = NULL`; the master's expansion excludes the detached date.
- [ ] `splitRecurringSeries` writes `UNTIL=` into the old master and creates a new master.

### Task 5: API — events CRUD routes
**Blocks:** 8, 9, 12  ·  **Blocked by:** 3, 4
**Files:**
- Create: `apps/zync-api/src/routes/calendar-events.ts`
- Modify: `apps/zync-api/src/routes/index.ts` (mount router)
**Steps:**
- [ ] Mount under `/api/calendar/events`; apply `authMiddleware`.
- [ ] `GET /api/calendar/events` — `requirePermission('calendar:read')`; validate `{ start, end, userId? }`; return `serializeCalendarEvent[]`.
- [ ] `POST /api/calendar/events` — `requirePermission('calendar:write')`; validate `createCalendarEventSchema`; reject `recurrence` unless resulting `source='manual'`; create; if attendees include external emails, enqueue invite email (Task 7); return serialized event.
- [ ] `GET /api/calendar/events/:id` — `requirePermission('calendar:read')`; 404 if not in tenant.
- [ ] `PATCH /api/calendar/events/:id` — `requirePermission('calendar:write')`; validate `updateCalendarEventSchema`. **Server-side read-only guard:** if the target row `source !== 'manual'`, respond `409` with `{ error: 'read_only', redirect: '/tasks/:taskId' }` (for `source='task'`). For recurring edits, branch on `editScope`: `'this'` → `detachRecurringInstance`; `'future'` → `splitRecurringSeries`; absent → update master in place. Re-send invites only to newly added external attendees.
- [ ] `DELETE /api/calendar/events/:id` — `requirePermission('calendar:write')`; reject when `source !== 'manual'` (read-only); branch on `scope` query (`'this'` detaches+marks deleted instance via EXDATE on master; `'future'` sets master `UNTIL`; absent → delete row, cascading detached children).
- [ ] All bodies validated with the shared Zod schemas; raw Drizzle never called from the route (use repo functions).
**Schema / Interfaces:**
```
GET    /api/calendar/events            calendar:read   query {start,end,userId?}      → CalendarEventObject[]
POST   /api/calendar/events            calendar:write  body createCalendarEventSchema → CalendarEventObject
GET    /api/calendar/events/:id        calendar:read                                  → CalendarEventObject
PATCH  /api/calendar/events/:id        calendar:write  body updateCalendarEventSchema → CalendarEventObject
DELETE /api/calendar/events/:id        calendar:write  query {scope?:'this'|'future'} → 204
```
**Acceptance:**
- [ ] PATCH/DELETE on a `source='task'` event returns 409 read-only with a `redirect` to the task — enforced server-side, not only in UI.
- [ ] `recurrence` cannot be set on non-manual events.
- [ ] `editScope:'this'` creates a detached instance; `'future'` splits the series.
- [ ] Routes use `calendar:read`/`calendar:write` (from `calendar-module`); no new permissions defined.

### Task 6: API — token-authenticated RSVP route
**Blocks:** 7  ·  **Blocked by:** 3, 4
**Files:**
- Create: `apps/zync-api/src/routes/calendar-rsvp.ts`
- Modify: `apps/zync-api/src/routes/index.ts`
**Steps:**
- [ ] `POST /api/calendar/events/:id/rsvp` — NO `authMiddleware` (public). Validate `rsvpSchema`.
- [ ] Verify the token with upstream `verifySignedToken`; payload = `{ eventId, email }`. Compare `eventId` to the path `:id` and the signed email with `timingSafeEqual` (security cross-cutting: timing-safe comparison required for all token/identity checks).
- [ ] Load the event by id within the token's tenant context (tenant id carried in signed payload); locate the matching attendee by email; set its `status` to the posted value in the `attendees` JSONB; persist.
- [ ] Return `200 { status }`; on any token failure return generic `401` (no enumeration of valid/invalid emails).
**Schema / Interfaces:**
```
POST /api/calendar/events/:id/rsvp   (public, token-auth)   body rsvpSchema {token,status}  → 200 {status}
// token = signSignedToken({ tenantId, eventId, email }, SIGNED_TOKEN_SECRET)  (issued by Task 7)
```
**Acceptance:**
- [ ] No session/permission required; only a valid signed token grants the update.
- [ ] `timingSafeEqual` is used for the email/eventId equality check; a tampered token is rejected with 401.
- [ ] RSVP mutates only the matching attendee's `status` within `attendees` JSONB.

### Task 7: `.ics` invite builder + Resend send
**Blocks:** —  ·  **Blocked by:** 2, 6
**Files:**
- Create: `apps/zync-api/src/lib/calendar-invite.ts`
**Steps:**
- [ ] Implement `buildEventIcs(event, organizer)` producing a VEVENT (`.ics`) string (uid = event id, DTSTART/DTEND from `start_at`/`end_at`, SUMMARY, LOCATION, DESCRIPTION, ORGANIZER, ATTENDEE lines).
- [ ] Implement `sendAttendeeInvites(event, newAttendees)` — for each external attendee (email NOT ending in `@{tenantDomain}`), mint an RSVP token via `signSignedToken({ tenantId, eventId, email })`, build Accept/Decline links to `POST /api/calendar/events/:id/rsvp`, and send via upstream `sendEmail` with the `.ics` as attachment. New attendees start `status='pending'`.
- [ ] Tenant-internal attendees (matching tenant domain) are NOT emailed.
**Schema / Interfaces:**
```ts
export function buildEventIcs(event: CalendarEventRow, organizerEmail: string): string;
export async function sendAttendeeInvites(env: Env, event: CalendarEventRow, newAttendees: Attendee[]): Promise<void>;
```
**Acceptance:**
- [ ] Only external attendees receive emails; internal (`@tenantDomain`) attendees do not.
- [ ] Email includes a valid `.ics` attachment and Accept/Decline links carrying a signed token.
- [ ] New external attendees persist with `status='pending'`.

### Task 8: Event detail side panel
**Blocks:** 13  ·  **Blocked by:** 5
**Files:**
- Create: `apps/zync-app/src/features/calendar/EventDetailPanel.tsx`
- Create: `apps/zync-app/src/features/calendar/useCalendarEvent.ts`
**Steps:**
- [ ] `useCalendarEvent(id)` TanStack Query hook hitting `GET /api/calendar/events/:id`.
- [ ] Render a non-blocking side panel (`role="complementary"`, `aria-label="פרטי אירוע"`) with header (title, Edit/Delete/Close), date/time line, location, Linked section (Customer/Project/Lead — Lead shows name + stage badge e.g. `PROPOSAL`), Attendees list (email + status icon), Description, and a recurrence summary ("Repeats weekly on Mondays") derived from the RRULE.
- [ ] When `read_only` and `source='task'`: show the Task block (`📌 Task: "<title>" [View task]`, Due + Status from task) and disable Edit (it routes to task detail instead).
- [ ] When `source IN ('google','outlook')`: show a sync badge.
- [ ] Closes on Escape; return focus to the calendar cell that was active before opening.
- [ ] Title & description use `dir="auto"`; layout uses CSS logical properties only.
**Acceptance:**
- [ ] Panel has `role="complementary"` + the exact Hebrew `aria-label="פרטי אירוע"`.
- [ ] Escape closes the panel and restores focus to the originating calendar cell.
- [ ] Task-sourced events render read-only with a "View task" action; no inline edit.

### Task 9: Create / Edit modal
**Blocks:** 13  ·  **Blocked by:** 5
**Files:**
- Create: `apps/zync-app/src/features/calendar/EventEditModal.tsx`
**Steps:**
- [ ] `Dialog`-based modal with fields: Title, Date (react-day-picker), Time start/end, All-day toggle, Location, Link-to selects (Customer/Project/Lead), Attendees editor (Task 10), Recurrence selector (Task 11), Color picker (Task 11), Description.
- [ ] Quick-create: opening from an empty calendar slot pre-fills the date/time.
- [ ] On save: `POST` (create) or `PATCH` (edit) using the shared schemas; on edit of a recurring master, prompt "Edit this event only" vs "Edit all future events" → send `editScope:'this'|'future'`.
- [ ] react-day-picker configured with `locale={locale === 'he' ? he : undefined}` and `dir={locale === 'he' ? 'rtl' : 'ltr'}`, `aria-label="בחר תאריך לאירוע"`.
- [ ] Title/description inputs use `dir="auto"`.
**Acceptance:**
- [ ] Editing a recurring event surfaces the this/future choice and posts the correct `editScope`.
- [ ] Date picker uses the `he` locale + RTL when locale is Hebrew, with the exact `aria-label="בחר תאריך לאירוע"`.
- [ ] Keyboard: arrows navigate days, Enter/Space select, Escape closes the picker.

### Task 10: Attendee editor
**Blocks:** 13  ·  **Blocked by:** 2, 9
**Files:**
- Create: `apps/zync-app/src/features/calendar/AttendeeEditor.tsx`
**Steps:**
- [ ] Email input + "Add" button; validate against `attendeeSchema`; list added attendees with remove (✕).
- [ ] New attendees default to `status='pending'`; show status icon for existing attendees (✓ accepted, ? pending, ✕ declined).
- [ ] Emit the attendees array up to the modal form state.
**Acceptance:**
- [ ] Invalid emails are rejected inline; valid ones append with `status='pending'`.
- [ ] Removing an attendee updates form state; existing statuses render with correct icons.

### Task 11: Recurrence selector + OKLCH color picker
**Blocks:** 12, 13  ·  **Blocked by:** 2, 9
**Files:**
- Create: `apps/zync-app/src/features/calendar/RecurrenceSelector.tsx`
- Create: `apps/zync-app/src/features/calendar/EventColorPicker.tsx`
**Steps:**
- [ ] Recurrence radio group: Does not repeat / Daily / Weekly on <weekday> / Monthly / Custom…; map selection to an RRULE string (`FREQ=DAILY`, `FREQ=WEEKLY;BYDAY=MO`, `FREQ=MONTHLY`, custom raw input). "Custom" exposes a raw RRULE field validated by `recurrenceSchema`.
- [ ] Color picker: Default + a fixed palette (Blue/Green/Red/Orange/Purple) each mapped to a design-token OKLCH value; emits an `oklchColorSchema`-valid string or null for Default. No hardcoded hex.
**Acceptance:**
- [ ] Selecting "Weekly on Monday" yields `FREQ=WEEKLY;BYDAY=MO`; "Does not repeat" yields null.
- [ ] Color choices emit valid OKLCH strings sourced from design tokens (no raw hex).

### Task 12: Client-side RRULE expansion + calendar grid wiring
**Blocks:** 13  ·  **Blocked by:** 5, 11
**Files:**
- Create: `apps/zync-app/src/features/calendar/expandRecurrence.ts`
- Modify: `apps/zync-app/src/features/calendar/CalendarGrid.tsx` (from calendar-module)
**Steps:**
- [ ] `expandRecurrence(event, rangeStart, rangeEnd)` using `rrule` (rrule.js): expand masters with non-NULL `recurrence` into virtual instances bounded to ≤6 months ahead; honor EXDATE/`UNTIL` so detached/split instances are skipped.
- [ ] Merge expanded virtual instances with concrete rows (including detached one-offs that carry `parent_event_id`) for rendering; clicking a virtual instance opens the detail panel seeded from the master + instance date.
- [ ] Do not write per-instance rows to the DB.
- [ ] Respect `prefers-reduced-motion` for any panel open/close transition (no animation when reduced motion is requested).
**Schema / Interfaces:**
```ts
export function expandRecurrence(event: CalendarEventObject, rangeStart: Date, rangeEnd: Date): VirtualEventInstance[];
// caps expansion at rangeStart + 6 months; applies EXDATE and UNTIL.
```
**Acceptance:**
- [ ] A weekly RRULE renders all instances within a visible month without any extra DB rows.
- [ ] Detached ("this only") and split ("future") edits are reflected: the master skips the overridden date.
- [ ] Panel transitions are disabled under `prefers-reduced-motion`.

### Task 13: Accessibility & RTL verification pass
**Blocks:** —  ·  **Blocked by:** 8, 9, 10, 11, 12
**Files:**
- Modify: `apps/zync-app/src/features/calendar/EventDetailPanel.tsx`
- Modify: `apps/zync-app/src/features/calendar/EventEditModal.tsx`
- Modify: `apps/zync-app/src/features/calendar/CalendarGrid.tsx`
**Steps:**
- [ ] Calendar cells: `role="gridcell"`, `aria-label="{date formatted long}"`, `aria-selected="true"` on selected date, `aria-current="date"` on today.
- [ ] Prev/next month nav buttons: `aria-label="חודש קודם"` / `"חודש הבא"`. Month/year heading: `aria-live="polite"`.
- [ ] Event chips: `role="button"`, `aria-label="{title}, {time}, {type}"`; truncated text exposes full title via the `title` attribute.
- [ ] Side panel: `role="complementary"`, `aria-label="פרטי אירוע"`, Escape-close with focus return to the originating cell.
- [ ] Confirm all layout uses CSS logical properties (`inset-inline-start`, `margin-inline-end`) — no `left`/`right`; title & description use `dir="auto"`.
**Acceptance:**
- [ ] All listed ARIA roles/labels present with the exact Hebrew strings (`חודש קודם`, `חודש הבא`, `פרטי אירוע`, `בחר תאריך לאירוע`).
- [ ] No physical `left`/`right` CSS in the calendar/event components; logical properties only.
- [ ] Keyboard navigation (arrows/Enter/Space/Escape) works in grid, date picker, and panel; focus returns correctly on close.
