@marco.colia/react-fast-scheduler 2.0.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,8 +1,37 @@
1
- import * as React from 'react';
2
- import { format } from 'date-fns';
3
1
  import { jsxs, jsx } from 'react/jsx-runtime';
2
+ import { format } from 'date-fns';
3
+ import * as React2 from 'react';
4
4
 
5
- // src/components/Scheduler.tsx
5
+ // src/components/defaultRenderAppointment/index.tsx
6
+ function defaultRenderAppointment({
7
+ appointment,
8
+ onPointerDown,
9
+ onResizePointerDown,
10
+ appointmentAppearance,
11
+ appointmentBackgroundColor
12
+ }) {
13
+ const rawMaybe = appointment.raw;
14
+ const description = typeof rawMaybe.description === "string" ? rawMaybe.description : void 0;
15
+ const legacyClassName = typeof appointmentBackgroundColor === "string" ? appointmentBackgroundColor : void 0;
16
+ const backgroundClassName = appointmentAppearance?.className ?? legacyClassName ?? "bg-muted/40 dark:bg-muted/30";
17
+ const className = `relative h-full cursor-grab overflow-hidden rounded-md border border-border p-2 pb-5 text-foreground ${backgroundClassName}`;
18
+ return /* @__PURE__ */ jsxs("div", { onPointerDown, className, children: [
19
+ /* @__PURE__ */ jsx("div", { className: "text-xs font-semibold", children: appointment.title }),
20
+ description ? /* @__PURE__ */ jsx("div", { className: "mt-1 text-[10px] font-medium text-muted-foreground", children: description }) : null,
21
+ /* @__PURE__ */ jsx(
22
+ "div",
23
+ {
24
+ role: "button",
25
+ "aria-label": "Resize appointment",
26
+ onPointerDown: (event) => {
27
+ event.stopPropagation();
28
+ onResizePointerDown(event);
29
+ },
30
+ className: "absolute inset-x-1 bottom-1 h-2 cursor-ns-resize rounded-full bg-border/90 transition-colors hover:bg-border"
31
+ }
32
+ )
33
+ ] });
34
+ }
6
35
 
7
36
  // src/components/constants.ts
8
37
  var STEP_MIN = 15;
@@ -12,26 +41,75 @@ var GUTTER_W = 72;
12
41
  var TOP_PAD = 12;
13
42
  var BOTTOM_PAD = 24;
14
43
  var RESOURCE_MIN_W = 420;
15
- var clamp = (n, min, max) => {
16
- return Math.max(min, Math.min(max, n));
44
+
45
+ // src/utils/scheduler-core.utils.ts
46
+ function isNameLike(resource) {
47
+ if (!resource || typeof resource !== "object") {
48
+ return false;
49
+ }
50
+ const maybe = resource;
51
+ return typeof maybe.firstName === "string" || typeof maybe.lastName === "string";
52
+ }
53
+ function toInputDateValue(date) {
54
+ const year = date.getFullYear();
55
+ const month = String(date.getMonth() + 1).padStart(2, "0");
56
+ const day = String(date.getDate()).padStart(2, "0");
57
+ return `${year}-${month}-${day}`;
58
+ }
59
+ function fromInputDateValue(value) {
60
+ const [yearRaw, monthRaw, dayRaw] = value.split("-");
61
+ const year = Number(yearRaw);
62
+ const month = Number(monthRaw);
63
+ const day = Number(dayRaw);
64
+ if (!Number.isInteger(year) || !Number.isInteger(month) || !Number.isInteger(day)) {
65
+ return null;
66
+ }
67
+ return new Date(year, month - 1, day);
68
+ }
69
+ var DEFAULT_APPOINTMENT_COLOR_TOKEN_CLASS_MAP = {
70
+ slate: "bg-slate-100 dark:bg-slate-900/40",
71
+ gray: "bg-gray-100 dark:bg-gray-900/40",
72
+ zinc: "bg-zinc-100 dark:bg-zinc-900/40",
73
+ neutral: "bg-neutral-100 dark:bg-neutral-900/40",
74
+ stone: "bg-stone-100 dark:bg-stone-900/40",
75
+ red: "bg-red-100 dark:bg-red-950/40",
76
+ orange: "bg-orange-100 dark:bg-orange-950/40",
77
+ amber: "bg-amber-100 dark:bg-amber-950/40",
78
+ yellow: "bg-yellow-100 dark:bg-yellow-950/40",
79
+ lime: "bg-lime-100 dark:bg-lime-950/40",
80
+ green: "bg-green-100 dark:bg-green-950/40",
81
+ emerald: "bg-emerald-100 dark:bg-emerald-950/40",
82
+ teal: "bg-teal-100 dark:bg-teal-950/40",
83
+ cyan: "bg-cyan-100 dark:bg-cyan-950/40",
84
+ sky: "bg-sky-100 dark:bg-sky-950/40",
85
+ blue: "bg-blue-100 dark:bg-blue-950/40",
86
+ indigo: "bg-indigo-100 dark:bg-indigo-950/40",
87
+ violet: "bg-violet-100 dark:bg-violet-950/40",
88
+ purple: "bg-purple-100 dark:bg-purple-950/40",
89
+ fuchsia: "bg-fuchsia-100 dark:bg-fuchsia-950/40",
90
+ pink: "bg-pink-100 dark:bg-pink-950/40",
91
+ rose: "bg-rose-100 dark:bg-rose-950/40"
17
92
  };
18
- var snap = (mins, stepMin = STEP_MIN) => {
93
+ function clamp(n, min, max) {
94
+ return Math.max(min, Math.min(max, n));
95
+ }
96
+ function snap(mins, stepMin = STEP_MIN) {
19
97
  return Math.round(mins / stepMin) * stepMin;
20
- };
21
- var parseHHMM = (hhmm) => {
98
+ }
99
+ function parseHHMM(hhmm) {
22
100
  const [h, m] = hhmm.split(":").map(Number);
23
101
  return h * 60 + m;
24
- };
25
- var fullName = (u) => {
26
- return `${u.firstName ?? ""} ${u.lastName ?? ""}`.trim();
27
- };
28
- var dayISO = (d) => {
29
- return format(d, "yyyy-MM-dd");
30
- };
31
- var shiftDays = (d, delta) => {
32
- return new Date(d.getFullYear(), d.getMonth(), d.getDate() + delta);
33
- };
34
- var dateAtMinute = (selectedDate, dayStartAbs, minuteFromStart) => {
102
+ }
103
+ function fullName(resource) {
104
+ return `${resource.firstName ?? ""} ${resource.lastName ?? ""}`.trim();
105
+ }
106
+ function dayISO(date) {
107
+ return format(date, "yyyy-MM-dd");
108
+ }
109
+ function shiftDays(date, delta) {
110
+ return new Date(date.getFullYear(), date.getMonth(), date.getDate() + delta);
111
+ }
112
+ function dateAtMinute(selectedDate, dayStartAbs, minuteFromStart) {
35
113
  const base = new Date(
36
114
  selectedDate.getFullYear(),
37
115
  selectedDate.getMonth(),
@@ -46,311 +124,314 @@ var dateAtMinute = (selectedDate, dayStartAbs, minuteFromStart) => {
46
124
  const mm = abs % 60;
47
125
  base.setHours(hh, mm, 0, 0);
48
126
  return base;
49
- };
50
- var minutesFromDayStart = (dayStartAbs, dt) => {
51
- return dt.getHours() * 60 + dt.getMinutes() - dayStartAbs;
52
- };
53
- var overlaps = (a, b) => {
127
+ }
128
+ function minutesFromDayStart(dayStartAbs, date) {
129
+ return date.getHours() * 60 + date.getMinutes() - dayStartAbs;
130
+ }
131
+ function overlaps(a, b) {
54
132
  return a.startMin < b.endMin && b.startMin < a.endMin;
55
- };
56
- var layoutOverlaps = (blocks) => {
133
+ }
134
+ function layoutOverlaps(blocks) {
57
135
  const sorted = [...blocks].sort((a, b) => a.startMin - b.startMin || a.endMin - b.endMin);
58
136
  const groups = [];
59
137
  let active = [];
60
138
  let current = [];
61
- for (const e of sorted) {
62
- active = active.filter((x) => x.endMin > e.startMin);
63
- const conflicts = active.some((x) => overlaps(x, e));
139
+ for (const block of sorted) {
140
+ active = active.filter((item) => item.endMin > block.startMin);
141
+ const conflicts = active.some((item) => overlaps(item, block));
64
142
  if (!conflicts && current.length > 0) {
65
143
  groups.push(current);
66
144
  current = [];
67
145
  }
68
- current.push(e);
69
- active.push(e);
146
+ current.push(block);
147
+ active.push(block);
70
148
  }
71
149
  if (current.length) {
72
150
  groups.push(current);
73
151
  }
74
- const out = [];
152
+ const output = [];
75
153
  for (const group of groups) {
76
- const g = [...group].sort((a, b) => a.startMin - b.startMin || a.endMin - b.endMin);
154
+ const sortedGroup = [...group].sort((a, b) => a.startMin - b.startMin || a.endMin - b.endMin);
77
155
  const laneEnd = [];
78
156
  const laneOf = /* @__PURE__ */ new Map();
79
- for (const e of g) {
157
+ for (const block of sortedGroup) {
80
158
  let placed = false;
81
159
  for (let i = 0; i < laneEnd.length; i++) {
82
- if (laneEnd[i] <= e.startMin) {
83
- laneEnd[i] = e.endMin;
84
- laneOf.set(e.id, i);
160
+ if (laneEnd[i] <= block.startMin) {
161
+ laneEnd[i] = block.endMin;
162
+ laneOf.set(block.id, i);
85
163
  placed = true;
86
164
  break;
87
165
  }
88
166
  }
89
167
  if (!placed) {
90
- laneEnd.push(e.endMin);
91
- laneOf.set(e.id, laneEnd.length - 1);
168
+ laneEnd.push(block.endMin);
169
+ laneOf.set(block.id, laneEnd.length - 1);
92
170
  }
93
171
  }
94
172
  const lanes = laneEnd.length;
95
- for (const e of g) {
96
- out.push({ ...e, lane: laneOf.get(e.id) ?? 0, lanes });
173
+ for (const block of sortedGroup) {
174
+ output.push({ ...block, lane: laneOf.get(block.id) ?? 0, lanes });
97
175
  }
98
176
  }
99
- return out;
100
- };
101
- function isNameLike(resource) {
102
- if (!resource || typeof resource !== "object") {
103
- return false;
104
- }
105
- const maybe = resource;
106
- return typeof maybe.firstName === "string" || typeof maybe.lastName === "string";
177
+ return output;
107
178
  }
108
- function toInputDateValue(date) {
109
- const year = date.getFullYear();
110
- const month = String(date.getMonth() + 1).padStart(2, "0");
111
- const day = String(date.getDate()).padStart(2, "0");
112
- return `${year}-${month}-${day}`;
179
+ function defaultRenderDatePicker({ selectedDate, onSelectedDateChange }) {
180
+ return /* @__PURE__ */ jsx(
181
+ "input",
182
+ {
183
+ type: "date",
184
+ value: toInputDateValue(selectedDate),
185
+ onChange: (event) => {
186
+ const nextDate = fromInputDateValue(event.target.value);
187
+ if (nextDate) {
188
+ onSelectedDateChange(nextDate);
189
+ }
190
+ },
191
+ className: "h-7 rounded-md border border-border bg-card px-2 text-xs text-foreground",
192
+ "aria-label": "Select date"
193
+ }
194
+ );
113
195
  }
114
- function fromInputDateValue(value) {
115
- const [yearRaw, monthRaw, dayRaw] = value.split("-");
116
- const year = Number(yearRaw);
117
- const month = Number(monthRaw);
118
- const day = Number(dayRaw);
119
- if (!Number.isInteger(year) || !Number.isInteger(month) || !Number.isInteger(day)) {
120
- return null;
121
- }
122
- return new Date(year, month - 1, day);
196
+ function defaultRenderResourceHeader({ resource }) {
197
+ return /* @__PURE__ */ jsx("strong", { children: resource.label });
123
198
  }
124
- var DEFAULT_APPOINTMENT_COLOR_TOKEN_CLASS_MAP = {
125
- slate: "bg-slate-100 dark:bg-slate-900/40",
126
- gray: "bg-gray-100 dark:bg-gray-900/40",
127
- zinc: "bg-zinc-100 dark:bg-zinc-900/40",
128
- neutral: "bg-neutral-100 dark:bg-neutral-900/40",
129
- stone: "bg-stone-100 dark:bg-stone-900/40",
130
- red: "bg-red-100 dark:bg-red-950/40",
131
- orange: "bg-orange-100 dark:bg-orange-950/40",
132
- amber: "bg-amber-100 dark:bg-amber-950/40",
133
- yellow: "bg-yellow-100 dark:bg-yellow-950/40",
134
- lime: "bg-lime-100 dark:bg-lime-950/40",
135
- green: "bg-green-100 dark:bg-green-950/40",
136
- emerald: "bg-emerald-100 dark:bg-emerald-950/40",
137
- teal: "bg-teal-100 dark:bg-teal-950/40",
138
- cyan: "bg-cyan-100 dark:bg-cyan-950/40",
139
- sky: "bg-sky-100 dark:bg-sky-950/40",
140
- blue: "bg-blue-100 dark:bg-blue-950/40",
141
- indigo: "bg-indigo-100 dark:bg-indigo-950/40",
142
- violet: "bg-violet-100 dark:bg-violet-950/40",
143
- purple: "bg-purple-100 dark:bg-purple-950/40",
144
- fuchsia: "bg-fuchsia-100 dark:bg-fuchsia-950/40",
145
- pink: "bg-pink-100 dark:bg-pink-950/40",
146
- rose: "bg-rose-100 dark:bg-rose-950/40"
147
- };
148
- function Scheduler({
199
+
200
+ // src/utils/scheduler-data.utils.ts
201
+ function buildResourceMap({
202
+ fallback,
203
+ getValue,
149
204
  resources,
150
- appointments,
151
- selectedDate,
152
- onSelectedDateChange,
205
+ toKey
206
+ }) {
207
+ const map = /* @__PURE__ */ new Map();
208
+ for (const resource of resources) {
209
+ map.set(toKey(resource), getValue(resource) ?? fallback);
210
+ }
211
+ return map;
212
+ }
213
+ function normalizeAppointments({
153
214
  adapter,
154
- onPersistMoveResize,
155
- onAppointmentChange,
156
- renderResourceHeader,
157
- renderAppointment,
158
- resourceAppointmentClassMap,
159
- getResourceAppointmentAppearance,
160
- getResourceAppointmentColorToken,
161
- appointmentColorTokenClassMap,
162
- getResourceAppointmentBackground,
163
- renderDatePicker,
164
- prevButtonLabel = "Prev",
165
- nextButtonLabel = "Next",
166
- dayStart = "09:00",
167
- dayEnd = "18:00"
215
+ appointments,
216
+ dayMinutes,
217
+ dayStartAbs,
218
+ selectedISO
168
219
  }) {
169
- const [drag, setDrag] = React.useState({ kind: "none" });
170
- const colRefs = React.useRef({});
171
- const suppressClickRef = React.useRef(false);
172
- const dayStartAbs = parseHHMM(dayStart);
173
- const dayEndAbs = parseHHMM(dayEnd);
174
- const dayMinutes = Math.max(0, dayEndAbs - dayStartAbs);
175
- const gridHeight = TOP_PAD + dayMinutes * PX_PER_MIN + BOTTOM_PAD;
176
- const gridMinWidth = GUTTER_W + Math.max(resources.length, 1) * RESOURCE_MIN_W;
177
- const selectedISO = React.useMemo(() => dayISO(selectedDate), [selectedDate]);
178
- const renderAppts = React.useMemo(() => {
179
- return appointments.map((appointment) => {
180
- const start = new Date(adapter.getStart(appointment));
181
- const end = new Date(adapter.getEnd(appointment));
182
- return {
183
- raw: appointment,
184
- id: adapter.getId(appointment),
185
- resourceId: adapter.getResourceId(appointment),
186
- start,
187
- end,
188
- startMin: clamp(minutesFromDayStart(dayStartAbs, start), 0, dayMinutes),
189
- endMin: clamp(minutesFromDayStart(dayStartAbs, end), 0, dayMinutes),
190
- title: adapter.getTitle(appointment)
191
- };
192
- }).filter((appointment) => dayISO(appointment.start) === selectedISO);
193
- }, [appointments, adapter, dayStartAbs, dayMinutes, selectedISO]);
194
- const appointmentMap = React.useMemo(() => {
195
- return new Map(renderAppts.map((appointment) => [appointment.id, appointment]));
196
- }, [renderAppts]);
197
- const effectiveAppts = React.useMemo(() => {
198
- if (drag.kind === "none") {
199
- return renderAppts;
200
- }
201
- return renderAppts.map((appointment) => {
202
- if (appointment.id !== drag.appointmentId) {
203
- return appointment;
204
- }
205
- return {
206
- ...appointment,
207
- resourceId: drag.resourceId,
208
- startMin: drag.startMin,
209
- endMin: drag.endMin,
210
- start: dateAtMinute(selectedDate, dayStartAbs, drag.startMin),
211
- end: dateAtMinute(selectedDate, dayStartAbs, drag.endMin)
212
- };
213
- });
214
- }, [drag, renderAppts, selectedDate, dayStartAbs]);
215
- const laidOutByResource = React.useMemo(() => {
216
- const byResource = /* @__PURE__ */ new Map();
217
- for (const resource of resources) {
218
- const events = effectiveAppts.filter((appointment) => appointment.resourceId === resource.id);
219
- byResource.set(resource.id, layoutOverlaps(events));
220
- }
221
- return byResource;
222
- }, [effectiveAppts, resources]);
223
- const appointmentBgByResource = React.useMemo(() => {
224
- const map = /* @__PURE__ */ new Map();
225
- if (!getResourceAppointmentBackground) {
226
- return map;
227
- }
228
- for (const resource of resources) {
229
- map.set(resource.id, getResourceAppointmentBackground(resource));
220
+ return appointments.map((appointment) => {
221
+ const start = new Date(adapter.getStart(appointment));
222
+ const end = new Date(adapter.getEnd(appointment));
223
+ return {
224
+ raw: appointment,
225
+ id: adapter.getId(appointment),
226
+ resourceId: adapter.getResourceId(appointment),
227
+ start,
228
+ end,
229
+ startMin: clamp(minutesFromDayStart(dayStartAbs, start), 0, dayMinutes),
230
+ endMin: clamp(minutesFromDayStart(dayStartAbs, end), 0, dayMinutes),
231
+ title: adapter.getTitle(appointment)
232
+ };
233
+ }).filter((appointment) => dayISO(appointment.start) === selectedISO);
234
+ }
235
+ function buildAppointmentMap(appointments) {
236
+ return new Map(appointments.map((appointment) => [appointment.id, appointment]));
237
+ }
238
+ function applyDragToAppointments(appointments, drag, selectedDate, dayStartAbs) {
239
+ if (drag.kind === "none") {
240
+ return appointments;
241
+ }
242
+ return appointments.map((appointment) => {
243
+ if (appointment.id !== drag.appointmentId) {
244
+ return appointment;
230
245
  }
231
- return map;
232
- }, [getResourceAppointmentBackground, resources]);
233
- const appointmentColorTokenClassByResource = React.useMemo(() => {
234
- const map = /* @__PURE__ */ new Map();
235
- const mergedClassMap = {
236
- ...DEFAULT_APPOINTMENT_COLOR_TOKEN_CLASS_MAP,
237
- ...appointmentColorTokenClassMap ?? {}
246
+ return {
247
+ ...appointment,
248
+ resourceId: drag.resourceId,
249
+ startMin: drag.startMin,
250
+ endMin: drag.endMin,
251
+ start: dateAtMinute(selectedDate, dayStartAbs, drag.startMin),
252
+ end: dateAtMinute(selectedDate, dayStartAbs, drag.endMin)
238
253
  };
239
- if (!getResourceAppointmentColorToken) {
240
- return map;
254
+ });
255
+ }
256
+ function buildLaidOutByResource(resources, appointments) {
257
+ const appointmentsByResource = /* @__PURE__ */ new Map();
258
+ for (const appointment of appointments) {
259
+ const current = appointmentsByResource.get(appointment.resourceId) ?? [];
260
+ current.push(appointment);
261
+ appointmentsByResource.set(appointment.resourceId, current);
262
+ }
263
+ const result = /* @__PURE__ */ new Map();
264
+ for (const resource of resources) {
265
+ result.set(resource.id, layoutOverlaps(appointmentsByResource.get(resource.id) ?? []));
266
+ }
267
+ return result;
268
+ }
269
+ function buildAppointmentAppearanceByResource(resources, resourceAppointmentClassMap, getResourceAppointmentAppearance, appointmentColorTokenClassByResource, appointmentBgByResource) {
270
+ const map = /* @__PURE__ */ new Map();
271
+ for (const resource of resources) {
272
+ const classFromIdMap = resourceAppointmentClassMap?.[String(resource.id)];
273
+ if (classFromIdMap) {
274
+ map.set(resource.id, { className: classFromIdMap });
275
+ continue;
241
276
  }
242
- for (const resource of resources) {
243
- const token = getResourceAppointmentColorToken(resource);
244
- map.set(resource.id, token ? mergedClassMap[token] : void 0);
277
+ const appearance = getResourceAppointmentAppearance?.(resource);
278
+ if (appearance) {
279
+ map.set(resource.id, appearance);
280
+ continue;
245
281
  }
246
- return map;
247
- }, [appointmentColorTokenClassMap, getResourceAppointmentColorToken, resources]);
248
- const appointmentAppearanceByResource = React.useMemo(() => {
249
- const map = /* @__PURE__ */ new Map();
250
- for (const resource of resources) {
251
- const classFromIdMap = resourceAppointmentClassMap?.[String(resource.id)];
252
- if (classFromIdMap) {
253
- map.set(resource.id, { className: classFromIdMap });
254
- continue;
282
+ const tokenClass = appointmentColorTokenClassByResource.get(resource.id);
283
+ if (tokenClass) {
284
+ map.set(resource.id, { className: tokenClass });
285
+ continue;
286
+ }
287
+ const legacyBackground = appointmentBgByResource.get(resource.id);
288
+ map.set(resource.id, legacyBackground ? { className: legacyBackground } : void 0);
289
+ }
290
+ return map;
291
+ }
292
+
293
+ // src/hooks/useSchedulerBaseData.ts
294
+ function useSchedulerBaseData({
295
+ adapter,
296
+ appointments,
297
+ dayEnd = "18:00",
298
+ dayStart = "09:00",
299
+ resources,
300
+ selectedDate
301
+ }) {
302
+ const dayStartAbs = React2.useMemo(() => parseHHMM(dayStart), [dayStart]);
303
+ const dayEndAbs = React2.useMemo(() => parseHHMM(dayEnd), [dayEnd]);
304
+ const dayMinutes = React2.useMemo(() => Math.max(0, dayEndAbs - dayStartAbs), [dayEndAbs, dayStartAbs]);
305
+ const gridHeight = React2.useMemo(() => TOP_PAD + dayMinutes * PX_PER_MIN + BOTTOM_PAD, [dayMinutes]);
306
+ const gridMinWidth = React2.useMemo(() => GUTTER_W + Math.max(resources.length, 1) * RESOURCE_MIN_W, [resources.length]);
307
+ const selectedISO = React2.useMemo(() => dayISO(selectedDate), [selectedDate]);
308
+ const renderAppts = React2.useMemo(() => {
309
+ return normalizeAppointments({
310
+ adapter,
311
+ appointments,
312
+ dayMinutes,
313
+ dayStartAbs,
314
+ selectedISO
315
+ });
316
+ }, [adapter, appointments, dayMinutes, dayStartAbs, selectedISO]);
317
+ const appointmentMap = React2.useMemo(() => buildAppointmentMap(renderAppts), [renderAppts]);
318
+ return {
319
+ appointmentMap,
320
+ dayEndAbs,
321
+ dayMinutes,
322
+ dayStartAbs,
323
+ gridHeight,
324
+ gridMinWidth,
325
+ renderAppts
326
+ };
327
+ }
328
+ function useSchedulerInteractions({
329
+ appointmentMap,
330
+ dayMinutes,
331
+ dayStartAbs,
332
+ onAppointmentChange,
333
+ onPersistMoveResize,
334
+ renderAppts,
335
+ resources,
336
+ selectedDate
337
+ }) {
338
+ const [drag, setDrag] = React2.useState({ kind: "none" });
339
+ const colRefs = React2.useRef({});
340
+ const suppressClickRef = React2.useRef(false);
341
+ const pointerToMin = React2.useCallback(
342
+ (resourceId, clientY) => {
343
+ const col = colRefs.current[String(resourceId)];
344
+ if (!col) {
345
+ return 0;
346
+ }
347
+ const y = clientY - col.getBoundingClientRect().top - TOP_PAD;
348
+ return clamp(snap(y / PX_PER_MIN), 0, dayMinutes);
349
+ },
350
+ [dayMinutes]
351
+ );
352
+ const pointerToResourceId = React2.useCallback(
353
+ (clientX, fallbackResourceId) => {
354
+ for (const resource of resources) {
355
+ const col = colRefs.current[String(resource.id)];
356
+ if (!col) {
357
+ continue;
358
+ }
359
+ const rect = col.getBoundingClientRect();
360
+ if (clientX >= rect.left && clientX <= rect.right) {
361
+ return resource.id;
362
+ }
255
363
  }
256
- const appearance = getResourceAppointmentAppearance?.(resource);
257
- if (appearance) {
258
- map.set(resource.id, appearance);
259
- continue;
364
+ return fallbackResourceId;
365
+ },
366
+ [resources]
367
+ );
368
+ const hasOverlap = React2.useCallback(
369
+ (appointmentId, resourceId, startMin, endMin) => {
370
+ const events = renderAppts.filter((appointment) => appointment.resourceId === resourceId && appointment.id !== appointmentId);
371
+ return events.some((appointment) => overlaps(appointment, { startMin, endMin }));
372
+ },
373
+ [renderAppts]
374
+ );
375
+ const persistDrag = React2.useCallback(
376
+ async (state) => {
377
+ const target = appointmentMap.get(state.appointmentId);
378
+ if (!target) {
379
+ return;
260
380
  }
261
- const tokenClass = appointmentColorTokenClassByResource.get(resource.id);
262
- if (tokenClass) {
263
- map.set(resource.id, { className: tokenClass });
264
- continue;
381
+ if (hasOverlap(state.appointmentId, state.resourceId, state.startMin, state.endMin)) {
382
+ return;
265
383
  }
266
- const legacyBackground = appointmentBgByResource.get(resource.id);
267
- if (!legacyBackground) {
268
- map.set(resource.id, void 0);
269
- continue;
384
+ const next = {
385
+ resourceId: state.resourceId,
386
+ start: dateAtMinute(selectedDate, dayStartAbs, state.startMin),
387
+ end: dateAtMinute(selectedDate, dayStartAbs, state.endMin)
388
+ };
389
+ if (onAppointmentChange) {
390
+ await onAppointmentChange({
391
+ kind: state.kind,
392
+ appointment: target.raw,
393
+ previous: {
394
+ resourceId: target.resourceId,
395
+ start: target.start,
396
+ end: target.end
397
+ },
398
+ next
399
+ });
400
+ return;
270
401
  }
271
- map.set(resource.id, { className: legacyBackground });
272
- }
273
- return map;
274
- }, [appointmentBgByResource, appointmentColorTokenClassByResource, getResourceAppointmentAppearance, resourceAppointmentClassMap, resources]);
275
- const pointerToMin = (resourceId, clientY) => {
276
- const col = colRefs.current[String(resourceId)];
277
- if (!col) {
278
- return 0;
279
- }
280
- const y = clientY - col.getBoundingClientRect().top - TOP_PAD;
281
- return clamp(snap(y / PX_PER_MIN), 0, dayMinutes);
282
- };
283
- const pointerToResourceId = (clientX, fallbackResourceId) => {
284
- for (const resource of resources) {
285
- const col = colRefs.current[String(resource.id)];
286
- if (!col) {
287
- continue;
402
+ if (onPersistMoveResize) {
403
+ await onPersistMoveResize({
404
+ appointment: target.raw,
405
+ resourceId: next.resourceId,
406
+ start: next.start,
407
+ end: next.end
408
+ });
288
409
  }
289
- const rect = col.getBoundingClientRect();
290
- if (clientX >= rect.left && clientX <= rect.right) {
291
- return resource.id;
410
+ },
411
+ [appointmentMap, dayStartAbs, hasOverlap, onAppointmentChange, onPersistMoveResize, selectedDate]
412
+ );
413
+ const onApptPointerDown = React2.useCallback(
414
+ (event, appointment) => {
415
+ if (event.button !== 0) {
416
+ return;
292
417
  }
293
- }
294
- return fallbackResourceId;
295
- };
296
- const hasOverlap = (appointmentId, resourceId, startMin, endMin) => {
297
- const events = renderAppts.filter((appointment) => appointment.resourceId === resourceId && appointment.id !== appointmentId);
298
- return events.some((appointment) => overlaps(appointment, { startMin, endMin }));
299
- };
300
- const persistDrag = async (state) => {
301
- const target = appointmentMap.get(state.appointmentId);
302
- if (!target) {
303
- return;
304
- }
305
- if (hasOverlap(state.appointmentId, state.resourceId, state.startMin, state.endMin)) {
306
- return;
307
- }
308
- const next = {
309
- resourceId: state.resourceId,
310
- start: dateAtMinute(selectedDate, dayStartAbs, state.startMin),
311
- end: dateAtMinute(selectedDate, dayStartAbs, state.endMin)
312
- };
313
- if (onAppointmentChange) {
314
- await onAppointmentChange({
315
- kind: state.kind,
316
- appointment: target.raw,
317
- previous: {
318
- resourceId: target.resourceId,
319
- start: target.start,
320
- end: target.end
321
- },
322
- next
323
- });
324
- return;
325
- }
326
- if (onPersistMoveResize) {
327
- await onPersistMoveResize({
328
- appointment: target.raw,
329
- resourceId: next.resourceId,
330
- start: next.start,
331
- end: next.end
418
+ event.stopPropagation();
419
+ suppressClickRef.current = false;
420
+ const pointerMin = pointerToMin(appointment.resourceId, event.clientY);
421
+ setDrag({
422
+ kind: "move",
423
+ appointmentId: appointment.id,
424
+ pointerId: event.pointerId,
425
+ resourceId: appointment.resourceId,
426
+ durationMin: Math.max(MIN_EVENT_MIN, appointment.endMin - appointment.startMin),
427
+ offsetMin: pointerMin - appointment.startMin,
428
+ startMin: appointment.startMin,
429
+ endMin: appointment.endMin
332
430
  });
333
- }
334
- };
335
- const onApptPointerDown = (event, appointment) => {
336
- if (event.button !== 0) {
337
- return;
338
- }
339
- event.stopPropagation();
340
- suppressClickRef.current = false;
341
- const pointerMin = pointerToMin(appointment.resourceId, event.clientY);
342
- setDrag({
343
- kind: "move",
344
- appointmentId: appointment.id,
345
- pointerId: event.pointerId,
346
- resourceId: appointment.resourceId,
347
- durationMin: Math.max(MIN_EVENT_MIN, appointment.endMin - appointment.startMin),
348
- offsetMin: pointerMin - appointment.startMin,
349
- startMin: appointment.startMin,
350
- endMin: appointment.endMin
351
- });
352
- };
353
- const onResizePointerDown = (event, appointment) => {
431
+ },
432
+ [pointerToMin]
433
+ );
434
+ const onResizePointerDown = React2.useCallback((event, appointment) => {
354
435
  if (event.button !== 0) {
355
436
  return;
356
437
  }
@@ -364,39 +445,42 @@ function Scheduler({
364
445
  startMin: appointment.startMin,
365
446
  endMin: appointment.endMin
366
447
  });
367
- };
368
- const onGlobalPointerMove = (event) => {
369
- if (drag.kind === "none") {
370
- return;
371
- }
372
- if (drag.kind === "move") {
373
- const nextResourceId = pointerToResourceId(event.clientX, drag.resourceId);
374
- const pointerMin2 = pointerToMin(nextResourceId, event.clientY);
375
- const rawStart = pointerMin2 - drag.offsetMin;
376
- const nextStartMin = clamp(snap(rawStart), 0, dayMinutes - drag.durationMin);
377
- const nextEndMin2 = nextStartMin + drag.durationMin;
378
- if (nextStartMin !== drag.startMin || nextEndMin2 !== drag.endMin || nextResourceId !== drag.resourceId) {
448
+ }, []);
449
+ const onGlobalPointerMove = React2.useCallback(
450
+ (event) => {
451
+ if (drag.kind === "none") {
452
+ return;
453
+ }
454
+ if (drag.kind === "move") {
455
+ const nextResourceId = pointerToResourceId(event.clientX, drag.resourceId);
456
+ const pointerMin2 = pointerToMin(nextResourceId, event.clientY);
457
+ const rawStart = pointerMin2 - drag.offsetMin;
458
+ const nextStartMin = clamp(snap(rawStart), 0, dayMinutes - drag.durationMin);
459
+ const nextEndMin2 = nextStartMin + drag.durationMin;
460
+ if (nextStartMin !== drag.startMin || nextEndMin2 !== drag.endMin || nextResourceId !== drag.resourceId) {
461
+ suppressClickRef.current = true;
462
+ setDrag({
463
+ ...drag,
464
+ resourceId: nextResourceId,
465
+ startMin: nextStartMin,
466
+ endMin: nextEndMin2
467
+ });
468
+ }
469
+ return;
470
+ }
471
+ const pointerMin = pointerToMin(drag.resourceId, event.clientY);
472
+ const nextEndMin = clamp(snap(pointerMin), drag.startMin + MIN_EVENT_MIN, dayMinutes);
473
+ if (nextEndMin !== drag.endMin) {
379
474
  suppressClickRef.current = true;
380
475
  setDrag({
381
476
  ...drag,
382
- resourceId: nextResourceId,
383
- startMin: nextStartMin,
384
- endMin: nextEndMin2
477
+ endMin: nextEndMin
385
478
  });
386
479
  }
387
- return;
388
- }
389
- const pointerMin = pointerToMin(drag.resourceId, event.clientY);
390
- const nextEndMin = clamp(snap(pointerMin), drag.startMin + MIN_EVENT_MIN, dayMinutes);
391
- if (nextEndMin !== drag.endMin) {
392
- suppressClickRef.current = true;
393
- setDrag({
394
- ...drag,
395
- endMin: nextEndMin
396
- });
397
- }
398
- };
399
- const onGlobalPointerUp = () => {
480
+ },
481
+ [dayMinutes, drag, pointerToMin, pointerToResourceId]
482
+ );
483
+ const onGlobalPointerUp = React2.useCallback(() => {
400
484
  if (drag.kind === "none") {
401
485
  return;
402
486
  }
@@ -405,55 +489,157 @@ function Scheduler({
405
489
  if (suppressClickRef.current) {
406
490
  void persistDrag(current);
407
491
  }
492
+ }, [drag, persistDrag]);
493
+ return {
494
+ colRefs,
495
+ drag,
496
+ onApptPointerDown,
497
+ onGlobalPointerMove,
498
+ onGlobalPointerUp,
499
+ onResizePointerDown,
500
+ suppressClickRef
408
501
  };
409
- const defaultRenderAppointment = ({
410
- appointment,
411
- onPointerDown,
412
- onResizePointerDown: onResizePointerDown2,
413
- appointmentAppearance,
414
- appointmentBackgroundColor
415
- }) => {
416
- const rawMaybe = appointment.raw;
417
- const description = typeof rawMaybe.description === "string" ? rawMaybe.description : void 0;
418
- const legacyClassName = typeof appointmentBackgroundColor === "string" ? appointmentBackgroundColor : void 0;
419
- const backgroundClassName = appointmentAppearance?.className ?? legacyClassName ?? "bg-muted/40 dark:bg-muted/30";
420
- const className = `relative h-full cursor-grab overflow-hidden rounded-md border border-border p-2 pb-5 text-foreground ${backgroundClassName}`;
421
- return /* @__PURE__ */ jsxs("div", { onPointerDown, className, children: [
422
- /* @__PURE__ */ jsx("div", { className: "text-xs font-semibold", children: appointment.title }),
423
- description ? /* @__PURE__ */ jsx("div", { className: "mt-1 text-[10px] font-medium text-muted-foreground", children: description }) : null,
424
- /* @__PURE__ */ jsx(
425
- "div",
426
- {
427
- role: "button",
428
- "aria-label": "Resize appointment",
429
- onPointerDown: (event) => {
430
- event.stopPropagation();
431
- onResizePointerDown2(event);
432
- },
433
- className: "absolute inset-x-1 bottom-1 h-2 cursor-ns-resize rounded-full bg-border/90 transition-colors hover:bg-border"
434
- }
435
- )
436
- ] });
437
- };
438
- const defaultRenderDatePicker = ({
439
- selectedDate: value,
440
- onSelectedDateChange: onDateChange
441
- }) => /* @__PURE__ */ jsx(
442
- "input",
443
- {
444
- type: "date",
445
- value: toInputDateValue(value),
446
- onChange: (event) => {
447
- const nextDate = fromInputDateValue(event.target.value);
448
- if (nextDate) {
449
- onDateChange(nextDate);
450
- }
451
- },
452
- className: "h-7 rounded-md border border-border bg-card px-2 text-xs text-foreground",
453
- "aria-label": "Select date"
502
+ }
503
+ function useSchedulerPresentationData({
504
+ appointmentColorTokenClassMap,
505
+ dayStartAbs,
506
+ drag,
507
+ getResourceAppointmentAppearance,
508
+ getResourceAppointmentBackground,
509
+ getResourceAppointmentColorToken,
510
+ renderAppts,
511
+ resourceAppointmentClassMap,
512
+ resources,
513
+ selectedDate
514
+ }) {
515
+ const effectiveAppts = React2.useMemo(() => {
516
+ return applyDragToAppointments(renderAppts, drag, selectedDate, dayStartAbs);
517
+ }, [dayStartAbs, drag, renderAppts, selectedDate]);
518
+ const laidOutByResource = React2.useMemo(() => {
519
+ return buildLaidOutByResource(resources, effectiveAppts);
520
+ }, [effectiveAppts, resources]);
521
+ const appointmentBgByResource = React2.useMemo(() => {
522
+ if (!getResourceAppointmentBackground) {
523
+ return /* @__PURE__ */ new Map();
454
524
  }
455
- );
456
- const defaultRenderResourceHeader = (resource) => /* @__PURE__ */ jsx("strong", { children: resource.label });
525
+ return buildResourceMap({
526
+ fallback: void 0,
527
+ getValue: getResourceAppointmentBackground,
528
+ resources,
529
+ toKey: (resource) => resource.id
530
+ });
531
+ }, [getResourceAppointmentBackground, resources]);
532
+ const appointmentColorTokenClassByResource = React2.useMemo(() => {
533
+ if (!getResourceAppointmentColorToken) {
534
+ return /* @__PURE__ */ new Map();
535
+ }
536
+ const mergedClassMap = {
537
+ ...DEFAULT_APPOINTMENT_COLOR_TOKEN_CLASS_MAP,
538
+ ...appointmentColorTokenClassMap ?? {}
539
+ };
540
+ return buildResourceMap({
541
+ fallback: void 0,
542
+ getValue: (resource) => {
543
+ const token = getResourceAppointmentColorToken(resource);
544
+ return token ? mergedClassMap[token] : void 0;
545
+ },
546
+ resources,
547
+ toKey: (resource) => resource.id
548
+ });
549
+ }, [appointmentColorTokenClassMap, getResourceAppointmentColorToken, resources]);
550
+ const appointmentAppearanceByResource = React2.useMemo(() => {
551
+ return buildAppointmentAppearanceByResource(
552
+ resources,
553
+ resourceAppointmentClassMap,
554
+ getResourceAppointmentAppearance,
555
+ appointmentColorTokenClassByResource,
556
+ appointmentBgByResource
557
+ );
558
+ }, [
559
+ appointmentBgByResource,
560
+ appointmentColorTokenClassByResource,
561
+ getResourceAppointmentAppearance,
562
+ resourceAppointmentClassMap,
563
+ resources
564
+ ]);
565
+ return {
566
+ appointmentAppearanceByResource,
567
+ appointmentBgByResource,
568
+ effectiveAppts,
569
+ laidOutByResource
570
+ };
571
+ }
572
+ function Scheduler({
573
+ resources,
574
+ appointments,
575
+ selectedDate,
576
+ onSelectedDateChange,
577
+ adapter,
578
+ onPersistMoveResize,
579
+ onAppointmentChange,
580
+ renderResourceHeader,
581
+ renderAppointment,
582
+ resourceAppointmentClassMap,
583
+ getResourceAppointmentAppearance,
584
+ getResourceAppointmentColorToken,
585
+ appointmentColorTokenClassMap,
586
+ getResourceAppointmentBackground,
587
+ renderDatePicker,
588
+ prevButtonLabel = "Prev",
589
+ nextButtonLabel = "Next",
590
+ dayStart = "09:00",
591
+ dayEnd = "18:00"
592
+ }) {
593
+ const {
594
+ appointmentMap,
595
+ dayMinutes,
596
+ dayStartAbs,
597
+ gridHeight,
598
+ gridMinWidth,
599
+ renderAppts
600
+ } = useSchedulerBaseData({
601
+ adapter,
602
+ appointments,
603
+ dayEnd,
604
+ dayStart,
605
+ resources,
606
+ selectedDate
607
+ });
608
+ const {
609
+ colRefs,
610
+ drag,
611
+ onApptPointerDown,
612
+ onGlobalPointerMove,
613
+ onGlobalPointerUp,
614
+ onResizePointerDown,
615
+ suppressClickRef
616
+ } = useSchedulerInteractions({
617
+ appointmentMap,
618
+ dayMinutes,
619
+ dayStartAbs,
620
+ onAppointmentChange,
621
+ onPersistMoveResize,
622
+ renderAppts,
623
+ resources,
624
+ selectedDate
625
+ });
626
+ const {
627
+ appointmentAppearanceByResource,
628
+ appointmentBgByResource,
629
+ laidOutByResource
630
+ } = useSchedulerPresentationData({
631
+ appointmentColorTokenClassMap,
632
+ dayStartAbs,
633
+ drag,
634
+ getResourceAppointmentAppearance,
635
+ getResourceAppointmentBackground,
636
+ getResourceAppointmentColorToken,
637
+ renderAppts,
638
+ resourceAppointmentClassMap,
639
+ resources,
640
+ selectedDate
641
+ });
642
+ const defaultResourceHeaderRenderer = (resource) => defaultRenderResourceHeader({ resource });
457
643
  return /* @__PURE__ */ jsxs(
458
644
  "section",
459
645
  {
@@ -492,7 +678,7 @@ function Scheduler({
492
678
  className: "rfs-resource-header border-border bg-card text-foreground",
493
679
  style: { minWidth: RESOURCE_MIN_W, flex: 1 },
494
680
  children: [
495
- (renderResourceHeader ?? defaultRenderResourceHeader)(resource),
681
+ (renderResourceHeader ?? defaultResourceHeaderRenderer)(resource),
496
682
  isNameLike(resource) ? /* @__PURE__ */ jsx("div", { className: "rfs-resource-subtitle text-muted-foreground", children: fullName(resource) }) : null
497
683
  ]
498
684
  },
@@ -585,7 +771,7 @@ function Scheduler({
585
771
  );
586
772
  }
587
773
 
588
- // src/appointment-change.ts
774
+ // src/utils/scheduler-appointment-change.utils.ts
589
775
  function applySchedulerAppointmentChange(appointments, change, options) {
590
776
  return appointments.map((item) => {
591
777
  if (options.getId(item) !== options.getId(change.appointment)) {