@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.
Files changed (126) hide show
  1. package/README.md +95 -81
  2. package/dist/cdn/svgrid-enterprise.svelte-external.js +26642 -11252
  3. package/dist/designer/assets/GridMenus-BtWVk9Ab.js +7 -0
  4. package/dist/designer/assets/SvGridChartPanel-DWlBO2SD.js +10 -0
  5. package/dist/designer/assets/SvGridChartView-8c8Mb4j8.js +1 -0
  6. package/dist/designer/assets/SvGridChartView-DX9HfkBR.css +1 -0
  7. package/dist/designer/assets/index-DsDgp9Xq.js +78758 -0
  8. package/dist/designer/assets/index-tTY_Dx4P.css +1 -0
  9. package/dist/designer/assets/jszip.min-fkJdmAmj.js +2 -0
  10. package/dist/designer/assets/pdfmake-DeCsnyl9.js +242 -0
  11. package/dist/designer/assets/smart.export-BZlSCE8T.js +35 -0
  12. package/dist/designer/assets/vfs_fonts-eX2NpmfX.js +1 -0
  13. package/dist/designer/index.html +13 -0
  14. package/dist/node/studio.js +15304 -2708
  15. package/package.json +35 -11
  16. package/src/SvAlertRuleEditor.svelte +294 -0
  17. package/src/SvAlertsManager.svelte +210 -0
  18. package/src/SvAlertsPanel.svelte +129 -0
  19. package/src/SvBoard.svelte +3 -1
  20. package/src/SvExpressionEditor.svelte +341 -0
  21. package/src/SvGridAlerts.dom.test.ts +113 -0
  22. package/src/SvGridAlerts.svelte +265 -0
  23. package/src/SvGridBoard.svelte +2358 -0
  24. package/src/SvGridEditPanel.svelte +849 -794
  25. package/src/SvGridScheduler.svelte +5358 -4410
  26. package/src/SvPivotDesigner.svelte +3 -3
  27. package/src/SvRecordDetail.svelte +6 -2
  28. package/src/SvSchedule.svelte +3 -1
  29. package/src/{ai-export-pdf.test.ts → ai-export-pdf.dom.test.ts} +77 -74
  30. package/src/{ai-export-xlsx.test.ts → ai-export-xlsx.dom.test.ts} +90 -87
  31. package/src/{ai-export.test.ts → ai-export.dom.test.ts} +114 -110
  32. package/src/alerts/alert-engine-attach.ts +165 -0
  33. package/src/alerts/alert-engine.test.ts +135 -0
  34. package/src/alerts/alert-engine.ts +260 -0
  35. package/src/alerts/alert-formats.test.ts +87 -0
  36. package/src/alerts/alert-formats.ts +77 -0
  37. package/src/alerts/alert-observer.test.ts +189 -0
  38. package/src/alerts/alert-observer.ts +208 -0
  39. package/src/alerts/alert-scheduler.ts +96 -0
  40. package/src/alerts/alert-storage.test.ts +54 -0
  41. package/src/alerts/alert-storage.ts +116 -0
  42. package/src/alerts/alert-store.svelte.ts +80 -0
  43. package/src/alerts/alert-types.ts +94 -0
  44. package/src/alerts.ts +28 -0
  45. package/src/board.dom.test.ts +941 -0
  46. package/src/board.ts +36 -0
  47. package/src/export-ooxml.ts +4 -0
  48. package/src/export-xls.ts +3 -0
  49. package/src/export.ts +6 -0
  50. package/src/expressions/evaluate.test.ts +111 -0
  51. package/src/expressions/evaluate.ts +237 -0
  52. package/src/expressions/expression-columns.ts +133 -0
  53. package/src/expressions/expression-types.ts +84 -0
  54. package/src/expressions/parse.test.ts +106 -0
  55. package/src/expressions/parse.ts +610 -0
  56. package/src/import.test.ts +1 -1
  57. package/src/import.ts +3 -1
  58. package/src/index.ts +192 -46
  59. package/src/install.ts +12 -2
  60. package/src/pivot-enable.ts +44 -0
  61. package/src/pivot.test.ts +0 -1
  62. package/src/scheduler-assignments.test.ts +97 -0
  63. package/src/scheduler-assignments.ts +134 -0
  64. package/src/scheduler-axis.test.ts +108 -0
  65. package/src/scheduler-axis.ts +238 -0
  66. package/src/scheduler-booking.test.ts +57 -0
  67. package/src/scheduler-booking.ts +63 -0
  68. package/src/scheduler-config.ts +179 -0
  69. package/src/scheduler-dependencies.test.ts +155 -0
  70. package/src/scheduler-dependencies.ts +223 -0
  71. package/src/scheduler-freebusy.test.ts +41 -0
  72. package/src/scheduler-freebusy.ts +36 -0
  73. package/src/scheduler-heatmap.test.ts +39 -0
  74. package/src/scheduler-heatmap.ts +55 -0
  75. package/src/scheduler-resource-tree.test.ts +84 -0
  76. package/src/scheduler-resource-tree.ts +107 -0
  77. package/src/scheduler-slots.test.ts +53 -0
  78. package/src/scheduler-slots.ts +94 -0
  79. package/src/scheduler-summary.test.ts +47 -0
  80. package/src/scheduler-summary.ts +81 -0
  81. package/src/schema-designer.ts +1 -1
  82. package/src/sources/index.ts +1 -1
  83. package/src/sources/introspect-supabase.test.ts +13 -1
  84. package/src/sources/introspect-supabase.ts +20 -0
  85. package/src/studio/copilot-core.test.ts +45 -0
  86. package/src/studio/copilot-core.ts +65 -0
  87. package/src/studio/deploy-cli.test.ts +56 -0
  88. package/src/studio/deploy-cli.ts +100 -0
  89. package/src/studio/emit-project.test.ts +800 -24
  90. package/src/studio/emit-project.ts +1307 -135
  91. package/src/studio/emit-schema.test.ts +17 -0
  92. package/src/studio/emit-schema.ts +182 -41
  93. package/src/studio/index.ts +59 -1
  94. package/src/studio/init-flow.test.ts +239 -0
  95. package/src/studio/init-flow.ts +358 -0
  96. package/src/studio/introspect-openapi.test.ts +84 -0
  97. package/src/studio/introspect-openapi.ts +252 -0
  98. package/src/studio/project.test.ts +57 -1
  99. package/src/studio/project.ts +302 -8
  100. package/src/studio/samples/crm.ts +282 -258
  101. package/src/studio/samples/datasets.test.ts +84 -0
  102. package/src/studio/samples/datasets.ts +340 -0
  103. package/src/studio/samples/fleet.ts +213 -186
  104. package/src/studio/samples/insurance.ts +223 -195
  105. package/src/studio/samples/inventory.ts +213 -182
  106. package/src/studio/samples/live-data.test.ts +99 -98
  107. package/src/studio/samples/live-data.ts +8 -10
  108. package/src/studio/samples/projects.ts +212 -190
  109. package/src/studio/samples/samples.test.ts +268 -216
  110. package/src/studio/samples/shared.ts +22 -145
  111. package/src/studio/samples/starter.ts +251 -0
  112. package/src/studio/samples/support.ts +209 -184
  113. package/src/studio/screen-suites.test.ts +226 -0
  114. package/src/studio/screen-suites.ts +445 -0
  115. package/src/studio/ui-components-surface.test.ts +185 -0
  116. package/src/studio/ui-components.generated.ts +7154 -0
  117. package/src/studio/ui-components.ts +273 -3
  118. package/src/sveltekit/index.ts +1 -0
  119. package/src/sveltekit/sql-source.test.ts +13 -0
  120. package/src/sveltekit/sql-source.ts +12 -7
  121. package/src/sveltekit/transport-scope.test.ts +125 -0
  122. package/src/sveltekit/transport.ts +76 -2
  123. package/src/upgrade-prompt.ts +2 -2
  124. package/src/watermark.ts +2 -2
  125. package/src/ai.test.ts +0 -522
  126. package/src/ai.ts +0 -1388
@@ -0,0 +1,155 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import {
3
+ buildDependencyGraph,
4
+ cascade,
5
+ hasCycle,
6
+ requiredStart,
7
+ topoOrder,
8
+ violations,
9
+ type EventTimes,
10
+ type SchedulerDependency,
11
+ } from './scheduler-dependencies'
12
+
13
+ // A fixed base day so the tests are deterministic (no Date.now()).
14
+ const D = (h: number, m = 0) => new Date(2026, 0, 5, h, m, 0, 0)
15
+ const ev = (startH: number, endH: number): EventTimes => ({ start: D(startH), end: D(endH) })
16
+ const times = (m: Record<string, EventTimes>) => new Map(Object.entries(m))
17
+
18
+ describe('requiredStart', () => {
19
+ const pred = ev(9, 11) // 09:00 - 11:00
20
+ const durMs = 60 * 60_000 // 1h successor
21
+
22
+ it('FS: successor starts at predecessor end', () => {
23
+ expect(requiredStart(pred, { id: 'd', from: 'a', to: 'b', type: 'FS' }, durMs)).toEqual(D(11))
24
+ })
25
+ it('FS honours lag (minutes)', () => {
26
+ expect(requiredStart(pred, { id: 'd', from: 'a', to: 'b', type: 'FS', lag: 30 }, durMs)).toEqual(D(11, 30))
27
+ })
28
+ it('FS honours negative lag (lead)', () => {
29
+ expect(requiredStart(pred, { id: 'd', from: 'a', to: 'b', type: 'FS', lag: -60 }, durMs)).toEqual(D(10))
30
+ })
31
+ it('SS: successor starts with predecessor start', () => {
32
+ expect(requiredStart(pred, { id: 'd', from: 'a', to: 'b', type: 'SS' }, durMs)).toEqual(D(9))
33
+ })
34
+ it('FF: successor ENDS at predecessor end (start = end - duration)', () => {
35
+ expect(requiredStart(pred, { id: 'd', from: 'a', to: 'b', type: 'FF' }, durMs)).toEqual(D(10))
36
+ })
37
+ it('SF: successor ENDS at predecessor start', () => {
38
+ expect(requiredStart(pred, { id: 'd', from: 'a', to: 'b', type: 'SF' }, durMs)).toEqual(D(8))
39
+ })
40
+ it('defaults to FS when type omitted', () => {
41
+ expect(requiredStart(pred, { id: 'd', from: 'a', to: 'b' }, durMs)).toEqual(D(11))
42
+ })
43
+ })
44
+
45
+ describe('buildDependencyGraph', () => {
46
+ it('indexes successors, predecessors and nodes; drops malformed/self links', () => {
47
+ const deps: SchedulerDependency[] = [
48
+ { id: '1', from: 'a', to: 'b' },
49
+ { id: '2', from: 'b', to: 'c' },
50
+ { id: '3', from: 'x', to: 'x' }, // self - dropped
51
+ { id: '4', from: '', to: 'z' }, // malformed - dropped
52
+ ]
53
+ const g = buildDependencyGraph(deps)
54
+ expect(g.deps).toHaveLength(2)
55
+ expect(g.succ.get('a')?.[0]?.to).toBe('b')
56
+ expect(g.pred.get('c')?.[0]?.from).toBe('b')
57
+ expect([...g.nodes].sort()).toEqual(['a', 'b', 'c'])
58
+ })
59
+ })
60
+
61
+ describe('topoOrder / hasCycle', () => {
62
+ it('orders predecessors before successors', () => {
63
+ const g = buildDependencyGraph([
64
+ { id: '1', from: 'a', to: 'b' },
65
+ { id: '2', from: 'b', to: 'c' },
66
+ ])
67
+ const { order, cyclicIds } = topoOrder(g)
68
+ expect(cyclicIds.size).toBe(0)
69
+ expect(order.indexOf('a')).toBeLessThan(order.indexOf('b'))
70
+ expect(order.indexOf('b')).toBeLessThan(order.indexOf('c'))
71
+ })
72
+ it('flags cyclic links and reports hasCycle', () => {
73
+ const deps: SchedulerDependency[] = [
74
+ { id: '1', from: 'a', to: 'b' },
75
+ { id: '2', from: 'b', to: 'a' },
76
+ ]
77
+ expect(hasCycle(deps)).toBe(true)
78
+ expect(topoOrder(buildDependencyGraph(deps)).cyclicIds.size).toBeGreaterThan(0)
79
+ })
80
+ })
81
+
82
+ describe('cascade', () => {
83
+ it('pushes an FS successor forward to the predecessor end', () => {
84
+ const t = times({ a: ev(9, 11), b: ev(9, 10) }) // b overlaps a - illegal
85
+ const out = cascade(t, [{ id: '1', from: 'a', to: 'b', type: 'FS' }])
86
+ expect(out.get('b')).toEqual({ start: D(11), end: D(12) }) // duration (1h) preserved
87
+ expect(out.has('a')).toBe(false) // predecessor untouched
88
+ })
89
+
90
+ it('never pulls a successor earlier when already legal', () => {
91
+ const t = times({ a: ev(9, 10), b: ev(14, 15) }) // b already after a
92
+ const out = cascade(t, [{ id: '1', from: 'a', to: 'b', type: 'FS' }])
93
+ expect(out.size).toBe(0)
94
+ })
95
+
96
+ it('cascades multi-hop chains in one pass', () => {
97
+ const t = times({ a: ev(9, 11), b: ev(9, 10), c: ev(9, 10) })
98
+ const out = cascade(t, [
99
+ { id: '1', from: 'a', to: 'b', type: 'FS' },
100
+ { id: '2', from: 'b', to: 'c', type: 'FS' },
101
+ ])
102
+ expect(out.get('b')).toEqual({ start: D(11), end: D(12) })
103
+ expect(out.get('c')).toEqual({ start: D(12), end: D(13) })
104
+ })
105
+
106
+ it('diamond: a successor with two predecessors takes the later constraint', () => {
107
+ // a ends 11, b ends 13; both feed d. d must start at max(11,13) = 13.
108
+ const t = times({ a: ev(9, 11), b: ev(9, 13), d: ev(9, 10) })
109
+ const out = cascade(t, [
110
+ { id: '1', from: 'a', to: 'd', type: 'FS' },
111
+ { id: '2', from: 'b', to: 'd', type: 'FS' },
112
+ ])
113
+ expect(out.get('d')).toEqual({ start: D(13), end: D(14) })
114
+ })
115
+
116
+ it('respects lag when cascading', () => {
117
+ const t = times({ a: ev(9, 11), b: ev(9, 10) })
118
+ const out = cascade(t, [{ id: '1', from: 'a', to: 'b', type: 'FS', lag: 15 }])
119
+ expect(out.get('b')).toEqual({ start: D(11, 15), end: D(12, 15) })
120
+ })
121
+
122
+ it('applies the snapForward hook (working-time skip)', () => {
123
+ // Snap any start at/after 12:00 forward to 13:00 (a lunch break).
124
+ const snapForward = (s: Date) => (s.getHours() === 12 ? new Date(2026, 0, 5, 13, 0) : s)
125
+ const t = times({ a: ev(10, 12), b: ev(9, 10) })
126
+ const out = cascade(t, [{ id: '1', from: 'a', to: 'b', type: 'FS' }], { snapForward })
127
+ expect(out.get('b')?.start).toEqual(new Date(2026, 0, 5, 13, 0))
128
+ expect(out.get('b')?.end).toEqual(new Date(2026, 0, 5, 14, 0)) // duration preserved past the break
129
+ })
130
+
131
+ it('ignores cyclic links without looping', () => {
132
+ const t = times({ a: ev(9, 11), b: ev(9, 10) })
133
+ const out = cascade(t, [
134
+ { id: '1', from: 'a', to: 'b' },
135
+ { id: '2', from: 'b', to: 'a' }, // cycle - both ignored
136
+ ])
137
+ expect(out.size).toBe(0)
138
+ })
139
+ })
140
+
141
+ describe('violations', () => {
142
+ it('reports a link whose successor starts too early', () => {
143
+ const t = times({ a: ev(9, 11), b: ev(10, 11) }) // b starts before a ends
144
+ const bad = violations(t, [{ id: '1', from: 'a', to: 'b', type: 'FS' }])
145
+ expect(bad.map((d) => d.id)).toEqual(['1'])
146
+ })
147
+ it('reports nothing when the link is satisfied', () => {
148
+ const t = times({ a: ev(9, 11), b: ev(11, 12) })
149
+ expect(violations(t, [{ id: '1', from: 'a', to: 'b', type: 'FS' }])).toHaveLength(0)
150
+ })
151
+ it('treats exact touching (start == pred end) as legal', () => {
152
+ const t = times({ a: ev(9, 11), b: ev(11, 12) })
153
+ expect(violations(t, [{ id: '1', from: 'a', to: 'b', type: 'FS' }])).toHaveLength(0)
154
+ })
155
+ })
@@ -0,0 +1,223 @@
1
+ /**
2
+ * scheduler-dependencies - the pure, framework-free core behind the Scheduler Pro
3
+ * *event dependencies* feature (predecessor / successor links + auto-reschedule).
4
+ * No Svelte, no DOM: just graph + date math over the native `Date`, so every rule
5
+ * here is unit-tested directly (mirrors scheduler-model.ts in @svgrid/grid).
6
+ *
7
+ * A dependency links a predecessor event to a successor with one of the four
8
+ * classic types (FS / SS / FF / SF) and an optional `lag` (minutes; negative = a
9
+ * lead). When a predecessor moves or resizes, {@link cascade} pushes successors
10
+ * forward just enough to keep every link legal, preserving each event's duration.
11
+ * Cascading never pulls an event earlier - it only resolves violations forward,
12
+ * the standard auto-schedule behaviour.
13
+ *
14
+ * Deliberately avoids the arg-less `new Date()` / `Date.now()` - callers pass the
15
+ * current event times in; working-time skipping is injected as a `snapForward`
16
+ * hook so this file needs no knowledge of shifts / business hours.
17
+ */
18
+
19
+ /** The four dependency kinds. `FS` (finish-to-start) is the default. */
20
+ export type DependencyType = 'FS' | 'SS' | 'FF' | 'SF'
21
+
22
+ /** A link from a predecessor event (`from`) to a successor (`to`). Keys are row
23
+ * keys (the scheduler's `getRowId`). `lag` is minutes; negative = a lead. */
24
+ export type SchedulerDependency = {
25
+ id: string
26
+ from: string
27
+ to: string
28
+ type?: DependencyType
29
+ lag?: number
30
+ }
31
+
32
+ /** The resolved [start, end) of one event, keyed by row key in a time map. */
33
+ export type EventTimes = { start: Date; end: Date }
34
+
35
+ const MS_MIN = 60_000
36
+ /** Sub-second slack so exact-touching links (successor.start == pred.end) are legal. */
37
+ const EPS_MS = 1_000
38
+
39
+ /** Indexed view of a dependency set: successors by predecessor, predecessors by
40
+ * successor, and lookup by id. Built once, reused by cascade / violations. */
41
+ export type DependencyGraph = {
42
+ deps: SchedulerDependency[]
43
+ /** predecessor key -> outgoing links */
44
+ succ: Map<string, SchedulerDependency[]>
45
+ /** successor key -> incoming links */
46
+ pred: Map<string, SchedulerDependency[]>
47
+ byId: Map<string, SchedulerDependency>
48
+ /** every key that appears as a `from` or `to`. */
49
+ nodes: Set<string>
50
+ }
51
+
52
+ /** Build the adjacency indexes for `deps`. */
53
+ export function buildDependencyGraph(
54
+ deps: ReadonlyArray<SchedulerDependency>,
55
+ ): DependencyGraph {
56
+ const succ = new Map<string, SchedulerDependency[]>()
57
+ const pred = new Map<string, SchedulerDependency[]>()
58
+ const byId = new Map<string, SchedulerDependency>()
59
+ const nodes = new Set<string>()
60
+ const list: SchedulerDependency[] = []
61
+ for (const d of deps) {
62
+ if (!d || !d.from || !d.to || d.from === d.to) continue // ignore malformed / self links
63
+ list.push(d)
64
+ byId.set(d.id, d)
65
+ nodes.add(d.from)
66
+ nodes.add(d.to)
67
+ ;(succ.get(d.from) ?? succ.set(d.from, []).get(d.from)!).push(d)
68
+ ;(pred.get(d.to) ?? pred.set(d.to, []).get(d.to)!).push(d)
69
+ }
70
+ return { deps: list, succ, pred, byId, nodes }
71
+ }
72
+
73
+ /**
74
+ * Kahn topological order of the graph's nodes. Returns `{ order, cyclicIds }`:
75
+ * `order` is a safe processing order for the acyclic part (predecessors before
76
+ * successors); `cyclicIds` are the ids of links that could not be ordered because
77
+ * they take part in a cycle (cascade ignores them so it can never loop).
78
+ */
79
+ export function topoOrder(graph: DependencyGraph): { order: string[]; cyclicIds: Set<string> } {
80
+ const indeg = new Map<string, number>()
81
+ for (const n of graph.nodes) indeg.set(n, 0)
82
+ for (const d of graph.deps) indeg.set(d.to, (indeg.get(d.to) ?? 0) + 1)
83
+
84
+ const queue: string[] = []
85
+ for (const [n, deg] of indeg) if (deg === 0) queue.push(n)
86
+ // Stable: process in insertion order for deterministic output.
87
+ const order: string[] = []
88
+ const settled = new Set<string>()
89
+ while (queue.length) {
90
+ const n = queue.shift()!
91
+ order.push(n)
92
+ settled.add(n)
93
+ for (const d of graph.succ.get(n) ?? []) {
94
+ const deg = (indeg.get(d.to) ?? 0) - 1
95
+ indeg.set(d.to, deg)
96
+ if (deg === 0) queue.push(d.to)
97
+ }
98
+ }
99
+ // Any node not settled sits inside (or downstream of) a cycle; links whose
100
+ // successor never settled are the cyclic ones.
101
+ const cyclicIds = new Set<string>()
102
+ for (const d of graph.deps) if (!settled.has(d.to)) cyclicIds.add(d.id)
103
+ return { order, cyclicIds }
104
+ }
105
+
106
+ /** True when `deps` contain a cycle. */
107
+ export function hasCycle(deps: ReadonlyArray<SchedulerDependency>): boolean {
108
+ return topoOrder(buildDependencyGraph(deps)).cyclicIds.size > 0
109
+ }
110
+
111
+ /**
112
+ * The earliest legal start for the successor of `dep`, given the predecessor's
113
+ * resolved times and the successor's own `durationMs` (kept constant). Encodes the
114
+ * FS / SS / FF / SF semantics plus `lag` (minutes).
115
+ */
116
+ export function requiredStart(
117
+ predTimes: EventTimes,
118
+ dep: SchedulerDependency,
119
+ successorDurationMs: number,
120
+ ): Date {
121
+ const lagMs = (dep.lag ?? 0) * MS_MIN
122
+ const type = dep.type ?? 'FS'
123
+ let t: number
124
+ switch (type) {
125
+ case 'FS':
126
+ t = predTimes.end.getTime() + lagMs
127
+ break
128
+ case 'SS':
129
+ t = predTimes.start.getTime() + lagMs
130
+ break
131
+ case 'FF':
132
+ t = predTimes.end.getTime() + lagMs - successorDurationMs
133
+ break
134
+ case 'SF':
135
+ t = predTimes.start.getTime() + lagMs - successorDurationMs
136
+ break
137
+ }
138
+ return new Date(t)
139
+ }
140
+
141
+ /** Options for {@link cascade}. */
142
+ export type CascadeOptions = {
143
+ /**
144
+ * Optional working-time snap: given a proposed start, return the next start that
145
+ * begins inside working time (the renderer supplies this from the scheduler's
146
+ * shifts / business hours; omit for calendar-time scheduling).
147
+ */
148
+ snapForward?: (start: Date) => Date
149
+ }
150
+
151
+ /**
152
+ * Forward-cascade successors so every dependency is satisfied. `times` maps row
153
+ * key -> current [start,end] (the just-moved predecessor already carries its NEW
154
+ * time). Processing runs in topological order, so multi-hop chains and diamonds
155
+ * (two paths into one successor) resolve to the latest required start in a single
156
+ * pass. Each shifted event keeps its duration. Returns ONLY the events whose times
157
+ * changed (never the untouched ones, never the moved predecessor itself unless a
158
+ * link also forces it). Cyclic links are ignored.
159
+ */
160
+ export function cascade(
161
+ times: ReadonlyMap<string, EventTimes>,
162
+ deps: ReadonlyArray<SchedulerDependency>,
163
+ opts: CascadeOptions = {},
164
+ ): Map<string, EventTimes> {
165
+ const graph = buildDependencyGraph(deps)
166
+ const { order, cyclicIds } = topoOrder(graph)
167
+ // Working copy we mutate as we walk downstream.
168
+ const cur = new Map<string, EventTimes>()
169
+ for (const [k, v] of times) cur.set(k, { start: v.start, end: v.end })
170
+
171
+ const changed = new Map<string, EventTimes>()
172
+ for (const key of order) {
173
+ const links = graph.pred.get(key)
174
+ if (!links || !links.length) continue
175
+ const self = cur.get(key)
176
+ if (!self) continue
177
+ const durationMs = self.end.getTime() - self.start.getTime()
178
+
179
+ let minStartMs = self.start.getTime()
180
+ for (const dep of links) {
181
+ if (cyclicIds.has(dep.id)) continue
182
+ const pred = cur.get(dep.from)
183
+ if (!pred) continue
184
+ const req = requiredStart(pred, dep, durationMs).getTime()
185
+ if (req > minStartMs) minStartMs = req
186
+ }
187
+ if (minStartMs <= self.start.getTime()) continue // already legal - never pull earlier
188
+
189
+ let newStart = new Date(minStartMs)
190
+ if (opts.snapForward) newStart = opts.snapForward(newStart)
191
+ const newEnd = new Date(newStart.getTime() + durationMs)
192
+ const next = { start: newStart, end: newEnd }
193
+ cur.set(key, next)
194
+ changed.set(key, next)
195
+ }
196
+ return changed
197
+ }
198
+
199
+ /**
200
+ * The dependencies currently unsatisfied by `times` (successor starts too early
201
+ * for the link). Used to paint violation-state arrows. Cyclic links are skipped.
202
+ */
203
+ export function violations(
204
+ times: ReadonlyMap<string, EventTimes>,
205
+ deps: ReadonlyArray<SchedulerDependency>,
206
+ ): SchedulerDependency[] {
207
+ const graph = buildDependencyGraph(deps)
208
+ const { cyclicIds } = topoOrder(graph)
209
+ const out: SchedulerDependency[] = []
210
+ for (const dep of graph.deps) {
211
+ if (cyclicIds.has(dep.id)) continue
212
+ const pred = times.get(dep.from)
213
+ const succ = times.get(dep.to)
214
+ if (!pred || !succ) continue
215
+ const durationMs = succ.end.getTime() - succ.start.getTime()
216
+ const req = requiredStart(pred, dep, durationMs).getTime()
217
+ // A link on end (FF / SF) constrains the successor's end; compare that edge.
218
+ const type = dep.type ?? 'FS'
219
+ const actual = type === 'FF' || type === 'SF' ? succ.end.getTime() - durationMs : succ.start.getTime()
220
+ if (actual + EPS_MS < req) out.push(dep)
221
+ }
222
+ return out
223
+ }
@@ -0,0 +1,41 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { commonFree, mergeBusy } from './scheduler-freebusy'
3
+ import type { Interval } from './scheduler-slots'
4
+
5
+ const D = (h: number, m = 0) => new Date(2026, 0, 5, h, m, 0, 0)
6
+ const iv = (sh: number, sm: number, eh: number, em: number): Interval => ({ start: D(sh, sm), end: D(eh, em) })
7
+ const hhmm = (d: Date) => `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
8
+ const show = (list: Interval[]) => list.map((s) => `${hhmm(s.start)}-${hhmm(s.end)}`)
9
+
10
+ describe('mergeBusy', () => {
11
+ it('unions overlapping busy blocks', () => {
12
+ expect(show(mergeBusy([iv(9, 0, 10, 0), iv(9, 30, 11, 0)]))).toEqual(['09:00-11:00'])
13
+ })
14
+ })
15
+
16
+ describe('commonFree', () => {
17
+ const day = [D(9, 0), D(17, 0)] as const
18
+
19
+ it('finds windows when everyone is free', () => {
20
+ // A busy 9-10 and 14-15; B busy 11-12. Common free: 10-11, 12-14, 15-17.
21
+ const busy = [[iv(9, 0, 10, 0), iv(14, 0, 15, 0)], [iv(11, 0, 12, 0)]]
22
+ expect(show(commonFree(busy, day[0], day[1], 30))).toEqual(['10:00-11:00', '12:00-14:00', '15:00-17:00'])
23
+ })
24
+
25
+ it('drops windows too short for the meeting', () => {
26
+ // Leaves only 10:00-10:30 free between busy blocks -> a 60-min meeting has none.
27
+ const busy = [[iv(9, 0, 10, 0)], [iv(10, 30, 17, 0)]]
28
+ expect(commonFree(busy, day[0], day[1], 60)).toEqual([])
29
+ expect(show(commonFree(busy, day[0], day[1], 30))).toEqual(['10:00-10:30'])
30
+ })
31
+
32
+ it('treats an attendee with no busy list as fully free', () => {
33
+ const busy = [[iv(12, 0, 13, 0)], []]
34
+ expect(show(commonFree(busy, day[0], day[1], 60))).toEqual(['09:00-12:00', '13:00-17:00'])
35
+ })
36
+
37
+ it('returns nothing when the union covers the whole day', () => {
38
+ const busy = [[iv(9, 0, 13, 0)], [iv(13, 0, 17, 0)]]
39
+ expect(commonFree(busy, day[0], day[1], 15)).toEqual([])
40
+ })
41
+ })
@@ -0,0 +1,36 @@
1
+ /**
2
+ * scheduler-freebusy - the pure core behind Scheduler Pro's *free/busy* and
3
+ * *cross-attendee find-a-time*. No Svelte, no DOM. Merges each attendee's busy
4
+ * intervals and finds the windows when EVERYONE is free (long enough for a meeting).
5
+ * Deterministic - the caller supplies all dates. Reuses the interval algebra from
6
+ * scheduler-slots (`mergeIntervals` / `subtractIntervals`).
7
+ */
8
+ import { mergeIntervals, subtractIntervals, type Interval } from './scheduler-slots'
9
+
10
+ export type { Interval }
11
+
12
+ const MS_MIN = 60_000
13
+
14
+ /** Union of a set of busy intervals into a sorted, disjoint list (an alias of the
15
+ * slots merge, named for the free/busy domain). */
16
+ export function mergeBusy(intervals: ReadonlyArray<Interval>): Interval[] {
17
+ return mergeIntervals(intervals)
18
+ }
19
+
20
+ /**
21
+ * The windows within `[dayStart, dayEnd]` when EVERY attendee is free, each at
22
+ * least `durationMin` long. `busyByPerson` is one busy list per attendee; a person
23
+ * with no entry is treated as fully free. The result is the complement of the union
24
+ * of all busy time, filtered to windows that can fit the meeting.
25
+ */
26
+ export function commonFree(
27
+ busyByPerson: ReadonlyArray<ReadonlyArray<Interval>>,
28
+ dayStart: Date,
29
+ dayEnd: Date,
30
+ durationMin: number,
31
+ ): Interval[] {
32
+ const allBusy = mergeIntervals(busyByPerson.flat())
33
+ const free = subtractIntervals({ start: dayStart, end: dayEnd }, allBusy)
34
+ const minMs = Math.max(0, durationMin) * MS_MIN
35
+ return free.filter((f) => f.end.getTime() - f.start.getTime() >= minMs)
36
+ }
@@ -0,0 +1,39 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { heatCells, heatPeak } from './scheduler-heatmap'
3
+
4
+ const ticks = [
5
+ { leftPct: 0, widthPct: 25 },
6
+ { leftPct: 25, widthPct: 25 },
7
+ { leftPct: 50, widthPct: 25 },
8
+ { leftPct: 75, widthPct: 25 },
9
+ ]
10
+
11
+ describe('heatCells', () => {
12
+ it('scales load to 0..1 intensity against the peak', () => {
13
+ const cells = heatCells(ticks, [0, 1, 2, 4], 4, 4)
14
+ expect(cells.map((c) => c.intensity)).toEqual([0, 0.25, 0.5, 1])
15
+ })
16
+
17
+ it('flags over-capacity cells', () => {
18
+ const cells = heatCells(ticks, [1, 2, 3, 5], 3, 5)
19
+ expect(cells.map((c) => c.over)).toEqual([false, false, false, true]) // 5 > capacity 3
20
+ })
21
+
22
+ it('clamps intensity to 1 when load exceeds the peak', () => {
23
+ const cells = heatCells(ticks, [8], 2, 4)
24
+ expect(cells[0]!.intensity).toBe(1)
25
+ })
26
+
27
+ it('carries the axis geometry through', () => {
28
+ const cells = heatCells(ticks, [1, 1, 1, 1], 1, 1)
29
+ expect(cells.map((c) => c.leftPct)).toEqual([0, 25, 50, 75])
30
+ expect(cells.every((c) => c.widthPct === 25)).toBe(true)
31
+ })
32
+ })
33
+
34
+ describe('heatPeak', () => {
35
+ it('returns the max load across rows (>= floor)', () => {
36
+ expect(heatPeak([[0, 1, 2], [3, 0], [1]])).toBe(3)
37
+ expect(heatPeak([[0, 0]], 1)).toBe(1) // floor
38
+ })
39
+ })
@@ -0,0 +1,55 @@
1
+ /**
2
+ * scheduler-heatmap - the pure core behind Scheduler Pro's *utilization heatmap*.
3
+ * No Svelte, no DOM. Distinct from the histogram (a bar strip): the heatmap tints
4
+ * a resource row's BACKGROUND per time-bucket by how loaded that resource is.
5
+ *
6
+ * The renderer computes each bucket's load (with `resourceLoad` / `overlapCount`
7
+ * from the assignment / model helpers) and passes it here to get a normalized
8
+ * intensity (0..1) + an over-capacity flag per cell; it then paints the cell with
9
+ * a theme colour at that intensity.
10
+ */
11
+
12
+ /** One background cell: its axis geometry + normalized load intensity. */
13
+ export type HeatCell = {
14
+ leftPct: number
15
+ widthPct: number
16
+ /** 0..1, load scaled against `peak` (clamped). */
17
+ intensity: number
18
+ /** True when the raw load exceeds capacity. */
19
+ over: boolean
20
+ }
21
+
22
+ /**
23
+ * Build the per-tick heat cells for one resource row. `ticks` supply the axis
24
+ * geometry, `loads` the load in each tick (same length/order), `capacity` the
25
+ * resource's capacity (over that = `over`), and `peak` the normalization scale
26
+ * (usually a global peak so rows are comparable). Zero-load ticks are still
27
+ * emitted (intensity 0) so the caller can decide whether to paint them.
28
+ */
29
+ export function heatCells(
30
+ ticks: ReadonlyArray<{ leftPct: number; widthPct: number }>,
31
+ loads: ReadonlyArray<number>,
32
+ capacity: number,
33
+ peak: number,
34
+ ): HeatCell[] {
35
+ const scale = Math.max(peak, capacity, 1)
36
+ const out: HeatCell[] = []
37
+ for (let i = 0; i < ticks.length; i++) {
38
+ const t = ticks[i]!
39
+ const load = loads[i] ?? 0
40
+ out.push({
41
+ leftPct: t.leftPct,
42
+ widthPct: t.widthPct,
43
+ intensity: Math.max(0, Math.min(1, load / scale)),
44
+ over: load > capacity + 1e-9,
45
+ })
46
+ }
47
+ return out
48
+ }
49
+
50
+ /** The peak load across a set of per-resource load rows (for a shared scale). */
51
+ export function heatPeak(loadRows: ReadonlyArray<ReadonlyArray<number>>, floor = 1): number {
52
+ let peak = floor
53
+ for (const row of loadRows) for (const v of row) if (v > peak) peak = v
54
+ return peak
55
+ }
@@ -0,0 +1,84 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { buildResourceRows, visibleResourceIds, type SchedulerResourceGroup } from './scheduler-resource-tree'
3
+
4
+ type Res = { id: string; title?: string; group?: string }
5
+ const groupOf = (r: Res) => r.group
6
+
7
+ const groups: SchedulerResourceGroup[] = [
8
+ { id: 'clinic', title: 'Clinic' },
9
+ { id: 'cardio', title: 'Cardiology', parentId: 'clinic' },
10
+ { id: 'derm', title: 'Dermatology', parentId: 'clinic' },
11
+ ]
12
+ const resources: Res[] = [
13
+ { id: 'dr-a', title: 'Dr A', group: 'cardio' },
14
+ { id: 'dr-b', title: 'Dr B', group: 'cardio' },
15
+ { id: 'dr-c', title: 'Dr C', group: 'derm' },
16
+ ]
17
+
18
+ describe('buildResourceRows', () => {
19
+ it('nests groups and interleaves resources in tree order', () => {
20
+ const rows = buildResourceRows(resources, groups, groupOf)
21
+ expect(rows.map((r) => `${r.kind}:${r.id}@${r.depth}`)).toEqual([
22
+ 'group:clinic@0',
23
+ 'group:cardio@1',
24
+ 'resource:dr-a@2',
25
+ 'resource:dr-b@2',
26
+ 'group:derm@1',
27
+ 'resource:dr-c@2',
28
+ ])
29
+ })
30
+
31
+ it('computes childResourceIds (roll-up) for each group subtree', () => {
32
+ const rows = buildResourceRows(resources, groups, groupOf)
33
+ const byId = Object.fromEntries(rows.filter((r) => r.kind === 'group').map((r) => [r.id, r.childResourceIds]))
34
+ expect(byId.clinic).toEqual(['dr-a', 'dr-b', 'dr-c'])
35
+ expect(byId.cardio).toEqual(['dr-a', 'dr-b'])
36
+ expect(byId.derm).toEqual(['dr-c'])
37
+ })
38
+
39
+ it('collapsing a group hides its subtree but keeps the header', () => {
40
+ const rows = buildResourceRows(resources, groups, groupOf, new Set(['cardio']))
41
+ expect(rows.map((r) => `${r.kind}:${r.id}`)).toEqual([
42
+ 'group:clinic',
43
+ 'group:cardio', // header stays
44
+ // dr-a, dr-b hidden
45
+ 'group:derm',
46
+ 'resource:dr-c',
47
+ ])
48
+ expect(rows.find((r) => r.id === 'cardio')?.collapsed).toBe(true)
49
+ })
50
+
51
+ it('collapsing an ancestor hides all descendants', () => {
52
+ const rows = buildResourceRows(resources, groups, groupOf, new Set(['clinic']))
53
+ expect(rows.map((r) => r.id)).toEqual(['clinic'])
54
+ })
55
+
56
+ it('puts ungrouped / unknown-group resources in a trailing section (no header)', () => {
57
+ const res: Res[] = [{ id: 'x', group: 'cardio' }, { id: 'y' }, { id: 'z', group: 'nope' }]
58
+ const rows = buildResourceRows(res, groups, groupOf)
59
+ const last2 = rows.slice(-2).map((r) => `${r.kind}:${r.id}@${r.depth}`)
60
+ expect(last2).toEqual(['resource:y@0', 'resource:z@0'])
61
+ })
62
+
63
+ it('treats a group with an unknown parent as a root', () => {
64
+ const g: SchedulerResourceGroup[] = [{ id: 'a', title: 'A', parentId: 'ghost' }]
65
+ const rows = buildResourceRows([{ id: 'r', group: 'a' }], g, groupOf)
66
+ expect(rows[0]).toMatchObject({ kind: 'group', id: 'a', depth: 0 })
67
+ })
68
+
69
+ it('does not loop on a cyclic parent chain', () => {
70
+ const g: SchedulerResourceGroup[] = [
71
+ { id: 'a', title: 'A', parentId: 'b' },
72
+ { id: 'b', title: 'B', parentId: 'a' },
73
+ ]
74
+ const rows = buildResourceRows([{ id: 'r', group: 'a' }], g, groupOf)
75
+ // Should terminate; both groups appear at most once.
76
+ expect(rows.filter((r) => r.id === 'a').length).toBeLessThanOrEqual(1)
77
+ expect(rows.filter((r) => r.id === 'b').length).toBeLessThanOrEqual(1)
78
+ })
79
+
80
+ it('visibleResourceIds returns leaf resources in order', () => {
81
+ const rows = buildResourceRows(resources, groups, groupOf)
82
+ expect(visibleResourceIds(rows)).toEqual(['dr-a', 'dr-b', 'dr-c'])
83
+ })
84
+ })