Project Details

Laundry Planner

Live demo: book, cancel and edit anything, the data is fake and stays in your browser
Open full screen

Project information

  • Frontend: React 19 + Vite
  • Language: JavaScript
  • Backend: Supabase (Postgres)
  • Hosting: Vercel
  • Category: Web app / tool
  • In use: Since December 2025, 2,600+ bookings

Project overview

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.

What I Built

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.

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.

Room Wheel Picker

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.

Recruiter-Safe Demo

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.

My Reservations

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.

No Double Bookings

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.

Polish From Real Use

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.

Highlights

  • Weekly calendar with today and current-hour highlighting
  • Multi-slot booking and cancelling
  • Washer and/or dryer per booking
  • My reservations panel with one-tap cancel
  • Add to calendar (.ics) with a reminder
  • Database-level double-booking guard
  • Promise-based bottom sheets instead of browser prompts
  • Non-blocking notices instead of alerts
  • Past hours locked, Today button
  • Scroll-wheel picker for touch, mouse and keyboard
  • Dark mode
  • One-off SQL migration to clean up 1,000+ old rows
  • Demo build with generated, per-visitor data

Readable Code Excerpts

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.

Swappable Data Layer

One switch for live and demo 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.

Read service switch excerpt
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 };
}
Wheel Picker Component

Native scrolling, no re-renders

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.

Read WheelPicker excerpt
// 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));
};
Promise-Based Sheets

Dialogs that read like native prompts

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.

Read bottom sheet excerpt
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));
Double-Booking Guard

Letting the database have the final say

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.

Read double-booking excerpt
// 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));
  }
}
Data Cleanup

Fixing messy real-world input

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.

Read SQL migration excerpt
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…