|
| 1 | +# Meeting time + timezone support — design |
| 2 | + |
| 3 | +**Date:** 2026-05-31 |
| 4 | +**Status:** Approved, ready for implementation plan |
| 5 | + |
| 6 | +## Problem |
| 7 | + |
| 8 | +Meetings currently store only a date (`meetings.meeting_date date`, migration 026) and |
| 9 | +render it as a raw `YYYY-MM-DD` string. There is no time of day, and no timezone |
| 10 | +awareness. Members in different timezones cannot tell *when* a meeting actually starts. |
| 11 | + |
| 12 | +We want the Google/Outlook Calendar model: an admin schedules a meeting at a specific |
| 13 | +moment in a chosen timezone, and every viewer sees that moment converted to *their own |
| 14 | +browser's* timezone. |
| 15 | + |
| 16 | +## Decisions (locked) |
| 17 | + |
| 18 | +| Decision | Choice | |
| 19 | +| :---------------------- | :------------------------------------------------------------------------------------------------------------------- | |
| 20 | +| Existing date-only rows | **Replace** `meeting_date` with an absolute instant; backfill existing rows to **7:00 PM IST** on their stored date. | |
| 21 | +| Time on new meetings | **Required** (date + time both mandatory). | |
| 22 | +| Admin timezone input | Timezone **dropdown, default `Asia/Kolkata` (IST)**. | |
| 23 | +| Viewer display | Browser-local time **+ short zone label**; the **originally-scheduled time + zone shown on hover**. | |
| 24 | +| Conversion location | **Tested JS utility** using `Intl` (Option A), not a Postgres trigger. | |
| 25 | + |
| 26 | +## Schema change |
| 27 | + |
| 28 | +Replace the single `meeting_date date` column on `public.meetings` with two columns: |
| 29 | + |
| 30 | +- `meeting_at timestamptz NOT NULL` — the absolute instant. **Source of truth** for |
| 31 | + sorting, querying, and all display. |
| 32 | +- `meeting_tz text NOT NULL` — the IANA timezone name the meeting was scheduled in |
| 33 | + (e.g. `Asia/Kolkata`, `America/New_York`). Used **only** to render the "original" |
| 34 | + time on hover. Validated against a known IANA list. |
| 35 | + |
| 36 | +This mirrors exactly what Google/Outlook persist: an instant plus its originating zone. |
| 37 | +We do **not** store the local wall-clock date/time separately — the "original" display |
| 38 | +is derived by formatting `meeting_at` in `meeting_tz` via `Intl`, keeping the two-column |
| 39 | +schema and avoiding redundant derived data. |
| 40 | + |
| 41 | +### Migration (new file, next number after 034) |
| 42 | + |
| 43 | +`scripts/prod/migrations/035_meetings_datetime.sql`: |
| 44 | + |
| 45 | +1. Add `meeting_at timestamptz` and `meeting_tz text` (nullable at first). |
| 46 | +2. Backfill: `meeting_at = (meeting_date + time '19:00') AT TIME ZONE 'Asia/Kolkata'`, |
| 47 | + `meeting_tz = 'Asia/Kolkata'` for every existing row. |
| 48 | +3. Set both columns `NOT NULL`. |
| 49 | +4. Drop the `meetings_status_date_idx` index and recreate it on |
| 50 | + `(status, meeting_at desc)`. |
| 51 | +5. Drop the old `meeting_date` column. |
| 52 | + |
| 53 | +### Downstream SQL touch points |
| 54 | + |
| 55 | +- **View `meetings_with_progress` (029)** — replace `meeting_date` with `meeting_at`, |
| 56 | + `meeting_tz`; recreate the view. |
| 57 | +- **Lock-when-closed trigger (027)** — confirm it references row columns generically; |
| 58 | + update if it names `meeting_date` explicitly. |
| 59 | +- **RLS policies (028)** — no change expected (column rename only); verify none reference |
| 60 | + `meeting_date`. |
| 61 | + |
| 62 | +## Conversion utility (Option A) |
| 63 | + |
| 64 | +New module `src/lib/datetime.ts` (or extend `src/lib/format.ts`): |
| 65 | + |
| 66 | +```ts |
| 67 | +// Interpret a wall-clock (date + time) as being in `tz`, return the absolute instant. |
| 68 | +export function zonedWallTimeToInstant( |
| 69 | + dateISO: string, // 'YYYY-MM-DD' |
| 70 | + timeHHMM: string, // 'HH:MM' |
| 71 | + tz: string, // IANA, e.g. 'Asia/Kolkata' |
| 72 | +): Date |
| 73 | +``` |
| 74 | + |
| 75 | +Implementation: the standard two-pass `Intl` offset algorithm — |
| 76 | +1. Build a naive UTC timestamp from the wall-clock parts via `Date.UTC(...)`. |
| 77 | +2. Format that instant in `tz` with `Intl.DateTimeFormat` to discover the zone's offset |
| 78 | + at that moment (correctly handling DST). |
| 79 | +3. Subtract the offset to get the true instant. (One refinement pass handles the rare |
| 80 | + DST-boundary off-by-one.) |
| 81 | + |
| 82 | +`Intl` ships full ICU in both Node (server actions) and the browser, so no new |
| 83 | +dependency is needed. |
| 84 | + |
| 85 | +### Tests (`src/lib/datetime.test.ts`, Vitest — required by CI) |
| 86 | + |
| 87 | +- IST (no DST): `2026-05-31 19:00 Asia/Kolkata` → `2026-05-31T13:30:00Z`. |
| 88 | +- US zone in DST: `2026-07-01 09:00 America/New_York` → `2026-07-01T13:00:00Z`. |
| 89 | +- US zone in standard time: `2026-01-01 09:00 America/New_York` → `2026-01-01T14:00:00Z`. |
| 90 | +- UTC passthrough: `2026-05-31 12:00 UTC` → `2026-05-31T12:00:00Z`. |
| 91 | +- Round-trip: formatting the result back in the source zone returns the input wall-clock. |
| 92 | + |
| 93 | +## Server actions (`src/lib/actions/meetings.ts` + `meetings-validation.ts`) |
| 94 | + |
| 95 | +- **`createMeeting` / `updateMeeting`**: read `meeting_date` (YYYY-MM-DD), |
| 96 | + `meeting_time` (HH:MM), and `meeting_tz` from FormData. Validate all three |
| 97 | + (date regex, time regex `^\d{2}:\d{2}$`, tz against the allowed IANA list). Compute |
| 98 | + `meeting_at = zonedWallTimeToInstant(...).toISOString()` and persist `meeting_at` + |
| 99 | + `meeting_tz`. Keep the `runAction` wrapper + `ActionResult` return contract. |
| 100 | +- **Reads (`meetings-reads.ts`)**: type changes — `meeting_date: string` becomes |
| 101 | + `meeting_at: string` (ISO 8601) + `meeting_tz: string`. Update `ORDER BY meeting_date` |
| 102 | + → `ORDER BY meeting_at`. |
| 103 | + |
| 104 | +## Admin form (`admin/meetings/new/new-meeting-form.tsx` + the edit form) |
| 105 | + |
| 106 | +- Replace the single `<input type="date">` with: `<input type="date">` + |
| 107 | + `<input type="time">` + a timezone `<select>`. |
| 108 | +- Timezone select: a curated IANA list (IST first/selected by default, plus common |
| 109 | + member zones — US East/Central/Pacific, UK, Gulf, Singapore/AU as needed). Default |
| 110 | + value `Asia/Kolkata`. Editing an existing meeting preselects its stored `meeting_tz` |
| 111 | + and prefills the date/time by formatting `meeting_at` in that zone. |
| 112 | + |
| 113 | +## Viewer display |
| 114 | + |
| 115 | +New **client** component `MeetingTime` (`src/components/meeting-time.tsx`): |
| 116 | + |
| 117 | +- Props: `meetingAt` (ISO string), `meetingTz` (IANA string). |
| 118 | +- Renders the local time: |
| 119 | + `new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short', timeZoneName: 'short' }).format(new Date(meetingAt))` |
| 120 | + → uses the browser's zone automatically, e.g. `31 May 2026, 7:00 PM GMT+5:30`. |
| 121 | +- `title` / tooltip shows the original: the same instant formatted with |
| 122 | + `timeZone: meetingTz`, prefixed `Scheduled: …` (e.g. `Scheduled: 31 May 2026, 7:00 PM IST`). |
| 123 | +- Must be a client component so it reads the viewer's browser zone. Guard against |
| 124 | + hydration mismatch: render a stable server fallback (the instant formatted in |
| 125 | + `meeting_tz`, matching the tooltip) and switch to browser-local after mount, OR mark |
| 126 | + the dynamic text `suppressHydrationWarning`. Decide in the plan; prefer the |
| 127 | + mount-swap so SSR output is deterministic. |
| 128 | + |
| 129 | +Replace the raw `{m.meeting_date}` interpolations on: |
| 130 | +- `src/app/(app)/meetings/page.tsx` |
| 131 | +- `src/app/(app)/admin/meetings/page.tsx` |
| 132 | +- `src/app/(app)/admin/meetings/[id]/page.tsx` |
| 133 | + |
| 134 | +## End time (added 2026-05-31, post-initial-design) |
| 135 | + |
| 136 | +Meetings carry a **start and an end time**, like Google/Outlook. The admin |
| 137 | +chooses the duration by picking the end time. |
| 138 | + |
| 139 | +- Schema: add `meeting_ends_at timestamptz NOT NULL`. It shares the meeting's |
| 140 | + `meeting_tz` (end is scheduled in the same zone as start). Folded into the |
| 141 | + same migration `035` (not yet applied to the DB). |
| 142 | +- Both start and end are **required** on new meetings. The form prefills the end |
| 143 | + to start + 1 hour as a convenience; the admin can change it. |
| 144 | +- Existing rows backfill to **start + 1 hour** (7:00–8:00 PM IST). |
| 145 | +- Validation: end must be **strictly after** start (compared as instants). |
| 146 | +- Display: a **range** in the viewer's browser zone, collapsing shared parts via |
| 147 | + `Intl.DateTimeFormat.prototype.formatRange` (e.g. `31 May 2026, 7:00 – 8:00 PM |
| 148 | + IST`), with the originally-scheduled range on hover. |
| 149 | + |
| 150 | +## Out of scope |
| 151 | + |
| 152 | +- Per-member saved timezone preferences (we use the browser zone, like Google). |
| 153 | +- Recurring meetings. |
| 154 | +- Calendar invites / .ics export. |
| 155 | + |
| 156 | +## Verification |
| 157 | + |
| 158 | +- `npm test` — datetime utility tests pass. |
| 159 | +- `npm run build` + `npm run lint` pass. |
| 160 | +- Manual: create a meeting at 7 PM IST; confirm a browser set to a US zone shows the |
| 161 | + correct converted local time and the IST original on hover. |
0 commit comments