Next.js scheduling app — users create events, share a link, collect availability without sign-ups. Two creator access methods (Google sign-in, private owner link) plus an admin dashboard.
- Next.js 16.2 (App Router)
- Supabase (Postgres + RLS)
- next-auth 4.24 (Google OAuth)
- React 19, vanilla CSS (no Tailwind build — colors hardcoded from Tailwind palette)
npm run dev # :3000
npm run build
npm run lint # ESLint, next/core-web-vitalsNo test suite configured.
- Dates: stored as
YYYY-MM-DDISO strings. Always parse asnew Date(dateString + 'T12:00:00')— noon UTC to dodge timezone bugs. - Styling: no Tailwind build. Use hex from
app/globals.css(slate-950#0f172a, slate-800#1e293b, indigo#6366f1, emerald#10b981). Don't reach for Tailwind classes. - Admin password is
ADMIN_PASSWORD— server-only, checked per-request via thex-admin-passwordheader (lib/adminAuth.js). Never expose it in aNEXT_PUBLIC_var. - API routes require
dynamic = 'force-dynamic'andruntime = 'nodejs'for NextAuth. - The browser never talks to Supabase. All reads/writes go through
app/api/routes using the service-role client; RLS denies the anon role on every table. Never add@supabase/supabase-jsusage to client components. - Capability tokens are the guest auth model:
manage_token(owner — events AND groups),participant_token(device-wide identity, global localStorage keywhen_works_participant_token),member_token(group member's personal invite link,?m=on/respond/[slug]— accepted in request bodies, never echoed back),response_token(legacy per-event respondent token — still accepted in request bodies, never returned or written client-side anymore),invite_token(hosting follow-up). Tokens are minted server-side and stored in the visitor's localStorage. - Identity = participants table (
lib/participants.js): signed-in users resolve by normalized email, guests byparticipant_token. The legacy identity columns (google_email/owner_user_id/owner_email) anduser_profileswere dropped by005_cleanup_legacy_identity.sql— never reference them. Emails are ALWAYS normalized (normalizeEmail) before storage or lookup. - Responses soft-delete via
deleted_at— every responses query must filter.is('deleted_at', null)unless it deliberately wants deleted rows (guest numbering, owner restore list, export). - Schedule/cron date math lives in
lib/schedule.js(pure, dependency-free, client-importable — "today" is always a parameter). Group cadence is hybrid:cadence_unit 'day'|'month'+cadence_interval+cadence_anchor_day; never reference the deprecatedcadence_days. - All cron mutations use compare-and-set updates (
update … where <marker still unclaimed>+.select(), 0 rows = another invocation won). The daily cron (app/api/cron/daily, registered invercel.json) auto-creates group polls, sends pre-send notices, sends deadline summaries, and auto-schedules opted-in polls;events.owner_summary_sent_atis the single owner-summary marker shared with the respond route's all-responded hook — always claim before sending. - Auto-scheduling (opt-in per group schedule): summaries stamp
events.auto_schedule_on(generation = next cron run ≥1 day later); the cron CAS-claimsevents.auto_scheduled_atbefore calling Google, then records a CLOSEDevent_followupsround (lib/hostingRounds.js). The owner's Google refresh token lives inparticipants.google_refresh_token(server-only, captured at sign-in inlib/auth.js). Pick/bucket/validation logic is pure inlib/autoSchedule.js; Google calls inlib/googleCalendar.js. Seedocs/decisions.md.
app/respond/[slug]/— public response flowapp/events/manage/[token]/— creator managementapp/groups/+app/groups/manage/[ref]/— groups (roster, cadence nudge, attendance scores, automatic-poll schedule)app/groups/pause/[token]/— one-click pause landing page (emailed capability)app/admin/— password-gated adminapp/api/— server routesapp/api/cron/daily/— daily Vercel Cron (auto-create polls, pre-send notices, deadline summaries); auth =Authorization: Bearer CRON_SECRETorx-admin-password(admin may pass?date=for testing)
docs/schema.md— full Supabase table referencedocs/access-control.md— three-path ownership resolution + RLS policiesdocs/response-flow.md— debounced auto-save, confirmation lock, snapshot/resetdocs/followups.md— hosting round flow, timezone normalization
Required for deployment:
NEXT_PUBLIC_SUPABASE_URL
SUPABASE_SERVICE_ROLE_KEY # Server-only; the ONLY Supabase key in use
GOOGLE_CLIENT_ID
GOOGLE_CLIENT_SECRET
ADMIN_PASSWORD # Server-only, admin dashboard password
CRON_SECRET # Server-only; Vercel sends it as the Bearer token on cron invocations
NEXTAUTH_SECRET # For JWT signing (auto-generated by next-auth)
NEXTAUTH_URL # Callback URL (prod: full domain, dev: http://localhost:3000)
Optional (group email notifications no-op gracefully without them):
RESEND_API_KEY # Server-only, Resend transactional email
RESEND_FROM_EMAIL # e.g. "When Works <notify@yourdomain>"
SQL migrations live in supabase/migrations/ and are run manually in the Supabase SQL editor (see file headers for ordering constraints).
Two Supabase projects since July 2026: dev (local) and staging share one Supabase project; production uses a separate one. Test data never touches prod, but every migration must be run in BOTH projects — dev/staging first, prod when the code deploys there. Within each environment, additive migrations still run before the code that needs them deploys, and destructive drops still wait until the referencing code is gone.