From b3e51d261a50e58a5452230ff59b0f6d4e9f5701 Mon Sep 17 00:00:00 2001 From: Ricardo Vega Date: Wed, 30 Sep 2026 09:30:30 -0500 Subject: [PATCH 1/9] Keep a coach's week on the calendar and run the background work. Weekly times are saved and filled 8 weeks ahead, and a one-minute tick keeps them topped up, advances expired overflow offers, and sends 24-hour reminders outside quiet hours. Sign-up needs only a name, schedule cards open the session, every coach screen shares one nav, and overflow texts name the session and the backup coach's pay. Co-Authored-By: Claude Opus 5.5 (1M context) --- README.md | 13 +- ROADMAP.md | 33 ++++ SPEC.md | 3 +- src/db/migrations/0007_weekly_slot.sql | 25 +++ src/domain/cascade.ts | 40 ++++- src/domain/scheduling.ts | 152 ++++++++++++----- src/jobs/tick.ts | 86 ++++++++++ src/lib/html.ts | 31 ++++ src/lib/time.ts | 13 ++ src/routes/coach.ts | 226 ++++++++++++++++--------- src/routes/public.ts | 18 +- src/server.ts | 29 +++- test/coach-onboarding.test.ts | 56 ++++++ test/tick.test.ts | 93 ++++++++++ test/weekly-slots.test.ts | 138 +++++++++++++++ 15 files changed, 815 insertions(+), 141 deletions(-) create mode 100644 src/db/migrations/0007_weekly_slot.sql create mode 100644 src/jobs/tick.ts create mode 100644 test/tick.test.ts create mode 100644 test/weekly-slots.test.ts diff --git a/README.md b/README.md index 5b61ce0..bddb7bf 100644 --- a/README.md +++ b/README.md @@ -93,7 +93,7 @@ The twelve screens of Coachatron (Slice 1): | 1 | Sign in | `/signin`, `/signin/otp`, `/signin/verify` | Coach | | 2 | Schedule | `/app/schedule` | Coach | | 3 | Session detail | `/app/sessions/:id`, `/app/sessions/:id/bookings/:bookingId/attendance`, `/app/sessions/:id/cancel` | Coach | -| 4 | Session types | `/app/session-types`, `/app/session-types/:id/generate-week` | Coach | +| 4 | Session types | `/app/session-types`, `/app/session-types/:id/generate-week` (weekly times), `/app/session-types/:id/slots/:slotId/remove` | Coach | | 5 | Pricing | `/app/pricing`, `/app/pricing/package`, `/app/pricing/plan` | Coach | | 6 | Roster | `/app/roster`, `/app/roster/:id/priority` | Coach | | 7 | Money (and connecting payments) | `/app/money`, `/app/money/payments` | Coach | @@ -132,11 +132,22 @@ Do not commit secrets. `npm test` uses the in-process fake in `test/fakes/relay. - **No refund-webhook automation.** Refunds are issued through the coach's own Square dashboard (`SPEC.md §9.4`); Coachatron doesn't automatically reverse a package credit if the coach later issues a partial refund. +- **No SMS segment ceilings yet.** `SPEC.md §10`'s per-coach daily and + monthly outbound caps are not enforced in the send path. Reminders add + one text per booking. - **The overflow cascade reuses the original session**, rather than creating a literal second "parallel session" row — the accepted backup coach is recorded on the same session via `assigned_roster_member_id`. See `ROADMAP.md` M4 for why. +## Background job + +The running server ticks once a minute (`src/jobs/tick.ts`). Each tick keeps +every weekly time 8 weeks ahead on the calendar, passes an expired overflow +offer to the next backup coach, and sends 24-hour reminders outside quiet +hours (9pm–8am in the coach's timezone). Tests call `runTick()` directly +with a fixed clock; `createApp()` never starts the timer. + None of these affect the three journeys in the product brief (booking + payment, the overflow cascade, the read-only Money screen), which are all covered by `test/journeys.test.ts`. diff --git a/ROADMAP.md b/ROADMAP.md index f1c308a..23b1f98 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1037,3 +1037,36 @@ but a real gap between `SPEC.md`'s wording and Slice 1 as shipped. Recorded as `SPEC.md §15` item 7 rather than either quietly building it (scope creep at the very last step, with no milestone-level test design behind it) or quietly ignoring the discrepancy. + +--- + +## M8: Take and run +Status: [x] done +Goal: A coach who sets up once never has to come back to keep the calendar +full, and the background work SPEC.md already promises actually runs. +Acceptance: +- [x] Weekly times are saved (`weekly_slot`) and fill 8 weeks ahead; the + same local time holds across a DST change; regenerating never + duplicates a session (`test/weekly-slots.test.ts`) +- [x] Removing a weekly time cancels its empty future sessions and keeps + booked ones for the coach to cancel one by one +- [x] A one-minute tick (`src/jobs/tick.ts`) tops up weekly times, advances + expired overflow offers, and sends one 24-hour reminder per booking + outside quiet hours, skipping opted-out numbers (`test/tick.test.ts`) +- [x] Sign-up asks only for a name: the timezone comes from the browser + and falls back to America/Chicago, and email is optional +- [x] Landing page has a Start link; schedule cards link to session detail + and show booked/capacity; every coach screen has the same plain nav +- [x] Overflow texts name the session; the offer text and page show the + headcount and the backup coach's pay (`session_type.backup_pay_cents`) +- [x] All five quality-bar commands still exit 0 + +Notes: +- `generateWeekSessions` (7 days, once) is replaced by `createWeeklySlots` + and `ensureSlotSessions`. The route path `generate-week` is kept so links + and tests stay stable; the screen is now "Weekly times". +- The tick starts only in the `server.ts` boot block, never in + `createApp()`, so tests stay deterministic. Railway runs one replica; more + would need `pg_try_advisory_lock` around `runTick`. +- No thirteenth screen: slot removal is a POST on screen 4. + diff --git a/SPEC.md b/SPEC.md index d7cb5aa..b3bca5e 100644 --- a/SPEC.md +++ b/SPEC.md @@ -140,7 +140,8 @@ the solo coach. They return only when a paying customer is blocked without them. 3. Creates first session type in one form: *what it's called, how long, how many athletes, how much*. Every other field is defaulted. 4. Picks recurring times on a weekly grid (tap the slots). Sessions are generated - 8 weeks ahead, rolling. + 8 weeks ahead, rolling. *(Implemented: each weekly time is a `weekly_slot`, + topped up by the one-minute tick job.)* 5. Connects payments (Square OAuth, one redirect). **Skippable** — the coach can share a booking link immediately and connect before the first payout. 6. Gets their link: `coachatron.com/c/`. Done. diff --git a/src/db/migrations/0007_weekly_slot.sql b/src/db/migrations/0007_weekly_slot.sql new file mode 100644 index 0000000..760f85b --- /dev/null +++ b/src/db/migrations/0007_weekly_slot.sql @@ -0,0 +1,25 @@ +-- A coach's weekly pattern. Sessions are generated from it 8 weeks ahead and +-- topped up by the tick job, so a schedule does not run out (SPEC.md 7.1). +create table if not exists weekly_slot ( + id serial primary key, + session_type_id integer not null references session_type(id), + weekday integer not null, + time_local text not null, + location_text text, + active boolean not null default true, + created_at timestamptz not null default now() +); + +-- weekday is 0 (Sunday) to 6 (Saturday) and time_local is HH:MM, both in the +-- coach timezone. The unique index makes regeneration idempotent. +alter table session add column if not exists weekly_slot_id integer references weekly_slot(id); +create unique index if not exists session_slot_start on session (weekly_slot_id, starts_at_utc); + +-- What a backup coach is paid for a session of this type, shown in the offer. +alter table session_type add column if not exists backup_pay_cents integer; + +-- One 24-hour reminder per booking (SPEC.md 10). +create table if not exists reminder_sent ( + booking_id integer primary key references booking(id), + sent_at timestamptz not null default now() +); diff --git a/src/domain/cascade.ts b/src/domain/cascade.ts index 0c76e86..59f48c6 100644 --- a/src/domain/cascade.ts +++ b/src/domain/cascade.ts @@ -2,6 +2,7 @@ import { randomBytes } from 'node:crypto'; import type { DbClient } from '../db/client.js'; import { sendSms } from '../relay/sms.js'; import { expireLivePendingForCoach } from './assistantPending.js'; +import { clipSms, formatConfirmWhen } from '../lib/time.js'; export const OVERFLOW_OFFER_TTL_MINUTES = 20; @@ -27,6 +28,9 @@ interface SessionOverflowInfo { coachPhone: string; capacity: number; overflowThreshold: number; + /** "Tue Oct 7, 6:00pm Keeper Group", for the texts. */ + label: string; + backupPayCents: number | null; } async function loadSessionOverflowInfo(db: DbClient, sessionId: number): Promise { @@ -36,8 +40,13 @@ async function loadSessionOverflowInfo(db: DbClient, sessionId: number): Promise capacity: number; capacity_override: number | null; overflow_threshold: number; + starts_at_utc: string | Date; + tz: string; + name: string; + backup_pay_cents: number | null; }>( - `select c.id as coach_id, c.phone as coach_phone, st.capacity, s.capacity_override, st.overflow_threshold + `select c.id as coach_id, c.phone as coach_phone, st.capacity, s.capacity_override, st.overflow_threshold, + s.starts_at_utc, s.tz, st.name, st.backup_pay_cents from session s join session_type st on st.id = s.session_type_id join coach c on c.id = st.coach_id @@ -52,6 +61,8 @@ async function loadSessionOverflowInfo(db: DbClient, sessionId: number): Promise coachPhone: row.coach_phone, capacity, overflowThreshold: row.overflow_threshold >= 0 ? row.overflow_threshold : capacity, + label: `${formatConfirmWhen(new Date(row.starts_at_utc).toISOString(), row.tz)} ${row.name}`, + backupPayCents: row.backup_pay_cents, }; } @@ -106,7 +117,7 @@ export async function checkOverflow(db: DbClient, sessionId: number): Promise { + const info = await loadSessionOverflowInfo(db, sessionId); + if (!info) return null; + const booked = await countBooked(db, sessionId); + const pay = info.backupPayCents ? ` Pays ${formatPay(info.backupPayCents)}.` : ''; + return `${info.label}, ${booked} athletes.${pay}`; } /** Declining advances the cascade immediately, without waiting for the diff --git a/src/domain/scheduling.ts b/src/domain/scheduling.ts index 61c65db..cd74ddb 100644 --- a/src/domain/scheduling.ts +++ b/src/domain/scheduling.ts @@ -76,58 +76,130 @@ export function addCalendarDays( } export interface WeeklySlot { - /** 0 = Sunday .. 6 = Saturday, matching Date#getUTCDay via the coach's tz */ + /** 0 = Sunday .. 6 = Saturday, in the coach's tz */ weekday: number; /** "HH:MM" 24-hour, local to the coach's tz */ timeLocal: string; + locationText?: string | null; } -export interface SessionTypeRow { +export interface SlotRow { id: number; - coach_id: number; - name: string; - duration_min: number; - capacity: number; - price_cents: number; + session_type_id: number; + weekday: number; + time_local: string; + location_text: string | null; } -/** Generates session rows for the next 7 days starting today (coach's tz), - * for each requested weekly slot whose weekday falls in that window. */ -export async function generateWeekSessions( +/** How far ahead a weekly slot keeps sessions on the calendar (SPEC.md §7.1). */ +export const SLOT_HORIZON_DAYS = 56; + +/** Saves weekly slots for a session type and fills their first 8 weeks. */ +export async function createWeeklySlots( db: DbClient, - sessionType: SessionTypeRow, + sessionTypeId: number, tz: string, slots: WeeklySlot[], now: Date, -): Promise { - const created: number[] = []; - for (let offset = 0; offset < 7; offset += 1) { - const day = new Date(now.getTime() + offset * 24 * 60 * 60 * 1000); - const dayParts = new Intl.DateTimeFormat('en-US', { - timeZone: tz, - year: 'numeric', - month: '2-digit', - day: '2-digit', - weekday: 'short', - }).formatToParts(day); - const get = (type: string) => dayParts.find((p) => p.type === type)?.value ?? ''; - const weekdayName = get('weekday'); - const weekdayIndex = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].indexOf(weekdayName); - const year = Number(get('year')); - const month = Number(get('month')); - const dayOfMonth = Number(get('day')); - - for (const slot of slots) { - if (slot.weekday !== weekdayIndex) continue; - const [hourStr, minuteStr] = slot.timeLocal.split(':'); - const startsAtUtc = zonedTimeToUtc(year, month, dayOfMonth, Number(hourStr), Number(minuteStr), tz); - const result = await db.query<{ id: number }>( - `insert into session (session_type_id, starts_at_utc, tz, status) - values ($1, $2, $3, 'scheduled') returning id`, - [sessionType.id, startsAtUtc.toISOString(), tz], - ); - created.push(result.rows[0].id); - } +): Promise { + const created: SlotRow[] = []; + for (const slot of slots) { + const result = await db.query( + `insert into weekly_slot (session_type_id, weekday, time_local, location_text) + values ($1, $2, $3, $4) + returning id, session_type_id, weekday, time_local, location_text`, + [sessionTypeId, slot.weekday, slot.timeLocal, slot.locationText ?? null], + ); + const row = result.rows[0]; + await ensureSlotSessions(db, row, tz, now); + created.push(row); } return created; } + +/** Creates the slot's sessions from today through the horizon. Walks + * calendar days in the coach's tz (DST-safe via addCalendarDays) and relies + * on the (weekly_slot_id, starts_at_utc) unique index, so running it again + * never duplicates a session. Returns how many sessions it added. */ +export async function ensureSlotSessions( + db: DbClient, + slot: SlotRow, + tz: string, + now: Date, + horizonDays = SLOT_HORIZON_DAYS, +): Promise { + const today = zonedParts(now, tz); + const [hour, minute] = slot.time_local.split(':').map(Number); + let added = 0; + for (let offset = 0; offset <= horizonDays; offset += 1) { + const d = addCalendarDays(today.year, today.month, today.day, offset); + if (new Date(Date.UTC(d.year, d.month - 1, d.day)).getUTCDay() !== slot.weekday) continue; + const startsAtUtc = zonedTimeToUtc(d.year, d.month, d.day, hour, minute, tz); + if (startsAtUtc <= now) continue; + const result = await db.query<{ id: number }>( + `insert into session (session_type_id, starts_at_utc, tz, location_text, weekly_slot_id, status) + values ($1, $2, $3, $4, $5, 'scheduled') + on conflict (weekly_slot_id, starts_at_utc) do nothing + returning id`, + [slot.session_type_id, startsAtUtc.toISOString(), tz, slot.location_text, slot.id], + ); + added += result.rows.length; + } + return added; +} + +/** Tick step: keeps every active slot of an active session type 8 weeks ahead. */ +export async function topUpAllSlots(db: DbClient, now: Date): Promise { + const slots = await db.query( + `select ws.id, ws.session_type_id, ws.weekday, ws.time_local, ws.location_text, c.tz + from weekly_slot ws + join session_type st on st.id = ws.session_type_id + join coach c on c.id = st.coach_id + where ws.active = true and st.active = true`, + ); + let added = 0; + for (const slot of slots.rows) { + added += await ensureSlotSessions(db, slot, slot.tz, now); + } + return added; +} + +export async function listWeeklySlots(db: DbClient, sessionTypeId: number): Promise { + const result = await db.query( + `select id, session_type_id, weekday, time_local, location_text + from weekly_slot + where session_type_id = $1 and active = true + order by weekday, time_local`, + [sessionTypeId], + ); + return result.rows; +} + +/** Stops a weekly slot. Its future sessions with nobody booked are + * cancelled quietly; sessions with bookings stay so the coach can cancel + * them one at a time (which texts the athletes). Returns how many stayed, + * or null if the slot is not this coach's. */ +export async function deactivateSlot(db: DbClient, coachId: number, slotId: number, now: Date): Promise { + const owned = await db.query<{ id: number }>( + `update weekly_slot ws set active = false + from session_type st + where ws.id = $1 and st.id = ws.session_type_id and st.coach_id = $2 + returning ws.id`, + [slotId, coachId], + ); + if (owned.rows.length === 0) return null; + await db.query( + `update session s set status = 'cancelled' + where s.weekly_slot_id = $1 and s.status = 'scheduled' and s.starts_at_utc > $2 + and not exists ( + select 1 from booking b where b.session_id = s.id and b.status in ('booked', 'pending') + )`, + [slotId, now.toISOString()], + ); + const kept = await db.query<{ n: string }>( + `select count(*)::text as n from session + where weekly_slot_id = $1 and status = 'scheduled' and starts_at_utc > $2`, + [slotId, now.toISOString()], + ); + return Number(kept.rows[0]?.n ?? '0'); +} diff --git a/src/jobs/tick.ts b/src/jobs/tick.ts new file mode 100644 index 0000000..a384b72 --- /dev/null +++ b/src/jobs/tick.ts @@ -0,0 +1,86 @@ +import type { DbClient } from '../db/client.js'; +import { advanceCascade, isOptedOut } from '../domain/cascade.js'; +import { topUpAllSlots, zonedParts } from '../domain/scheduling.js'; +import { clipSms, formatConfirmWhen } from '../lib/time.js'; +import { sendSms } from '../relay/sms.js'; + +/** No non-urgent SMS between 9pm and 8am local (SPEC.md §10). */ +const QUIET_END_HOUR = 8; +const QUIET_START_HOUR = 21; +const REMINDER_WINDOW_MS = 24 * 60 * 60 * 1000; +const REMINDER_FLOOR_MS = 2 * 60 * 60 * 1000; + +export function inQuietHours(now: Date, tz: string): boolean { + const hour = zonedParts(now, tz).hour; + return hour < QUIET_END_HOUR || hour >= QUIET_START_HOUR; +} + +/** Texts each booked athlete once, inside 24h of the session and outside + * quiet hours. A session too early in the morning for the 24h mark is + * reminded at 8am the day before instead. */ +export async function sendDueReminders(db: DbClient, now: Date): Promise { + const due = await db.query<{ + booking_id: number; + athlete_name: string; + contact_phone: string; + starts_at_utc: string; + tz: string; + location_text: string | null; + name: string; + coach_tz: string; + }>( + `select b.id as booking_id, b.athlete_name, b.contact_phone, s.starts_at_utc, s.tz, s.location_text, + st.name, c.tz as coach_tz + from booking b + join session s on s.id = b.session_id + join session_type st on st.id = s.session_type_id + join coach c on c.id = st.coach_id + where b.status = 'booked' and s.status = 'scheduled' + and s.starts_at_utc > $1 and s.starts_at_utc <= $2 + and not exists (select 1 from reminder_sent r where r.booking_id = b.id) + order by s.starts_at_utc`, + [new Date(now.getTime() + REMINDER_FLOOR_MS).toISOString(), new Date(now.getTime() + REMINDER_WINDOW_MS).toISOString()], + ); + + let sent = 0; + for (const row of due.rows) { + if (inQuietHours(now, row.coach_tz)) continue; + const claimed = await db.query<{ booking_id: number }>( + 'insert into reminder_sent (booking_id) values ($1) on conflict (booking_id) do nothing returning booking_id', + [row.booking_id], + ); + if (claimed.rows.length === 0) continue; + if (await isOptedOut(db, row.contact_phone)) continue; + const when = formatConfirmWhen(new Date(row.starts_at_utc).toISOString(), row.tz); + const place = row.location_text ? ` at ${row.location_text}` : ''; + await sendSms({ + to: row.contact_phone, + body: clipSms(`Coachatron: ${row.athlete_name}'s ${row.name} is ${when}${place}.`), + }); + sent += 1; + } + return sent; +} + +type TickStep = (db: DbClient, now: Date) => Promise; + +const STEPS: Array<[string, TickStep]> = [ + ['top up weekly slots', topUpAllSlots], + ['advance overflow cascades', advanceCascade], + ['send reminders', sendDueReminders], +]; + +/** The scheduled job (SPEC.md §11: "a scheduled job over a table"). Each + * step catches its own failure so one broken step never starves the rest. */ +export async function runTick(db: DbClient, now: Date, steps: Array<[string, TickStep]> = STEPS): Promise { + const failed: string[] = []; + for (const [name, step] of steps) { + try { + await step(db, now); + } catch (err) { + failed.push(name); + console.error(`tick: ${name} failed:`, err); + } + } + return failed; +} diff --git a/src/lib/html.ts b/src/lib/html.ts index b63ca96..82cc36b 100644 --- a/src/lib/html.ts +++ b/src/lib/html.ts @@ -219,6 +219,17 @@ export const POSTCARD_TOKENS_CSS = ` .btn-row { display: flex; gap: 8px; } .btn-row button { margin-top: 8px; flex: 1; } button.ghost { background: transparent; color: var(--teal); border: 1px solid var(--teal); } + a.card { display: block; color: inherit; text-decoration: none; } + .coach-nav { + display: flex; + flex-wrap: wrap; + gap: 4px 14px; + margin: 0 0 14px; + font-family: ui-sans-serif, system-ui, sans-serif; + font-size: 0.9rem; + } + .coach-nav a { color: var(--screen-muted); text-decoration: none; padding: 6px 0; } + .coach-nav a[aria-current="page"] { color: var(--teal); font-weight: 600; } @media (min-width: 768px) { :root { --shell-max: min(640px, 100%); } body { @@ -247,6 +258,26 @@ export const POSTCARD_TOKENS_CSS = ` } `; +export type CoachNavKey = 'schedule' | 'types' | 'pricing' | 'roster' | 'money'; + +const COACH_NAV: Array<[CoachNavKey, string, string]> = [ + ['schedule', '/app/schedule', 'Schedule'], + ['types', '/app/session-types', 'Types'], + ['pricing', '/app/pricing', 'Pricing'], + ['roster', '/app/roster', 'Roster'], + ['money', '/app/money', 'Money'], +]; + +/** Plain links across the coach screens. Muted text, not buttons, so each + * screen keeps its one teal action. */ +export function coachNav(active?: CoachNavKey): SafeHtml { + return html``; +} + export function page(title: string, body: SafeHtml): string { return ` diff --git a/src/lib/time.ts b/src/lib/time.ts index 43beccc..09c5be5 100644 --- a/src/lib/time.ts +++ b/src/lib/time.ts @@ -43,3 +43,16 @@ export function clipSms(text: string, max = 160): string { if (t.length <= max) return t; return `${t.slice(0, max - 1)}…`; } + +export const DEFAULT_TZ = 'America/Chicago'; + +/** True for an IANA zone Intl accepts, e.g. "America/Denver". */ +export function isValidTimeZone(tz: string): boolean { + if (!tz || !tz.includes('/')) return false; + try { + new Intl.DateTimeFormat('en-US', { timeZone: tz }); + return true; + } catch { + return false; + } +} diff --git a/src/routes/coach.ts b/src/routes/coach.ts index 771f0d1..d9db47f 100644 --- a/src/routes/coach.ts +++ b/src/routes/coach.ts @@ -16,9 +16,9 @@ import { } from '../domain/auth.js'; import { APP_BASE_URL, APP_FEE_BPS, SELLER_AGREEMENT_VERSION } from '../config.js'; import { acceptSellerAgreement, getSellerStatus, startSellerOnboarding } from '../relay/seller.js'; -import { generateWeekSessions, type WeeklySlot } from '../domain/scheduling.js'; -import { html, page, raw } from '../lib/html.js'; -import { formatLocal } from '../lib/time.js'; +import { createWeeklySlots, deactivateSlot, listWeeklySlots, type WeeklySlot } from '../domain/scheduling.js'; +import { coachNav, html, page, raw } from '../lib/html.js'; +import { DEFAULT_TZ, formatLocal, isValidTimeZone } from '../lib/time.js'; import { getPackagesForCoach, getPlansForCoach, createPackage, createPlan } from '../domain/pricing.js'; import { summarizeMoney } from '../domain/money.js'; import { handleCoachMessage, loadScheduleAssistant, resolvePendingForCoach } from '../domain/assistant.js'; @@ -91,7 +91,7 @@ coachRouter.get('/signin', (_req, res) => {

Enter your phone number to sign in or sign up.

- +
`, ), @@ -108,7 +108,7 @@ coachRouter.post('/signin/otp', async (req, res) => { html`

Coachatron

- +

Enter a valid phone number.

`, @@ -127,6 +127,12 @@ coachRouter.get('/signin/verify', (req, res) => { res.status(200).send(renderVerifyForm(phone, {})); }); +// The browser knows the coach's timezone; a typed IANA name was a +// five-minute-setup tax. The server still validates it and falls back. +const TZ_SCRIPT = raw( + ``, +); + function renderVerifyForm( phone: string, values: { code?: string; name?: string; email?: string; tz?: string }, @@ -139,19 +145,19 @@ function renderVerifyForm(
- + -

First time here? Tell us about your business.

+

New here? Just your name. Everything else can wait.

- - - - - + + + +
- ${error ? raw(`

${error}

`) : raw('')}`, + ${error ? raw(`

${error}

`) : raw('')} + ${TZ_SCRIPT}`, ); } @@ -171,16 +177,12 @@ coachRouter.post('/signin/verify', async (req, res) => { let coach: CoachRow | null = await findCoachByPhone(db, phone); if (!coach) { - if (!name || !email || !tz || !tz.includes('/')) { - res - .status(422) - .send( - renderVerifyForm(phone, { code, name, email, tz }, 'Enter your name, email, and a timezone like America/Chicago.'), - ); + if (!name) { + res.status(422).send(renderVerifyForm(phone, { code, name, email, tz }, 'Enter your name.')); return; } await consumeOtp(db, otpId); - coach = await createCoach(db, { phone, name, email, tz }); + coach = await createCoach(db, { phone, name, email, tz: isValidTimeZone(tz) ? tz : DEFAULT_TZ }); } else { await consumeOtp(db, otpId); } @@ -195,20 +197,32 @@ coachRouter.post('/signin/verify', async (req, res) => { coachRouter.get('/app/session-types', requireAuth, async (_req, res) => { const db = getDb(); const coachId = res.locals.coachId as number; - const types = await db.query<{ id: number; name: string; duration_min: number; capacity: number; price_cents: number }>( - 'select id, name, duration_min, capacity, price_cents from session_type where coach_id = $1 order by id', + const types = await db.query<{ + id: number; + name: string; + duration_min: number; + capacity: number; + price_cents: number; + backup_pay_cents: number | null; + }>( + 'select id, name, duration_min, capacity, price_cents, backup_pay_cents from session_type where coach_id = $1 order by id', [coachId], ); res.status(200).send( page( 'Session types', - html`

Session types

+ html`${coachNav('types')} +

Session types

${types.rows.map( (t) => - html`
- ${t.name} — ${t.duration_min} min, capacity ${t.capacity}, $${(t.price_cents / 100).toFixed(2)} - -
`, + html` + ${t.name} +
+ ${t.duration_min} min · ${t.capacity} athletes · $${(t.price_cents / 100).toFixed(2)} + Weekly times › +
+ ${t.backup_pay_cents ? html`
Backup coach pays $${(t.backup_pay_cents / 100).toFixed(2)}
` : raw('')} +
`, )}
@@ -217,8 +231,10 @@ coachRouter.get('/app/session-types', requireAuth, async (_req, res) => { - + + +
`, ), @@ -232,8 +248,19 @@ coachRouter.post('/app/session-types', requireAuth, async (req, res) => { const capacity = Number(field(req.body, 'capacity')); const priceDollars = Number(field(req.body, 'price_dollars')); const priceCents = Math.round(priceDollars * 100); - - if (!name || !Number.isInteger(durationMin) || durationMin <= 0 || !Number.isInteger(capacity) || capacity <= 0 || !Number.isFinite(priceCents) || priceCents <= 0) { + const backupPayRaw = field(req.body, 'backup_pay_dollars'); + const backupPayCents = backupPayRaw ? Math.round(Number(backupPayRaw) * 100) : null; + + if ( + !name || + !Number.isInteger(durationMin) || + durationMin <= 0 || + !Number.isInteger(capacity) || + capacity <= 0 || + !Number.isFinite(priceCents) || + priceCents <= 0 || + (backupPayCents !== null && (!Number.isFinite(backupPayCents) || backupPayCents < 0)) + ) { res.status(422).send( page( 'Session types', @@ -247,8 +274,9 @@ coachRouter.post('/app/session-types', requireAuth, async (req, res) => { const db = getDb(); await db.query( - 'insert into session_type (coach_id, name, duration_min, capacity, price_cents, active) values ($1, $2, $3, $4, $5, true)', - [coachId, name, durationMin, capacity, priceCents], + `insert into session_type (coach_id, name, duration_min, capacity, price_cents, backup_pay_cents, active) + values ($1, $2, $3, $4, $5, $6, true)`, + [coachId, name, durationMin, capacity, priceCents, backupPayCents || null], ); res.redirect(303, '/app/session-types'); }); @@ -257,79 +285,104 @@ coachRouter.get('/app/session-types/:id/generate-week', requireAuth, async (req, const db = getDb(); const coachId = res.locals.coachId as number; const typeId = Number(req.params.id); - const typeRows = await db.query<{ id: number }>('select id from session_type where id = $1 and coach_id = $2', [ - typeId, - coachId, - ]); + const typeRows = await db.query<{ id: number; name: string }>( + 'select id, name from session_type where id = $1 and coach_id = $2', + [typeId, coachId], + ); if (typeRows.rows.length === 0) { res.status(404).send(page('Not found', html`

Not found

`)); return; } - res.status(200).send( - page( - 'Generate this week', - html`

Weekly times

-

Check the days this session runs and set a start time for each.

-
- ${WEEKDAY_LABELS.map( - (label, i) => - html``, - )} - -
`, - ), - ); + const kept = typeof req.query.kept === 'string' ? Number(req.query.kept) : 0; + res.status(200).send(renderWeeklyTimes(typeRows.rows[0], await listWeeklySlots(db, typeId), kept)); }); +function renderWeeklyTimes( + type: { id: number; name: string }, + slots: Array<{ id: number; weekday: number; time_local: string; location_text: string | null }>, + kept: number, + error?: string, +) { + return page( + 'Weekly times', + html`${coachNav('types')} +

${type.name}

+

Weekly times. Sessions stay on your calendar 8 weeks ahead.

+ ${kept > 0 + ? html`

${kept} upcoming ${kept === 1 ? 'session has' : 'sessions have'} bookings and stayed. Cancel ${kept === 1 ? 'it' : 'them'} from the schedule if you need to.

` + : raw('')} + ${slots.map( + (slot) => html`
+
+ ${WEEKDAY_LABELS[slot.weekday]} ${slot.time_local}${slot.location_text ? ` · ${slot.location_text}` : ''} +
+ +
+
+
`, + )} +
+ ${WEEKDAY_LABELS.map( + (label, i) => + html``, + )} + + + +
+ ${error ? html`

${error}

` : raw('')}`, + ); +} + coachRouter.post('/app/session-types/:id/generate-week', requireAuth, async (req, res) => { const db = getDb(); const coachId = res.locals.coachId as number; const typeId = Number(req.params.id); - const typeRows = await db.query<{ - id: number; - coach_id: number; - name: string; - duration_min: number; - capacity: number; - price_cents: number; - }>('select id, coach_id, name, duration_min, capacity, price_cents from session_type where id = $1 and coach_id = $2', [ - typeId, - coachId, - ]); + const typeRows = await db.query<{ id: number; name: string }>( + 'select id, name from session_type where id = $1 and coach_id = $2', + [typeId, coachId], + ); if (typeRows.rows.length === 0) { res.status(404).send(page('Not found', html`

Not found

`)); return; } const coachRows = await db.query<{ tz: string }>('select tz from coach where id = $1', [coachId]); - const tz = coachRows.rows[0]?.tz ?? 'America/Chicago'; + const tz = coachRows.rows[0]?.tz ?? DEFAULT_TZ; const body = req.body as Record; + const locationText = field(body, 'location') || null; const slots: WeeklySlot[] = []; for (let i = 0; i < 7; i += 1) { const checked = field(body, `day_${i}`) === '1'; const timeLocal = field(body, `time_${i}`); if (checked && /^\d{2}:\d{2}$/.test(timeLocal)) { - slots.push({ weekday: i, timeLocal }); + slots.push({ weekday: i, timeLocal, locationText }); } } if (slots.length === 0) { - res.status(422).send( - page( - 'Weekly times', - html`

Weekly times

-

Pick at least one day and time.

`, - ), - ); + res.status(422).send(renderWeeklyTimes(typeRows.rows[0], await listWeeklySlots(db, typeId), 0, 'Pick at least one day and time.')); return; } - await generateWeekSessions(db, typeRows.rows[0], tz, slots, new Date()); + await createWeeklySlots(db, typeId, tz, slots, new Date()); res.redirect(303, '/app/schedule'); }); +coachRouter.post('/app/session-types/:id/slots/:slotId/remove', requireAuth, async (req, res) => { + const db = getDb(); + const coachId = res.locals.coachId as number; + const typeId = Number(req.params.id); + const kept = await deactivateSlot(db, coachId, Number(req.params.slotId), new Date()); + if (kept === null) { + res.status(404).send(page('Not found', html`

Not found

`)); + return; + } + res.redirect(303, `/app/session-types/${typeId}/generate-week${kept > 0 ? `?kept=${kept}` : ''}`); +}); + // ---- Screen 2: schedule ---- coachRouter.get('/app/schedule', requireAuth, async (_req, res) => { @@ -342,8 +395,10 @@ coachRouter.get('/app/schedule', requireAuth, async (_req, res) => { name: string; capacity: number; price_cents: number; + booked: string; }>( - `select s.id, s.starts_at_utc, s.tz, st.name, st.capacity, st.price_cents + `select s.id, s.starts_at_utc, s.tz, st.name, coalesce(s.capacity_override, st.capacity) as capacity, st.price_cents, + coalesce((select count(*) from booking b where b.session_id = s.id and b.status = 'booked'), 0)::text as booked from session s join session_type st on st.id = s.session_type_id where st.coach_id = $1 and s.status = 'scheduled' and s.starts_at_utc > now() @@ -358,7 +413,8 @@ coachRouter.get('/app/schedule', requireAuth, async (_req, res) => { page( 'Schedule', html`
-

This week

+ ${coachNav('schedule')} +

Coming up

${assistant.reply ? html`

${assistant.reply.body}

@@ -372,15 +428,15 @@ coachRouter.get('/app/schedule', requireAuth, async (_req, res) => {
` : raw('')} ${sessions.rows.length === 0 - ? html`

No sessions yet. Create a session type and generate a week.

` + ? html`

No sessions yet. Add a session type and its weekly times.

+ Session types` : sessions.rows.map( (s) => - html``, +
${s.name}${s.booked}/${s.capacity} · $${(s.price_cents / 100).toFixed(2)}
+
`, )} - Session types
@@ -470,7 +526,8 @@ coachRouter.get('/app/sessions/:id', requireAuth, async (req, res) => { res.status(200).send( page( session.name, - html`

${session.name}

+ html`${coachNav('schedule')} +

${session.name}

${formatLocal(session.starts_at_utc, session.tz)}

Roster (${bookings.rows.length} attendees)

@@ -589,7 +646,8 @@ coachRouter.get('/app/pricing', requireAuth, async (_req, res) => { res.status(200).send( page( 'Pricing', - html`

Pricing & credits

+ html`${coachNav('pricing')} +

Pricing & credits

Session packages

${packagesList.length === 0 ? html`

No packages yet.

` @@ -696,7 +754,8 @@ coachRouter.get('/app/roster', requireAuth, async (_req, res) => { res.status(200).send( page( 'Roster', - html`

Overflow roster

+ html`${coachNav('roster')} +

Overflow roster

${members.length === 0 ? html`

No roster members yet.

` : html`