feat(web): recurring scheduled meetings - #81
Merged
Conversation
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) <noreply@anthropic.com>
ThreatCrush Security Scan66 finding(s) HIGH/CRITICAL: 13 | MEDIUM: 35 | LOW: 18
…and 16 more. Full results in the Security tab. Snippets are redacted; ThreatCrush never prints matched credential material. |
ralyodio
added a commit
that referenced
this pull request
Aug 19, 2026
* feat(web): remind people about a meeting before it starts Email and web push at 1 day, 1 hour, 15 minutes and 1 minute before a meeting. Four toggles in settings, each governing both channels, all on by default. ## When a reminder is due Each lead time owns a *band* rather than an instant: the day-before reminder covers 24 hours down to 1 hour, the hour-before covers 60 to 15 minutes, and so on down. The obvious implementation -- fire when now is within a minute or two of `start - lead` -- fails both ways at once. Miss the window to a deploy or a slow tick and the reminder is gone with nothing to show it; widen the tolerance to compensate and the hour-before notice goes out twenty minutes late, when the fifteen-minute one is about to say something more accurate. With bands there is no tolerance to tune. A runner that has been down for three hours comes back and sends the tightest reminder still true, and skips the ones it slept through -- which is the right fate for a reminder about something that has since drawn much closer. The message says the real remaining time rather than the band's name, so a meeting booked 25 minutes ahead is never told it starts "in 1 hour". ## Sending each one once `meeting_reminders` is a ledger whose unique constraint is claimed *before* the message goes out, so overlapping cron runs cannot both send. At-most-once deliberately: a crash between claim and send loses one reminder, where the alternative can mail somebody repeatedly about a meeting already in their diary. The key includes `occurrence_at`, and that column is the whole reason this works for recurring meetings. #81 made a recurring series one row whose `scheduled_at` is the next occurrence, rolled forward after each one finishes -- so a key of (session, lead) would fire a weekly meeting's day-before notice once, in its first week, and stay silent for ever with a ledger that looked correctly filled in. Keying on the instant reopens the slots at every roll-forward. ## The toggles are not where the others are The existing event toggles only render once the browser is subscribed to push, which is right for them -- they describe push and nothing else. These four also govern the emailed reminder, so they get their own always-visible section: hiding them behind a push subscription would leave anyone who has not enabled push, or cannot, receiving email they had no way to turn off. Invitees have no account and therefore no settings row, so they get email with no per-lead control; declining the invitation stops all of it. ## Scheduling A minute cron (pg_cron -> pg_net) posts to /api/reminders/run with a shared secret. It lives in the app rather than in Postgres because the Resend client, the web-push library and the VAPID keys are already wired up here. Without REMINDERS_CRON_SECRET the route refuses everything: an endpoint that mails a meeting's whole invitee list is not one to leave open because a variable was missed on a new environment. Migration applied to prod as 20260819103845_meeting_reminders. The cron job is not scheduled yet -- that waits for this to deploy and for the secret to be set. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(test): let each workspace run its own tests 91 of 174 test files failed on master, 282 tests with them, on `document is not defined`, `localStorage is not defined` and `Cannot find package '@/lib/...'`. None of it was real. The root vitest.config.ts declared `environment: 'node'` and its own repository-wide `include`, which silently overrode the vitest.config.ts that apps/web, apps/mobile, apps/desktop, packages/shared-types and packages/ai-core each already had. apps/web's has specified jsdom, the `@/` alias and a setup file all along: the sixteen settings tests that fail from the root pass through it without touching a line of test code. `projects` lets each workspace keep its environment, its aliases and its setup. That is not a tidiness argument -- this repo has three different `@/` aliases (apps/web/src, apps/mobile/src, apps/desktop/src/renderer), so no single root-level alias could ever have served them. before 91 failed | 83 passed (174 files), 282 failed | 855 passed after 174 passed (174 files), 1780 passed The test count rises by 643 because most of those files were failing during collection, so their tests had never run at all and were never counted. Two workspaces needed a config of their own first, and both were places where this change could have quietly reduced coverage instead of restoring it: - packages/remote-input has 12 test files and no config. It needs jsdom rather than node, because it maps browser key and pointer events. - scripts/ has 3 test files and is not a workspace, so a projects list of apps/* and packages/* would have dropped them -- and reported 171 files, all passing, which is exactly the shape of a problem nobody notices. That is why the list is written out rather than globbed, and why the check was the file count and not the pass count. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(test): put back the scripts config I overwrote scripts/vitest.config.ts already existed, for running vitest from inside scripts/. The previous commit replaced it wholesale and dropped its explicit root and its 10s testTimeout along the way. Both are back. The only difference from the original is now the comment explaining why the root config has to name this directory: it is not a pnpm workspace, so a projects list of apps/* and packages/* skips it entirely. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(lint): satisfy the type rules this repo actually enforces CI's Lint job failed on nine errors in the new files. This repo's eslint config is stricter than the typecheck: it rejects `as` casts that widen, rejects `!` assertions outright, and rejects reading fields off PostgREST's `any` rows straight into a template or an object literal. - `dueLead` now iterates `LEAD_MINUTES.entries()`, which hands back the element already typed. Indexing needed either a cast or a `!` to convince the compiler the element exists, and both are forbidden here. - `admin()` returns an inferred type. Annotating it `SupabaseClient` was the unsafe-return error: the bare type defaults its schema parameters differently from what `createClient` actually returns. - The host's profile row is narrowed once, into a named `ProfileRow`, rather than at each use. - Dropped a redundant `Number()`, an `?? []` on a value already narrowed non-null, and one level of optional chaining that could not be nullish. No behaviour changes. Typecheck clean, 174 files and 1780 tests still passing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(lint): the test files are linted too I linted the new sources and not the new tests, so a number in a template literal survived into CI. Same rule, same fix. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
The Schedule Meeting modal on
/dashboardcan now book a repeating meeting.0 = forever, the same convention stored in the databaseA live sentence under the controls reads back what was chosen, e.g. "Repeats every 2 weeks on Wednesday, 8 times."
How it works
A series is one
scheduled_sessionsrow, not one row per date, so it keeps a single join code, invitee list and set of invite emails.scheduled_atalways points at the next occurrence.Rolling forward is lazy: whenever a host's meetings are read, any occurrence that has already finished is counted and the row is advanced to the next date. No cron job, and a meeting stays startable while it is running. A series with a finite count stops on its last occurrence and is marked
completed.Editing follows the obvious rules: moving the meeting or changing how often it repeats re-bases the series (new anchor, count starts again); changing only the count leaves the tally alone. Turning repetition off clears the settings so it can't be inherited later.
Monthly series keep their day of the month — one booked on the 31st lands on the 28th of February and returns to the 31st in March, rather than sliding permanently.
Also
.icsdownloads carry anRRULE, so the calendar gets the whole seriesDatabase
supabase/migrations/20260819120000_recurring_scheduled_sessions.sqladds five additive, defaulted columns. Already applied to prod (yuwjbjskkghlyrdkhexu) — this repo has no CI step that applies migrations, and the columns are backward compatible with the currently deployed code.Checks
tsc --noEmitcleaneslint src/clean (2 pre-existing warnings elsewhere)vitest runinapps/web: 751 passed, including 33 new recurrence tests and 5 new modal testsnext buildsucceeds🤖 Generated with Claude Code