@lavalogic/scoria 0.40.16 → 0.40.18

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.
@@ -6,7 +6,7 @@ import Button from "../Button.svelte";
6
6
  import Icon from "../Icon.svelte";
7
7
  import LoadingOverlay from "../LoadingOverlay.svelte";
8
8
  import { CalendarViewMode } from "./AppointmentCalendarProps.js";
9
- let { doors, appointments, selectedDate, viewMode, ondatechange, onviewmodechange, ondragcreate, onappointmentclick, onappointmentmove, startHour = 0, endHour = 24, slotIntervalMinutes = 15, isLoading = false, headerExtra } = $props();
9
+ let { doors, appointments, selectedDate, viewMode, ondatechange, onviewmodechange, ondragcreate, onappointmentclick, onappointmentmove, startHour = 0, endHour = 24, slotIntervalMinutes = 15, availability, centreHour = 12, isLoading = false, headerExtra } = $props();
10
10
  /**
11
11
  * `selectedDate` is always read by the component, never written. The
12
12
  * runtime type may be either a plain `Date` or a `SvelteDate`; both
@@ -92,6 +92,83 @@ const timeSlots = $derived.by(() => {
92
92
  }
93
93
  return slots;
94
94
  });
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
+ function slotUnavailableInWindows(windows, slot) {
104
+ const minutes = slot.hour * 60 + slot.minute;
105
+ for (const window of windows) {
106
+ if (minutes >= window.startMinutes && minutes < window.endMinutes) {
107
+ return false;
108
+ }
109
+ }
110
+ return true;
111
+ }
112
+ function isSlotUnavailable(doorId, slotIndex) {
113
+ if (!availability) {
114
+ return false;
115
+ }
116
+ const windows = availability.get(doorId);
117
+ if (!windows) {
118
+ return false;
119
+ }
120
+ const slot = timeSlots[slotIndex];
121
+ if (!slot) {
122
+ return false;
123
+ }
124
+ return slotUnavailableInWindows(windows, slot);
125
+ }
126
+ /**
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.
131
+ */
132
+ const unavailableRunsByDoor = $derived.by(() => {
133
+ // Local index built inside a $derived; outer reactivity covers reads.
134
+ // eslint-disable-next-line svelte/prefer-svelte-reactivity
135
+ const byDoor = new Map();
136
+ const slotCount = timeSlots.length;
137
+ if (!availability || slotCount === 0) {
138
+ return byDoor;
139
+ }
140
+ for (const door of doors) {
141
+ const windows = availability.get(door.id);
142
+ if (!windows) {
143
+ continue;
144
+ }
145
+ const runs = [];
146
+ let runStart = null;
147
+ // One extra iteration (i === slotCount) flushes a run that reaches
148
+ // the end of the day.
149
+ for (let i = 0; i <= slotCount; i++) {
150
+ const unavailable = i < slotCount && slotUnavailableInWindows(windows, timeSlots[i]);
151
+ if (unavailable) {
152
+ if (runStart === null) {
153
+ runStart = i;
154
+ }
155
+ } else if (runStart !== null) {
156
+ runs.push({
157
+ topPercent: runStart / slotCount * 100,
158
+ heightPercent: (i - runStart) / slotCount * 100
159
+ });
160
+ runStart = null;
161
+ }
162
+ }
163
+ if (runs.length > 0) {
164
+ byDoor.set(door.id, runs);
165
+ }
166
+ }
167
+ return byDoor;
168
+ });
169
+ function unavailableRunsAt(doorId) {
170
+ return unavailableRunsByDoor.get(doorId) ?? [];
171
+ }
95
172
  /**
96
173
  * Build a `YYYY-MM-DD` key for date bucketing. Avoids `toISOString`
97
174
  * timezone conversions that would smear appointments across days.
@@ -202,6 +279,10 @@ function onSlotMouseDown(doorId, slotIndex, e) {
202
279
  if (!ondragcreate || appointmentDragStarted) {
203
280
  return;
204
281
  }
282
+ // Unavailable slots cannot seed a new appointment.
283
+ if (isSlotUnavailable(doorId, slotIndex)) {
284
+ return;
285
+ }
205
286
  isDragging = true;
206
287
  dragDoorId = doorId;
207
288
  dragStartSlotIndex = slotIndex;
@@ -221,6 +302,10 @@ function onSlotMouseEnter(doorId, slotIndex, e) {
221
302
  }
222
303
  // Appointment move drag
223
304
  if (appointmentDragStarted) {
305
+ // Freeze the drop target rather than land it on an unavailable slot.
306
+ if (isSlotUnavailable(doorId, slotIndex)) {
307
+ return;
308
+ }
224
309
  isDraggingAppointment = true;
225
310
  moveTargetDoorId = doorId;
226
311
  moveTargetSlotIndex = slotIndex;
@@ -230,6 +315,10 @@ function onSlotMouseEnter(doorId, slotIndex, e) {
230
315
  if (!isDragging || doorId !== dragDoorId) {
231
316
  return;
232
317
  }
318
+ // Keep the selection within the door's available windows.
319
+ if (isSlotUnavailable(doorId, slotIndex)) {
320
+ return;
321
+ }
233
322
  dragEndSlotIndex = slotIndex;
234
323
  }
235
324
  function onSlotMouseUp() {
@@ -417,6 +506,38 @@ const viewModes = [
417
506
  label: "Month"
418
507
  }
419
508
  ];
509
+ // ---- Centre-on-hour scrolling ----
510
+ /** The whole hour whose row carries the `data-centre-row` marker. */
511
+ const centreRowHour = $derived(centreHour == null ? null : Math.floor(centreHour));
512
+ /** The scrollable calendar body; bound so we can centre it on the marker. */
513
+ 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
+ let hasCentred = false;
520
+ $effect(() => {
521
+ // Re-arm whenever we leave the time-grid views so re-entry recentres.
522
+ if (viewMode !== CalendarViewMode.Day && viewMode !== CalendarViewMode.Week) {
523
+ hasCentred = false;
524
+ return;
525
+ }
526
+ if (hasCentred || centreRowHour == null || !calendarBody) {
527
+ return;
528
+ }
529
+ const marker = calendarBody.querySelector("[data-centre-row]");
530
+ if (!marker) {
531
+ return;
532
+ }
533
+ // Centre the marker row in the viewport via rect maths so nested
534
+ // sticky/relative offset parents don't skew the target.
535
+ const bodyRect = calendarBody.getBoundingClientRect();
536
+ const markerRect = marker.getBoundingClientRect();
537
+ const delta = markerRect.top + markerRect.height / 2 - (bodyRect.top + bodyRect.height / 2);
538
+ calendarBody.scrollTop += delta;
539
+ hasCentred = true;
540
+ });
420
541
  </script>
421
542
 
422
543
  <!-- A drag can end anywhere on the page (the pointer is often released outside
@@ -511,7 +632,10 @@ const viewModes = [
511
632
  {/if}
512
633
 
513
634
  <!-- Calendar body -->
514
- <div class="calendar-body">
635
+ <div
636
+ class="calendar-body"
637
+ bind:this={calendarBody}
638
+ >
515
639
  {#if isLoading}
516
640
  <LoadingOverlay />
517
641
  {/if}
@@ -590,6 +714,9 @@ const viewModes = [
590
714
  class="time-label"
591
715
  class:hour-start={slot.minute === 0}
592
716
  class:row-hover={hoveredSlotIndex === slotIdx}
717
+ data-centre-row={slot.hour === centreRowHour && slot.minute === 0
718
+ ? ''
719
+ : undefined}
593
720
  >
594
721
  {#if slot.minute === 0}
595
722
  <span>{slot.label}</span>
@@ -608,6 +735,7 @@ const viewModes = [
608
735
  class:drag-selected={isSlotInDragRange(door.id, slotIdx)}
609
736
  class:move-target={isSlotInMoveTarget(door.id, slotIdx)}
610
737
  class:row-hover={hoveredSlotIndex === slotIdx}
738
+ class:unavailable={isSlotUnavailable(door.id, slotIdx)}
611
739
  onmousedown={(e) => {
612
740
  onSlotMouseDown(door.id, slotIdx, e);
613
741
  }}
@@ -617,6 +745,13 @@ const viewModes = [
617
745
  }}
618
746
  ></div>
619
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}
620
755
  <!-- Appointment blocks positioned absolutely (rendered after, sit above slots) -->
621
756
  {#each appointmentsAt(door.id, day) as apt (apt.id)}
622
757
  <!-- svelte-ignore a11y_no_static_element_interactions -->
@@ -858,6 +993,19 @@ const viewModes = [
858
993
  border-bottom-color: #b4e2f0;
859
994
  z-index: 2;
860
995
  }
996
+ .slot-cell.unavailable {
997
+ cursor: not-allowed;
998
+ }
999
+
1000
+ .unavailable-overlay {
1001
+ position: absolute;
1002
+ left: 0;
1003
+ right: 0;
1004
+ z-index: 3;
1005
+ pointer-events: none;
1006
+ background-color: rgba(128, 150, 165, 0.06);
1007
+ background-image: repeating-linear-gradient(45deg, transparent, transparent 6px, rgba(128, 150, 165, 0.14) 6px, rgba(128, 150, 165, 0.14) 12px);
1008
+ }
861
1009
 
862
1010
  .appointment-block {
863
1011
  position: absolute;
@@ -40,6 +40,16 @@ export interface DragSelection {
40
40
  readonly dateFrom: Date;
41
41
  readonly dateTo: Date;
42
42
  }
43
+ /**
44
+ * A span of available time on the currently displayed day, expressed in
45
+ * minutes from midnight (`startMinutes` inclusive, `endMinutes`
46
+ * exclusive). Consumed by {@link AppointmentCalendarProps.availability}
47
+ * to shade out the times a door is not bookable.
48
+ */
49
+ export interface DoorAvailabilityWindow {
50
+ readonly startMinutes: number;
51
+ readonly endMinutes: number;
52
+ }
43
53
  /**
44
54
  * Arguments passed to {@link AppointmentCalendarProps.onappointmentmove}.
45
55
  *
@@ -85,6 +95,25 @@ export interface AppointmentCalendarProps {
85
95
  endHour?: number;
86
96
  /** The slot interval in minutes (default: 15). */
87
97
  slotIntervalMinutes?: number;
98
+ /**
99
+ * Per-door availability for the currently displayed day, keyed by
100
+ * door (segment) id.
101
+ *
102
+ * When a door id is present, any slot whose start falls outside all
103
+ * of its {@link DoorAvailabilityWindow}s is shaded as unavailable and
104
+ * cannot be used to create or receive an appointment. A door absent
105
+ * from the map is treated as fully available (no shading); map an id
106
+ * to an empty array to mark a door wholly unavailable for the day.
107
+ *
108
+ * Omit the prop entirely to disable availability shading.
109
+ */
110
+ availability?: ReadonlyMap<number, ReadonlyArray<DoorAvailabilityWindow>>;
111
+ /**
112
+ * Hour of the day (0–23) to vertically centre when the day/week grid
113
+ * first appears (default: 12, i.e. midday). Set to `null` to keep the
114
+ * grid scrolled to the top.
115
+ */
116
+ centreHour?: number | null;
88
117
  /** Whether the calendar is loading data. */
89
118
  isLoading?: boolean;
90
119
  /** Optional snippet for extra header content (e.g. buttons). */
package/dist/index.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  export { default as AccordionGroup } from './Components/AccordionGroup.svelte';
2
2
  export { default as AppointmentCalendar } from './Components/AppointmentCalendar/AppointmentCalendar.svelte';
3
- export type { AppointmentCalendarProps, AppointmentMoveArgs, CalendarAppointment, CalendarDoor, CalendarViewMode, DragSelection, } from './Components/AppointmentCalendar/AppointmentCalendarProps.js';
3
+ export type { AppointmentCalendarProps, AppointmentMoveArgs, CalendarAppointment, CalendarDoor, CalendarViewMode, DoorAvailabilityWindow, DragSelection, } from './Components/AppointmentCalendar/AppointmentCalendarProps.js';
4
4
  export type { AccordionGroupButtonProps, AccordionGroupOption, } from './Components/AccordionGroupButtonProps.js';
5
5
  export type { AccordionGroupProps } from './Components/AccordionGroupProps.js';
6
6
  export { default as Action } from './Components/Action.svelte';
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@lavalogic/scoria",
3
3
  "description": "Svelte components used for the FloWMS Web Frontend",
4
- "version": "0.40.16",
4
+ "version": "0.40.18",
5
5
  "publishConfig": {
6
6
  "access": "restricted"
7
7
  },