fix(portal): send preferredTime as HH:MM:SS and format booking slot labels (GRO-2211)
The Book New wizard held raw ISO slot strings (e.g.
"2026-06-09T10:00:00.000Z") from /api/book/availability and rendered them
verbatim on the slot buttons, the Review "Date & Time" line, and the success
screen, and POSTed them straight as preferredTime. The api inserts that into
the Postgres `time` column, so a full ISO datetime is `invalid input syntax
for type time` → unhandled 500, breaking portal booking (GRO-2211).
- Add shared UTC helpers formatSlotLabel(slot) and slotToTime(slot) so the
display label and the submitted time derive from the same canonical UTC ISO
slot and never desync. Both format/extract in UTC (slots are UTC business
hours); guards tolerate already-formatted labels and HH:MM:SS values.
- BookNew: render formatSlotLabel(time) on slot buttons, the Review line, and
the confirmation; submit preferredTime: slotToTime(selectedTime) → HH:MM:SS.
- Export BookingFlow for testing.
- Tests: helper unit tests + a Book New funnel integration test asserting the
rendered slot label is "10:00 AM" (never raw ISO) and the waitlist POST body
preferredTime matches /^\d{2}:\d{2}:\d{2}$/.
GRO-2213
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { parseTimeTo24Hour, isUpcoming, normalizeAppointment, CustomerNotesSection, ConfirmationSection, StatusBadge } from "../portal/sections/Appointments.tsx";
|
||||
import { parseTimeTo24Hour, isUpcoming, normalizeAppointment, CustomerNotesSection, ConfirmationSection, StatusBadge, formatSlotLabel, slotToTime, BookingFlow } from "../portal/sections/Appointments.tsx";
|
||||
|
||||
const UPCOMING_APPT = {
|
||||
id: "appt-1",
|
||||
@@ -690,4 +690,115 @@ describe("RescheduleFlow dynamic time slots", () => {
|
||||
expect(screen.getByText("1:00 PM")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
describe("slot helpers (GRO-2213)", () => {
|
||||
it("formatSlotLabel formats a canonical UTC ISO slot to a UTC clock label", () => {
|
||||
expect(formatSlotLabel("2026-06-09T10:00:00.000Z")).toBe("10:00 AM");
|
||||
expect(formatSlotLabel("2026-06-09T14:30:00.000Z")).toBe("2:30 PM");
|
||||
expect(formatSlotLabel("2026-06-09T09:00:00.000Z")).toBe("9:00 AM");
|
||||
});
|
||||
|
||||
it("formatSlotLabel never echoes a raw ISO string", () => {
|
||||
expect(formatSlotLabel("2026-06-09T10:00:00.000Z")).not.toMatch(/\d{4}-\d{2}-\d{2}T/);
|
||||
});
|
||||
|
||||
it("formatSlotLabel passes through an already-formatted label unchanged", () => {
|
||||
expect(formatSlotLabel("10:00 AM")).toBe("10:00 AM");
|
||||
});
|
||||
|
||||
it("slotToTime extracts the UTC HH:MM:SS time component from an ISO slot", () => {
|
||||
expect(slotToTime("2026-06-09T10:00:00.000Z")).toBe("10:00:00");
|
||||
expect(slotToTime("2026-06-09T14:30:00.000Z")).toBe("14:30:00");
|
||||
expect(slotToTime("2026-06-09T10:00:00.000Z")).toMatch(/^\d{2}:\d{2}:\d{2}$/);
|
||||
});
|
||||
|
||||
it("slotToTime guards a value that is already HH:MM:SS", () => {
|
||||
expect(slotToTime("10:00:00")).toBe("10:00:00");
|
||||
});
|
||||
|
||||
it("slotToTime converts a 12-hour label fallback to HH:MM:SS", () => {
|
||||
expect(slotToTime("9:00 AM")).toBe("09:00:00");
|
||||
expect(slotToTime("2:30 PM")).toBe("14:30:00");
|
||||
});
|
||||
});
|
||||
|
||||
describe("BookingFlow Book New funnel (GRO-2213)", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
global.fetch = vi.fn();
|
||||
});
|
||||
|
||||
function routedFetch(captured: { waitlistBody?: Record<string, unknown> }) {
|
||||
return (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = typeof input === "string" ? input : input.toString();
|
||||
if (url.includes("/api/portal/pets")) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({ pets: [{ id: "pet-1", name: "Buddy", breed: "Lab" }] }),
|
||||
} as Response);
|
||||
}
|
||||
if (url.includes("/api/portal/services")) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
services: [{ id: "service-1", name: "Bath & Brush", isAddOn: false, duration: 60, price: 50 }],
|
||||
}),
|
||||
} as Response);
|
||||
}
|
||||
if (url.includes("/api/book/availability")) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ["2026-06-09T10:00:00.000Z", "2026-06-09T14:30:00.000Z"],
|
||||
} as Response);
|
||||
}
|
||||
if (url.includes("/api/portal/waitlist")) {
|
||||
captured.waitlistBody = JSON.parse((init?.body as string) ?? "{}");
|
||||
return Promise.resolve({ ok: true, json: async () => ({}) } as Response);
|
||||
}
|
||||
return Promise.resolve({ ok: true, json: async () => ({}) } as Response);
|
||||
};
|
||||
}
|
||||
|
||||
it("renders formatted slot labels (not raw ISO) and submits preferredTime as HH:MM:SS", async () => {
|
||||
const captured: { waitlistBody?: Record<string, unknown> } = {};
|
||||
vi.mocked(global.fetch).mockImplementation(routedFetch(captured) as typeof fetch);
|
||||
|
||||
render(<BookingFlow onClose={() => {}} sessionId="test-session-id" />);
|
||||
|
||||
// Step 1 — pick pet (auto-advances to step 2)
|
||||
await waitFor(() => expect(screen.getByText("Buddy")).toBeInTheDocument());
|
||||
fireEvent.click(screen.getByText("Buddy"));
|
||||
|
||||
// Step 2 — pick service, then Next
|
||||
await waitFor(() => expect(screen.getByText("Bath & Brush")).toBeInTheDocument());
|
||||
fireEvent.click(screen.getByText("Bath & Brush"));
|
||||
fireEvent.click(screen.getByRole("button", { name: /^Next$/ }));
|
||||
|
||||
// Step 3 — groomer, Next
|
||||
await waitFor(() => expect(screen.getByText("First Available")).toBeInTheDocument());
|
||||
fireEvent.click(screen.getByRole("button", { name: /^Next$/ }));
|
||||
|
||||
// Step 4 — date + slot
|
||||
await waitFor(() => expect(screen.getByLabelText(/date/i)).toBeInTheDocument());
|
||||
fireEvent.change(screen.getByLabelText(/date/i), { target: { value: "2026-06-09" } });
|
||||
|
||||
// Slot button shows the formatted UTC label, never the raw ISO
|
||||
await waitFor(() => expect(screen.getByText("10:00 AM")).toBeInTheDocument());
|
||||
expect(screen.queryByText(/2026-06-09T10:00:00/)).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByText("10:00 AM"));
|
||||
fireEvent.click(screen.getByRole("button", { name: /^Next$/ }));
|
||||
|
||||
// Step 5 — review shows the formatted label
|
||||
await waitFor(() => expect(screen.getByText(/Review & Confirm/i)).toBeInTheDocument());
|
||||
expect(screen.getByText(/10:00 AM/)).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /Confirm Booking/i }));
|
||||
|
||||
await waitFor(() => expect(captured.waitlistBody).toBeDefined());
|
||||
const body = captured.waitlistBody ?? {};
|
||||
expect(body.preferredTime).toMatch(/^\d{2}:\d{2}:\d{2}$/);
|
||||
expect(body.preferredTime).toBe("10:00:00");
|
||||
expect(body.preferredDate).toBe("2026-06-09");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user