(() => {
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) {
/>
-
+ {/* Repeat */}
+
+
+
+ Repeat
+
+
+
+ {repeatFreq && (
+ <>
+
+
+
+ Every
+
+
+ {
+ setRepeatInterval(Number(e.target.value));
+ }}
+ className="w-20 rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 focus:outline-none"
+ />
+ {REPEAT_UNIT[repeatFreq]}
+
+
+
+
+ Number of times
+
+
+ {
+ setRepeatCount(Number(e.target.value));
+ }}
+ className="w-20 rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 focus:outline-none"
+ />
+ 0 = forever
+
+
+
+
+ {describeRecurrence(
+ { freq: repeatFreq, interval: repeatInterval, count: repeatCount },
+ new Date(scheduledAt)
+ )}
+ . Everyone keeps the same join code and link.
+
+ >
+ )}
+
+
{/* Description */}
diff --git a/apps/web/src/app/dashboard/components/UpcomingMeetings.tsx b/apps/web/src/app/dashboard/components/UpcomingMeetings.tsx
index e9ef736..cf91bd3 100644
--- a/apps/web/src/app/dashboard/components/UpcomingMeetings.tsx
+++ b/apps/web/src/app/dashboard/components/UpcomingMeetings.tsx
@@ -12,9 +12,16 @@ import {
Loader2,
CalendarPlus,
CheckCircle,
+ Repeat,
} from 'lucide-react';
import { buildGoogleCalendarUrl, buildOutlookUrl, downloadIcs } from '@/lib/calendar';
import { ScheduleMeetingModal } from './ScheduleMeetingModal';
+import {
+ describeRecurrence,
+ occurrencesRemaining,
+ ruleFromRow,
+ shortRecurrenceLabel,
+} from '@/lib/recurrence';
import {
isScheduledMeetingCurrent,
isScheduledMeetingStartable,
@@ -37,6 +44,10 @@ interface ScheduledSession {
status: string;
invitee_count: number;
invitees: Invitee[];
+ recurrence_freq?: string | null;
+ recurrence_interval?: number | null;
+ recurrence_count?: number | null;
+ occurrences_elapsed?: number | null;
}
interface ListResponse {
@@ -74,6 +85,18 @@ function formatDate(isoString: string, nowMs: number): string {
});
}
+/** "Weekly", or "Weekly · 3 left" once a series has a finite number of dates left. */
+function repeatLabel(session: ScheduledSession): string | null {
+ const short = shortRecurrenceLabel(ruleFromRow(session));
+ if (!short) return null;
+
+ const left = occurrencesRemaining(
+ session.recurrence_count ?? 0,
+ session.occurrences_elapsed ?? 0
+ );
+ return left === null ? short : `${short} · ${String(left)} left`;
+}
+
interface Props {
onSchedule: () => void;
}
@@ -242,6 +265,18 @@ export function UpcomingMeetings({ onSchedule }: Props) {
? `${String(session.duration_minutes)}m`
: `${String(session.duration_minutes / 60)}h`}
+ {repeatLabel(session) && (
+
+
+ {repeatLabel(session)}
+
+ )}
{session.invitee_count > 0 && (
@@ -300,6 +335,7 @@ export function UpcomingMeetings({ onSchedule }: Props) {
startIso: session.scheduled_at,
durationMinutes: session.duration_minutes,
joinUrl: `${window.location.origin}/join/${session.join_code}`,
+ recurrence: ruleFromRow(session),
})}
target="_blank"
rel="noopener noreferrer"
@@ -318,6 +354,7 @@ export function UpcomingMeetings({ onSchedule }: Props) {
startIso: session.scheduled_at,
durationMinutes: session.duration_minutes,
joinUrl: `${window.location.origin}/join/${session.join_code}`,
+ recurrence: ruleFromRow(session),
})}
target="_blank"
rel="noopener noreferrer"
@@ -338,6 +375,7 @@ export function UpcomingMeetings({ onSchedule }: Props) {
startIso: session.scheduled_at,
durationMinutes: session.duration_minutes,
joinUrl: `${window.location.origin}/join/${session.join_code}`,
+ recurrence: ruleFromRow(session),
});
setCalendarOpenId(null);
}}
diff --git a/apps/web/src/lib/calendar.ts b/apps/web/src/lib/calendar.ts
index 4535a29..b1f1ce0 100644
--- a/apps/web/src/lib/calendar.ts
+++ b/apps/web/src/lib/calendar.ts
@@ -1,9 +1,13 @@
+import { buildRrule, type RecurrenceRule } from './recurrence';
+
export interface CalendarEvent {
title: string;
description: string | null;
startIso: string;
durationMinutes: number;
joinUrl: string;
+ /** Set for a repeating meeting so the event lands as a series, not one date. */
+ recurrence?: RecurrenceRule | undefined;
}
function fmtIcs(d: Date): string {
@@ -24,6 +28,8 @@ export function buildGoogleCalendarUrl(event: CalendarEvent): string {
details,
location: event.joinUrl,
});
+ const rrule = event.recurrence ? buildRrule(event.recurrence) : null;
+ if (rrule) params.set('recur', `RRULE:${rrule}`);
return `https://calendar.google.com/calendar/render?${params.toString()}`;
}
@@ -45,6 +51,7 @@ export function downloadIcs(event: CalendarEvent): void {
const start = new Date(event.startIso);
const end = new Date(start.getTime() + event.durationMinutes * 60000);
const desc = [event.description, `Join at: ${event.joinUrl}`].filter(Boolean).join('\\n\\n');
+ const rrule = event.recurrence ? buildRrule(event.recurrence) : null;
const ics = [
'BEGIN:VCALENDAR',
'VERSION:2.0',
@@ -52,6 +59,7 @@ export function downloadIcs(event: CalendarEvent): void {
'BEGIN:VEVENT',
`DTSTART:${fmtIcs(start)}`,
`DTEND:${fmtIcs(end)}`,
+ ...(rrule ? [`RRULE:${rrule}`] : []),
`SUMMARY:${event.title}`,
`DESCRIPTION:${desc}`,
`LOCATION:${event.joinUrl}`,
diff --git a/apps/web/src/lib/recurrence-rollforward.test.ts b/apps/web/src/lib/recurrence-rollforward.test.ts
new file mode 100644
index 0000000..d8eb660
--- /dev/null
+++ b/apps/web/src/lib/recurrence-rollforward.test.ts
@@ -0,0 +1,92 @@
+import { describe, it, expect, vi } from 'vitest';
+import { rollForwardRow, rollForwardRows, type RecurringRow } from './recurrence-rollforward';
+
+function row(overrides: Partial = {}): RecurringRow {
+ return {
+ id: 'meeting-1',
+ scheduled_at: '2026-08-19T09:00:00.000Z',
+ duration_minutes: 60,
+ recurrence_freq: 'weekly',
+ recurrence_interval: 1,
+ recurrence_count: 0,
+ occurrences_elapsed: 0,
+ recurrence_anchor_at: '2026-08-19T09:00:00.000Z',
+ status: 'pending',
+ ...overrides,
+ };
+}
+
+describe('rollForwardRow', () => {
+ it('leaves a one-off meeting alone however far in the past it is', () => {
+ const original = row({ recurrence_freq: null });
+ const result = rollForwardRow(original, new Date('2027-01-01T00:00:00.000Z'));
+ expect(result.changed).toBe(false);
+ expect(result.row).toBe(original);
+ });
+
+ it('leaves a cancelled series alone', () => {
+ const result = rollForwardRow(
+ row({ status: 'cancelled' }),
+ new Date('2026-09-30T00:00:00.000Z')
+ );
+ expect(result.changed).toBe(false);
+ });
+
+ it('moves a lapsed occurrence to the next one', () => {
+ const result = rollForwardRow(row(), new Date('2026-08-19T10:30:00.000Z'));
+ expect(result.changed).toBe(true);
+ expect(new Date(result.row.scheduled_at).toISOString()).toBe('2026-08-26T09:00:00.000Z');
+ expect(result.row.occurrences_elapsed).toBe(1);
+ expect(result.row.status).toBe('pending');
+ });
+
+ it('marks a bounded series completed once it runs out', () => {
+ const result = rollForwardRow(
+ row({ recurrence_count: 2 }),
+ new Date('2026-10-01T00:00:00.000Z')
+ );
+ expect(result.row.status).toBe('completed');
+ expect(result.row.occurrences_elapsed).toBe(2);
+ expect(new Date(result.row.scheduled_at).toISOString()).toBe('2026-08-26T09:00:00.000Z');
+ });
+
+ it('does not touch a meeting that is currently running', () => {
+ const result = rollForwardRow(row(), new Date('2026-08-19T09:45:00.000Z'));
+ expect(result.changed).toBe(false);
+ });
+});
+
+describe('rollForwardRows', () => {
+ it('persists only the rows that moved', async () => {
+ const eq = vi.fn().mockResolvedValue({ error: null });
+ const update = vi.fn().mockReturnValue({ eq });
+ const svc = { from: vi.fn().mockReturnValue({ update }) };
+
+ const rows = [
+ row({ id: 'moved' }),
+ row({ id: 'future', scheduled_at: '2027-01-01T09:00:00.000Z' }),
+ ];
+
+ const result = await rollForwardRows(svc, rows, new Date('2026-08-19T10:30:00.000Z'));
+
+ expect(update).toHaveBeenCalledTimes(1);
+ expect(eq).toHaveBeenCalledWith('id', 'moved');
+ expect(update.mock.calls[0]?.[0]).toMatchObject({
+ scheduled_at: '2026-08-26T09:00:00.000Z',
+ occurrences_elapsed: 1,
+ });
+ // Callers get current times back even for the untouched row.
+ expect(result[1]?.scheduled_at).toBe('2027-01-01T09:00:00.000Z');
+ });
+
+ it('still returns rolled-forward rows when the write fails', async () => {
+ const eq = vi.fn().mockResolvedValue({ error: { message: 'nope' } });
+ const svc = { from: vi.fn().mockReturnValue({ update: vi.fn().mockReturnValue({ eq }) }) };
+ const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined);
+
+ const result = await rollForwardRows(svc, [row()], new Date('2026-08-19T10:30:00.000Z'));
+
+ expect(result[0]?.scheduled_at).toBe('2026-08-26T09:00:00.000Z');
+ consoleError.mockRestore();
+ });
+});
diff --git a/apps/web/src/lib/recurrence-rollforward.ts b/apps/web/src/lib/recurrence-rollforward.ts
new file mode 100644
index 0000000..7f58894
--- /dev/null
+++ b/apps/web/src/lib/recurrence-rollforward.ts
@@ -0,0 +1,131 @@
+/* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any */
+import { advanceSeries, RECURRENCE_FREQUENCIES, type RecurrenceFreq } from './recurrence';
+
+/**
+ * Lazy roll-forward for recurring meetings.
+ *
+ * Nothing runs on a schedule: whenever a recurring meeting is read — the
+ * dashboard list, a join-code lookup — any occurrence that has already finished
+ * is counted and `scheduled_at` moved to the next one. A series with a finite
+ * count stops on its last occurrence and is marked `completed`.
+ */
+
+export interface RecurringRow {
+ id: string;
+ scheduled_at: string;
+ duration_minutes: number;
+ recurrence_freq: string | null;
+ recurrence_interval: number | null;
+ recurrence_count: number | null;
+ occurrences_elapsed: number | null;
+ recurrence_anchor_at: string | null;
+ status?: string;
+}
+
+export interface RollForwardResult {
+ row: T;
+ changed: boolean;
+}
+
+/** Compute — without writing — where a row should sit now. */
+export function rollForwardRow(row: T, now: Date): RollForwardResult {
+ const freq = row.recurrence_freq;
+ if (!RECURRENCE_FREQUENCIES.includes(freq as RecurrenceFreq)) return { row, changed: false };
+ if (row.status === 'cancelled' || row.status === 'completed') return { row, changed: false };
+
+ const scheduledAt = new Date(row.scheduled_at);
+ if (isNaN(scheduledAt.getTime())) return { row, changed: false };
+
+ const anchorAt = row.recurrence_anchor_at ? new Date(row.recurrence_anchor_at) : scheduledAt;
+
+ const advance = advanceSeries(
+ {
+ scheduledAt,
+ durationMinutes: row.duration_minutes,
+ anchorAt: isNaN(anchorAt.getTime()) ? scheduledAt : anchorAt,
+ freq: freq as RecurrenceFreq,
+ interval: row.recurrence_interval ?? 1,
+ count: row.recurrence_count ?? 0,
+ elapsed: row.occurrences_elapsed ?? 0,
+ },
+ now
+ );
+
+ if (!advance) return { row, changed: false };
+
+ return {
+ row: {
+ ...row,
+ scheduled_at: advance.scheduledAt.toISOString(),
+ occurrences_elapsed: advance.elapsed,
+ ...(advance.completed ? { status: 'completed' } : {}),
+ },
+ changed: true,
+ };
+}
+
+/**
+ * Roll every row forward and persist the ones that moved. Returns the updated
+ * rows so callers can respond with current data without re-reading.
+ *
+ * A failed write is logged and swallowed: the caller still gets the correct
+ * times, and the next read will try again.
+ */
+export async function rollForwardRows(
+ svc: any,
+ rows: T[],
+ now: Date = new Date()
+): Promise {
+ const results = rows.map((row) => rollForwardRow(row, now));
+ const moved = results.filter((r) => r.changed).map((r) => r.row);
+
+ await Promise.all(
+ moved.map(async (row) => {
+ const { error } = await svc
+ .from('scheduled_sessions')
+ .update({
+ scheduled_at: row.scheduled_at,
+ occurrences_elapsed: row.occurrences_elapsed,
+ ...(row.status === 'completed' ? { status: 'completed' } : {}),
+ updated_at: new Date().toISOString(),
+ })
+ .eq('id', row.id);
+
+ if (error) console.error('Recurrence roll-forward error:', row.id, error);
+ })
+ );
+
+ return results.map((r) => r.row);
+}
+
+/**
+ * Roll forward every recurring meeting a host owns whose next occurrence is in
+ * the past. Called before listing, because a lapsed occurrence is exactly the
+ * one an "upcoming" query filters out.
+ */
+export async function rollForwardHostSeries(svc: any, hostUserId: string): Promise {
+ const now = new Date();
+
+ // Housekeeping must never be the reason a read fails, so anything that goes
+ // wrong here is logged and the caller carries on with the times it has.
+ try {
+ const { data, error } = await svc
+ .from('scheduled_sessions')
+ .select(
+ 'id, scheduled_at, duration_minutes, recurrence_freq, recurrence_interval, recurrence_count, occurrences_elapsed, recurrence_anchor_at, status'
+ )
+ .eq('host_user_id', hostUserId)
+ .eq('status', 'pending')
+ .not('recurrence_freq', 'is', null)
+ .lt('scheduled_at', now.toISOString());
+
+ if (error) {
+ console.error('Recurrence roll-forward lookup error:', error);
+ return;
+ }
+
+ await rollForwardRows(svc, (data ?? []) as RecurringRow[], now);
+ } catch (err) {
+ console.error('Recurrence roll-forward failed:', err);
+ }
+}
diff --git a/apps/web/src/lib/recurrence.test.ts b/apps/web/src/lib/recurrence.test.ts
new file mode 100644
index 0000000..043e5fb
--- /dev/null
+++ b/apps/web/src/lib/recurrence.test.ts
@@ -0,0 +1,191 @@
+import { describe, it, expect } from 'vitest';
+import {
+ advanceSeries,
+ buildRrule,
+ describeRecurrence,
+ nextOccurrence,
+ occurrencesRemaining,
+ ruleFromRow,
+ shortRecurrenceLabel,
+} from './recurrence';
+
+// Local-time constructor: recurrence deliberately works in wall-clock terms.
+function at(y: number, m: number, d: number, h = 9, min = 0): Date {
+ return new Date(y, m - 1, d, h, min, 0, 0);
+}
+
+describe('nextOccurrence', () => {
+ it('steps daily and weekly by whole days', () => {
+ expect(nextOccurrence(at(2026, 8, 19), 'daily', 1)).toEqual(at(2026, 8, 20));
+ expect(nextOccurrence(at(2026, 8, 19), 'daily', 3)).toEqual(at(2026, 8, 22));
+ expect(nextOccurrence(at(2026, 8, 19), 'weekly', 1)).toEqual(at(2026, 8, 26));
+ expect(nextOccurrence(at(2026, 8, 19), 'weekly', 2)).toEqual(at(2026, 9, 2));
+ });
+
+ it('keeps the time of day', () => {
+ expect(nextOccurrence(at(2026, 8, 19, 14, 30), 'weekly', 1)).toEqual(at(2026, 8, 26, 14, 30));
+ });
+
+ it('keeps the weekday for weekly series', () => {
+ const start = at(2026, 8, 19); // Wednesday
+ const next = nextOccurrence(start, 'weekly', 1);
+ expect(next.getDay()).toBe(start.getDay());
+ });
+
+ it('steps monthly on the same day of the month', () => {
+ expect(nextOccurrence(at(2026, 1, 15), 'monthly', 1)).toEqual(at(2026, 2, 15));
+ expect(nextOccurrence(at(2026, 1, 15), 'monthly', 3)).toEqual(at(2026, 4, 15));
+ });
+
+ it('clamps a monthly series into short months without losing its day', () => {
+ const anchorDay = 31;
+ const feb = nextOccurrence(at(2026, 1, 31), 'monthly', 1, anchorDay);
+ expect(feb).toEqual(at(2026, 2, 28));
+
+ // The next step comes back to the 31st rather than sticking at the 28th.
+ const mar = nextOccurrence(feb, 'monthly', 1, anchorDay);
+ expect(mar).toEqual(at(2026, 3, 31));
+ });
+});
+
+describe('advanceSeries', () => {
+ const base = {
+ durationMinutes: 60,
+ anchorAt: at(2026, 8, 19),
+ freq: 'weekly' as const,
+ interval: 1,
+ count: 0,
+ elapsed: 0,
+ };
+
+ it('leaves an occurrence that has not started alone', () => {
+ const result = advanceSeries({ ...base, scheduledAt: at(2026, 8, 19) }, at(2026, 8, 18));
+ expect(result).toBeNull();
+ });
+
+ it('leaves an occurrence that is under way alone', () => {
+ const result = advanceSeries(
+ { ...base, scheduledAt: at(2026, 8, 19, 9, 0) },
+ at(2026, 8, 19, 9, 30)
+ );
+ expect(result).toBeNull();
+ });
+
+ it('advances once the occurrence has finished', () => {
+ const result = advanceSeries(
+ { ...base, scheduledAt: at(2026, 8, 19, 9, 0) },
+ at(2026, 8, 19, 10, 1)
+ );
+ expect(result).toEqual({ scheduledAt: at(2026, 8, 26, 9, 0), elapsed: 1, completed: false });
+ });
+
+ it('skips every occurrence missed while nobody looked', () => {
+ const result = advanceSeries({ ...base, scheduledAt: at(2026, 8, 19) }, at(2026, 9, 10));
+ expect(result?.scheduledAt).toEqual(at(2026, 9, 16));
+ expect(result?.elapsed).toBe(4);
+ expect(result?.completed).toBe(false);
+ });
+
+ it('stops on the last occurrence of a bounded series', () => {
+ const result = advanceSeries(
+ { ...base, count: 3, scheduledAt: at(2026, 8, 19) },
+ at(2026, 12, 1)
+ );
+ // Occurrences are the 19th, 26th and 2 Sept; the series ends on that third one.
+ expect(result).toEqual({ scheduledAt: at(2026, 9, 2), elapsed: 3, completed: true });
+ });
+
+ it('never completes an unlimited series', () => {
+ const result = advanceSeries(
+ { ...base, count: 0, scheduledAt: at(2026, 8, 19) },
+ at(2027, 8, 19)
+ );
+ expect(result?.completed).toBe(false);
+ });
+
+ it('counts an occurrence as elapsed only after its full duration', () => {
+ const result = advanceSeries(
+ { ...base, durationMinutes: 120, scheduledAt: at(2026, 8, 19, 9, 0) },
+ at(2026, 8, 19, 10, 30)
+ );
+ expect(result).toBeNull();
+ });
+});
+
+describe('occurrencesRemaining', () => {
+ it('reports null for an unlimited series', () => {
+ expect(occurrencesRemaining(0, 12)).toBeNull();
+ });
+
+ it('counts down a bounded series and never goes negative', () => {
+ expect(occurrencesRemaining(8, 3)).toBe(5);
+ expect(occurrencesRemaining(8, 9)).toBe(0);
+ });
+});
+
+describe('describeRecurrence', () => {
+ it('describes a one-off meeting', () => {
+ expect(describeRecurrence({ freq: null, interval: 1, count: 0 })).toBe('Does not repeat');
+ });
+
+ it('names the weekday for a weekly series and says how many times', () => {
+ expect(describeRecurrence({ freq: 'weekly', interval: 1, count: 8 }, at(2026, 8, 19))).toBe(
+ 'Repeats every week on Wednesday, 8 times'
+ );
+ });
+
+ it('says forever when the count is zero', () => {
+ expect(describeRecurrence({ freq: 'daily', interval: 2, count: 0 })).toBe(
+ 'Repeats every 2 days, forever'
+ );
+ });
+
+ it('names the day of the month for a monthly series', () => {
+ expect(describeRecurrence({ freq: 'monthly', interval: 1, count: 1 }, at(2026, 8, 19))).toBe(
+ 'Repeats every month on day 19, 1 time'
+ );
+ });
+});
+
+describe('shortRecurrenceLabel', () => {
+ it('is null for a one-off', () => {
+ expect(shortRecurrenceLabel({ freq: null, interval: 1, count: 0 })).toBeNull();
+ });
+
+ it('uses the plain adverb at interval 1', () => {
+ expect(shortRecurrenceLabel({ freq: 'weekly', interval: 1, count: 0 })).toBe('Weekly');
+ });
+
+ it('spells out longer intervals', () => {
+ expect(shortRecurrenceLabel({ freq: 'weekly', interval: 3, count: 0 })).toBe('Every 3 weeks');
+ });
+});
+
+describe('buildRrule', () => {
+ it('is null without a frequency', () => {
+ expect(buildRrule({ freq: null, interval: 1, count: 0 })).toBeNull();
+ });
+
+ it('omits INTERVAL and COUNT at their defaults', () => {
+ expect(buildRrule({ freq: 'weekly', interval: 1, count: 0 })).toBe('FREQ=WEEKLY');
+ });
+
+ it('includes INTERVAL and COUNT when set', () => {
+ expect(buildRrule({ freq: 'monthly', interval: 2, count: 6 })).toBe(
+ 'FREQ=MONTHLY;INTERVAL=2;COUNT=6'
+ );
+ });
+});
+
+describe('ruleFromRow', () => {
+ it('treats a missing or unknown frequency as a one-off', () => {
+ expect(ruleFromRow({})).toEqual({ freq: null, interval: 1, count: 0 });
+ expect(ruleFromRow({ recurrence_freq: 'yearly' }).freq).toBeNull();
+ });
+
+ it('reads the stored rule', () => {
+ expect(
+ ruleFromRow({ recurrence_freq: 'daily', recurrence_interval: 2, recurrence_count: 5 })
+ ).toEqual({ freq: 'daily', interval: 2, count: 5 });
+ });
+});
diff --git a/apps/web/src/lib/recurrence.ts b/apps/web/src/lib/recurrence.ts
new file mode 100644
index 0000000..e2b7101
--- /dev/null
+++ b/apps/web/src/lib/recurrence.ts
@@ -0,0 +1,210 @@
+/**
+ * Recurring scheduled meetings.
+ *
+ * A recurring meeting is a single `scheduled_sessions` row, not one row per
+ * occurrence: it keeps one join code, one invitee list and one set of emails for
+ * the whole series. `scheduled_at` always points at the *next* occurrence and is
+ * rolled forward lazily (see `lib/recurrence-rollforward.ts`) once an occurrence
+ * has finished, so no cron job is needed.
+ */
+
+export type RecurrenceFreq = 'daily' | 'weekly' | 'monthly';
+
+export const RECURRENCE_FREQUENCIES: RecurrenceFreq[] = ['daily', 'weekly', 'monthly'];
+
+export const MAX_RECURRENCE_INTERVAL = 30;
+export const MAX_RECURRENCE_COUNT = 365;
+
+export interface RecurrenceRule {
+ /** null means the meeting happens once. */
+ freq: RecurrenceFreq | null;
+ /** Repeat every N days/weeks/months. */
+ interval: number;
+ /** Total number of occurrences; 0 means it repeats forever. */
+ count: number;
+}
+
+export const NO_RECURRENCE: RecurrenceRule = { freq: null, interval: 1, count: 0 };
+
+const UNIT_LABEL: Record = {
+ daily: 'day',
+ weekly: 'week',
+ monthly: 'month',
+};
+
+const RRULE_FREQ: Record = {
+ daily: 'DAILY',
+ weekly: 'WEEKLY',
+ monthly: 'MONTHLY',
+};
+
+function daysInMonth(year: number, monthIndex: number): number {
+ return new Date(year, monthIndex + 1, 0).getDate();
+}
+
+/**
+ * The occurrence after `from`.
+ *
+ * Arithmetic is deliberately done in local (wall clock) time so a 9am standup
+ * stays at 9am across a daylight-saving change. `anchorDayOfMonth` is the day of
+ * the month the series was booked on: monthly meetings step from that day and
+ * clamp to the length of the target month, so a series booked on the 31st lands
+ * on the 28th of February without permanently losing its day.
+ */
+export function nextOccurrence(
+ from: Date,
+ freq: RecurrenceFreq,
+ interval: number,
+ anchorDayOfMonth?: number
+): Date {
+ const step = Math.max(1, Math.trunc(interval));
+ const next = new Date(from.getTime());
+
+ if (freq === 'daily') {
+ next.setDate(next.getDate() + step);
+ return next;
+ }
+
+ if (freq === 'weekly') {
+ next.setDate(next.getDate() + step * 7);
+ return next;
+ }
+
+ const targetDay = anchorDayOfMonth ?? from.getDate();
+ // Move to the 1st first: setMonth() on the 31st would otherwise overflow into
+ // the month after next.
+ next.setDate(1);
+ next.setMonth(next.getMonth() + step);
+ next.setDate(Math.min(targetDay, daysInMonth(next.getFullYear(), next.getMonth())));
+ return next;
+}
+
+export interface SeriesState {
+ scheduledAt: Date;
+ durationMinutes: number;
+ /** The first occurrence — fixes the day of the month for monthly series. */
+ anchorAt: Date;
+ freq: RecurrenceFreq;
+ interval: number;
+ /** 0 = unlimited. */
+ count: number;
+ /** Occurrences that have already finished. */
+ elapsed: number;
+}
+
+export interface SeriesAdvance {
+ scheduledAt: Date;
+ elapsed: number;
+ /** The series ran out of occurrences — the meeting is done. */
+ completed: boolean;
+}
+
+/** How many steps we will walk in one roll-forward before giving up. */
+const MAX_ADVANCE_STEPS = 10_000;
+
+/**
+ * Roll a series forward to its next unfinished occurrence.
+ *
+ * Returns null when nothing changed — the current occurrence has not ended yet,
+ * or the series already ran out. An occurrence counts as elapsed only once its
+ * full duration has passed, so a meeting stays startable while it is running.
+ */
+export function advanceSeries(state: SeriesState, now: Date): SeriesAdvance | null {
+ const step = Math.max(1, Math.trunc(state.interval));
+ const anchorDay = state.anchorAt.getDate();
+
+ let scheduledAt = state.scheduledAt;
+ let elapsed = state.elapsed;
+ let changed = false;
+
+ for (let i = 0; i < MAX_ADVANCE_STEPS; i++) {
+ const endsAt = scheduledAt.getTime() + state.durationMinutes * 60_000;
+ if (endsAt > now.getTime()) break;
+
+ elapsed += 1;
+ changed = true;
+
+ // A bounded series stops on its last occurrence rather than rolling past it.
+ if (state.count > 0 && elapsed >= state.count) {
+ return { scheduledAt, elapsed, completed: true };
+ }
+
+ scheduledAt = nextOccurrence(scheduledAt, state.freq, step, anchorDay);
+ }
+
+ return changed ? { scheduledAt, elapsed, completed: false } : null;
+}
+
+/** How many occurrences are left, including the next one. null = unlimited. */
+export function occurrencesRemaining(count: number, elapsed: number): number | null {
+ if (count <= 0) return null;
+ return Math.max(0, count - elapsed);
+}
+
+/**
+ * "Repeats every 2 weeks on Tuesday, 8 times" — the sentence shown under the
+ * recurrence controls and in invite emails.
+ */
+export function describeRecurrence(rule: RecurrenceRule, startsAt?: Date | null): string {
+ if (!rule.freq) return 'Does not repeat';
+
+ const step = Math.max(1, Math.trunc(rule.interval));
+ const unit = UNIT_LABEL[rule.freq];
+ const every = step === 1 ? `every ${unit}` : `every ${String(step)} ${unit}s`;
+
+ let when = '';
+ if (startsAt && !isNaN(startsAt.getTime())) {
+ if (rule.freq === 'weekly') {
+ when = ` on ${startsAt.toLocaleDateString('en-US', { weekday: 'long' })}`;
+ } else if (rule.freq === 'monthly') {
+ when = ` on day ${String(startsAt.getDate())}`;
+ }
+ }
+
+ const times =
+ rule.count > 0 ? `, ${String(rule.count)} time${rule.count === 1 ? '' : 's'}` : ', forever';
+
+ return `Repeats ${every}${when}${times}`;
+}
+
+/** Short form for list rows and badges: "Weekly", "Every 2 weeks". */
+export function shortRecurrenceLabel(rule: RecurrenceRule): string | null {
+ if (!rule.freq) return null;
+ const step = Math.max(1, Math.trunc(rule.interval));
+ if (step === 1) {
+ return { daily: 'Daily', weekly: 'Weekly', monthly: 'Monthly' }[rule.freq];
+ }
+ return `Every ${String(step)} ${UNIT_LABEL[rule.freq]}s`;
+}
+
+/**
+ * The iCalendar RRULE for the series, without the "RRULE:" prefix. Used by both
+ * the .ics download and the Google Calendar link.
+ */
+export function buildRrule(rule: RecurrenceRule): string | null {
+ if (!rule.freq) return null;
+ const step = Math.max(1, Math.trunc(rule.interval));
+ const parts = [`FREQ=${RRULE_FREQ[rule.freq]}`];
+ if (step > 1) parts.push(`INTERVAL=${String(step)}`);
+ if (rule.count > 0) parts.push(`COUNT=${String(rule.count)}`);
+ return parts.join(';');
+}
+
+/** The recurrence columns of a scheduled_sessions row. */
+export interface RecurrenceRow {
+ recurrence_freq?: string | null;
+ recurrence_interval?: number | null;
+ recurrence_count?: number | null;
+}
+
+/** Read a recurrence rule off a scheduled_sessions row (or anything shaped like one). */
+export function ruleFromRow(row: RecurrenceRow): RecurrenceRule {
+ const freq = RECURRENCE_FREQUENCIES.includes(row.recurrence_freq as RecurrenceFreq)
+ ? (row.recurrence_freq as RecurrenceFreq)
+ : null;
+ return {
+ freq,
+ interval: row.recurrence_interval ?? 1,
+ count: row.recurrence_count ?? 0,
+ };
+}
diff --git a/apps/web/src/lib/validations.ts b/apps/web/src/lib/validations.ts
index 4a131f7..0cd4c44 100644
--- a/apps/web/src/lib/validations.ts
+++ b/apps/web/src/lib/validations.ts
@@ -1,5 +1,6 @@
import { z } from 'zod';
import { MAX_SCHEDULED_MEETING_DURATION_MINUTES } from './scheduled-meeting-timing';
+import { MAX_RECURRENCE_COUNT, MAX_RECURRENCE_INTERVAL } from './recurrence';
// Password must be at least 8 characters with at least one uppercase letter and one number
const passwordSchema = z
@@ -124,6 +125,16 @@ export const notificationPreferencesSchema = z.object({
hostDisconnected: z.boolean().default(true),
});
+// How a meeting repeats. Absent (or an explicit null frequency) means it happens once.
+// `recurrenceCount` is the number of occurrences, with 0 meaning "forever".
+const recurrenceFreqSchema = z.enum(['daily', 'weekly', 'monthly']);
+
+const recurrenceFields = {
+ recurrenceFreq: recurrenceFreqSchema.nullable().optional(),
+ recurrenceInterval: z.number().int().min(1).max(MAX_RECURRENCE_INTERVAL).optional(),
+ recurrenceCount: z.number().int().min(0).max(MAX_RECURRENCE_COUNT).optional(),
+};
+
// Schedule a meeting
export const scheduleMeetingSchema = z.object({
title: z.string().min(1, 'Title is required').max(120, 'Title must be less than 120 characters'),
@@ -134,6 +145,7 @@ export const scheduleMeetingSchema = z.object({
.array(z.string().email('Invalid email address'))
.max(50, 'Maximum 50 invitees')
.optional(),
+ ...recurrenceFields,
});
// Edit a scheduled meeting. Accepts either the column names (scheduled_at) or the
@@ -162,6 +174,7 @@ export const updateScheduledMeetingSchema = z
.array(z.string().email('Invalid email address'))
.max(50, 'Maximum 50 invitees')
.optional(),
+ ...recurrenceFields,
})
.transform((input) => {
const scheduledAt = input.scheduled_at ?? input.scheduledAt;
@@ -175,12 +188,26 @@ export const updateScheduledMeetingSchema = z
? [...new Set(input.inviteeEmails.map((email) => email.toLowerCase().trim()))]
: undefined;
+ // Turning recurrence off also clears its settings, so a meeting that is later
+ // made recurring again does not inherit a stale interval or count.
+ const recurrence =
+ input.recurrenceFreq === undefined
+ ? {}
+ : input.recurrenceFreq === null
+ ? { recurrence_freq: null, recurrence_interval: 1, recurrence_count: 0 }
+ : {
+ recurrence_freq: input.recurrenceFreq,
+ recurrence_interval: input.recurrenceInterval ?? 1,
+ recurrence_count: input.recurrenceCount ?? 0,
+ };
+
return {
...(input.title !== undefined && { title: input.title.trim() }),
...(description !== undefined && { description }),
...(scheduledAt !== undefined && { scheduled_at: scheduledAt }),
...(durationMinutes !== undefined && { duration_minutes: durationMinutes }),
...(inviteeEmails !== undefined && { inviteeEmails }),
+ ...recurrence,
};
});
diff --git a/supabase/migrations/20260819120000_recurring_scheduled_sessions.sql b/supabase/migrations/20260819120000_recurring_scheduled_sessions.sql
new file mode 100644
index 0000000..aa38389
--- /dev/null
+++ b/supabase/migrations/20260819120000_recurring_scheduled_sessions.sql
@@ -0,0 +1,42 @@
+-- Recurring scheduled meetings
+--
+-- A recurring meeting stays ONE row: it keeps a single join code, invitee list
+-- and set of invite emails for the whole series. `scheduled_at` always points at
+-- the next occurrence and is rolled forward by the app once an occurrence has
+-- finished, so there is no cron job and no row-per-occurrence fan-out.
+
+ALTER TABLE public.scheduled_sessions
+ ADD COLUMN IF NOT EXISTS recurrence_freq TEXT
+ CHECK (recurrence_freq IN ('daily', 'weekly', 'monthly')),
+ -- Repeat every N days/weeks/months.
+ ADD COLUMN IF NOT EXISTS recurrence_interval INTEGER NOT NULL DEFAULT 1
+ CHECK (recurrence_interval BETWEEN 1 AND 30),
+ -- Total occurrences in the series. 0 means it repeats forever.
+ ADD COLUMN IF NOT EXISTS recurrence_count INTEGER NOT NULL DEFAULT 0
+ CHECK (recurrence_count BETWEEN 0 AND 365),
+ -- Occurrences that have already finished.
+ ADD COLUMN IF NOT EXISTS occurrences_elapsed INTEGER NOT NULL DEFAULT 0
+ CHECK (occurrences_elapsed >= 0),
+ -- The first occurrence. Fixes the day of the month for monthly series so one
+ -- booked on the 31st does not permanently slide to the 28th after February.
+ ADD COLUMN IF NOT EXISTS recurrence_anchor_at TIMESTAMPTZ;
+
+COMMENT ON COLUMN public.scheduled_sessions.recurrence_freq IS
+ 'daily | weekly | monthly. NULL means the meeting happens once.';
+COMMENT ON COLUMN public.scheduled_sessions.recurrence_count IS
+ 'Total occurrences in the series; 0 = repeats forever.';
+COMMENT ON COLUMN public.scheduled_sessions.occurrences_elapsed IS
+ 'Occurrences that have already finished; the app advances scheduled_at lazily.';
+COMMENT ON COLUMN public.scheduled_sessions.recurrence_anchor_at IS
+ 'First occurrence of the series — anchors the day of the month for monthly rules.';
+
+-- Existing rows are one-offs; give them an anchor so the app can treat every row
+-- uniformly if one is later turned into a series.
+UPDATE public.scheduled_sessions
+SET recurrence_anchor_at = scheduled_at
+WHERE recurrence_anchor_at IS NULL;
+
+-- Roll-forward looks up the host's unfinished recurring meetings.
+CREATE INDEX IF NOT EXISTS idx_scheduled_sessions_recurring
+ ON public.scheduled_sessions (host_user_id, scheduled_at)
+ WHERE recurrence_freq IS NOT NULL AND status = 'pending';