feat(web): offer host duration choices and preserve booked lengths #87

Merged
eleith merged 1 commits from feat/host-duration-choices into main 2026-10-24 01:43:59 +00:00
24 changed files with 1423 additions and 158 deletions

No files matched your search

+7
View File
@@ -0,0 +1,7 @@
---
'@when/web': patch
'@when/worker': patch
'@when/cli': patch
---
Offer hosts a finite set of booking lengths alongside configured choices. Preserve the current appointment length by default when rescheduling, allowing other lengths only when the requester may choose them. Keep new-booking defaults explicit and the duration picker reachable when a chosen length has no availability.
+20
View File
@@ -42,6 +42,12 @@ schedules:
from: '09:00'
to: '17:00'
host-durations:
weekly:
- days: [mon, tue, wed, thu, fri]
from: '09:00'
to: '12:00'
meetings:
chat:
title: '30-minute chat'
@@ -67,6 +73,20 @@ meetings:
padding_after_minutes: 15
daily_booking_limit: 1
host-durations:
title: 'Host duration choices fixture'
visibility: 'unlisted'
duration_minutes: 60
additional_duration_minutes: [25]
require_approval: true
show_slots: true
schedule: 'host-durations'
booking_calendar: 'main'
notice_minutes: 0
padding_before_minutes: 0
padding_after_minutes: 0
daily_booking_limit: null
url:
app: 'http://localhost:4183'
worker: 'http://127.0.0.1:9'
+1 -1
View File
@@ -110,7 +110,7 @@ test('host adds beside a busy appointment despite guest buffers and daily cap',
expect(overlapRejected).toMatchObject({ type: 'failure', status: 409 });
const unsupported = await page.request.post(`/admin/schedule/${slug}`, {
...postOptions,
form: { ...form, slot: `${day}T15:00:00Z`, duration: '45' }
form: { ...form, slot: `${day}T15:00:00Z`, duration: '75' }
});
const durationRejected = await unsupported.json();
expect(durationRejected).toMatchObject({ type: 'failure', status: 400 });
+451
View File
@@ -0,0 +1,451 @@
import { expect, test, type Page, type Request } from '@playwright/test';
import { signInAsAdmin } from './support/auth.ts';
import { readAppointment } from './support/database.ts';
import { seedAppointment } from './support/seed.ts';
const slug = 'host-durations';
const hostDurations = [15, 25, 30, 45, 60, 90, 120, 180, 240, 300];
const postOptions = {
headers: { origin: 'http://localhost:4183', accept: 'application/json' },
maxRedirects: 0
};
const forgedHost = { role: 'host', viewer: 'host', isAdmin: 'true' };
function futureWeekday(offset: number, retry: number): string {
const date = new Date();
date.setUTCDate(date.getUTCDate() + offset);
for (let attempt = 0; attempt <= retry; attempt++) {
if (attempt > 0) date.setUTCDate(date.getUTCDate() + 1);
while (date.getUTCDay() === 0 || date.getUTCDay() === 6) {
date.setUTCDate(date.getUTCDate() + 1);
}
}
return date.toISOString().slice(0, 10);
}
function durationTrigger(page: Page, current: number) {
return page.getByRole('button', {
name: `Change duration, currently ${current} minutes`,
exact: true
});
}
async function chooseDuration(page: Page, current: number, next: number): Promise<void> {
await durationTrigger(page, current).click();
const dialog = page.getByRole('dialog', { name: 'How long?' });
await dialog.getByRole('button', { name: `${next} minutes`, exact: true }).click();
await expect(dialog).not.toBeVisible();
}
test('host uses the configured default and finite palette, recovering locally from no availability', async ({
page,
request
}, testInfo) => {
const day = futureWeekday(14, testInfo.retry);
await signInAsAdmin(page);
await page.goto(`/schedule/${slug}?date=${day}`);
await expect(durationTrigger(page, 60)).toBeVisible();
await page.getByRole('button', { name: 'Back', exact: true }).click();
await expect(page.getByRole('heading', { name: 'Step 1 of 3: Pick a day' })).toBeVisible();
await durationTrigger(page, 60).click();
const dialog = page.getByRole('dialog', { name: 'How long?' });
await expect(dialog.locator('.duration-row')).toHaveText(
hostDurations.map((duration) => `${duration} minutes`)
);
const durationRequests: string[] = [];
const trackDurationRequest = (request: Request) => {
if (request.isNavigationRequest() || ['fetch', 'xhr'].includes(request.resourceType())) {
durationRequests.push(request.url());
}
};
page.on('request', trackDurationRequest);
await dialog.getByRole('button', { name: '300 minutes', exact: true }).click();
await expect(
page.getByText('No availability in the near future.', { exact: true })
).toBeVisible();
await expect(durationTrigger(page, 300)).toBeVisible();
await chooseDuration(page, 300, 90);
await expect(page.getByRole('heading', { name: 'Step 1 of 3: Pick a day' })).toBeVisible();
await expect(page.getByText('No availability in the near future.', { exact: true })).toHaveCount(
0
);
await chooseDuration(page, 90, 300);
await expect(
page.getByText('No availability in the near future.', { exact: true })
).toBeVisible();
await chooseDuration(page, 300, 15);
await expect(page.getByRole('heading', { name: 'Step 1 of 3: Pick a day' })).toBeVisible();
await expect(durationTrigger(page, 15)).toBeVisible();
await chooseDuration(page, 15, 90);
await page.getByRole('button', { name: 'Continue', exact: true }).click();
await expect(page.getByRole('heading', { name: 'Step 2 of 3: Pick a time' })).toBeVisible();
await expect(durationTrigger(page, 90)).toBeVisible();
const target = page.getByRole('radio', { name: /10:30\s*AM\s*–\s*12:00\s*PM/i });
await expect(target).toBeVisible();
page.off('request', trackDurationRequest);
expect(durationRequests).toEqual([]);
await target.click();
await page.getByRole('button', { name: 'Confirm', exact: true }).click();
await expect(page.locator('#appointment-form')).toHaveAttribute(
'action',
`/admin/schedule/${slug}`
);
await expect(page.locator('input[name="duration"]')).toHaveValue('90');
const slot = await page.locator('input[name="slot"]').inputValue();
expect(Date.parse(slot)).toBe(Date.parse(`${day}T10:30:00Z`));
const form = {
slot,
duration: '90',
name: 'Host Duration Guest',
email: 'duration@example.test'
};
for (const client of [request, page.request]) {
// Neither a forged current length nor a real host cookie promotes the public action.
const rejected = await client.post(`/schedule/${slug}`, {
...postOptions,
form: { ...form, ...forgedHost, currentDuration: '90' }
});
const result = await rejected.json();
expect(result).toMatchObject({ type: 'failure', status: 400 });
}
await page.getByLabel('What is your name?').fill(form.name);
await page.getByLabel('What is your email?').fill(form.email);
await page.getByRole('button', { name: 'Request', exact: true }).click();
await page.waitForURL(/\/appointment\/[^/?]+$/);
const id = new URL(page.url()).pathname.split('/').at(-1)!;
const created = await readAppointment(id);
expect(created).toMatchObject({
event_type_id: slug,
status: 'confirmed',
guest_name: form.name,
guest_email: form.email
});
expect(Date.parse(created.start_time)).toBe(Date.parse(slot));
expect(Date.parse(created.end_time) - Date.parse(created.start_time)).toBe(90 * 60_000);
});
test('rescheduling defaults to the trusted current 90 for both roles and omission preserves it', async ({
page,
request
}, testInfo) => {
// Between the existing host-availability cases at roughly 40 and 50 days.
const day = futureWeekday(45, testInfo.retry);
const original = await seedAppointment({
eventTypeId: slug,
status: 'confirmed',
start: new Date(`${day}T09:00:00Z`),
durationMinutes: 90
});
const publicPath = `/appointment/${original.id}/reschedule`;
const link = `${publicPath}?token=${original.cancel_token}&date=${day}`;
await page.goto(link);
await expect(durationTrigger(page, 90)).toBeVisible();
await durationTrigger(page, 90).click();
const dialog = page.getByRole('dialog', { name: 'How long?' });
await expect(dialog.locator('.duration-row')).toHaveText([
'60 minutes',
'25 minutes',
'90 minutes'
]);
await expect(dialog.getByRole('button', { name: '120 minutes', exact: true })).toHaveCount(0);
await dialog.getByRole('button', { name: 'Close', exact: true }).click();
const form = {
slot: `${day}T10:30:00Z`,
token: original.cancel_token,
name: original.guest_name,
email: original.guest_email!,
timezone: 'UTC',
reschedule_reason: 'Keep the booked length at a new time'
};
const forged = { ...form, ...forgedHost, currentDuration: '120', duration: '120' };
const rejected = await request.post(publicPath, { ...postOptions, form: forged });
const rejectedResult = await rejected.json();
expect(rejectedResult).toMatchObject({ type: 'failure', status: 400 });
const sameStart = await request.post(publicPath, {
...postOptions,
form: { ...form, slot: original.start_time, duration: '25' }
});
const sameStartResult = await sameStart.json();
expect(sameStartResult).toMatchObject({ type: 'failure', status: 400 });
await signInAsAdmin(page);
await page.goto(link);
await expect(durationTrigger(page, 90)).toBeVisible();
await durationTrigger(page, 90).click();
await expect(dialog.locator('.duration-row')).toHaveText(
hostDurations.map((duration) => `${duration} minutes`)
);
await dialog.getByRole('button', { name: 'Close', exact: true }).click();
const hostOnPublic = await page.request.post(publicPath, { ...postOptions, form: forged });
const hostOnPublicResult = await hostOnPublic.json();
expect(hostOnPublicResult).toMatchObject({ type: 'failure', status: 400 });
const hostSameStart = await page.request.post(`/admin/appointment/${original.id}/reschedule`, {
...postOptions,
form: { ...form, slot: original.start_time, duration: '25' }
});
const hostSameStartResult = await hostSameStart.json();
expect(hostSameStartResult).toMatchObject({ type: 'failure', status: 400 });
const unchanged = await readAppointment(original.id);
expect(unchanged).toEqual(original);
// Omit duration entirely; even a forged currentDuration must not replace the row's length.
const moved = await request.post(publicPath, {
...postOptions,
form: { ...form, ...forgedHost, currentDuration: '120' }
});
const redirect = await moved.json();
expect(redirect).toMatchObject({ type: 'redirect', status: 303 });
expect(redirect.location).toMatch(/^\/appointment\/[^/?]+\?token=.+/);
const id = new URL(redirect.location, 'http://localhost:4183').pathname.split('/').at(-1)!;
expect(id).not.toBe(original.id);
const old = await readAppointment(original.id);
const next = await readAppointment(id);
expect(old.status).toBe('rescheduled');
expect(next).toMatchObject({
event_type_id: slug,
status: 'pending',
origin_id: original.id,
guest_name: original.guest_name,
guest_email: original.guest_email,
guest_timezone: 'UTC'
});
expect(Date.parse(next.start_time)).toBe(Date.parse(form.slot));
expect(Date.parse(next.end_time) - Date.parse(next.start_time)).toBe(90 * 60_000);
expect(next.cancel_token).not.toBe(original.cancel_token);
expect(redirect.location).toBe(
`/appointment/${id}?token=${encodeURIComponent(next.cancel_token)}`
);
});
test('guest can change a booked host-only 90 to configured 25, but cannot invent 75', async ({
page,
request
}, testInfo) => {
const day = futureWeekday(24, testInfo.retry);
const original = await seedAppointment({
eventTypeId: slug,
status: 'confirmed',
start: new Date(`${day}T09:00:00Z`),
durationMinutes: 90
});
const publicPath = `/appointment/${original.id}/reschedule`;
const reason = 'Use the shorter configured length';
const rejected = await request.post(publicPath, {
...postOptions,
form: {
slot: `${day}T10:30:00Z`,
duration: '75',
currentDuration: '75',
...forgedHost,
token: original.cancel_token,
name: original.guest_name,
email: original.guest_email!,
reschedule_reason: reason
}
});
const rejectedResult = await rejected.json();
expect(rejectedResult).toMatchObject({ type: 'failure', status: 400 });
const unchanged = await readAppointment(original.id);
expect(unchanged).toEqual(original);
await page.goto(`${publicPath}?token=${original.cancel_token}&date=${day}`);
await expect(durationTrigger(page, 90)).toBeVisible();
await chooseDuration(page, 90, 25);
await expect(durationTrigger(page, 25)).toBeVisible();
await page.getByRole('radio', { name: /10:30\s*AM\s*–\s*10:55\s*AM/i }).click();
await page.getByRole('button', { name: 'Confirm', exact: true }).click();
await expect(page.locator('#appointment-form')).toHaveAttribute('action', publicPath);
await expect(page.locator('input[name="duration"]')).toHaveValue('25');
await expect(page.getByLabel('What is your name?')).toHaveValue(original.guest_name);
await expect(page.getByLabel('What is your email?')).toHaveValue(original.guest_email!);
const slot = await page.locator('input[name="slot"]').inputValue();
await page.getByLabel('Reason for rescheduling').fill(reason);
await page.getByRole('button', { name: 'Confirm Reschedule', exact: true }).click();
await page.waitForURL(/\/appointment\/[^/?]+\?token=.+/);
const id = new URL(page.url()).pathname.split('/').at(-1)!;
expect(id).not.toBe(original.id);
const old = await readAppointment(original.id);
const next = await readAppointment(id);
expect(old.status).toBe('rescheduled');
expect(next).toMatchObject({
event_type_id: slug,
status: 'pending',
origin_id: original.id,
guest_name: original.guest_name,
guest_email: original.guest_email
});
expect(Date.parse(next.start_time)).toBe(Date.parse(slot));
expect(Date.parse(next.start_time)).not.toBe(Date.parse(original.start_time));
expect(Date.parse(next.end_time) - Date.parse(next.start_time)).toBe(25 * 60_000);
});
test('an empty weekend timeline keeps its duration toolbar usable without fetching', async ({
page
}, testInfo) => {
const saturday = new Date();
saturday.setUTCDate(saturday.getUTCDate() + 7 + testInfo.retry * 7);
while (saturday.getUTCDay() !== 6) {
saturday.setUTCDate(saturday.getUTCDate() + 1);
}
const day = saturday.toISOString().slice(0, 10);
await signInAsAdmin(page);
await page.context().addCookies([
{
name: 'tz',
value: encodeURIComponent('Pacific/Kiritimati'),
url: 'http://localhost:4183'
}
]);
// Friday's UTC window reaches Saturday here, letting the public UI open that day.
await page.goto(`/schedule/${slug}?date=${day}`);
await expect(page.getByRole('heading', { name: 'Step 2 of 3: Pick a time' })).toBeVisible();
await expect(page.getByRole('radio').first()).toBeVisible();
const durationRequests: string[] = [];
const trackDurationRequest = (request: Request) => {
if (request.isNavigationRequest() || ['fetch', 'xhr'].includes(request.resourceType())) {
durationRequests.push(request.url());
}
};
page.on('request', trackDurationRequest);
await page.getByRole('button', { name: /^Change timezone, currently/ }).click();
const timezoneDialog = page.getByRole('dialog', { name: 'Choose timezone' });
await timezoneDialog.getByRole('searchbox').fill('Abidjan');
await timezoneDialog.getByRole('button', { name: /^Abidjan\b/ }).click();
await expect(timezoneDialog).not.toBeVisible();
// Abidjan is UTC year-round: Saturday now has no working-window geometry.
await expect(page.locator('.timeline-scroll')).toHaveCount(0);
await expect(page.getByText('No available times on this date.', { exact: true })).toBeVisible();
await expect(page.getByRole('group', { name: 'Duration selector' })).toBeVisible();
await expect(durationTrigger(page, 60)).toBeVisible();
await chooseDuration(page, 60, 90);
await expect(durationTrigger(page, 90)).toBeVisible();
await expect(page.getByText('No available times on this date.', { exact: true })).toBeVisible();
await chooseDuration(page, 90, 25);
await expect(durationTrigger(page, 25)).toBeVisible();
await expect(page.locator('.timeline-scroll')).toHaveCount(0);
await expect(page.getByText('No available times on this date.', { exact: true })).toBeVisible();
await expect(page.getByText('No availability in the near future.', { exact: true })).toHaveCount(
0
);
await page.getByRole('button', { name: 'Back', exact: true }).click();
await expect(
page.locator('.cal-day:not([data-unavailable]):not([data-disabled])').first()
).toBeVisible();
page.off('request', trackDurationRequest);
expect(durationRequests).toEqual([]);
});
test('client navigation between meeting slugs replaces duration defaults, choices and maps', async ({
page
}, testInfo) => {
const day = futureWeekday(20, testInfo.retry);
await signInAsAdmin(page);
await page.goto(`/schedule/${slug}?date=${day}`);
await chooseDuration(page, 60, 90);
await expect(durationTrigger(page, 90)).toBeVisible();
const lateSlot = page.getByRole('radio', { name: /0?4:30\s*PM\s*–\s*0?5:00\s*PM/i });
await expect(lateSlot).toHaveCount(0);
await page.evaluate((href) => {
const anchor = document.createElement('a');
anchor.id = 'e2e-switch-meeting';
anchor.href = href;
anchor.textContent = 'Switch to chat fixture';
anchor.setAttribute('data-sveltekit-preload-data', 'off');
document.body.append(anchor);
}, `/schedule/chat?date=${day}`);
const documentRequests: string[] = [];
const trackDocumentRequest = (request: Request) => {
if (request.isNavigationRequest()) documentRequests.push(request.url());
};
page.on('request', trackDocumentRequest);
await page.getByRole('link', { name: 'Switch to chat fixture', exact: true }).click();
await expect(page).toHaveURL(`/schedule/chat?date=${day}`);
await expect(durationTrigger(page, 30)).toBeVisible();
// The body sentinel survives client routing, but would disappear on a document reload.
await expect(page.locator('#e2e-switch-meeting')).toHaveCount(1);
page.off('request', trackDocumentRequest);
expect(documentRequests).toEqual([]);
await durationTrigger(page, 30).click();
const dialog = page.getByRole('dialog', { name: 'How long?' });
await expect(dialog.locator('.duration-row')).toHaveText(
hostDurations.filter((duration) => duration !== 25).map((duration) => `${duration} minutes`)
);
await expect(dialog.getByRole('button', { name: '25 minutes', exact: true })).toHaveCount(0);
await dialog.getByRole('button', { name: 'Close', exact: true }).click();
await expect(lateSlot).toBeVisible();
});
test('an enhanced host booking conflict preserves entered fields and the chosen length', async ({
page
}, testInfo) => {
const day = futureWeekday(34, testInfo.retry);
await signInAsAdmin(page);
await page.goto(`/schedule/${slug}?date=${day}`);
await chooseDuration(page, 60, 90);
await page.getByRole('radio', { name: /10:30\s*AM\s*–\s*12:00\s*PM/i }).click();
await page.getByRole('button', { name: 'Confirm', exact: true }).click();
const name = 'Retained Host Guest';
const email = 'retained-duration@example.test';
await page.getByLabel('What is your name?').fill(name);
await page.getByLabel('What is your email?').fill(email);
await expect(page.locator('input[name="duration"]')).toHaveValue('90');
const slot = await page.locator('input[name="slot"]').inputValue();
const blocker = await seedAppointment({
eventTypeId: slug,
status: 'confirmed',
start: new Date(slot),
durationMinutes: 90
});
const responsePromise = page.waitForResponse((response) => {
const url = new URL(response.url());
return response.request().method() === 'POST' && url.pathname === `/admin/schedule/${slug}`;
});
await page.getByRole('button', { name: 'Request', exact: true }).click();
const response = await responsePromise;
const result = await response.json();
expect(result).toMatchObject({ type: 'failure', status: 409 });
// Default enhance does not apply a different route's failure to the current page's form prop.
expect(result.data).toContain('That time is no longer available. Please pick another.');
await expect(page.getByRole('heading', { name: 'Step 3 of 3: Enter your info' })).toBeVisible();
await expect(page.getByLabel('What is your name?')).toHaveValue(name);
await expect(page.getByLabel('What is your email?')).toHaveValue(email);
await expect(page.locator('input[name="duration"]')).toHaveValue('90');
await expect(page.locator('input[name="slot"]')).toHaveValue(slot);
// Re-run the same identity's load through public routing, not a document reload.
await page.evaluate((href) => {
const anchor = document.createElement('a');
anchor.id = 'e2e-refresh-meeting';
anchor.href = href;
anchor.textContent = 'Refresh this meeting fixture';
anchor.setAttribute('data-sveltekit-preload-data', 'off');
document.body.append(anchor);
}, `/schedule/${slug}?duration=90&date=${day}`);
const historyLength = await page.evaluate(() => history.length);
const loadPromise = page.waitForResponse((loadResponse) => {
const url = new URL(loadResponse.url());
return (
loadResponse.request().method() === 'GET' && url.pathname === `/schedule/${slug}/__data.json`
);
});
await page.getByRole('link', { name: 'Refresh this meeting fixture', exact: true }).click();
const loadResponse = await loadPromise;
expect(loadResponse.ok()).toBe(true);
await expect.poll(() => page.evaluate(() => history.length)).toBe(historyLength + 1);
await expect(page.locator('#e2e-refresh-meeting')).toHaveCount(1);
await expect(page.getByRole('heading', { name: 'Step 3 of 3: Enter your info' })).toBeVisible();
await expect(page.getByLabel('What is your name?')).toHaveValue(name);
await expect(page.getByLabel('What is your email?')).toHaveValue(email);
await expect(page.locator('input[name="duration"]')).toHaveValue('90');
await expect(page.locator('input[name="slot"]')).toHaveValue(slot);
const unchangedBlocker = await readAppointment(blocker.id);
expect(unchangedBlocker).toEqual(blocker);
// This checks preservation only, not the deferred same-identity slot-map resync.
});
@@ -2,6 +2,7 @@
import { page } from '$app/state';
import DatePicker from '$lib/components/DatePicker.svelte';
import DayTimeline from '$lib/components/DayTimeline.svelte';
import DurationDialog from '$lib/components/DurationDialog.svelte';
import CalendarSkeleton from '$lib/components/CalendarSkeleton.svelte';
import TimelineSkeleton from '$lib/components/TimelineSkeleton.svelte';
import RescheduleBanner from '$lib/components/RescheduleBanner.svelte';
@@ -68,7 +69,10 @@
const durations = data.eventType.durations;
// svelte-ignore state_referenced_locally
const slotsByDuration = data.availability.slotsByDuration;
const initialDuration = durations.includes(durationParam) ? durationParam : durations[0];
// svelte-ignore state_referenced_locally
const initialDuration = durations.includes(durationParam)
? durationParam
: data.eventType.duration_minutes;
const flow = createAppointmentFlow({ slotsByDuration, durations, initialDuration });
const ptz = getPreferredTimezone();
@@ -78,6 +82,7 @@
let selectedSlot = $derived(flow.selectedSlot);
let userTz = $derived(flow.userTz);
let isGuestFormValid = $state(false);
let durationOpen = $state(false);
let formAction = $derived.by(() => {
if (isHost) {
@@ -129,7 +134,7 @@
const urlSync = createAppointmentUrlSync(flow, {
deepLink,
tz: ptz.current ?? data.user.timezone,
defaultDuration: durations[0]
defaultDuration: data.eventType.duration_minutes
});
let linkNotice = $derived(urlSync.notice);
</script>
@@ -161,6 +166,18 @@
</header>
<main class="appointment">
{#if !rescheduleError && durations.length > 1 && (step === 1 || flow.availableDates.size === 0)}
<button
type="button"
class="duration-trigger"
onclick={() => (durationOpen = true)}
aria-label="Change duration, currently {flow.duration} minutes"
aria-haspopup="dialog"
>
Change duration · {flow.duration} min
</button>
{/if}
{#if rescheduleError}
<div class="card reschedule-error-card">
<h1 class="error-title">Can't reschedule this appointment</h1>
@@ -274,6 +291,13 @@
{/if}
</main>
<DurationDialog
bind:open={durationOpen}
{durations}
value={flow.duration}
onSelect={flow.setDuration}
/>
<style>
.appointment {
max-width: 960px;
@@ -282,6 +306,29 @@
color: var(--when-color-text);
}
.duration-trigger {
display: block;
min-height: calc(var(--space-8) + var(--space-4));
margin: var(--space-4) auto;
padding: var(--space-2) var(--space-4);
background: var(--color-surface);
border: none;
border-radius: var(--radius);
font: inherit;
font-size: var(--font-size-sm);
color: var(--when-color-primary);
cursor: pointer;
}
.duration-trigger:hover {
background: var(--color-surface-active);
}
.duration-trigger:focus-visible {
outline: none;
box-shadow: var(--shadow-focus);
}
.empty {
text-align: center;
color: var(--color-text-muted);
+16 -8
View File
@@ -280,8 +280,8 @@
}
</script>
{#if viewDate && timeline}
<div class="timeline-container">
<div class="timeline-container">
{#if viewDate}
<div class="slots-header">
<button
type="button"
@@ -330,6 +330,8 @@
</div>
{/if}
</div>
{/if}
{#if timeline}
<div class="timeline-scroll">
<div class="timeline" style:height="{(timeline.totalMs / 3600000) * 96}px">
{#each timeline.labels as { label, top } (label)}
@@ -392,12 +394,18 @@
</div>
</div>
</div>
</div>
{:else}
<div class="timeline-container empty-state">
<p>Select a highlighted date to see available times.</p>
</div>
{/if}
{:else}
<div class="empty-state">
<p>
{#if viewDate}
No available times on this date.
{:else}
Select a highlighted date to see available times.
{/if}
</p>
</div>
{/if}
</div>
{#snippet discreteSlots()}
{#if timeline}
@@ -1,6 +1,7 @@
import { expect, test } from 'vitest';
import { describe, expect, test } from 'vitest';
import type { Meeting } from '@when/config';
import { resolveDuration } from './duration';
import type { Appointment } from '@when/db';
import { resolveBookingDurations, resolveDuration } from './duration';
import { validConfig } from '$lib/server/__fixtures__/valid-config';
const base: Meeting = {
@@ -8,6 +9,7 @@ const base: Meeting = {
title: 'Chat',
booking_calendar: 'cal'
};
const hostPalette = [15, 30, 45, 60, 90, 120, 180, 240, 300];
function fd(entries: Record<string, string>): FormData {
const f = new FormData();
@@ -15,34 +17,159 @@ function fd(entries: Record<string, string>): FormData {
return f;
}
test('single-length meeting: defaults to the only length when none submitted', () => {
expect(resolveDuration(base, fd({}))).toBe(30);
function currentDuration(minutes: number): Pick<Appointment, 'start_time' | 'end_time'> {
const start = Temporal.Instant.from('2030-06-03T10:00:00Z');
return { start_time: start.toString(), end_time: start.add({ minutes }).toString() };
}
describe('resolveBookingDurations', () => {
test('hosts get the full palette union configured extras, deduplicated without mutating config', () => {
const meeting = {
...base,
duration_minutes: 60,
additional_duration_minutes: [75, 15, 17, 120]
};
const original = structuredClone(meeting);
const profile = resolveBookingDurations(meeting, 'host');
expect(profile).toEqual({
defaultDuration: 60,
durations: [15, 17, 30, 45, 60, 75, 90, 120, 180, 240, 300]
});
expect(meeting).toEqual(original);
expect(profile.durations).not.toBe(meeting.additional_duration_minutes);
expect(resolveBookingDurations(meeting, 'host')).toEqual(profile);
});
test('guest choices retain configured order, not the host palette or numeric order', () => {
const meeting = { ...base, duration_minutes: 60, additional_duration_minutes: [15, 75, 30] };
const original = structuredClone(meeting);
expect(resolveBookingDurations(meeting, 'guest')).toEqual({
defaultDuration: 60,
durations: [60, 15, 75, 30]
});
expect(meeting).toEqual(original);
});
test('a new single-length meeting offers one guest length and the full host palette', () => {
expect(resolveBookingDurations(base, 'guest')).toEqual({
defaultDuration: 30,
durations: [30]
});
expect(resolveBookingDurations(base, 'host')).toEqual({
defaultDuration: 30,
durations: hostPalette
});
});
test.each(['guest', 'host'] as const)(
'%s retains the actual current 90 or non-palette 85 as its default',
(role) => {
for (const minutes of [90, 85]) {
const current = currentDuration(minutes);
const original = structuredClone(current);
const profile = resolveBookingDurations(base, role, current);
expect(profile.defaultDuration).toBe(minutes);
expect(profile.durations).toEqual(
role === 'guest'
? [30, minutes]
: minutes === 90
? hostPalette
: [15, 30, 45, 60, 85, 90, 120, 180, 240, 300]
);
expect(current).toEqual(original);
}
}
);
test('retaining a configured guest length does not duplicate or reorder it', () => {
const meeting = { ...base, duration_minutes: 60, additional_duration_minutes: [15, 30] };
expect(resolveBookingDurations(meeting, 'guest', currentDuration(30))).toEqual({
defaultDuration: 30,
durations: [60, 15, 30]
});
});
test.each([
['spring forward', '2030-03-10T01:30:00-05:00', '2030-03-10T04:00:00-04:00', 90],
['fall back', '2030-11-03T01:30:00-04:00', '2030-11-03T01:55:00-05:00', 85],
['seconds across DST', '2030-03-10T01:45:30-05:00', '2030-03-10T04:10:30-04:00', 85],
['equivalent offsets', '2030-06-03T12:00:00+02:00', '2030-06-03T11:25:00+00:00', 85]
])('uses elapsed instants without rounding for %s', (_label, start_time, end_time, minutes) => {
const current = { start_time: String(start_time), end_time: String(end_time) };
for (const role of ['guest', 'host'] as const) {
const profile = resolveBookingDurations(base, role, current);
expect(profile.defaultDuration).toBe(minutes);
expect(profile.durations).toContain(minutes);
expect(resolveDuration(base, role, fd({}), current)).toBe(minutes);
expect(resolveDuration(base, role, fd({ duration: String(minutes) }), current)).toBe(minutes);
}
});
});
test('array meeting: defaults to the first length when none submitted', () => {
expect(
resolveDuration(
{ ...base, duration_minutes: 45, additional_duration_minutes: [15, 60] },
fd({})
)
).toBe(45);
});
describe('resolveDuration', () => {
test.each(['guest', 'host'] as const)(
'%s defaults missing and empty submissions to configured default, not the first host choice',
(role) => {
const meeting = { ...base, duration_minutes: 60, additional_duration_minutes: [15, 30] };
expect(resolveDuration(base, role, fd({}))).toBe(30);
expect(resolveDuration(meeting, role, fd({}))).toBe(60);
expect(resolveDuration(meeting, role, fd({ duration: '' }))).toBe(60);
}
);
test('array meeting: accepts a submitted length that is offered', () => {
expect(
resolveDuration(
{ ...base, duration_minutes: 15, additional_duration_minutes: [30, 60] },
fd({ duration: '60' })
)
).toBe(60);
});
test('guests can choose configured lengths but not new host-only lengths', () => {
const meeting = { ...base, duration_minutes: 15, additional_duration_minutes: [30, 60] };
expect(resolveDuration(meeting, 'guest', fd({ duration: '60' }))).toBe(60);
expect(resolveDuration(meeting, 'guest', fd({ duration: '45' }))).toBeNull();
expect(resolveDuration(meeting, 'guest', fd({ duration: '90' }))).toBeNull();
});
test('returns null when a submitted length is not offered', () => {
expect(
resolveDuration(
{ ...base, duration_minutes: 15, additional_duration_minutes: [30] },
fd({ duration: '45' })
)
).toBeNull();
expect(resolveDuration(base, fd({ duration: '999' }))).toBeNull();
test('hosts can submit every palette length and configured extras, but not arbitrary lengths', () => {
const meeting = { ...base, additional_duration_minutes: [75] };
for (const minutes of [...hostPalette, 75]) {
expect(resolveDuration(meeting, 'host', fd({ duration: String(minutes) }))).toBe(minutes);
}
for (const role of ['guest', 'host'] as const) {
for (const duration of ['17', '999', '0', '-30', 'not-a-number']) {
expect(resolveDuration(meeting, role, fd({ duration }))).toBeNull();
}
}
});
test.each(['guest', 'host'] as const)(
'%s can keep 90 and 85 with missing, empty, or explicit duration',
(role) => {
for (const minutes of [90, 85]) {
const current = currentDuration(minutes);
expect(resolveDuration(base, role, fd({}), current)).toBe(minutes);
expect(resolveDuration(base, role, fd({ duration: '' }), current)).toBe(minutes);
expect(resolveDuration(base, role, fd({ duration: String(minutes) }), current)).toBe(
minutes
);
}
}
);
test('a guest retaining 90 can change to configured 30, not host-only 120; hosts may choose 120', () => {
const current = currentDuration(90);
expect(resolveDuration(base, 'guest', fd({ duration: '120' }), current)).toBeNull();
expect(resolveDuration(base, 'guest', fd({ duration: '30' }), current)).toBe(30);
expect(resolveDuration(base, 'host', fd({ duration: '120' }), current)).toBe(120);
});
test('forged role and current-duration fields cannot replace the trusted role or server row', () => {
const current = currentDuration(85);
const forged = {
role: 'host',
currentDuration: '120',
start_time: '2030-06-03T10:00:00Z',
end_time: '2030-06-03T12:00:00Z'
};
expect(resolveDuration(base, 'guest', fd(forged), current)).toBe(85);
expect(resolveDuration(base, 'guest', fd({ ...forged, duration: '' }), current)).toBe(85);
expect(resolveDuration(base, 'guest', fd({ ...forged, duration: '120' }), current)).toBeNull();
expect(resolveDuration(base, 'guest', fd({ ...forged, duration: '85' }), current)).toBe(85);
expect(resolveDuration(base, 'guest', fd(forged))).toBe(30);
expect(resolveDuration(base, 'guest', fd({ ...forged, duration: '120' }))).toBeNull();
});
});
@@ -1,10 +1,44 @@
import { durationsOf, type Meeting } from '@when/config';
import type { Appointment } from '@when/db';
import type { AppointmentRole } from './actions';
// Missing → the default (first) length; a submitted value the meeting doesn't offer → null.
export function resolveDuration(eventType: Meeting, form: FormData): number | null {
const durations = durationsOf(eventType);
const HOST_DURATIONS = [15, 30, 45, 60, 90, 120, 180, 240, 300];
export interface BookingDurations {
defaultDuration: number;
durations: number[];
}
export function resolveBookingDurations(
eventType: Meeting,
role: AppointmentRole,
currentAppointment: Pick<Appointment, 'start_time' | 'end_time'> | null = null
): BookingDurations {
const defaultDuration = currentAppointment
? (Date.parse(currentAppointment.end_time) - Date.parse(currentAppointment.start_time)) / 60_000
: eventType.duration_minutes;
const choices = durationsOf(eventType);
if (role === 'host') choices.push(...HOST_DURATIONS);
// Keeping a booked length is not permission to choose another host-only length.
choices.push(defaultDuration);
const durations = [...new Set(choices)];
if (role === 'host') durations.sort((a, b) => a - b);
return { defaultDuration, durations };
}
export function resolveDuration(
eventType: Meeting,
role: AppointmentRole,
form: FormData,
currentAppointment: Pick<Appointment, 'start_time' | 'end_time'> | null = null
): number | null {
const { defaultDuration, durations } = resolveBookingDurations(
eventType,
role,
currentAppointment
);
const raw = form.get('duration');
if (raw === null || raw === '') return durations[0];
if (raw === null || raw === '') return defaultDuration;
const value = Number(raw);
return durations.includes(value) ? value : null;
}
@@ -1,5 +1,6 @@
import { describe, expect, test } from 'vitest';
import { toPublicEventType, toPublicAppointment } from './sanitize';
import { resolveBookingDurations } from './duration';
import type { Meeting } from '@when/config';
import type { Appointment } from '@when/db';
import { validConfig } from '$lib/server/__fixtures__/valid-config';
@@ -40,39 +41,40 @@ const baseRow: Appointment = {
describe('toPublicEventType', () => {
test('hides location for guests', () => {
const res = toPublicEventType('chat', baseEventType, 'guest');
const profile = resolveBookingDurations(baseEventType, 'guest');
const res = toPublicEventType('chat', baseEventType, 'guest', profile);
expect(res.location).toBeUndefined();
});
test('shows location for hosts', () => {
const res = toPublicEventType('chat', baseEventType, 'host');
const profile = resolveBookingDurations(baseEventType, 'host');
const res = toPublicEventType('chat', baseEventType, 'host', profile);
expect(res.location).toBe('https://meet.example.com/jane');
});
test('applies default padding and notice values', () => {
const res = toPublicEventType('chat', baseEventType, 'guest');
const profile = resolveBookingDurations(baseEventType, 'guest');
const res = toPublicEventType('chat', baseEventType, 'guest', profile);
expect(res.padding_before_minutes).toBe(0);
expect(res.padding_after_minutes).toBe(0);
expect(res.notice_minutes).toBe(120);
});
test('passes through explicit padding and notice values', () => {
const res = toPublicEventType(
'chat',
{
...baseEventType,
padding_before_minutes: 15,
padding_after_minutes: 10,
notice_minutes: 60
},
'guest'
);
const meeting = {
...baseEventType,
padding_before_minutes: 15,
padding_after_minutes: 10,
notice_minutes: 60
};
const profile = resolveBookingDurations(meeting, 'guest');
const res = toPublicEventType('chat', meeting, 'guest', profile);
expect(res.padding_before_minutes).toBe(15);
expect(res.padding_after_minutes).toBe(10);
expect(res.notice_minutes).toBe(60);
});
test('projects effective host padding without changing durations, notice, privacy, or config', () => {
test('projects effective host padding and role-specific choices without changing notice, privacy, or config', () => {
const cfg = structuredClone(validConfig);
const meeting = cfg.meetings['30-min-chat'];
Object.assign(meeting, {
@@ -84,9 +86,10 @@ describe('toPublicEventType', () => {
additional_duration_minutes: [15, 60]
});
const original = structuredClone(cfg);
const guest = toPublicEventType('chat', meeting, 'guest');
const host = toPublicEventType('chat', meeting, 'host');
const guestProfile = resolveBookingDurations(meeting, 'guest');
const hostProfile = resolveBookingDurations(meeting, 'host');
const guest = toPublicEventType('chat', meeting, 'guest', guestProfile);
const host = toPublicEventType('chat', meeting, 'host', hostProfile);
expect(guest.padding_before_minutes).toBe(15);
expect(guest.padding_after_minutes).toBe(10);
@@ -96,21 +99,64 @@ describe('toPublicEventType', () => {
expect(guest.notice_minutes).toBe(60);
expect(host).toEqual({
...guest,
durations: [15, 30, 45, 60, 90, 120, 180, 240, 300],
location: meeting.location,
padding_before_minutes: 0,
padding_after_minutes: 0
});
expect(toPublicEventType('chat', meeting, 'guest')).toEqual(guest);
expect(host.durations).toBe(hostProfile.durations);
expect(guest.durations).toBe(guestProfile.durations);
expect(toPublicEventType('chat', meeting, 'guest', guestProfile)).toEqual(guest);
expect(cfg).toEqual(original);
});
test.each(['guest', 'host'] as const)(
'%s consumes the supplied profile default and exact choice array instead of recomputing them',
(role) => {
const meeting = { ...baseEventType, duration_minutes: 60, additional_duration_minutes: [15] };
const profile = { defaultDuration: 85, durations: [60, 15, 85] };
const originalMeeting = structuredClone(meeting);
const originalProfile = structuredClone(profile);
const res = toPublicEventType('chat', meeting, role, profile);
expect(res.duration_minutes).toBe(85);
expect(res.durations).toBe(profile.durations);
expect(res.durations).toEqual([60, 15, 85]);
expect(meeting).toEqual(originalMeeting);
expect(profile).toEqual(originalProfile);
}
);
test('creation projects configured 60 as default even though the host choices start at 15', () => {
const meeting = { ...baseEventType, duration_minutes: 60 };
const profile = resolveBookingDurations(meeting, 'host');
const res = toPublicEventType('chat', meeting, 'host', profile);
expect(res.duration_minutes).toBe(60);
expect(res.durations[0]).toBe(15);
});
test.each(['guest', 'host'] as const)(
'%s reschedule projects actual current 90 instead of configured 30',
(role) => {
const current = { ...baseRow, end_time: '2026-05-01T16:30:00Z' };
const profile = resolveBookingDurations(baseEventType, role, current);
const res = toPublicEventType('chat', baseEventType, role, profile);
expect(res.duration_minutes).toBe(90);
expect(res.durations).toBe(profile.durations);
expect(res.durations).toContain(30);
expect(res.durations).toContain(90);
}
);
test('maps show_slots', () => {
const res = toPublicEventType('chat', { ...baseEventType, show_slots: true }, 'guest');
const meeting = { ...baseEventType, show_slots: true };
const profile = resolveBookingDurations(meeting, 'guest');
const res = toPublicEventType('chat', meeting, 'guest', profile);
expect(res.show_slots).toBe(true);
});
test('passes through require_approval', () => {
const res = toPublicEventType('chat', baseEventType, 'guest');
const profile = resolveBookingDurations(baseEventType, 'guest');
const res = toPublicEventType('chat', baseEventType, 'guest', profile);
expect(res.require_approval).toBe(false);
});
});
@@ -1,7 +1,8 @@
import { durationsOf, parseGuestAnswers, type Meeting } from '@when/config';
import { parseGuestAnswers, type Meeting } from '@when/config';
import { parseActionLog, type ActionLogEntry, type Appointment } from '@when/db';
import { resolveAvailabilityLimits } from '../availability/settings';
import type { AppointmentRole } from './actions';
import type { BookingDurations } from './duration';
export interface PublicEventType extends Pick<
Meeting,
@@ -16,8 +17,8 @@ export interface PublicEventType extends Pick<
| 'notice_minutes'
> {
slug: string; // the meeting's config key, which is its booking page URL
duration_minutes: number; // the default length
durations: number[]; // every offered length, default first
duration_minutes: number; // the effective default: configured for booking, current for rescheduling
durations: number[]; // every offered length, independent of the default's position
}
export interface PublicAppointment {
@@ -37,14 +38,15 @@ export interface PublicAppointment {
export function toPublicEventType(
slug: string,
eventType: Meeting,
role: AppointmentRole
role: AppointmentRole,
bookingDurations: BookingDurations
): PublicEventType {
const limits = resolveAvailabilityLimits(eventType, role);
return {
slug,
title: eventType.title,
duration_minutes: eventType.duration_minutes,
durations: durationsOf(eventType),
duration_minutes: bookingDurations.defaultDuration,
durations: bookingDurations.durations,
description: eventType.description,
visibility: eventType.visibility,
require_approval: eventType.require_approval,
@@ -1,6 +1,8 @@
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import type { WhenConfiguration } from '@when/config';
import type { Appointment } from '@when/db';
import { validConfig } from '$lib/server/__fixtures__/valid-config';
import { resolveBookingDurations, resolveDuration } from '../appointment/duration';
const h = vi.hoisted(() => ({ nowMs: 0 }));
vi.mock('$lib/server/clock', () => ({ systemClock: { nowMs: () => h.nowMs } }));
@@ -23,6 +25,20 @@ const CHAT = '30-min-chat';
const EDGE_START = `${DAY}T16:30:00Z`;
let db: ReturnType<typeof openDb>;
function currentAppointment(
start: string,
minutes = 30
): Pick<Appointment, 'start_time' | 'end_time'> {
const instant = Temporal.Instant.from(start);
return { start_time: start, end_time: instant.add({ minutes }).toString() };
}
function durationForm(duration?: number): FormData {
const form = new FormData();
if (duration !== undefined) form.set('duration', String(duration));
return form;
}
function makeConfig(durations: number[]): WhenConfiguration {
const cfg = structuredClone(validConfig);
cfg.user.timezone = 'UTC';
@@ -70,7 +86,8 @@ describe('per-duration availability', () => {
test('precomputes one slot map per offered length', async () => {
const cfg = makeConfig([30, 60]);
const availability = await loadAvailability(db, cfg, CHAT, cfg.meetings[CHAT], 'guest');
expect(availability.durations).toEqual([30, 60]);
expect(availability.bookingDurations).toEqual({ defaultDuration: 30, durations: [30, 60] });
expect(availability).not.toHaveProperty('durations');
expect(Object.keys(availability.slotsByDuration)).toEqual(['30', '60']);
expect(availability.slotsByDuration[30][DAY].length).toBeGreaterThan(0);
expect(availability.slotsByDuration[60][DAY].length).toBeGreaterThan(0);
@@ -96,7 +113,7 @@ describe('per-duration availability', () => {
'guest',
EDGE_START,
30,
`${DAY}T10:00:00Z`
currentAppointment(`${DAY}T10:00:00Z`)
);
const movedLong = await isRescheduleSlotBookable(
db,
@@ -106,7 +123,7 @@ describe('per-duration availability', () => {
'guest',
EDGE_START,
60,
`${DAY}T10:00:00Z`
currentAppointment(`${DAY}T10:00:00Z`)
);
expect([newShort, newLong, movedShort, movedLong]).toEqual([true, false, true, false]);
});
@@ -131,7 +148,7 @@ describe('per-duration availability', () => {
'guest',
`${DAY}T10:00:00Z`,
45,
`${DAY}T11:00:00Z`
currentAppointment(`${DAY}T11:00:00Z`)
);
expect(created).toBe(false);
expect(moved).toBe(false);
@@ -149,7 +166,7 @@ describe('per-duration availability', () => {
'guest',
slot,
30,
`${DAY}T11:00:00Z`
currentAppointment(`${DAY}T11:00:00Z`)
);
expect(created).toBe(false);
expect(moved).toBe(false);
@@ -166,7 +183,7 @@ describe('per-duration availability', () => {
test('a single-length meeting keeps one map', async () => {
const cfg = makeConfig([30]);
const availability = await loadAvailability(db, cfg, CHAT, cfg.meetings[CHAT], 'guest');
expect(availability.durations).toEqual([30]);
expect(availability.bookingDurations).toEqual({ defaultDuration: 30, durations: [30] });
expect(Object.keys(availability.slotsByDuration)).toEqual(['30']);
});
@@ -192,7 +209,7 @@ describe('busy-data read windows', () => {
'guest',
EDGE_START,
30,
`${DAY}T10:00:00Z`
currentAppointment(`${DAY}T10:00:00Z`)
);
const windows = vi.mocked(getBusyIntervals).mock.calls.map(([, , window]) => window);
expect(windows).toEqual([
@@ -230,7 +247,7 @@ describe('busy-data read windows', () => {
'guest',
slot,
30,
`${DAY}T21:00:00Z`
currentAppointment(`${DAY}T21:00:00Z`)
);
// Create's historical day read omits the next-day event; reschedule's horizon includes it.
expect(created).toBe(true);
@@ -298,7 +315,7 @@ describe('active appointments and self-exclusion', () => {
'guest',
own,
30,
own
currentAppointment(own)
);
const otherSlot = await isRescheduleSlotBookable(
db,
@@ -308,7 +325,7 @@ describe('active appointments and self-exclusion', () => {
'guest',
`${DAY}T11:00:00Z`,
30,
own
currentAppointment(own)
);
expect(ownSlot).toBe(true);
expect(otherSlot).toBe(false);
@@ -327,7 +344,7 @@ describe('active appointments and self-exclusion', () => {
'guest',
`${DAY}T13:00:00Z`,
30,
own
currentAppointment(own)
);
expect(moved).toBe(false);
});
@@ -356,7 +373,7 @@ describe('role-specific availability limits', () => {
role,
slot,
30,
`${DAY}T14:00:00Z`
currentAppointment(`${DAY}T14:00:00Z`)
);
const expected = role === 'host';
expect(availability.slotsByDuration[30][DAY].includes(slot), role).toBe(expected);
@@ -385,7 +402,16 @@ describe('role-specific availability limits', () => {
for (const role of ['guest', 'host'] as const) {
const availability = await loadAvailability(db, cfg, CHAT, meeting, role);
const created = await isNewSlotBookable(db, cfg, CHAT, meeting, role, slot, 30);
const moved = await isRescheduleSlotBookable(db, cfg, CHAT, meeting, role, slot, 30, slot);
const moved = await isRescheduleSlotBookable(
db,
cfg,
CHAT,
meeting,
role,
slot,
30,
currentAppointment(slot)
);
expect(availability.slotsByDuration[30][DAY], role).not.toContain(slot);
expect([created, moved], role).toEqual([false, false]);
}
@@ -407,13 +433,23 @@ describe('role-specific availability limits', () => {
.where('id', '=', `appt-${CHAT}`)
.execute();
const current = currentAppointment(own);
for (const role of ['guest', 'host'] as const) {
const availability = await loadAvailability(db, cfg, CHAT, meeting, role);
const rescheduleAvailability = await loadAvailability(db, cfg, CHAT, meeting, role, own);
const rescheduleAvailability = await loadAvailability(db, cfg, CHAT, meeting, role, current);
const created = await isNewSlotBookable(db, cfg, CHAT, meeting, role, free, 30);
const moved = await isRescheduleSlotBookable(db, cfg, CHAT, meeting, role, free, 30, own);
const moved = await isRescheduleSlotBookable(db, cfg, CHAT, meeting, role, free, 30, current);
const occupied = await isNewSlotBookable(db, cfg, CHAT, meeting, role, own, 30);
const retained = await isRescheduleSlotBookable(db, cfg, CHAT, meeting, role, own, 30, own);
const retained = await isRescheduleSlotBookable(
db,
cfg,
CHAT,
meeting,
role,
own,
30,
current
);
const expected = role === 'host';
if (role === 'guest') {
expect(availability.slotsByDuration[30][DAY]).toBeUndefined();
@@ -449,8 +485,9 @@ describe('role-specific availability limits', () => {
meeting.daily_booking_limit = 1;
meeting.notice_minutes = 600;
const availability = await loadAvailability(db, cfg, CHAT, meeting, role);
expect(availability.durations).toEqual([30, 60]);
expect(Object.keys(availability.slotsByDuration)).toEqual(['30', '60']);
const durations = role === 'host' ? [15, 30, 45, 60, 90, 120, 180, 240, 300] : [30, 60];
expect(availability.bookingDurations).toEqual({ defaultDuration: 30, durations });
expect(Object.keys(availability.slotsByDuration)).toEqual(durations.map(String));
expect(availability.slotsByDuration[30][DAY]).toContain(`${DAY}T10:00:00Z`);
expect(availability.slotsByDuration[30][DAY]).toContain(EDGE_START);
@@ -459,7 +496,7 @@ describe('role-specific availability limits', () => {
{ rule: 'notice', slot: `${DAY}T09:30:00Z`, duration: 30 },
{ rule: 'slot grid', slot: `${DAY}T10:15:00Z`, duration: 30 },
{ rule: 'lookahead', slot: '2030-06-11T10:00:00Z', duration: 30 },
{ rule: 'unconfigured duration', slot: `${DAY}T11:00:00Z`, duration: 45 }
{ rule: 'unoffered duration', slot: `${DAY}T11:00:00Z`, duration: 75 }
];
for (const { rule, slot, duration } of rejected) {
const created = await isNewSlotBookable(db, cfg, CHAT, meeting, role, slot, duration);
@@ -471,7 +508,7 @@ describe('role-specific availability limits', () => {
role,
slot,
duration,
`${DAY}T14:00:00Z`
currentAppointment(`${DAY}T14:00:00Z`)
);
expect(
availability.slotsByDuration[duration]?.[slot.slice(0, 10)] ?? [],
@@ -482,3 +519,251 @@ describe('role-specific availability limits', () => {
}
);
});
describe('booking duration profiles', () => {
test('new hosts get a map for every palette and configured extra while guests keep configured order', async () => {
const cfg = makeConfig([60, 75, 17, 15]);
const meeting = cfg.meetings[CHAT];
const original = structuredClone(cfg);
for (const role of ['guest', 'host'] as const) {
const availability = await loadAvailability(db, cfg, CHAT, meeting, role);
const durations =
role === 'host' ? [15, 17, 30, 45, 60, 75, 90, 120, 180, 240, 300] : [60, 75, 17, 15];
expect(availability.bookingDurations).toEqual({ defaultDuration: 60, durations });
expect(availability.bookingDurations).toEqual(resolveBookingDurations(meeting, role));
expect(Object.keys(availability.slotsByDuration).map(Number)).toEqual(
[...durations].sort((a, b) => a - b)
);
expect(resolveDuration(meeting, role, durationForm())).toBe(60);
for (const duration of durations) {
const slot = `${DAY}T09:00:00Z`;
const bookable = await isNewSlotBookable(db, cfg, CHAT, meeting, role, slot, duration);
expect(availability.slotsByDuration[duration][DAY], `${role} ${duration}`).toContain(slot);
expect(resolveDuration(meeting, role, durationForm(duration))).toBe(duration);
expect(bookable, `${role} ${duration}`).toBe(true);
}
}
expect(cfg).toEqual(original);
});
test.each(['guest', 'host'] as const)(
'%s current 90 profile, picker, and submissions agree without granting other guest lengths',
async (role) => {
const cfg = makeConfig([30]);
const meeting = cfg.meetings[CHAT];
const own = `${DAY}T10:00:00Z`;
await book(CHAT, own, `${DAY}T11:30:00Z`);
const current = await db
.selectFrom('appointments')
.selectAll()
.where('id', '=', `appt-${CHAT}`)
.executeTakeFirstOrThrow();
const availability = await loadAvailability(db, cfg, CHAT, meeting, role, current);
const newAvailability = await loadAvailability(db, cfg, CHAT, meeting, role);
const durations = role === 'host' ? [15, 30, 45, 60, 90, 120, 180, 240, 300] : [30, 90];
expect(availability.bookingDurations).toEqual({ defaultDuration: 90, durations });
expect(availability.bookingDurations).toEqual(
resolveBookingDurations(meeting, role, current)
);
expect(Object.keys(availability.slotsByDuration)).toEqual(durations.map(String));
expect(newAvailability.bookingDurations.defaultDuration).toBe(30);
expect(newAvailability.bookingDurations.durations).toEqual(
role === 'host' ? durations : [30]
);
expect(resolveDuration(meeting, role, durationForm(), current)).toBe(90);
expect(availability.slotsByDuration[90][DAY]).toContain(own);
const retained = await isRescheduleSlotBookable(
db,
cfg,
CHAT,
meeting,
role,
own,
90,
current
);
expect(retained).toBe(true);
const free = `${DAY}T11:30:00Z`;
for (const duration of [30, 90, 120]) {
const expectedNew = role === 'host' || duration === 30;
const expectedMoved = role === 'host' || duration !== 120;
const created = await isNewSlotBookable(db, cfg, CHAT, meeting, role, free, duration);
const moved = await isRescheduleSlotBookable(
db,
cfg,
CHAT,
meeting,
role,
free,
duration,
current
);
expect(newAvailability.slotsByDuration[duration]?.[DAY] ?? []).toEqual(expect.any(Array));
expect((newAvailability.slotsByDuration[duration]?.[DAY] ?? []).includes(free)).toBe(
expectedNew
);
expect((availability.slotsByDuration[duration]?.[DAY] ?? []).includes(free)).toBe(
expectedMoved
);
expect(resolveDuration(meeting, role, durationForm(duration), current)).toBe(
expectedMoved ? duration : null
);
expect([created, moved]).toEqual([expectedNew, expectedMoved]);
}
const short = await isRescheduleSlotBookable(
db,
cfg,
CHAT,
meeting,
role,
EDGE_START,
30,
current
);
const long = await isRescheduleSlotBookable(
db,
cfg,
CHAT,
meeting,
role,
EDGE_START,
90,
current
);
expect(availability.slotsByDuration[30][DAY]).toContain(EDGE_START);
expect(availability.slotsByDuration[90][DAY]).not.toContain(EDGE_START);
expect([short, long]).toEqual([true, false]);
}
);
test.each(['guest', 'host'] as const)(
'%s gets a usable map and submission for current non-palette 85 only when rescheduling',
async (role) => {
const cfg = makeConfig([30]);
const meeting = cfg.meetings[CHAT];
await book(CHAT, `${DAY}T10:00:00Z`, `${DAY}T11:25:00Z`);
const current = await db
.selectFrom('appointments')
.selectAll()
.where('id', '=', `appt-${CHAT}`)
.executeTakeFirstOrThrow();
const availability = await loadAvailability(db, cfg, CHAT, meeting, role, current);
const slot = `${DAY}T13:00:00Z`;
const moved = await isRescheduleSlotBookable(db, cfg, CHAT, meeting, role, slot, 85, current);
const created = await isNewSlotBookable(db, cfg, CHAT, meeting, role, slot, 85);
expect(availability.bookingDurations.defaultDuration).toBe(85);
expect(availability.bookingDurations.durations.filter((duration) => duration === 85)).toEqual(
[85]
);
expect(availability.slotsByDuration[85][DAY]).toContain(slot);
expect(resolveDuration(meeting, role, durationForm(), current)).toBe(85);
expect([moved, created]).toEqual([true, false]);
}
);
test.each(['appointment', 'remote'] as const)(
'chosen long durations still respect actual %s busy time beyond the short duration',
async (source) => {
const cfg = makeConfig([30]);
const meeting = cfg.meetings[CHAT];
meeting.padding_before_minutes = 15;
meeting.padding_after_minutes = 15;
await book(CHAT, `${DAY}T14:00:00Z`, `${DAY}T15:30:00Z`);
const current = await db
.selectFrom('appointments')
.selectAll()
.where('id', '=', `appt-${CHAT}`)
.executeTakeFirstOrThrow();
if (source === 'appointment') {
cfg.meetings.lunch = { ...meeting };
await book('lunch', `${DAY}T11:00:00Z`, `${DAY}T11:30:00Z`);
} else {
await replaceCalendarBusy(db, meeting.booking_calendar, [
{ start: `${DAY}T11:00:00Z`, end: `${DAY}T11:30:00Z` }
]);
}
const slot = `${DAY}T10:00:00Z`;
for (const role of ['guest', 'host'] as const) {
const availability = await loadAvailability(db, cfg, CHAT, meeting, role, current);
const short = await isRescheduleSlotBookable(
db,
cfg,
CHAT,
meeting,
role,
slot,
30,
current
);
const long = await isRescheduleSlotBookable(
db,
cfg,
CHAT,
meeting,
role,
slot,
90,
current
);
expect(availability.slotsByDuration[30][DAY]).toContain(slot);
expect(availability.slotsByDuration[90][DAY]).not.toContain(slot);
expect([short, long]).toEqual([true, false]);
}
const newHostShort = await isNewSlotBookable(db, cfg, CHAT, meeting, 'host', slot, 30);
const newHostLong = await isNewSlotBookable(db, cfg, CHAT, meeting, 'host', slot, 90);
expect([newHostShort, newHostLong]).toEqual([true, false]);
}
);
test('a long host-created row still occupies time and counts against the guest daily cap', async () => {
const cfg = makeConfig([30]);
const meeting = cfg.meetings[CHAT];
const own = `${DAY}T10:00:00Z`;
await book(CHAT, own, `${DAY}T11:30:00Z`);
await db
.updateTable('appointments')
.set({
action_log: JSON.stringify([{ action: 'create', actor: 'host', at: `${DAY}T00:00:00Z` }])
})
.where('id', '=', `appt-${CHAT}`)
.execute();
const current = await db
.selectFrom('appointments')
.selectAll()
.where('id', '=', `appt-${CHAT}`)
.executeTakeFirstOrThrow();
meeting.daily_booking_limit = 1;
const guest = await loadAvailability(db, cfg, CHAT, meeting, 'guest', current);
const host = await loadAvailability(db, cfg, CHAT, meeting, 'host', current);
const free = `${DAY}T13:00:00Z`;
const guestMoved = await isRescheduleSlotBookable(
db,
cfg,
CHAT,
meeting,
'guest',
free,
90,
current
);
const hostMoved = await isRescheduleSlotBookable(
db,
cfg,
CHAT,
meeting,
'host',
free,
90,
current
);
expect(guest.slotsByDuration[90][DAY]).toBeUndefined();
expect(host.slotsByDuration[90][DAY]).toContain(free);
expect([guestMoved, hostMoved]).toEqual([false, true]);
meeting.daily_booking_limit = null;
const uncappedGuest = await loadAvailability(db, cfg, CHAT, meeting, 'guest');
expect(uncappedGuest.slotsByDuration[30][DAY]).not.toContain(`${DAY}T11:00:00Z`);
expect(uncappedGuest.slotsByDuration[30][DAY]).toContain(free);
});
});
+20 -18
View File
@@ -1,7 +1,7 @@
import type { Kysely } from 'kysely';
import { getBusyIntervals, type BusyInterval, type Database } from '@when/db';
import { getBusyIntervals, type Appointment, type BusyInterval, type Database } from '@when/db';
import { busyCalendarsFor } from '@when/calendar';
import { durationsOf, type Meeting, type WhenConfiguration } from '@when/config';
import type { Meeting, WhenConfiguration } from '@when/config';
import { systemClock } from '$lib/server/clock';
import { computeSlots, type ComputeOptions } from './calc';
import { mergeBlocks } from './intervals';
@@ -10,10 +10,11 @@ import { resolveAvailabilitySettings } from './settings';
import { buildBaseWindows, candidateDates } from './windows';
import type { AvailabilitySettings } from './types';
import type { AppointmentRole } from '../appointment/actions';
import { resolveBookingDurations, type BookingDurations } from '../appointment/duration';
export interface Availability {
settings: AvailabilitySettings;
durations: number[];
bookingDurations: BookingDurations;
slotsByDuration: Record<number, Record<string, string[]>>;
workingWindows: { start: string; end: string }[];
busyBlocks: { start: string; end: string }[];
@@ -25,19 +26,19 @@ export async function loadAvailability(
slug: string,
eventType: Meeting,
role: AppointmentRole,
excludeStart: string | null = null
currentAppointment: Pick<Appointment, 'start_time' | 'end_time'> | null = null
): Promise<Availability> {
const { options, allowedDurations } = await readAvailabilityInputs(
const { options, bookingDurations } = await readAvailabilityInputs(
db,
cfg,
slug,
eventType,
role,
excludeStart
currentAppointment
);
const { settings, rangeStart, rangeEnd, userTz } = options;
const slotsByDuration: Record<number, Record<string, string[]>> = {};
for (const duration of allowedDurations) {
for (const duration of bookingDurations.durations) {
const slots = computeSlots({ ...options, settings: { ...settings, duration } });
const byDate: Record<string, string[]> = {};
for (const slot of slots) {
@@ -56,7 +57,7 @@ export async function loadAvailability(
const busyBlocks = mergeBlocks([...options.existingAppointments, ...options.remoteBusy]).map(
(block) => ({ start: block.start.toString(), end: block.end.toString() })
);
return { settings, durations: allowedDurations, slotsByDuration, workingWindows, busyBlocks };
return { settings, bookingDurations, slotsByDuration, workingWindows, busyBlocks };
}
// Keep creation's day-bounded busy read distinct from rescheduling's full horizon.
@@ -70,7 +71,7 @@ export async function isNewSlotBookable(
duration: number
): Promise<boolean> {
const busyWindow = slotDayWindow(slot, cfg.user.timezone);
const { options, allowedDurations } = await readAvailabilityInputs(
const { options, bookingDurations } = await readAvailabilityInputs(
db,
cfg,
slug,
@@ -79,7 +80,7 @@ export async function isNewSlotBookable(
null,
busyWindow
);
return isOfferedSlot(options, allowedDurations, slot, duration);
return isOfferedSlot(options, bookingDurations.durations, slot, duration);
}
export async function isRescheduleSlotBookable(
@@ -90,17 +91,17 @@ export async function isRescheduleSlotBookable(
role: AppointmentRole,
slot: string,
duration: number,
excludeStart: string
currentAppointment: Pick<Appointment, 'start_time' | 'end_time'>
): Promise<boolean> {
const { options, allowedDurations } = await readAvailabilityInputs(
const { options, bookingDurations } = await readAvailabilityInputs(
db,
cfg,
slug,
eventType,
role,
excludeStart
currentAppointment
);
return isOfferedSlot(options, allowedDurations, slot, duration);
return isOfferedSlot(options, bookingDurations.durations, slot, duration);
}
async function readAvailabilityInputs(
@@ -109,10 +110,11 @@ async function readAvailabilityInputs(
slug: string,
eventType: Meeting,
role: AppointmentRole,
excludeStart: string | null,
currentAppointment: Pick<Appointment, 'start_time' | 'end_time'> | null,
remoteBusyWindow?: BusyInterval
): Promise<{ options: ComputeOptions; allowedDurations: number[] }> {
): Promise<{ options: ComputeOptions; bookingDurations: BookingDurations }> {
const settings = resolveAvailabilitySettings(cfg, eventType, role);
const bookingDurations = resolveBookingDurations(eventType, role, currentAppointment);
const now = Temporal.Instant.fromEpochMilliseconds(systemClock.nowMs());
const rangeEnd = now.add({ hours: 24 * settings.maximum_lookahead });
const blocks = await loadAppointmentBlocks(
@@ -121,7 +123,7 @@ async function readAvailabilityInputs(
now,
rangeEnd,
cfg.user.timezone,
excludeStart
currentAppointment?.start_time ?? null
);
const busyWindow = remoteBusyWindow ?? { start: now.toString(), end: rangeEnd.toString() };
const remote = await getBusyIntervals(db, busyCalendarsFor(eventType), busyWindow);
@@ -140,7 +142,7 @@ async function readAvailabilityInputs(
remoteBusy,
perDayCount: blocks.perDayCount
},
allowedDurations: durationsOf(eventType)
bookingDurations
};
}
@@ -18,6 +18,7 @@ import {
type Appointment
} from '@when/db';
import { toPublicAppointment, toPublicEventType } from '$lib/server/appointment/sanitize';
import { resolveBookingDurations } from '$lib/server/appointment/duration';
import type { Actions, PageServerLoad } from './$types';
import { cancelAppointment } from '$lib/server/appointment/cancel';
import { validateReason } from '$lib/server/appointment/form.server';
@@ -133,10 +134,12 @@ export const load: PageServerLoad = async ({ params, url, locals, cookies }) =>
row.video_chat_request !== 'suppress' &&
(row.video_chat_request === 'generate' || automaticVideoChat);
const bookingDurations = resolveBookingDurations(resolvedEventType, role, row);
return {
appointment,
videoChatRequested,
eventType: toPublicEventType(row.event_type_id, resolvedEventType, role),
eventType: toPublicEventType(row.event_type_id, resolvedEventType, role, bookingDurations),
calendarLinks,
actions,
flash: flash ?? null,
@@ -42,17 +42,17 @@ export const load: PageServerLoad = async ({ params, url, locals }) => {
});
const rescheduleError = ctx.kind === 'error' ? ctx.code : null;
const { slotsByDuration, workingWindows, busyBlocks } = await loadAvailability(
const { bookingDurations, slotsByDuration, workingWindows, busyBlocks } = await loadAvailability(
getDb(),
cfg,
row.event_type_id,
eventType,
role,
row.start_time
row
);
return {
eventType: toPublicEventType(row.event_type_id, eventType, role),
eventType: toPublicEventType(row.event_type_id, eventType, role, bookingDurations),
formFields: resolveFormFields(eventType),
availability: { slotsByDuration, workingWindows, busyBlocks },
reschedule: {
@@ -94,7 +94,7 @@ export const actions: Actions = {
if (!reasonResult.ok) return fail(400, { error: reasonResult.error });
const reason = reasonResult.reason;
const duration = resolveDuration(eventType, form);
const duration = resolveDuration(eventType, 'guest', form, found);
if (duration === null) return fail(400, { error: 'Please pick a valid meeting length.' });
// Re-validate the slot is currently bookable, ignoring the appointment's own current slot.
@@ -106,7 +106,7 @@ export const actions: Actions = {
'guest',
slotStr,
duration,
found.start_time
found
);
if (!bookable) {
return fail(409, { error: 'That time is no longer available. Please pick another.' });
@@ -1,7 +1,10 @@
<script lang="ts">
import { page } from '$app/state';
import AppointmentWizard from '$lib/components/AppointmentWizard.svelte';
let { data, form } = $props();
</script>
<AppointmentWizard {data} {form} />
{#key `${page.params.id}:${data.role}`}
<AppointmentWizard {data} {form} />
{/key}
@@ -1,6 +1,9 @@
import { describe, expect, test, vi, beforeEach } from 'vitest';
import { validConfig } from '$lib/server/__fixtures__/valid-config';
import type { Appointment } from '@when/db';
import type { Meeting } from '@when/config';
import type { AppointmentRole } from '$lib/server/appointment/actions';
import { resolveBookingDurations } from '$lib/server/appointment/duration';
const h = vi.hoisted(() => ({
db: {},
@@ -46,11 +49,18 @@ vi.mock('$lib/server/appointment/form.server', async (io) => {
validateReason: h.validateReason
};
});
vi.mock('$lib/server/appointment/duration', () => ({ resolveDuration: h.resolveDuration }));
vi.mock('$lib/server/appointment/sanitize', () => ({
toPublicAppointment: h.toPublicAppointment,
toPublicEventType: h.toPublicEventType
}));
vi.mock('$lib/server/appointment/duration', async (importOriginal) => {
const original = await importOriginal<typeof import('$lib/server/appointment/duration')>();
return { ...original, resolveDuration: h.resolveDuration };
});
vi.mock('$lib/server/appointment/sanitize', async (importOriginal) => {
const original = await importOriginal<typeof import('$lib/server/appointment/sanitize')>();
return {
...original,
toPublicAppointment: h.toPublicAppointment,
toPublicEventType: h.toPublicEventType.mockImplementation(original.toPublicEventType)
};
});
vi.mock('@when/config', async (io) => {
const original = await io<typeof import('@when/config')>();
return {
@@ -73,6 +83,7 @@ function appt(): Appointment {
id: 'appt-1',
event_type_id: '30-min-chat',
start_time: CURRENT,
end_time: '2099-05-01T16:30:00Z',
cancel_token: 'tok-1',
status: 'confirmed'
} as unknown as Appointment;
@@ -122,13 +133,31 @@ beforeEach(() => {
h.row.current = appt();
h.viewAllowed.mockResolvedValue(true);
h.classify.mockReturnValue({ kind: 'ok' });
h.loadAvailability.mockResolvedValue({ slotsByDuration: {}, workingWindows: [], busyBlocks: [] });
h.loadAvailability.mockImplementation(
async (
_db,
_cfg,
_slug,
meeting: Meeting,
role: AppointmentRole,
currentAppointment: Appointment
) => {
const bookingDurations = resolveBookingDurations(meeting, role, currentAppointment);
return {
bookingDurations,
slotsByDuration: Object.fromEntries(
bookingDurations.durations.map((duration) => [duration, {}])
),
workingWindows: [],
busyBlocks: []
};
}
);
h.isRescheduleSlotBookable.mockResolvedValue(true);
h.resolveTimezone.mockReturnValue('UTC');
h.validateReason.mockReturnValue({ ok: true, reason: null });
h.resolveDuration.mockReturnValue(30);
h.toPublicAppointment.mockReturnValue({ id: 'appt-1' });
h.toPublicEventType.mockReturnValue({ slug: '30-min-chat' });
});
describe('/appointment/[id]/reschedule load', () => {
@@ -151,13 +180,21 @@ describe('/appointment/[id]/reschedule load', () => {
expect(result.reschedule.appt).toEqual({ id: 'appt-1' });
expect(result.reschedule.error).toBeNull();
expect(result.reschedule.token).toBe('tok-1');
expect(result.eventType).toEqual({ slug: '30-min-chat' });
const availability = await h.loadAvailability.mock.results[0].value;
expect(result.eventType).toMatchObject({ slug: '30-min-chat', duration_minutes: 90 });
expect(result.eventType.durations).toBe(availability.bookingDurations.durations);
expect(result.availability.slotsByDuration).toBe(availability.slotsByDuration);
expect(Object.keys(result.availability.slotsByDuration).map(Number)).toEqual(
[...result.eventType.durations].sort((a, b) => a - b)
);
expect(h.toPublicAppointment).toHaveBeenCalledExactlyOnceWith(h.row.current, role);
expect(h.toPublicEventType).toHaveBeenCalledExactlyOnceWith(
'30-min-chat',
validConfig.meetings['30-min-chat'],
role
role,
availability.bookingDurations
);
expect(h.toPublicEventType.mock.calls[0][3]).toBe(availability.bookingDurations);
expect(result.role).toBe(role);
expect(result).not.toHaveProperty('viewer');
expect(result).not.toHaveProperty('isAdmin');
@@ -168,7 +205,7 @@ describe('/appointment/[id]/reschedule load', () => {
'30-min-chat',
validConfig.meetings['30-min-chat'],
role,
CURRENT
h.row.current
);
}
);
@@ -247,7 +284,7 @@ describe('/appointment/[id]/reschedule book action', () => {
'guest',
NEW_SLOT,
30,
CURRENT
h.row.current
);
expect(h.reschedule).not.toHaveBeenCalled();
});
@@ -255,8 +292,22 @@ describe('/appointment/[id]/reschedule book action', () => {
test('ignores forged host flags and checks guest availability excluding self', async () => {
h.parseForm.mockReturnValue({ ok: true, data: {} });
h.isRescheduleSlotBookable.mockResolvedValue(false);
const result = await book(form({ viewer: 'host', role: 'host', isAdmin: 'true' }));
const fd = form({
viewer: 'host',
role: 'host',
isAdmin: 'true',
currentDuration: '240',
end_time: '2099-05-01T19:00:00Z'
});
const result = await book(fd);
expect(result.status).toBe(409);
expect(h.resolveDuration).toHaveBeenCalledExactlyOnceWith(
validConfig.meetings['30-min-chat'],
'guest',
fd,
h.row.current
);
expect(h.resolveDuration.mock.calls[0][3]).toBe(h.row.current);
expect(h.isRescheduleSlotBookable).toHaveBeenCalledExactlyOnceWith(
h.db,
validConfig,
@@ -265,8 +316,9 @@ describe('/appointment/[id]/reschedule book action', () => {
'guest',
NEW_SLOT,
30,
CURRENT
h.row.current
);
expect(h.isRescheduleSlotBookable.mock.calls[0][7]).toBe(h.row.current);
expect(h.reschedule).not.toHaveBeenCalled();
});
@@ -280,13 +332,25 @@ describe('/appointment/[id]/reschedule book action', () => {
appointment: { id: 'appt-2', cancel_token: 'tok-2' }
});
const session = forged ? { user: { name: 'Host' } } : null;
const event = bookEvent(
form(forged ? { viewer: 'host', role: 'host', isAdmin: 'true' } : {}),
session
const fd = form(
forged ? { viewer: 'host', role: 'host', isAdmin: 'true', currentDuration: '240' } : {}
);
const event = bookEvent(fd, session);
const r = await caught(() => actions.default(event));
expect(r.status).toBe(303);
expect(r.location).toBe('/appointment/appt-2?token=tok-2');
expect(h.resolveDuration).toHaveBeenCalledExactlyOnceWith(
validConfig.meetings['30-min-chat'],
'guest',
fd,
h.row.current
);
expect(event.cookies.set).toHaveBeenCalledWith('submitted', 'reschedule', {
path: '/',
maxAge: 10,
httpOnly: true,
sameSite: 'lax'
});
expect(h.isRescheduleSlotBookable).toHaveBeenCalledExactlyOnceWith(
h.db,
validConfig,
@@ -295,7 +359,7 @@ describe('/appointment/[id]/reschedule book action', () => {
'guest',
NEW_SLOT,
60,
CURRENT
h.row.current
);
expect(h.reschedule).toHaveBeenCalledWith(
expect.anything(),
@@ -1,12 +1,24 @@
import { describe, expect, test, vi } from 'vitest';
import { beforeEach, describe, expect, test, vi } from 'vitest';
import { actions, load } from './+page.server';
import { validConfig } from '$lib/server/__fixtures__/valid-config';
import type { Appointment } from '@when/db';
import type { AppointmentRole } from '$lib/server/appointment/actions';
import { resolveBookingDurations } from '$lib/server/appointment/duration';
import { toPublicEventType } from '$lib/server/appointment/sanitize';
const h = vi.hoisted(() => ({ cancelAppointment: vi.fn() }));
vi.mock('$lib/server/appointment/cancel', () => ({ cancelAppointment: h.cancelAppointment }));
vi.mock('$lib/server/appointment/duration', async (importOriginal) => {
const original = await importOriginal<typeof import('$lib/server/appointment/duration')>();
return { ...original, resolveBookingDurations: vi.fn(original.resolveBookingDurations) };
});
vi.mock('$lib/server/appointment/sanitize', async (importOriginal) => {
const original = await importOriginal<typeof import('$lib/server/appointment/sanitize')>();
return { ...original, toPublicEventType: vi.fn(original.toPublicEventType) };
});
beforeEach(() => vi.clearAllMocks());
const mockDb = {
selectFrom: vi.fn(),
@@ -41,6 +53,11 @@ const mockAppt: Appointment = {
updated_at: ''
};
const bookedAppointment: Appointment = {
...mockAppt,
id: 'appt-long',
end_time: '2099-05-01T16:30:00Z'
};
// transitions.ts appends the move to the old row's log and copies that whole log onto the
// new row, so a later row carries every earlier move as well as its own.
function moves(...pairs: [previousId: string, nextId: string][]) {
@@ -117,6 +134,7 @@ vi.mock('@when/db', async (importOriginal) => {
...actual,
findAppointment: async (_db: unknown, id: string) => {
if (id === 'appt-1') return mockAppt;
if (id === bookedAppointment.id) return bookedAppointment;
if (id === 'appt-pending')
return {
...mockAppt,
@@ -179,6 +197,37 @@ function loadAppointment(id: string, token: string | null, role: AppointmentRole
}
describe('/appointment/[id] server load', () => {
test.for([
{
row: bookedAppointment,
meeting: validConfig.meetings['30-min-chat'],
role: 'guest' as const
},
{ row: bookedAppointment, meeting: validConfig.meetings['30-min-chat'], role: 'host' as const }
])(
'projects $row.id with an explicit $role profile based on its booked length',
async ({ row, meeting, role }) => {
const result = await loadAppointment(
row.id,
role === 'guest' ? row.cancel_token : null,
role
);
expect(resolveBookingDurations).toHaveBeenCalledExactlyOnceWith(meeting, role, row);
expect(vi.mocked(resolveBookingDurations).mock.calls[0][2]).toBe(row);
const bookingDurations = vi.mocked(resolveBookingDurations).mock.results[0].value;
expect(toPublicEventType).toHaveBeenCalledExactlyOnceWith(
row.event_type_id,
meeting,
role,
bookingDurations
);
expect(vi.mocked(toPublicEventType).mock.calls[0][3]).toBe(bookingDurations);
expect(result.eventType.duration_minutes).toBe(90);
expect(result.eventType.durations).toBe(bookingDurations.durations);
expect(result.eventType.durations).toContain(90);
}
);
test.for(['guest', 'host'] as const)(
'reports %s and applies the same role to privacy and actions',
async (role) => {
@@ -430,15 +479,19 @@ describe('/appointment/[id] cancel action — characterization', () => {
});
test('another row token in the chain cannot cancel this row', async () => {
expect(await cancelWith('chain-3', 'tok-1')).toMatchObject({ status: 403 });
expect(await cancelWith('chain-3', 'tok-2')).toMatchObject({ status: 403 });
const first = await cancelWith('chain-3', 'tok-1');
const second = await cancelWith('chain-3', 'tok-2');
expect(first).toMatchObject({ status: 403 });
expect(second).toMatchObject({ status: 403 });
});
test('an empty token cannot cancel', async () => {
expect(await cancelWith('chain-3', '')).toMatchObject({ status: 403 });
const result = await cancelWith('chain-3', '');
expect(result).toMatchObject({ status: 403 });
});
test('an unknown appointment is a 404, not a token check', async () => {
expect(await cancelWith('nope', 'tok-3')).toMatchObject({ status: 404 });
const result = await cancelWith('nope', 'tok-3');
expect(result).toMatchObject({ status: 404 });
});
});
@@ -2,10 +2,10 @@ import { error, fail, redirect } from '@sveltejs/kit';
import { isNewSlotBookable, loadAvailability } from '$lib/server/availability/booking';
import { logger } from '$lib/server/logger';
import { getConfig, getDb } from '$lib/server/state';
import { durationsOf, resolveFormFields } from '@when/config';
import { resolveFormFields } from '@when/config';
import { createAppointment } from '$lib/server/appointment/create';
import type { AppointmentRole } from '$lib/server/appointment/actions';
import { resolveDuration } from '$lib/server/appointment/duration';
import { resolveBookingDurations, resolveDuration } from '$lib/server/appointment/duration';
import {
parseAndValidateAppointmentForm,
parseSlot,
@@ -26,12 +26,12 @@ export const load: PageServerLoad = async ({ params, url, locals }) => {
// Strip malformed/unknown deep-link params up front by redirecting to the canonical URL.
const clean = normalizeDeepLinkParams(url.searchParams);
const durations = durationsOf(eventType);
const canonicalDurations = resolveBookingDurations(eventType, role);
const expected: [string, string][] = [];
if (
clean.duration !== undefined &&
durations.includes(clean.duration) &&
clean.duration !== durations[0]
canonicalDurations.durations.includes(clean.duration) &&
clean.duration !== canonicalDurations.defaultDuration
) {
expected.push(['duration', String(clean.duration)]);
}
@@ -49,7 +49,7 @@ export const load: PageServerLoad = async ({ params, url, locals }) => {
redirect(307, query ? `${url.pathname}?${query}` : url.pathname);
}
const { slotsByDuration, workingWindows, busyBlocks } = await loadAvailability(
const { bookingDurations, slotsByDuration, workingWindows, busyBlocks } = await loadAvailability(
getDb(),
cfg,
params.slug,
@@ -58,7 +58,7 @@ export const load: PageServerLoad = async ({ params, url, locals }) => {
);
return {
eventType: toPublicEventType(params.slug, eventType, role),
eventType: toPublicEventType(params.slug, eventType, role, bookingDurations),
formFields: resolveFormFields(eventType),
availability: { slotsByDuration, workingWindows, busyBlocks },
reschedule: null,
@@ -88,7 +88,7 @@ export const actions: Actions = {
}
const { name, email, answers, location: resolvedLocation } = parsed.data;
const duration = resolveDuration(eventType, form);
const duration = resolveDuration(eventType, 'guest', form);
if (duration === null) {
bookingAttemptsTotal.inc({ event_type_id: params.slug, status: 'invalid_input' });
return fail(400, { error: 'Please pick a valid meeting length.' });
@@ -4,4 +4,6 @@
let { data, form } = $props();
</script>
<AppointmentWizard {data} {form} deepLink />
{#key `${data.eventType.slug}:${data.role}`}
<AppointmentWizard {data} {form} deepLink />
{/key}
@@ -1,11 +1,15 @@
import { describe, expect, test, vi, beforeEach } from 'vitest';
import { validConfig } from '$lib/server/__fixtures__/valid-config';
import type { WhenConfiguration } from '@when/config';
import type { Meeting, WhenConfiguration } from '@when/config';
import type { AppointmentRole } from '$lib/server/appointment/actions';
import { resolveBookingDurations } from '$lib/server/appointment/duration';
import { toPublicEventType } from '$lib/server/appointment/sanitize';
const h = vi.hoisted(() => ({
cfg: { current: null as unknown as WhenConfiguration },
loadAvailability: vi.fn(),
db: {},
getDb: vi.fn(),
isNewSlotBookable: vi.fn(),
createAppointment: vi.fn(),
parseForm: vi.fn(),
@@ -13,7 +17,7 @@ const h = vi.hoisted(() => ({
resolveDuration: vi.fn()
}));
vi.mock('$lib/server/state', () => ({ getConfig: () => h.cfg.current, getDb: () => h.db }));
vi.mock('$lib/server/state', () => ({ getConfig: () => h.cfg.current, getDb: h.getDb }));
vi.mock('$lib/server/availability/booking', () => ({
loadAvailability: h.loadAvailability,
isNewSlotBookable: h.isNewSlotBookable
@@ -27,7 +31,14 @@ vi.mock('$lib/server/appointment/form.server', async (io) => {
resolveTimezone: h.resolveTimezone
};
});
vi.mock('$lib/server/appointment/duration', () => ({ resolveDuration: h.resolveDuration }));
vi.mock('$lib/server/appointment/duration', async (importOriginal) => {
const original = await importOriginal<typeof import('$lib/server/appointment/duration')>();
return { ...original, resolveDuration: h.resolveDuration };
});
vi.mock('$lib/server/appointment/sanitize', async (importOriginal) => {
const original = await importOriginal<typeof import('$lib/server/appointment/sanitize')>();
return { ...original, toPublicEventType: vi.fn(original.toPublicEventType) };
});
import { load, actions } from './+page.server';
@@ -67,7 +78,20 @@ beforeEach(() => {
vi.clearAllMocks();
h.cfg.current = validConfig;
h.resolveTimezone.mockReturnValue('UTC');
h.loadAvailability.mockResolvedValue({ slotsByDuration: {}, workingWindows: [], busyBlocks: [] });
h.getDb.mockReturnValue(h.db);
h.loadAvailability.mockImplementation(
async (_db, _cfg, _slug, meeting: Meeting, role: AppointmentRole) => {
const bookingDurations = resolveBookingDurations(meeting, role);
return {
bookingDurations,
slotsByDuration: Object.fromEntries(
bookingDurations.durations.map((duration) => [duration, {}])
),
workingWindows: [],
busyBlocks: []
};
}
);
});
describe('/schedule/[slug] load — deep-link canonicalization', () => {
@@ -82,9 +106,11 @@ describe('/schedule/[slug] load — deep-link canonicalization', () => {
const r = await caught(() => load(loadEvent('?foo=bar')));
expect(r.status).toBe(307);
expect(r.location).toBe('/schedule/30-min-chat');
expect(h.getDb).not.toHaveBeenCalled();
expect(h.loadAvailability).not.toHaveBeenCalled();
});
test('strips the default duration (durations[0]) from the query', async () => {
test('strips the configured default duration from the query', async () => {
const r = await caught(() => load(loadEvent('?duration=30')));
expect(r.status).toBe(307);
expect(r.location).toBe('/schedule/30-min-chat');
@@ -112,6 +138,30 @@ describe('/schedule/[slug] load — deep-link canonicalization', () => {
expect(r.location).toBe('/schedule/30-min-chat?duration=60');
});
test('canonicalizes the configured default of 60 before reading availability, not the host palette minimum', async () => {
h.cfg.current = structuredClone(validConfig);
h.cfg.current.meetings['30-min-chat'].duration_minutes = 60;
const session = { user: { name: 'Host' } };
const redirect = await caught(() => load(loadEvent('?duration=60', session)));
expect(redirect).toMatchObject({ status: 307, location: '/schedule/30-min-chat' });
expect(h.getDb).not.toHaveBeenCalled();
expect(h.loadAvailability).not.toHaveBeenCalled();
const loaded = await load(loadEvent('?duration=15', session));
const result = loaded as LoadResult;
expect(result.eventType.duration_minutes).toBe(60);
expect(result.eventType.durations[0]).toBe(15);
});
test('keeps a host-only duration=90 for hosts but strips it for guests before availability', async () => {
const redirect = await caught(() => load(loadEvent('?duration=90')));
expect(redirect).toMatchObject({ status: 307, location: '/schedule/30-min-chat' });
expect(h.getDb).not.toHaveBeenCalled();
expect(h.loadAvailability).not.toHaveBeenCalled();
const loaded = await load(loadEvent('?duration=90', { user: { name: 'Host' } }));
const result = loaded as LoadResult;
expect(result.eventType.durations).toContain(90);
});
test.for([
{ role: 'guest', session: null },
{ role: 'host', session: { user: { name: 'Host' } } }
@@ -124,6 +174,20 @@ describe('/schedule/[slug] load — deep-link canonicalization', () => {
});
const loaded = await load(loadEvent(`?slot=${SLOT}`, session));
const result = loaded as LoadResult;
const availability = await h.loadAvailability.mock.results[0].value;
expect(toPublicEventType).toHaveBeenCalledExactlyOnceWith(
'30-min-chat',
h.cfg.current.meetings['30-min-chat'],
role,
availability.bookingDurations
);
expect(vi.mocked(toPublicEventType).mock.calls[0][3]).toBe(availability.bookingDurations);
expect(result.eventType.duration_minutes).toBe(availability.bookingDurations.defaultDuration);
expect(result.eventType.durations).toBe(availability.bookingDurations.durations);
expect(result.availability.slotsByDuration).toBe(availability.slotsByDuration);
expect(Object.keys(result.availability.slotsByDuration).map(Number)).toEqual(
[...result.eventType.durations].sort((a, b) => a - b)
);
expect(result.eventType.slug).toBe('30-min-chat');
expect(result.eventType.location).toBe(role === 'host' ? 'Private host location' : undefined);
expect(result.eventType.padding_before_minutes).toBe(role === 'host' ? 0 : 15);
@@ -221,7 +285,13 @@ describe('/schedule/[slug] book action', () => {
fd.set('viewer', 'host');
fd.set('role', 'host');
fd.set('isAdmin', 'true');
fd.set('currentDuration', '90');
const response = await actions.default(bookEvent(fd));
expect(h.resolveDuration).toHaveBeenCalledExactlyOnceWith(
h.cfg.current.meetings['30-min-chat'],
'guest',
fd
);
expect((response as Failure)?.status).toBe(409);
expect(h.isNewSlotBookable).toHaveBeenCalledExactlyOnceWith(
h.db,
@@ -248,12 +318,24 @@ describe('/schedule/[slug] book action', () => {
fd.set('viewer', 'host');
fd.set('role', 'host');
fd.set('isAdmin', 'true');
fd.set('currentDuration', '90');
}
const session = forged ? { user: { name: 'Host' } } : null;
const event = bookEvent(fd, session);
const r = await caught(() => actions.default(event));
expect(r.status).toBe(303);
expect(r.location).toBe('/appointment/new-1?token=tok%20new');
expect(h.resolveDuration).toHaveBeenCalledExactlyOnceWith(
h.cfg.current.meetings['30-min-chat'],
'guest',
fd
);
expect(event.cookies.set).toHaveBeenCalledWith('submitted', 'request', {
path: '/',
maxAge: 10,
httpOnly: true,
sameSite: 'lax'
});
expect(h.isNewSlotBookable).toHaveBeenCalledExactlyOnceWith(
h.db,
h.cfg.current,
@@ -31,7 +31,7 @@ export const actions: Actions = {
if (!reasonResult.ok) return fail(400, { error: reasonResult.error });
const reason = reasonResult.reason;
const duration = resolveDuration(eventType, form);
const duration = resolveDuration(eventType, 'host', form, found);
if (duration === null) return fail(400, { error: 'Please pick a valid meeting length.' });
// Re-validate the slot is currently bookable, ignoring the appointment's own current slot.
@@ -43,7 +43,7 @@ export const actions: Actions = {
'host',
slotStr,
duration,
found.start_time
found
);
if (!bookable) {
return fail(409, { error: 'That time is no longer available. Please pick another.' });
@@ -19,7 +19,10 @@ vi.mock('$lib/server/availability/booking', () => ({
isRescheduleSlotBookable: h.isRescheduleSlotBookable
}));
vi.mock('$lib/server/appointment/reschedule', () => ({ rescheduleAppointment: h.reschedule }));
vi.mock('$lib/server/appointment/duration', () => ({ resolveDuration: h.resolveDuration }));
vi.mock('$lib/server/appointment/duration', async (importOriginal) => {
const original = await importOriginal<typeof import('$lib/server/appointment/duration')>();
return { ...original, resolveDuration: h.resolveDuration };
});
vi.mock('$lib/server/appointment/form.server', async (io) => {
const original = await io<typeof import('$lib/server/appointment/form.server')>();
return { ...original, validateReason: h.validateReason };
@@ -35,6 +38,7 @@ const row = {
id: 'appt-1',
event_type_id: '30-min-chat',
start_time: CURRENT,
end_time: '2099-05-01T16:30:00Z',
cancel_token: 'own-token',
status: 'confirmed'
};
@@ -74,9 +78,21 @@ beforeEach(() => {
describe('/admin/appointment/[id]/reschedule book action', () => {
test('needs no guest token, delegates the chosen length excluding self, and reschedules as host', async () => {
const result = await caught(() => actions.default(bookEvent()));
const event = bookEvent();
const form = await event.request.formData();
form.set('role', 'guest');
form.set('currentDuration', '240');
form.set('end_time', '2099-05-01T19:00:00Z');
const result = await caught(() => actions.default(event));
expect(result).toMatchObject({ status: 303, location: '/appointment/appt-2' });
expect(h.findAppointment).toHaveBeenCalledExactlyOnceWith(h.db, row.id);
expect(h.resolveDuration).toHaveBeenCalledExactlyOnceWith(
validConfig.meetings[row.event_type_id],
'host',
form,
row
);
expect(h.resolveDuration.mock.calls[0][3]).toBe(row);
expect(h.isRescheduleSlotBookable).toHaveBeenCalledExactlyOnceWith(
h.db,
validConfig,
@@ -85,8 +101,9 @@ describe('/admin/appointment/[id]/reschedule book action', () => {
'host',
NEW_SLOT,
60,
CURRENT
row
);
expect(h.isRescheduleSlotBookable.mock.calls[0][7]).toBe(row);
expect(h.validateReason).toHaveBeenCalledWith(expect.any(FormData), 'rescheduling');
expect(h.reschedule).toHaveBeenCalledExactlyOnceWith(
expect.objectContaining({ db: h.db, cfg: validConfig }),
@@ -31,7 +31,7 @@ export const actions: Actions = {
}
const { name, email, answers, location: resolvedLocation } = parsed.data;
const duration = resolveDuration(eventType, form);
const duration = resolveDuration(eventType, 'host', form);
if (duration === null) {
return fail(400, { error: 'Please pick a valid meeting length.' });
}
@@ -12,7 +12,10 @@ const h = vi.hoisted(() => ({
vi.mock('$lib/server/state', () => ({ getConfig: () => validConfig, getDb: () => h.db }));
vi.mock('$lib/server/availability/booking', () => ({ isNewSlotBookable: h.isNewSlotBookable }));
vi.mock('$lib/server/appointment/create', () => ({ createAppointment: h.createAppointment }));
vi.mock('$lib/server/appointment/duration', () => ({ resolveDuration: h.resolveDuration }));
vi.mock('$lib/server/appointment/duration', async (importOriginal) => {
const original = await importOriginal<typeof import('$lib/server/appointment/duration')>();
return { ...original, resolveDuration: h.resolveDuration };
});
vi.mock('$lib/server/appointment/form.server', async (io) => {
const original = await io<typeof import('$lib/server/appointment/form.server')>();
return { ...original, parseAndValidateAppointmentForm: h.parseForm };
@@ -55,8 +58,17 @@ beforeEach(() => {
describe('/admin/schedule/[slug] book action', () => {
test('delegates the chosen length and books as host with the configured timezone fallback', async () => {
const result = await caught(() => actions.default(bookEvent()));
const event = bookEvent();
const form = await event.request.formData();
form.set('role', 'guest');
form.set('currentDuration', '300');
const result = await caught(() => actions.default(event));
expect(result).toMatchObject({ status: 303, location: '/appointment/new-1' });
expect(h.resolveDuration).toHaveBeenCalledExactlyOnceWith(
validConfig.meetings['30-min-chat'],
'host',
form
);
expect(h.isNewSlotBookable).toHaveBeenCalledExactlyOnceWith(
h.db,
validConfig,