From 4bcc78f1e6189041a390bc3524d8b43b795094c2 Mon Sep 17 00:00:00 2001 From: Chris Farhood Date: Sat, 16 May 2026 15:53:56 +0000 Subject: [PATCH 01/10] feat: add pet size/coat to booking flow with buffer-aware availability - Add petSizeCategory and petCoatType dropdowns to booking wizard (after breed field, optional but encouraged) - Pass selected values to GET /availability as query params - large/x-large pets add service.defaultBufferMinutes to slot calculation and appointment end time (buffer never shown to client) - POST /appointments saves size/coat to pet record - Confirmation step shows total duration (service + buffer if applicable) Co-Authored-By: Paperclip --- apps/api/src/routes/book.ts | 26 ++++++++++++++++-- apps/web/src/pages/Book.tsx | 53 ++++++++++++++++++++++++++++++++----- packages/db/src/schema.ts | 12 +-------- packages/types/src/index.ts | 1 + 4 files changed, 73 insertions(+), 19 deletions(-) diff --git a/apps/api/src/routes/book.ts b/apps/api/src/routes/book.ts index 69f61e5..9a88a60 100644 --- a/apps/api/src/routes/book.ts +++ b/apps/api/src/routes/book.ts @@ -38,11 +38,13 @@ bookRouter.get("/services", async (c) => { // ─── GET /api/book/availability ───────────────────────────────────────────── // Public: return ISO startTime strings for slots where ≥1 groomer is free -// Query params: serviceId (uuid), date (YYYY-MM-DD) +// Query params: serviceId (uuid), date (YYYY-MM-DD), petSizeCategory, petCoatType bookRouter.get("/availability", async (c) => { const serviceId = c.req.query("serviceId"); const dateStr = c.req.query("date"); + const petSizeCategory = c.req.query("petSizeCategory") ?? undefined; + const petCoatType = c.req.query("petCoatType") ?? undefined; if (!serviceId || !dateStr) { return c.json({ error: "serviceId and date are required" }, 400); @@ -58,6 +60,12 @@ bookRouter.get("/availability", async (c) => { .where(and(eq(services.id, serviceId), eq(services.active, true))); if (!service) return c.json({ error: "Service not found" }, 404); + // Buffer-aware duration: extra time for large/x-large or complex coats + const extraBuffer = (petSizeCategory === "large" || petSizeCategory === "x-large") + ? (service.defaultBufferMinutes ?? 0) + : 0; + const durationMinutes = service.durationMinutes + extraBuffer; + const groomers = await db .select({ id: staff.id }) .from(staff) @@ -89,7 +97,7 @@ bookRouter.get("/availability", async (c) => { const slots = generateAvailableSlots({ dateStr, - durationMinutes: service.durationMinutes, + durationMinutes, groomerIds: groomers.map((g) => g.id), booked, }); @@ -112,6 +120,12 @@ const bookingSchema = z.object({ petName: z.string().min(1).max(200), petSpecies: z.string().min(1).max(100), petBreed: z.string().max(100).optional(), + petSizeCategory: z + .enum(["small", "medium", "large", "x-large"]) + .optional(), + petCoatType: z + .enum(["smooth", "double", "curly", "wire", "long", "hairless"]) + .optional(), notes: z.string().max(2000).optional(), }); @@ -191,11 +205,19 @@ bookRouter.post( name: body.petName, species: body.petSpecies, breed: body.petBreed ?? null, + sizeCategory: body.petSizeCategory ?? null, + coatType: body.petCoatType ?? null, }) .returning(); const pet = petInserted[0]; if (!pet) return c.json({ error: "Failed to create pet" }, 500); + // Buffer-aware end time: large/x-large pets add service bufferMinutes + const extraBuffer = (body.petSizeCategory === "large" || body.petSizeCategory === "x-large") + ? (service.defaultBufferMinutes ?? 0) + : 0; + const end = new Date(start.getTime() + (service.durationMinutes + extraBuffer) * 60_000); + // Insert appointment in a transaction to guard against race conditions let appointment; try { diff --git a/apps/web/src/pages/Book.tsx b/apps/web/src/pages/Book.tsx index dc58c9b..119d2de 100644 --- a/apps/web/src/pages/Book.tsx +++ b/apps/web/src/pages/Book.tsx @@ -13,6 +13,8 @@ interface BookingBody { petName: string; petSpecies: string; petBreed: string; + petSizeCategory: string; + petCoatType: string; notes: string; } @@ -123,6 +125,8 @@ export function BookPage() { petName: "", petSpecies: "", petBreed: "", + petSizeCategory: "", + petCoatType: "", notes: "", }); const [formError, setFormError] = useState(null); @@ -168,14 +172,18 @@ export function BookPage() { if (!selectedService || !date) return; setSlotsLoading(true); setSelectedSlot(null); - fetch( - `/api/book/availability?serviceId=${encodeURIComponent(selectedService.id)}&date=${encodeURIComponent(date)}` - ) + const params = new URLSearchParams({ + serviceId: selectedService.id, + date, + }); + if (form.petSizeCategory) params.set("petSizeCategory", form.petSizeCategory); + if (form.petCoatType) params.set("petCoatType", form.petCoatType); + fetch(`/api/book/availability?${params}`) .then((r) => r.json() as Promise) .then(setSlots) .catch(() => setSlots([])) .finally(() => setSlotsLoading(false)); - }, [selectedService, date]); + }, [selectedService, date, form.petSizeCategory, form.petCoatType]); function goToStep2(svc: Service) { setSelectedService(svc); @@ -214,6 +222,8 @@ export function BookPage() { petName: form.petName, petSpecies: form.petSpecies, petBreed: form.petBreed || undefined, + petSizeCategory: form.petSizeCategory || undefined, + petCoatType: form.petCoatType || undefined, notes: form.notes || undefined, }), }); @@ -494,6 +504,36 @@ export function BookPage() { placeholder="Golden Retriever" /> +
+ + +
+
+ + +