333 lines
12 KiB
TypeScript
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>
|
|
);
|
|
}
|