Skip to content

Commit fc49531

Browse files
authored
Feat/meeting time timezone (#22)
1 parent 0d4cca5 commit fc49531

27 files changed

Lines changed: 2324 additions & 298 deletions

docs/superpowers/plans/2026-05-31-meeting-time-timezone.md

Lines changed: 1003 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
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`, Vitestrequired 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 zonesUS 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:008: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.
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
-- =============================================================================
2+
-- 035 — Meetings: replace date-only meeting_date with a precise instant.
3+
--
4+
-- meetings.meeting_date (date) is replaced by:
5+
-- meeting_at timestamptz — the absolute instant (source of truth)
6+
-- meeting_tz text — IANA zone it was scheduled in (for "original"
7+
-- display; the app shows each viewer their own zone)
8+
--
9+
-- Existing rows are backfilled to 7:00 PM IST on their stored date.
10+
--
11+
-- Dependent objects rebuilt here:
12+
-- - index meetings_status_date_idx (was on meeting_date)
13+
-- - view meetings_with_progress (current def: migration 031)
14+
-- - func fn_meetings_lock_closed (current def: migration 034 — referenced
15+
-- meeting_date in BOTH guard branches)
16+
-- =============================================================================
17+
18+
begin;
19+
20+
-- 1. Add the new columns (nullable while we backfill).
21+
alter table public.meetings
22+
add column if not exists meeting_at timestamptz,
23+
add column if not exists meeting_tz text;
24+
25+
-- 2. Backfill existing rows: 7:00 PM IST on the stored date.
26+
update public.meetings
27+
set
28+
meeting_at = (meeting_date + time '19:00') at time zone 'Asia/Kolkata',
29+
meeting_tz = 'Asia/Kolkata'
30+
where meeting_at is null;
31+
32+
-- 3. Enforce NOT NULL now that every row has values.
33+
alter table public.meetings
34+
alter column meeting_at set not null,
35+
alter column meeting_tz set not null;
36+
37+
-- 4. Drop the view (depends on meeting_date) before dropping the column.
38+
drop view if exists public.meetings_with_progress;
39+
40+
-- 5. Drop the old index, then the old column.
41+
drop index if exists public.meetings_status_date_idx;
42+
alter table public.meetings drop column meeting_date;
43+
44+
-- 6. Recreate the index on the new instant.
45+
create index if not exists meetings_status_date_idx
46+
on public.meetings (status, meeting_at desc);
47+
48+
-- 7. Recreate the view (031's definition, with meeting_date -> meeting_at + meeting_tz).
49+
create view public.meetings_with_progress
50+
with (security_invoker = true)
51+
as
52+
select
53+
m.id,
54+
m.title,
55+
m.meeting_at,
56+
m.meeting_tz,
57+
m.status,
58+
m.linked_poll_id,
59+
m.action_items_md,
60+
m.created_by,
61+
m.created_at,
62+
m.closed_at,
63+
m.closed_by,
64+
coalesce(a.attendee_count, 0) as attendee_count,
65+
coalesce(a.captured_count, 0) as captured_count,
66+
m.agenda_md,
67+
coalesce(a.present_count, 0) as present_count
68+
from public.meetings m
69+
left join lateral (
70+
select
71+
count(*)::int as attendee_count,
72+
(count(*) filter (where ma.attended))::int as present_count,
73+
(count(*) filter (where ma.notes_md is not null and ma.attended))::int as captured_count
74+
from public.meeting_attendees ma
75+
where ma.meeting_id = m.id
76+
) a on true;
77+
78+
-- 8. Recreate the close-lock trigger (034's definition). Both guard branches
79+
-- compared new.meeting_date = old.meeting_date; swap to meeting_at + meeting_tz.
80+
create or replace function public.fn_meetings_lock_closed()
81+
returns trigger
82+
language plpgsql
83+
as $$
84+
begin
85+
if old.status = 'closed' then
86+
-- allow a clean transition back to 'open'
87+
if new.status = 'open'
88+
and new.closed_at is null
89+
and new.closed_by is null
90+
and new.title = old.title
91+
and new.meeting_at = old.meeting_at
92+
and new.meeting_tz = old.meeting_tz
93+
and new.linked_poll_id is not distinct from old.linked_poll_id
94+
then
95+
return new;
96+
end if;
97+
98+
-- allow editing the action-items list while the meeting stays closed;
99+
-- every other column must be unchanged
100+
if new.status = 'closed'
101+
and new.id = old.id
102+
and new.title = old.title
103+
and new.meeting_at = old.meeting_at
104+
and new.meeting_tz = old.meeting_tz
105+
and new.random_seed = old.random_seed
106+
and new.linked_poll_id is not distinct from old.linked_poll_id
107+
and new.agenda_md is not distinct from old.agenda_md
108+
and new.created_by = old.created_by
109+
and new.created_at = old.created_at
110+
and new.closed_at is not distinct from old.closed_at
111+
and new.closed_by is not distinct from old.closed_by
112+
then
113+
return new;
114+
end if;
115+
116+
raise exception 'meeting is closed; reopen it before editing';
117+
end if;
118+
return new;
119+
end
120+
$$;
121+
122+
commit;
123+
124+
notify pgrst, 'reload schema';

0 commit comments

Comments
 (0)