From 6057a763e735fdf0b094203297228c20ac3b64f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=CF=BBartin?= <51332188+martin0024@users.noreply.github.com> Date: Sat, 26 Sep 2026 14:56:09 -0400 Subject: [PATCH] feat(availability): implement coordinator availability management with overrides and previews --- __tests__/availability-editor.test.ts | 300 +++++++++++++++ .../coordinator-availability-actions.test.ts | 81 +++++ app/(authenticated)/availability/page.tsx | 86 +++++ app/(authenticated)/availability/queries.ts | 20 + app/private-lessons/actions.ts | 43 ++- app/private-lessons/schema.ts | 42 +-- components/app-sidebar.tsx | 8 + .../availability/availability-preview.tsx | 201 +++++++++++ .../coordinator-availability-editor.tsx | 141 ++++++++ .../availability/date-overrides-card.tsx | 184 ++++++++++ components/availability/override-dialog.tsx | 310 ++++++++++++++++ components/availability/slot-explainer.tsx | 80 ++++ components/availability/time-window-row.tsx | 79 ++++ components/availability/weekly-hours-card.tsx | 341 ++++++++++++++++++ lib/availability-editor.ts | 284 +++++++++++++++ lib/availability.ts | 29 ++ 16 files changed, 2198 insertions(+), 31 deletions(-) create mode 100644 __tests__/availability-editor.test.ts create mode 100644 app/(authenticated)/availability/page.tsx create mode 100644 app/(authenticated)/availability/queries.ts create mode 100644 components/availability/availability-preview.tsx create mode 100644 components/availability/coordinator-availability-editor.tsx create mode 100644 components/availability/date-overrides-card.tsx create mode 100644 components/availability/override-dialog.tsx create mode 100644 components/availability/slot-explainer.tsx create mode 100644 components/availability/time-window-row.tsx create mode 100644 components/availability/weekly-hours-card.tsx create mode 100644 lib/availability-editor.ts diff --git a/__tests__/availability-editor.test.ts b/__tests__/availability-editor.test.ts new file mode 100644 index 0000000..011b81c --- /dev/null +++ b/__tests__/availability-editor.test.ts @@ -0,0 +1,300 @@ +import { + addDays, + buildPreviewWeeks, + describeSlots, + formatShortDate, + formatTime, + formatTimeRange, + formatTimeZone, + hasWindowErrors, + nextWeekdayOnOrAfter, + previewRange, + splitIntoSlots, + startOfWeekMonday, + timeZoneOptions, + todayInTimeZone, + validateDayWindows, +} from "@/lib/availability-editor"; + +describe("formatTime", () => { + it.each([ + ["00:00", "12:00 AM"], + ["09:05", "9:05 AM"], + ["12:00", "12:00 PM"], + ["13:30", "1:30 PM"], + ["23:59", "11:59 PM"], + ])("%s → %s", (input, expected) => { + expect(formatTime(input)).toBe(expected); + }); + + it("formats a range", () => { + expect(formatTimeRange("09:00", "11:30")).toBe("9:00 AM – 11:30 AM"); + }); +}); + +describe("splitIntoSlots", () => { + it("cuts back-to-back slots from the window start and reports leftover", () => { + expect(splitIntoSlots("09:00", "11:30", 60)).toEqual({ + starts: ["09:00", "10:00"], + leftoverMinutes: 30, + }); + }); + + it("has no leftover when the window divides evenly", () => { + expect(splitIntoSlots("09:00", "10:30", 45)).toEqual({ + starts: ["09:00", "09:45"], + leftoverMinutes: 0, + }); + }); + + it("returns no slots when the window is shorter than a lesson", () => { + expect(splitIntoSlots("09:00", "09:30", 60)).toEqual({ + starts: [], + leftoverMinutes: 30, + }); + }); + + it("returns no slots for an inverted window", () => { + expect(splitIntoSlots("11:00", "09:00", 60)).toEqual({ + starts: [], + leftoverMinutes: 0, + }); + }); +}); + +describe("describeSlots", () => { + it("mentions leftover time", () => { + expect(describeSlots("09:00", "11:30", 60)).toBe( + "2 × 60-min slots · last 30 min not bookable", + ); + }); + + it("uses the singular for one slot", () => { + expect(describeSlots("09:00", "10:00", 60)).toBe("1 × 60-min slot"); + }); + + it("flags windows too short for a lesson", () => { + expect(describeSlots("09:00", "09:30", 60)).toBe( + "Too short for a 60-min lesson", + ); + }); +}); + +describe("validateDayWindows", () => { + it("accepts non-overlapping windows", () => { + const errors = validateDayWindows([ + { start: "09:00", end: "10:00", recurrence: "weekly" }, + { start: "10:00", end: "11:00", recurrence: "weekly" }, + ]); + expect(errors).toEqual([null, null]); + expect(hasWindowErrors(errors)).toBe(false); + }); + + it("flags an end time before the start time", () => { + expect(validateDayWindows([{ start: "11:00", end: "10:00" }])).toEqual([ + "End time must be after start time", + ]); + }); + + it("flags an end time equal to the start time", () => { + expect(validateDayWindows([{ start: "10:00", end: "10:00" }])).toEqual([ + "End time must be after start time", + ]); + }); + + it("flags missing times", () => { + expect(validateDayWindows([{ start: "", end: "10:00" }])).toEqual([ + "Enter a start and end time", + ]); + }); + + it("flags both overlapping windows", () => { + const errors = validateDayWindows([ + { start: "10:00", end: "11:00", recurrence: "weekly" }, + { start: "10:30", end: "12:00", recurrence: "weekly" }, + ]); + expect(errors).toEqual([ + "Overlaps with 10:30 AM – 12:00 PM", + "Overlaps with 10:00 AM – 11:00 AM", + ]); + expect(hasWindowErrors(errors)).toBe(true); + }); + + it("allows overlapping every-other-week windows on alternate weeks", () => { + expect( + validateDayWindows([ + { + start: "09:00", + end: "12:00", + recurrence: "biweekly", + anchorDate: "2026-09-07", + }, + { + start: "10:00", + end: "14:00", + recurrence: "biweekly", + anchorDate: "2026-09-14", + }, + ]), + ).toEqual([null, null]); + }); + + it("flags every-other-week windows that land on the same weeks", () => { + const errors = validateDayWindows([ + { + start: "09:00", + end: "12:00", + recurrence: "biweekly", + anchorDate: "2026-09-07", + }, + { + start: "10:00", + end: "14:00", + recurrence: "biweekly", + anchorDate: "2026-09-21", + }, + ]); + expect(hasWindowErrors(errors)).toBe(true); + }); + + it("requires a first week for every-other-week windows", () => { + expect( + validateDayWindows([ + { start: "09:00", end: "10:00", recurrence: "biweekly" }, + ]), + ).toEqual(["Pick the first week for every-other-week hours"]); + }); + + it("ignores invalid neighbours when checking overlap", () => { + expect( + validateDayWindows([ + { start: "09:00", end: "10:00" }, + { start: "09:30", end: "09:00" }, + ]), + ).toEqual([null, "End time must be after start time"]); + }); +}); + +describe("date helpers", () => { + it("adds days across month boundaries", () => { + expect(addDays("2026-09-29", 3)).toBe("2026-10-02"); + expect(addDays("2026-03-01", -1)).toBe("2026-02-28"); + }); + + it("finds the Monday of the week", () => { + expect(startOfWeekMonday("2026-09-26")).toBe("2026-09-21"); // Saturday + expect(startOfWeekMonday("2026-09-27")).toBe("2026-09-21"); // Sunday + expect(startOfWeekMonday("2026-09-28")).toBe("2026-09-28"); // Monday + }); + + it("finds the next given weekday", () => { + expect(nextWeekdayOnOrAfter("2026-09-26", 1)).toBe("2026-09-28"); + expect(nextWeekdayOnOrAfter("2026-09-26", 6)).toBe("2026-09-26"); + }); + + it("formats short dates", () => { + expect(formatShortDate("2026-10-12")).toBe("Mon, Oct 12"); + expect(formatShortDate("2027-01-04", true)).toBe("Mon, Jan 4, 2027"); + }); + + it("reads today in the given time zone", () => { + // 02:00 UTC on Sep 27 is still Sep 26 in Toronto. + const now = new Date("2026-09-27T02:00:00Z"); + expect(todayInTimeZone("America/Toronto", now)).toBe("2026-09-26"); + expect(todayInTimeZone("UTC", now)).toBe("2026-09-27"); + }); +}); + +describe("time zone helpers", () => { + it("labels a zone with its city and offset", () => { + expect(formatTimeZone("America/Toronto", "2026-07-01")).toBe( + "Toronto (GMT-4)", + ); + expect(formatTimeZone("America/St_Johns", "2026-07-01")).toBe( + "St Johns (GMT-2:30)", + ); + }); + + it("uses the offset in effect on the given date", () => { + expect(formatTimeZone("America/Toronto", "2026-12-01")).toBe( + "Toronto (GMT-5)", + ); + }); + + it("keeps an uncommon saved zone in the options", () => { + expect(timeZoneOptions("Asia/Tokyo")[0]).toBe("Asia/Tokyo"); + expect(timeZoneOptions("America/Toronto")).not.toContain("Asia/Tokyo"); + }); +}); + +describe("previewRange", () => { + it("starts on this week's Monday but only fetches from today", () => { + expect(previewRange("2026-09-26", 0)).toEqual({ + weekStart: "2026-09-21", + from: "2026-09-26", + to: "2026-10-18", + }); + }); + + it("pages forward four weeks at a time", () => { + expect(previewRange("2026-09-26", 1)).toEqual({ + weekStart: "2026-10-19", + from: "2026-10-19", + to: "2026-11-15", + }); + }); +}); + +describe("buildPreviewWeeks", () => { + const weeks = buildPreviewWeeks({ + weekStart: "2026-09-21", + weeks: 2, + today: "2026-09-23", + occurrences: [ + { date: "2026-09-21", start: "09:00", end: "10:00", source: "weekly" }, + { date: "2026-09-28", start: "13:00", end: "14:00", source: "weekly" }, + { date: "2026-09-28", start: "09:00", end: "10:00", source: "weekly" }, + { + date: "2026-09-29", + start: "10:00", + end: "11:00", + source: "override", + }, + ], + overrides: [ + { date: "2026-09-29", windows: [{ start: "10:00", end: "11:00" }] }, + { date: "2026-09-30", windows: [] }, + ], + }); + + it("builds Monday-first weeks of seven days", () => { + expect(weeks).toHaveLength(2); + expect(weeks[0]!.map((d) => d.date)).toEqual([ + "2026-09-21", + "2026-09-22", + "2026-09-23", + "2026-09-24", + "2026-09-25", + "2026-09-26", + "2026-09-27", + ]); + }); + + it("hides windows on past days", () => { + expect(weeks[0]![0]).toMatchObject({ isPast: true, windows: [] }); + expect(weeks[0]![2]!.isPast).toBe(false); + }); + + it("sorts windows by start time", () => { + expect(weeks[1]![0]!.windows.map((w) => w.start)).toEqual([ + "09:00", + "13:00", + ]); + }); + + it("marks custom-hours and day-off overrides", () => { + expect(weeks[1]![1]!.override).toBe("custom"); + expect(weeks[1]![2]).toMatchObject({ override: "day_off", windows: [] }); + expect(weeks[1]![3]!.override).toBeNull(); + }); +}); diff --git a/__tests__/coordinator-availability-actions.test.ts b/__tests__/coordinator-availability-actions.test.ts index fd71795..0f24dcd 100644 --- a/__tests__/coordinator-availability-actions.test.ts +++ b/__tests__/coordinator-availability-actions.test.ts @@ -6,6 +6,7 @@ import { setCoordinatorAvailabilityOverride, clearCoordinatorAvailabilityOverride, listCoordinatorAvailability, + listCoordinatorAvailabilityOverrides, } from "@/app/private-lessons/actions"; import { EMPTY_WEEKLY_HOURS } from "@/lib/availability"; @@ -22,8 +23,10 @@ const deleteFn = jest.fn(() => ({ where: deleteWhere })) as jest.Mock; const selectLimit = jest.fn(); let selectWhereResult: unknown[] = []; +const selectOrderBy = jest.fn(() => Promise.resolve(selectWhereResult)); const selectWhere = jest.fn(() => ({ limit: selectLimit, + orderBy: selectOrderBy, then( onFulfilled?: (value: unknown) => unknown, onRejected?: (reason: unknown) => unknown, @@ -433,3 +436,81 @@ describe("listCoordinatorAvailability", () => { expect(select).not.toHaveBeenCalled(); }); }); + +describe("listCoordinatorAvailabilityOverrides", () => { + it("returns upcoming overrides, including days off", async () => { + asCoordinator(); + selectWhereResult = [ + { date: "2026-10-12", windows: [] }, + { date: "2026-10-15", windows: [{ start: "13:00", end: "15:00" }] }, + ]; + + const result = await listCoordinatorAvailabilityOverrides({ + coordinatorId: COORDINATOR_ID, + from: "2026-10-01", + }); + + expect(result).toEqual({ + overrides: [ + { date: "2026-10-12", windows: [] }, + { + date: "2026-10-15", + windows: [{ start: "13:00", end: "15:00" }], + }, + ], + }); + expect(selectOrderBy).toHaveBeenCalled(); + }); + + it("lets an admin list any coordinator's overrides", async () => { + asAdmin(); + + const result = await listCoordinatorAvailabilityOverrides({ + coordinatorId: OTHER_COORDINATOR_ID, + from: "2026-10-01", + }); + + expect(result).toEqual({ overrides: [] }); + }); + + it("returns Unauthorized for a parent", async () => { + getUser.mockResolvedValue({ + data: { user: { id: COORDINATOR_ID } }, + }); + getClaims.mockResolvedValue({ + data: { claims: { user_role: "user" } }, + }); + + const result = await listCoordinatorAvailabilityOverrides({ + coordinatorId: COORDINATOR_ID, + from: "2026-10-01", + }); + + expect(result).toEqual({ error: "Unauthorized" }); + expect(select).not.toHaveBeenCalled(); + }); + + it("blocks a coordinator from listing another coordinator's overrides", async () => { + asCoordinator(); + + const result = await listCoordinatorAvailabilityOverrides({ + coordinatorId: OTHER_COORDINATOR_ID, + from: "2026-10-01", + }); + + expect(result).toEqual({ error: "Unauthorized" }); + expect(select).not.toHaveBeenCalled(); + }); + + it("rejects an invalid from date", async () => { + asCoordinator(); + + const result = await listCoordinatorAvailabilityOverrides({ + coordinatorId: COORDINATOR_ID, + from: "2026-02-30", + }); + + expect(result).toEqual({ error: "Invalid date" }); + expect(select).not.toHaveBeenCalled(); + }); +}); diff --git a/app/(authenticated)/availability/page.tsx b/app/(authenticated)/availability/page.tsx new file mode 100644 index 0000000..b11a112 --- /dev/null +++ b/app/(authenticated)/availability/page.tsx @@ -0,0 +1,86 @@ +import { Suspense } from "react"; +import { redirect } from "next/navigation"; + +import { Spinner } from "@/components/ui/spinner"; +import { PageHeader, PageShell } from "@/components/page-shell"; +import { CoordinatorAvailabilityEditor } from "@/components/availability/coordinator-availability-editor"; +import { createClient } from "@/utils/supabase/server"; +import { getUserRole } from "@/lib/auth/require-admin"; +import { ROLES } from "@/lib/roles"; +import { + fetchCoordinatorAvailabilityEditorState, + listCoordinatorAvailability, + listCoordinatorAvailabilityOverrides, +} from "@/app/private-lessons/actions"; +import { previewRange, todayInTimeZone } from "@/lib/availability-editor"; +import { listPrivateLessonDurations } from "./queries"; + +export default function AvailabilityPage() { + return ( + + + + } + > + + + ); +} + +async function AvailabilityContent() { + const supabase = await createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + + if (!user) { + redirect("/login"); + } + + const role = await getUserRole(); + if (role !== ROLES.COORDINATOR) { + redirect("/"); + } + + const coordinatorId = user.id; + const [state, lessonDurations] = await Promise.all([ + fetchCoordinatorAvailabilityEditorState({ coordinatorId }), + listPrivateLessonDurations(coordinatorId), + ]); + if ("error" in state) { + throw new Error(state.error); + } + + const today = todayInTimeZone(state.timezone); + const range = previewRange(today, 0); + const [preview, overrideList] = await Promise.all([ + listCoordinatorAvailability({ + coordinatorId, + from: range.from, + to: range.to, + }), + listCoordinatorAvailabilityOverrides({ coordinatorId, from: today }), + ]); + if ("error" in preview) throw new Error(preview.error); + if ("error" in overrideList) throw new Error(overrideList.error); + + return ( + + + + + ); +} diff --git a/app/(authenticated)/availability/queries.ts b/app/(authenticated)/availability/queries.ts new file mode 100644 index 0000000..88ab86f --- /dev/null +++ b/app/(authenticated)/availability/queries.ts @@ -0,0 +1,20 @@ +import { and, eq } from "drizzle-orm"; + +import { db } from "@/lib/db"; +import { services } from "@/lib/db/schema"; + +export async function listPrivateLessonDurations( + coordinatorId: string, +): Promise { + const rows = await db + .selectDistinct({ durationMinutes: services.durationMinutes }) + .from(services) + .where( + and( + eq(services.coordinatorId, coordinatorId), + eq(services.type, "private_lessons"), + eq(services.status, "active"), + ), + ); + return rows.map((row) => row.durationMinutes).sort((a, b) => a - b); +} diff --git a/app/private-lessons/actions.ts b/app/private-lessons/actions.ts index 957e26e..bcfebf1 100644 --- a/app/private-lessons/actions.ts +++ b/app/private-lessons/actions.ts @@ -4,6 +4,7 @@ import { setCoordinatorAvailabilityOverrideSchema, clearCoordinatorAvailabilityOverrideSchema, listCoordinatorAvailabilitySchema, + listCoordinatorAvailabilityOverridesSchema, fetchCoordinatorAvailabilityEditorStateSchema, } from "@/app/private-lessons/schema"; import { @@ -13,7 +14,7 @@ import { } from "@/lib/availability"; import { ROLES } from "@/lib/roles"; -import { and, eq, gte, lte } from "drizzle-orm"; +import { and, asc, eq, gte, lte } from "drizzle-orm"; import { db } from "@/lib/db"; import { @@ -243,6 +244,46 @@ export async function fetchCoordinatorAvailabilityEditorState( }; } +export type CoordinatorAvailabilityOverride = { + date: string; + windows: AvailabilityOverrideWindow[]; +}; + +export type ListCoordinatorAvailabilityOverridesResult = + | { overrides: CoordinatorAvailabilityOverride[] } + | { error: string }; + +export async function listCoordinatorAvailabilityOverrides( + input: unknown, +): Promise { + const parsed = listCoordinatorAvailabilityOverridesSchema.safeParse(input); + if (!parsed.success) { + return { + error: parsed.error.issues[0]?.message ?? "Invalid input", + }; + } + + const { coordinatorId, from } = parsed.data; + const denied = await authorizeCoordinatorAvailability(coordinatorId); + if (denied) return denied; + + const rows = await db + .select({ + date: coordinatorAvailabilityOverrides.date, + windows: coordinatorAvailabilityOverrides.windows, + }) + .from(coordinatorAvailabilityOverrides) + .where( + and( + eq(coordinatorAvailabilityOverrides.coordinatorId, coordinatorId), + gte(coordinatorAvailabilityOverrides.date, from), + ), + ) + .orderBy(asc(coordinatorAvailabilityOverrides.date)); + + return { overrides: rows }; +} + export type ListCoordinatorAvailabilityResult = | { occurrences: AvailabilityOccurrence[]; diff --git a/app/private-lessons/schema.ts b/app/private-lessons/schema.ts index 6109c17..162efc0 100644 --- a/app/private-lessons/schema.ts +++ b/app/private-lessons/schema.ts @@ -1,8 +1,9 @@ import { z } from "zod"; import { - onSameBiweeklyCycle, + toMinutes, utcMidnight, + windowsConflict, ymdFromUtc, } from "@/lib/availability"; @@ -28,36 +29,12 @@ const timezoneSchema = z.string().refine((timeZone) => { 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; -} - -type OverlapWindow = { - start: string; - end: string; - recurrence?: "weekly" | "biweekly"; - anchorDate?: string; -}; - -function canCoincide(a: OverlapWindow, b: OverlapWindow): boolean { - if (a.recurrence !== "biweekly" || b.recurrence !== "biweekly") return true; - if (!a.anchorDate || !b.anchorDate) return true; - return onSameBiweeklyCycle(a.anchorDate, b.anchorDate); -} - -function windowsOverlap(windows: OverlapWindow[]): boolean { +function windowsOverlap( + windows: Parameters[0][], +): boolean { for (let i = 0; i < windows.length; i++) { for (let j = i + 1; j < windows.length; j++) { - const a = windows[i]!; - const b = windows[j]!; - if ( - toMinutes(a.start) < toMinutes(b.end) && - toMinutes(b.start) < toMinutes(a.end) && - canCoincide(a, b) - ) { - return true; - } + if (windowsConflict(windows[i]!, windows[j]!)) return true; } } return false; @@ -133,6 +110,11 @@ export const fetchCoordinatorAvailabilityEditorStateSchema = z.object({ overrideDate: dateSchema.optional(), }); +export const listCoordinatorAvailabilityOverridesSchema = z.object({ + coordinatorId: z.string().uuid(), + from: dateSchema, +}); + export const listCoordinatorAvailabilitySchema = z .object({ coordinatorId: z.string().uuid(), @@ -151,4 +133,4 @@ export const listCoordinatorAvailabilitySchema = z message: "Range cannot be longer than one year", path: ["to"], }, - ); \ No newline at end of file + ); diff --git a/components/app-sidebar.tsx b/components/app-sidebar.tsx index c71f519..ba16580 100644 --- a/components/app-sidebar.tsx +++ b/components/app-sidebar.tsx @@ -13,6 +13,7 @@ import { Form, CalendarClock, Baby, + CalendarRange, LogOut, type LucideIcon, } from "lucide-react"; @@ -111,6 +112,13 @@ export function AppSidebar({ icon: Baby, }); } + if (role === ROLES.COORDINATOR) { + items.push({ + title: "Availability", + href: "/availability", + icon: CalendarRange, + }); + } return items; }, [role]); diff --git a/components/availability/availability-preview.tsx b/components/availability/availability-preview.tsx new file mode 100644 index 0000000..5cac202 --- /dev/null +++ b/components/availability/availability-preview.tsx @@ -0,0 +1,201 @@ +"use client"; + +import { CalendarOff, ChevronLeft, ChevronRight } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { + Card, + CardAction, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { + addDays, + formatShortDate, + formatTime, + formatTimeZone, + type PreviewDay, +} from "@/lib/availability-editor"; +import { cn } from "@/lib/utils"; + +const DAY_HEADERS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]; + +function LegendSwatch({ + className, + children, +}: { + className: string; + children: React.ReactNode; +}) { + return ( + + + {children} + + ); +} + +function DayCell({ day, today }: { day: PreviewDay; today: string }) { + const dayNumber = Number(day.date.slice(8)); + const isToday = day.date === today; + const empty = day.windows.length === 0 && day.override !== "day_off"; + + return ( +
+
+ + {formatShortDate(day.date)} + {dayNumber} + + {day.override === "custom" && ( + + Override + + )} +
+ {day.override === "day_off" ? ( + + + Day off + + ) : ( + day.windows.map((window) => ( + + {formatTime(window.start)} + + to {formatTime(window.end)} + + + )) + )} +
+ ); +} + +export function AvailabilityPreview({ + weeks, + today, + timezone, + canGoBack, + loading, + onPrevious, + onNext, +}: { + weeks: PreviewDay[][]; + today: string; + timezone: string; + canGoBack: boolean; + loading: boolean; + onPrevious: () => void; + onNext: () => void; +}) { + const first = weeks[0]?.[0]?.date; + const last = weeks.at(-1)?.at(-1)?.date; + + return ( + + + Preview + + What families can book from once weekly hours and overrides are + combined. Times in {formatTimeZone(timezone, today)}. + + + + + {first && last + ? `${formatShortDate(first).slice(5)} – ${formatShortDate(last).slice(5)}` + : ""} + + + + + +
+ + Weekly hours + + Date override + + Day off + +
+
+ {DAY_HEADERS.map((label) => ( + + {label} + + ))} +
+ {weeks.map((week) => ( +
+

+ {formatShortDate(week[0]!.date)} –{" "} + {formatShortDate(addDays(week[0]!.date, 6))} +

+
+ {week.map((day) => ( + + ))} +
+
+ ))} +
+
+ ); +} diff --git a/components/availability/coordinator-availability-editor.tsx b/components/availability/coordinator-availability-editor.tsx new file mode 100644 index 0000000..186caf5 --- /dev/null +++ b/components/availability/coordinator-availability-editor.tsx @@ -0,0 +1,141 @@ +"use client"; + +import { useState, useTransition } from "react"; +import { toast } from "sonner"; + +import { + listCoordinatorAvailability, + listCoordinatorAvailabilityOverrides, + type CoordinatorAvailabilityOverride, +} from "@/app/private-lessons/actions"; +import type { AvailabilityOccurrence } from "@/lib/availability"; +import { + buildPreviewWeeks, + DEFAULT_LESSON_MINUTES, + PREVIEW_WEEKS, + previewRange, + todayInTimeZone, +} from "@/lib/availability-editor"; +import type { CoordinatorWeeklyHours } from "@/lib/db/schema"; +import { AvailabilityPreview } from "./availability-preview"; +import { DateOverridesCard } from "./date-overrides-card"; +import { SlotExplainer } from "./slot-explainer"; +import { WeeklyHoursCard } from "./weekly-hours-card"; + +export type CoordinatorAvailabilityEditorProps = { + coordinatorId: string; + initialHours: CoordinatorWeeklyHours; + initialTimezone: string; + initialOverrides: CoordinatorAvailabilityOverride[]; + initialOccurrences: AvailabilityOccurrence[]; + initialToday: string; + lessonDurations: number[]; +}; + +export function CoordinatorAvailabilityEditor({ + coordinatorId, + initialHours, + initialTimezone, + initialOverrides, + initialOccurrences, + initialToday, + lessonDurations, +}: CoordinatorAvailabilityEditorProps) { + const [savedHours, setSavedHours] = useState(initialHours); + const [timezone, setTimezone] = useState(initialTimezone); + const [today, setToday] = useState(initialToday); + const [overrides, setOverrides] = useState(initialOverrides); + const [occurrences, setOccurrences] = useState(initialOccurrences); + const [page, setPage] = useState(0); + const [loading, startLoading] = useTransition(); + + const lessonMinutes = + lessonDurations.length > 0 + ? Math.min(...lessonDurations) + : DEFAULT_LESSON_MINUTES; + + function reload({ + nextPage = page, + nextToday = today, + }: { nextPage?: number; nextToday?: string } = {}) { + startLoading(async () => { + const range = previewRange(nextToday, nextPage); + const [preview, overrideList] = await Promise.all([ + listCoordinatorAvailability({ + coordinatorId, + from: range.from, + to: range.to, + }), + listCoordinatorAvailabilityOverrides({ + coordinatorId, + from: nextToday, + }), + ]); + if ("error" in preview || "error" in overrideList) { + toast.error("Couldn't refresh your availability", { + description: + ("error" in preview && preview.error) || + ("error" in overrideList && overrideList.error) || + undefined, + }); + return; + } + setPage(nextPage); + setOccurrences(preview.occurrences); + setOverrides(overrideList.overrides); + }); + } + + const range = previewRange(today, page); + const weeks = buildPreviewWeeks({ + weekStart: range.weekStart, + weeks: PREVIEW_WEEKS, + today, + occurrences, + overrides, + }); + + return ( +
+
+ { + const nextToday = todayInTimeZone(saved.timezone); + setSavedHours(saved.hours); + setTimezone(saved.timezone); + setToday(nextToday); + reload({ nextToday }); + }} + /> + 0} + loading={loading} + onPrevious={() => reload({ nextPage: page - 1 })} + onNext={() => reload({ nextPage: page + 1 })} + /> +
+
+ reload()} + /> + +
+
+ ); +} diff --git a/components/availability/date-overrides-card.tsx b/components/availability/date-overrides-card.tsx new file mode 100644 index 0000000..90c5f0a --- /dev/null +++ b/components/availability/date-overrides-card.tsx @@ -0,0 +1,184 @@ +"use client"; + +import { useState, useTransition } from "react"; +import { CalendarOff, CalendarPlus, Pencil, Trash2 } from "lucide-react"; +import { toast } from "sonner"; + +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardAction, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { + clearCoordinatorAvailabilityOverride, + type CoordinatorAvailabilityOverride, +} from "@/app/private-lessons/actions"; +import { formatShortDate, formatTimeRange } from "@/lib/availability-editor"; +import type { CoordinatorWeeklyHours } from "@/lib/db/schema"; +import { OverrideDialog } from "./override-dialog"; + +export function DateOverridesCard({ + coordinatorId, + overrides, + weeklyHours, + today, + lessonMinutes, + onChanged, +}: { + coordinatorId: string; + overrides: CoordinatorAvailabilityOverride[]; + weeklyHours: CoordinatorWeeklyHours; + today: string; + lessonMinutes: number; + onChanged: () => void; +}) { + const [dialogOpen, setDialogOpen] = useState(false); + const [editDate, setEditDate] = useState(null); + const [removingDate, setRemovingDate] = useState(null); + const [, startRemoving] = useTransition(); + + function openFor(date: string | null) { + setEditDate(date); + setDialogOpen(true); + } + + function handleRemove(date: string) { + setRemovingDate(date); + startRemoving(async () => { + const result = await clearCoordinatorAvailabilityOverride({ + coordinatorId, + date, + }); + setRemovingDate(null); + if ("error" in result) { + toast.error("Override not removed", { + description: result.error, + }); + return; + } + toast.success(`${formatShortDate(date)} is back to your weekly hours`); + onChanged(); + }); + } + + return ( + + + + Date overrides + + + Replace your weekly hours on a specific date, or take the day + off. + + + + + + + {overrides.length === 0 ? ( +

+ No upcoming overrides. Your weekly hours apply every day. +

+ ) : ( +
    + {overrides.map((override) => { + const dayOff = override.windows.length === 0; + return ( +
  • +
    + + {formatShortDate( + override.date, + override.date.slice(0, 4) !== + today.slice(0, 4), + )} + + {dayOff ? ( + + + Day off + + ) : ( + + {override.windows + .map((w) => + formatTimeRange(w.start, w.end), + ) + .join(", ")} + + )} +
    +
    + + + + + Edit + + + + + + + Remove — use weekly hours + + +
    +
  • + ); + })} +
+ )} +
+ + +
+ ); +} diff --git a/components/availability/override-dialog.tsx b/components/availability/override-dialog.tsx new file mode 100644 index 0000000..cf5caf8 --- /dev/null +++ b/components/availability/override-dialog.tsx @@ -0,0 +1,310 @@ +"use client"; + +import { useMemo, useState, useTransition } from "react"; +import { AlertCircle, CalendarOff, Clock, Plus } from "lucide-react"; +import { toast } from "sonner"; + +import { Button } from "@/components/ui/button"; +import { Calendar } from "@/components/ui/calendar"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; +import { + setCoordinatorAvailabilityOverride, + type CoordinatorAvailabilityOverride, +} from "@/app/private-lessons/actions"; +import { availabilityForRange } from "@/lib/availability"; +import { + formatShortDate, + formatTimeRange, + fromYmd, + hasWindowErrors, + toYmd, + validateDayWindows, +} from "@/lib/availability-editor"; +import type { + AvailabilityOverrideWindow, + CoordinatorWeeklyHours, +} from "@/lib/db/schema"; +import { TimeWindowRow } from "./time-window-row"; + +type Mode = "custom" | "day_off"; + +type Props = { + open: boolean; + onOpenChange: (open: boolean) => void; + coordinatorId: string; + initialDate: string | null; + overrides: CoordinatorAvailabilityOverride[]; + weeklyHours: CoordinatorWeeklyHours; + today: string; + lessonMinutes: number; + onSaved: () => void; +}; + +export function OverrideDialog({ open, onOpenChange, ...props }: Props) { + return ( + + + {open && ( + onOpenChange(false)} + {...props} + /> + )} + + + ); +} + +function weeklyWindowsOn( + hours: CoordinatorWeeklyHours, + date: string, +): AvailabilityOverrideWindow[] { + return availabilityForRange({ + hours, + overrides: {}, + from: date, + to: date, + }).map(({ start, end }) => ({ start, end })); +} + +function OverrideForm({ + coordinatorId, + initialDate, + overrides, + weeklyHours, + today, + lessonMinutes, + onSaved, + onDone, +}: Omit & { onDone: () => void }) { + const overrideByDate = useMemo( + () => new Map(overrides.map((o) => [o.date, o.windows])), + [overrides], + ); + + function stateFor(date: string | null): { + mode: Mode; + windows: AvailabilityOverrideWindow[]; + } { + if (!date) return { mode: "custom", windows: [] }; + const existing = overrideByDate.get(date); + if (existing) { + return existing.length === 0 + ? { mode: "day_off", windows: [] } + : { mode: "custom", windows: existing }; + } + const usual = weeklyWindowsOn(weeklyHours, date); + return { + mode: "custom", + windows: usual.length ? usual : [{ start: "09:00", end: "12:00" }], + }; + } + + const [date, setDate] = useState(initialDate); + const [mode, setMode] = useState(() => stateFor(initialDate).mode); + const [windows, setWindows] = useState(() => stateFor(initialDate).windows); + const [serverError, setServerError] = useState(null); + const [saving, startSaving] = useTransition(); + + const errors = validateDayWindows(windows); + const usualHours = date ? weeklyWindowsOn(weeklyHours, date) : []; + const isExisting = date !== null && overrideByDate.has(date); + const canSave = + date !== null && + !saving && + (mode === "day_off" || (windows.length > 0 && !hasWindowErrors(errors))); + + function pickDate(next: string) { + const state = stateFor(next); + setDate(next); + setMode(state.mode); + setWindows(state.windows); + setServerError(null); + } + + function handleSave() { + if (!date) return; + startSaving(async () => { + const result = await setCoordinatorAvailabilityOverride({ + coordinatorId, + date, + windows: mode === "day_off" ? [] : windows, + }); + if ("error" in result) { + setServerError(result.error); + return; + } + toast.success( + mode === "day_off" + ? `${formatShortDate(date)} marked as a day off` + : `Custom hours saved for ${formatShortDate(date)}`, + ); + onSaved(); + onDone(); + }); + } + + return ( + <> + + + {initialDate ? "Edit date override" : "Add date override"} + + + An override replaces your weekly hours on that date only. Lessons + already booked are not affected. + + + +
+ day && pickDate(toYmd(day))} + disabled={{ before: fromYmd(today) }} + modifiers={{ + overridden: overrides.map((o) => fromYmd(o.date)), + }} + modifiersClassNames={{ + overridden: + "[&>button]:underline [&>button]:decoration-brand-gold [&>button]:decoration-2 [&>button]:underline-offset-4", + }} + className="rounded-xl border border-border p-3 [--cell-size:--spacing(8)]" + /> + +
+ {date === null ? ( +

+ Pick a date on the calendar to override it. +

+ ) : ( + <> +
+

+ {formatShortDate(date, true)} +

+

+ {usualHours.length + ? `Usual hours: ${usualHours + .map((w) => formatTimeRange(w.start, w.end)) + .join(", ")}` + : "No weekly hours on this date."} + {isExisting && " · Already overridden"} +

+
+ + { + if (!value) return; + setServerError(null); + setMode(value as Mode); + }} + aria-label="Override type" + > + + + Custom hours + + + + Day off + + + + {mode === "day_off" ? ( +

+ Nothing can be booked with you on this date. +

+ ) : ( +
+ {windows.map((window, index) => ( + { + setServerError(null); + setWindows((prev) => + prev.map((w, i) => + i === index ? next : w, + ), + ); + }} + onRemove={() => + setWindows((prev) => + prev.filter((_, i) => i !== index), + ) + } + /> + ))} + {windows.length === 0 && ( +

+ Add at least one window, or choose Day off. +

+ )} + +
+ )} + + )} + + {serverError && ( +

+ + {serverError} +

+ )} +
+
+ + + + + + + ); +} diff --git a/components/availability/slot-explainer.tsx b/components/availability/slot-explainer.tsx new file mode 100644 index 0000000..4364fe6 --- /dev/null +++ b/components/availability/slot-explainer.tsx @@ -0,0 +1,80 @@ +import { Info } from "lucide-react"; + +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { formatTime, splitIntoSlots } from "@/lib/availability-editor"; + +const EXAMPLE_START = "09:00"; +const EXAMPLE_END = "11:30"; + +export function SlotExplainer({ + lessonDurations, + lessonMinutes, +}: { + lessonDurations: number[]; + lessonMinutes: number; +}) { + const { starts, leftoverMinutes } = splitIntoSlots( + EXAMPLE_START, + EXAMPLE_END, + lessonMinutes, + ); + const total = 150; + + return ( + + + + + How bookable slots work + + + At checkout, each window is cut into back-to-back slots the + length of the lesson, starting at the window's start time. + Time left over at the end is not bookable. + + + +
+ {starts.map((start) => ( +
+ {formatTime(start)} +
+ ))} + {leftoverMinutes > 0 && ( +
+ {leftoverMinutes} min +
+ )} +
+

+ {formatTime(EXAMPLE_START)} – {formatTime(EXAMPLE_END)} with a{" "} + {lessonMinutes}-minute lesson offers{" "} + {starts.map(formatTime).join(" and ")}. +

+

+ {lessonDurations.length > 0 + ? `Your private lessons are ${lessonDurations + .map((minutes) => `${minutes} min`) + .join(", ")} long.` + : "You don't coordinate any active private lessons yet; slot hints assume 60 minutes."} +

+
+
+ ); +} diff --git a/components/availability/time-window-row.tsx b/components/availability/time-window-row.tsx new file mode 100644 index 0000000..43c3dd3 --- /dev/null +++ b/components/availability/time-window-row.tsx @@ -0,0 +1,79 @@ +"use client"; + +import { Trash2 } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { describeSlots } from "@/lib/availability-editor"; +import { cn } from "@/lib/utils"; + +export function TimeWindowRow({ + start, + end, + error, + lessonMinutes, + label, + onChange, + onRemove, + children, +}: { + start: string; + end: string; + error: string | null; + lessonMinutes: number; + label: string; + onChange: (next: { start: string; end: string }) => void; + onRemove: () => void; + children?: React.ReactNode; +}) { + const invalid = error !== null; + return ( +
+
+ onChange({ start: e.target.value, end })} + /> + – + onChange({ start, end: e.target.value })} + /> + {children} + +
+

+ {error ?? describeSlots(start, end, lessonMinutes)} +

+
+ ); +} diff --git a/components/availability/weekly-hours-card.tsx b/components/availability/weekly-hours-card.tsx new file mode 100644 index 0000000..e68bd63 --- /dev/null +++ b/components/availability/weekly-hours-card.tsx @@ -0,0 +1,341 @@ +"use client"; + +import { useMemo, useState, useTransition } from "react"; +import { AlertCircle, Globe, Plus } from "lucide-react"; +import { toast } from "sonner"; + +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { saveCoordinatorWeeklyHours } from "@/app/private-lessons/actions"; +import { + formatTimeZone, + hasWindowErrors, + nextWeekdayOnOrAfter, + timeZoneOptions, + validateDayWindows, + WEEKDAY_NAMES, + WEEKDAY_ORDER, + type Weekday, +} from "@/lib/availability-editor"; +import { toMinutes } from "@/lib/availability"; +import type { + AvailabilityWindow, + CoordinatorWeeklyHours, +} from "@/lib/db/schema"; +import { TimeWindowRow } from "./time-window-row"; + +function nextWindow(existing: AvailabilityWindow[]): AvailabilityWindow { + const last = existing.at(-1); + if (!last) return { start: "09:00", end: "12:00", recurrence: "weekly" }; + const start = toMinutes(last.end) + 60; + if (start + 60 >= 24 * 60) { + return { start: "", end: "", recurrence: "weekly" }; + } + const fmt = (m: number) => + `${String(Math.floor(m / 60)).padStart(2, "0")}:${String(m % 60).padStart(2, "0")}`; + return { start: fmt(start), end: fmt(start + 60), recurrence: "weekly" }; +} + +export function WeeklyHoursCard({ + coordinatorId, + initialHours, + initialTimezone, + today, + lessonMinutes, + onSaved, +}: { + coordinatorId: string; + initialHours: CoordinatorWeeklyHours; + initialTimezone: string; + today: string; + lessonMinutes: number; + onSaved: (saved: { + hours: CoordinatorWeeklyHours; + timezone: string; + }) => void; +}) { + const [hours, setHours] = useState(initialHours); + const [timezone, setTimezone] = useState(initialTimezone); + const [saved, setSaved] = useState({ + hours: initialHours, + timezone: initialTimezone, + }); + const [serverError, setServerError] = useState(null); + const [saving, startSaving] = useTransition(); + + const errors = useMemo( + () => + Object.fromEntries( + WEEKDAY_ORDER.map((day) => [day, validateDayWindows(hours[day])]), + ) as Record, + [hours], + ); + const invalid = WEEKDAY_ORDER.some((day) => hasWindowErrors(errors[day])); + const dirty = + timezone !== saved.timezone || + JSON.stringify(hours) !== JSON.stringify(saved.hours); + + function updateDay( + day: Weekday, + update: (windows: AvailabilityWindow[]) => AvailabilityWindow[], + ) { + setServerError(null); + setHours((prev) => ({ ...prev, [day]: update(prev[day]) })); + } + + function updateWindow( + day: Weekday, + index: number, + patch: Partial, + ) { + updateDay(day, (windows) => + windows.map((window, i) => { + if (i !== index) return window; + const next = { ...window, ...patch }; + if (next.recurrence === "weekly") delete next.anchorDate; + return next; + }), + ); + } + + function handleSave() { + startSaving(async () => { + const result = await saveCoordinatorWeeklyHours({ + coordinatorId, + timezone, + hours, + }); + if ("error" in result) { + setServerError(result.error); + toast.error("Weekly hours not saved", { + description: result.error, + }); + return; + } + setSaved({ hours, timezone }); + toast.success("Weekly hours saved"); + onSaved({ hours, timezone }); + }); + } + + function handleDiscard() { + setHours(saved.hours); + setTimezone(saved.timezone); + setServerError(null); + } + + return ( + + +
+ + Weekly hours + + + Your regular schedule for private lessons. It applies to every + private lesson you coordinate. + +
+
+ + Time zone + + +
+
+ + + {WEEKDAY_ORDER.map((day) => { + const windows = hours[day]; + return ( +
+
+ + {WEEKDAY_NAMES[day]} + + {windows.length === 0 && ( + + Unavailable + + )} +
+
+ {windows.map((window, index) => { + const label = `${WEEKDAY_NAMES[day]} window ${index + 1}`; + return ( + + updateWindow(day, index, times) + } + onRemove={() => + updateDay(day, (list) => + list.filter((_, i) => i !== index), + ) + } + > + + {window.recurrence === "biweekly" && ( + + )} + + ); + })} + +
+
+ ); + })} +
+ + +
+ {serverError ? ( +

+ + {serverError} +

+ ) : invalid ? ( +

+ + Fix the highlighted hours before saving. +

+ ) : dirty ? ( +

+ You have unsaved changes. +

+ ) : ( +

+ Changes only affect future bookings. Lessons already booked + stay as they are. +

+ )} +
+
+ {dirty && ( + + )} + +
+
+
+ ); +} diff --git a/lib/availability-editor.ts b/lib/availability-editor.ts new file mode 100644 index 0000000..1a3bca8 --- /dev/null +++ b/lib/availability-editor.ts @@ -0,0 +1,284 @@ +import { + toMinutes, + utcMidnight, + windowsConflict, + ymdFromUtc, + type AvailabilityOccurrence, +} from "@/lib/availability"; +import type { + AvailabilityOverrideWindow, + AvailabilityWindow, +} from "@/lib/db/schema"; + +export type Weekday = 0 | 1 | 2 | 3 | 4 | 5 | 6; + +export const WEEKDAY_ORDER: Weekday[] = [1, 2, 3, 4, 5, 6, 0]; + +export const WEEKDAY_NAMES: Record = { + 0: "Sunday", + 1: "Monday", + 2: "Tuesday", + 3: "Wednesday", + 4: "Thursday", + 5: "Friday", + 6: "Saturday", +}; + +const WEEKDAY_SHORT = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; +const MONTHS_SHORT = [ + "Jan", + "Feb", + "Mar", + "Apr", + "May", + "Jun", + "Jul", + "Aug", + "Sep", + "Oct", + "Nov", + "Dec", +]; + +const MS_PER_DAY = 24 * 60 * 60 * 1000; + +export const DEFAULT_TIMEZONE = "America/Toronto"; +export const DEFAULT_LESSON_MINUTES = 60; + +export function formatTime(time: string): string { + const [hours, minutes] = time.split(":").map(Number); + const period = hours < 12 ? "AM" : "PM"; + const hour12 = hours % 12 === 0 ? 12 : hours % 12; + return `${hour12}:${String(minutes).padStart(2, "0")} ${period}`; +} + +export function formatTimeRange(start: string, end: string): string { + return `${formatTime(start)} – ${formatTime(end)}`; +} + +function fromMinutes(total: number): string { + return `${String(Math.floor(total / 60)).padStart(2, "0")}:${String( + total % 60, + ).padStart(2, "0")}`; +} + +export function splitIntoSlots( + start: string, + end: string, + durationMinutes: number, +): { starts: string[]; leftoverMinutes: number } { + const from = toMinutes(start); + const to = toMinutes(end); + if (durationMinutes <= 0 || to <= from) { + return { starts: [], leftoverMinutes: Math.max(0, to - from) }; + } + const starts: string[] = []; + let cursor = from; + while (cursor + durationMinutes <= to) { + starts.push(fromMinutes(cursor)); + cursor += durationMinutes; + } + return { starts, leftoverMinutes: to - cursor }; +} + +export function describeSlots( + start: string, + end: string, + durationMinutes: number, +): string { + const { starts, leftoverMinutes } = splitIntoSlots( + start, + end, + durationMinutes, + ); + const count = + starts.length === 0 + ? `Too short for a ${durationMinutes}-min lesson` + : `${starts.length} × ${durationMinutes}-min slot${ + starts.length === 1 ? "" : "s" + }`; + if (starts.length === 0 || leftoverMinutes === 0) return count; + return `${count} · last ${leftoverMinutes} min not bookable`; +} + +type EditableWindow = Pick & + Partial>; + +export function validateDayWindows( + windows: EditableWindow[], +): (string | null)[] { + return windows.map((window, index) => { + if (!window.start || !window.end) return "Enter a start and end time"; + if (toMinutes(window.end) <= toMinutes(window.start)) { + return "End time must be after start time"; + } + if (window.recurrence === "biweekly" && !window.anchorDate) { + return "Pick the first week for every-other-week hours"; + } + const clash = windows.find( + (other, otherIndex) => + otherIndex !== index && + other.start && + other.end && + toMinutes(other.end) > toMinutes(other.start) && + windowsConflict(window, other), + ); + if (clash) { + return `Overlaps with ${formatTimeRange(clash.start, clash.end)}`; + } + return null; + }); +} + +export function hasWindowErrors(errors: (string | null)[]): boolean { + return errors.some((error) => error !== null); +} + +export function todayInTimeZone(timeZone: string, now = new Date()): string { + return new Intl.DateTimeFormat("en-CA", { + timeZone, + year: "numeric", + month: "2-digit", + day: "2-digit", + }).format(now); +} + +export function addDays(ymd: string, days: number): string { + return ymdFromUtc(utcMidnight(ymd) + days * MS_PER_DAY); +} + +export function weekdayOf(ymd: string): Weekday { + return new Date(utcMidnight(ymd)).getUTCDay() as Weekday; +} + +export function startOfWeekMonday(ymd: string): string { + return addDays(ymd, -((weekdayOf(ymd) + 6) % 7)); +} + +export function nextWeekdayOnOrAfter(ymd: string, weekday: Weekday): string { + return addDays(ymd, (weekday - weekdayOf(ymd) + 7) % 7); +} + +export function formatShortDate(ymd: string, withYear = false): string { + const [year, month, day] = ymd.split("-").map(Number); + const base = `${WEEKDAY_SHORT[weekdayOf(ymd)]}, ${MONTHS_SHORT[month - 1]} ${day}`; + return withYear ? `${base}, ${year}` : base; +} + +export function toYmd(date: Date): string { + return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart( + 2, + "0", + )}-${String(date.getDate()).padStart(2, "0")}`; +} + +export function fromYmd(ymd: string): Date { + const [year, month, day] = ymd.split("-").map(Number); + return new Date(year, month - 1, day); +} + +export function formatTimeZone(timeZone: string, onDate: string): string { + const city = timeZone.split("/").pop()?.replaceAll("_", " ") ?? timeZone; + try { + const offset = new Intl.DateTimeFormat("en-US", { + timeZone, + timeZoneName: "shortOffset", + }) + .formatToParts(new Date(utcMidnight(onDate) + 12 * 60 * 60 * 1000)) + .find((part) => part.type === "timeZoneName")?.value; + return offset ? `${city} (${offset})` : city; + } catch { + return timeZone; + } +} + +const COMMON_TIMEZONES = [ + "America/St_Johns", + "America/Halifax", + "America/Toronto", + "America/New_York", + "America/Winnipeg", + "America/Chicago", + "America/Regina", + "America/Edmonton", + "America/Denver", + "America/Vancouver", + "America/Los_Angeles", + "Europe/London", + "Europe/Paris", + "UTC", +]; + +export function timeZoneOptions(current: string): string[] { + return COMMON_TIMEZONES.includes(current) + ? COMMON_TIMEZONES + : [current, ...COMMON_TIMEZONES]; +} + +export const PREVIEW_WEEKS = 4; + +export function previewRange(today: string, page: number) { + const weekStart = addDays( + startOfWeekMonday(today), + page * PREVIEW_WEEKS * 7, + ); + return { + weekStart, + from: weekStart < today ? today : weekStart, + to: addDays(weekStart, PREVIEW_WEEKS * 7 - 1), + }; +} + +export type PreviewDay = { + date: string; + isPast: boolean; + windows: AvailabilityOccurrence[]; + override: "custom" | "day_off" | null; +}; + +export function buildPreviewWeeks({ + weekStart, + weeks, + today, + occurrences, + overrides, +}: { + weekStart: string; + weeks: number; + today: string; + occurrences: AvailabilityOccurrence[]; + overrides: { date: string; windows: AvailabilityOverrideWindow[] }[]; +}): PreviewDay[][] { + const byDate = new Map(); + for (const occurrence of occurrences) { + const list = byDate.get(occurrence.date) ?? []; + list.push(occurrence); + byDate.set(occurrence.date, list); + } + const overrideByDate = new Map( + overrides.map((override) => [override.date, override.windows]), + ); + + return Array.from({ length: weeks }, (_, week) => + Array.from({ length: 7 }, (_, day) => { + const date = addDays(weekStart, week * 7 + day); + const isPast = utcMidnight(date) < utcMidnight(today); + const override = overrideByDate.get(date); + return { + date, + isPast, + windows: isPast + ? [] + : [...(byDate.get(date) ?? [])].sort( + (a, b) => toMinutes(a.start) - toMinutes(b.start), + ), + override: + isPast || override === undefined + ? null + : override.length === 0 + ? "day_off" + : "custom", + }; + }), + ); +} diff --git a/lib/availability.ts b/lib/availability.ts index 3e81f6f..80ed51d 100644 --- a/lib/availability.ts +++ b/lib/availability.ts @@ -47,6 +47,35 @@ import type { return weeks % 2 === 0; } + export function toMinutes(time: string): number { + const [hours, minutes] = time.split(":").map(Number); + return hours * 60 + minutes; + } + + type ComparableWindow = { + start: string; + end: string; + recurrence?: "weekly" | "biweekly"; + anchorDate?: string; + }; + + function canCoincide(a: ComparableWindow, b: ComparableWindow): boolean { + if (a.recurrence !== "biweekly" || b.recurrence !== "biweekly") return true; + if (!a.anchorDate || !b.anchorDate) return true; + return onSameBiweeklyCycle(a.anchorDate, b.anchorDate); + } + + export function windowsConflict( + a: ComparableWindow, + b: ComparableWindow, + ): boolean { + return ( + toMinutes(a.start) < toMinutes(b.end) && + toMinutes(b.start) < toMinutes(a.end) && + canCoincide(a, b) + ); + } + function isBiweeklyOn(ymd: string, anchorDate: string): boolean { if (utcMidnight(ymd) < utcMidnight(anchorDate)) return false; return onSameBiweeklyCycle(ymd, anchorDate);