Files
Crussell/frontend/src/routes/book/canvas.md
T
popertots ad0ad253ad
Backend CI / Lint & vulns (push) Failing after 1m59s
Backend CI / Tests (push) Successful in 2m1s
Backend CI / Race detector (push) Failing after 4m0s
style: apply prettier formatting to frontend
2026-06-25 13:26:26 +01:00

11 KiB
Raw Blame History

Goal

Decompose +page.svelte into focused, testable components with clear data flow and minimal shared state.


Highlevel structure

/routes/book/+page.svelte
/lib/components/booking/
  BookingFlow.svelte
  StepIndicator.svelte
  ServiceSelector.svelte
  ServiceCard.svelte
  DatePicker.svelte
  TimeSlotPicker.svelte
  CustomerDetailsForm.svelte
  BookingSummary.svelte
  BookingActions.svelte
/lib/stores/booking.ts
/lib/types/booking.ts

State ownership (important)

Single source of truth: BookingFlow.svelte

  • currentStep
  • selectedServices
  • selectedDate
  • selectedTime
  • customerInfo
  • pricing / totals

Everything else receives props + dispatches events. No hidden global coupling. 🧠


Components

1. BookingFlow.svelte

Role: Orchestrator

  • Holds booking state
  • Validates transitions between steps
  • Calls API + handles toasts

Props: none Emits: none

This is the only component allowed to know everything.


2. StepIndicator.svelte

Role: Visual progress

Props

currentStep: number;
totalSteps: number;

Pure UI. Zero logic.


3. ServiceSelector.svelte

Role: Choose services

Props

services: Service[]
selected: Service[]

Emits

select(service);
deselect(service);

Internally renders multiple ServiceCards.


4. ServiceCard.svelte

Role: One service tile

Props

service: Service;
selected: boolean;

No awareness of booking steps or pricing totals.


5. DatePicker.svelte

Role: Calendar selection

Wraps your existing Calendar usage.

Props

date: CalendarDate | undefined;

Emits

change(date);

6. TimeSlotPicker.svelte

Role: Pick available time

Props

date: CalendarDate
selectedTime: string | null
availability: TimeSlot[]

Emits

select(time);

7. CustomerDetailsForm.svelte

Role: Collect user info

Props

value: CustomerInfo;

Emits

update(value);

No submit button here. Forms shouldnt decide flow control.


8. BookingSummary.svelte

Role: Readonly confirmation

Props

services: Service[]
date: CalendarDate
time: string
customer: CustomerInfo
total: number

Zero mutation. Snapshot only.


9. BookingActions.svelte

Role: Navigation + submit

Props

canBack: boolean;
canNext: boolean;
isSubmitting: boolean;

Emits

back();
next();
submit();

Shared types

Move all interfaces out of +page.svelte:

// lib/types/booking.ts
export interface Service { ... }
export interface CustomerInfo { ... }
export interface TimeSlot { ... }

// lib/stores/booking.ts
export const bookingStore = $state({ ... })

Use only if the flow must persist across routes or reloads. Otherwise keep it local.


Result

  • Smaller files
  • Predictable data flow
  • Each component explainable in one sentence
  • Easier testing and future changes

Entropy reduced. ✂️🧩


Step 1 — Extract the brain (no store yet)

We start by wrapping the existing logic, not rewriting it.

1. Create BookingFlow.svelte

Move all bookingrelated state and logic out of +page.svelte into this file.

<script lang="ts">
	import StepIndicator from './StepIndicator.svelte';
	import ServiceSelector from './ServiceSelector.svelte';
	import DatePicker from './DatePicker.svelte';
	import TimeSlotPicker from './TimeSlotPicker.svelte';
	import CustomerDetailsForm from './CustomerDetailsForm.svelte';
	import BookingSummary from './BookingSummary.svelte';
	import BookingActions from './BookingActions.svelte';

	import type { Service, CustomerInfo, TimeSlot } from '$lib/types/booking';

	// --- State (copied verbatim from +page.svelte) ---
	let currentStep = 1;
	let selectedServices: Service[] = [];
	let selectedDate;
	let selectedTime: string | null = null;
	let customerInfo: CustomerInfo = {
		/* unchanged */
	};

	// pricing, derived values, API calls stay here
</script>

<StepIndicator {currentStep} totalSteps={4} />

{#if currentStep === 1}
	<ServiceSelector
		{services}
		selected={selectedServices}
		on:select={(e) => selectedServices.push(e.detail)}
		on:deselect={(e) => (selectedServices = selectedServices.filter((s) => s.id !== e.detail.id))}
	/>
{:else if currentStep === 2}
	<DatePicker bind:date={selectedDate} />
	<TimeSlotPicker date={selectedDate} {availability} bind:selectedTime />
{:else if currentStep === 3}
	<CustomerDetailsForm bind:value={customerInfo} />
{:else if currentStep === 4}
	<BookingSummary
		services={selectedServices}
		date={selectedDate}
		time={selectedTime}
		customer={customerInfo}
		{total}
	/>
{/if}

<BookingActions
	canBack={currentStep > 1}
	canNext={currentStep < 4}
	on:back={() => currentStep--}
	on:next={() => currentStep++}
	on:submit={submitBooking}
/>

Nothing clever yet. This is a containerization step, not a refactor.


2. Reduce +page.svelte to glue

<script lang="ts">
	import BookingFlow from '$lib/components/booking/BookingFlow.svelte';
</script>

<BookingFlow />

At this point:

  • Behaviour is identical
  • No store introduced
  • You have a single, explicit "brain"

Step 2 — Extract ServiceCard (low risk, high reward)

This is the safest cut: pure UI, minimal state, zero flow control.


2.1 Identify the slice

In ServiceSelector, find the repeated markup that:

  • Displays service name / price / duration
  • Highlights selected state
  • Handles click / toggle

If it renders one service, it becomes a card.


2.2 Create ServiceCard.svelte

<script lang="ts">
	import type { Service } from '$lib/types/booking';
	import { createEventDispatcher } from 'svelte';

	export let service: Service;
	export let selected = false;

	const dispatch = createEventDispatcher();

	function toggle() {
		dispatch(selected ? 'deselect' : 'select', service);
	}
</script>

<button class:selected on:click={toggle}>
	<h3>{service.name}</h3>
	<p>{service.duration} min</p>
	<p>£{service.price}</p>
</button>

<style>
	button {
		/* existing styles */
	}
	.selected {
		/* existing selected styles */
	}
</style>

No booking logic. No totals. No step awareness.


2.3 Simplify ServiceSelector.svelte

<script lang="ts">
	import ServiceCard from './ServiceCard.svelte';
	import type { Service } from '$lib/types/booking';

	export let services: Service[] = [];
	export let selected: Service[] = [];
</script>

<div class="grid">
	{#each services as service (service.id)}
		<ServiceCard
			{service}
			selected={selected.some((s) => s.id === service.id)}
			on:select
			on:deselect
		/>
	{/each}
</div>

Selection state still lives above. This is critical.


Validation checkpoint

At this point:

  • ServiceCard is dumb
  • ServiceSelector coordinates cards
  • BookingFlow owns truth

If this feels boring, good. Boring code is stable code.


Step 3 — Extract CustomerDetailsForm (controlled input boundary)

This step removes a classic source of entropy: forms that secretly control flow.

The rule here is strict:

Forms collect data. Parents decide what to do with it.


3.1 Identify the form logic

In BookingFlow, locate:

  • Name / email / phone inputs
  • Validation messages
  • on:input handlers

Anything that mutates customerInfo belongs in the form except submission.


3.2 Create CustomerDetailsForm.svelte

<script lang="ts">
	import type { CustomerInfo } from '$lib/types/booking';
	import { createEventDispatcher } from 'svelte';

	export let value: CustomerInfo;

	const dispatch = createEventDispatcher();

	function update<K extends keyof CustomerInfo>(key: K, val: CustomerInfo[K]) {
		dispatch('update', { ...value, [key]: val });
	}
</script>

<div class="space-y-4">
	<input
		type="text"
		placeholder="Name"
		value={value.name}
		on:input={(e) => update('name', e.currentTarget.value)}
	/>

	<input
		type="email"
		placeholder="Email"
		value={value.email}
		on:input={(e) => update('email', e.currentTarget.value)}
	/>

	<input
		type="tel"
		placeholder="Phone"
		value={value.phone}
		on:input={(e) => update('phone', e.currentTarget.value)}
	/>
</div>

No submit button. No step logic. No API calls.


3.3 Wire it back into BookingFlow

Replace inline inputs with:

<CustomerDetailsForm value={customerInfo} on:update={(e) => (customerInfo = e.detail)} />

Validation still lives in BookingFlow:

  • Can we go to the next step?
  • Is submit enabled?

Validation checkpoint

You should now observe:

  • The form is reusable
  • BookingFlow got smaller
  • Step logic is easier to read

If you cant explain where a rule lives in one sentence, its in the wrong place.


Step 4 — Split DatePicker and TimeSlotPicker (explicit dependency)

This is the first step where ordering matters. Time is meaningless without a date. We make that dependency obvious and onedirectional.

Rule of the step:

Date flows down. Time flows up.


4.1 Extract DatePicker.svelte

This component selects only a date. No availability logic. No time awareness.

<script lang="ts">
	import { createEventDispatcher } from 'svelte';
	import type { CalendarDate } from '@internationalized/date';

	export let date: CalendarDate | undefined;

	const dispatch = createEventDispatcher();
</script>

<Calendar value={date} on:change={(e) => dispatch('change', e.detail)} />

It emits intent. Thats it.


4.2 Extract TimeSlotPicker.svelte

Time slots depend on inputs, never globals.

<script lang="ts">
	import { createEventDispatcher } from 'svelte';
	import type { TimeSlot } from '$lib/types/booking';

	export let date; // required
	export let availability: TimeSlot[] = [];
	export let selectedTime: string | null = null;

	const dispatch = createEventDispatcher();
</script>

{#if !date}
	<p class="text-muted">Select a date first</p>
{:else}
	<div class="grid">
		{#each availability as slot (slot.time)}
			<button
				class:selected={slot.time === selectedTime}
				disabled={!slot.available}
				on:click={() => dispatch('select', slot.time)}
			>
				{slot.time}
			</button>
		{/each}
	</div>
{/if}

No fetching. No step logic. Just rendering.


4.3 Wire in BookingFlow

Here is the complete and correct wiring, including guards and reset logic:

<DatePicker
	date={selectedDate}
	on:change={(e) => {
		const newDate = e.detail;
		selectedDate = newDate;

		// changing date invalidates time
		selectedTime = null;

		// fetch / recompute availability here
		loadAvailability(newDate);
	}}
/>

<TimeSlotPicker
	date={selectedDate}
	{availability}
	{selectedTime}
	on:select={(e) => {
		selectedTime = e.detail;
	}}
/>

All temporal logic lives here. Children stay honest.