Phase 5
Phase 5 is complete. All 53 tasks are done, verified on a fresh database in a single clean run: **121 unit tests**, **55 integration tests**, and **15 E2E journeys** (6 Phase 4 + 9 new Phase 5) all passing, plus a clean `tsc`/`eslint`/`build`. Summary of this final stretch: - **Fixed a real bug**: `StaffService`'s break/time-off update/delete functions threw uncaught instead of failing gracefully on a permission mismatch — fixed with proper `PERMISSION_DENIED` handling, covered by new integration tests. - **Built the remaining Phase 5 UI**: Breaks editor, Time Off editor, Barber Profile, and Notification Preferences — all wired to real services, no placeholders. - **Extended seed data**: more customers, loyalty accounts/transactions/rewards, and appointments spanning every status today (anchored to real time so the "Current Appointment" priority logic is reliably exercised). - **Found and fixed a second real bug live in the browser**: the Break/Time-Off edit sheets showed stale default values instead of the item being edited (a classic `useState`-doesn't-reinitialize-on-prop-change bug) — fixed with a `key` prop to force remount. - **Wrote the full Phase 5 test suite**: unit tests for `selectCurrentAppointment` and all `StaffService` validators, integration tests for `LoyaltyService` and `StaffService` (including the permission-fix regression tests), and 9 new Playwright E2E journeys covering the brief's exact required list. - **Did the desktop-responsive pass**: a persistent sidebar nav replacing the bottom bar at `lg:`, a genuine calendar+"Right Now" side-by-side split, and 2-column layouts for the clients list and customer details — addressing the brief's explicit "more than simple centering" requirement. - **Wrote [docs/phase-5-summary.md](docs/phase-5-summary.md)** documenting everything above in full. Nothing is committed — the whole session (Phases 2–5) sits uncommitted on top of the initial template commit, same as you left it. Let me know if you'd like me to commit, or if you want to move on to Phase 6.
This commit is contained in:
81 files changed
+8024
-113
No files matched your search
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { AppointmentStatus } from "@/generated/prisma/enums";
|
||||
import { canTransition, validateTransition } from "../appointment.service";
|
||||
import { canTransition, selectCurrentAppointment, validateTransition } from "../appointment.service";
|
||||
|
||||
const ALL_STATUSES = Object.values(AppointmentStatus);
|
||||
|
||||
@@ -65,3 +65,66 @@ describe("validateTransition", () => {
|
||||
expect(result.issues[0]?.code).toBe("INVALID_STATUS_TRANSITION");
|
||||
});
|
||||
});
|
||||
|
||||
describe("selectCurrentAppointment", () => {
|
||||
const now = new Date("2026-08-02T12:00:00.000Z");
|
||||
|
||||
interface Fixture {
|
||||
id: string;
|
||||
status: AppointmentStatus;
|
||||
startAt: Date;
|
||||
endAt: Date;
|
||||
}
|
||||
|
||||
function appt(id: string, status: AppointmentStatus, startOffsetMin: number, durationMin = 30): Fixture {
|
||||
const startAt = new Date(now.getTime() + startOffsetMin * 60_000);
|
||||
return { id, status, startAt, endAt: new Date(startAt.getTime() + durationMin * 60_000) };
|
||||
}
|
||||
|
||||
it("returns null for an empty list", () => {
|
||||
expect(selectCurrentAppointment([], now)).toBeNull();
|
||||
});
|
||||
|
||||
it("tier 1: prefers IN_PROGRESS over everything else", () => {
|
||||
const inProgress = appt("in-progress", AppointmentStatus.IN_PROGRESS, -10, 30);
|
||||
const checkedIn = appt("checked-in", AppointmentStatus.CHECKED_IN, 5, 30);
|
||||
const scheduledNow = appt("scheduled-now", AppointmentStatus.CONFIRMED, -5, 30);
|
||||
const result = selectCurrentAppointment([checkedIn, scheduledNow, inProgress], now);
|
||||
expect(result?.id).toBe("in-progress");
|
||||
});
|
||||
|
||||
it("tier 2: CHECKED_IN wins when nothing is IN_PROGRESS", () => {
|
||||
const checkedIn = appt("checked-in", AppointmentStatus.CHECKED_IN, 5, 30);
|
||||
const upcoming = appt("upcoming", AppointmentStatus.CONFIRMED, 120, 30);
|
||||
const result = selectCurrentAppointment([upcoming, checkedIn], now);
|
||||
expect(result?.id).toBe("checked-in");
|
||||
});
|
||||
|
||||
it("tier 3: a CONFIRMED appointment whose window contains now, but never checked in", () => {
|
||||
const scheduledNow = appt("scheduled-now", AppointmentStatus.CONFIRMED, -5, 30);
|
||||
const upcoming = appt("upcoming", AppointmentStatus.CONFIRMED, 120, 30);
|
||||
const result = selectCurrentAppointment([upcoming, scheduledNow], now);
|
||||
expect(result?.id).toBe("scheduled-now");
|
||||
});
|
||||
|
||||
it("tier 4: falls back to the next upcoming appointment today, earliest first", () => {
|
||||
const later = appt("later", AppointmentStatus.CONFIRMED, 180, 30);
|
||||
const sooner = appt("sooner", AppointmentStatus.PENDING, 60, 30);
|
||||
const result = selectCurrentAppointment([later, sooner], now);
|
||||
expect(result?.id).toBe("sooner");
|
||||
});
|
||||
|
||||
it("tier 5: null when every appointment is terminal or in the past", () => {
|
||||
const completed = appt("completed", AppointmentStatus.COMPLETED, -120, 30);
|
||||
const cancelled = appt("cancelled", AppointmentStatus.CANCELLED, 60, 30);
|
||||
const noShow = appt("no-show", AppointmentStatus.NO_SHOW, -60, 30);
|
||||
const result = selectCurrentAppointment([completed, cancelled, noShow], now);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("ignores CANCELLED and NO_SHOW rows even when their window contains now", () => {
|
||||
const cancelledNow = appt("cancelled-now", AppointmentStatus.CANCELLED, -5, 30);
|
||||
const result = selectCurrentAppointment([cancelledNow], now);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -233,6 +233,48 @@ describe("booking engine (integration)", () => {
|
||||
});
|
||||
expect(slots.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("a partial-day (Block Time) entry only blocks its own window, not the whole day", async () => {
|
||||
const { staffId } = await createTestStaff(business);
|
||||
await prisma.timeOff.create({
|
||||
data: {
|
||||
staffId,
|
||||
startDate: new Date(`${monday}T00:00:00.000Z`),
|
||||
endDate: new Date(`${monday}T00:00:00.000Z`),
|
||||
status: "APPROVED",
|
||||
reason: "Team meeting",
|
||||
startMinutes: 12 * 60,
|
||||
endMinutes: 13 * 60,
|
||||
},
|
||||
});
|
||||
|
||||
const { slots } = await getAvailableTimeSlots(prisma, {
|
||||
businessId: business.businessId,
|
||||
serviceIds: [business.haircutServiceId],
|
||||
date: monday,
|
||||
});
|
||||
// Slots outside the block still exist — this is not a whole-day block.
|
||||
expect(slots.length).toBeGreaterThan(0);
|
||||
const duringBlock = slots.some((s) => s.startMinutes >= 12 * 60 && s.startMinutes < 13 * 60);
|
||||
expect(duringBlock).toBe(false);
|
||||
|
||||
const { validation: insideBlock } = await validateBookingSlot(prisma, {
|
||||
businessId: business.businessId,
|
||||
serviceIds: [business.haircutServiceId],
|
||||
staffId,
|
||||
startAt: wallClockToInstant(monday, 12 * 60 + 15, business.timezone),
|
||||
});
|
||||
expect(insideBlock.valid).toBe(false);
|
||||
expect(insideBlock.issues.map((i) => i.code)).toContain("STAFF_ON_LEAVE");
|
||||
|
||||
const { validation: outsideBlock } = await validateBookingSlot(prisma, {
|
||||
businessId: business.businessId,
|
||||
serviceIds: [business.haircutServiceId],
|
||||
staffId,
|
||||
startAt: wallClockToInstant(monday, 14 * 60, business.timezone),
|
||||
});
|
||||
expect(outsideBlock.valid).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("multiple services", () => {
|
||||
|
||||
Reference in new issue
Block a user