diff --git a/__tests__/availability.test.ts b/__tests__/availability.test.ts new file mode 100644 index 0000000..e8ea734 --- /dev/null +++ b/__tests__/availability.test.ts @@ -0,0 +1,108 @@ +import { + availabilityForRange, + EMPTY_WEEKLY_HOURS, +} from "@/lib/availability"; +import type { CoordinatorWeeklyHours } from "@/lib/db/schema"; + +const mondayWeekly: CoordinatorWeeklyHours = { + ...EMPTY_WEEKLY_HOURS, + 1: [{ start: "09:00", end: "11:00", recurrence: "weekly" }], +}; + +describe("availabilityForRange", () => { + it("expands weekly hours onto matching weekdays", () => { + expect( + availabilityForRange({ + hours: mondayWeekly, + overrides: {}, + from: "2026-03-01", + to: "2026-03-07", + }), + ).toEqual([ + { + date: "2026-03-02", + start: "09:00", + end: "11:00", + source: "weekly", + }, + ]); + }); + + it("replaces weekly hours when an override exists for that date", () => { + expect( + availabilityForRange({ + hours: mondayWeekly, + overrides: { + "2026-03-02": [{ start: "13:00", end: "15:00" }], + }, + from: "2026-03-01", + to: "2026-03-07", + }), + ).toEqual([ + { + date: "2026-03-02", + start: "13:00", + end: "15:00", + source: "override", + }, + ]); + }); + + it("treats an empty override as a full day off", () => { + expect( + availabilityForRange({ + hours: mondayWeekly, + overrides: { "2026-03-02": [] }, + from: "2026-03-01", + to: "2026-03-07", + }), + ).toEqual([]); + }); + + it("skips biweekly windows on the off week", () => { + const hours: CoordinatorWeeklyHours = { + ...EMPTY_WEEKLY_HOURS, + 1: [ + { + start: "09:00", + end: "11:00", + recurrence: "biweekly", + anchorDate: "2026-03-02", + }, + ], + }; + + expect( + availabilityForRange({ + hours, + overrides: {}, + from: "2026-03-02", + to: "2026-03-16", + }), + ).toEqual([ + { + date: "2026-03-02", + start: "09:00", + end: "11:00", + source: "weekly", + }, + { + date: "2026-03-16", + start: "09:00", + end: "11:00", + source: "weekly", + }, + ]); + }); + + it("returns nothing when from is after to", () => { + expect( + availabilityForRange({ + hours: mondayWeekly, + overrides: {}, + from: "2026-03-07", + to: "2026-03-01", + }), + ).toEqual([]); + }); +}); diff --git a/__tests__/coordinator-availability-actions.test.ts b/__tests__/coordinator-availability-actions.test.ts new file mode 100644 index 0000000..ec41c34 --- /dev/null +++ b/__tests__/coordinator-availability-actions.test.ts @@ -0,0 +1,288 @@ +/** + * @jest-environment node + */ +import { + saveCoordinatorWeeklyHours, + setCoordinatorAvailabilityOverride, + clearCoordinatorAvailabilityOverride, + listCoordinatorAvailability, +} from "@/app/coaching/actions"; +import { EMPTY_WEEKLY_HOURS } from "@/lib/availability"; + +const COORDINATOR_ID = "11111111-1111-1111-1111-111111111111"; +const OTHER_COORDINATOR_ID = "22222222-2222-2222-2222-222222222222"; +const ADMIN_ID = "33333333-3333-3333-3333-333333333333"; + +const onConflictDoUpdate = jest.fn().mockResolvedValue(undefined); +const insertValues = jest.fn(() => ({ onConflictDoUpdate })); +const insert = jest.fn(() => ({ values: insertValues })) as jest.Mock; + +const deleteWhere = jest.fn().mockResolvedValue(undefined); +const deleteFn = jest.fn(() => ({ where: deleteWhere })) as jest.Mock; + +const selectLimit = jest.fn(); +let selectWhereResult: unknown[] = []; +const selectWhere = jest.fn(() => ({ + limit: selectLimit, + then( + onFulfilled?: (value: unknown) => unknown, + onRejected?: (reason: unknown) => unknown, + ) { + return Promise.resolve(selectWhereResult).then(onFulfilled, onRejected); + }, +})); +const selectFrom = jest.fn(() => ({ where: selectWhere })); +const select = jest.fn(() => ({ from: selectFrom })) as jest.Mock; + +jest.mock("@/lib/db", () => ({ + db: { + insert: (...args: unknown[]) => insert(...args), + select: (...args: unknown[]) => select(...args), + delete: (...args: unknown[]) => deleteFn(...args), + }, +})); + +const getUser = jest.fn(); +const getClaims = jest.fn(); +jest.mock("@/utils/supabase/server", () => ({ + createClient: async () => ({ auth: { getUser, getClaims } }), +})); + +const mondayHours = { + ...EMPTY_WEEKLY_HOURS, + 1: [{ start: "09:00", end: "11:00", recurrence: "weekly" as const }], +}; + +function asCoordinator(id = COORDINATOR_ID) { + getUser.mockResolvedValue({ data: { user: { id } } }); + getClaims.mockResolvedValue({ + data: { claims: { user_role: "coordinator" } }, + }); +} + +function asAdmin() { + getUser.mockResolvedValue({ data: { user: { id: ADMIN_ID } } }); + getClaims.mockResolvedValue({ + data: { claims: { user_role: "admin" } }, + }); +} + +beforeEach(() => { + jest.clearAllMocks(); + selectWhereResult = []; + selectLimit.mockResolvedValue([]); +}); + +describe("saveCoordinatorWeeklyHours", () => { + it("returns Unauthorized when not signed in", async () => { + getUser.mockResolvedValue({ data: { user: null } }); + getClaims.mockResolvedValue({ data: { claims: null } }); + + const result = await saveCoordinatorWeeklyHours({ + coordinatorId: COORDINATOR_ID, + hours: EMPTY_WEEKLY_HOURS, + }); + + expect(result).toEqual({ error: "Unauthorized" }); + expect(insert).not.toHaveBeenCalled(); + }); + + it("returns Unauthorized for a parent", async () => { + getUser.mockResolvedValue({ + data: { user: { id: COORDINATOR_ID } }, + }); + getClaims.mockResolvedValue({ + data: { claims: { user_role: "user" } }, + }); + + const result = await saveCoordinatorWeeklyHours({ + coordinatorId: COORDINATOR_ID, + hours: EMPTY_WEEKLY_HOURS, + }); + + expect(result).toEqual({ error: "Unauthorized" }); + expect(insert).not.toHaveBeenCalled(); + }); + + it("lets a coordinator save their own hours", async () => { + asCoordinator(); + + const result = await saveCoordinatorWeeklyHours({ + coordinatorId: COORDINATOR_ID, + hours: EMPTY_WEEKLY_HOURS, + }); + + expect(result).toEqual({ ok: true }); + expect(insertValues).toHaveBeenCalledWith( + expect.objectContaining({ + coordinatorId: COORDINATOR_ID, + timezone: "America/Toronto", + hours: EMPTY_WEEKLY_HOURS, + }), + ); + }); + + it("blocks a coordinator from saving another coordinator's hours", async () => { + asCoordinator(); + + const result = await saveCoordinatorWeeklyHours({ + coordinatorId: OTHER_COORDINATOR_ID, + hours: EMPTY_WEEKLY_HOURS, + }); + + expect(result).toEqual({ error: "Unauthorized" }); + expect(insert).not.toHaveBeenCalled(); + }); + + it("lets an admin save any coordinator's hours", async () => { + asAdmin(); + + const result = await saveCoordinatorWeeklyHours({ + coordinatorId: OTHER_COORDINATOR_ID, + hours: EMPTY_WEEKLY_HOURS, + }); + + expect(result).toEqual({ ok: true }); + expect(insertValues).toHaveBeenCalledWith( + expect.objectContaining({ coordinatorId: OTHER_COORDINATOR_ID }), + ); + }); + + it("rejects overlapping windows on the same day", async () => { + asCoordinator(); + + const result = await saveCoordinatorWeeklyHours({ + coordinatorId: COORDINATOR_ID, + hours: { + ...EMPTY_WEEKLY_HOURS, + 1: [ + { start: "10:00", end: "11:00", recurrence: "weekly" }, + { start: "10:30", end: "12:00", recurrence: "weekly" }, + ], + }, + }); + + expect(result).toEqual({ + error: "Windows on the same day must not overlap", + }); + expect(insert).not.toHaveBeenCalled(); + }); +}); + +describe("setCoordinatorAvailabilityOverride", () => { + it("upserts the date override", async () => { + asCoordinator(); + + const result = await setCoordinatorAvailabilityOverride({ + coordinatorId: COORDINATOR_ID, + date: "2026-03-02", + windows: [], + }); + + expect(result).toEqual({ ok: true }); + expect(insertValues).toHaveBeenCalledWith({ + coordinatorId: COORDINATOR_ID, + date: "2026-03-02", + windows: [], + }); + }); + + it("blocks a coordinator from setting another coordinator's override", async () => { + asCoordinator(); + + const result = await setCoordinatorAvailabilityOverride({ + coordinatorId: OTHER_COORDINATOR_ID, + date: "2026-03-02", + windows: [], + }); + + expect(result).toEqual({ error: "Unauthorized" }); + expect(insert).not.toHaveBeenCalled(); + }); +}); + +describe("clearCoordinatorAvailabilityOverride", () => { + it("deletes the override for that date", async () => { + asCoordinator(); + + const result = await clearCoordinatorAvailabilityOverride({ + coordinatorId: COORDINATOR_ID, + date: "2026-03-02", + }); + + expect(result).toEqual({ ok: true }); + expect(deleteFn).toHaveBeenCalled(); + expect(deleteWhere).toHaveBeenCalled(); + }); +}); + +describe("listCoordinatorAvailability", () => { + it("returns Unauthorized for a parent", async () => { + getUser.mockResolvedValue({ + data: { user: { id: COORDINATOR_ID } }, + }); + getClaims.mockResolvedValue({ + data: { claims: { user_role: "user" } }, + }); + + const result = await listCoordinatorAvailability({ + coordinatorId: COORDINATOR_ID, + from: "2026-03-01", + to: "2026-03-07", + }); + + expect(result).toEqual({ error: "Unauthorized" }); + expect(select).not.toHaveBeenCalled(); + }); + + it("expands weekly hours for the requested range", async () => { + asCoordinator(); + selectLimit.mockResolvedValue([{ hours: mondayHours }]); + + const result = await listCoordinatorAvailability({ + coordinatorId: COORDINATOR_ID, + from: "2026-03-01", + to: "2026-03-07", + }); + + expect(result).toEqual({ + occurrences: [ + { + date: "2026-03-02", + start: "09:00", + end: "11:00", + source: "weekly", + }, + ], + }); + }); + + it("lets an empty override hide weekly hours for that date", async () => { + asCoordinator(); + selectLimit.mockResolvedValue([{ hours: mondayHours }]); + selectWhereResult = [{ date: "2026-03-02", windows: [] }]; + + const result = await listCoordinatorAvailability({ + coordinatorId: COORDINATOR_ID, + from: "2026-03-01", + to: "2026-03-07", + }); + + expect(result).toEqual({ occurrences: [] }); + }); + + it("rejects a range longer than one year", async () => { + asCoordinator(); + + const result = await listCoordinatorAvailability({ + coordinatorId: COORDINATOR_ID, + from: "2026-01-01", + to: "2027-01-03", + }); + + expect(result).toEqual({ + error: "Range cannot be longer than one year", + }); + expect(select).not.toHaveBeenCalled(); + }); +}); diff --git a/app/coaching/actions.ts b/app/coaching/actions.ts index 623d11a..36466bc 100644 --- a/app/coaching/actions.ts +++ b/app/coaching/actions.ts @@ -1,9 +1,29 @@ "use server"; +import { + saveCoordinatorWeeklyHoursSchema, + setCoordinatorAvailabilityOverrideSchema, + clearCoordinatorAvailabilityOverrideSchema, + listCoordinatorAvailabilitySchema, + fetchCoordinatorAvailabilityEditorStateSchema, +} from "@/app/coaching/schema"; +import { + availabilityForRange, + EMPTY_WEEKLY_HOURS, + type AvailabilityOccurrence, +} from "@/lib/availability"; +import { ROLES } from "@/lib/roles"; -import { eq } from "drizzle-orm"; +import { and, eq, gte, lte } from "drizzle-orm"; import { db } from "@/lib/db"; -import { coachingSessions, services } from "@/lib/db/schema"; +import { + coachingSessions, + coordinatorAvailabilityHours, + coordinatorAvailabilityOverrides, + services, + type AvailabilityOverrideWindow, + type CoordinatorWeeklyHours, +} from "@/lib/db/schema"; import { createClient } from "@/utils/supabase/server"; export type Availability = { start: string; end: string }; @@ -52,3 +72,222 @@ export async function submitAvailabilities({ return { coachingSessionId: row.id }; } + +async function canEditCoordinatorAvailability( + coordinatorId: string, +): Promise { + const supabase = await createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + if (!user) return false; + + const { data } = await supabase.auth.getClaims(); + const role = data?.claims?.user_role; + + if (role === ROLES.ADMIN) return true; + if (role === ROLES.COORDINATOR && user.id === coordinatorId) return true; + return false; +} + +export type SaveCoordinatorWeeklyHoursResult = + | { ok: true } + | { error: string }; + +export async function saveCoordinatorWeeklyHours( + input: unknown, +): Promise { + const parsed = saveCoordinatorWeeklyHoursSchema.safeParse(input); + if (!parsed.success) { + return { + error: parsed.error.issues[0]?.message ?? "Invalid input", + }; + } + + const { coordinatorId, timezone, hours } = parsed.data; + if (!(await canEditCoordinatorAvailability(coordinatorId))) { + return { error: "Unauthorized" }; + } + + await db + .insert(coordinatorAvailabilityHours) + .values({ coordinatorId, timezone, hours }) + .onConflictDoUpdate({ + target: coordinatorAvailabilityHours.coordinatorId, + set: { timezone, hours, updatedAt: new Date() }, + }); + + return { ok: true }; +} + + +export type SetCoordinatorAvailabilityOverrideResult = + | { ok: true } + | { error: string }; + +export async function setCoordinatorAvailabilityOverride( + input: unknown, +): Promise { + const parsed = setCoordinatorAvailabilityOverrideSchema.safeParse(input); + if (!parsed.success) { + return { + error: parsed.error.issues[0]?.message ?? "Invalid input", + }; + } + + const { coordinatorId, date, windows } = parsed.data; + if (!(await canEditCoordinatorAvailability(coordinatorId))) { + return { error: "Unauthorized" }; + } + + await db + .insert(coordinatorAvailabilityOverrides) + .values({ coordinatorId, date, windows }) + .onConflictDoUpdate({ + target: [ + coordinatorAvailabilityOverrides.coordinatorId, + coordinatorAvailabilityOverrides.date, + ], + set: { windows, updatedAt: new Date() }, + }); + + return { ok: true }; +} + +export type ClearCoordinatorAvailabilityOverrideResult = + | { ok: true } + | { error: string }; + +export async function clearCoordinatorAvailabilityOverride( + input: unknown, +): Promise { + const parsed = clearCoordinatorAvailabilityOverrideSchema.safeParse(input); + if (!parsed.success) { + return { + error: parsed.error.issues[0]?.message ?? "Invalid input", + }; + } + + const { coordinatorId, date } = parsed.data; + if (!(await canEditCoordinatorAvailability(coordinatorId))) { + return { error: "Unauthorized" }; + } + + await db + .delete(coordinatorAvailabilityOverrides) + .where( + and( + eq(coordinatorAvailabilityOverrides.coordinatorId, coordinatorId), + eq(coordinatorAvailabilityOverrides.date, date), + ), + ); + + return { ok: true }; +} + +export type FetchCoordinatorAvailabilityEditorStateResult = + | { + hours: CoordinatorWeeklyHours; + timezone: string; + /** null = no override row for that date; [] = explicit day off */ + override: AvailabilityOverrideWindow[] | null; + } + | { error: string }; + +export async function fetchCoordinatorAvailabilityEditorState( + input: unknown, +): Promise { + const parsed = + fetchCoordinatorAvailabilityEditorStateSchema.safeParse(input); + if (!parsed.success) { + return { + error: parsed.error.issues[0]?.message ?? "Invalid input", + }; + } + + const { coordinatorId, overrideDate } = parsed.data; + if (!(await canEditCoordinatorAvailability(coordinatorId))) { + return { error: "Unauthorized" }; + } + + const [hoursRow] = await db + .select() + .from(coordinatorAvailabilityHours) + .where(eq(coordinatorAvailabilityHours.coordinatorId, coordinatorId)) + .limit(1); + + let override: AvailabilityOverrideWindow[] | null = null; + if (overrideDate) { + const [overrideRow] = await db + .select() + .from(coordinatorAvailabilityOverrides) + .where( + and( + eq( + coordinatorAvailabilityOverrides.coordinatorId, + coordinatorId, + ), + eq(coordinatorAvailabilityOverrides.date, overrideDate), + ), + ) + .limit(1); + override = overrideRow ? overrideRow.windows : null; + } + + return { + hours: hoursRow?.hours ?? EMPTY_WEEKLY_HOURS, + timezone: hoursRow?.timezone ?? "America/Toronto", + override, + }; +} + +export type ListCoordinatorAvailabilityResult = + | { occurrences: AvailabilityOccurrence[] } + | { error: string }; + +export async function listCoordinatorAvailability( + input: unknown, +): Promise { + const parsed = listCoordinatorAvailabilitySchema.safeParse(input); + if (!parsed.success) { + return { + error: parsed.error.issues[0]?.message ?? "Invalid input", + }; + } + + const { coordinatorId, from, to } = parsed.data; + if (!(await canEditCoordinatorAvailability(coordinatorId))) { + return { error: "Unauthorized" }; + } + + const [hoursRow] = await db + .select() + .from(coordinatorAvailabilityHours) + .where(eq(coordinatorAvailabilityHours.coordinatorId, coordinatorId)) + .limit(1); + + const overrideRows = await db + .select() + .from(coordinatorAvailabilityOverrides) + .where( + and( + eq(coordinatorAvailabilityOverrides.coordinatorId, coordinatorId), + gte(coordinatorAvailabilityOverrides.date, from), + lte(coordinatorAvailabilityOverrides.date, to), + ), + ); + + const overrides: Record = {}; + for (const row of overrideRows) { + overrides[row.date] = row.windows; + } + + return { + occurrences: availabilityForRange({ + hours: hoursRow?.hours ?? EMPTY_WEEKLY_HOURS, + overrides, + from, + to, + }), + }; +} \ No newline at end of file diff --git a/app/coaching/schema.ts b/app/coaching/schema.ts new file mode 100644 index 0000000..5c4f211 --- /dev/null +++ b/app/coaching/schema.ts @@ -0,0 +1,123 @@ +import { z } from "zod"; + +const timeSchema = z.string().regex(/^\d{2}:\d{2}$/, "Invalid time"); +const dateSchema = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Invalid date"); + +const MS_PER_DAY = 24 * 60 * 60 * 1000; +const MAX_RANGE_DAYS = 366; + +function toMinutes(time: string): number { + const [hours, minutes] = time.split(":").map(Number); + return hours * 60 + minutes; +} + +function utcMidnight(ymd: string): number { + const [year, month, day] = ymd.split("-").map(Number); + return Date.UTC(year, month - 1, day); +} + +function windowsOverlap( + windows: { start: string; end: string }[], +): boolean { + const ranges = windows + .map((window) => ({ + start: toMinutes(window.start), + end: toMinutes(window.end), + })) + .sort((a, b) => a.start - b.start); + + for (let i = 1; i < ranges.length; i++) { + if (ranges[i]!.start < ranges[i - 1]!.end) return true; + } + return false; +} + +export const availabilityWindowSchema = z + .object({ + start: timeSchema, + end: timeSchema, + recurrence: z.enum(["weekly", "biweekly"]), + anchorDate: dateSchema.optional(), + }) + .refine((window) => toMinutes(window.end) > toMinutes(window.start), { + message: "End must be after start", + path: ["end"], + }) + .refine( + (window) => + window.recurrence !== "biweekly" || window.anchorDate !== undefined, + { + message: "Every other week needs an anchor date", + path: ["anchorDate"], + }, + ); + +export const overrideWindowSchema = z + .object({ + start: timeSchema, + end: timeSchema, + }) + .refine((window) => toMinutes(window.end) > toMinutes(window.start), { + message: "End must be after start", + path: ["end"], + }); + +export const weeklyHoursSchema = z + .object({ + 0: z.array(availabilityWindowSchema), + 1: z.array(availabilityWindowSchema), + 2: z.array(availabilityWindowSchema), + 3: z.array(availabilityWindowSchema), + 4: z.array(availabilityWindowSchema), + 5: z.array(availabilityWindowSchema), + 6: z.array(availabilityWindowSchema), + }) + .refine((week) => !Object.values(week).some(windowsOverlap), { + message: "Windows on the same day must not overlap", + }); + +export const saveCoordinatorWeeklyHoursSchema = z.object({ + coordinatorId: z.string().uuid(), + timezone: z.string().min(1).default("America/Toronto"), + hours: weeklyHoursSchema, +}); + +export const setCoordinatorAvailabilityOverrideSchema = z.object({ + coordinatorId: z.string().uuid(), + date: dateSchema, + windows: z + .array(overrideWindowSchema) + .refine((windows) => !windowsOverlap(windows), { + message: "Windows on the same day must not overlap", + }), +}); + +export const clearCoordinatorAvailabilityOverrideSchema = z.object({ + coordinatorId: z.string().uuid(), + date: dateSchema, +}); + +export const fetchCoordinatorAvailabilityEditorStateSchema = z.object({ + coordinatorId: z.string().uuid(), + overrideDate: dateSchema.optional(), +}); + +export const listCoordinatorAvailabilitySchema = z + .object({ + coordinatorId: z.string().uuid(), + from: dateSchema, + to: dateSchema, + }) + .refine((range) => utcMidnight(range.from) <= utcMidnight(range.to), { + message: "from must be on or before to", + path: ["to"], + }) + .refine( + (range) => + utcMidnight(range.to) - utcMidnight(range.from) <= + MAX_RANGE_DAYS * MS_PER_DAY, + { + message: "Range cannot be longer than one year", + path: ["to"], + }, + ); \ No newline at end of file diff --git a/lib/availability.ts b/lib/availability.ts new file mode 100644 index 0000000..51a72be --- /dev/null +++ b/lib/availability.ts @@ -0,0 +1,108 @@ +import type { + AvailabilityOverrideWindow, + AvailabilityWindow, + CoordinatorWeeklyHours, + } from "@/lib/db/schema"; + + export const EMPTY_WEEKLY_HOURS: CoordinatorWeeklyHours = { + 0: [], + 1: [], + 2: [], + 3: [], + 4: [], + 5: [], + 6: [], + }; + + export type AvailabilityOccurrence = { + date: string; + start: string; + end: string; + source: "weekly" | "override"; + }; + + const MS_PER_DAY = 24 * 60 * 60 * 1000; + + function utcMidnight(ymd: string): number { + const [year, month, day] = ymd.split("-").map(Number); + return Date.UTC(year, month - 1, day); + } + + function ymdFromUtc(ms: number): string { + return new Date(ms).toISOString().slice(0, 10); + } + + function weekday(ymd: string): 0 | 1 | 2 | 3 | 4 | 5 | 6 { + return new Date(utcMidnight(ymd)).getUTCDay() as 0 | 1 | 2 | 3 | 4 | 5 | 6; + } + + function startOfSundayWeek(ymd: string): number { + const ms = utcMidnight(ymd); + return ms - new Date(ms).getUTCDay() * MS_PER_DAY; + } + + function isBiweeklyOn(ymd: string, anchorDate: string): boolean { + const weeks = + (startOfSundayWeek(ymd) - startOfSundayWeek(anchorDate)) / + (7 * MS_PER_DAY); + return weeks % 2 === 0; + } + + function appliesOnDate(window: AvailabilityWindow, ymd: string): boolean { + if (window.recurrence === "weekly") return true; + if (!window.anchorDate) return false; + return isBiweeklyOn(ymd, window.anchorDate); + } + + function eachYmd(from: string, to: string): string[] { + const days: string[] = []; + let ms = utcMidnight(from); + const end = utcMidnight(to); + while (ms <= end) { + days.push(ymdFromUtc(ms)); + ms += MS_PER_DAY; + } + return days; + } + + export function availabilityForRange({ + hours, + overrides, + from, + to, + }: { + hours: CoordinatorWeeklyHours; + overrides: Record; + from: string; + to: string; + }): AvailabilityOccurrence[] { + if (utcMidnight(from) > utcMidnight(to)) return []; + + const result: AvailabilityOccurrence[] = []; + + for (const date of eachYmd(from, to)) { + if (Object.hasOwn(overrides, date)) { + for (const window of overrides[date] ?? []) { + result.push({ + date, + start: window.start, + end: window.end, + source: "override", + }); + } + continue; + } + + for (const window of hours[weekday(date)]) { + if (!appliesOnDate(window, date)) continue; + result.push({ + date, + start: window.start, + end: window.end, + source: "weekly", + }); + } + } + + return result; + } \ No newline at end of file diff --git a/lib/db/schema.ts b/lib/db/schema.ts index bc1190e..bd1312c 100644 --- a/lib/db/schema.ts +++ b/lib/db/schema.ts @@ -259,3 +259,59 @@ export const formQuestionAnswers = pgTable("form_question_answers", { createdAt: timestamp("created_at").defaultNow().notNull(), updatedAt: timestamp("updated_at").defaultNow().notNull(), }); + +export type AvailabilityWindow = { + start: string; + end: string; + recurrence: "weekly" | "biweekly"; + anchorDate?: string; +}; + +export type CoordinatorWeeklyHours = { + 0: AvailabilityWindow[]; + 1: AvailabilityWindow[]; + 2: AvailabilityWindow[]; + 3: AvailabilityWindow[]; + 4: AvailabilityWindow[]; + 5: AvailabilityWindow[]; + 6: AvailabilityWindow[]; +}; + +export type AvailabilityOverrideWindow = { + start: string; + end: string; +}; + +export const coordinatorAvailabilityHours = pgTable( + "coordinator_availability_hours", + { + id: uuid("id").primaryKey().defaultRandom(), + coordinatorId: uuid("coordinator_id") + .references(() => profiles.id, { onDelete: "cascade" }) + .notNull() + .unique(), + timezone: text("timezone").notNull().default("America/Toronto"), + hours: jsonb("hours").$type().notNull(), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at").defaultNow().notNull(), + }, +); + +export const coordinatorAvailabilityOverrides = pgTable( + "coordinator_availability_overrides", + { + id: uuid("id").primaryKey().defaultRandom(), + coordinatorId: uuid("coordinator_id") + .references(() => profiles.id, { onDelete: "cascade" }) + .notNull(), + date: date("date", { mode: "string" }).notNull(), + windows: jsonb("windows").$type().notNull(), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at").defaultNow().notNull(), + }, + (t) => [ + uniqueIndex( + "coordinator_availability_overrides_coordinator_id_date_idx", + ).on(t.coordinatorId, t.date), + ], +); \ No newline at end of file