From d6f37932bd659889fa23fb3492f0d8ae3b19fc0e Mon Sep 17 00:00:00 2001 From: Andrew McCallum Date: Mon, 2 Nov 2026 19:57:35 +0000 Subject: [PATCH] Explain blocked forms, allow no coffee cut-off, sync until done - An empty required field now brings the field to the middle of the screen and says what's missing. Firefox for Android shows no message of its own, and the field it jumped to sat under the sticky header, so buttons like Add repeating run seemed to do nothing. - Coffee orders can stay open after the run ("No cut-off", now the default): they close when the run stops showing as upcoming, three hours after the start. The nudge still goes before the start. - Sync now keeps going until every waiting run is imported, instead of 25 per press. Co-Authored-By: Claude Opus 5.5 --- apps/api/src/clubs/coffee.spec.ts | 33 +++++++++++ apps/api/src/clubs/notices.spec.ts | 22 +++++++ apps/api/src/clubs/notices.ts | 9 ++- apps/api/src/clubs/routes.ts | 3 +- apps/web-e2e/src/smoke.spec.ts | 4 ++ apps/web/src/app/app.tsx | 2 + apps/web/src/components/form-hint.tsx | 58 +++++++++++++++++++ .../web/src/components/intervals-settings.tsx | 2 +- apps/web/src/lib/integrations.ts | 39 +++++++++++-- apps/web/src/pages/club-manage.tsx | 9 ++- apps/web/src/pages/club-run.tsx | 8 ++- apps/web/src/styles.css | 4 ++ libs/domain/src/coffee.spec.ts | 8 +++ libs/domain/src/coffee.ts | 14 ++++- libs/schemas/src/club.ts | 11 ++-- 15 files changed, 206 insertions(+), 20 deletions(-) create mode 100644 apps/web/src/components/form-hint.tsx diff --git a/apps/api/src/clubs/coffee.spec.ts b/apps/api/src/clubs/coffee.spec.ts index 5132565..ba4b8ac 100644 --- a/apps/api/src/clubs/coffee.spec.ts +++ b/apps/api/src/clubs/coffee.spec.ts @@ -5,6 +5,7 @@ import { orderSummarySchema, runCoffeeSchema, } from '@openrun/schemas'; +import { NO_CUTOFF } from '@openrun/domain'; import { type App, get, send, setup, signUp } from '../test-utils.js'; const HOUR = 3_600_000; @@ -200,6 +201,38 @@ describe('coffee', () => { }); }); + it('keeps orders open after the run with no cut-off, the default', async () => { + const { app, owner, ann, club, tea } = await clubWithMenu(); + const startedAt = new Date(Date.now() - HOUR).toISOString(); + const run = await json( + await send(app, owner, 'POST', `/api/clubs/${club.id}/runs`, { + title: 'Social', + startsAt: startedAt, + }), + clubRunDetailSchema, + ); + // Open until three hours after the start. + expect(run.coffee).toMatchObject({ open: true }); + expect(Date.parse(run.coffee?.closesAt ?? '')).toBe( + Date.parse(startedAt) + 3 * HOUR, + ); + await send(app, ann, 'PUT', `/api/clubs/${club.id}/runs/${run.id}/rsvp`, { + status: 'going', + }); + const placed = await send( + app, + ann, + 'PUT', + `/api/clubs/${club.id}/runs/${run.id}/order`, + { menuItemId: tea.id }, + ); + expect(placed.status).toBe(200); + + // A run that finished long ago has closed. + const old = await addRun(app, owner, club.id, -4 * HOUR, NO_CUTOFF); + expect(old.coffee?.open).toBe(false); + }); + it('orders your usual when you say you’re going, if you ask it to', async () => { const { app, owner, ann, club, flatWhite } = await clubWithMenu(); const usual = await send(app, ann, 'PUT', `/api/clubs/${club.id}/usual`, { diff --git a/apps/api/src/clubs/notices.spec.ts b/apps/api/src/clubs/notices.spec.ts index 122311d..3fdf270 100644 --- a/apps/api/src/clubs/notices.spec.ts +++ b/apps/api/src/clubs/notices.spec.ts @@ -1,5 +1,6 @@ import { eq } from 'drizzle-orm'; import { schema } from '@openrun/db'; +import { NO_CUTOFF } from '@openrun/domain'; import { clubRunDetailSchema, clubSchema, @@ -194,6 +195,27 @@ describe('run reminders', () => { sendCoffeeNudges(t.db, t.notifier, new Date('2027-01-09T07:15:00Z')); expect(t.sent).toHaveLength(1); }); + + it('nudges before the start when orders stay open after the run', async () => { + const t = await club(); + await send(t.app, t.olly, 'POST', `/api/clubs/${t.club.id}/menu`, { + name: 'Flat white', + }); + const run = await t.addRun(SAT_8AM, { orderCutoffMin: NO_CUTOFF }); + await t.rsvp(t.ann, run.id, 'going'); + sendCoffeeNudges(t.db, t.notifier, new Date('2027-01-09T07:25:00Z')); + expect(t.sent).toEqual([]); + sendCoffeeNudges(t.db, t.notifier, new Date('2027-01-09T07:35:00Z')); + expect(t.sent).toMatchObject([ + { + endpoint: 'https://push.example.com/ann', + title: 'Want a coffee after Social?', + }, + ]); + // Not again after the start, though orders are still open. + sendCoffeeNudges(t.db, t.notifier, new Date('2027-01-09T08:30:00Z')); + expect(t.sent).toHaveLength(1); + }); }); describe('run changes', () => { diff --git a/apps/api/src/clubs/notices.ts b/apps/api/src/clubs/notices.ts index 8fac9f3..a767b27 100644 --- a/apps/api/src/clubs/notices.ts +++ b/apps/api/src/clubs/notices.ts @@ -213,7 +213,9 @@ export function sendCoffeeNudges(db: Db, notifier: Notifier, now = new Date()) { new Date(now.getTime() + 25 * HOUR_MS), )) { const closes = ordersCloseAt(run.startsAt, run.orderCutoffMin); - if (closes <= now || closes.getTime() - now.getTime() > COFFEE_NUDGE_MS) { + // With orders open after the run, nudge before the start instead. + const nudgeBy = closes > run.startsAt ? run.startsAt : closes; + if (nudgeBy <= now || nudgeBy.getTime() - now.getTime() > COFFEE_NUDGE_MS) { continue; } const club = clubOf(db, run.clubId); @@ -245,7 +247,10 @@ export function sendCoffeeNudges(db: Db, notifier: Notifier, now = new Date()) { notifier, [userId], { - title: `Coffee orders close at ${localTime(closes, club.timezone)}`, + title: + closes > run.startsAt + ? `Want a coffee after ${run.title}?` + : `Coffee orders close at ${localTime(closes, club.timezone)}`, body: `Order for after ${run.title} with ${club.name}.`, url: runUrl(run), tag: `coffee:${run.id}`, diff --git a/apps/api/src/clubs/routes.ts b/apps/api/src/clubs/routes.ts index 8da06b0..fbf220f 100644 --- a/apps/api/src/clubs/routes.ts +++ b/apps/api/src/clubs/routes.ts @@ -8,6 +8,7 @@ import { describeWeather, formatRule, parseRule, + RUN_STILL_ON_MIN, } from '@openrun/domain'; import { type Attendee, @@ -73,7 +74,7 @@ type ClubRow = typeof schema.club.$inferSelect; type RunRow = typeof schema.clubRun.$inferSelect; /** Runs that started up to this long ago still show as upcoming. */ -const STILL_ON_MS = 3 * 60 * 60 * 1000; +const STILL_ON_MS = RUN_STILL_ON_MIN * 60_000; function memberCount(c: Ctx, clubId: string) { return ( diff --git a/apps/web-e2e/src/smoke.spec.ts b/apps/web-e2e/src/smoke.spec.ts index 27252b8..bac5826 100644 --- a/apps/web-e2e/src/smoke.spec.ts +++ b/apps/web-e2e/src/smoke.spec.ts @@ -292,6 +292,10 @@ test.describe('clubs', () => { page.getByRole('heading', { name: 'Manage club' }), ).toBeVisible(); + // Sending it without a name says what's missing. + await page.getByRole('button', { name: 'Add repeating run' }).click(); + await expect(page.getByRole('alert')).toHaveText('Fill in “Name” first.'); + // Every day, so there's always a run in the next few days. await page.getByLabel('Name', { exact: true }).first().fill('Club run'); for (const day of ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']) { diff --git a/apps/web/src/app/app.tsx b/apps/web/src/app/app.tsx index b598837..7cf2b38 100644 --- a/apps/web/src/app/app.tsx +++ b/apps/web/src/app/app.tsx @@ -1,4 +1,5 @@ import { Navigate, Outlet, Route, Routes } from 'react-router-dom'; +import { FormHint } from '../components/form-hint'; import { Layout } from '../components/layout'; import { UpdatePrompt } from '../components/update-prompt'; import { useMe } from '../lib/queries'; @@ -63,6 +64,7 @@ export function App() { } /> + ); diff --git a/apps/web/src/components/form-hint.tsx b/apps/web/src/components/form-hint.tsx new file mode 100644 index 0000000..ecc8339 --- /dev/null +++ b/apps/web/src/components/form-hint.tsx @@ -0,0 +1,58 @@ +import { useEffect, useState } from 'react'; + +type Field = HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement; + +const FIELDS = 'input:invalid, select:invalid, textarea:invalid'; + +/** + * Says why a form didn't send. The browser blocks a form with an empty + * required field, but Firefox for Android shows no message, and the field it + * jumps to can end up under the sticky header, so it looked like the button + * did nothing. This brings the first such field into the middle of the screen + * and names it. + */ +export function FormHint() { + const [hint, setHint] = useState(null); + + useEffect(() => { + let timer: ReturnType | undefined; + const onInvalid = (e: Event) => { + const field = e.target as Field; + if (!field.validity) return; + // Ours replaces the browser's own bubble, so every browser looks alike. + e.preventDefault(); + // The event fires for each bad field; deal with the first only. + if (field.form && field.form.querySelector(FIELDS) !== field) return; + field.scrollIntoView({ block: 'center', behavior: 'smooth' }); + field.focus({ preventScroll: true }); + const label = + field.labels?.[0]?.textContent?.trim() || + field.getAttribute('aria-label'); + setHint( + field.validity.valueMissing && label + ? `Fill in “${label}” first.` + : field.validationMessage, + ); + clearTimeout(timer); + timer = setTimeout(() => setHint(null), 6000); + }; + const onInput = () => setHint(null); + document.addEventListener('invalid', onInvalid, true); + document.addEventListener('input', onInput, true); + return () => { + clearTimeout(timer); + document.removeEventListener('invalid', onInvalid, true); + document.removeEventListener('input', onInput, true); + }; + }, []); + + if (!hint) return null; + return ( +
+ {hint} +
+ ); +} diff --git a/apps/web/src/components/intervals-settings.tsx b/apps/web/src/components/intervals-settings.tsx index abcb468..53d4ddc 100644 --- a/apps/web/src/components/intervals-settings.tsx +++ b/apps/web/src/components/intervals-settings.tsx @@ -224,7 +224,7 @@ function Connected({ connection }: { connection: IntervalsConnection }) { onChange={(e) => changeStart(e.target.value)} />

- Going further back fetches the older runs over the next few syncs. + Going further back fetches the older runs when you next sync.

diff --git a/apps/web/src/lib/integrations.ts b/apps/web/src/lib/integrations.ts index 6518c2c..77a7293 100644 --- a/apps/web/src/lib/integrations.ts +++ b/apps/web/src/lib/integrations.ts @@ -1,5 +1,9 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { type ConnectIntervals, integrationsSchema } from '@openrun/schemas'; +import { + type ConnectIntervals, + type Integrations, + integrationsSchema, +} from '@openrun/schemas'; import { api } from './api'; const KEY = ['integrations']; @@ -39,10 +43,35 @@ export const useUpdateIntervals = () => }), ); -export const useSyncIntervals = () => - useIntegrationMutation(() => - api('/integrations/intervals/sync', integrationsSchema, { method: 'POST' }), - ); +/** Enough for about 1,000 runs at 25 a sync; scheduled syncs fetch the rest. */ +const MAX_SYNC_ROUNDS = 40; + +/** + * Each sync imports a batch of runs, so keep syncing until nothing is left, + * updating the counts after each batch. + */ +export function useSyncIntervals() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async () => { + let data: Integrations | undefined; + for (let round = 0; round < MAX_SYNC_ROUNDS; round++) { + data = await api('/integrations/intervals/sync', integrationsSchema, { + method: 'POST', + }); + queryClient.setQueryData(KEY, data); + void queryClient.invalidateQueries({ queryKey: ['activities'] }); + const latest = data.intervals.connection?.syncs[0]; + // Stop if a batch got nowhere, rather than asking again and again. + const handled = latest + ? latest.imported + latest.duplicates + latest.failed + : 0; + if (!latest?.more || handled === 0) break; + } + return data; + }, + }); +} export const useDisconnectIntervals = () => useIntegrationMutation(() => diff --git a/apps/web/src/pages/club-manage.tsx b/apps/web/src/pages/club-manage.tsx index e229adc..b50eab4 100644 --- a/apps/web/src/pages/club-manage.tsx +++ b/apps/web/src/pages/club-manage.tsx @@ -1,6 +1,7 @@ import { type FormEvent, Suspense, lazy, useMemo, useState } from 'react'; import { Link, useNavigate, useParams } from 'react-router-dom'; import { + NO_CUTOFF, WEEKDAYS, formatMenuOptions, parseMenuOptions, @@ -113,7 +114,7 @@ function RepeatingRuns({ club }: { club: Club }) { startTime: String(form.get('startTime')), locationId: String(form.get('seriesLocation') ?? '') || null, notes: String(form.get('seriesNotes') ?? '') || null, - orderCutoffMin: Number(form.get('seriesCutoff') ?? 0), + orderCutoffMin: Number(form.get('seriesCutoff') ?? NO_CUTOFF), }, { onSuccess: () => setAdding(false) }, ); @@ -319,7 +320,7 @@ function OneOffRun({ club }: { club: Club }) { startsAt: zonedTime(day, time, club.timezone).toISOString(), locationId: String(form.get('runLocation') ?? '') || null, notes: String(form.get('runNotes') ?? '') || null, - orderCutoffMin: Number(form.get('runCutoff') ?? 0), + orderCutoffMin: Number(form.get('runCutoff') ?? NO_CUTOFF), }, { onSuccess: (run) => navigate(`/clubs/${club.id}/runs/${run.id}`) }, ); @@ -381,6 +382,7 @@ const hasMenu = (club: Club) => club.menu.some((i) => !i.hidden); /** Minutes before the start that coffee orders lock. */ const CUTOFFS: [number, string][] = [ + [NO_CUTOFF, 'No cut-off'], [0, 'When the run starts'], [15, '15 minutes before'], [30, '30 minutes before'], @@ -395,7 +397,7 @@ function CutoffSelect({ club, name }: { club: Club; name: string }) { - {CUTOFFS.map(([value, text]) => (