diff --git a/.changeset/duration-aware-rescheduling.md b/.changeset/duration-aware-rescheduling.md new file mode 100644 index 00000000..bb380ae2 --- /dev/null +++ b/.changeset/duration-aware-rescheduling.md @@ -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. diff --git a/apps/web/e2e/chain.e2e.ts b/apps/web/e2e/chain.e2e.ts index 400509bf..44f6d230 100644 --- a/apps/web/e2e/chain.e2e.ts +++ b/apps/web/e2e/chain.e2e.ts @@ -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(); diff --git a/apps/web/e2e/conditional-form.e2e.ts b/apps/web/e2e/conditional-form.e2e.ts index 2068b65f..3f8dce4d 100644 --- a/apps/web/e2e/conditional-form.e2e.ts +++ b/apps/web/e2e/conditional-form.e2e.ts @@ -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); }); diff --git a/apps/web/e2e/fixture/config/when.yaml b/apps/web/e2e/fixture/config/when.yaml index 5d45b7f4..b4e93bea 100644 --- a/apps/web/e2e/fixture/config/when.yaml +++ b/apps/web/e2e/fixture/config/when.yaml @@ -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' diff --git a/apps/web/e2e/host-durations.e2e.ts b/apps/web/e2e/host-durations.e2e.ts index 6cedfa87..bc59537b 100644 --- a/apps/web/e2e/host-durations.e2e.ts +++ b/apps/web/e2e/host-durations.e2e.ts @@ -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 { + 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 ({ diff --git a/apps/web/src/lib/appointment.test.ts b/apps/web/src/lib/appointment.test.ts index 006d4113..073c5ec3 100644 --- a/apps/web/src/lib/appointment.test.ts +++ b/apps/web/src/lib/appointment.test.ts @@ -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', () => { diff --git a/apps/web/src/lib/appointment.ts b/apps/web/src/lib/appointment.ts index 01180728..7614986b 100644 --- a/apps/web/src/lib/appointment.ts +++ b/apps/web/src/lib/appointment.ts @@ -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) }; }); diff --git a/apps/web/src/lib/appointmentFlow.svelte.test.ts b/apps/web/src/lib/appointmentFlow.svelte.test.ts index fb468917..2d2f93e4 100644 --- a/apps/web/src/lib/appointmentFlow.svelte.test.ts +++ b/apps/web/src/lib/appointmentFlow.svelte.test.ts @@ -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', () => { diff --git a/apps/web/src/lib/appointmentFlow.svelte.ts b/apps/web/src/lib/appointmentFlow.svelte.ts index e563fc8e..abe40e97 100644 --- a/apps/web/src/lib/appointmentFlow.svelte.ts +++ b/apps/web/src/lib/appointmentFlow.svelte.ts @@ -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; readonly offeredDates: Set; + readonly hasChanges: boolean; + readonly canSubmit: boolean; readonly canAdvance: boolean; advance(): void; goBack(): void; @@ -33,6 +36,7 @@ export interface AppointmentFlowInit { slotsByDuration: Record>; 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(1); - let viewDate = $state(null); - let selectedSlot = $state(null); + let viewDate = $state(originalSlot ? instantToDateKey(originalSlot, 'UTC') : null); + let selectedSlot = $state(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 diff --git a/apps/web/src/lib/appointmentUrlSync.svelte.ts b/apps/web/src/lib/appointmentUrlSync.svelte.ts index 54bcfc91..ac99108f 100644 --- a/apps/web/src/lib/appointmentUrlSync.svelte.ts +++ b/apps/web/src/lib/appointmentUrlSync.svelte.ts @@ -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 | 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 | 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 | 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, diff --git a/apps/web/src/lib/components/AppointmentWizard.svelte b/apps/web/src/lib/components/AppointmentWizard.svelte index e4c0b8a2..d3d5e6b8 100644 --- a/apps/web/src/lib/components/AppointmentWizard.svelte +++ b/apps/web/src/lib/components/AppointmentWizard.svelte @@ -1,5 +1,6 @@ @@ -165,11 +173,12 @@ Return home {:else} - {#if rescheduleAppt} + {#if rescheduleAppt && ptz.current} {/if} @@ -191,7 +200,7 @@ Step {step} of 3: {#if step === 1}Pick a day{:else if step === 2}Pick a time{:else}Enter your info{/if} - {#if step === 2 && viewDate} + {#if step === 2 && viewDate && ptz.current}

{formatDate(viewDate)}

{:else if step === 3 && selectedSlot}

@@ -209,6 +218,17 @@ {/if} + {#if rescheduleAppt && !selectedSlot} +

+ Choose an available start time for this duration. +

+ {:else if rescheduleAppt && !originalStartAvailable && selectedSlot === originalStart} +

+ The current start time isn't available for this duration. Choose another time or + duration. +

+ {/if} +
{#if flow.availableDates.size === 0}

No availability in the near future.

@@ -226,14 +246,13 @@ busyBlocks={data.availability.busyBlocks} eventType={data.eventType} showSlots={data.eventType.show_slots} - originalSlot={rescheduleAppt?.start_time ?? null} /> {:else} {/if} {/if} - {#if step === 3 && selectedSlot} + {#if step === 3 && (selectedSlot || rescheduleAppt)}
@@ -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); diff --git a/apps/web/src/lib/components/DayTimeline.svelte b/apps/web/src/lib/components/DayTimeline.svelte index 365adea0..4baccfed 100644 --- a/apps/web/src/lib/components/DayTimeline.svelte +++ b/apps/web/src/lib/components/DayTimeline.svelte @@ -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)!} -
- {orig.time} (Current) -
- {/if} @@ -329,21 +303,19 @@ {#snippet discreteSlots()} {#if timeline} {#each timeline.slots as s (s.iso)} - {#if !s.isOriginal} - {@const isSelected = s.iso === selectedSlot} - - {/if} + {@const isSelected = s.iso === selectedSlot} + {/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} - - {/if} + {@const isSelected = s.iso === selectedSlot} + {/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; } diff --git a/apps/web/src/lib/components/GuestForm.svelte b/apps/web/src/lib/components/GuestForm.svelte index 771d3862..5fe09f2c 100644 --- a/apps/web/src/lib/components/GuestForm.svelte +++ b/apps/web/src/lib/components/GuestForm.svelte @@ -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 }); }; + };
@@ -140,7 +144,7 @@ /> {/if} - diff --git a/apps/web/src/lib/components/RescheduleBanner.svelte b/apps/web/src/lib/components/RescheduleBanner.svelte index 18db831f..65a6f9c7 100644 --- a/apps/web/src/lib/components/RescheduleBanner.svelte +++ b/apps/web/src/lib/components/RescheduleBanner.svelte @@ -1,30 +1,64 @@ - -