Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions apps/api/src/clubs/coffee.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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`, {
Expand Down
22 changes: 22 additions & 0 deletions apps/api/src/clubs/notices.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { eq } from 'drizzle-orm';
import { schema } from '@openrun/db';
import { NO_CUTOFF } from '@openrun/domain';
import {
clubRunDetailSchema,
clubSchema,
Expand Down Expand Up @@ -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', () => {
Expand Down
9 changes: 7 additions & 2 deletions apps/api/src/clubs/notices.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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}`,
Expand Down
3 changes: 2 additions & 1 deletion apps/api/src/clubs/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
describeWeather,
formatRule,
parseRule,
RUN_STILL_ON_MIN,
} from '@openrun/domain';
import {
type Attendee,
Expand Down Expand Up @@ -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 (
Expand Down
4 changes: 4 additions & 0 deletions apps/web-e2e/src/smoke.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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']) {
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/app/app.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -63,6 +64,7 @@ export function App() {
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
<FormHint />
<UpdatePrompt />
</>
);
Expand Down
58 changes: 58 additions & 0 deletions apps/web/src/components/form-hint.tsx
Original file line number Diff line number Diff line change
@@ -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<string | null>(null);

useEffect(() => {
let timer: ReturnType<typeof setTimeout> | 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 (
<div
role="alert"
className="fixed inset-x-4 bottom-24 z-40 mx-auto max-w-md rounded-2xl bg-red-600 p-3 text-sm font-medium text-white shadow-lg"
>
{hint}
</div>
);
}
2 changes: 1 addition & 1 deletion apps/web/src/components/intervals-settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -224,7 +224,7 @@ function Connected({ connection }: { connection: IntervalsConnection }) {
onChange={(e) => changeStart(e.target.value)}
/>
<p className="mt-1 text-xs text-stone-500">
Going further back fetches the older runs over the next few syncs.
Going further back fetches the older runs when you next sync.
</p>
</div>

Expand Down
39 changes: 34 additions & 5 deletions apps/web/src/lib/integrations.ts
Original file line number Diff line number Diff line change
@@ -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'];
Expand Down Expand Up @@ -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(() =>
Expand Down
9 changes: 6 additions & 3 deletions apps/web/src/pages/club-manage.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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) },
);
Expand Down Expand Up @@ -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}`) },
);
Expand Down Expand Up @@ -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'],
Expand All @@ -395,7 +397,7 @@ function CutoffSelect({ club, name }: { club: Club; name: string }) {
<label className="label" htmlFor={name}>
Coffee orders close
</label>
<select className="input" id={name} name={name} defaultValue={0}>
<select className="input" id={name} name={name} defaultValue={NO_CUTOFF}>
{CUTOFFS.map(([value, text]) => (
<option key={value} value={value}>
{text}
Expand All @@ -404,6 +406,7 @@ function CutoffSelect({ club, name }: { club: Club; name: string }) {
</select>
<p className="mt-1 text-xs text-stone-500">
After this the order list stops changing, ready to show at the café.
With no cut-off, people can still order after the run.
</p>
</div>
);
Expand Down
8 changes: 5 additions & 3 deletions apps/web/src/pages/club-run.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -225,9 +225,11 @@ function Coffee({ run, coffee }: { run: ClubRunDetail; coffee: RunCoffee }) {
{!picking && <FormError message={order.error?.message} />}

<p className="text-xs text-stone-500">
{coffee.open
? `Orders close ${closes}.`
: 'Orders have closed. The list is ready for the café.'}
{!coffee.open
? 'Orders have closed. The list is ready for the café.'
: coffee.closesAt > run.startsAt
? 'You can still order after the run.'
: `Orders close ${closes}.`}
</p>
</section>
);
Expand Down
4 changes: 4 additions & 0 deletions apps/web/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,10 @@
.input {
@apply w-full rounded-xl border border-stone-300 bg-white px-3 py-2.5 text-base outline-none focus:border-brand-500 focus:ring-2 focus:ring-brand-500/30 dark:border-stone-700 dark:bg-stone-900;
}
/* After someone has tried to send the form, not before. */
.input:user-invalid {
border-color: var(--color-red-500);
}
.btn-primary {
@apply inline-flex items-center justify-center gap-2 rounded-xl px-4 py-2.5 font-medium transition disabled:opacity-50 bg-brand-600 text-white hover:bg-brand-700;
}
Expand Down
8 changes: 8 additions & 0 deletions libs/domain/src/coffee.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {
describeChoices,
formatMenuOptions,
NO_CUTOFF,
ordersCloseAt,
parseMenuOptions,
resolveChoices,
Expand Down Expand Up @@ -51,6 +52,13 @@ describe('orders', () => {
);
});

it('stays open after the run with no cut-off', () => {
const start = new Date('2026-10-31T08:00:00Z');
expect(ordersCloseAt(start, NO_CUTOFF).toISOString()).toBe(
'2026-10-31T11:00:00.000Z',
);
});

it('sums up what to ask for at the counter', () => {
const oatLarge = [
{ option: 'Milk', choice: 'Oat' },
Expand Down
14 changes: 13 additions & 1 deletion libs/domain/src/coffee.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,19 @@ export function describeChoices(choices: OrderChoice[]): string {
return choices.map((c) => c.choice).join(', ');
}

/** When orders lock: `cutoffMinutes` before the run starts. */
/** A club run still counts as on, and shows as upcoming, this long after it starts. */
export const RUN_STILL_ON_MIN = 3 * 60;

/**
* The cut-off for "no cut-off": orders stay open after the run, until it
* stops showing as upcoming.
*/
export const NO_CUTOFF = -RUN_STILL_ON_MIN;

/**
* When orders lock: `cutoffMinutes` before the run starts, or after it for a
* negative cut-off (see NO_CUTOFF).
*/
export function ordersCloseAt(startsAt: Date, cutoffMinutes: number): Date {
return new Date(startsAt.getTime() - cutoffMinutes * 60_000);
}
Expand Down
Loading
Loading