added availability

This commit is contained in:
2026-05-01 18:24:09 -04:00
parent ed7cae1acd
commit 036512f590
22 changed files with 8313 additions and 4 deletions

316
src/lib/availability.ts Normal file
View File

@@ -0,0 +1,316 @@
// Pure availability algorithm. No DB dependency — caller passes resource state in.
// See /Users/noise/Documents/obsidian/Massage/Initial.md §5 for design notes.
//
// All Date inputs are UTC-instant moments; timezone-sensitive logic happens only
// inside checkWorkingHours, where we convert to the practice's wall clock to
// compare against minutes-from-midnight working-hours rows.
import { addMinutes, isBefore } from "date-fns";
// ============================================================
// Types
// ============================================================
export type ServiceLite = {
id: string;
durationMin: number;
bufferAfterMin: number;
requiredTherapistTags: string[];
requiredRoomTags: string[];
};
export type WorkingHoursEntry = {
weekday: number; // 0=Sun .. 6=Sat (matches Date.getDay())
startMin: number; // minutes from midnight, practice-local wall clock
endMin: number; // exclusive upper bound
effectiveFrom?: Date | null; // UTC; inclusive
effectiveTo?: Date | null; // UTC; exclusive
};
export type AvailabilityOverrideEntry = {
startsAt: Date; // UTC
endsAt: Date; // UTC
kind: "BLOCK" | "EXTRA_HOURS";
};
export type BookingInterval = {
startsAt: Date; // UTC
endsAt: Date; // UTC; for room bookings include buffer (= roomReleasedAt)
};
export type TherapistState = {
id: string;
active: boolean;
tags: ReadonlySet<string>;
serviceIds: ReadonlySet<string>; // ServiceTherapist allowlist
workingHours: WorkingHoursEntry[];
overrides: AvailabilityOverrideEntry[];
bookings: BookingInterval[]; // only HOLD/CONFIRMED rows
};
export type RoomState = {
id: string;
active: boolean;
tags: ReadonlySet<string>;
blocks: BookingInterval[]; // RoomBlock rows
bookings: BookingInterval[]; // bookings extended to roomReleasedAt
};
export type FindSlotsOptions = {
service: ServiceLite;
from: Date; // UTC; inclusive lower bound
to: Date; // UTC; exclusive upper bound
therapists: TherapistState[];
rooms: RoomState[];
practiceTz: string; // IANA TZ, e.g. "America/New_York"
slotGranularityMin?: number; // default 15
preferredTherapistId?: string; // if set, only this therapist is considered
};
export type Slot = {
startsAt: Date; // UTC
endsAt: Date; // UTC
candidateTherapistIds: string[];
candidateRoomIds: string[];
};
// ============================================================
// Public entry point
// ============================================================
export function findSlots(opts: FindSlotsOptions): Slot[] {
const granularityMin = opts.slotGranularityMin ?? 15;
if (granularityMin <= 0 || !Number.isInteger(granularityMin)) {
throw new Error("slotGranularityMin must be a positive integer");
}
if (!isBefore(opts.from, opts.to)) return [];
// 1. Pre-filter resources by tag/eligibility (cheap, set-based)
const eligibleTherapists = opts.therapists.filter((t) => {
if (!t.active) return false;
if (!t.serviceIds.has(opts.service.id)) return false;
if (!hasAllTags(t.tags, opts.service.requiredTherapistTags)) return false;
if (opts.preferredTherapistId && t.id !== opts.preferredTherapistId) return false;
return true;
});
const eligibleRooms = opts.rooms.filter((r) => {
if (!r.active) return false;
if (!hasAllTags(r.tags, opts.service.requiredRoomTags)) return false;
return true;
});
if (eligibleTherapists.length === 0 || eligibleRooms.length === 0) return [];
// 2. Walk the slot grid in UTC. Step by granularity; emit when at least
// one therapist and one room are available for the candidate slot.
const slots: Slot[] = [];
const start = roundUpToGranularity(opts.from, granularityMin);
for (
let s = start;
isBefore(addMinutes(s, opts.service.durationMin - 1), opts.to);
s = addMinutes(s, granularityMin)
) {
const e = addMinutes(s, opts.service.durationMin);
const roomReleased = addMinutes(e, opts.service.bufferAfterMin);
const candidateTherapistIds: string[] = [];
for (const t of eligibleTherapists) {
if (therapistAvailable(t, s, e, opts.practiceTz)) {
candidateTherapistIds.push(t.id);
}
}
if (candidateTherapistIds.length === 0) continue;
const candidateRoomIds: string[] = [];
for (const r of eligibleRooms) {
if (roomAvailable(r, s, roomReleased)) {
candidateRoomIds.push(r.id);
}
}
if (candidateRoomIds.length === 0) continue;
slots.push({ startsAt: s, endsAt: e, candidateTherapistIds, candidateRoomIds });
}
return slots;
}
// ============================================================
// Per-resource predicates (exported for direct testing)
// ============================================================
export function therapistAvailable(
t: TherapistState,
startsAt: Date,
endsAt: Date,
practiceTz: string,
): boolean {
// Bookings — any overlap kills the slot.
for (const b of t.bookings) {
if (intervalsOverlap(b.startsAt, b.endsAt, startsAt, endsAt)) return false;
}
// BLOCK overrides — any overlap kills the slot.
for (const ov of t.overrides) {
if (
ov.kind === "BLOCK" &&
intervalsOverlap(ov.startsAt, ov.endsAt, startsAt, endsAt)
) {
return false;
}
}
// Slot must be either inside regular working hours OR inside an EXTRA_HOURS window.
const inExtraHours = t.overrides.some(
(ov) =>
ov.kind === "EXTRA_HOURS" &&
intervalContains(ov.startsAt, ov.endsAt, startsAt, endsAt),
);
if (inExtraHours) return true;
return inWorkingHours(t.workingHours, startsAt, endsAt, practiceTz);
}
export function roomAvailable(
r: RoomState,
startsAt: Date,
endsAtIncludingBuffer: Date,
): boolean {
for (const b of r.blocks) {
if (intervalsOverlap(b.startsAt, b.endsAt, startsAt, endsAtIncludingBuffer)) {
return false;
}
}
for (const b of r.bookings) {
if (intervalsOverlap(b.startsAt, b.endsAt, startsAt, endsAtIncludingBuffer)) {
return false;
}
}
return true;
}
// ============================================================
// Working-hours check (timezone-aware)
// ============================================================
export function inWorkingHours(
entries: WorkingHoursEntry[],
startsAt: Date,
endsAt: Date,
practiceTz: string,
): boolean {
const local = zonedParts(startsAt, practiceTz);
const localEnd = zonedParts(endsAt, practiceTz);
// Cross-midnight slots are not allowed — reject.
if (
local.year !== localEnd.year ||
local.month !== localEnd.month ||
local.day !== localEnd.day
) {
return false;
}
const weekday = local.weekday;
const startMin = local.hour * 60 + local.minute;
const endMin = localEnd.hour * 60 + localEnd.minute;
for (const w of entries) {
if (w.weekday !== weekday) continue;
if (w.startMin > startMin) continue;
if (endMin > w.endMin) continue;
if (w.effectiveFrom && startsAt < w.effectiveFrom) continue;
if (w.effectiveTo && startsAt >= w.effectiveTo) continue;
return true;
}
return false;
}
// ============================================================
// Helpers
// ============================================================
function hasAllTags(have: ReadonlySet<string>, need: readonly string[]): boolean {
for (const tag of need) if (!have.has(tag)) return false;
return true;
}
function intervalsOverlap(
aStart: Date,
aEnd: Date,
bStart: Date,
bEnd: Date,
): boolean {
// Half-open [) semantics, matching the booking exclusion constraints.
return aStart < bEnd && bStart < aEnd;
}
function intervalContains(
outerStart: Date,
outerEnd: Date,
innerStart: Date,
innerEnd: Date,
): boolean {
return outerStart <= innerStart && innerEnd <= outerEnd;
}
function roundUpToGranularity(d: Date, granularityMin: number): Date {
const ms = d.getTime();
const stepMs = granularityMin * 60_000;
const remainder = ms % stepMs;
if (remainder === 0) return d;
return new Date(ms + (stepMs - remainder));
}
// ============================================================
// Timezone — system-TZ-independent extraction via Intl
// ============================================================
const WEEKDAY_INDEX: Readonly<Record<string, number>> = {
Sun: 0, Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6,
};
const zonedFormatterCache = new Map<string, Intl.DateTimeFormat>();
function getZonedFormatter(tz: string): Intl.DateTimeFormat {
let f = zonedFormatterCache.get(tz);
if (!f) {
f = new Intl.DateTimeFormat("en-US", {
timeZone: tz,
year: "numeric",
month: "2-digit",
day: "2-digit",
weekday: "short",
hour: "2-digit",
minute: "2-digit",
hour12: false,
});
zonedFormatterCache.set(tz, f);
}
return f;
}
type ZonedParts = {
year: number;
month: number;
day: number;
weekday: number;
hour: number;
minute: number;
};
function zonedParts(date: Date, tz: string): ZonedParts {
const parts = getZonedFormatter(tz).formatToParts(date);
const m: Record<string, string> = {};
for (const p of parts) m[p.type] = p.value;
// hour12:false in en-US returns "24" for midnight; normalize.
let hour = Number(m.hour);
if (hour === 24) hour = 0;
return {
year: Number(m.year),
month: Number(m.month),
day: Number(m.day),
weekday: WEEKDAY_INDEX[m.weekday] ?? 0,
hour,
minute: Number(m.minute),
};
}

19
src/lib/db.ts Normal file
View File

@@ -0,0 +1,19 @@
import { PrismaPg } from "@prisma/adapter-pg";
import { PrismaClient } from "@/generated/prisma/client";
declare global {
var __prisma: PrismaClient | undefined;
}
function makeClient(): PrismaClient {
const adapter = new PrismaPg({
connectionString: process.env.DATABASE_URL,
});
return new PrismaClient({ adapter });
}
export const db = globalThis.__prisma ?? makeClient();
if (process.env.NODE_ENV !== "production") {
globalThis.__prisma = db;
}

270
src/lib/seed.ts Normal file
View File

@@ -0,0 +1,270 @@
import type { PrismaClient } from "@/generated/prisma/client";
// Tag vocabulary (informal — no controlled-vocab table v1).
export const TAGS = {
// Therapist qualifications
swedish: "swedish",
deepTissue: "deep-tissue",
prenatalCert: "prenatal-cert",
hotStoneCert: "hot-stone-cert",
lymphatic: "lymphatic",
// Room capabilities
prenatalTable: "prenatal-table",
hotStoneEquipped: "hot-stone-equipped",
couples: "couples",
wetRoom: "wet-room",
} as const;
type TherapistSeed = {
email: string;
name: string;
bio?: string;
tags: string[];
};
const THERAPISTS: TherapistSeed[] = [
{ email: "mei@touchbase.local", name: "Mei Tanaka", tags: [TAGS.swedish, TAGS.deepTissue, TAGS.lymphatic] },
{ email: "carlos@touchbase.local", name: "Carlos Rivera", tags: [TAGS.swedish, TAGS.deepTissue] },
{ email: "priya@touchbase.local", name: "Priya Patel", tags: [TAGS.swedish, TAGS.prenatalCert] },
{ email: "jordan@touchbase.local", name: "Jordan Lee", tags: [TAGS.swedish, TAGS.hotStoneCert] },
{ email: "aisha@touchbase.local", name: "Aisha Johnson", tags: [TAGS.swedish, TAGS.lymphatic, TAGS.prenatalCert] },
{ email: "rin@touchbase.local", name: "Rin Park", tags: [TAGS.swedish, TAGS.deepTissue, TAGS.hotStoneCert] },
{ email: "daniel@touchbase.local", name: "Daniel Costa", tags: [TAGS.swedish] },
{ email: "sara@touchbase.local", name: "Sara Goldberg", tags: [TAGS.swedish, TAGS.prenatalCert, TAGS.lymphatic] },
{ email: "emeka@touchbase.local", name: "Emeka Obi", tags: [TAGS.swedish, TAGS.deepTissue, TAGS.hotStoneCert] },
{ email: "marisol@touchbase.local", name: "Marisol Cruz", tags: [TAGS.swedish, TAGS.lymphatic] },
];
type RoomSeed = { name: string; tags: string[] };
const ROOMS: RoomSeed[] = [
{ name: "Room 1 — Sunset", tags: [] },
{ name: "Room 2 — Ocean", tags: [] },
{ name: "Room 3 — Cedar", tags: [TAGS.hotStoneEquipped] },
{ name: "Room 4 — Bamboo", tags: [TAGS.hotStoneEquipped] },
{ name: "Room 5 — Lotus", tags: [TAGS.prenatalTable] },
{ name: "Room 6 — Sage", tags: [TAGS.prenatalTable, TAGS.hotStoneEquipped] },
{ name: "Room 7 — Maple", tags: [TAGS.couples] },
{ name: "Room 8 — Willow", tags: [] },
{ name: "Room 9 — Birch", tags: [TAGS.wetRoom] },
{ name: "Room 10 — Quiet", tags: [] },
];
type ServiceSeed = {
name: string;
description: string;
durationMin: number;
bufferAfterMin: number;
priceCents: number;
depositCents: number;
requiredTherapistTags: string[];
requiredRoomTags: string[];
};
const SERVICES: ServiceSeed[] = [
{
name: "60-minute Swedish",
description: "Classic relaxation massage.",
durationMin: 60,
bufferAfterMin: 15,
priceCents: 9000,
depositCents: 2000,
requiredTherapistTags: [TAGS.swedish],
requiredRoomTags: [],
},
{
name: "90-minute Deep Tissue",
description: "Targeted work on chronic tension; firm pressure.",
durationMin: 90,
bufferAfterMin: 15,
priceCents: 13000,
depositCents: 3000,
requiredTherapistTags: [TAGS.deepTissue],
requiredRoomTags: [],
},
{
name: "75-minute Prenatal",
description: "Side-lying massage for expectant mothers; certified therapists only.",
durationMin: 75,
bufferAfterMin: 20,
priceCents: 11500,
depositCents: 2500,
requiredTherapistTags: [TAGS.prenatalCert],
requiredRoomTags: [TAGS.prenatalTable],
},
{
name: "90-minute Hot Stone",
description: "Heated basalt stones; equipment-dependent.",
durationMin: 90,
bufferAfterMin: 30,
priceCents: 13500,
depositCents: 3000,
requiredTherapistTags: [TAGS.hotStoneCert],
requiredRoomTags: [TAGS.hotStoneEquipped],
},
{
name: "60-minute Lymphatic Drainage",
description: "Light, rhythmic strokes to support lymphatic flow.",
durationMin: 60,
bufferAfterMin: 15,
priceCents: 10000,
depositCents: 2000,
requiredTherapistTags: [TAGS.lymphatic],
requiredRoomTags: [],
},
];
const CUSTOMERS = [
{ email: "alex@example.com", name: "Alex Park" },
{ email: "robin@example.com", name: "Robin Halloran" },
{ email: "sam@example.com", name: "Sam Beaumont" },
];
const ADMIN = { email: "admin@touchbase.local", name: "Admin User" };
// 0=Sun .. 6=Sat. Tue=2, Wed=3, ... Sat=6. Default schedule: Tue-Sat 10:00-19:00 local.
const DEFAULT_WORKDAYS = [2, 3, 4, 5, 6];
const DEFAULT_START_MIN = 10 * 60; // 10:00
const DEFAULT_END_MIN = 19 * 60; // 19:00
const TABLES_TO_WIPE = [
"AuditLog",
"Notification",
"Payment",
"Booking",
"AvailabilityOverride",
"WorkingHours",
"ServiceTherapist",
"Service",
"RoomBlock",
"RoomTag",
"Room",
"TherapistTag",
"Therapist",
"Customer",
"User",
];
export type SeedResult = {
admin: { id: string };
therapists: { id: string; name: string; tags: string[] }[];
rooms: { id: string; name: string; tags: string[] }[];
services: {
id: string;
name: string;
durationMin: number;
bufferAfterMin: number;
requiredTherapistTags: string[];
requiredRoomTags: string[];
}[];
customers: { id: string; name: string; email: string }[];
};
export async function wipe(db: PrismaClient): Promise<void> {
// Single statement; CASCADE not needed because we're listing all FK-referenced tables.
// RESTART IDENTITY is a no-op since we use cuid PKs but harmless to include.
const list = TABLES_TO_WIPE.map((t) => `"${t}"`).join(", ");
await db.$executeRawUnsafe(
`TRUNCATE TABLE ${list} RESTART IDENTITY CASCADE;`,
);
}
export async function seed(db: PrismaClient): Promise<SeedResult> {
await wipe(db);
// Admin
const admin = await db.user.create({
data: { email: ADMIN.email, name: ADMIN.name, role: "ADMIN" },
});
// Therapists
const therapistRows: SeedResult["therapists"] = [];
for (const t of THERAPISTS) {
const user = await db.user.create({
data: {
email: t.email,
name: t.name,
role: "THERAPIST",
therapist: {
create: {
bio: t.bio,
tags: { create: t.tags.map((tag) => ({ tag })) },
workingHours: {
create: DEFAULT_WORKDAYS.map((weekday) => ({
weekday,
startMin: DEFAULT_START_MIN,
endMin: DEFAULT_END_MIN,
})),
},
},
},
},
});
therapistRows.push({ id: user.id, name: t.name, tags: t.tags });
}
// Rooms
const roomRows: SeedResult["rooms"] = [];
for (const r of ROOMS) {
const room = await db.room.create({
data: {
name: r.name,
tags: { create: r.tags.map((tag) => ({ tag })) },
},
});
roomRows.push({ id: room.id, name: r.name, tags: r.tags });
}
// Services + ServiceTherapist allowlist (all qualified therapists are allowed)
const serviceRows: SeedResult["services"] = [];
for (const s of SERVICES) {
const eligible = therapistRows.filter((t) =>
s.requiredTherapistTags.every((tag) => t.tags.includes(tag)),
);
const service = await db.service.create({
data: {
name: s.name,
description: s.description,
durationMin: s.durationMin,
bufferAfterMin: s.bufferAfterMin,
priceCents: s.priceCents,
depositCents: s.depositCents,
requiredTherapistTags: s.requiredTherapistTags,
requiredRoomTags: s.requiredRoomTags,
therapists: {
create: eligible.map((t) => ({ therapistId: t.id })),
},
},
});
serviceRows.push({
id: service.id,
name: s.name,
durationMin: s.durationMin,
bufferAfterMin: s.bufferAfterMin,
requiredTherapistTags: s.requiredTherapistTags,
requiredRoomTags: s.requiredRoomTags,
});
}
// Customers
const customerRows: SeedResult["customers"] = [];
for (const c of CUSTOMERS) {
const user = await db.user.create({
data: {
email: c.email,
name: c.name,
role: "CUSTOMER",
customer: { create: {} },
},
});
customerRows.push({ id: user.id, name: c.name, email: c.email });
}
return {
admin: { id: admin.id },
therapists: therapistRows,
rooms: roomRows,
services: serviceRows,
customers: customerRows,
};
}