Booking Engine
Architecture: Six focused services with the BookingService/SchedulingService overlap resolved by giving SchedulingService exactly one job, the transactional double-booking guard , while BookingService owns the create/reschedule/cancel workflow around it. 59 unit tests, 24 integration tests
This commit is contained in:
28 files changed
+4853
-15
No files matched your search
@@ -0,0 +1,98 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { Weekday } from "@/generated/prisma/enums";
|
||||
import {
|
||||
addDays,
|
||||
calendarDateOf,
|
||||
compareCalendarDates,
|
||||
eachCalendarDate,
|
||||
isAfterCalendarDate,
|
||||
isBeforeCalendarDate,
|
||||
minutesFromMidnight,
|
||||
todayIn,
|
||||
wallClockToInstant,
|
||||
weekdayOf,
|
||||
} from "../business-time";
|
||||
|
||||
describe("weekdayOf", () => {
|
||||
it("maps calendar dates to the correct Weekday", () => {
|
||||
// 2026-08-03 is a Monday.
|
||||
expect(weekdayOf("2026-08-03")).toBe(Weekday.MONDAY);
|
||||
expect(weekdayOf("2026-08-04")).toBe(Weekday.TUESDAY);
|
||||
expect(weekdayOf("2026-08-08")).toBe(Weekday.SATURDAY);
|
||||
expect(weekdayOf("2026-08-09")).toBe(Weekday.SUNDAY);
|
||||
});
|
||||
});
|
||||
|
||||
describe("wallClockToInstant / minutesFromMidnight round-trip", () => {
|
||||
it("round-trips a plain wall-clock time in a fixed-offset zone", () => {
|
||||
const instant = wallClockToInstant("2026-01-15", 9 * 60 + 30, "Europe/London");
|
||||
expect(minutesFromMidnight(instant, "Europe/London")).toBe(9 * 60 + 30);
|
||||
expect(calendarDateOf(instant, "Europe/London")).toBe("2026-01-15");
|
||||
});
|
||||
|
||||
it("produces the documented UTC instant for a known GMT (winter) date", () => {
|
||||
// 2026-01-15 is outside BST — Europe/London is UTC+0.
|
||||
const instant = wallClockToInstant("2026-01-15", 10 * 60, "Europe/London");
|
||||
expect(instant.toISOString()).toBe("2026-01-15T10:00:00.000Z");
|
||||
});
|
||||
});
|
||||
|
||||
describe("DST correctness (Europe/London BST transition, last Sunday of March 2026)", () => {
|
||||
it("the same wall-clock time yields UTC instants shifted by the DST offset change", () => {
|
||||
// Before the 2026-03-29 transition: Europe/London is GMT (UTC+0).
|
||||
const beforeTransition = wallClockToInstant("2026-03-28", 10 * 60, "Europe/London");
|
||||
expect(beforeTransition.toISOString()).toBe("2026-03-28T10:00:00.000Z");
|
||||
|
||||
// After the transition: Europe/London is BST (UTC+1).
|
||||
const afterTransition = wallClockToInstant("2026-03-30", 10 * 60, "Europe/London");
|
||||
expect(afterTransition.toISOString()).toBe("2026-03-30T09:00:00.000Z");
|
||||
|
||||
// A naive "always UTC+0" implementation would put these exactly 48h
|
||||
// apart; DST-correct math puts them 47h apart because a wall-clock
|
||||
// hour was skipped.
|
||||
const diffHours = (afterTransition.getTime() - beforeTransition.getTime()) / 3_600_000;
|
||||
expect(diffHours).toBe(47);
|
||||
});
|
||||
|
||||
it("minutesFromMidnight recovers the same local wall-clock time on both sides of the transition", () => {
|
||||
const before = wallClockToInstant("2026-03-28", 10 * 60, "Europe/London");
|
||||
const after = wallClockToInstant("2026-03-30", 10 * 60, "Europe/London");
|
||||
expect(minutesFromMidnight(before, "Europe/London")).toBe(10 * 60);
|
||||
expect(minutesFromMidnight(after, "Europe/London")).toBe(10 * 60);
|
||||
});
|
||||
});
|
||||
|
||||
describe("calendar date arithmetic", () => {
|
||||
it("addDays is pure calendar-date math, independent of any zone", () => {
|
||||
expect(addDays("2026-02-27", 1)).toBe("2026-02-28");
|
||||
expect(addDays("2026-02-28", 1)).toBe("2026-03-01"); // 2026 is not a leap year
|
||||
expect(addDays("2026-12-31", 1)).toBe("2027-01-01");
|
||||
});
|
||||
|
||||
it("compares calendar dates correctly", () => {
|
||||
expect(compareCalendarDates("2026-01-01", "2026-01-02")).toBeLessThan(0);
|
||||
expect(compareCalendarDates("2026-01-02", "2026-01-01")).toBeGreaterThan(0);
|
||||
expect(compareCalendarDates("2026-01-01", "2026-01-01")).toBe(0);
|
||||
expect(isBeforeCalendarDate("2026-01-01", "2026-01-02")).toBe(true);
|
||||
expect(isAfterCalendarDate("2026-01-02", "2026-01-01")).toBe(true);
|
||||
});
|
||||
|
||||
it("eachCalendarDate yields an inclusive range and nothing when from > to", () => {
|
||||
expect([...eachCalendarDate("2026-01-01", "2026-01-03")]).toEqual([
|
||||
"2026-01-01",
|
||||
"2026-01-02",
|
||||
"2026-01-03",
|
||||
]);
|
||||
expect([...eachCalendarDate("2026-01-03", "2026-01-01")]).toEqual([]);
|
||||
});
|
||||
|
||||
it("eachCalendarDate guards against absurdly large ranges", () => {
|
||||
expect(() => [...eachCalendarDate("2026-01-01", "2030-01-01")]).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("todayIn", () => {
|
||||
it("returns a well-formed calendar date", () => {
|
||||
expect(todayIn("Europe/London")).toMatch(/^\d{4}-\d{2}-\d{2}$/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
import { DateTime } from "luxon";
|
||||
import { Weekday } from "@/generated/prisma/enums";
|
||||
|
||||
/**
|
||||
* All scheduling math (working hours, breaks, booking rules) happens in
|
||||
* two currencies: a `CalendarDate` ("2026-08-12", no time zone attached —
|
||||
* a calendar date is a calendar date regardless of zone) and
|
||||
* minutes-since-local-midnight (0–1439) for wall-clock times within a
|
||||
* day. Appointments themselves are stored as UTC `Date` instants (see
|
||||
* prisma/schema.prisma, Appointment.startAt/endAt). This module is the
|
||||
* ONLY place that converts between the two, via Luxon, so DST transitions
|
||||
* are handled correctly in exactly one place instead of scattered
|
||||
* `getHours()`/`setHours()` calls that silently assume the server's own
|
||||
* time zone. See docs/implementation-plan.md §14.
|
||||
*/
|
||||
|
||||
export type CalendarDate = string; // "YYYY-MM-DD"
|
||||
|
||||
const ISO_WEEKDAY_ORDER: Weekday[] = [
|
||||
Weekday.MONDAY,
|
||||
Weekday.TUESDAY,
|
||||
Weekday.WEDNESDAY,
|
||||
Weekday.THURSDAY,
|
||||
Weekday.FRIDAY,
|
||||
Weekday.SATURDAY,
|
||||
Weekday.SUNDAY,
|
||||
];
|
||||
|
||||
function requireISODate(dt: DateTime, context: string): string {
|
||||
const iso = dt.toISODate();
|
||||
if (!iso) throw new Error(`business-time: invalid date in ${context}`);
|
||||
return iso;
|
||||
}
|
||||
|
||||
/** Which Weekday a calendar date falls on, independent of time zone (a calendar date has no time-of-day ambiguity). */
|
||||
export function weekdayOf(date: CalendarDate): Weekday {
|
||||
const dt = DateTime.fromISO(date, { zone: "utc" });
|
||||
return ISO_WEEKDAY_ORDER[dt.weekday - 1]!;
|
||||
}
|
||||
|
||||
/** Minutes since local midnight (0–1439) for a UTC instant, as observed in `zone`. */
|
||||
export function minutesFromMidnight(instant: Date, zone: string): number {
|
||||
const dt = DateTime.fromJSDate(instant, { zone });
|
||||
return dt.hour * 60 + dt.minute;
|
||||
}
|
||||
|
||||
/** The business-local calendar date a UTC instant falls on. */
|
||||
export function calendarDateOf(instant: Date, zone: string): CalendarDate {
|
||||
return requireISODate(DateTime.fromJSDate(instant, { zone }), "calendarDateOf");
|
||||
}
|
||||
|
||||
/**
|
||||
* Combine a calendar date + minutes-from-midnight (a business-local wall
|
||||
* clock time) into the correct UTC instant. DST-safe: Luxon resolves the
|
||||
* wall clock against the zone's actual offset for that specific date, not
|
||||
* a fixed offset — the same "10:00" on either side of a DST transition
|
||||
* produces different UTC instants, correctly.
|
||||
*/
|
||||
export function wallClockToInstant(date: CalendarDate, minutesFromMidnightValue: number, zone: string): Date {
|
||||
const dt = DateTime.fromISO(date, { zone }).startOf("day").plus({ minutes: minutesFromMidnightValue });
|
||||
return dt.toJSDate();
|
||||
}
|
||||
|
||||
export function todayIn(zone: string): CalendarDate {
|
||||
return requireISODate(DateTime.now().setZone(zone), "todayIn");
|
||||
}
|
||||
|
||||
/** The UTC instant at which `date` begins in `zone` — the inclusive lower bound for "appointments that fall on this business day". */
|
||||
export function startOfBusinessDay(date: CalendarDate, zone: string): Date {
|
||||
return wallClockToInstant(date, 0, zone);
|
||||
}
|
||||
|
||||
/** The UTC instant at which `date` ends in `zone` (= the start of the next day) — the exclusive upper bound for "appointments that fall on this business day". */
|
||||
export function endOfBusinessDay(date: CalendarDate, zone: string): Date {
|
||||
return wallClockToInstant(date, 24 * 60, zone);
|
||||
}
|
||||
|
||||
/**
|
||||
* `TimeOff`/date-only columns (`@db.Date`) are compared as UTC midnight
|
||||
* regardless of business time zone — a day off is the same calendar day
|
||||
* everywhere, it isn't a wall-clock-in-a-zone concept the way appointment
|
||||
* times are. Always go through this helper when reading or writing a
|
||||
* `@db.Date` column so the convention stays consistent both directions.
|
||||
*/
|
||||
export function calendarDateToUtcMidnight(date: CalendarDate): Date {
|
||||
return new Date(`${date}T00:00:00.000Z`);
|
||||
}
|
||||
|
||||
/** Pure calendar-date arithmetic — deliberately zone-independent (adding whole days never crosses a DST boundary ambiguity the way wall-clock-to-instant conversion can). */
|
||||
export function addDays(date: CalendarDate, days: number): CalendarDate {
|
||||
return requireISODate(DateTime.fromISO(date, { zone: "utc" }).plus({ days }), "addDays");
|
||||
}
|
||||
|
||||
export function compareCalendarDates(a: CalendarDate, b: CalendarDate): number {
|
||||
return a < b ? -1 : a > b ? 1 : 0;
|
||||
}
|
||||
|
||||
export function isBeforeCalendarDate(a: CalendarDate, b: CalendarDate): boolean {
|
||||
return compareCalendarDates(a, b) < 0;
|
||||
}
|
||||
|
||||
export function isAfterCalendarDate(a: CalendarDate, b: CalendarDate): boolean {
|
||||
return compareCalendarDates(a, b) > 0;
|
||||
}
|
||||
|
||||
export function diffMinutes(a: Date, b: Date): number {
|
||||
return (a.getTime() - b.getTime()) / 60_000;
|
||||
}
|
||||
|
||||
/** Every CalendarDate from `from` to `to` inclusive. Throws if the range is absurdly large, as a guard against a caller accidentally passing unbounded input. */
|
||||
export function* eachCalendarDate(from: CalendarDate, to: CalendarDate): Generator<CalendarDate> {
|
||||
if (isAfterCalendarDate(from, to)) return;
|
||||
let cursor = from;
|
||||
let guard = 0;
|
||||
while (!isAfterCalendarDate(cursor, to)) {
|
||||
if (++guard > 366) throw new Error("eachCalendarDate: range exceeds 366 days");
|
||||
yield cursor;
|
||||
cursor = addDays(cursor, 1);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user