From 10dd014b146e7df2f91166c85c104912158e0743 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Wed, 19 Aug 2026 10:03:41 +0000 Subject: [PATCH] feat(web): recurring scheduled meetings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Schedule Meeting modal can now book a series: daily, weekly or monthly, every N of those, for a fixed number of occurrences or forever (0 = forever, matching how the count is stored). A series is one scheduled_sessions row rather than one row per date, so it keeps a single join code, invitee list and set of invite emails. scheduled_at always points at the next occurrence and is rolled forward lazily when the meeting is read, once an occurrence has finished — no cron job, and a meeting stays startable while it is running. A bounded series stops on its last occurrence and is marked completed. Also: the dashboard row carries a repeat badge with the dates left, the invite and update emails say how the meeting repeats, and the Google and .ics exports carry an RRULE so the calendar gets the series too. Co-Authored-By: Claude Opus 5 (1M context) --- apps/web/src/app/actions/meetings.ts | 35 +++ .../app/api/scheduled-sessions/[id]/route.ts | 56 ++++- .../app/api/scheduled-sessions/route.test.ts | 19 ++ .../src/app/api/scheduled-sessions/route.ts | 16 ++ .../components/ScheduleMeetingModal.test.tsx | 116 +++++++++- .../components/ScheduleMeetingModal.tsx | 136 +++++++++++- .../dashboard/components/UpcomingMeetings.tsx | 38 ++++ apps/web/src/lib/calendar.ts | 8 + .../src/lib/recurrence-rollforward.test.ts | 92 ++++++++ apps/web/src/lib/recurrence-rollforward.ts | 131 +++++++++++ apps/web/src/lib/recurrence.test.ts | 191 ++++++++++++++++ apps/web/src/lib/recurrence.ts | 210 ++++++++++++++++++ apps/web/src/lib/validations.ts | 27 +++ ...819120000_recurring_scheduled_sessions.sql | 42 ++++ 14 files changed, 1113 insertions(+), 4 deletions(-) create mode 100644 apps/web/src/lib/recurrence-rollforward.test.ts create mode 100644 apps/web/src/lib/recurrence-rollforward.ts create mode 100644 apps/web/src/lib/recurrence.test.ts create mode 100644 apps/web/src/lib/recurrence.ts create mode 100644 supabase/migrations/20260819120000_recurring_scheduled_sessions.sql diff --git a/apps/web/src/app/actions/meetings.ts b/apps/web/src/app/actions/meetings.ts index 37381ef4..ecb23989 100644 --- a/apps/web/src/app/actions/meetings.ts +++ b/apps/web/src/app/actions/meetings.ts @@ -2,6 +2,17 @@ 'use server'; import { createEmailer } from '@profullstack/emailer'; +import { describeRecurrence, type RecurrenceRule } from '@/lib/recurrence'; + +/** The "Repeats every week on Tuesday, forever" row, or '' for a one-off meeting. */ +function recurrenceRow(recurrence: RecurrenceRule | undefined, scheduledAt: string): string { + if (!recurrence?.freq) return ''; + const label = describeRecurrence(recurrence, new Date(scheduledAt)).replace(/^Repeats /, ''); + return ` + Repeats + ${label} + `; +} interface InvitePayload { scheduledSessionId: string; @@ -11,6 +22,8 @@ interface InvitePayload { durationMinutes: number; joinCode: string; hostName: string; + /** Set when the meeting repeats — the whole series shares this one invite. */ + recurrence?: RecurrenceRule; invitees: { email: string; name: string | null; token: string }[]; } @@ -32,6 +45,9 @@ interface UpdatePayload { // Set when someone was removed from the meeting: the join code was rotated, so the // code in this email replaces the one the recipient was originally sent. codeChanged?: boolean; + recurrence?: RecurrenceRule; + /** The host changed how (or whether) the meeting repeats. */ + recurrenceChanged?: boolean; } interface RemovalPayload { @@ -64,6 +80,7 @@ function inviteEmailHtml(opts: { rsvpAcceptUrl: string; rsvpDeclineUrl: string; joinUrl: string; + recurrence?: RecurrenceRule; }): string { const formattedDate = formatDateTime(opts.scheduledAt); const greeting = opts.inviteeName ? `Hi ${opts.inviteeName},` : 'Hi there,'; @@ -101,6 +118,7 @@ function inviteEmailHtml(opts: { Duration ${durationLabel} + ${recurrenceRow(opts.recurrence, opts.scheduledAt)} Host ${opts.hostName} @@ -174,6 +192,8 @@ function updateEmailHtml(opts: { hostName: string; joinUrl: string; codeChanged?: boolean; + recurrence?: RecurrenceRule; + recurrenceChanged?: boolean; }): string { const timeChanged = new Date(opts.scheduledAt).getTime() !== new Date(opts.previousScheduledAt).getTime(); @@ -218,6 +238,16 @@ function updateEmailHtml(opts: { Duration ${durationLabel} + ${ + opts.recurrence?.freq + ? recurrenceRow(opts.recurrence, opts.scheduledAt) + : opts.recurrenceChanged + ? ` + Repeats + No longer repeats + ` + : '' + } ${ opts.description ? ` @@ -301,6 +331,7 @@ export async function sendMeetingInvites( joinUrl: `${appUrl}/join/${payload.joinCode}`, rsvpAcceptUrl: `${rsvpBase}?rsvp=accepted`, rsvpDeclineUrl: `${rsvpBase}?rsvp=declined`, + ...(payload.recurrence !== undefined && { recurrence: payload.recurrence }), }); try { @@ -343,6 +374,10 @@ export async function sendMeetingUpdate( hostName: payload.hostName, joinUrl: `${appUrl}/join/${payload.joinCode}`, ...(payload.codeChanged !== undefined && { codeChanged: payload.codeChanged }), + ...(payload.recurrence !== undefined && { recurrence: payload.recurrence }), + ...(payload.recurrenceChanged !== undefined && { + recurrenceChanged: payload.recurrenceChanged, + }), }); try { diff --git a/apps/web/src/app/api/scheduled-sessions/[id]/route.ts b/apps/web/src/app/api/scheduled-sessions/[id]/route.ts index ac1555cc..708232eb 100644 --- a/apps/web/src/app/api/scheduled-sessions/[id]/route.ts +++ b/apps/web/src/app/api/scheduled-sessions/[id]/route.ts @@ -10,6 +10,7 @@ import { sendInviteeRemoval, } from '@/app/actions/meetings'; import { getUniqueJoinCode, liveSessionExistsForCode } from '@/lib/join-code'; +import { ruleFromRow, type RecurrenceRow } from '@/lib/recurrence'; import { randomBytes } from 'crypto'; // GET /api/scheduled-sessions/[id] @@ -48,12 +49,60 @@ function detailsChanged(before: any, after: any): boolean { if ((before.title as string) !== (after.title as string)) return true; if ((before.description ?? null) !== (after.description ?? null)) return true; if ((before.duration_minutes as number) !== (after.duration_minutes as number)) return true; + if (recurrenceChanged(before, after)) return true; return ( new Date(before.scheduled_at as string).getTime() !== new Date(after.scheduled_at as string).getTime() ); } +function recurrenceChanged(before: any, after: any): boolean { + const a = ruleFromRow(before as RecurrenceRow); + const b = ruleFromRow(after as RecurrenceRow); + return a.freq !== b.freq || a.interval !== b.interval || a.count !== b.count; +} + +/** + * Bookkeeping the recurrence columns need alongside an edit. + * + * Moving the meeting, or changing how often it repeats, re-bases the series: the + * new time becomes the anchor and the occurrence count starts again, so "repeat + * 8 times" means eight more from here. Changing only the count leaves the tally + * alone — except when the new limit is already used up, where the occurrence now + * on the books becomes the last one rather than being cancelled out from under + * the invitees. + */ +function recurrenceUpdate(existing: any, fields: Record): Record { + const before = ruleFromRow(existing as RecurrenceRow); + const after = ruleFromRow({ ...existing, ...fields } as RecurrenceRow); + + const movedTo = + typeof fields.scheduled_at === 'string' && + new Date(fields.scheduled_at).getTime() !== new Date(existing.scheduled_at as string).getTime() + ? fields.scheduled_at + : null; + + const rebased = + movedTo !== null || before.freq !== after.freq || before.interval !== after.interval; + + if (rebased) { + const anchor = movedTo ?? (existing.scheduled_at as string); + return { + recurrence_anchor_at: anchor, + occurrences_elapsed: 0, + // A finished series that is given a new time runs again. + ...(existing.status === 'completed' ? { status: 'pending' } : {}), + }; + } + + const elapsed = (existing.occurrences_elapsed as number | null) ?? 0; + if (after.count > 0 && elapsed >= after.count) { + return { occurrences_elapsed: after.count - 1 }; + } + + return {}; +} + // PATCH /api/scheduled-sessions/[id] — edit the meeting and/or its invitee list. // `inviteeEmails` is the complete desired list: addresses not already invited get an // invite email, addresses that disappear are removed and told so. @@ -83,10 +132,12 @@ export async function PATCH(request: Request, { params }: { params: Promise<{ id let updated = existing; + const recurrenceEdits = recurrenceUpdate(existing, fields); + if (Object.keys(fields).length > 0) { const { data, error } = await (svc as any) .from('scheduled_sessions') - .update({ ...fields, updated_at: new Date().toISOString() }) + .update({ ...fields, ...recurrenceEdits, updated_at: new Date().toISOString() }) .eq('id', id) .eq('host_user_id', user.id) .select() @@ -198,6 +249,7 @@ export async function PATCH(request: Request, { params }: { params: Promise<{ id durationMinutes: updated.duration_minutes as number, joinCode, hostName, + recurrence: ruleFromRow(updated as RecurrenceRow), invitees: added.map((i) => ({ email: i.email, name: i.name, token: i.invite_token })), }); if (!inviteResult.ok) console.error('Invite email error:', inviteResult.error); @@ -225,6 +277,8 @@ export async function PATCH(request: Request, { params }: { params: Promise<{ id durationMinutes: updated.duration_minutes as number, joinCode, hostName, + recurrence: ruleFromRow(updated as RecurrenceRow), + recurrenceChanged: recurrenceChanged(existing, updated), inviteeEmails: retained.map((i) => i.email), codeChanged: codeRotated, }); diff --git a/apps/web/src/app/api/scheduled-sessions/route.test.ts b/apps/web/src/app/api/scheduled-sessions/route.test.ts index 622c0a7d..824e9356 100644 --- a/apps/web/src/app/api/scheduled-sessions/route.test.ts +++ b/apps/web/src/app/api/scheduled-sessions/route.test.ts @@ -18,6 +18,12 @@ vi.mock('@/app/actions/meetings', () => ({ sendMeetingInvites: vi.fn(), })); +// Advancing recurring meetings has its own tests; here it only has to be called. +const mockRollForwardHostSeries = vi.fn(); +vi.mock('@/lib/recurrence-rollforward', () => ({ + rollForwardHostSeries: (...args: unknown[]) => mockRollForwardHostSeries(...args), +})); + interface ScheduledRow { id: string; scheduled_at: string; @@ -112,6 +118,19 @@ describe('GET /api/scheduled-sessions', () => { expect(lt).not.toHaveBeenCalled(); }); + it('advances the host recurring meetings before listing them', async () => { + setupList([row('future', '2026-08-14T18:00:00.000Z')]); + + const response = await GET( + new Request('http://localhost/api/scheduled-sessions?filter=upcoming') + ); + + expect(response.status).toBe(200); + // A lapsed occurrence sits in the past — exactly what "upcoming" filters out — + // so it has to be rolled forward before the query runs. + expect(mockRollForwardHostSeries).toHaveBeenCalledWith(expect.anything(), mockUser.id); + }); + it('keeps currently running meetings out of the past filter', async () => { const { gte, lt } = setupList([ row('expired', '2026-08-14T16:00:00.000Z'), diff --git a/apps/web/src/app/api/scheduled-sessions/route.ts b/apps/web/src/app/api/scheduled-sessions/route.ts index b1bfcdb6..99e93384 100644 --- a/apps/web/src/app/api/scheduled-sessions/route.ts +++ b/apps/web/src/app/api/scheduled-sessions/route.ts @@ -5,6 +5,8 @@ import { successResponse, errorResponse, handleApiError } from '@/lib/api'; import { scheduleMeetingSchema } from '@/lib/validations'; import { sendMeetingInvites } from '@/app/actions/meetings'; import { getUniqueJoinCode } from '@/lib/join-code'; +import { ruleFromRow, type RecurrenceRow } from '@/lib/recurrence'; +import { rollForwardHostSeries } from '@/lib/recurrence-rollforward'; import { randomBytes } from 'crypto'; import { earliestPossibleCurrentMeetingStart, @@ -57,6 +59,13 @@ export async function POST(request: Request) { scheduled_at: input.scheduledAt, duration_minutes: input.durationMinutes, join_code: joinCode, + // A recurring meeting is one row: scheduled_at tracks the next occurrence + // and the anchor keeps the day of the month stable for monthly series. + recurrence_freq: input.recurrenceFreq ?? null, + recurrence_interval: input.recurrenceFreq ? (input.recurrenceInterval ?? 1) : 1, + recurrence_count: input.recurrenceFreq ? (input.recurrenceCount ?? 0) : 0, + occurrences_elapsed: 0, + recurrence_anchor_at: input.scheduledAt, }) .select() .single(); @@ -100,6 +109,7 @@ export async function POST(request: Request) { durationMinutes: input.durationMinutes, joinCode, hostName, + recurrence: ruleFromRow(scheduled as RecurrenceRow), invitees: invitees.map((i) => ({ email: i.email, name: i.name, token: i.invite_token })), }); if (!emailResult.ok) { @@ -125,6 +135,12 @@ export async function GET(request: Request) { if (authError || !user) return errorResponse('Authentication required', 401); const svc = serviceClient(); + + // A recurring meeting whose occurrence has finished sits in the past until it + // is rolled forward — exactly what an "upcoming" query filters out — so + // advance this host's series before reading. + await rollForwardHostSeries(svc, user.id); + const nowMs = Date.now(); const now = new Date(nowMs).toISOString(); diff --git a/apps/web/src/app/dashboard/components/ScheduleMeetingModal.test.tsx b/apps/web/src/app/dashboard/components/ScheduleMeetingModal.test.tsx index cfaee000..98c32db1 100644 --- a/apps/web/src/app/dashboard/components/ScheduleMeetingModal.test.tsx +++ b/apps/web/src/app/dashboard/components/ScheduleMeetingModal.test.tsx @@ -53,7 +53,7 @@ describe('ScheduleMeetingModal — edit mode', () => { /> ); - expect(screen.getByRole('combobox')).toHaveValue('75'); + expect(screen.getByLabelText(/duration/i)).toHaveValue('75'); }); it('PATCHes the meeting, preserving the scheduled instant', async () => { @@ -189,4 +189,118 @@ describe('ScheduleMeetingModal — create mode', () => { expect(init.method).toBe('POST'); expect(lastRequestBody(fetchMock).inviteeEmails).toBeUndefined(); }); + + it('sends no recurrence for a one-off meeting', async () => { + const user = userEvent.setup(); + const fetchMock = mockFetchOk(); + + render(); + await user.type(screen.getByPlaceholderText('e.g. Weekly Team Sync'), 'One Off'); + await user.click(screen.getByRole('button', { name: /schedule meeting/i })); + + await waitFor(() => { + expect(fetchMock).toHaveBeenCalled(); + }); + + const body = lastRequestBody(fetchMock); + expect(body.recurrenceFreq).toBeUndefined(); + expect(body.recurrenceInterval).toBeUndefined(); + }); + + it('hides the repeat detail fields until a frequency is chosen', async () => { + const user = userEvent.setup(); + mockFetchOk(); + + render(); + expect(screen.queryByLabelText(/number of times/i)).not.toBeInTheDocument(); + + await user.selectOptions(screen.getByLabelText(/repeat/i), 'weekly'); + expect(screen.getByLabelText(/number of times/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/^every$/i)).toHaveValue(1); + }); + + it('POSTs the chosen recurrence', async () => { + const user = userEvent.setup(); + const fetchMock = mockFetchOk(); + + render(); + await user.type(screen.getByPlaceholderText('e.g. Weekly Team Sync'), 'Standup'); + await user.selectOptions(screen.getByLabelText(/repeat/i), 'daily'); + + const interval = screen.getByLabelText(/^every$/i); + await user.clear(interval); + await user.type(interval, '2'); + + const count = screen.getByLabelText(/number of times/i); + await user.clear(count); + await user.type(count, '10'); + + await user.click(screen.getByRole('button', { name: /schedule meeting/i })); + await waitFor(() => { + expect(fetchMock).toHaveBeenCalled(); + }); + + const body = lastRequestBody(fetchMock); + expect(body.recurrenceFreq).toBe('daily'); + expect(body.recurrenceInterval).toBe(2); + expect(body.recurrenceCount).toBe(10); + }); + + it('treats a count of 0 as repeating forever', async () => { + const user = userEvent.setup(); + const fetchMock = mockFetchOk(); + + render(); + await user.type(screen.getByPlaceholderText('e.g. Weekly Team Sync'), 'Forever'); + await user.selectOptions(screen.getByLabelText(/repeat/i), 'weekly'); + + expect(screen.getByLabelText(/number of times/i)).toHaveValue(0); + expect(screen.getByText('0 = forever')).toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: /schedule meeting/i })); + await waitFor(() => { + expect(fetchMock).toHaveBeenCalled(); + }); + + expect(lastRequestBody(fetchMock).recurrenceCount).toBe(0); + }); +}); + +describe('ScheduleMeetingModal — editing a series', () => { + const series: EditableMeeting = { + ...meeting, + recurrence_freq: 'weekly', + recurrence_interval: 2, + recurrence_count: 8, + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('prefills the existing recurrence', () => { + mockFetchOk(); + render(); + + expect(screen.getByLabelText(/repeat/i)).toHaveValue('weekly'); + expect(screen.getByLabelText(/^every$/i)).toHaveValue(2); + expect(screen.getByLabelText(/number of times/i)).toHaveValue(8); + }); + + it('sends a null frequency when the series is turned off, so it actually clears', async () => { + const user = userEvent.setup(); + const fetchMock = mockFetchOk(); + + render(); + await user.selectOptions(screen.getByLabelText(/repeat/i), ''); + await user.click(screen.getByRole('button', { name: /save changes/i })); + + await waitFor(() => { + expect(fetchMock).toHaveBeenCalled(); + }); + + const body = lastRequestBody(fetchMock); + expect(body.recurrenceFreq).toBeNull(); + expect('recurrenceInterval' in body).toBe(false); + }); }); diff --git a/apps/web/src/app/dashboard/components/ScheduleMeetingModal.tsx b/apps/web/src/app/dashboard/components/ScheduleMeetingModal.tsx index 724b4d4b..457900c2 100644 --- a/apps/web/src/app/dashboard/components/ScheduleMeetingModal.tsx +++ b/apps/web/src/app/dashboard/components/ScheduleMeetingModal.tsx @@ -1,7 +1,14 @@ 'use client'; import { useState, useRef, type FormEvent } from 'react'; -import { X, Calendar, Clock, Users, Plus, Trash2, Loader2 } from 'lucide-react'; +import { X, Calendar, Clock, Users, Plus, Trash2, Loader2, Repeat } from 'lucide-react'; +import { + describeRecurrence, + MAX_RECURRENCE_COUNT, + MAX_RECURRENCE_INTERVAL, + ruleFromRow, + type RecurrenceFreq, +} from '@/lib/recurrence'; /** The subset of a scheduled session the modal needs in order to edit it. */ export interface EditableMeeting { @@ -11,6 +18,9 @@ export interface EditableMeeting { scheduled_at: string; duration_minutes: number; invitees?: { email: string }[]; + recurrence_freq?: string | null; + recurrence_interval?: number | null; + recurrence_count?: number | null; } interface Props { @@ -39,6 +49,25 @@ function localDatetimeValue(date: Date): string { return `${String(date.getFullYear())}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`; } +const REPEAT_OPTIONS: { label: string; value: RecurrenceFreq | '' }[] = [ + { label: "Doesn't repeat", value: '' }, + { label: 'Daily', value: 'daily' }, + { label: 'Weekly', value: 'weekly' }, + { label: 'Monthly', value: 'monthly' }, +]; + +const REPEAT_UNIT: Record = { + daily: 'days', + weekly: 'weeks', + monthly: 'months', +}; + +/** Number inputs hand back NaN when cleared — keep the payload in range regardless. */ +function clamp(value: number, min: number, max: number): number { + if (!Number.isFinite(value)) return min; + return Math.min(max, Math.max(min, Math.trunc(value))); +} + function durationLabel(minutes: number): string { return minutes < 60 ? `${String(minutes)} minutes` @@ -57,6 +86,13 @@ export function ScheduleMeetingModal({ onClose, onSaved, meeting }: Props) { localDatetimeValue(meeting ? new Date(meeting.scheduled_at) : defaultTime) ); const [durationMinutes, setDurationMinutes] = useState(meeting?.duration_minutes ?? 60); + + const existingRule = ruleFromRow(meeting ?? {}); + const [repeatFreq, setRepeatFreq] = useState(existingRule.freq ?? ''); + const [repeatInterval, setRepeatInterval] = useState(existingRule.interval); + // 0 means "keep repeating forever" — the same convention the API stores. + const [repeatCount, setRepeatCount] = useState(existingRule.count); + const [emails, setEmails] = useState(() => { const existing = meeting?.invitees?.map((i) => i.email) ?? []; return existing.length > 0 ? existing : ['']; @@ -128,6 +164,15 @@ export function ScheduleMeetingModal({ onClose, onSaved, meeting }: Props) { description: isEditing ? description.trim() : description.trim() || undefined, scheduledAt: localDate.toISOString(), durationMinutes, + // On edit, send the frequency even when it is null so switching a + // meeting back to a one-off actually clears the series. + ...(repeatFreq || isEditing ? { recurrenceFreq: repeatFreq || null } : {}), + ...(repeatFreq + ? { + recurrenceInterval: clamp(repeatInterval, 1, MAX_RECURRENCE_INTERVAL), + recurrenceCount: clamp(repeatCount, 0, MAX_RECURRENCE_COUNT), + } + : {}), inviteeEmails: isEditing ? validEmails : validEmails.length > 0 @@ -217,11 +262,15 @@ export function ScheduleMeetingModal({ onClose, onSaved, meeting }: Props) { />
-
+ {/* Description */}