@lavalogic/scoria 0.40.13 → 0.40.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (143) hide show
  1. package/README.md +58 -58
  2. package/dist/Components/AccordionGroup.svelte +35 -46
  3. package/dist/Components/AccordionGroupButton.svelte +68 -76
  4. package/dist/Components/Action.svelte +103 -135
  5. package/dist/Components/AlertModal.svelte +171 -190
  6. package/dist/Components/AppointmentCalendar/AppointmentCalendar.svelte +614 -697
  7. package/dist/Components/Base/BaseModal.svelte +90 -116
  8. package/dist/Components/Base/ContextWrapper.svelte +67 -72
  9. package/dist/Components/Base/Pagination.svelte +173 -196
  10. package/dist/Components/Base/Snippets.svelte +34 -36
  11. package/dist/Components/BigRadioSet.svelte +36 -38
  12. package/dist/Components/Bubble.svelte +78 -101
  13. package/dist/Components/Button.svelte +175 -208
  14. package/dist/Components/Checkbox.svelte +75 -86
  15. package/dist/Components/CollapsibleCard.svelte +72 -87
  16. package/dist/Components/CollapsibleMenuGroup.svelte +53 -57
  17. package/dist/Components/ConditionBuilder/ConditionBuilder.svelte +39 -64
  18. package/dist/Components/Contexts/ContextMenu.svelte +141 -156
  19. package/dist/Components/Contexts/ContextMenuDivider.svelte +15 -15
  20. package/dist/Components/Contexts/ContextMenuOption.svelte +53 -60
  21. package/dist/Components/Contexts/Toast.svelte +81 -86
  22. package/dist/Components/Contexts/ToastContainer.svelte +58 -62
  23. package/dist/Components/Contexts/Tooltip.svelte +36 -37
  24. package/dist/Components/DataMatrixIcon.svelte +95 -108
  25. package/dist/Components/DateInput.svelte +118 -146
  26. package/dist/Components/DatePicker.svelte +255 -294
  27. package/dist/Components/DesktopModal.svelte +70 -90
  28. package/dist/Components/Dial.svelte +167 -197
  29. package/dist/Components/DimensionInput.svelte +124 -169
  30. package/dist/Components/DragMenu/DragMenuHolder.svelte +98 -114
  31. package/dist/Components/DragMenu/DraggableCard.svelte +64 -76
  32. package/dist/Components/FileCard.svelte +21 -24
  33. package/dist/Components/FileUpload.svelte +190 -225
  34. package/dist/Components/GridInput.svelte +159 -196
  35. package/dist/Components/GridInputSet.svelte +24 -29
  36. package/dist/Components/HorizontalTabGroup.svelte +73 -89
  37. package/dist/Components/HorizontalTabGroupButton.svelte +40 -44
  38. package/dist/Components/Icon.svelte +99 -128
  39. package/dist/Components/Icons.js +357 -357
  40. package/dist/Components/InfoAlert.svelte +35 -38
  41. package/dist/Components/LoadingAnimation.svelte +691 -755
  42. package/dist/Components/LoadingModal.svelte +36 -44
  43. package/dist/Components/LoadingOverlay.svelte +22 -26
  44. package/dist/Components/Modal.svelte +204 -254
  45. package/dist/Components/MultiSelect.svelte +186 -216
  46. package/dist/Components/Nav/Nav.svelte +94 -110
  47. package/dist/Components/Nav/NavServiceMenuList.svelte +57 -61
  48. package/dist/Components/Nav/NavTab.svelte +58 -62
  49. package/dist/Components/Nav/ServiceMenuItem.svelte +103 -111
  50. package/dist/Components/Nav/ServicesNav.svelte +72 -79
  51. package/dist/Components/NumberInput.svelte +121 -155
  52. package/dist/Components/OptionCards.svelte +62 -73
  53. package/dist/Components/PageHeading.svelte +23 -25
  54. package/dist/Components/PasswordInput.svelte +69 -88
  55. package/dist/Components/PopoverButton.svelte +151 -0
  56. package/dist/Components/PopoverButton.svelte.d.ts +9 -0
  57. package/dist/Components/PopoverButtonProps.d.ts +21 -0
  58. package/dist/Components/PopoverButtonProps.js +1 -0
  59. package/dist/Components/Portal.svelte +29 -35
  60. package/dist/Components/ProgressBubble.svelte +91 -106
  61. package/dist/Components/QuerySelect.svelte +260 -346
  62. package/dist/Components/SidebarPanel.svelte +34 -36
  63. package/dist/Components/SingleSelect.svelte +196 -244
  64. package/dist/Components/StepButton.svelte +65 -70
  65. package/dist/Components/StepForm.svelte +115 -135
  66. package/dist/Components/Switch.svelte +57 -69
  67. package/dist/Components/Table/ARCHITECTURE.md +298 -298
  68. package/dist/Components/Table/Body/QuickSearchCell.svelte +578 -655
  69. package/dist/Components/Table/Body/Rows/ColumnHighlight.svelte +42 -51
  70. package/dist/Components/Table/Body/Rows/ColumnList.svelte +42 -58
  71. package/dist/Components/Table/Body/Rows/ColumnListRow.svelte +403 -442
  72. package/dist/Components/Table/Body/Rows/Columns/AccessorComponent.svelte +51 -78
  73. package/dist/Components/Table/Body/Rows/Columns/ActionsCell.svelte +165 -184
  74. package/dist/Components/Table/Body/Rows/Columns/BubbleCell.svelte +110 -123
  75. package/dist/Components/Table/Body/Rows/Columns/ExpandCell.svelte +134 -160
  76. package/dist/Components/Table/Body/Rows/Columns/FocusableImmutableCell.svelte +190 -231
  77. package/dist/Components/Table/Body/Rows/Columns/ProgressCell.svelte +82 -104
  78. package/dist/Components/Table/Body/Rows/Columns/TableAction.svelte +52 -87
  79. package/dist/Components/Table/Body/Rows/Columns/TableCheckbox.svelte +122 -150
  80. package/dist/Components/Table/Body/Rows/Columns/TableColumn.svelte +132 -159
  81. package/dist/Components/Table/Body/Rows/Columns/TableDatePicker.svelte +190 -236
  82. package/dist/Components/Table/Body/Rows/Columns/TableNumberInput.svelte +173 -211
  83. package/dist/Components/Table/Body/Rows/Columns/TableQuerySelect.svelte +204 -244
  84. package/dist/Components/Table/Body/Rows/Columns/TableRowSelectionCheckbox.svelte +140 -177
  85. package/dist/Components/Table/Body/Rows/Columns/TableSelect.svelte +188 -217
  86. package/dist/Components/Table/Body/Rows/Columns/TableTextInput.svelte +173 -193
  87. package/dist/Components/Table/Body/Rows/Columns/ValidityCell.svelte +166 -187
  88. package/dist/Components/Table/Body/Rows/TableRow.svelte +245 -288
  89. package/dist/Components/Table/Body/TableBody.svelte +141 -154
  90. package/dist/Components/Table/Headers/HighlightAllHeader.svelte +35 -41
  91. package/dist/Components/Table/Headers/SelectAllHeader.svelte +24 -30
  92. package/dist/Components/Table/Headers/TableHead.svelte +348 -402
  93. package/dist/Components/Table/Misc/ColumnPanel.svelte +37 -39
  94. package/dist/Components/Table/Misc/ColumnPanelModal.svelte +35 -39
  95. package/dist/Components/Table/Misc/FilterInput.svelte +556 -619
  96. package/dist/Components/Table/Misc/FilterPanel.svelte +170 -183
  97. package/dist/Components/Table/Misc/FilterPanelModal.svelte +25 -31
  98. package/dist/Components/Table/Misc/ShareViewModal.svelte +252 -281
  99. package/dist/Components/Table/Misc/SidePanel.svelte +32 -35
  100. package/dist/Components/Table/Misc/TableConfigurationModal.svelte +574 -629
  101. package/dist/Components/Table/Misc/TableFooter.svelte +326 -332
  102. package/dist/Components/Table/Misc/TableHorizontalBar.svelte +134 -140
  103. package/dist/Components/Table/Misc/TableSidebar.svelte +183 -198
  104. package/dist/Components/Table/Misc/TableViewDropdown.svelte +83 -103
  105. package/dist/Components/Table/Misc/ToolboxPanel.svelte +83 -100
  106. package/dist/Components/Table/Misc/ToolboxPanelModal.svelte +88 -107
  107. package/dist/Components/Table/Misc/UpdateViewOptionsModal.svelte +163 -190
  108. package/dist/Components/Table/NestedTable.svelte +102 -128
  109. package/dist/Components/Table/Table.svelte +531 -663
  110. package/dist/Components/Test/TestContextConsumer.svelte +11 -13
  111. package/dist/Components/TextArea.svelte +95 -130
  112. package/dist/Components/TextInput.svelte +125 -176
  113. package/dist/Components/ThreeStateRadio.svelte +111 -130
  114. package/dist/Components/Touch/BoolCard.svelte +33 -45
  115. package/dist/Components/Touch/DateModal.svelte +164 -204
  116. package/dist/Components/Touch/EditCard.svelte +48 -59
  117. package/dist/Components/Touch/InfoCard.svelte +28 -31
  118. package/dist/Components/Touch/InfoTextCard.svelte +26 -28
  119. package/dist/Components/Touch/ModalExplanation.svelte +15 -17
  120. package/dist/Components/Touch/NumberKeyboard.svelte +181 -218
  121. package/dist/Components/Touch/NumberModal.svelte +59 -79
  122. package/dist/Components/Touch/PlusMinusCard.svelte +43 -46
  123. package/dist/Components/Touch/SelectModal.svelte +111 -126
  124. package/dist/Components/Touch/SummaryCard.svelte +80 -93
  125. package/dist/Components/Touch/TextAreaModal.svelte +72 -94
  126. package/dist/Components/Touch/TextModal.svelte +77 -105
  127. package/dist/Components/Touch/TouchCard.svelte +31 -33
  128. package/dist/Components/Touch/TouchModal.svelte +214 -270
  129. package/dist/Components/Touch/UtilityButton.svelte +88 -109
  130. package/dist/Components/VerticalTabGroup.svelte +60 -78
  131. package/dist/Components/VerticalTabGroupButton.svelte +54 -58
  132. package/dist/Helpers/Datamatrix.d.ts +19 -19
  133. package/dist/Helpers/Datamatrix.js +291 -291
  134. package/dist/Types/Examples/ExampleModal.svelte +31 -35
  135. package/dist/index.d.ts +2 -0
  136. package/dist/index.js +1 -0
  137. package/dist/scss/_basics.scss +12 -12
  138. package/dist/scss/_colours.scss +134 -134
  139. package/dist/scss/_mixins.scss +3392 -3392
  140. package/dist/scss/_motion.scss +57 -57
  141. package/dist/scss/_sizing.scss +85 -85
  142. package/dist/scss/_transitions.scss +8 -8
  143. package/package.json +1 -1
@@ -1,731 +1,648 @@
1
- <svelte:options runes />
2
-
3
- <script lang="ts">
4
- import { Colour } from '../../scss/colours.js';
5
- import { Size } from '../../Types/Internal/Size.js';
6
- import Button from '../Button.svelte';
7
- import Icon from '../Icon.svelte';
8
- import LoadingOverlay from '../LoadingOverlay.svelte';
9
- import { CalendarViewMode } from './AppointmentCalendarProps.js';
10
- import type {
11
- AppointmentCalendarProps,
12
- CalendarAppointment,
13
- DragSelection,
14
- } from './AppointmentCalendarProps.js';
15
-
16
- let {
17
- doors,
18
- appointments,
19
- selectedDate,
20
- viewMode,
21
- ondatechange,
22
- onviewmodechange,
23
- ondragcreate,
24
- onappointmentclick,
25
- onappointmentmove,
26
- startHour = 0,
27
- endHour = 24,
28
- slotIntervalMinutes = 15,
29
- isLoading = false,
30
- headerExtra,
31
- }: AppointmentCalendarProps = $props();
32
-
33
- /**
34
- * `selectedDate` is always read by the component, never written. The
35
- * runtime type may be either a plain `Date` or a `SvelteDate`; both
36
- * expose the same `Date` interface that we use here. Cast to `Date`
37
- * for narrowing inside the script.
38
- */
39
- const selected = $derived(selectedDate as Date);
40
-
41
- /**
42
- * Today's reference Date hoisted out of the per-cell loops so that
43
- * rendering the month grid does not allocate 42 fresh `Date`
44
- * instances on every state change. `$derived` does not invalidate
45
- * over time on its own; if you need real day-rollover semantics
46
- * track it with a `SvelteDate` and a `setInterval`.
47
- */
48
- const today = $derived(new Date());
49
-
50
- // ---- Date helpers ----
51
-
52
- const dayNames = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
53
- const monthNames = [
54
- 'January',
55
- 'February',
56
- 'March',
57
- 'April',
58
- 'May',
59
- 'June',
60
- 'July',
61
- 'August',
62
- 'September',
63
- 'October',
64
- 'November',
65
- 'December',
66
- ];
67
-
68
- function formatDate(d: Date): string {
69
- if (viewMode === CalendarViewMode.Month) {
70
- return `${monthNames[d.getMonth()]} ${d.getFullYear()}`;
71
- }
72
- return `${dayNames[d.getDay()]} ${d.getDate()} ${monthNames[d.getMonth()]} ${d.getFullYear()}`;
1
+ <svelte:options runes />
2
+
3
+ <script lang="ts">import { Colour } from "../../scss/colours.js";
4
+ import { Size } from "../../Types/Internal/Size.js";
5
+ import Button from "../Button.svelte";
6
+ import Icon from "../Icon.svelte";
7
+ 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, isLoading = false, headerExtra } = $props();
10
+ /**
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.
15
+ */
16
+ const selected = $derived(selectedDate);
17
+ /**
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`.
23
+ */
24
+ const today = $derived(new Date());
25
+ // ---- Date helpers ----
26
+ const dayNames = [
27
+ "Sunday",
28
+ "Monday",
29
+ "Tuesday",
30
+ "Wednesday",
31
+ "Thursday",
32
+ "Friday",
33
+ "Saturday"
34
+ ];
35
+ const monthNames = [
36
+ "January",
37
+ "February",
38
+ "March",
39
+ "April",
40
+ "May",
41
+ "June",
42
+ "July",
43
+ "August",
44
+ "September",
45
+ "October",
46
+ "November",
47
+ "December"
48
+ ];
49
+ function formatDate(d) {
50
+ if (viewMode === CalendarViewMode.Month) {
51
+ return `${monthNames[d.getMonth()]} ${d.getFullYear()}`;
73
52
  }
74
-
75
- function sameDay(a: Date, b: Date): boolean {
76
- return (
77
- a.getFullYear() === b.getFullYear() &&
78
- a.getMonth() === b.getMonth() &&
79
- a.getDate() === b.getDate()
80
- );
81
- }
82
-
83
- function addDays(d: Date, n: number): Date {
53
+ return `${dayNames[d.getDay()]} ${d.getDate()} ${monthNames[d.getMonth()]} ${d.getFullYear()}`;
54
+ }
55
+ function sameDay(a, b) {
56
+ return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate();
57
+ }
58
+ function addDays(d, n) {
59
+ // eslint-disable-next-line svelte/prefer-svelte-reactivity
60
+ const result = new Date(d);
61
+ result.setDate(result.getDate() + n);
62
+ return result;
63
+ }
64
+ function navigateDate(direction) {
65
+ if (viewMode === CalendarViewMode.Day) {
66
+ ondatechange(addDays(selected, direction));
67
+ } else if (viewMode === CalendarViewMode.Week) {
68
+ ondatechange(addDays(selected, direction * 7));
69
+ } else {
84
70
  // eslint-disable-next-line svelte/prefer-svelte-reactivity
85
- const result = new Date(d);
86
- result.setDate(result.getDate() + n);
87
- return result;
71
+ const d = new Date(selected);
72
+ d.setMonth(d.getMonth() + direction);
73
+ ondatechange(d);
88
74
  }
89
-
90
- function navigateDate(direction: number): void {
91
- if (viewMode === CalendarViewMode.Day) {
92
- ondatechange(addDays(selected, direction));
93
- } else if (viewMode === CalendarViewMode.Week) {
94
- ondatechange(addDays(selected, direction * 7));
95
- } else {
96
- // eslint-disable-next-line svelte/prefer-svelte-reactivity
97
- const d = new Date(selected);
98
- d.setMonth(d.getMonth() + direction);
99
- ondatechange(d);
75
+ }
76
+ function goToToday() {
77
+ ondatechange(new Date());
78
+ }
79
+ // ---- Time slot generation ----
80
+ const timeSlots = $derived.by(() => {
81
+ const slots = [];
82
+ for (let h = startHour; h < endHour; h++) {
83
+ for (let m = 0; m < 60; m += slotIntervalMinutes) {
84
+ const hh = String(h).padStart(2, "0");
85
+ const mm = String(m).padStart(2, "0");
86
+ slots.push({
87
+ hour: h,
88
+ minute: m,
89
+ label: `${hh}:${mm}`
90
+ });
100
91
  }
101
92
  }
102
-
103
- function goToToday(): void {
104
- ondatechange(new Date());
105
- }
106
-
107
- // ---- Time slot generation ----
108
-
109
- const timeSlots: Array<{ hour: number; minute: number; label: string }> = $derived.by(() => {
110
- const slots: Array<{ hour: number; minute: number; label: string }> = [];
111
- for (let h = startHour; h < endHour; h++) {
112
- for (let m = 0; m < 60; m += slotIntervalMinutes) {
113
- const hh = String(h).padStart(2, '0');
114
- const mm = String(m).padStart(2, '0');
115
- slots.push({ hour: h, minute: m, label: `${hh}:${mm}` });
116
- }
93
+ return slots;
94
+ });
95
+ /**
96
+ * Build a `YYYY-MM-DD` key for date bucketing. Avoids `toISOString`
97
+ * timezone conversions that would smear appointments across days.
98
+ */
99
+ function dayKey(d) {
100
+ const y = d.getFullYear();
101
+ const m = String(d.getMonth() + 1).padStart(2, "0");
102
+ const day = String(d.getDate()).padStart(2, "0");
103
+ return `${y}-${m}-${day}`;
104
+ }
105
+ /**
106
+ * Precomputed `Map<doorId, Map<dayKey, PositionedAppointment[]>>` for
107
+ * day and week views. Replaces the previous per-cell
108
+ * `appointments.filter(...)` calls which were `O(doors * appointments)`
109
+ * per render and allocated a fresh `Date` per appointment per pass.
110
+ */
111
+ const positionedByDoorAndDay = $derived.by(() => {
112
+ const totalMinutes = (endHour - startHour) * 60;
113
+ // Local indexes built inside a $derived; outer reactivity already covers reads.
114
+ // eslint-disable-next-line svelte/prefer-svelte-reactivity
115
+ const byDoor = new Map();
116
+ for (const apt of appointments) {
117
+ const from = new Date(apt.dateFrom);
118
+ const to = new Date(apt.dateTo);
119
+ const startMin = (from.getHours() - startHour) * 60 + from.getMinutes();
120
+ const endMin = (to.getHours() - startHour) * 60 + to.getMinutes();
121
+ const durationMin = Math.max(endMin - startMin, slotIntervalMinutes);
122
+ const positioned = {
123
+ ...apt,
124
+ topPercent: Math.max(0, startMin) / totalMinutes * 100,
125
+ heightPercent: Math.min(durationMin, totalMinutes - startMin) / totalMinutes * 100
126
+ };
127
+ const key = dayKey(from);
128
+ let perDay = byDoor.get(apt.segmentId);
129
+ if (!perDay) {
130
+ // eslint-disable-next-line svelte/prefer-svelte-reactivity
131
+ perDay = new Map();
132
+ byDoor.set(apt.segmentId, perDay);
133
+ }
134
+ const list = perDay.get(key);
135
+ if (list) {
136
+ list.push(positioned);
137
+ } else {
138
+ perDay.set(key, [positioned]);
117
139
  }
118
- return slots;
119
- });
120
-
121
- // ---- Appointment positioning (day view) ----
122
-
123
- type PositionedAppointment = CalendarAppointment & {
124
- readonly topPercent: number;
125
- readonly heightPercent: number;
126
- };
127
-
128
- /**
129
- * Build a `YYYY-MM-DD` key for date bucketing. Avoids `toISOString`
130
- * timezone conversions that would smear appointments across days.
131
- */
132
- function dayKey(d: Date): string {
133
- const y = d.getFullYear();
134
- const m = String(d.getMonth() + 1).padStart(2, '0');
135
- const day = String(d.getDate()).padStart(2, '0');
136
- return `${y}-${m}-${day}`;
137
140
  }
138
-
139
- /**
140
- * Precomputed `Map<doorId, Map<dayKey, PositionedAppointment[]>>` for
141
- * day and week views. Replaces the previous per-cell
142
- * `appointments.filter(...)` calls which were `O(doors * appointments)`
143
- * per render and allocated a fresh `Date` per appointment per pass.
144
- */
145
- const positionedByDoorAndDay = $derived.by(() => {
146
- const totalMinutes = (endHour - startHour) * 60;
147
- // Local indexes built inside a $derived; outer reactivity already covers reads.
148
- // eslint-disable-next-line svelte/prefer-svelte-reactivity
149
- const byDoor = new Map<number, Map<string, Array<PositionedAppointment>>>();
150
- for (const apt of appointments) {
151
- const from = new Date(apt.dateFrom);
152
- const to = new Date(apt.dateTo);
153
- const startMin = (from.getHours() - startHour) * 60 + from.getMinutes();
154
- const endMin = (to.getHours() - startHour) * 60 + to.getMinutes();
155
- const durationMin = Math.max(endMin - startMin, slotIntervalMinutes);
156
- const positioned: PositionedAppointment = {
157
- ...apt,
158
- topPercent: (Math.max(0, startMin) / totalMinutes) * 100,
159
- heightPercent: (Math.min(durationMin, totalMinutes - startMin) / totalMinutes) * 100,
160
- };
161
- const key = dayKey(from);
162
- let perDay = byDoor.get(apt.segmentId);
163
- if (!perDay) {
164
- // eslint-disable-next-line svelte/prefer-svelte-reactivity
165
- perDay = new Map();
166
- byDoor.set(apt.segmentId, perDay);
167
- }
168
- const list = perDay.get(key);
169
- if (list) {
170
- list.push(positioned);
171
- } else {
172
- perDay.set(key, [positioned]);
173
- }
141
+ return byDoor;
142
+ });
143
+ function appointmentsAt(doorId, day) {
144
+ return positionedByDoorAndDay.get(doorId)?.get(dayKey(day)) ?? [];
145
+ }
146
+ /**
147
+ * Precomputed appointment-count badges per day for the month view.
148
+ * Replaces the previous `O(cells * doors * appointments)` recomputation
149
+ * which scaled to ~42 000 ops on a single render with 10 doors and
150
+ * 100 appointments.
151
+ */
152
+ const countsByDay = $derived.by(() => {
153
+ // Local indexes built inside a $derived; outer reactivity already covers reads.
154
+ // eslint-disable-next-line svelte/prefer-svelte-reactivity
155
+ const map = new Map();
156
+ // One scan over appointments builds a nested Map<dayKey, Map<doorId, count>>.
157
+ // eslint-disable-next-line svelte/prefer-svelte-reactivity
158
+ const intermediate = new Map();
159
+ for (const apt of appointments) {
160
+ const key = dayKey(new Date(apt.dateFrom));
161
+ let perDay = intermediate.get(key);
162
+ if (!perDay) {
163
+ // eslint-disable-next-line svelte/prefer-svelte-reactivity
164
+ perDay = new Map();
165
+ intermediate.set(key, perDay);
174
166
  }
175
- return byDoor;
176
- });
177
-
178
- function appointmentsAt(doorId: number, day: Date): ReadonlyArray<PositionedAppointment> {
179
- return positionedByDoorAndDay.get(doorId)?.get(dayKey(day)) ?? [];
167
+ perDay.set(apt.segmentId, (perDay.get(apt.segmentId) ?? 0) + 1);
180
168
  }
181
-
182
- /**
183
- * Precomputed appointment-count badges per day for the month view.
184
- * Replaces the previous `O(cells * doors * appointments)` recomputation
185
- * which scaled to ~42 000 ops on a single render with 10 doors and
186
- * 100 appointments.
187
- */
188
- const countsByDay = $derived.by(() => {
189
- // Local indexes built inside a $derived; outer reactivity already covers reads.
190
- // eslint-disable-next-line svelte/prefer-svelte-reactivity
191
- const map = new Map<string, Array<{ doorId: number; doorName: string; count: number }>>();
192
- // One scan over appointments builds a nested Map<dayKey, Map<doorId, count>>.
193
- // eslint-disable-next-line svelte/prefer-svelte-reactivity
194
- const intermediate = new Map<string, Map<number, number>>();
195
- for (const apt of appointments) {
196
- const key = dayKey(new Date(apt.dateFrom));
197
- let perDay = intermediate.get(key);
198
- if (!perDay) {
199
- // eslint-disable-next-line svelte/prefer-svelte-reactivity
200
- perDay = new Map();
201
- intermediate.set(key, perDay);
169
+ // Flatten to the door-ordered list the markup expects.
170
+ for (const [key, perDay] of intermediate) {
171
+ const list = [];
172
+ for (const door of doors) {
173
+ const count = perDay.get(door.id);
174
+ if (count) {
175
+ list.push({
176
+ doorId: door.id,
177
+ doorName: door.name,
178
+ count
179
+ });
202
180
  }
203
- perDay.set(apt.segmentId, (perDay.get(apt.segmentId) ?? 0) + 1);
204
181
  }
205
- // Flatten to the door-ordered list the markup expects.
206
- for (const [key, perDay] of intermediate) {
207
- const list: Array<{ doorId: number; doorName: string; count: number }> = [];
208
- for (const door of doors) {
209
- const count = perDay.get(door.id);
210
- if (count) {
211
- list.push({ doorId: door.id, doorName: door.name, count });
212
- }
213
- }
214
- if (list.length > 0) {
215
- map.set(key, list);
216
- }
182
+ if (list.length > 0) {
183
+ map.set(key, list);
217
184
  }
218
- return map;
219
- });
220
-
221
- function countsAt(day: Date): ReadonlyArray<{ doorId: number; doorName: string; count: number }> {
222
- return countsByDay.get(dayKey(day)) ?? [];
223
185
  }
224
-
225
- // ---- Click and drag logic ----
226
-
227
- let isDragging = $state(false);
228
- let dragDoorId: number | null = $state(null);
229
- let dragStartSlotIndex: number | null = $state(null);
230
- let dragEndSlotIndex: number | null = $state(null);
231
-
232
- function onSlotMouseDown(doorId: number, slotIndex: number, e: MouseEvent): void {
233
- // Only start a drag-create on the primary (left) button. Otherwise a
234
- // middle-click (autoscroll) or right-click would open the create modal.
235
- if (e.button !== 0) {
236
- return;
237
- }
238
- if (!ondragcreate || appointmentDragStarted) {
239
- return;
240
- }
241
- isDragging = true;
242
- dragDoorId = doorId;
243
- dragStartSlotIndex = slotIndex;
244
- dragEndSlotIndex = slotIndex;
186
+ return map;
187
+ });
188
+ function countsAt(day) {
189
+ return countsByDay.get(dayKey(day)) ?? [];
190
+ }
191
+ // ---- Click and drag logic ----
192
+ let isDragging = $state(false);
193
+ let dragDoorId = $state(null);
194
+ let dragStartSlotIndex = $state(null);
195
+ let dragEndSlotIndex = $state(null);
196
+ function onSlotMouseDown(doorId, slotIndex, e) {
197
+ // Only start a drag-create on the primary (left) button. Otherwise a
198
+ // middle-click (autoscroll) or right-click would open the create modal.
199
+ if (e.button !== 0) {
200
+ return;
245
201
  }
246
-
247
- function onSlotMouseEnter(doorId: number, slotIndex: number, e: MouseEvent): void {
248
- // If a drag is in progress but the primary button is no longer held, the
249
- // pointer-up happened somewhere we did not observe (e.g. outside the
250
- // calendar). Reset so we do not "drag" without the button being pressed.
251
- if ((isDragging || appointmentDragStarted) && (e.buttons & 1) === 0) {
252
- isDragging = false;
253
- dragDoorId = null;
254
- dragStartSlotIndex = null;
255
- dragEndSlotIndex = null;
256
- resetAppointmentDrag();
257
- return;
258
- }
259
- // Appointment move drag
260
- if (appointmentDragStarted) {
261
- isDraggingAppointment = true;
262
- moveTargetDoorId = doorId;
263
- moveTargetSlotIndex = slotIndex;
264
- return;
265
- }
266
- // Slot drag-create
267
- if (!isDragging || doorId !== dragDoorId) {
268
- return;
269
- }
270
- dragEndSlotIndex = slotIndex;
202
+ if (!ondragcreate || appointmentDragStarted) {
203
+ return;
271
204
  }
272
-
273
- function onSlotMouseUp(): void {
274
- // ---- Handle appointment drag-move ----
275
- if (
276
- isDraggingAppointment &&
277
- draggedAppointment &&
278
- moveTargetDoorId != null &&
279
- moveTargetSlotIndex != null
280
- ) {
281
- const slot = timeSlots[moveTargetSlotIndex];
282
- // eslint-disable-next-line svelte/prefer-svelte-reactivity
283
- const newDateFrom = new Date(selected);
284
- newDateFrom.setHours(slot.hour, slot.minute, 0, 0);
285
- const newDateTo = new Date(
286
- newDateFrom.getTime() + draggedAptSlotCount * slotIntervalMinutes * 60000
287
- );
288
-
289
- // Check whether the position actually changed
290
- const origFrom = new Date(draggedAppointment.dateFrom);
291
- const samePosition =
292
- moveTargetDoorId === draggedAppointment.segmentId &&
293
- newDateFrom.getTime() === origFrom.getTime();
294
-
295
- if (samePosition) {
296
- // Dropped in the same spot - treat as a click
297
- onappointmentclick?.(draggedAppointment);
298
- } else if (!hasOverlap(moveTargetDoorId, newDateFrom, newDateTo, draggedAppointment.id)) {
299
- const door = doors.find((d) => d.id === moveTargetDoorId);
300
- onappointmentmove?.({
301
- appointment: draggedAppointment,
302
- newDoorId: moveTargetDoorId,
303
- newDoorName: door?.name ?? '',
304
- newDateFrom,
305
- newDateTo,
306
- });
307
- }
308
-
309
- resetAppointmentDrag();
310
- return;
311
- }
312
-
313
- // ---- Handle appointment click (mousedown + mouseup without leaving the block) ----
314
- if (appointmentDragStarted && !isDraggingAppointment && draggedAppointment) {
315
- onappointmentclick?.(draggedAppointment);
316
- resetAppointmentDrag();
317
- return;
318
- }
319
-
320
- resetAppointmentDrag();
321
-
322
- // ---- Handle drag-create ----
323
- if (
324
- !isDragging ||
325
- dragDoorId == null ||
326
- dragStartSlotIndex == null ||
327
- dragEndSlotIndex == null
328
- ) {
329
- return;
330
- }
331
-
332
- const startIdx = Math.min(dragStartSlotIndex, dragEndSlotIndex);
333
- const endIdx = Math.max(dragStartSlotIndex, dragEndSlotIndex);
334
-
335
- const startSlot = timeSlots[startIdx];
336
- const endSlot = timeSlots[endIdx];
337
-
338
- // eslint-disable-next-line svelte/prefer-svelte-reactivity
339
- const dateFrom = new Date(selected);
340
- dateFrom.setHours(startSlot.hour, startSlot.minute, 0, 0);
341
-
342
- // eslint-disable-next-line svelte/prefer-svelte-reactivity
343
- const dateTo = new Date(selected);
344
- // End slot is the start of the last selected slot; add one interval for the end time
345
- dateTo.setHours(endSlot.hour, endSlot.minute + slotIntervalMinutes, 0, 0);
346
-
347
- const door = doors.find((d) => d.id === dragDoorId);
348
-
349
- const selection: DragSelection = {
350
- doorId: dragDoorId,
351
- doorName: door?.name ?? '',
352
- dateFrom,
353
- dateTo,
354
- };
355
-
205
+ isDragging = true;
206
+ dragDoorId = doorId;
207
+ dragStartSlotIndex = slotIndex;
208
+ dragEndSlotIndex = slotIndex;
209
+ }
210
+ function onSlotMouseEnter(doorId, slotIndex, e) {
211
+ // If a drag is in progress but the primary button is no longer held, the
212
+ // pointer-up happened somewhere we did not observe (e.g. outside the
213
+ // calendar). Reset so we do not "drag" without the button being pressed.
214
+ if ((isDragging || appointmentDragStarted) && (e.buttons & 1) === 0) {
356
215
  isDragging = false;
357
216
  dragDoorId = null;
358
217
  dragStartSlotIndex = null;
359
218
  dragEndSlotIndex = null;
360
-
361
- ondragcreate?.(selection);
219
+ resetAppointmentDrag();
220
+ return;
362
221
  }
363
-
364
- function isSlotInDragRange(doorId: number, slotIndex: number): boolean {
365
- if (!isDragging || doorId !== dragDoorId) {
366
- return false;
367
- }
368
- if (dragStartSlotIndex == null || dragEndSlotIndex == null) {
369
- return false;
370
- }
371
- const min = Math.min(dragStartSlotIndex, dragEndSlotIndex);
372
- const max = Math.max(dragStartSlotIndex, dragEndSlotIndex);
373
- return slotIndex >= min && slotIndex <= max;
222
+ // Appointment move drag
223
+ if (appointmentDragStarted) {
224
+ isDraggingAppointment = true;
225
+ moveTargetDoorId = doorId;
226
+ moveTargetSlotIndex = slotIndex;
227
+ return;
374
228
  }
375
-
376
- // ---- Row hover tracking ----
377
-
378
- let hoveredSlotIndex: number | null = $state(null);
379
-
380
- // ---- Appointment drag-move logic ----
381
-
382
- let appointmentDragStarted = $state(false);
383
- let isDraggingAppointment = $state(false);
384
- let draggedAppointment: CalendarAppointment | null = $state(null);
385
- let draggedAptSlotCount = $state(0);
386
- let moveTargetDoorId: number | null = $state(null);
387
- let moveTargetSlotIndex: number | null = $state(null);
388
-
389
- function onAppointmentMouseDown(apt: CalendarAppointment, e: MouseEvent): void {
390
- // Only react to the primary (left) button so middle/right clicks don't
391
- // start a move or register as an appointment click.
392
- if (e.button !== 0) {
393
- return;
394
- }
395
- if (!onappointmentmove) {
396
- return;
229
+ // Slot drag-create
230
+ if (!isDragging || doorId !== dragDoorId) {
231
+ return;
232
+ }
233
+ dragEndSlotIndex = slotIndex;
234
+ }
235
+ function onSlotMouseUp() {
236
+ // ---- Handle appointment drag-move ----
237
+ if (isDraggingAppointment && draggedAppointment && moveTargetDoorId != null && moveTargetSlotIndex != null) {
238
+ const slot = timeSlots[moveTargetSlotIndex];
239
+ // eslint-disable-next-line svelte/prefer-svelte-reactivity
240
+ const newDateFrom = new Date(selected);
241
+ newDateFrom.setHours(slot.hour, slot.minute, 0, 0);
242
+ const newDateTo = new Date(newDateFrom.getTime() + draggedAptSlotCount * slotIntervalMinutes * 6e4);
243
+ // Check whether the position actually changed
244
+ const origFrom = new Date(draggedAppointment.dateFrom);
245
+ const samePosition = moveTargetDoorId === draggedAppointment.segmentId && newDateFrom.getTime() === origFrom.getTime();
246
+ if (samePosition) {
247
+ // Dropped in the same spot - treat as a click
248
+ onappointmentclick?.(draggedAppointment);
249
+ } else if (!hasOverlap(moveTargetDoorId, newDateFrom, newDateTo, draggedAppointment.id)) {
250
+ const door = doors.find((d) => d.id === moveTargetDoorId);
251
+ onappointmentmove?.({
252
+ appointment: draggedAppointment,
253
+ newDoorId: moveTargetDoorId,
254
+ newDoorName: door?.name ?? "",
255
+ newDateFrom,
256
+ newDateTo
257
+ });
397
258
  }
398
- e.preventDefault();
399
- appointmentDragStarted = true;
400
- draggedAppointment = apt;
401
-
402
- const from = new Date(apt.dateFrom);
403
- const to = new Date(apt.dateTo);
404
- const durationMin = (to.getTime() - from.getTime()) / 60000;
405
- draggedAptSlotCount = Math.max(1, Math.ceil(durationMin / slotIntervalMinutes));
259
+ resetAppointmentDrag();
260
+ return;
406
261
  }
407
-
408
- function resetAppointmentDrag(): void {
409
- appointmentDragStarted = false;
410
- isDraggingAppointment = false;
411
- draggedAppointment = null;
412
- draggedAptSlotCount = 0;
413
- moveTargetDoorId = null;
414
- moveTargetSlotIndex = null;
262
+ // ---- Handle appointment click (mousedown + mouseup without leaving the block) ----
263
+ if (appointmentDragStarted && !isDraggingAppointment && draggedAppointment) {
264
+ onappointmentclick?.(draggedAppointment);
265
+ resetAppointmentDrag();
266
+ return;
415
267
  }
416
-
417
- function isSlotInMoveTarget(doorId: number, slotIndex: number): boolean {
418
- if (!isDraggingAppointment || moveTargetDoorId !== doorId) {
268
+ resetAppointmentDrag();
269
+ // ---- Handle drag-create ----
270
+ if (!isDragging || dragDoorId == null || dragStartSlotIndex == null || dragEndSlotIndex == null) {
271
+ return;
272
+ }
273
+ const startIdx = Math.min(dragStartSlotIndex, dragEndSlotIndex);
274
+ const endIdx = Math.max(dragStartSlotIndex, dragEndSlotIndex);
275
+ const startSlot = timeSlots[startIdx];
276
+ const endSlot = timeSlots[endIdx];
277
+ // eslint-disable-next-line svelte/prefer-svelte-reactivity
278
+ const dateFrom = new Date(selected);
279
+ dateFrom.setHours(startSlot.hour, startSlot.minute, 0, 0);
280
+ // eslint-disable-next-line svelte/prefer-svelte-reactivity
281
+ const dateTo = new Date(selected);
282
+ // End slot is the start of the last selected slot; add one interval for the end time
283
+ dateTo.setHours(endSlot.hour, endSlot.minute + slotIntervalMinutes, 0, 0);
284
+ const door = doors.find((d) => d.id === dragDoorId);
285
+ const selection = {
286
+ doorId: dragDoorId,
287
+ doorName: door?.name ?? "",
288
+ dateFrom,
289
+ dateTo
290
+ };
291
+ isDragging = false;
292
+ dragDoorId = null;
293
+ dragStartSlotIndex = null;
294
+ dragEndSlotIndex = null;
295
+ ondragcreate?.(selection);
296
+ }
297
+ function isSlotInDragRange(doorId, slotIndex) {
298
+ if (!isDragging || doorId !== dragDoorId) {
299
+ return false;
300
+ }
301
+ if (dragStartSlotIndex == null || dragEndSlotIndex == null) {
302
+ return false;
303
+ }
304
+ const min = Math.min(dragStartSlotIndex, dragEndSlotIndex);
305
+ const max = Math.max(dragStartSlotIndex, dragEndSlotIndex);
306
+ return slotIndex >= min && slotIndex <= max;
307
+ }
308
+ // ---- Row hover tracking ----
309
+ let hoveredSlotIndex = $state(null);
310
+ // ---- Appointment drag-move logic ----
311
+ let appointmentDragStarted = $state(false);
312
+ let isDraggingAppointment = $state(false);
313
+ let draggedAppointment = $state(null);
314
+ let draggedAptSlotCount = $state(0);
315
+ let moveTargetDoorId = $state(null);
316
+ let moveTargetSlotIndex = $state(null);
317
+ function onAppointmentMouseDown(apt, e) {
318
+ // Only react to the primary (left) button so middle/right clicks don't
319
+ // start a move or register as an appointment click.
320
+ if (e.button !== 0) {
321
+ return;
322
+ }
323
+ if (!onappointmentmove) {
324
+ return;
325
+ }
326
+ e.preventDefault();
327
+ appointmentDragStarted = true;
328
+ draggedAppointment = apt;
329
+ const from = new Date(apt.dateFrom);
330
+ const to = new Date(apt.dateTo);
331
+ const durationMin = (to.getTime() - from.getTime()) / 6e4;
332
+ draggedAptSlotCount = Math.max(1, Math.ceil(durationMin / slotIntervalMinutes));
333
+ }
334
+ function resetAppointmentDrag() {
335
+ appointmentDragStarted = false;
336
+ isDraggingAppointment = false;
337
+ draggedAppointment = null;
338
+ draggedAptSlotCount = 0;
339
+ moveTargetDoorId = null;
340
+ moveTargetSlotIndex = null;
341
+ }
342
+ function isSlotInMoveTarget(doorId, slotIndex) {
343
+ if (!isDraggingAppointment || moveTargetDoorId !== doorId) {
344
+ return false;
345
+ }
346
+ if (moveTargetSlotIndex == null) {
347
+ return false;
348
+ }
349
+ return slotIndex >= moveTargetSlotIndex && slotIndex < moveTargetSlotIndex + draggedAptSlotCount;
350
+ }
351
+ function hasOverlap(doorId, dateFrom, dateTo, excludeAptId) {
352
+ return appointments.some((apt) => {
353
+ if (apt.id === excludeAptId) {
419
354
  return false;
420
355
  }
421
- if (moveTargetSlotIndex == null) {
356
+ if (apt.segmentId !== doorId) {
422
357
  return false;
423
358
  }
424
- return (
425
- slotIndex >= moveTargetSlotIndex && slotIndex < moveTargetSlotIndex + draggedAptSlotCount
426
- );
427
- }
428
-
429
- function hasOverlap(doorId: number, dateFrom: Date, dateTo: Date, excludeAptId: number): boolean {
430
- return appointments.some((apt) => {
431
- if (apt.id === excludeAptId) {
432
- return false;
433
- }
434
- if (apt.segmentId !== doorId) {
435
- return false;
436
- }
437
- const aptFrom = new Date(apt.dateFrom);
438
- const aptTo = new Date(apt.dateTo);
439
- return dateFrom < aptTo && dateTo > aptFrom;
359
+ const aptFrom = new Date(apt.dateFrom);
360
+ const aptTo = new Date(apt.dateTo);
361
+ return dateFrom < aptTo && dateTo > aptFrom;
362
+ });
363
+ }
364
+ // ---- Month view helpers ----
365
+ function getMonthDays(date) {
366
+ const year = date.getFullYear();
367
+ const month = date.getMonth();
368
+ const firstDayOfMonth = new Date(year, month, 1);
369
+ // Start from Monday of the week containing the first day
370
+ // eslint-disable-next-line svelte/prefer-svelte-reactivity
371
+ const startDate = new Date(firstDayOfMonth);
372
+ const dayOfWeek = startDate.getDay();
373
+ const daysFromMonday = dayOfWeek === 0 ? 6 : dayOfWeek - 1;
374
+ startDate.setDate(startDate.getDate() - daysFromMonday);
375
+ const days = [];
376
+ // eslint-disable-next-line svelte/prefer-svelte-reactivity
377
+ const current = new Date(startDate);
378
+ // Generate 6 weeks (42 days) to fill the grid
379
+ for (let i = 0; i < 42; i++) {
380
+ days.push({
381
+ date: new Date(current),
382
+ isCurrentMonth: current.getMonth() === month
440
383
  });
384
+ current.setDate(current.getDate() + 1);
441
385
  }
442
-
443
- // ---- Month view helpers ----
444
-
445
- function getMonthDays(date: Date): Array<{ date: Date; isCurrentMonth: boolean }> {
446
- const year = date.getFullYear();
447
- const month = date.getMonth();
448
-
449
- const firstDayOfMonth = new Date(year, month, 1);
450
-
451
- // Start from Monday of the week containing the first day
452
- // eslint-disable-next-line svelte/prefer-svelte-reactivity
453
- const startDate = new Date(firstDayOfMonth);
454
- const dayOfWeek = startDate.getDay();
455
- const daysFromMonday = dayOfWeek === 0 ? 6 : dayOfWeek - 1;
456
- startDate.setDate(startDate.getDate() - daysFromMonday);
457
-
458
- const days: Array<{ date: Date; isCurrentMonth: boolean }> = [];
459
- // eslint-disable-next-line svelte/prefer-svelte-reactivity
460
- const current = new Date(startDate);
461
-
462
- // Generate 6 weeks (42 days) to fill the grid
463
- for (let i = 0; i < 42; i++) {
464
- days.push({
465
- date: new Date(current),
466
- isCurrentMonth: current.getMonth() === month,
467
- });
468
- current.setDate(current.getDate() + 1);
469
- }
470
-
471
- return days;
386
+ return days;
387
+ }
388
+ /** Cached month grid; rebuilt only when `selected` changes. */
389
+ const monthDays = $derived(getMonthDays(selected));
390
+ // ---- Week view helpers ----
391
+ function getWeekDays(date) {
392
+ // eslint-disable-next-line svelte/prefer-svelte-reactivity
393
+ const d = new Date(date);
394
+ const day = d.getDay();
395
+ const mondayOffset = day === 0 ? -6 : 1 - day;
396
+ d.setDate(d.getDate() + mondayOffset);
397
+ const week = [];
398
+ for (let i = 0; i < 7; i++) {
399
+ week.push(new Date(d));
400
+ d.setDate(d.getDate() + 1);
472
401
  }
473
-
474
- /** Cached month grid; rebuilt only when `selected` changes. */
475
- const monthDays = $derived(getMonthDays(selected));
476
-
477
- // ---- Week view helpers ----
478
-
479
- function getWeekDays(date: Date): Array<Date> {
480
- // eslint-disable-next-line svelte/prefer-svelte-reactivity
481
- const d = new Date(date);
482
- const day = d.getDay();
483
- const mondayOffset = day === 0 ? -6 : 1 - day;
484
- d.setDate(d.getDate() + mondayOffset);
485
-
486
- const week: Array<Date> = [];
487
- for (let i = 0; i < 7; i++) {
488
- week.push(new Date(d));
489
- d.setDate(d.getDate() + 1);
490
- }
491
- return week;
402
+ return week;
403
+ }
404
+ const weekDays = $derived(getWeekDays(selected));
405
+ // ---- View mode buttons ----
406
+ const viewModes = [
407
+ {
408
+ mode: CalendarViewMode.Day,
409
+ label: "Day"
410
+ },
411
+ {
412
+ mode: CalendarViewMode.Week,
413
+ label: "Week"
414
+ },
415
+ {
416
+ mode: CalendarViewMode.Month,
417
+ label: "Month"
492
418
  }
493
-
494
- const weekDays = $derived(getWeekDays(selected));
495
-
496
- // ---- View mode buttons ----
497
-
498
- const viewModes: ReadonlyArray<{ mode: CalendarViewMode; label: string }> = [
499
- { mode: CalendarViewMode.Day, label: 'Day' },
500
- { mode: CalendarViewMode.Week, label: 'Week' },
501
- { mode: CalendarViewMode.Month, label: 'Month' },
502
- ];
503
- </script>
504
-
505
- <!-- A drag can end anywhere on the page (the pointer is often released outside
506
- the calendar when sweeping across columns). Listen on the window so the
507
- drag state is always finalised and never left "stuck" for the next drag. -->
508
- <svelte:window onmouseup={onSlotMouseUp} />
509
-
510
- <!-- svelte-ignore a11y_no_static_element_interactions -->
511
- <!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
512
- <div
513
- class="appointment-calendar"
514
- class:is-moving-appointment={isDraggingAppointment}
515
- role="application"
516
- >
517
- <!-- Header: navigation + view toggle -->
518
- <div class="calendar-header">
519
- <div class="header-left">
520
- <Button
521
- label="Today"
522
- size={Size.Small}
523
- onclick={goToToday}
524
- >
525
- Today
526
- </Button>
527
- <div class="nav-arrows">
528
- <button
529
- class="nav-btn"
530
- onclick={() => {
531
- navigateDate(-1);
532
- }}
533
- aria-label="Previous"
534
- >
535
- <Icon
536
- name="chevron-left"
537
- size={Size.Small}
538
- colour={Colour['$ui-blue-14']}
539
- />
540
- </button>
541
- <button
542
- class="nav-btn"
543
- onclick={() => {
544
- navigateDate(1);
545
- }}
546
- aria-label="Next"
547
- >
548
- <Icon
549
- name="chevron-right"
550
- size={Size.Small}
551
- colour={Colour['$ui-blue-14']}
552
- />
553
- </button>
554
- </div>
555
- <h2 class="current-date-label">{formatDate(selected)}</h2>
556
- </div>
557
- <div class="header-right">
558
- {#if headerExtra}
559
- {@render headerExtra()}
560
- {/if}
561
- <div class="view-toggle">
562
- {#each viewModes as { mode, label } (mode)}
563
- <button
564
- class="view-btn"
565
- class:active={viewMode === mode}
566
- onclick={() => {
567
- onviewmodechange(mode);
568
- }}
569
- >
570
- {label}
571
- </button>
572
- {/each}
573
- </div>
574
- </div>
575
- </div>
576
-
577
- <!-- Week day tabs rendered OUTSIDE the scrollable body so they stay frozen -->
578
- {#if viewMode === CalendarViewMode.Week}
579
- <div class="week-day-tabs">
580
- {#each weekDays as day (day.getTime())}
581
- <button
582
- class="week-day-tab"
583
- class:active={sameDay(day, selected)}
584
- class:today={sameDay(day, today)}
585
- onclick={() => {
586
- ondatechange(day);
587
- }}
588
- >
589
- <span class="day-name">{dayNames[day.getDay()].slice(0, 3)}</span>
590
- <span class="day-num">{day.getDate()}</span>
591
- </button>
592
- {/each}
593
- </div>
594
- {/if}
595
-
596
- <!-- Calendar body -->
597
- <div class="calendar-body">
598
- {#if isLoading}
599
- <LoadingOverlay />
600
- {/if}
601
-
602
- {#if viewMode === CalendarViewMode.Day}
603
- <!-- DAY VIEW -->
604
- <!-- svelte-ignore a11y_no_static_element_interactions -->
605
- <div
606
- class="day-view"
607
- onmouseleave={() => {
608
- hoveredSlotIndex = null;
609
- }}
610
- >
611
- <!-- eslint-disable-next-line @typescript-eslint/no-confusing-void-expression -->
612
- {@render doorGrid(selected)}
613
- </div>
614
- {:else if viewMode === CalendarViewMode.Week}
615
- <!-- WEEK VIEW: day grid only, tabs are above calendar-body -->
616
- <!-- svelte-ignore a11y_no_static_element_interactions -->
617
- <div
618
- class="day-view"
619
- onmouseleave={() => {
620
- hoveredSlotIndex = null;
621
- }}
622
- >
623
- <!-- eslint-disable-next-line @typescript-eslint/no-confusing-void-expression -->
624
- {@render doorGrid(selected)}
625
- </div>
626
- {:else}
627
- <!-- MONTH VIEW -->
628
- <div class="month-view">
629
- <div class="month-header-row">
630
- {#each ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'] as dayLabel (dayLabel)}
631
- <div class="month-day-header">{dayLabel}</div>
632
- {/each}
633
- </div>
634
- <div class="month-grid">
635
- {#each monthDays as { date: dayDate, isCurrentMonth } (dayDate.getTime())}
636
- <button
637
- class="month-day-cell"
638
- class:other-month={!isCurrentMonth}
639
- class:today={sameDay(dayDate, today)}
640
- class:selected={sameDay(dayDate, selected)}
641
- onclick={() => {
642
- ondatechange(dayDate);
643
- onviewmodechange(CalendarViewMode.Day);
644
- }}
645
- >
646
- <span class="day-number">{dayDate.getDate()}</span>
647
- <div class="day-badges">
648
- {#each countsAt(dayDate) as { doorId, doorName, count } (doorId)}
649
- <span class="door-badge">
650
- {doorName}: {count}
651
- </span>
652
- {/each}
653
- </div>
654
- </button>
655
- {/each}
656
- </div>
657
- </div>
658
- {/if}
659
- </div>
660
- </div>
661
-
662
- {#snippet doorGrid(day: Date)}
663
- <div class="grid-header">
664
- <div class="time-header">Time</div>
665
- {#each doors as door (door.id)}
666
- <div class="door-header">{door.name}</div>
667
- {/each}
668
- </div>
669
- <div class="grid-body">
670
- <div class="time-column">
671
- {#each timeSlots as slot, slotIdx (slot.hour * 60 + slot.minute)}
672
- <div
673
- class="time-label"
674
- class:hour-start={slot.minute === 0}
675
- class:row-hover={hoveredSlotIndex === slotIdx}
676
- >
677
- {#if slot.minute === 0}
678
- <span>{slot.label}</span>
679
- {/if}
680
- </div>
681
- {/each}
682
- </div>
683
- {#each doors as door (door.id)}
684
- <div class="door-column">
685
- <!-- Slot cells for drag interaction (rendered first, sit below appointments) -->
686
- {#each timeSlots as slot, slotIdx (slot.hour * 60 + slot.minute)}
687
- <!-- svelte-ignore a11y_no_static_element_interactions -->
688
- <div
689
- class="slot-cell"
690
- class:hour-start={slot.minute === 0}
691
- class:drag-selected={isSlotInDragRange(door.id, slotIdx)}
692
- class:move-target={isSlotInMoveTarget(door.id, slotIdx)}
693
- class:row-hover={hoveredSlotIndex === slotIdx}
694
- onmousedown={(e) => {
695
- onSlotMouseDown(door.id, slotIdx, e);
696
- }}
697
- onmouseenter={(e) => {
698
- hoveredSlotIndex = slotIdx;
699
- onSlotMouseEnter(door.id, slotIdx, e);
700
- }}
701
- ></div>
702
- {/each}
703
- <!-- Appointment blocks positioned absolutely (rendered after, sit above slots) -->
704
- {#each appointmentsAt(door.id, day) as apt (apt.id)}
705
- <!-- svelte-ignore a11y_no_static_element_interactions -->
706
- <div
707
- class="appointment-block"
708
- class:inbound={apt.typeCode === 'INBOUND'}
709
- class:outbound={apt.typeCode === 'OUTBOUND'}
710
- class:dragging={isDraggingAppointment && draggedAppointment?.id === apt.id}
711
- class:movable={!!onappointmentmove}
712
- style="top: {apt.topPercent}%; height: {apt.heightPercent}%;"
713
- onmousedown={(e) => {
714
- onAppointmentMouseDown(apt, e);
715
- }}
716
- >
717
- <span class="apt-type">{apt.typeCode}</span>
718
- <span class="apt-label">{apt.label}</span>
719
- {#if apt.sublabel}
720
- <span class="apt-sublabel">{apt.sublabel}</span>
721
- {/if}
722
- </div>
723
- {/each}
724
- </div>
725
- {/each}
726
- </div>
727
- {/snippet}
728
-
419
+ ];
420
+ </script>
421
+
422
+ <!-- A drag can end anywhere on the page (the pointer is often released outside
423
+ the calendar when sweeping across columns). Listen on the window so the
424
+ drag state is always finalised and never left "stuck" for the next drag. -->
425
+ <svelte:window onmouseup={onSlotMouseUp} />
426
+
427
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
428
+ <!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
429
+ <div
430
+ class="appointment-calendar"
431
+ class:is-moving-appointment={isDraggingAppointment}
432
+ role="application"
433
+ >
434
+ <!-- Header: navigation + view toggle -->
435
+ <div class="calendar-header">
436
+ <div class="header-left">
437
+ <Button
438
+ label="Today"
439
+ size={Size.Small}
440
+ onclick={goToToday}
441
+ >
442
+ Today
443
+ </Button>
444
+ <div class="nav-arrows">
445
+ <button
446
+ class="nav-btn"
447
+ onclick={() => {
448
+ navigateDate(-1);
449
+ }}
450
+ aria-label="Previous"
451
+ >
452
+ <Icon
453
+ name="chevron-left"
454
+ size={Size.Small}
455
+ colour={Colour['$ui-blue-14']}
456
+ />
457
+ </button>
458
+ <button
459
+ class="nav-btn"
460
+ onclick={() => {
461
+ navigateDate(1);
462
+ }}
463
+ aria-label="Next"
464
+ >
465
+ <Icon
466
+ name="chevron-right"
467
+ size={Size.Small}
468
+ colour={Colour['$ui-blue-14']}
469
+ />
470
+ </button>
471
+ </div>
472
+ <h2 class="current-date-label">{formatDate(selected)}</h2>
473
+ </div>
474
+ <div class="header-right">
475
+ {#if headerExtra}
476
+ {@render headerExtra()}
477
+ {/if}
478
+ <div class="view-toggle">
479
+ {#each viewModes as { mode, label } (mode)}
480
+ <button
481
+ class="view-btn"
482
+ class:active={viewMode === mode}
483
+ onclick={() => {
484
+ onviewmodechange(mode);
485
+ }}
486
+ >
487
+ {label}
488
+ </button>
489
+ {/each}
490
+ </div>
491
+ </div>
492
+ </div>
493
+
494
+ <!-- Week day tabs rendered OUTSIDE the scrollable body so they stay frozen -->
495
+ {#if viewMode === CalendarViewMode.Week}
496
+ <div class="week-day-tabs">
497
+ {#each weekDays as day (day.getTime())}
498
+ <button
499
+ class="week-day-tab"
500
+ class:active={sameDay(day, selected)}
501
+ class:today={sameDay(day, today)}
502
+ onclick={() => {
503
+ ondatechange(day);
504
+ }}
505
+ >
506
+ <span class="day-name">{dayNames[day.getDay()].slice(0, 3)}</span>
507
+ <span class="day-num">{day.getDate()}</span>
508
+ </button>
509
+ {/each}
510
+ </div>
511
+ {/if}
512
+
513
+ <!-- Calendar body -->
514
+ <div class="calendar-body">
515
+ {#if isLoading}
516
+ <LoadingOverlay />
517
+ {/if}
518
+
519
+ {#if viewMode === CalendarViewMode.Day}
520
+ <!-- DAY VIEW -->
521
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
522
+ <div
523
+ class="day-view"
524
+ onmouseleave={() => {
525
+ hoveredSlotIndex = null;
526
+ }}
527
+ >
528
+ <!-- eslint-disable-next-line @typescript-eslint/no-confusing-void-expression -->
529
+ {@render doorGrid(selected)}
530
+ </div>
531
+ {:else if viewMode === CalendarViewMode.Week}
532
+ <!-- WEEK VIEW: day grid only, tabs are above calendar-body -->
533
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
534
+ <div
535
+ class="day-view"
536
+ onmouseleave={() => {
537
+ hoveredSlotIndex = null;
538
+ }}
539
+ >
540
+ <!-- eslint-disable-next-line @typescript-eslint/no-confusing-void-expression -->
541
+ {@render doorGrid(selected)}
542
+ </div>
543
+ {:else}
544
+ <!-- MONTH VIEW -->
545
+ <div class="month-view">
546
+ <div class="month-header-row">
547
+ {#each ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'] as dayLabel (dayLabel)}
548
+ <div class="month-day-header">{dayLabel}</div>
549
+ {/each}
550
+ </div>
551
+ <div class="month-grid">
552
+ {#each monthDays as { date: dayDate, isCurrentMonth } (dayDate.getTime())}
553
+ <button
554
+ class="month-day-cell"
555
+ class:other-month={!isCurrentMonth}
556
+ class:today={sameDay(dayDate, today)}
557
+ class:selected={sameDay(dayDate, selected)}
558
+ onclick={() => {
559
+ ondatechange(dayDate);
560
+ onviewmodechange(CalendarViewMode.Day);
561
+ }}
562
+ >
563
+ <span class="day-number">{dayDate.getDate()}</span>
564
+ <div class="day-badges">
565
+ {#each countsAt(dayDate) as { doorId, doorName, count } (doorId)}
566
+ <span class="door-badge">
567
+ {doorName}: {count}
568
+ </span>
569
+ {/each}
570
+ </div>
571
+ </button>
572
+ {/each}
573
+ </div>
574
+ </div>
575
+ {/if}
576
+ </div>
577
+ </div>
578
+
579
+ {#snippet doorGrid(day: Date)}
580
+ <div class="grid-header">
581
+ <div class="time-header">Time</div>
582
+ {#each doors as door (door.id)}
583
+ <div class="door-header">{door.name}</div>
584
+ {/each}
585
+ </div>
586
+ <div class="grid-body">
587
+ <div class="time-column">
588
+ {#each timeSlots as slot, slotIdx (slot.hour * 60 + slot.minute)}
589
+ <div
590
+ class="time-label"
591
+ class:hour-start={slot.minute === 0}
592
+ class:row-hover={hoveredSlotIndex === slotIdx}
593
+ >
594
+ {#if slot.minute === 0}
595
+ <span>{slot.label}</span>
596
+ {/if}
597
+ </div>
598
+ {/each}
599
+ </div>
600
+ {#each doors as door (door.id)}
601
+ <div class="door-column">
602
+ <!-- Slot cells for drag interaction (rendered first, sit below appointments) -->
603
+ {#each timeSlots as slot, slotIdx (slot.hour * 60 + slot.minute)}
604
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
605
+ <div
606
+ class="slot-cell"
607
+ class:hour-start={slot.minute === 0}
608
+ class:drag-selected={isSlotInDragRange(door.id, slotIdx)}
609
+ class:move-target={isSlotInMoveTarget(door.id, slotIdx)}
610
+ class:row-hover={hoveredSlotIndex === slotIdx}
611
+ onmousedown={(e) => {
612
+ onSlotMouseDown(door.id, slotIdx, e);
613
+ }}
614
+ onmouseenter={(e) => {
615
+ hoveredSlotIndex = slotIdx;
616
+ onSlotMouseEnter(door.id, slotIdx, e);
617
+ }}
618
+ ></div>
619
+ {/each}
620
+ <!-- Appointment blocks positioned absolutely (rendered after, sit above slots) -->
621
+ {#each appointmentsAt(door.id, day) as apt (apt.id)}
622
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
623
+ <div
624
+ class="appointment-block"
625
+ class:inbound={apt.typeCode === 'INBOUND'}
626
+ class:outbound={apt.typeCode === 'OUTBOUND'}
627
+ class:dragging={isDraggingAppointment && draggedAppointment?.id === apt.id}
628
+ class:movable={!!onappointmentmove}
629
+ style="top: {apt.topPercent}%; height: {apt.heightPercent}%;"
630
+ onmousedown={(e) => {
631
+ onAppointmentMouseDown(apt, e);
632
+ }}
633
+ >
634
+ <span class="apt-type">{apt.typeCode}</span>
635
+ <span class="apt-label">{apt.label}</span>
636
+ {#if apt.sublabel}
637
+ <span class="apt-sublabel">{apt.sublabel}</span>
638
+ {/if}
639
+ </div>
640
+ {/each}
641
+ </div>
642
+ {/each}
643
+ </div>
644
+ {/snippet}
645
+
729
646
  <style>.appointment-calendar {
730
647
  display: flex;
731
648
  flex-flow: column nowrap;
@@ -1150,4 +1067,4 @@
1150
1067
  white-space: nowrap;
1151
1068
  overflow: hidden;
1152
1069
  text-overflow: ellipsis;
1153
- }</style>
1070
+ }</style>