Files
touchbase/test/availability.test.ts

457 lines
14 KiB
TypeScript
Raw Normal View History

2026-05-01 18:24:09 -04:00
import { describe, expect, test } from "vitest";
import {
findSlots,
inWorkingHours,
therapistAvailable,
type FindSlotsOptions,
type RoomState,
type ServiceLite,
type TherapistState,
type WorkingHoursEntry,
} from "@/lib/availability";
// ============================================================
// Fixture helpers
// ============================================================
const TZ = "America/New_York";
const D = (iso: string) => new Date(iso);
// Tue 2026-05-05 in the New York practice timezone:
// 10:00 EDT == 14:00 UTC (EDT = UTC-4)
// We'll use this date a lot in fixtures.
const TUE_LOCAL_10AM = D("2026-05-05T14:00:00Z"); // 2026-05-05 10:00 EDT
const TUE_LOCAL_11AM = D("2026-05-05T15:00:00Z");
const TUE_LOCAL_12PM = D("2026-05-05T16:00:00Z");
const TUE_LOCAL_5PM = D("2026-05-05T21:00:00Z");
const TUE_LOCAL_7PM = D("2026-05-05T23:00:00Z");
const TUE_LOCAL_9AM = D("2026-05-05T13:00:00Z"); // before opening
// Spring-forward 2026 in US Eastern: Mar 8 2026, local 02:00 EST → 03:00 EDT.
const SPRING_FORWARD_SUN_LOCAL_10AM = D("2026-03-08T14:00:00Z"); // 10:00 EDT after the jump
const SERVICE_60MIN: ServiceLite = {
id: "svc-60",
durationMin: 60,
bufferAfterMin: 15,
requiredTherapistTags: [],
requiredRoomTags: [],
};
const SERVICE_90MIN_PRENATAL: ServiceLite = {
id: "svc-90-prenatal",
durationMin: 90,
bufferAfterMin: 20,
requiredTherapistTags: ["prenatal-cert"],
requiredRoomTags: ["prenatal-table"],
};
const FULL_WEEK_10_TO_19: WorkingHoursEntry[] = [0, 1, 2, 3, 4, 5, 6].map((d) => ({
weekday: d,
startMin: 10 * 60,
endMin: 19 * 60,
}));
const TUE_THRU_SAT_10_TO_19: WorkingHoursEntry[] = [2, 3, 4, 5, 6].map((d) => ({
weekday: d,
startMin: 10 * 60,
endMin: 19 * 60,
}));
function baseTherapist(overrides: Partial<TherapistState> = {}): TherapistState {
return {
id: "t1",
active: true,
tags: new Set(["swedish"]),
serviceIds: new Set([SERVICE_60MIN.id]),
workingHours: TUE_THRU_SAT_10_TO_19,
overrides: [],
bookings: [],
...overrides,
};
}
function baseRoom(overrides: Partial<RoomState> = {}): RoomState {
return {
id: "r1",
active: true,
tags: new Set<string>(),
blocks: [],
bookings: [],
...overrides,
};
}
function baseOpts(overrides: Partial<FindSlotsOptions> = {}): FindSlotsOptions {
return {
service: SERVICE_60MIN,
from: TUE_LOCAL_10AM,
to: TUE_LOCAL_12PM,
therapists: [baseTherapist()],
rooms: [baseRoom()],
practiceTz: TZ,
slotGranularityMin: 15,
...overrides,
};
}
// ============================================================
// inWorkingHours — direct tests
// ============================================================
describe("inWorkingHours", () => {
test("slot inside Tuesday working hours is accepted", () => {
expect(
inWorkingHours(TUE_THRU_SAT_10_TO_19, TUE_LOCAL_10AM, TUE_LOCAL_11AM, TZ),
).toBe(true);
});
test("slot before opening is rejected", () => {
expect(
inWorkingHours(TUE_THRU_SAT_10_TO_19, TUE_LOCAL_9AM, TUE_LOCAL_10AM, TZ),
).toBe(false);
});
test("slot ending exactly at closing is accepted (endMin is inclusive of equality)", () => {
expect(
inWorkingHours(TUE_THRU_SAT_10_TO_19, TUE_LOCAL_5PM, TUE_LOCAL_7PM, TZ),
).toBe(true);
});
test("Monday slot rejected when Mon is not a working day", () => {
// 2026-05-04 is a Monday
const monLocal10am = D("2026-05-04T14:00:00Z");
const monLocal11am = D("2026-05-04T15:00:00Z");
expect(
inWorkingHours(TUE_THRU_SAT_10_TO_19, monLocal10am, monLocal11am, TZ),
).toBe(false);
});
test("DST spring-forward Sunday: 10:00 local works because we shift to EDT", () => {
// Even though the local clock skipped 02:0003:00 EST, 10:00 EDT is well after.
expect(
inWorkingHours(
FULL_WEEK_10_TO_19,
SPRING_FORWARD_SUN_LOCAL_10AM,
D("2026-03-08T15:00:00Z"), // 11:00 EDT
TZ,
),
).toBe(true);
});
test("effectiveFrom/effectiveTo bracket is enforced", () => {
const wh: WorkingHoursEntry[] = [{
weekday: 2,
startMin: 10 * 60,
endMin: 19 * 60,
effectiveFrom: D("2026-06-01T00:00:00Z"),
}];
// Slot before effectiveFrom should not match.
expect(
inWorkingHours(wh, TUE_LOCAL_10AM, TUE_LOCAL_11AM, TZ),
).toBe(false);
});
test("cross-midnight slot is rejected", () => {
// Hypothetical 11pm-1am slot would span two local days.
expect(
inWorkingHours(
FULL_WEEK_10_TO_19,
D("2026-05-06T03:00:00Z"), // 23:00 local Tuesday
D("2026-05-06T05:00:00Z"), // 01:00 local Wednesday
TZ,
),
).toBe(false);
});
});
// ============================================================
// therapistAvailable — direct tests
// ============================================================
describe("therapistAvailable", () => {
test("free therapist in working hours is available", () => {
const t = baseTherapist();
expect(therapistAvailable(t, TUE_LOCAL_10AM, TUE_LOCAL_11AM, TZ)).toBe(true);
});
test("booking overlap blocks the slot", () => {
const t = baseTherapist({
bookings: [{ startsAt: TUE_LOCAL_10AM, endsAt: TUE_LOCAL_11AM }],
});
expect(
therapistAvailable(
t,
D("2026-05-05T14:30:00Z"), // 10:30 EDT — overlaps 10:0011:00
D("2026-05-05T15:30:00Z"),
TZ,
),
).toBe(false);
});
test("BLOCK override during working hours blocks the slot", () => {
const t = baseTherapist({
overrides: [{
kind: "BLOCK",
startsAt: D("2026-05-05T14:30:00Z"),
endsAt: D("2026-05-05T15:30:00Z"),
}],
});
expect(therapistAvailable(t, TUE_LOCAL_10AM, TUE_LOCAL_11AM, TZ)).toBe(false);
});
test("EXTRA_HOURS opens a slot outside regular working hours", () => {
// Therapist's regular hours don't include Sunday; EXTRA_HOURS does.
const t = baseTherapist({
workingHours: TUE_THRU_SAT_10_TO_19, // no Sunday
overrides: [{
kind: "EXTRA_HOURS",
startsAt: D("2026-05-10T14:00:00Z"), // Sun 10:00 EDT
endsAt: D("2026-05-10T18:00:00Z"), // Sun 14:00 EDT
}],
});
expect(
therapistAvailable(
t,
D("2026-05-10T14:00:00Z"),
D("2026-05-10T15:00:00Z"),
TZ,
),
).toBe(true);
});
});
// ============================================================
// findSlots — integration
// ============================================================
describe("findSlots", () => {
test("baseline: 60-min service in a 2-hour window emits 5 slots at 15-min granularity", () => {
// Window 10:0012:00 local. Service is 60 min. Slots can start at
// 10:00, 10:15, 10:30, 10:45, 11:00 (last finishes at 12:00).
const slots = findSlots(baseOpts());
expect(slots).toHaveLength(5);
expect(slots[0].startsAt).toEqual(TUE_LOCAL_10AM);
expect(slots[4].startsAt).toEqual(TUE_LOCAL_11AM);
expect(slots[4].endsAt).toEqual(TUE_LOCAL_12PM);
});
test("empty therapists list yields no slots", () => {
expect(findSlots(baseOpts({ therapists: [] }))).toEqual([]);
});
test("empty rooms list yields no slots", () => {
expect(findSlots(baseOpts({ rooms: [] }))).toEqual([]);
});
test("therapist tag mismatch excludes therapist", () => {
// Service requires prenatal-cert, but the only room has the right tag.
const slots = findSlots(
baseOpts({
service: SERVICE_90MIN_PRENATAL,
// Window large enough for one 90-min slot
to: D("2026-05-05T15:30:00Z"),
therapists: [
baseTherapist({
tags: new Set(["swedish"]), // missing prenatal-cert
serviceIds: new Set([SERVICE_90MIN_PRENATAL.id]),
}),
],
rooms: [
baseRoom({ tags: new Set(["prenatal-table"]) }),
],
}),
);
expect(slots).toEqual([]);
});
test("room tag mismatch excludes room", () => {
const slots = findSlots(
baseOpts({
service: SERVICE_90MIN_PRENATAL,
to: D("2026-05-05T15:30:00Z"),
therapists: [
baseTherapist({
tags: new Set(["prenatal-cert"]),
serviceIds: new Set([SERVICE_90MIN_PRENATAL.id]),
}),
],
rooms: [
baseRoom({ tags: new Set<string>() }), // missing prenatal-table
],
}),
);
expect(slots).toEqual([]);
});
test("ServiceTherapist allowlist excludes therapists not opted in", () => {
const slots = findSlots(
baseOpts({
therapists: [
baseTherapist({ serviceIds: new Set(["some-other-service"]) }),
],
}),
);
expect(slots).toEqual([]);
});
test("inactive therapist excluded", () => {
expect(
findSlots(baseOpts({ therapists: [baseTherapist({ active: false })] })),
).toEqual([]);
});
test("inactive room excluded", () => {
expect(
findSlots(baseOpts({ rooms: [baseRoom({ active: false })] })),
).toEqual([]);
});
test("therapist on PTO via BLOCK override has no slots in PTO window", () => {
const slots = findSlots(
baseOpts({
therapists: [
baseTherapist({
overrides: [{
kind: "BLOCK",
startsAt: TUE_LOCAL_10AM,
endsAt: TUE_LOCAL_12PM,
}],
}),
],
}),
);
expect(slots).toEqual([]);
});
test("room buffer prevents back-to-back booking inside the buffer window", () => {
// Existing booking 10:0011:00 with 15-min buffer ⇒ room locked till 11:15.
// Window 10:0012:00 ⇒ next legal start is 11:15. With granularity 15 and
// duration 60, candidates 11:15 (end 12:15 out of window!), so 0 slots
// beyond the existing one. But the existing booking's slot itself isn't a
// candidate (the room is occupied). Expand window to 12:30 to test legal
// resumption at 11:15.
const slots = findSlots(
baseOpts({
to: D("2026-05-05T16:30:00Z"), // 12:30 EDT
rooms: [
baseRoom({
bookings: [{
startsAt: TUE_LOCAL_10AM,
endsAt: D("2026-05-05T15:15:00Z"), // = 11:00 + 15 min buffer
}],
}),
],
}),
);
// Legal slots: 11:15 → 12:15 (only one, since 11:30 → 12:30 also fits).
const starts = slots.map((s) => s.startsAt.toISOString());
expect(starts).toContain("2026-05-05T15:15:00.000Z"); // 11:15 EDT
expect(starts).toContain("2026-05-05T15:30:00.000Z"); // 11:30 EDT
// Should NOT include any time before 11:15 (room locked).
expect(
starts.filter((s) => s < "2026-05-05T15:15:00.000Z"),
).toEqual([]);
});
test("room block excludes that room during the block window", () => {
const slots = findSlots(
baseOpts({
rooms: [
baseRoom({
blocks: [{
startsAt: D("2026-05-05T14:30:00Z"), // 10:30 EDT
endsAt: D("2026-05-05T15:30:00Z"), // 11:30 EDT
}],
}),
],
}),
);
// 10:0011:00 slot? Block starts 10:30, so room buffer on slot 10:00 → 11:15
// overlaps the block — excluded.
const starts = slots.map((s) => s.startsAt.toISOString());
expect(starts).not.toContain("2026-05-05T14:00:00.000Z"); // 10:00
expect(starts).not.toContain("2026-05-05T14:15:00.000Z"); // 10:15
});
test("preferredTherapistId restricts results to that therapist", () => {
const t1 = baseTherapist({ id: "t1" });
const t2 = baseTherapist({ id: "t2" });
const slots = findSlots(
baseOpts({
therapists: [t1, t2],
preferredTherapistId: "t2",
}),
);
for (const s of slots) {
expect(s.candidateTherapistIds).toEqual(["t2"]);
}
expect(slots.length).toBeGreaterThan(0);
});
test("from-time not on grid is rounded up to the next granular boundary", () => {
const slots = findSlots(
baseOpts({
from: D("2026-05-05T14:07:00Z"), // 10:07 EDT — should round up to 10:15
to: D("2026-05-05T16:00:00Z"), // 12:00 EDT
}),
);
expect(slots[0].startsAt).toEqual(D("2026-05-05T14:15:00Z"));
});
test("out-of-working-hours window yields no slots", () => {
const slots = findSlots(
baseOpts({
from: D("2026-05-05T11:00:00Z"), // 7:00 EDT
to: D("2026-05-05T13:00:00Z"), // 9:00 EDT — entirely before open
}),
);
expect(slots).toEqual([]);
});
test("DST spring-forward Sunday: slot grid still produces clean slots after the jump", () => {
const slots = findSlots(
baseOpts({
from: D("2026-03-08T14:00:00Z"), // 10:00 EDT (after the jump)
to: D("2026-03-08T16:00:00Z"), // 12:00 EDT
therapists: [
baseTherapist({ workingHours: FULL_WEEK_10_TO_19 }),
],
}),
);
expect(slots.length).toBeGreaterThan(0);
// First slot should be exactly 10:00 EDT.
expect(slots[0].startsAt).toEqual(SPRING_FORWARD_SUN_LOCAL_10AM);
});
test("returns slots sorted by start time, ascending", () => {
const slots = findSlots(
baseOpts({
to: D("2026-05-05T17:00:00Z"), // 13:00 EDT — wider window
}),
);
for (let i = 1; i < slots.length; i++) {
expect(slots[i].startsAt.getTime()).toBeGreaterThan(
slots[i - 1].startsAt.getTime(),
);
}
});
test("two therapists, only one available at a given slot — slot still emitted with that one as candidate", () => {
const t1 = baseTherapist({
id: "t1",
bookings: [{
startsAt: TUE_LOCAL_10AM,
endsAt: TUE_LOCAL_11AM,
}],
});
const t2 = baseTherapist({ id: "t2" });
const slots = findSlots(baseOpts({ therapists: [t1, t2] }));
const tenAm = slots.find(
(s) => s.startsAt.toISOString() === "2026-05-05T14:00:00.000Z",
);
expect(tenAm).toBeDefined();
expect(tenAm!.candidateTherapistIds).toEqual(["t2"]);
});
});