Files
touchbase/test/availability.test.ts
2026-05-01 18:24:09 -04:00

457 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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"]);
});
});