@lavalogic/scoria 0.40.20 → 0.40.22

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,96 +245,88 @@ 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
  }
324
297
  function onSlotMouseUp() {
325
- // ---- Handle appointment drag-move ----
326
- if (isDraggingAppointment && draggedAppointment && moveTargetDoorId != null && moveTargetSlotIndex != null) {
298
+ // ---- Handle event drag-move ----
299
+ if (isDraggingEvent && draggedEvent && moveTargetResourceId != null && moveTargetSlotIndex != null) {
327
300
  const slot = timeSlots[moveTargetSlotIndex];
328
301
  // 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();
302
+ const newStart = new Date(selected);
303
+ newStart.setHours(slot.hour, slot.minute, 0, 0);
304
+ const newEnd = new Date(newStart.getTime() + draggedSlotCount * slotIntervalMinutes * 6e4);
305
+ const origFrom = new Date(draggedEvent.start);
306
+ const samePosition = moveTargetResourceId === draggedEvent.resourceId && newStart.getTime() === origFrom.getTime();
335
307
  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
308
+ // Dropped in the same spot - treat as a click.
309
+ oneventclick?.(draggedEvent);
310
+ } else if (!hasOverlap(moveTargetResourceId, newStart, newEnd, draggedEvent.id)) {
311
+ oneventmove?.({
312
+ event: draggedEvent,
313
+ newResourceId: moveTargetResourceId,
314
+ newStart,
315
+ newEnd
346
316
  });
347
317
  }
348
- resetAppointmentDrag();
318
+ resetEventDrag();
349
319
  return;
350
320
  }
351
- // ---- Handle appointment click (mousedown + mouseup without leaving the block) ----
352
- if (appointmentDragStarted && !isDraggingAppointment && draggedAppointment) {
353
- onappointmentclick?.(draggedAppointment);
354
- resetAppointmentDrag();
321
+ // ---- Handle event click (mousedown + mouseup without leaving the block) ----
322
+ if (eventDragStarted && !isDraggingEvent && draggedEvent) {
323
+ oneventclick?.(draggedEvent);
324
+ resetEventDrag();
355
325
  return;
356
326
  }
357
- resetAppointmentDrag();
327
+ resetEventDrag();
358
328
  // ---- Handle drag-create ----
359
- if (!isDragging || dragDoorId == null || dragStartSlotIndex == null || dragEndSlotIndex == null) {
329
+ if (!isDragging || dragResourceId == null || dragStartSlotIndex == null || dragEndSlotIndex == null) {
360
330
  return;
361
331
  }
362
332
  const startIdx = Math.min(dragStartSlotIndex, dragEndSlotIndex);
@@ -364,27 +334,25 @@ function onSlotMouseUp() {
364
334
  const startSlot = timeSlots[startIdx];
365
335
  const endSlot = timeSlots[endIdx];
366
336
  // eslint-disable-next-line svelte/prefer-svelte-reactivity
367
- const dateFrom = new Date(selected);
368
- dateFrom.setHours(startSlot.hour, startSlot.minute, 0, 0);
337
+ const start = new Date(selected);
338
+ start.setHours(startSlot.hour, startSlot.minute, 0, 0);
369
339
  // 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);
340
+ const end = new Date(selected);
341
+ // End slot is the start of the last selected slot; add one interval.
342
+ end.setHours(endSlot.hour, endSlot.minute + slotIntervalMinutes, 0, 0);
374
343
  const selection = {
375
- doorId: dragDoorId,
376
- doorName: door?.name ?? "",
377
- dateFrom,
378
- dateTo
344
+ resourceId: dragResourceId,
345
+ start,
346
+ end
379
347
  };
380
348
  isDragging = false;
381
- dragDoorId = null;
349
+ dragResourceId = null;
382
350
  dragStartSlotIndex = null;
383
351
  dragEndSlotIndex = null;
384
- ondragcreate?.(selection);
352
+ onrangeselect?.(selection);
385
353
  }
386
- function isSlotInDragRange(doorId, slotIndex) {
387
- if (!isDragging || doorId !== dragDoorId) {
354
+ function isSlotInDragRange(resourceId, slotIndex) {
355
+ if (!isDragging || resourceId !== dragResourceId) {
388
356
  return false;
389
357
  }
390
358
  if (dragStartSlotIndex == null || dragEndSlotIndex == null) {
@@ -396,58 +364,57 @@ function isSlotInDragRange(doorId, slotIndex) {
396
364
  }
397
365
  // ---- Row hover tracking ----
398
366
  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);
367
+ // ---- Event drag-move logic ----
368
+ let eventDragStarted = $state(false);
369
+ let isDraggingEvent = $state(false);
370
+ let draggedEvent = $state(null);
371
+ let draggedSlotCount = $state(0);
372
+ let moveTargetResourceId = $state(null);
405
373
  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.
374
+ function onEventMouseDown(ev, e) {
375
+ // Only react to the primary (left) button.
409
376
  if (e.button !== 0) {
410
377
  return;
411
378
  }
412
- if (!onappointmentmove) {
379
+ if (!oneventmove) {
413
380
  return;
414
381
  }
415
382
  e.preventDefault();
416
- appointmentDragStarted = true;
417
- draggedAppointment = apt;
418
- const from = new Date(apt.dateFrom);
419
- const to = new Date(apt.dateTo);
383
+ eventDragStarted = true;
384
+ draggedEvent = ev;
385
+ const from = new Date(ev.start);
386
+ const to = new Date(ev.end);
420
387
  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;
388
+ draggedSlotCount = Math.max(1, Math.ceil(durationMin / slotIntervalMinutes));
389
+ }
390
+ function resetEventDrag() {
391
+ eventDragStarted = false;
392
+ isDraggingEvent = false;
393
+ draggedEvent = null;
394
+ draggedSlotCount = 0;
395
+ moveTargetResourceId = null;
429
396
  moveTargetSlotIndex = null;
430
397
  }
431
- function isSlotInMoveTarget(doorId, slotIndex) {
432
- if (!isDraggingAppointment || moveTargetDoorId !== doorId) {
398
+ function isSlotInMoveTarget(resourceId, slotIndex) {
399
+ if (!isDraggingEvent || moveTargetResourceId !== resourceId) {
433
400
  return false;
434
401
  }
435
402
  if (moveTargetSlotIndex == null) {
436
403
  return false;
437
404
  }
438
- return slotIndex >= moveTargetSlotIndex && slotIndex < moveTargetSlotIndex + draggedAptSlotCount;
405
+ return slotIndex >= moveTargetSlotIndex && slotIndex < moveTargetSlotIndex + draggedSlotCount;
439
406
  }
440
- function hasOverlap(doorId, dateFrom, dateTo, excludeAptId) {
441
- return appointments.some((apt) => {
442
- if (apt.id === excludeAptId) {
407
+ function hasOverlap(resourceId, start, end, excludeId) {
408
+ return events.some((ev) => {
409
+ if (ev.id === excludeId) {
443
410
  return false;
444
411
  }
445
- if (apt.segmentId !== doorId) {
412
+ if (ev.resourceId !== resourceId) {
446
413
  return false;
447
414
  }
448
- const aptFrom = new Date(apt.dateFrom);
449
- const aptTo = new Date(apt.dateTo);
450
- return dateFrom < aptTo && dateTo > aptFrom;
415
+ const evFrom = new Date(ev.start);
416
+ const evTo = new Date(ev.end);
417
+ return start < evTo && end > evFrom;
451
418
  });
452
419
  }
453
420
  // ---- Month view helpers ----
@@ -455,7 +422,7 @@ function getMonthDays(date) {
455
422
  const year = date.getFullYear();
456
423
  const month = date.getMonth();
457
424
  const firstDayOfMonth = new Date(year, month, 1);
458
- // Start from Monday of the week containing the first day
425
+ // Start from Monday of the week containing the first day.
459
426
  // eslint-disable-next-line svelte/prefer-svelte-reactivity
460
427
  const startDate = new Date(firstDayOfMonth);
461
428
  const dayOfWeek = startDate.getDay();
@@ -464,7 +431,7 @@ function getMonthDays(date) {
464
431
  const days = [];
465
432
  // eslint-disable-next-line svelte/prefer-svelte-reactivity
466
433
  const current = new Date(startDate);
467
- // Generate 6 weeks (42 days) to fill the grid
434
+ // Generate 6 weeks (42 days) to fill the grid.
468
435
  for (let i = 0; i < 42; i++) {
469
436
  days.push({
470
437
  date: new Date(current),
@@ -507,18 +474,10 @@ const viewModes = [
507
474
  }
508
475
  ];
509
476
  // ---- Centre-on-hour scrolling ----
510
- /** The whole hour whose row carries the `data-centre-row` marker. */
511
477
  const centreRowHour = $derived(centreHour == null ? null : Math.floor(centreHour));
512
- /** The scrollable calendar body; bound so we can centre it on the marker. */
513
478
  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
479
  let hasCentred = false;
520
480
  $effect(() => {
521
- // Re-arm whenever we leave the time-grid views so re-entry recentres.
522
481
  if (viewMode !== CalendarViewMode.Day && viewMode !== CalendarViewMode.Week) {
523
482
  hasCentred = false;
524
483
  return;
@@ -530,265 +489,252 @@ $effect(() => {
530
489
  if (!marker) {
531
490
  return;
532
491
  }
533
- // Centre the marker row in the viewport via rect maths so nested
534
- // sticky/relative offset parents don't skew the target.
535
492
  const bodyRect = calendarBody.getBoundingClientRect();
536
493
  const markerRect = marker.getBoundingClientRect();
537
494
  const delta = markerRect.top + markerRect.height / 2 - (bodyRect.top + bodyRect.height / 2);
538
495
  calendarBody.scrollTop += delta;
539
496
  hasCentred = true;
540
497
  });
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 {
498
+ </script>
499
+
500
+ <!-- A drag can end anywhere on the page; listen on the window so the drag state
501
+ is always finalised and never left "stuck" for the next drag. -->
502
+ <svelte:window onmouseup={onSlotMouseUp} />
503
+
504
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
505
+ <!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
506
+ <div
507
+ class="resource-calendar"
508
+ class:is-moving-event={isDraggingEvent}
509
+ role="application"
510
+ >
511
+ <!-- Header: navigation + view toggle -->
512
+ <div class="calendar-header">
513
+ <div class="header-left">
514
+ <Button
515
+ label="Today"
516
+ size={Size.Small}
517
+ onclick={goToToday}
518
+ >
519
+ Today
520
+ </Button>
521
+ <div class="nav-arrows">
522
+ <button
523
+ class="nav-btn"
524
+ onclick={() => {
525
+ navigateDate(-1);
526
+ }}
527
+ aria-label="Previous"
528
+ >
529
+ <Icon
530
+ name="chevron-left"
531
+ size={Size.Small}
532
+ colour={Colour['$ui-blue-14']}
533
+ />
534
+ </button>
535
+ <button
536
+ class="nav-btn"
537
+ onclick={() => {
538
+ navigateDate(1);
539
+ }}
540
+ aria-label="Next"
541
+ >
542
+ <Icon
543
+ name="chevron-right"
544
+ size={Size.Small}
545
+ colour={Colour['$ui-blue-14']}
546
+ />
547
+ </button>
548
+ </div>
549
+ <h2 class="current-date-label">{formatDate(selected)}</h2>
550
+ </div>
551
+ <div class="header-right">
552
+ {#if headerExtra}
553
+ {@render headerExtra()}
554
+ {/if}
555
+ <div class="view-toggle">
556
+ {#each viewModes as { mode, label } (mode)}
557
+ <button
558
+ class="view-btn"
559
+ class:active={viewMode === mode}
560
+ onclick={() => {
561
+ onviewmodechange(mode);
562
+ }}
563
+ >
564
+ {label}
565
+ </button>
566
+ {/each}
567
+ </div>
568
+ </div>
569
+ </div>
570
+
571
+ <!-- Week day tabs rendered OUTSIDE the scrollable body so they stay frozen -->
572
+ {#if viewMode === CalendarViewMode.Week}
573
+ <div class="week-day-tabs">
574
+ {#each weekDays as day (day.getTime())}
575
+ <button
576
+ class="week-day-tab"
577
+ class:active={sameDay(day, selected)}
578
+ class:today={sameDay(day, today)}
579
+ onclick={() => {
580
+ ondatechange(day);
581
+ }}
582
+ >
583
+ <span class="day-name">{dayNames[day.getDay()].slice(0, 3)}</span>
584
+ <span class="day-num">{day.getDate()}</span>
585
+ </button>
586
+ {/each}
587
+ </div>
588
+ {/if}
589
+
590
+ <!-- Calendar body -->
591
+ <div
592
+ class="calendar-body"
593
+ bind:this={calendarBody}
594
+ >
595
+ {#if isLoading}
596
+ <LoadingOverlay />
597
+ {/if}
598
+
599
+ {#if viewMode === CalendarViewMode.Day}
600
+ <!-- DAY VIEW -->
601
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
602
+ <div
603
+ class="day-view"
604
+ onmouseleave={() => {
605
+ hoveredSlotIndex = null;
606
+ }}
607
+ >
608
+ <!-- eslint-disable-next-line @typescript-eslint/no-confusing-void-expression -->
609
+ {@render resourceGrid(selected)}
610
+ </div>
611
+ {:else if viewMode === CalendarViewMode.Week}
612
+ <!-- WEEK VIEW: day grid only, tabs are above calendar-body -->
613
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
614
+ <div
615
+ class="day-view"
616
+ onmouseleave={() => {
617
+ hoveredSlotIndex = null;
618
+ }}
619
+ >
620
+ <!-- eslint-disable-next-line @typescript-eslint/no-confusing-void-expression -->
621
+ {@render resourceGrid(selected)}
622
+ </div>
623
+ {:else}
624
+ <!-- MONTH VIEW -->
625
+ <div class="month-view">
626
+ <div class="month-header-row">
627
+ {#each ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'] as dayLabel (dayLabel)}
628
+ <div class="month-day-header">{dayLabel}</div>
629
+ {/each}
630
+ </div>
631
+ <div class="month-grid">
632
+ {#each monthDays as { date: dayDate, isCurrentMonth } (dayDate.getTime())}
633
+ <button
634
+ class="month-day-cell"
635
+ class:other-month={!isCurrentMonth}
636
+ class:today={sameDay(dayDate, today)}
637
+ class:selected={sameDay(dayDate, selected)}
638
+ onclick={() => {
639
+ ondatechange(dayDate);
640
+ onviewmodechange(CalendarViewMode.Day);
641
+ }}
642
+ >
643
+ <span class="day-number">{dayDate.getDate()}</span>
644
+ <div class="day-badges">
645
+ {#each countsAt(dayDate) as { resourceId, resourceLabel, count } (resourceId)}
646
+ <span class="resource-badge">
647
+ {resourceLabel}: {count}
648
+ </span>
649
+ {/each}
650
+ </div>
651
+ </button>
652
+ {/each}
653
+ </div>
654
+ </div>
655
+ {/if}
656
+ </div>
657
+ </div>
658
+
659
+ {#snippet resourceGrid(day: Date)}
660
+ <div class="grid-header">
661
+ <div class="time-header">Time</div>
662
+ {#each resources as resource (resource.id)}
663
+ <div class="resource-header">{resource.label}</div>
664
+ {/each}
665
+ </div>
666
+ <div class="grid-body">
667
+ <div class="time-column">
668
+ {#each timeSlots as slot, slotIdx (slot.hour * 60 + slot.minute)}
669
+ <div
670
+ class="time-label"
671
+ class:hour-start={slot.minute === 0}
672
+ class:row-hover={hoveredSlotIndex === slotIdx}
673
+ data-centre-row={slot.hour === centreRowHour && slot.minute === 0 ? '' : undefined}
674
+ >
675
+ {#if slot.minute === 0}
676
+ <span>{slot.label}</span>
677
+ {/if}
678
+ </div>
679
+ {/each}
680
+ </div>
681
+ {#each resources as resource (resource.id)}
682
+ <div class="resource-column">
683
+ <!-- Slot cells for drag interaction (rendered first, below events) -->
684
+ {#each timeSlots as slot, slotIdx (slot.hour * 60 + slot.minute)}
685
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
686
+ <div
687
+ class="slot-cell"
688
+ class:hour-start={slot.minute === 0}
689
+ class:drag-selected={isSlotInDragRange(resource.id, slotIdx)}
690
+ class:move-target={isSlotInMoveTarget(resource.id, slotIdx)}
691
+ class:row-hover={hoveredSlotIndex === slotIdx}
692
+ class:unavailable={isSlotUnavailable(resource.id, slotIdx)}
693
+ onmousedown={(e) => {
694
+ onSlotMouseDown(resource.id, slotIdx, e);
695
+ }}
696
+ onmouseenter={(e) => {
697
+ hoveredSlotIndex = slotIdx;
698
+ onSlotMouseEnter(resource.id, slotIdx, e);
699
+ }}
700
+ ></div>
701
+ {/each}
702
+ <!-- Unavailable shading: one continuous block per contiguous run -->
703
+ {#each unavailableRunsAt(resource.id) as run (run.topPercent)}
704
+ <div
705
+ class="unavailable-overlay"
706
+ style="top: {run.topPercent}%; height: {run.heightPercent}%;"
707
+ ></div>
708
+ {/each}
709
+ <!-- Event blocks positioned absolutely (rendered after, above slots).
710
+ The consumer renders the card body via the `event` snippet. -->
711
+ {#each eventsAt(resource.id, day) as ev (ev.id)}
712
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
713
+ <div
714
+ class="event-block"
715
+ class:dragging={isDraggingEvent && draggedEvent?.id === ev.id}
716
+ class:movable={!!oneventmove}
717
+ style="top: {ev.topPercent}%; height: {ev.heightPercent}%;"
718
+ onmousedown={(e) => {
719
+ onEventMouseDown(ev, e);
720
+ }}
721
+ >
722
+ {@render eventContent(ev)}
723
+ </div>
724
+ {/each}
725
+ </div>
726
+ {/each}
727
+ </div>
728
+ {/snippet}
729
+
730
+ <style>.resource-calendar {
785
731
  display: flex;
786
732
  flex-flow: column nowrap;
787
733
  height: 100%;
788
734
  background-color: #ffffff;
789
735
  overflow: hidden;
790
736
  }
791
- .appointment-calendar.is-moving-appointment {
737
+ .resource-calendar.is-moving-event {
792
738
  cursor: grabbing;
793
739
  }
794
740
 
@@ -899,7 +845,7 @@ $effect(() => {
899
845
  }
900
846
 
901
847
  .time-header,
902
- .door-header {
848
+ .resource-header {
903
849
  padding: 0.5rem;
904
850
  font-size: 0.875rem;
905
851
  font-weight: 600;
@@ -919,7 +865,7 @@ $effect(() => {
919
865
  background-color: #e9ecf1;
920
866
  }
921
867
 
922
- .door-header {
868
+ .resource-header {
923
869
  flex: 1;
924
870
  min-width: 8rem;
925
871
  }
@@ -964,7 +910,7 @@ $effect(() => {
964
910
  white-space: nowrap;
965
911
  }
966
912
 
967
- .door-column {
913
+ .resource-column {
968
914
  flex: 1;
969
915
  min-width: 8rem;
970
916
  position: relative;
@@ -1010,75 +956,23 @@ $effect(() => {
1010
956
  background-image: repeating-linear-gradient(45deg, transparent, transparent 6px, rgba(128, 150, 165, 0.14) 6px, rgba(128, 150, 165, 0.14) 12px);
1011
957
  }
1012
958
 
1013
- .appointment-block {
959
+ .event-block {
1014
960
  position: absolute;
1015
961
  left: 2px;
1016
962
  right: 2px;
1017
963
  z-index: 5;
1018
964
  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
965
  overflow: hidden;
1026
966
  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
967
  user-select: none;
1031
968
  }
1032
- .appointment-block.movable {
969
+ .event-block.movable {
1033
970
  cursor: grab;
1034
971
  }
1035
- .appointment-block.dragging {
972
+ .event-block.dragging {
1036
973
  opacity: 0.4;
1037
974
  cursor: grabbing;
1038
975
  }
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
976
 
1083
977
  .week-day-tabs {
1084
978
  display: flex;
@@ -1093,7 +987,8 @@ $effect(() => {
1093
987
  display: flex;
1094
988
  flex-flow: column nowrap;
1095
989
  align-items: center;
1096
- gap: 0.15rem;
990
+ justify-content: center;
991
+ gap: 0.2rem;
1097
992
  padding: 0.5rem 0.25rem;
1098
993
  border: none;
1099
994
  border-right: solid 1px #cbd4da;
@@ -1115,24 +1010,26 @@ $effect(() => {
1115
1010
  background-color: #259fc7;
1116
1011
  color: #ffffff;
1117
1012
  border-radius: 50%;
1118
- width: 1.5rem;
1119
- height: 1.5rem;
1120
- display: flex;
1121
- align-items: center;
1122
- justify-content: center;
1123
1013
  }
1124
1014
 
1125
1015
  .day-name {
1126
- font-size: 0.75rem;
1016
+ font-size: 0.875rem;
1127
1017
  color: #647d8e;
1128
1018
  text-transform: uppercase;
1129
1019
  font-weight: 600;
1020
+ line-height: 1;
1130
1021
  }
1131
1022
 
1132
1023
  .day-num {
1133
- font-size: 1rem;
1134
- color: #3a4952;
1135
- font-weight: 500;
1024
+ font-size: 0.875rem;
1025
+ color: #647d8e;
1026
+ font-weight: 600;
1027
+ line-height: 1;
1028
+ height: 1.5rem;
1029
+ min-width: 1.5rem;
1030
+ display: flex;
1031
+ align-items: center;
1032
+ justify-content: center;
1136
1033
  }
1137
1034
 
1138
1035
  .month-view {
@@ -1219,7 +1116,7 @@ $effect(() => {
1219
1116
  overflow: hidden;
1220
1117
  }
1221
1118
 
1222
- .door-badge {
1119
+ .resource-badge {
1223
1120
  font-size: 0.75rem;
1224
1121
  color: #259fc7;
1225
1122
  background-color: #e6f8fd;
@@ -1228,4 +1125,4 @@ $effect(() => {
1228
1125
  white-space: nowrap;
1229
1126
  overflow: hidden;
1230
1127
  text-overflow: ellipsis;
1231
- }</style>
1128
+ }</style>