diff --git a/.changeset/clear-unavailable-wizard-day.md b/.changeset/clear-unavailable-wizard-day.md new file mode 100644 index 00000000..f9906a4f --- /dev/null +++ b/.changeset/clear-unavailable-wizard-day.md @@ -0,0 +1,5 @@ +--- +'@when/web': patch +--- + +Clear calendar selections that no longer support the chosen duration and prevent continuing with an unavailable day. Keep valid selections and allow recovery from an empty time picker. diff --git a/.changeset/order-booking-durations.md b/.changeset/order-booking-durations.md new file mode 100644 index 00000000..8b64c5b6 --- /dev/null +++ b/.changeset/order-booking-durations.md @@ -0,0 +1,5 @@ +--- +'@when/web': patch +--- + +Order guest and host duration choices numerically without changing configured defaults, valid duration-link selections, booked rescheduling durations, or role-based permissions. diff --git a/.changeset/shared-wizard-duration-picker.md b/.changeset/shared-wizard-duration-picker.md new file mode 100644 index 00000000..604d42fe --- /dev/null +++ b/.changeset/shared-wizard-duration-picker.md @@ -0,0 +1,5 @@ +--- +'@when/web': patch +--- + +Show a compact duration and timezone settings row on both the day and time steps, including when no availability remains. Use dialogs instead of duration steppers, show fixed durations as text, and keep final details read-only. Clear unavailable day selections after timezone changes while preserving selected appointment times. diff --git a/apps/web/e2e/host-durations.e2e.ts b/apps/web/e2e/host-durations.e2e.ts index 8a059dfb..a8c6abbc 100644 --- a/apps/web/e2e/host-durations.e2e.ts +++ b/apps/web/e2e/host-durations.e2e.ts @@ -1,8 +1,10 @@ +import { openDb, replaceCalendarBusy } from '@when/db'; import { expect, test, type Page, type Request } from './support/test.ts'; import { signInAsAdmin } from './support/auth.ts'; -import { readAppointment } from './support/database.ts'; +import { APP_DB_PATH, readAppointment } from './support/database.ts'; import { seedAppointment } from './support/seed.ts'; import { futureWeekday } from './support/dates.ts'; +import { pickFirstBookableDay } from './support/wizard.ts'; const slug = 'host-durations'; const hostDurations = [15, 25, 30, 45, 60, 90, 120, 180, 240, 300]; @@ -26,6 +28,28 @@ async function chooseDuration(page: Page, current: number, next: number): Promis await expect(dialog).not.toBeVisible(); } +test('a duration change clears an unavailable selected day and disables Continue', async ({ + page +}) => { + const day = futureWeekday(); + const db = openDb(APP_DB_PATH); + try { + // The guest's 25-minute choice fits this day; 60 minutes only fits other days. + await replaceCalendarBusy(db, 'main', [{ start: `${day}T09:30:00Z`, end: `${day}T12:00:00Z` }]); + } finally { + await db.destroy(); + } + await page.goto(`/schedule/${slug}?duration=25&date=${day}`); + await expect(page.getByRole('heading', { name: 'Step 2 of 3: Pick a time' })).toBeVisible(); + await page.getByRole('button', { name: 'Back', exact: true }).click(); + await expect(page.locator('.cal-day[data-selected]')).toHaveCount(1); + await chooseDuration(page, 25, 60); + await expect(page.getByRole('heading', { name: 'Step 1 of 3: Pick a day' })).toBeVisible(); + await expect(page.locator('.cal-day[data-selected]')).toHaveCount(0); + await expect(page.getByRole('button', { name: 'Continue', exact: true })).toBeDisabled(); + await expect.poll(() => new URL(page.url()).searchParams.get('date')).toBeNull(); +}); + test('host uses the configured default and finite palette, recovering locally from no availability', async ({ baseURL, page, @@ -37,6 +61,7 @@ test('host uses the configured default and finite palette, recovering locally fr await expect(durationTrigger(page, 60)).toBeVisible(); await page.getByRole('button', { name: 'Back', exact: true }).click(); await expect(page.getByRole('heading', { name: 'Step 1 of 3: Pick a day' })).toBeVisible(); + await expect(page.getByRole('button', { name: /^Change timezone, currently/ })).toBeVisible(); await durationTrigger(page, 60).click(); const dialog = page.getByRole('dialog', { name: 'How long?' }); await expect(dialog.locator('.duration-row')).toHaveText( @@ -55,6 +80,7 @@ test('host uses the configured default and finite palette, recovering locally fr page.getByText('No availability in the near future.', { exact: true }) ).toBeVisible(); await expect(durationTrigger(page, 300)).toBeVisible(); + await expect(page.getByRole('button', { name: /^Change timezone, currently/ })).toBeVisible(); await chooseDuration(page, 300, 90); await expect(page.getByRole('heading', { name: 'Step 1 of 3: Pick a day' })).toBeVisible(); await expect(page.getByText('No availability in the near future.', { exact: true })).toHaveCount( @@ -68,9 +94,14 @@ test('host uses the configured default and finite palette, recovering locally fr await expect(page.getByRole('heading', { name: 'Step 1 of 3: Pick a day' })).toBeVisible(); await expect(durationTrigger(page, 15)).toBeVisible(); await chooseDuration(page, 15, 90); - await page.getByRole('button', { name: 'Continue', exact: true }).click(); + // The earlier unavailable duration invalidated the calendar selection; pick a day again. + await expect(page.getByRole('button', { name: 'Continue', exact: true })).toBeDisabled(); + await pickFirstBookableDay(page); await expect(page.getByRole('heading', { name: 'Step 2 of 3: Pick a time' })).toBeVisible(); + const selectedDay = new URL(page.url()).searchParams.get('date'); await expect(durationTrigger(page, 90)).toBeVisible(); + await chooseDuration(page, 90, 60); + await chooseDuration(page, 60, 90); const target = page.getByRole('radio', { name: /10:30\s*AM\s*–\s*12:00\s*PM/i }); await expect(target).toBeVisible(); page.off('request', trackDurationRequest); @@ -78,13 +109,15 @@ test('host uses the configured default and finite palette, recovering locally fr await target.click(); await page.getByRole('button', { name: 'Confirm', exact: true }).click(); + await expect(page.getByRole('group', { name: 'Booking settings' })).toHaveCount(0); + await expect(page.locator('.stage-subtitle')).toContainText(/90\s+min/); await expect(page.locator('#appointment-form')).toHaveAttribute( 'action', `/admin/schedule/${slug}` ); await expect(page.locator('input[name="duration"]')).toHaveValue('90'); const slot = await page.locator('input[name="slot"]').inputValue(); - expect(Date.parse(slot)).toBe(Date.parse(`${day}T10:30:00Z`)); + expect(Date.parse(slot)).toBe(Date.parse(`${selectedDay}T10:30:00Z`)); const form = { slot, duration: '90', @@ -136,8 +169,8 @@ test('rescheduling defaults to the trusted current 90 for both roles and omissio await durationTrigger(page, 90).click(); const dialog = page.getByRole('dialog', { name: 'How long?' }); await expect(dialog.locator('.duration-row')).toHaveText([ - '60 minutes', '25 minutes', + '60 minutes', '90 minutes' ]); await expect(dialog.getByRole('button', { name: '120 minutes', exact: true })).toHaveCount(0); @@ -318,7 +351,7 @@ test('an empty weekend timeline keeps its duration toolbar usable without fetchi // Abidjan is UTC year-round: Saturday now has no working-window geometry. await expect(page.locator('.timeline-scroll')).toHaveCount(0); await expect(page.getByText('No available times on this date.', { exact: true })).toBeVisible(); - await expect(page.getByRole('group', { name: 'Duration selector' })).toBeVisible(); + await expect(page.getByRole('group', { name: 'Booking settings' })).toBeVisible(); await expect(durationTrigger(page, 60)).toBeVisible(); await chooseDuration(page, 60, 90); await expect(durationTrigger(page, 90)).toBeVisible(); diff --git a/apps/web/src/lib/appointmentFlow.svelte.test.ts b/apps/web/src/lib/appointmentFlow.svelte.test.ts index ab6bd23d..fb468917 100644 --- a/apps/web/src/lib/appointmentFlow.svelte.test.ts +++ b/apps/web/src/lib/appointmentFlow.svelte.test.ts @@ -61,6 +61,50 @@ describe('createAppointmentFlow duration', () => { expect(flow.selectedSlot).toBe(SLOT_10); }); + it('clears a day that no longer offers the new duration and prevents continuing', () => { + const flow = makeFlow({ + slotsByDuration: { + 30: { '2026-07-20': [SLOT_10], '2026-07-21': ['2026-07-21T10:00:00Z'] }, + 60: { '2026-07-21': ['2026-07-21T10:00:00Z'] } + } + }); + flow.selectDate('2026-07-20'); + flow.setDuration(60); + expect(flow.availableDates.has('2026-07-21')).toBe(true); + expect(flow.viewDate).toBeNull(); + expect(flow.canAdvance).toBe(false); + flow.advance(); + expect(flow.step).toBe(1); + }); + + it('preserves a day that still offers the new duration', () => { + const flow = makeFlow(); + flow.selectDate('2026-07-20'); + flow.setDuration(60); + expect(flow.viewDate).toBe('2026-07-20'); + expect(flow.canAdvance).toBe(true); + }); + + it('keeps an empty time-picker day for recovery but clears it when returning to the calendar', () => { + const flow = makeFlow({ + slotsByDuration: { + 30: { '2026-07-20': [SLOT_10], '2026-07-21': ['2026-07-21T10:00:00Z'] }, + 60: { '2026-07-21': ['2026-07-21T10:00:00Z'] } + } + }); + flow.openDate('2026-07-20'); + flow.selectSlot(SLOT_10); + flow.setDuration(60); + expect(flow.step).toBe(2); + expect(flow.viewDate).toBe('2026-07-20'); + expect(flow.selectedSlot).toBeNull(); + expect(flow.canAdvance).toBe(false); + flow.goBack(); + expect(flow.step).toBe(1); + expect(flow.viewDate).toBeNull(); + expect(flow.canAdvance).toBe(false); + }); + it('steps back from the details step when the length drops the slot', () => { const flow = makeFlow(); flow.selectSlot(SLOT_1030); @@ -147,11 +191,15 @@ describe('createAppointmentFlow selection', () => { expect(flow.step).toBe(2); }); - it('selectSlot records the slot and derives the day from it', () => { + it('keeps the selected instant and updates its day when the timezone changes', () => { const flow = makeFlow(); flow.selectSlot(SLOT_1030); expect(flow.selectedSlot).toBe(SLOT_1030); expect(flow.viewDate).toBe('2026-07-20'); + flow.setTz('Pacific/Kiritimati'); + expect(flow.selectedSlot).toBe(SLOT_1030); + expect(flow.viewDate).toBe('2026-07-21'); + expect(flow.canAdvance).toBe(true); }); it('clearSlot drops the slot', () => { @@ -161,15 +209,19 @@ describe('createAppointmentFlow selection', () => { expect(flow.selectedSlot).toBeNull(); }); - it('re-buckets available dates when the timezone changes', () => { + it('re-buckets available dates and clears an unavailable calendar selection when the timezone changes', () => { const flow = createAppointmentFlow({ slotsByDuration: { 30: { '2026-07-20': ['2026-07-20T02:00:00Z'] } }, durations: [30], initialDuration: 30 }); - expect(flow.availableDates.has('2026-07-20')).toBe(true); + flow.selectDate('2026-07-20'); + expect(flow.canAdvance).toBe(true); flow.setTz('America/New_York'); expect(flow.userTz).toBe('America/New_York'); expect(flow.availableDates.has('2026-07-19')).toBe(true); + expect(flow.availableDates.has('2026-07-20')).toBe(false); + expect(flow.viewDate).toBeNull(); + expect(flow.canAdvance).toBe(false); }); }); diff --git a/apps/web/src/lib/appointmentFlow.svelte.ts b/apps/web/src/lib/appointmentFlow.svelte.ts index e2c6095b..e563fc8e 100644 --- a/apps/web/src/lib/appointmentFlow.svelte.ts +++ b/apps/web/src/lib/appointmentFlow.svelte.ts @@ -56,9 +56,21 @@ export function createAppointmentFlow({ const slotsByDate = $derived(slotsByDuration[duration] ?? {}); const allSlots = $derived(flattenSlots(slotsByDate)); const dates = $derived(datesFromSlots(allSlots, userTz)); - // The raw server day keys (host tz), unlike `availableDates` which re-buckets in `userTz`. const offeredDates = $derived(dateKeys(slotsByDate)); - const canAdvance = $derived(canAdvanceRule(step, viewDate, selectedSlot)); + const canAdvance = $derived.by(() => { + if (step === 1) { + if (viewDate === null) return false; + return dates.has(viewDate); + } + return canAdvanceRule(step, viewDate, selectedSlot); + }); + + function clearUnavailableDateSelection() { + if (step === 1 && viewDate && !dates.has(viewDate)) { + viewDate = null; + selectedSlot = null; + } + } function scrollToTop() { if (typeof window !== 'undefined') window.scrollTo({ top: 0 }); @@ -103,10 +115,12 @@ export function createAppointmentFlow({ goBack() { if (step === 1) return; step = (step - 1) as WizardStep; + clearUnavailableDateSelection(); scrollToTop(); }, goToStep(target) { step = target; + clearUnavailableDateSelection(); }, selectDate(dateKey) { if (dateKey == null) { @@ -134,6 +148,8 @@ export function createAppointmentFlow({ }, setTz(tz) { userTz = tz; + if (selectedSlot) viewDate = instantToDateKey(selectedSlot, userTz); + clearUnavailableDateSelection(); }, // Changing the length swaps the live slot map. If the picked time no longer // exists at the new length, drop it and step back so the guest re-picks. @@ -144,6 +160,7 @@ export function createAppointmentFlow({ selectedSlot = null; if (step === 3) step = 2; } + clearUnavailableDateSelection(); } }; } diff --git a/apps/web/src/lib/components/AppointmentWizard.svelte b/apps/web/src/lib/components/AppointmentWizard.svelte index 66468947..a375fece 100644 --- a/apps/web/src/lib/components/AppointmentWizard.svelte +++ b/apps/web/src/lib/components/AppointmentWizard.svelte @@ -2,7 +2,7 @@ import { page } from '$app/state'; import DatePicker from '$lib/components/DatePicker.svelte'; import DayTimeline from '$lib/components/DayTimeline.svelte'; - import DurationDialog from '$lib/components/DurationDialog.svelte'; + import WizardPickerControls from '$lib/components/WizardPickerControls.svelte'; import CalendarSkeleton from '$lib/components/CalendarSkeleton.svelte'; import TimelineSkeleton from '$lib/components/TimelineSkeleton.svelte'; import RescheduleBanner from '$lib/components/RescheduleBanner.svelte'; @@ -80,7 +80,6 @@ let selectedSlot = $derived(flow.selectedSlot); let userTz = $derived(flow.userTz); let isGuestFormValid = $state(false); - let durationOpen = $state(false); let previousAppointmentHref = $derived( rescheduleAppt @@ -109,7 +108,6 @@ $effect(() => { if (!ptz.current) return; flow.setTz(ptz.current); - if (flow.selectedSlot) flow.selectSlot(flow.selectedSlot); }); // svelte-ignore state_referenced_locally @@ -148,18 +146,6 @@
- {#if !rescheduleError && durations.length > 1 && (step === 1 || flow.availableDates.size === 0)} - - {/if} - {#if rescheduleError}

Can't reschedule this appointment

@@ -178,8 +164,6 @@

Return home
- {:else if flow.availableDates.size === 0} -

No availability in the near future.

{:else} {#if rescheduleAppt} + {#if step < 3} + + {/if} +
- {#if step === 1} + {#if flow.availableDates.size === 0} +

No availability in the near future.

+ {:else if step === 1} {#if ptz.current} {:else} @@ -239,10 +229,7 @@ originalSlot={rescheduleAppt?.start_time ?? null} /> {:else} - 1} - /> + {/if} {/if} @@ -273,13 +260,6 @@ {/if}
- - diff --git a/apps/web/src/lib/server/appointment/duration.test.ts b/apps/web/src/lib/server/appointment/duration.test.ts index ecda616a..55837494 100644 --- a/apps/web/src/lib/server/appointment/duration.test.ts +++ b/apps/web/src/lib/server/appointment/duration.test.ts @@ -40,12 +40,12 @@ describe('resolveBookingDurations', () => { expect(resolveBookingDurations(meeting, 'host')).toEqual(profile); }); - test('guest choices retain configured order, not the host palette or numeric order', () => { + test('guest choices are numerically ordered without changing the configured default or adding host choices', () => { const meeting = { ...base, duration_minutes: 60, additional_duration_minutes: [15, 75, 30] }; const original = structuredClone(meeting); expect(resolveBookingDurations(meeting, 'guest')).toEqual({ defaultDuration: 60, - durations: [60, 15, 75, 30] + durations: [15, 30, 60, 75] }); expect(meeting).toEqual(original); }); @@ -81,11 +81,11 @@ describe('resolveBookingDurations', () => { } ); - test('retaining a configured guest length does not duplicate or reorder it', () => { + test('rescheduling retains the booked default independently of the sorted choices', () => { const meeting = { ...base, duration_minutes: 60, additional_duration_minutes: [15, 30] }; expect(resolveBookingDurations(meeting, 'guest', currentDuration(30))).toEqual({ defaultDuration: 30, - durations: [60, 15, 30] + durations: [15, 30, 60] }); }); diff --git a/apps/web/src/lib/server/appointment/duration.ts b/apps/web/src/lib/server/appointment/duration.ts index 53a498b4..ccda4063 100644 --- a/apps/web/src/lib/server/appointment/duration.ts +++ b/apps/web/src/lib/server/appointment/duration.ts @@ -19,10 +19,8 @@ export function resolveBookingDurations( : eventType.duration_minutes; const choices = durationsOf(eventType); if (role === 'host') choices.push(...HOST_DURATIONS); - // Keeping a booked length is not permission to choose another host-only length. choices.push(defaultDuration); - const durations = [...new Set(choices)]; - if (role === 'host') durations.sort((a, b) => a - b); + const durations = [...new Set(choices)].sort((a, b) => a - b); return { defaultDuration, durations }; } diff --git a/apps/web/src/lib/server/appointment/sanitize.test.ts b/apps/web/src/lib/server/appointment/sanitize.test.ts index 913e885e..75279417 100644 --- a/apps/web/src/lib/server/appointment/sanitize.test.ts +++ b/apps/web/src/lib/server/appointment/sanitize.test.ts @@ -95,7 +95,7 @@ describe('toPublicEventType', () => { expect(guest.padding_after_minutes).toBe(10); expect(guest.location).toBeUndefined(); expect(guest.duration_minutes).toBe(30); - expect(guest.durations).toEqual([30, 15, 60]); + expect(guest.durations).toEqual([15, 30, 60]); expect(guest.notice_minutes).toBe(60); expect(host).toEqual({ ...guest, diff --git a/apps/web/src/lib/server/availability/booking.test.ts b/apps/web/src/lib/server/availability/booking.test.ts index ea14ca5b..c752dd48 100644 --- a/apps/web/src/lib/server/availability/booking.test.ts +++ b/apps/web/src/lib/server/availability/booking.test.ts @@ -521,14 +521,14 @@ describe('role-specific availability limits', () => { }); describe('booking duration profiles', () => { - test('new hosts get a map for every palette and configured extra while guests keep configured order', async () => { + test('role-specific duration maps follow numeric choices without changing the configured default', async () => { const cfg = makeConfig([60, 75, 17, 15]); const meeting = cfg.meetings[CHAT]; const original = structuredClone(cfg); for (const role of ['guest', 'host'] as const) { const availability = await loadAvailability(db, cfg, CHAT, meeting, role); const durations = - role === 'host' ? [15, 17, 30, 45, 60, 75, 90, 120, 180, 240, 300] : [60, 75, 17, 15]; + role === 'host' ? [15, 17, 30, 45, 60, 75, 90, 120, 180, 240, 300] : [15, 17, 60, 75]; expect(availability.bookingDurations).toEqual({ defaultDuration: 60, durations }); expect(availability.bookingDurations).toEqual(resolveBookingDurations(meeting, role)); expect(Object.keys(availability.slotsByDuration).map(Number)).toEqual(