feat(web): reschedule time, duration, or both #113

Merged
eleith merged 1 commits from feat/reschedule-duration into main 2026-11-02 00:14:59 +00:00
27 changed files with 1466 additions and 425 deletions

No files matched your search

@@ -0,0 +1,7 @@
---
'@when/web': patch
---
Allow rescheduling to change the start time, duration, or both within the existing guest and admin constraints. Start with the current day and interval selected in the calendar wizard, retain the start when a new duration fits, and build a factual proposed-selection summary as changes are made. Keep navigation available and disable only final reschedule submission until the interval is valid and changed, with the same check inside the write transaction.
Restore pending guests' saved public location choices in the reschedule form without exposing private host-entered meeting details.
+2
View File
@@ -12,6 +12,7 @@ test('a stale link shows the move but cannot authorize access to the new appoint
const staleLink = `/appointment/${original.id}?token=${original.cancel_token}`;
await page.goto(`/appointment/${original.id}/reschedule?token=${original.cancel_token}`);
await expect(page.getByRole('heading', { name: 'Step 1 of 3: Pick a day' })).toBeVisible();
await pickLastBookableDay(page);
await pickSlotFromEndOfDay(page);
await page.getByRole('button', { name: 'Confirm' }).click();
@@ -42,6 +43,7 @@ test('the live token is a master key over the appointment history', async ({ pag
const original = await seedAppointment({ status: 'confirmed' });
await page.goto(`/appointment/${original.id}/reschedule?token=${original.cancel_token}`);
await expect(page.getByRole('heading', { name: 'Step 1 of 3: Pick a day' })).toBeVisible();
await pickLastBookableDay(page);
await pickSlotFromEndOfDay(page);
await page.getByRole('button', { name: 'Confirm' }).click();
+17
View File
@@ -1,6 +1,7 @@
import { expect, test } from './support/test.ts';
import { readAppointment } from './support/database.ts';
import { pickFirstBookableDay, pickSlotFromEndOfDay } from './support/wizard.ts';
import { signInAsAdmin } from './support/auth.ts';
test('conditional questions are required only while visible and hidden answers are not saved', async ({
page
@@ -12,6 +13,7 @@ test('conditional questions are required only while visible and hidden answers a
await page.getByLabel('Your name').fill('Phone Guest');
await page.getByLabel('Phone number').fill('+1 202 555 0123');
await page.getByLabel('Meeting place').selectOption('Garden trail');
const focus = page.getByLabel('Meeting focus');
const details = page.getByLabel('Project details');
const submit = page.getByRole('button', { name: 'Request', exact: true });
@@ -39,6 +41,7 @@ test('conditional questions are required only while visible and hidden answers a
event_type_id: 'conditional-form',
guest_name: 'Phone Guest',
guest_email: null,
location: 'Garden trail',
status: 'pending'
});
// This form legitimately collects a phone number instead of an email address.
@@ -46,4 +49,18 @@ test('conditional questions are required only while visible and hidden answers a
{ name: 'phone', label: 'Phone number', type: 'phone', value: '+1 202 555 0123' },
{ name: 'focus', label: 'Meeting focus', type: 'choice', value: 'General question' }
]);
const reschedule = `/appointment/${id}/reschedule?token=${encodeURIComponent(row.cancel_token)}`;
for (const role of ['guest', 'host']) {
if (role === 'host') await signInAsAdmin(page);
await page.goto(reschedule);
await page.getByRole('button', { name: 'Continue', exact: true }).click();
await page.getByRole('button', { name: 'Confirm', exact: true }).click();
const location = page.getByLabel('Meeting place');
await expect(location).toHaveValue('Garden trail');
await expect(page.getByLabel('Meeting focus')).toHaveValue('General question');
if (role === 'guest') await expect(location).toBeEnabled();
else await expect(location).toBeDisabled();
}
expect(await readAppointment(id)).toEqual(row);
});
+5
View File
@@ -88,6 +88,11 @@ meetings:
label: 'Meeting focus'
required: true
choices: ['General question', 'Project review']
- name: 'meeting_place'
type: 'event_location'
label: 'Meeting place'
required: false
choices: ['Lakeside path', 'Garden trail']
- name: 'details'
type: 'paragraph'
label: 'Project details'
+568 -56
View File
@@ -1,4 +1,4 @@
import { openDb, replaceCalendarBusy } from '@when/db';
import { openDb, parseActionLog, replaceCalendarBusy } from '@when/db';
import { expect, test, type Page, type Request } from './support/test.ts';
import { signInAsAdmin } from './support/auth.ts';
import { APP_DB_PATH, readAppointment } from './support/database.ts';
@@ -28,6 +28,59 @@ async function chooseDuration(page: Page, current: number, next: number): Promis
await expect(dialog).not.toBeVisible();
}
async function expectRescheduleBannerLayout(page: Page): Promise<void> {
const summary = page.getByRole('complementary', { name: 'Reschedule summary' });
const returnLink = summary.getByRole('link', { name: 'Back to appointment', exact: true });
const columns = summary.locator('.summary > div');
const desktop = page.viewportSize()!;
let [originalBox, proposedBox, linkBox] = await Promise.all([
columns.nth(0).boundingBox(),
columns.nth(1).boundingBox(),
returnLink.boundingBox()
]);
expect(linkBox!.x + linkBox!.width).toBeLessThanOrEqual(originalBox!.x);
expect(originalBox!.x + originalBox!.width).toBeLessThanOrEqual(proposedBox!.x);
expect(Math.abs(linkBox!.width - originalBox!.width)).toBeLessThanOrEqual(1);
expect(Math.abs(originalBox!.width - proposedBox!.width)).toBeLessThanOrEqual(1);
await expect(returnLink).toHaveCSS('border-right-width', '1px');
await expect(columns.nth(1)).toHaveCSS('border-left-width', '1px');
const dividerColor = await returnLink.evaluate(
(element) => getComputedStyle(element).borderRightColor
);
await expect(columns.nth(1)).toHaveCSS('border-left-color', dividerColor);
try {
await page.setViewportSize({ width: 390, height: 844 });
await expect(summary).toHaveCSS('border-left-width', '0px');
await expect(summary).toHaveCSS('border-right-width', '0px');
await expect(summary).toHaveCSS('border-radius', '0px');
await expect(summary).toHaveCSS('margin-bottom', '0px');
[originalBox, proposedBox, linkBox] = await Promise.all([
columns.nth(0).boundingBox(),
columns.nth(1).boundingBox(),
returnLink.boundingBox()
]);
expect(linkBox!.y + linkBox!.height).toBeLessThanOrEqual(originalBox!.y);
expect(Math.abs(originalBox!.y - proposedBox!.y)).toBeLessThanOrEqual(1);
expect(originalBox!.x + originalBox!.width).toBeLessThanOrEqual(proposedBox!.x);
expect(Math.abs(originalBox!.width - proposedBox!.width)).toBeLessThanOrEqual(1);
await expect(returnLink).toHaveCSS('border-bottom-width', '1px');
await expect(columns.nth(1)).toHaveCSS('border-left-width', '1px');
await expect(columns.nth(1)).toHaveCSS('border-top-width', '0px');
const factsBox = await summary.locator('.summary').boundingBox();
expect(Math.abs(linkBox!.width - factsBox!.width)).toBeLessThanOrEqual(1);
expect(linkBox!.height).toBeGreaterThanOrEqual(44);
const labelBox = await returnLink.locator('.return-label').boundingBox();
expect(
Math.abs(labelBox!.x + labelBox!.width / 2 - (linkBox!.x + linkBox!.width / 2))
).toBeLessThanOrEqual(1);
const mobileBannerBox = await summary.boundingBox();
const linkRowCenter = (mobileBannerBox!.y + linkBox!.y + linkBox!.height) / 2;
expect(Math.abs(labelBox!.y + labelBox!.height / 2 - linkRowCenter)).toBeLessThanOrEqual(1);
} finally {
await page.setViewportSize(desktop);
}
}
test('a duration change clears an unavailable selected day and disables Continue', async ({
page
}) => {
@@ -150,7 +203,7 @@ test('host uses the configured default and finite palette, recovering locally fr
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 ({
test('rescheduling starts on the selected calendar at the trusted current 90, blocks only final no-op submission for both roles, and omission preserves it', async ({
baseURL,
page,
request
@@ -163,8 +216,12 @@ test('rescheduling defaults to the trusted current 90 for both roles and omissio
durationMinutes: 90
});
const publicPath = `/appointment/${original.id}/reschedule`;
const link = `${publicPath}?token=${original.cancel_token}&date=${day}`;
const link = `${publicPath}?token=${original.cancel_token}&slot=${encodeURIComponent(original.start_time)}`;
await page.goto(link);
await expect(page.getByRole('heading', { name: 'Step 1 of 3: Pick a day' })).toBeVisible();
await expect(page.locator('.cal-day[data-selected]')).toHaveAttribute('data-value', day);
await expect(page.getByRole('button', { name: 'Continue', exact: true })).toBeEnabled();
await expect(page.locator('#appointment-form')).toHaveCount(0);
await expect(durationTrigger(page, 90)).toBeVisible();
await durationTrigger(page, 90).click();
const dialog = page.getByRole('dialog', { name: 'How long?' });
@@ -175,6 +232,46 @@ test('rescheduling defaults to the trusted current 90 for both roles and omissio
]);
await expect(dialog.getByRole('button', { name: '120 minutes', exact: true })).toHaveCount(0);
await dialog.getByRole('button', { name: 'Close', exact: true }).click();
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(page.getByRole('radio', { name: /0?9:00\s*AM\s*–\s*10:30\s*AM/i })).toHaveAttribute(
'aria-checked',
'true'
);
await expect(page.getByRole('button', { name: 'Confirm', exact: true })).toBeEnabled();
await page.getByRole('button', { name: 'Confirm', exact: true }).click();
await expect(page.getByRole('heading', { name: 'Step 3 of 3: Enter your info' })).toBeVisible();
await page.getByLabel('Reason for rescheduling').fill('No interval change');
await expect(page.getByLabel('What is your name?')).toHaveValue(original.guest_name);
await expect(page.getByLabel('What is your email?')).toHaveValue(original.guest_email!);
await expect(
page.getByRole('button', { name: 'Confirm Reschedule', exact: true })
).toBeDisabled();
const summary = page.getByRole('complementary', { name: 'Reschedule summary' });
await expect(summary.getByText('Original', { exact: true })).toBeVisible();
await expect(summary.locator('dd').nth(1)).toHaveText('—');
await expect(summary.locator('dd').nth(0).getByRole('link')).toHaveCount(0);
const returnLink = summary.getByRole('link', { name: 'Back to appointment', exact: true });
await expect(returnLink).toHaveAttribute(
'href',
`/appointment/${original.id}?token=${encodeURIComponent(original.cancel_token)}`
);
await expectRescheduleBannerLayout(page);
const unchangedUrl = page.url();
const actionPosts: string[] = [];
const trackActionPost = (request: Request) => {
if (request.method() === 'POST' && new URL(request.url()).pathname === publicPath) {
actionPosts.push(request.url());
}
};
page.on('request', trackActionPost);
await page.getByLabel('What is your name?').press('Enter');
// Bypass the disabled button to exercise enhance's own no-op cancellation.
await page
.locator('#appointment-form')
.evaluate((form) => (form as HTMLFormElement).requestSubmit());
await expect(page).toHaveURL(unchangedUrl);
expect(await readAppointment(original.id)).toEqual(original);
const form = {
slot: `${day}T10:30:00Z`,
@@ -190,19 +287,28 @@ test('rescheduling defaults to the trusted current 90 for both roles and omissio
expect(rejectedResult).toMatchObject({ type: 'failure', status: 400 });
const sameStart = await request.post(publicPath, {
...postOptions(baseURL!),
form: { ...form, slot: original.start_time, duration: '25' }
form: { ...form, slot: original.start_time, duration: '90' }
});
const sameStartResult = await sameStart.json();
expect(sameStartResult).toMatchObject({ type: 'failure', status: 400 });
await signInAsAdmin(page);
await page.goto(link);
await expect(page.getByRole('heading', { name: 'Step 1 of 3: Pick a day' })).toBeVisible();
await expect(page.locator('.cal-day[data-selected]')).toHaveAttribute('data-value', day);
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();
await page.getByRole('button', { name: 'Continue', exact: true }).click();
await expect(page.getByRole('button', { name: 'Confirm', exact: true })).toBeEnabled();
await page.getByRole('button', { name: 'Confirm', exact: true }).click();
await page.getByLabel('Reason for rescheduling').fill('Still no interval change');
await expect(
page.getByRole('button', { name: 'Confirm Reschedule', exact: true })
).toBeDisabled();
const hostOnPublic = await page.request.post(publicPath, {
...postOptions(baseURL!),
form: forged
@@ -211,7 +317,7 @@ test('rescheduling defaults to the trusted current 90 for both roles and omissio
expect(hostOnPublicResult).toMatchObject({ type: 'failure', status: 400 });
const hostSameStart = await page.request.post(`/admin/appointment/${original.id}/reschedule`, {
...postOptions(baseURL!),
form: { ...form, slot: original.start_time, duration: '25' }
form: { ...form, slot: original.start_time, duration: '90' }
});
const hostSameStartResult = await hostSameStart.json();
expect(hostSameStartResult).toMatchObject({ type: 'failure', status: 400 });
@@ -245,12 +351,165 @@ test('rescheduling defaults to the trusted current 90 for both roles and omissio
expect(redirect.location).toBe(
`/appointment/${id}?token=${encodeURIComponent(next.cancel_token)}`
);
// Watch through the rest of the journey so delayed action requests cannot pass unnoticed.
page.off('request', trackActionPost);
expect(actionPosts).toEqual([]);
});
test('guest can change a booked host-only 90 to configured 25, but cannot invent 75', async ({
baseURL,
page,
request
for (const { meeting, minutes, picker } of [
{ meeting: slug, minutes: 25, picker: 'discrete slots' },
{ meeting: 'chat', minutes: 30, picker: 'continuous timeline' }
]) {
test(`guest can change only duration with ${picker}, but cannot invent 75`, async ({
baseURL,
page,
request
}) => {
const day = futureWeekday();
const original = await seedAppointment({
eventTypeId: meeting,
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(baseURL!),
form: {
slot: original.start_time,
duration: '75',
currentDuration: '75',
...forgedHost,
token: original.cancel_token,
name: original.guest_name,
email: original.guest_email!,
reschedule_reason: reason
}
});
expect(await rejected.json()).toMatchObject({ type: 'failure', status: 400 });
expect(await readAppointment(original.id)).toEqual(original);
await page.goto(`${publicPath}?token=${original.cancel_token}`);
await expect(page.getByRole('heading', { name: 'Step 1 of 3: Pick a day' })).toBeVisible();
await expect(page.locator('.cal-day[data-selected]')).toHaveAttribute('data-value', day);
const summary = page.getByRole('complementary', { name: 'Reschedule summary' });
await expect(summary.locator('dd').nth(1)).toHaveText('—');
await chooseDuration(page, 90, minutes);
await expect(summary.getByText('Proposed', { exact: true })).toBeVisible();
await page.getByRole('button', { name: 'Continue', exact: true }).click();
await expect(page.getByRole('heading', { name: 'Step 2 of 3: Pick a time' })).toBeVisible();
const sameStart = page.getByRole('radio', {
name: new RegExp(`0?9:00\\s*AM\\s*–\\s*0?9:${minutes}\\s*AM`, 'i')
});
await expect(sameStart).toHaveAttribute('aria-checked', 'true');
await expect(page.getByRole('button', { name: 'Confirm', exact: true })).toBeEnabled();
await expect(summary.locator('dd').nth(1).locator('span').nth(1)).toHaveText(
`9:00am – 9:${minutes}am`
);
await sameStart.focus();
await sameStart.press('ArrowDown');
await expect(sameStart).toHaveAttribute('aria-checked', 'false');
await page.getByRole('radio', { checked: true }).press('ArrowUp');
await expect(sameStart).toHaveAttribute('aria-checked', 'true');
// Restoring the existing interval keeps selection and navigation; only final submission is gated.
await chooseDuration(page, minutes, 90);
await expect(page.getByRole('button', { name: 'Confirm', exact: true })).toBeEnabled();
await expect(page.getByRole('radio', { checked: true })).toHaveAccessibleName(
/0?9:00\s*AM\s*–\s*10:30\s*AM/i
);
await expect(summary.locator('dd').nth(1)).toHaveText('—');
await chooseDuration(page, 90, minutes);
await expect(sameStart).toHaveAttribute('aria-checked', 'true');
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(String(minutes));
await expect(page.getByLabel('What is your name?')).toHaveValue(original.guest_name);
await expect(page.getByLabel('What is your name?')).toBeEnabled();
await expect(page.getByLabel('What is your email?')).toHaveValue(original.guest_email!);
await expect(page.getByLabel('What is your email?')).toBeEnabled();
const slot = await page.locator('input[name="slot"]').inputValue();
expect(Date.parse(slot)).toBe(Date.parse(original.start_time));
await page.getByLabel('Reason for rescheduling').fill(reason);
await expect(
page.getByRole('button', { name: 'Confirm Reschedule', exact: true })
).toBeEnabled();
await page.getByLabel('What is your name?').press('Enter');
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: meeting,
status: 'pending',
origin_id: original.id,
guest_name: original.guest_name,
guest_email: original.guest_email,
ics_sequence: original.ics_sequence + 1
});
expect(Date.parse(next.start_time)).toBe(Date.parse(original.start_time));
expect(Date.parse(next.end_time) - Date.parse(next.start_time)).toBe(minutes * 60_000);
expect(parseActionLog(next.action_log)).toContainEqual(
expect.objectContaining({ action: 'reschedule', actor: 'guest' })
);
});
}
test('host can reschedule only duration to a host-only length', async ({ page }) => {
const day = futureWeekday();
const original = await seedAppointment({
eventTypeId: slug,
status: 'confirmed',
start: new Date(`${day}T09:00:00Z`),
durationMinutes: 25
});
await signInAsAdmin(page);
await page.goto(`/appointment/${original.id}/reschedule?token=${original.cancel_token}`);
await expect(page.getByRole('heading', { name: 'Step 1 of 3: Pick a day' })).toBeVisible();
await expect(page.locator('.cal-day[data-selected]')).toHaveAttribute('data-value', day);
await chooseDuration(page, 25, 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(page.getByRole('radio', { name: /0?9:00\s*AM\s*–\s*10:30\s*AM/i })).toHaveAttribute(
'aria-checked',
'true'
);
await page.getByRole('button', { name: 'Confirm', exact: true }).click();
await expect(page.locator('#appointment-form')).toHaveAttribute(
'action',
`/admin/appointment/${original.id}/reschedule`
);
await expect(page.getByLabel('What is your name?')).toBeDisabled();
await page.getByLabel('Reason for rescheduling').fill('Extend this meeting');
await page.getByRole('button', { name: 'Confirm Reschedule', exact: true }).click();
await page.waitForURL(/\/appointment\/[^/?]+$/);
const id = new URL(page.url()).pathname.split('/').at(-1)!;
expect(id).not.toBe(original.id);
expect((await readAppointment(original.id)).status).toBe('rescheduled');
const next = await readAppointment(id);
expect(next).toMatchObject({
event_type_id: slug,
status: 'confirmed',
origin_id: original.id,
guest_name: original.guest_name,
guest_email: original.guest_email,
ics_sequence: original.ics_sequence + 1
});
expect(Date.parse(next.start_time)).toBe(Date.parse(original.start_time));
expect(Date.parse(next.end_time) - Date.parse(next.start_time)).toBe(90 * 60_000);
expect(parseActionLog(next.action_log)).toContainEqual(
expect.objectContaining({ action: 'reschedule', actor: 'host' })
);
});
test('calendar Back navigation preserves duration, partial proposed facts, and explicit day or slot intent', async ({
page
}) => {
const day = futureWeekday();
const original = await seedAppointment({
@@ -259,58 +518,311 @@ test('guest can change a booked host-only 90 to configured 25, but cannot invent
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(baseURL!),
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 link = `/appointment/${original.id}/reschedule?token=${original.cancel_token}`;
const otherDate = new Date(`${day}T09:00:00Z`);
do {
otherDate.setUTCDate(otherDate.getUTCDate() + 1);
} while (otherDate.getUTCDay() === 0 || otherDate.getUTCDay() === 6);
const otherDay = otherDate.toISOString().slice(0, 10);
const dayLabel = new Intl.DateTimeFormat('en-US', {
timeZone: 'UTC',
weekday: 'long',
month: 'long',
day: 'numeric'
});
const rejectedResult = await rejected.json();
expect(rejectedResult).toMatchObject({ type: 'failure', status: 400 });
const unchanged = await readAppointment(original.id);
expect(unchanged).toEqual(original);
const summary = page.getByRole('complementary', { name: 'Reschedule summary' });
const proposed = summary.locator('dd').nth(1);
const otherSummaryDate = new Intl.DateTimeFormat('en-US', {
timeZone: 'UTC',
weekday: 'short',
month: 'short',
day: 'numeric'
}).format(otherDate);
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();
// Explicit day links open the time picker, without inventing a time selection.
await page.goto(`${link}&date=${otherDay}`);
await expect(page.getByRole('heading', { name: 'Step 2 of 3: Pick a time' })).toBeVisible();
await expect(page.locator('.stage-subtitle')).toHaveText(dayLabel.format(otherDate));
await expect(page.getByRole('radio', { checked: true })).toHaveCount(0);
await expect(page.getByRole('button', { name: 'Confirm', exact: true })).toBeEnabled();
await expect(summary.getByText('Proposed', { exact: true })).toBeVisible();
await expect(proposed.locator('span').nth(0)).toHaveText(otherSummaryDate);
await expect(proposed.locator('span').nth(1)).toHaveText('—');
await expect(proposed).not.toContainText(/\bmin\b/);
const unavailable = `${day}T11:30:00Z`;
await page.goto(`${link}&slot=${encodeURIComponent(unavailable)}`);
await expect(page.getByText(/is no longer available\. Pick another time/)).toBeVisible();
await expect(page.getByRole('radio', { checked: true })).toHaveCount(0);
await expect(page.getByRole('button', { name: 'Confirm', exact: true })).toBeEnabled();
await expect(proposed).toContainText('—');
await expect(proposed).not.toContainText(/Choose|No changes yet/);
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 name?')).toBeEnabled();
await expect(page.getByLabel('What is your email?')).toHaveValue(original.guest_email!);
await expect(page.getByLabel('What is your email?')).toBeEnabled();
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=.+/);
await expect(page.getByRole('heading', { name: 'Step 3 of 3: Enter your info' })).toBeVisible();
await page.getByLabel('Reason for rescheduling').fill('A time is still missing');
await expect(
page.getByRole('button', { name: 'Confirm Reschedule', exact: true })
).toBeDisabled();
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
const alternative = `${day}T10:30:00Z`;
await page.context().addCookies([{ name: 'tz', value: 'UTC', url: new URL(page.url()).origin }]);
const response = await page.goto(`${link}&slot=${encodeURIComponent(alternative)}`);
expect(response!.status()).toBe(200);
const serverPage = await page.evaluate(
(html) => {
const document = new DOMParser().parseFromString(html, 'text/html');
return {
heading: document.querySelector('.stage-title')?.textContent?.trim(),
action: document.querySelector('#appointment-form')?.getAttribute('action'),
slot: document.querySelector('input[name="slot"]')?.getAttribute('value')
};
},
await response!.text()
);
expect(serverPage).toEqual({
heading: 'Step 3 of 3: Enter your info',
action: `/appointment/${original.id}/reschedule`,
slot: alternative
});
await expect(page.getByRole('heading', { name: 'Step 3 of 3: Enter your info' })).toBeVisible();
expect(Date.parse(await page.locator('input[name="slot"]').inputValue())).toBe(
Date.parse(alternative)
);
await page.getByRole('button', { name: 'Back', exact: true }).click();
const selectedTime = page.getByRole('radio', { checked: true });
await selectedTime.press('ArrowUp');
await selectedTime.press('ArrowUp');
await expect(selectedTime).toHaveAccessibleName(/0?9:30\s*AM\s*–\s*11:00\s*AM/i);
// Changing display zones must not replay the incoming 10:30 slot over this new draft.
for (const { zone, time, range } of [
{ zone: 'Kiritimati', time: /11:30\s*PM\s*–\s*0?1:00\s*AM/i, range: '11:30pm – 1:00am' },
{ zone: 'Abidjan', time: /0?9:30\s*AM\s*–\s*11:00\s*AM/i, range: '9:30am – 11:00am' }
]) {
await page.getByRole('button', { name: /^Change timezone, currently/ }).click();
const timezone = page.getByRole('dialog', { name: 'Choose timezone' });
await timezone.getByRole('searchbox').fill(zone);
await timezone.getByRole('button', { name: new RegExp(`^${zone}\\b`) }).click();
await expect(timezone).not.toBeVisible();
await expect(page.getByRole('heading', { name: 'Step 2 of 3: Pick a time' })).toBeVisible();
await expect(selectedTime).toHaveAccessibleName(time);
await expect(proposed.locator('span').nth(1)).toHaveText(range);
}
await page.getByRole('button', { name: 'Confirm', exact: true }).click();
expect(Date.parse(await page.locator('input[name="slot"]').inputValue())).toBe(
Date.parse(`${day}T09:30:00Z`)
);
// Without query intent, start on the real calendar with the booked day selected.
await page.goto(link);
await expect(page.getByRole('heading', { name: 'Step 1 of 3: Pick a day' })).toBeVisible();
await expect(page.locator('.cal-day[data-selected]')).toHaveAttribute('data-value', day);
await expect(summary.locator('dd').nth(1)).toHaveText('—');
await page.getByRole('button', { name: 'Continue', exact: true }).click();
await expect(page.getByRole('radio', { checked: true })).toHaveAccessibleName(
/0?9:00\s*AM\s*–\s*10:30\s*AM/i
);
await page.getByRole('button', { name: 'Back', exact: true }).click();
await chooseDuration(page, 90, 25);
await page.locator(`.cal-day[data-value="${otherDay}"]`).click();
await expect(page.locator('.cal-day[data-selected]')).toHaveAttribute('data-value', otherDay);
await expect(summary.getByText('Proposed', { exact: true })).toBeVisible();
await expect(proposed.locator('span').nth(0)).toHaveText(otherSummaryDate);
await expect(proposed.locator('span').nth(1)).toHaveText('—');
await expect(proposed).not.toContainText(/\bmin\b/);
await page.getByRole('button', { name: 'Continue', exact: true }).click();
await expect(page.locator('.stage-subtitle')).toHaveText(dayLabel.format(otherDate));
await expect(page.getByRole('radio', { checked: true })).toHaveCount(0);
await expect(page.getByRole('button', { name: 'Confirm', exact: true })).toBeEnabled();
await page.getByRole('button', { name: 'Back', exact: true }).click();
// Deselecting a day exposes both missing facts, not proposed action copy.
await page.locator('.cal-day[data-selected]').click();
await expect(page.locator('.cal-day[data-selected]')).toHaveCount(0);
await expect(proposed.locator('span')).toHaveText(['—', '—']);
await expect(proposed).not.toContainText(/Choose|No changes yet/);
await expect(page.getByRole('button', { name: 'Continue', exact: true })).toBeEnabled();
const originalDay = page.locator(`.cal-day[data-value="${day}"]`);
// Returning from a next-month day can put the original just outside the visible grid.
if ((await originalDay.count()) === 0) await page.locator('.cal-nav-btn').first().click();
await originalDay.click();
await expect(page.locator('.cal-day[data-selected]')).toHaveAttribute('data-value', day);
await expect(durationTrigger(page, 25)).toBeVisible();
await page.getByRole('button', { name: 'Continue', exact: true }).click();
await expect(page.locator('.stage-subtitle')).toHaveText(
dayLabel.format(new Date(original.start_time))
);
await expect(page.getByRole('radio', { checked: true })).toHaveCount(0);
await page.getByRole('radio', { name: /0?9:30\s*AM\s*–\s*0?9:55\s*AM/i }).click();
await page.getByRole('button', { name: 'Confirm', exact: true }).click();
await expect(page.getByRole('heading', { name: 'Step 3 of 3: Enter your info' })).toBeVisible();
await expect(summary.locator('dd').nth(0).locator('span').nth(1)).toHaveText('9:00am – 10:30am');
await expect(proposed.locator('span').nth(1)).toHaveText('9:30am – 9:55am');
await expect(page.locator('input[name="duration"]')).toHaveValue('25');
expect(Date.parse(await page.locator('input[name="slot"]').inputValue())).toBe(
Date.parse(`${day}T09:30:00Z`)
);
});
test('an unavailable original interval starts on its selected UTC+14 calendar day, permits navigation, and requires a fitting change to submit', async ({
baseURL,
page
}) => {
const day = futureWeekday();
const original = await seedAppointment({
eventTypeId: slug,
status: 'confirmed',
start: new Date(`${day}T11:30:00Z`),
durationMinutes: 90
});
const timezone = 'Pacific/Kiritimati';
await page
.context()
.addCookies([{ name: 'tz', value: encodeURIComponent(timezone), url: baseURL! }]);
const link = `/appointment/${original.id}/reschedule?token=${original.cancel_token}`;
const localDay = new Intl.DateTimeFormat('sv-SE', { timeZone: timezone }).format(
new Date(original.start_time)
);
const localDate = new Intl.DateTimeFormat('en-US', {
timeZone: timezone,
weekday: 'long',
month: 'long',
day: 'numeric'
}).format(new Date(original.start_time));
const localSummaryDate = new Intl.DateTimeFormat('en-US', {
timeZone: timezone,
weekday: 'short',
month: 'short',
day: 'numeric'
}).format(new Date(original.start_time));
// The .000 original-slot link is calendar context, even though 90 minutes overruns noon.
await page.goto(`${link}&slot=${encodeURIComponent(original.start_time)}`);
await expect(page.getByRole('heading', { name: 'Step 1 of 3: Pick a day' })).toBeVisible();
await expect(page.locator('.cal-day[data-selected]')).toHaveAttribute('data-value', localDay);
await expect(page.getByText(/is no longer available\. Pick another time/)).toHaveCount(0);
await page.goto(link);
await expect(page.getByRole('heading', { name: 'Step 1 of 3: Pick a day' })).toBeVisible();
await expect(page.locator('.cal-day[data-selected]')).toHaveAttribute('data-value', localDay);
await expect(durationTrigger(page, 90)).toBeVisible();
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(page.locator('.stage-subtitle')).toHaveText(localDate);
await expect(page.getByRole('button', { name: 'Confirm', exact: true })).toBeEnabled();
await expect(page.getByRole('status')).toHaveText(
"The current start time isn't available for this duration. Choose another time or duration."
);
const summary = page.getByRole('complementary', { name: 'Reschedule summary' });
await expect(summary.locator('dd').nth(0)).toContainText(localSummaryDate);
await expect(summary.locator('dd').nth(0).locator('span').nth(1)).toHaveText('1:30am – 3:00am');
await expect(summary.locator('dd').nth(1)).toHaveText('—');
await page.getByRole('button', { name: 'Confirm', exact: true }).click();
await page.getByLabel('Reason for rescheduling').fill('This interval is not offered');
await expect(
page.getByRole('button', { name: 'Confirm Reschedule', exact: true })
).toBeDisabled();
await page.getByRole('button', { name: 'Back', exact: true }).click();
await chooseDuration(page, 90, 25);
await expect(page.locator('.stage-subtitle')).toHaveText(localDate);
await expect(page.getByRole('radio', { name: /0?1:30\s*AM\s*–\s*0?1:55\s*AM/i })).toHaveAttribute(
'aria-checked',
'true'
);
await expect(page.getByRole('button', { name: 'Confirm', exact: true })).toBeEnabled();
await expect(page.locator('.reschedule-notice')).toHaveCount(0);
await expect(summary.locator('dd').nth(1)).toContainText(localSummaryDate);
await expect(summary.locator('dd').nth(1).locator('span').nth(1)).toHaveText('1:30am – 1:55am');
await chooseDuration(page, 25, 60);
await expect(page.getByRole('radio', { checked: true })).toHaveCount(0);
await expect(page.getByRole('button', { name: 'Confirm', exact: true })).toBeEnabled();
await expect(page.getByRole('status')).toHaveText(
'Choose an available start time for this duration.'
);
await expect(summary.locator('dd').nth(1)).toContainText('—');
await expect(summary.locator('dd').nth(1).locator('span').nth(1)).toHaveText('—');
await page.getByRole('button', { name: 'Confirm', exact: true }).click();
await expect(page.getByRole('heading', { name: 'Step 3 of 3: Enter your info' })).toBeVisible();
await page.getByLabel('Reason for rescheduling').fill('No compatible start has been chosen');
await expect(
page.getByRole('button', { name: 'Confirm Reschedule', exact: true })
).toBeDisabled();
await page.getByRole('button', { name: 'Back', exact: true }).click();
await chooseDuration(page, 60, 25);
await page.getByRole('radio', { name: /0?1:30\s*AM\s*–\s*0?1:55\s*AM/i }).click();
await page.getByRole('button', { name: 'Confirm', exact: true }).click();
expect(Date.parse(await page.locator('input[name="slot"]').inputValue())).toBe(
Date.parse(original.start_time)
);
await expect(page.locator('input[name="duration"]')).toHaveValue('25');
await page.getByLabel('Reason for rescheduling').fill('Use the fitting shorter length');
await expect(page.getByRole('button', { name: 'Confirm Reschedule', exact: true })).toBeEnabled();
});
test.describe('rescheduling before the browser timezone is initialized', () => {
test.use({ timezoneId: 'America/Los_Angeles' });
test('a no-cookie explicit day waits for the viewer timezone, while a no-query reload selects the original local calendar day', async ({
page
}) => {
const day = futureWeekday();
const original = await seedAppointment({
eventTypeId: slug,
status: 'confirmed',
start: new Date(`${day}T00:30:00Z`),
durationMinutes: 60
});
const link = `/appointment/${original.id}/reschedule?token=${original.cancel_token}`;
expect((await page.context().cookies()).filter(({ name }) => name === 'tz')).toEqual([]);
const response = await page.goto(`${link}&date=${day}`);
expect(await response!.text()).not.toContain('You selected');
await expect(
page.getByRole('button', { name: /^Change timezone, currently Los Angeles/ })
).toBeVisible();
await expect(page.getByRole('heading', { name: 'Step 2 of 3: Pick a time' })).toBeVisible();
await expect(page.getByRole('radio').first()).toBeVisible();
const dateLabel = (date: Date) =>
new Intl.DateTimeFormat('en-US', {
timeZone: 'America/Los_Angeles',
weekday: 'long',
month: 'long',
day: 'numeric'
}).format(date);
await expect(page.locator('.stage-subtitle')).toHaveText(
dateLabel(new Date(`${day}T12:00:00Z`))
);
await expect(page.getByRole('radio', { checked: true })).toHaveCount(0);
await expect(page.getByRole('button', { name: 'Confirm', exact: true })).toBeEnabled();
await page.goto(link);
await expect(page.getByRole('heading', { name: 'Step 1 of 3: Pick a day' })).toBeVisible();
const localDay = new Intl.DateTimeFormat('sv-SE', {
timeZone: 'America/Los_Angeles'
}).format(new Date(original.start_time));
await expect(page.locator('.cal-day[data-selected]')).toHaveAttribute('data-value', localDay);
await page.getByRole('button', { name: 'Continue', exact: true }).click();
await expect(page.locator('.stage-subtitle')).toHaveText(
dateLabel(new Date(original.start_time))
);
const currentTime = new Intl.DateTimeFormat('en-US', {
timeZone: 'America/Los_Angeles',
hour: '2-digit',
minute: '2-digit'
})
.format(new Date(original.start_time))
.replace(/\s+/g, '')
.replace(/^0/, '')
.toLowerCase();
await expect(page.getByRole('complementary', { name: 'Reschedule summary' })).toContainText(
currentTime
);
await expect(page.getByRole('button', { name: 'Confirm', exact: true })).toBeEnabled();
await page.getByRole('button', { name: 'Confirm', exact: true }).click();
await page.getByLabel('Reason for rescheduling').fill('The original interval is unchanged');
await expect(
page.getByRole('button', { name: 'Confirm Reschedule', exact: true })
).toBeDisabled();
});
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 ({
+7 -13
View File
@@ -224,7 +224,6 @@ describe('buildDayTimeline', () => {
eventType: { duration_minutes: 30 },
daySlots: ['2025-06-15T09:00:00Z', '2025-06-15T09:30:00Z'],
tz: 'UTC',
originalSlot: null,
// well before the view window, so nothing is in the past
now: Temporal.Instant.from('2025-06-15T00:00:00Z')
};
@@ -281,24 +280,22 @@ describe('buildDayTimeline', () => {
expect(t.buffers[0].height).toBeCloseTo(20);
});
test('places slots by start and duration, flagging the original', () => {
const t = buildDayTimeline({ ...base, originalSlot: '2025-06-15T09:30:00Z' })!;
test('places slots by start and duration', () => {
const t = buildDayTimeline(base)!;
expect(t.slots).toEqual([
{
iso: '2025-06-15T09:00:00Z',
time: '09:00 AM',
endTime: '09:30 AM',
top: 10,
height: 5,
isOriginal: false
height: 5
},
{
iso: '2025-06-15T09:30:00Z',
time: '09:30 AM',
endTime: '10:00 AM',
top: 15,
height: 5,
isOriginal: true
height: 5
}
]);
});
@@ -457,16 +454,14 @@ describe('nearestTimelineSlot', () => {
time: '9:00 AM',
endTime: '9:30 AM',
top: 10,
height: 5,
isOriginal: false
height: 5
},
{
iso: '2025-06-15T09:30:00Z',
time: '9:30 AM',
endTime: '10:00 AM',
top: 15,
height: 5,
isOriginal: false
height: 5
}
];
@@ -486,8 +481,7 @@ describe('isDirectSlotHit', () => {
time: '9:00 AM',
endTime: '9:30 AM',
top: 10,
height: 5,
isOriginal: false
height: 5
};
test('returns true within slot bounds', () => {
+1 -5
View File
@@ -197,7 +197,6 @@ export interface TimelineSlot extends TimelineBlock {
iso: string;
time: string;
endTime: string;
isOriginal: boolean;
}
export interface TimelineLabel {
@@ -222,7 +221,6 @@ export interface BuildDayTimelineParams {
eventType: TimelineEventType;
daySlots: string[];
tz: string;
originalSlot?: string | null;
now?: InstantFns.Record | unknown;
}
@@ -249,7 +247,6 @@ export function buildDayTimeline({
eventType,
daySlots,
tz,
originalSlot = null,
now
}: BuildDayTimelineParams): DayTimeline | null {
const nowInst = now ? resolveInstant(now) : NowFns.instant();
@@ -344,8 +341,7 @@ export function buildDayTimeline({
time: formatTime(iso, tz),
endTime: formatTime(InstantFns.toString(end), tz),
top: toPercent(start),
height: toPercent(end) - toPercent(start),
isOriginal: iso === originalSlot
height: toPercent(end) - toPercent(start)
};
});
+117 -2
View File
@@ -115,6 +115,119 @@ describe('createAppointmentFlow duration', () => {
});
});
describe('createAppointmentFlow rescheduling', () => {
it('normalizes the original instant and starts on the calendar with navigation allowed but no-op submission blocked', () => {
const flow = makeFlow({
reschedule: { start: '2026-07-20T10:00:00.000Z', duration: 30 }
});
expect(flow.step).toBe(1);
expect(flow.viewDate).toBe('2026-07-20');
expect(flow.selectedSlot).toBe(SLOT_10);
expect(flow.hasChanges).toBe(false);
expect(flow.canAdvance).toBe(true);
expect(flow.canSubmit).toBe(false);
flow.advance();
expect(flow.step).toBe(2);
expect(flow.canAdvance).toBe(true);
flow.advance();
expect(flow.step).toBe(3);
expect(flow.canSubmit).toBe(false);
flow.goBack();
flow.goToStep(3);
expect(flow.step).toBe(3);
expect(flow.selectedSlot).toBe(SLOT_10);
expect(flow.hasChanges).toBe(false);
expect(flow.canSubmit).toBe(false);
});
it('retains a fitting start across length changes and blocks only submission when the booked length is restored', () => {
const flow = makeFlow({
initialDuration: 60,
reschedule: { start: SLOT_10, duration: 60 }
});
flow.setDuration(30);
expect(flow.step).toBe(1);
expect(flow.duration).toBe(30);
expect(flow.selectedSlot).toBe(SLOT_10);
expect(flow.hasChanges).toBe(true);
expect(flow.canAdvance).toBe(true);
expect(flow.canSubmit).toBe(true);
flow.advance();
flow.advance();
expect(flow.step).toBe(3);
expect(flow.canSubmit).toBe(true);
flow.setDuration(60);
expect(flow.duration).toBe(60);
expect(flow.selectedSlot).toBe(SLOT_10);
expect(flow.hasChanges).toBe(false);
expect(flow.canAdvance).toBe(true);
expect(flow.canSubmit).toBe(false);
flow.goBack();
expect(flow.step).toBe(2);
flow.advance();
expect(flow.step).toBe(3);
expect(flow.canSubmit).toBe(false);
});
it('allows navigation through unoffered original context and partial day or duration changes without allowing submission', () => {
const original = '2026-07-20T11:30:00Z';
const flow = makeFlow({
slotsByDuration: {
30: { '2026-07-20': [original], '2026-07-21': ['2026-07-21T11:30:00Z'] },
60: { '2026-07-20': [SLOT_10], '2026-07-21': ['2026-07-21T11:30:00Z'] }
},
initialDuration: 60,
reschedule: { start: '2026-07-20T11:30:00.000Z', duration: 60 }
});
flow.setTz('Pacific/Kiritimati');
expect(flow.step).toBe(1);
expect(flow.viewDate).toBe('2026-07-21');
expect(flow.selectedSlot).toBe(original);
expect(flow.allSlots).not.toContain(original);
expect(flow.hasChanges).toBe(false);
expect(flow.canAdvance).toBe(true);
expect(flow.canSubmit).toBe(false);
flow.advance();
expect(flow.step).toBe(2);
flow.advance();
expect(flow.step).toBe(3);
expect(flow.canSubmit).toBe(false);
flow.goBack();
flow.setDuration(30);
expect(flow.selectedSlot).toBe(original);
expect(flow.hasChanges).toBe(true);
expect(flow.canSubmit).toBe(true);
flow.goBack();
expect(flow.viewDate).toBe('2026-07-21');
flow.selectDate('2026-07-22');
expect(flow.viewDate).toBe('2026-07-22');
expect(flow.selectedSlot).toBeNull();
expect(flow.hasChanges).toBe(true);
expect(flow.canAdvance).toBe(true);
expect(flow.canSubmit).toBe(false);
flow.setDuration(60);
expect(flow.duration).toBe(60);
expect(flow.viewDate).toBe('2026-07-22');
expect(flow.selectedSlot).toBeNull();
expect(flow.hasChanges).toBe(true);
expect(flow.canAdvance).toBe(true);
expect(flow.canSubmit).toBe(false);
flow.advance();
expect(flow.step).toBe(2);
flow.advance();
expect(flow.step).toBe(3);
expect(flow.canSubmit).toBe(false);
flow.openDate('2026-07-21');
expect(flow.viewDate).toBe('2026-07-21');
expect(flow.selectedSlot).toBeNull();
flow.setDuration(30);
expect(flow.hasChanges).toBe(true);
expect(flow.canAdvance).toBe(true);
expect(flow.canSubmit).toBe(false);
});
});
describe('createAppointmentFlow navigation', () => {
it('blocks advancing from step 1 until a date is in view', () => {
const flow = makeFlow();
@@ -182,13 +295,15 @@ describe('createAppointmentFlow selection', () => {
expect(flow.selectedSlot).toBeNull();
});
it('openDate jumps to the timeline and drops any slot', () => {
it('openDate preserves a slot on the viewed day and drops it on another day', () => {
const flow = makeFlow();
flow.selectSlot(SLOT_10);
flow.openDate('2026-07-20');
expect(flow.viewDate).toBe('2026-07-20');
expect(flow.selectedSlot).toBeNull();
expect(flow.selectedSlot).toBe(SLOT_10);
expect(flow.step).toBe(2);
flow.openDate('2026-07-21');
expect(flow.selectedSlot).toBeNull();
});
it('keeps the selected instant and updates its day when the timezone changes', () => {
+45 -7
View File
@@ -1,3 +1,4 @@
import * as browserInstantFns from 'temporal-polyfill/fns/Instant';
import {
availableDates as datesFromSlots,
canAdvance as canAdvanceRule,
@@ -17,6 +18,8 @@ export interface AppointmentFlow {
readonly allSlots: string[];
readonly availableDates: Set<string>;
readonly offeredDates: Set<string>;
readonly hasChanges: boolean;
readonly canSubmit: boolean;
readonly canAdvance: boolean;
advance(): void;
goBack(): void;
@@ -33,6 +36,7 @@ export interface AppointmentFlowInit {
slotsByDuration: Record<number, Record<string, string[]>>;
durations: number[];
initialDuration: number;
reschedule?: { start: string; duration: number };
}
/**
@@ -45,11 +49,26 @@ export interface AppointmentFlowInit {
export function createAppointmentFlow({
slotsByDuration,
durations,
initialDuration
initialDuration,
reschedule
}: AppointmentFlowInit): AppointmentFlow {
const originalSlot = reschedule
? browserInstantFns.toString(browserInstantFns.fromString(reschedule.start))
: null;
// A duration link behaves like changing length: retain the start only if it fits.
// At the booked length, keep the original interval even if it is no longer offered.
let initialSlot = originalSlot;
if (
reschedule &&
originalSlot &&
initialDuration !== reschedule.duration &&
!flattenSlots(slotsByDuration[initialDuration] ?? {}).includes(originalSlot)
) {
initialSlot = null;
}
let step = $state<WizardStep>(1);
let viewDate = $state<string | null>(null);
let selectedSlot = $state<string | null>(null);
let viewDate = $state<string | null>(originalSlot ? instantToDateKey(originalSlot, 'UTC') : null);
let selectedSlot = $state<string | null>(initialSlot);
let userTz = $state('UTC');
let duration = $state(initialDuration);
@@ -57,7 +76,16 @@ export function createAppointmentFlow({
const allSlots = $derived(flattenSlots(slotsByDate));
const dates = $derived(datesFromSlots(allSlots, userTz));
const offeredDates = $derived(dateKeys(slotsByDate));
const originalDate = $derived(originalSlot ? instantToDateKey(originalSlot, userTz) : null);
const intervalChanged = $derived(
!reschedule || selectedSlot !== originalSlot || duration !== reschedule.duration
);
const hasChanges = $derived(intervalChanged || viewDate !== originalDate);
const canSubmit = $derived(
selectedSlot !== null && allSlots.includes(selectedSlot) && intervalChanged
);
const canAdvance = $derived.by(() => {
if (reschedule) return true;
if (step === 1) {
if (viewDate === null) return false;
return dates.has(viewDate);
@@ -66,7 +94,7 @@ export function createAppointmentFlow({
});
function clearUnavailableDateSelection() {
if (step === 1 && viewDate && !dates.has(viewDate)) {
if (step === 1 && viewDate && viewDate !== originalDate && !dates.has(viewDate)) {
viewDate = null;
selectedSlot = null;
}
@@ -104,6 +132,12 @@ export function createAppointmentFlow({
get offeredDates() {
return offeredDates;
},
get hasChanges() {
return hasChanges;
},
get canSubmit() {
return canSubmit;
},
get canAdvance() {
return canAdvance;
},
@@ -128,15 +162,15 @@ export function createAppointmentFlow({
selectedSlot = null;
return;
}
if (!dates.has(dateKey)) return;
if (!dates.has(dateKey) && dateKey !== originalDate) return;
viewDate = dateKey;
if (selectedSlot && !selectedSlot.startsWith(dateKey)) selectedSlot = null;
if (selectedSlot && instantToDateKey(selectedSlot, userTz) !== dateKey) selectedSlot = null;
},
// Open the time picker on a day from a deep link, regardless of current availability —
// the timeline shows that day's slots (or its empty state).
openDate(dateKey) {
viewDate = dateKey;
selectedSlot = null;
if (selectedSlot && instantToDateKey(selectedSlot, userTz) !== dateKey) selectedSlot = null;
step = 2;
},
selectSlot(iso) {
@@ -147,8 +181,12 @@ export function createAppointmentFlow({
selectedSlot = null;
},
setTz(tz) {
const viewingOriginalDate = viewDate === originalDate;
userTz = tz;
if (selectedSlot) viewDate = instantToDateKey(selectedSlot, userTz);
else if (viewingOriginalDate && originalSlot) {
viewDate = instantToDateKey(originalSlot, userTz);
}
clearUnavailableDateSelection();
},
// Changing the length swaps the live slot map. If the picked time no longer
+78 -30
View File
@@ -1,3 +1,5 @@
import * as InstantFns from 'temporal-polyfill/fns/Instant';
import { untrack } from 'svelte';
import { page } from '$app/state';
import { goto, afterNavigate } from '$app/navigation';
import {
@@ -7,58 +9,104 @@ import {
urlStateMatches,
type DeepLinkResult
} from './appointment';
import type { AppointmentFlow } from './appointmentFlow.svelte';
import { instantToDateKey } from './datetime';
import type { AppointmentFlow, AppointmentFlowInit } from './appointmentFlow.svelte';
export interface AppointmentUrlSync {
readonly notice: NonNullable<DeepLinkResult['notice']> | null;
}
/**
* Wires the appointment flow to the URL, keeping the wizard declarative:
* applies an incoming ?slot/?date deep link on load, exposes any "no longer
* available" notice, and mirrors the flow position back into the query string
* (canonical order, guarded against a re-encode loop). All the browser/router
* coupling lives here. Call once during wizard init, after the viewer timezone
* has been set on the flow so a deep-linked slot resolves to the right day.
*/
/** Applies the incoming link once the viewer timezone is known, then mirrors flow state. */
export function createAppointmentUrlSync(
flow: AppointmentFlow,
opts: { deepLink: boolean; tz: string; defaultDuration: number }
): AppointmentUrlSync {
const result = resolveDeepLink({
slotParam: page.url.searchParams.get('slot'),
dateParam: page.url.searchParams.get('date'),
allSlots: flow.allSlots,
tz: opts.tz
});
let notice = $state<NonNullable<DeepLinkResult['notice']> | null>(result.notice ?? null);
if (result.slot) {
flow.selectSlot(result.slot);
flow.goToStep(3);
} else if (result.date) {
flow.openDate(result.date);
opts: {
deepLink: boolean;
initialTimezone: string;
timezone: () => string | null;
defaultDuration: number;
reschedule?: AppointmentFlowInit['reschedule'];
}
): AppointmentUrlSync {
const slotParam = page.url.searchParams.get('slot');
const dateParam = page.url.searchParams.get('date');
let notice = $state<NonNullable<DeepLinkResult['notice']> | null>(null);
let requestedDate: string | undefined;
let linkApplied = $state(false);
flow.setTz(opts.initialTimezone);
function resolveIncomingLink(tz: string) {
let result = resolveDeepLink({ slotParam, dateParam, allSlots: flow.allSlots, tz });
// The current interval is context, not an extra offered slot.
if (opts.reschedule) {
const originalSlot = InstantFns.toString(InstantFns.fromString(opts.reschedule.start));
const originalDate = instantToDateKey(originalSlot, tz);
let requestedSlot = result.slot;
if (!requestedSlot && result.notice?.kind === 'slot') {
requestedSlot = result.notice.requested;
}
if (requestedSlot === originalSlot && flow.duration === opts.reschedule.duration) {
result = { step: 1, slot: originalSlot };
} else if (result.notice?.kind === 'date' && result.notice.requested === originalDate) {
result = { step: 2, date: originalDate };
}
}
return result;
}
function applyLink(tz: string) {
const result = resolveIncomingLink(tz);
notice = result.notice ?? null;
requestedDate = result.date;
if (notice) {
flow.clearSlot();
if (!result.date) {
flow.selectDate(null);
flow.goToStep(1);
}
}
if (result.slot) {
flow.selectSlot(result.slot);
flow.goToStep(result.step);
} else if (result.date) {
flow.openDate(result.date);
}
}
const initialTimezone = opts.timezone();
if (initialTimezone) {
flow.setTz(initialTimezone);
applyLink(initialTimezone);
linkApplied = true;
}
$effect(() => {
const tz = opts.timezone();
if (!tz) return;
untrack(() => {
flow.setTz(tz);
if (linkApplied) return;
applyLink(tz);
linkApplied = true;
});
});
let routerReady = $state(false);
afterNavigate(() => {
routerReady = true;
});
// Clear the notice once the guest moves past the unavailable slot/day.
$effect(() => {
if (!opts.deepLink || !notice) return;
if (!notice) return;
if (notice.kind === 'slot') {
if (flow.selectedSlot || (flow.viewDate && flow.viewDate !== result.date)) notice = null;
if (flow.selectedSlot || (flow.viewDate && flow.viewDate !== requestedDate)) notice = null;
} else if (flow.viewDate) {
notice = null;
}
});
// Mirror the flow position into the URL, skipping the write when it already matches.
$effect(() => {
if (!routerReady || !opts.deepLink) return;
if (!routerReady || !linkApplied || !opts.deepLink) return;
const desired = desiredUrlState({
duration: flow.duration,
defaultDuration: opts.defaultDuration,
@@ -1,5 +1,6 @@
<script lang="ts">
import { page } from '$app/state';
import * as InstantFns from 'temporal-polyfill/fns/Instant';
import DatePicker from '#lib/components/DatePicker.svelte';
import DayTimeline from '#lib/components/DayTimeline.svelte';
import WizardPickerControls from '#lib/components/WizardPickerControls.svelte';
@@ -72,7 +73,11 @@
? durationParam
: data.eventType.duration_minutes;
const flow = createAppointmentFlow({ slotsByDuration, durations, initialDuration });
// svelte-ignore state_referenced_locally
const reschedule = rescheduleAppt
? { start: rescheduleAppt.start_time, duration: data.eventType.duration_minutes }
: undefined;
const flow = createAppointmentFlow({ slotsByDuration, durations, initialDuration, reschedule });
const ptz = getPreferredTimezone();
let step = $derived(flow.step);
@@ -81,6 +86,15 @@
let userTz = $derived(flow.userTz);
let isGuestFormValid = $state(false);
const originalStart = $derived.by(() => {
if (!rescheduleAppt) return null;
const start = InstantFns.fromString(rescheduleAppt.start_time);
return InstantFns.toString(start);
});
const originalStartAvailable = $derived(
originalStart !== null && flow.allSlots.includes(originalStart)
);
let previousAppointmentHref = $derived(
rescheduleAppt
? `/appointment/${rescheduleAppt.id}?token=${encodeURIComponent(rescheduleToken || '')}`
@@ -102,19 +116,13 @@
return t ? Math.round(t.totalMs / 3600000) : 0;
});
// Set the viewer timezone before the URL sync applies a deep-linked slot, so it
// resolves to the right day. The effect re-applies once tz resolves client-side.
if (ptz.current) flow.setTz(ptz.current);
$effect(() => {
if (!ptz.current) return;
flow.setTz(ptz.current);
});
// svelte-ignore state_referenced_locally
const urlSync = createAppointmentUrlSync(flow, {
deepLink,
tz: ptz.current ?? data.user.timezone,
defaultDuration: data.eventType.duration_minutes
initialTimezone: ptz.current ?? data.user.timezone,
timezone: () => ptz.current,
defaultDuration: data.eventType.duration_minutes,
reschedule
});
let linkNotice = $derived(urlSync.notice);
</script>
@@ -165,11 +173,12 @@
<a class="error-back-btn" href="/">Return home</a>
</div>
{:else}
{#if rescheduleAppt}
{#if rescheduleAppt && ptz.current}
<RescheduleBanner
previousHref={previousAppointmentHref}
startTime={rescheduleAppt.start_time}
tz={userTz}
endTime={rescheduleAppt.end_time}
{flow}
/>
{/if}
@@ -191,7 +200,7 @@
<span class="stage-step">Step {step} of 3:</span>
{#if step === 1}Pick a day{:else if step === 2}Pick a time{:else}Enter your info{/if}
</h1>
{#if step === 2 && viewDate}
{#if step === 2 && viewDate && ptz.current}
<p class="stage-subtitle">{formatDate(viewDate)}</p>
{:else if step === 3 && selectedSlot}
<p class="stage-subtitle">
@@ -209,6 +218,17 @@
<WizardPickerControls {flow} timezoneReady={!!ptz.current} />
{/if}
{#if rescheduleAppt && !selectedSlot}
<p class="reschedule-notice" role="status">
Choose an available start time for this duration.
</p>
{:else if rescheduleAppt && !originalStartAvailable && selectedSlot === originalStart}
<p class="reschedule-notice" role="status">
The current start time isn't available for this duration. Choose another time or
duration.
</p>
{/if}
<div class="appointment-body">
{#if flow.availableDates.size === 0}
<p class="empty">No availability in the near future.</p>
@@ -226,14 +246,13 @@
busyBlocks={data.availability.busyBlocks}
eventType={data.eventType}
showSlots={data.eventType.show_slots}
originalSlot={rescheduleAppt?.start_time ?? null}
/>
{:else}
<TimelineSkeleton rows={timelineSkeletonRows} />
{/if}
{/if}
{#if step === 3 && selectedSlot}
{#if step === 3 && (selectedSlot || rescheduleAppt)}
<GuestForm
{flow}
formFields={data.formFields}
@@ -251,8 +270,9 @@
<WizardActions
{flow}
isReschedule={!!rescheduleAppt}
timezoneReady={!!ptz.current}
requireApproval={data.eventType.require_approval}
canSubmit={step === 3 ? isGuestFormValid : true}
{isGuestFormValid}
homeHref={previousAppointmentHref || '/'}
/>
</div>
@@ -268,6 +288,11 @@
color: var(--when-color-text);
}
.reschedule-notice {
padding: var(--space-4) var(--space-7);
color: var(--when-color-text-secondary);
}
.empty {
text-align: center;
color: var(--color-text-muted);
+38 -76
View File
@@ -18,17 +18,9 @@
busyBlocks: { start: string; end: string }[];
eventType: TimelineEventType;
showSlots?: boolean;
originalSlot?: string | null;
}
let {
flow,
workingWindows,
busyBlocks,
eventType,
showSlots = false,
originalSlot = null
}: Props = $props();
let { flow, workingWindows, busyBlocks, eventType, showSlots = false }: Props = $props();
// read-only views of the shared flow; all mutations go through flow.* below
let viewDate = $derived(flow.viewDate);
@@ -48,18 +40,11 @@
busyBlocks,
eventType: liveEventType,
daySlots,
tz: userTz,
originalSlot
tz: userTz
})
: null
);
function selectSlot(iso: string) {
const s = timeline?.slots.find((slot) => slot.iso === iso);
if (s?.isOriginal) return;
flow.selectSlot(iso);
}
const DRAG_THRESHOLD_PX = 6;
const SNAP_HOLD_MS = 90;
@@ -174,16 +159,16 @@
function snapToClick(percent: number) {
if (!timeline) return;
const best = nearestTimelineSlot(timeline.slots, percent);
if (!best || best.isOriginal) return;
if (!best) return;
if (isDirectSlotHit(best, percent)) {
gestures.clearSnap();
if (selectedSlot !== best.iso) selectSlot(best.iso);
if (selectedSlot !== best.iso) flow.selectSlot(best.iso);
return;
}
gestures.snapTo(percent, () => {
if (selectedSlot !== best.iso) selectSlot(best.iso);
if (selectedSlot !== best.iso) flow.selectSlot(best.iso);
});
}
@@ -209,7 +194,7 @@
onDropSlot: (percent) => {
if (!timeline) return;
const best = nearestTimelineSlot(timeline.slots, percent);
if (best && !best.isOriginal) selectSlot(best.iso);
if (best) flow.selectSlot(best.iso);
},
onClearSlot: () => flow.clearSlot(),
onClickTrack: (percent) => {
@@ -226,7 +211,7 @@
function handleKeyDown(e: KeyboardEvent) {
if (!timeline) return;
const available = timeline.slots.filter((s) => !s.isOriginal);
const available = timeline.slots;
if (available.length === 0) return;
const currentIndex = available.findIndex((s) => s.iso === selectedSlot);
@@ -235,12 +220,12 @@
e.preventDefault();
gestures.clearSnap();
const next = currentIndex < available.length - 1 ? currentIndex + 1 : 0;
selectSlot(available[next].iso);
flow.selectSlot(available[next].iso);
} else if (e.key === 'ArrowUp' || e.key === 'ArrowLeft') {
e.preventDefault();
gestures.clearSnap();
const prev = currentIndex > 0 ? currentIndex - 1 : available.length - 1;
selectSlot(available[prev].iso);
flow.selectSlot(available[prev].iso);
} else if (e.key === 'Escape' || e.key === 'Delete' || e.key === 'Backspace') {
e.preventDefault();
gestures.clearSnap();
@@ -299,17 +284,6 @@
{:else}
{@render continuousTrack()}
{/if}
{#if timeline.slots.some((s) => s.isOriginal)}
{@const orig = timeline.slots.find((s) => s.isOriginal)!}
<div
class="slot-block original-appointment"
style:top="{orig.top}%"
style:height="{orig.height}%"
>
<span class="slot-text">{orig.time} (Current)</span>
</div>
{/if}
</div>
</div>
</div>
@@ -329,21 +303,19 @@
{#snippet discreteSlots()}
{#if timeline}
{#each timeline.slots as s (s.iso)}
{#if !s.isOriginal}
{@const isSelected = s.iso === selectedSlot}
<button
type="button"
role="radio"
aria-checked={isSelected}
class="slot-btn"
class:selected={isSelected}
style:top="{s.top}%"
style:height="{s.height}%"
onclick={() => selectSlot(s.iso)}
>
<span class="slot-text">{s.time} – {s.endTime}</span>
</button>
{/if}
{@const isSelected = s.iso === selectedSlot}
<button
type="button"
role="radio"
aria-checked={isSelected}
class="slot-btn"
class:selected={isSelected}
style:top="{s.top}%"
style:height="{s.height}%"
onclick={() => flow.selectSlot(s.iso)}
>
<span class="slot-text">{s.time} – {s.endTime}</span>
</button>
{/each}
{/if}
{/snippet}
@@ -351,24 +323,22 @@
{#snippet continuousTrack()}
{#if timeline}
{#each timeline.slots as s (s.iso)}
{#if !s.isOriginal}
{@const isSelected = s.iso === selectedSlot}
<button
type="button"
role="radio"
aria-checked={isSelected}
class="slot-hit-target"
class:selected={isSelected}
style:top="{s.top}%"
style:height="{s.height}%"
onclick={() => {
gestures.clearSnap();
selectSlot(s.iso);
}}
>
<span class="visually-hidden">{s.time} – {s.endTime}</span>
</button>
{/if}
{@const isSelected = s.iso === selectedSlot}
<button
type="button"
role="radio"
aria-checked={isSelected}
class="slot-hit-target"
class:selected={isSelected}
style:top="{s.top}%"
style:height="{s.height}%"
onclick={() => {
gestures.clearSnap();
flow.selectSlot(s.iso);
}}
>
<span class="visually-hidden">{s.time} – {s.endTime}</span>
</button>
{/each}
{#if selectedSlot}
@@ -599,14 +569,6 @@
color: var(--color-danger);
}
.slot-block.original-appointment {
background: var(--color-surface-muted);
border: 1px dashed var(--color-border-strong);
color: var(--color-text-secondary);
cursor: not-allowed;
z-index: 4;
}
.slot-text {
font-weight: 600;
}
+8 -4
View File
@@ -94,11 +94,15 @@
onvaliditychange?.(isFormValid);
});
const enhanceWithFeedback: SubmitFunction =
() =>
async ({ update }) => {
const enhanceWithFeedback: SubmitFunction = ({ cancel }) => {
if (!isFormValid || !flow.canSubmit) {
cancel();
return;
}
return async ({ update }) => {
await update({ navigate: false });
};
};
</script>
<div class="appointment-form">
@@ -140,7 +144,7 @@
/>
{/if}
<button type="submit" class="submit-btn" disabled={!isFormValid}>
<button type="submit" class="submit-btn" disabled={!isFormValid || !flow.canSubmit}>
{#if rescheduleAppt}Confirm Reschedule{:else if requireApproval}Request{:else}Schedule{/if}
</button>
</form>
@@ -1,30 +1,64 @@
<!-- Shown above the wizard when the guest is rescheduling an existing appointment. -->
<script lang="ts">
import IconInfo from 'virtual:icons/ph/info';
import { formatSlot } from '#lib/datetime.js';
import IconCaretLeft from 'virtual:icons/ph/caret-left';
import * as InstantFns from 'temporal-polyfill/fns/Instant';
import { formatDateCompact, formatTimeRangeShort, instantToDateKey } from '#lib/datetime.js';
import type { AppointmentFlow } from '#lib/appointmentFlow.svelte.js';
interface Props {
previousHref: string;
startTime: string;
tz: string;
endTime: string;
flow: AppointmentFlow;
}
let { previousHref, startTime, tz }: Props = $props();
let { previousHref, startTime, endTime, flow }: Props = $props();
const originalDate = $derived(instantToDateKey(startTime, flow.userTz));
const proposedEnd = $derived.by(() => {
if (!flow.selectedSlot) return null;
const start = InstantFns.fromString(flow.selectedSlot);
return InstantFns.toString(InstantFns.addMinutes(start, flow.duration));
});
</script>
<aside class="banner">
<span class="icon"><IconInfo aria-hidden="true" /></span>
<span class="text">
Rescheduling <a class="link" href={previousHref}>previous appointment</a>
for {formatSlot(startTime, tz)}.
</span>
<aside class="banner" aria-label="Reschedule summary">
<a class="return-link" href={previousHref}>
<span class="return-label">
<IconCaretLeft aria-hidden="true" />
<span>Back to appointment</span>
</span>
</a>
<dl class="summary">
<div>
<dt>Original</dt>
<dd>
<span>{formatDateCompact(originalDate)}</span>
<span>{formatTimeRangeShort(startTime, endTime, flow.userTz)}</span>
</dd>
</div>
<div>
<dt>Proposed</dt>
<dd>
{#if flow.hasChanges}
<span>{flow.viewDate ? formatDateCompact(flow.viewDate) : '—'}</span>
<span>
{flow.selectedSlot && proposedEnd
? formatTimeRangeShort(flow.selectedSlot, proposedEnd, flow.userTz)
: '—'}
</span>
{:else}
—
{/if}
</dd>
</div>
</dl>
</aside>
<style>
.banner {
display: flex;
align-items: center;
gap: var(--space-4);
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
align-items: stretch;
gap: var(--space-5);
padding: var(--space-5) var(--space-6);
margin-bottom: var(--space-6);
background: var(--color-info-bg);
@@ -32,27 +66,76 @@
border-radius: var(--radius-md);
}
.icon {
font-size: var(--font-size-xl);
color: var(--color-info-strong);
flex-shrink: 0;
display: inline-flex;
}
.text {
font-size: var(--font-size-md);
line-height: 1.4;
.summary {
display: grid;
grid-column: span 2;
grid-template-columns: subgrid;
gap: var(--space-5);
margin: 0;
color: var(--when-color-text);
}
.link {
color: var(--color-info-strong);
.summary > div + div {
border-left: 1px solid var(--color-info-border);
padding-left: var(--space-5);
}
dt {
font-weight: 600;
font-size: var(--font-size-sm);
}
dd {
display: flex;
flex-direction: column;
margin: 0;
}
.return-link {
display: grid;
place-items: center;
min-height: calc(var(--space-8) + var(--space-4));
padding: var(--space-3) var(--space-4);
border-right: 1px solid var(--color-info-border);
color: var(--color-info-strong);
text-decoration: underline;
text-underline-offset: 2px;
}
.link:hover {
.return-label {
display: inline-flex;
align-items: center;
gap: var(--space-2);
line-height: 1;
}
.return-link:hover {
text-decoration: none;
}
.return-link:focus-visible {
outline: 2px solid var(--color-info-strong);
outline-offset: 2px;
}
@media (max-width: 768px) {
.banner {
grid-template-columns: minmax(0, 1fr);
padding-top: 0;
border-inline: 0;
border-radius: 0;
margin-bottom: 0;
}
.summary {
grid-column: auto;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.return-link {
padding-block: var(--space-5);
border-right: 0;
border-bottom: 1px solid var(--color-info-border);
}
}
</style>
@@ -9,12 +9,20 @@
interface Props {
flow: AppointmentFlow;
isReschedule: boolean;
timezoneReady: boolean;
requireApproval: PublicEventType['require_approval'];
canSubmit?: boolean;
isGuestFormValid?: boolean;
homeHref?: string;
}
let { flow, isReschedule, requireApproval, canSubmit = true, homeHref = '/' }: Props = $props();
let {
flow,
isReschedule,
timezoneReady,
requireApproval,
isGuestFormValid = true,
homeHref = '/'
}: Props = $props();
// read-only views of the shared flow; navigation goes through flow.* below
let step = $derived(flow.step);
@@ -24,9 +32,9 @@
</script>
<div class="actions">
{#if step === 1 && viewDate}
{#if timezoneReady && step === 1 && viewDate}
<p class="summary">You selected {formatDate(viewDate)}</p>
{:else if step === 2 && selectedSlot}
{:else if timezoneReady && step === 2 && selectedSlot}
<p class="summary">You selected {formatTime(selectedSlot, userTz)}</p>
{/if}
@@ -65,7 +73,7 @@
type="submit"
form="appointment-form"
class="button"
disabled={!selectedSlot || !canSubmit}
disabled={!flow.canSubmit || !isGuestFormValid}
>
{#if isReschedule}Confirm Reschedule{:else if requireApproval}Request{:else}Schedule{/if}
</button>
+11
View File
@@ -7,6 +7,7 @@ import {
formatWeekday,
formatTime,
formatTimeShort,
formatTimeRangeShort,
formatTimeRange,
formatSlot,
formatTimestamp,
@@ -113,6 +114,16 @@ describe('formatTimeShort', () => {
});
});
describe('formatTimeRangeShort', () => {
test('joins compact start and end times', () => {
expect(formatTimeRangeShort(ISO, ISO_END, 'UTC')).toBe('9:30am – 10:00am');
});
test('keeps a range crossing local midnight time-only', () => {
expect(formatTimeRangeShort(ISO, ISO_END, 'Pacific/Kiritimati')).toBe('11:30pm – 12:00am');
});
});
describe('formatTimeRange', () => {
test('joins start and end with an en dash', () => {
expect(formatTimeRange(ISO, ISO_END, 'UTC')).toBe('09:30 AM – 10:00 AM');
+5
View File
@@ -101,6 +101,11 @@ export function formatTimeShort(iso: string, tz: string): string {
}
}
/** Compact time range in `tz`, e.g. "9:00am – 9:30am", without dates. */
export function formatTimeRangeShort(start: string, end: string, tz: string): string {
return `${formatTimeShort(start, tz)} – ${formatTimeShort(end, tz)}`;
}
/** Time range across two instants in `tz`, e.g. "09:00 AM – 09:30 AM". */
export function formatTimeRange(start: string, end: string, tz: string): string {
try {
@@ -120,6 +120,32 @@ describe('duration change between reschedule prevalidation and commit', () => {
);
});
test.each(['guest', 'host'] as const)(
'%s duration-only request becomes a no-op after the host saves that duration',
async (role) => {
ctx.cfg.meetings['30-min-chat'].additional_duration_minutes = [60];
const page = await currentRow();
const request = prevalidate(page, role, 60);
request.newStart = page.start_time;
request.newEnd = Temporal.Instant.from(page.start_time).add({ minutes: 60 }).toString();
await changeAppointmentDuration(ctx, { appointmentId, durationMinutes: 60 });
const committed = await currentRow();
expect(committed.end_time).toBe(request.newEnd);
expect(committed.ics_sequence).toBe(1);
expect(await rescheduleAppointment(ctx, request)).toEqual({
ok: false,
reason: 'no_changes'
});
expect(await db.selectFrom('appointments').selectAll().execute()).toEqual([committed]);
expect(enqueueAppointmentReconciliation).toHaveBeenCalledExactlyOnceWith(
committed,
'edited-by-host'
);
}
);
test.each([
{
name: 'guest requested configured 30 before the host change',
@@ -254,45 +254,67 @@ describe('rescheduleAppointment', () => {
vi.mocked(enqueueAppointmentReconciliation).mockResolvedValue(undefined);
});
test('happy path: creates a linked new row, ends the old one, queues a calendar sync', async () => {
const db = await makeDb();
try {
await db
.insertInto('appointments')
.values({ ...opBaseRow, id: 'r1', status: 'confirmed', cancel_token: 't1' })
.execute();
const row = await fetchRow(db, 'r1');
test.each([
{ initiator: 'guest', newStart: '2099-01-02T10:00:00Z', newEnd: '2099-01-02T10:30:00Z' },
{ initiator: 'guest', newStart: opBaseRow.start_time, newEnd: '2099-01-01T16:00:00Z' },
{ initiator: 'host', newStart: opBaseRow.start_time, newEnd: '2099-01-01T17:00:00Z' }
] as const)(
'creates a linked successor and sync for $initiator interval $newStart – $newEnd',
async ({ initiator, newStart, newEnd }) => {
const cfg = structuredClone(validConfig);
cfg.meetings['30-min-chat'].additional_duration_minutes = [60];
const db = await makeDb();
try {
await db
.insertInto('appointments')
.values({ ...opBaseRow, id: 'r1', status: 'confirmed', cancel_token: 't1' })
.execute();
const row = await fetchRow(db, 'r1');
const result = await rescheduleAppointment(
{ db, cfg: validConfig, clock: systemClock },
{
appointmentId: row.id,
initiator: 'guest',
newStart: '2099-01-02T10:00:00Z',
newEnd: '2099-01-02T10:30:00Z'
const result = await rescheduleAppointment(
{ db, cfg, clock: systemClock },
{
appointmentId: row.id,
initiator,
newStart,
newEnd
}
);
expect(result.ok).toBe(true);
if (result.ok) {
const next = result.appointment;
expect(next.id).not.toBe('r1');
expect(next.start_time).toBe(newStart);
expect(next.end_time).toBe(newEnd);
expect(next.status).toBe('confirmed');
expect(next.ics_sequence).toBe(1);
expect(next.origin_id).toBe('r1');
expect(next.cancel_token).not.toBe('t1');
const old = await fetchRow(db, 'r1');
expect(old.status).toBe('rescheduled');
expect(old.action_log).toBe(next.action_log);
expect(parseActionLog(next.action_log)).toEqual([
expect.objectContaining({
action: 'reschedule',
actor: initiator,
payload: expect.objectContaining({
metadata: { previous_id: row.id, next_id: next.id }
})
})
]);
expect(enqueueAppointmentReconciliation).toHaveBeenCalledExactlyOnceWith(
next,
`rescheduled-by-${initiator}`
);
}
);
expect(result.ok).toBe(true);
if (result.ok) {
const next = result.appointment;
expect(next.id).not.toBe('r1');
expect(next.start_time).toBe('2099-01-02T10:00:00Z');
expect(next.end_time).toBe('2099-01-02T10:30:00Z');
expect(next.status).toBe('confirmed');
expect(next.ics_sequence).toBe(1);
expect(next.origin_id).toBe('r1');
expect(next.cancel_token).not.toBe('t1');
const old = await fetchRow(db, 'r1');
expect(old.status).toBe('rescheduled');
expect(enqueueAppointmentReconciliation).toHaveBeenCalledWith(next, 'rescheduled-by-guest');
} finally {
await db.destroy();
}
} finally {
await db.destroy();
}
});
);
test('guest re-collect: the new row takes the re-submitted form values', async () => {
const db = await makeDb();
@@ -47,7 +47,7 @@ export interface RescheduleAppointmentInput {
export type RescheduleAppointmentResult =
| { ok: true; appointment: Appointment }
| { ok: false; reason: 'gated' | 'conflict' | 'slot_taken' };
| { ok: false; reason: 'gated' | 'conflict' | 'slot_taken' | 'no_changes' };
function isUniqueViolation(err: unknown): boolean {
if (!err || typeof err !== 'object') return false;
@@ -80,6 +80,13 @@ export async function rescheduleAppointment(
if (!profile.durations.includes(duration)) return { ok: false, reason: 'gated' } as const;
}
if (
Temporal.Instant.from(input.newStart).equals(appointment.start_time) &&
Temporal.Instant.from(input.newEnd).equals(appointment.end_time)
) {
return { ok: false, reason: 'no_changes' } as const;
}
const details = rescheduleDetails(appointment, input, eventType);
const moved = await rescheduleAppointmentTransition(
connection,
@@ -1,7 +1,7 @@
import { describe, expect, test } from 'vitest';
import { toPublicEventType, toPublicAppointment } from './sanitize';
import { toPublicEventType, toPublicAppointment, toRescheduleAppointment } from './sanitize';
import { resolveBookingDurations } from './duration';
import type { Meeting } from '@when/config';
import type { FormField, Meeting } from '@when/config';
import type { Appointment } from '@when/db';
import { validConfig } from '#lib/server/__fixtures__/valid-config.js';
@@ -288,6 +288,55 @@ describe('toPublicAppointment decline note privacy', () => {
});
});
describe('toRescheduleAppointment', () => {
const locationField: FormField = {
name: 'meeting_place',
type: 'event_location',
label: 'Meeting place',
required: false,
choices: ['Lakeside path', 'Garden trail']
};
test('restores a pending guest location choice only in the reschedule form', () => {
const row: Appointment = { ...baseRow, status: 'pending', location: 'Garden trail' };
const original = structuredClone(row);
const guest = toRescheduleAppointment(row, 'guest', [locationField]);
expect(guest.location).toBe('Garden trail');
expect(guest.note).toBeNull();
expect(guest.video_chat).toBeNull();
expect(guest).not.toHaveProperty('cancel_token');
expect(toPublicAppointment(row, 'guest').location).toBeNull();
expect(toRescheduleAppointment(row, 'host', [locationField]).location).toBe('Garden trail');
expect(row).toEqual(original);
});
test('does not reveal a private host-entered location to a pending guest', () => {
const row: Appointment = { ...baseRow, status: 'pending' };
const guest = toRescheduleAppointment(row, 'guest', [locationField]);
expect(guest.location).toBeNull();
expect(guest.note).toBeNull();
expect(guest.video_chat).toBeNull();
expect(toRescheduleAppointment(row, 'host', [locationField]).location).toBe(row.location);
});
test.for([
[],
[{ name: 'meeting_place', type: 'event_location', label: 'Meeting place', required: false }],
[
{
name: 'other',
type: 'choice',
label: 'Other question',
required: false,
choices: ['Garden trail']
}
]
] satisfies FormField[][])('requires an explicit location-choice field: %j', (fields) => {
const row: Appointment = { ...baseRow, status: 'pending', location: 'Garden trail' };
expect(toRescheduleAppointment(row, 'guest', fields).location).toBeNull();
});
});
describe('toPublicAppointment', () => {
test('shows location, note, and video_chat for hosts', () => {
const res = toPublicAppointment(baseRow, 'host');
@@ -1,4 +1,4 @@
import { parseGuestAnswers, type Meeting } from '@when/config';
import { parseGuestAnswers, type FormField, type Meeting } from '@when/config';
import { parseActionLog, type ActionLogEntry, type Appointment } from '@when/db';
import { resolveAvailabilityLimits } from '../availability/settings';
import type { AppointmentRole } from './actions';
@@ -58,6 +58,21 @@ export function toPublicEventType(
};
}
export function toRescheduleAppointment(
row: Appointment,
role: AppointmentRole,
formFields: readonly FormField[]
): PublicAppointment {
const appointment = toPublicAppointment(row, role);
if (role !== 'guest' || row.status !== 'pending') return appointment;
// Restore an already-public form choice, not a private host-entered location.
const field = formFields.find((field) => field.type === 'event_location');
const location = field?.choices?.find((choice) => choice === row.location);
if (location === undefined) return appointment;
return { ...appointment, location };
}
export function toPublicAppointment(row: Appointment, role: AppointmentRole): PublicAppointment {
const action_log = parseActionLog(row.action_log);
@@ -301,22 +301,25 @@ describe('active appointments and self-exclusion', () => {
expect(availability.slotsByDuration[30][DAY]).not.toContain(`${DAY}T10:00:00Z`);
});
test('rescheduling excludes itself, not another meeting type', async () => {
const cfg = makeConfig([30]);
test('same-start duration changes exclude self but not conflicts beyond the old end', async () => {
const cfg = makeConfig([15, 30, 60, 90]);
cfg.meetings.lunch = { ...cfg.meetings[CHAT] };
await book(CHAT, `${DAY}T10:00:00Z`, `${DAY}T10:30:00Z`);
await book('lunch', `${DAY}T11:00:00Z`, `${DAY}T11:30:00Z`);
const own = `${DAY}T10:00:00Z`;
const ownSlot = await isRescheduleSlotBookable(
db,
cfg,
CHAT,
cfg.meetings[CHAT],
'guest',
own,
30,
currentAppointment(own)
);
for (const duration of [15, 60, 90]) {
const ownSlot = await isRescheduleSlotBookable(
db,
cfg,
CHAT,
cfg.meetings[CHAT],
'guest',
own,
duration,
currentAppointment(own)
);
expect(ownSlot, `same start with ${duration} minutes`).toBe(duration < 90);
}
const otherSlot = await isRescheduleSlotBookable(
db,
cfg,
@@ -327,7 +330,6 @@ describe('active appointments and self-exclusion', () => {
30,
currentAppointment(own)
);
expect(ownSlot).toBe(true);
expect(otherSlot).toBe(false);
});
@@ -16,7 +16,7 @@ import {
validateReason
} from '#lib/server/appointment/form.server.js';
import { appointmentContext } from '#lib/server/appointment/context.js';
import { toPublicAppointment, toPublicEventType } from '#lib/server/appointment/sanitize.js';
import { toRescheduleAppointment, toPublicEventType } from '#lib/server/appointment/sanitize.js';
import type { Actions, PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ params, url, locals }) => {
@@ -42,6 +42,7 @@ export const load: PageServerLoad = async ({ params, url, locals }) => {
now
});
const rescheduleError = ctx.kind === 'error' ? ctx.code : null;
const formFields = resolveFormFields(eventType);
const { bookingDurations, slotsByDuration, workingWindows, busyBlocks } = await loadAvailability(
getDb(),
@@ -58,12 +59,12 @@ export const load: PageServerLoad = async ({ params, url, locals }) => {
: `/appointment/${row.id}/reschedule`;
return {
eventType: toPublicEventType(row.event_type_id, eventType, role, bookingDurations),
formFields: resolveFormFields(eventType),
formFields,
formAction,
guestFieldsDisabled: role === 'host' && rescheduleError === null,
availability: { slotsByDuration, workingWindows, busyBlocks },
reschedule: {
appt: rescheduleError ? null : toPublicAppointment(row, role),
appt: rescheduleError ? null : toRescheduleAppointment(row, role, formFields),
error: rescheduleError,
token
},
@@ -84,9 +85,6 @@ export const actions: Actions = {
if (!found || found.cancel_token !== token) {
return fail(403, { error: 'Invalid reschedule token.' });
}
if (found.start_time === slotStr) {
return fail(400, { error: 'Please select a new time slot.' });
}
const cfg = getConfig();
const eventType = cfg.meetings[found.event_type_id];
@@ -104,6 +102,11 @@ export const actions: Actions = {
const duration = resolveDuration(eventType, 'guest', form, found);
if (duration === null) return fail(400, { error: 'Please pick a valid meeting length.' });
const end = start.add({ minutes: duration });
if (start.equals(found.start_time) && end.equals(found.end_time)) {
return fail(400, { error: 'Please select a new time or duration.' });
}
// Re-validate the slot is currently bookable, ignoring the appointment's own current slot.
const bookable = await isRescheduleSlotBookable(
getDb(),
@@ -111,7 +114,7 @@ export const actions: Actions = {
found.event_type_id,
eventType,
'guest',
slotStr,
start.toString(),
duration,
found
);
@@ -119,7 +122,6 @@ export const actions: Actions = {
return fail(409, { error: 'That time is no longer available. Please pick another.' });
}
const end = start.add({ minutes: duration });
const result = await rescheduleAppointment(appointmentContext(), {
appointmentId: found.id,
initiator: 'guest',
@@ -130,6 +132,9 @@ export const actions: Actions = {
reason
});
if (!result.ok) {
if (result.reason === 'no_changes') {
return fail(400, { error: 'Please select a new time or duration.' });
}
if (result.reason === 'slot_taken') {
return fail(409, { error: 'That time was just taken. Please pick another.' });
}
@@ -17,11 +17,11 @@ const h = vi.hoisted(() => ({
resolveTimezone: vi.fn(() => 'UTC'),
validateReason: vi.fn(),
resolveDuration: vi.fn(),
toPublicAppointment: vi.fn(),
toRescheduleAppointment: vi.fn(),
toPublicEventType: vi.fn()
}));
vi.mock('#lib/server/state.js', () => ({ getConfig: () => validConfig, getDb: () => h.db }));
vi.mock('#lib/server/state.js', () => ({ getConfig: () => cfg, getDb: () => h.db }));
vi.mock('@when/db', async (io) => {
const original = await io<typeof import('@when/db')>();
return {
@@ -51,13 +51,16 @@ vi.mock('#lib/server/appointment/form.server.js', async (io) => {
});
vi.mock('#lib/server/appointment/duration.js', async (importOriginal) => {
const original = await importOriginal<typeof import('#lib/server/appointment/duration.js')>();
return { ...original, resolveDuration: h.resolveDuration };
return {
...original,
resolveDuration: h.resolveDuration.mockImplementation(original.resolveDuration)
};
});
vi.mock('#lib/server/appointment/sanitize.js', async (importOriginal) => {
const original = await importOriginal<typeof import('#lib/server/appointment/sanitize.js')>();
return {
...original,
toPublicAppointment: h.toPublicAppointment,
toRescheduleAppointment: h.toRescheduleAppointment,
toPublicEventType: h.toPublicEventType.mockImplementation(original.toPublicEventType)
};
});
@@ -73,7 +76,10 @@ import { load, actions } from './+page.server';
type LoadResult = Exclude<Awaited<ReturnType<typeof load>>, void>;
type Thrown = { status: number; location?: string };
type Failure = { status?: number };
type Failure = { status?: number; data?: { error?: string } };
const cfg = structuredClone(validConfig);
cfg.meetings['30-min-chat'].additional_duration_minutes = [60, 120];
const CURRENT = '2099-05-01T15:00:00Z';
const NEW_SLOT = '2099-05-02T16:00:00Z';
@@ -156,8 +162,7 @@ beforeEach(() => {
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.toRescheduleAppointment.mockReturnValue({ id: 'appt-1' });
});
describe('/appointment/[id]/reschedule load', () => {
@@ -197,10 +202,10 @@ describe('/appointment/[id]/reschedule load', () => {
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.toRescheduleAppointment).toHaveBeenCalledExactlyOnceWith(h.row.current, role, []);
expect(h.toPublicEventType).toHaveBeenCalledExactlyOnceWith(
'30-min-chat',
validConfig.meetings['30-min-chat'],
cfg.meetings['30-min-chat'],
role,
availability.bookingDurations
);
@@ -213,9 +218,9 @@ describe('/appointment/[id]/reschedule load', () => {
expect(result).not.toHaveProperty('isHost');
expect(h.loadAvailability).toHaveBeenCalledExactlyOnceWith(
h.db,
validConfig,
cfg,
'30-min-chat',
validConfig.meetings['30-min-chat'],
cfg.meetings['30-min-chat'],
role,
h.row.current
);
@@ -256,8 +261,7 @@ describe('/appointment/[id]/reschedule book action', () => {
test('400 on an invalid duration before checking availability', async () => {
h.parseForm.mockReturnValue({ ok: true, data: {} });
h.resolveDuration.mockReturnValue(null);
const result = await book(form());
const result = await book(form({ slot: CURRENT, duration: '75' }));
expect(result.status).toBe(400);
expect(h.isRescheduleSlotBookable).not.toHaveBeenCalled();
expect(h.reschedule).not.toHaveBeenCalled();
@@ -271,50 +275,60 @@ describe('/appointment/[id]/reschedule book action', () => {
});
test('403 when the token does not match the appointment', async () => {
const result = await book(form({ token: 'wrong' }));
const result = await book(form({ slot: CURRENT, token: 'wrong' }));
expect(result?.status).toBe(403);
});
test('400 when the chosen slot equals the current slot', async () => {
const result = await book(form({ slot: CURRENT }));
expect(result?.status).toBe(400);
});
test.for([CURRENT, '2099-05-01T17:00:00+02:00', '2099-05-01T15:00:00.000Z'])(
'400 without availability or mutation for an unchanged interval: %s',
async (slot) => {
h.parseForm.mockReturnValue({ ok: true, data: {} });
h.row.current!.end_time = '2099-05-01T18:30:00+02:00';
const result = await book(form({ slot, duration: '90' }));
expect(result.status).toBe(400);
expect(result.data?.error).toBe('Please select a new time or duration.');
expect(h.isRescheduleSlotBookable).not.toHaveBeenCalled();
expect(h.reschedule).not.toHaveBeenCalled();
}
);
test('400 on invalid form fields', async () => {
h.parseForm.mockReturnValue({ ok: false, errors: { name: 'x' } });
const result = await book(form());
const result = await book(form({ slot: CURRENT }));
expect(result?.status).toBe(400);
});
test('400 on an invalid reason', async () => {
h.parseForm.mockReturnValue({ ok: true, data: {} });
h.validateReason.mockReturnValue({ ok: false, error: 'too long' });
const result = await book(form());
const result = await book(form({ slot: CURRENT }));
expect(result?.status).toBe(400);
});
test('re-validates excluding the current slot, 409 when not bookable', async () => {
test('409 without mutation when a longer same-start interval is unavailable', async () => {
h.parseForm.mockReturnValue({ ok: true, data: {} });
h.isRescheduleSlotBookable.mockResolvedValue(false);
const result = await book(form());
const result = await book(form({ slot: CURRENT, duration: '120' }));
expect(result?.status).toBe(409);
expect(h.isRescheduleSlotBookable).toHaveBeenCalledWith(
h.db,
validConfig,
cfg,
'30-min-chat',
validConfig.meetings['30-min-chat'],
cfg.meetings['30-min-chat'],
'guest',
NEW_SLOT,
30,
CURRENT,
120,
h.row.current
);
expect(h.reschedule).not.toHaveBeenCalled();
});
test('ignores forged host flags and checks guest availability excluding self', async () => {
test('rejects a forged host-only length even at the current start', async () => {
h.parseForm.mockReturnValue({ ok: true, data: {} });
h.isRescheduleSlotBookable.mockResolvedValue(false);
const fd = form({
slot: CURRENT,
duration: '240',
viewer: 'host',
role: 'host',
isAdmin: 'true',
@@ -322,47 +336,67 @@ describe('/appointment/[id]/reschedule book action', () => {
end_time: '2099-05-01T19:00:00Z'
});
const result = await book(fd);
expect(result.status).toBe(409);
expect(result.status).toBe(400);
expect(h.resolveDuration).toHaveBeenCalledExactlyOnceWith(
validConfig.meetings['30-min-chat'],
cfg.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,
'30-min-chat',
validConfig.meetings['30-min-chat'],
'guest',
NEW_SLOT,
30,
h.row.current
);
expect(h.isRescheduleSlotBookable.mock.calls[0][7]).toBe(h.row.current);
expect(h.isRescheduleSlotBookable).not.toHaveBeenCalled();
expect(h.reschedule).not.toHaveBeenCalled();
});
test.for([false, true])(
'reschedules as guest even with forged host fields: %s',
async (forged) => {
test.for([
{
slot: NEW_SLOT,
newStart: NEW_SLOT,
duration: 60,
newEnd: '2099-05-02T17:00:00Z',
forged: false
},
{
slot: NEW_SLOT,
newStart: NEW_SLOT,
duration: 90,
newEnd: '2099-05-02T17:30:00Z',
forged: true
},
{
slot: '2099-05-01T15:00:00.000Z',
newStart: CURRENT,
duration: 60,
newEnd: '2099-05-01T16:00:00Z',
forged: false
},
{
slot: '2099-05-01T17:00:00+02:00',
newStart: CURRENT,
duration: 120,
newEnd: '2099-05-01T17:00:00Z',
forged: true
}
])(
'reschedules as guest at $slot for $duration minutes, ignoring host flags: $forged',
async ({ slot, newStart, duration, newEnd, forged }) => {
h.parseForm.mockReturnValue({ ok: true, data: {} });
h.resolveDuration.mockReturnValue(60);
h.reschedule.mockResolvedValue({
ok: true,
appointment: { id: 'appt-2', cancel_token: 'tok-2' }
});
const session = forged ? { user: { name: 'Host' } } : null;
const fd = form(
forged ? { viewer: 'host', role: 'host', isAdmin: 'true', currentDuration: '240' } : {}
);
const fd = form({
slot,
duration: String(duration),
...(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'],
cfg.meetings['30-min-chat'],
'guest',
fd,
h.row.current
@@ -375,19 +409,19 @@ describe('/appointment/[id]/reschedule book action', () => {
});
expect(h.isRescheduleSlotBookable).toHaveBeenCalledExactlyOnceWith(
h.db,
validConfig,
cfg,
'30-min-chat',
validConfig.meetings['30-min-chat'],
cfg.meetings['30-min-chat'],
'guest',
NEW_SLOT,
60,
newStart,
duration,
h.row.current
);
expect(h.reschedule).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
newStart: NEW_SLOT,
newEnd: '2099-05-02T17:00:00Z',
newStart,
newEnd,
initiator: 'guest'
})
);
@@ -401,10 +435,14 @@ describe('/appointment/[id]/reschedule book action', () => {
expect(result?.status).toBe(409);
});
test('409 when the appointment can no longer be rescheduled', async () => {
test.for([
{ reason: 'gated', status: 409, error: 'This appointment can no longer be rescheduled.' },
{ reason: 'no_changes', status: 400, error: 'Please select a new time or duration.' }
])('returns $status when the transaction refuses $reason', async ({ reason, status, error }) => {
h.parseForm.mockReturnValue({ ok: true, data: {} });
h.reschedule.mockResolvedValue({ ok: false, reason: 'gated' });
h.reschedule.mockResolvedValue({ ok: false, reason });
const result = await book(form());
expect(result?.status).toBe(409);
expect(result?.status).toBe(status);
expect(result.data?.error).toBe(error);
});
});
@@ -19,10 +19,6 @@ export const actions: Actions = {
const found = await findAppointment(getDb(), params.id);
if (!found) return fail(404, { error: 'Appointment not found.' });
if (found.start_time === slotStr) {
return fail(400, { error: 'Please select a new time slot.' });
}
const cfg = getConfig();
const eventType = cfg.meetings[found.event_type_id];
if (!eventType) return fail(409, { error: 'This meeting type no longer exists.' });
@@ -34,6 +30,11 @@ export const actions: Actions = {
const duration = resolveDuration(eventType, 'host', form, found);
if (duration === null) return fail(400, { error: 'Please pick a valid meeting length.' });
const end = start.add({ minutes: duration });
if (start.equals(found.start_time) && end.equals(found.end_time)) {
return fail(400, { error: 'Please select a new time or duration.' });
}
// Re-validate the slot is currently bookable, ignoring the appointment's own current slot.
const bookable = await isRescheduleSlotBookable(
getDb(),
@@ -41,7 +42,7 @@ export const actions: Actions = {
found.event_type_id,
eventType,
'host',
slotStr,
start.toString(),
duration,
found
);
@@ -49,7 +50,6 @@ export const actions: Actions = {
return fail(409, { error: 'That time is no longer available. Please pick another.' });
}
const end = start.add({ minutes: duration });
const result = await rescheduleAppointment(appointmentContext(), {
appointmentId: found.id,
initiator: 'host', // Admin is always the host
@@ -58,6 +58,9 @@ export const actions: Actions = {
reason
});
if (!result.ok) {
if (result.reason === 'no_changes') {
return fail(400, { error: 'Please select a new time or duration.' });
}
if (result.reason === 'slot_taken') {
return fail(409, { error: 'That time was just taken. Please pick another.' });
}
@@ -10,7 +10,7 @@ const h = vi.hoisted(() => ({
resolveDuration: vi.fn()
}));
vi.mock('#lib/server/state.js', () => ({ getConfig: () => validConfig, getDb: () => h.db }));
vi.mock('#lib/server/state.js', () => ({ getConfig: () => cfg, getDb: () => h.db }));
vi.mock('@when/db', async (io) => {
const original = await io<typeof import('@when/db')>();
return { ...original, findAppointment: h.findAppointment };
@@ -21,7 +21,10 @@ vi.mock('#lib/server/availability/booking.js', () => ({
vi.mock('#lib/server/appointment/reschedule.js', () => ({ rescheduleAppointment: h.reschedule }));
vi.mock('#lib/server/appointment/duration.js', async (importOriginal) => {
const original = await importOriginal<typeof import('#lib/server/appointment/duration.js')>();
return { ...original, resolveDuration: h.resolveDuration };
return {
...original,
resolveDuration: h.resolveDuration.mockImplementation(original.resolveDuration)
};
});
vi.mock('#lib/server/appointment/form.server.js', async (io) => {
const original = await io<typeof import('#lib/server/appointment/form.server.js')>();
@@ -32,6 +35,9 @@ import { actions } from './+page.server';
type Failure = { status?: number; data?: { error?: string } };
type Thrown = { status: number; location?: string };
const cfg = structuredClone(validConfig);
cfg.meetings['30-min-chat'].additional_duration_minutes = [75];
const CURRENT = '2099-05-01T15:00:00Z';
const NEW_SLOT = '2099-05-02T16:00:00Z';
const row = {
@@ -43,18 +49,18 @@ const row = {
status: 'confirmed'
};
function bookEvent(slot = NEW_SLOT): Parameters<typeof actions.default>[0] {
function bookEvent(slot = NEW_SLOT, duration = 60): Parameters<typeof actions.default>[0] {
const fd = new FormData();
fd.set('slot', slot);
fd.set('duration', '60');
fd.set('duration', String(duration));
fd.set('reason', 'Moving the meeting');
return { request: { formData: async () => fd }, params: { id: row.id } } as unknown as Parameters<
typeof actions.default
>[0];
}
async function book(slot = NEW_SLOT): Promise<Failure> {
const response = await actions.default(bookEvent(slot));
async function book(slot = NEW_SLOT, duration = 60): Promise<Failure> {
const response = await actions.default(bookEvent(slot, duration));
return response as Failure;
}
@@ -71,51 +77,68 @@ beforeEach(() => {
vi.clearAllMocks();
h.findAppointment.mockResolvedValue(row);
h.isRescheduleSlotBookable.mockResolvedValue(true);
h.resolveDuration.mockReturnValue(60);
h.validateReason.mockReturnValue({ ok: true, reason: 'Moving the meeting' });
h.reschedule.mockResolvedValue({ ok: true, appointment: { id: 'appt-2' } });
});
describe('/admin/appointment/[id]/reschedule book action', () => {
test('needs no guest token, delegates the chosen length excluding self, and reschedules as host', async () => {
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,
row.event_type_id,
validConfig.meetings[row.event_type_id],
'host',
NEW_SLOT,
60,
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 }),
{
appointmentId: row.id,
initiator: 'host',
newStart: NEW_SLOT,
newEnd: '2099-05-02T17:00:00Z',
reason: 'Moving the meeting'
}
);
});
test.for([
{ slot: NEW_SLOT, newStart: NEW_SLOT, duration: 60, newEnd: '2099-05-02T17:00:00Z' },
{
slot: '2099-05-01T15:00:00.000Z',
newStart: CURRENT,
duration: 60,
newEnd: '2099-05-01T16:00:00Z'
},
{
slot: '2099-05-01T17:00:00+02:00',
newStart: CURRENT,
duration: 120,
newEnd: '2099-05-01T17:00:00Z'
},
{ slot: CURRENT, newStart: CURRENT, duration: 75, newEnd: '2099-05-01T16:15:00Z' }
])(
'reschedules as host without a guest token at $slot for $duration minutes',
async ({ slot, newStart, duration, newEnd }) => {
const event = bookEvent(slot, duration);
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(
cfg.meetings[row.event_type_id],
'host',
form,
row
);
expect(h.resolveDuration.mock.calls[0][3]).toBe(row);
expect(h.isRescheduleSlotBookable).toHaveBeenCalledExactlyOnceWith(
h.db,
cfg,
row.event_type_id,
cfg.meetings[row.event_type_id],
'host',
newStart,
duration,
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 }),
{
appointmentId: row.id,
initiator: 'host',
newStart,
newEnd,
reason: 'Moving the meeting'
}
);
}
);
test('404 without checking availability when the row is missing', async () => {
h.findAppointment.mockResolvedValue(null);
@@ -125,19 +148,27 @@ describe('/admin/appointment/[id]/reschedule book action', () => {
expect(h.reschedule).not.toHaveBeenCalled();
});
test.for(['', 'not-a-time', CURRENT])(
'400 on missing, malformed or unchanged slot %s',
test.for(['', 'not-a-time'])('400 on missing or malformed slot %s', async (slot) => {
const result = await book(slot);
expect(result.status).toBe(400);
expect(h.isRescheduleSlotBookable).not.toHaveBeenCalled();
expect(h.reschedule).not.toHaveBeenCalled();
});
test.for([CURRENT, '2099-05-01T17:00:00+02:00', '2099-05-01T15:00:00.000Z'])(
'400 without availability or mutation for an unchanged 90-minute interval: %s',
async (slot) => {
const result = await book(slot);
h.findAppointment.mockResolvedValue({ ...row, end_time: '2099-05-01T18:30:00+02:00' });
const result = await book(slot, 90);
expect(result.status).toBe(400);
expect(result.data?.error).toBe('Please select a new time or duration.');
expect(h.isRescheduleSlotBookable).not.toHaveBeenCalled();
expect(h.reschedule).not.toHaveBeenCalled();
}
);
test('400 on an invalid length before checking availability', async () => {
h.resolveDuration.mockReturnValue(null);
const result = await book();
test('400 on an arbitrary non-palette length before checking availability', async () => {
const result = await book(CURRENT, 77);
expect(result.status).toBe(400);
expect(h.isRescheduleSlotBookable).not.toHaveBeenCalled();
expect(h.reschedule).not.toHaveBeenCalled();
@@ -145,28 +176,39 @@ describe('/admin/appointment/[id]/reschedule book action', () => {
test('400 on an invalid reason before checking duration or availability', async () => {
h.validateReason.mockReturnValue({ ok: false, error: 'too long' });
const result = await book();
const result = await book(CURRENT);
expect(result.status).toBe(400);
expect(h.resolveDuration).not.toHaveBeenCalled();
expect(h.isRescheduleSlotBookable).not.toHaveBeenCalled();
expect(h.reschedule).not.toHaveBeenCalled();
});
test('409 without mutation when the checker rejects availability', async () => {
test('409 without mutation when a longer same-start interval is unavailable', async () => {
h.isRescheduleSlotBookable.mockResolvedValue(false);
const result = await book();
const result = await book(CURRENT, 120);
expect(result.status).toBe(409);
expect(result.data?.error).toBe('That time is no longer available. Please pick another.');
expect(h.isRescheduleSlotBookable).toHaveBeenCalledExactlyOnceWith(
h.db,
cfg,
row.event_type_id,
cfg.meetings[row.event_type_id],
'host',
CURRENT,
120,
row
);
expect(h.reschedule).not.toHaveBeenCalled();
});
test.for([
['slot_taken', 'That time was just taken. Please pick another.'],
['gated', 'This appointment can no longer be rescheduled.']
])('409 when the mutation returns %s', async ([reason, message]) => {
{ reason: 'slot_taken', status: 409, error: 'That time was just taken. Please pick another.' },
{ reason: 'gated', status: 409, error: 'This appointment can no longer be rescheduled.' },
{ reason: 'no_changes', status: 400, error: 'Please select a new time or duration.' }
])('returns $status when the transaction refuses $reason', async ({ reason, status, error }) => {
h.reschedule.mockResolvedValue({ ok: false, reason });
const result = await book();
expect(result.status).toBe(409);
expect(result.data?.error).toBe(message);
expect(result.status).toBe(status);
expect(result.data?.error).toBe(error);
});
});