Files
cal-diy-oidc/apps/scheduler/components/CalendarWorkspace.tsx
T
ZachariahSharma e6fc365278
Create PR containing updated CHANGELOG.md and release packages to NPM once PR is merged / Release (push) Has been cancelled
Run i18n AI automation / Run i18n (push) Has been cancelled
Next.js Bundle Analysis / analyze (push) Has been cancelled
Remove scheduler connections and meeting extras
2026-06-15 15:10:39 -06:00

333 lines
12 KiB
TypeScript

"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { MutualSlot, PublicBusyBlock, SchedulerUser } from "@scheduler/lib/scheduler/types";
type CalendarWorkspaceProps = {
viewerId: number;
team: SchedulerUser[];
initialBusy: PublicBusyBlock[];
initialSlots: MutualSlot[];
/** Server-computed Monday 00:00 ISO of the visible week — shared so the first
* client fetch range matches the data rendered on the server. */
weekStartIso: string;
};
const DURATION_PRESETS = [15, 30, 45, 60] as const;
const DAY_START_HOUR = 7;
const DAY_END_HOUR = 19;
const PX_PER_HOUR = 48;
const WEEKDAYS = ["Mon", "Tue", "Wed", "Thu", "Fri"] as const;
const REFETCH_DEBOUNCE_MS = 300;
type CreateState =
| { kind: "idle" }
| { kind: "saving" }
| { kind: "error"; message: string }
| { kind: "unavailable"; message: string }
| { kind: "created" };
/** Monday 00:00 (local) of the week containing `from`. */
function startOfWeek(from: Date): Date {
const date = new Date(from);
date.setHours(0, 0, 0, 0);
const weekday = date.getDay(); // 0 = Sun
const mondayOffset = weekday === 0 ? -6 : 1 - weekday;
date.setDate(date.getDate() + mondayOffset);
return date;
}
function addDays(date: Date, days: number): Date {
const next = new Date(date);
next.setDate(next.getDate() + days);
return next;
}
function minutesIntoDay(iso: string): number {
const date = new Date(iso);
return date.getHours() * 60 + date.getMinutes();
}
function sameDay(iso: string, day: Date): boolean {
const date = new Date(iso);
return (
date.getFullYear() === day.getFullYear() &&
date.getMonth() === day.getMonth() &&
date.getDate() === day.getDate()
);
}
function offsetTop(iso: string): number {
const minutes = minutesIntoDay(iso) - DAY_START_HOUR * 60;
return (minutes / 60) * PX_PER_HOUR;
}
function blockHeight(startIso: string, endIso: string): number {
const minutes = (new Date(endIso).getTime() - new Date(startIso).getTime()) / 60_000;
return Math.max((minutes / 60) * PX_PER_HOUR, 14);
}
function formatTime(iso: string): string {
return new Date(iso).toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });
}
export function CalendarWorkspace({
viewerId,
team,
initialBusy,
initialSlots,
weekStartIso,
}: CalendarWorkspaceProps) {
const weekStart = useMemo(() => startOfWeek(new Date(weekStartIso)), [weekStartIso]);
const days = useMemo(() => WEEKDAYS.map((_, index) => addDays(weekStart, index)), [weekStart]);
const hours = useMemo(() => {
const list: number[] = [];
for (let hour = DAY_START_HOUR; hour < DAY_END_HOUR; hour += 1) list.push(hour);
return list;
}, []);
const [selectedAttendeeIds, setSelectedAttendeeIds] = useState<number[]>([viewerId]);
const [busy, setBusy] = useState<PublicBusyBlock[]>(initialBusy);
const [slots, setSlots] = useState<MutualSlot[]>(initialSlots);
const [durationMinutes, setDurationMinutes] = useState<number>(30);
const [title, setTitle] = useState("");
const [selectedSlotStart, setSelectedSlotStart] = useState<string | null>(null);
const [createState, setCreateState] = useState<CreateState>({ kind: "idle" });
const [loading, setLoading] = useState(false);
const isFirstRender = useRef(true);
const rangeStart = days[0].toISOString();
const rangeEnd = addDays(weekStart, WEEKDAYS.length).toISOString();
const toggleAttendee = useCallback(
(id: number) => {
if (id === viewerId) return; // organizer always attends
setSelectedAttendeeIds((current) =>
current.includes(id) ? current.filter((value) => value !== id) : [...current, id]
);
},
[viewerId]
);
useEffect(() => {
if (isFirstRender.current) {
isFirstRender.current = false;
return;
}
const controller = new AbortController();
const timer = setTimeout(async () => {
setLoading(true);
try {
const params = new URLSearchParams({
attendeeIds: selectedAttendeeIds.join(","),
rangeStart,
rangeEnd,
durationMinutes: String(durationMinutes),
});
const response = await fetch(`/api/scheduler/availability?${params.toString()}`, {
signal: controller.signal,
});
if (!response.ok) throw new Error("availability request failed");
const data = (await response.json()) as { busy: PublicBusyBlock[]; mutualSlots: MutualSlot[] };
setBusy(data.busy);
setSlots(data.mutualSlots);
setSelectedSlotStart(null);
} catch (error) {
if (!controller.signal.aborted) {
setCreateState({ kind: "error", message: "Could not refresh availability." });
}
} finally {
if (!controller.signal.aborted) setLoading(false);
}
}, REFETCH_DEBOUNCE_MS);
return () => {
controller.abort();
clearTimeout(timer);
};
}, [selectedAttendeeIds, durationMinutes, rangeStart, rangeEnd]);
const createMeeting = useCallback(async () => {
if (!selectedSlotStart || !title.trim()) {
setCreateState({ kind: "error", message: "Pick a slot and enter a title first." });
return;
}
setCreateState({ kind: "saving" });
try {
const response = await fetch("/api/scheduler/meetings", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
title: title.trim(),
attendeeIds: selectedAttendeeIds,
start: selectedSlotStart,
durationMinutes,
}),
});
if (response.status === 501) {
setCreateState({
kind: "unavailable",
message: "Meeting booking is not wired up yet (CAL_BOOKING_SERVICE_BOUNDARY).",
});
return;
}
if (response.status === 409) {
setCreateState({ kind: "unavailable", message: "That slot is no longer available." });
return;
}
if (!response.ok) throw new Error("create failed");
setCreateState({ kind: "created" });
} catch {
setCreateState({ kind: "error", message: "Could not create the meeting." });
}
}, [selectedSlotStart, title, selectedAttendeeIds, durationMinutes]);
return (
<div className="calendar-layout">
<section className="calendar-panel" aria-label="Team week">
<header className="panel-header">
<h1>Calendar</h1>
<span className="muted">{loading ? "Updating availability…" : "Mutual free slots outlined"}</span>
</header>
<div className="week-scroller">
<div className="week-grid">
<div className="time-gutter">
{hours.map((hour) => (
<div key={hour} className="hour-label" style={{ height: PX_PER_HOUR }}>
{hour}:00
</div>
))}
</div>
{days.map((day, index) => (
<div key={day.toISOString()} className="day-column">
<div className="day-heading">
{WEEKDAYS[index]} {day.getMonth() + 1}/{day.getDate()}
</div>
<div className="day-body" style={{ height: (DAY_END_HOUR - DAY_START_HOUR) * PX_PER_HOUR }}>
{busy
.filter((block) => sameDay(block.start, day))
.map((block) => {
// Teammate blocks never reveal a title, even if one leaks
// past the server-side privacy filter.
const label = block.userId === viewerId ? block.title ?? "Busy" : "Busy";
return (
<div
key={block.id}
className="busy-block"
role="img"
aria-label={`${WEEKDAYS[index]} ${formatTime(block.start)} ${label}`}
data-own={block.userId === viewerId}
style={{ top: offsetTop(block.start), height: blockHeight(block.start, block.end) }}
title={label}>
{label}
</div>
);
})}
{slots
.filter((slot) => sameDay(slot.start, day))
.map((slot) => (
<button
key={slot.start}
type="button"
className="mutual-slot"
data-selected={slot.start === selectedSlotStart}
aria-label={`Schedule ${WEEKDAYS[index]} at ${formatTime(slot.start)}`}
aria-pressed={slot.start === selectedSlotStart}
style={{ top: offsetTop(slot.start), height: blockHeight(slot.start, slot.end) }}
onClick={() => setSelectedSlotStart(slot.start)}>
{formatTime(slot.start)}
</button>
))}
</div>
</div>
))}
</div>
</div>
</section>
<aside className="composer" aria-label="Meeting composer">
<h2>Schedule meeting</h2>
<fieldset className="composer-group">
<legend>Attendees</legend>
{team.map((member) => (
<label key={member.id} className="attendee-row">
<input
type="checkbox"
checked={selectedAttendeeIds.includes(member.id)}
disabled={member.id === viewerId}
onChange={() => toggleAttendee(member.id)}
/>
<span>
{member.name}
{member.id === viewerId ? " (you)" : ""}
</span>
</label>
))}
</fieldset>
<label className="field">
<span>Title</span>
<input value={title} onChange={(event) => setTitle(event.target.value)} placeholder="Sync" />
</label>
<fieldset className="composer-group">
<legend>Duration</legend>
<div className="duration-presets">
{DURATION_PRESETS.map((preset) => (
<button
key={preset}
type="button"
className="secondary-button"
data-active={durationMinutes === preset}
onClick={() => setDurationMinutes(preset)}>
{preset}m
</button>
))}
<input
type="number"
min={5}
step={5}
className="duration-custom"
value={durationMinutes}
onChange={(event) => setDurationMinutes(Math.max(5, Number(event.target.value) || 5))}
aria-label="Custom duration in minutes"
/>
</div>
</fieldset>
<div className="selected-slot">
{selectedSlotStart ? (
<span>
Selected:{" "}
{new Date(selectedSlotStart).toLocaleString([], {
weekday: "short",
hour: "numeric",
minute: "2-digit",
})}
</span>
) : (
<span className="muted">Pick a mutual slot on the calendar.</span>
)}
</div>
<button
type="button"
className="primary-button"
onClick={createMeeting}
disabled={createState.kind === "saving"}>
{createState.kind === "saving" ? "Creating…" : "Create meeting"}
</button>
{createState.kind === "error" && <p className="notice notice-error">{createState.message}</p>}
{createState.kind === "unavailable" && <p className="notice notice-warn">{createState.message}</p>}
{createState.kind === "created" && <p className="notice notice-ok">Meeting created.</p>}
</aside>
</div>
);
}