Booking Flow
Students select several free hours at once and confirm them in a single step. Cancelling and editing machines works the same way for reserved slots.
Laundry Planner is a shared weekly calendar for the washing machine and tumble dryer in my student residence. Students pick an hour, choose their room and which machines they need, and everyone sees the same schedule, so nobody walks down to a machine that's already taken.
It's a real tool, not an exercise: students have been booking with it since December 2025. A lot of the work after the first version went into problems that only show up with real users, such as messy input, small phone screens, keeping the data clean and two students trying to book the same hour.
The app is a single-page React frontend talking directly to a Supabase Postgres database. It works on desktop and phone, with touch-friendly controls such as bottom sheets and a scroll wheel.
Students select several free hours at once and confirm them in a single step. Cancelling and editing machines works the same way for reserved slots.
An iOS-style scroll wheel replaced a free-text room field, so every booking is stored in one format. It is built on native scroll-snap without any library.
A separate demo build swaps the database layer for a fake one in the browser, so visitors can try everything without touching the students' real bookings.
A panel lists your upcoming reservations, with back-to-back hours merged into one block. Each one can be cancelled from there or added to your calendar as an .ics file with a reminder.
The app re-checks the week before saving, and a unique index in Postgres has the final say. If two students book the same hour at once, the second one gets a clear message instead of a clash.
Notices replace blocking alerts, past hours are locked, a Today button jumps back to the current hour, and an open tab keeps itself up to date.
These snippets show the main decisions behind the project: a swappable data layer, a dependency-free scroll wheel, dialogs that read like native prompts, guarding against double bookings, and cleaning up real-world data.
All database access goes through five functions. The demo build points them at a localStorage version that fills each week with generated bookings the first time it is opened, so the UI never knows which one it is talking to.
import { IS_DEMO } from "../constants/env";
import * as live from "./liveBookings";
import * as demo from "./demoBookings";
const api = IS_DEMO ? demo : live;
export const fetchBookingsForWeek = api.fetchBookingsForWeek;
export const fetchUpcomingForRoom = api.fetchUpcomingForRoom;
export const createBookings = api.createBookings;
export const deleteBookingsByIds = api.deleteBookingsByIds;
export const updateMachinesByIds = api.updateMachinesByIds;
// demoBookings.js: seed a week the first time someone opens it
export async function fetchBookingsForWeek(weekKey) {
await delay();
const state = loadState();
if (!state.seededWeeks.includes(weekKey)) {
state.seededWeeks.push(weekKey);
state.bookings.push(...generateWeek(weekKey));
saveState(state);
}
const data = state.bookings.filter((b) => b.week_key === weekKey);
return { data, error: null };
}
The browser's scroll-snap handles momentum on phones. On each scroll frame the items are tilted straight in the DOM, and the selected value is only reported to React once scrolling settles.
// Tilt and fade items by their distance from the centre
const paint = () => {
frameRef.current = 0;
const el = listRef.current;
if (!el) return;
const center = el.scrollTop / ITEM_HEIGHT;
Array.from(el.children).forEach((child, i) => {
const distance = Math.max(-3, Math.min(3, i - center));
child.style.transform = `perspective(300px) rotateX(${distance * -22}deg)`;
child.style.opacity = String(Math.max(0.2, 1 - Math.abs(distance) * 0.32));
});
};
const handleScroll = () => {
if (!frameRef.current) frameRef.current = requestAnimationFrame(paint);
clearTimeout(settleTimerRef.current);
settleTimerRef.current = setTimeout(settle, SETTLE_DELAY);
};
const settle = () => {
const el = listRef.current;
if (!el || dragRef.current) return;
emit(Math.round(el.scrollTop / ITEM_HEIGHT));
};
Opening a bottom sheet returns a promise that resolves when the student taps OK or Cancel. The booking flow can simply await the answer, like window.confirm, but with a custom mobile UI.
const openConfirmSheet = ({ title, message }) => {
return new Promise((resolve) => {
sheetResolveRef.current = resolve;
setSheetKind("confirmCancel");
setSheetTitle(title);
setSheetMessage(message);
requestAnimationFrame(() => setSheetOpen(true));
});
};
// In the cancel flow
const ok = await openConfirmSheet({
title: `Cancel ${toDelete.length} reservation(s)?`,
message: "Tap OK to cancel all selected reserved slots.",
});
if (!ok) return;
const { error } = await deleteBookingsByIds(toDelete.map((b) => b.id));
Before saving, the app re-reads the week and skips hours someone just took, but that still leaves a split-second gap. A unique index on the hour closes it. When Postgres refuses an insert, the app fetches the week again, drops the taken hours and tries once more.
// Postgres: create unique index bookings_unique_slot
// on public.bookings (week_key, day_index, hour);
const UNIQUE_VIOLATION = "23505";
let { data, error } = await createBookings(toInsert);
// The insert is all-or-nothing: drop the hours someone just took and retry once
if (error?.code === UNIQUE_VIOLATION) {
const { data: fresh, error: refetchError } = await fetchBookingsForWeek(weekKey);
if (!refetchError) {
replaceBookings(fresh);
toInsert = buildRows(selected, room, machines, fresh);
({ data, error } = await createBookings(toInsert));
}
}
The old free-text field had stored the same room as 1.09, 1.9 and 109. After a backup, one migration rewrote every old row to the F.RR format the wheel picker now enforces.
update public.bookings set room =
case
-- "1.9" becomes "1.09"
when room ~ '^[0-3]\.[1-9]$'
then split_part(room, '.', 1) || '.0' || split_part(room, '.', 2)
-- "0005", "109" and "206" become "0.05", "1.09" and "2.06"
when room ~ '^[0-9]+$'
and lpad(ltrim(room, '0'), 3, '0') ~ '^[0-3](0[1-9]|1[0-2])$'
then left(lpad(ltrim(room, '0'), 3, '0'), 1) || '.'
|| right(lpad(ltrim(room, '0'), 3, '0'), 2)
else room
end
where room !~ '^[0-3]\.(0[1-9]|1[0-2])$';
Loading portfolio…