@svgrid/grid 2.1.21 → 2.1.23

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 (219) hide show
  1. package/README.md +9 -2
  2. package/dist/DockNodeView.svelte +306 -0
  3. package/dist/DockNodeView.svelte.d.ts +7 -0
  4. package/dist/DockPopoutHost.svelte +17 -0
  5. package/dist/DockPopoutHost.svelte.d.ts +14 -0
  6. package/dist/GridMenus.svelte +83 -3
  7. package/dist/SvAutoComplete.svelte +5 -3
  8. package/dist/SvCarousel.svelte +20 -42
  9. package/dist/SvColorInput.svelte +4 -3
  10. package/dist/SvColorInput.svelte.d.ts +1 -1
  11. package/dist/SvComboBox.svelte +7 -3
  12. package/dist/SvCommand.svelte +28 -85
  13. package/dist/SvCountryInput.svelte +1 -1
  14. package/dist/SvDateRangeInput.svelte +1 -1
  15. package/dist/SvDateTimePicker.svelte +9 -3
  16. package/dist/SvDateTimePicker.svelte.d.ts +3 -0
  17. package/dist/SvDockLayout.svelte +244 -0
  18. package/dist/SvDockLayout.svelte.d.ts +77 -0
  19. package/dist/SvDockManager.svelte +922 -0
  20. package/dist/SvDockManager.svelte.d.ts +106 -0
  21. package/dist/SvDrawer.svelte +69 -47
  22. package/dist/SvDrawer.svelte.d.ts +4 -0
  23. package/dist/SvDropDownList.svelte +85 -13
  24. package/dist/SvDropDownList.svelte.d.ts +3 -0
  25. package/dist/SvField.svelte +21 -4
  26. package/dist/SvField.svelte.d.ts +1 -0
  27. package/dist/SvForm.svelte +201 -91
  28. package/dist/SvForm.svelte.d.ts +15 -22
  29. package/dist/SvGrid.controller.svelte.d.ts +32 -1
  30. package/dist/SvGrid.controller.svelte.js +156 -27
  31. package/dist/SvGrid.css +193 -5
  32. package/dist/SvGrid.svelte +357 -12
  33. package/dist/SvGrid.types.d.ts +452 -1
  34. package/dist/SvGridDropdown.svelte +8 -1
  35. package/dist/SvGridSelect.svelte +1 -0
  36. package/dist/SvListBox.svelte +36 -14
  37. package/dist/SvMaskedInput.svelte +4 -3
  38. package/dist/SvMaskedInput.svelte.d.ts +1 -1
  39. package/dist/SvMenu.svelte +1 -1
  40. package/dist/SvMenuList.svelte +23 -71
  41. package/dist/SvMenuList.svelte.d.ts +2 -13
  42. package/dist/SvModal.svelte +12 -29
  43. package/dist/SvMultiSelect.svelte +1 -0
  44. package/dist/SvNumberInput.svelte +4 -3
  45. package/dist/SvNumberInput.svelte.d.ts +1 -1
  46. package/dist/SvPagination.svelte +18 -18
  47. package/dist/SvPasswordInput.svelte +3 -2
  48. package/dist/SvPasswordInput.svelte.d.ts +1 -1
  49. package/dist/SvPhoneInput.svelte +3 -2
  50. package/dist/SvPhoneInput.svelte.d.ts +1 -1
  51. package/dist/SvPopover.svelte +1 -1
  52. package/dist/SvStepper.svelte +6 -9
  53. package/dist/SvTagsInput.svelte +3 -2
  54. package/dist/SvTagsInput.svelte.d.ts +1 -1
  55. package/dist/SvTooltip.svelte +12 -23
  56. package/dist/SvTour.svelte +32 -4
  57. package/dist/SvTreeSelect.svelte +1 -0
  58. package/dist/build-api.js +76 -54
  59. package/dist/cdn/svgrid.js +10907 -6444
  60. package/dist/cdn/svgrid.svelte-external.js +9622 -5475
  61. package/dist/columns.js +6 -0
  62. package/dist/conditional-formatting.d.ts +67 -3
  63. package/dist/conditional-formatting.js +124 -17
  64. package/dist/createCarousel.svelte.d.ts +72 -0
  65. package/dist/createCarousel.svelte.js +80 -0
  66. package/dist/createCommand.svelte.d.ts +62 -0
  67. package/dist/createCommand.svelte.js +109 -0
  68. package/dist/createForm.svelte.d.ts +62 -0
  69. package/dist/createForm.svelte.js +374 -0
  70. package/dist/createMenu.svelte.d.ts +88 -0
  71. package/dist/createMenu.svelte.js +153 -0
  72. package/dist/createOverlay.svelte.d.ts +38 -0
  73. package/dist/createOverlay.svelte.js +68 -0
  74. package/dist/createPagination.d.ts +74 -0
  75. package/dist/createPagination.js +71 -0
  76. package/dist/createPopoverSelect.svelte.js +6 -12
  77. package/dist/createStepper.d.ts +44 -0
  78. package/dist/createStepper.js +26 -0
  79. package/dist/createTooltip.svelte.d.ts +32 -0
  80. package/dist/createTooltip.svelte.js +61 -0
  81. package/dist/datetime/timezone.d.ts +36 -0
  82. package/dist/datetime/timezone.js +110 -0
  83. package/dist/dock-context.d.ts +52 -0
  84. package/dist/dock-context.js +1 -0
  85. package/dist/dock-manager-model.d.ts +136 -0
  86. package/dist/dock-manager-model.js +514 -0
  87. package/dist/dock-model.d.ts +105 -0
  88. package/dist/dock-model.js +288 -0
  89. package/dist/dock-popout.d.ts +30 -0
  90. package/dist/dock-popout.js +81 -0
  91. package/dist/editor-contract.d.ts +2 -0
  92. package/dist/filter-operators.d.ts +6 -0
  93. package/dist/filter-operators.js +35 -2
  94. package/dist/filtering/excel-filters.d.ts +18 -1
  95. package/dist/filtering/excel-filters.js +56 -0
  96. package/dist/form-field.d.ts +73 -0
  97. package/dist/form-field.js +15 -0
  98. package/dist/index.d.ts +25 -5
  99. package/dist/index.js +40 -4
  100. package/dist/list-nav.d.ts +18 -0
  101. package/dist/list-nav.js +30 -0
  102. package/dist/list-option.d.ts +2 -0
  103. package/dist/menu-item.d.ts +20 -0
  104. package/dist/menu-item.js +1 -0
  105. package/dist/menus.d.ts +5 -0
  106. package/dist/menus.js +68 -16
  107. package/dist/panel-resize.d.ts +25 -0
  108. package/dist/panel-resize.js +40 -0
  109. package/dist/popover.d.ts +29 -3
  110. package/dist/popover.js +22 -6
  111. package/dist/recurrence.d.ts +33 -7
  112. package/dist/recurrence.js +151 -13
  113. package/dist/row-drag.d.ts +8 -0
  114. package/dist/row-drag.js +11 -0
  115. package/dist/scheduler-ical.d.ts +30 -0
  116. package/dist/scheduler-ical.js +180 -0
  117. package/dist/scheduler-model.d.ts +269 -0
  118. package/dist/scheduler-model.js +602 -0
  119. package/dist/scheduler-view.svelte.d.ts +22 -0
  120. package/dist/scheduler-view.svelte.js +13 -0
  121. package/dist/spreadsheet.js +20 -12
  122. package/dist/ui-app.types.d.ts +3 -0
  123. package/package.json +5 -1
  124. package/src/DockNodeView.svelte +306 -0
  125. package/src/DockPopoutHost.svelte +17 -0
  126. package/src/GridMenus.svelte +83 -3
  127. package/src/SvAutoComplete.svelte +5 -3
  128. package/src/SvCarousel.svelte +20 -42
  129. package/src/SvCarousel.test.ts +68 -0
  130. package/src/SvColorInput.svelte +4 -3
  131. package/src/SvComboBox.svelte +7 -3
  132. package/src/SvCommand.svelte +28 -85
  133. package/src/SvCommand.test.ts +78 -0
  134. package/src/SvCountryInput.svelte +1 -1
  135. package/src/SvDateRangeInput.svelte +1 -1
  136. package/src/SvDateTimePicker.svelte +9 -3
  137. package/src/SvDockLayout.svelte +244 -0
  138. package/src/SvDockManager.svelte +922 -0
  139. package/src/SvDrawer.svelte +69 -47
  140. package/src/SvDropDownList.svelte +85 -13
  141. package/src/SvField.svelte +21 -4
  142. package/src/SvForm.svelte +201 -91
  143. package/src/SvForm.test.ts +299 -0
  144. package/src/SvGrid.controller.svelte.ts +152 -28
  145. package/src/SvGrid.css +193 -5
  146. package/src/SvGrid.svelte +357 -12
  147. package/src/SvGrid.types.ts +463 -1
  148. package/src/SvGridDropdown.svelte +8 -1
  149. package/src/SvGridSelect.svelte +1 -0
  150. package/src/SvListBox.svelte +36 -14
  151. package/src/SvMaskedInput.svelte +4 -3
  152. package/src/SvMenu.svelte +1 -1
  153. package/src/SvMenu.test.ts +24 -0
  154. package/src/SvMenuList.svelte +23 -71
  155. package/src/SvModal.svelte +12 -29
  156. package/src/SvModal.test.ts +82 -0
  157. package/src/SvMultiSelect.svelte +1 -0
  158. package/src/SvNumberInput.svelte +4 -3
  159. package/src/SvPagination.svelte +18 -18
  160. package/src/SvPasswordInput.svelte +3 -2
  161. package/src/SvPhoneInput.svelte +3 -2
  162. package/src/SvPopover.svelte +1 -1
  163. package/src/SvStepper.svelte +6 -9
  164. package/src/SvTagsInput.svelte +3 -2
  165. package/src/SvTooltip.svelte +12 -23
  166. package/src/SvTooltip.test.ts +73 -0
  167. package/src/SvTour.svelte +32 -4
  168. package/src/SvTreeSelect.svelte +1 -0
  169. package/src/build-api.ts +74 -51
  170. package/src/columns.ts +5 -0
  171. package/src/conditional-formatting.test.ts +104 -0
  172. package/src/conditional-formatting.ts +176 -19
  173. package/src/createCarousel.svelte.ts +104 -0
  174. package/src/createCommand.svelte.ts +125 -0
  175. package/src/createForm.svelte.ts +333 -0
  176. package/src/createMenu.svelte.ts +202 -0
  177. package/src/createOverlay.svelte.ts +101 -0
  178. package/src/createPagination.test.ts +65 -0
  179. package/src/createPagination.ts +84 -0
  180. package/src/createPopoverSelect.svelte.ts +5 -9
  181. package/src/createStepper.test.ts +62 -0
  182. package/src/createStepper.ts +61 -0
  183. package/src/createTooltip.svelte.ts +86 -0
  184. package/src/datetime/timezone.test.ts +69 -0
  185. package/src/datetime/timezone.ts +134 -0
  186. package/src/dock-context.ts +47 -0
  187. package/src/dock-manager-model.test.ts +275 -0
  188. package/src/dock-manager-model.ts +593 -0
  189. package/src/dock-model.test.ts +163 -0
  190. package/src/dock-model.ts +368 -0
  191. package/src/dock-popout.ts +94 -0
  192. package/src/editor-contract.ts +2 -0
  193. package/src/filter-operators.test.ts +59 -0
  194. package/src/filter-operators.ts +35 -2
  195. package/src/filtering/excel-filters.test.ts +86 -1
  196. package/src/filtering/excel-filters.ts +65 -0
  197. package/src/form-field.ts +87 -0
  198. package/src/index.ts +158 -4
  199. package/src/list-nav.test.ts +49 -0
  200. package/src/list-nav.ts +29 -0
  201. package/src/list-option.ts +2 -0
  202. package/src/menu-item.ts +21 -0
  203. package/src/menus.test.ts +48 -0
  204. package/src/menus.ts +73 -16
  205. package/src/panel-resize.ts +52 -0
  206. package/src/popover.bounds.test.ts +93 -0
  207. package/src/popover.ts +51 -7
  208. package/src/recurrence.test.ts +88 -1
  209. package/src/recurrence.ts +166 -15
  210. package/src/row-drag.ts +10 -0
  211. package/src/scheduler-ical.test.ts +68 -0
  212. package/src/scheduler-ical.ts +180 -0
  213. package/src/scheduler-model.test.ts +546 -0
  214. package/src/scheduler-model.ts +853 -0
  215. package/src/scheduler-view.svelte.ts +33 -0
  216. package/src/spreadsheet.ts +20 -12
  217. package/src/svgrid.effect-driven-filter.svelte.test.ts +116 -0
  218. package/src/ui-app.types.ts +3 -0
  219. package/src/virtual.test.ts +6 -4
@@ -0,0 +1,546 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import {
3
+ resolveEvents,
4
+ hasConflict,
5
+ overlapCount,
6
+ overlapsBands,
7
+ workingIntervals,
8
+ withinWorking,
9
+ eventsOnDay,
10
+ layoutDayEvents,
11
+ monthWeekSegments,
12
+ agendaGroups,
13
+ rangeForView,
14
+ daysForView,
15
+ navigateAnchor,
16
+ timelineAxis,
17
+ timelineGeom,
18
+ timelineRows,
19
+ type EventSpec,
20
+ type ResolvedEvent,
21
+ type RecurrenceException,
22
+ } from './scheduler-model'
23
+ import type { RecurrenceRule } from './recurrence'
24
+ import type { SchedulerResource } from './SvGrid.types'
25
+
26
+ type Row = {
27
+ id: string
28
+ title: string
29
+ start: string
30
+ end?: string
31
+ allDay?: boolean
32
+ who?: string
33
+ repeat?: RecurrenceRule | RecurrenceRule[]
34
+ exceptions?: RecurrenceException[]
35
+ }
36
+
37
+ function spec(over: Partial<EventSpec<Row>> = {}): EventSpec<Row> {
38
+ return {
39
+ getKey: (r) => r.id,
40
+ getStart: (r) => r.start,
41
+ getEnd: (r) => r.end,
42
+ getAllDay: (r) => r.allDay ?? false,
43
+ getTitle: (r) => r.title,
44
+ getResource: (r) => r.who,
45
+ getRecurrence: (r) => r.repeat,
46
+ getExceptions: (r) => r.exceptions,
47
+ ...over,
48
+ }
49
+ }
50
+
51
+ // A local-time date, kept explicit so tests don't depend on the wall clock.
52
+ const at = (y: number, m: number, d: number, h = 0, min = 0) => new Date(y, m - 1, d, h, min)
53
+
54
+ describe('resolveEvents', () => {
55
+ it('keeps single events that overlap the window and drops those outside', () => {
56
+ const rows: Row[] = [
57
+ { id: 'a', title: 'In', start: '2026-07-15T09:00', end: '2026-07-15T10:00' },
58
+ { id: 'b', title: 'Out', start: '2026-09-01T09:00', end: '2026-09-01T10:00' },
59
+ ]
60
+ const out = resolveEvents(rows, spec(), at(2026, 7, 1), at(2026, 8, 1))
61
+ expect(out.map((e) => e.rowKey)).toEqual(['a'])
62
+ expect(out[0]!.recurring).toBe(false)
63
+ })
64
+
65
+ it('falls back to a default duration when a row has no end', () => {
66
+ const rows: Row[] = [{ id: 'a', title: 'x', start: '2026-07-15T09:00' }]
67
+ const out = resolveEvents(rows, spec({ defaultDurationMin: 45 }), at(2026, 7, 1), at(2026, 8, 1))
68
+ expect(out[0]!.end.getTime() - out[0]!.start.getTime()).toBe(45 * 60_000)
69
+ })
70
+
71
+ it('expands a weekly recurrence to one instance per matching day, keeping the time', () => {
72
+ const rows: Row[] = [
73
+ {
74
+ id: 'standup',
75
+ title: 'Standup',
76
+ start: '2026-07-06T09:30',
77
+ end: '2026-07-06T09:45',
78
+ repeat: { freq: 'weekly', weekdays: [1] }, // Mondays
79
+ },
80
+ ]
81
+ const out = resolveEvents(rows, spec(), at(2026, 7, 1), at(2026, 8, 1))
82
+ // Mondays in July 2026: 6, 13, 20, 27
83
+ expect(out).toHaveLength(4)
84
+ expect(out.every((e) => e.recurring)).toBe(true)
85
+ expect(out.every((e) => e.start.getHours() === 9 && e.start.getMinutes() === 30)).toBe(true)
86
+ expect(out.every((e) => e.end.getTime() - e.start.getTime() === 15 * 60_000)).toBe(true)
87
+ // Distinct instance keys.
88
+ expect(new Set(out.map((e) => e.key)).size).toBe(4)
89
+ })
90
+
91
+ it('does not emit recurring occurrences before the event start date', () => {
92
+ // 2026-07-15 is a Wednesday; the rule matches Mon-Fri, but the event only
93
+ // begins on the 15th, so Mon 13 / Tue 14 must NOT appear.
94
+ const rows: Row[] = [
95
+ {
96
+ id: 'e',
97
+ title: 'Sync',
98
+ start: '2026-07-15T10:00',
99
+ end: '2026-07-15T10:30',
100
+ repeat: { freq: 'weekly', weekdays: [1, 2, 3, 4, 5] },
101
+ },
102
+ ]
103
+ const out = resolveEvents(rows, spec(), at(2026, 7, 13), at(2026, 7, 20))
104
+ expect(out.map((e) => e.start.getDate()).sort((a, b) => a - b)).toEqual([15, 16, 17])
105
+ })
106
+
107
+ it('carries a secondary color (left-strip accent) when the spec provides one', () => {
108
+ const rows: Row[] = [{ id: 'a', title: 'Shift', start: '2026-07-15T09:00', end: '2026-07-15T12:00' }]
109
+ const out = resolveEvents(
110
+ rows,
111
+ spec({ getColor: () => '#111', getSecondaryColor: () => '#f80' }),
112
+ at(2026, 7, 1),
113
+ at(2026, 8, 1),
114
+ )
115
+ expect(out[0]!.color).toBe('#111')
116
+ expect(out[0]!.color2).toBe('#f80')
117
+ })
118
+
119
+ const weekly: Row = {
120
+ id: 'standup',
121
+ title: 'Standup',
122
+ start: '2026-07-06T09:30',
123
+ end: '2026-07-06T09:45',
124
+ repeat: { freq: 'weekly', weekdays: [1] }, // Mondays: 6, 13, 20, 27
125
+ }
126
+
127
+ it('skips a deleted recurrence occurrence', () => {
128
+ const rows: Row[] = [{ ...weekly, exceptions: [{ occurrenceStart: '2026-07-13T09:30', deleted: true }] }]
129
+ const out = resolveEvents(rows, spec(), at(2026, 7, 1), at(2026, 8, 1))
130
+ expect(out.map((e) => e.start.getDate())).toEqual([6, 20, 27]) // 13 removed
131
+ })
132
+
133
+ it('applies a per-occurrence override (moved time + retitled) to one instance only', () => {
134
+ const rows: Row[] = [
135
+ { ...weekly, exceptions: [{ occurrenceStart: '2026-07-13T09:30', start: '2026-07-13T14:00', end: '2026-07-13T15:00', title: 'Moved' }] },
136
+ ]
137
+ const out = resolveEvents(rows, spec(), at(2026, 7, 1), at(2026, 8, 1))
138
+ expect(out).toHaveLength(4)
139
+ const moved = out.find((e) => e.start.getDate() === 13)!
140
+ expect(moved.start.getHours()).toBe(14)
141
+ expect(moved.end.getHours()).toBe(15)
142
+ expect(moved.title).toBe('Moved')
143
+ expect(moved.isException).toBe(true)
144
+ // Its identity is keyed on the CANONICAL 09:30 start, not the moved time.
145
+ expect(moved.occurrenceStart!.getHours()).toBe(9)
146
+ expect(moved.occurrenceStart!.getMinutes()).toBe(30)
147
+ // Untouched siblings keep the base time + title and are not exceptions.
148
+ const other = out.find((e) => e.start.getDate() === 20)!
149
+ expect(other.start.getHours()).toBe(9)
150
+ expect(other.title).toBe('Standup')
151
+ expect(other.isException).toBeFalsy()
152
+ })
153
+
154
+ it('sorts all-day before timed at the same start', () => {
155
+ const rows: Row[] = [
156
+ { id: 'timed', title: 't', start: '2026-07-15T00:00', end: '2026-07-15T01:00' },
157
+ { id: 'allday', title: 'a', start: '2026-07-15T00:00', allDay: true },
158
+ ]
159
+ const out = resolveEvents(rows, spec(), at(2026, 7, 1), at(2026, 8, 1))
160
+ expect(out.map((e) => e.rowKey)).toEqual(['allday', 'timed'])
161
+ })
162
+ })
163
+
164
+ describe('hasConflict', () => {
165
+ const rows: Row[] = [
166
+ { id: 'a', title: 'A', start: '2026-07-15T09:00', end: '2026-07-15T10:00', who: 'room1' },
167
+ { id: 'b', title: 'B', start: '2026-07-15T11:00', end: '2026-07-15T12:00', who: 'room2' },
168
+ ]
169
+ const events = resolveEvents(rows, spec(), at(2026, 7, 1), at(2026, 8, 1))
170
+
171
+ it('detects an overlap on the same resource', () => {
172
+ expect(hasConflict(at(2026, 7, 15, 9, 30), at(2026, 7, 15, 10, 30), 'room1', events)).toBe(true)
173
+ })
174
+ it('ignores overlaps on a different resource', () => {
175
+ expect(hasConflict(at(2026, 7, 15, 9, 30), at(2026, 7, 15, 10, 30), 'room2', events)).toBe(false)
176
+ })
177
+ it('ignores the event being moved (excludeRowKey)', () => {
178
+ expect(hasConflict(at(2026, 7, 15, 9, 0), at(2026, 7, 15, 10, 0), 'room1', events, 'a')).toBe(false)
179
+ })
180
+ it('is false when there is a clear gap', () => {
181
+ expect(hasConflict(at(2026, 7, 15, 10, 0), at(2026, 7, 15, 11, 0), 'room1', events)).toBe(false)
182
+ })
183
+ })
184
+
185
+ describe('overlapCount (capacity)', () => {
186
+ const rows: Row[] = [
187
+ { id: 'a', title: 'A', start: '2026-07-15T09:00', end: '2026-07-15T10:00', who: 'room1' },
188
+ { id: 'b', title: 'B', start: '2026-07-15T09:30', end: '2026-07-15T10:30', who: 'room1' },
189
+ { id: 'c', title: 'C', start: '2026-07-15T09:15', end: '2026-07-15T09:45', who: 'room2' },
190
+ ]
191
+ const events = resolveEvents(rows, spec(), at(2026, 7, 1), at(2026, 8, 1))
192
+ it('counts overlapping events on the same resource', () => {
193
+ expect(overlapCount(at(2026, 7, 15, 9, 20), at(2026, 7, 15, 9, 40), 'room1', events)).toBe(2)
194
+ })
195
+ it('excludes the moved event and other resources', () => {
196
+ expect(overlapCount(at(2026, 7, 15, 9, 20), at(2026, 7, 15, 9, 40), 'room1', events, 'a')).toBe(1)
197
+ expect(overlapCount(at(2026, 7, 15, 9, 20), at(2026, 7, 15, 9, 40), 'room2', events)).toBe(1)
198
+ })
199
+ })
200
+
201
+ describe('overlapsBands (restricted hours)', () => {
202
+ const lunch = [{ start: 12, end: 13 }]
203
+ it('is true when the range overlaps a band', () => {
204
+ expect(overlapsBands(12 * 60 + 30, 13 * 60 + 30, lunch)).toBe(true)
205
+ expect(overlapsBands(11 * 60, 12 * 60 + 15, lunch)).toBe(true)
206
+ })
207
+ it('is false when the range is clear of every band', () => {
208
+ expect(overlapsBands(13 * 60, 14 * 60, lunch)).toBe(false)
209
+ expect(overlapsBands(10 * 60, 12 * 60, lunch)).toBe(false) // ends exactly at 12
210
+ })
211
+ })
212
+
213
+ describe('workingIntervals / withinWorking (per-resource availability)', () => {
214
+ const win = [
215
+ { days: [1, 3, 5], start: 9, end: 13 }, // Mon/Wed/Fri morning
216
+ { days: [2, 4], start: 14, end: 18 }, // Tue/Thu afternoon
217
+ ]
218
+ const band = [0, 24 * 60] as const
219
+
220
+ it('returns the matching window for a weekday (in minutes)', () => {
221
+ expect(workingIntervals(1, win, band[0], band[1])).toEqual([[540, 780]]) // Mon 9-13
222
+ expect(workingIntervals(2, win, band[0], band[1])).toEqual([[840, 1080]]) // Tue 14-18
223
+ })
224
+ it('is empty for a day with no matching window (day off)', () => {
225
+ expect(workingIntervals(0, win, band[0], band[1])).toEqual([]) // Sunday off
226
+ })
227
+ it('clamps to the visible band and merges overlapping windows', () => {
228
+ const w = [{ start: 6, end: 12 }, { start: 11, end: 20 }]
229
+ expect(workingIntervals(1, w, 8 * 60, 18 * 60)).toEqual([[480, 1080]]) // merged 8-18
230
+ })
231
+ it('withinWorking is true only when the slot fits inside a window', () => {
232
+ const iv = workingIntervals(1, win, band[0], band[1]) // [[540,780]]
233
+ expect(withinWorking(600, 660, iv)).toBe(true) // 10-11 inside 9-13
234
+ expect(withinWorking(780, 840, iv)).toBe(false) // 13-14 outside
235
+ expect(withinWorking(720, 840, iv)).toBe(false) // 12-14 crosses the edge
236
+ })
237
+ })
238
+
239
+ describe('eventsOnDay', () => {
240
+ it('includes an event that spans the day even if it starts earlier', () => {
241
+ const rows: Row[] = [
242
+ { id: 'multi', title: 'trip', start: '2026-07-14T20:00', end: '2026-07-16T08:00' },
243
+ ]
244
+ const evs = resolveEvents(rows, spec(), at(2026, 7, 1), at(2026, 8, 1))
245
+ expect(eventsOnDay(evs, at(2026, 7, 15))).toHaveLength(1)
246
+ expect(eventsOnDay(evs, at(2026, 7, 17))).toHaveLength(0)
247
+ })
248
+ })
249
+
250
+ // N overlapping events, all starting at 09:00 for `hours` hours.
251
+ function pileUp(n: number, hours = 1): Row[] {
252
+ return Array.from({ length: n }, (_, i) => ({
253
+ id: `e${i}`,
254
+ title: `e${i}`,
255
+ start: '2026-07-15T09:00',
256
+ end: `2026-07-15T${String(9 + hours).padStart(2, '0')}:00`,
257
+ }))
258
+ }
259
+
260
+ describe('layoutDayEvents (split, default)', () => {
261
+ it('places two overlapping events in two half-width columns', () => {
262
+ const rows: Row[] = [
263
+ { id: 'a', title: 'a', start: '2026-07-15T09:00', end: '2026-07-15T10:00' },
264
+ { id: 'b', title: 'b', start: '2026-07-15T09:30', end: '2026-07-15T10:30' },
265
+ ]
266
+ const evs = resolveEvents(rows, spec(), at(2026, 7, 15), at(2026, 7, 16))
267
+ const { events, overflows } = layoutDayEvents(evs, at(2026, 7, 15))
268
+ expect(events).toHaveLength(2)
269
+ expect(overflows).toHaveLength(0)
270
+ expect(events.every((p) => p.colCount === 2)).toBe(true)
271
+ expect(new Set(events.map((p) => p.col))).toEqual(new Set([0, 1]))
272
+ expect(events.every((p) => Math.abs(p.widthPct - 50) < 0.01)).toBe(true)
273
+ expect(new Set(events.map((p) => p.leftPct))).toEqual(new Set([0, 50]))
274
+ })
275
+
276
+ it('gives a non-overlapping event the full width', () => {
277
+ const rows: Row[] = [
278
+ { id: 'a', title: 'a', start: '2026-07-15T09:00', end: '2026-07-15T10:00' },
279
+ { id: 'b', title: 'b', start: '2026-07-15T11:00', end: '2026-07-15T12:00' },
280
+ ]
281
+ const evs = resolveEvents(rows, spec(), at(2026, 7, 15), at(2026, 7, 16))
282
+ const { events } = layoutDayEvents(evs, at(2026, 7, 15))
283
+ expect(events.every((p) => p.colCount === 1 && p.widthPct === 100)).toBe(true)
284
+ })
285
+
286
+ it('expands an event into adjacent columns that are free during its span', () => {
287
+ // a + b (both 10-11) occupy col0/col1; c (10:30-12:30) is forced to col2 =>
288
+ // 3-col cluster. d starts 11:30 after a/b end, takes col0, and must WIDEN
289
+ // across the now-free col1 up to col2 (c overlaps) => 2/3 width, not 1/3.
290
+ const rows: Row[] = [
291
+ { id: 'a', title: 'a', start: '2026-07-15T10:00', end: '2026-07-15T11:00' },
292
+ { id: 'b', title: 'b', start: '2026-07-15T10:00', end: '2026-07-15T11:00' },
293
+ { id: 'c', title: 'c', start: '2026-07-15T10:30', end: '2026-07-15T12:30' },
294
+ { id: 'd', title: 'd', start: '2026-07-15T11:30', end: '2026-07-15T12:30' },
295
+ ]
296
+ const evs = resolveEvents(rows, spec(), at(2026, 7, 15), at(2026, 7, 16))
297
+ const { events } = layoutDayEvents(evs, at(2026, 7, 15))
298
+ const byId = new Map(events.map((p) => [p.event.rowKey, p]))
299
+ expect(byId.get('d')!.colCount).toBe(3)
300
+ expect(byId.get('d')!.col).toBe(0)
301
+ // d widens across the free col1, stopping before c (col2): ~2/3 of the width.
302
+ expect(byId.get('d')!.widthPct).toBeCloseTo((2 / 3) * 100, 1)
303
+ // a still overlaps b so it stays a single column wide (1/3).
304
+ expect(byId.get('a')!.widthPct).toBeCloseTo((1 / 3) * 100, 1)
305
+ })
306
+
307
+ it('positions events as a percentage of the visible band', () => {
308
+ const rows: Row[] = [{ id: 'a', title: 'a', start: '2026-07-15T12:00', end: '2026-07-15T13:00' }]
309
+ const evs = resolveEvents(rows, spec(), at(2026, 7, 15), at(2026, 7, 16))
310
+ const p = layoutDayEvents(evs, at(2026, 7, 15), { dayStartHour: 8, dayEndHour: 20 }).events[0]!
311
+ expect(p.topPct).toBeCloseTo((4 / 12) * 100)
312
+ expect(p.heightPct).toBeCloseTo((1 / 12) * 100)
313
+ })
314
+
315
+ it('ignores all-day events', () => {
316
+ const rows: Row[] = [{ id: 'a', title: 'a', start: '2026-07-15T00:00', allDay: true }]
317
+ const evs = resolveEvents(rows, spec(), at(2026, 7, 15), at(2026, 7, 16))
318
+ expect(layoutDayEvents(evs, at(2026, 7, 15)).events).toHaveLength(0)
319
+ })
320
+
321
+ it('splits N-way with no cap', () => {
322
+ const evs = resolveEvents(pileUp(8), spec(), at(2026, 7, 15), at(2026, 7, 16))
323
+ const { events, overflows } = layoutDayEvents(evs, at(2026, 7, 15))
324
+ expect(events).toHaveLength(8)
325
+ expect(overflows).toHaveLength(0)
326
+ expect(events.every((p) => p.colCount === 8 && Math.abs(p.widthPct - 12.5) < 0.01)).toBe(true)
327
+ })
328
+ })
329
+
330
+ describe('layoutDayEvents (cap)', () => {
331
+ it('keeps clusters that fit within maxColumns fully visible', () => {
332
+ const evs = resolveEvents(pileUp(3), spec(), at(2026, 7, 15), at(2026, 7, 16))
333
+ const { events, overflows } = layoutDayEvents(evs, at(2026, 7, 15), { mode: 'cap', maxColumns: 3 })
334
+ expect(events).toHaveLength(3)
335
+ expect(overflows).toHaveLength(0)
336
+ })
337
+
338
+ it('shows maxColumns-1 events + one "+N more" overflow tile', () => {
339
+ const evs = resolveEvents(pileUp(6), spec(), at(2026, 7, 15), at(2026, 7, 16))
340
+ const { events, overflows } = layoutDayEvents(evs, at(2026, 7, 15), { mode: 'cap', maxColumns: 3 })
341
+ // 3 columns -> 2 real + 1 overflow slot; 6 events => 2 shown, 4 hidden.
342
+ expect(events).toHaveLength(2)
343
+ expect(overflows).toHaveLength(1)
344
+ expect(overflows[0]!.count).toBe(4)
345
+ expect(overflows[0]!.events).toHaveLength(4)
346
+ // Overflow tile sits in the last of the 3 columns.
347
+ expect(overflows[0]!.leftPct).toBeCloseTo((2 / 3) * 100)
348
+ expect(overflows[0]!.widthPct).toBeCloseTo(100 / 3)
349
+ })
350
+
351
+ it('clamps maxColumns to a minimum of 2', () => {
352
+ const evs = resolveEvents(pileUp(5), spec(), at(2026, 7, 15), at(2026, 7, 16))
353
+ const { events, overflows } = layoutDayEvents(evs, at(2026, 7, 15), { mode: 'cap', maxColumns: 1 })
354
+ expect(events).toHaveLength(1) // maxColumns floored to 2 -> 1 real + overflow
355
+ expect(overflows[0]!.count).toBe(4)
356
+ })
357
+ })
358
+
359
+ describe('layoutDayEvents (stack)', () => {
360
+ it('offsets overlapping events instead of shrinking them, with rising z-index', () => {
361
+ const evs = resolveEvents(pileUp(3), spec(), at(2026, 7, 15), at(2026, 7, 16))
362
+ const { events, overflows } = layoutDayEvents(evs, at(2026, 7, 15), {
363
+ mode: 'stack',
364
+ stackOffsetPct: 14,
365
+ })
366
+ expect(events).toHaveLength(3)
367
+ expect(overflows).toHaveLength(0)
368
+ const byCol = [...events].sort((a, b) => a.col - b.col)
369
+ expect(byCol.map((p) => p.leftPct)).toEqual([0, 14, 28])
370
+ expect(byCol.map((p) => p.zIndex)).toEqual([1, 2, 3])
371
+ // Earliest column is widest; later ones are offset but still wide.
372
+ expect(byCol[0]!.widthPct).toBe(100)
373
+ expect(byCol[2]!.widthPct).toBe(72)
374
+ })
375
+ })
376
+
377
+ describe('monthWeekSegments (spanning bars)', () => {
378
+ const weekStart = at(2026, 7, 13) // Monday (Mon13..Sun19)
379
+ function segsFor(rows: Row[]) {
380
+ const evs = resolveEvents(rows, spec(), at(2026, 7, 1), at(2026, 8, 1))
381
+ return monthWeekSegments(evs, weekStart)
382
+ }
383
+
384
+ it('a single-day event is a 1-column segment in lane 0', () => {
385
+ const { segments, laneCount } = segsFor([{ id: 'a', title: 'a', start: '2026-07-14T10:00', end: '2026-07-14T11:00' }])
386
+ expect(segments).toHaveLength(1)
387
+ expect(segments[0]).toMatchObject({ startCol: 1, endCol: 1, lane: 0, continuesLeft: false, continuesRight: false })
388
+ expect(laneCount).toBe(1)
389
+ })
390
+
391
+ it('a multi-day event spans multiple columns as one segment', () => {
392
+ const { segments } = segsFor([{ id: 'a', title: 'a', start: '2026-07-14T09:00', end: '2026-07-16T17:00' }])
393
+ expect(segments).toHaveLength(1)
394
+ expect(segments[0]).toMatchObject({ startCol: 1, endCol: 3 })
395
+ })
396
+
397
+ it('an event crossing the week boundary is clipped + flagged continuesRight', () => {
398
+ const { segments } = segsFor([{ id: 'a', title: 'a', start: '2026-07-18T20:00', end: '2026-07-20T08:00' }])
399
+ expect(segments).toHaveLength(1)
400
+ expect(segments[0]).toMatchObject({ startCol: 5, endCol: 6, continuesRight: true, continuesLeft: false })
401
+ })
402
+
403
+ it('an event starting before the week is clipped + flagged continuesLeft', () => {
404
+ const { segments } = segsFor([{ id: 'a', title: 'a', start: '2026-07-11T09:00', end: '2026-07-14T17:00' }])
405
+ expect(segments[0]).toMatchObject({ startCol: 0, endCol: 1, continuesLeft: true })
406
+ })
407
+
408
+ it('an end at exact midnight does not spill onto the next day', () => {
409
+ const { segments } = segsFor([{ id: 'a', title: 'a', start: '2026-07-14T09:00', end: '2026-07-15T00:00' }])
410
+ expect(segments[0]).toMatchObject({ startCol: 1, endCol: 1 })
411
+ })
412
+
413
+ it('overlapping events stack into separate lanes', () => {
414
+ const { segments, laneCount } = segsFor([
415
+ { id: 'a', title: 'a', start: '2026-07-14T09:00', end: '2026-07-16T17:00' },
416
+ { id: 'b', title: 'b', start: '2026-07-15T09:00', end: '2026-07-17T17:00' },
417
+ ])
418
+ expect(laneCount).toBe(2)
419
+ expect(new Set(segments.map((s) => s.lane))).toEqual(new Set([0, 1]))
420
+ })
421
+
422
+ it('non-overlapping events reuse lane 0', () => {
423
+ const { segments, laneCount } = segsFor([
424
+ { id: 'a', title: 'a', start: '2026-07-13T09:00', end: '2026-07-13T17:00' },
425
+ { id: 'b', title: 'b', start: '2026-07-16T09:00', end: '2026-07-16T17:00' },
426
+ ])
427
+ expect(laneCount).toBe(1)
428
+ expect(segments.every((s) => s.lane === 0)).toBe(true)
429
+ })
430
+ })
431
+
432
+ describe('agendaGroups', () => {
433
+ it('buckets events by day in chronological order', () => {
434
+ const rows: Row[] = [
435
+ { id: 'a', title: 'a', start: '2026-07-16T09:00', end: '2026-07-16T10:00' },
436
+ { id: 'b', title: 'b', start: '2026-07-15T14:00', end: '2026-07-15T15:00' },
437
+ { id: 'c', title: 'c', start: '2026-07-15T09:00', end: '2026-07-15T10:00' },
438
+ ]
439
+ const evs = resolveEvents(rows, spec(), at(2026, 7, 1), at(2026, 8, 1))
440
+ const groups = agendaGroups(evs)
441
+ expect(groups).toHaveLength(2)
442
+ expect(groups[0]!.events.map((e) => e.rowKey)).toEqual(['c', 'b'])
443
+ expect(groups[1]!.events.map((e) => e.rowKey)).toEqual(['a'])
444
+ })
445
+ })
446
+
447
+ describe('rangeForView / daysForView / navigateAnchor', () => {
448
+ it('month range is a fixed 6-week grid aligned to the week start', () => {
449
+ const { start, end } = rangeForView('month', at(2026, 7, 15), 1) // Monday start
450
+ expect(start.getDay()).toBe(1)
451
+ expect((end.getTime() - start.getTime()) / 86_400_000).toBe(42)
452
+ })
453
+
454
+ it('week view yields 7 days from the week start', () => {
455
+ const days = daysForView('week', at(2026, 7, 15), 0)
456
+ expect(days).toHaveLength(7)
457
+ expect(days[0]!.getDay()).toBe(0)
458
+ })
459
+
460
+ it('day view yields just the anchor day', () => {
461
+ const days = daysForView('day', at(2026, 7, 15))
462
+ expect(days).toHaveLength(1)
463
+ expect(days[0]!.getDate()).toBe(15)
464
+ })
465
+
466
+ it('navigates by month / week / day', () => {
467
+ expect(navigateAnchor('month', at(2026, 7, 15), 1).getMonth()).toBe(7) // August
468
+ expect(navigateAnchor('week', at(2026, 7, 15), -1).getDate()).toBe(8)
469
+ expect(navigateAnchor('day', at(2026, 7, 15), 1).getDate()).toBe(16)
470
+ })
471
+ })
472
+
473
+ describe('timeline model', () => {
474
+ const R: SchedulerResource[] = [
475
+ { id: 'a', title: 'A' },
476
+ { id: 'b', title: 'B' },
477
+ ]
478
+ const ev = (id: string, start: Date, end: Date, resourceId?: string): ResolvedEvent =>
479
+ ({ key: id, rowKey: id, row: {}, title: id, start, end, allDay: false, resourceId, recurring: false })
480
+
481
+ it('rangeForView / navigateAnchor cover the timeline views', () => {
482
+ expect(rangeForView('timelineDay', at(2026, 7, 15)).start.getDate()).toBe(15)
483
+ expect(rangeForView('timelineWeek', at(2026, 7, 15), 0).start.getDay()).toBe(0)
484
+ const mo = rangeForView('timelineMonth', at(2026, 7, 15))
485
+ expect(mo.start.getDate()).toBe(1)
486
+ expect(mo.end.getMonth()).toBe(7) // exclusive next month (Aug)
487
+ const yr = rangeForView('timelineYear', at(2026, 7, 15))
488
+ expect(yr.start.getMonth()).toBe(0)
489
+ expect(yr.end.getFullYear()).toBe(2027)
490
+ expect(navigateAnchor('timelineMonth', at(2026, 7, 15), 1).getMonth()).toBe(7)
491
+ expect(navigateAnchor('timelineYear', at(2026, 7, 15), -1).getFullYear()).toBe(2025)
492
+ })
493
+
494
+ it('timelineAxis: day ticks respect the band, week=7 days, year=12 months', () => {
495
+ const day = timelineAxis('timelineDay', at(2026, 7, 15), at(2026, 7, 16), { dayStartHour: 8, dayEndHour: 18 })
496
+ expect(day.ticks).toHaveLength(10) // 08..17
497
+ expect(day.start.getHours()).toBe(8)
498
+ expect(day.ticks[0]!.leftPct).toBeCloseTo(0)
499
+ const week = timelineAxis('timelineWeek', at(2026, 7, 6), at(2026, 7, 13))
500
+ expect(week.ticks).toHaveLength(7)
501
+ const month = timelineAxis('timelineMonth', at(2026, 7, 1), at(2026, 8, 1))
502
+ expect(month.ticks).toHaveLength(31)
503
+ const year = timelineAxis('timelineYear', at(2026, 1, 1), at(2027, 1, 1))
504
+ expect(year.ticks).toHaveLength(12)
505
+ expect(year.majors).toHaveLength(4) // quarters
506
+ })
507
+
508
+ it('timelineGeom clamps to the axis window + flags clipped edges', () => {
509
+ const axisStart = at(2026, 7, 6)
510
+ const axisMs = 7 * 86_400_000
511
+ // fully inside: day 2 of 7, 1-day event
512
+ const g = timelineGeom(at(2026, 7, 8), at(2026, 7, 9), axisStart, axisMs)!
513
+ expect(g.leftPct).toBeCloseTo((2 / 7) * 100)
514
+ expect(g.widthPct).toBeCloseTo((1 / 7) * 100)
515
+ expect(g.continuesLeft).toBe(false)
516
+ // starts before the window -> clipped left
517
+ const g2 = timelineGeom(at(2026, 7, 4), at(2026, 7, 7), axisStart, axisMs)!
518
+ expect(g2.leftPct).toBeCloseTo(0)
519
+ expect(g2.continuesLeft).toBe(true)
520
+ // entirely outside
521
+ expect(timelineGeom(at(2026, 8, 1), at(2026, 8, 2), axisStart, axisMs)).toBeNull()
522
+ })
523
+
524
+ it('timelineRows: buckets by resource + lane-packs overlaps', () => {
525
+ const events = [
526
+ ev('1', at(2026, 7, 6, 9), at(2026, 7, 6, 11), 'a'),
527
+ ev('2', at(2026, 7, 6, 10), at(2026, 7, 6, 12), 'a'), // overlaps #1 -> lane 1
528
+ ev('3', at(2026, 7, 6, 13), at(2026, 7, 6, 14), 'a'), // after #1 -> lane 0
529
+ ev('4', at(2026, 7, 6, 9), at(2026, 7, 6, 10), 'b'),
530
+ ]
531
+ const rows = timelineRows(R, events)
532
+ expect(rows).toHaveLength(2)
533
+ const a = rows[0]!
534
+ expect(a.laneCount).toBe(2)
535
+ expect(a.items.find((i) => i.event.key === '2')!.lane).toBe(1)
536
+ expect(a.items.find((i) => i.event.key === '3')!.lane).toBe(0)
537
+ expect(rows[1]!.items).toHaveLength(1)
538
+ })
539
+
540
+ it('timelineRows: a single null row when there are no resources', () => {
541
+ const rows = timelineRows(null, [ev('1', at(2026, 7, 6, 9), at(2026, 7, 6, 10))])
542
+ expect(rows).toHaveLength(1)
543
+ expect(rows[0]!.resource).toBeNull()
544
+ expect(rows[0]!.items).toHaveLength(1)
545
+ })
546
+ })