@svgrid/enterprise 2.2.1 → 2.3.1
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 +95 -81
- package/dist/cdn/svgrid-enterprise.svelte-external.js +26642 -11252
- package/dist/designer/assets/GridMenus-BtWVk9Ab.js +7 -0
- package/dist/designer/assets/SvGridChartPanel-DWlBO2SD.js +10 -0
- package/dist/designer/assets/SvGridChartView-8c8Mb4j8.js +1 -0
- package/dist/designer/assets/SvGridChartView-DX9HfkBR.css +1 -0
- package/dist/designer/assets/index-DsDgp9Xq.js +78758 -0
- package/dist/designer/assets/index-tTY_Dx4P.css +1 -0
- package/dist/designer/assets/jszip.min-fkJdmAmj.js +2 -0
- package/dist/designer/assets/pdfmake-DeCsnyl9.js +242 -0
- package/dist/designer/assets/smart.export-BZlSCE8T.js +35 -0
- package/dist/designer/assets/vfs_fonts-eX2NpmfX.js +1 -0
- package/dist/designer/index.html +13 -0
- package/dist/node/studio.js +15304 -2708
- package/package.json +35 -11
- package/src/SvAlertRuleEditor.svelte +294 -0
- package/src/SvAlertsManager.svelte +210 -0
- package/src/SvAlertsPanel.svelte +129 -0
- package/src/SvBoard.svelte +3 -1
- package/src/SvExpressionEditor.svelte +341 -0
- package/src/SvGridAlerts.dom.test.ts +113 -0
- package/src/SvGridAlerts.svelte +265 -0
- package/src/SvGridBoard.svelte +2358 -0
- package/src/SvGridEditPanel.svelte +849 -794
- package/src/SvGridScheduler.svelte +5358 -4410
- package/src/SvPivotDesigner.svelte +3 -3
- package/src/SvRecordDetail.svelte +6 -2
- package/src/SvSchedule.svelte +3 -1
- package/src/{ai-export-pdf.test.ts → ai-export-pdf.dom.test.ts} +77 -74
- package/src/{ai-export-xlsx.test.ts → ai-export-xlsx.dom.test.ts} +90 -87
- package/src/{ai-export.test.ts → ai-export.dom.test.ts} +114 -110
- package/src/alerts/alert-engine-attach.ts +165 -0
- package/src/alerts/alert-engine.test.ts +135 -0
- package/src/alerts/alert-engine.ts +260 -0
- package/src/alerts/alert-formats.test.ts +87 -0
- package/src/alerts/alert-formats.ts +77 -0
- package/src/alerts/alert-observer.test.ts +189 -0
- package/src/alerts/alert-observer.ts +208 -0
- package/src/alerts/alert-scheduler.ts +96 -0
- package/src/alerts/alert-storage.test.ts +54 -0
- package/src/alerts/alert-storage.ts +116 -0
- package/src/alerts/alert-store.svelte.ts +80 -0
- package/src/alerts/alert-types.ts +94 -0
- package/src/alerts.ts +28 -0
- package/src/board.dom.test.ts +941 -0
- package/src/board.ts +36 -0
- package/src/export-ooxml.ts +4 -0
- package/src/export-xls.ts +3 -0
- package/src/export.ts +6 -0
- package/src/expressions/evaluate.test.ts +111 -0
- package/src/expressions/evaluate.ts +237 -0
- package/src/expressions/expression-columns.ts +133 -0
- package/src/expressions/expression-types.ts +84 -0
- package/src/expressions/parse.test.ts +106 -0
- package/src/expressions/parse.ts +610 -0
- package/src/import.test.ts +1 -1
- package/src/import.ts +3 -1
- package/src/index.ts +192 -46
- package/src/install.ts +12 -2
- package/src/pivot-enable.ts +44 -0
- package/src/pivot.test.ts +0 -1
- package/src/scheduler-assignments.test.ts +97 -0
- package/src/scheduler-assignments.ts +134 -0
- package/src/scheduler-axis.test.ts +108 -0
- package/src/scheduler-axis.ts +238 -0
- package/src/scheduler-booking.test.ts +57 -0
- package/src/scheduler-booking.ts +63 -0
- package/src/scheduler-config.ts +179 -0
- package/src/scheduler-dependencies.test.ts +155 -0
- package/src/scheduler-dependencies.ts +223 -0
- package/src/scheduler-freebusy.test.ts +41 -0
- package/src/scheduler-freebusy.ts +36 -0
- package/src/scheduler-heatmap.test.ts +39 -0
- package/src/scheduler-heatmap.ts +55 -0
- package/src/scheduler-resource-tree.test.ts +84 -0
- package/src/scheduler-resource-tree.ts +107 -0
- package/src/scheduler-slots.test.ts +53 -0
- package/src/scheduler-slots.ts +94 -0
- package/src/scheduler-summary.test.ts +47 -0
- package/src/scheduler-summary.ts +81 -0
- package/src/schema-designer.ts +1 -1
- package/src/sources/index.ts +1 -1
- package/src/sources/introspect-supabase.test.ts +13 -1
- package/src/sources/introspect-supabase.ts +20 -0
- package/src/studio/copilot-core.test.ts +45 -0
- package/src/studio/copilot-core.ts +65 -0
- package/src/studio/deploy-cli.test.ts +56 -0
- package/src/studio/deploy-cli.ts +100 -0
- package/src/studio/emit-project.test.ts +800 -24
- package/src/studio/emit-project.ts +1307 -135
- package/src/studio/emit-schema.test.ts +17 -0
- package/src/studio/emit-schema.ts +182 -41
- package/src/studio/index.ts +59 -1
- package/src/studio/init-flow.test.ts +239 -0
- package/src/studio/init-flow.ts +358 -0
- package/src/studio/introspect-openapi.test.ts +84 -0
- package/src/studio/introspect-openapi.ts +252 -0
- package/src/studio/project.test.ts +57 -1
- package/src/studio/project.ts +302 -8
- package/src/studio/samples/crm.ts +282 -258
- package/src/studio/samples/datasets.test.ts +84 -0
- package/src/studio/samples/datasets.ts +340 -0
- package/src/studio/samples/fleet.ts +213 -186
- package/src/studio/samples/insurance.ts +223 -195
- package/src/studio/samples/inventory.ts +213 -182
- package/src/studio/samples/live-data.test.ts +99 -98
- package/src/studio/samples/live-data.ts +8 -10
- package/src/studio/samples/projects.ts +212 -190
- package/src/studio/samples/samples.test.ts +268 -216
- package/src/studio/samples/shared.ts +22 -145
- package/src/studio/samples/starter.ts +251 -0
- package/src/studio/samples/support.ts +209 -184
- package/src/studio/screen-suites.test.ts +226 -0
- package/src/studio/screen-suites.ts +445 -0
- package/src/studio/ui-components-surface.test.ts +185 -0
- package/src/studio/ui-components.generated.ts +7154 -0
- package/src/studio/ui-components.ts +273 -3
- package/src/sveltekit/index.ts +1 -0
- package/src/sveltekit/sql-source.test.ts +13 -0
- package/src/sveltekit/sql-source.ts +12 -7
- package/src/sveltekit/transport-scope.test.ts +125 -0
- package/src/sveltekit/transport.ts +76 -2
- package/src/upgrade-prompt.ts +2 -2
- package/src/watermark.ts +2 -2
- package/src/ai.test.ts +0 -522
- package/src/ai.ts +0 -1388
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { buildAxis, resolveZoom, timeToX, xToTime, zoomPresets } from './scheduler-axis'
|
|
3
|
+
|
|
4
|
+
const day = (d: number, h = 0) => new Date(2026, 0, d, h, 0, 0, 0)
|
|
5
|
+
|
|
6
|
+
describe('buildAxis - linear (no collapse)', () => {
|
|
7
|
+
it('maps a full day linearly at pxPerMinute', () => {
|
|
8
|
+
const axis = buildAxis(day(5), day(6), { pxPerMinute: 1 }) // 1px/min => 1440px
|
|
9
|
+
expect(axis.totalPx).toBe(1440)
|
|
10
|
+
expect(timeToX(day(5, 12), axis)).toBe(720) // noon = halfway
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
it('timeToX / xToTime round-trip within a working span', () => {
|
|
14
|
+
const axis = buildAxis(day(5), day(6), { pxPerMinute: 1 })
|
|
15
|
+
const t = day(5, 9)
|
|
16
|
+
expect(xToTime(timeToX(t, axis), axis).getTime()).toBe(t.getTime())
|
|
17
|
+
})
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
describe('buildAxis - business hours collapse', () => {
|
|
21
|
+
it('collapses nights to a fixed gap so working hours dominate', () => {
|
|
22
|
+
const axis = buildAxis(day(5), day(7), {
|
|
23
|
+
pxPerMinute: 1,
|
|
24
|
+
businessHours: { start: 9, end: 17 }, // 8h = 480px working
|
|
25
|
+
collapseNonWorking: true,
|
|
26
|
+
collapsedGapPx: 10,
|
|
27
|
+
})
|
|
28
|
+
const working = axis.segments.filter((s) => s.kind === 'working')
|
|
29
|
+
const collapsed = axis.segments.filter((s) => s.kind === 'collapsed')
|
|
30
|
+
expect(working).toHaveLength(2) // two work days
|
|
31
|
+
expect(working[0]!.px).toBe(480)
|
|
32
|
+
// Gaps: before day5 9am, night between, after day6 5pm - each a collapsed 10px.
|
|
33
|
+
expect(collapsed.every((s) => s.px === 10)).toBe(true)
|
|
34
|
+
expect(axis.totalPx).toBe(480 * 2 + 10 * collapsed.length)
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('omits gaps entirely when collapsedGapPx is 0', () => {
|
|
38
|
+
const axis = buildAxis(day(5), day(6), {
|
|
39
|
+
pxPerMinute: 1,
|
|
40
|
+
businessHours: { start: 9, end: 17 },
|
|
41
|
+
collapseNonWorking: true,
|
|
42
|
+
collapsedGapPx: 0,
|
|
43
|
+
})
|
|
44
|
+
expect(axis.totalPx).toBe(480) // just the working span
|
|
45
|
+
})
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
describe('buildAxis - weekend collapse', () => {
|
|
49
|
+
it('collapses whole non-working days', () => {
|
|
50
|
+
// Jan 2026: 3rd = Sat, 4th = Sun. Range Fri Jan 2 -> Mon Jan 5.
|
|
51
|
+
const axis = buildAxis(day(2), day(5), {
|
|
52
|
+
pxPerMinute: 1,
|
|
53
|
+
nonWorkingDays: [0, 6],
|
|
54
|
+
collapseWeekends: true,
|
|
55
|
+
collapsedGapPx: 8,
|
|
56
|
+
})
|
|
57
|
+
const collapsed = axis.segments.filter((s) => s.kind === 'collapsed')
|
|
58
|
+
// Sat + Sun are contiguous non-working, so they coalesce to ONE 8px gap.
|
|
59
|
+
expect(collapsed).toHaveLength(1)
|
|
60
|
+
expect(collapsed[0]!.px).toBe(8)
|
|
61
|
+
expect(collapsed[0]!.start.getDate()).toBe(3) // Sat
|
|
62
|
+
expect(collapsed[0]!.end.getDate()).toBe(5) // through to Mon 00:00
|
|
63
|
+
// Fri (full day) + Mon-partial working stays full width.
|
|
64
|
+
const working = axis.segments.filter((s) => s.kind === 'working')
|
|
65
|
+
expect(working.reduce((sum, s) => sum + s.px, 0)).toBe(1440) // Fri = 1440px working (24h, no business hours)
|
|
66
|
+
})
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
describe('buildAxis - piecewise mapping across a collapsed gap', () => {
|
|
70
|
+
it('maps times on either side of a gap to the right x', () => {
|
|
71
|
+
const axis = buildAxis(day(5), day(7), {
|
|
72
|
+
pxPerMinute: 1,
|
|
73
|
+
businessHours: { start: 9, end: 17 },
|
|
74
|
+
collapseNonWorking: true,
|
|
75
|
+
collapsedGapPx: 10,
|
|
76
|
+
})
|
|
77
|
+
// End of day 5 work (17:00) sits at leading-gap + 480 working px.
|
|
78
|
+
const lead = axis.segments[0]!.px // leading collapsed gap (00:00-09:00)
|
|
79
|
+
expect(timeToX(day(5, 17), axis)).toBe(lead + 480)
|
|
80
|
+
// Start of day 6 work (09:00) sits one more gap past that.
|
|
81
|
+
expect(timeToX(day(6, 9), axis)).toBe(lead + 480 + 10)
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('round-trips a working-time point through x and back', () => {
|
|
85
|
+
const axis = buildAxis(day(5), day(7), {
|
|
86
|
+
pxPerMinute: 1,
|
|
87
|
+
businessHours: { start: 9, end: 17 },
|
|
88
|
+
collapseNonWorking: true,
|
|
89
|
+
collapsedGapPx: 10,
|
|
90
|
+
})
|
|
91
|
+
const t = day(6, 13)
|
|
92
|
+
expect(xToTime(timeToX(t, axis), axis).getTime()).toBe(t.getTime())
|
|
93
|
+
})
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
describe('resolveZoom', () => {
|
|
97
|
+
it('passes a ZoomLevel through and computes pxPerMinute', () => {
|
|
98
|
+
const level = zoomPresets.find((z) => z.id === 'hour')!
|
|
99
|
+
const r = resolveZoom(level)
|
|
100
|
+
expect(r.level.id).toBe('hour')
|
|
101
|
+
expect(r.pxPerMinute).toBeCloseTo(level.pxPerTick / level.tickMinutes)
|
|
102
|
+
})
|
|
103
|
+
it('treats a number as a clamped ladder index', () => {
|
|
104
|
+
expect(resolveZoom(0).level).toBe(zoomPresets[0])
|
|
105
|
+
expect(resolveZoom(999).level).toBe(zoomPresets[zoomPresets.length - 1])
|
|
106
|
+
expect(resolveZoom(-5).level).toBe(zoomPresets[0])
|
|
107
|
+
})
|
|
108
|
+
})
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* scheduler-axis - the pure core behind Scheduler Pro's *non-working-time
|
|
3
|
+
* collapse* and *continuous zoom*. No Svelte, no DOM.
|
|
4
|
+
*
|
|
5
|
+
* Instead of a single linear time<->pixel scale, the timeline axis becomes a
|
|
6
|
+
* PIECEWISE map: working spans keep a full pixel width (`pxPerMinute`), while
|
|
7
|
+
* non-working spans (nights / weekends) collapse to a thin fixed-width gap (or
|
|
8
|
+
* are omitted). {@link buildAxis} produces the ordered segments; {@link timeToX}
|
|
9
|
+
* / {@link xToTime} map across them. The renderer routes all event positioning
|
|
10
|
+
* and drag snapping through these so a collapsed axis stays consistent.
|
|
11
|
+
*
|
|
12
|
+
* Callers supply the range + working definition; this file never reads the clock.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const MS_MIN = 60_000
|
|
16
|
+
const MS_DAY = 86_400_000
|
|
17
|
+
|
|
18
|
+
export type AxisSegmentKind = 'working' | 'break' | 'collapsed'
|
|
19
|
+
|
|
20
|
+
/** One contiguous stretch of the axis with a resolved pixel width + offset. */
|
|
21
|
+
export type AxisSegment = {
|
|
22
|
+
start: Date
|
|
23
|
+
end: Date
|
|
24
|
+
/** Left offset in px from the axis origin. */
|
|
25
|
+
x: number
|
|
26
|
+
/** Rendered width in px (collapsed gaps get a fixed small width). */
|
|
27
|
+
px: number
|
|
28
|
+
kind: AxisSegmentKind
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export type Axis = {
|
|
32
|
+
start: Date
|
|
33
|
+
end: Date
|
|
34
|
+
segments: AxisSegment[]
|
|
35
|
+
totalPx: number
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export type BuildAxisOptions = {
|
|
39
|
+
/** Pixel width of one working minute. Default 0.5 (30px/hour). */
|
|
40
|
+
pxPerMinute?: number
|
|
41
|
+
/** Business-hours window in hours, e.g. `{ start: 9, end: 17 }`. Omit = 24h working. */
|
|
42
|
+
businessHours?: { start: number; end: number }
|
|
43
|
+
/** Weekday numbers (0 = Sun .. 6 = Sat) treated as non-working days. */
|
|
44
|
+
nonWorkingDays?: ReadonlyArray<number>
|
|
45
|
+
/** Collapse nights / breaks (time outside business hours) to a gap. */
|
|
46
|
+
collapseNonWorking?: boolean
|
|
47
|
+
/** Collapse whole non-working days (weekends) to a gap. */
|
|
48
|
+
collapseWeekends?: boolean
|
|
49
|
+
/** Width (px) of a collapsed gap marker. Default 12; 0 omits it entirely. */
|
|
50
|
+
collapsedGapPx?: number
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** True when `weekday` is a non-working day. */
|
|
54
|
+
function isNonWorkingDay(weekday: number, days: ReadonlyArray<number> | undefined): boolean {
|
|
55
|
+
return !!days && days.includes(weekday)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The working `[startMs, endMs)` intervals for the local day beginning at
|
|
60
|
+
* `dayStart`, from `businessHours` (or the whole day when omitted). Empty when the
|
|
61
|
+
* day is a non-working day.
|
|
62
|
+
*/
|
|
63
|
+
function workingSpansForDay(
|
|
64
|
+
dayStart: Date,
|
|
65
|
+
bh: { start: number; end: number } | undefined,
|
|
66
|
+
nonWorkingDays: ReadonlyArray<number> | undefined,
|
|
67
|
+
): Array<[number, number]> {
|
|
68
|
+
if (isNonWorkingDay(dayStart.getDay(), nonWorkingDays)) return []
|
|
69
|
+
const base = dayStart.getTime()
|
|
70
|
+
if (!bh) return [[base, base + MS_DAY]]
|
|
71
|
+
const s = base + bh.start * 60 * MS_MIN
|
|
72
|
+
const e = base + bh.end * 60 * MS_MIN
|
|
73
|
+
return e > s ? [[s, e]] : []
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Build the piecewise axis over `[rangeStart, rangeEnd]`. Walks day by day,
|
|
78
|
+
* emitting `working` segments (full width) interleaved with `break` (outside
|
|
79
|
+
* business hours) and whole non-working-day segments. A non-working segment is
|
|
80
|
+
* rendered `collapsed` (fixed gap px) when the matching collapse flag is on, else
|
|
81
|
+
* kept at full width with kind `break`.
|
|
82
|
+
*/
|
|
83
|
+
export function buildAxis(rangeStart: Date, rangeEnd: Date, opts: BuildAxisOptions = {}): Axis {
|
|
84
|
+
const pxPerMinute = opts.pxPerMinute ?? 0.5
|
|
85
|
+
const gapPx = opts.collapsedGapPx ?? 12
|
|
86
|
+
const pxOfMs = (ms: number) => (ms / MS_MIN) * pxPerMinute
|
|
87
|
+
|
|
88
|
+
// Collect all working spans across the range, clipped to it.
|
|
89
|
+
const rangeS = rangeStart.getTime()
|
|
90
|
+
const rangeE = rangeEnd.getTime()
|
|
91
|
+
const working: Array<[number, number]> = []
|
|
92
|
+
let day = new Date(rangeStart.getFullYear(), rangeStart.getMonth(), rangeStart.getDate())
|
|
93
|
+
// Guard: never loop forever; ranges are bounded (days count).
|
|
94
|
+
let guard = 0
|
|
95
|
+
while (day.getTime() < rangeE && guard++ < 4000) {
|
|
96
|
+
for (const [s, e] of workingSpansForDay(day, opts.businessHours, opts.nonWorkingDays)) {
|
|
97
|
+
const cs = Math.max(s, rangeS)
|
|
98
|
+
const ce = Math.min(e, rangeE)
|
|
99
|
+
if (ce > cs) working.push([cs, ce])
|
|
100
|
+
}
|
|
101
|
+
day = new Date(day.getTime() + MS_DAY)
|
|
102
|
+
}
|
|
103
|
+
working.sort((a, b) => a[0] - b[0])
|
|
104
|
+
|
|
105
|
+
// Weave working spans with the non-working gaps between them (and the edges).
|
|
106
|
+
// Build raw segments (x assigned in a finalize pass so we can coalesce first).
|
|
107
|
+
type Raw = { start: number; end: number; px: number; kind: AxisSegmentKind }
|
|
108
|
+
const raw: Raw[] = []
|
|
109
|
+
let cursor = rangeS
|
|
110
|
+
const pushSegment = (s: number, e: number, kind: AxisSegmentKind) => {
|
|
111
|
+
if (e <= s) return
|
|
112
|
+
if (kind === 'working') {
|
|
113
|
+
raw.push({ start: s, end: e, px: pxOfMs(e - s), kind })
|
|
114
|
+
return
|
|
115
|
+
}
|
|
116
|
+
// non-working: split into per-day pieces so a whole non-working DAY (weekend)
|
|
117
|
+
// collapses per `collapseWeekends` while a night / break collapses per
|
|
118
|
+
// `collapseNonWorking`. Consecutive collapsed pieces are merged below.
|
|
119
|
+
let a = s
|
|
120
|
+
while (a < e) {
|
|
121
|
+
const d = new Date(a)
|
|
122
|
+
const dayStart = new Date(d.getFullYear(), d.getMonth(), d.getDate())
|
|
123
|
+
const b = Math.min(e, dayStart.getTime() + MS_DAY)
|
|
124
|
+
const wholeDayOff = isNonWorkingDay(dayStart.getDay(), opts.nonWorkingDays)
|
|
125
|
+
const collapse = wholeDayOff ? opts.collapseWeekends : opts.collapseNonWorking
|
|
126
|
+
if (collapse) raw.push({ start: a, end: b, px: gapPx > 0 ? gapPx : 0, kind: 'collapsed' })
|
|
127
|
+
else raw.push({ start: a, end: b, px: pxOfMs(b - a), kind: 'break' })
|
|
128
|
+
a = b
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
for (const [ws, we] of working) {
|
|
133
|
+
if (ws > cursor) pushSegment(cursor, ws, 'break') // non-working gap before this span
|
|
134
|
+
pushSegment(ws, we, 'working')
|
|
135
|
+
cursor = we
|
|
136
|
+
}
|
|
137
|
+
if (cursor < rangeE) pushSegment(cursor, rangeE, 'break') // trailing non-working tail
|
|
138
|
+
|
|
139
|
+
// Coalesce consecutive collapsed pieces into one gap marker (a single collapsed
|
|
140
|
+
// stretch renders as one "gap" of `gapPx`, not one per calendar day), then
|
|
141
|
+
// assign cumulative x offsets.
|
|
142
|
+
const segments: AxisSegment[] = []
|
|
143
|
+
let x = 0
|
|
144
|
+
for (const r of raw) {
|
|
145
|
+
const prev = segments[segments.length - 1]
|
|
146
|
+
if (r.kind === 'collapsed' && prev && prev.kind === 'collapsed') {
|
|
147
|
+
prev.end = new Date(r.end) // extend the existing gap; px stays fixed
|
|
148
|
+
continue
|
|
149
|
+
}
|
|
150
|
+
segments.push({ start: new Date(r.start), end: new Date(r.end), x, px: r.px, kind: r.kind })
|
|
151
|
+
x += r.px
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return { start: rangeStart, end: rangeEnd, segments, totalPx: x }
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Find the segment containing `t` (by time), or null when outside the axis. */
|
|
158
|
+
function segmentAt(axis: Axis, t: number): AxisSegment | null {
|
|
159
|
+
for (const seg of axis.segments) {
|
|
160
|
+
if (t >= seg.start.getTime() && t < seg.end.getTime()) return seg
|
|
161
|
+
}
|
|
162
|
+
// Exact end-of-axis maps to the last segment's trailing edge.
|
|
163
|
+
const last = axis.segments[axis.segments.length - 1]
|
|
164
|
+
if (last && t === last.end.getTime()) return last
|
|
165
|
+
return null
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Map a time to an x offset (px) across the piecewise axis. Clamps to [0,totalPx]. */
|
|
169
|
+
export function timeToX(date: Date, axis: Axis): number {
|
|
170
|
+
const t = date.getTime()
|
|
171
|
+
if (t <= axis.start.getTime()) return 0
|
|
172
|
+
if (t >= axis.end.getTime()) return axis.totalPx
|
|
173
|
+
const seg = segmentAt(axis, t)
|
|
174
|
+
if (!seg) return 0
|
|
175
|
+
const span = seg.end.getTime() - seg.start.getTime()
|
|
176
|
+
const frac = span > 0 ? (t - seg.start.getTime()) / span : 0
|
|
177
|
+
return seg.x + frac * seg.px
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Map an x offset (px) back to a time across the piecewise axis. */
|
|
181
|
+
export function xToTime(x: number, axis: Axis): Date {
|
|
182
|
+
if (x <= 0) return new Date(axis.start.getTime())
|
|
183
|
+
if (x >= axis.totalPx) return new Date(axis.end.getTime())
|
|
184
|
+
for (const seg of axis.segments) {
|
|
185
|
+
if (x >= seg.x && x < seg.x + seg.px) {
|
|
186
|
+
const span = seg.end.getTime() - seg.start.getTime()
|
|
187
|
+
const frac = seg.px > 0 ? (x - seg.x) / seg.px : 0
|
|
188
|
+
return new Date(seg.start.getTime() + frac * span)
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
return new Date(axis.end.getTime())
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// --- zoom -------------------------------------------------------------------
|
|
195
|
+
|
|
196
|
+
export type ZoomMajorUnit = 'hour' | 'day' | 'week' | 'month' | 'quarter' | 'year'
|
|
197
|
+
|
|
198
|
+
/** One zoom preset: tick size + pixel density + the coarser grouping unit. */
|
|
199
|
+
export type ZoomLevel = {
|
|
200
|
+
id: string
|
|
201
|
+
label: string
|
|
202
|
+
/** Minutes per minor tick. */
|
|
203
|
+
tickMinutes: number
|
|
204
|
+
/** Pixel width of one minor tick. */
|
|
205
|
+
pxPerTick: number
|
|
206
|
+
majorUnit: ZoomMajorUnit
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** The built-in zoom ladder, most zoomed-in first (minute detail -> months). */
|
|
210
|
+
export const zoomPresets: ZoomLevel[] = [
|
|
211
|
+
{ id: '5min', label: '5 min', tickMinutes: 5, pxPerTick: 24, majorUnit: 'hour' },
|
|
212
|
+
{ id: '15min', label: '15 min', tickMinutes: 15, pxPerTick: 30, majorUnit: 'hour' },
|
|
213
|
+
{ id: '30min', label: '30 min', tickMinutes: 30, pxPerTick: 40, majorUnit: 'day' },
|
|
214
|
+
{ id: 'hour', label: 'Hourly', tickMinutes: 60, pxPerTick: 50, majorUnit: 'day' },
|
|
215
|
+
{ id: '3hour', label: '3 hours', tickMinutes: 180, pxPerTick: 44, majorUnit: 'day' },
|
|
216
|
+
{ id: 'day', label: 'Daily', tickMinutes: 1440, pxPerTick: 64, majorUnit: 'week' },
|
|
217
|
+
{ id: 'week', label: 'Weekly', tickMinutes: 10_080, pxPerTick: 80, majorUnit: 'month' },
|
|
218
|
+
{ id: 'month', label: 'Monthly', tickMinutes: 43_200, pxPerTick: 72, majorUnit: 'quarter' },
|
|
219
|
+
]
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Resolve `zoom` to a {@link ZoomLevel}. A `ZoomLevel` passes through; a number is
|
|
223
|
+
* an index into `ladder` (clamped). Returns the pixel density too via
|
|
224
|
+
* `pxPerMinute = pxPerTick / tickMinutes` (use it as `buildAxis`'s `pxPerMinute`).
|
|
225
|
+
*/
|
|
226
|
+
export function resolveZoom(
|
|
227
|
+
zoom: number | ZoomLevel,
|
|
228
|
+
ladder: ReadonlyArray<ZoomLevel> = zoomPresets,
|
|
229
|
+
): { level: ZoomLevel; pxPerMinute: number } {
|
|
230
|
+
let level: ZoomLevel
|
|
231
|
+
if (typeof zoom === 'number') {
|
|
232
|
+
const i = Math.max(0, Math.min(ladder.length - 1, Math.round(zoom)))
|
|
233
|
+
level = ladder[i]!
|
|
234
|
+
} else {
|
|
235
|
+
level = zoom
|
|
236
|
+
}
|
|
237
|
+
return { level, pxPerMinute: level.pxPerTick / level.tickMinutes }
|
|
238
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { afterLead, respectsBuffer, withinDurationBounds, type BusyEvent } from './scheduler-booking'
|
|
3
|
+
|
|
4
|
+
const D = (h: number, m = 0) => new Date(2026, 0, 5, h, m, 0, 0)
|
|
5
|
+
const ev = (key: string, sh: number, eh: number, data?: unknown): BusyEvent => ({ key, start: D(sh), end: D(eh), data })
|
|
6
|
+
|
|
7
|
+
describe('respectsBuffer', () => {
|
|
8
|
+
const others = [ev('a', 9, 10), ev('b', 13, 14)]
|
|
9
|
+
|
|
10
|
+
it('passes when the gap before and after is large enough', () => {
|
|
11
|
+
// book 11:00-12:00: 60 min after a(ends 10), 60 min before b(starts 13) -> ok for 30/30 buffers
|
|
12
|
+
expect(respectsBuffer(D(11), D(12), others, 30, 30)).toBe(true)
|
|
13
|
+
})
|
|
14
|
+
it('rejects a direct overlap', () => {
|
|
15
|
+
expect(respectsBuffer(D(9, 30), D(10, 30), others, 0, 0)).toBe(false)
|
|
16
|
+
})
|
|
17
|
+
it('rejects when the gap before is too small', () => {
|
|
18
|
+
// book 10:15-11:00: only 15 min after a(ends 10); need 30 -> reject
|
|
19
|
+
expect(respectsBuffer(D(10, 15), D(11), others, 30, 30)).toBe(false)
|
|
20
|
+
})
|
|
21
|
+
it('rejects when the gap after is too small', () => {
|
|
22
|
+
// book 12:00-12:45: only 15 min before b(starts 13); need 30 -> reject
|
|
23
|
+
expect(respectsBuffer(D(12), D(12, 45), others, 30, 30)).toBe(false)
|
|
24
|
+
})
|
|
25
|
+
it('excludes the moving booking by key', () => {
|
|
26
|
+
const set = [ev('self', 11, 12), ev('a', 9, 10)]
|
|
27
|
+
// moving self to 10:00-10:30 would clash with its own old slot unless excluded
|
|
28
|
+
expect(respectsBuffer(D(10), D(10, 30), set, 0, 0, 'self')).toBe(true)
|
|
29
|
+
})
|
|
30
|
+
it('supports a per-neighbour gap (travel time by location)', () => {
|
|
31
|
+
const jobs = [ev('a', 9, 10, 'north'), ev('b', 13, 14, 'south')]
|
|
32
|
+
const travel = (n: BusyEvent) => (n.data === 'south' ? 90 : 20) // 90 min to/from south
|
|
33
|
+
// book 11:00-12:00: 60 min before b(south) but travel needs 90 -> reject
|
|
34
|
+
expect(respectsBuffer(D(11), D(12), jobs, travel, travel)).toBe(false)
|
|
35
|
+
// 11:00-11:30 leaves 90 before b -> ok
|
|
36
|
+
expect(respectsBuffer(D(11), D(11, 30), jobs, travel, travel)).toBe(true)
|
|
37
|
+
})
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
describe('withinDurationBounds', () => {
|
|
41
|
+
it('enforces min and max minutes', () => {
|
|
42
|
+
expect(withinDurationBounds(D(9), D(9, 30), 30, 90)).toBe(true)
|
|
43
|
+
expect(withinDurationBounds(D(9), D(9, 15), 30, 90)).toBe(false) // too short
|
|
44
|
+
expect(withinDurationBounds(D(9), D(11), 30, 90)).toBe(false) // too long
|
|
45
|
+
})
|
|
46
|
+
it('treats undefined bounds as unbounded', () => {
|
|
47
|
+
expect(withinDurationBounds(D(9), D(15), undefined, undefined)).toBe(true)
|
|
48
|
+
})
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
describe('afterLead', () => {
|
|
52
|
+
it('requires the start to be at least leadMin after now', () => {
|
|
53
|
+
const now = D(10)
|
|
54
|
+
expect(afterLead(D(12), now, 120)).toBe(true) // exactly 120 min ahead
|
|
55
|
+
expect(afterLead(D(11), now, 120)).toBe(false) // only 60 min ahead
|
|
56
|
+
})
|
|
57
|
+
})
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* scheduler-booking - pure booking-rule helpers for Scheduler Pro: buffers /
|
|
3
|
+
* travel time between bookings, minimum lead time, and duration bounds. No Svelte,
|
|
4
|
+
* no DOM; the renderer folds these into its `bookingBlocked` guard. Callers supply
|
|
5
|
+
* "now" (never `Date.now()` here) so the rules stay deterministic + unit-testable.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const MS_MIN = 60_000
|
|
9
|
+
|
|
10
|
+
/** A booked interval on a resource, keyed for exclusion, carrying optional data
|
|
11
|
+
* (e.g. a location) so a per-neighbour gap function can read it. */
|
|
12
|
+
export type BusyEvent<T = unknown> = { start: Date; end: Date; key: string; data?: T }
|
|
13
|
+
|
|
14
|
+
/** Minutes of gap required before/after, either a flat number or a function of the
|
|
15
|
+
* neighbouring booking (used for travel time between locations). */
|
|
16
|
+
export type GapMin<T = unknown> = number | ((neighbour: BusyEvent<T>) => number)
|
|
17
|
+
|
|
18
|
+
const gapOf = <T>(g: GapMin<T>, ev: BusyEvent<T>): number => (typeof g === 'function' ? g(ev) : g)
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* True when placing `[start, end]` keeps at least the required gap from every other
|
|
22
|
+
* booking in `events` (same resource): `gapBefore` minutes clear of the previous
|
|
23
|
+
* booking's end and `gapAfter` minutes clear of the next booking's start. A direct
|
|
24
|
+
* overlap always fails. `gapBefore` / `gapAfter` may be functions of the neighbour
|
|
25
|
+
* for travel time. `excludeKey` skips the booking being moved.
|
|
26
|
+
*/
|
|
27
|
+
export function respectsBuffer<T = unknown>(
|
|
28
|
+
start: Date,
|
|
29
|
+
end: Date,
|
|
30
|
+
events: ReadonlyArray<BusyEvent<T>>,
|
|
31
|
+
gapBefore: GapMin<T>,
|
|
32
|
+
gapAfter: GapMin<T>,
|
|
33
|
+
excludeKey?: string,
|
|
34
|
+
): boolean {
|
|
35
|
+
const s = start.getTime()
|
|
36
|
+
const e = end.getTime()
|
|
37
|
+
for (const ev of events) {
|
|
38
|
+
if (ev.key === excludeKey) continue
|
|
39
|
+
const es = ev.start.getTime()
|
|
40
|
+
const ee = ev.end.getTime()
|
|
41
|
+
if (s < ee && e > es) return false // direct overlap
|
|
42
|
+
if (ee <= s) {
|
|
43
|
+
if (s - ee < gapOf(gapBefore, ev) * MS_MIN) return false
|
|
44
|
+
} else if (es >= e) {
|
|
45
|
+
if (es - e < gapOf(gapAfter, ev) * MS_MIN) return false
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return true
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** True when the booking's duration is within `[minMin, maxMin]` (either bound may
|
|
52
|
+
* be undefined = unbounded). */
|
|
53
|
+
export function withinDurationBounds(start: Date, end: Date, minMin?: number, maxMin?: number): boolean {
|
|
54
|
+
const dur = (end.getTime() - start.getTime()) / MS_MIN
|
|
55
|
+
if (minMin != null && dur < minMin - 1e-9) return false
|
|
56
|
+
if (maxMin != null && dur > maxMin + 1e-9) return false
|
|
57
|
+
return true
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** True when the booking starts at least `leadMin` minutes after `now` (min notice). */
|
|
61
|
+
export function afterLead(start: Date, now: Date, leadMin: number): boolean {
|
|
62
|
+
return start.getTime() >= now.getTime() + leadMin * MS_MIN
|
|
63
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SchedulerProConfig - the Enterprise ("Scheduler Pro") superset of the free grid
|
|
3
|
+
* {@link SchedulerConfig}. The base scheduler prop + its config type ship in
|
|
4
|
+
* `@svgrid/grid`; the Pro renderer (SvGridScheduler) reads these extra fields at
|
|
5
|
+
* runtime (the renderer is registered untyped, so nothing in the free grid needs
|
|
6
|
+
* to know about them). Consumers using Pro features type their config as
|
|
7
|
+
* `SchedulerProConfig` - it is structurally assignable to the `scheduler` prop.
|
|
8
|
+
*
|
|
9
|
+
* All three feature waves live here:
|
|
10
|
+
* - Wave A: event dependencies + auto-reschedule
|
|
11
|
+
* - Wave B: multi-assignment + resource histogram + column summary
|
|
12
|
+
* - Wave C: non-working-time collapse + continuous zoom
|
|
13
|
+
*/
|
|
14
|
+
import type {
|
|
15
|
+
SchedulerConfig,
|
|
16
|
+
SchedulerResource,
|
|
17
|
+
TableFeatures,
|
|
18
|
+
RowData,
|
|
19
|
+
} from '@svgrid/grid'
|
|
20
|
+
import type { SchedulerDependency } from './scheduler-dependencies'
|
|
21
|
+
import type { SchedulerAssignment } from './scheduler-assignments'
|
|
22
|
+
import type { ColumnReducer } from './scheduler-summary'
|
|
23
|
+
import type { ZoomLevel } from './scheduler-axis'
|
|
24
|
+
import type { SchedulerResourceGroup } from './scheduler-resource-tree'
|
|
25
|
+
|
|
26
|
+
export type SchedulerProConfig<
|
|
27
|
+
TFeatures extends TableFeatures = TableFeatures,
|
|
28
|
+
TData extends RowData = RowData,
|
|
29
|
+
> = SchedulerConfig<TFeatures, TData> & {
|
|
30
|
+
// --- Wave A: dependencies + auto-reschedule --------------------------------
|
|
31
|
+
/**
|
|
32
|
+
* Event dependency links (predecessor -> successor). `from` / `to` are row ids
|
|
33
|
+
* (the grid's `getRowId`). Draw arrows between linked events and, with
|
|
34
|
+
* `autoReschedule`, push successors forward when a predecessor moves.
|
|
35
|
+
*/
|
|
36
|
+
dependencies?: ReadonlyArray<SchedulerDependency>;
|
|
37
|
+
/**
|
|
38
|
+
* Per-row field holding this row's dependency links (as `SchedulerDependency[]`
|
|
39
|
+
* or a `to`-key list). An alternative to the flat `dependencies` list.
|
|
40
|
+
*/
|
|
41
|
+
dependencyField?: keyof TData & string;
|
|
42
|
+
/**
|
|
43
|
+
* Cascade successors forward on move / resize to keep every link legal.
|
|
44
|
+
* Defaults to `true` when any dependency is present.
|
|
45
|
+
*/
|
|
46
|
+
autoReschedule?: boolean;
|
|
47
|
+
/** When cascading, skip non-working intervals (business hours / shifts). */
|
|
48
|
+
dependencyRespectWorkingTime?: boolean;
|
|
49
|
+
/** Fired with the cascaded shifts after a move / resize (never mutates rows). */
|
|
50
|
+
onDependenciesChange?: (moves: Array<{ id: string; start: Date; end: Date }>) => void;
|
|
51
|
+
/** Fired when the user draws a new link (drag from one event edge onto another). */
|
|
52
|
+
onDependencyAdd?: (dep: SchedulerDependency) => void;
|
|
53
|
+
/** Fired when the user removes a link (arrow context menu). */
|
|
54
|
+
onDependencyRemove?: (id: string) => void;
|
|
55
|
+
|
|
56
|
+
// --- Grouped / tree resources (timeline) -----------------------------------
|
|
57
|
+
/**
|
|
58
|
+
* Group the timeline's resource rows into a collapsible tree. `resourceGroups`
|
|
59
|
+
* defines the group nodes (nest via `parentId`); `resourceGroupOf` maps each
|
|
60
|
+
* resource to its group id. Resources with no / unknown group trail ungrouped.
|
|
61
|
+
*/
|
|
62
|
+
resourceGroups?: ReadonlyArray<SchedulerResourceGroup>;
|
|
63
|
+
resourceGroupOf?: (resource: SchedulerResource) => string | undefined;
|
|
64
|
+
/** Allow collapsing group headers (default true when `resourceGroups` is set). */
|
|
65
|
+
collapsibleGroups?: boolean;
|
|
66
|
+
/** localStorage key to persist which groups are collapsed. */
|
|
67
|
+
groupPersistKey?: string;
|
|
68
|
+
|
|
69
|
+
// --- Eligibility / skill matching (a booking rule) --------------------------
|
|
70
|
+
/**
|
|
71
|
+
* Restrict which events may be placed on which resources (provider matching,
|
|
72
|
+
* room type, technician skills). Return `false` and a drag / resize / create
|
|
73
|
+
* onto that resource is rejected + a "Not eligible" flash. The most flexible
|
|
74
|
+
* form; takes precedence over the declarative `requiresField` shortcut.
|
|
75
|
+
*/
|
|
76
|
+
eligible?: (event: TData, resource: SchedulerResource) => boolean;
|
|
77
|
+
/**
|
|
78
|
+
* Declarative shortcut: a per-event field holding the required skill tag(s).
|
|
79
|
+
* An event is eligible for a resource only if the resource's skills (via
|
|
80
|
+
* `resourceSkillsOf`, or a `skills` array on the resource) include every tag.
|
|
81
|
+
*/
|
|
82
|
+
requiresField?: keyof TData & string;
|
|
83
|
+
/** Where a resource's skill tags come from (default: a `skills` array on it). */
|
|
84
|
+
resourceSkillsOf?: (resource: SchedulerResource) => ReadonlyArray<string> | undefined;
|
|
85
|
+
|
|
86
|
+
// --- Booking-rule depth (buffers / lead time / duration / travel) ----------
|
|
87
|
+
/** Required clear minutes before / after each booking on a resource (a flat
|
|
88
|
+
* number, or a function of the event). A move / create that breaks it is rejected. */
|
|
89
|
+
bufferBeforeMin?: number | ((event: TData) => number);
|
|
90
|
+
bufferAfterMin?: number | ((event: TData) => number);
|
|
91
|
+
/** Minimum notice: a booking must start at least this many minutes after "now". */
|
|
92
|
+
minLeadMin?: number;
|
|
93
|
+
/** Min / max booking duration in minutes. */
|
|
94
|
+
minDurationMin?: number;
|
|
95
|
+
maxDurationMin?: number;
|
|
96
|
+
/** Travel time (minutes) required between two consecutive bookings on the same
|
|
97
|
+
* resource - widens the buffer by the trip between their locations. */
|
|
98
|
+
travelTimeOf?: (from: TData, to: TData) => number;
|
|
99
|
+
|
|
100
|
+
// --- Bookable slots / find-a-time (Calendly-style) --------------------------
|
|
101
|
+
/**
|
|
102
|
+
* Show the open, bookable slots of a fixed duration per resource in the Day
|
|
103
|
+
* timeline - the free time left after existing bookings, buffers and lead time.
|
|
104
|
+
* Clicking a slot books it (via `onSlotPick`, else `onEventAdd`).
|
|
105
|
+
*/
|
|
106
|
+
bookable?: { durationMin: number; stepMin?: number };
|
|
107
|
+
/** Fired when a bookable slot is clicked. Falls back to `onEventAdd`. */
|
|
108
|
+
onSlotPick?: (start: Date, end: Date, resourceId?: string) => void;
|
|
109
|
+
|
|
110
|
+
// --- Multi-calendar overlay -------------------------------------------------
|
|
111
|
+
/**
|
|
112
|
+
* Overlay several calendars, each colour-coded and toggleable from a legend.
|
|
113
|
+
* Events map to a calendar via `calendarField`; a calendar's colour becomes the
|
|
114
|
+
* event's default (unless `colorField` overrides), and hiding a calendar filters
|
|
115
|
+
* its events out of every view.
|
|
116
|
+
*/
|
|
117
|
+
calendars?: ReadonlyArray<{ id: string; title: string; color?: string; hidden?: boolean }>;
|
|
118
|
+
calendarField?: keyof TData & string;
|
|
119
|
+
|
|
120
|
+
// --- Free/busy availability (for find-a-time) -------------------------------
|
|
121
|
+
/**
|
|
122
|
+
* External busy intervals to shade on a resource's timeline row - times it is
|
|
123
|
+
* unavailable that aren't events in this scheduler (its own calendar). Combine
|
|
124
|
+
* with the exported `commonFree` helper to find a meeting time across attendees.
|
|
125
|
+
*/
|
|
126
|
+
freeBusyOf?: (resource: SchedulerResource) => ReadonlyArray<{ start: Date; end: Date }>;
|
|
127
|
+
|
|
128
|
+
// --- Wave B: multi-assignment + summaries ----------------------------------
|
|
129
|
+
/**
|
|
130
|
+
* Many-to-many event <-> resource assignments. When set, one event can appear
|
|
131
|
+
* under several resources; drives the timeline / resource-column bucketing
|
|
132
|
+
* instead of the single `resourceField`.
|
|
133
|
+
*/
|
|
134
|
+
assignments?: ReadonlyArray<SchedulerAssignment>;
|
|
135
|
+
/** Per-row field holding an array of resource ids (alt to the flat `assignments`). */
|
|
136
|
+
assignmentField?: keyof TData & string;
|
|
137
|
+
/**
|
|
138
|
+
* Show a per-resource utilization histogram band (timeline views). `true` uses
|
|
139
|
+
* defaults; the object form sets a capacity field + band height (px).
|
|
140
|
+
*/
|
|
141
|
+
resourceHistogram?: boolean | { capacityField?: keyof SchedulerResource & string; height?: number };
|
|
142
|
+
/**
|
|
143
|
+
* Tint each resource row's BACKGROUND by load per time-bucket (a utilization
|
|
144
|
+
* heatmap) - distinct from the histogram bar strip. `true` uses defaults; the
|
|
145
|
+
* object form sets the capacity field (over that = a hot / red cell).
|
|
146
|
+
*/
|
|
147
|
+
utilizationHeatmap?: boolean | { capacityField?: keyof SchedulerResource & string };
|
|
148
|
+
/**
|
|
149
|
+
* A per-time-column summary strip (counts / sums / a custom reducer) aligned to
|
|
150
|
+
* the axis. `false` (default) hides it.
|
|
151
|
+
*/
|
|
152
|
+
columnSummary?: false | {
|
|
153
|
+
reducer: ColumnReducer<TData>;
|
|
154
|
+
label?: string;
|
|
155
|
+
position?: 'top' | 'bottom';
|
|
156
|
+
};
|
|
157
|
+
/** Fired when an event is dragged from one resource column to another (reassign). */
|
|
158
|
+
onAssignmentChange?: (change: { eventId: string; from?: string; to?: string }) => void;
|
|
159
|
+
|
|
160
|
+
// --- Wave C: axis collapse + zoom ------------------------------------------
|
|
161
|
+
/** Compress non-working hours out of the time axis (nights collapse to a gap). */
|
|
162
|
+
collapseNonWorking?: boolean;
|
|
163
|
+
/** Compress `nonWorkingDays` (e.g. weekends) out of the timeline axis. */
|
|
164
|
+
collapseWeekends?: boolean;
|
|
165
|
+
/** Width (px) of a collapsed-gap marker. Default 12; `0` omits it entirely. */
|
|
166
|
+
collapsedGapPx?: number;
|
|
167
|
+
/**
|
|
168
|
+
* Minimum width (px) of one timeline minor tick (a day in Week / Month). Widens
|
|
169
|
+
* the timeline - and scrolls it horizontally - so narrow, sub-day events (e.g.
|
|
170
|
+
* hour-scale shifts) have room for their label. Overrides the per-view default.
|
|
171
|
+
*/
|
|
172
|
+
timelineTickMinWidth?: number;
|
|
173
|
+
/** Continuous zoom preset - supersedes `slotSizes` when set (minute .. year). */
|
|
174
|
+
zoom?: number | ZoomLevel;
|
|
175
|
+
/** Custom zoom-preset ladder (replaces the built-in one). */
|
|
176
|
+
zoomLevels?: ReadonlyArray<ZoomLevel>;
|
|
177
|
+
/** Fired when the zoom level changes (slider / wheel). */
|
|
178
|
+
onZoomChange?: (level: ZoomLevel) => void;
|
|
179
|
+
}
|