fix(web): make wizard duration selection consistent #98
No files matched your search
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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 @@
|
||||
</header>
|
||||
|
||||
<main class="appointment">
|
||||
{#if !rescheduleError && durations.length > 1 && (step === 1 || flow.availableDates.size === 0)}
|
||||
<button
|
||||
type="button"
|
||||
class="duration-trigger"
|
||||
onclick={() => (durationOpen = true)}
|
||||
aria-label="Change duration, currently {flow.duration} minutes"
|
||||
aria-haspopup="dialog"
|
||||
>
|
||||
Change duration · {flow.duration} min
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#if rescheduleError}
|
||||
<div class="card reschedule-error-card">
|
||||
<h1 class="error-title">Can't reschedule this appointment</h1>
|
||||
@@ -178,8 +164,6 @@
|
||||
</p>
|
||||
<a class="error-back-btn" href="/">Return home</a>
|
||||
</div>
|
||||
{:else if flow.availableDates.size === 0}
|
||||
<p class="empty">No availability in the near future.</p>
|
||||
{:else}
|
||||
{#if rescheduleAppt}
|
||||
<RescheduleBanner
|
||||
@@ -221,8 +205,14 @@
|
||||
{/if}
|
||||
</header>
|
||||
|
||||
{#if step < 3}
|
||||
<WizardPickerControls {flow} timezoneReady={!!ptz.current} />
|
||||
{/if}
|
||||
|
||||
<div class="appointment-body">
|
||||
{#if step === 1}
|
||||
{#if flow.availableDates.size === 0}
|
||||
<p class="empty">No availability in the near future.</p>
|
||||
{:else if step === 1}
|
||||
{#if ptz.current}
|
||||
<DatePicker {flow} />
|
||||
{:else}
|
||||
@@ -239,10 +229,7 @@
|
||||
originalSlot={rescheduleAppt?.start_time ?? null}
|
||||
/>
|
||||
{:else}
|
||||
<TimelineSkeleton
|
||||
rows={timelineSkeletonRows}
|
||||
showDuration={data.eventType.durations.length > 1}
|
||||
/>
|
||||
<TimelineSkeleton rows={timelineSkeletonRows} />
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
@@ -273,13 +260,6 @@
|
||||
{/if}
|
||||
</main>
|
||||
|
||||
<DurationDialog
|
||||
bind:open={durationOpen}
|
||||
{durations}
|
||||
value={flow.duration}
|
||||
onSelect={flow.setDuration}
|
||||
/>
|
||||
|
||||
<style>
|
||||
.appointment {
|
||||
max-width: 960px;
|
||||
@@ -288,29 +268,6 @@
|
||||
color: var(--when-color-text);
|
||||
}
|
||||
|
||||
.duration-trigger {
|
||||
display: block;
|
||||
min-height: calc(var(--space-8) + var(--space-4));
|
||||
margin: var(--space-4) auto;
|
||||
padding: var(--space-2) var(--space-4);
|
||||
background: var(--color-surface);
|
||||
border: none;
|
||||
border-radius: var(--radius);
|
||||
font: inherit;
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--when-color-primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.duration-trigger:hover {
|
||||
background: var(--color-surface-active);
|
||||
}
|
||||
|
||||
.duration-trigger:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: var(--shadow-focus);
|
||||
}
|
||||
|
||||
.empty {
|
||||
text-align: center;
|
||||
color: var(--color-text-muted);
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
<script lang="ts">
|
||||
import IconGlobe from 'virtual:icons/ph/globe';
|
||||
import IconMinus from 'virtual:icons/ph/minus';
|
||||
import IconPlus from 'virtual:icons/ph/plus';
|
||||
import { formatTzShort, tzCity, tzOffset } from '$lib/datetime';
|
||||
import { onDestroy } from 'svelte';
|
||||
import {
|
||||
slotsOnDate,
|
||||
@@ -15,8 +11,6 @@
|
||||
type TimelineSlot
|
||||
} from '$lib/appointment';
|
||||
import type { AppointmentFlow } from '$lib/appointmentFlow.svelte';
|
||||
import TimezoneDialog from './TimezoneDialog.svelte';
|
||||
import DurationDialog from './DurationDialog.svelte';
|
||||
|
||||
interface Props {
|
||||
flow: AppointmentFlow;
|
||||
@@ -40,35 +34,10 @@
|
||||
let viewDate = $derived(flow.viewDate);
|
||||
let selectedSlot = $derived(flow.selectedSlot);
|
||||
let userTz = $derived(flow.userTz);
|
||||
let durations = $derived(flow.durations);
|
||||
|
||||
let currentIndex = $derived(durations.indexOf(flow.duration));
|
||||
let canDecrease = $derived(currentIndex > 0);
|
||||
let canIncrease = $derived(currentIndex >= 0 && currentIndex < durations.length - 1);
|
||||
let prevDuration = $derived(canDecrease ? durations[currentIndex - 1] : null);
|
||||
let nextDuration = $derived(canIncrease ? durations[currentIndex + 1] : null);
|
||||
|
||||
function decreaseDuration() {
|
||||
if (canDecrease && prevDuration !== null) {
|
||||
flow.setDuration(prevDuration);
|
||||
}
|
||||
}
|
||||
|
||||
function increaseDuration() {
|
||||
if (canIncrease && nextDuration !== null) {
|
||||
flow.setDuration(nextDuration);
|
||||
}
|
||||
}
|
||||
|
||||
// The picked length is flow state; overlay it onto the static event-type config.
|
||||
let liveEventType = $derived({ ...eventType, duration_minutes: flow.duration });
|
||||
|
||||
let tzFull = $derived(formatTzShort(userTz));
|
||||
let tzShort = $derived(tzOffset(userTz) || tzCity(userTz));
|
||||
|
||||
let tzOpen = $state(false);
|
||||
let durationOpen = $state(false);
|
||||
|
||||
let daySlots = $derived(viewDate ? slotsOnDate(flow.allSlots, viewDate, userTz) : []);
|
||||
|
||||
let timeline = $derived(
|
||||
@@ -281,56 +250,6 @@
|
||||
</script>
|
||||
|
||||
<div class="timeline-container">
|
||||
{#if viewDate}
|
||||
<div class="slots-header">
|
||||
<button
|
||||
type="button"
|
||||
class="tz-chip"
|
||||
onclick={() => (tzOpen = true)}
|
||||
aria-label="Change timezone, currently {tzFull}"
|
||||
>
|
||||
<span class="tz-icon"><IconGlobe aria-hidden="true" /></span>
|
||||
<span class="tz-label tz-full">{tzFull}</span>
|
||||
<span class="tz-label tz-short">{tzShort}</span>
|
||||
</button>
|
||||
|
||||
{#if durations.length > 1}
|
||||
<div class="duration-control">
|
||||
<div class="duration-stepper" role="group" aria-label="Duration selector">
|
||||
<button
|
||||
type="button"
|
||||
class="stepper-btn stepper-btn-left"
|
||||
onclick={decreaseDuration}
|
||||
disabled={!canDecrease}
|
||||
aria-label="Decrease duration"
|
||||
title={canDecrease ? `Decrease to ${prevDuration} min` : undefined}
|
||||
>
|
||||
<IconMinus aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="stepper-display"
|
||||
onclick={() => (durationOpen = true)}
|
||||
aria-label="Change duration, currently {flow.duration} minutes"
|
||||
aria-haspopup="dialog"
|
||||
>
|
||||
<span class="duration-value">{flow.duration} min</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="stepper-btn stepper-btn-right"
|
||||
onclick={increaseDuration}
|
||||
disabled={!canIncrease}
|
||||
aria-label="Increase duration"
|
||||
title={canIncrease ? `Increase to ${nextDuration} min` : undefined}
|
||||
>
|
||||
<IconPlus aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{#if timeline}
|
||||
<div class="timeline-scroll">
|
||||
<div class="timeline" style:height="{(timeline.totalMs / 3600000) * 96}px">
|
||||
@@ -481,14 +400,6 @@
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
<TimezoneDialog bind:open={tzOpen} />
|
||||
<DurationDialog
|
||||
bind:open={durationOpen}
|
||||
{durations}
|
||||
value={flow.duration}
|
||||
onSelect={flow.setDuration}
|
||||
/>
|
||||
|
||||
<style>
|
||||
.timeline-container {
|
||||
width: 100%;
|
||||
@@ -505,174 +416,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- timeline toolbar / header ---- */
|
||||
.slots-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: var(--space-4) var(--space-7);
|
||||
background: var(--color-quiet-bg);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
min-height: 44px;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.slots-header {
|
||||
padding: var(--space-3) var(--space-5);
|
||||
}
|
||||
}
|
||||
|
||||
.duration-control {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.duration-stepper {
|
||||
display: inline-flex;
|
||||
align-items: stretch;
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
|
||||
transition: border-color var(--transition);
|
||||
}
|
||||
|
||||
.duration-stepper:focus-within {
|
||||
border-color: var(--color-border-strong);
|
||||
}
|
||||
|
||||
.stepper-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 0 var(--space-3);
|
||||
min-width: 38px;
|
||||
height: 38px;
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
font-size: var(--font-size-base);
|
||||
transition:
|
||||
background var(--transition),
|
||||
color var(--transition),
|
||||
opacity var(--transition);
|
||||
}
|
||||
|
||||
.stepper-btn:not(:disabled):hover {
|
||||
background: var(--color-surface-active);
|
||||
color: var(--when-color-text);
|
||||
}
|
||||
|
||||
.stepper-btn:disabled {
|
||||
opacity: 0.25;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.stepper-btn:focus-visible {
|
||||
outline: 2px solid var(--when-color-primary);
|
||||
outline-offset: -2px;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.stepper-btn-left {
|
||||
border-right: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.stepper-btn-right {
|
||||
border-left: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.stepper-display {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 0 var(--space-4);
|
||||
height: 38px;
|
||||
font: inherit;
|
||||
color: var(--when-color-text);
|
||||
cursor: pointer;
|
||||
transition: background var(--transition);
|
||||
}
|
||||
|
||||
.stepper-display:hover {
|
||||
background: var(--color-surface-active);
|
||||
color: var(--when-color-text);
|
||||
}
|
||||
|
||||
.stepper-display:focus-visible {
|
||||
outline: 2px solid var(--when-color-primary);
|
||||
outline-offset: -2px;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.duration-value {
|
||||
font-size: var(--font-size-md);
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ---- timezone pill on right ---- */
|
||||
.tz-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius);
|
||||
padding: 0 var(--space-3);
|
||||
height: 38px;
|
||||
font: inherit;
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
|
||||
transition:
|
||||
background var(--transition),
|
||||
color var(--transition),
|
||||
border-color var(--transition);
|
||||
}
|
||||
|
||||
.tz-chip:hover {
|
||||
background: var(--color-surface-active);
|
||||
color: var(--when-color-text);
|
||||
border-color: var(--color-border-strong);
|
||||
}
|
||||
|
||||
.tz-chip:focus-visible {
|
||||
outline: 2px solid var(--when-color-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.tz-icon {
|
||||
display: inline-flex;
|
||||
font-size: var(--font-size-base);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.tz-chip:hover .tz-icon {
|
||||
color: var(--when-color-text);
|
||||
}
|
||||
|
||||
.tz-short {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.tz-full {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tz-short {
|
||||
display: inline;
|
||||
}
|
||||
}
|
||||
|
||||
.timeline-scroll {
|
||||
position: relative;
|
||||
max-height: 60vh;
|
||||
|
||||
@@ -2,19 +2,12 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
rows: number;
|
||||
showDuration?: boolean;
|
||||
}
|
||||
|
||||
let { rows, showDuration = false }: Props = $props();
|
||||
let { rows }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="skeleton" aria-hidden="true">
|
||||
<div class="header">
|
||||
<span class="timezone"></span>
|
||||
{#if showDuration}
|
||||
<span class="stepper-placeholder"></span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="scroll">
|
||||
<div class="track" style:height="{rows * 96}px">
|
||||
{#each Array.from({ length: rows }, (_, i) => i) as i (i)}
|
||||
@@ -35,38 +28,6 @@
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--space-4) var(--space-7);
|
||||
background: var(--color-quiet-bg);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.header {
|
||||
padding: var(--space-3) var(--space-5);
|
||||
}
|
||||
}
|
||||
|
||||
.stepper-placeholder {
|
||||
width: 7rem;
|
||||
height: 38px;
|
||||
border-radius: var(--radius);
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.timezone {
|
||||
width: 5rem;
|
||||
height: 38px;
|
||||
border-radius: var(--radius);
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.scroll {
|
||||
position: relative;
|
||||
max-height: 60vh;
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
<script lang="ts">
|
||||
import IconCaretDown from 'virtual:icons/ph/caret-down';
|
||||
import { formatTzShort } from '$lib/datetime';
|
||||
import type { AppointmentFlow } from '$lib/appointmentFlow.svelte';
|
||||
import DurationDialog from './DurationDialog.svelte';
|
||||
import TimezoneDialog from './TimezoneDialog.svelte';
|
||||
|
||||
interface Props {
|
||||
flow: AppointmentFlow;
|
||||
timezoneReady: boolean;
|
||||
}
|
||||
|
||||
let { flow, timezoneReady }: Props = $props();
|
||||
let durationOpen = $state(false);
|
||||
let tzOpen = $state(false);
|
||||
let timezoneLabel = $derived(formatTzShort(flow.userTz));
|
||||
</script>
|
||||
|
||||
<div class="picker-controls" role="group" aria-label="Booking settings">
|
||||
{#if flow.durations.length > 1}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (durationOpen = true)}
|
||||
aria-label="Change duration, currently {flow.duration} minutes"
|
||||
aria-haspopup="dialog"
|
||||
>
|
||||
{flow.duration} min <IconCaretDown aria-hidden="true" />
|
||||
</button>
|
||||
{:else}
|
||||
<span class="fixed-duration">{flow.duration} min</span>
|
||||
{/if}
|
||||
|
||||
{#if timezoneReady}
|
||||
<button
|
||||
type="button"
|
||||
class="timezone"
|
||||
onclick={() => (tzOpen = true)}
|
||||
aria-label="Change timezone, currently {timezoneLabel}"
|
||||
aria-haspopup="dialog"
|
||||
>
|
||||
{timezoneLabel}
|
||||
<IconCaretDown aria-hidden="true" />
|
||||
</button>
|
||||
{:else}
|
||||
<span class="timezone-placeholder" aria-hidden="true"></span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<DurationDialog
|
||||
bind:open={durationOpen}
|
||||
durations={flow.durations}
|
||||
value={flow.duration}
|
||||
onSelect={flow.setDuration}
|
||||
/>
|
||||
<TimezoneDialog bind:open={tzOpen} />
|
||||
|
||||
<style>
|
||||
.picker-controls {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-2) var(--space-5);
|
||||
border-bottom: calc(var(--space-1) / 2) solid var(--color-border);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
button,
|
||||
.fixed-duration {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
min-height: calc(var(--space-8) + var(--space-4));
|
||||
padding: 0 var(--space-3);
|
||||
}
|
||||
|
||||
button {
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: var(--radius);
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background: var(--color-surface-active);
|
||||
color: var(--when-color-text);
|
||||
}
|
||||
|
||||
button:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: var(--shadow-focus);
|
||||
}
|
||||
|
||||
.timezone,
|
||||
.timezone-placeholder {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.timezone-placeholder {
|
||||
width: var(--space-10);
|
||||
height: var(--space-6);
|
||||
border-radius: var(--radius);
|
||||
background: var(--color-quiet-bg);
|
||||
}
|
||||
</style>
|
||||
@@ -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]
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user