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]) => (