From a781b5354768b8df396b40858576b974ef758aa0 Mon Sep 17 00:00:00 2001 From: Flea Flicker Date: Tue, 9 Jun 2026 10:37:36 +0000 Subject: [PATCH] feat(GRO-2319): live-render full StatusBadge palette in portal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - StatusBadge normalizes underscore status keys (no_show → no-show) so a DB-sourced no_show appointment renders the styled "No-show" badge instead of a raw gray label (item 1). - deriveDisplayStatus derives a Pending badge for an upcoming appointment whose confirmationStatus is pending, and Waitlisted for synthetic waitlist entries (item 2, CMPO-approved on GRO-2326/GRO-2328). - AppointmentCard renders waitlist-backed entries with a muted dashed-border card + "You're on the waitlist…" subtext, and hides the confirm/notes/ reschedule/cancel actions (a waitlist entry is not a booked appointment). - Tests for normalizeStatusKey, deriveDisplayStatus, and the No-show badge. - UAT_PLAYBOOK.md §5.12f added for the now-live palette states. Co-Authored-By: Paperclip --- UAT_PLAYBOOK.md | 19 +++++++++ src/__tests__/Appointments.test.tsx | 48 +++++++++++++++++++++- src/portal/sections/Appointments.tsx | 60 ++++++++++++++++++++++++---- 3 files changed, 118 insertions(+), 9 deletions(-) diff --git a/UAT_PLAYBOOK.md b/UAT_PLAYBOOK.md index 4011e10..6cf326d 100644 --- a/UAT_PLAYBOOK.md +++ b/UAT_PLAYBOOK.md @@ -217,6 +217,25 @@ export const { signIn, signOut, useSession, changePassword } = authClient; | TC-WEB-5.12.16 | Badge status from data | Compare badge label to appointment.status field | Badge label matches the API appointment status exactly | | TC-WEB-5.12.17 | Unknown status fallback | Render badge with unknown status value | Badge renders with the raw status string as label and fallback CSS class | +#### 5.12f Live StatusBadge palette — no-show / pending / waitlisted (GRO-2319) + +These cases exercise the full StatusBadge palette as it is now produced live by +the seeded UAT customer (`uat-customer@groombook.dev`), not just unit-rendered. + +| # | Scenario | Steps | Expected | +|---|----------|-------|----------| +| TC-WEB-5.12.26 | No-show badge (item 1) | Sign in as `uat-customer@groombook.dev`, open `Appointments` → **Past** tab, find the seeded `no_show` appointment | A styled yellow **"No-show"** badge renders (`bg-yellow-100 text-yellow-700`) — **not** a raw gray `no_show` label. The DB `no_show` (underscore) status is normalized to the `no-show` palette key. | +| TC-WEB-5.12.27 | Pending derivation (item 2) | On the **Upcoming** tab, find the seeded upcoming appointment whose `confirmationStatus` is `pending` (groomer-unconfirmed) | The card's top-row badge reads amber **"Pending"** (derived from `confirmationStatus`), even though the raw appointment status is `scheduled`. | +| TC-WEB-5.12.28 | Confirmed not overridden | On the **Upcoming** tab, find the seeded confirmed appointment (`confirmationStatus = confirmed`) | Badge still reads green **"Confirmed"** — the pending derivation does not override a confirmed appointment. | +| TC-WEB-5.12.29 | Waitlisted card (item 2) | On the **Upcoming** tab, find the seeded waitlist entry for the customer | A card renders with a blue **"Waitlisted"** badge, a **dashed muted border**, and the subtext _"You're on the waitlist — we'll let you know if a spot opens."_ The Confirm / Reschedule / Cancel / Notes actions are **not** shown for this entry (it is not a booked appointment). | + +> **GRO-2319 note:** the DB `appointment_status` enum cannot represent `pending` +> or `waitlisted`, so those badges are derived in the portal: `pending` from an +> upcoming appointment's `confirmationStatus`, and `waitlisted` from active +> `waitlist_entries` surfaced by `GET /api/portal/appointments` as synthetic +> cards. The `no_show` → `no-show` key normalization fixes the cosmetic badge +> mismatch (item 1). + #### 5.12d Appointment API Shape Normalization (GRO-2180) | # | Scenario | Steps | Expected | diff --git a/src/__tests__/Appointments.test.tsx b/src/__tests__/Appointments.test.tsx index c878b46..6c09dcf 100644 --- a/src/__tests__/Appointments.test.tsx +++ b/src/__tests__/Appointments.test.tsx @@ -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, normalizeService, formatServicePrice, CustomerNotesSection, ConfirmationSection, StatusBadge, formatSlotLabel, slotToTime, BookingFlow } from "../portal/sections/Appointments.tsx"; +import { parseTimeTo24Hour, isUpcoming, normalizeAppointment, normalizeService, formatServicePrice, CustomerNotesSection, ConfirmationSection, StatusBadge, normalizeStatusKey, deriveDisplayStatus, formatSlotLabel, slotToTime, BookingFlow } from "../portal/sections/Appointments.tsx"; const UPCOMING_APPT = { id: "appt-1", @@ -517,6 +517,52 @@ describe("StatusBadge", () => { expect(badge?.className).toContain("bg-stone-100"); expect(badge?.className).toContain("text-stone-600"); }); + + // GRO-2319 item 1: DB stores `no_show` (underscore) but the palette key is + // `no-show` (hyphen) — without normalization it rendered raw gray text. + it("renders the styled No-show badge for DB `no_show` status", () => { + render(); + const badge = screen.getByText("No-show").closest('span'); + expect(badge?.className).toContain("bg-yellow-100"); + expect(badge?.className).toContain("text-yellow-700"); + }); +}); + +describe("normalizeStatusKey (GRO-2319 item 1)", () => { + it("maps underscore status keys to the hyphen palette key", () => { + expect(normalizeStatusKey("no_show")).toBe("no-show"); + }); + + it("leaves already-hyphenated / single-word keys unchanged", () => { + expect(normalizeStatusKey("no-show")).toBe("no-show"); + expect(normalizeStatusKey("confirmed")).toBe("confirmed"); + }); +}); + +describe("deriveDisplayStatus (GRO-2319 item 2)", () => { + it("derives Pending for an upcoming, unconfirmed appointment", () => { + expect( + deriveDisplayStatus({ ...UPCOMING_APPT, status: "scheduled", confirmationStatus: "pending" }), + ).toBe("pending"); + }); + + it("keeps the raw status when the appointment is confirmed", () => { + expect( + deriveDisplayStatus({ ...UPCOMING_APPT, status: "confirmed", confirmationStatus: "confirmed" }), + ).toBe("confirmed"); + }); + + it("does not derive Pending for a past appointment", () => { + expect( + deriveDisplayStatus({ ...PAST_APPT, status: "completed", confirmationStatus: "pending" }), + ).toBe("completed"); + }); + + it("always shows Waitlisted for a waitlist-backed entry", () => { + expect( + deriveDisplayStatus({ ...UPCOMING_APPT, status: "waitlisted", confirmationStatus: undefined }), + ).toBe("waitlisted"); + }); }); describe("RescheduleFlow dynamic time slots", () => { diff --git a/src/portal/sections/Appointments.tsx b/src/portal/sections/Appointments.tsx index bf55d90..a4bb5a6 100644 --- a/src/portal/sections/Appointments.tsx +++ b/src/portal/sections/Appointments.tsx @@ -315,9 +315,19 @@ const STATUS_LABELS: Record = { scheduled: 'Scheduled', }; +// The DB `appointment_status` enum stores `no_show` (underscore), but the badge +// palette is keyed on `no-show` (hyphen). Without normalization a no-show +// appointment renders as a raw gray `no_show` label instead of the styled +// "No-show" badge (GRO-2319 item 1). Map underscore status keys to the hyphen +// palette key so DB-sourced statuses resolve to their intended badge style. +export function normalizeStatusKey(status: string): string { + return status.replace(/_/g, '-'); +} + export function StatusBadge({ status }: { status: string }) { - const label = STATUS_LABELS[status] ?? status; - const colorClass = STATUS_COLORS[status] ?? 'bg-stone-100 text-stone-600'; + const key = normalizeStatusKey(status); + const label = STATUS_LABELS[key] ?? status; + const colorClass = STATUS_COLORS[key] ?? 'bg-stone-100 text-stone-600'; return ( {label} @@ -325,6 +335,19 @@ export function StatusBadge({ status }: { status: string }) { ); } +// Derives the badge state shown on an Upcoming/Past card from the appointment's +// raw status plus its confirmationStatus (GRO-2319 item 2, CMPO-approved): +// - a synthetic waitlist entry (status `waitlisted`) always shows Waitlisted +// - an upcoming appointment the groomer has not yet confirmed +// (`confirmationStatus === 'pending'`) shows Pending — semantically honest +// and reduces anxiety-driven follow-up messages +// - otherwise the raw status drives the badge +export function deriveDisplayStatus(appt: Appointment): string { + if (appt.status === 'waitlisted') return 'waitlisted'; + if (isUpcoming(appt) && appt.confirmationStatus === 'pending') return 'pending'; + return appt.status; +} + const CONFIRMATION_STATUS_COLORS: Record = { confirmed: 'bg-green-100 text-green-700', pending: 'bg-amber-100 text-amber-700', @@ -508,11 +531,24 @@ function AppointmentCard({ sessionId: string | null; onReschedule: (appt: Appointment) => void; }) { + // A waitlist-backed entry (GRO-2319 item 2, CMPO UX spec GRO-2328) is not a + // confirmed appointment: it gets a muted, dashed-border card and a subtext + // line so the customer can tell it apart from booked appointments, and the + // appointment-only actions (confirm / notes / reschedule / cancel) are hidden. + const isWaitlist = appt.status === 'waitlisted'; return ( -
+
- + {expanded ? ( ) : ( @@ -567,11 +608,14 @@ function AppointmentCard({ {appt.notes}

)} - {isUpcoming(appt) && !readOnly && ( + {!isWaitlist && isUpcoming(appt) && !readOnly && ( )} - {isUpcoming(appt) && } - {appt.status !== 'completed' && + {!isWaitlist && isUpcoming(appt) && ( + + )} + {!isWaitlist && + appt.status !== 'completed' && appt.status !== 'cancelled' && !readOnly && (
-- 2.52.0