Seed data extended with a second barber (different hours/services), a beard-trim service + category, breaks, time off, a guest customer, and appointments in all three key states — verified against a live throwaway Postgres.
14 new integration tests (38 total with Phase 3's) covering guest/authenticated booking, any-barber assignment, slot-conflict handling, ownership enforcement, guest-data isolation, and book-again repricing.
Playwright E2E suite for the 6 required customer journeys, with its own global-setup.ts that migrates and seeds a database automatically.
Full verification pass, which caught and fixed three real bugs along the way (not just test-workarounds):
A date off-by-one bug in the calendar picker that made real weekdays show as closed for anyone in a UTC+ timezone (including the business's own).
A dialog that could vanish mid-confirmation on cancellation, before the customer could read the late-cancellation notice.
A silent test-cleanup leak in the Phase 3 fixtures that had been leaking stray businesses since Phase 3.
Plus a baseline desktop-responsive fix (centered max-w-2xl columns) across Home/Appointments/Settings/booking flow, since desktop had been effectively unstyled.
Final state: 90 unit tests, 38 integration tests, and all 6 E2E journeys passing together on a fresh database; clean tsc, eslint, and next build. Full writeup with routes, security model, guest-matching rules, known limitations, and what to check before Phase 5 is in docs/phase-4-summary.md.
This commit is contained in:
Stefan Lazic committed 2026-08-02 21:39:24 +01:00
1 parent 8b63cc66a9
commit 6ae145d187
66 files changed
+4599 -107

No files matched your search

+21
View File
@@ -0,0 +1,21 @@
import { cache } from "react";
import { prisma } from "@/lib/db/prisma";
/**
* v1 is single-business (see docs/implementation-plan.md §11) — every
* page that needs "the business" fetches it through here rather than
* hardcoding a slug or re-querying independently. Wrapped in React's
* `cache()` so multiple Server Components in the same request (a layout
* and its page, for instance) share one query instead of issuing it
* once each.
*/
export const getPrimaryBusiness = cache(async () => {
return prisma.business.findFirstOrThrow({
include: {
locations: true,
hours: true,
settings: true,
bookingRule: true,
},
});
});
+45
View File
@@ -0,0 +1,45 @@
import { minutesFromMidnight, todayIn, weekdayOf } from "@/lib/time/business-time";
import { Weekday } from "@/generated/prisma/enums";
export interface OpeningStatus {
isOpen: boolean;
label: string;
}
interface DayHoursRow {
dayOfWeek: Weekday;
isClosed: boolean;
opensAtMinutes: number | null;
closesAtMinutes: number | null;
}
function formatMinutes(minutes: number): string {
const h = Math.floor(minutes / 60);
const m = minutes % 60;
return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`;
}
/**
* Purely presentational (read-only "open now?" label for the home page)
* — not part of the booking engine's own open/closed check, which
* AvailabilityService already performs independently as a real booking
* rule. This is display-only, so it lives here rather than in
* services/scheduling.
*/
export function computeOpeningStatus(hours: DayHoursRow[], timezone: string): OpeningStatus {
const today = todayIn(timezone);
const weekday = weekdayOf(today);
const todayHours = hours.find((h) => h.dayOfWeek === weekday);
if (!todayHours || todayHours.isClosed || todayHours.opensAtMinutes === null || todayHours.closesAtMinutes === null) {
return { isOpen: false, label: "Closed today" };
}
const nowMinutes = minutesFromMidnight(new Date(), timezone);
const isOpen = nowMinutes >= todayHours.opensAtMinutes && nowMinutes < todayHours.closesAtMinutes;
return {
isOpen,
label: isOpen ? `Open until ${formatMinutes(todayHours.closesAtMinutes)}` : `Closed · Opens ${formatMinutes(todayHours.opensAtMinutes)}`,
};
}