Skip to content

feat(web): recurring scheduled meetings - #81

Merged
ralyodio merged 1 commit into
masterfrom
worktree-recurring-meetings
Aug 19, 2026
Merged

feat(web): recurring scheduled meetings#81
ralyodio merged 1 commit into
masterfrom
worktree-recurring-meetings

Conversation

@ralyodio

Copy link
Copy Markdown
Contributor

What

The Schedule Meeting modal on /dashboard can now book a repeating meeting.

  • Repeat: Doesn't repeat / Daily / Weekly / Monthly
  • Every N days/weeks/months (1–30)
  • Number of times0 = forever, the same convention stored in the database

A 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_sessions row, not 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.

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

  • Dashboard rows get a repeat badge with the dates left ("Weekly · 3 left")
  • Invite and update emails carry a Repeats row
  • Google Calendar links and .ics downloads carry an RRULE, so the calendar gets the whole series

Database

supabase/migrations/20260819120000_recurring_scheduled_sessions.sql adds 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 --noEmit clean
  • eslint src/ clean (2 pre-existing warnings elsewhere)
  • vitest run in apps/web: 751 passed, including 33 new recurrence tests and 5 new modal tests
  • next build succeeds

🤖 Generated with Claude Code

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>
@github-actions

Copy link
Copy Markdown

ThreatCrush Security Scan

66 finding(s)

HIGH/CRITICAL: 13 | MEDIUM: 35 | LOW: 18

Severity Rule Location
HIGH sh-eval-expansion .githooks/pre-commit:33
HIGH js-electron-node-integration apps/desktop/src/main/window.ts:49
HIGH sh-remote-script-execution apps/installer/scripts/install.sh:691
HIGH sh-unquoted-expansion-destructive apps/installer/scripts/install.sh:715
HIGH sh-remote-script-execution apps/installer/scripts/install.sh:813
HIGH sh-remote-script-execution apps/installer/scripts/install.sh:815
HIGH sh-remote-script-execution apps/installer/scripts/install.sh:894
HIGH sh-unquoted-expansion-destructive apps/installer/scripts/install.sh:910
HIGH sh-remote-script-execution apps/installer/scripts/install.sh:1064
HIGH sh-remote-script-execution apps/installer/scripts/install.sh:1066
HIGH sh-remote-script-execution apps/installer/scripts/install.sh:1128
HIGH sh-remote-script-execution apps/livekit/setup-livekit-server.sh:93
HIGH sh-remote-script-execution apps/turn/deploy-droplet.sh:62
MEDIUM insecure-temp-file .githooks/commit-msg:19
MEDIUM insecure-temp-file .githooks/post-commit:22
MEDIUM insecure-temp-file apps/installer/scripts/install.sh:370
MEDIUM insecure-temp-file apps/installer/scripts/install.sh:393
MEDIUM insecure-temp-file apps/installer/scripts/install.sh:402
MEDIUM insecure-temp-file apps/installer/scripts/install.sh:438
MEDIUM js-unescaped-html-sink apps/web/src/app/blog/[slug]/page.tsx:48
MEDIUM js-unescaped-html-sink apps/web/src/app/blog/[slug]/page.tsx:73
MEDIUM js-unescaped-html-sink apps/web/src/app/c/[handle]/page.tsx:192
MEDIUM js-unescaped-html-sink apps/web/src/app/l/[joinCode]/page.tsx:125
MEDIUM js-unescaped-html-sink apps/web/src/app/l/[joinCode]/page.tsx:219
MEDIUM js-unescaped-html-sink apps/web/src/app/layout.tsx:142
MEDIUM js-unescaped-html-sink apps/web/src/app/live/page.tsx:145
MEDIUM js-unescaped-html-sink apps/web/src/app/page.tsx:122
MEDIUM js-unescaped-html-sink apps/web/src/app/pricing/page.tsx:284
MEDIUM js-open-redirect apps/web/src/app/pricing/UpgradeButton.tsx:50
MEDIUM js-unescaped-html-sink apps/web/src/app/u/[username]/page.tsx:282
MEDIUM redos-nested-quantifier apps/web/src/lib/deliverable.ts:11
MEDIUM manifest-install-lifecycle-script package.json:55
MEDIUM sql-template-interpolation packages/ai-core/src/prompts.ts:36
MEDIUM js-shell-exec-interpolation scripts/lib/package-managers/apt.ts:154
MEDIUM js-shell-exec-interpolation scripts/lib/package-managers/apt.ts:160
MEDIUM js-shell-exec-interpolation scripts/lib/package-managers/apt.ts:208
MEDIUM js-shell-exec-interpolation scripts/lib/package-managers/apt.ts:313
MEDIUM js-shell-exec-interpolation scripts/lib/package-managers/aur.ts:341
MEDIUM js-shell-exec-interpolation scripts/lib/package-managers/chocolatey.ts:264
MEDIUM js-shell-exec-interpolation scripts/lib/package-managers/chocolatey.ts:288
MEDIUM js-shell-exec-interpolation scripts/lib/package-managers/rpm.ts:201
MEDIUM js-shell-exec-interpolation scripts/lib/package-managers/rpm.ts:261
MEDIUM js-shell-exec-interpolation scripts/release.mjs:145
MEDIUM js-shell-exec-interpolation scripts/release.mjs:146
MEDIUM js-shell-exec-interpolation scripts/release.mjs:147
MEDIUM js-shell-exec-interpolation scripts/version-bump.mjs:169
MEDIUM js-shell-exec-interpolation scripts/version-bump.mjs:170
MEDIUM js-shell-exec-interpolation scripts/version-bump.mjs:171
LOW insecure-temp-file apps/desktop/src/main/streaming/index.test.ts:64
LOW secret-generic-credential apps/livekit/fly.toml:12

…and 16 more. Full results in the Security tab.

Snippets are redacted; ThreatCrush never prints matched credential material.

@ralyodio
ralyodio merged commit 30453f9 into master Aug 19, 2026
13 checks passed
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant