Updated Calendard
CI / ci (push) Canceled after 0s

The calendar is now react-big-calendar.
This commit is contained in:
Stefan Lazic committed 2026-10-08 11:01:32 +01:00
1 parent 8c267eff25
commit a8a8d1e045
10 files changed
+956 -230

No files matched your search

+7 -1
View File
@@ -244,7 +244,13 @@ export default async function OwnerCalendarPage({
<EmptyState icon={CalendarX2} title="No active staff" description="Add a staff member from Team." />
</div>
) : (
<DayCalendar columns={columns} currentTimeMinutes={currentTimeMinutes} appointmentHrefBase="/owner/appointments" />
<DayCalendar
columns={columns}
date={date}
currentTimeMinutes={currentTimeMinutes}
appointmentHrefBase="/owner/appointments"
reschedule="owner"
/>
)
) : appointments.length === 0 ? (
<div className="px-6">
+1 -1
View File
@@ -187,7 +187,7 @@ export default async function StaffCalendarPage({
description={wholeDayOff.reason ?? "Approved time off."}
/>
) : view === "day" ? (
<DayCalendar columns={columns} currentTimeMinutes={currentTimeMinutes} />
<DayCalendar columns={columns} date={date} currentTimeMinutes={currentTimeMinutes} reschedule="staff" />
) : calendarAppointments.length === 0 ? (
<div className="px-6 lg:px-0">
<EmptyState
@@ -0,0 +1,55 @@
"use server";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { getActingBusinessContext } from "@/lib/business/get-acting-business-context";
import { wallClockToInstant } from "@/lib/time/business-time";
import { ownerRescheduleAppointmentAction } from "@/features/owner/actions/appointments";
import { rescheduleStaffAppointmentAction } from "./appointment-actions";
const moveSchema = z.object({
appointmentId: z.string().min(1),
/** The column it was dropped in — a different one than it started in is a reassignment (owner only). */
staffId: z.string().min(1),
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
startMinutes: z.number().int().min(0).max(24 * 60 - 1),
mode: z.enum(["owner", "staff"]),
});
export interface MoveCalendarAppointmentResult {
success: boolean;
error?: string;
}
/**
* Drag-to-reschedule from the calendar. A thin adapter, not a second
* reschedule path: it only turns "this date, these business-local minutes"
* into the instant the existing actions already take, then delegates — so
* slot validation (working hours, breaks, overlaps, rules), the audit log,
* customer notifications, reminder rescheduling and Google Calendar sync all
* behave exactly as they do from the appointment screen. Each delegate
* enforces its own permission (owner: any appointment in the business;
* staff: their own only), and the `mode` here is a request, never trusted.
*/
export async function moveCalendarAppointmentAction(input: unknown): Promise<MoveCalendarAppointmentResult> {
const parsed = moveSchema.safeParse(input);
if (!parsed.success) return { success: false, error: "That move isn't valid." };
const context = await getActingBusinessContext();
if (!context) return { success: false, error: "You must be signed in." };
const { appointmentId, staffId, date, startMinutes, mode } = parsed.data;
const startAt = wallClockToInstant(date, startMinutes, context.businessTimezone).toISOString();
const result =
mode === "owner"
? await ownerRescheduleAppointmentAction({ appointmentId, staffId, startAt })
: // A barber moves their own bookings in time only — never to someone else.
await rescheduleStaffAppointmentAction({ appointmentId, startAt });
if (result.success) {
revalidatePath("/owner/calendar");
revalidatePath("/staff");
}
return result;
}
@@ -0,0 +1,26 @@
import { describe, expect, it } from "vitest";
import {
floatingDateToMinutes,
floatingDisplayDate,
floatingNotToday,
minutesToFloatingDate,
} from "../calendar-time";
describe("calendar floating time", () => {
it("round-trips every quarter hour of the day", () => {
for (let minutes = 0; minutes < 24 * 60; minutes += 15) {
expect(floatingDateToMinutes(minutesToFloatingDate(minutes))).toBe(minutes);
}
});
it("puts every time on the same single calendar day, regardless of the minute", () => {
const day = (m: number) => minutesToFloatingDate(m).toDateString();
expect(day(0)).toBe(day(7 * 60));
expect(day(0)).toBe(day(23 * 60 + 45));
expect(day(0)).toBe(floatingDisplayDate().toDateString());
});
it("'not today' is a different day than the displayed one", () => {
expect(floatingNotToday().toDateString()).not.toBe(floatingDisplayDate().toDateString());
});
});
@@ -0,0 +1,34 @@
/**
* The calendar library works in JavaScript `Date`s, which carry the
* BROWSER's time zone — but this app's scheduling data is in business-local
* minutes-since-midnight on a business-local calendar date (see
* lib/time/business-time.ts). Mixing the two is how a barbershop in
* London shows a 10:00 booking at 05:00 to someone viewing from New York.
*
* So the calendar never sees a real instant. It's handed "floating" dates —
* minutes laid onto one fixed, arbitrary day — and reports a drop back as
* plain minutes-since-midnight; the actual date and the business time zone
* are applied on the server (see features/staff/actions/calendar-move.ts).
* The day is a mid-winter Monday so no time zone's DST change can land on it.
*/
const BASE_YEAR = 2000;
const BASE_MONTH = 0;
const BASE_DAY = 3;
export function minutesToFloatingDate(minutes: number): Date {
return new Date(BASE_YEAR, BASE_MONTH, BASE_DAY, Math.floor(minutes / 60), minutes % 60, 0, 0);
}
export function floatingDateToMinutes(date: Date): number {
return date.getHours() * 60 + date.getMinutes();
}
/** The day the calendar displays — any date on the floating day. */
export function floatingDisplayDate(): Date {
return minutesToFloatingDate(0);
}
/** A moment guaranteed to fall on a different day than the displayed one — passed as "now" when the viewed day isn't today, so the library draws no current-time line. */
export function floatingNotToday(): Date {
return new Date(BASE_YEAR, BASE_MONTH, BASE_DAY - 2, 12, 0, 0, 0);
}
@@ -0,0 +1,158 @@
/*
* Restyles react-big-calendar (see day-calendar.tsx) to match the app: the
* library draws its own box, colours and event chrome, so everything here
* either removes that or swaps in the app's tokens. Scoped under .day-cal so
* nothing leaks to any other page. Colours use the theme variables with a
* literal fallback in case Tailwind hasn't emitted one.
*/
.day-cal {
--dc-line: var(--color-line, #e1e7e2);
--dc-muted: var(--color-muted, #5c6961);
--dc-danger: var(--color-danger, #ef4444);
}
/* Dragging a booking must never highlight the text around it. */
.day-cal .rbc-calendar {
user-select: none;
-webkit-user-select: none;
}
.day-cal .rbc-calendar,
.day-cal .rbc-time-view {
background: transparent;
border: 0;
font-family: inherit;
}
/* Header row: the app's avatar + name + hours, no library chrome. */
.day-cal .rbc-time-header {
border: 0;
margin-right: 0 !important; /* the library reserves scrollbar width; the page scrolls instead */
}
/* Stays in view under the page's sticky top bar (81px tall) so, halfway down
the day, you can still see whose column you're looking at. */
.day-cal .rbc-time-header {
position: sticky;
top: 81px;
z-index: 25;
background: var(--color-stone, #f4f6f4);
}
.day-cal .rbc-time-header-content {
border-left: 1px solid var(--dc-line);
}
.day-cal .rbc-header {
border-bottom: 0;
border-left: 1px solid var(--dc-line);
padding: 0;
font-weight: inherit;
overflow: visible;
}
.day-cal .rbc-header:first-child {
border-left: 0;
}
/* No all-day row — appointments always have a time. */
.day-cal .rbc-allday-cell,
.day-cal .rbc-time-header-cell + .rbc-row {
display: none;
}
/* Grid. */
.day-cal .rbc-time-content {
border-top: 1px solid var(--dc-line);
overflow-y: visible; /* full-height grid; the page owns the scrollbar */
}
.day-cal .rbc-time-content > * + * > * {
border-left: 1px solid var(--dc-line);
}
.day-cal .rbc-timeslot-group {
min-height: 112px; /* 4 x 28px quarter-hour rows == the app's 112px per hour */
border-bottom: 1px solid var(--dc-line);
}
.day-cal .rbc-time-slot {
border-top: 0;
}
.day-cal .rbc-day-slot .rbc-time-slot {
border-top: 1px solid rgba(0, 0, 0, 0.04);
}
.day-cal .rbc-day-slot .rbc-time-slot:first-child {
border-top: 0;
}
.day-cal .rbc-today {
background: transparent;
}
/* Wide enough to read a booking; narrower screens scroll sideways with the
hour labels pinned. The header cells must match the column width exactly or
a name stops lining up with its column once the row overflows. */
.day-cal .rbc-time-view-resources .rbc-day-slot,
.day-cal .rbc-time-view-resources .rbc-header,
.day-cal .rbc-time-view-resources .rbc-day-bg {
min-width: 190px; /* a minimum, not a width — wider screens still stretch the columns to fill */
}
/* Hour labels. */
.day-cal .rbc-time-gutter .rbc-timeslot-group {
border-bottom: 0;
}
.day-cal .rbc-label {
padding: 0 8px 0 0;
color: var(--dc-muted);
font-size: 10px;
font-weight: 700;
}
.day-cal .rbc-time-gutter,
.day-cal .rbc-time-header-gutter {
background: var(--color-stone, #f4f6f4);
z-index: 5;
}
/* Current time. */
.day-cal .rbc-current-time-indicator {
height: 2px;
background-color: var(--dc-danger);
z-index: 30;
}
/* Outside the working window: the same diagonal hatch as breaks/time off. */
.day-cal .day-cal-offhours {
background-color: rgba(225, 231, 226, 0.15);
background-image: repeating-linear-gradient(135deg, transparent, transparent 6px, rgba(0, 0, 0, 0.05) 6px, rgba(0, 0, 0, 0.05) 12px);
}
/* Events: the library's coloured box is replaced by the app's own block (EventContent). */
.day-cal .rbc-event,
.day-cal .rbc-background-event {
background: transparent;
border: 0;
padding: 0;
box-shadow: none;
border-radius: 0;
outline: none;
}
.day-cal .rbc-event .rbc-event-content {
height: 100%;
}
.day-cal .rbc-event.rbc-selected {
background: transparent;
}
.day-cal .rbc-background-event {
z-index: 1;
opacity: 1;
pointer-events: none;
}
.day-cal .rbc-event:focus-visible > .rbc-event-content > div {
outline: 2px solid var(--color-ink, #111b15);
outline-offset: 1px;
}
/* The library's own time label is hidden — EventContent draws a compact one. */
.day-cal .rbc-event-label {
display: none;
}
.day-cal .day-cal-movable {
cursor: grab;
}
.day-cal .day-cal-movable:active {
cursor: grabbing;
}
.day-cal .rbc-addons-dnd-drag-preview {
opacity: 0.85;
}
+366 -220
View File
@@ -1,42 +1,36 @@
"use client";
import Image from "next/image";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useEffect, useMemo, useRef, useState, useTransition } from "react";
import { Calendar, dateFnsLocalizer } from "react-big-calendar";
import withDragAndDrop from "react-big-calendar/lib/addons/dragAndDrop";
import type { EventInteractionArgs } from "react-big-calendar/lib/addons/dragAndDrop";
import { format, getDay, parse, startOfWeek } from "date-fns";
import { enGB } from "date-fns/locale";
import { Ban, CheckCheck, CircleDot, Clock, Gift, LogIn, NotebookText, UserX } from "lucide-react";
import type { LucideIcon } from "lucide-react";
import "react-big-calendar/lib/css/react-big-calendar.css";
import "react-big-calendar/lib/addons/dragAndDrop/styles.css";
import "./day-calendar.css";
import { Button } from "@/components/ui/button";
import { Modal } from "@/components/ui/modal";
import { cn } from "@/lib/utils/cn";
import type { AppointmentStatus } from "@/types/domain";
import { moveCalendarAppointmentAction } from "../actions/calendar-move";
import {
floatingDateToMinutes,
floatingDisplayDate,
floatingNotToday,
minutesToFloatingDate,
} from "./calendar-time";
const START_HOUR = 7;
const END_HOUR = 21;
const PX_PER_HOUR = 112;
const MINUTES_PER_HOUR = 60;
const RANGE_START_MINUTES = START_HOUR * MINUTES_PER_HOUR;
const RANGE_END_MINUTES = END_HOUR * MINUTES_PER_HOUR;
const HEADER_HEIGHT = 56;
function topFor(minutes: number): number {
return ((clampToRange(minutes) - RANGE_START_MINUTES) / MINUTES_PER_HOUR) * PX_PER_HOUR;
}
function clampToRange(minutes: number): number {
return Math.min(RANGE_END_MINUTES, Math.max(RANGE_START_MINUTES, minutes));
}
function heightFor(startMinutes: number, endMinutes: number): number {
return Math.max(24, topFor(endMinutes) - topFor(startMinutes));
}
/** Compact "9:30" / "1:15" clock text — no AM/PM, matching the tight in-block time-range label. */
function formatCompactTime(minutes: number): string {
const h = Math.floor(minutes / MINUTES_PER_HOUR);
const m = minutes % MINUTES_PER_HOUR;
const displayHour = h % 12 === 0 ? 12 : h % 12;
return `${displayHour}:${String(m).padStart(2, "0")}`;
}
/** "2:47 pm" — the current-time indicator's own label, read out next to its dot on the axis. */
function formatCurrentTimeLabel(minutes: number): string {
const h = Math.floor(minutes / MINUTES_PER_HOUR);
const m = minutes % MINUTES_PER_HOUR;
const period = h >= 12 ? "pm" : "am";
const displayHour = h % 12 === 0 ? 12 : h % 12;
return `${displayHour}:${String(m).padStart(2, "0")} ${period}`;
}
/** The grid is drawn full height and the PAGE scrolls — one scroll area, not a scrollbox inside the page. */
const CALENDAR_HEIGHT = HEADER_HEIGHT + (END_HOUR - START_HOUR) * PX_PER_HOUR + 8;
export interface CalendarAppointment {
id: string;
@@ -70,6 +64,30 @@ export interface CalendarColumn {
workingWindow: { startMinutes: number; endMinutes: number } | null;
}
const localizer = dateFnsLocalizer({
format,
parse,
getDay,
startOfWeek: (date: Date) => startOfWeek(date, { weekStartsOn: 1 }),
locales: { "en-GB": enGB },
});
const EMPTY_MOVES: Record<string, { startMinutes: number; resourceId: string }> = {};
const DnDCalendar = withDragAndDrop<CalendarEvent, CalendarResource>(Calendar);
interface CalendarResource {
id: string;
column: CalendarColumn;
}
type CalendarEvent =
| { kind: "appointment"; id: string; resourceId: string; start: Date; end: Date; title: string; appointment: CalendarAppointment }
| { kind: "block"; id: string; resourceId: string; start: Date; end: Date; title: string; block: CalendarBlock };
type AppointmentEvent = Extract<CalendarEvent, { kind: "appointment" }>;
/** Statuses the booking service will actually reschedule (see NON_RESCHEDULABLE_STATUSES in booking.service.ts). */
const MOVABLE_STATUSES: AppointmentStatus[] = ["pending_approval", "confirmed", "arrived"];
const STATUS_BADGE_ICON: Record<AppointmentStatus, LucideIcon> = {
pending_approval: Clock,
confirmed: CheckCheck,
@@ -80,222 +98,350 @@ const STATUS_BADGE_ICON: Record<AppointmentStatus, LucideIcon> = {
no_show: UserX,
};
/** Column width on small screens — under 100% so the next column peeks in at the edge, inviting a swipe (per the reference design). Desktop ignores this and flexes to fill instead. */
const COLUMN_WIDTH_CLASS = "w-[86%] shrink-0 snap-start sm:w-[300px] lg:w-auto lg:flex-1";
/**
* A shared-axis, multi-column, swipeable day grid — one time axis pinned to
* the left edge (via `sticky`, so it never scrolls out of view), one column
* per staff member snapping into place as the row is swiped horizontally
* (CSS scroll-snap — no touch/gesture JS needed, matching this app's
* "reliable tap/scroll over fragile custom gestures" preference). A single
* staff member's own view (staff/page.tsx) is just the one-column case of
* the same component. Deliberately no drag-and-drop — every interactive
* element is a plain Link (open details / start a reschedule from there).
* Pure presentation: every minute value here is already computed by the
* page from real WorkingHours/Break/TimeOff/Appointment rows.
* The day grid for one date — one time axis, one column per staff member.
* A single staff member's own view is just the one-column case.
*
* Built on react-big-calendar, but everything it draws is restyled to match
* the app (see day-calendar.css) and it never sees a real time zone: times
* are handed over as "floating" dates (see calendar-time.ts).
*
* `reschedule` turns on drag-to-move. A drop never saves by itself — it opens
* a confirmation first, because a reschedule emails the customer. "owner"
* lets a booking be dragged to another barber's column (a reassignment);
* "staff" only along the time axis of their own column. The server re-checks
* everything (see moveCalendarAppointmentAction), so this is a convenience,
* not the gate. Omit it for a read-only calendar.
*/
export function DayCalendar({
columns,
date,
currentTimeMinutes,
appointmentHrefBase = "/staff/appointments",
reschedule,
}: {
columns: CalendarColumn[];
/** The business-local calendar date being shown ("YYYY-MM-DD") — needed only to turn a drop back into a real instant on the server. */
date: string;
/** Minutes-from-midnight right now, only passed when the viewed day is today. */
currentTimeMinutes: number | null;
/** Owner's all-staff calendar reuses this component unchanged but links into /owner/appointments/[id] instead — see docs/phase-6-summary.md, "All-staff calendar approach". */
/** Owner's all-staff calendar links into /owner/appointments/[id] instead — see docs/phase-6-summary.md, "All-staff calendar". */
appointmentHrefBase?: string;
reschedule?: "owner" | "staff";
}) {
const hourMarks = Array.from({ length: END_HOUR - START_HOUR + 1 }, (_, i) => START_HOUR + i);
const gridHeight = (END_HOUR - START_HOUR) * PX_PER_HOUR;
const showCurrentTime =
currentTimeMinutes !== null && currentTimeMinutes >= RANGE_START_MINUTES && currentTimeMinutes <= RANGE_END_MINUTES;
const router = useRouter();
const [isPending, startTransition] = useTransition();
const rootRef = useRef<HTMLDivElement>(null);
const [pendingMove, setPendingMove] = useState<{
event: AppointmentEvent;
resourceId: string;
startMinutes: number;
} | null>(null);
const [moveError, setMoveError] = useState<string | null>(null);
/**
* Moves that already succeeded, shown in place until the refreshed server
* data arrives. Tied to the `columns` they were made against, so a new
* `columns` (the refresh landing) discards them with no effect needed.
*/
const [movedState, setMovedState] = useState<{
forColumns: CalendarColumn[];
positions: Record<string, { startMinutes: number; resourceId: string }>;
}>({ forColumns: columns, positions: {} });
const moved = movedState.forColumns === columns ? movedState.positions : EMPTY_MOVES;
const { events, backgroundEvents, resources } = useMemo(() => {
const events: CalendarEvent[] = [];
const backgroundEvents: CalendarEvent[] = [];
for (const column of columns) {
for (const appointment of column.appointments) {
const override = moved[appointment.id];
const start = override ? override.startMinutes : appointment.startMinutes;
const length = appointment.endMinutes - appointment.startMinutes;
events.push({
kind: "appointment",
id: appointment.id,
resourceId: override ? override.resourceId : column.id,
start: minutesToFloatingDate(start),
end: minutesToFloatingDate(start + length),
title: appointment.customerName,
appointment,
});
}
for (const block of column.blocks) {
backgroundEvents.push({
kind: "block",
id: block.id,
resourceId: column.id,
start: minutesToFloatingDate(block.startMinutes),
end: minutesToFloatingDate(block.endMinutes),
title: block.label,
block,
});
}
}
return {
events,
backgroundEvents,
resources: columns.map<CalendarResource>((column) => ({ id: column.id, column })),
};
}, [columns, moved]);
const columnById = useMemo(() => new Map(columns.map((c) => [c.id, c])), [columns]);
// On today's view, bring the current time into view — the grid is a full
// 14 hours tall and the page itself scrolls, so without this a barber
// opening the calendar at 3pm lands on 7am.
useEffect(() => {
if (currentTimeMinutes === null) return;
// The library draws the line a moment after mount, so wait for it.
const timer = window.setTimeout(() => {
rootRef.current?.querySelector(".rbc-current-time-indicator")?.scrollIntoView({ block: "center" });
}, 150);
return () => window.clearTimeout(timer);
// Once, on first render of the day — not on every refresh after a move.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const canMove = (event: CalendarEvent) =>
!!reschedule && event.kind === "appointment" && MOVABLE_STATUSES.includes(event.appointment.status);
const handleDrop = ({ event, start, resourceId }: EventInteractionArgs<CalendarEvent>) => {
if (event.kind !== "appointment") return;
const startMinutes = floatingDateToMinutes(new Date(start));
// A barber moves their own booking in time only.
const targetResourceId = reschedule === "owner" && typeof resourceId === "string" ? resourceId : event.resourceId;
if (startMinutes === event.appointment.startMinutes && targetResourceId === event.resourceId) return;
setMoveError(null);
setPendingMove({ event, resourceId: targetResourceId, startMinutes });
};
const confirmMove = () => {
if (!pendingMove || !reschedule) return;
const { event, resourceId, startMinutes } = pendingMove;
startTransition(async () => {
const result = await moveCalendarAppointmentAction({
appointmentId: event.id,
staffId: resourceId,
date,
startMinutes,
mode: reschedule,
});
if (!result.success) {
setMoveError(result.error ?? "Couldn't move this appointment.");
return;
}
setMovedState({ forColumns: columns, positions: { ...moved, [event.id]: { startMinutes, resourceId } } });
setPendingMove(null);
router.refresh();
});
};
const fromName = pendingMove ? columnById.get(pendingMove.event.resourceId)?.name : undefined;
const toName = pendingMove ? columnById.get(pendingMove.resourceId)?.name : undefined;
const reassigning = !!pendingMove && pendingMove.resourceId !== pendingMove.event.resourceId;
const scrollMinutes = Math.max(START_HOUR * 60, (currentTimeMinutes ?? 9 * 60) - 60);
return (
<div className="flex gap-2 px-6 pb-8" data-testid="calendar-grid">
{/* Time axis — a plain sibling of the scrolling columns strip below, not part of its horizontal scroll at all, so it's immune to any scroll-snap/sticky edge cases: it simply never moves. */}
<div className="w-11 shrink-0" style={{ height: HEADER_HEIGHT + gridHeight }}>
<div style={{ height: HEADER_HEIGHT }} aria-hidden="true" />
<div className="relative" style={{ height: gridHeight }}>
{hourMarks.map((hour) =>
hour === END_HOUR ? null : (
<div key={hour} className="absolute inset-x-0" style={{ top: (hour - START_HOUR) * PX_PER_HOUR }}>
<span className="text-muted absolute -top-2 right-0 text-[10px] font-bold">{formatHour(hour)}</span>
{[15, 30, 45].map((minute) => (
<span
key={minute}
className="text-muted/70 absolute right-0 text-[9px]"
style={{ top: (minute / MINUTES_PER_HOUR) * PX_PER_HOUR - 6 }}
>
{minute}
</span>
))}
</div>
),
)}
{showCurrentTime && (
<div
className="absolute inset-x-0 z-40 flex items-center justify-end gap-1"
style={{ top: topFor(currentTimeMinutes!) }}
aria-hidden="true"
>
<span className="bg-danger h-2 w-2 shrink-0 -translate-y-1/2 rounded-full" />
<span className="bg-danger absolute left-full -translate-y-1/2 rounded px-1.5 py-0.5 text-[9px] font-bold whitespace-nowrap text-white">
{formatCurrentTimeLabel(currentTimeMinutes!)}
</span>
</div>
)}
</div>
<div ref={rootRef} className="day-cal px-6 pb-8" data-testid="calendar-grid">
<div style={{ height: CALENDAR_HEIGHT }}>
<DnDCalendar
localizer={localizer}
culture="en-GB"
toolbar={false}
view="day"
views={["day"]}
date={floatingDisplayDate()}
onNavigate={() => {}}
onView={() => {}}
getNow={() => (currentTimeMinutes === null ? floatingNotToday() : minutesToFloatingDate(currentTimeMinutes))}
events={events}
backgroundEvents={backgroundEvents}
resources={resources}
resourceIdAccessor={(r) => r.id}
resourceTitleAccessor={(r) => r.column.name}
step={15}
timeslots={4}
min={minutesToFloatingDate(START_HOUR * 60)}
max={minutesToFloatingDate(END_HOUR * 60)}
scrollToTime={minutesToFloatingDate(scrollMinutes)}
dayLayoutAlgorithm="no-overlap"
selectable={false}
resizable={false}
draggableAccessor={canMove}
onEventDrop={handleDrop}
onSelectEvent={(event) => {
if (event.kind === "appointment") router.push(`${appointmentHrefBase}/${event.id}`);
}}
eventPropGetter={(event) => ({
className: cn("day-cal-appointment", canMove(event) && "day-cal-movable"),
})}
slotPropGetter={(slotDate, resourceId) =>
isOffHours(typeof resourceId === "string" ? columnById.get(resourceId) : undefined, slotDate)
? { className: "day-cal-offhours" }
: {}
}
formats={{
timeGutterFormat: (value) => formatHourLabel(value.getHours()),
// Hidden visually (the block draws its own compact time) but kept for
// the accessible name, which the library builds as "<range>: <title>".
eventTimeRangeFormat: ({ start, end }) =>
`${formatClock(floatingDateToMinutes(start))} – ${formatClock(floatingDateToMinutes(end))}`,
}}
components={{
event: EventContent,
resourceHeader: ({ resource }) => <ColumnHeader column={(resource as CalendarResource).column} />,
}}
/>
</div>
{/* Staff columns — a separately-scrolling strip, so swiping through staff never drags the axis along with it. Each column is a full swipeable "page": its own header, then its own grid body. */}
<div className="snap-x snap-mandatory flex flex-1 gap-3 overflow-x-auto">
{columns.map((column) => (
<div key={column.id} className={COLUMN_WIDTH_CLASS}>
<div className="flex items-center gap-2 pb-3" style={{ height: HEADER_HEIGHT }}>
{column.avatarUrl ? (
<Image
src={column.avatarUrl}
alt=""
width={36}
height={36}
className="h-9 w-9 shrink-0 rounded-full border border-line object-cover"
/>
) : (
<div className="bg-white text-muted flex h-9 w-9 shrink-0 items-center justify-center rounded-full border border-line text-sm font-bold">
{column.name.charAt(0)}
</div>
)}
<div className="min-w-0">
<p className="truncate text-sm font-bold">{column.name}</p>
<p className="text-muted truncate text-[10px]">{column.hoursLabel ?? "Off today"}</p>
</div>
<Modal
open={!!pendingMove}
onClose={() => {
if (!isPending) setPendingMove(null);
}}
title={reassigning ? "Move to another barber?" : "Move this appointment?"}
>
{pendingMove && (
<div className="space-y-4">
<div className="space-y-1 text-sm">
<p className="font-bold">{pendingMove.event.appointment.customerName}</p>
<p className="text-muted">{pendingMove.event.appointment.serviceNames}</p>
<p className="pt-2">
<span className="text-muted">From </span>
{formatClock(pendingMove.event.appointment.startMinutes)}
{fromName ? ` with ${fromName}` : ""}
</p>
<p>
<span className="text-muted">To </span>
<strong>{formatClock(pendingMove.startMinutes)}</strong>
{toName ? ` with ${toName}` : ""}
</p>
</div>
<div className="border-line relative border-l" style={{ height: gridHeight }}>
{/* Hour gridlines */}
{hourMarks.map((hour) => (
<div
key={hour}
className="border-line absolute inset-x-0 border-t"
style={{ top: (hour - START_HOUR) * PX_PER_HOUR }}
/>
))}
{column.wholeDayOff ? (
<div className="bg-line/10 absolute inset-0 flex items-center justify-center">
<span className="text-muted text-xs font-bold tracking-widest uppercase">Off today</span>
</div>
) : (
<>
{/* Outside-working-hours shading — diagonal hatch, same texture as the break/time-off blocks below. */}
{column.workingWindow ? (
<>
{column.workingWindow.startMinutes > RANGE_START_MINUTES && (
<div
className={HATCH_CLASS}
style={{ top: 0, height: topFor(column.workingWindow.startMinutes) }}
aria-hidden="true"
/>
)}
{column.workingWindow.endMinutes < RANGE_END_MINUTES && (
<div
className={HATCH_CLASS}
style={{ top: topFor(column.workingWindow.endMinutes), bottom: 0 }}
aria-hidden="true"
/>
)}
</>
) : (
<div className={cn(HATCH_CLASS, "inset-0")} aria-hidden="true" />
)}
{/* Breaks / time off */}
{column.blocks.map((block) => (
<div
key={block.id}
className="border-warning/40 bg-warning/10 absolute inset-x-1 z-10 overflow-hidden rounded-lg border bg-[repeating-linear-gradient(135deg,transparent,transparent_6px,rgba(0,0,0,0.04)_6px,rgba(0,0,0,0.04)_12px)] px-2 py-1"
style={{
top: topFor(block.startMinutes),
height: heightFor(block.startMinutes, block.endMinutes),
}}
>
<p className="text-warning-foreground truncate text-[10px] font-bold tracking-widest uppercase">
{block.label}
</p>
</div>
))}
{/* Appointments */}
{column.appointments.map((appt) => {
const blockHeight = heightFor(appt.startMinutes, appt.endMinutes);
const BadgeIcon = STATUS_BADGE_ICON[appt.status];
return (
<Link
key={appt.id}
href={`${appointmentHrefBase}/${appt.id}`}
className={cn(
"absolute inset-x-1 z-20 overflow-hidden rounded-lg border-l-4 p-2 shadow-sm transition-shadow hover:shadow-md",
"focus-visible:ring-ink focus-visible:ring-2 focus-visible:ring-offset-1",
statusBlockClass(appt.status),
)}
style={{
top: topFor(appt.startMinutes),
height: blockHeight,
}}
>
<span
className={cn(
"absolute top-1 right-1 flex h-4 w-4 items-center justify-center rounded-full",
statusBadgeClass(appt.status),
)}
>
<BadgeIcon className="h-2.5 w-2.5" aria-hidden="true" />
<span className="sr-only">{statusLabel(appt.status)}</span>
</span>
<div className="flex items-center gap-1 pr-4">
<p className="truncate text-[11px] font-bold">
{formatCompactTime(appt.startMinutes)} – {formatCompactTime(appt.endMinutes)}
</p>
{appt.hasNotes && <NotebookText className="text-muted h-2.5 w-2.5 shrink-0" aria-label="Has notes" />}
{appt.hasAvailableReward && (
<Gift className="text-accent-loyalty h-2.5 w-2.5 shrink-0" aria-label="Reward available" />
)}
</div>
{blockHeight >= 44 && (
<p className="truncate text-[11px] opacity-80">
{appt.customerName} • {appt.serviceNames}
</p>
)}
</Link>
);
})}
{/* Current-time indicator */}
{showCurrentTime && (
<div
className="border-danger pointer-events-none absolute inset-x-0 z-30 border-t-2"
style={{ top: topFor(currentTimeMinutes!) }}
aria-hidden="true"
/>
)}
</>
)}
<p className="text-muted text-xs">The customer will be notified of the change.</p>
{moveError && (
<p role="alert" className="text-danger text-sm font-medium">
{moveError}
</p>
)}
<div className="flex gap-3">
<Button variant="secondary" className="flex-1" disabled={isPending} onClick={() => setPendingMove(null)}>
Cancel
</Button>
<Button className="flex-1" isLoading={isPending} onClick={confirmMove}>
Move
</Button>
</div>
</div>
))}
)}
</Modal>
</div>
);
}
/** True for slots outside a staff member's working window (or the whole column when they're off) — those get the same hatch texture the break/time-off blocks use. */
function isOffHours(column: CalendarColumn | undefined, slotDate: Date): boolean {
if (!column) return false;
const minutes = floatingDateToMinutes(slotDate);
return (
column.wholeDayOff ||
!column.workingWindow ||
minutes < column.workingWindow.startMinutes ||
minutes >= column.workingWindow.endMinutes
);
}
function ColumnHeader({ column }: { column: CalendarColumn }) {
return (
<div className="flex items-center gap-2 px-2 text-left" style={{ height: HEADER_HEIGHT }}>
{column.avatarUrl ? (
<Image
src={column.avatarUrl}
alt=""
width={36}
height={36}
className="border-line h-9 w-9 shrink-0 rounded-full border object-cover"
/>
) : (
<div className="border-line text-muted flex h-9 w-9 shrink-0 items-center justify-center rounded-full border bg-white text-sm font-bold">
{column.name.charAt(0)}
</div>
)}
<div className="min-w-0">
<p className="truncate text-sm font-bold">{column.name}</p>
<p className="text-muted truncate text-[10px] font-normal">
{column.wholeDayOff ? "Off today" : (column.hoursLabel ?? "Off today")}
</p>
</div>
</div>
);
}
const HATCH_CLASS =
"bg-line/15 absolute inset-x-0 bg-[repeating-linear-gradient(135deg,transparent,transparent_6px,rgba(0,0,0,0.05)_6px,rgba(0,0,0,0.05)_12px)]";
function EventContent({ event }: { event: CalendarEvent }) {
if (event.kind === "block") {
return (
<div className="border-warning/40 bg-warning/10 h-full overflow-hidden rounded-lg border p-1.5">
<p className="text-warning-foreground truncate text-[10px] font-bold tracking-widest uppercase">{event.block.label}</p>
</div>
);
}
function formatHour(hour: number): string {
if (hour === 0 || hour === 24) return "12:00";
if (hour === 12) return "12:00";
return `${hour > 12 ? hour - 12 : hour}:00`;
const { appointment } = event;
const BadgeIcon = STATUS_BADGE_ICON[appointment.status];
const height = ((appointment.endMinutes - appointment.startMinutes) / 60) * PX_PER_HOUR;
return (
<div
className={cn(
"relative h-full overflow-hidden rounded-lg border-l-4 p-2 text-left shadow-sm transition-shadow hover:shadow-md",
statusBlockClass(appointment.status),
)}
>
<span
className={cn(
"absolute top-1 right-1 flex h-4 w-4 items-center justify-center rounded-full",
statusBadgeClass(appointment.status),
)}
>
<BadgeIcon className="h-2.5 w-2.5" aria-hidden="true" />
<span className="sr-only">{statusLabel(appointment.status)}</span>
</span>
<div className="flex items-center gap-1 pr-4">
<p className="truncate text-[11px] font-bold">
{formatCompactTime(appointment.startMinutes)} – {formatCompactTime(appointment.endMinutes)}
</p>
{appointment.hasNotes && <NotebookText className="text-muted h-2.5 w-2.5 shrink-0" aria-label="Has notes" />}
{appointment.hasAvailableReward && (
<Gift className="text-accent-loyalty h-2.5 w-2.5 shrink-0" aria-label="Reward available" />
)}
</div>
{height >= 44 && (
<p className="truncate text-[11px] opacity-80">
{appointment.customerName} • {appointment.serviceNames}
</p>
)}
</div>
);
}
/** "9 AM" / "12 PM" gutter label. */
function formatHourLabel(hour: number): string {
const period = hour >= 12 ? "PM" : "AM";
return `${hour % 12 === 0 ? 12 : hour % 12} ${period}`;
}
/** Compact "9:30" / "1:15" clock text — no AM/PM, matching the tight in-block time-range label. */
function formatCompactTime(minutes: number): string {
const h = Math.floor(minutes / 60);
const m = minutes % 60;
const displayHour = h % 12 === 0 ? 12 : h % 12;
return `${displayHour}:${String(m).padStart(2, "0")}`;
}
/** "2:45 PM" for the confirmation dialog. */
function formatClock(minutes: number): string {
const h = Math.floor(minutes / 60);
const m = minutes % 60;
return `${h % 12 === 0 ? 12 : h % 12}:${String(m).padStart(2, "0")} ${h >= 12 ? "PM" : "AM"}`;
}
function statusLabel(status: AppointmentStatus): string {