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:
Stefan Lazic committed 2026-08-02 23:44:18 +01:00
1 parent 6ae145d187
commit ffee9d6754
81 files changed
+8024 -113

No files matched your search

+312 -1
View File
@@ -19,6 +19,11 @@ function daysFromNow(days: number, hour: number, minute = 0): Date {
return date;
}
/** For appointments that need to be genuinely "in progress" / "starting soon" right now, regardless of what time the seed happens to run — used to exercise Phase 5's Current Appointment selection tiers reliably. */
function minutesFromNow(minutes: number): Date {
return new Date(Date.now() + minutes * 60_000);
}
const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL });
const prisma = new PrismaClient({ adapter });
@@ -262,6 +267,18 @@ async function main() {
},
],
},
// A short recurring break, distinct from Marcus's lunch — exercises
// the Breaks editor with a second, differently-timed staff member.
breaks: {
create: [
{
label: "Coffee break",
startMinutes: 15 * 60,
endMinutes: 15 * 60 + 15,
days: { create: [{ dayOfWeek: Weekday.TUESDAY }, { dayOfWeek: Weekday.THURSDAY }] },
},
],
},
},
},
},
@@ -342,7 +359,48 @@ async function main() {
},
});
// A second registered customer, close to their loyalty reward threshold —
// exercises the "available reward" indicator across the clients list,
// customer details, and calendar.
const secondCustomerPasswordHash = await bcrypt.hash("password123", 12);
const secondCustomerUser = await prisma.user.upsert({
where: { email: "sam.osei@example.com" },
update: {},
create: {
email: "sam.osei@example.com",
name: "Sam Osei",
passwordHash: secondCustomerPasswordHash,
emailVerified: new Date(),
customerProfile: {
create: {
firstName: "Sam",
lastName: "Osei",
email: "sam.osei@example.com",
phone: "+44 7700 900789",
isGuest: false,
preferredBarberId: barberUser.staffProfile?.id,
},
},
},
include: { customerProfile: true },
});
// A second guest customer, for clients-list search/sort variety.
const secondGuestCustomer = await prisma.customerProfile.upsert({
where: { id: `${business.id}-guest-priya-kapoor` },
update: {},
create: {
id: `${business.id}-guest-priya-kapoor`,
firstName: "Priya",
lastName: "Kapoor",
email: "priya.kapoor@example.com",
phone: "+44 7700 900321",
isGuest: true,
},
});
const registeredCustomer = registeredCustomerUser.customerProfile;
const secondCustomer = secondCustomerUser.customerProfile;
if (registeredCustomer && ownerUser.staffProfile && barberUser.staffProfile) {
// Upcoming, confirmed — registered customer with the owner/barber.
const upcomingStart = daysFromNow(3, 11, 0);
@@ -410,12 +468,265 @@ async function main() {
});
}
// ── Phase 5: today's appointments across statuses ─────────────────────
// Spread across all three staff so signing in as any of them shows a
// populated Calendar/Current Appointment immediately. The IN_PROGRESS,
// CHECKED_IN, and "scheduled now" (CONFIRMED) rows below are anchored to
// the actual current time (minutesFromNow) rather than a fixed hour, so
// each one reliably exercises a different tier of
// AppointmentService.selectCurrentAppointment regardless of when the
// seed is run.
if (ownerUser.staffProfile && barberUser.staffProfile && secondBarberUser.staffProfile && secondCustomer) {
const marcus = ownerUser.staffProfile;
const leo = barberUser.staffProfile;
const jamie = secondBarberUser.staffProfile;
// Marcus: completed earlier today (tests loyalty stamp-on-completion).
const marcusCompletedStart = daysFromNow(0, 9, 0);
await prisma.appointment.upsert({
where: { id: `${business.id}-appt-today-completed` },
update: {},
create: {
id: `${business.id}-appt-today-completed`,
businessId: business.id,
customerId: registeredCustomer!.id,
staffId: marcus.id,
status: "COMPLETED",
startAt: marcusCompletedStart,
endAt: new Date(marcusCompletedStart.getTime() + haircut.durationMinutes * 60_000),
services: {
create: [{ serviceId: haircut.id, priceAtBooking: haircut.price, durationAtBookingMinutes: haircut.durationMinutes }],
},
},
});
// Marcus: no-show earlier today.
const marcusNoShowStart = daysFromNow(0, 9, 45);
await prisma.appointment.upsert({
where: { id: `${business.id}-appt-today-noshow` },
update: {},
create: {
id: `${business.id}-appt-today-noshow`,
businessId: business.id,
customerId: guestCustomer.id,
staffId: marcus.id,
status: "NO_SHOW",
startAt: marcusNoShowStart,
endAt: new Date(marcusNoShowStart.getTime() + skinfade.durationMinutes * 60_000),
services: {
create: [{ serviceId: skinfade.id, priceAtBooking: skinfade.price, durationAtBookingMinutes: skinfade.durationMinutes }],
},
},
});
// Marcus: IN_PROGRESS right now — Current Appointment tier 1.
await prisma.appointment.upsert({
where: { id: `${business.id}-appt-today-inprogress` },
update: {},
create: {
id: `${business.id}-appt-today-inprogress`,
businessId: business.id,
customerId: secondCustomer.id,
staffId: marcus.id,
status: "IN_PROGRESS",
startAt: minutesFromNow(-10),
endAt: minutesFromNow(20),
notes: "Regular, wants it short on the sides.",
services: {
create: [{ serviceId: skinfade.id, priceAtBooking: skinfade.price, durationAtBookingMinutes: skinfade.durationMinutes }],
},
},
});
// Marcus: confirmed later this evening — also a natural fixture for
// testing the break/time-off "overlaps an appointment" conflict flow
// (try adding a break or blocking time from 16:30-17:30 today).
const marcusLaterStart = daysFromNow(0, 17, 0);
await prisma.appointment.upsert({
where: { id: `${business.id}-appt-today-confirmed` },
update: {},
create: {
id: `${business.id}-appt-today-confirmed`,
businessId: business.id,
customerId: secondGuestCustomer.id,
staffId: marcus.id,
status: "CONFIRMED",
startAt: marcusLaterStart,
endAt: new Date(marcusLaterStart.getTime() + haircut.durationMinutes * 60_000),
services: {
create: [{ serviceId: haircut.id, priceAtBooking: haircut.price, durationAtBookingMinutes: haircut.durationMinutes }],
},
},
});
// Leo: checked in, about to start — Current Appointment tier 2.
await prisma.appointment.upsert({
where: { id: `${business.id}-appt-today-checkedin` },
update: {},
create: {
id: `${business.id}-appt-today-checkedin`,
businessId: business.id,
// Deliberately not Alex Chen — she's the customer e2e suite's own
// "book again" fixture with Leo (see appt-completed above and
// e2e/customer-journeys.spec.ts), and doubling up on that exact
// customer/staff/service combo here would make her appointments
// list match two cards instead of one.
customerId: secondCustomer.id,
staffId: leo.id,
status: "CHECKED_IN",
startAt: minutesFromNow(5),
endAt: minutesFromNow(5 + haircut.durationMinutes),
services: {
create: [{ serviceId: haircut.id, priceAtBooking: haircut.price, durationAtBookingMinutes: haircut.durationMinutes }],
},
},
});
// Leo: pending confirmation later today.
const leoPendingStart = daysFromNow(0, 18, 0);
await prisma.appointment.upsert({
where: { id: `${business.id}-appt-today-pending` },
update: {},
create: {
id: `${business.id}-appt-today-pending`,
businessId: business.id,
customerId: secondCustomer.id,
staffId: leo.id,
status: "PENDING",
startAt: leoPendingStart,
endAt: new Date(leoPendingStart.getTime() + haircut.durationMinutes * 60_000),
services: {
create: [{ serviceId: haircut.id, priceAtBooking: haircut.price, durationAtBookingMinutes: haircut.durationMinutes }],
},
},
});
// Jamie: confirmed and already underway by the clock, but never
// checked in — Current Appointment tier 3 ("scheduled now").
await prisma.appointment.upsert({
where: { id: `${business.id}-appt-today-scheduled-now` },
update: {},
create: {
id: `${business.id}-appt-today-scheduled-now`,
businessId: business.id,
customerId: secondGuestCustomer.id,
staffId: jamie.id,
status: "CONFIRMED",
startAt: minutesFromNow(-5),
endAt: minutesFromNow(25),
services: {
create: [{ serviceId: skinfade.id, priceAtBooking: skinfade.price, durationAtBookingMinutes: skinfade.durationMinutes }],
},
},
});
// Jamie: cancelled earlier today, for status-variety in the calendar/agenda view.
const jamieCancelledStart = daysFromNow(0, 14, 0);
await prisma.appointment.upsert({
where: { id: `${business.id}-appt-today-cancelled` },
update: {},
create: {
id: `${business.id}-appt-today-cancelled`,
businessId: business.id,
customerId: guestCustomer.id,
staffId: jamie.id,
status: "CANCELLED",
startAt: jamieCancelledStart,
endAt: new Date(jamieCancelledStart.getTime() + beardTrim.durationMinutes * 60_000),
cancellationReason: "Customer rescheduled by phone",
cancelledAt: minutesFromNow(-90),
services: {
create: [{ serviceId: beardTrim.id, priceAtBooking: beardTrim.price, durationAtBookingMinutes: beardTrim.durationMinutes }],
},
},
});
// ── Loyalty: accounts, a stamp ledger, and one available reward ─────
const loyaltyProgramme = await prisma.loyaltyProgramme.findUniqueOrThrow({
where: { businessId: business.id },
});
if (registeredCustomer) {
const alexAccount = await prisma.loyaltyAccount.upsert({
where: { customerId_loyaltyProgrammeId: { customerId: registeredCustomer.id, loyaltyProgrammeId: loyaltyProgramme.id } },
update: {},
create: { customerId: registeredCustomer.id, loyaltyProgrammeId: loyaltyProgramme.id, currentStamps: 3 },
});
const alexStampCount = await prisma.loyaltyTransaction.count({ where: { loyaltyAccountId: alexAccount.id } });
if (alexStampCount === 0) {
await prisma.loyaltyTransaction.createMany({
data: [
{ loyaltyAccountId: alexAccount.id, type: "STAMP_ADDED", amount: 1, authoredByStaffId: leo.id, appointmentId: `${business.id}-appt-completed` },
{ loyaltyAccountId: alexAccount.id, type: "STAMP_ADDED", amount: 1, authoredByStaffId: marcus.id },
{ loyaltyAccountId: alexAccount.id, type: "STAMP_ADDED", amount: 1, authoredByStaffId: marcus.id },
],
});
}
}
// Sam: right at the reward threshold — exercises the "available reward" indicator end-to-end.
const samAccount = await prisma.loyaltyAccount.upsert({
where: { customerId_loyaltyProgrammeId: { customerId: secondCustomer.id, loyaltyProgrammeId: loyaltyProgramme.id } },
update: {},
create: { customerId: secondCustomer.id, loyaltyProgrammeId: loyaltyProgramme.id, currentStamps: loyaltyProgramme.stampsRequired },
});
const samStampCount = await prisma.loyaltyTransaction.count({ where: { loyaltyAccountId: samAccount.id } });
if (samStampCount === 0) {
await prisma.loyaltyTransaction.createMany({
data: Array.from({ length: loyaltyProgramme.stampsRequired }, () => ({
loyaltyAccountId: samAccount.id,
type: "STAMP_ADDED" as const,
amount: 1,
authoredByStaffId: leo.id,
})),
});
await prisma.loyaltyReward.create({
data: {
loyaltyAccountId: samAccount.id,
rewardType: loyaltyProgramme.rewardType,
value: loyaltyProgramme.rewardValue,
label: loyaltyProgramme.rewardLabel ?? "Reward",
status: "AVAILABLE",
},
});
}
// ── Customer notes — internal-only, never shown on customer routes ──
if (registeredCustomer) {
await prisma.customerNote.upsert({
where: { id: `${business.id}-note-alex-1` },
update: {},
create: {
id: `${business.id}-note-alex-1`,
customerId: registeredCustomer.id,
authorStaffId: marcus.id,
body: "Prefers scissor-over-comb, not clippers, on top. Sensitive skin around the neckline.",
},
});
}
await prisma.customerNote.upsert({
where: { id: `${business.id}-note-sam-1` },
update: {},
create: {
id: `${business.id}-note-sam-1`,
customerId: secondCustomer.id,
authorStaffId: leo.id,
body: "Always running a few minutes late — worth a courtesy reminder text.",
},
});
}
console.log("Seed complete:", {
business: business.name,
roles: [ownerRole.name, barberRole.name],
services: [haircut.name, skinfade.name, beardTrim.name],
staff: [ownerUser.name, barberUser.name, secondBarberUser.name],
customers: [registeredCustomerUser.name, `${guestCustomer.firstName} ${guestCustomer.lastName} (guest)`],
customers: [
registeredCustomerUser.name,
secondCustomerUser.name,
`${guestCustomer.firstName} ${guestCustomer.lastName} (guest)`,
`${secondGuestCustomer.firstName} ${secondGuestCustomer.lastName} (guest)`,
],
});
}