@lavalogic/scoria 0.40.21 → 0.40.24

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.
@@ -1,25 +1,25 @@
1
- <svelte:options runes />
2
-
3
- <script lang="ts">import { Colour } from "../../scss/colours.js";
1
+ <svelte:options runes />
2
+
3
+ <script
4
+ lang="ts"
5
+ generics="E extends CalendarEvent"
6
+ >import { Colour } from "../../scss/colours.js";
4
7
  import { Size } from "../../Types/Internal/Size.js";
5
8
  import Button from "../Button.svelte";
6
9
  import Icon from "../Icon.svelte";
7
10
  import LoadingOverlay from "../LoadingOverlay.svelte";
8
- import { CalendarViewMode } from "./AppointmentCalendarProps.js";
9
- let { doors, appointments, selectedDate, viewMode, ondatechange, onviewmodechange, ondragcreate, onappointmentclick, onappointmentmove, startHour = 0, endHour = 24, slotIntervalMinutes = 15, availability, centreHour = 12, isLoading = false, headerExtra } = $props();
11
+ import { CalendarViewMode } from "./ResourceCalendarProps.js";
12
+ let { resources, events, event: eventContent, selectedDate, viewMode, ondatechange, onviewmodechange, onrangeselect, oneventclick, oneventmove, startHour = 0, endHour = 24, slotIntervalMinutes = 15, availability, centreHour = 12, isLoading = false, headerExtra } = $props();
10
13
  /**
11
- * `selectedDate` is always read by the component, never written. The
12
- * runtime type may be either a plain `Date` or a `SvelteDate`; both
13
- * expose the same `Date` interface that we use here. Cast to `Date`
14
- * for narrowing inside the script.
14
+ * `selectedDate` is always read by the component, never written. The runtime
15
+ * type may be a plain `Date` or a `SvelteDate`; both expose the same `Date`
16
+ * interface. Cast to `Date` for narrowing inside the script.
15
17
  */
16
18
  const selected = $derived(selectedDate);
17
19
  /**
18
- * Today's reference Date hoisted out of the per-cell loops so that
19
- * rendering the month grid does not allocate 42 fresh `Date`
20
- * instances on every state change. `$derived` does not invalidate
21
- * over time on its own; if you need real day-rollover semantics
22
- * track it with a `SvelteDate` and a `setInterval`.
20
+ * Today's reference Date hoisted out of the per-cell loops so rendering the
21
+ * month grid does not allocate 42 fresh `Date` instances on every state
22
+ * change. `$derived` does not invalidate over time on its own.
23
23
  */
24
24
  const today = $derived(new Date());
25
25
  // ---- Date helpers ----
@@ -93,13 +93,6 @@ const timeSlots = $derived.by(() => {
93
93
  return slots;
94
94
  });
95
95
  // ---- Availability shading ----
96
- /**
97
- * Whether the given slot is outside the door's available windows.
98
- *
99
- * A door absent from {@link availability} (or no `availability` map at
100
- * all) is treated as fully available, so nothing is shaded until the
101
- * caller supplies windows for that door.
102
- */
103
96
  function slotUnavailableInWindows(windows, slot) {
104
97
  const minutes = slot.hour * 60 + slot.minute;
105
98
  for (const window of windows) {
@@ -109,11 +102,11 @@ function slotUnavailableInWindows(windows, slot) {
109
102
  }
110
103
  return true;
111
104
  }
112
- function isSlotUnavailable(doorId, slotIndex) {
105
+ function isSlotUnavailable(resourceId, slotIndex) {
113
106
  if (!availability) {
114
107
  return false;
115
108
  }
116
- const windows = availability.get(doorId);
109
+ const windows = availability.get(resourceId);
117
110
  if (!windows) {
118
111
  return false;
119
112
  }
@@ -124,28 +117,26 @@ function isSlotUnavailable(doorId, slotIndex) {
124
117
  return slotUnavailableInWindows(windows, slot);
125
118
  }
126
119
  /**
127
- * Contiguous unavailable runs per door, expressed as top/height
128
- * percentages of the day column. Rendered as a single overlay block per
129
- * run so the shading pattern is continuous across the whole region
130
- * rather than restarting (and visibly seaming) at every slot cell.
120
+ * Contiguous unavailable runs per resource, as top/height percentages of the
121
+ * day column. Rendered as a single overlay block per run so the shading is
122
+ * continuous rather than restarting (and visibly seaming) at every slot.
131
123
  */
132
- const unavailableRunsByDoor = $derived.by(() => {
133
- // Local index built inside a $derived; outer reactivity covers reads.
124
+ const unavailableRunsByResource = $derived.by(() => {
134
125
  // eslint-disable-next-line svelte/prefer-svelte-reactivity
135
- const byDoor = new Map();
126
+ const byResource = new Map();
136
127
  const slotCount = timeSlots.length;
137
128
  if (!availability || slotCount === 0) {
138
- return byDoor;
129
+ return byResource;
139
130
  }
140
- for (const door of doors) {
141
- const windows = availability.get(door.id);
131
+ for (const resource of resources) {
132
+ const windows = availability.get(resource.id);
142
133
  if (!windows) {
143
134
  continue;
144
135
  }
145
136
  const runs = [];
146
137
  let runStart = null;
147
- // One extra iteration (i === slotCount) flushes a run that reaches
148
- // the end of the day.
138
+ // One extra iteration (i === slotCount) flushes a run that reaches the
139
+ // end of the day.
149
140
  for (let i = 0; i <= slotCount; i++) {
150
141
  const unavailable = i < slotCount && slotUnavailableInWindows(windows, timeSlots[i]);
151
142
  if (unavailable) {
@@ -161,18 +152,15 @@ const unavailableRunsByDoor = $derived.by(() => {
161
152
  }
162
153
  }
163
154
  if (runs.length > 0) {
164
- byDoor.set(door.id, runs);
155
+ byResource.set(resource.id, runs);
165
156
  }
166
157
  }
167
- return byDoor;
158
+ return byResource;
168
159
  });
169
- function unavailableRunsAt(doorId) {
170
- return unavailableRunsByDoor.get(doorId) ?? [];
160
+ function unavailableRunsAt(resourceId) {
161
+ return unavailableRunsByResource.get(resourceId) ?? [];
171
162
  }
172
- /**
173
- * Build a `YYYY-MM-DD` key for date bucketing. Avoids `toISOString`
174
- * timezone conversions that would smear appointments across days.
175
- */
163
+ /** Build a `YYYY-MM-DD` key for date bucketing without timezone smearing. */
176
164
  function dayKey(d) {
177
165
  const y = d.getFullYear();
178
166
  const m = String(d.getMonth() + 1).padStart(2, "0");
@@ -180,33 +168,31 @@ function dayKey(d) {
180
168
  return `${y}-${m}-${day}`;
181
169
  }
182
170
  /**
183
- * Precomputed `Map<doorId, Map<dayKey, PositionedAppointment[]>>` for
184
- * day and week views. Replaces the previous per-cell
185
- * `appointments.filter(...)` calls which were `O(doors * appointments)`
186
- * per render and allocated a fresh `Date` per appointment per pass.
171
+ * Precomputed `Map<resourceId, Map<dayKey, PositionedEvent[]>>` for day and
172
+ * week views avoids per-cell `events.filter(...)` and per-event `Date`
173
+ * allocations on every render.
187
174
  */
188
- const positionedByDoorAndDay = $derived.by(() => {
175
+ const positionedByResourceAndDay = $derived.by(() => {
189
176
  const totalMinutes = (endHour - startHour) * 60;
190
- // Local indexes built inside a $derived; outer reactivity already covers reads.
191
177
  // eslint-disable-next-line svelte/prefer-svelte-reactivity
192
- const byDoor = new Map();
193
- for (const apt of appointments) {
194
- const from = new Date(apt.dateFrom);
195
- const to = new Date(apt.dateTo);
178
+ const byResource = new Map();
179
+ for (const ev of events) {
180
+ const from = new Date(ev.start);
181
+ const to = new Date(ev.end);
196
182
  const startMin = (from.getHours() - startHour) * 60 + from.getMinutes();
197
183
  const endMin = (to.getHours() - startHour) * 60 + to.getMinutes();
198
184
  const durationMin = Math.max(endMin - startMin, slotIntervalMinutes);
199
185
  const positioned = {
200
- ...apt,
186
+ ...ev,
201
187
  topPercent: Math.max(0, startMin) / totalMinutes * 100,
202
188
  heightPercent: Math.min(durationMin, totalMinutes - startMin) / totalMinutes * 100
203
189
  };
204
190
  const key = dayKey(from);
205
- let perDay = byDoor.get(apt.segmentId);
191
+ let perDay = byResource.get(ev.resourceId);
206
192
  if (!perDay) {
207
193
  // eslint-disable-next-line svelte/prefer-svelte-reactivity
208
194
  perDay = new Map();
209
- byDoor.set(apt.segmentId, perDay);
195
+ byResource.set(ev.resourceId, perDay);
210
196
  }
211
197
  const list = perDay.get(key);
212
198
  if (list) {
@@ -215,43 +201,35 @@ const positionedByDoorAndDay = $derived.by(() => {
215
201
  perDay.set(key, [positioned]);
216
202
  }
217
203
  }
218
- return byDoor;
204
+ return byResource;
219
205
  });
220
- function appointmentsAt(doorId, day) {
221
- return positionedByDoorAndDay.get(doorId)?.get(dayKey(day)) ?? [];
206
+ function eventsAt(resourceId, day) {
207
+ return positionedByResourceAndDay.get(resourceId)?.get(dayKey(day)) ?? [];
222
208
  }
223
- /**
224
- * Precomputed appointment-count badges per day for the month view.
225
- * Replaces the previous `O(cells * doors * appointments)` recomputation
226
- * which scaled to ~42 000 ops on a single render with 10 doors and
227
- * 100 appointments.
228
- */
209
+ /** Precomputed event-count badges per day for the month view. */
229
210
  const countsByDay = $derived.by(() => {
230
- // Local indexes built inside a $derived; outer reactivity already covers reads.
231
211
  // eslint-disable-next-line svelte/prefer-svelte-reactivity
232
212
  const map = new Map();
233
- // One scan over appointments builds a nested Map<dayKey, Map<doorId, count>>.
234
213
  // eslint-disable-next-line svelte/prefer-svelte-reactivity
235
214
  const intermediate = new Map();
236
- for (const apt of appointments) {
237
- const key = dayKey(new Date(apt.dateFrom));
215
+ for (const ev of events) {
216
+ const key = dayKey(new Date(ev.start));
238
217
  let perDay = intermediate.get(key);
239
218
  if (!perDay) {
240
219
  // eslint-disable-next-line svelte/prefer-svelte-reactivity
241
220
  perDay = new Map();
242
221
  intermediate.set(key, perDay);
243
222
  }
244
- perDay.set(apt.segmentId, (perDay.get(apt.segmentId) ?? 0) + 1);
223
+ perDay.set(ev.resourceId, (perDay.get(ev.resourceId) ?? 0) + 1);
245
224
  }
246
- // Flatten to the door-ordered list the markup expects.
247
225
  for (const [key, perDay] of intermediate) {
248
226
  const list = [];
249
- for (const door of doors) {
250
- const count = perDay.get(door.id);
227
+ for (const resource of resources) {
228
+ const count = perDay.get(resource.id);
251
229
  if (count) {
252
230
  list.push({
253
- doorId: door.id,
254
- doorName: door.name,
231
+ resourceId: resource.id,
232
+ resourceLabel: resource.label,
255
233
  count
256
234
  });
257
235
  }
@@ -267,124 +245,137 @@ function countsAt(day) {
267
245
  }
268
246
  // ---- Click and drag logic ----
269
247
  let isDragging = $state(false);
270
- let dragDoorId = $state(null);
248
+ let dragResourceId = $state(null);
271
249
  let dragStartSlotIndex = $state(null);
272
250
  let dragEndSlotIndex = $state(null);
273
- function onSlotMouseDown(doorId, slotIndex, e) {
274
- // Only start a drag-create on the primary (left) button. Otherwise a
275
- // middle-click (autoscroll) or right-click would open the create modal.
251
+ function onSlotMouseDown(resourceId, slotIndex, e) {
252
+ // Only start a drag-create on the primary (left) button.
276
253
  if (e.button !== 0) {
277
254
  return;
278
255
  }
279
- if (!ondragcreate || appointmentDragStarted) {
256
+ if (!onrangeselect || eventDragStarted) {
280
257
  return;
281
258
  }
282
- // Unavailable slots cannot seed a new appointment.
283
- if (isSlotUnavailable(doorId, slotIndex)) {
259
+ if (isSlotUnavailable(resourceId, slotIndex)) {
284
260
  return;
285
261
  }
286
262
  isDragging = true;
287
- dragDoorId = doorId;
263
+ dragResourceId = resourceId;
288
264
  dragStartSlotIndex = slotIndex;
289
265
  dragEndSlotIndex = slotIndex;
290
266
  }
291
- function onSlotMouseEnter(doorId, slotIndex, e) {
267
+ function onSlotMouseEnter(resourceId, slotIndex, e) {
292
268
  // If a drag is in progress but the primary button is no longer held, the
293
- // pointer-up happened somewhere we did not observe (e.g. outside the
294
- // calendar). Reset so we do not "drag" without the button being pressed.
295
- if ((isDragging || appointmentDragStarted) && (e.buttons & 1) === 0) {
269
+ // pointer-up happened somewhere we did not observe. Reset.
270
+ if ((isDragging || eventDragStarted) && (e.buttons & 1) === 0) {
296
271
  isDragging = false;
297
- dragDoorId = null;
272
+ dragResourceId = null;
298
273
  dragStartSlotIndex = null;
299
274
  dragEndSlotIndex = null;
300
- resetAppointmentDrag();
275
+ resetEventDrag();
301
276
  return;
302
277
  }
303
- // Appointment move drag
304
- if (appointmentDragStarted) {
305
- // Freeze the drop target rather than land it on an unavailable slot.
306
- if (isSlotUnavailable(doorId, slotIndex)) {
278
+ // Event move drag
279
+ if (eventDragStarted) {
280
+ if (isSlotUnavailable(resourceId, slotIndex)) {
307
281
  return;
308
282
  }
309
- isDraggingAppointment = true;
310
- moveTargetDoorId = doorId;
283
+ isDraggingEvent = true;
284
+ moveTargetResourceId = resourceId;
311
285
  moveTargetSlotIndex = slotIndex;
312
286
  return;
313
287
  }
314
288
  // Slot drag-create
315
- if (!isDragging || doorId !== dragDoorId) {
289
+ if (!isDragging || resourceId !== dragResourceId) {
316
290
  return;
317
291
  }
318
- // Keep the selection within the door's available windows.
319
- if (isSlotUnavailable(doorId, slotIndex)) {
292
+ if (isSlotUnavailable(resourceId, slotIndex)) {
320
293
  return;
321
294
  }
322
295
  dragEndSlotIndex = slotIndex;
323
296
  }
297
+ /** Builds a range selection spanning slots [startIdx, endIdx] inclusive. */
298
+ function buildSlotRange(resourceId, startIdx, endIdx) {
299
+ const startSlot = timeSlots[startIdx];
300
+ const endSlot = timeSlots[endIdx];
301
+ // eslint-disable-next-line svelte/prefer-svelte-reactivity
302
+ const start = new Date(selected);
303
+ start.setHours(startSlot.hour, startSlot.minute, 0, 0);
304
+ // eslint-disable-next-line svelte/prefer-svelte-reactivity
305
+ const end = new Date(selected);
306
+ // End slot is the start of the last selected slot; add one interval.
307
+ end.setHours(endSlot.hour, endSlot.minute + slotIntervalMinutes, 0, 0);
308
+ return {
309
+ resourceId,
310
+ start,
311
+ end
312
+ };
313
+ }
314
+ /**
315
+ * Double-clicking a single slot creates a one-interval range there. A single
316
+ * click is intentionally inert (see `onSlotMouseUp`): only a double-click or a
317
+ * multi-cell drag opens the consumer's range-select flow.
318
+ */
319
+ function onSlotDoubleClick(resourceId, slotIndex) {
320
+ if (!onrangeselect || isSlotUnavailable(resourceId, slotIndex)) {
321
+ return;
322
+ }
323
+ onrangeselect(buildSlotRange(resourceId, slotIndex, slotIndex));
324
+ }
324
325
  function onSlotMouseUp() {
325
- // ---- Handle appointment drag-move ----
326
- if (isDraggingAppointment && draggedAppointment && moveTargetDoorId != null && moveTargetSlotIndex != null) {
326
+ // ---- Handle event drag-move ----
327
+ if (isDraggingEvent && draggedEvent && moveTargetResourceId != null && moveTargetSlotIndex != null) {
327
328
  const slot = timeSlots[moveTargetSlotIndex];
328
329
  // eslint-disable-next-line svelte/prefer-svelte-reactivity
329
- const newDateFrom = new Date(selected);
330
- newDateFrom.setHours(slot.hour, slot.minute, 0, 0);
331
- const newDateTo = new Date(newDateFrom.getTime() + draggedAptSlotCount * slotIntervalMinutes * 6e4);
332
- // Check whether the position actually changed
333
- const origFrom = new Date(draggedAppointment.dateFrom);
334
- const samePosition = moveTargetDoorId === draggedAppointment.segmentId && newDateFrom.getTime() === origFrom.getTime();
330
+ const newStart = new Date(selected);
331
+ newStart.setHours(slot.hour, slot.minute, 0, 0);
332
+ const newEnd = new Date(newStart.getTime() + draggedSlotCount * slotIntervalMinutes * 6e4);
333
+ const origFrom = new Date(draggedEvent.start);
334
+ const samePosition = moveTargetResourceId === draggedEvent.resourceId && newStart.getTime() === origFrom.getTime();
335
335
  if (samePosition) {
336
- // Dropped in the same spot - treat as a click
337
- onappointmentclick?.(draggedAppointment);
338
- } else if (!hasOverlap(moveTargetDoorId, newDateFrom, newDateTo, draggedAppointment.id)) {
339
- const door = doors.find((d) => d.id === moveTargetDoorId);
340
- onappointmentmove?.({
341
- appointment: draggedAppointment,
342
- newDoorId: moveTargetDoorId,
343
- newDoorName: door?.name ?? "",
344
- newDateFrom,
345
- newDateTo
336
+ // Dropped in the same spot - treat as a click.
337
+ oneventclick?.(draggedEvent);
338
+ } else if (!hasOverlap(moveTargetResourceId, newStart, newEnd, draggedEvent.id)) {
339
+ oneventmove?.({
340
+ event: draggedEvent,
341
+ newResourceId: moveTargetResourceId,
342
+ newStart,
343
+ newEnd
346
344
  });
347
345
  }
348
- resetAppointmentDrag();
346
+ resetEventDrag();
349
347
  return;
350
348
  }
351
- // ---- Handle appointment click (mousedown + mouseup without leaving the block) ----
352
- if (appointmentDragStarted && !isDraggingAppointment && draggedAppointment) {
353
- onappointmentclick?.(draggedAppointment);
354
- resetAppointmentDrag();
349
+ // ---- Handle event click (mousedown + mouseup without leaving the block) ----
350
+ if (eventDragStarted && !isDraggingEvent && draggedEvent) {
351
+ oneventclick?.(draggedEvent);
352
+ resetEventDrag();
355
353
  return;
356
354
  }
357
- resetAppointmentDrag();
355
+ resetEventDrag();
358
356
  // ---- Handle drag-create ----
359
- if (!isDragging || dragDoorId == null || dragStartSlotIndex == null || dragEndSlotIndex == null) {
357
+ if (!isDragging || dragResourceId == null || dragStartSlotIndex == null || dragEndSlotIndex == null) {
360
358
  return;
361
359
  }
362
360
  const startIdx = Math.min(dragStartSlotIndex, dragEndSlotIndex);
363
361
  const endIdx = Math.max(dragStartSlotIndex, dragEndSlotIndex);
364
- const startSlot = timeSlots[startIdx];
365
- const endSlot = timeSlots[endIdx];
366
- // eslint-disable-next-line svelte/prefer-svelte-reactivity
367
- const dateFrom = new Date(selected);
368
- dateFrom.setHours(startSlot.hour, startSlot.minute, 0, 0);
369
- // eslint-disable-next-line svelte/prefer-svelte-reactivity
370
- const dateTo = new Date(selected);
371
- // End slot is the start of the last selected slot; add one interval for the end time
372
- dateTo.setHours(endSlot.hour, endSlot.minute + slotIntervalMinutes, 0, 0);
373
- const door = doors.find((d) => d.id === dragDoorId);
374
- const selection = {
375
- doorId: dragDoorId,
376
- doorName: door?.name ?? "",
377
- dateFrom,
378
- dateTo
379
- };
362
+ const resourceId = dragResourceId;
363
+ // Capture done; clear the drag state before any early return so it is
364
+ // never left stuck for the next interaction.
380
365
  isDragging = false;
381
- dragDoorId = null;
366
+ dragResourceId = null;
382
367
  dragStartSlotIndex = null;
383
368
  dragEndSlotIndex = null;
384
- ondragcreate?.(selection);
369
+ // A single-cell selection is a plain click - intentionally inert. Creating
370
+ // from one cell requires a double-click (onSlotDoubleClick); only a drag
371
+ // across multiple cells opens the range-select flow here.
372
+ if (startIdx === endIdx) {
373
+ return;
374
+ }
375
+ onrangeselect?.(buildSlotRange(resourceId, startIdx, endIdx));
385
376
  }
386
- function isSlotInDragRange(doorId, slotIndex) {
387
- if (!isDragging || doorId !== dragDoorId) {
377
+ function isSlotInDragRange(resourceId, slotIndex) {
378
+ if (!isDragging || resourceId !== dragResourceId) {
388
379
  return false;
389
380
  }
390
381
  if (dragStartSlotIndex == null || dragEndSlotIndex == null) {
@@ -396,58 +387,57 @@ function isSlotInDragRange(doorId, slotIndex) {
396
387
  }
397
388
  // ---- Row hover tracking ----
398
389
  let hoveredSlotIndex = $state(null);
399
- // ---- Appointment drag-move logic ----
400
- let appointmentDragStarted = $state(false);
401
- let isDraggingAppointment = $state(false);
402
- let draggedAppointment = $state(null);
403
- let draggedAptSlotCount = $state(0);
404
- let moveTargetDoorId = $state(null);
390
+ // ---- Event drag-move logic ----
391
+ let eventDragStarted = $state(false);
392
+ let isDraggingEvent = $state(false);
393
+ let draggedEvent = $state(null);
394
+ let draggedSlotCount = $state(0);
395
+ let moveTargetResourceId = $state(null);
405
396
  let moveTargetSlotIndex = $state(null);
406
- function onAppointmentMouseDown(apt, e) {
407
- // Only react to the primary (left) button so middle/right clicks don't
408
- // start a move or register as an appointment click.
397
+ function onEventMouseDown(ev, e) {
398
+ // Only react to the primary (left) button.
409
399
  if (e.button !== 0) {
410
400
  return;
411
401
  }
412
- if (!onappointmentmove) {
402
+ if (!oneventmove) {
413
403
  return;
414
404
  }
415
405
  e.preventDefault();
416
- appointmentDragStarted = true;
417
- draggedAppointment = apt;
418
- const from = new Date(apt.dateFrom);
419
- const to = new Date(apt.dateTo);
406
+ eventDragStarted = true;
407
+ draggedEvent = ev;
408
+ const from = new Date(ev.start);
409
+ const to = new Date(ev.end);
420
410
  const durationMin = (to.getTime() - from.getTime()) / 6e4;
421
- draggedAptSlotCount = Math.max(1, Math.ceil(durationMin / slotIntervalMinutes));
422
- }
423
- function resetAppointmentDrag() {
424
- appointmentDragStarted = false;
425
- isDraggingAppointment = false;
426
- draggedAppointment = null;
427
- draggedAptSlotCount = 0;
428
- moveTargetDoorId = null;
411
+ draggedSlotCount = Math.max(1, Math.ceil(durationMin / slotIntervalMinutes));
412
+ }
413
+ function resetEventDrag() {
414
+ eventDragStarted = false;
415
+ isDraggingEvent = false;
416
+ draggedEvent = null;
417
+ draggedSlotCount = 0;
418
+ moveTargetResourceId = null;
429
419
  moveTargetSlotIndex = null;
430
420
  }
431
- function isSlotInMoveTarget(doorId, slotIndex) {
432
- if (!isDraggingAppointment || moveTargetDoorId !== doorId) {
421
+ function isSlotInMoveTarget(resourceId, slotIndex) {
422
+ if (!isDraggingEvent || moveTargetResourceId !== resourceId) {
433
423
  return false;
434
424
  }
435
425
  if (moveTargetSlotIndex == null) {
436
426
  return false;
437
427
  }
438
- return slotIndex >= moveTargetSlotIndex && slotIndex < moveTargetSlotIndex + draggedAptSlotCount;
428
+ return slotIndex >= moveTargetSlotIndex && slotIndex < moveTargetSlotIndex + draggedSlotCount;
439
429
  }
440
- function hasOverlap(doorId, dateFrom, dateTo, excludeAptId) {
441
- return appointments.some((apt) => {
442
- if (apt.id === excludeAptId) {
430
+ function hasOverlap(resourceId, start, end, excludeId) {
431
+ return events.some((ev) => {
432
+ if (ev.id === excludeId) {
443
433
  return false;
444
434
  }
445
- if (apt.segmentId !== doorId) {
435
+ if (ev.resourceId !== resourceId) {
446
436
  return false;
447
437
  }
448
- const aptFrom = new Date(apt.dateFrom);
449
- const aptTo = new Date(apt.dateTo);
450
- return dateFrom < aptTo && dateTo > aptFrom;
438
+ const evFrom = new Date(ev.start);
439
+ const evTo = new Date(ev.end);
440
+ return start < evTo && end > evFrom;
451
441
  });
452
442
  }
453
443
  // ---- Month view helpers ----
@@ -455,7 +445,7 @@ function getMonthDays(date) {
455
445
  const year = date.getFullYear();
456
446
  const month = date.getMonth();
457
447
  const firstDayOfMonth = new Date(year, month, 1);
458
- // Start from Monday of the week containing the first day
448
+ // Start from Monday of the week containing the first day.
459
449
  // eslint-disable-next-line svelte/prefer-svelte-reactivity
460
450
  const startDate = new Date(firstDayOfMonth);
461
451
  const dayOfWeek = startDate.getDay();
@@ -464,7 +454,7 @@ function getMonthDays(date) {
464
454
  const days = [];
465
455
  // eslint-disable-next-line svelte/prefer-svelte-reactivity
466
456
  const current = new Date(startDate);
467
- // Generate 6 weeks (42 days) to fill the grid
457
+ // Generate 6 weeks (42 days) to fill the grid.
468
458
  for (let i = 0; i < 42; i++) {
469
459
  days.push({
470
460
  date: new Date(current),
@@ -507,18 +497,10 @@ const viewModes = [
507
497
  }
508
498
  ];
509
499
  // ---- Centre-on-hour scrolling ----
510
- /** The whole hour whose row carries the `data-centre-row` marker. */
511
500
  const centreRowHour = $derived(centreHour == null ? null : Math.floor(centreHour));
512
- /** The scrollable calendar body; bound so we can centre it on the marker. */
513
501
  let calendarBody = $state();
514
- /**
515
- * Whether the grid has been centred for the current spell in a
516
- * day/week view. Plain (non-reactive) so re-centring only happens when
517
- * the grid (re)appears, never while the user scrolls or data loads.
518
- */
519
502
  let hasCentred = false;
520
503
  $effect(() => {
521
- // Re-arm whenever we leave the time-grid views so re-entry recentres.
522
504
  if (viewMode !== CalendarViewMode.Day && viewMode !== CalendarViewMode.Week) {
523
505
  hasCentred = false;
524
506
  return;
@@ -530,265 +512,255 @@ $effect(() => {
530
512
  if (!marker) {
531
513
  return;
532
514
  }
533
- // Centre the marker row in the viewport via rect maths so nested
534
- // sticky/relative offset parents don't skew the target.
535
515
  const bodyRect = calendarBody.getBoundingClientRect();
536
516
  const markerRect = marker.getBoundingClientRect();
537
517
  const delta = markerRect.top + markerRect.height / 2 - (bodyRect.top + bodyRect.height / 2);
538
518
  calendarBody.scrollTop += delta;
539
519
  hasCentred = true;
540
520
  });
541
- </script>
542
-
543
- <!-- A drag can end anywhere on the page (the pointer is often released outside
544
- the calendar when sweeping across columns). Listen on the window so the
545
- drag state is always finalised and never left "stuck" for the next drag. -->
546
- <svelte:window onmouseup={onSlotMouseUp} />
547
-
548
- <!-- svelte-ignore a11y_no_static_element_interactions -->
549
- <!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
550
- <div
551
- class="appointment-calendar"
552
- class:is-moving-appointment={isDraggingAppointment}
553
- role="application"
554
- >
555
- <!-- Header: navigation + view toggle -->
556
- <div class="calendar-header">
557
- <div class="header-left">
558
- <Button
559
- label="Today"
560
- size={Size.Small}
561
- onclick={goToToday}
562
- >
563
- Today
564
- </Button>
565
- <div class="nav-arrows">
566
- <button
567
- class="nav-btn"
568
- onclick={() => {
569
- navigateDate(-1);
570
- }}
571
- aria-label="Previous"
572
- >
573
- <Icon
574
- name="chevron-left"
575
- size={Size.Small}
576
- colour={Colour['$ui-blue-14']}
577
- />
578
- </button>
579
- <button
580
- class="nav-btn"
581
- onclick={() => {
582
- navigateDate(1);
583
- }}
584
- aria-label="Next"
585
- >
586
- <Icon
587
- name="chevron-right"
588
- size={Size.Small}
589
- colour={Colour['$ui-blue-14']}
590
- />
591
- </button>
592
- </div>
593
- <h2 class="current-date-label">{formatDate(selected)}</h2>
594
- </div>
595
- <div class="header-right">
596
- {#if headerExtra}
597
- {@render headerExtra()}
598
- {/if}
599
- <div class="view-toggle">
600
- {#each viewModes as { mode, label } (mode)}
601
- <button
602
- class="view-btn"
603
- class:active={viewMode === mode}
604
- onclick={() => {
605
- onviewmodechange(mode);
606
- }}
607
- >
608
- {label}
609
- </button>
610
- {/each}
611
- </div>
612
- </div>
613
- </div>
614
-
615
- <!-- Week day tabs rendered OUTSIDE the scrollable body so they stay frozen -->
616
- {#if viewMode === CalendarViewMode.Week}
617
- <div class="week-day-tabs">
618
- {#each weekDays as day (day.getTime())}
619
- <button
620
- class="week-day-tab"
621
- class:active={sameDay(day, selected)}
622
- class:today={sameDay(day, today)}
623
- onclick={() => {
624
- ondatechange(day);
625
- }}
626
- >
627
- <span class="day-name">{dayNames[day.getDay()].slice(0, 3)}</span>
628
- <span class="day-num">{day.getDate()}</span>
629
- </button>
630
- {/each}
631
- </div>
632
- {/if}
633
-
634
- <!-- Calendar body -->
635
- <div
636
- class="calendar-body"
637
- bind:this={calendarBody}
638
- >
639
- {#if isLoading}
640
- <LoadingOverlay />
641
- {/if}
642
-
643
- {#if viewMode === CalendarViewMode.Day}
644
- <!-- DAY VIEW -->
645
- <!-- svelte-ignore a11y_no_static_element_interactions -->
646
- <div
647
- class="day-view"
648
- onmouseleave={() => {
649
- hoveredSlotIndex = null;
650
- }}
651
- >
652
- <!-- eslint-disable-next-line @typescript-eslint/no-confusing-void-expression -->
653
- {@render doorGrid(selected)}
654
- </div>
655
- {:else if viewMode === CalendarViewMode.Week}
656
- <!-- WEEK VIEW: day grid only, tabs are above calendar-body -->
657
- <!-- svelte-ignore a11y_no_static_element_interactions -->
658
- <div
659
- class="day-view"
660
- onmouseleave={() => {
661
- hoveredSlotIndex = null;
662
- }}
663
- >
664
- <!-- eslint-disable-next-line @typescript-eslint/no-confusing-void-expression -->
665
- {@render doorGrid(selected)}
666
- </div>
667
- {:else}
668
- <!-- MONTH VIEW -->
669
- <div class="month-view">
670
- <div class="month-header-row">
671
- {#each ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'] as dayLabel (dayLabel)}
672
- <div class="month-day-header">{dayLabel}</div>
673
- {/each}
674
- </div>
675
- <div class="month-grid">
676
- {#each monthDays as { date: dayDate, isCurrentMonth } (dayDate.getTime())}
677
- <button
678
- class="month-day-cell"
679
- class:other-month={!isCurrentMonth}
680
- class:today={sameDay(dayDate, today)}
681
- class:selected={sameDay(dayDate, selected)}
682
- onclick={() => {
683
- ondatechange(dayDate);
684
- onviewmodechange(CalendarViewMode.Day);
685
- }}
686
- >
687
- <span class="day-number">{dayDate.getDate()}</span>
688
- <div class="day-badges">
689
- {#each countsAt(dayDate) as { doorId, doorName, count } (doorId)}
690
- <span class="door-badge">
691
- {doorName}: {count}
692
- </span>
693
- {/each}
694
- </div>
695
- </button>
696
- {/each}
697
- </div>
698
- </div>
699
- {/if}
700
- </div>
701
- </div>
702
-
703
- {#snippet doorGrid(day: Date)}
704
- <div class="grid-header">
705
- <div class="time-header">Time</div>
706
- {#each doors as door (door.id)}
707
- <div class="door-header">{door.name}</div>
708
- {/each}
709
- </div>
710
- <div class="grid-body">
711
- <div class="time-column">
712
- {#each timeSlots as slot, slotIdx (slot.hour * 60 + slot.minute)}
713
- <div
714
- class="time-label"
715
- class:hour-start={slot.minute === 0}
716
- class:row-hover={hoveredSlotIndex === slotIdx}
717
- data-centre-row={slot.hour === centreRowHour && slot.minute === 0
718
- ? ''
719
- : undefined}
720
- >
721
- {#if slot.minute === 0}
722
- <span>{slot.label}</span>
723
- {/if}
724
- </div>
725
- {/each}
726
- </div>
727
- {#each doors as door (door.id)}
728
- <div class="door-column">
729
- <!-- Slot cells for drag interaction (rendered first, sit below appointments) -->
730
- {#each timeSlots as slot, slotIdx (slot.hour * 60 + slot.minute)}
731
- <!-- svelte-ignore a11y_no_static_element_interactions -->
732
- <div
733
- class="slot-cell"
734
- class:hour-start={slot.minute === 0}
735
- class:drag-selected={isSlotInDragRange(door.id, slotIdx)}
736
- class:move-target={isSlotInMoveTarget(door.id, slotIdx)}
737
- class:row-hover={hoveredSlotIndex === slotIdx}
738
- class:unavailable={isSlotUnavailable(door.id, slotIdx)}
739
- onmousedown={(e) => {
740
- onSlotMouseDown(door.id, slotIdx, e);
741
- }}
742
- onmouseenter={(e) => {
743
- hoveredSlotIndex = slotIdx;
744
- onSlotMouseEnter(door.id, slotIdx, e);
745
- }}
746
- ></div>
747
- {/each}
748
- <!-- Unavailable shading: one continuous block per contiguous run -->
749
- {#each unavailableRunsAt(door.id) as run (run.topPercent)}
750
- <div
751
- class="unavailable-overlay"
752
- style="top: {run.topPercent}%; height: {run.heightPercent}%;"
753
- ></div>
754
- {/each}
755
- <!-- Appointment blocks positioned absolutely (rendered after, sit above slots) -->
756
- {#each appointmentsAt(door.id, day) as apt (apt.id)}
757
- <!-- svelte-ignore a11y_no_static_element_interactions -->
758
- <div
759
- class="appointment-block"
760
- class:inbound={apt.typeCode === 'INBOUND'}
761
- class:outbound={apt.typeCode === 'OUTBOUND'}
762
- class:dragging={isDraggingAppointment && draggedAppointment?.id === apt.id}
763
- class:movable={!!onappointmentmove}
764
- style="top: {apt.topPercent}%; height: {apt.heightPercent}%;"
765
- onmousedown={(e) => {
766
- onAppointmentMouseDown(apt, e);
767
- }}
768
- >
769
- <span class="apt-type">{apt.typeCode}</span>
770
- <span class="apt-label">{apt.label}</span>
771
- {#if apt.sublabel}
772
- <span class="apt-sublabel">{apt.sublabel}</span>
773
- {/if}
774
- {#if apt.reference}
775
- <span class="apt-reference">Ref: {apt.reference}</span>
776
- {/if}
777
- </div>
778
- {/each}
779
- </div>
780
- {/each}
781
- </div>
782
- {/snippet}
783
-
784
- <style>.appointment-calendar {
521
+ </script>
522
+
523
+ <!-- A drag can end anywhere on the page; listen on the window so the drag state
524
+ is always finalised and never left "stuck" for the next drag. -->
525
+ <svelte:window onmouseup={onSlotMouseUp} />
526
+
527
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
528
+ <!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
529
+ <div
530
+ class="resource-calendar"
531
+ class:is-moving-event={isDraggingEvent}
532
+ role="application"
533
+ >
534
+ <!-- Header: navigation + view toggle -->
535
+ <div class="calendar-header">
536
+ <div class="header-left">
537
+ <Button
538
+ label="Today"
539
+ size={Size.Small}
540
+ onclick={goToToday}
541
+ >
542
+ Today
543
+ </Button>
544
+ <div class="nav-arrows">
545
+ <button
546
+ class="nav-btn"
547
+ onclick={() => {
548
+ navigateDate(-1);
549
+ }}
550
+ aria-label="Previous"
551
+ >
552
+ <Icon
553
+ name="chevron-left"
554
+ size={Size.Small}
555
+ colour={Colour['$ui-blue-14']}
556
+ />
557
+ </button>
558
+ <button
559
+ class="nav-btn"
560
+ onclick={() => {
561
+ navigateDate(1);
562
+ }}
563
+ aria-label="Next"
564
+ >
565
+ <Icon
566
+ name="chevron-right"
567
+ size={Size.Small}
568
+ colour={Colour['$ui-blue-14']}
569
+ />
570
+ </button>
571
+ </div>
572
+ <h2 class="current-date-label">{formatDate(selected)}</h2>
573
+ </div>
574
+ <div class="header-right">
575
+ {#if headerExtra}
576
+ {@render headerExtra()}
577
+ {/if}
578
+ <div class="view-toggle">
579
+ {#each viewModes as { mode, label } (mode)}
580
+ <button
581
+ class="view-btn"
582
+ class:active={viewMode === mode}
583
+ onclick={() => {
584
+ onviewmodechange(mode);
585
+ }}
586
+ >
587
+ {label}
588
+ </button>
589
+ {/each}
590
+ </div>
591
+ </div>
592
+ </div>
593
+
594
+ <!-- Week day tabs rendered OUTSIDE the scrollable body so they stay frozen -->
595
+ {#if viewMode === CalendarViewMode.Week}
596
+ <div class="week-day-tabs">
597
+ {#each weekDays as day (day.getTime())}
598
+ <button
599
+ class="week-day-tab"
600
+ class:active={sameDay(day, selected)}
601
+ class:today={sameDay(day, today)}
602
+ onclick={() => {
603
+ ondatechange(day);
604
+ }}
605
+ >
606
+ <span class="day-name">{dayNames[day.getDay()].slice(0, 3)}</span>
607
+ <span class="day-num">{day.getDate()}</span>
608
+ </button>
609
+ {/each}
610
+ </div>
611
+ {/if}
612
+
613
+ <!-- Calendar body -->
614
+ <div
615
+ class="calendar-body"
616
+ bind:this={calendarBody}
617
+ >
618
+ {#if isLoading}
619
+ <LoadingOverlay />
620
+ {/if}
621
+
622
+ {#if viewMode === CalendarViewMode.Day}
623
+ <!-- DAY VIEW -->
624
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
625
+ <div
626
+ class="day-view"
627
+ onmouseleave={() => {
628
+ hoveredSlotIndex = null;
629
+ }}
630
+ >
631
+ <!-- eslint-disable-next-line @typescript-eslint/no-confusing-void-expression -->
632
+ {@render resourceGrid(selected)}
633
+ </div>
634
+ {:else if viewMode === CalendarViewMode.Week}
635
+ <!-- WEEK VIEW: day grid only, tabs are above calendar-body -->
636
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
637
+ <div
638
+ class="day-view"
639
+ onmouseleave={() => {
640
+ hoveredSlotIndex = null;
641
+ }}
642
+ >
643
+ <!-- eslint-disable-next-line @typescript-eslint/no-confusing-void-expression -->
644
+ {@render resourceGrid(selected)}
645
+ </div>
646
+ {:else}
647
+ <!-- MONTH VIEW -->
648
+ <div class="month-view">
649
+ <div class="month-header-row">
650
+ {#each ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'] as dayLabel (dayLabel)}
651
+ <div class="month-day-header">{dayLabel}</div>
652
+ {/each}
653
+ </div>
654
+ <div class="month-grid">
655
+ {#each monthDays as { date: dayDate, isCurrentMonth } (dayDate.getTime())}
656
+ <button
657
+ class="month-day-cell"
658
+ class:other-month={!isCurrentMonth}
659
+ class:today={sameDay(dayDate, today)}
660
+ class:selected={sameDay(dayDate, selected)}
661
+ onclick={() => {
662
+ ondatechange(dayDate);
663
+ onviewmodechange(CalendarViewMode.Day);
664
+ }}
665
+ >
666
+ <span class="day-number">{dayDate.getDate()}</span>
667
+ <div class="day-badges">
668
+ {#each countsAt(dayDate) as { resourceId, resourceLabel, count } (resourceId)}
669
+ <span class="resource-badge">
670
+ {resourceLabel}: {count}
671
+ </span>
672
+ {/each}
673
+ </div>
674
+ </button>
675
+ {/each}
676
+ </div>
677
+ </div>
678
+ {/if}
679
+ </div>
680
+ </div>
681
+
682
+ {#snippet resourceGrid(day: Date)}
683
+ <div class="grid-header">
684
+ <div class="time-header">Time</div>
685
+ {#each resources as resource (resource.id)}
686
+ <div class="resource-header">{resource.label}</div>
687
+ {/each}
688
+ </div>
689
+ <div class="grid-body">
690
+ <div class="time-column">
691
+ {#each timeSlots as slot, slotIdx (slot.hour * 60 + slot.minute)}
692
+ <div
693
+ class="time-label"
694
+ class:hour-start={slot.minute === 0}
695
+ class:row-hover={hoveredSlotIndex === slotIdx}
696
+ data-centre-row={slot.hour === centreRowHour && slot.minute === 0 ? '' : undefined}
697
+ >
698
+ {#if slot.minute === 0}
699
+ <span>{slot.label}</span>
700
+ {/if}
701
+ </div>
702
+ {/each}
703
+ </div>
704
+ {#each resources as resource (resource.id)}
705
+ <div class="resource-column">
706
+ <!-- Slot cells for drag interaction (rendered first, below events) -->
707
+ {#each timeSlots as slot, slotIdx (slot.hour * 60 + slot.minute)}
708
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
709
+ <div
710
+ class="slot-cell"
711
+ class:hour-start={slot.minute === 0}
712
+ class:drag-selected={isSlotInDragRange(resource.id, slotIdx)}
713
+ class:move-target={isSlotInMoveTarget(resource.id, slotIdx)}
714
+ class:row-hover={hoveredSlotIndex === slotIdx}
715
+ class:unavailable={isSlotUnavailable(resource.id, slotIdx)}
716
+ onmousedown={(e) => {
717
+ onSlotMouseDown(resource.id, slotIdx, e);
718
+ }}
719
+ onmouseenter={(e) => {
720
+ hoveredSlotIndex = slotIdx;
721
+ onSlotMouseEnter(resource.id, slotIdx, e);
722
+ }}
723
+ ondblclick={() => {
724
+ onSlotDoubleClick(resource.id, slotIdx);
725
+ }}
726
+ ></div>
727
+ {/each}
728
+ <!-- Unavailable shading: one continuous block per contiguous run -->
729
+ {#each unavailableRunsAt(resource.id) as run (run.topPercent)}
730
+ <div
731
+ class="unavailable-overlay"
732
+ style="top: {run.topPercent}%; height: {run.heightPercent}%;"
733
+ ></div>
734
+ {/each}
735
+ <!-- Event blocks positioned absolutely (rendered after, above slots).
736
+ The consumer renders the card body via the `event` snippet. -->
737
+ {#each eventsAt(resource.id, day) as ev (ev.id)}
738
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
739
+ <div
740
+ class="event-block"
741
+ class:dragging={isDraggingEvent && draggedEvent?.id === ev.id}
742
+ class:movable={!!oneventmove}
743
+ style="top: {ev.topPercent}%; height: {ev.heightPercent}%;"
744
+ onmousedown={(e) => {
745
+ onEventMouseDown(ev, e);
746
+ }}
747
+ >
748
+ {@render eventContent(ev)}
749
+ </div>
750
+ {/each}
751
+ </div>
752
+ {/each}
753
+ </div>
754
+ {/snippet}
755
+
756
+ <style>.resource-calendar {
785
757
  display: flex;
786
758
  flex-flow: column nowrap;
787
759
  height: 100%;
788
760
  background-color: #ffffff;
789
761
  overflow: hidden;
790
762
  }
791
- .appointment-calendar.is-moving-appointment {
763
+ .resource-calendar.is-moving-event {
792
764
  cursor: grabbing;
793
765
  }
794
766
 
@@ -899,7 +871,7 @@ $effect(() => {
899
871
  }
900
872
 
901
873
  .time-header,
902
- .door-header {
874
+ .resource-header {
903
875
  padding: 0.5rem;
904
876
  font-size: 0.875rem;
905
877
  font-weight: 600;
@@ -919,7 +891,7 @@ $effect(() => {
919
891
  background-color: #e9ecf1;
920
892
  }
921
893
 
922
- .door-header {
894
+ .resource-header {
923
895
  flex: 1;
924
896
  min-width: 8rem;
925
897
  }
@@ -964,7 +936,7 @@ $effect(() => {
964
936
  white-space: nowrap;
965
937
  }
966
938
 
967
- .door-column {
939
+ .resource-column {
968
940
  flex: 1;
969
941
  min-width: 8rem;
970
942
  position: relative;
@@ -1010,75 +982,21 @@ $effect(() => {
1010
982
  background-image: repeating-linear-gradient(45deg, transparent, transparent 6px, rgba(128, 150, 165, 0.14) 6px, rgba(128, 150, 165, 0.14) 12px);
1011
983
  }
1012
984
 
1013
- .appointment-block {
985
+ .event-block {
1014
986
  position: absolute;
1015
987
  left: 2px;
1016
988
  right: 2px;
1017
989
  z-index: 5;
1018
- border-radius: 4px;
1019
- border: solid 1px #9fdaec;
1020
- background-color: #f4fbfd;
1021
- padding: 0.25rem 0.4rem;
1022
- display: flex;
1023
- flex-flow: column nowrap;
1024
- gap: 0.15rem;
1025
- overflow: hidden;
1026
990
  cursor: pointer;
1027
- font-size: 0.75rem;
1028
- text-align: left;
1029
- transition: border-color ease-out 0.15s 0s, background-color ease-out 0.15s 0s, color ease-out 0.15s 0s, fill ease-out 0.15s 0s;
1030
991
  user-select: none;
1031
992
  }
1032
- .appointment-block.movable {
993
+ .event-block.movable {
1033
994
  cursor: grab;
1034
995
  }
1035
- .appointment-block.dragging {
996
+ .event-block.dragging {
1036
997
  opacity: 0.4;
1037
998
  cursor: grabbing;
1038
999
  }
1039
- .appointment-block:hover {
1040
- background-color: #e6f8fd;
1041
- border-color: #74c9e4;
1042
- }
1043
- .appointment-block.inbound {
1044
- border-color: #9fdaec;
1045
- }
1046
- .appointment-block.inbound .apt-type {
1047
- color: #259fc7;
1048
- }
1049
- .appointment-block.outbound {
1050
- border-color: #66bb6a;
1051
- background-color: #eaf7ec;
1052
- }
1053
- .appointment-block.outbound .apt-type {
1054
- color: #2e7d32;
1055
- }
1056
- .appointment-block.outbound:hover {
1057
- background-color: #dcefdd;
1058
- border-color: #43a047;
1059
- }
1060
-
1061
- .apt-type {
1062
- font-weight: 600;
1063
- font-size: 0.75rem;
1064
- text-transform: uppercase;
1065
- }
1066
-
1067
- .apt-label {
1068
- color: #3a4952;
1069
- font-weight: 500;
1070
- }
1071
-
1072
- .apt-sublabel {
1073
- color: #647d8e;
1074
- font-size: 0.75rem;
1075
- }
1076
-
1077
- .apt-reference {
1078
- color: #647d8e;
1079
- font-size: 0.75rem;
1080
- font-weight: 600;
1081
- }
1082
1000
 
1083
1001
  .week-day-tabs {
1084
1002
  display: flex;
@@ -1222,7 +1140,7 @@ $effect(() => {
1222
1140
  overflow: hidden;
1223
1141
  }
1224
1142
 
1225
- .door-badge {
1143
+ .resource-badge {
1226
1144
  font-size: 0.75rem;
1227
1145
  color: #259fc7;
1228
1146
  background-color: #e6f8fd;
@@ -1231,4 +1149,4 @@ $effect(() => {
1231
1149
  white-space: nowrap;
1232
1150
  overflow: hidden;
1233
1151
  text-overflow: ellipsis;
1234
- }</style>
1152
+ }</style>