@svgrid/grid 2.2.25 → 2.2.27
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.
- package/README.md +199 -145
- package/dist/FlexRender.svelte +96 -96
- package/dist/GridFooter.svelte +179 -179
- package/dist/GridMenus.svelte +734 -728
- package/dist/SvAutoComplete.svelte +165 -165
- package/dist/SvCalendar.svelte +503 -503
- package/dist/SvCarousel.svelte +141 -141
- package/dist/SvCheckBox.svelte +101 -101
- package/dist/SvColorInput.svelte +181 -181
- package/dist/SvComboBox.svelte +275 -275
- package/dist/SvContextMenu.svelte +116 -116
- package/dist/SvCountryInput.svelte +160 -160
- package/dist/SvDrawer.svelte +250 -250
- package/dist/SvDropDownList.svelte +374 -374
- package/dist/SvDurationInput.svelte +126 -126
- package/dist/SvField.svelte +293 -293
- package/dist/SvForm.svelte +437 -437
- package/dist/SvGrid.css +2723 -2719
- package/dist/SvGrid.svelte +3333 -3333
- package/dist/SvGridChart.svelte +1724 -1724
- package/dist/SvGridChartPanel.svelte +485 -485
- package/dist/SvGridChartView.svelte +70 -70
- package/dist/SvGridDropdown.svelte +696 -696
- package/dist/SvGridSelect.svelte +259 -259
- package/dist/SvGroupCell.svelte +109 -109
- package/dist/SvListBox.svelte +287 -287
- package/dist/SvMaskedInput.svelte +122 -122
- package/dist/SvMenu.svelte +124 -124
- package/dist/SvMenuList.svelte +146 -146
- package/dist/SvMultiSelect.svelte +281 -281
- package/dist/SvNumberInput.svelte +168 -168
- package/dist/SvOtpInput.svelte +158 -158
- package/dist/SvPasswordInput.svelte +151 -151
- package/dist/SvPhoneInput.svelte +131 -131
- package/dist/SvPopover.svelte +197 -197
- package/dist/SvRadioGroup.svelte +107 -107
- package/dist/SvRowGroupPanel.svelte +170 -170
- package/dist/SvScrollArea.svelte +61 -61
- package/dist/SvSlider.svelte +200 -200
- package/dist/SvSwitchButton.svelte +107 -107
- package/dist/SvTagsInput.svelte +112 -112
- package/dist/SvTextInput.svelte +147 -147
- package/dist/SvTimePicker.svelte +245 -245
- package/dist/SvToaster.svelte +159 -159
- package/dist/SvToggleButton.svelte +85 -85
- package/dist/SvTooltip.svelte +161 -161
- package/dist/SvTour.svelte +204 -204
- package/dist/SvTree.svelte +437 -437
- package/dist/SvTreeSelect.svelte +233 -233
- package/dist/a11y/dismissable.d.ts +11 -0
- package/dist/a11y/dismissable.js +21 -0
- package/dist/ai.js +3 -3
- package/dist/cdn/GridMenus-BzxEYNtW.js +421 -0
- package/dist/cdn/GridMenus-Dr-nGEXn.js +417 -0
- package/dist/cdn/{SvDateTimePicker-CYC7gRER.js → SvDateTimePicker-B1sDc9Pz.js} +451 -444
- package/dist/cdn/{SvDateTimePicker-DtQKd4Ns.js → SvDateTimePicker-Bh0FTs1E.js} +114 -107
- package/dist/cdn/{SvGridChart-BhnFdLd0.js → SvGridChart-BKQsO5fZ.js} +1 -1
- package/dist/cdn/{SvGridChart-BcLCYvw9.js → SvGridChart-Bgk4hDs8.js} +1 -1
- package/dist/cdn/{SvGridChartPanel-DvjwxLvK.js → SvGridChartPanel-DSkHIQDd.js} +1 -1
- package/dist/cdn/{SvGridChartPanel-Cmx9DHAS.js → SvGridChartPanel-DkCc2Z7b.js} +1 -1
- package/dist/cdn/{SvGridChartView-B40DTJSX.js → SvGridChartView-BrbVaELm.js} +1 -1
- package/dist/cdn/{SvGridChartView-PwqI4s02.js → SvGridChartView-i9rWvSKg.js} +1 -1
- package/dist/cdn/{SvGridDropdown-hpnHvOGt.js → SvGridDropdown-CGxxopQ4.js} +1 -1
- package/dist/cdn/{SvGridDropdown-DjALc_4f.js → SvGridDropdown-D08sVmAy.js} +1 -1
- package/dist/cdn/{src-DysW9qbk.js → src-Cw8d9U8j.js} +5753 -5753
- package/dist/cdn/{src-bYfr_7aR.js → src-cgs1wcUS.js} +8102 -8102
- package/dist/cdn/svgrid.js +9 -9
- package/dist/cdn/svgrid.svelte-external.js +9 -9
- package/dist/chart-export.js +8 -8
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/package.json +30 -13
- package/src/FlexRender.svelte +96 -96
- package/src/GridFooter.svelte +179 -179
- package/src/GridMenus.svelte +734 -728
- package/src/SvAutoComplete.svelte +165 -165
- package/src/SvCalendar.svelte +503 -503
- package/src/SvCalendar.test.ts +226 -226
- package/src/SvCarousel.svelte +141 -141
- package/src/SvCheckBox.svelte +101 -101
- package/src/SvColorInput.svelte +181 -181
- package/src/SvComboBox.svelte +275 -275
- package/src/SvContextMenu.svelte +116 -116
- package/src/SvCountryInput.svelte +160 -160
- package/src/SvDrawer.svelte +250 -250
- package/src/SvDropDownList.svelte +374 -374
- package/src/SvDurationInput.svelte +126 -126
- package/src/SvField.svelte +293 -293
- package/src/SvForm.svelte +437 -437
- package/src/SvForm.test.ts +411 -411
- package/src/SvGrid.controller.svelte.ts +3239 -3239
- package/src/SvGrid.css +2723 -2719
- package/src/SvGrid.svelte +3333 -3333
- package/src/SvGrid.types.ts +1613 -1613
- package/src/SvGridChart.svelte +1724 -1724
- package/src/SvGridChartPanel.svelte +485 -485
- package/src/SvGridChartView.svelte +70 -70
- package/src/SvGridDropdown.svelte +696 -696
- package/src/SvGridSelect.svelte +259 -259
- package/src/SvGroupCell.svelte +109 -109
- package/src/SvListBox.svelte +287 -287
- package/src/SvMaskedInput.svelte +122 -122
- package/src/SvMenu.svelte +124 -124
- package/src/SvMenu.test.ts +97 -97
- package/src/SvMenuList.svelte +146 -146
- package/src/SvMultiSelect.svelte +281 -281
- package/src/SvNumberInput.svelte +168 -168
- package/src/SvOtpInput.svelte +158 -158
- package/src/SvPasswordInput.svelte +151 -151
- package/src/SvPhoneInput.svelte +131 -131
- package/src/SvPopover.svelte +197 -197
- package/src/SvRadioGroup.svelte +107 -107
- package/src/SvRowGroupPanel.svelte +170 -170
- package/src/SvScrollArea.svelte +61 -61
- package/src/SvSlider.svelte +200 -200
- package/src/SvSwitchButton.svelte +107 -107
- package/src/SvTagsInput.svelte +112 -112
- package/src/SvTextInput.svelte +147 -147
- package/src/SvTimePicker.svelte +245 -245
- package/src/SvToaster.svelte +159 -159
- package/src/SvToaster.test.ts +95 -95
- package/src/SvToggleButton.svelte +85 -85
- package/src/SvTooltip.svelte +161 -161
- package/src/SvTour.svelte +204 -204
- package/src/SvTree.svelte +437 -437
- package/src/SvTreeSelect.svelte +233 -233
- package/src/a11y/dismissable.test.ts +119 -95
- package/src/a11y/dismissable.ts +114 -94
- package/src/a11y.contract.test.ts +49 -49
- package/src/a11y.test.ts +59 -59
- package/src/a11y.ts +61 -61
- package/src/ai.test.ts +502 -502
- package/src/ai.ts +1391 -1391
- package/src/build-api.coverage.test.ts +633 -633
- package/src/build-api.ts +895 -895
- package/src/builtin-editors.grid.test.ts +85 -85
- package/src/cell-formatting.ts +169 -169
- package/src/cell-render.ts +469 -469
- package/src/chart-export.ts +201 -201
- package/src/chart-view.svelte.ts +36 -36
- package/src/chart.ts +2296 -2296
- package/src/collaboration.test.ts +104 -104
- package/src/collaboration.ts +167 -167
- package/src/core.performance.test.ts +30 -30
- package/src/core.ts +1111 -1111
- package/src/createAutocomplete.svelte.ts +132 -132
- package/src/createCombobox.svelte.ts +191 -191
- package/src/createCountryInput.svelte.ts +157 -157
- package/src/createDropdownList.svelte.ts +168 -168
- package/src/createForm.svelte.ts +386 -386
- package/src/createGrid.svelte.ts +42 -42
- package/src/createGrid.test.ts +10 -10
- package/src/createGridState.svelte.ts +17 -17
- package/src/createMenu.svelte.ts +225 -225
- package/src/createPopoverSelect.svelte.ts +213 -213
- package/src/createSlider.svelte.ts +191 -191
- package/src/createTooltip.svelte.ts +144 -144
- package/src/createTree.svelte.ts +319 -319
- package/src/editing.test.ts +859 -859
- package/src/editing.ts +675 -675
- package/src/editor-contract.ts +157 -157
- package/src/export-data-api.test.ts +126 -126
- package/src/export-format.test.ts +107 -107
- package/src/export-format.ts +598 -598
- package/src/filter-operators.ts +157 -157
- package/src/flex-render.ts +3 -3
- package/src/form-field.ts +127 -127
- package/src/index.ts +713 -712
- package/src/js-scroller.svelte.ts +173 -173
- package/src/keyboard.test.ts +59 -59
- package/src/keyboard.ts +97 -97
- package/src/list-nav.test.ts +49 -49
- package/src/list-nav.ts +29 -29
- package/src/list-option.test.ts +56 -56
- package/src/list-option.ts +172 -172
- package/src/menus.test.ts +700 -700
- package/src/menus.ts +652 -652
- package/src/merge-objects.ts +48 -48
- package/src/overlays.test.ts +90 -90
- package/src/render-component.ts +28 -28
- package/src/scheduler-model.test.ts +562 -562
- package/src/scheduler-model.ts +872 -872
- package/src/selection.test.ts +754 -754
- package/src/selection.ts +600 -600
- package/src/server-data-source.test.ts +289 -289
- package/src/server-data-source.ts +413 -413
- package/src/sparkline.test.ts +68 -68
- package/src/sparkline.ts +169 -169
- package/src/spreadsheet.test.ts +489 -489
- package/src/spreadsheet.ts +312 -312
- package/src/static-functions.ts +11 -11
- package/src/subscribe.ts +38 -38
- package/src/svgrid-wrapper.types.ts +524 -524
- package/src/svgrid.behavior.test.ts +706 -706
- package/src/svgrid.charting.test.ts +534 -534
- package/src/svgrid.features.test.ts +157 -157
- package/src/svgrid.filter-menu-scroll.test.ts +102 -0
- package/src/svgrid.new-features.wrapper.test.ts +251 -251
- package/src/svgrid.wrapper.test.ts +40 -40
- package/src/test-setup.ts +62 -62
- package/src/themes/index.ts +215 -215
- package/src/toast-store.svelte.ts +246 -246
- package/src/toast-store.test.ts +147 -147
- package/src/ui-buttons.test.ts +144 -144
- package/src/ui-inputs.test.ts +118 -118
- package/src/ui-range.test.ts +70 -70
- package/src/ui-selection.test.ts +155 -155
- package/src/ui-tier1.test.ts +142 -142
- package/src/virtual.test.ts +88 -88
- package/src/virtualization/column-virtualizer.test.ts +27 -27
- package/src/virtualization/column-virtualizer.ts +30 -30
- package/src/virtualization/svelte-virtualizer.svelte.ts +26 -26
- package/src/virtualization/types.ts +30 -30
- package/src/virtualization/virtualizer.test.ts +47 -47
- package/src/virtualization/virtualizer.ts +296 -296
- package/themes/ember.css +71 -71
- package/dist/cdn/GridMenus-GfCAHgkZ.js +0 -420
- package/dist/cdn/GridMenus-GiVNSeDU.js +0 -416
|
@@ -1,562 +1,562 @@
|
|
|
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('applies per-occurrence `fields` overrides to one instance (merged row + resource)', () => {
|
|
155
|
-
const rows: Row[] = [
|
|
156
|
-
{ ...weekly, who: 'room1', exceptions: [{ occurrenceStart: '2026-07-13T09:30', fields: { who: 'room9', title: 'Standup (offsite)' } }] },
|
|
157
|
-
]
|
|
158
|
-
const out = resolveEvents(rows, spec(), at(2026, 7, 1), at(2026, 8, 1))
|
|
159
|
-
const overridden = out.find((e) => e.start.getDate() === 13)!
|
|
160
|
-
expect(overridden.resourceId).toBe('room9') // re-derived from the merged row
|
|
161
|
-
expect((overridden.row as Row).who).toBe('room9') // the instance row carries the override
|
|
162
|
-
expect(overridden.title).toBe('Standup (offsite)') // title from fields when not set explicitly
|
|
163
|
-
expect(overridden.isException).toBe(true)
|
|
164
|
-
// Siblings keep the base resource + row.
|
|
165
|
-
const sibling = out.find((e) => e.start.getDate() === 20)!
|
|
166
|
-
expect(sibling.resourceId).toBe('room1')
|
|
167
|
-
expect((sibling.row as Row).who).toBe('room1')
|
|
168
|
-
})
|
|
169
|
-
|
|
170
|
-
it('sorts all-day before timed at the same start', () => {
|
|
171
|
-
const rows: Row[] = [
|
|
172
|
-
{ id: 'timed', title: 't', start: '2026-07-15T00:00', end: '2026-07-15T01:00' },
|
|
173
|
-
{ id: 'allday', title: 'a', start: '2026-07-15T00:00', allDay: true },
|
|
174
|
-
]
|
|
175
|
-
const out = resolveEvents(rows, spec(), at(2026, 7, 1), at(2026, 8, 1))
|
|
176
|
-
expect(out.map((e) => e.rowKey)).toEqual(['allday', 'timed'])
|
|
177
|
-
})
|
|
178
|
-
})
|
|
179
|
-
|
|
180
|
-
describe('hasConflict', () => {
|
|
181
|
-
const rows: Row[] = [
|
|
182
|
-
{ id: 'a', title: 'A', start: '2026-07-15T09:00', end: '2026-07-15T10:00', who: 'room1' },
|
|
183
|
-
{ id: 'b', title: 'B', start: '2026-07-15T11:00', end: '2026-07-15T12:00', who: 'room2' },
|
|
184
|
-
]
|
|
185
|
-
const events = resolveEvents(rows, spec(), at(2026, 7, 1), at(2026, 8, 1))
|
|
186
|
-
|
|
187
|
-
it('detects an overlap on the same resource', () => {
|
|
188
|
-
expect(hasConflict(at(2026, 7, 15, 9, 30), at(2026, 7, 15, 10, 30), 'room1', events)).toBe(true)
|
|
189
|
-
})
|
|
190
|
-
it('ignores overlaps on a different resource', () => {
|
|
191
|
-
expect(hasConflict(at(2026, 7, 15, 9, 30), at(2026, 7, 15, 10, 30), 'room2', events)).toBe(false)
|
|
192
|
-
})
|
|
193
|
-
it('ignores the event being moved (excludeRowKey)', () => {
|
|
194
|
-
expect(hasConflict(at(2026, 7, 15, 9, 0), at(2026, 7, 15, 10, 0), 'room1', events, 'a')).toBe(false)
|
|
195
|
-
})
|
|
196
|
-
it('is false when there is a clear gap', () => {
|
|
197
|
-
expect(hasConflict(at(2026, 7, 15, 10, 0), at(2026, 7, 15, 11, 0), 'room1', events)).toBe(false)
|
|
198
|
-
})
|
|
199
|
-
})
|
|
200
|
-
|
|
201
|
-
describe('overlapCount (capacity)', () => {
|
|
202
|
-
const rows: Row[] = [
|
|
203
|
-
{ id: 'a', title: 'A', start: '2026-07-15T09:00', end: '2026-07-15T10:00', who: 'room1' },
|
|
204
|
-
{ id: 'b', title: 'B', start: '2026-07-15T09:30', end: '2026-07-15T10:30', who: 'room1' },
|
|
205
|
-
{ id: 'c', title: 'C', start: '2026-07-15T09:15', end: '2026-07-15T09:45', who: 'room2' },
|
|
206
|
-
]
|
|
207
|
-
const events = resolveEvents(rows, spec(), at(2026, 7, 1), at(2026, 8, 1))
|
|
208
|
-
it('counts overlapping events on the same resource', () => {
|
|
209
|
-
expect(overlapCount(at(2026, 7, 15, 9, 20), at(2026, 7, 15, 9, 40), 'room1', events)).toBe(2)
|
|
210
|
-
})
|
|
211
|
-
it('excludes the moved event and other resources', () => {
|
|
212
|
-
expect(overlapCount(at(2026, 7, 15, 9, 20), at(2026, 7, 15, 9, 40), 'room1', events, 'a')).toBe(1)
|
|
213
|
-
expect(overlapCount(at(2026, 7, 15, 9, 20), at(2026, 7, 15, 9, 40), 'room2', events)).toBe(1)
|
|
214
|
-
})
|
|
215
|
-
})
|
|
216
|
-
|
|
217
|
-
describe('overlapsBands (restricted hours)', () => {
|
|
218
|
-
const lunch = [{ start: 12, end: 13 }]
|
|
219
|
-
it('is true when the range overlaps a band', () => {
|
|
220
|
-
expect(overlapsBands(12 * 60 + 30, 13 * 60 + 30, lunch)).toBe(true)
|
|
221
|
-
expect(overlapsBands(11 * 60, 12 * 60 + 15, lunch)).toBe(true)
|
|
222
|
-
})
|
|
223
|
-
it('is false when the range is clear of every band', () => {
|
|
224
|
-
expect(overlapsBands(13 * 60, 14 * 60, lunch)).toBe(false)
|
|
225
|
-
expect(overlapsBands(10 * 60, 12 * 60, lunch)).toBe(false) // ends exactly at 12
|
|
226
|
-
})
|
|
227
|
-
})
|
|
228
|
-
|
|
229
|
-
describe('workingIntervals / withinWorking (per-resource availability)', () => {
|
|
230
|
-
const win = [
|
|
231
|
-
{ days: [1, 3, 5], start: 9, end: 13 }, // Mon/Wed/Fri morning
|
|
232
|
-
{ days: [2, 4], start: 14, end: 18 }, // Tue/Thu afternoon
|
|
233
|
-
]
|
|
234
|
-
const band = [0, 24 * 60] as const
|
|
235
|
-
|
|
236
|
-
it('returns the matching window for a weekday (in minutes)', () => {
|
|
237
|
-
expect(workingIntervals(1, win, band[0], band[1])).toEqual([[540, 780]]) // Mon 9-13
|
|
238
|
-
expect(workingIntervals(2, win, band[0], band[1])).toEqual([[840, 1080]]) // Tue 14-18
|
|
239
|
-
})
|
|
240
|
-
it('is empty for a day with no matching window (day off)', () => {
|
|
241
|
-
expect(workingIntervals(0, win, band[0], band[1])).toEqual([]) // Sunday off
|
|
242
|
-
})
|
|
243
|
-
it('clamps to the visible band and merges overlapping windows', () => {
|
|
244
|
-
const w = [{ start: 6, end: 12 }, { start: 11, end: 20 }]
|
|
245
|
-
expect(workingIntervals(1, w, 8 * 60, 18 * 60)).toEqual([[480, 1080]]) // merged 8-18
|
|
246
|
-
})
|
|
247
|
-
it('withinWorking is true only when the slot fits inside a window', () => {
|
|
248
|
-
const iv = workingIntervals(1, win, band[0], band[1]) // [[540,780]]
|
|
249
|
-
expect(withinWorking(600, 660, iv)).toBe(true) // 10-11 inside 9-13
|
|
250
|
-
expect(withinWorking(780, 840, iv)).toBe(false) // 13-14 outside
|
|
251
|
-
expect(withinWorking(720, 840, iv)).toBe(false) // 12-14 crosses the edge
|
|
252
|
-
})
|
|
253
|
-
})
|
|
254
|
-
|
|
255
|
-
describe('eventsOnDay', () => {
|
|
256
|
-
it('includes an event that spans the day even if it starts earlier', () => {
|
|
257
|
-
const rows: Row[] = [
|
|
258
|
-
{ id: 'multi', title: 'trip', start: '2026-07-14T20:00', end: '2026-07-16T08:00' },
|
|
259
|
-
]
|
|
260
|
-
const evs = resolveEvents(rows, spec(), at(2026, 7, 1), at(2026, 8, 1))
|
|
261
|
-
expect(eventsOnDay(evs, at(2026, 7, 15))).toHaveLength(1)
|
|
262
|
-
expect(eventsOnDay(evs, at(2026, 7, 17))).toHaveLength(0)
|
|
263
|
-
})
|
|
264
|
-
})
|
|
265
|
-
|
|
266
|
-
// N overlapping events, all starting at 09:00 for `hours` hours.
|
|
267
|
-
function pileUp(n: number, hours = 1): Row[] {
|
|
268
|
-
return Array.from({ length: n }, (_, i) => ({
|
|
269
|
-
id: `e${i}`,
|
|
270
|
-
title: `e${i}`,
|
|
271
|
-
start: '2026-07-15T09:00',
|
|
272
|
-
end: `2026-07-15T${String(9 + hours).padStart(2, '0')}:00`,
|
|
273
|
-
}))
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
describe('layoutDayEvents (split, default)', () => {
|
|
277
|
-
it('places two overlapping events in two half-width columns', () => {
|
|
278
|
-
const rows: Row[] = [
|
|
279
|
-
{ id: 'a', title: 'a', start: '2026-07-15T09:00', end: '2026-07-15T10:00' },
|
|
280
|
-
{ id: 'b', title: 'b', start: '2026-07-15T09:30', end: '2026-07-15T10:30' },
|
|
281
|
-
]
|
|
282
|
-
const evs = resolveEvents(rows, spec(), at(2026, 7, 15), at(2026, 7, 16))
|
|
283
|
-
const { events, overflows } = layoutDayEvents(evs, at(2026, 7, 15))
|
|
284
|
-
expect(events).toHaveLength(2)
|
|
285
|
-
expect(overflows).toHaveLength(0)
|
|
286
|
-
expect(events.every((p) => p.colCount === 2)).toBe(true)
|
|
287
|
-
expect(new Set(events.map((p) => p.col))).toEqual(new Set([0, 1]))
|
|
288
|
-
expect(events.every((p) => Math.abs(p.widthPct - 50) < 0.01)).toBe(true)
|
|
289
|
-
expect(new Set(events.map((p) => p.leftPct))).toEqual(new Set([0, 50]))
|
|
290
|
-
})
|
|
291
|
-
|
|
292
|
-
it('gives a non-overlapping event the full width', () => {
|
|
293
|
-
const rows: Row[] = [
|
|
294
|
-
{ id: 'a', title: 'a', start: '2026-07-15T09:00', end: '2026-07-15T10:00' },
|
|
295
|
-
{ id: 'b', title: 'b', start: '2026-07-15T11:00', end: '2026-07-15T12:00' },
|
|
296
|
-
]
|
|
297
|
-
const evs = resolveEvents(rows, spec(), at(2026, 7, 15), at(2026, 7, 16))
|
|
298
|
-
const { events } = layoutDayEvents(evs, at(2026, 7, 15))
|
|
299
|
-
expect(events.every((p) => p.colCount === 1 && p.widthPct === 100)).toBe(true)
|
|
300
|
-
})
|
|
301
|
-
|
|
302
|
-
it('expands an event into adjacent columns that are free during its span', () => {
|
|
303
|
-
// a + b (both 10-11) occupy col0/col1; c (10:30-12:30) is forced to col2 =>
|
|
304
|
-
// 3-col cluster. d starts 11:30 after a/b end, takes col0, and must WIDEN
|
|
305
|
-
// across the now-free col1 up to col2 (c overlaps) => 2/3 width, not 1/3.
|
|
306
|
-
const rows: Row[] = [
|
|
307
|
-
{ id: 'a', title: 'a', start: '2026-07-15T10:00', end: '2026-07-15T11:00' },
|
|
308
|
-
{ id: 'b', title: 'b', start: '2026-07-15T10:00', end: '2026-07-15T11:00' },
|
|
309
|
-
{ id: 'c', title: 'c', start: '2026-07-15T10:30', end: '2026-07-15T12:30' },
|
|
310
|
-
{ id: 'd', title: 'd', start: '2026-07-15T11:30', end: '2026-07-15T12:30' },
|
|
311
|
-
]
|
|
312
|
-
const evs = resolveEvents(rows, spec(), at(2026, 7, 15), at(2026, 7, 16))
|
|
313
|
-
const { events } = layoutDayEvents(evs, at(2026, 7, 15))
|
|
314
|
-
const byId = new Map(events.map((p) => [p.event.rowKey, p]))
|
|
315
|
-
expect(byId.get('d')!.colCount).toBe(3)
|
|
316
|
-
expect(byId.get('d')!.col).toBe(0)
|
|
317
|
-
// d widens across the free col1, stopping before c (col2): ~2/3 of the width.
|
|
318
|
-
expect(byId.get('d')!.widthPct).toBeCloseTo((2 / 3) * 100, 1)
|
|
319
|
-
// a still overlaps b so it stays a single column wide (1/3).
|
|
320
|
-
expect(byId.get('a')!.widthPct).toBeCloseTo((1 / 3) * 100, 1)
|
|
321
|
-
})
|
|
322
|
-
|
|
323
|
-
it('positions events as a percentage of the visible band', () => {
|
|
324
|
-
const rows: Row[] = [{ id: 'a', title: 'a', start: '2026-07-15T12:00', end: '2026-07-15T13:00' }]
|
|
325
|
-
const evs = resolveEvents(rows, spec(), at(2026, 7, 15), at(2026, 7, 16))
|
|
326
|
-
const p = layoutDayEvents(evs, at(2026, 7, 15), { dayStartHour: 8, dayEndHour: 20 }).events[0]!
|
|
327
|
-
expect(p.topPct).toBeCloseTo((4 / 12) * 100)
|
|
328
|
-
expect(p.heightPct).toBeCloseTo((1 / 12) * 100)
|
|
329
|
-
})
|
|
330
|
-
|
|
331
|
-
it('ignores all-day events', () => {
|
|
332
|
-
const rows: Row[] = [{ id: 'a', title: 'a', start: '2026-07-15T00:00', allDay: true }]
|
|
333
|
-
const evs = resolveEvents(rows, spec(), at(2026, 7, 15), at(2026, 7, 16))
|
|
334
|
-
expect(layoutDayEvents(evs, at(2026, 7, 15)).events).toHaveLength(0)
|
|
335
|
-
})
|
|
336
|
-
|
|
337
|
-
it('splits N-way with no cap', () => {
|
|
338
|
-
const evs = resolveEvents(pileUp(8), spec(), at(2026, 7, 15), at(2026, 7, 16))
|
|
339
|
-
const { events, overflows } = layoutDayEvents(evs, at(2026, 7, 15))
|
|
340
|
-
expect(events).toHaveLength(8)
|
|
341
|
-
expect(overflows).toHaveLength(0)
|
|
342
|
-
expect(events.every((p) => p.colCount === 8 && Math.abs(p.widthPct - 12.5) < 0.01)).toBe(true)
|
|
343
|
-
})
|
|
344
|
-
})
|
|
345
|
-
|
|
346
|
-
describe('layoutDayEvents (cap)', () => {
|
|
347
|
-
it('keeps clusters that fit within maxColumns fully visible', () => {
|
|
348
|
-
const evs = resolveEvents(pileUp(3), spec(), at(2026, 7, 15), at(2026, 7, 16))
|
|
349
|
-
const { events, overflows } = layoutDayEvents(evs, at(2026, 7, 15), { mode: 'cap', maxColumns: 3 })
|
|
350
|
-
expect(events).toHaveLength(3)
|
|
351
|
-
expect(overflows).toHaveLength(0)
|
|
352
|
-
})
|
|
353
|
-
|
|
354
|
-
it('shows maxColumns-1 events + one "+N more" overflow tile', () => {
|
|
355
|
-
const evs = resolveEvents(pileUp(6), spec(), at(2026, 7, 15), at(2026, 7, 16))
|
|
356
|
-
const { events, overflows } = layoutDayEvents(evs, at(2026, 7, 15), { mode: 'cap', maxColumns: 3 })
|
|
357
|
-
// 3 columns -> 2 real + 1 overflow slot; 6 events => 2 shown, 4 hidden.
|
|
358
|
-
expect(events).toHaveLength(2)
|
|
359
|
-
expect(overflows).toHaveLength(1)
|
|
360
|
-
expect(overflows[0]!.count).toBe(4)
|
|
361
|
-
expect(overflows[0]!.events).toHaveLength(4)
|
|
362
|
-
// Overflow tile sits in the last of the 3 columns.
|
|
363
|
-
expect(overflows[0]!.leftPct).toBeCloseTo((2 / 3) * 100)
|
|
364
|
-
expect(overflows[0]!.widthPct).toBeCloseTo(100 / 3)
|
|
365
|
-
})
|
|
366
|
-
|
|
367
|
-
it('clamps maxColumns to a minimum of 2', () => {
|
|
368
|
-
const evs = resolveEvents(pileUp(5), spec(), at(2026, 7, 15), at(2026, 7, 16))
|
|
369
|
-
const { events, overflows } = layoutDayEvents(evs, at(2026, 7, 15), { mode: 'cap', maxColumns: 1 })
|
|
370
|
-
expect(events).toHaveLength(1) // maxColumns floored to 2 -> 1 real + overflow
|
|
371
|
-
expect(overflows[0]!.count).toBe(4)
|
|
372
|
-
})
|
|
373
|
-
})
|
|
374
|
-
|
|
375
|
-
describe('layoutDayEvents (stack)', () => {
|
|
376
|
-
it('offsets overlapping events instead of shrinking them, with rising z-index', () => {
|
|
377
|
-
const evs = resolveEvents(pileUp(3), spec(), at(2026, 7, 15), at(2026, 7, 16))
|
|
378
|
-
const { events, overflows } = layoutDayEvents(evs, at(2026, 7, 15), {
|
|
379
|
-
mode: 'stack',
|
|
380
|
-
stackOffsetPct: 14,
|
|
381
|
-
})
|
|
382
|
-
expect(events).toHaveLength(3)
|
|
383
|
-
expect(overflows).toHaveLength(0)
|
|
384
|
-
const byCol = [...events].sort((a, b) => a.col - b.col)
|
|
385
|
-
expect(byCol.map((p) => p.leftPct)).toEqual([0, 14, 28])
|
|
386
|
-
expect(byCol.map((p) => p.zIndex)).toEqual([1, 2, 3])
|
|
387
|
-
// Earliest column is widest; later ones are offset but still wide.
|
|
388
|
-
expect(byCol[0]!.widthPct).toBe(100)
|
|
389
|
-
expect(byCol[2]!.widthPct).toBe(72)
|
|
390
|
-
})
|
|
391
|
-
})
|
|
392
|
-
|
|
393
|
-
describe('monthWeekSegments (spanning bars)', () => {
|
|
394
|
-
const weekStart = at(2026, 7, 13) // Monday (Mon13..Sun19)
|
|
395
|
-
function segsFor(rows: Row[]) {
|
|
396
|
-
const evs = resolveEvents(rows, spec(), at(2026, 7, 1), at(2026, 8, 1))
|
|
397
|
-
return monthWeekSegments(evs, weekStart)
|
|
398
|
-
}
|
|
399
|
-
|
|
400
|
-
it('a single-day event is a 1-column segment in lane 0', () => {
|
|
401
|
-
const { segments, laneCount } = segsFor([{ id: 'a', title: 'a', start: '2026-07-14T10:00', end: '2026-07-14T11:00' }])
|
|
402
|
-
expect(segments).toHaveLength(1)
|
|
403
|
-
expect(segments[0]).toMatchObject({ startCol: 1, endCol: 1, lane: 0, continuesLeft: false, continuesRight: false })
|
|
404
|
-
expect(laneCount).toBe(1)
|
|
405
|
-
})
|
|
406
|
-
|
|
407
|
-
it('a multi-day event spans multiple columns as one segment', () => {
|
|
408
|
-
const { segments } = segsFor([{ id: 'a', title: 'a', start: '2026-07-14T09:00', end: '2026-07-16T17:00' }])
|
|
409
|
-
expect(segments).toHaveLength(1)
|
|
410
|
-
expect(segments[0]).toMatchObject({ startCol: 1, endCol: 3 })
|
|
411
|
-
})
|
|
412
|
-
|
|
413
|
-
it('an event crossing the week boundary is clipped + flagged continuesRight', () => {
|
|
414
|
-
const { segments } = segsFor([{ id: 'a', title: 'a', start: '2026-07-18T20:00', end: '2026-07-20T08:00' }])
|
|
415
|
-
expect(segments).toHaveLength(1)
|
|
416
|
-
expect(segments[0]).toMatchObject({ startCol: 5, endCol: 6, continuesRight: true, continuesLeft: false })
|
|
417
|
-
})
|
|
418
|
-
|
|
419
|
-
it('an event starting before the week is clipped + flagged continuesLeft', () => {
|
|
420
|
-
const { segments } = segsFor([{ id: 'a', title: 'a', start: '2026-07-11T09:00', end: '2026-07-14T17:00' }])
|
|
421
|
-
expect(segments[0]).toMatchObject({ startCol: 0, endCol: 1, continuesLeft: true })
|
|
422
|
-
})
|
|
423
|
-
|
|
424
|
-
it('an end at exact midnight does not spill onto the next day', () => {
|
|
425
|
-
const { segments } = segsFor([{ id: 'a', title: 'a', start: '2026-07-14T09:00', end: '2026-07-15T00:00' }])
|
|
426
|
-
expect(segments[0]).toMatchObject({ startCol: 1, endCol: 1 })
|
|
427
|
-
})
|
|
428
|
-
|
|
429
|
-
it('overlapping events stack into separate lanes', () => {
|
|
430
|
-
const { segments, laneCount } = segsFor([
|
|
431
|
-
{ id: 'a', title: 'a', start: '2026-07-14T09:00', end: '2026-07-16T17:00' },
|
|
432
|
-
{ id: 'b', title: 'b', start: '2026-07-15T09:00', end: '2026-07-17T17:00' },
|
|
433
|
-
])
|
|
434
|
-
expect(laneCount).toBe(2)
|
|
435
|
-
expect(new Set(segments.map((s) => s.lane))).toEqual(new Set([0, 1]))
|
|
436
|
-
})
|
|
437
|
-
|
|
438
|
-
it('non-overlapping events reuse lane 0', () => {
|
|
439
|
-
const { segments, laneCount } = segsFor([
|
|
440
|
-
{ id: 'a', title: 'a', start: '2026-07-13T09:00', end: '2026-07-13T17:00' },
|
|
441
|
-
{ id: 'b', title: 'b', start: '2026-07-16T09:00', end: '2026-07-16T17:00' },
|
|
442
|
-
])
|
|
443
|
-
expect(laneCount).toBe(1)
|
|
444
|
-
expect(segments.every((s) => s.lane === 0)).toBe(true)
|
|
445
|
-
})
|
|
446
|
-
})
|
|
447
|
-
|
|
448
|
-
describe('agendaGroups', () => {
|
|
449
|
-
it('buckets events by day in chronological order', () => {
|
|
450
|
-
const rows: Row[] = [
|
|
451
|
-
{ id: 'a', title: 'a', start: '2026-07-16T09:00', end: '2026-07-16T10:00' },
|
|
452
|
-
{ id: 'b', title: 'b', start: '2026-07-15T14:00', end: '2026-07-15T15:00' },
|
|
453
|
-
{ id: 'c', title: 'c', start: '2026-07-15T09:00', end: '2026-07-15T10:00' },
|
|
454
|
-
]
|
|
455
|
-
const evs = resolveEvents(rows, spec(), at(2026, 7, 1), at(2026, 8, 1))
|
|
456
|
-
const groups = agendaGroups(evs)
|
|
457
|
-
expect(groups).toHaveLength(2)
|
|
458
|
-
expect(groups[0]!.events.map((e) => e.rowKey)).toEqual(['c', 'b'])
|
|
459
|
-
expect(groups[1]!.events.map((e) => e.rowKey)).toEqual(['a'])
|
|
460
|
-
})
|
|
461
|
-
})
|
|
462
|
-
|
|
463
|
-
describe('rangeForView / daysForView / navigateAnchor', () => {
|
|
464
|
-
it('month range is a fixed 6-week grid aligned to the week start', () => {
|
|
465
|
-
const { start, end } = rangeForView('month', at(2026, 7, 15), 1) // Monday start
|
|
466
|
-
expect(start.getDay()).toBe(1)
|
|
467
|
-
expect((end.getTime() - start.getTime()) / 86_400_000).toBe(42)
|
|
468
|
-
})
|
|
469
|
-
|
|
470
|
-
it('week view yields 7 days from the week start', () => {
|
|
471
|
-
const days = daysForView('week', at(2026, 7, 15), 0)
|
|
472
|
-
expect(days).toHaveLength(7)
|
|
473
|
-
expect(days[0]!.getDay()).toBe(0)
|
|
474
|
-
})
|
|
475
|
-
|
|
476
|
-
it('day view yields just the anchor day', () => {
|
|
477
|
-
const days = daysForView('day', at(2026, 7, 15))
|
|
478
|
-
expect(days).toHaveLength(1)
|
|
479
|
-
expect(days[0]!.getDate()).toBe(15)
|
|
480
|
-
})
|
|
481
|
-
|
|
482
|
-
it('navigates by month / week / day', () => {
|
|
483
|
-
expect(navigateAnchor('month', at(2026, 7, 15), 1).getMonth()).toBe(7) // August
|
|
484
|
-
expect(navigateAnchor('week', at(2026, 7, 15), -1).getDate()).toBe(8)
|
|
485
|
-
expect(navigateAnchor('day', at(2026, 7, 15), 1).getDate()).toBe(16)
|
|
486
|
-
})
|
|
487
|
-
})
|
|
488
|
-
|
|
489
|
-
describe('timeline model', () => {
|
|
490
|
-
const R: SchedulerResource[] = [
|
|
491
|
-
{ id: 'a', title: 'A' },
|
|
492
|
-
{ id: 'b', title: 'B' },
|
|
493
|
-
]
|
|
494
|
-
const ev = (id: string, start: Date, end: Date, resourceId?: string): ResolvedEvent =>
|
|
495
|
-
({ key: id, rowKey: id, row: {}, title: id, start, end, allDay: false, resourceId, recurring: false })
|
|
496
|
-
|
|
497
|
-
it('rangeForView / navigateAnchor cover the timeline views', () => {
|
|
498
|
-
expect(rangeForView('timelineDay', at(2026, 7, 15)).start.getDate()).toBe(15)
|
|
499
|
-
expect(rangeForView('timelineWeek', at(2026, 7, 15), 0).start.getDay()).toBe(0)
|
|
500
|
-
const mo = rangeForView('timelineMonth', at(2026, 7, 15))
|
|
501
|
-
expect(mo.start.getDate()).toBe(1)
|
|
502
|
-
expect(mo.end.getMonth()).toBe(7) // exclusive next month (Aug)
|
|
503
|
-
const yr = rangeForView('timelineYear', at(2026, 7, 15))
|
|
504
|
-
expect(yr.start.getMonth()).toBe(0)
|
|
505
|
-
expect(yr.end.getFullYear()).toBe(2027)
|
|
506
|
-
expect(navigateAnchor('timelineMonth', at(2026, 7, 15), 1).getMonth()).toBe(7)
|
|
507
|
-
expect(navigateAnchor('timelineYear', at(2026, 7, 15), -1).getFullYear()).toBe(2025)
|
|
508
|
-
})
|
|
509
|
-
|
|
510
|
-
it('timelineAxis: day ticks respect the band, week=7 days, year=12 months', () => {
|
|
511
|
-
const day = timelineAxis('timelineDay', at(2026, 7, 15), at(2026, 7, 16), { dayStartHour: 8, dayEndHour: 18 })
|
|
512
|
-
expect(day.ticks).toHaveLength(10) // 08..17
|
|
513
|
-
expect(day.start.getHours()).toBe(8)
|
|
514
|
-
expect(day.ticks[0]!.leftPct).toBeCloseTo(0)
|
|
515
|
-
const week = timelineAxis('timelineWeek', at(2026, 7, 6), at(2026, 7, 13))
|
|
516
|
-
expect(week.ticks).toHaveLength(7)
|
|
517
|
-
const month = timelineAxis('timelineMonth', at(2026, 7, 1), at(2026, 8, 1))
|
|
518
|
-
expect(month.ticks).toHaveLength(31)
|
|
519
|
-
const year = timelineAxis('timelineYear', at(2026, 1, 1), at(2027, 1, 1))
|
|
520
|
-
expect(year.ticks).toHaveLength(12)
|
|
521
|
-
expect(year.majors).toHaveLength(4) // quarters
|
|
522
|
-
})
|
|
523
|
-
|
|
524
|
-
it('timelineGeom clamps to the axis window + flags clipped edges', () => {
|
|
525
|
-
const axisStart = at(2026, 7, 6)
|
|
526
|
-
const axisMs = 7 * 86_400_000
|
|
527
|
-
// fully inside: day 2 of 7, 1-day event
|
|
528
|
-
const g = timelineGeom(at(2026, 7, 8), at(2026, 7, 9), axisStart, axisMs)!
|
|
529
|
-
expect(g.leftPct).toBeCloseTo((2 / 7) * 100)
|
|
530
|
-
expect(g.widthPct).toBeCloseTo((1 / 7) * 100)
|
|
531
|
-
expect(g.continuesLeft).toBe(false)
|
|
532
|
-
// starts before the window -> clipped left
|
|
533
|
-
const g2 = timelineGeom(at(2026, 7, 4), at(2026, 7, 7), axisStart, axisMs)!
|
|
534
|
-
expect(g2.leftPct).toBeCloseTo(0)
|
|
535
|
-
expect(g2.continuesLeft).toBe(true)
|
|
536
|
-
// entirely outside
|
|
537
|
-
expect(timelineGeom(at(2026, 8, 1), at(2026, 8, 2), axisStart, axisMs)).toBeNull()
|
|
538
|
-
})
|
|
539
|
-
|
|
540
|
-
it('timelineRows: buckets by resource + lane-packs overlaps', () => {
|
|
541
|
-
const events = [
|
|
542
|
-
ev('1', at(2026, 7, 6, 9), at(2026, 7, 6, 11), 'a'),
|
|
543
|
-
ev('2', at(2026, 7, 6, 10), at(2026, 7, 6, 12), 'a'), // overlaps #1 -> lane 1
|
|
544
|
-
ev('3', at(2026, 7, 6, 13), at(2026, 7, 6, 14), 'a'), // after #1 -> lane 0
|
|
545
|
-
ev('4', at(2026, 7, 6, 9), at(2026, 7, 6, 10), 'b'),
|
|
546
|
-
]
|
|
547
|
-
const rows = timelineRows(R, events)
|
|
548
|
-
expect(rows).toHaveLength(2)
|
|
549
|
-
const a = rows[0]!
|
|
550
|
-
expect(a.laneCount).toBe(2)
|
|
551
|
-
expect(a.items.find((i) => i.event.key === '2')!.lane).toBe(1)
|
|
552
|
-
expect(a.items.find((i) => i.event.key === '3')!.lane).toBe(0)
|
|
553
|
-
expect(rows[1]!.items).toHaveLength(1)
|
|
554
|
-
})
|
|
555
|
-
|
|
556
|
-
it('timelineRows: a single null row when there are no resources', () => {
|
|
557
|
-
const rows = timelineRows(null, [ev('1', at(2026, 7, 6, 9), at(2026, 7, 6, 10))])
|
|
558
|
-
expect(rows).toHaveLength(1)
|
|
559
|
-
expect(rows[0]!.resource).toBeNull()
|
|
560
|
-
expect(rows[0]!.items).toHaveLength(1)
|
|
561
|
-
})
|
|
562
|
-
})
|
|
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('applies per-occurrence `fields` overrides to one instance (merged row + resource)', () => {
|
|
155
|
+
const rows: Row[] = [
|
|
156
|
+
{ ...weekly, who: 'room1', exceptions: [{ occurrenceStart: '2026-07-13T09:30', fields: { who: 'room9', title: 'Standup (offsite)' } }] },
|
|
157
|
+
]
|
|
158
|
+
const out = resolveEvents(rows, spec(), at(2026, 7, 1), at(2026, 8, 1))
|
|
159
|
+
const overridden = out.find((e) => e.start.getDate() === 13)!
|
|
160
|
+
expect(overridden.resourceId).toBe('room9') // re-derived from the merged row
|
|
161
|
+
expect((overridden.row as Row).who).toBe('room9') // the instance row carries the override
|
|
162
|
+
expect(overridden.title).toBe('Standup (offsite)') // title from fields when not set explicitly
|
|
163
|
+
expect(overridden.isException).toBe(true)
|
|
164
|
+
// Siblings keep the base resource + row.
|
|
165
|
+
const sibling = out.find((e) => e.start.getDate() === 20)!
|
|
166
|
+
expect(sibling.resourceId).toBe('room1')
|
|
167
|
+
expect((sibling.row as Row).who).toBe('room1')
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
it('sorts all-day before timed at the same start', () => {
|
|
171
|
+
const rows: Row[] = [
|
|
172
|
+
{ id: 'timed', title: 't', start: '2026-07-15T00:00', end: '2026-07-15T01:00' },
|
|
173
|
+
{ id: 'allday', title: 'a', start: '2026-07-15T00:00', allDay: true },
|
|
174
|
+
]
|
|
175
|
+
const out = resolveEvents(rows, spec(), at(2026, 7, 1), at(2026, 8, 1))
|
|
176
|
+
expect(out.map((e) => e.rowKey)).toEqual(['allday', 'timed'])
|
|
177
|
+
})
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
describe('hasConflict', () => {
|
|
181
|
+
const rows: Row[] = [
|
|
182
|
+
{ id: 'a', title: 'A', start: '2026-07-15T09:00', end: '2026-07-15T10:00', who: 'room1' },
|
|
183
|
+
{ id: 'b', title: 'B', start: '2026-07-15T11:00', end: '2026-07-15T12:00', who: 'room2' },
|
|
184
|
+
]
|
|
185
|
+
const events = resolveEvents(rows, spec(), at(2026, 7, 1), at(2026, 8, 1))
|
|
186
|
+
|
|
187
|
+
it('detects an overlap on the same resource', () => {
|
|
188
|
+
expect(hasConflict(at(2026, 7, 15, 9, 30), at(2026, 7, 15, 10, 30), 'room1', events)).toBe(true)
|
|
189
|
+
})
|
|
190
|
+
it('ignores overlaps on a different resource', () => {
|
|
191
|
+
expect(hasConflict(at(2026, 7, 15, 9, 30), at(2026, 7, 15, 10, 30), 'room2', events)).toBe(false)
|
|
192
|
+
})
|
|
193
|
+
it('ignores the event being moved (excludeRowKey)', () => {
|
|
194
|
+
expect(hasConflict(at(2026, 7, 15, 9, 0), at(2026, 7, 15, 10, 0), 'room1', events, 'a')).toBe(false)
|
|
195
|
+
})
|
|
196
|
+
it('is false when there is a clear gap', () => {
|
|
197
|
+
expect(hasConflict(at(2026, 7, 15, 10, 0), at(2026, 7, 15, 11, 0), 'room1', events)).toBe(false)
|
|
198
|
+
})
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
describe('overlapCount (capacity)', () => {
|
|
202
|
+
const rows: Row[] = [
|
|
203
|
+
{ id: 'a', title: 'A', start: '2026-07-15T09:00', end: '2026-07-15T10:00', who: 'room1' },
|
|
204
|
+
{ id: 'b', title: 'B', start: '2026-07-15T09:30', end: '2026-07-15T10:30', who: 'room1' },
|
|
205
|
+
{ id: 'c', title: 'C', start: '2026-07-15T09:15', end: '2026-07-15T09:45', who: 'room2' },
|
|
206
|
+
]
|
|
207
|
+
const events = resolveEvents(rows, spec(), at(2026, 7, 1), at(2026, 8, 1))
|
|
208
|
+
it('counts overlapping events on the same resource', () => {
|
|
209
|
+
expect(overlapCount(at(2026, 7, 15, 9, 20), at(2026, 7, 15, 9, 40), 'room1', events)).toBe(2)
|
|
210
|
+
})
|
|
211
|
+
it('excludes the moved event and other resources', () => {
|
|
212
|
+
expect(overlapCount(at(2026, 7, 15, 9, 20), at(2026, 7, 15, 9, 40), 'room1', events, 'a')).toBe(1)
|
|
213
|
+
expect(overlapCount(at(2026, 7, 15, 9, 20), at(2026, 7, 15, 9, 40), 'room2', events)).toBe(1)
|
|
214
|
+
})
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
describe('overlapsBands (restricted hours)', () => {
|
|
218
|
+
const lunch = [{ start: 12, end: 13 }]
|
|
219
|
+
it('is true when the range overlaps a band', () => {
|
|
220
|
+
expect(overlapsBands(12 * 60 + 30, 13 * 60 + 30, lunch)).toBe(true)
|
|
221
|
+
expect(overlapsBands(11 * 60, 12 * 60 + 15, lunch)).toBe(true)
|
|
222
|
+
})
|
|
223
|
+
it('is false when the range is clear of every band', () => {
|
|
224
|
+
expect(overlapsBands(13 * 60, 14 * 60, lunch)).toBe(false)
|
|
225
|
+
expect(overlapsBands(10 * 60, 12 * 60, lunch)).toBe(false) // ends exactly at 12
|
|
226
|
+
})
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
describe('workingIntervals / withinWorking (per-resource availability)', () => {
|
|
230
|
+
const win = [
|
|
231
|
+
{ days: [1, 3, 5], start: 9, end: 13 }, // Mon/Wed/Fri morning
|
|
232
|
+
{ days: [2, 4], start: 14, end: 18 }, // Tue/Thu afternoon
|
|
233
|
+
]
|
|
234
|
+
const band = [0, 24 * 60] as const
|
|
235
|
+
|
|
236
|
+
it('returns the matching window for a weekday (in minutes)', () => {
|
|
237
|
+
expect(workingIntervals(1, win, band[0], band[1])).toEqual([[540, 780]]) // Mon 9-13
|
|
238
|
+
expect(workingIntervals(2, win, band[0], band[1])).toEqual([[840, 1080]]) // Tue 14-18
|
|
239
|
+
})
|
|
240
|
+
it('is empty for a day with no matching window (day off)', () => {
|
|
241
|
+
expect(workingIntervals(0, win, band[0], band[1])).toEqual([]) // Sunday off
|
|
242
|
+
})
|
|
243
|
+
it('clamps to the visible band and merges overlapping windows', () => {
|
|
244
|
+
const w = [{ start: 6, end: 12 }, { start: 11, end: 20 }]
|
|
245
|
+
expect(workingIntervals(1, w, 8 * 60, 18 * 60)).toEqual([[480, 1080]]) // merged 8-18
|
|
246
|
+
})
|
|
247
|
+
it('withinWorking is true only when the slot fits inside a window', () => {
|
|
248
|
+
const iv = workingIntervals(1, win, band[0], band[1]) // [[540,780]]
|
|
249
|
+
expect(withinWorking(600, 660, iv)).toBe(true) // 10-11 inside 9-13
|
|
250
|
+
expect(withinWorking(780, 840, iv)).toBe(false) // 13-14 outside
|
|
251
|
+
expect(withinWorking(720, 840, iv)).toBe(false) // 12-14 crosses the edge
|
|
252
|
+
})
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
describe('eventsOnDay', () => {
|
|
256
|
+
it('includes an event that spans the day even if it starts earlier', () => {
|
|
257
|
+
const rows: Row[] = [
|
|
258
|
+
{ id: 'multi', title: 'trip', start: '2026-07-14T20:00', end: '2026-07-16T08:00' },
|
|
259
|
+
]
|
|
260
|
+
const evs = resolveEvents(rows, spec(), at(2026, 7, 1), at(2026, 8, 1))
|
|
261
|
+
expect(eventsOnDay(evs, at(2026, 7, 15))).toHaveLength(1)
|
|
262
|
+
expect(eventsOnDay(evs, at(2026, 7, 17))).toHaveLength(0)
|
|
263
|
+
})
|
|
264
|
+
})
|
|
265
|
+
|
|
266
|
+
// N overlapping events, all starting at 09:00 for `hours` hours.
|
|
267
|
+
function pileUp(n: number, hours = 1): Row[] {
|
|
268
|
+
return Array.from({ length: n }, (_, i) => ({
|
|
269
|
+
id: `e${i}`,
|
|
270
|
+
title: `e${i}`,
|
|
271
|
+
start: '2026-07-15T09:00',
|
|
272
|
+
end: `2026-07-15T${String(9 + hours).padStart(2, '0')}:00`,
|
|
273
|
+
}))
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
describe('layoutDayEvents (split, default)', () => {
|
|
277
|
+
it('places two overlapping events in two half-width columns', () => {
|
|
278
|
+
const rows: Row[] = [
|
|
279
|
+
{ id: 'a', title: 'a', start: '2026-07-15T09:00', end: '2026-07-15T10:00' },
|
|
280
|
+
{ id: 'b', title: 'b', start: '2026-07-15T09:30', end: '2026-07-15T10:30' },
|
|
281
|
+
]
|
|
282
|
+
const evs = resolveEvents(rows, spec(), at(2026, 7, 15), at(2026, 7, 16))
|
|
283
|
+
const { events, overflows } = layoutDayEvents(evs, at(2026, 7, 15))
|
|
284
|
+
expect(events).toHaveLength(2)
|
|
285
|
+
expect(overflows).toHaveLength(0)
|
|
286
|
+
expect(events.every((p) => p.colCount === 2)).toBe(true)
|
|
287
|
+
expect(new Set(events.map((p) => p.col))).toEqual(new Set([0, 1]))
|
|
288
|
+
expect(events.every((p) => Math.abs(p.widthPct - 50) < 0.01)).toBe(true)
|
|
289
|
+
expect(new Set(events.map((p) => p.leftPct))).toEqual(new Set([0, 50]))
|
|
290
|
+
})
|
|
291
|
+
|
|
292
|
+
it('gives a non-overlapping event the full width', () => {
|
|
293
|
+
const rows: Row[] = [
|
|
294
|
+
{ id: 'a', title: 'a', start: '2026-07-15T09:00', end: '2026-07-15T10:00' },
|
|
295
|
+
{ id: 'b', title: 'b', start: '2026-07-15T11:00', end: '2026-07-15T12:00' },
|
|
296
|
+
]
|
|
297
|
+
const evs = resolveEvents(rows, spec(), at(2026, 7, 15), at(2026, 7, 16))
|
|
298
|
+
const { events } = layoutDayEvents(evs, at(2026, 7, 15))
|
|
299
|
+
expect(events.every((p) => p.colCount === 1 && p.widthPct === 100)).toBe(true)
|
|
300
|
+
})
|
|
301
|
+
|
|
302
|
+
it('expands an event into adjacent columns that are free during its span', () => {
|
|
303
|
+
// a + b (both 10-11) occupy col0/col1; c (10:30-12:30) is forced to col2 =>
|
|
304
|
+
// 3-col cluster. d starts 11:30 after a/b end, takes col0, and must WIDEN
|
|
305
|
+
// across the now-free col1 up to col2 (c overlaps) => 2/3 width, not 1/3.
|
|
306
|
+
const rows: Row[] = [
|
|
307
|
+
{ id: 'a', title: 'a', start: '2026-07-15T10:00', end: '2026-07-15T11:00' },
|
|
308
|
+
{ id: 'b', title: 'b', start: '2026-07-15T10:00', end: '2026-07-15T11:00' },
|
|
309
|
+
{ id: 'c', title: 'c', start: '2026-07-15T10:30', end: '2026-07-15T12:30' },
|
|
310
|
+
{ id: 'd', title: 'd', start: '2026-07-15T11:30', end: '2026-07-15T12:30' },
|
|
311
|
+
]
|
|
312
|
+
const evs = resolveEvents(rows, spec(), at(2026, 7, 15), at(2026, 7, 16))
|
|
313
|
+
const { events } = layoutDayEvents(evs, at(2026, 7, 15))
|
|
314
|
+
const byId = new Map(events.map((p) => [p.event.rowKey, p]))
|
|
315
|
+
expect(byId.get('d')!.colCount).toBe(3)
|
|
316
|
+
expect(byId.get('d')!.col).toBe(0)
|
|
317
|
+
// d widens across the free col1, stopping before c (col2): ~2/3 of the width.
|
|
318
|
+
expect(byId.get('d')!.widthPct).toBeCloseTo((2 / 3) * 100, 1)
|
|
319
|
+
// a still overlaps b so it stays a single column wide (1/3).
|
|
320
|
+
expect(byId.get('a')!.widthPct).toBeCloseTo((1 / 3) * 100, 1)
|
|
321
|
+
})
|
|
322
|
+
|
|
323
|
+
it('positions events as a percentage of the visible band', () => {
|
|
324
|
+
const rows: Row[] = [{ id: 'a', title: 'a', start: '2026-07-15T12:00', end: '2026-07-15T13:00' }]
|
|
325
|
+
const evs = resolveEvents(rows, spec(), at(2026, 7, 15), at(2026, 7, 16))
|
|
326
|
+
const p = layoutDayEvents(evs, at(2026, 7, 15), { dayStartHour: 8, dayEndHour: 20 }).events[0]!
|
|
327
|
+
expect(p.topPct).toBeCloseTo((4 / 12) * 100)
|
|
328
|
+
expect(p.heightPct).toBeCloseTo((1 / 12) * 100)
|
|
329
|
+
})
|
|
330
|
+
|
|
331
|
+
it('ignores all-day events', () => {
|
|
332
|
+
const rows: Row[] = [{ id: 'a', title: 'a', start: '2026-07-15T00:00', allDay: true }]
|
|
333
|
+
const evs = resolveEvents(rows, spec(), at(2026, 7, 15), at(2026, 7, 16))
|
|
334
|
+
expect(layoutDayEvents(evs, at(2026, 7, 15)).events).toHaveLength(0)
|
|
335
|
+
})
|
|
336
|
+
|
|
337
|
+
it('splits N-way with no cap', () => {
|
|
338
|
+
const evs = resolveEvents(pileUp(8), spec(), at(2026, 7, 15), at(2026, 7, 16))
|
|
339
|
+
const { events, overflows } = layoutDayEvents(evs, at(2026, 7, 15))
|
|
340
|
+
expect(events).toHaveLength(8)
|
|
341
|
+
expect(overflows).toHaveLength(0)
|
|
342
|
+
expect(events.every((p) => p.colCount === 8 && Math.abs(p.widthPct - 12.5) < 0.01)).toBe(true)
|
|
343
|
+
})
|
|
344
|
+
})
|
|
345
|
+
|
|
346
|
+
describe('layoutDayEvents (cap)', () => {
|
|
347
|
+
it('keeps clusters that fit within maxColumns fully visible', () => {
|
|
348
|
+
const evs = resolveEvents(pileUp(3), spec(), at(2026, 7, 15), at(2026, 7, 16))
|
|
349
|
+
const { events, overflows } = layoutDayEvents(evs, at(2026, 7, 15), { mode: 'cap', maxColumns: 3 })
|
|
350
|
+
expect(events).toHaveLength(3)
|
|
351
|
+
expect(overflows).toHaveLength(0)
|
|
352
|
+
})
|
|
353
|
+
|
|
354
|
+
it('shows maxColumns-1 events + one "+N more" overflow tile', () => {
|
|
355
|
+
const evs = resolveEvents(pileUp(6), spec(), at(2026, 7, 15), at(2026, 7, 16))
|
|
356
|
+
const { events, overflows } = layoutDayEvents(evs, at(2026, 7, 15), { mode: 'cap', maxColumns: 3 })
|
|
357
|
+
// 3 columns -> 2 real + 1 overflow slot; 6 events => 2 shown, 4 hidden.
|
|
358
|
+
expect(events).toHaveLength(2)
|
|
359
|
+
expect(overflows).toHaveLength(1)
|
|
360
|
+
expect(overflows[0]!.count).toBe(4)
|
|
361
|
+
expect(overflows[0]!.events).toHaveLength(4)
|
|
362
|
+
// Overflow tile sits in the last of the 3 columns.
|
|
363
|
+
expect(overflows[0]!.leftPct).toBeCloseTo((2 / 3) * 100)
|
|
364
|
+
expect(overflows[0]!.widthPct).toBeCloseTo(100 / 3)
|
|
365
|
+
})
|
|
366
|
+
|
|
367
|
+
it('clamps maxColumns to a minimum of 2', () => {
|
|
368
|
+
const evs = resolveEvents(pileUp(5), spec(), at(2026, 7, 15), at(2026, 7, 16))
|
|
369
|
+
const { events, overflows } = layoutDayEvents(evs, at(2026, 7, 15), { mode: 'cap', maxColumns: 1 })
|
|
370
|
+
expect(events).toHaveLength(1) // maxColumns floored to 2 -> 1 real + overflow
|
|
371
|
+
expect(overflows[0]!.count).toBe(4)
|
|
372
|
+
})
|
|
373
|
+
})
|
|
374
|
+
|
|
375
|
+
describe('layoutDayEvents (stack)', () => {
|
|
376
|
+
it('offsets overlapping events instead of shrinking them, with rising z-index', () => {
|
|
377
|
+
const evs = resolveEvents(pileUp(3), spec(), at(2026, 7, 15), at(2026, 7, 16))
|
|
378
|
+
const { events, overflows } = layoutDayEvents(evs, at(2026, 7, 15), {
|
|
379
|
+
mode: 'stack',
|
|
380
|
+
stackOffsetPct: 14,
|
|
381
|
+
})
|
|
382
|
+
expect(events).toHaveLength(3)
|
|
383
|
+
expect(overflows).toHaveLength(0)
|
|
384
|
+
const byCol = [...events].sort((a, b) => a.col - b.col)
|
|
385
|
+
expect(byCol.map((p) => p.leftPct)).toEqual([0, 14, 28])
|
|
386
|
+
expect(byCol.map((p) => p.zIndex)).toEqual([1, 2, 3])
|
|
387
|
+
// Earliest column is widest; later ones are offset but still wide.
|
|
388
|
+
expect(byCol[0]!.widthPct).toBe(100)
|
|
389
|
+
expect(byCol[2]!.widthPct).toBe(72)
|
|
390
|
+
})
|
|
391
|
+
})
|
|
392
|
+
|
|
393
|
+
describe('monthWeekSegments (spanning bars)', () => {
|
|
394
|
+
const weekStart = at(2026, 7, 13) // Monday (Mon13..Sun19)
|
|
395
|
+
function segsFor(rows: Row[]) {
|
|
396
|
+
const evs = resolveEvents(rows, spec(), at(2026, 7, 1), at(2026, 8, 1))
|
|
397
|
+
return monthWeekSegments(evs, weekStart)
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
it('a single-day event is a 1-column segment in lane 0', () => {
|
|
401
|
+
const { segments, laneCount } = segsFor([{ id: 'a', title: 'a', start: '2026-07-14T10:00', end: '2026-07-14T11:00' }])
|
|
402
|
+
expect(segments).toHaveLength(1)
|
|
403
|
+
expect(segments[0]).toMatchObject({ startCol: 1, endCol: 1, lane: 0, continuesLeft: false, continuesRight: false })
|
|
404
|
+
expect(laneCount).toBe(1)
|
|
405
|
+
})
|
|
406
|
+
|
|
407
|
+
it('a multi-day event spans multiple columns as one segment', () => {
|
|
408
|
+
const { segments } = segsFor([{ id: 'a', title: 'a', start: '2026-07-14T09:00', end: '2026-07-16T17:00' }])
|
|
409
|
+
expect(segments).toHaveLength(1)
|
|
410
|
+
expect(segments[0]).toMatchObject({ startCol: 1, endCol: 3 })
|
|
411
|
+
})
|
|
412
|
+
|
|
413
|
+
it('an event crossing the week boundary is clipped + flagged continuesRight', () => {
|
|
414
|
+
const { segments } = segsFor([{ id: 'a', title: 'a', start: '2026-07-18T20:00', end: '2026-07-20T08:00' }])
|
|
415
|
+
expect(segments).toHaveLength(1)
|
|
416
|
+
expect(segments[0]).toMatchObject({ startCol: 5, endCol: 6, continuesRight: true, continuesLeft: false })
|
|
417
|
+
})
|
|
418
|
+
|
|
419
|
+
it('an event starting before the week is clipped + flagged continuesLeft', () => {
|
|
420
|
+
const { segments } = segsFor([{ id: 'a', title: 'a', start: '2026-07-11T09:00', end: '2026-07-14T17:00' }])
|
|
421
|
+
expect(segments[0]).toMatchObject({ startCol: 0, endCol: 1, continuesLeft: true })
|
|
422
|
+
})
|
|
423
|
+
|
|
424
|
+
it('an end at exact midnight does not spill onto the next day', () => {
|
|
425
|
+
const { segments } = segsFor([{ id: 'a', title: 'a', start: '2026-07-14T09:00', end: '2026-07-15T00:00' }])
|
|
426
|
+
expect(segments[0]).toMatchObject({ startCol: 1, endCol: 1 })
|
|
427
|
+
})
|
|
428
|
+
|
|
429
|
+
it('overlapping events stack into separate lanes', () => {
|
|
430
|
+
const { segments, laneCount } = segsFor([
|
|
431
|
+
{ id: 'a', title: 'a', start: '2026-07-14T09:00', end: '2026-07-16T17:00' },
|
|
432
|
+
{ id: 'b', title: 'b', start: '2026-07-15T09:00', end: '2026-07-17T17:00' },
|
|
433
|
+
])
|
|
434
|
+
expect(laneCount).toBe(2)
|
|
435
|
+
expect(new Set(segments.map((s) => s.lane))).toEqual(new Set([0, 1]))
|
|
436
|
+
})
|
|
437
|
+
|
|
438
|
+
it('non-overlapping events reuse lane 0', () => {
|
|
439
|
+
const { segments, laneCount } = segsFor([
|
|
440
|
+
{ id: 'a', title: 'a', start: '2026-07-13T09:00', end: '2026-07-13T17:00' },
|
|
441
|
+
{ id: 'b', title: 'b', start: '2026-07-16T09:00', end: '2026-07-16T17:00' },
|
|
442
|
+
])
|
|
443
|
+
expect(laneCount).toBe(1)
|
|
444
|
+
expect(segments.every((s) => s.lane === 0)).toBe(true)
|
|
445
|
+
})
|
|
446
|
+
})
|
|
447
|
+
|
|
448
|
+
describe('agendaGroups', () => {
|
|
449
|
+
it('buckets events by day in chronological order', () => {
|
|
450
|
+
const rows: Row[] = [
|
|
451
|
+
{ id: 'a', title: 'a', start: '2026-07-16T09:00', end: '2026-07-16T10:00' },
|
|
452
|
+
{ id: 'b', title: 'b', start: '2026-07-15T14:00', end: '2026-07-15T15:00' },
|
|
453
|
+
{ id: 'c', title: 'c', start: '2026-07-15T09:00', end: '2026-07-15T10:00' },
|
|
454
|
+
]
|
|
455
|
+
const evs = resolveEvents(rows, spec(), at(2026, 7, 1), at(2026, 8, 1))
|
|
456
|
+
const groups = agendaGroups(evs)
|
|
457
|
+
expect(groups).toHaveLength(2)
|
|
458
|
+
expect(groups[0]!.events.map((e) => e.rowKey)).toEqual(['c', 'b'])
|
|
459
|
+
expect(groups[1]!.events.map((e) => e.rowKey)).toEqual(['a'])
|
|
460
|
+
})
|
|
461
|
+
})
|
|
462
|
+
|
|
463
|
+
describe('rangeForView / daysForView / navigateAnchor', () => {
|
|
464
|
+
it('month range is a fixed 6-week grid aligned to the week start', () => {
|
|
465
|
+
const { start, end } = rangeForView('month', at(2026, 7, 15), 1) // Monday start
|
|
466
|
+
expect(start.getDay()).toBe(1)
|
|
467
|
+
expect((end.getTime() - start.getTime()) / 86_400_000).toBe(42)
|
|
468
|
+
})
|
|
469
|
+
|
|
470
|
+
it('week view yields 7 days from the week start', () => {
|
|
471
|
+
const days = daysForView('week', at(2026, 7, 15), 0)
|
|
472
|
+
expect(days).toHaveLength(7)
|
|
473
|
+
expect(days[0]!.getDay()).toBe(0)
|
|
474
|
+
})
|
|
475
|
+
|
|
476
|
+
it('day view yields just the anchor day', () => {
|
|
477
|
+
const days = daysForView('day', at(2026, 7, 15))
|
|
478
|
+
expect(days).toHaveLength(1)
|
|
479
|
+
expect(days[0]!.getDate()).toBe(15)
|
|
480
|
+
})
|
|
481
|
+
|
|
482
|
+
it('navigates by month / week / day', () => {
|
|
483
|
+
expect(navigateAnchor('month', at(2026, 7, 15), 1).getMonth()).toBe(7) // August
|
|
484
|
+
expect(navigateAnchor('week', at(2026, 7, 15), -1).getDate()).toBe(8)
|
|
485
|
+
expect(navigateAnchor('day', at(2026, 7, 15), 1).getDate()).toBe(16)
|
|
486
|
+
})
|
|
487
|
+
})
|
|
488
|
+
|
|
489
|
+
describe('timeline model', () => {
|
|
490
|
+
const R: SchedulerResource[] = [
|
|
491
|
+
{ id: 'a', title: 'A' },
|
|
492
|
+
{ id: 'b', title: 'B' },
|
|
493
|
+
]
|
|
494
|
+
const ev = (id: string, start: Date, end: Date, resourceId?: string): ResolvedEvent =>
|
|
495
|
+
({ key: id, rowKey: id, row: {}, title: id, start, end, allDay: false, resourceId, recurring: false })
|
|
496
|
+
|
|
497
|
+
it('rangeForView / navigateAnchor cover the timeline views', () => {
|
|
498
|
+
expect(rangeForView('timelineDay', at(2026, 7, 15)).start.getDate()).toBe(15)
|
|
499
|
+
expect(rangeForView('timelineWeek', at(2026, 7, 15), 0).start.getDay()).toBe(0)
|
|
500
|
+
const mo = rangeForView('timelineMonth', at(2026, 7, 15))
|
|
501
|
+
expect(mo.start.getDate()).toBe(1)
|
|
502
|
+
expect(mo.end.getMonth()).toBe(7) // exclusive next month (Aug)
|
|
503
|
+
const yr = rangeForView('timelineYear', at(2026, 7, 15))
|
|
504
|
+
expect(yr.start.getMonth()).toBe(0)
|
|
505
|
+
expect(yr.end.getFullYear()).toBe(2027)
|
|
506
|
+
expect(navigateAnchor('timelineMonth', at(2026, 7, 15), 1).getMonth()).toBe(7)
|
|
507
|
+
expect(navigateAnchor('timelineYear', at(2026, 7, 15), -1).getFullYear()).toBe(2025)
|
|
508
|
+
})
|
|
509
|
+
|
|
510
|
+
it('timelineAxis: day ticks respect the band, week=7 days, year=12 months', () => {
|
|
511
|
+
const day = timelineAxis('timelineDay', at(2026, 7, 15), at(2026, 7, 16), { dayStartHour: 8, dayEndHour: 18 })
|
|
512
|
+
expect(day.ticks).toHaveLength(10) // 08..17
|
|
513
|
+
expect(day.start.getHours()).toBe(8)
|
|
514
|
+
expect(day.ticks[0]!.leftPct).toBeCloseTo(0)
|
|
515
|
+
const week = timelineAxis('timelineWeek', at(2026, 7, 6), at(2026, 7, 13))
|
|
516
|
+
expect(week.ticks).toHaveLength(7)
|
|
517
|
+
const month = timelineAxis('timelineMonth', at(2026, 7, 1), at(2026, 8, 1))
|
|
518
|
+
expect(month.ticks).toHaveLength(31)
|
|
519
|
+
const year = timelineAxis('timelineYear', at(2026, 1, 1), at(2027, 1, 1))
|
|
520
|
+
expect(year.ticks).toHaveLength(12)
|
|
521
|
+
expect(year.majors).toHaveLength(4) // quarters
|
|
522
|
+
})
|
|
523
|
+
|
|
524
|
+
it('timelineGeom clamps to the axis window + flags clipped edges', () => {
|
|
525
|
+
const axisStart = at(2026, 7, 6)
|
|
526
|
+
const axisMs = 7 * 86_400_000
|
|
527
|
+
// fully inside: day 2 of 7, 1-day event
|
|
528
|
+
const g = timelineGeom(at(2026, 7, 8), at(2026, 7, 9), axisStart, axisMs)!
|
|
529
|
+
expect(g.leftPct).toBeCloseTo((2 / 7) * 100)
|
|
530
|
+
expect(g.widthPct).toBeCloseTo((1 / 7) * 100)
|
|
531
|
+
expect(g.continuesLeft).toBe(false)
|
|
532
|
+
// starts before the window -> clipped left
|
|
533
|
+
const g2 = timelineGeom(at(2026, 7, 4), at(2026, 7, 7), axisStart, axisMs)!
|
|
534
|
+
expect(g2.leftPct).toBeCloseTo(0)
|
|
535
|
+
expect(g2.continuesLeft).toBe(true)
|
|
536
|
+
// entirely outside
|
|
537
|
+
expect(timelineGeom(at(2026, 8, 1), at(2026, 8, 2), axisStart, axisMs)).toBeNull()
|
|
538
|
+
})
|
|
539
|
+
|
|
540
|
+
it('timelineRows: buckets by resource + lane-packs overlaps', () => {
|
|
541
|
+
const events = [
|
|
542
|
+
ev('1', at(2026, 7, 6, 9), at(2026, 7, 6, 11), 'a'),
|
|
543
|
+
ev('2', at(2026, 7, 6, 10), at(2026, 7, 6, 12), 'a'), // overlaps #1 -> lane 1
|
|
544
|
+
ev('3', at(2026, 7, 6, 13), at(2026, 7, 6, 14), 'a'), // after #1 -> lane 0
|
|
545
|
+
ev('4', at(2026, 7, 6, 9), at(2026, 7, 6, 10), 'b'),
|
|
546
|
+
]
|
|
547
|
+
const rows = timelineRows(R, events)
|
|
548
|
+
expect(rows).toHaveLength(2)
|
|
549
|
+
const a = rows[0]!
|
|
550
|
+
expect(a.laneCount).toBe(2)
|
|
551
|
+
expect(a.items.find((i) => i.event.key === '2')!.lane).toBe(1)
|
|
552
|
+
expect(a.items.find((i) => i.event.key === '3')!.lane).toBe(0)
|
|
553
|
+
expect(rows[1]!.items).toHaveLength(1)
|
|
554
|
+
})
|
|
555
|
+
|
|
556
|
+
it('timelineRows: a single null row when there are no resources', () => {
|
|
557
|
+
const rows = timelineRows(null, [ev('1', at(2026, 7, 6, 9), at(2026, 7, 6, 10))])
|
|
558
|
+
expect(rows).toHaveLength(1)
|
|
559
|
+
expect(rows[0]!.resource).toBeNull()
|
|
560
|
+
expect(rows[0]!.items).toHaveLength(1)
|
|
561
|
+
})
|
|
562
|
+
})
|