@svgrid/enterprise 2.0.4 → 2.2.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.
Files changed (50) hide show
  1. package/README.md +18 -6
  2. package/dist/cdn/svgrid-enterprise.svelte-external.js +14024 -6835
  3. package/dist/node/studio.js +7888 -2459
  4. package/package.json +9 -4
  5. package/src/SvGridMasterDetail.svelte +24 -3
  6. package/src/SvGridScheduler.svelte +4410 -0
  7. package/src/SvPivotDesigner.svelte +1990 -1045
  8. package/src/SvSchemaChart.svelte +10 -9
  9. package/src/ai.test.ts +522 -522
  10. package/src/ai.ts +202 -2
  11. package/src/index.ts +409 -384
  12. package/src/install.ts +10 -0
  13. package/src/pivot-chart.test.ts +86 -0
  14. package/src/pivot-chart.ts +112 -0
  15. package/src/scheduler.ts +37 -0
  16. package/src/scheduling.test.ts +194 -0
  17. package/src/scheduling.ts +293 -0
  18. package/src/sources/filters.ts +6 -0
  19. package/src/studio/HANDLERS-DESIGN.md +142 -0
  20. package/src/studio/cli.ts +7 -2
  21. package/src/studio/emit-project.test.ts +1447 -13
  22. package/src/studio/emit-project.ts +3995 -1273
  23. package/src/studio/emit-schema.ts +146 -29
  24. package/src/studio/index.ts +320 -195
  25. package/src/studio/project.test.ts +370 -0
  26. package/src/studio/project.ts +1146 -26
  27. package/src/studio/sample-data.ts +4 -1
  28. package/src/studio/samples/ats.ts +2 -2
  29. package/src/studio/samples/clinic.ts +4 -2
  30. package/src/studio/samples/crm.ts +16 -8
  31. package/src/studio/samples/events.ts +4 -2
  32. package/src/studio/samples/fleet.ts +4 -2
  33. package/src/studio/samples/gym.ts +4 -2
  34. package/src/studio/samples/hr.ts +3 -1
  35. package/src/studio/samples/live-data.ts +308 -308
  36. package/src/studio/samples/projects.ts +2 -2
  37. package/src/studio/samples/restaurant.ts +4 -2
  38. package/src/studio/samples/samples.test.ts +13 -5
  39. package/src/studio/samples/shared.ts +346 -305
  40. package/src/studio/samples/support.ts +3 -1
  41. package/src/studio/scaffold.test.ts +15 -1
  42. package/src/studio/scaffold.ts +16 -0
  43. package/src/studio/themes.ts +7 -0
  44. package/src/studio/ui-components.ts +472 -0
  45. package/src/sveltekit/transport.test.ts +26 -0
  46. package/src/sveltekit/transport.ts +50 -5
  47. package/dist/designer/assets/index-Dp44bTid.js +0 -939
  48. package/dist/designer/assets/index-RJp6x8tw.css +0 -1
  49. package/dist/designer/assets/jszip.min-CjMo-QGg.js +0 -2
  50. package/dist/designer/index.html +0 -13
@@ -0,0 +1,4410 @@
1
+ <script
2
+ lang="ts"
3
+ generics="TFeatures extends TableFeatures = TableFeatures, TData extends RowData = RowData"
4
+ >
5
+ // Calendar / scheduler renderer for SvGrid. Rendered in place of the table
6
+ // when the `scheduler` prop is set. Like the Kanban board (SvGridBoard) it is
7
+ // a pure *view of the grid*:
8
+ // - reads the grid's already filtered + sorted rows
9
+ // - resolves them into events for the visible window (recurring rows are
10
+ // expanded via the shared recurrence engine)
11
+ // - renders Month / Week / Day / Agenda, optionally splitting Day into
12
+ // per-resource columns
13
+ // - drag-to-move + edge-resize are computed and applied here as a per-row
14
+ // overlay keyed by row id, so consumers write no move code; `onEventMove`
15
+ // / `onEventResize` fire purely as notifications for persistence.
16
+ // Themes entirely from the grid's `--sg-*` tokens; the model math lives in
17
+ // ./scheduler-model (pure + unit-tested).
18
+ import { untrack } from "svelte";
19
+ import type {
20
+ ColumnDef,
21
+ RowData,
22
+ TableFeatures,
23
+ SchedulerConfig,
24
+ SchedulerEventMoveEvent,
25
+ SchedulerEventResizeEvent,
26
+ SchedulerView,
27
+ SchedulerResource,
28
+ MenuItem,
29
+ FormField,
30
+ FormFieldType,
31
+ EventSpec,
32
+ ResolvedEvent,
33
+ RecurrenceRule,
34
+ } from "@svgrid/grid";
35
+ import {
36
+ startOfDay,
37
+ startOfMonth,
38
+ addDays,
39
+ monthMatrix,
40
+ weekdayOrder,
41
+ isSameDay,
42
+ snapMinute,
43
+ toDate,
44
+ resolveEvents,
45
+ eventsOnDay,
46
+ layoutDayEvents,
47
+ monthWeekSegments,
48
+ agendaGroups,
49
+ rangeForView,
50
+ daysForView,
51
+ navigateAnchor,
52
+ timelineAxis,
53
+ timelineGeom,
54
+ timelineRows,
55
+ hasConflict,
56
+ overlapCount,
57
+ overlapsBands,
58
+ workingIntervals,
59
+ withinWorking,
60
+ normalizeTimeZone,
61
+ toZonedLocal,
62
+ fromZonedLocal,
63
+ zoneAbbr,
64
+ zoneParts,
65
+ instantFromWallClock,
66
+ SvMenuList,
67
+ SvForm,
68
+ SvDrawer,
69
+ SvDateTimePicker,
70
+ SvCheckBox,
71
+ SvNumberInput,
72
+ SvDropDownList,
73
+ portalToBody,
74
+ popIn,
75
+ createDismissableLayer,
76
+ } from "@svgrid/grid";
77
+
78
+ let {
79
+ data,
80
+ columns,
81
+ scheduler,
82
+ getRowId,
83
+ }: {
84
+ data: ReadonlyArray<TData>;
85
+ columns: Array<ColumnDef<TFeatures, TData>>;
86
+ scheduler: SchedulerConfig<TFeatures, TData>;
87
+ getRowId?: (row: TData, index: number) => string;
88
+ } = $props();
89
+
90
+ // --- config with defaults ---
91
+ const views = $derived(scheduler.views ?? (["month", "week", "day", "agenda"] as SchedulerView[]));
92
+ const weekStartsOn = $derived(scheduler.weekStartsOn ?? 0);
93
+ // Slot size: config default, optionally overridden at runtime by the ruler
94
+ // slot-size picker (`slotSizes`). Snap granularity + ruler subdivision.
95
+ let slotOverride = $state<number | null>(null);
96
+ const slotMinutes = $derived(Math.max(5, slotOverride ?? scheduler.slotMinutes ?? 30));
97
+ const slotSizes = $derived(scheduler.slotSizes ?? []);
98
+ const dayStartHour = $derived(scheduler.dayStartHour ?? 0);
99
+ const dayEndHour = $derived(scheduler.dayEndHour ?? 24);
100
+ const agendaDays = $derived(scheduler.agendaDays ?? 30);
101
+ const editable = $derived(scheduler.editable === true);
102
+ const resourceField = $derived(scheduler.resourceField);
103
+ const collisionMode = $derived(scheduler.collisionMode ?? "split");
104
+ const maxColumns = $derived(scheduler.maxColumns ?? 3);
105
+
106
+ // svelte-ignore state_referenced_locally
107
+ let view = $state<SchedulerView>(scheduler.initialView ?? "month");
108
+
109
+ // --- time zone -----------------------------------------------------------
110
+ // The calendar runs in "pseudo-local" time: every absolute instant is shifted
111
+ // so its LOCAL wall-clock equals its wall-clock in `timeZone`, letting all the
112
+ // existing local date math position it in that zone. `toZ` does that at the
113
+ // read boundary (row field -> Date); `fromZ` reverses it when emitting a user
114
+ // edit back to the consumer (which stores a real instant).
115
+ const tz = $derived(normalizeTimeZone(scheduler.timeZone));
116
+ const toZ = (v: unknown): Date | undefined => {
117
+ const d = toDate(v as never);
118
+ return d ? toZonedLocal(d, tz) : undefined;
119
+ };
120
+ const fromZ = (d: Date): Date => fromZonedLocal(d, tz);
121
+ // Emit boundary: user edits are computed in pseudo-local time; convert their
122
+ // start/end back to real instants before handing them to consumer callbacks.
123
+ type MoveEv = Parameters<NonNullable<typeof scheduler.onEventMove>>[0];
124
+ type ResizeEv = Parameters<NonNullable<typeof scheduler.onEventResize>>[0];
125
+ type RangeArg = Parameters<NonNullable<typeof scheduler.onRangeSelect>>[0];
126
+ function emitMove(e: MoveEv) {
127
+ scheduler.onEventMove?.({ ...e, start: fromZ(e.start), end: fromZ(e.end) });
128
+ }
129
+ function emitResize(e: ResizeEv) {
130
+ scheduler.onEventResize?.({ ...e, start: fromZ(e.start), end: fromZ(e.end) });
131
+ }
132
+ function emitRange(sel: RangeArg) {
133
+ scheduler.onRangeSelect?.({ ...sel, start: fromZ(sel.start), end: fromZ(sel.end), days: sel.days.map(fromZ) });
134
+ }
135
+ function emitAdd(start: Date, end: Date, resourceId?: string, allDay?: boolean) {
136
+ scheduler.onEventAdd?.(fromZ(start), fromZ(end), resourceId, allDay);
137
+ }
138
+
139
+ // --- recurring-occurrence editing ("This event" vs "All events") ---------
140
+ // When a recurrenceExceptionsField + onOccurrenceChange are set, editing one
141
+ // occurrence of a series asks which scope to apply. Otherwise edits fall back
142
+ // to the whole series (backward compatible).
143
+ const hasExceptions = $derived(!!scheduler.recurrenceExceptionsField && !!scheduler.onOccurrenceChange);
144
+ let recurScope = $state<{ x: number; y: number; title: string; kind: "edit" | "delete"; occurrence: () => void; following?: () => void; series: () => void } | null>(null);
145
+ function askRecurScope(x: number, y: number, ev: ResolvedEvent<TData>, kind: "edit" | "delete", occurrence: () => void, series: () => void, following?: () => void) {
146
+ if (!hasExceptions || !ev.occurrenceStart) { series(); return; }
147
+ // Keep the popover fully on-screen (a drop near the viewport edge would push
148
+ // its buttons off the bottom / right).
149
+ const cx = Math.max(6, Math.min(x, window.innerWidth - 184));
150
+ const cy = Math.max(6, Math.min(y, window.innerHeight - (following ? 146 : 118)));
151
+ recurScope = { x: cx, y: cy, title: ev.title, kind, occurrence, following, series };
152
+ }
153
+ // Emit a per-occurrence override as a real instant (converts out of pseudo-local).
154
+ // `scope` = 'occurrence' (merge into the row) or 'following' (split the series).
155
+ function emitOccurrence(ev: ResolvedEvent<TData>, opts: { start?: Date; end?: Date; deleted?: boolean; title?: string; allDay?: boolean }, scope: "occurrence" | "following" = "occurrence") {
156
+ if (!ev.occurrenceStart) return;
157
+ const occ = fromZ(ev.occurrenceStart);
158
+ scheduler.onOccurrenceChange?.({
159
+ row: ev.row,
160
+ occurrenceStart: occ,
161
+ scope,
162
+ exception: {
163
+ occurrenceStart: occ,
164
+ ...(opts.deleted ? { deleted: true } : {}),
165
+ ...(opts.start ? { start: fromZ(opts.start) } : {}),
166
+ ...(opts.end ? { end: fromZ(opts.end) } : {}),
167
+ ...(opts.title != null ? { title: opts.title } : {}),
168
+ ...(opts.allDay != null ? { allDay: opts.allDay } : {}),
169
+ },
170
+ });
171
+ }
172
+
173
+ // Component context: the arg-less `new Date()` is fine here (runtime), unlike
174
+ // the pure model. Default the anchor to today, at the zone's midnight.
175
+ // svelte-ignore state_referenced_locally
176
+ let anchor = $state<Date>(startOfDay(toZonedLocal(toDate(scheduler.initialDate) ?? new Date(), normalizeTimeZone(scheduler.timeZone))));
177
+
178
+ // --- value access + stable per-row key (mirrors SvGridBoard) ---
179
+ const indexOf = $derived(new Map<TData, number>(data.map((r, i) => [r, i])));
180
+ const synthetic = new WeakMap<object, string>();
181
+ let synthSeq = 0;
182
+ function key(row: TData): string {
183
+ if (getRowId) return getRowId(row, indexOf.get(row) ?? 0);
184
+ const obj = row as unknown as object;
185
+ let id = synthetic.get(obj);
186
+ if (!id) {
187
+ id = `__s${++synthSeq}`;
188
+ synthetic.set(obj, id);
189
+ }
190
+ return id;
191
+ }
192
+ function fieldValue(row: TData, field: string): unknown {
193
+ const e = edits[key(row)];
194
+ if (e && field in e) return e[field];
195
+ return (row as Record<string, unknown>)[field];
196
+ }
197
+
198
+ // --- the move / edit overlay: never mutates the consumer's rows ---
199
+ let startOfE = $state<Record<string, Date>>({});
200
+ let endOfE = $state<Record<string, Date>>({});
201
+ let resourceOfE = $state<Record<string, string>>({});
202
+ let allDayOf = $state<Record<string, boolean>>({});
203
+ let edits = $state<Record<string, Record<string, unknown>>>({});
204
+
205
+ // --- columns lookup + default title field ---
206
+ const fieldColumns = $derived(columns.filter((c) => typeof c.field === "string"));
207
+ const titleField = $derived(
208
+ scheduler.titleField ?? (fieldColumns[0]?.field as string | undefined),
209
+ );
210
+ function headerLabel(field: string): string {
211
+ const col = fieldColumns.find((c) => c.field === field);
212
+ return col && typeof col.header === "string" ? col.header : field;
213
+ }
214
+
215
+ // --- build the event spec from config + overlay, then resolve for the window ---
216
+ const spec = $derived.by<EventSpec<TData>>(() => {
217
+ void tz; // re-resolve events (their positions) when the display zone changes
218
+ return {
219
+ getKey: (r) => key(r),
220
+ getStart: (r) => startOfE[key(r)] ?? (toZ(fieldValue(r, scheduler.startField)) as never),
221
+ getEnd: (r) =>
222
+ endOfE[key(r)] ??
223
+ (scheduler.endField ? (toZ(fieldValue(r, scheduler.endField)) as never) : undefined),
224
+ getAllDay: (r) =>
225
+ allDayOf[key(r)] ?? (scheduler.allDayField ? !!fieldValue(r, scheduler.allDayField) : false),
226
+ getTitle: (r) => (titleField ? String(fieldValue(r, titleField) ?? "") : ""),
227
+ getColor: (r) =>
228
+ scheduler.colorField
229
+ ? (fieldValue(r, scheduler.colorField) as string | undefined)
230
+ : scheduler.color,
231
+ getSecondaryColor: (r) =>
232
+ scheduler.secondaryColorField
233
+ ? (fieldValue(r, scheduler.secondaryColorField) as string | undefined)
234
+ : undefined,
235
+ getStatus: (r) =>
236
+ scheduler.statusField
237
+ ? (fieldValue(r, scheduler.statusField) as string | undefined)
238
+ : undefined,
239
+ getResource: (r) =>
240
+ resourceOfE[key(r)] ??
241
+ (resourceField ? (fieldValue(r, resourceField) as string | undefined) : undefined),
242
+ getRecurrence: (r) =>
243
+ recurEnabled ? (fieldValue(r, recurField) as never) : undefined,
244
+ getExceptions: (r) => {
245
+ const f = scheduler.recurrenceExceptionsField;
246
+ if (!f) return undefined;
247
+ const arr = fieldValue(r, f) as ReadonlyArray<{ occurrenceStart: unknown; deleted?: boolean; start?: unknown; end?: unknown; title?: string; allDay?: boolean }> | undefined;
248
+ return arr?.map((e) => ({
249
+ occurrenceStart: toZ(e.occurrenceStart) ?? new Date(NaN),
250
+ deleted: e.deleted,
251
+ start: e.start != null ? toZ(e.start) : undefined,
252
+ end: e.end != null ? toZ(e.end) : undefined,
253
+ title: e.title,
254
+ allDay: e.allDay,
255
+ }));
256
+ },
257
+ defaultDurationMin: scheduler.defaultDurationMin ?? 60,
258
+ };
259
+ });
260
+ // Recurrence works when a `recurrenceField` is set OR the drawer is on (so any
261
+ // event can be made recurring from the drawer). Falls back to a default field
262
+ // name to store the rule when the consumer didn't name one.
263
+ const recurEnabled = $derived(!!scheduler.recurrenceField || !!scheduler.drawer);
264
+ const recurField = $derived(scheduler.recurrenceField ?? "recurrence");
265
+
266
+ const range = $derived(rangeForView(view, anchor, weekStartsOn, agendaDays));
267
+ const events = $derived(resolveEvents(data, spec, range.start, range.end));
268
+
269
+ // --- resources: full list (for the legend/grouping) derived from config or
270
+ // the data. `hiddenResources` is a per-view filter applied to `viewEvents`. ---
271
+ const resources = $derived.by<SchedulerResource[]>(() => {
272
+ if (!resourceField) return [];
273
+ if (scheduler.resources?.length) return [...scheduler.resources];
274
+ const seen = new Set<string>();
275
+ const out: SchedulerResource[] = [];
276
+ for (const e of events) {
277
+ const id = e.resourceId ?? "";
278
+ if (!seen.has(id)) {
279
+ seen.add(id);
280
+ out.push({ id, title: id || "(none)" });
281
+ }
282
+ }
283
+ return out;
284
+ });
285
+ let hiddenResources = $state<Set<string>>(new Set());
286
+ // The events actually rendered: `events` minus any filtered-out resources.
287
+ // Used by EVERY view so the resource filter works everywhere.
288
+ const viewEvents = $derived(
289
+ resourceField && hiddenResources.size
290
+ ? events.filter((e) => !hiddenResources.has(e.resourceId ?? ""))
291
+ : events,
292
+ );
293
+ function toggleResource(id: string) {
294
+ const next = new Set(hiddenResources);
295
+ if (next.has(id)) next.delete(id);
296
+ else next.add(id);
297
+ hiddenResources = next;
298
+ }
299
+
300
+ // Resource grouping applies to the time-grid (Week / Day) views.
301
+ const resourcesEnabled = $derived(!!resourceField && (view === "day" || view === "week"));
302
+ const groupByDate = $derived(scheduler.groupByDate === true);
303
+
304
+ // --- timeline views (horizontal: time left→right, resources as rows) ---
305
+ const isTimeline = $derived(view.startsWith("timeline"));
306
+ const tlLaneH = $derived(scheduler.timelineLaneHeight ?? 26);
307
+ const tlResW = $derived(scheduler.resourceAreaWidth ?? 160);
308
+ const tlSlot = $derived(Math.max(1, scheduler.timelineSlotMinutes ?? slotMinutes));
309
+ const tlAxis = $derived(
310
+ timelineAxis(view, range.start, range.end, {
311
+ dayStartHour,
312
+ dayEndHour,
313
+ today: startOfDay(toZonedLocal(new Date(), tz)),
314
+ }),
315
+ );
316
+ // One tick is at least this wide; the axis scrolls horizontally past it.
317
+ const TL_TICK_MIN = $derived(
318
+ view === "timelineMonth" ? 42 : view === "timelineDay" ? 68 : view === "timelineYear" ? 80 : 96,
319
+ );
320
+ // Measured width of the timeline scroller, so the axis can STRETCH to fill it
321
+ // (week / year) yet still scroll when the ticks genuinely overflow (month).
322
+ let tlOuterW = $state(0);
323
+ const tlAxisWidth = $derived(
324
+ Math.max(tlAxis.ticks.length * TL_TICK_MIN, tlOuterW ? tlOuterW - tlResW : 0, 320),
325
+ );
326
+ const tlRows = $derived(timelineRows(resourceField ? resources : null, viewEvents));
327
+ const VIEW_LABELS: Record<SchedulerView, string> = {
328
+ month: "Month",
329
+ week: "Week",
330
+ day: "Day",
331
+ agenda: "Agenda",
332
+ timelineDay: "Timeline · Day",
333
+ timelineWeek: "Timeline · Week",
334
+ timelineMonth: "Timeline · Month",
335
+ timelineYear: "Timeline · Year",
336
+ };
337
+ const viewLabel = (v: SchedulerView) => VIEW_LABELS[v] ?? v;
338
+
339
+ // --- time-grid columns: resource x day when grouped, else one per day. ---
340
+ type GridCol = { key: string; label: string; sub: string; date: Date; resourceId?: string; color?: string; today: boolean };
341
+ type GroupHeader = { key: string; label: string; color?: string; span: number };
342
+ const gridDays = $derived(daysForView(view, anchor, weekStartsOn));
343
+ const gridCols = $derived.by<GridCol[]>(() => {
344
+ const isToday = (d: Date) => isSameDay(d, startOfDay(toZonedLocal(new Date(), tz)));
345
+ if (!resourcesEnabled) {
346
+ return gridDays.map((d) => ({
347
+ key: `day:${d.getTime()}`,
348
+ label: wd(d.getDay()),
349
+ sub: `${d.getDate()}`,
350
+ date: d,
351
+ today: isToday(d),
352
+ }));
353
+ }
354
+ const cols: GridCol[] = [];
355
+ if (groupByDate) {
356
+ for (const d of gridDays)
357
+ for (const r of resources)
358
+ cols.push({ key: `${d.getTime()}|${r.id}`, label: r.title ?? r.id, sub: "", date: d, resourceId: r.id, color: r.color, today: false });
359
+ } else {
360
+ for (const r of resources)
361
+ for (const d of gridDays)
362
+ cols.push({ key: `${r.id}|${d.getTime()}`, label: wd(d.getDay()), sub: `${d.getDate()}`, date: d, resourceId: r.id, color: r.color, today: isToday(d) });
363
+ }
364
+ return cols;
365
+ });
366
+ // The spanning header row above the columns (resource groups, or date groups).
367
+ const groupHeaders = $derived.by<GroupHeader[]>(() => {
368
+ if (!resourcesEnabled) return [];
369
+ if (groupByDate)
370
+ return gridDays.map((d) => ({ key: `d:${d.getTime()}`, label: `${wd(d.getDay())} ${d.getDate()}`, span: resources.length }));
371
+ return resources.map((r) => ({ key: `r:${r.id}`, label: r.title ?? r.id, color: r.color, span: gridDays.length }));
372
+ });
373
+ // When grouped there can be many columns, so give them a min width + h-scroll.
374
+ const GROUP_COL_MIN = 96;
375
+ const gridMinWidth = $derived(resourcesEnabled ? 56 + gridCols.length * GROUP_COL_MIN : 0);
376
+
377
+ function colEvents(col: GridCol): ResolvedEvent<TData>[] {
378
+ return eventsOnDay(viewEvents, col.date).filter((e) =>
379
+ col.resourceId != null ? (e.resourceId ?? "") === col.resourceId : true,
380
+ );
381
+ }
382
+ // An event belongs in the all-day ROW (not the hourly grid) when it is flagged
383
+ // all-day OR spans more than one calendar day - otherwise a multi-day event
384
+ // would fill whole day columns. Row events render as spanning bars up top.
385
+ function isTimeGridRow(e: ResolvedEvent<TData>): boolean {
386
+ if (e.allDay) return true;
387
+ return startOfDay(new Date(e.end.getTime() - 1)).getTime() > startOfDay(e.start).getTime();
388
+ }
389
+ function colLayout(col: GridCol) {
390
+ return layoutDayEvents(
391
+ colEvents(col).filter((e) => !isTimeGridRow(e)),
392
+ col.date,
393
+ {
394
+ dayStartHour,
395
+ dayEndHour,
396
+ mode: collisionMode,
397
+ maxColumns,
398
+ },
399
+ );
400
+ }
401
+ function colAllDay(col: GridCol) {
402
+ return colEvents(col).filter((e) => isTimeGridRow(e));
403
+ }
404
+ // Spanning bars for the all-day row (non-resource week/day): reuses the month
405
+ // week-segment packing over the visible days.
406
+ const allDayBars = $derived(view === "week" && !resourcesEnabled);
407
+ const allDaySegs = $derived.by(() => {
408
+ if (!allDayBars) return { segments: [], laneCount: 0 };
409
+ const rowEvents = viewEvents.filter((e) => isTimeGridRow(e));
410
+ return monthWeekSegments(rowEvents, gridDays[0] ?? anchor);
411
+ });
412
+
413
+ const monthWeeks = $derived(monthMatrix(anchor, weekStartsOn, 6));
414
+ const agenda = $derived(agendaGroups(viewEvents));
415
+
416
+ // --- month spanning-bar layout ---
417
+ const MONTH_DAYNUM_H = 22; // px reserved at the top of each cell for the date
418
+ const MONTH_LANE_H = 20; // px per event-bar lane
419
+ const MONTH_MORE_H = 16; // px reserved for the "+N more" row
420
+ let monthBodyH = $state(0);
421
+ const visibleMonthLanes = $derived.by(() => {
422
+ const weekH = monthBodyH / Math.max(1, monthWeeks.length);
423
+ return Math.max(1, Math.floor((weekH - MONTH_DAYNUM_H - MONTH_MORE_H) / MONTH_LANE_H));
424
+ });
425
+ // Events hidden (beyond the visible lanes) that cover a given day column.
426
+ function monthMoreCount(
427
+ segments: ReadonlyArray<{ startCol: number; endCol: number; lane: number }>,
428
+ col: number,
429
+ ): number {
430
+ return segments.filter(
431
+ (s) => s.lane >= visibleMonthLanes && s.startCol <= col && s.endCol >= col,
432
+ ).length;
433
+ }
434
+ // The all-day row is ALWAYS shown in the time-grid so it is always a drop
435
+ // target (drag an event up to make it all-day).
436
+ const hasAllDayRow = $derived(view === "week" || view === "day");
437
+ const bandHours = $derived(Math.max(1, dayEndHour - dayStartHour));
438
+ const hourList = $derived(Array.from({ length: bandHours }, (_, i) => dayStartHour + i));
439
+
440
+ // Ruler subdivision: every slot is a fixed SLOT_PX tall, so the hour grows
441
+ // with the granularity (1h -> 30px rows, 30m -> 2x30px = 60px/hour, 15m ->
442
+ // 4x30, 5m -> 12x30). Event positions are percent-based, so this stays correct.
443
+ const SLOT_PX = 30;
444
+ const slotsPerHour = $derived(Math.max(1, Math.round(60 / slotMinutes)));
445
+ const slotPx = SLOT_PX;
446
+ const hourPx = $derived(slotsPerHour * SLOT_PX);
447
+ const gridSlots = $derived.by(() => {
448
+ const out: { min: number; startsOnHour: boolean; endsOnHour: boolean }[] = [];
449
+ const total = bandHours * slotsPerHour;
450
+ for (let i = 0; i < total; i++) {
451
+ const min = dayStartHour * 60 + i * slotMinutes;
452
+ out.push({ min, startsOnHour: min % 60 === 0, endsOnHour: (min + slotMinutes) % 60 === 0 });
453
+ }
454
+ return out;
455
+ });
456
+ const slotSizeLabel = (m: number) => (m % 60 === 0 ? `${m / 60}h` : `${m}m`);
457
+ function slotRulerLabel(s: { min: number; startsOnHour: boolean }): string {
458
+ if (s.startsOnHour) return hourLabel(s.min / 60);
459
+ if (slotMinutes >= 15) return `:${String(s.min % 60).padStart(2, "0")}`;
460
+ return "";
461
+ }
462
+
463
+ // --- current-time indicator (the "now" line), ticking each minute ---
464
+ // `now` is kept in pseudo-local time (see `tz`) so it positions in the zone.
465
+ const nowIndicator = $derived(scheduler.nowIndicator !== false);
466
+ // svelte-ignore state_referenced_locally
467
+ let now = $state(toZonedLocal(new Date(), normalizeTimeZone(scheduler.timeZone)));
468
+ $effect(() => {
469
+ if (!nowIndicator && !scheduler.reminderField) return; // tick for the now-line OR reminders
470
+ now = toZonedLocal(new Date(), tz);
471
+ const id = setInterval(() => (now = toZonedLocal(new Date(), tz)), 60_000);
472
+ return () => clearInterval(id);
473
+ });
474
+ const nowMin = $derived(now.getHours() * 60 + now.getMinutes());
475
+
476
+ // --- reminders: fire once as an event's lead time is crossed ---------------
477
+ const firedReminders = new Set<string>();
478
+ let reminders = $state<{ id: number; text: string }[]>([]);
479
+ let reminderSeq = 0;
480
+ $effect(() => {
481
+ const field = scheduler.reminderField;
482
+ if (!field) return;
483
+ const nowMs = now.getTime();
484
+ for (const ev of events) {
485
+ const lead = Number(fieldValue(ev.row, field));
486
+ if (!Number.isFinite(lead) || lead < 0) continue;
487
+ const untilMin = (ev.start.getTime() - nowMs) / 60000;
488
+ // Just entered the lead window and not yet started (allow a 1-min slack).
489
+ if (untilMin > lead || untilMin < -1) continue;
490
+ const key = `${ev.rowKey}#${ev.start.getTime()}`;
491
+ if (firedReminders.has(key)) continue;
492
+ firedReminders.add(key);
493
+ const mins = Math.max(0, Math.round(untilMin));
494
+ untrack(() => {
495
+ scheduler.onReminder?.(ev.row, mins);
496
+ const id = ++reminderSeq;
497
+ reminders = [...reminders, { id, text: `${ev.title || "Event"} ${mins <= 0 ? "is starting" : `in ${mins} min`}` }];
498
+ setTimeout(() => (reminders = reminders.filter((r) => r.id !== id)), 6000);
499
+ });
500
+ }
501
+ });
502
+
503
+ // --- secondary time-zone rulers (a "world clock" left of the primary gutter) ---
504
+ const primaryZoneLabel = $derived(tz ? zoneAbbr(fromZ(now), tz) : "");
505
+ const secondaryRulers = $derived.by(() => {
506
+ const list = scheduler.secondaryTimeZones;
507
+ if (!list?.length || !hasAllDayRow) return [] as { label: string; rows: string[] }[];
508
+ // Map each primary-band hour to the wall-clock hour in the other zone, using
509
+ // the anchor day for the (DST-dependent) offset.
510
+ return list.map((sz) => {
511
+ const zid = normalizeTimeZone(sz.id);
512
+ const rows = hourList.map((h) => {
513
+ const inst = instantFromWallClock(anchor.getFullYear(), anchor.getMonth() + 1, anchor.getDate(), h, 0, 0, tz);
514
+ return hourLabel(zid ? zoneParts(inst, zid).hour : h);
515
+ });
516
+ const headInst = instantFromWallClock(anchor.getFullYear(), anchor.getMonth() + 1, anchor.getDate(), 12, 0, 0, tz);
517
+ return { label: sz.label ?? (zid ? zoneAbbr(headInst, zid) : sz.id), rows };
518
+ });
519
+ });
520
+ const gutterCount = $derived(1 + secondaryRulers.length);
521
+
522
+ // --- booking rules: working-hours shading + conflict prevention ----------
523
+ const businessHours = $derived(scheduler.businessHours);
524
+ const nonWorkingDaySet = $derived(new Set(scheduler.nonWorkingDays ?? []));
525
+ const shadeUntilNow = $derived(scheduler.shadeUntilNow === true);
526
+ const disableConflicts = $derived(scheduler.disableConflicts === true);
527
+ const isNonWorkingDay = (d: Date) => nonWorkingDaySet.has(d.getDay());
528
+ const resById = $derived(new Map(resources.map((r) => [r.id, r])));
529
+ const anyAvailability = $derived(resources.some((r) => r.availability?.length));
530
+ // Hard booking bounds + highlights (Wave B follow-ups).
531
+ const restrictedHours = $derived(scheduler.restrictedHours ?? []);
532
+ const dayKey = (d: Date) => startOfDay(d).getTime();
533
+ const zDayKey = (v: unknown) => dayKey(toZonedLocal(toDate(v as never) ?? new Date(), tz));
534
+ const restrictedDateSet = $derived(new Set((scheduler.restrictedDates ?? []).map(zDayKey)));
535
+ const specialByDay = $derived(
536
+ new Map((scheduler.specialDates ?? []).map((s) => [zDayKey(s.date), s])),
537
+ );
538
+ const minDate = $derived(scheduler.minDate != null ? startOfDay(toZonedLocal(toDate(scheduler.minDate as never)!, tz)) : null);
539
+ const maxDate = $derived(scheduler.maxDate != null ? startOfDay(toZonedLocal(toDate(scheduler.maxDate as never)!, tz)) : null);
540
+ const maxEventsPerSlot = $derived(scheduler.maxEventsPerSlot);
541
+ const anyDateOverrides = $derived(resources.some((r) => r.dateOverrides?.length));
542
+ const isRestrictedDate = (d: Date) => restrictedDateSet.has(dayKey(d));
543
+ const hasBookingShade = $derived(
544
+ !!businessHours || shadeUntilNow || nonWorkingDaySet.size > 0 || anyAvailability ||
545
+ restrictedHours.length > 0 || restrictedDateSet.size > 0 || anyDateOverrides,
546
+ );
547
+ // A resource's explicit windows for ONE specific date via `dateOverrides`
548
+ // (off -> [], custom windows -> those). Returns null when no override matches.
549
+ function resourceDateWindows(res: SchedulerResource | undefined, date: Date): Array<[number, number]> | null {
550
+ const ov = res?.dateOverrides?.find((o) => isSameDay(toZonedLocal(toDate(o.date as never) ?? new Date(), tz), date));
551
+ if (!ov) return null;
552
+ if (ov.off) return [];
553
+ const bandStart = dayStartHour * 60;
554
+ const bandEnd = dayEndHour * 60;
555
+ return (ov.windows ?? []).map((w) => [Math.max(bandStart, w.start * 60), Math.min(bandEnd, w.end * 60)] as [number, number]).filter(([a, b]) => b > a);
556
+ }
557
+ // The working [startMin, endMin] intervals for a column: the column's RESOURCE
558
+ // availability if it has any (per-doctor hours), else the global businessHours /
559
+ // nonWorkingDays. Empty = the whole day is off.
560
+ function columnWorkIntervals(col: GridCol): Array<[number, number]> {
561
+ const bandStart = dayStartHour * 60;
562
+ const bandEnd = dayEndHour * 60;
563
+ const res = col.resourceId != null ? resById.get(col.resourceId) : undefined;
564
+ const wd = col.date.getDay();
565
+ const override = resourceDateWindows(res, col.date);
566
+ if (override) return override;
567
+ if (res?.availability?.length) return workingIntervals(wd, res.availability, bandStart, bandEnd);
568
+ if (nonWorkingDaySet.has(wd)) return [];
569
+ if (businessHours) return [[Math.max(bandStart, businessHours.start * 60), Math.min(bandEnd, businessHours.end * 60)]];
570
+ return [[bandStart, bandEnd]]; // fully working (no shade)
571
+ }
572
+ // Shaded bands (top% / height%) = the complement of the working intervals.
573
+ function columnShadeBands(col: GridCol): { top: number; height: number }[] {
574
+ const bandStart = dayStartHour * 60;
575
+ const bandEnd = dayEndHour * 60;
576
+ const total = bandHours * 60;
577
+ const out: { top: number; height: number }[] = [];
578
+ let cursor = bandStart;
579
+ for (const [a, b] of columnWorkIntervals(col)) {
580
+ if (a > cursor) out.push({ top: ((cursor - bandStart) / total) * 100, height: ((a - cursor) / total) * 100 });
581
+ cursor = Math.max(cursor, b);
582
+ }
583
+ if (cursor < bandEnd) out.push({ top: ((cursor - bandStart) / total) * 100, height: ((bandEnd - cursor) / total) * 100 });
584
+ return out;
585
+ }
586
+ // Hard-restricted bands (distinct hatch) = the whole day when the date is
587
+ // blocked, else each `restrictedHours` band clamped to the visible band.
588
+ function columnRestrictedBands(col: GridCol): { top: number; height: number }[] {
589
+ const bandStart = dayStartHour * 60;
590
+ const bandEnd = dayEndHour * 60;
591
+ const total = bandHours * 60;
592
+ if (isRestrictedDate(col.date)) return [{ top: 0, height: 100 }];
593
+ const out: { top: number; height: number }[] = [];
594
+ for (const b of restrictedHours) {
595
+ const a = Math.max(bandStart, b.start * 60);
596
+ const z = Math.min(bandEnd, b.end * 60);
597
+ if (z > a) out.push({ top: ((a - bandStart) / total) * 100, height: ((z - a) / total) * 100 });
598
+ }
599
+ return out;
600
+ }
601
+ const restrictToBusinessHours = $derived(scheduler.restrictToBusinessHours === true);
602
+ // A brief flash when a drop/create is rejected (double-book or out-of-hours).
603
+ let conflictMsg = $state<string | null>(null);
604
+ let conflictTimer: ReturnType<typeof setTimeout> | undefined;
605
+ function flashBlocked(msg: string) {
606
+ conflictMsg = msg;
607
+ clearTimeout(conflictTimer);
608
+ conflictTimer = setTimeout(() => (conflictMsg = null), 1500);
609
+ }
610
+ // True when [start,end] falls outside the working windows for `resourceId`
611
+ // (its own availability if set, else the global businessHours / nonWorkingDays).
612
+ function outsideWorkingTime(start: Date, end: Date, resourceId: string | undefined): boolean {
613
+ const bandStart = dayStartHour * 60;
614
+ const bandEnd = dayEndHour * 60;
615
+ const res = resourceId != null ? resById.get(resourceId) : undefined;
616
+ const wd = start.getDay();
617
+ const override = resourceDateWindows(res, start);
618
+ let intervals: Array<[number, number]>;
619
+ if (override) intervals = override;
620
+ else if (res?.availability?.length) intervals = workingIntervals(wd, res.availability, bandStart, bandEnd);
621
+ else if (nonWorkingDaySet.has(wd)) intervals = [];
622
+ else if (businessHours) intervals = [[Math.max(bandStart, businessHours.start * 60), Math.min(bandEnd, businessHours.end * 60)]];
623
+ else return false; // no working-time restriction configured
624
+ const sMin = start.getHours() * 60 + start.getMinutes();
625
+ const rawEnd = end.getHours() * 60 + end.getMinutes();
626
+ const eMin = rawEnd === 0 ? 24 * 60 : rawEnd; // end at midnight = end of day
627
+ return !withinWorking(sMin, eMin, intervals);
628
+ }
629
+ // Returns true (and flashes) when placing [start,end] should be rejected -
630
+ // out-of-hours (when restricted) or a same-resource double-book. Caller reverts.
631
+ function bookingBlocked(start: Date, end: Date, resourceId: string | undefined, excludeRowKey: string): boolean {
632
+ // Hard bounds first (always enforced, no opt-in).
633
+ if (minDate && startOfDay(start).getTime() < minDate.getTime()) { flashBlocked("Before the allowed range"); return true; }
634
+ if (maxDate && startOfDay(start).getTime() > maxDate.getTime()) { flashBlocked("After the allowed range"); return true; }
635
+ if (isRestrictedDate(start)) { flashBlocked("Date is blocked"); return true; }
636
+ if (restrictedHours.length) {
637
+ const sMin = start.getHours() * 60 + start.getMinutes();
638
+ const rawEnd = end.getHours() * 60 + end.getMinutes();
639
+ const eMin = rawEnd === 0 ? 24 * 60 : rawEnd;
640
+ if (overlapsBands(sMin, eMin, restrictedHours)) { flashBlocked("Restricted time"); return true; }
641
+ }
642
+ if (restrictToBusinessHours && outsideWorkingTime(start, end, resourceId)) {
643
+ flashBlocked("Outside working hours");
644
+ return true;
645
+ }
646
+ if (disableConflicts && hasConflict(start, end, resourceId ?? undefined, events, excludeRowKey)) {
647
+ flashBlocked("Time slot already booked");
648
+ return true;
649
+ }
650
+ if (maxEventsPerSlot != null && overlapCount(start, end, resourceId ?? undefined, events, excludeRowKey) >= maxEventsPerSlot) {
651
+ flashBlocked(`Limit of ${maxEventsPerSlot} per slot reached`);
652
+ return true;
653
+ }
654
+ return false;
655
+ }
656
+
657
+ // --- undo / redo of drag-move + resize -----------------------------------
658
+ const historyEnabled = $derived(scheduler.history === true);
659
+ type EvState = { start: Date; end: Date; resource?: string; allDay?: boolean };
660
+ type HistCmd = { key: string; row: TData; before: EvState; after: EvState; kind: "move" | "resize" };
661
+ let undoStack: HistCmd[] = [];
662
+ let redoStack: HistCmd[] = [];
663
+ function pushHistory(cmd: HistCmd) {
664
+ if (!historyEnabled) return;
665
+ undoStack.push(cmd);
666
+ if (undoStack.length > 100) undoStack.shift();
667
+ redoStack = [];
668
+ }
669
+ function applyState(cmd: HistCmd, s: EvState) {
670
+ const k = cmd.key;
671
+ startOfE[k] = s.start;
672
+ endOfE[k] = s.end;
673
+ if (s.allDay !== undefined) allDayOf[k] = s.allDay;
674
+ if (s.resource !== undefined) resourceOfE[k] = s.resource;
675
+ if (cmd.kind === "move") emitMove({ row: cmd.row, start: s.start, end: s.end, allDay: s.allDay ?? false, toResource: s.resource });
676
+ else emitResize({ row: cmd.row, start: s.start, end: s.end });
677
+ }
678
+ function undoHistory() {
679
+ const cmd = undoStack.pop();
680
+ if (!cmd) return;
681
+ applyState(cmd, cmd.before);
682
+ redoStack.push(cmd);
683
+ }
684
+ function redoHistory() {
685
+ const cmd = redoStack.pop();
686
+ if (!cmd) return;
687
+ applyState(cmd, cmd.after);
688
+ undoStack.push(cmd);
689
+ }
690
+ $effect(() => {
691
+ if (!historyEnabled) return;
692
+ const onKey = (e: KeyboardEvent) => {
693
+ if (!(e.ctrlKey || e.metaKey)) return;
694
+ const t = e.target as HTMLElement | null;
695
+ if (t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.isContentEditable)) return;
696
+ const key = e.key.toLowerCase();
697
+ if (key === "z" && !e.shiftKey) {
698
+ e.preventDefault();
699
+ undoHistory();
700
+ } else if ((key === "z" && e.shiftKey) || key === "y") {
701
+ e.preventDefault();
702
+ redoHistory();
703
+ }
704
+ };
705
+ window.addEventListener("keydown", onKey);
706
+ return () => window.removeEventListener("keydown", onKey);
707
+ });
708
+
709
+ // --- custom event content + hover tooltip --------------------------------
710
+ const tooltipCfg = $derived(scheduler.tooltip);
711
+ const hasTooltip = $derived(!!tooltipCfg);
712
+ const tooltipSnippet = $derived(typeof tooltipCfg === "function" ? tooltipCfg : undefined);
713
+ let tipEv = $state<ResolvedEvent<TData> | null>(null);
714
+ let tipPos = $state({ x: 0, y: 0 });
715
+ let tipTimer: ReturnType<typeof setTimeout> | undefined;
716
+ function onEventEnter(e: MouseEvent, ev: ResolvedEvent<TData>) {
717
+ if (!hasTooltip || drag || tlDrag || monthDrag) return;
718
+ clearTimeout(tipTimer);
719
+ const target = e.currentTarget as HTMLElement;
720
+ tipTimer = setTimeout(() => {
721
+ const r = target.getBoundingClientRect();
722
+ tipPos = { x: Math.round(r.left + r.width / 2), y: Math.round(r.top) };
723
+ tipEv = ev;
724
+ }, scheduler.tooltipDelay ?? 400);
725
+ }
726
+ function onEventLeave() {
727
+ clearTimeout(tipTimer);
728
+ tipEv = null;
729
+ }
730
+ const resourceTitle = (id: string | undefined) => resources.find((r) => r.id === id)?.title ?? id;
731
+
732
+ // --- unscheduled backlog: drag an item onto the Week/Day grid to schedule it ---
733
+ type BacklogItem = { id: string; title: string; durationMin?: number; color?: string };
734
+ const backlogItems = $derived(scheduler.unscheduled ?? []);
735
+ const hasBacklog = $derived(backlogItems.length > 0 && view !== "agenda");
736
+ let backlogDrag = $state<{ item: BacklogItem; x: number; y: number; over: boolean } | null>(null);
737
+ function startBacklogDrag(item: BacklogItem, e: PointerEvent) {
738
+ if (e.button !== 0) return;
739
+ e.preventDefault();
740
+ backlogDrag = { item, x: e.clientX, y: e.clientY, over: false };
741
+ window.addEventListener("pointermove", onBacklogMove);
742
+ window.addEventListener("pointerup", onBacklogEnd, { once: true });
743
+ }
744
+ function onBacklogMove(e: PointerEvent) {
745
+ if (!backlogDrag) return;
746
+ backlogDrag = { ...backlogDrag, x: e.clientX, y: e.clientY, over: pointInEl(e.clientX, e.clientY, gridScrollEl) };
747
+ }
748
+ function onBacklogEnd(e: PointerEvent) {
749
+ window.removeEventListener("pointermove", onBacklogMove);
750
+ const d = backlogDrag;
751
+ backlogDrag = null;
752
+ if (!d) return;
753
+ const dur = d.item.durationMin ?? scheduler.defaultDurationMin ?? 60;
754
+ if (view === "month") {
755
+ // Drop onto a month day cell -> an all-day event on that date.
756
+ const el = document.elementFromPoint(e.clientX, e.clientY);
757
+ const cell = el?.closest?.("[data-day]") as HTMLElement | null;
758
+ if (!cell) return;
759
+ const t = Number(cell.getAttribute("data-day"));
760
+ if (!Number.isFinite(t)) return;
761
+ scheduler.onSchedule?.(d.item, fromZ(new Date(t)), undefined);
762
+ return;
763
+ }
764
+ if (isTimeline) {
765
+ // Drop onto the horizontal timeline: x -> time, y -> resource row.
766
+ if (!pointInEl(e.clientX, e.clientY, tlScrollEl)) return;
767
+ const start = snapTlStart(tlTimeAtX(e.clientX));
768
+ const resId = tlResAt(e.clientX, e.clientY);
769
+ const end = new Date(start.getTime() + dur * 60000);
770
+ if (bookingBlocked(start, end, resId, "")) return;
771
+ scheduler.onSchedule?.(d.item, fromZ(start), resId);
772
+ return;
773
+ }
774
+ if (!pointInEl(e.clientX, e.clientY, gridScrollEl)) return;
775
+ const col = colAt(e.clientX);
776
+ if (!col) return;
777
+ const min = snapMinute(minuteAt(e.clientY), slotMinutes);
778
+ const start = dateAtMinute(col.date, min);
779
+ const end = new Date(start.getTime() + dur * 60000);
780
+ if (bookingBlocked(start, end, col.resourceId, "")) return;
781
+ scheduler.onSchedule?.(d.item, fromZ(start), col.resourceId);
782
+ }
783
+ // Fraction (0-1) of the hourly band the current time sits at, or null if the
784
+ // band doesn't include "now" (e.g. a narrow business-hours band at night).
785
+ const nowBandPct = $derived(
786
+ nowIndicator && nowMin >= dayStartHour * 60 && nowMin <= dayEndHour * 60
787
+ ? ((nowMin - dayStartHour * 60) / (bandHours * 60)) * 100
788
+ : null,
789
+ );
790
+ // Horizontal position (%) of "now" on the timeline axis, or null if off-axis.
791
+ // (timelineGeom rejects zero-width spans, so position it directly.)
792
+ const nowTlPct = $derived.by(() => {
793
+ if (!nowIndicator || !isTimeline) return null;
794
+ const frac = (now.getTime() - tlAxis.start.getTime()) / tlAxis.totalMs;
795
+ return frac >= 0 && frac <= 1 ? frac * 100 : null;
796
+ });
797
+
798
+ // --- labels / formatting ---
799
+ const WEEKDAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
800
+ const MONTHS = [
801
+ "January", "February", "March", "April", "May", "June",
802
+ "July", "August", "September", "October", "November", "December",
803
+ ];
804
+ const wd = (i: number): string => WEEKDAYS[i] ?? "";
805
+ const mon = (i: number): string => MONTHS[i] ?? "";
806
+ const headerOrder = $derived(weekdayOrder(weekStartsOn));
807
+ function fmtTime(d: Date): string {
808
+ const h = d.getHours();
809
+ const m = d.getMinutes();
810
+ const ap = h < 12 ? "am" : "pm";
811
+ const h12 = h % 12 === 0 ? 12 : h % 12;
812
+ return m === 0 ? `${h12}${ap}` : `${h12}:${String(m).padStart(2, "0")}${ap}`;
813
+ }
814
+ function hourLabel(h: number): string {
815
+ const ap = h < 12 ? "am" : "pm";
816
+ const h12 = h % 12 === 0 ? 12 : h % 12;
817
+ return `${h12} ${ap}`;
818
+ }
819
+ const titleLabel = $derived.by(() => {
820
+ if (view === "month" || view === "timelineMonth") return `${mon(anchor.getMonth())} ${anchor.getFullYear()}`;
821
+ if (view === "timelineYear") return `${anchor.getFullYear()}`;
822
+ if (view === "day" || view === "timelineDay") return anchor.toLocaleDateString(undefined, { weekday: "long", month: "long", day: "numeric", year: "numeric" });
823
+ // week / timelineWeek / agenda: a range
824
+ const days = view === "week" ? daysForView("week", anchor, weekStartsOn) : [range.start, addDays(range.end, -1)];
825
+ const a = days[0] ?? anchor;
826
+ const b = days[days.length - 1] ?? anchor;
827
+ const sameMonth = a.getMonth() === b.getMonth();
828
+ return `${mon(a.getMonth()).slice(0, 3)} ${a.getDate()} - ${sameMonth ? "" : mon(b.getMonth()).slice(0, 3) + " "}${b.getDate()}, ${b.getFullYear()}`;
829
+ });
830
+
831
+ function today() {
832
+ clearRangeSelect();
833
+ anchor = startOfDay(toZonedLocal(new Date(), tz));
834
+ scheduler.onNavigate?.(anchor);
835
+ }
836
+ function go(dir: number) {
837
+ clearRangeSelect();
838
+ const next = navigateAnchor(view, anchor, dir);
839
+ // Respect min/max navigation bounds.
840
+ if (minDate && next.getTime() < minDate.getTime()) return;
841
+ if (maxDate && next.getTime() > maxDate.getTime()) return;
842
+ anchor = next;
843
+ scheduler.onNavigate?.(anchor);
844
+ }
845
+ // Disable prev/next when the next step would cross a bound.
846
+ const canGoPrev = $derived(!minDate || navigateAnchor(view, anchor, -1).getTime() >= minDate.getTime());
847
+ const canGoNext = $derived(!maxDate || navigateAnchor(view, anchor, 1).getTime() <= maxDate.getTime());
848
+ function setView(v: SchedulerView) {
849
+ clearRangeSelect();
850
+ view = v;
851
+ }
852
+ // Controlled date: when `scheduler.date` changes externally, navigate to it.
853
+ // `anchor` is read untracked so internal nav (prev/next) is not fought.
854
+ $effect(() => {
855
+ const d = scheduler.date;
856
+ if (d == null) return;
857
+ const target = startOfDay(toZonedLocal(toDate(d) ?? new Date(), normalizeTimeZone(scheduler.timeZone)));
858
+ untrack(() => {
859
+ if (target.getTime() !== anchor.getTime()) anchor = target;
860
+ });
861
+ });
862
+
863
+ // --- drag to move / resize in the time-grid, non-recurring timed events only.
864
+ // move - drag the body: shifts start (+ end) and can cross columns
865
+ // resize-start - drag the TOP grip: moves start, end stays put
866
+ // resize-end - drag the BOTTOM grip: moves end, start stays put
867
+ // A small threshold distinguishes a drag from a click, and `suppressClick`
868
+ // stops the click that follows a real drag from also opening the event. ---
869
+ type DragMode = "move" | "resize-start" | "resize-end";
870
+ type Drag = {
871
+ ev: ResolvedEvent<TData>;
872
+ mode: DragMode;
873
+ startX: number;
874
+ startY: number;
875
+ grabOffsetMin: number; // where inside the event the pointer grabbed (move)
876
+ durationMin: number;
877
+ origStart: Date;
878
+ origEnd: Date;
879
+ startCol: GridCol;
880
+ moved: boolean;
881
+ previewStart: Date;
882
+ previewEnd: Date;
883
+ previewCol: GridCol;
884
+ x: number; // live cursor position, for the drag ghost that follows the pointer
885
+ y: number;
886
+ overAllDay: GridCol | null; // set when a move drag hovers the all-day row
887
+ };
888
+ let drag = $state<Drag | null>(null);
889
+ let bodyEl = $state<HTMLElement | null>(null);
890
+ let allDayRowEl = $state<HTMLElement | null>(null);
891
+ let backlogEl = $state<HTMLElement | null>(null);
892
+ // True while an event drag hovers the backlog panel (drop = unschedule).
893
+ let dragOverBacklog = $state(false);
894
+ let tlLanesEl = $state<HTMLElement | null>(null); // timeline: the axis-width lane track
895
+ let tlScrollEl = $state<HTMLElement | null>(null); // timeline: the horizontal scroll viewport
896
+ // The single scroller (.sv-sched-xscroll) owns both scroll axes; the header and
897
+ // gutter stay in place via position:sticky, so no scrollbar-width reservation is
898
+ // needed to keep columns aligned.
899
+ let gridScrollEl = $state<HTMLElement | null>(null);
900
+ let suppressClick = false;
901
+ const DRAG_THRESHOLD = 4; // px before a press becomes a drag
902
+
903
+ function minuteAt(clientY: number): number {
904
+ if (!bodyEl) return dayStartHour * 60;
905
+ const r = bodyEl.getBoundingClientRect();
906
+ const frac = Math.min(1, Math.max(0, (clientY - r.top) / r.height));
907
+ return dayStartHour * 60 + frac * bandHours * 60;
908
+ }
909
+ function colAt(clientX: number): GridCol | null {
910
+ if (!bodyEl) return null;
911
+ const cells = bodyEl.querySelectorAll<HTMLElement>("[data-col-key]");
912
+ for (const cell of cells) {
913
+ const r = cell.getBoundingClientRect();
914
+ if (clientX >= r.left && clientX <= r.right) {
915
+ return gridCols.find((c) => c.key === cell.dataset.colKey) ?? null;
916
+ }
917
+ }
918
+ return null;
919
+ }
920
+ // Absolute Date for a minute-of-day on `day` (handles 1440 = next midnight).
921
+ const dateAtMinute = (day: Date, min: number): Date =>
922
+ new Date(startOfDay(day).getTime() + min * 60000);
923
+ const minuteOfDay = (d: Date): number => d.getHours() * 60 + d.getMinutes();
924
+ // % offsets within the visible band, for positioning an event (or its preview).
925
+ const pctTop = (d: Date): number =>
926
+ ((minuteOfDay(d) - dayStartHour * 60) / (bandHours * 60)) * 100;
927
+ const pctHeight = (s: Date, e: Date): number =>
928
+ (((e.getTime() - s.getTime()) / 60000) / (bandHours * 60)) * 100;
929
+
930
+ function startTimeDrag(
931
+ e: PointerEvent,
932
+ ev: ResolvedEvent<TData>,
933
+ col: GridCol,
934
+ mode: DragMode,
935
+ ) {
936
+ if (e.button !== 0) return; // ignore right/middle button (let the context menu open)
937
+ // Clear any stale suppress from a prior drag whose trailing click never
938
+ // reached openEvent (e.g. a grip resize), so this press decides afresh.
939
+ suppressClick = false;
940
+ rangeSel = null; // dragging an event dismisses any pending cell selection
941
+ // Recurring events ARE editable, but only when a recurrence editor is wired
942
+ // (drag/resize edits the whole series' time-of-day + duration).
943
+ if (!editable || ev.allDay || (ev.recurring && !recurEditable)) return;
944
+ e.preventDefault();
945
+ e.stopPropagation();
946
+ drag = {
947
+ ev,
948
+ mode,
949
+ startX: e.clientX,
950
+ startY: e.clientY,
951
+ grabOffsetMin: Math.max(0, minuteAt(e.clientY) - minuteOfDay(ev.start)),
952
+ durationMin: (ev.end.getTime() - ev.start.getTime()) / 60000,
953
+ origStart: ev.start,
954
+ origEnd: ev.end,
955
+ startCol: col,
956
+ moved: false,
957
+ previewStart: ev.start,
958
+ previewEnd: ev.end,
959
+ previewCol: col,
960
+ x: e.clientX,
961
+ y: e.clientY,
962
+ overAllDay: null,
963
+ };
964
+ window.addEventListener("pointermove", onDragMove);
965
+ window.addEventListener("pointerup", onDragEnd, { once: true });
966
+ }
967
+ function onDragMove(e: PointerEvent) {
968
+ if (!drag) return;
969
+ if (!drag.moved) {
970
+ if (
971
+ Math.abs(e.clientX - drag.startX) < DRAG_THRESHOLD &&
972
+ Math.abs(e.clientY - drag.startY) < DRAG_THRESHOLD
973
+ )
974
+ return;
975
+ drag.moved = true;
976
+ }
977
+ dragOverBacklog = drag.mode === "move" && !!scheduler.onUnschedule && !!backlogEl && pointInEl(e.clientX, e.clientY, backlogEl);
978
+ drag.x = e.clientX;
979
+ drag.y = e.clientY;
980
+ const rawMin = minuteAt(e.clientY);
981
+ const bandStart = dayStartHour * 60;
982
+ const bandEnd = dayEndHour * 60;
983
+ if (drag.mode === "move") {
984
+ // Hovering the all-day row converts the move to an all-day drop: highlight
985
+ // that day's all-day cell instead of previewing a timed block (same feedback
986
+ // shape as dragging an all-day bar the other way).
987
+ drag.overAllDay =
988
+ !drag.ev.recurring && pointInEl(e.clientX, e.clientY, allDayRowEl)
989
+ ? colAt(e.clientX) ?? drag.previewCol
990
+ : null;
991
+ // Recurring instances stay in their own column (a series edit changes the
992
+ // time-of-day, not the day/resource), so lock the column for them.
993
+ const col = drag.ev.recurring ? drag.startCol : colAt(e.clientX) ?? drag.startCol;
994
+ const startMin = snapMinute(
995
+ Math.min(bandEnd - drag.durationMin, Math.max(bandStart, rawMin - drag.grabOffsetMin)),
996
+ slotMinutes,
997
+ );
998
+ const s = dateAtMinute(col.date, startMin);
999
+ drag.previewStart = s;
1000
+ drag.previewEnd = new Date(s.getTime() + drag.durationMin * 60000);
1001
+ drag.previewCol = col;
1002
+ } else if (drag.mode === "resize-end") {
1003
+ const startMin = minuteOfDay(drag.origStart);
1004
+ const endMin = Math.min(
1005
+ bandEnd,
1006
+ Math.max(startMin + slotMinutes, snapMinute(rawMin, slotMinutes)),
1007
+ );
1008
+ drag.previewStart = drag.origStart;
1009
+ drag.previewEnd = dateAtMinute(drag.startCol.date, endMin);
1010
+ } else {
1011
+ // resize-start: drag the top edge, end stays put
1012
+ const endMin = minuteOfDay(drag.origEnd);
1013
+ const startMin = Math.max(
1014
+ bandStart,
1015
+ Math.min(endMin - slotMinutes, snapMinute(rawMin, slotMinutes)),
1016
+ );
1017
+ drag.previewStart = dateAtMinute(drag.startCol.date, startMin);
1018
+ drag.previewEnd = drag.origEnd;
1019
+ }
1020
+ }
1021
+ const pointInEl = (x: number, y: number, el: HTMLElement | null) => {
1022
+ if (!el) return false;
1023
+ const r = el.getBoundingClientRect();
1024
+ return x >= r.left && x <= r.right && y >= r.top && y <= r.bottom;
1025
+ };
1026
+ function onDragEnd(e: PointerEvent) {
1027
+ window.removeEventListener("pointermove", onDragMove);
1028
+ const d = drag;
1029
+ drag = null;
1030
+ if (!d || !d.moved) return;
1031
+ suppressClick = true; // this drag's trailing click must not open the event
1032
+ dragOverBacklog = false;
1033
+ const { ev, previewStart, previewEnd, previewCol, mode } = d;
1034
+ const k = ev.rowKey;
1035
+ // Dropped onto the backlog panel -> unschedule the event.
1036
+ if (mode === "move" && scheduler.onUnschedule && backlogEl && pointInEl(e.clientX, e.clientY, backlogEl)) {
1037
+ scheduler.onUnschedule(ev.row);
1038
+ return;
1039
+ }
1040
+ // Dropped in the all-day row -> convert a timed event to an all-day event.
1041
+ if (mode === "move" && !ev.recurring && pointInEl(e.clientX, e.clientY, allDayRowEl)) {
1042
+ const col = colAt(e.clientX) ?? previewCol;
1043
+ allDayOf[k] = true;
1044
+ const s = startOfDay(col.date);
1045
+ const en = addDays(s, 1);
1046
+ startOfE[k] = s;
1047
+ endOfE[k] = en;
1048
+ const toResource = col.resourceId;
1049
+ if (toResource != null) resourceOfE[k] = toResource;
1050
+ emitMove({ row: ev.row, start: s, end: en, allDay: true, fromResource: ev.resourceId, toResource });
1051
+ return;
1052
+ }
1053
+ if (ev.recurring) {
1054
+ // Edit the SERIES: apply the new time-of-day (+ duration) to the base row,
1055
+ // keeping the base date, so every occurrence shifts and the pattern holds.
1056
+ const applySeries = () => {
1057
+ const baseStart = toDate(fieldValue(ev.row, scheduler.startField) as never) ?? ev.start;
1058
+ const ns = dayWithTimeOf(baseStart, previewStart);
1059
+ const ne = new Date(ns.getTime() + (previewEnd.getTime() - previewStart.getTime()));
1060
+ startOfE[k] = ns;
1061
+ endOfE[k] = ne;
1062
+ if (mode === "move") emitMove({ row: ev.row, start: ns, end: ne, allDay: false });
1063
+ else emitResize({ row: ev.row, start: ns, end: ne });
1064
+ };
1065
+ // "This event": store an override for just this occurrence.
1066
+ askRecurScope(e.clientX, e.clientY, ev, "edit", () => emitOccurrence(ev, { start: previewStart, end: previewEnd }), applySeries, () => emitOccurrence(ev, { start: previewStart, end: previewEnd }, "following"));
1067
+ return;
1068
+ }
1069
+ // Reject a double-booking (same resource) - snap back, don't apply/emit.
1070
+ const checkRes = mode === "move" ? previewCol.resourceId : ev.resourceId;
1071
+ if (bookingBlocked(previewStart, previewEnd, checkRes, k)) return;
1072
+ startOfE[k] = previewStart;
1073
+ endOfE[k] = previewEnd;
1074
+ if (mode === "move") {
1075
+ const toResource = previewCol.resourceId;
1076
+ if (toResource != null) resourceOfE[k] = toResource;
1077
+ emitMove({
1078
+ row: ev.row,
1079
+ start: previewStart,
1080
+ end: previewEnd,
1081
+ allDay: false,
1082
+ fromResource: ev.resourceId,
1083
+ toResource,
1084
+ } satisfies SchedulerEventMoveEvent<TData>);
1085
+ applyBulkMove(k, previewStart.getTime() - d.origStart.getTime());
1086
+ } else {
1087
+ emitResize({
1088
+ row: ev.row,
1089
+ start: previewStart,
1090
+ end: previewEnd,
1091
+ } satisfies SchedulerEventResizeEvent<TData>);
1092
+ }
1093
+ pushHistory({
1094
+ key: k,
1095
+ row: ev.row,
1096
+ kind: mode === "move" ? "move" : "resize",
1097
+ before: { start: d.origStart, end: d.origEnd, resource: ev.resourceId, allDay: false },
1098
+ after: { start: previewStart, end: previewEnd, resource: mode === "move" ? previewCol.resourceId : ev.resourceId, allDay: false },
1099
+ });
1100
+ }
1101
+
1102
+ // --- month drag: shift an event by whole days. `overDay` (the timestamp of
1103
+ // the day cell under the cursor) + a moved flag drive the visual feedback. ---
1104
+ let monthDrag = $state<{ ev: ResolvedEvent<TData>; startX: number; startY: number; moved: boolean } | null>(null);
1105
+ let monthOverDay = $state<number | null>(null);
1106
+ let monthDragPos = $state({ x: 0, y: 0 });
1107
+ function startMonthDrag(e: PointerEvent, ev: ResolvedEvent<TData>) {
1108
+ if (e.button !== 0) return; // ignore right/middle button (let the context menu open)
1109
+ suppressClick = false;
1110
+ if (!editable || ev.recurring) return;
1111
+ e.preventDefault();
1112
+ monthDrag = { ev, startX: e.clientX, startY: e.clientY, moved: false };
1113
+ monthOverDay = null;
1114
+ monthDragPos = { x: e.clientX, y: e.clientY };
1115
+ window.addEventListener("pointermove", onMonthMove);
1116
+ window.addEventListener("pointerup", onMonthDrop, { once: true });
1117
+ }
1118
+ function onMonthMove(e: PointerEvent) {
1119
+ if (!monthDrag) return;
1120
+ if (
1121
+ !monthDrag.moved &&
1122
+ Math.abs(e.clientX - monthDrag.startX) < DRAG_THRESHOLD &&
1123
+ Math.abs(e.clientY - monthDrag.startY) < DRAG_THRESHOLD
1124
+ )
1125
+ return;
1126
+ monthDrag.moved = true;
1127
+ monthDragPos = { x: e.clientX, y: e.clientY };
1128
+ const el = document.elementFromPoint(e.clientX, e.clientY)?.closest<HTMLElement>("[data-day]");
1129
+ monthOverDay = el?.dataset.day ? Number(el.dataset.day) : null;
1130
+ }
1131
+ function onMonthDrop(e: PointerEvent) {
1132
+ window.removeEventListener("pointermove", onMonthMove);
1133
+ const el = document.elementFromPoint(e.clientX, e.clientY)?.closest<HTMLElement>("[data-day]");
1134
+ if (monthDrag?.moved && el?.dataset.day) {
1135
+ const target = new Date(Number(el.dataset.day));
1136
+ const ev = monthDrag.ev;
1137
+ const dayDelta = Math.round((startOfDay(target).getTime() - startOfDay(ev.start).getTime()) / 86400000);
1138
+ if (dayDelta !== 0) {
1139
+ suppressClick = true; // the drop's trailing click must not open the event
1140
+ const s = addDays(ev.start, dayDelta);
1141
+ const en = addDays(ev.end, dayDelta);
1142
+ startOfE[ev.rowKey] = s;
1143
+ endOfE[ev.rowKey] = en;
1144
+ emitMove({ row: ev.row, start: s, end: en, allDay: ev.allDay });
1145
+ }
1146
+ }
1147
+ monthDrag = null;
1148
+ monthOverDay = null;
1149
+ }
1150
+
1151
+ // --- month resize: drag a chip's left/right edge across days to change the
1152
+ // event's start / end date (keeping its time-of-day). ---
1153
+ let monthResize = $state<{ ev: ResolvedEvent<TData>; edge: "start" | "end"; startX: number; startY: number; moved: boolean } | null>(null);
1154
+ function startMonthResize(e: PointerEvent, ev: ResolvedEvent<TData>, edge: "start" | "end") {
1155
+ if (e.button !== 0) return; // ignore right/middle button (let the context menu open)
1156
+ suppressClick = false;
1157
+ if (!editable || ev.recurring) return;
1158
+ e.preventDefault();
1159
+ e.stopPropagation();
1160
+ monthResize = { ev, edge, startX: e.clientX, startY: e.clientY, moved: false };
1161
+ monthOverDay = null;
1162
+ window.addEventListener("pointermove", onMonthResizeMove);
1163
+ window.addEventListener("pointerup", onMonthResizeEnd, { once: true });
1164
+ }
1165
+ // Combine a target day (midnight) with an existing datetime's time-of-day.
1166
+ function dayWithTimeOf(day: Date, timeOf: Date): Date {
1167
+ const d = new Date(day);
1168
+ d.setHours(timeOf.getHours(), timeOf.getMinutes(), timeOf.getSeconds(), 0);
1169
+ return d;
1170
+ }
1171
+ // Compute + apply a day-edge resize to the overlay (LIVE, so the bar grows as
1172
+ // you drag). Shared by the month bars and the week/day all-day bars. A TIMED
1173
+ // multi-day event keeps its time-of-day; an ALL-DAY event snaps to whole days,
1174
+ // treating the hovered column as the inclusive edge day (its end is the
1175
+ // exclusive next-midnight, matching how the spanning segments are laid out).
1176
+ function applyDayEdgeResize(ev: ResolvedEvent<TData>, edge: "start" | "end", targetDayMs: number) {
1177
+ const targetDay = startOfDay(new Date(targetDayMs));
1178
+ const k = ev.rowKey;
1179
+ if (edge === "end") {
1180
+ if (ev.allDay) {
1181
+ const startDay = startOfDay(ev.start);
1182
+ const lastDay = targetDay.getTime() < startDay.getTime() ? startDay : targetDay;
1183
+ endOfE[k] = addDays(lastDay, 1); // exclusive midnight after the last day
1184
+ } else {
1185
+ const day = targetDay.getTime() < startOfDay(ev.start).getTime() ? startOfDay(ev.start) : targetDay;
1186
+ let ne = dayWithTimeOf(day, ev.end);
1187
+ if (ne.getTime() <= ev.start.getTime()) ne = new Date(ev.start.getTime() + 30 * 60000);
1188
+ endOfE[k] = ne;
1189
+ }
1190
+ } else {
1191
+ if (ev.allDay) {
1192
+ // last visible day = startOfDay(end - 1ms); don't let start pass it.
1193
+ const lastDay = startOfDay(new Date(ev.end.getTime() - 1));
1194
+ startOfE[k] = targetDay.getTime() > lastDay.getTime() ? lastDay : targetDay;
1195
+ } else {
1196
+ const day = targetDay.getTime() > startOfDay(ev.end).getTime() ? startOfDay(ev.end) : targetDay;
1197
+ let ns = dayWithTimeOf(day, ev.start);
1198
+ if (ns.getTime() >= ev.end.getTime()) ns = new Date(ev.end.getTime() - 30 * 60000);
1199
+ startOfE[k] = ns;
1200
+ }
1201
+ }
1202
+ }
1203
+ function onMonthResizeMove(e: PointerEvent) {
1204
+ if (!monthResize) return;
1205
+ if (
1206
+ !monthResize.moved &&
1207
+ Math.abs(e.clientX - monthResize.startX) < DRAG_THRESHOLD &&
1208
+ Math.abs(e.clientY - monthResize.startY) < DRAG_THRESHOLD
1209
+ )
1210
+ return;
1211
+ monthResize.moved = true;
1212
+ const el = document.elementFromPoint(e.clientX, e.clientY)?.closest<HTMLElement>("[data-day]");
1213
+ monthOverDay = el?.dataset.day ? Number(el.dataset.day) : null;
1214
+ if (monthOverDay != null) applyDayEdgeResize(monthResize.ev, monthResize.edge, monthOverDay);
1215
+ }
1216
+ function onMonthResizeEnd() {
1217
+ window.removeEventListener("pointermove", onMonthResizeMove);
1218
+ const r = monthResize;
1219
+ monthResize = null;
1220
+ monthOverDay = null;
1221
+ if (!r || !r.moved) return;
1222
+ suppressClick = true;
1223
+ const ev = r.ev;
1224
+ const k = ev.rowKey;
1225
+ emitResize({ row: ev.row, start: startOfE[k] ?? ev.start, end: endOfE[k] ?? ev.end });
1226
+ }
1227
+
1228
+ // Double-clicking an empty month day adds an event on that day.
1229
+ function onMonthSlotAdd(day: Date) {
1230
+ monthRangeSel = null; // a double-click creates directly - drop the click's marker
1231
+ if (!scheduler.onEventAdd) return;
1232
+ const s = new Date(day);
1233
+ s.setHours(9, 0, 0, 0);
1234
+ emitAdd(s, new Date(s.getTime() + (scheduler.defaultDurationMin ?? 60) * 60000));
1235
+ }
1236
+
1237
+ // --- month day-cell range selection: click / drag whole days to mark an
1238
+ // all-day date range, then Enter / "Add Event" to create (arrows navigate). ---
1239
+ let monthRangeSel = $state<{ anchor: number; cur: number; moved: boolean; pending?: boolean } | null>(null);
1240
+ let monthRangeDown: { day: number; startX: number; startY: number; dragging: boolean } | null = null;
1241
+ function dayAtPoint(clientX: number, clientY: number): number | null {
1242
+ const el = document.elementFromPoint(clientX, clientY)?.closest<HTMLElement>("[data-day]");
1243
+ return el?.dataset.day ? Number(el.dataset.day) : null;
1244
+ }
1245
+ function startMonthRangeSelect(day: Date, e: PointerEvent) {
1246
+ if (!rangeSelectable || e.button !== 0) return;
1247
+ e.preventDefault(); // don't let the drag start a native text selection
1248
+ suppressClick = false;
1249
+ monthRangeDown = { day: startOfDay(day).getTime(), startX: e.clientX, startY: e.clientY, dragging: false };
1250
+ window.addEventListener("pointermove", onMonthRangeSelectMove);
1251
+ window.addEventListener("pointerup", onMonthRangeSelectEnd, { once: true });
1252
+ }
1253
+ function onMonthRangeSelectMove(e: PointerEvent) {
1254
+ if (!monthRangeDown) return;
1255
+ if (!monthRangeDown.dragging) {
1256
+ if (Math.abs(e.clientX - monthRangeDown.startX) < DRAG_THRESHOLD && Math.abs(e.clientY - monthRangeDown.startY) < DRAG_THRESHOLD) return;
1257
+ monthRangeDown.dragging = true;
1258
+ }
1259
+ const t = dayAtPoint(e.clientX, e.clientY);
1260
+ monthRangeSel = { anchor: monthRangeDown.day, cur: t ?? monthRangeDown.day, moved: true, pending: true };
1261
+ }
1262
+ function onMonthRangeSelectEnd() {
1263
+ window.removeEventListener("pointermove", onMonthRangeSelectMove);
1264
+ const down = monthRangeDown;
1265
+ monthRangeDown = null;
1266
+ if (!down) return;
1267
+ if (down.dragging) { suppressClick = true; return; } // a drag already marked the range
1268
+ // A plain click marks a single day (keyboard nav starting point).
1269
+ monthRangeSel = { anchor: down.day, cur: down.day, moved: true, pending: true };
1270
+ }
1271
+ const monthRangeDays = $derived.by(() => {
1272
+ if (!monthRangeSel || !monthRangeSel.moved) return null;
1273
+ return { lo: Math.min(monthRangeSel.anchor, monthRangeSel.cur), hi: Math.max(monthRangeSel.anchor, monthRangeSel.cur) };
1274
+ });
1275
+ const isMonthCellSelected = (date: Date) => {
1276
+ if (!monthRangeDays) return false;
1277
+ const t = startOfDay(date).getTime();
1278
+ return t >= monthRangeDays.lo && t <= monthRangeDays.hi;
1279
+ };
1280
+ function commitMonthRangeSelect() {
1281
+ const sel = monthRangeSel;
1282
+ if (!sel || !sel.moved) return;
1283
+ const lo = Math.min(sel.anchor, sel.cur);
1284
+ const hi = Math.max(sel.anchor, sel.cur);
1285
+ const start = new Date(lo);
1286
+ const end = addDays(new Date(hi), 1); // inclusive last day -> next midnight
1287
+ const days: Date[] = [];
1288
+ for (let d = new Date(lo); d.getTime() <= hi; d = addDays(d, 1)) days.push(new Date(d));
1289
+ monthRangeSel = null;
1290
+ if (scheduler.onRangeSelect) emitRange({ start, end, allDay: true, days, resourceIds: [] });
1291
+ else emitAdd(start, end, undefined, true);
1292
+ }
1293
+ function moveMonthRangeSelect(dDays: number, extend: boolean) {
1294
+ const sel = monthRangeSel;
1295
+ if (!sel) return;
1296
+ const first = monthWeeks[0]![0]!.date.getTime();
1297
+ const last = monthWeeks[monthWeeks.length - 1]![6]!.date.getTime();
1298
+ const nextCur = Math.min(last, Math.max(first, addDays(new Date(sel.cur), dDays).getTime()));
1299
+ monthRangeSel = extend
1300
+ ? { anchor: sel.anchor, cur: nextCur, moved: true, pending: true }
1301
+ : { anchor: nextCur, cur: nextCur, moved: true, pending: true };
1302
+ }
1303
+
1304
+ // --- all-day BAR drag (week time-grid): move across days within the all-day
1305
+ // row, or drop into the hourly grid to convert the event to a timed one. ---
1306
+ let allDayDrag = $state<{ ev: ResolvedEvent<TData>; startX: number; startY: number; moved: boolean; x: number; y: number } | null>(null);
1307
+ // Live drop preview: a timed block in the hourly grid, or the target day in
1308
+ // the all-day row - drives the visual feedback while dragging an all-day bar.
1309
+ let allDayPreview = $state<
1310
+ | { kind: "timed"; colKey: string; topPct: number; heightPct: number; label: string }
1311
+ | { kind: "allday"; colKey: string }
1312
+ | null
1313
+ >(null);
1314
+ function startAllDayDrag(e: PointerEvent, ev: ResolvedEvent<TData>) {
1315
+ if (e.button !== 0) return; // ignore right/middle button (let the context menu open)
1316
+ suppressClick = false;
1317
+ if (!editable || ev.recurring) return;
1318
+ e.preventDefault();
1319
+ e.stopPropagation();
1320
+ allDayDrag = { ev, startX: e.clientX, startY: e.clientY, moved: false, x: e.clientX, y: e.clientY };
1321
+ allDayPreview = null;
1322
+ window.addEventListener("pointermove", onAllDayDragMove);
1323
+ window.addEventListener("pointerup", onAllDayDragEnd, { once: true });
1324
+ }
1325
+ function onAllDayDragMove(e: PointerEvent) {
1326
+ if (!allDayDrag) return;
1327
+ if (
1328
+ !allDayDrag.moved &&
1329
+ Math.abs(e.clientX - allDayDrag.startX) < DRAG_THRESHOLD &&
1330
+ Math.abs(e.clientY - allDayDrag.startY) < DRAG_THRESHOLD
1331
+ )
1332
+ return;
1333
+ allDayDrag.moved = true;
1334
+ allDayDrag.x = e.clientX;
1335
+ allDayDrag.y = e.clientY;
1336
+ const col = colAt(e.clientX);
1337
+ if (col && bodyEl && pointInEl(e.clientX, e.clientY, bodyEl)) {
1338
+ const min = snapMinute(minuteAt(e.clientY), slotMinutes);
1339
+ const durMin = Math.max(slotMinutes, Math.min((allDayDrag.ev.end.getTime() - allDayDrag.ev.start.getTime()) / 60000, 240));
1340
+ allDayPreview = {
1341
+ kind: "timed",
1342
+ colKey: col.key,
1343
+ topPct: ((min - dayStartHour * 60) / (bandHours * 60)) * 100,
1344
+ heightPct: (durMin / (bandHours * 60)) * 100,
1345
+ label: fmtTime(dateAtMinute(col.date, min)),
1346
+ };
1347
+ } else if (col && allDayRowEl && pointInEl(e.clientX, e.clientY, allDayRowEl)) {
1348
+ allDayPreview = { kind: "allday", colKey: col.key };
1349
+ } else {
1350
+ allDayPreview = null;
1351
+ }
1352
+ }
1353
+ function onAllDayDragEnd(e: PointerEvent) {
1354
+ window.removeEventListener("pointermove", onAllDayDragMove);
1355
+ const d = allDayDrag;
1356
+ allDayDrag = null;
1357
+ allDayPreview = null;
1358
+ if (!d || !d.moved) return;
1359
+ suppressClick = true;
1360
+ const ev = d.ev;
1361
+ const k = ev.rowKey;
1362
+ const durationMs = ev.end.getTime() - ev.start.getTime();
1363
+ const col = colAt(e.clientX);
1364
+ if (bodyEl && pointInEl(e.clientX, e.clientY, bodyEl) && col) {
1365
+ // Dropped in the hourly grid -> convert to a TIMED event at the drop time.
1366
+ const min = snapMinute(minuteAt(e.clientY), slotMinutes);
1367
+ const s = dateAtMinute(col.date, min);
1368
+ const en = new Date(s.getTime() + Math.max(slotMinutes * 60000, Math.min(durationMs, 4 * 3600000)));
1369
+ allDayOf[k] = false;
1370
+ startOfE[k] = s;
1371
+ endOfE[k] = en;
1372
+ if (col.resourceId != null) resourceOfE[k] = col.resourceId;
1373
+ emitMove({ row: ev.row, start: s, end: en, allDay: false, fromResource: ev.resourceId, toResource: col.resourceId });
1374
+ return;
1375
+ }
1376
+ // Otherwise move by whole days within the all-day row (colAt resolves the
1377
+ // column from the x position, so it works over the all-day row too).
1378
+ if (col) {
1379
+ const dayDelta = Math.round((startOfDay(col.date).getTime() - startOfDay(ev.start).getTime()) / 86400000);
1380
+ if (dayDelta !== 0) {
1381
+ const s = addDays(ev.start, dayDelta);
1382
+ const en = addDays(ev.end, dayDelta);
1383
+ startOfE[k] = s;
1384
+ endOfE[k] = en;
1385
+ emitMove({ row: ev.row, start: s, end: en, allDay: true });
1386
+ }
1387
+ }
1388
+ }
1389
+
1390
+ // --- all-day BAR resize (week/day all-day row): drag a bar's left/right edge
1391
+ // across day columns to change the event's start / end DATE (keeping its
1392
+ // time-of-day), the same day-based edge resize the month bars use. ---
1393
+ let allDayResize = $state<{ ev: ResolvedEvent<TData>; edge: "start" | "end"; startX: number; startY: number; moved: boolean } | null>(null);
1394
+ function startAllDayResize(e: PointerEvent, ev: ResolvedEvent<TData>, edge: "start" | "end") {
1395
+ if (e.button !== 0) return; // ignore right/middle button (let the context menu open)
1396
+ suppressClick = false;
1397
+ if (!editable || ev.recurring) return;
1398
+ e.preventDefault();
1399
+ e.stopPropagation();
1400
+ allDayResize = { ev, edge, startX: e.clientX, startY: e.clientY, moved: false };
1401
+ window.addEventListener("pointermove", onAllDayResizeMove);
1402
+ window.addEventListener("pointerup", onAllDayResizeEnd, { once: true });
1403
+ }
1404
+ function onAllDayResizeMove(e: PointerEvent) {
1405
+ if (!allDayResize) return;
1406
+ if (
1407
+ !allDayResize.moved &&
1408
+ Math.abs(e.clientX - allDayResize.startX) < DRAG_THRESHOLD &&
1409
+ Math.abs(e.clientY - allDayResize.startY) < DRAG_THRESHOLD
1410
+ )
1411
+ return;
1412
+ allDayResize.moved = true;
1413
+ // Resolve the day column under the pointer and apply live (the bar re-lays
1414
+ // out as allDaySegs recomputes, so it grows / shrinks as you drag).
1415
+ const col = colAt(e.clientX);
1416
+ if (col) applyDayEdgeResize(allDayResize.ev, allDayResize.edge, startOfDay(col.date).getTime());
1417
+ }
1418
+ function onAllDayResizeEnd() {
1419
+ window.removeEventListener("pointermove", onAllDayResizeMove);
1420
+ const r = allDayResize;
1421
+ allDayResize = null;
1422
+ if (!r || !r.moved) return;
1423
+ suppressClick = true;
1424
+ const ev = r.ev;
1425
+ const k = ev.rowKey;
1426
+ emitResize({ row: ev.row, start: startOfE[k] ?? ev.start, end: endOfE[k] ?? ev.end });
1427
+ }
1428
+
1429
+ // --- keyboard move (a11y): arrows nudge the focused event ---
1430
+ function onEventKey(e: KeyboardEvent, ev: ResolvedEvent<TData>) {
1431
+ if (!editable || ev.recurring) return;
1432
+ let dDay = 0;
1433
+ let dMin = 0;
1434
+ if (e.key === "ArrowLeft") dDay = -1;
1435
+ else if (e.key === "ArrowRight") dDay = 1;
1436
+ else if (e.key === "ArrowUp") dMin = -slotMinutes;
1437
+ else if (e.key === "ArrowDown") dMin = slotMinutes;
1438
+ else if (e.key === "Enter" || e.key === " ") {
1439
+ e.preventDefault();
1440
+ openEvent(ev);
1441
+ return;
1442
+ } else return;
1443
+ e.preventDefault();
1444
+ const s = new Date(ev.start.getTime() + dDay * 86400000 + dMin * 60000);
1445
+ const en = new Date(ev.end.getTime() + dDay * 86400000 + dMin * 60000);
1446
+ startOfE[ev.rowKey] = s;
1447
+ endOfE[ev.rowKey] = en;
1448
+ emitMove({ row: ev.row, start: s, end: en, allDay: ev.allDay, toResource: ev.resourceId });
1449
+ }
1450
+
1451
+ // --- context menu (mirrors SvGridBoard's dismissable-layer pattern) ---
1452
+ let menuOpen = $state(false);
1453
+ let menuItems = $state<MenuItem[]>([]);
1454
+ let menuPos = $state({ x: 0, y: 0 });
1455
+ let menuPanel = $state<HTMLElement | null>(null);
1456
+ // Clipboard: duplicate an event right after itself (same resource + duration).
1457
+ function duplicateEvent(ev: ResolvedEvent<TData>) {
1458
+ const dur = ev.end.getTime() - ev.start.getTime();
1459
+ emitAdd(new Date(ev.end.getTime()), new Date(ev.end.getTime() + dur), ev.resourceId, ev.allDay);
1460
+ }
1461
+ function openMenu(e: MouseEvent, ev: ResolvedEvent<TData>) {
1462
+ // Default actions (Edit / Duplicate / Delete) + any custom `eventMenu` items.
1463
+ const items: MenuItem[] = [];
1464
+ if (scheduler.drawer) items.push({ label: "Edit", onSelect: () => doOpenEvent(ev) });
1465
+ if (scheduler.onEventAdd) items.push({ label: "Duplicate", onSelect: () => duplicateEvent(ev) });
1466
+ if (scheduler.onEventDelete)
1467
+ items.push({
1468
+ label: "Delete",
1469
+ onSelect: () =>
1470
+ askRecurScope(e.clientX, e.clientY, ev, "delete", () => emitOccurrence(ev, { deleted: true }), () => scheduler.onEventDelete!(ev.row), () => emitOccurrence(ev, { deleted: true }, "following")),
1471
+ });
1472
+ const custom = scheduler.eventMenu?.(ev.row);
1473
+ if (custom?.length) {
1474
+ if (items.length) items.push({ separator: true });
1475
+ items.push(...custom);
1476
+ }
1477
+ if (!items.length) return;
1478
+ e.preventDefault();
1479
+ e.stopPropagation();
1480
+ menuItems = items;
1481
+ menuPos = { x: e.clientX, y: e.clientY };
1482
+ menuOpen = true;
1483
+ }
1484
+ $effect(() => {
1485
+ if (!menuOpen) return;
1486
+ const layer = createDismissableLayer({
1487
+ element: () => menuPanel,
1488
+ onDismiss: () => (menuOpen = false),
1489
+ });
1490
+ layer.activate();
1491
+ const onScroll = () => (menuOpen = false);
1492
+ window.addEventListener("scroll", onScroll, true);
1493
+ return () => {
1494
+ layer.release();
1495
+ window.removeEventListener("scroll", onScroll, true);
1496
+ };
1497
+ });
1498
+
1499
+ // Scope-choice popover for recurring-occurrence edits (dismiss = cancel).
1500
+ let scopePanel = $state<HTMLElement | null>(null);
1501
+ $effect(() => {
1502
+ if (!recurScope) return;
1503
+ const layer = createDismissableLayer({
1504
+ element: () => scopePanel,
1505
+ onDismiss: () => (recurScope = null),
1506
+ });
1507
+ layer.activate();
1508
+ return () => layer.release();
1509
+ });
1510
+
1511
+ // --- "+N more" popover: a list of the events a cap-overflow tile (or a month
1512
+ // day cell) couldn't show. Clicking one opens it. Same dismissable pattern. ---
1513
+ let listOpen = $state(false);
1514
+ let listEvents = $state<ResolvedEvent<TData>[]>([]);
1515
+ let listTitle = $state("");
1516
+ let listPos = $state({ x: 0, y: 0 });
1517
+ let listPanel = $state<HTMLElement | null>(null);
1518
+ function openList(e: MouseEvent, evs: ResolvedEvent<TData>[], title: string) {
1519
+ e.preventDefault();
1520
+ e.stopPropagation();
1521
+ if (suppressClick) {
1522
+ suppressClick = false;
1523
+ return;
1524
+ }
1525
+ listEvents = [...evs].sort((a, b) => a.start.getTime() - b.start.getTime());
1526
+ listTitle = title;
1527
+ listPos = { x: e.clientX, y: e.clientY };
1528
+ listOpen = true;
1529
+ }
1530
+ function pickFromList(ev: ResolvedEvent<TData>) {
1531
+ listOpen = false;
1532
+ openEvent(ev);
1533
+ }
1534
+ $effect(() => {
1535
+ if (!listOpen) return;
1536
+ const layer = createDismissableLayer({
1537
+ element: () => listPanel,
1538
+ onDismiss: () => (listOpen = false),
1539
+ });
1540
+ layer.activate();
1541
+ const onScroll = () => (listOpen = false);
1542
+ window.addEventListener("scroll", onScroll, true);
1543
+ return () => {
1544
+ layer.release();
1545
+ window.removeEventListener("scroll", onScroll, true);
1546
+ };
1547
+ });
1548
+
1549
+ // --- detail drawer / editor (mirrors SvGridBoard) ---
1550
+ let drawerRow = $state.raw<TData | null>(null);
1551
+ // The resolved event the drawer was opened on (carries recurring/occurrenceStart
1552
+ // so a recurring-occurrence edit can ask This / Following / All on save).
1553
+ let drawerEv = $state.raw<ResolvedEvent<TData> | null>(null);
1554
+ // `drawerOpen` drives the SvDrawer open/close animation independently of
1555
+ // `drawerRow`: on close we flip it false so the panel slides out, and clear
1556
+ // `drawerRow` only once the exit finishes (SvDrawer.onClosed) so the content
1557
+ // stays rendered while it animates away.
1558
+ let drawerOpen = $state(false);
1559
+ const drawerCfg = $derived(
1560
+ scheduler.drawer && typeof scheduler.drawer === "object" ? scheduler.drawer : null,
1561
+ );
1562
+ type DrawerCol = ColumnDef<TFeatures, TData>;
1563
+ // The start / end / all-day fields get the dedicated "When" editor, so keep
1564
+ // them out of the auto-generated SvForm to avoid duplicate + date-only inputs.
1565
+ const whenFields = $derived(
1566
+ new Set([scheduler.startField, scheduler.endField, scheduler.allDayField].filter(Boolean) as string[]),
1567
+ );
1568
+ const drawerFieldCols = $derived.by<DrawerCol[]>(() => {
1569
+ const wanted = drawerCfg?.fields;
1570
+ const base = wanted?.length
1571
+ ? wanted.map((f) => fieldColumns.find((c) => c.field === f)).filter((c): c is DrawerCol => !!c)
1572
+ : fieldColumns;
1573
+ return base.filter((c) => !whenFields.has(c.field as string));
1574
+ });
1575
+ function formType(t: string | undefined): FormFieldType {
1576
+ switch (t) {
1577
+ case "number": return "number";
1578
+ case "checkbox": return "checkbox";
1579
+ case "date":
1580
+ case "date-native":
1581
+ case "datetime":
1582
+ case "datetime-native": return "date";
1583
+ case "textarea": return "textarea";
1584
+ case "password": return "password";
1585
+ case "color": return "color";
1586
+ case "list":
1587
+ case "select":
1588
+ case "rich-select": return "select";
1589
+ default: return "text";
1590
+ }
1591
+ }
1592
+ function drawerOptions(col: DrawerCol, row: TData) {
1593
+ const raw = typeof col.editorOptions === "function" ? col.editorOptions(row) : col.editorOptions;
1594
+ if (!raw) return undefined;
1595
+ return raw.map((o) =>
1596
+ typeof o === "object" && o != null && "value" in o
1597
+ ? {
1598
+ value: (o as { value: unknown }).value as string | number,
1599
+ label: String((o as { label?: unknown }).label ?? (o as { value: unknown }).value),
1600
+ // Carry a color swatch through so the drawer's select shows it.
1601
+ color: (o as { color?: unknown }).color as string | undefined,
1602
+ }
1603
+ : { value: o as string | number, label: String(o) },
1604
+ );
1605
+ }
1606
+ let drawerInitial = $state<Record<string, unknown>>({});
1607
+ // Live mirror of the form's field values (via SvForm onChange), so dismissing
1608
+ // the drawer by clicking outside can commit the current edits.
1609
+ let drawerValues = $state<Record<string, unknown>>({});
1610
+
1611
+ // --- "When" editor: all-day toggle + start/end datetime pickers (show + edit
1612
+ // the time). Held as Date objects for the SvDateTimePicker, written back as
1613
+ // local ISO strings so consumers keep a string. ---
1614
+ const whenHasAllDay = $derived(!!scheduler.allDayField);
1615
+ let whenAllDay = $state(false);
1616
+ let whenStart = $state<Date | null>(null);
1617
+ let whenEnd = $state<Date | null>(null);
1618
+ // Serialize a Date to a local ISO string: 'YYYY-MM-DDTHH:mm' (timed) or
1619
+ // 'YYYY-MM-DD' (all-day) - the same shape the demos store on their rows.
1620
+ const isoLocal = (d: Date | null | undefined, allDay: boolean) => {
1621
+ if (!d) return "";
1622
+ const s = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
1623
+ return allDay ? s : `${s}T${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
1624
+ };
1625
+ // The drawer holds pseudo-local dates (zone wall-clock); serialize back to the
1626
+ // consumer as a real instant in a `timeZone`, else the local ISO shape.
1627
+ const serializeWhen = (d: Date | null | undefined, allDay: boolean) => {
1628
+ if (!d) return "";
1629
+ if (!tz) return isoLocal(d, allDay);
1630
+ return allDay ? isoLocal(d, true) : fromZ(d).toISOString();
1631
+ };
1632
+ function loadWhen(row: TData) {
1633
+ whenAllDay = scheduler.allDayField ? !!fieldValue(row, scheduler.allDayField) : false;
1634
+ whenStart = (toZ(fieldValue(row, scheduler.startField)) ?? null) as Date | null;
1635
+ whenEnd = scheduler.endField ? ((toZ(fieldValue(row, scheduler.endField)) ?? null) as Date | null) : whenStart;
1636
+ }
1637
+ // Toggling all-day normalizes the two dates (midnight for all-day, else 9/10h).
1638
+ function setWhenAllDay(v: boolean) {
1639
+ whenAllDay = v;
1640
+ if (whenStart)
1641
+ whenStart = v ? startOfDay(whenStart) : new Date(whenStart.getFullYear(), whenStart.getMonth(), whenStart.getDate(), 9, 0);
1642
+ if (whenEnd)
1643
+ whenEnd = v ? startOfDay(whenEnd) : new Date(whenEnd.getFullYear(), whenEnd.getMonth(), whenEnd.getDate(), 10, 0);
1644
+ }
1645
+
1646
+ // --- recurrence pattern editor (in the drawer, when recurrenceField is set).
1647
+ // An enterprise-grade single-rule editor: None / Daily / Weekly / Monthly /
1648
+ // Yearly + interval, weekday chips, monthly by day-of-month / positional
1649
+ // weekday / last day, yearly by month + date or positional weekday, and an
1650
+ // end condition (never / on a date / after N occurrences). ---
1651
+ type RecFreq = "" | "daily" | "weekly" | "monthly" | "yearly";
1652
+ type MonthMode = "day" | "weekday" | "lastday";
1653
+ type YearMode = "day" | "weekday";
1654
+ type RecEnd = "never" | "until" | "count";
1655
+ const REPEAT_OPTIONS = [
1656
+ { value: "", label: "Does not repeat" },
1657
+ { value: "daily", label: "Daily" },
1658
+ { value: "weekly", label: "Weekly" },
1659
+ { value: "monthly", label: "Monthly" },
1660
+ { value: "yearly", label: "Yearly" },
1661
+ ] as const;
1662
+ const WEEKDAYS_FULL = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
1663
+ const WEEKDAY_OPTIONS = $derived(headerOrder.map((d) => ({ value: d, label: WEEKDAYS_FULL[d] ?? "" })));
1664
+ const MONTH_OPTIONS = MONTHS.map((m, i) => ({ value: i, label: m }));
1665
+ const WEEK_OF_MONTH_OPTIONS = [
1666
+ { value: 1, label: "first" },
1667
+ { value: 2, label: "second" },
1668
+ { value: 3, label: "third" },
1669
+ { value: 4, label: "fourth" },
1670
+ { value: -1, label: "last" },
1671
+ ] as const;
1672
+ const MONTH_MODE_OPTIONS = [
1673
+ { value: "day", label: "On a day of the month" },
1674
+ { value: "weekday", label: "On a weekday of the month" },
1675
+ { value: "lastday", label: "On the last day" },
1676
+ ] as const;
1677
+ const YEAR_MODE_OPTIONS = [
1678
+ { value: "day", label: "On a specific date" },
1679
+ { value: "weekday", label: "On a weekday" },
1680
+ ] as const;
1681
+ const END_OPTIONS = [
1682
+ { value: "never", label: "Never" },
1683
+ { value: "until", label: "On a date" },
1684
+ { value: "count", label: "After a number of times" },
1685
+ ] as const;
1686
+ // The recurrence editor is offered whenever the drawer is on, so any event can
1687
+ // be made recurring (or have its pattern removed) - it never needs an explicit
1688
+ // `recurrenceField` to appear.
1689
+ const recurEditable = $derived(!!scheduler.drawer);
1690
+ let recFreq = $state<RecFreq>("");
1691
+ let recInterval = $state(1);
1692
+ let recWeekdays = $state<Set<number>>(new Set());
1693
+ let recDay = $state(1);
1694
+ let recMonthMode = $state<MonthMode>("day");
1695
+ let recYearMode = $state<YearMode>("day");
1696
+ let recMonth = $state(0);
1697
+ let recWeekOfMonth = $state(1);
1698
+ let recPosWeekday = $state(1);
1699
+ let recEnd = $state<RecEnd>("never");
1700
+ let recUntil = $state<Date | null>(null);
1701
+ let recCount = $state(10);
1702
+ let recFrom = $state<string>("");
1703
+ const isoDate = (d: Date | null | undefined) =>
1704
+ d ? `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}` : "";
1705
+ function loadRule(row: TData) {
1706
+ const anchor = toDate(fieldValue(row, scheduler.startField) as never) ?? new Date();
1707
+ const raw = fieldValue(row, recurField);
1708
+ const rule = (Array.isArray(raw) ? raw[0] : raw) as RecurrenceRule | null | undefined;
1709
+ // Sensible defaults seeded from the event's own start date, so turning a
1710
+ // one-off into a recurring event pre-fills "this weekday / this date".
1711
+ recInterval = 1;
1712
+ recWeekdays = new Set([anchor.getDay()]);
1713
+ recDay = anchor.getDate();
1714
+ recMonth = anchor.getMonth();
1715
+ recMonthMode = "day";
1716
+ recYearMode = "day";
1717
+ // Seed the positional week so its occurrence lands on the anchor's own date:
1718
+ // use "last" when the anchor is the final same-weekday of its month (incl. a
1719
+ // 5th week), else the 1..4 ordinal - so a positional pattern reproduces the
1720
+ // event's start date rather than an earlier (clamped) one.
1721
+ const dim = new Date(anchor.getFullYear(), anchor.getMonth() + 1, 0).getDate();
1722
+ recWeekOfMonth = anchor.getDate() + 7 > dim ? -1 : Math.floor((anchor.getDate() - 1) / 7) + 1;
1723
+ recPosWeekday = anchor.getDay();
1724
+ recEnd = "never";
1725
+ recUntil = null;
1726
+ recCount = 10;
1727
+ recFrom = "";
1728
+ if (rule && typeof rule === "object" && rule.freq) {
1729
+ recFreq = rule.freq;
1730
+ recInterval = Math.max(1, Math.floor(rule.interval ?? 1));
1731
+ if (rule.weekdays?.length) recWeekdays = new Set(rule.weekdays);
1732
+ recFrom = rule.from ? isoDate(toDate(rule.from)) : "";
1733
+ if (rule.month != null) recMonth = rule.month;
1734
+ // Monthly / yearly "where in the month": positional weekday, last day, or day.
1735
+ if (rule.weekOfMonth != null && rule.weekdays?.length) {
1736
+ recMonthMode = "weekday";
1737
+ recYearMode = "weekday";
1738
+ recWeekOfMonth = rule.weekOfMonth;
1739
+ recPosWeekday = rule.weekdays[0]!;
1740
+ } else if (rule.day === -1) {
1741
+ recMonthMode = "lastday";
1742
+ } else if (rule.day != null) {
1743
+ recMonthMode = "day";
1744
+ recYearMode = "day";
1745
+ recDay = rule.day;
1746
+ }
1747
+ // End condition.
1748
+ if (rule.count != null) {
1749
+ recEnd = "count";
1750
+ recCount = Math.max(1, Math.floor(rule.count));
1751
+ } else if (rule.until) {
1752
+ recEnd = "until";
1753
+ recUntil = toDate(rule.until) ?? null;
1754
+ }
1755
+ } else {
1756
+ recFreq = "";
1757
+ }
1758
+ }
1759
+ function toggleWeekday(d: number) {
1760
+ const next = new Set(recWeekdays);
1761
+ if (next.has(d)) next.delete(d);
1762
+ else next.add(d);
1763
+ recWeekdays = next;
1764
+ }
1765
+ function buildRule(row: TData): RecurrenceRule | undefined {
1766
+ if (!recFreq) return undefined;
1767
+ const rule: RecurrenceRule = { freq: recFreq };
1768
+ if (recInterval > 1) rule.interval = recInterval;
1769
+ if (recFreq === "weekly" && recWeekdays.size) rule.weekdays = [...recWeekdays].sort((a, b) => a - b);
1770
+ if (recFreq === "monthly") {
1771
+ if (recMonthMode === "weekday") {
1772
+ rule.weekOfMonth = recWeekOfMonth;
1773
+ rule.weekdays = [recPosWeekday];
1774
+ } else if (recMonthMode === "lastday") {
1775
+ rule.day = -1;
1776
+ } else if (recDay) {
1777
+ rule.day = recDay;
1778
+ }
1779
+ }
1780
+ if (recFreq === "yearly") {
1781
+ rule.month = recMonth;
1782
+ if (recYearMode === "weekday") {
1783
+ rule.weekOfMonth = recWeekOfMonth;
1784
+ rule.weekdays = [recPosWeekday];
1785
+ } else if (recDay) {
1786
+ rule.day = recDay;
1787
+ }
1788
+ }
1789
+ // End condition: an explicit occurrence count, or an until date.
1790
+ if (recEnd === "count") rule.count = Math.max(1, Math.floor(recCount));
1791
+ else if (recEnd === "until" && recUntil) rule.until = isoDate(recUntil);
1792
+ // Anchor `from` when the phase matters (interval > 1, count, or a positional
1793
+ // pattern), or preserve an existing anchor.
1794
+ const needsAnchor = recInterval > 1 || recEnd === "count";
1795
+ if (needsAnchor) rule.from = recFrom || isoDate(toDate(fieldValue(row, scheduler.startField) as never));
1796
+ else if (recFrom) rule.from = recFrom;
1797
+ return rule;
1798
+ }
1799
+
1800
+ const drawerFields = $derived.by<FormField[]>(() => {
1801
+ const row = drawerRow;
1802
+ if (!row) return [];
1803
+ return drawerFieldCols.map((col) => {
1804
+ const f = col.field as string;
1805
+ return {
1806
+ name: f,
1807
+ label: typeof col.header === "string" ? col.header : f,
1808
+ type: formType(col.editorType),
1809
+ options: drawerOptions(col, row),
1810
+ } satisfies FormField;
1811
+ });
1812
+ });
1813
+ function openEvent(ev: ResolvedEvent<TData>) {
1814
+ // A click that ended a drag/resize must not also open the event.
1815
+ if (suppressClick) {
1816
+ suppressClick = false;
1817
+ return;
1818
+ }
1819
+ doOpenEvent(ev);
1820
+ }
1821
+ function doOpenEvent(ev: ResolvedEvent<TData>) {
1822
+ rangeSel = null; // opening an event dismisses any pending cell selection
1823
+ tlRangeSel = null;
1824
+ monthRangeSel = null;
1825
+ if (scheduler.drawer) {
1826
+ const row = ev.row;
1827
+ const next: Record<string, unknown> = {};
1828
+ for (const col of drawerFieldCols) {
1829
+ const f = col.field as string;
1830
+ next[f] = fieldValue(row, f);
1831
+ }
1832
+ drawerInitial = next;
1833
+ drawerValues = { ...next };
1834
+ if (recurEditable) loadRule(row);
1835
+ loadWhen(row);
1836
+ drawerRow = row;
1837
+ drawerEv = ev;
1838
+ drawerOpen = true;
1839
+ }
1840
+ }
1841
+
1842
+ // --- multi-select existing events (Ctrl/Cmd-click, Shift range, Delete) ---
1843
+ // On by default; opt out with `eventSelectable: false`.
1844
+ const eventSelectable = $derived(scheduler.eventSelectable !== false);
1845
+ let selectedKeys = $state<Set<string>>(new Set());
1846
+ const isSelected = (ev: ResolvedEvent<TData>) => selectedKeys.has(ev.rowKey);
1847
+ function emitSelection() {
1848
+ const seen = new Set<string>();
1849
+ const rows: TData[] = [];
1850
+ for (const e of events) if (selectedKeys.has(e.rowKey) && !seen.has(e.rowKey)) { seen.add(e.rowKey); rows.push(e.row); }
1851
+ scheduler.onEventSelectionChange?.(rows);
1852
+ }
1853
+ function onEventClick(e: MouseEvent, ev: ResolvedEvent<TData>) {
1854
+ if (suppressClick) { suppressClick = false; return; }
1855
+ if (eventSelectable && (e.ctrlKey || e.metaKey)) {
1856
+ const next = new Set(selectedKeys);
1857
+ if (next.has(ev.rowKey)) next.delete(ev.rowKey);
1858
+ else next.add(ev.rowKey);
1859
+ selectedKeys = next;
1860
+ emitSelection();
1861
+ return;
1862
+ }
1863
+ if (eventSelectable && e.shiftKey && selectedKeys.size) {
1864
+ // Range-select by start order across the resolved events.
1865
+ const ordered = [...events].sort((a, b) => a.start.getTime() - b.start.getTime());
1866
+ const idxs = ordered.map((o, i) => ({ o, i })).filter(({ o }) => selectedKeys.has(o.rowKey) || o.rowKey === ev.rowKey).map(({ i }) => i);
1867
+ const lo = Math.min(...idxs), hi = Math.max(...idxs);
1868
+ const next = new Set(selectedKeys);
1869
+ for (let i = lo; i <= hi; i++) next.add(ordered[i]!.rowKey);
1870
+ selectedKeys = next;
1871
+ emitSelection();
1872
+ return;
1873
+ }
1874
+ if (eventSelectable && selectedKeys.size) { selectedKeys = new Set(); emitSelection(); }
1875
+ doOpenEvent(ev);
1876
+ }
1877
+ // Apply the same time (+resource) delta the dragged event took to every OTHER
1878
+ // selected event, so a multi-selection moves together.
1879
+ function applyBulkMove(draggedKey: string, deltaMs: number) {
1880
+ if (!eventSelectable || selectedKeys.size < 2 || !selectedKeys.has(draggedKey)) return;
1881
+ const seen = new Set<string>([draggedKey]);
1882
+ for (const e of events) {
1883
+ if (!selectedKeys.has(e.rowKey) || seen.has(e.rowKey) || e.recurring) continue;
1884
+ seen.add(e.rowKey);
1885
+ const ns = new Date(e.start.getTime() + deltaMs);
1886
+ const ne = new Date(e.end.getTime() + deltaMs);
1887
+ startOfE[e.rowKey] = ns;
1888
+ endOfE[e.rowKey] = ne;
1889
+ emitMove({ row: e.row, start: ns, end: ne, allDay: e.allDay, fromResource: e.resourceId, toResource: e.resourceId });
1890
+ }
1891
+ }
1892
+ $effect(() => {
1893
+ if (!eventSelectable) return;
1894
+ const onKey = (e: KeyboardEvent) => {
1895
+ if (!selectedKeys.size) return;
1896
+ if (e.key === "Delete" || e.key === "Backspace") {
1897
+ if (!scheduler.onEventDelete) return;
1898
+ e.preventDefault();
1899
+ const seen = new Set<string>();
1900
+ for (const ev of events) if (selectedKeys.has(ev.rowKey) && !seen.has(ev.rowKey)) { seen.add(ev.rowKey); scheduler.onEventDelete(ev.row); }
1901
+ selectedKeys = new Set();
1902
+ emitSelection();
1903
+ } else if (e.key === "Escape") {
1904
+ selectedKeys = new Set();
1905
+ emitSelection();
1906
+ }
1907
+ };
1908
+ window.addEventListener("keydown", onKey);
1909
+ return () => window.removeEventListener("keydown", onKey);
1910
+ });
1911
+
1912
+ // --- range selection: drag empty cells to mark a time range / rectangle ---
1913
+ // A drag only *marks* the range (`pending`); the event is created on an
1914
+ // explicit confirm - Enter, or the "Add Event" context menu - never on release.
1915
+ // On by default; opt out with `rangeSelectable: false`.
1916
+ const rangeSelectable = $derived(scheduler.rangeSelectable !== false);
1917
+ type RangeSel = { anchorCol: GridCol; anchorMin: number; curCol: GridCol; curMin: number; moved: boolean; pending?: boolean; allDay?: boolean };
1918
+ let rangeSel = $state<RangeSel | null>(null);
1919
+ // The active press. Kept separate so a pointerdown never blanks the current
1920
+ // mirror (which caused a flash on the 2nd click of a double-click); the visible
1921
+ // selection only updates once we know it's a drag (move) or a click (up).
1922
+ let rangeDown: { col: GridCol; min: number; allDay: boolean; startX: number; startY: number; dragging: boolean } | null = null;
1923
+ function startRangeSelect(col: GridCol, e: PointerEvent) {
1924
+ if (!rangeSelectable || e.button !== 0) return;
1925
+ suppressClick = false;
1926
+ e.preventDefault();
1927
+ rangeDown = { col, min: snapMinute(minuteAt(e.clientY), slotMinutes), allDay: false, startX: e.clientX, startY: e.clientY, dragging: false };
1928
+ window.addEventListener("pointermove", onRangeSelectMove);
1929
+ window.addEventListener("pointerup", onRangeSelectEnd, { once: true });
1930
+ }
1931
+ function onRangeSelectMove(e: PointerEvent) {
1932
+ if (!rangeDown) return;
1933
+ if (!rangeDown.dragging) {
1934
+ if (Math.abs(e.clientX - rangeDown.startX) < DRAG_THRESHOLD && Math.abs(e.clientY - rangeDown.startY) < DRAG_THRESHOLD) return;
1935
+ rangeDown.dragging = true;
1936
+ }
1937
+ const col = colAt(e.clientX) ?? rangeDown.col;
1938
+ const min = snapMinute(minuteAt(e.clientY), slotMinutes);
1939
+ rangeSel = { anchorCol: rangeDown.col, anchorMin: rangeDown.min, curCol: col, curMin: min, moved: true, pending: true };
1940
+ }
1941
+ // Drag-select across the ALL-DAY row -> a whole-day range (creates all-day events).
1942
+ function startAllDayRangeSelect(col: GridCol, e: PointerEvent) {
1943
+ if (!rangeSelectable || e.button !== 0) return;
1944
+ suppressClick = false;
1945
+ e.preventDefault();
1946
+ rangeDown = { col, min: 0, allDay: true, startX: e.clientX, startY: e.clientY, dragging: false };
1947
+ window.addEventListener("pointermove", onAllDayRangeMove);
1948
+ window.addEventListener("pointerup", onRangeSelectEnd, { once: true });
1949
+ }
1950
+ function onAllDayRangeMove(e: PointerEvent) {
1951
+ if (!rangeDown) return;
1952
+ if (!rangeDown.dragging) {
1953
+ if (Math.abs(e.clientX - rangeDown.startX) < DRAG_THRESHOLD) return;
1954
+ rangeDown.dragging = true;
1955
+ }
1956
+ rangeSel = { anchorCol: rangeDown.col, anchorMin: 0, curCol: colAt(e.clientX) ?? rangeDown.col, curMin: 0, moved: true, pending: true, allDay: true };
1957
+ }
1958
+ function onRangeSelectEnd() {
1959
+ window.removeEventListener("pointermove", onRangeSelectMove);
1960
+ window.removeEventListener("pointermove", onAllDayRangeMove);
1961
+ const down = rangeDown;
1962
+ rangeDown = null;
1963
+ if (!down) return;
1964
+ if (down.dragging) {
1965
+ suppressClick = true; // a drag happened; keep the marked range (already set)
1966
+ return;
1967
+ }
1968
+ // A plain click selects the single cell at the press position (for keyboard
1969
+ // nav / Enter); a double-click on it creates directly (see onSlotDblClick).
1970
+ rangeSel = { anchorCol: down.col, anchorMin: down.min, curCol: down.col, curMin: down.min, moved: true, pending: true, allDay: down.allDay };
1971
+ }
1972
+ // Move the pending selection by whole days (dDay) / slots (dMin). Without
1973
+ // `extend`, the whole (collapsed) selection moves; with it, only the moving end
1974
+ // moves, growing/shrinking the range (Shift+arrows). Clamped to the day band.
1975
+ function moveRangeSelect(dDay: number, dMin: number, extend: boolean) {
1976
+ const sel = rangeSel;
1977
+ if (!sel) return;
1978
+ const curIdx = gridCols.findIndex((c) => c.key === sel.curCol.key);
1979
+ const nextIdx = Math.min(gridCols.length - 1, Math.max(0, curIdx + dDay));
1980
+ const nextCol = gridCols[nextIdx] ?? sel.curCol;
1981
+ const clampMin = (m: number) => Math.min(dayEndHour * 60 - slotMinutes, Math.max(dayStartHour * 60, m));
1982
+
1983
+ // Vertical crossing between the all-day row and the timed band (navigation
1984
+ // only - Shift-extend keeps the range within its current zone).
1985
+ let nextAllDay = sel.allDay ?? false;
1986
+ let nextMin: number;
1987
+ if (!extend && hasAllDayRow && sel.allDay && dMin > 0) {
1988
+ nextAllDay = false; // Down from the all-day row -> the first timed cell
1989
+ nextMin = dayStartHour * 60;
1990
+ } else if (!extend && hasAllDayRow && !sel.allDay && dMin < 0 && sel.curMin <= dayStartHour * 60) {
1991
+ nextAllDay = true; // Up from the top timed cell -> the all-day row
1992
+ nextMin = 0;
1993
+ } else {
1994
+ nextMin = sel.allDay ? 0 : clampMin(sel.curMin + dMin);
1995
+ }
1996
+
1997
+ if (extend) {
1998
+ sel.curCol = nextCol;
1999
+ sel.curMin = nextMin;
2000
+ } else {
2001
+ sel.allDay = nextAllDay;
2002
+ sel.anchorCol = nextCol;
2003
+ sel.anchorMin = nextMin;
2004
+ sel.curCol = nextCol;
2005
+ sel.curMin = nextMin;
2006
+ }
2007
+ sel.moved = true;
2008
+ sel.pending = true;
2009
+ rangeSel = { ...sel };
2010
+ scrollActiveCellIntoView();
2011
+ }
2012
+ // Distance from the scroller's top to the hourly body's top - i.e. the height
2013
+ // of the sticky header block, which now lives inside the single (both-axis)
2014
+ // scroller. Vertical-scroll targets are body-relative, so they add this base.
2015
+ function gridBodyTopInScroller(): number {
2016
+ if (!bodyEl || !gridScrollEl) return 0;
2017
+ return bodyEl.getBoundingClientRect().top - gridScrollEl.getBoundingClientRect().top + gridScrollEl.scrollTop;
2018
+ }
2019
+ // Keep the moving end of the selection visible in the vertical scroller.
2020
+ function scrollActiveCellIntoView() {
2021
+ if (!rangeSel || rangeSel.allDay || !gridScrollEl || !bodyEl) return;
2022
+ const top = ((rangeSel.curMin - dayStartHour * 60) / (bandHours * 60)) * bodyEl.offsetHeight;
2023
+ const view = gridScrollEl;
2024
+ // The sticky header overlays the top of the scroller, so the usable body
2025
+ // viewport is clientHeight minus the header height.
2026
+ const usable = view.clientHeight - gridBodyTopInScroller();
2027
+ const pad = 24;
2028
+ if (top < view.scrollTop + pad) view.scrollTop = Math.max(0, top - pad);
2029
+ else if (top > view.scrollTop + usable - pad) view.scrollTop = top - usable + pad;
2030
+ }
2031
+ // On a full-day ruler the grid would open at midnight (empty). When the
2032
+ // week/day grid mounts (or you switch back to it), scroll past the empty
2033
+ // early hours to a business-hours start. Only affects bands that begin before
2034
+ // then - a grid already starting at/after this hour is left untouched.
2035
+ const SCROLL_TO_HOUR = 7;
2036
+ let scrolledForView = "";
2037
+ $effect(() => {
2038
+ const v = view;
2039
+ const el = gridScrollEl;
2040
+ if (!el || (v !== "week" && v !== "day")) {
2041
+ scrolledForView = "";
2042
+ return;
2043
+ }
2044
+ if (scrolledForView === v) return;
2045
+ scrolledForView = v;
2046
+ requestAnimationFrame(() => {
2047
+ // scrollTop maps directly to body-local position (a sticky header does not
2048
+ // consume scroll offset), so no header term is needed here.
2049
+ if (gridScrollEl) gridScrollEl.scrollTop = Math.max(0, (SCROLL_TO_HOUR - dayStartHour) * hourPx);
2050
+ });
2051
+ });
2052
+ // Turn the pending grid range into event(s) and clear the marker. Fires
2053
+ // `onRangeSelect` (or falls back to `onEventAdd` for the first cell).
2054
+ function commitRangeSelect() {
2055
+ const sel = rangeSel;
2056
+ if (!sel || !sel.moved) return;
2057
+ const ai = gridCols.findIndex((c) => c.key === sel.anchorCol.key);
2058
+ const bi = gridCols.findIndex((c) => c.key === sel.curCol.key);
2059
+ const cols = gridCols.slice(Math.min(ai, bi), Math.max(ai, bi) + 1);
2060
+ const firstCol = cols[0] ?? sel.anchorCol;
2061
+ const days = [...new Set(cols.map((c) => startOfDay(c.date).getTime()))].map((t) => new Date(t));
2062
+ const resourceIds = resourceField ? [...new Set(cols.map((c) => c.resourceId).filter((r): r is string => r != null))] : [];
2063
+ rangeSel = null;
2064
+ if (sel.allDay) {
2065
+ // Whole-day range -> an all-day event spanning the covered days.
2066
+ const lastCol = cols[cols.length - 1] ?? firstCol;
2067
+ const start = startOfDay(firstCol.date);
2068
+ const end = addDays(startOfDay(lastCol.date), 1);
2069
+ if (scheduler.onRangeSelect) emitRange({ start, end, allDay: true, days, resourceIds });
2070
+ else emitAdd(start, end, firstCol.resourceId, true);
2071
+ return;
2072
+ }
2073
+ // A CONTINUOUS date/time range: from the earlier (day, time) to the later
2074
+ // one - not a per-day rectangle. Spanning days just makes it a longer range.
2075
+ const aDT = dateAtMinute(sel.anchorCol.date, sel.anchorMin).getTime();
2076
+ const cDT = dateAtMinute(sel.curCol.date, sel.curMin).getTime();
2077
+ const start = new Date(Math.min(aDT, cDT));
2078
+ const end = new Date(Math.max(aDT, cDT) + slotMinutes * 60000);
2079
+ if (bookingBlocked(start, end, firstCol.resourceId, "")) return;
2080
+ if (scheduler.onRangeSelect) emitRange({ start, end, days, resourceIds });
2081
+ else emitAdd(start, end, firstCol.resourceId, false);
2082
+ }
2083
+ // The continuous datetime range being marked (min..max of the two drag points).
2084
+ const rangeSelSpan = $derived.by(() => {
2085
+ if (!rangeSel || !rangeSel.moved || rangeSel.allDay) return null;
2086
+ const aDT = dateAtMinute(rangeSel.anchorCol.date, rangeSel.anchorMin).getTime();
2087
+ const cDT = dateAtMinute(rangeSel.curCol.date, rangeSel.curMin).getTime();
2088
+ return { start: Math.min(aDT, cDT), end: Math.max(aDT, cDT) + slotMinutes * 60000 };
2089
+ });
2090
+ // Per-column mirror segments: the slice of the continuous range that falls in
2091
+ // each day's visible band (start day: from the start time down; middle days:
2092
+ // full; end day: down to the end time). Keyed by column.
2093
+ const rangeSelSegs = $derived.by(() => {
2094
+ if (!rangeSelSpan || !rangeSel) return null;
2095
+ // When grouped by resource, columns of different resources share a date - the
2096
+ // range belongs to the clicked resource only, so don't bleed into the others.
2097
+ const anchorRes = resourceField ? rangeSel.anchorCol.resourceId : undefined;
2098
+ const bandStartMin = dayStartHour * 60;
2099
+ const bandTotalMin = bandHours * 60;
2100
+ const segs = new Map<string, { top: number; height: number }>();
2101
+ for (const col of gridCols) {
2102
+ if (resourceField && col.resourceId !== anchorRes) continue;
2103
+ const dayLo = dateAtMinute(col.date, bandStartMin).getTime();
2104
+ const dayHi = dateAtMinute(col.date, dayEndHour * 60).getTime();
2105
+ const s = Math.max(rangeSelSpan.start, dayLo);
2106
+ const e = Math.min(rangeSelSpan.end, dayHi);
2107
+ if (e <= s) continue;
2108
+ const topMin = (s - dayLo) / 60000;
2109
+ const hMin = (e - s) / 60000;
2110
+ segs.set(col.key, { top: (topMin / bandTotalMin) * 100, height: (hMin / bandTotalMin) * 100 });
2111
+ }
2112
+ return segs;
2113
+ });
2114
+ // All-day range: just the covered day cells (whole-day, no time band).
2115
+ const rangeSelAllDayKeys = $derived.by(() => {
2116
+ if (!rangeSel || !rangeSel.moved || !rangeSel.allDay) return null;
2117
+ const anchorRes = resourceField ? rangeSel.anchorCol.resourceId : undefined;
2118
+ const ai = gridCols.findIndex((c) => c.key === rangeSel!.anchorCol.key);
2119
+ const bi = gridCols.findIndex((c) => c.key === rangeSel!.curCol.key);
2120
+ return new Set(
2121
+ gridCols
2122
+ .slice(Math.min(ai, bi), Math.max(ai, bi) + 1)
2123
+ .filter((c) => !resourceField || c.resourceId === anchorRes)
2124
+ .map((c) => c.key),
2125
+ );
2126
+ });
2127
+
2128
+ // Range selection in the TIMELINE (horizontal: time span × resource rows).
2129
+ type TlRangeSel = { anchorIdx: number; anchorT: number; curIdx: number; curT: number; moved: boolean; pending?: boolean };
2130
+ let tlRangeSel = $state<TlRangeSel | null>(null);
2131
+ function startTlRangeSelect(rowIdx: number, e: PointerEvent) {
2132
+ if (!rangeSelectable || e.button !== 0) return;
2133
+ suppressClick = false;
2134
+ e.preventDefault();
2135
+ const t = tlTimeAtX(e.clientX).getTime();
2136
+ tlRangeSel = { anchorIdx: rowIdx, anchorT: t, curIdx: rowIdx, curT: t, moved: false };
2137
+ window.addEventListener("pointermove", onTlRangeSelectMove);
2138
+ window.addEventListener("pointerup", onTlRangeSelectEnd, { once: true });
2139
+ }
2140
+ function onTlRangeSelectMove(e: PointerEvent) {
2141
+ if (!tlRangeSel) return;
2142
+ const t = tlTimeAtX(e.clientX).getTime();
2143
+ const resId = tlResAt(e.clientX, e.clientY);
2144
+ const idx = resId != null ? tlRows.findIndex((r) => (r.resource?.id ?? "") === resId) : tlRangeSel.curIdx;
2145
+ if (!tlRangeSel.moved && idx === tlRangeSel.anchorIdx && Math.abs(t - tlRangeSel.anchorT) < tlSlot * TL_MS_MIN) return;
2146
+ tlRangeSel.moved = true;
2147
+ tlRangeSel.curT = t;
2148
+ if (idx >= 0) tlRangeSel.curIdx = idx;
2149
+ }
2150
+ function onTlRangeSelectEnd() {
2151
+ window.removeEventListener("pointermove", onTlRangeSelectMove);
2152
+ const sel = tlRangeSel;
2153
+ if (!sel) return;
2154
+ if (sel.moved) suppressClick = true; // a drag happened; keep the marked span
2155
+ // A drag marks the span; a plain click marks a single cell. Either way the
2156
+ // range is only *pending* - the event is created on Enter / "Add Event", and
2157
+ // arrow keys can now move/extend it (see moveTlRangeSelect).
2158
+ tlRangeSel = { ...sel, moved: true, pending: true };
2159
+ }
2160
+ // Turn the pending timeline span into event(s) and clear the marker.
2161
+ function commitTlRangeSelect() {
2162
+ const sel = tlRangeSel;
2163
+ if (!sel || !sel.moved) return;
2164
+ const start = new Date(tlStepStart(Math.min(sel.anchorT, sel.curT)));
2165
+ const end = new Date(tlStepEnd(Math.max(sel.anchorT, sel.curT)));
2166
+ const rows = tlRows.slice(Math.min(sel.anchorIdx, sel.curIdx), Math.max(sel.anchorIdx, sel.curIdx) + 1);
2167
+ const resourceIds = resourceField ? rows.map((r) => r.resource?.id).filter((r): r is string => r != null) : [];
2168
+ // Days the span touches.
2169
+ const days: Date[] = [];
2170
+ for (let d = startOfDay(start); d.getTime() < end.getTime(); d = addDays(d, 1)) days.push(new Date(d));
2171
+ if (!days.length) days.push(startOfDay(start));
2172
+ tlRangeSel = null;
2173
+ // Only the Day zoom is timed; Week/Month/Year select whole days -> all-day.
2174
+ const allDay = view !== "timelineDay";
2175
+ if (scheduler.onRangeSelect) emitRange({ start, end, allDay, days, resourceIds });
2176
+ else emitAdd(start, end, resourceIds[0], allDay);
2177
+ }
2178
+ // Timeline mirror band: covered rows + horizontal extent. Snapped to whole
2179
+ // cells so a single click still shows a one-cell marker (a slot in Day, a day
2180
+ // in Week/Month, a month in Year).
2181
+ const tlRangeBand = $derived.by(() => {
2182
+ if (!tlRangeSel || !tlRangeSel.moved) return null;
2183
+ const s = tlStepStart(Math.min(tlRangeSel.anchorT, tlRangeSel.curT));
2184
+ const e = tlStepEnd(Math.max(tlRangeSel.anchorT, tlRangeSel.curT));
2185
+ const g = timelineGeom(new Date(s), new Date(e), tlAxis.start, tlAxis.totalMs);
2186
+ if (!g) return null;
2187
+ return { lo: Math.min(tlRangeSel.anchorIdx, tlRangeSel.curIdx), hi: Math.max(tlRangeSel.anchorIdx, tlRangeSel.curIdx), leftPct: g.leftPct, widthPct: g.widthPct };
2188
+ });
2189
+
2190
+ // --- confirm / cancel a PENDING range selection ---------------------------
2191
+ // A drag now only marks the range (grid or timeline); this is where it turns
2192
+ // into event(s): Enter confirms, Escape cancels, and a right-click offers the
2193
+ // same "Add Event" action through the context menu.
2194
+ const hasPendingRange = $derived(!!rangeSel?.pending || !!tlRangeSel?.pending || !!monthRangeSel?.pending);
2195
+ function commitPendingRange() {
2196
+ if (rangeSel?.pending) commitRangeSelect();
2197
+ else if (tlRangeSel?.pending) commitTlRangeSelect();
2198
+ else if (monthRangeSel?.pending) commitMonthRangeSelect();
2199
+ }
2200
+ function clearRangeSelect() {
2201
+ rangeSel = null;
2202
+ tlRangeSel = null;
2203
+ monthRangeSel = null;
2204
+ }
2205
+ $effect(() => {
2206
+ if (!rangeSelectable) return;
2207
+ const onKey = (e: KeyboardEvent) => {
2208
+ if (!rangeSel?.pending && !tlRangeSel?.pending && !monthRangeSel?.pending) return;
2209
+ // Don't hijack typing (search box, drawer inputs, etc.).
2210
+ const t = e.target as HTMLElement | null;
2211
+ if (t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.isContentEditable)) return;
2212
+ if (e.key === "Enter") {
2213
+ e.preventDefault();
2214
+ commitPendingRange();
2215
+ } else if (e.key === "Escape") {
2216
+ e.preventDefault();
2217
+ clearRangeSelect();
2218
+ } else if (rangeSel?.pending) {
2219
+ // Arrow keys navigate the selected cell; Shift extends the range.
2220
+ const shift = e.shiftKey;
2221
+ if (e.key === "ArrowUp") { e.preventDefault(); moveRangeSelect(0, -slotMinutes, shift); }
2222
+ else if (e.key === "ArrowDown") { e.preventDefault(); moveRangeSelect(0, slotMinutes, shift); }
2223
+ else if (e.key === "ArrowLeft") { e.preventDefault(); moveRangeSelect(-1, 0, shift); }
2224
+ else if (e.key === "ArrowRight") { e.preventDefault(); moveRangeSelect(1, 0, shift); }
2225
+ } else if (tlRangeSel?.pending) {
2226
+ // Timeline: Left/Right step along the time axis, Up/Down across resource
2227
+ // rows; Shift extends the range from the anchor cell.
2228
+ const shift = e.shiftKey;
2229
+ if (e.key === "ArrowLeft") { e.preventDefault(); moveTlRangeSelect(-1, 0, shift); }
2230
+ else if (e.key === "ArrowRight") { e.preventDefault(); moveTlRangeSelect(1, 0, shift); }
2231
+ else if (e.key === "ArrowUp") { e.preventDefault(); moveTlRangeSelect(0, -1, shift); }
2232
+ else if (e.key === "ArrowDown") { e.preventDefault(); moveTlRangeSelect(0, 1, shift); }
2233
+ } else if (monthRangeSel?.pending) {
2234
+ // Month: Left/Right move one day, Up/Down one week; Shift extends.
2235
+ const shift = e.shiftKey;
2236
+ if (e.key === "ArrowLeft") { e.preventDefault(); moveMonthRangeSelect(-1, shift); }
2237
+ else if (e.key === "ArrowRight") { e.preventDefault(); moveMonthRangeSelect(1, shift); }
2238
+ else if (e.key === "ArrowUp") { e.preventDefault(); moveMonthRangeSelect(-7, shift); }
2239
+ else if (e.key === "ArrowDown") { e.preventDefault(); moveMonthRangeSelect(7, shift); }
2240
+ }
2241
+ };
2242
+ window.addEventListener("keydown", onKey);
2243
+ return () => window.removeEventListener("keydown", onKey);
2244
+ });
2245
+ // Right-clicking a pending range opens an "Add Event" menu (reuses the event
2246
+ // context-menu chrome). Skipped when an event already handled the right-click.
2247
+ function openRangeMenu(e: MouseEvent) {
2248
+ if (e.defaultPrevented) return;
2249
+ if (!rangeSel?.pending && !tlRangeSel?.pending && !monthRangeSel?.pending) return;
2250
+ e.preventDefault();
2251
+ e.stopPropagation();
2252
+ menuItems = [{ label: "Add Event", shortcut: "Enter", onSelect: commitPendingRange }];
2253
+ menuPos = { x: e.clientX, y: e.clientY };
2254
+ menuOpen = true;
2255
+ }
2256
+
2257
+ function saveDrawer(values: Record<string, unknown>) {
2258
+ const row = drawerRow;
2259
+ if (!row) return;
2260
+ const k = key(row);
2261
+ // Editing a recurring OCCURRENCE via the drawer: ask This / Following / All.
2262
+ const ev = drawerEv;
2263
+ if (hasExceptions && ev?.recurring && ev.occurrenceStart) {
2264
+ const newTitle = scheduler.titleField ? (values[scheduler.titleField] as string | undefined) : undefined;
2265
+ const startChanged = !!whenStart && whenStart.getTime() !== ev.start.getTime();
2266
+ const endChanged = !!whenEnd && !!ev.end && whenEnd.getTime() !== ev.end.getTime();
2267
+ const titleChanged = newTitle != null && newTitle !== ev.title;
2268
+ if (!startChanged && !endChanged && !titleChanged) { drawerOpen = false; return; }
2269
+ const occOpts = { start: whenStart ?? undefined, end: whenEnd ?? undefined, allDay: whenAllDay, title: titleChanged ? newTitle : undefined };
2270
+ drawerOpen = false;
2271
+ askRecurScope(
2272
+ Math.round(window.innerWidth / 2 - 90), Math.round(window.innerHeight / 2 - 70),
2273
+ ev, "edit",
2274
+ () => emitOccurrence(ev, occOpts, "occurrence"),
2275
+ () => applyDrawerToSeries(row, k, { ...values }),
2276
+ () => emitOccurrence(ev, occOpts, "following"),
2277
+ );
2278
+ return;
2279
+ }
2280
+ applyDrawerToSeries(row, k, values);
2281
+ }
2282
+ function applyDrawerToSeries(row: TData, k: string, values: Record<string, unknown>) {
2283
+ // Fold the recurrence rule + the "When" fields into the saved values.
2284
+ if (recurEditable) {
2285
+ values = { ...values, [recurField]: buildRule(row) };
2286
+ }
2287
+ values = { ...values, [scheduler.startField]: serializeWhen(whenStart, whenAllDay) };
2288
+ if (scheduler.endField) values = { ...values, [scheduler.endField]: serializeWhen(whenEnd, whenAllDay) };
2289
+ if (scheduler.allDayField) values = { ...values, [scheduler.allDayField]: whenAllDay };
2290
+
2291
+ const changes: Record<string, unknown> = {};
2292
+ for (const col of drawerFieldCols) {
2293
+ const f = col.field as string;
2294
+ if (values[f] !== fieldValue(row, f)) changes[f] = values[f];
2295
+ }
2296
+ if (recurEditable) changes[recurField] = values[recurField];
2297
+ changes[scheduler.startField] = values[scheduler.startField];
2298
+ if (scheduler.endField) changes[scheduler.endField] = values[scheduler.endField];
2299
+ if (scheduler.allDayField) changes[scheduler.allDayField] = values[scheduler.allDayField];
2300
+ edits[k] = { ...(edits[k] ?? {}), ...values };
2301
+ // keep the render overlay in sync with the edited start / end / all-day.
2302
+ if (whenStart) startOfE[k] = whenStart;
2303
+ if (whenEnd) endOfE[k] = whenEnd;
2304
+ if (scheduler.allDayField) allDayOf[k] = whenAllDay;
2305
+ scheduler.onEventCommit?.({ row, changes, values: { ...values } });
2306
+ drawerOpen = false;
2307
+ }
2308
+ function deleteDrawer() {
2309
+ const row = drawerRow;
2310
+ if (!row) return;
2311
+ drawerOpen = false;
2312
+ scheduler.onEventDelete?.(row);
2313
+ }
2314
+ // Explicit discard: close without committing (bypasses the save-on-dismiss).
2315
+ function cancelDrawer() {
2316
+ drawerOpen = false;
2317
+ }
2318
+ // Dismissing the drawer (click-outside / Escape) commits the current edits.
2319
+ function commitDrawer() {
2320
+ if (drawerRow) saveDrawer({ ...drawerValues });
2321
+ }
2322
+ const drawerTitle = $derived.by(() => {
2323
+ const row = drawerRow;
2324
+ if (!row) return "";
2325
+ const t = drawerCfg?.title;
2326
+ if (typeof t === "function") return t(row);
2327
+ if (typeof t === "string") return t;
2328
+ return titleField ? String(fieldValue(row, titleField) ?? "Event") : "Event";
2329
+ });
2330
+
2331
+ // Add on empty-slot double-click.
2332
+ function onSlotDblClick(col: GridCol, e: MouseEvent) {
2333
+ rangeSel = null; // a double-click creates directly - drop the click's marker
2334
+ if (!scheduler.onEventAdd) return;
2335
+ const min = snapMinute(minuteAt(e.clientY), slotMinutes);
2336
+ const s = dateAtMinute(col.date, min);
2337
+ const en = new Date(s.getTime() + (scheduler.defaultDurationMin ?? 60) * 60000);
2338
+ if (bookingBlocked(s, en, col.resourceId, "")) return;
2339
+ emitAdd(s, en, col.resourceId, false);
2340
+ }
2341
+
2342
+ // --- timeline geometry + drag/resize (horizontal) ---
2343
+ const TL_MS_MIN = 60_000;
2344
+ function tlTimeAtX(clientX: number): Date {
2345
+ if (!tlLanesEl) return tlAxis.start;
2346
+ const r = tlLanesEl.getBoundingClientRect();
2347
+ const frac = Math.min(1, Math.max(0, (clientX - r.left) / r.width));
2348
+ return new Date(tlAxis.start.getTime() + frac * tlAxis.totalMs);
2349
+ }
2350
+ const snapTlStart = (d: Date): Date =>
2351
+ view === "timelineDay"
2352
+ ? new Date(Math.round(d.getTime() / (tlSlot * TL_MS_MIN)) * (tlSlot * TL_MS_MIN))
2353
+ : startOfDay(d);
2354
+ const snapTlEnd = (d: Date): Date =>
2355
+ view === "timelineDay"
2356
+ ? new Date(Math.round(d.getTime() / (tlSlot * TL_MS_MIN)) * (tlSlot * TL_MS_MIN))
2357
+ : addDays(startOfDay(d), 1); // inclusive day → next midnight
2358
+ function tlResAt(clientX: number, clientY: number): string | undefined {
2359
+ const el = document.elementFromPoint(clientX, clientY)?.closest<HTMLElement>("[data-tlres]");
2360
+ return el?.dataset.tlres || undefined;
2361
+ }
2362
+ // One navigable "cell" along the axis: a slot in Day, a whole day in
2363
+ // Week/Month, a whole month in Year. Used for the click marker + keyboard nav.
2364
+ function tlStepStart(t: number): number {
2365
+ if (view === "timelineDay") return Math.floor(t / (tlSlot * TL_MS_MIN)) * (tlSlot * TL_MS_MIN);
2366
+ const b = startOfDay(new Date(t));
2367
+ if (view === "timelineYear") return new Date(b.getFullYear(), b.getMonth(), 1).getTime();
2368
+ return b.getTime();
2369
+ }
2370
+ function tlStepEnd(t: number): number {
2371
+ if (view === "timelineDay") return tlStepStart(t) + tlSlot * TL_MS_MIN;
2372
+ const b = new Date(tlStepStart(t));
2373
+ if (view === "timelineYear") return new Date(b.getFullYear(), b.getMonth() + 1, 1).getTime();
2374
+ return addDays(b, 1).getTime();
2375
+ }
2376
+ function tlStepShift(t: number, d: number): number {
2377
+ if (view === "timelineDay") return tlStepStart(t) + d * tlSlot * TL_MS_MIN;
2378
+ const b = new Date(tlStepStart(t));
2379
+ if (view === "timelineYear") return new Date(b.getFullYear(), b.getMonth() + d, 1).getTime();
2380
+ return addDays(b, d).getTime();
2381
+ }
2382
+ // Keyboard navigation for the timeline cell selection. dCol steps along the
2383
+ // time axis, dRow moves between resource rows; `extend` (Shift) grows the range
2384
+ // from the anchor, otherwise the whole selection moves as a single cell.
2385
+ function moveTlRangeSelect(dCol: number, dRow: number, extend: boolean) {
2386
+ const sel = tlRangeSel;
2387
+ if (!sel) return;
2388
+ const nextT = tlStepShift(sel.curT, dCol);
2389
+ const nextIdx = Math.min(tlRows.length - 1, Math.max(0, sel.curIdx + dRow));
2390
+ tlRangeSel = extend
2391
+ ? { ...sel, curT: nextT, curIdx: nextIdx, moved: true, pending: true }
2392
+ : { anchorT: nextT, curT: nextT, anchorIdx: nextIdx, curIdx: nextIdx, moved: true, pending: true };
2393
+ tlScrollActiveIntoView(nextT);
2394
+ }
2395
+ function tlScrollActiveIntoView(t: number) {
2396
+ if (!tlScrollEl) return;
2397
+ const g = timelineGeom(new Date(tlStepStart(t)), new Date(tlStepEnd(t)), tlAxis.start, tlAxis.totalMs);
2398
+ if (!g) return;
2399
+ const cellLeft = tlResW + (g.leftPct / 100) * tlAxisWidth;
2400
+ const cellRight = tlResW + ((g.leftPct + g.widthPct) / 100) * tlAxisWidth;
2401
+ const viewLeft = tlScrollEl.scrollLeft;
2402
+ const viewRight = viewLeft + tlScrollEl.clientWidth;
2403
+ // The resource gutter sticks to the left, so a cell is hidden until it clears it.
2404
+ if (cellLeft < viewLeft + tlResW) tlScrollEl.scrollLeft = Math.max(0, cellLeft - tlResW - 20);
2405
+ else if (cellRight > viewRight) tlScrollEl.scrollLeft = cellRight - tlScrollEl.clientWidth + 20;
2406
+ }
2407
+
2408
+ type TlMode = "move" | "resize-start" | "resize-end";
2409
+ type TlDrag = {
2410
+ ev: ResolvedEvent<TData>;
2411
+ mode: TlMode;
2412
+ startX: number;
2413
+ startY: number;
2414
+ grabOffsetMs: number;
2415
+ durationMs: number;
2416
+ origStart: Date;
2417
+ origEnd: Date;
2418
+ moved: boolean;
2419
+ previewStart: Date;
2420
+ previewEnd: Date;
2421
+ previewRes: string | undefined;
2422
+ x: number;
2423
+ y: number;
2424
+ };
2425
+ let tlDrag = $state<TlDrag | null>(null);
2426
+ function startTlDrag(e: PointerEvent, ev: ResolvedEvent<TData>, mode: TlMode) {
2427
+ if (e.button !== 0) return; // ignore right/middle button (let the context menu open)
2428
+ suppressClick = false;
2429
+ if (!editable || (ev.recurring && !recurEditable)) return;
2430
+ e.preventDefault();
2431
+ e.stopPropagation();
2432
+ tlDrag = {
2433
+ ev,
2434
+ mode,
2435
+ startX: e.clientX,
2436
+ startY: e.clientY,
2437
+ grabOffsetMs: Math.max(0, tlTimeAtX(e.clientX).getTime() - ev.start.getTime()),
2438
+ durationMs: ev.end.getTime() - ev.start.getTime(),
2439
+ origStart: ev.start,
2440
+ origEnd: ev.end,
2441
+ moved: false,
2442
+ previewStart: ev.start,
2443
+ previewEnd: ev.end,
2444
+ previewRes: ev.resourceId,
2445
+ x: e.clientX,
2446
+ y: e.clientY,
2447
+ };
2448
+ window.addEventListener("pointermove", onTlDragMove);
2449
+ window.addEventListener("pointerup", onTlDragEnd, { once: true });
2450
+ }
2451
+ function onTlDragMove(e: PointerEvent) {
2452
+ if (!tlDrag) return;
2453
+ if (!tlDrag.moved) {
2454
+ if (Math.abs(e.clientX - tlDrag.startX) < DRAG_THRESHOLD && Math.abs(e.clientY - tlDrag.startY) < DRAG_THRESHOLD) return;
2455
+ tlDrag.moved = true;
2456
+ }
2457
+ tlDrag.x = e.clientX;
2458
+ tlDrag.y = e.clientY;
2459
+ dragOverBacklog = tlDrag.mode === "move" && !!scheduler.onUnschedule && !!backlogEl && pointInEl(e.clientX, e.clientY, backlogEl);
2460
+ const t = tlTimeAtX(e.clientX);
2461
+ const minDur = (view === "timelineDay" ? tlSlot : 24 * 60) * TL_MS_MIN;
2462
+ if (tlDrag.mode === "move") {
2463
+ const ns = snapTlStart(new Date(t.getTime() - tlDrag.grabOffsetMs));
2464
+ tlDrag.previewStart = ns;
2465
+ tlDrag.previewEnd = new Date(ns.getTime() + tlDrag.durationMs);
2466
+ // Recurring instances keep their row; others can change resource.
2467
+ tlDrag.previewRes = tlDrag.ev.recurring ? tlDrag.ev.resourceId : tlResAt(e.clientX, e.clientY) ?? tlDrag.previewRes;
2468
+ } else if (tlDrag.mode === "resize-end") {
2469
+ const ne = snapTlEnd(t);
2470
+ tlDrag.previewEnd = new Date(Math.max(ne.getTime(), tlDrag.origStart.getTime() + minDur));
2471
+ tlDrag.previewStart = tlDrag.origStart;
2472
+ } else {
2473
+ const ns = snapTlStart(t);
2474
+ tlDrag.previewStart = new Date(Math.min(ns.getTime(), tlDrag.origEnd.getTime() - minDur));
2475
+ tlDrag.previewEnd = tlDrag.origEnd;
2476
+ }
2477
+ }
2478
+ function onTlDragEnd() {
2479
+ window.removeEventListener("pointermove", onTlDragMove);
2480
+ const d = tlDrag;
2481
+ tlDrag = null;
2482
+ if (!d || !d.moved) return;
2483
+ suppressClick = true;
2484
+ dragOverBacklog = false;
2485
+ const { ev, previewStart, previewEnd, previewRes, mode } = d;
2486
+ const k = ev.rowKey;
2487
+ // Dropped onto the backlog panel -> unschedule.
2488
+ if (mode === "move" && scheduler.onUnschedule && backlogEl && pointInEl(d.x, d.y, backlogEl)) {
2489
+ scheduler.onUnschedule(ev.row);
2490
+ return;
2491
+ }
2492
+ if (ev.recurring) {
2493
+ // Series edit: apply the new time-of-day (+ duration) to the base row.
2494
+ const applySeries = () => {
2495
+ const baseStart = toDate(fieldValue(ev.row, scheduler.startField) as never) ?? ev.start;
2496
+ const ns = dayWithTimeOf(baseStart, previewStart);
2497
+ const ne = new Date(ns.getTime() + (previewEnd.getTime() - previewStart.getTime()));
2498
+ startOfE[k] = ns;
2499
+ endOfE[k] = ne;
2500
+ if (mode === "move") emitMove({ row: ev.row, start: ns, end: ne, allDay: ev.allDay });
2501
+ else emitResize({ row: ev.row, start: ns, end: ne });
2502
+ };
2503
+ askRecurScope(d.x, d.y, ev, "edit", () => emitOccurrence(ev, { start: previewStart, end: previewEnd }), applySeries, () => emitOccurrence(ev, { start: previewStart, end: previewEnd }, "following"));
2504
+ return;
2505
+ }
2506
+ const checkRes = mode === "move" ? previewRes : ev.resourceId;
2507
+ if (bookingBlocked(previewStart, previewEnd, checkRes, k)) return;
2508
+ startOfE[k] = previewStart;
2509
+ endOfE[k] = previewEnd;
2510
+ if (mode === "move") {
2511
+ if (previewRes != null) resourceOfE[k] = previewRes;
2512
+ emitMove({ row: ev.row, start: previewStart, end: previewEnd, allDay: ev.allDay, fromResource: ev.resourceId, toResource: previewRes });
2513
+ applyBulkMove(k, previewStart.getTime() - d.origStart.getTime());
2514
+ } else {
2515
+ emitResize({ row: ev.row, start: previewStart, end: previewEnd });
2516
+ }
2517
+ pushHistory({
2518
+ key: k,
2519
+ row: ev.row,
2520
+ kind: mode === "move" ? "move" : "resize",
2521
+ before: { start: d.origStart, end: d.origEnd, resource: ev.resourceId, allDay: ev.allDay },
2522
+ after: { start: previewStart, end: previewEnd, resource: mode === "move" ? previewRes : ev.resourceId, allDay: ev.allDay },
2523
+ });
2524
+ }
2525
+ // Double-click an empty spot in a resource row → add there.
2526
+ function onTlSlotDblClick(resourceId: string | undefined, e: MouseEvent) {
2527
+ tlRangeSel = null; // a double-click creates directly - drop the click's marker
2528
+ if (!scheduler.onEventAdd) return;
2529
+ const s = snapTlStart(tlTimeAtX(e.clientX));
2530
+ const durMs = (view === "timelineDay" ? scheduler.defaultDurationMin ?? 60 : 24 * 60) * TL_MS_MIN;
2531
+ emitAdd(s, new Date(s.getTime() + durMs), resourceId, view !== "timelineDay");
2532
+ }
2533
+ // Geometry for one timeline bar. A RESIZE morphs the bar in place (live); a
2534
+ // MOVE leaves the source bar where it is (dimmed) - a separate preview bar in
2535
+ // the destination row shows where it lands (so cross-row drags read clearly).
2536
+ function tlBarGeom(ev: ResolvedEvent<TData>) {
2537
+ const d = tlDrag;
2538
+ const isDrag = d?.ev.key === ev.key && d.moved;
2539
+ if (isDrag && d!.mode !== "move") return timelineGeom(d!.previewStart, d!.previewEnd, tlAxis.start, tlAxis.totalMs);
2540
+ return timelineGeom(ev.start, ev.end, tlAxis.start, tlAxis.totalMs);
2541
+ }
2542
+ // The move-preview geometry (for the destination-row ghost bar).
2543
+ const tlMovePreview = $derived.by(() => {
2544
+ if (!tlDrag || !tlDrag.moved || tlDrag.mode !== "move") return null;
2545
+ const g = timelineGeom(tlDrag.previewStart, tlDrag.previewEnd, tlAxis.start, tlAxis.totalMs);
2546
+ return g ? { g, resId: tlDrag.previewRes ?? "", ev: tlDrag.ev } : null;
2547
+ });
2548
+
2549
+ function eventStyle(ev: ResolvedEvent<TData>): string {
2550
+ let s = ev.color ? `--sv-sched-accent:${ev.color};` : "";
2551
+ // A secondary color paints the left strip (see the `.sv-sched-event`/`-bar`
2552
+ // border-left, which falls back to the main accent when unset).
2553
+ if (ev.color2) s += `--sv-sched-accent2:${ev.color2};`;
2554
+ return s;
2555
+ }
2556
+ // Normalize a free/busy status to a modifier ('free'|'tentative'|'oof') or
2557
+ // undefined (busy/default). Applied as `data-status` on event elements.
2558
+ function evStatus(ev: ResolvedEvent<TData>): string | undefined {
2559
+ const s = (ev.status ?? "").toString().toLowerCase().replace(/[\s_-]/g, "");
2560
+ if (s === "free") return "free";
2561
+ if (s === "tentative") return "tentative";
2562
+ if (s === "oof" || s === "outofoffice") return "oof";
2563
+ return undefined;
2564
+ }
2565
+ </script>
2566
+
2567
+ <div class="sv-sched" role="group" aria-roledescription="calendar">
2568
+ <!-- toolbar -->
2569
+ <div class="sv-sched-toolbar">
2570
+ <div class="sv-sched-nav">
2571
+ <button type="button" class="sv-sched-btn" onclick={() => go(-1)} disabled={!canGoPrev} aria-label="Previous">‹</button>
2572
+ <button type="button" class="sv-sched-btn" onclick={today}>Today</button>
2573
+ <button type="button" class="sv-sched-btn" onclick={() => go(1)} disabled={!canGoNext} aria-label="Next">›</button>
2574
+ </div>
2575
+ <div class="sv-sched-title" aria-live="polite">{titleLabel}</div>
2576
+ <div class="sv-sched-views">
2577
+ {#if slotSizes.length && (view === "week" || view === "day")}
2578
+ <div class="sv-sched-slots" role="group" aria-label="Slot size">
2579
+ {#each slotSizes as ms (ms)}
2580
+ <button
2581
+ type="button"
2582
+ class="sv-sched-btn sv-sched-btn-slot"
2583
+ class:sv-sched-btn-active={slotMinutes === ms}
2584
+ aria-pressed={slotMinutes === ms}
2585
+ onclick={() => (slotOverride = ms)}
2586
+ >{slotSizeLabel(ms)}</button>
2587
+ {/each}
2588
+ </div>
2589
+ {/if}
2590
+ {#each views as v (v)}
2591
+ <button
2592
+ type="button"
2593
+ class="sv-sched-btn"
2594
+ class:sv-sched-btn-active={view === v}
2595
+ onclick={() => setView(v)}
2596
+ >{viewLabel(v)}</button>
2597
+ {/each}
2598
+ </div>
2599
+ </div>
2600
+
2601
+ {#if resourceField && resources.length}
2602
+ <!-- Resource legend + filter: colour key that toggles a resource in EVERY
2603
+ view (Week/Day group into columns; Month/Agenda filter by it). -->
2604
+ <div class="sv-sched-reslegend">
2605
+ {#each resources as r (r.id)}
2606
+ <button
2607
+ type="button"
2608
+ class="sv-sched-reschip"
2609
+ class:sv-sched-reschip-off={hiddenResources.has(r.id)}
2610
+ style={r.color ? `--sv-sched-accent:${r.color};` : ""}
2611
+ aria-pressed={!hiddenResources.has(r.id)}
2612
+ onclick={() => toggleResource(r.id)}
2613
+ >
2614
+ <span class="sv-sched-dot"></span>
2615
+ <span>{r.title ?? r.id}</span>
2616
+ </button>
2617
+ {/each}
2618
+ </div>
2619
+ {/if}
2620
+
2621
+ <div class="sv-sched-workarea">
2622
+ {#if hasBacklog}
2623
+ <aside class="sv-sched-backlog" class:sv-sched-backlog-drop={dragOverBacklog} bind:this={backlogEl}>
2624
+ <div class="sv-sched-backlog-head">{scheduler.backlogTitle ?? "Unscheduled"}</div>
2625
+ {#each backlogItems as item (item.id)}
2626
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
2627
+ <div class="sv-sched-backlog-item" style={item.color ? `--sv-sched-accent:${item.color};` : ""} onpointerdown={(e) => startBacklogDrag(item, e)}>
2628
+ <span class="sv-sched-dot"></span><span class="sv-sched-backlog-title">{item.title}</span>
2629
+ </div>
2630
+ {/each}
2631
+ </aside>
2632
+ {/if}
2633
+ <div class="sv-sched-viewwrap">
2634
+ {#if view === "month"}
2635
+ <div class="sv-sched-month">
2636
+ <div class="sv-sched-monthhead">
2637
+ {#each headerOrder as dowIdx (dowIdx)}
2638
+ <div class="sv-sched-dow">{wd(dowIdx)}</div>
2639
+ {/each}
2640
+ </div>
2641
+ <div class="sv-sched-monthbody" bind:clientHeight={monthBodyH}>
2642
+ {#each monthWeeks as week, wi (wi)}
2643
+ {@const weekStart = week[0]!.date}
2644
+ {@const seg = monthWeekSegments(viewEvents, weekStart)}
2645
+ <div class="sv-sched-week">
2646
+ {#each week as cell, ci (cell.date.getTime())}
2647
+ {@const moreN = monthMoreCount(seg.segments, ci)}
2648
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
2649
+ <div
2650
+ class="sv-sched-daycell"
2651
+ class:sv-sched-daycell-out={!cell.inMonth}
2652
+ class:sv-sched-daycell-nonworking={isNonWorkingDay(cell.date)}
2653
+ class:sv-sched-today={isSameDay(cell.date, startOfDay(toZonedLocal(new Date(), tz)))}
2654
+ class:sv-sched-daycell-drop={monthOverDay === cell.date.getTime() &&
2655
+ (monthDrag?.moved === true || monthResize?.moved === true)}
2656
+ class:sv-sched-select-cell={isMonthCellSelected(cell.date)}
2657
+ data-day={cell.date.getTime()}
2658
+ onpointerdown={(e) => startMonthRangeSelect(cell.date, e)}
2659
+ oncontextmenu={openRangeMenu}
2660
+ ondblclick={() => onMonthSlotAdd(cell.date)}
2661
+ >
2662
+ <div class="sv-sched-daynum">{cell.date.getDate()}</div>
2663
+ {#if moreN > 0}
2664
+ <button
2665
+ type="button"
2666
+ class="sv-sched-more"
2667
+ style={`top:${MONTH_DAYNUM_H + visibleMonthLanes * MONTH_LANE_H}px`}
2668
+ onpointerdown={(e) => e.stopPropagation()}
2669
+ onclick={(e) => openList(e, eventsOnDay(viewEvents, cell.date), `${mon(cell.date.getMonth())} ${cell.date.getDate()}`)}
2670
+ >+{moreN} more</button>
2671
+ {/if}
2672
+ </div>
2673
+ {/each}
2674
+ <!-- Continuous spanning event bars, layered over the day cells. -->
2675
+ <div
2676
+ class="sv-sched-weekbars"
2677
+ class:sv-sched-weekbars-dragging={monthDrag?.moved === true || monthResize?.moved === true || monthRangeSel?.moved === true}
2678
+ >
2679
+ {#each seg.segments as s (s.event.key)}
2680
+ {#if s.lane < visibleMonthLanes}
2681
+ {@const canEditBar = editable && !s.event.recurring}
2682
+ <button
2683
+ type="button"
2684
+ class="sv-sched-bar"
2685
+ data-status={evStatus(s.event)}
2686
+ class:sv-sched-bar-recurring={s.event.recurring}
2687
+ class:sv-sched-bar-draggable={canEditBar}
2688
+ class:sv-sched-bar-source={monthDrag?.moved && monthDrag.ev.key === s.event.key}
2689
+ class:sv-sched-bar-selected={isSelected(s.event)}
2690
+ class:sv-sched-bar-cont-left={s.continuesLeft}
2691
+ class:sv-sched-bar-cont-right={s.continuesRight}
2692
+ style={`left:calc(${(s.startCol / 7) * 100}% + 3px); width:calc(${((s.endCol - s.startCol + 1) / 7) * 100}% - 6px); top:${MONTH_DAYNUM_H + s.lane * MONTH_LANE_H}px; height:${MONTH_LANE_H - 3}px; ${eventStyle(s.event)}`}
2693
+ onclick={(e) => onEventClick(e, s.event)}
2694
+ oncontextmenu={(e) => openMenu(e, s.event)}
2695
+ onpointerdown={(e) => startMonthDrag(e, s.event)}
2696
+ onkeydown={(e) => onEventKey(e, s.event)}
2697
+ onmouseenter={(e) => onEventEnter(e, s.event)}
2698
+ onmouseleave={onEventLeave}
2699
+ title={s.event.title}
2700
+ >
2701
+ {#if canEditBar && !s.continuesLeft}
2702
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
2703
+ <span
2704
+ class="sv-sched-chip-resize sv-sched-chip-resize-start"
2705
+ aria-hidden="true"
2706
+ onpointerdown={(e) => startMonthResize(e, s.event, "start")}
2707
+ ></span>
2708
+ {/if}
2709
+ {#if scheduler.event}{@render scheduler.event(s.event.row)}{:else}{#if !s.event.allDay && !s.continuesLeft}<span class="sv-sched-dot"></span><span class="sv-sched-bar-time">{fmtTime(s.event.start)}</span>{/if}<span class="sv-sched-bar-title">{s.event.title}</span>{/if}
2710
+ {#if canEditBar && !s.continuesRight}
2711
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
2712
+ <span
2713
+ class="sv-sched-chip-resize sv-sched-chip-resize-end"
2714
+ aria-hidden="true"
2715
+ onpointerdown={(e) => startMonthResize(e, s.event, "end")}
2716
+ ></span>
2717
+ {/if}
2718
+ </button>
2719
+ {/if}
2720
+ {/each}
2721
+ </div>
2722
+ </div>
2723
+ {/each}
2724
+ </div>
2725
+ </div>
2726
+ {:else if view === "agenda"}
2727
+ <div class="sv-sched-agenda">
2728
+ {#if agenda.length === 0}
2729
+ <div class="sv-sched-empty">No events in this range.</div>
2730
+ {/if}
2731
+ {#each agenda as group (group.day.getTime())}
2732
+ <div class="sv-sched-agenda-day">
2733
+ <div class="sv-sched-agenda-date">
2734
+ <span class="sv-sched-agenda-dow">{wd(group.day.getDay())}</span>
2735
+ <span class="sv-sched-agenda-num">{group.day.getDate()}</span>
2736
+ <span class="sv-sched-agenda-mon">{mon(group.day.getMonth()).slice(0, 3)}</span>
2737
+ </div>
2738
+ <div class="sv-sched-agenda-events">
2739
+ {#each group.events as ev (ev.key)}
2740
+ <button
2741
+ type="button"
2742
+ class="sv-sched-agenda-row"
2743
+ data-status={evStatus(ev)}
2744
+ class:sv-sched-event-selected={isSelected(ev)}
2745
+ style={eventStyle(ev)}
2746
+ onclick={(e) => onEventClick(e, ev)}
2747
+ oncontextmenu={(e) => openMenu(e, ev)}
2748
+ onmouseenter={(e) => onEventEnter(e, ev)}
2749
+ onmouseleave={onEventLeave}
2750
+ >
2751
+ {#if scheduler.event}{@render scheduler.event(ev.row)}{:else}
2752
+ <span class="sv-sched-dot"></span>
2753
+ <span class="sv-sched-agenda-time">
2754
+ {ev.allDay ? "all day" : `${fmtTime(ev.start)} - ${fmtTime(ev.end)}`}
2755
+ </span>
2756
+ <span class="sv-sched-agenda-title">{ev.title}</span>
2757
+ {#if ev.resourceId}<span class="sv-sched-tag">{ev.resourceId}</span>{/if}
2758
+ {/if}
2759
+ </button>
2760
+ {/each}
2761
+ </div>
2762
+ </div>
2763
+ {/each}
2764
+ </div>
2765
+ {:else if isTimeline}
2766
+ <!-- timeline: horizontal time axis, resources as rows -->
2767
+ <div class="sv-sched-tl" bind:this={tlScrollEl} bind:clientWidth={tlOuterW} style={`--tl-res-w:${tlResW}px; --tl-axis-w:${tlAxisWidth}px; --tl-lane-h:${tlLaneH}px`}>
2768
+ <!-- header: corner + 2-row axis (majors over ticks) -->
2769
+ <div class="sv-sched-tl-head">
2770
+ <div class="sv-sched-tl-corner"></div>
2771
+ <div class="sv-sched-tl-axis">
2772
+ <div class="sv-sched-tl-majors">
2773
+ {#each tlAxis.majors as m (m.leftPct)}
2774
+ <div class="sv-sched-tl-major" style={`left:${m.leftPct}%; width:${m.widthPct}%`}>{m.label}</div>
2775
+ {/each}
2776
+ </div>
2777
+ <div class="sv-sched-tl-ticks" bind:this={tlLanesEl}>
2778
+ {#each tlAxis.ticks as t (t.start.getTime())}
2779
+ <div class="sv-sched-tl-tick" class:sv-sched-today={t.today} style={`left:${t.leftPct}%; width:${t.widthPct}%`}>
2780
+ <span>{t.label}</span>
2781
+ </div>
2782
+ {/each}
2783
+ </div>
2784
+ </div>
2785
+ </div>
2786
+ <!-- body: resource gutter + lane track, scrolls together -->
2787
+ <div class="sv-sched-tl-body">
2788
+ {#each tlRows as row, ri (row.resource?.id ?? "__all")}
2789
+ {@const rowH = Math.max(1, row.laneCount) * tlLaneH + 6}
2790
+ {@const isDropRow = !!tlMovePreview && tlMovePreview.resId === (row.resource?.id ?? "")}
2791
+ <div class="sv-sched-tl-row" class:sv-sched-tl-row-drop={isDropRow} style={`height:${rowH}px`}>
2792
+ <div class="sv-sched-tl-resgutter">
2793
+ {#if row.resource?.color}<span class="sv-sched-dot" style={`--sv-sched-accent:${row.resource.color}`}></span>{/if}
2794
+ <span class="sv-sched-tl-resname">{row.resource?.title ?? row.resource?.id ?? "All"}</span>
2795
+ </div>
2796
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
2797
+ <div
2798
+ class="sv-sched-tl-lanes"
2799
+ data-tlres={row.resource?.id ?? ""}
2800
+ onpointerdown={(e) => startTlRangeSelect(ri, e)}
2801
+ ondblclick={(e) => onTlSlotDblClick(row.resource?.id, e)}
2802
+ oncontextmenu={openRangeMenu}
2803
+ >
2804
+ <!-- vertical tick gridlines -->
2805
+ {#each tlAxis.ticks as t (t.start.getTime())}
2806
+ <div class="sv-sched-tl-gridline" class:sv-sched-today={t.today} style={`left:${t.leftPct}%; width:${t.widthPct}%`}></div>
2807
+ {/each}
2808
+ {#if nowTlPct != null}
2809
+ <div class="sv-sched-tl-nowline" style={`left:${nowTlPct}%`}></div>
2810
+ {/if}
2811
+ {#if tlRangeBand && ri >= tlRangeBand.lo && ri <= tlRangeBand.hi}
2812
+ <div class="sv-sched-select-mirror" style={`left:${tlRangeBand.leftPct}%; width:${tlRangeBand.widthPct}%; top:2px; bottom:2px`}></div>
2813
+ {/if}
2814
+ {#if isDropRow && tlMovePreview}
2815
+ <!-- Move preview: where the dragged bar lands in this row. -->
2816
+ <div
2817
+ class="sv-sched-drag-preview sv-sched-tl-bar"
2818
+ style={`left:${tlMovePreview.g.leftPct}%; width:${tlMovePreview.g.widthPct}%; top:3px; height:${tlLaneH - 4}px; ${eventStyle(tlMovePreview.ev)}`}
2819
+ >
2820
+ <span class="sv-sched-bar-title">{tlMovePreview.ev.title}</span>
2821
+ </div>
2822
+ {/if}
2823
+ {#each row.items as it (it.event.key)}
2824
+ {@const g = tlBarGeom(it.event)}
2825
+ {#if g}
2826
+ {@const canEdit = editable && (!it.event.recurring || recurEditable)}
2827
+ {@const wpx = (g.widthPct / 100) * tlAxisWidth}
2828
+ <button
2829
+ type="button"
2830
+ class="sv-sched-bar sv-sched-tl-bar"
2831
+ data-status={evStatus(it.event)}
2832
+ class:sv-sched-tl-bar-tiny={wpx < 34}
2833
+ class:sv-sched-bar-recurring={it.event.recurring}
2834
+ class:sv-sched-bar-draggable={canEdit}
2835
+ class:sv-sched-bar-source={tlDrag?.moved && tlDrag.mode === "move" && tlDrag.ev.key === it.event.key}
2836
+ class:sv-sched-bar-resizing={tlDrag?.moved && tlDrag.mode !== "move" && tlDrag.ev.key === it.event.key}
2837
+ class:sv-sched-bar-selected={isSelected(it.event)}
2838
+ class:sv-sched-bar-cont-left={g.continuesLeft}
2839
+ class:sv-sched-bar-cont-right={g.continuesRight}
2840
+ style={`left:${g.leftPct}%; width:${g.widthPct}%; top:${it.lane * tlLaneH + 3}px; height:${tlLaneH - 4}px; ${eventStyle(it.event)}`}
2841
+ onpointerdown={(e) => startTlDrag(e, it.event, "move")}
2842
+ onclick={(e) => onEventClick(e, it.event)}
2843
+ oncontextmenu={(e) => openMenu(e, it.event)}
2844
+ onkeydown={(e) => onEventKey(e, it.event)}
2845
+ onmouseenter={(e) => onEventEnter(e, it.event)}
2846
+ onmouseleave={onEventLeave}
2847
+ title={it.event.title}
2848
+ >
2849
+ {#if canEdit && !g.continuesLeft}
2850
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
2851
+ <span class="sv-sched-chip-resize sv-sched-chip-resize-start" aria-hidden="true" onpointerdown={(e) => startTlDrag(e, it.event, "resize-start")}></span>
2852
+ {/if}
2853
+ {#if wpx >= 34}
2854
+ {#if scheduler.event}{@render scheduler.event(it.event.row)}{:else}{#if view === "timelineDay" && !it.event.allDay && !g.continuesLeft}<span class="sv-sched-bar-time">{fmtTime(it.event.start)}</span>{/if}<span class="sv-sched-bar-title">{it.event.title}</span>{/if}
2855
+ {/if}
2856
+ {#if canEdit && !g.continuesRight}
2857
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
2858
+ <span class="sv-sched-chip-resize sv-sched-chip-resize-end" aria-hidden="true" onpointerdown={(e) => startTlDrag(e, it.event, "resize-end")}></span>
2859
+ {/if}
2860
+ </button>
2861
+ {/if}
2862
+ {/each}
2863
+ </div>
2864
+ </div>
2865
+ {/each}
2866
+ <!-- filler row so the axis gridlines fill the remaining height -->
2867
+ <div class="sv-sched-tl-row sv-sched-tl-filler">
2868
+ <div class="sv-sched-tl-resgutter"></div>
2869
+ <div class="sv-sched-tl-lanes">
2870
+ {#each tlAxis.ticks as t (t.start.getTime())}
2871
+ <div class="sv-sched-tl-gridline" class:sv-sched-today={t.today} style={`left:${t.leftPct}%; width:${t.widthPct}%`}></div>
2872
+ {/each}
2873
+ {#if nowTlPct != null}
2874
+ <div class="sv-sched-tl-nowline" style={`left:${nowTlPct}%`}></div>
2875
+ {/if}
2876
+ </div>
2877
+ </div>
2878
+ </div>
2879
+ </div>
2880
+ {:else}
2881
+ <!-- week / day time-grid -->
2882
+ <div class="sv-sched-grid">
2883
+ <div class="sv-sched-xscroll" bind:this={gridScrollEl}>
2884
+ <div
2885
+ class="sv-sched-xinner"
2886
+ style={`--cols:${gridCols.length};--gutn:${gutterCount};--gutw:${gutterCount * 56}px;${gridMinWidth ? ` min-width:${gridMinWidth}px;` : ""}`}
2887
+ >
2888
+ <!-- Sticky header block: stays pinned to the top of the single (both-axis)
2889
+ scroller while the body scrolls vertically, and scrolls horizontally
2890
+ with the columns. -->
2891
+ <div class="sv-sched-headwrap">
2892
+ {#if groupHeaders.length}
2893
+ <div class="sv-sched-grouphead">
2894
+ <div class="sv-sched-gutter-head"></div>
2895
+ {#each groupHeaders as g (g.key)}
2896
+ <div class="sv-sched-groupcell" style={`grid-column: span ${g.span};${g.color ? ` --sv-sched-accent:${g.color};` : ""}`}>
2897
+ {#if g.color}<span class="sv-sched-dot"></span>{/if}
2898
+ <span class="sv-sched-group-label">{g.label}</span>
2899
+ </div>
2900
+ {/each}
2901
+ </div>
2902
+ {/if}
2903
+ <div class="sv-sched-gridhead">
2904
+ <div class="sv-sched-gutter-head sv-sched-zonehead">
2905
+ {#each secondaryRulers as sr (sr.label)}<span class="sv-sched-zonelabel">{sr.label}</span>{/each}
2906
+ {#if primaryZoneLabel}<span class="sv-sched-zonelabel sv-sched-zonelabel-primary">{primaryZoneLabel}</span>{/if}
2907
+ </div>
2908
+ {#each gridCols as col (col.key)}
2909
+ {@const special = specialByDay.get(dayKey(col.date))}
2910
+ <div class="sv-sched-colhead" class:sv-sched-today={col.today} class:sv-sched-colhead-special={!!special}>
2911
+ {#if col.color && groupByDate}<span class="sv-sched-dot" style={`--sv-sched-accent:${col.color};`}></span>{/if}
2912
+ <span class="sv-sched-colhead-label">{col.label}</span>
2913
+ {#if col.sub}<span class="sv-sched-colhead-sub">{col.sub}</span>{/if}
2914
+ {#if special}<span class="sv-sched-special-tag" style={special.color ? `--sv-sched-special:${special.color};` : ""} title={special.label ?? "Special date"}>{special.label ?? "•"}</span>{/if}
2915
+ </div>
2916
+ {/each}
2917
+ </div>
2918
+
2919
+ {#if hasAllDayRow}
2920
+ <div class="sv-sched-allday" bind:this={allDayRowEl} style={allDayBars ? `height:${Math.max(1, allDaySegs.laneCount) * 22 + 8}px` : ""}>
2921
+ <div class="sv-sched-gutter-head sv-sched-allday-label">all-day</div>
2922
+ {#if allDayBars}
2923
+ <!-- Per-day background cells keep the column separator lines continuous
2924
+ with the header + hourly grid; the bars overlay spans them. -->
2925
+ {#each gridCols as col (col.key)}
2926
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
2927
+ <div
2928
+ class="sv-sched-allday-colbg"
2929
+ class:sv-sched-daycell-drop={(allDayPreview?.kind === "allday" && allDayPreview.colKey === col.key) ||
2930
+ drag?.overAllDay?.key === col.key}
2931
+ class:sv-sched-select-cell={rangeSelAllDayKeys?.has(col.key)}
2932
+ onpointerdown={(e) => startAllDayRangeSelect(col, e)}
2933
+ oncontextmenu={openRangeMenu}
2934
+ ></div>
2935
+ {/each}
2936
+ <!-- Multi-day / all-day events as continuous spanning bars, lane-stacked. -->
2937
+ <div class="sv-sched-allday-bars">
2938
+ {#each allDaySegs.segments as s (s.event.key)}
2939
+ {@const canEditBar = editable && !s.event.recurring}
2940
+ <button
2941
+ type="button"
2942
+ class="sv-sched-bar"
2943
+ class:sv-sched-bar-recurring={s.event.recurring}
2944
+ class:sv-sched-bar-draggable={canEditBar}
2945
+ class:sv-sched-bar-source={allDayDrag?.moved && allDayDrag.ev.key === s.event.key}
2946
+ class:sv-sched-bar-cont-left={s.continuesLeft}
2947
+ class:sv-sched-bar-cont-right={s.continuesRight}
2948
+ style={`left:calc(${(s.startCol / gridCols.length) * 100}% + 3px); width:calc(${((s.endCol - s.startCol + 1) / gridCols.length) * 100}% - 6px); top:${s.lane * 22 + 2}px; height:19px; ${eventStyle(s.event)}`}
2949
+ onpointerdown={(e) => startAllDayDrag(e, s.event)}
2950
+ onclick={() => openEvent(s.event)}
2951
+ oncontextmenu={(e) => openMenu(e, s.event)}
2952
+ onmouseenter={(e) => onEventEnter(e, s.event)}
2953
+ onmouseleave={onEventLeave}
2954
+ title={s.event.title}
2955
+ >
2956
+ {#if canEditBar && !s.continuesLeft}
2957
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
2958
+ <span
2959
+ class="sv-sched-chip-resize sv-sched-chip-resize-start"
2960
+ aria-hidden="true"
2961
+ onpointerdown={(e) => startAllDayResize(e, s.event, "start")}
2962
+ ></span>
2963
+ {/if}
2964
+ {#if scheduler.event}{@render scheduler.event(s.event.row)}{:else}<span class="sv-sched-bar-title">{s.event.title}</span>{/if}
2965
+ {#if canEditBar && !s.continuesRight}
2966
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
2967
+ <span
2968
+ class="sv-sched-chip-resize sv-sched-chip-resize-end"
2969
+ aria-hidden="true"
2970
+ onpointerdown={(e) => startAllDayResize(e, s.event, "end")}
2971
+ ></span>
2972
+ {/if}
2973
+ </button>
2974
+ {/each}
2975
+ </div>
2976
+ {:else}
2977
+ {#each gridCols as col (col.key)}
2978
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
2979
+ <div
2980
+ class="sv-sched-allday-cell"
2981
+ class:sv-sched-select-cell={rangeSelAllDayKeys?.has(col.key)}
2982
+ onpointerdown={(e) => startAllDayRangeSelect(col, e)}
2983
+ oncontextmenu={openRangeMenu}
2984
+ >
2985
+ {#each colAllDay(col) as ev (ev.key)}
2986
+ <button type="button" class="sv-sched-chip" style={eventStyle(ev)} onclick={() => openEvent(ev)} oncontextmenu={(e) => openMenu(e, ev)}>
2987
+ <span class="sv-sched-chip-title">{ev.title}</span>
2988
+ </button>
2989
+ {/each}
2990
+ </div>
2991
+ {/each}
2992
+ {/if}
2993
+ </div>
2994
+ {/if}
2995
+ </div>
2996
+
2997
+ <div class="sv-sched-gridscroll">
2998
+ <div class="sv-sched-gridbody" style={`height:${bandHours * hourPx}px`} bind:this={bodyEl}>
2999
+ {#each secondaryRulers as sr, srIdx (sr.label)}
3000
+ <div class="sv-sched-gutter sv-sched-gutter-secondary" style={`left:${srIdx * 56}px`}>
3001
+ {#each sr.rows as lbl, i (i)}
3002
+ <div class="sv-sched-hour" style={`height:${hourPx}px`}><span>{lbl}</span></div>
3003
+ {/each}
3004
+ </div>
3005
+ {/each}
3006
+ <div class="sv-sched-gutter" style={`left:${secondaryRulers.length * 56}px`}>
3007
+ {#each gridSlots as s (s.min)}
3008
+ <div class="sv-sched-hour" class:sv-sched-hour-minor={!s.startsOnHour} style={`height:${slotPx}px`}><span>{slotRulerLabel(s)}</span></div>
3009
+ {/each}
3010
+ {#if nowBandPct != null}
3011
+ <div class="sv-sched-now-label" style={`top:${nowBandPct}%`}>{fmtTime(now)}</div>
3012
+ {/if}
3013
+ </div>
3014
+ {#each gridCols as col (col.key)}
3015
+ {@const layout = colLayout(col)}
3016
+ {@const isDropCol = drag?.moved === true && drag?.mode === "move" && drag?.previewCol?.key === col.key}
3017
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
3018
+ <div
3019
+ class="sv-sched-col"
3020
+ class:sv-sched-col-drop={isDropCol}
3021
+ data-col-key={col.key}
3022
+ onpointerdown={(e) => startRangeSelect(col, e)}
3023
+ ondblclick={(e) => onSlotDblClick(col, e)}
3024
+ oncontextmenu={openRangeMenu}
3025
+ >
3026
+ {#each gridSlots as s (s.min)}
3027
+ <div class="sv-sched-slot" class:sv-sched-slot-hour={s.endsOnHour} style={`height:${slotPx}px`}></div>
3028
+ {/each}
3029
+ {#if hasBookingShade}
3030
+ {#each columnShadeBands(col) as sb (sb.top)}
3031
+ <div class="sv-sched-shade" style={`top:${sb.top}%;height:${sb.height}%`}></div>
3032
+ {/each}
3033
+ {#each columnRestrictedBands(col) as rb (rb.top)}
3034
+ <div class="sv-sched-shade sv-sched-shade-restricted" style={`top:${rb.top}%;height:${rb.height}%`}></div>
3035
+ {/each}
3036
+ {#if shadeUntilNow && col.today && nowBandPct != null}
3037
+ <div class="sv-sched-shade" style={`top:0;height:${nowBandPct}%`}></div>
3038
+ {/if}
3039
+ {/if}
3040
+ {#if nowBandPct != null && col.today}
3041
+ <div class="sv-sched-nowline" style={`top:${nowBandPct}%`}><span class="sv-sched-now-dot"></span></div>
3042
+ {/if}
3043
+ {#if rangeSelSegs?.has(col.key)}
3044
+ {@const seg = rangeSelSegs.get(col.key)!}
3045
+ <div class="sv-sched-select-mirror" style={`top:${seg.top}%; height:${seg.height}%; left:2px; right:2px`}></div>
3046
+ {/if}
3047
+ {#each layout.events as p (p.event.key)}
3048
+ {@const isDrag = drag?.ev.key === p.event.key && drag?.moved === true}
3049
+ {@const isResize = isDrag && drag!.mode !== "move"}
3050
+ {@const isSource = isDrag && drag!.mode === "move"}
3051
+ {@const top = isResize ? pctTop(drag!.previewStart) : p.topPct}
3052
+ {@const height = isResize ? pctHeight(drag!.previewStart, drag!.previewEnd) : p.heightPct}
3053
+ {@const canEdit = editable && (!p.event.recurring || recurEditable)}
3054
+ <button
3055
+ type="button"
3056
+ class="sv-sched-event"
3057
+ data-status={evStatus(p.event)}
3058
+ class:sv-sched-event-recurring={p.event.recurring}
3059
+ class:sv-sched-event-draggable={canEdit}
3060
+ class:sv-sched-event-dragging={isResize}
3061
+ class:sv-sched-event-source={isSource}
3062
+ class:sv-sched-event-stacked={collisionMode === "stack" && p.zIndex > 1}
3063
+ class:sv-sched-event-selected={isSelected(p.event)}
3064
+ style={`top:${top}%; height:${height}%; left:calc(${p.leftPct}% + 2px); width:calc(${p.widthPct}% - 4px); --z:${p.zIndex}; ${eventStyle(p.event)}`}
3065
+ onpointerdown={(e) => startTimeDrag(e, p.event, col, "move")}
3066
+ onclick={(e) => onEventClick(e, p.event)}
3067
+ oncontextmenu={(e) => openMenu(e, p.event)}
3068
+ onkeydown={(e) => onEventKey(e, p.event)}
3069
+ onmouseenter={(e) => onEventEnter(e, p.event)}
3070
+ onmouseleave={onEventLeave}
3071
+ >
3072
+ {#if canEdit}
3073
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
3074
+ <span
3075
+ class="sv-sched-resize sv-sched-resize-top"
3076
+ aria-hidden="true"
3077
+ onpointerdown={(e) => startTimeDrag(e, p.event, col, "resize-start")}
3078
+ ></span>
3079
+ {/if}
3080
+ {#if scheduler.event}{@render scheduler.event(p.event.row)}{:else}<span class="sv-sched-event-time">{fmtTime(isResize ? drag!.previewStart : p.event.start)}{isResize ? ` - ${fmtTime(drag!.previewEnd)}` : ""}</span><span class="sv-sched-event-title">{p.event.title}</span>{/if}
3081
+ {#if canEdit}
3082
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
3083
+ <span
3084
+ class="sv-sched-resize sv-sched-resize-bottom"
3085
+ aria-hidden="true"
3086
+ onpointerdown={(e) => startTimeDrag(e, p.event, col, "resize-end")}
3087
+ ></span>
3088
+ {/if}
3089
+ </button>
3090
+ {/each}
3091
+ {#if isDropCol && drag && !drag.overAllDay}
3092
+ <!-- Drag preview: the event shown in its DESTINATION column at the
3093
+ drop time, so a cross-column move reads clearly. Hidden while
3094
+ hovering the all-day row (the drop becomes an all-day event). -->
3095
+ <div
3096
+ class="sv-sched-drag-preview"
3097
+ style={`top:${pctTop(drag.previewStart)}%; height:${pctHeight(drag.previewStart, drag.previewEnd)}%; ${eventStyle(drag.ev)}`}
3098
+ >
3099
+ <span class="sv-sched-event-time">{fmtTime(drag.previewStart)} - {fmtTime(drag.previewEnd)}</span>
3100
+ <span class="sv-sched-event-title">{drag.ev.title}</span>
3101
+ </div>
3102
+ {/if}
3103
+ {#if allDayPreview?.kind === "timed" && allDayPreview.colKey === col.key && allDayDrag}
3104
+ <!-- Preview of an all-day bar being dropped into the hourly grid. -->
3105
+ <div
3106
+ class="sv-sched-drag-preview"
3107
+ style={`top:${allDayPreview.topPct}%; height:${allDayPreview.heightPct}%; ${eventStyle(allDayDrag.ev)}`}
3108
+ >
3109
+ <span class="sv-sched-event-time">{allDayPreview.label}</span>
3110
+ <span class="sv-sched-event-title">{allDayDrag.ev.title}</span>
3111
+ </div>
3112
+ {/if}
3113
+ {#each layout.overflows as o, i (col.key + ":ovf:" + i)}
3114
+ <button
3115
+ type="button"
3116
+ class="sv-sched-overflow"
3117
+ style={`top:${o.topPct}%; height:${o.heightPct}%; left:calc(${o.leftPct}% + 2px); width:calc(${o.widthPct}% - 4px);`}
3118
+ onclick={(e) => openList(e, o.events, `${o.count} more events`)}
3119
+ title={`${o.count} more events`}
3120
+ >+{o.count} more</button>
3121
+ {/each}
3122
+ </div>
3123
+ {/each}
3124
+ </div>
3125
+ </div>
3126
+ </div>
3127
+ </div>
3128
+ </div>
3129
+ {/if}
3130
+ </div>
3131
+ </div>
3132
+ </div>
3133
+
3134
+ {#if backlogDrag}
3135
+ <div class="sv-sched-backlog-ghost" use:portalToBody style:position="fixed" style:left={`${backlogDrag.x + 12}px`} style:top={`${backlogDrag.y + 12}px`} class:sv-sched-backlog-ghost-over={backlogDrag.over}>
3136
+ {backlogDrag.item.title}
3137
+ </div>
3138
+ {/if}
3139
+
3140
+ {#if menuOpen}
3141
+ <div
3142
+ bind:this={menuPanel}
3143
+ class="sv-sched-menu"
3144
+ use:portalToBody
3145
+ use:popIn={{}}
3146
+ style:position="fixed"
3147
+ style:top={`${menuPos.y}px`}
3148
+ style:left={`${menuPos.x}px`}
3149
+ aria-label="Event actions"
3150
+ >
3151
+ <SvMenuList items={menuItems} onclose={() => (menuOpen = false)} onselect={() => (menuOpen = false)} />
3152
+ </div>
3153
+ {/if}
3154
+
3155
+ {#if conflictMsg}
3156
+ <div class="sv-sched-conflict" use:portalToBody role="status">{conflictMsg}</div>
3157
+ {/if}
3158
+ {#if reminders.length}
3159
+ <div class="sv-sched-reminders" use:portalToBody role="status" aria-live="polite">
3160
+ {#each reminders as r (r.id)}
3161
+ <div class="sv-sched-reminder"><span class="sv-sched-reminder-bell" aria-hidden="true">🔔</span>{r.text}</div>
3162
+ {/each}
3163
+ </div>
3164
+ {/if}
3165
+
3166
+ {#if tipEv}
3167
+ <div
3168
+ class="sv-sched-tooltip"
3169
+ use:portalToBody
3170
+ role="tooltip"
3171
+ style:position="fixed"
3172
+ style:left={`${tipPos.x}px`}
3173
+ style:top={`${tipPos.y}px`}
3174
+ >
3175
+ {#if tooltipSnippet}
3176
+ {@render tooltipSnippet(tipEv.row)}
3177
+ {:else}
3178
+ <div class="sv-sched-tooltip-title">{tipEv.title}</div>
3179
+ <div class="sv-sched-tooltip-meta">{tipEv.allDay ? "All day" : `${fmtTime(tipEv.start)} - ${fmtTime(tipEv.end)}`}</div>
3180
+ {#if tipEv.resourceId}<div class="sv-sched-tooltip-meta">{resourceTitle(tipEv.resourceId)}</div>{/if}
3181
+ {/if}
3182
+ </div>
3183
+ {/if}
3184
+
3185
+ {#if recurScope}
3186
+ <div
3187
+ bind:this={scopePanel}
3188
+ class="sv-sched-scope"
3189
+ use:portalToBody
3190
+ use:popIn={{}}
3191
+ style:position="fixed"
3192
+ style:top={`${recurScope.y}px`}
3193
+ style:left={`${recurScope.x}px`}
3194
+ role="dialog"
3195
+ aria-label={recurScope.kind === "delete" ? "Delete recurring event" : "Edit recurring event"}
3196
+ >
3197
+ <div class="sv-sched-scope-title">{recurScope.kind === "delete" ? "Delete recurring event" : "Edit recurring event"}</div>
3198
+ <button type="button" class="sv-sched-scope-btn" onclick={() => { recurScope?.occurrence(); recurScope = null; }}>This event</button>
3199
+ {#if recurScope.following}
3200
+ <button type="button" class="sv-sched-scope-btn" onclick={() => { recurScope?.following?.(); recurScope = null; }}>This and following</button>
3201
+ {/if}
3202
+ <button type="button" class="sv-sched-scope-btn" onclick={() => { recurScope?.series(); recurScope = null; }}>All events</button>
3203
+ </div>
3204
+ {/if}
3205
+
3206
+ {#if listOpen}
3207
+ <div
3208
+ bind:this={listPanel}
3209
+ class="sv-sched-listpop"
3210
+ use:portalToBody
3211
+ use:popIn={{}}
3212
+ style:position="fixed"
3213
+ style:top={`${listPos.y}px`}
3214
+ style:left={`${listPos.x}px`}
3215
+ role="dialog"
3216
+ aria-label={listTitle}
3217
+ >
3218
+ <div class="sv-sched-listpop-head">{listTitle}</div>
3219
+ <div class="sv-sched-listpop-body">
3220
+ {#each listEvents as ev (ev.key)}
3221
+ <button
3222
+ type="button"
3223
+ class="sv-sched-listpop-item"
3224
+ style={eventStyle(ev)}
3225
+ onclick={() => pickFromList(ev)}
3226
+ >
3227
+ <span class="sv-sched-dot"></span>
3228
+ <span class="sv-sched-listpop-time">
3229
+ {ev.allDay ? "all day" : `${fmtTime(ev.start)} - ${fmtTime(ev.end)}`}
3230
+ </span>
3231
+ <span class="sv-sched-listpop-title">{ev.title}</span>
3232
+ </button>
3233
+ {/each}
3234
+ </div>
3235
+ </div>
3236
+ {/if}
3237
+
3238
+ {#if monthDrag?.moved}
3239
+ <!-- Cursor-following ghost so a month drag reads clearly. -->
3240
+ <div
3241
+ class="sv-sched-month-ghost"
3242
+ use:portalToBody
3243
+ style={`position:fixed; left:${monthDragPos.x + 12}px; top:${monthDragPos.y + 12}px; ${eventStyle(monthDrag.ev)}`}
3244
+ >
3245
+ <span class="sv-sched-dot"></span>
3246
+ <span class="sv-sched-month-ghost-title">{monthDrag.ev.title}</span>
3247
+ </div>
3248
+ {/if}
3249
+
3250
+ {#if allDayDrag?.moved}
3251
+ <!-- Cursor-following ghost + a hint of the destination while dragging an
3252
+ all-day bar (to a time slot, or another day). -->
3253
+ <div
3254
+ class="sv-sched-month-ghost"
3255
+ use:portalToBody
3256
+ style={`position:fixed; left:${allDayDrag.x + 12}px; top:${allDayDrag.y + 12}px; ${eventStyle(allDayDrag.ev)}`}
3257
+ >
3258
+ <span class="sv-sched-dot"></span>
3259
+ <span class="sv-sched-month-ghost-title">
3260
+ {allDayDrag.ev.title}{allDayPreview?.kind === "timed" ? ` -> ${allDayPreview.label}` : ""}
3261
+ </span>
3262
+ </div>
3263
+ {/if}
3264
+
3265
+ {#if drag?.moved && drag.mode === "move"}
3266
+ <!-- Cursor-following ghost for a time-grid MOVE, so the feedback matches the
3267
+ all-day drag: dimmed source + destination preview + this pointer pill. -->
3268
+ <div
3269
+ class="sv-sched-month-ghost"
3270
+ use:portalToBody
3271
+ style={`position:fixed; left:${drag.x + 12}px; top:${drag.y + 12}px; ${eventStyle(drag.ev)}`}
3272
+ >
3273
+ <span class="sv-sched-dot"></span>
3274
+ <span class="sv-sched-month-ghost-title">
3275
+ {drag.ev.title}{drag.overAllDay ? " -> all-day" : ` -> ${fmtTime(drag.previewStart)}`}
3276
+ </span>
3277
+ </div>
3278
+ {/if}
3279
+
3280
+ <SvDrawer
3281
+ bind:open={drawerOpen}
3282
+ title={drawerTitle}
3283
+ side={drawerCfg?.side ?? "right"}
3284
+ size={drawerCfg?.size ?? "380px"}
3285
+ hideClose
3286
+ onClose={commitDrawer}
3287
+ onClosed={() => (drawerRow = null)}
3288
+ >
3289
+ <div class="sv-sched-when">
3290
+ {#if whenHasAllDay}
3291
+ <div class="sv-sched-when-check">
3292
+ <SvCheckBox checked={whenAllDay} onChange={setWhenAllDay}>All day</SvCheckBox>
3293
+ </div>
3294
+ {/if}
3295
+ <div class="sv-sched-when-row">
3296
+ <span class="sv-sched-when-label">Start</span>
3297
+ <div class="sv-sched-when-input">
3298
+ <SvDateTimePicker
3299
+ value={whenStart}
3300
+ onChange={(d) => (whenStart = d)}
3301
+ dropDownDisplayMode={whenAllDay ? "calendar" : "both"}
3302
+ formatString={whenAllDay ? "yyyy-MM-dd" : "yyyy-MM-dd HH:mm"}
3303
+ />
3304
+ </div>
3305
+ </div>
3306
+ <div class="sv-sched-when-row">
3307
+ <span class="sv-sched-when-label">End</span>
3308
+ <div class="sv-sched-when-input">
3309
+ <SvDateTimePicker
3310
+ value={whenEnd}
3311
+ onChange={(d) => (whenEnd = d)}
3312
+ dropDownDisplayMode={whenAllDay ? "calendar" : "both"}
3313
+ formatString={whenAllDay ? "yyyy-MM-dd" : "yyyy-MM-dd HH:mm"}
3314
+ />
3315
+ </div>
3316
+ </div>
3317
+ </div>
3318
+ {#if recurEditable}
3319
+ <div class="sv-sched-recur">
3320
+ <div class="sv-sched-recur-row">
3321
+ <span class="sv-sched-recur-label">Repeat</span>
3322
+ <div class="sv-sched-recur-select sv-sched-recur-repeat">
3323
+ <SvDropDownList
3324
+ options={REPEAT_OPTIONS}
3325
+ value={recFreq}
3326
+ onChange={(v) => (recFreq = v as RecFreq)}
3327
+ />
3328
+ </div>
3329
+ </div>
3330
+ {#if recFreq}
3331
+ <div class="sv-sched-recur-row">
3332
+ <span class="sv-sched-recur-label">Every</span>
3333
+ <div class="sv-sched-recur-num">
3334
+ <SvNumberInput bind:value={recInterval} min={1} step={1} />
3335
+ </div>
3336
+ <span class="sv-sched-recur-unit">
3337
+ {recFreq === "daily" ? "day(s)" : recFreq === "weekly" ? "week(s)" : recFreq === "monthly" ? "month(s)" : "year(s)"}
3338
+ </span>
3339
+ </div>
3340
+ {#if recFreq === "weekly"}
3341
+ <div class="sv-sched-recur-row">
3342
+ <span class="sv-sched-recur-label">On</span>
3343
+ <div class="sv-sched-recur-days">
3344
+ {#each headerOrder as d (d)}
3345
+ <button type="button" class="sv-sched-recur-day" class:sv-sched-recur-day-on={recWeekdays.has(d)} onclick={() => toggleWeekday(d)}>
3346
+ {wd(d).charAt(0)}
3347
+ </button>
3348
+ {/each}
3349
+ </div>
3350
+ </div>
3351
+ {/if}
3352
+ {#if recFreq === "monthly"}
3353
+ <div class="sv-sched-recur-row">
3354
+ <span class="sv-sched-recur-label">On</span>
3355
+ <div class="sv-sched-recur-select sv-sched-recur-monthmode">
3356
+ <SvDropDownList options={MONTH_MODE_OPTIONS} value={recMonthMode} onChange={(v) => (recMonthMode = v as MonthMode)} />
3357
+ </div>
3358
+ </div>
3359
+ {#if recMonthMode === "day"}
3360
+ <div class="sv-sched-recur-row">
3361
+ <span class="sv-sched-recur-label">Day</span>
3362
+ <div class="sv-sched-recur-num">
3363
+ <SvNumberInput bind:value={recDay} min={1} max={31} step={1} />
3364
+ </div>
3365
+ </div>
3366
+ {:else if recMonthMode === "weekday"}
3367
+ <div class="sv-sched-recur-row">
3368
+ <span class="sv-sched-recur-label">The</span>
3369
+ <div class="sv-sched-recur-select">
3370
+ <SvDropDownList options={WEEK_OF_MONTH_OPTIONS} value={recWeekOfMonth} onChange={(v) => (recWeekOfMonth = v as number)} />
3371
+ </div>
3372
+ <div class="sv-sched-recur-select">
3373
+ <SvDropDownList options={WEEKDAY_OPTIONS} value={recPosWeekday} onChange={(v) => (recPosWeekday = v as number)} />
3374
+ </div>
3375
+ </div>
3376
+ {/if}
3377
+ {/if}
3378
+ {#if recFreq === "yearly"}
3379
+ <div class="sv-sched-recur-row">
3380
+ <span class="sv-sched-recur-label">In</span>
3381
+ <div class="sv-sched-recur-select">
3382
+ <SvDropDownList options={MONTH_OPTIONS} value={recMonth} onChange={(v) => (recMonth = v as number)} />
3383
+ </div>
3384
+ <div class="sv-sched-recur-select">
3385
+ <SvDropDownList options={YEAR_MODE_OPTIONS} value={recYearMode} onChange={(v) => (recYearMode = v as YearMode)} />
3386
+ </div>
3387
+ </div>
3388
+ {#if recYearMode === "day"}
3389
+ <div class="sv-sched-recur-row">
3390
+ <span class="sv-sched-recur-label">Day</span>
3391
+ <div class="sv-sched-recur-num">
3392
+ <SvNumberInput bind:value={recDay} min={1} max={31} step={1} />
3393
+ </div>
3394
+ </div>
3395
+ {:else}
3396
+ <div class="sv-sched-recur-row">
3397
+ <span class="sv-sched-recur-label">The</span>
3398
+ <div class="sv-sched-recur-select">
3399
+ <SvDropDownList options={WEEK_OF_MONTH_OPTIONS} value={recWeekOfMonth} onChange={(v) => (recWeekOfMonth = v as number)} />
3400
+ </div>
3401
+ <div class="sv-sched-recur-select">
3402
+ <SvDropDownList options={WEEKDAY_OPTIONS} value={recPosWeekday} onChange={(v) => (recPosWeekday = v as number)} />
3403
+ </div>
3404
+ </div>
3405
+ {/if}
3406
+ {/if}
3407
+ <div class="sv-sched-recur-row">
3408
+ <span class="sv-sched-recur-label">Ends</span>
3409
+ <div class="sv-sched-recur-select sv-sched-recur-end">
3410
+ <SvDropDownList options={END_OPTIONS} value={recEnd} onChange={(v) => (recEnd = v as RecEnd)} />
3411
+ </div>
3412
+ </div>
3413
+ {#if recEnd === "until"}
3414
+ <div class="sv-sched-recur-row">
3415
+ <span class="sv-sched-recur-label">On</span>
3416
+ <div class="sv-sched-recur-date">
3417
+ <SvDateTimePicker
3418
+ value={recUntil}
3419
+ onChange={(d) => (recUntil = d)}
3420
+ dropDownDisplayMode="calendar"
3421
+ formatString="yyyy-MM-dd"
3422
+ nullable
3423
+ />
3424
+ </div>
3425
+ </div>
3426
+ {:else if recEnd === "count"}
3427
+ <div class="sv-sched-recur-row">
3428
+ <span class="sv-sched-recur-label">After</span>
3429
+ <div class="sv-sched-recur-num sv-sched-recur-count">
3430
+ <SvNumberInput bind:value={recCount} min={1} step={1} />
3431
+ </div>
3432
+ <span class="sv-sched-recur-unit">occurrence(s)</span>
3433
+ </div>
3434
+ {/if}
3435
+ {/if}
3436
+ </div>
3437
+ {/if}
3438
+ <SvForm
3439
+ fields={drawerFields}
3440
+ initial={drawerInitial}
3441
+ columns={drawerCfg?.columns ?? 1}
3442
+ submitLabel={drawerCfg?.submitLabel ?? "Save"}
3443
+ cancelLabel="Cancel"
3444
+ onSubmit={saveDrawer}
3445
+ onCancel={cancelDrawer}
3446
+ onChange={(v) => (drawerValues = v)}
3447
+ />
3448
+ {#if scheduler.onEventDelete}
3449
+ <button type="button" class="sv-sched-delete" onclick={deleteDrawer}>
3450
+ Delete event
3451
+ </button>
3452
+ {/if}
3453
+ </SvDrawer>
3454
+
3455
+ <style>
3456
+ .sv-sched {
3457
+ --sv-sched-accent: var(--sg-accent, #4f46e5);
3458
+ display: flex;
3459
+ flex-direction: column;
3460
+ height: 100%;
3461
+ min-height: 0;
3462
+ font: inherit;
3463
+ color: var(--sg-fg, #1f2937);
3464
+ background: var(--sg-bg, #fff);
3465
+ border: 1px solid var(--sg-border, #e5e7eb);
3466
+ border-radius: var(--sg-radius, 8px);
3467
+ overflow: hidden;
3468
+ /* Dragging to select cells must not sweep-select the calendar's text
3469
+ (day numbers, event titles). Editors/menus are portalled out, so this
3470
+ does not affect the drawer inputs. */
3471
+ -webkit-user-select: none;
3472
+ user-select: none;
3473
+ }
3474
+
3475
+ /* toolbar */
3476
+ .sv-sched-toolbar {
3477
+ display: flex;
3478
+ align-items: center;
3479
+ gap: 12px;
3480
+ padding: 8px 10px;
3481
+ border-bottom: 1px solid var(--sg-border, #e5e7eb);
3482
+ flex: 0 0 auto;
3483
+ }
3484
+ .sv-sched-nav { display: flex; gap: 4px; }
3485
+ .sv-sched-title { flex: 1 1 auto; font-weight: 600; font-size: 0.95rem; }
3486
+ .sv-sched-views { display: flex; gap: 4px; align-items: center; }
3487
+ .sv-sched-slots { display: inline-flex; gap: 2px; margin-right: 8px; padding-right: 8px; border-right: 1px solid var(--sg-border, #e5e7eb); }
3488
+ .sv-sched-btn-slot { min-width: 34px; padding-left: 7px; padding-right: 7px; }
3489
+ /* Resource legend / filter (shown in every view when resourceField is set). */
3490
+ .sv-sched-reslegend {
3491
+ display: flex;
3492
+ flex-wrap: wrap;
3493
+ gap: 6px;
3494
+ padding: 6px 10px;
3495
+ border-bottom: 1px solid var(--sg-border, #e5e7eb);
3496
+ flex: 0 0 auto;
3497
+ }
3498
+ .sv-sched-reschip {
3499
+ display: inline-flex;
3500
+ align-items: center;
3501
+ gap: 6px;
3502
+ border: 1px solid var(--sg-border, #e5e7eb);
3503
+ background: var(--sg-bg, #fff);
3504
+ color: inherit;
3505
+ border-radius: 999px;
3506
+ padding: 2px 10px 2px 8px;
3507
+ font: inherit;
3508
+ font-size: 0.8rem;
3509
+ cursor: pointer;
3510
+ line-height: 1.5;
3511
+ }
3512
+ .sv-sched-reschip:hover { background: color-mix(in srgb, var(--sg-fg, #1f2937) 6%, transparent); }
3513
+ .sv-sched-reschip-off { opacity: 0.45; text-decoration: line-through; }
3514
+ .sv-sched-btn {
3515
+ appearance: none;
3516
+ border: 1px solid var(--sg-border, #e5e7eb);
3517
+ background: var(--sg-bg, #fff);
3518
+ color: inherit;
3519
+ border-radius: 6px;
3520
+ padding: 4px 10px;
3521
+ font: inherit;
3522
+ font-size: 0.85rem;
3523
+ cursor: pointer;
3524
+ line-height: 1.4;
3525
+ }
3526
+ .sv-sched-btn:hover { background: color-mix(in srgb, var(--sg-fg, #1f2937) 8%, transparent); }
3527
+ .sv-sched-btn-active {
3528
+ background: var(--sv-sched-accent);
3529
+ border-color: var(--sv-sched-accent);
3530
+ color: #fff;
3531
+ }
3532
+
3533
+ /* month */
3534
+ .sv-sched-month { display: flex; flex-direction: column; flex: 1 1 auto; min-height: 0; }
3535
+ .sv-sched-monthhead { display: grid; grid-template-columns: repeat(7, 1fr); flex: 0 0 auto; }
3536
+ .sv-sched-dow {
3537
+ padding: 6px 8px;
3538
+ font-size: 0.72rem;
3539
+ text-transform: uppercase;
3540
+ letter-spacing: 0.04em;
3541
+ color: var(--sg-muted, #6b7280);
3542
+ border-bottom: 1px solid var(--sg-border, #e5e7eb);
3543
+ text-align: right;
3544
+ }
3545
+ .sv-sched-monthbody { display: flex; flex-direction: column; flex: 1 1 auto; min-height: 0; }
3546
+ .sv-sched-week { position: relative; display: grid; grid-template-columns: repeat(7, 1fr); flex: 1 1 0; min-height: 84px; }
3547
+ .sv-sched-daycell {
3548
+ position: relative;
3549
+ border-right: 1px solid var(--sg-border, #e5e7eb);
3550
+ border-bottom: 1px solid var(--sg-border, #e5e7eb);
3551
+ padding: 2px 3px 4px;
3552
+ overflow: hidden;
3553
+ min-width: 0;
3554
+ }
3555
+ .sv-sched-daycell-out { background: color-mix(in srgb, var(--sg-fg, #1f2937) 5%, transparent); color: var(--sg-muted, #9ca3af); }
3556
+ .sv-sched-daynum { font-size: 0.78rem; text-align: right; padding: 1px 3px; }
3557
+ .sv-sched-today .sv-sched-daynum {
3558
+ float: right;
3559
+ background: var(--sv-sched-accent);
3560
+ color: #fff;
3561
+ border-radius: 999px;
3562
+ min-width: 1.4em;
3563
+ text-align: center;
3564
+ }
3565
+ .sv-sched-colhead.sv-sched-today .sv-sched-colhead-sub {
3566
+ display: inline-block;
3567
+ background: var(--sv-sched-accent);
3568
+ color: #fff;
3569
+ border-radius: 999px;
3570
+ min-width: 1.4em;
3571
+ text-align: center;
3572
+ }
3573
+ .sv-sched-more {
3574
+ position: absolute;
3575
+ left: 4px;
3576
+ border: none;
3577
+ background: none;
3578
+ color: var(--sg-muted, #6b7280);
3579
+ font: inherit;
3580
+ font-size: 0.72rem;
3581
+ padding: 0 3px;
3582
+ cursor: pointer;
3583
+ border-radius: 4px;
3584
+ z-index: 3;
3585
+ }
3586
+ .sv-sched-more:hover {
3587
+ color: var(--sv-sched-accent);
3588
+ background: color-mix(in srgb, var(--sv-sched-accent) 12%, transparent);
3589
+ }
3590
+
3591
+ /* Month spanning bars: one continuous element per event per week, laid over
3592
+ the day-cell grid and stacked into lanes. */
3593
+ .sv-sched-weekbars { position: absolute; inset: 0; pointer-events: none; }
3594
+ .sv-sched-weekbars-dragging .sv-sched-bar { pointer-events: none; }
3595
+ .sv-sched-bar {
3596
+ position: absolute;
3597
+ box-sizing: border-box;
3598
+ pointer-events: auto;
3599
+ display: flex;
3600
+ align-items: center;
3601
+ gap: 4px;
3602
+ text-align: left;
3603
+ border: 1px solid color-mix(in srgb, var(--sv-sched-accent) 45%, transparent);
3604
+ border-left: 4px solid var(--sv-sched-accent2, var(--sv-sched-accent));
3605
+ background: color-mix(in srgb, var(--sv-sched-accent) 18%, var(--sg-bg, #fff));
3606
+ color: inherit;
3607
+ border-radius: 4px;
3608
+ padding: 0 5px;
3609
+ font: inherit;
3610
+ font-size: 0.72rem;
3611
+ line-height: 1.2;
3612
+ cursor: pointer;
3613
+ overflow: hidden;
3614
+ white-space: nowrap;
3615
+ }
3616
+ .sv-sched-bar:hover { background: color-mix(in srgb, var(--sv-sched-accent) 30%, var(--sg-bg, #fff)); z-index: 4; }
3617
+ .sv-sched-bar-draggable { cursor: grab; }
3618
+ .sv-sched-bar-draggable:active { cursor: grabbing; }
3619
+ .sv-sched-bar-source { opacity: 0.32; }
3620
+ .sv-sched-bar-recurring { border-left-style: double; border-left-width: 4px; }
3621
+ /* Continuation edges (event spills into the previous / next week) go flat. */
3622
+ .sv-sched-bar-cont-left { border-top-left-radius: 0; border-bottom-left-radius: 0; border-left-width: 0; padding-left: 6px; }
3623
+ .sv-sched-bar-cont-right { border-top-right-radius: 0; border-bottom-right-radius: 0; }
3624
+ .sv-sched-bar-time { color: color-mix(in srgb, var(--sg-fg, #1f2937) 60%, transparent); font-variant-numeric: tabular-nums; font-size: 0.68rem; }
3625
+ .sv-sched-bar-title { overflow: hidden; text-overflow: ellipsis; font-weight: 500; }
3626
+ /* Month resize grips: drag a bar's left/right edge across days to change the
3627
+ event's start / end date. Revealed on hover, like the time-grid grips. */
3628
+ .sv-sched-chip-resize {
3629
+ position: absolute;
3630
+ top: 0;
3631
+ bottom: 0;
3632
+ width: 7px;
3633
+ cursor: ew-resize;
3634
+ opacity: 0;
3635
+ z-index: 2;
3636
+ }
3637
+ .sv-sched-chip-resize-start { left: 0; }
3638
+ .sv-sched-chip-resize-end { right: 0; }
3639
+ .sv-sched-chip-resize::after {
3640
+ content: "";
3641
+ position: absolute;
3642
+ top: 2px;
3643
+ bottom: 2px;
3644
+ width: 2px;
3645
+ border-radius: 2px;
3646
+ background: var(--sv-sched-accent);
3647
+ }
3648
+ .sv-sched-chip-resize-start::after { left: 1px; }
3649
+ .sv-sched-chip-resize-end::after { right: 1px; }
3650
+ .sv-sched-bar:hover .sv-sched-chip-resize { opacity: 0.9; }
3651
+ /* Month drag feedback: target day highlights, ghost follows the cursor. */
3652
+ .sv-sched-daycell-drop {
3653
+ background: color-mix(in srgb, var(--sg-accent, #4f46e5) 12%, transparent) !important;
3654
+ box-shadow: inset 0 0 0 2px var(--sg-accent, #4f46e5);
3655
+ }
3656
+ .sv-sched-month-ghost {
3657
+ z-index: 70;
3658
+ pointer-events: none;
3659
+ display: flex;
3660
+ align-items: center;
3661
+ gap: 5px;
3662
+ max-width: 220px;
3663
+ padding: 3px 8px;
3664
+ border-radius: 5px;
3665
+ border: 1px solid var(--sv-sched-accent);
3666
+ background: color-mix(in srgb, var(--sv-sched-accent) 26%, var(--sg-bg, #fff));
3667
+ color: var(--sg-fg, #1f2937);
3668
+ font-size: 0.75rem;
3669
+ font-weight: 500;
3670
+ box-shadow: 0 8px 20px rgba(0, 0, 0, 0.28);
3671
+ white-space: nowrap;
3672
+ }
3673
+ .sv-sched-month-ghost-title { overflow: hidden; text-overflow: ellipsis; }
3674
+ .sv-sched-dot {
3675
+ flex: 0 0 auto;
3676
+ width: 7px; height: 7px;
3677
+ border-radius: 50%;
3678
+ background: var(--sv-sched-accent);
3679
+ }
3680
+ /* all-day row chip (time-grid week/day). */
3681
+ .sv-sched-chip {
3682
+ display: flex;
3683
+ align-items: center;
3684
+ gap: 4px;
3685
+ width: 100%;
3686
+ text-align: left;
3687
+ border: none;
3688
+ background: color-mix(in srgb, var(--sv-sched-accent) 16%, transparent);
3689
+ color: inherit;
3690
+ border-radius: 4px;
3691
+ padding: 1px 6px;
3692
+ font: inherit;
3693
+ font-size: 0.72rem;
3694
+ line-height: 1.3;
3695
+ cursor: pointer;
3696
+ overflow: hidden;
3697
+ white-space: nowrap;
3698
+ }
3699
+ .sv-sched-chip:hover { background: color-mix(in srgb, var(--sv-sched-accent) 26%, transparent); }
3700
+ .sv-sched-chip-title { overflow: hidden; text-overflow: ellipsis; }
3701
+
3702
+ /* time-grid */
3703
+ .sv-sched-grid { display: flex; flex-direction: column; flex: 1 1 auto; min-height: 0; }
3704
+ /* Single scroll viewport for BOTH axes. Being viewport-width, its vertical
3705
+ scrollbar sits at the viewport's right edge (always reachable) and the
3706
+ horizontal one at the bottom - instead of the vertical bar being stranded at
3707
+ the far-right edge of the wide, horizontally-scrolled column area. The header
3708
+ stays put via position:sticky (top) and the time gutter via sticky (left). */
3709
+ .sv-sched-xscroll { flex: 1 1 auto; min-height: 0; overflow: auto; }
3710
+ .sv-sched-xinner { display: flex; flex-direction: column; width: 100%; }
3711
+ /* Header block pinned to the top of the scroller; scrolls horizontally with the
3712
+ columns, stays put vertically. */
3713
+ .sv-sched-headwrap { position: sticky; top: 0; z-index: 56; background: var(--sg-bg, #fff); }
3714
+ .sv-sched-grouphead, .sv-sched-gridhead, .sv-sched-allday {
3715
+ display: grid;
3716
+ grid-template-columns: var(--gutw, 56px) repeat(var(--cols, 7), 1fr);
3717
+ flex: 0 0 auto;
3718
+ border-bottom: 1px solid var(--sg-border, #e5e7eb);
3719
+ box-sizing: border-box;
3720
+ }
3721
+ /* Corner cell(s): pinned both ways (inside the sticky header, sticky to the left). */
3722
+ .sv-sched-gutter-head { position: sticky; left: 0; z-index: 1; background: var(--sg-bg, #fff); }
3723
+ /* Resource / date group header row (spanning cells above the columns). */
3724
+ .sv-sched-groupcell {
3725
+ display: flex;
3726
+ align-items: center;
3727
+ justify-content: center;
3728
+ gap: 5px;
3729
+ padding: 4px 8px;
3730
+ font-size: 0.78rem;
3731
+ font-weight: 600;
3732
+ border-left: 1px solid var(--sg-border, #e5e7eb);
3733
+ background: color-mix(in srgb, var(--sg-fg, #1f2937) 4%, transparent);
3734
+ overflow: hidden;
3735
+ white-space: nowrap;
3736
+ }
3737
+ .sv-sched-group-label { overflow: hidden; text-overflow: ellipsis; }
3738
+ .sv-sched-colhead {
3739
+ padding: 6px 8px;
3740
+ text-align: center;
3741
+ font-size: 0.8rem;
3742
+ border-left: 1px solid var(--sg-border, #e5e7eb);
3743
+ display: flex;
3744
+ flex-direction: column;
3745
+ align-items: center;
3746
+ gap: 1px;
3747
+ }
3748
+ .sv-sched-colhead-label { color: var(--sg-muted, #6b7280); font-size: 0.7rem; text-transform: uppercase; }
3749
+ .sv-sched-colhead-sub { font-weight: 600; font-size: 0.95rem; padding: 0 6px; }
3750
+ .sv-sched-gutter-head { font-size: 0.68rem; color: var(--sg-muted, #9ca3af); }
3751
+ .sv-sched-allday-label { display: flex; align-items: center; justify-content: flex-end; padding-right: 6px; }
3752
+ .sv-sched-allday-cell { border-left: 1px solid var(--sg-border, #e5e7eb); padding: 2px; display: flex; flex-direction: column; gap: 2px; min-width: 0; }
3753
+ .sv-sched-allday { position: relative; min-height: 28px; }
3754
+ /* Background cells that carry the day column separator lines under the bars. */
3755
+ .sv-sched-allday-colbg { border-left: 1px solid var(--sg-border, #e5e7eb); min-width: 0; }
3756
+ /* Bars overlay: sits over the day columns (after the 56px gutter). Its right
3757
+ edge matches the padded track area so bars align with the scrolled body. */
3758
+ /* pointer-events:none lets a drag on empty all-day area reach the colbg cells
3759
+ underneath (for range-select); the bars themselves stay interactive. */
3760
+ .sv-sched-allday-bars { position: absolute; left: var(--gutw, 56px); top: 0; right: 0; bottom: 0; pointer-events: none; }
3761
+ .sv-sched-allday-colbg, .sv-sched-allday-cell { cursor: default; }
3762
+ .sv-sched-select-cell { background: color-mix(in srgb, var(--sv-sched-accent, #4f46e5) 22%, transparent) !important; }
3763
+
3764
+ /* No longer a scroll container - both axes scroll on .sv-sched-xscroll; this is
3765
+ just the body wrapper (the gridbody carries its own explicit height). */
3766
+ .sv-sched-gridscroll { min-height: 0; }
3767
+ .sv-sched-gridbody {
3768
+ display: grid;
3769
+ grid-template-columns: repeat(var(--gutn, 1), 56px) repeat(var(--cols, 7), 1fr);
3770
+ position: relative;
3771
+ }
3772
+ /* Time gutter pinned to the left of the scroller (its `left` offset is set
3773
+ inline so stacked secondary rulers step across). z-index above the events so
3774
+ day columns scroll cleanly underneath; sticky still forms a containing block
3775
+ for the absolute now-label. */
3776
+ .sv-sched-gutter { display: flex; flex-direction: column; position: sticky; z-index: 55; background: var(--sg-bg, #fff); }
3777
+ .sv-sched-gutter-secondary { opacity: 0.72; }
3778
+ .sv-sched-gutter-secondary .sv-sched-hour span { color: var(--sg-muted, #9ca3af); }
3779
+ /* Zone-abbreviation labels in the head corner, aligned over each gutter column. */
3780
+ .sv-sched-zonehead { display: flex; align-items: flex-end; padding-bottom: 3px; }
3781
+ .sv-sched-zonelabel {
3782
+ width: 56px;
3783
+ flex: 0 0 56px;
3784
+ text-align: right;
3785
+ padding-right: 6px;
3786
+ font-size: 0.6rem;
3787
+ font-weight: 600;
3788
+ text-transform: uppercase;
3789
+ color: var(--sg-muted, #9ca3af);
3790
+ box-sizing: border-box;
3791
+ }
3792
+ .sv-sched-zonelabel-primary { color: var(--sg-fg, #1f2937); }
3793
+
3794
+ /* ---- current-time ("now") indicator ---- */
3795
+ .sv-sched-nowline {
3796
+ position: absolute;
3797
+ left: 0;
3798
+ right: 0;
3799
+ height: 0;
3800
+ border-top: 2px solid var(--sv-sched-now, #ef4444);
3801
+ z-index: 6;
3802
+ pointer-events: none;
3803
+ }
3804
+ .sv-sched-now-dot {
3805
+ position: absolute;
3806
+ left: -3px;
3807
+ top: -4px;
3808
+ width: 8px;
3809
+ height: 8px;
3810
+ border-radius: 50%;
3811
+ background: var(--sv-sched-now, #ef4444);
3812
+ }
3813
+ .sv-sched-now-label {
3814
+ position: absolute;
3815
+ right: 3px;
3816
+ transform: translateY(-50%);
3817
+ padding: 0 4px;
3818
+ border-radius: 3px;
3819
+ font-size: 0.62rem;
3820
+ font-weight: 600;
3821
+ line-height: 1.35;
3822
+ color: #fff;
3823
+ background: var(--sv-sched-now, #ef4444);
3824
+ white-space: nowrap;
3825
+ z-index: 7;
3826
+ pointer-events: none;
3827
+ }
3828
+ .sv-sched-tl-nowline {
3829
+ position: absolute;
3830
+ top: 0;
3831
+ bottom: 0;
3832
+ width: 0;
3833
+ border-left: 2px solid var(--sv-sched-now, #ef4444);
3834
+ z-index: 6;
3835
+ pointer-events: none;
3836
+ }
3837
+
3838
+ /* ---- booking rules: non-working / out-of-hours shading + conflict flash ---- */
3839
+ .sv-sched-shade {
3840
+ position: absolute;
3841
+ left: 0;
3842
+ right: 0;
3843
+ background: repeating-linear-gradient(
3844
+ -45deg,
3845
+ color-mix(in srgb, var(--sg-fg, #1f2937) 6%, transparent),
3846
+ color-mix(in srgb, var(--sg-fg, #1f2937) 6%, transparent) 6px,
3847
+ transparent 6px,
3848
+ transparent 12px
3849
+ );
3850
+ pointer-events: none;
3851
+ z-index: 0;
3852
+ }
3853
+ /* Hard-restricted bands read stronger (red-tinted hatch) than plain off-hours. */
3854
+ .sv-sched-shade-restricted {
3855
+ background: repeating-linear-gradient(
3856
+ -45deg,
3857
+ color-mix(in srgb, var(--sv-sched-now, #ef4444) 16%, transparent),
3858
+ color-mix(in srgb, var(--sv-sched-now, #ef4444) 16%, transparent) 6px,
3859
+ transparent 6px,
3860
+ transparent 12px
3861
+ );
3862
+ }
3863
+ .sv-sched-colhead-special { box-shadow: inset 0 -2px 0 0 var(--sv-sched-special, var(--sg-accent, #4f46e5)); }
3864
+ .sv-sched-special-tag {
3865
+ display: inline-block; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
3866
+ font-size: 0.62rem; font-weight: 600; line-height: 1.3; padding: 0 5px; border-radius: 999px;
3867
+ color: var(--sv-sched-special, var(--sg-accent, #4f46e5));
3868
+ background: color-mix(in srgb, var(--sv-sched-special, var(--sg-accent, #4f46e5)) 16%, transparent);
3869
+ }
3870
+ .sv-sched-btn:disabled { opacity: 0.4; cursor: default; }
3871
+ /* Free/busy status treatments (Outlook-style). [data-status] beats the base. */
3872
+ .sv-sched-event[data-status="free"], .sv-sched-bar[data-status="free"], .sv-sched-agenda-row[data-status="free"] {
3873
+ background: transparent; color: var(--sv-sched-accent); border-style: dashed;
3874
+ }
3875
+ .sv-sched-event[data-status="tentative"], .sv-sched-bar[data-status="tentative"], .sv-sched-agenda-row[data-status="tentative"] {
3876
+ background-image: repeating-linear-gradient(-45deg,
3877
+ color-mix(in srgb, var(--sv-sched-accent) 42%, transparent),
3878
+ color-mix(in srgb, var(--sv-sched-accent) 42%, transparent) 3px,
3879
+ transparent 3px, transparent 7px);
3880
+ }
3881
+ .sv-sched-event[data-status="oof"], .sv-sched-bar[data-status="oof"], .sv-sched-agenda-row[data-status="oof"] {
3882
+ border-left-width: 5px; border-left-color: #7c3aed;
3883
+ background: color-mix(in srgb, #7c3aed 16%, var(--sg-bg, #fff));
3884
+ }
3885
+ .sv-sched-daycell-nonworking { background: color-mix(in srgb, var(--sg-fg, #1f2937) 5%, transparent); }
3886
+ .sv-sched-conflict {
3887
+ position: fixed;
3888
+ left: 50%;
3889
+ bottom: 28px;
3890
+ transform: translateX(-50%);
3891
+ z-index: 2147483002;
3892
+ padding: 8px 14px;
3893
+ border-radius: 8px;
3894
+ font-size: 0.82rem;
3895
+ font-weight: 600;
3896
+ color: #fff;
3897
+ background: #ef4444;
3898
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2);
3899
+ pointer-events: none;
3900
+ }
3901
+ .sv-sched-reminders {
3902
+ position: fixed; right: 20px; top: 20px; z-index: 2147483002;
3903
+ display: flex; flex-direction: column; gap: 8px; pointer-events: none;
3904
+ }
3905
+ .sv-sched-reminder {
3906
+ display: flex; align-items: center; gap: 8px; padding: 9px 13px; border-radius: 9px;
3907
+ font-size: 0.82rem; font-weight: 600; color: var(--sg-fg, #1f2937);
3908
+ background: var(--sg-bg, #fff); border: 1px solid var(--sg-border, #e5e7eb);
3909
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.18); border-left: 4px solid var(--sg-accent, #4f46e5);
3910
+ }
3911
+ .sv-sched-reminder-bell { font-size: 0.9rem; }
3912
+ .sv-sched-tooltip {
3913
+ z-index: 2147483002;
3914
+ transform: translate(-50%, calc(-100% - 8px));
3915
+ max-width: 260px;
3916
+ padding: 7px 10px;
3917
+ border-radius: 8px;
3918
+ font-size: 0.78rem;
3919
+ line-height: 1.35;
3920
+ color: var(--sg-bg, #fff);
3921
+ background: color-mix(in srgb, var(--sg-fg, #1f2937) 92%, transparent);
3922
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2);
3923
+ pointer-events: none;
3924
+ }
3925
+ .sv-sched-tooltip-title { font-weight: 600; }
3926
+ .sv-sched-tooltip-meta { opacity: 0.85; font-variant-numeric: tabular-nums; }
3927
+
3928
+ /* ---- unscheduled backlog panel ---- */
3929
+ .sv-sched-workarea { display: flex; flex: 1 1 auto; min-height: 0; }
3930
+ .sv-sched-viewwrap { display: flex; flex-direction: column; flex: 1 1 auto; min-height: 0; min-width: 0; }
3931
+ .sv-sched-backlog {
3932
+ flex: 0 0 auto;
3933
+ width: 186px;
3934
+ display: flex;
3935
+ flex-direction: column;
3936
+ gap: 5px;
3937
+ padding: 8px;
3938
+ border-right: 1px solid var(--sg-border, #e5e7eb);
3939
+ overflow-y: auto;
3940
+ }
3941
+ .sv-sched-backlog-head { font-size: 0.68rem; font-weight: 600; text-transform: uppercase; color: var(--sg-muted, #9ca3af); padding: 2px 4px 4px; }
3942
+ .sv-sched-backlog-drop { outline: 2px dashed var(--sg-accent, #4f46e5); outline-offset: -3px; background: color-mix(in srgb, var(--sg-accent, #4f46e5) 10%, transparent); }
3943
+ .sv-sched-backlog-item {
3944
+ display: flex;
3945
+ align-items: center;
3946
+ gap: 6px;
3947
+ padding: 7px 9px;
3948
+ border-radius: 6px;
3949
+ cursor: grab;
3950
+ border: 1px solid var(--sg-border, #e5e7eb);
3951
+ background: color-mix(in srgb, var(--sv-sched-accent, #4f46e5) 10%, transparent);
3952
+ font-size: 0.82rem;
3953
+ touch-action: none;
3954
+ }
3955
+ .sv-sched-backlog-item:hover { background: color-mix(in srgb, var(--sv-sched-accent, #4f46e5) 18%, transparent); }
3956
+ .sv-sched-backlog-title { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
3957
+ .sv-sched-backlog-ghost {
3958
+ z-index: 2147483003;
3959
+ pointer-events: none;
3960
+ padding: 5px 9px;
3961
+ border-radius: 6px;
3962
+ background: var(--sv-sched-accent, #4f46e5);
3963
+ color: #fff;
3964
+ font-size: 0.8rem;
3965
+ box-shadow: 0 6px 20px rgba(0, 0, 0, 0.25);
3966
+ opacity: 0.9;
3967
+ }
3968
+ .sv-sched-backlog-ghost-over { outline: 2px solid #fff; outline-offset: 1px; }
3969
+ .sv-sched-hour {
3970
+ position: relative;
3971
+ border-bottom: 1px solid transparent;
3972
+ }
3973
+ .sv-sched-hour span {
3974
+ position: absolute;
3975
+ top: -0.6em;
3976
+ right: 6px;
3977
+ font-size: 0.68rem;
3978
+ color: var(--sg-muted, #9ca3af);
3979
+ background: var(--sg-bg, #fff);
3980
+ padding: 0 2px;
3981
+ }
3982
+ /* The first hour label has no gridline above it (only the scroll-container
3983
+ edge), so straddling would clip it under the all-day row - top-align it. */
3984
+ .sv-sched-hour:first-child span { top: 0; }
3985
+ /* Sub-hour ruler labels (e.g. :30, :15) - lighter and smaller than the hour. */
3986
+ .sv-sched-hour-minor span { font-size: 0.6rem; opacity: 0.62; }
3987
+ .sv-sched-col { position: relative; border-left: 1px solid var(--sg-border, #e5e7eb); }
3988
+ /* Sub-hour slot lines are faint; the on-the-hour line is the normal border. */
3989
+ .sv-sched-slot { border-bottom: 1px solid color-mix(in srgb, var(--sg-border, #e5e7eb) 45%, transparent); }
3990
+ .sv-sched-slot-hour { border-bottom-color: var(--sg-border, #eef0f2); }
3991
+ .sv-sched-event {
3992
+ position: absolute;
3993
+ box-sizing: border-box;
3994
+ border: 1px solid color-mix(in srgb, var(--sv-sched-accent) 55%, transparent);
3995
+ /* Left strip = the secondary accent when set, else the main accent. */
3996
+ border-left: 4px solid var(--sv-sched-accent2, var(--sv-sched-accent));
3997
+ background: color-mix(in srgb, var(--sv-sched-accent) 16%, var(--sg-bg, #fff));
3998
+ color: inherit;
3999
+ border-radius: 4px;
4000
+ padding: 1px 5px;
4001
+ font: inherit;
4002
+ font-size: 0.74rem;
4003
+ line-height: 1.2;
4004
+ text-align: left;
4005
+ /* Block flow (not a column) so time + title share a line and wrap - short
4006
+ events (e.g. a 15-min standup) still show their title clipped, not blank. */
4007
+ display: block;
4008
+ min-height: 15px;
4009
+ overflow: hidden;
4010
+ cursor: pointer;
4011
+ touch-action: none;
4012
+ z-index: var(--z, 1);
4013
+ }
4014
+ .sv-sched-event:hover,
4015
+ .sv-sched-event:focus-visible { z-index: 30; }
4016
+ .sv-sched-event:hover { background: color-mix(in srgb, var(--sv-sched-accent) 26%, var(--sg-bg, #fff)); }
4017
+ /* Stacked (offset) events get a subtle ring so overlaps read as separate cards. */
4018
+ .sv-sched-event-stacked { box-shadow: -1px 0 0 0 color-mix(in srgb, var(--sg-bg, #fff) 70%, transparent); }
4019
+ .sv-sched-event-draggable { cursor: grab; }
4020
+ .sv-sched-event-dragging {
4021
+ opacity: 0.92;
4022
+ box-shadow: 0 6px 16px rgba(0, 0, 0, 0.22);
4023
+ z-index: 40;
4024
+ cursor: grabbing;
4025
+ }
4026
+ /* While moving, the origin fades in place and a ghost shows the destination. */
4027
+ .sv-sched-event-source {
4028
+ opacity: 0.32;
4029
+ cursor: grabbing;
4030
+ }
4031
+ .sv-sched-col-drop {
4032
+ background: color-mix(in srgb, var(--sg-accent, #4f46e5) 8%, transparent);
4033
+ }
4034
+ .sv-sched-drag-preview {
4035
+ position: absolute;
4036
+ left: 2px;
4037
+ right: 2px;
4038
+ box-sizing: border-box;
4039
+ pointer-events: none;
4040
+ z-index: 50;
4041
+ border: 1.5px solid var(--sv-sched-accent);
4042
+ border-radius: 4px;
4043
+ background: color-mix(in srgb, var(--sv-sched-accent) 26%, var(--sg-bg, #fff));
4044
+ box-shadow: 0 8px 20px rgba(0, 0, 0, 0.28);
4045
+ padding: 2px 6px;
4046
+ font-size: 0.75rem;
4047
+ display: flex;
4048
+ flex-direction: column;
4049
+ gap: 1px;
4050
+ overflow: hidden;
4051
+ }
4052
+ /* "+N more" overflow tile for collisionMode 'cap'. */
4053
+ .sv-sched-overflow {
4054
+ position: absolute;
4055
+ box-sizing: border-box;
4056
+ border: 1px dashed var(--sg-border, #cbd5e1);
4057
+ background: color-mix(in srgb, var(--sg-fg, #1f2937) 6%, transparent);
4058
+ color: var(--sg-muted, #64748b);
4059
+ border-radius: 4px;
4060
+ padding: 2px 4px;
4061
+ font: inherit;
4062
+ font-size: 0.72rem;
4063
+ font-weight: 600;
4064
+ text-align: center;
4065
+ cursor: pointer;
4066
+ overflow: hidden;
4067
+ z-index: 3;
4068
+ }
4069
+ .sv-sched-overflow:hover {
4070
+ background: color-mix(in srgb, var(--sv-sched-accent) 14%, var(--sg-bg, #fff));
4071
+ color: var(--sv-sched-accent);
4072
+ border-color: var(--sv-sched-accent);
4073
+ }
4074
+ .sv-sched-event-recurring { border-left-style: double; border-left-width: 4px; }
4075
+ .sv-sched-event-time { color: var(--sg-muted, #6b7280); font-variant-numeric: tabular-nums; font-size: 0.66rem; margin-right: 4px; white-space: nowrap; }
4076
+ .sv-sched-event-title { font-weight: 500; }
4077
+ /* Resize grips (indicators) at the top + bottom edges. Hidden until the event
4078
+ is hovered / focused / being dragged, then a short centred bar appears. */
4079
+ .sv-sched-resize {
4080
+ position: absolute;
4081
+ left: 0;
4082
+ right: 0;
4083
+ height: 7px;
4084
+ display: flex;
4085
+ align-items: center;
4086
+ justify-content: center;
4087
+ cursor: ns-resize;
4088
+ opacity: 0;
4089
+ transition: opacity 0.12s ease;
4090
+ touch-action: none;
4091
+ z-index: 2;
4092
+ }
4093
+ .sv-sched-resize-top { top: 0; }
4094
+ .sv-sched-resize-bottom { bottom: 0; }
4095
+ .sv-sched-resize::after {
4096
+ content: "";
4097
+ width: 26px;
4098
+ height: 3px;
4099
+ border-radius: 3px;
4100
+ background: var(--sv-sched-accent);
4101
+ box-shadow: 0 0 0 1.5px var(--sg-bg, #fff);
4102
+ }
4103
+ .sv-sched-event-draggable:hover .sv-sched-resize,
4104
+ .sv-sched-event-draggable:focus-visible .sv-sched-resize,
4105
+ .sv-sched-event-dragging .sv-sched-resize {
4106
+ opacity: 1;
4107
+ }
4108
+ @media (prefers-reduced-motion: reduce) {
4109
+ .sv-sched-resize { transition: none; }
4110
+ }
4111
+
4112
+ /* agenda */
4113
+ .sv-sched-agenda { flex: 1 1 auto; overflow-y: auto; min-height: 0; padding: 4px 0; }
4114
+ .sv-sched-empty { padding: 24px; text-align: center; color: var(--sg-muted, #6b7280); }
4115
+ .sv-sched-agenda-day { display: flex; gap: 12px; padding: 8px 14px; border-bottom: 1px solid var(--sg-border, #eef0f2); }
4116
+ .sv-sched-agenda-date { flex: 0 0 56px; text-align: center; line-height: 1.1; }
4117
+ .sv-sched-agenda-dow { display: block; font-size: 0.68rem; text-transform: uppercase; color: var(--sg-muted, #9ca3af); }
4118
+ .sv-sched-agenda-num { display: block; font-size: 1.35rem; font-weight: 600; }
4119
+ .sv-sched-agenda-mon { display: block; font-size: 0.7rem; color: var(--sg-muted, #6b7280); }
4120
+ .sv-sched-agenda-events { flex: 1 1 auto; display: flex; flex-direction: column; gap: 4px; min-width: 0; }
4121
+ .sv-sched-agenda-row {
4122
+ display: flex;
4123
+ align-items: center;
4124
+ gap: 8px;
4125
+ width: 100%;
4126
+ text-align: left;
4127
+ border: 1px solid var(--sg-border, #eef0f2);
4128
+ border-radius: 6px;
4129
+ background: var(--sg-bg, #fff);
4130
+ color: inherit;
4131
+ padding: 6px 10px;
4132
+ font: inherit;
4133
+ cursor: pointer;
4134
+ }
4135
+ .sv-sched-agenda-row:hover { background: color-mix(in srgb, var(--sg-fg, #1f2937) 8%, transparent); }
4136
+ .sv-sched-agenda-time { flex: 0 0 auto; color: var(--sg-muted, #6b7280); font-variant-numeric: tabular-nums; font-size: 0.82rem; min-width: 96px; }
4137
+ .sv-sched-agenda-title { flex: 1 1 auto; font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
4138
+ .sv-sched-tag {
4139
+ flex: 0 0 auto;
4140
+ font-size: 0.72rem;
4141
+ padding: 1px 7px;
4142
+ border-radius: 999px;
4143
+ background: color-mix(in srgb, var(--sv-sched-accent) 16%, transparent);
4144
+ color: var(--sv-sched-accent);
4145
+ }
4146
+
4147
+ .sv-sched-menu {
4148
+ z-index: 2147483000; /* above the calendar content (portalled to body) */
4149
+ background: var(--sg-bg, #fff);
4150
+ border: 1px solid var(--sg-border, #e5e7eb);
4151
+ border-radius: 8px;
4152
+ box-shadow: 0 8px 28px rgba(0, 0, 0, 0.16);
4153
+ overflow: hidden;
4154
+ }
4155
+
4156
+ /* Recurring-edit scope chooser (This event / All events). */
4157
+ .sv-sched-scope {
4158
+ z-index: 2147483001;
4159
+ display: flex;
4160
+ flex-direction: column;
4161
+ gap: 2px;
4162
+ padding: 6px;
4163
+ min-width: 168px;
4164
+ background: var(--sg-bg, #fff);
4165
+ border: 1px solid var(--sg-border, #e5e7eb);
4166
+ border-radius: 8px;
4167
+ box-shadow: 0 8px 28px rgba(0, 0, 0, 0.16);
4168
+ }
4169
+ .sv-sched-scope-title {
4170
+ font-size: 0.72rem;
4171
+ font-weight: 600;
4172
+ color: var(--sg-muted, #6b7280);
4173
+ padding: 2px 8px 4px;
4174
+ }
4175
+ .sv-sched-scope-btn {
4176
+ text-align: left;
4177
+ padding: 7px 10px;
4178
+ border: none;
4179
+ border-radius: 6px;
4180
+ background: transparent;
4181
+ color: inherit;
4182
+ font: inherit;
4183
+ cursor: pointer;
4184
+ }
4185
+ .sv-sched-scope-btn:hover { background: color-mix(in srgb, var(--sv-sched-accent, #4f46e5) 14%, transparent); }
4186
+
4187
+ /* "+N more" event-list popover (cap overflow + month day cell). */
4188
+ .sv-sched-listpop {
4189
+ min-width: 220px;
4190
+ max-width: 320px;
4191
+ max-height: 320px;
4192
+ display: flex;
4193
+ flex-direction: column;
4194
+ background: var(--sg-bg, #fff);
4195
+ border: 1px solid var(--sg-border, #e5e7eb);
4196
+ border-radius: 8px;
4197
+ box-shadow: 0 8px 28px rgba(0, 0, 0, 0.16);
4198
+ overflow: hidden;
4199
+ z-index: 60;
4200
+ }
4201
+ .sv-sched-listpop-head {
4202
+ padding: 8px 12px;
4203
+ font-weight: 600;
4204
+ font-size: 0.82rem;
4205
+ border-bottom: 1px solid var(--sg-border, #e5e7eb);
4206
+ }
4207
+ .sv-sched-listpop-body { overflow-y: auto; padding: 4px; display: flex; flex-direction: column; gap: 2px; }
4208
+ .sv-sched-listpop-item {
4209
+ display: flex;
4210
+ align-items: center;
4211
+ gap: 8px;
4212
+ width: 100%;
4213
+ text-align: left;
4214
+ border: none;
4215
+ background: none;
4216
+ color: inherit;
4217
+ border-radius: 6px;
4218
+ padding: 5px 8px;
4219
+ font: inherit;
4220
+ font-size: 0.8rem;
4221
+ cursor: pointer;
4222
+ }
4223
+ .sv-sched-listpop-item:hover { background: color-mix(in srgb, var(--sg-fg, #1f2937) 8%, transparent); }
4224
+ .sv-sched-listpop-time { flex: 0 0 auto; color: var(--sg-muted, #6b7280); font-variant-numeric: tabular-nums; font-size: 0.74rem; }
4225
+ .sv-sched-listpop-title { flex: 1 1 auto; font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
4226
+
4227
+ /* Delete button in the detail drawer (shown when onEventDelete is set). */
4228
+ .sv-sched-delete {
4229
+ margin-top: 12px;
4230
+ width: 100%;
4231
+ border: 1px solid color-mix(in srgb, var(--sg-danger, #dc2626) 45%, transparent);
4232
+ background: color-mix(in srgb, var(--sg-danger, #dc2626) 10%, transparent);
4233
+ color: var(--sg-danger, #dc2626);
4234
+ border-radius: 6px;
4235
+ padding: 8px 12px;
4236
+ font: inherit;
4237
+ font-size: 0.85rem;
4238
+ font-weight: 500;
4239
+ cursor: pointer;
4240
+ }
4241
+ .sv-sched-delete:hover { background: color-mix(in srgb, var(--sg-danger, #dc2626) 18%, transparent); }
4242
+
4243
+ /* Recurrence pattern editor (in the drawer, when recurrenceField is set). */
4244
+ /* "When" editor (all-day toggle + start/end datetime) at the top of the drawer. */
4245
+ .sv-sched-when {
4246
+ display: flex;
4247
+ flex-direction: column;
4248
+ gap: 8px;
4249
+ margin-bottom: 14px;
4250
+ }
4251
+ .sv-sched-when-check { display: inline-flex; align-items: center; gap: 6px; font-size: 0.85rem; }
4252
+ .sv-sched-when-row { display: flex; align-items: center; gap: 8px; font-size: 0.82rem; }
4253
+ .sv-sched-when-label { flex: 0 0 44px; color: var(--sg-muted, #6b7280); }
4254
+ .sv-sched-when-input { flex: 1 1 auto; min-width: 0; }
4255
+ /* Let the wrapped Sv date pickers fill the drawer width (their default is fixed). */
4256
+ .sv-sched-when-input :global(.sv-dtp) { width: 100%; }
4257
+ .sv-sched-recur {
4258
+ display: flex;
4259
+ flex-direction: column;
4260
+ gap: 8px;
4261
+ margin-bottom: 14px;
4262
+ padding: 10px 12px;
4263
+ border: 1px solid var(--sg-border, #e5e7eb);
4264
+ border-radius: 8px;
4265
+ background: color-mix(in srgb, var(--sg-fg, #1f2937) 3%, transparent);
4266
+ }
4267
+ .sv-sched-recur-row { display: flex; align-items: center; gap: 8px; font-size: 0.82rem; }
4268
+ .sv-sched-recur-label { flex: 0 0 92px; color: var(--sg-muted, #6b7280); }
4269
+ .sv-sched-recur-unit { color: var(--sg-muted, #6b7280); }
4270
+ .sv-sched-recur-select { flex: 1 1 auto; min-width: 0; }
4271
+ .sv-sched-recur-num { flex: 0 0 auto; }
4272
+ .sv-sched-recur-date { flex: 1 1 auto; min-width: 0; }
4273
+ /* Wrapped Sv controls fill their cell (the number input stays compact so the
4274
+ unit label beside it fits). */
4275
+ .sv-sched-recur-select :global(.sv-ddl),
4276
+ .sv-sched-recur-date :global(.sv-dtp) { width: 100%; }
4277
+ .sv-sched-recur-num :global(.sv-num) { width: 88px; min-width: 88px; }
4278
+ .sv-sched-recur-days { display: flex; gap: 4px; flex-wrap: wrap; }
4279
+ .sv-sched-recur-day {
4280
+ width: 26px;
4281
+ height: 26px;
4282
+ border: 1px solid var(--sg-border, #e5e7eb);
4283
+ background: var(--sg-bg, #fff);
4284
+ color: inherit;
4285
+ border-radius: 50%;
4286
+ font: inherit;
4287
+ font-size: 0.72rem;
4288
+ font-weight: 600;
4289
+ cursor: pointer;
4290
+ text-transform: uppercase;
4291
+ }
4292
+ .sv-sched-recur-day-on {
4293
+ background: var(--sv-sched-accent);
4294
+ border-color: var(--sv-sched-accent);
4295
+ color: #fff;
4296
+ }
4297
+
4298
+ /* ---- timeline views (horizontal: time left→right, resources = rows) ---- */
4299
+ .sv-sched-tl {
4300
+ flex: 1 1 auto;
4301
+ min-height: 0;
4302
+ display: flex;
4303
+ flex-direction: column;
4304
+ /* Single scroll container: horizontal (header + body share one width) AND
4305
+ vertical - so the vertical scrollbar sits at the VIEWPORT's right edge, not
4306
+ at the far-right of the wide content. The sticky head handles the vertical. */
4307
+ overflow: auto;
4308
+ }
4309
+ .sv-sched-tl-head {
4310
+ display: flex;
4311
+ position: sticky;
4312
+ top: 0;
4313
+ z-index: 3;
4314
+ background: var(--sg-bg, #fff);
4315
+ border-bottom: 1px solid var(--sg-border, #e5e7eb);
4316
+ width: calc(var(--tl-res-w) + var(--tl-axis-w));
4317
+ }
4318
+ .sv-sched-tl-corner {
4319
+ flex: 0 0 var(--tl-res-w);
4320
+ position: sticky;
4321
+ left: 0;
4322
+ z-index: 1;
4323
+ background: var(--sg-bg, #fff);
4324
+ border-right: 1px solid var(--sg-border, #e5e7eb);
4325
+ }
4326
+ .sv-sched-tl-axis { flex: 0 0 var(--tl-axis-w); position: relative; }
4327
+ .sv-sched-tl-majors { position: relative; height: 22px; border-bottom: 1px solid var(--sg-border, #e5e7eb); }
4328
+ .sv-sched-tl-major {
4329
+ position: absolute; top: 0; height: 22px; display: flex; align-items: center; justify-content: center;
4330
+ font-size: 0.72rem; font-weight: 600; box-sizing: border-box; border-left: 1px solid var(--sg-border, #e5e7eb);
4331
+ overflow: hidden; white-space: nowrap;
4332
+ }
4333
+ .sv-sched-tl-ticks { position: relative; height: 26px; }
4334
+ .sv-sched-tl-tick {
4335
+ position: absolute; top: 0; height: 26px; display: flex; align-items: center; justify-content: center;
4336
+ font-size: 0.68rem; color: var(--sg-muted, #6b7280); box-sizing: border-box;
4337
+ border-left: 1px solid var(--sg-border, #e5e7eb); overflow: hidden; white-space: nowrap;
4338
+ }
4339
+ .sv-sched-tl-tick.sv-sched-today { color: var(--sv-sched-accent); font-weight: 700; }
4340
+ .sv-sched-tl-tick span { padding: 0 4px; }
4341
+
4342
+ /* Body fills the remaining height; a trailing filler row extends the axis
4343
+ gridlines to the bottom so the timeline uses the full component space. */
4344
+ .sv-sched-tl-body {
4345
+ width: calc(var(--tl-res-w) + var(--tl-axis-w));
4346
+ flex: 1 1 auto;
4347
+ min-height: 0;
4348
+ display: flex;
4349
+ flex-direction: column;
4350
+ /* No own scroll: vertical scrolling belongs to the outer .sv-sched-tl (so its
4351
+ scrollbar pins to the viewport, not the content's far right). flex:1 keeps
4352
+ the filler row filling the height when there are few resource rows. */
4353
+ overflow: visible;
4354
+ }
4355
+ .sv-sched-tl-row { display: flex; border-bottom: 1px solid var(--sg-border, #e5e7eb); flex: 0 0 auto; }
4356
+ .sv-sched-tl-row.sv-sched-tl-filler { flex: 1 1 auto; min-height: 0; border-bottom: none; }
4357
+ .sv-sched-tl-filler .sv-sched-tl-resgutter { border-right: 1px solid var(--sg-border, #e5e7eb); }
4358
+ .sv-sched-tl-resgutter {
4359
+ flex: 0 0 var(--tl-res-w);
4360
+ position: sticky;
4361
+ left: 0;
4362
+ z-index: 2;
4363
+ display: flex; align-items: center; gap: 6px; padding: 0 10px;
4364
+ background: var(--sg-bg, #fff);
4365
+ border-right: 1px solid var(--sg-border, #e5e7eb);
4366
+ font-size: 0.82rem; font-weight: 500;
4367
+ }
4368
+ .sv-sched-tl-resname { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
4369
+ .sv-sched-tl-lanes { flex: 0 0 var(--tl-axis-w); position: relative; }
4370
+ .sv-sched-tl-gridline {
4371
+ position: absolute; top: 0; bottom: 0;
4372
+ border-left: 1px solid color-mix(in srgb, var(--sg-fg, #1f2937) 6%, transparent);
4373
+ }
4374
+ .sv-sched-tl-gridline.sv-sched-today { background: color-mix(in srgb, var(--sv-sched-accent) 7%, transparent); }
4375
+ /* Destination row while dragging a bar to another resource. */
4376
+ .sv-sched-tl-row-drop .sv-sched-tl-lanes { background: color-mix(in srgb, var(--sv-sched-accent, #4f46e5) 9%, transparent); }
4377
+ .sv-sched-tl-row-drop .sv-sched-tl-resgutter { background: color-mix(in srgb, var(--sv-sched-accent, #4f46e5) 12%, var(--sg-bg, #fff)); }
4378
+ .sv-sched-tl-bar {
4379
+ /* a horizontal event bar; inherits the shared .sv-sched-bar look */
4380
+ min-width: 6px; /* keep very short events visible + clickable */
4381
+ overflow: hidden;
4382
+ }
4383
+ .sv-sched-tl-bar .sv-sched-bar-time { flex: none; }
4384
+ /* Too narrow to read: a clean colored tick (no clipped text, no grips). */
4385
+ .sv-sched-tl-bar-tiny { padding: 0; }
4386
+ .sv-sched-tl-bar-tiny .sv-sched-chip-resize { display: none; }
4387
+ /* Actively resizing: keep it fully opaque + emphasized so the live grow/shrink
4388
+ reads clearly (never dimmed like a move source). */
4389
+ .sv-sched-bar-resizing {
4390
+ opacity: 1;
4391
+ z-index: 7;
4392
+ box-shadow: 0 0 0 2px color-mix(in srgb, var(--sv-sched-accent, #4f46e5) 55%, transparent), 0 4px 14px -4px rgba(0, 0, 0, 0.45);
4393
+ }
4394
+
4395
+ /* ---- selection: drag-select mirror + multi-selected events ---- */
4396
+ .sv-sched-select-mirror {
4397
+ position: absolute;
4398
+ z-index: 5;
4399
+ pointer-events: none;
4400
+ border-radius: 4px;
4401
+ background: color-mix(in srgb, var(--sv-sched-accent, #4f46e5) 22%, transparent);
4402
+ border: 1px solid color-mix(in srgb, var(--sv-sched-accent, #4f46e5) 55%, transparent);
4403
+ }
4404
+ .sv-sched-event-selected,
4405
+ .sv-sched-bar-selected {
4406
+ outline: 2px solid var(--sv-sched-accent, #4f46e5);
4407
+ outline-offset: 1px;
4408
+ box-shadow: 0 0 0 3px color-mix(in srgb, var(--sv-sched-accent, #4f46e5) 30%, transparent);
4409
+ }
4410
+ </style>