Skip to content

Commit 7ae97c3

Browse files
evangauerclaude
andcommitted
Add open-slot availability for the calendar
A real calendar suggests free times instead of making staff eyeball gaps. Adds a pure findOpenSlots engine (working window + slot length + busy intervals -> free slots; supports a finer step than the slot, never runs past day end, back-to-back aware) with 5 unit tests, exposed as appointments.availableSlots (by date, duration, optional doctor/room, working hours). Reusable by the portal booking flow and the agent. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent eed9608 commit 7ae97c3

3 files changed

Lines changed: 143 additions & 0 deletions

File tree

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { describe, it, expect } from "vitest";
2+
import { findOpenSlots } from "../availability";
3+
4+
const d = (iso: string) => new Date(iso);
5+
6+
describe("findOpenSlots", () => {
7+
it("fills an empty day with non-overlapping slots", () => {
8+
const slots = findOpenSlots({
9+
dayStart: d("2026-06-02T09:00:00Z"),
10+
dayEnd: d("2026-06-02T12:00:00Z"),
11+
slotMinutes: 30,
12+
busy: [],
13+
});
14+
expect(slots).toHaveLength(6); // 3 hours / 30 min
15+
expect(slots[0]!.start.toISOString()).toBe("2026-06-02T09:00:00.000Z");
16+
expect(slots[5]!.end.toISOString()).toBe("2026-06-02T12:00:00.000Z");
17+
});
18+
19+
it("removes slots that overlap a busy interval but keeps adjacent ones", () => {
20+
const slots = findOpenSlots({
21+
dayStart: d("2026-06-02T09:00:00Z"),
22+
dayEnd: d("2026-06-02T11:00:00Z"),
23+
slotMinutes: 30,
24+
busy: [{ startTime: d("2026-06-02T09:30:00Z"), endTime: d("2026-06-02T10:00:00Z") }],
25+
});
26+
const starts = slots.map((s) => s.start.toISOString());
27+
// 09:00 ok, 09:30 blocked, 10:00 ok (back-to-back), 10:30 ok
28+
expect(starts).toEqual([
29+
"2026-06-02T09:00:00.000Z",
30+
"2026-06-02T10:00:00.000Z",
31+
"2026-06-02T10:30:00.000Z",
32+
]);
33+
});
34+
35+
it("does not return a slot that would run past dayEnd", () => {
36+
const slots = findOpenSlots({
37+
dayStart: d("2026-06-02T09:00:00Z"),
38+
dayEnd: d("2026-06-02T09:45:00Z"),
39+
slotMinutes: 30,
40+
busy: [],
41+
});
42+
// Only 09:00-09:30 fits; 09:30-10:00 would exceed dayEnd.
43+
expect(slots).toHaveLength(1);
44+
});
45+
46+
it("supports a finer step than the slot length", () => {
47+
const slots = findOpenSlots({
48+
dayStart: d("2026-06-02T09:00:00Z"),
49+
dayEnd: d("2026-06-02T10:00:00Z"),
50+
slotMinutes: 30,
51+
stepMinutes: 15,
52+
busy: [],
53+
});
54+
// starts at :00, :15, :30 (last that still fits a 30-min slot before 10:00)
55+
expect(slots.map((s) => s.start.toISOString())).toEqual([
56+
"2026-06-02T09:00:00.000Z",
57+
"2026-06-02T09:15:00.000Z",
58+
"2026-06-02T09:30:00.000Z",
59+
]);
60+
});
61+
62+
it("throws on a non-positive slot length", () => {
63+
expect(() =>
64+
findOpenSlots({ dayStart: d("2026-06-02T09:00:00Z"), dayEnd: d("2026-06-02T10:00:00Z"), slotMinutes: 0, busy: [] })
65+
).toThrow(/positive/);
66+
});
67+
});
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { overlaps } from "./conflicts";
2+
3+
/**
4+
* Pure open-slot generation. Given a working window, a slot length, and the
5+
* busy intervals (existing appointments), produce the free slots a new
6+
* appointment of that length could occupy. No I/O.
7+
*/
8+
9+
export interface BusyInterval {
10+
startTime: Date;
11+
endTime: Date;
12+
}
13+
14+
export interface OpenSlot {
15+
start: Date;
16+
end: Date;
17+
}
18+
19+
export function findOpenSlots(opts: {
20+
dayStart: Date;
21+
dayEnd: Date;
22+
slotMinutes: number;
23+
/** Candidate start cadence; defaults to slotMinutes (non-overlapping slots). */
24+
stepMinutes?: number;
25+
busy: BusyInterval[];
26+
}): OpenSlot[] {
27+
const { dayStart, dayEnd, slotMinutes, busy } = opts;
28+
if (slotMinutes <= 0) throw new Error("slotMinutes must be positive.");
29+
const step = (opts.stepMinutes && opts.stepMinutes > 0 ? opts.stepMinutes : slotMinutes) * 60_000;
30+
const slotMs = slotMinutes * 60_000;
31+
32+
const slots: OpenSlot[] = [];
33+
for (let t = dayStart.getTime(); t + slotMs <= dayEnd.getTime(); t += step) {
34+
const start = new Date(t);
35+
const end = new Date(t + slotMs);
36+
const blocked = busy.some((b) => overlaps(start, end, b.startTime, b.endTime));
37+
if (!blocked) slots.push({ start, end });
38+
}
39+
return slots;
40+
}

apps/web/server/routers/appointments.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
hasConflict,
1919
type ExistingBooking,
2020
} from "@/lib/scheduling/conflicts";
21+
import { findOpenSlots } from "@/lib/scheduling/availability";
2122

2223
/** Fetch blocking appointments overlapping [start, end) for conflict checks. */
2324
async function fetchOverlapping(
@@ -296,6 +297,41 @@ export const appointmentsRouter = createRouter({
296297
return updated!;
297298
}),
298299

300+
/** Open slots on a given date for a doctor and/or room. */
301+
availableSlots: protectedProcedure
302+
.input(
303+
z.object({
304+
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
305+
durationMinutes: z.number().int().min(5).max(480).default(30),
306+
stepMinutes: z.number().int().min(5).max(240).optional(),
307+
doctorId: z.string().uuid().optional(),
308+
roomId: z.string().uuid().optional(),
309+
dayStartHour: z.number().int().min(0).max(23).default(8),
310+
dayEndHour: z.number().int().min(1).max(24).default(18),
311+
})
312+
)
313+
.query(async ({ ctx, input }) => {
314+
const dayStart = new Date(`${input.date}T${String(input.dayStartHour).padStart(2, "0")}:00:00`);
315+
const dayEnd = new Date(`${input.date}T${String(input.dayEndHour % 24 || 24).padStart(2, "0")}:00:00`);
316+
317+
const existing = await fetchOverlapping(ctx.db, ctx.practiceId, dayStart, dayEnd);
318+
// Only the chosen doctor/room blocks availability; if neither given, any
319+
// booking on the day blocks (treat the schedule as a single resource).
320+
const busy = existing.filter((b) => {
321+
if (input.doctorId && b.doctorId === input.doctorId) return true;
322+
if (input.roomId && b.roomId === input.roomId) return true;
323+
return !input.doctorId && !input.roomId;
324+
});
325+
326+
return findOpenSlots({
327+
dayStart,
328+
dayEnd,
329+
slotMinutes: input.durationMinutes,
330+
stepMinutes: input.stepMinutes,
331+
busy,
332+
});
333+
}),
334+
299335
listTypes: protectedProcedure.query(async ({ ctx }) => {
300336
return ctx.db
301337
.select()

0 commit comments

Comments
 (0)