holidays feature
CI / ci (push) Canceled after 0s

The owner page

Where: Manage → Business → Holidays, at /owner/settings/holidays. It needs the same permission as Business Details.
Adding: you enter a name, a start date and an optional end date. Leaving the end empty gives a single day.
Upcoming and past: upcoming holidays are listed by date, with a trash icon to remove each one. Past ones sit in a collapsed “Past” list.
Existing bookings: if appointments are already booked on those dates, you see them and confirm with “Add Anyway”, the same pattern as Opening Hours. Nothing is cancelled automatically.

What it does to booking

Customer calendar: holiday dates are greyed out, for every staff member and every service.
Time slots: no slots are offered on those dates.
Direct bookings: staff and owner bookings and reschedules onto a holiday are refused, with a message naming the holiday. This is deliberate, but if you want owners to be able to override it, that’s a small change.
This commit is contained in:
Stefan Lazic committed 2026-10-17 12:23:53 +01:00
1 parent 31c48296d9
commit 9f981173f4
13 files changed
+914 -3

No files matched your search

@@ -0,0 +1,186 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { Weekday } from "@/generated/prisma/enums";
import { addDays, todayIn, wallClockToInstant, weekdayOf } from "@/lib/time/business-time";
import type { CalendarDate } from "@/lib/time/business-time";
import {
getAvailableDates,
getAvailableTimeSlots,
validateBookingSlot,
} from "@/services/scheduling/availability.service";
import {
cleanupTestBusiness,
createTestAppointment,
createTestBusiness,
createTestCustomer,
createTestStaff,
prisma,
} from "@/services/scheduling/__tests__/fixtures";
import type { TestBusiness } from "@/services/scheduling/__tests__/fixtures";
import {
createBusinessHoliday,
deleteBusinessHoliday,
findHolidayConflicts,
getClosedDates,
listBusinessHolidays,
} from "../holiday.service";
function nextWeekday(timezone: string, target: Weekday, minDaysAhead = 7): CalendarDate {
let date = addDays(todayIn(timezone), minDaysAhead);
for (let i = 0; i < 8; i++) {
if (weekdayOf(date) === target) return date;
date = addDays(date, 1);
}
throw new Error("unreachable");
}
describe("business holidays (integration)", () => {
let business: TestBusiness;
let monday: CalendarDate;
let tuesday: CalendarDate;
let wednesday: CalendarDate;
beforeEach(async () => {
business = await createTestBusiness();
monday = nextWeekday(business.timezone, Weekday.MONDAY);
tuesday = addDays(monday, 1);
wednesday = addDays(monday, 2);
});
afterEach(async () => {
await cleanupTestBusiness(business);
});
const addHoliday = (name: string, startDate: CalendarDate, endDate: CalendarDate) =>
createBusinessHoliday(prisma, {
businessId: business.businessId,
holiday: { name, startDate, endDate },
});
it("stores and lists holidays as inclusive calendar dates", async () => {
await addHoliday("Refit", monday, tuesday);
expect(await listBusinessHolidays(prisma, business.businessId)).toEqual([
expect.objectContaining({ name: "Refit", startDate: monday, endDate: tuesday }),
]);
});
it("closes every date in a range, but not the days around it", async () => {
await createTestStaff(business);
await addHoliday("Refit", monday, tuesday);
const { dates } = await getAvailableDates(prisma, {
businessId: business.businessId,
serviceIds: [business.haircutServiceId],
fromDate: monday,
toDate: wednesday,
});
expect(dates).not.toContain(monday);
expect(dates).not.toContain(tuesday);
expect(dates).toContain(wednesday);
});
it("offers no time slots on a holiday", async () => {
await createTestStaff(business);
await addHoliday("Bank holiday", monday, monday);
const { slots, validation } = await getAvailableTimeSlots(prisma, {
businessId: business.businessId,
serviceIds: [business.haircutServiceId],
date: monday,
});
expect(validation.valid).toBe(true);
expect(slots).toEqual([]);
});
it("refuses to book a holiday directly, naming it", async () => {
const { staffId } = await createTestStaff(business);
await addHoliday("Bank holiday", monday, monday);
const result = await validateBookingSlot(prisma, {
businessId: business.businessId,
serviceIds: [business.haircutServiceId],
staffId,
startAt: wallClockToInstant(monday, 10 * 60, business.timezone),
});
expect(result.validation.valid).toBe(false);
expect(result.validation.issues.map((i) => i.code)).toContain("BUSINESS_HOLIDAY");
expect(result.validation.issues.find((i) => i.code === "BUSINESS_HOLIDAY")?.message).toContain(
"Bank holiday",
);
});
it("reopens the dates once the holiday is removed", async () => {
await createTestStaff(business);
const created = await addHoliday("Bank holiday", monday, monday);
if (!created.success) throw new Error("setup failed");
expect(
await deleteBusinessHoliday(prisma, {
businessId: business.businessId,
holidayId: created.id,
}),
).toBe(true);
const { dates } = await getAvailableDates(prisma, {
businessId: business.businessId,
serviceIds: [business.haircutServiceId],
fromDate: monday,
toDate: monday,
});
expect(dates).toContain(monday);
});
it("never deletes another business's holiday", async () => {
const created = await addHoliday("Bank holiday", monday, monday);
if (!created.success) throw new Error("setup failed");
expect(
await deleteBusinessHoliday(prisma, { businessId: "someone-else", holidayId: created.id }),
).toBe(false);
expect(await listBusinessHolidays(prisma, business.businessId)).toHaveLength(1);
});
it("only reports closed dates inside the window asked for", async () => {
await addHoliday("Long break", monday, addDays(monday, 10));
const closed = await getClosedDates(prisma, {
businessId: business.businessId,
fromDate: tuesday,
toDate: wednesday,
});
expect([...closed.keys()]).toEqual([tuesday, wednesday]);
expect(closed.get(tuesday)).toBe("Long break");
});
it("reports appointments already booked in the range, without cancelling them", async () => {
const { staffId } = await createTestStaff(business);
const { customerId } = await createTestCustomer(business);
const inRange = await createTestAppointment(business, {
customerId,
staffId,
startAt: wallClockToInstant(monday, 10 * 60, business.timezone),
});
await createTestAppointment(business, {
customerId,
staffId,
startAt: wallClockToInstant(addDays(monday, 5), 10 * 60, business.timezone),
});
const conflicts = await findHolidayConflicts(prisma, {
businessId: business.businessId,
businessTimezone: business.timezone,
startDate: monday,
endDate: tuesday,
});
expect(conflicts.map((c) => c.id)).toEqual([inRange.appointmentId]);
const stored = await prisma.appointment.findUniqueOrThrow({
where: { id: inRange.appointmentId },
});
expect(stored.status).not.toBe("CANCELLED");
});
});