diff --git a/apps/web/src/app/actions/meetings.ts b/apps/web/src/app/actions/meetings.ts index 37381ef..ecb2398 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 ac1555c..708232e 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 622c0a7..824e935 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 b1bfcdb..99e9338 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 cfaee00..98c32db 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 724b4d4..457900c 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 */}