@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,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
|
+
})
|