fix(portal): send preferredTime as HH:MM:SS and format booking slot labels (GRO-2211) (#51)
CI / Test (push) Successful in 18s
CI / Lint & Typecheck (push) Successful in 25s
CI / Build & Push Docker Image (push) Successful in 12s
CI / Test (pull_request) Successful in 22s
CI / Lint & Typecheck (pull_request) Successful in 28s
CI / Build & Push Docker Image (pull_request) Successful in 12s

This commit was merged in pull request #51.
This commit is contained in:
2026-06-08 16:41:14 +00:00
parent 2b494c01f8
commit 0d52ddd9f0
2 changed files with 153 additions and 7 deletions
+113 -2
View File
@@ -1,6 +1,6 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react"; 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 = { const UPCOMING_APPT = {
id: "appt-1", id: "appt-1",
@@ -690,4 +690,115 @@ describe("RescheduleFlow dynamic time slots", () => {
expect(screen.getByText("1:00 PM")).toBeInTheDocument(); 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");
});
});
+40 -5
View File
@@ -110,6 +110,41 @@ export function parseTimeTo24Hour(time: string | null | undefined): string {
return `${hours24.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:00`; return `${hours24.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:00`;
} }
// A booking slot is the canonical UTC ISO instant returned by
// `/api/book/availability` (e.g. "2026-06-09T10:00:00.000Z" is the 10:00 UTC
// business slot — see api `src/lib/slots.ts`, which builds them with
// `setUTCHours`). Display label and submit payload both derive from the slot via
// these helpers so they never desync. Always format/extract in UTC: slots are
// generated as UTC business hours, so a browser-local conversion would mislabel
// the slot and diverge from the stored Postgres `time` column.
export function formatSlotLabel(slot: string): string {
const d = new Date(slot);
// Non-ISO input (e.g. an already-formatted "10:00 AM" label) — show as-is.
if (Number.isNaN(d.getTime())) return slot;
return new Intl.DateTimeFormat('en-US', {
hour: 'numeric',
minute: '2-digit',
hour12: true,
timeZone: 'UTC',
}).format(d);
}
// Extracts the UTC `HH:MM:SS` time component the api stores in the Postgres
// `time` column. The api inserts this verbatim, so a full ISO datetime here is
// an `invalid input syntax for type time` 500 (GRO-2211).
export function slotToTime(slot: string): string {
if (/^\d{2}:\d{2}:\d{2}$/.test(slot)) return slot; // already HH:MM:SS
const d = new Date(slot);
if (!Number.isNaN(d.getTime())) {
const hh = String(d.getUTCHours()).padStart(2, '0');
const mm = String(d.getUTCMinutes()).padStart(2, '0');
const ss = String(d.getUTCSeconds()).padStart(2, '0');
return `${hh}:${mm}:${ss}`;
}
// "10:00 AM"-style label fallback.
return parseTimeTo24Hour(slot);
}
export function isUpcoming(appt: Appointment): boolean { export function isUpcoming(appt: Appointment): boolean {
const now = new Date(); const now = new Date();
// Prefer the absolute ISO `startTime` from the API; fall back to the // Prefer the absolute ISO `startTime` from the API; fall back to the
@@ -860,7 +895,7 @@ interface BookingFlowProps {
sessionId: string | null; sessionId: string | null;
} }
function BookingFlow({ onClose, sessionId }: BookingFlowProps) { export function BookingFlow({ onClose, sessionId }: BookingFlowProps) {
const [step, setStep] = useState(1); const [step, setStep] = useState(1);
const [pets, setPets] = useState<Pet[]>([]); const [pets, setPets] = useState<Pet[]>([]);
const [services, setServices] = useState<Service[]>([]); const [services, setServices] = useState<Service[]>([]);
@@ -972,7 +1007,7 @@ function BookingFlow({ onClose, sessionId }: BookingFlowProps) {
addOnIds: selectedAddOns.map((s) => s.id), addOnIds: selectedAddOns.map((s) => s.id),
groomerId: selectedGroomer === 'first-available' ? null : selectedGroomer, groomerId: selectedGroomer === 'first-available' ? null : selectedGroomer,
preferredDate: selectedDate, preferredDate: selectedDate,
preferredTime: selectedTime, preferredTime: slotToTime(selectedTime),
notes: notes || undefined, notes: notes || undefined,
recurring: recurring || undefined, recurring: recurring || undefined,
}), }),
@@ -1035,7 +1070,7 @@ function BookingFlow({ onClose, sessionId }: BookingFlowProps) {
Appointment Requested! Appointment Requested!
</h3> </h3>
<p className="text-sm text-stone-500 mb-4"> <p className="text-sm text-stone-500 mb-4">
{selectedPet?.name} on {formatDate(selectedDate)} at {selectedTime} {selectedPet?.name} on {formatDate(selectedDate)} at {formatSlotLabel(selectedTime)}
</p> </p>
<button <button
onClick={onClose} onClick={onClose}
@@ -1255,7 +1290,7 @@ function BookingFlow({ onClose, sessionId }: BookingFlowProps) {
: 'border-stone-200 hover:border-stone-300' : 'border-stone-200 hover:border-stone-300'
}`} }`}
> >
{time} {formatSlotLabel(time)}
</button> </button>
))} ))}
</div> </div>
@@ -1325,7 +1360,7 @@ function BookingFlow({ onClose, sessionId }: BookingFlowProps) {
<div className="flex justify-between"> <div className="flex justify-between">
<span className="text-stone-500">Date & Time</span> <span className="text-stone-500">Date & Time</span>
<span className="font-medium"> <span className="font-medium">
{formatDate(selectedDate)} at {selectedTime} {formatDate(selectedDate)} at {formatSlotLabel(selectedTime)}
</span> </span>
</div> </div>
{recurring && ( {recurring && (