@svgrid/enterprise 2.2.1 → 2.3.0

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 (101) hide show
  1. package/README.md +95 -81
  2. package/dist/cdn/svgrid-enterprise.svelte-external.js +22468 -10459
  3. package/dist/node/studio.js +11590 -2907
  4. package/package.json +31 -10
  5. package/src/SvAlertRuleEditor.svelte +294 -0
  6. package/src/SvAlertsManager.svelte +210 -0
  7. package/src/SvAlertsPanel.svelte +129 -0
  8. package/src/SvBoard.svelte +3 -1
  9. package/src/SvExpressionEditor.svelte +341 -0
  10. package/src/SvGridAlerts.dom.test.ts +113 -0
  11. package/src/SvGridAlerts.svelte +265 -0
  12. package/src/SvGridBoard.svelte +2355 -0
  13. package/src/SvGridEditPanel.svelte +849 -794
  14. package/src/SvGridScheduler.svelte +5334 -4410
  15. package/src/SvPivotDesigner.svelte +3 -3
  16. package/src/SvRecordDetail.svelte +6 -2
  17. package/src/SvSchedule.svelte +3 -1
  18. package/src/{ai-export-pdf.test.ts → ai-export-pdf.dom.test.ts} +4 -1
  19. package/src/{ai-export-xlsx.test.ts → ai-export-xlsx.dom.test.ts} +4 -1
  20. package/src/{ai-export.test.ts → ai-export.dom.test.ts} +5 -1
  21. package/src/alerts/alert-engine-attach.ts +165 -0
  22. package/src/alerts/alert-engine.test.ts +135 -0
  23. package/src/alerts/alert-engine.ts +260 -0
  24. package/src/alerts/alert-formats.test.ts +87 -0
  25. package/src/alerts/alert-formats.ts +77 -0
  26. package/src/alerts/alert-observer.test.ts +189 -0
  27. package/src/alerts/alert-observer.ts +208 -0
  28. package/src/alerts/alert-scheduler.ts +96 -0
  29. package/src/alerts/alert-storage.test.ts +54 -0
  30. package/src/alerts/alert-storage.ts +116 -0
  31. package/src/alerts/alert-store.svelte.ts +80 -0
  32. package/src/alerts/alert-types.ts +94 -0
  33. package/src/alerts.ts +28 -0
  34. package/src/board.dom.test.ts +941 -0
  35. package/src/board.ts +36 -0
  36. package/src/export.ts +6 -0
  37. package/src/expressions/evaluate.test.ts +111 -0
  38. package/src/expressions/evaluate.ts +228 -0
  39. package/src/expressions/expression-columns.ts +133 -0
  40. package/src/expressions/expression-types.ts +84 -0
  41. package/src/expressions/parse.test.ts +106 -0
  42. package/src/expressions/parse.ts +610 -0
  43. package/src/import.ts +2 -0
  44. package/src/index.ts +180 -46
  45. package/src/install.ts +12 -2
  46. package/src/pivot-enable.ts +44 -0
  47. package/src/scheduler-assignments.test.ts +97 -0
  48. package/src/scheduler-assignments.ts +134 -0
  49. package/src/scheduler-axis.test.ts +108 -0
  50. package/src/scheduler-axis.ts +238 -0
  51. package/src/scheduler-booking.test.ts +57 -0
  52. package/src/scheduler-booking.ts +63 -0
  53. package/src/scheduler-config.ts +179 -0
  54. package/src/scheduler-dependencies.test.ts +155 -0
  55. package/src/scheduler-dependencies.ts +223 -0
  56. package/src/scheduler-freebusy.test.ts +41 -0
  57. package/src/scheduler-freebusy.ts +36 -0
  58. package/src/scheduler-heatmap.test.ts +39 -0
  59. package/src/scheduler-heatmap.ts +55 -0
  60. package/src/scheduler-resource-tree.test.ts +84 -0
  61. package/src/scheduler-resource-tree.ts +107 -0
  62. package/src/scheduler-slots.test.ts +53 -0
  63. package/src/scheduler-slots.ts +94 -0
  64. package/src/scheduler-summary.test.ts +47 -0
  65. package/src/scheduler-summary.ts +81 -0
  66. package/src/sources/index.ts +1 -1
  67. package/src/sources/introspect-supabase.test.ts +13 -1
  68. package/src/sources/introspect-supabase.ts +20 -0
  69. package/src/studio/copilot-core.test.ts +45 -0
  70. package/src/studio/copilot-core.ts +65 -0
  71. package/src/studio/deploy-cli.test.ts +56 -0
  72. package/src/studio/deploy-cli.ts +100 -0
  73. package/src/studio/emit-project.test.ts +503 -18
  74. package/src/studio/emit-project.ts +829 -111
  75. package/src/studio/emit-schema.test.ts +17 -0
  76. package/src/studio/emit-schema.ts +166 -35
  77. package/src/studio/index.ts +25 -1
  78. package/src/studio/introspect-openapi.test.ts +84 -0
  79. package/src/studio/introspect-openapi.ts +252 -0
  80. package/src/studio/project.test.ts +57 -0
  81. package/src/studio/project.ts +193 -6
  82. package/src/studio/samples/crm.ts +282 -258
  83. package/src/studio/samples/fleet.ts +213 -186
  84. package/src/studio/samples/insurance.ts +223 -195
  85. package/src/studio/samples/inventory.ts +213 -182
  86. package/src/studio/samples/live-data.test.ts +99 -98
  87. package/src/studio/samples/live-data.ts +8 -10
  88. package/src/studio/samples/projects.ts +212 -190
  89. package/src/studio/samples/samples.test.ts +52 -0
  90. package/src/studio/samples/shared.ts +26 -6
  91. package/src/studio/samples/starter.ts +251 -0
  92. package/src/studio/samples/support.ts +209 -184
  93. package/src/studio/ui-components-surface.test.ts +185 -0
  94. package/src/studio/ui-components.generated.ts +5617 -0
  95. package/src/studio/ui-components.ts +641 -472
  96. package/src/sveltekit/sql-source.test.ts +13 -0
  97. package/src/sveltekit/sql-source.ts +11 -6
  98. package/src/upgrade-prompt.ts +2 -2
  99. package/src/watermark.ts +2 -2
  100. package/src/ai.test.ts +0 -522
  101. package/src/ai.ts +0 -1388
package/src/index.ts CHANGED
@@ -54,6 +54,82 @@ export { installEnterprise, type EnterpriseGridApi, type EnterpriseAIApi, type E
54
54
  // renderer so `<SvGrid scheduler={...}>` shows a Month/Week/Day/Agenda calendar.
55
55
  // The pure model helpers + config types live in @svgrid/grid; import them from there.
56
56
  export { enableSchedulerView, SvGridScheduler } from './scheduler'
57
+ // Kanban board view (Pro). `enableBoardView()` registers the renderer so
58
+ // `<SvGrid board={...}>` shows lanes of cards. Config types live in @svgrid/grid.
59
+ export { enableBoardView, SvGridBoard } from './board'
60
+ // In-grid pivot mode (Pro). `enablePivot()` registers the pivot engine so
61
+ // `<SvGrid pivot={...}>` renders a pivot table in place of the flat table.
62
+ export { enablePivot } from './pivot-enable'
63
+ // Scheduler Pro feature surface (dependencies, multi-assignment + summaries,
64
+ // axis collapse + zoom). Config type extends the free grid's SchedulerConfig;
65
+ // the pure models are unit-tested framework-free helpers the renderer consumes.
66
+ export type { SchedulerProConfig } from './scheduler-config'
67
+ export {
68
+ buildDependencyGraph,
69
+ topoOrder,
70
+ hasCycle,
71
+ requiredStart,
72
+ cascade,
73
+ violations,
74
+ type DependencyType,
75
+ type SchedulerDependency,
76
+ type EventTimes,
77
+ type DependencyGraph,
78
+ type CascadeOptions,
79
+ } from './scheduler-dependencies'
80
+ export {
81
+ expandAssignments,
82
+ resourceLoad,
83
+ overallocations,
84
+ assignedMinutes,
85
+ type SchedulerAssignment,
86
+ type Allocation,
87
+ type Overallocation,
88
+ } from './scheduler-assignments'
89
+ export {
90
+ columnSummaries,
91
+ sumReducer,
92
+ countReducer,
93
+ busyMinutesReducer,
94
+ type ColumnReducer,
95
+ type SummaryItem,
96
+ } from './scheduler-summary'
97
+ export {
98
+ buildAxis,
99
+ timeToX,
100
+ xToTime,
101
+ resolveZoom,
102
+ zoomPresets,
103
+ type Axis,
104
+ type AxisSegment,
105
+ type AxisSegmentKind,
106
+ type BuildAxisOptions,
107
+ type ZoomLevel,
108
+ type ZoomMajorUnit,
109
+ } from './scheduler-axis'
110
+ export {
111
+ buildResourceRows,
112
+ visibleResourceIds,
113
+ type SchedulerResourceGroup,
114
+ type ResourceRow,
115
+ } from './scheduler-resource-tree'
116
+ export { heatCells, heatPeak, type HeatCell } from './scheduler-heatmap'
117
+ export {
118
+ respectsBuffer,
119
+ withinDurationBounds,
120
+ afterLead,
121
+ type BusyEvent,
122
+ type GapMin,
123
+ } from './scheduler-booking'
124
+ export {
125
+ availableSlots,
126
+ mergeIntervals,
127
+ subtractIntervals,
128
+ type Interval,
129
+ type Slot,
130
+ type SlotOptions,
131
+ } from './scheduler-slots'
132
+ export { mergeBusy, commonFree } from './scheduler-freebusy'
57
133
  export {
58
134
  createStagedEditing,
59
135
  type StagedChange,
@@ -141,6 +217,7 @@ export {
141
217
  dummyJsonAdapter,
142
218
  jsonServerAdapter,
143
219
  introspectSupabaseTable,
220
+ listSupabaseTables,
144
221
  createSupabaseRealtime,
145
222
  createRelationLookup,
146
223
  createSupabaseAuth,
@@ -240,6 +317,7 @@ export {
240
317
  emitStudioApp,
241
318
  sampleApps,
242
319
  getSampleApp,
320
+ starterProject,
243
321
  liveDataSamples,
244
322
  getLiveDataSample,
245
323
  type SampleApp,
@@ -360,50 +438,106 @@ export {
360
438
  type Well,
361
439
  } from './pivot-designer'
362
440
 
363
- export {
364
- // Provider plumbing
365
- setAIProvider,
366
- getAIProvider,
367
- hasAIProvider,
368
- mockAIProvider,
369
- type AIProvider,
370
- type AIRequest,
371
- type AITask,
372
- // Filter
373
- aiFilter,
374
- type AIFilterOptions,
375
- type AIFilterResult,
376
- type AIFilterClause,
377
- type AISortClause,
378
- // Smart fill
379
- aiSmartFill,
380
- type AISmartFillOptions,
381
- type AISmartFillResult,
382
- type AISmartFillExample,
383
- // Summarize
384
- aiSummarize,
385
- type AISummarizeOptions,
386
- type AISummarizeTarget,
387
- type AISummary,
388
- // Classify
389
- aiClassify,
390
- type AIClassifyOptions,
391
- type AIClassifyResult,
392
- // Natural-language export
393
- aiExport,
394
- type AIExportOptions,
395
- type AIExportPlan,
396
- // Anomaly detection
397
- aiFindAnomalies,
398
- type AIAnomaly,
399
- type AIAnomalyOptions,
400
- type AIAnomalyResult,
401
- // Natural-language chart ("chart this")
402
- aiChart,
403
- enableAiCharting,
404
- disableAiCharting,
405
- type AIChartOptions,
406
- type AIChartPlan,
407
- type AIChartType,
408
- } from './ai'
441
+ // The AI helpers (setAIProvider, aiFilter, aiSmartFill, aiSummarize, aiClassify,
442
+ // aiExport, aiFindAnomalies, aiChart, enableAiCharting, ...) are now BUILT-IN and
443
+ // FREE in @svgrid/grid - import them from there. Enterprise only registers its
444
+ // export engine so AI-planned Excel/PDF exports can run (see install.ts).
409
445
  export { pivotToChartSpec, type PivotChartOptions } from './pivot-chart'
446
+
447
+ // Expression / query language (Pro). One predicate/scalar/change model that
448
+ // powers alerts (and, in time, styled + calculated columns). Leaf comparisons
449
+ // reuse the grid's own `applyExcelFilter`, so operators match the filter row.
450
+ export {
451
+ evaluatePredicate,
452
+ evaluateScalar,
453
+ evaluateChange,
454
+ BUILTIN_FUNCTION_NAMES,
455
+ isBuiltinFunction,
456
+ } from './expressions/evaluate'
457
+ export {
458
+ parsePredicate,
459
+ stringifyPredicate,
460
+ stringifyScalar,
461
+ validateExpression,
462
+ collectColumnRefs,
463
+ ExpressionParseError,
464
+ } from './expressions/parse'
465
+ export {
466
+ OPERATORS,
467
+ operatorMeta,
468
+ operatorsForType,
469
+ isValueless,
470
+ isSetOperator,
471
+ isRangeOperator,
472
+ columnById,
473
+ resolveColumnRef,
474
+ columnLabel,
475
+ columnRefText,
476
+ type ExprColumn,
477
+ type ExprColumnType,
478
+ type OperatorMeta,
479
+ } from './expressions/expression-columns'
480
+ export type {
481
+ ScalarExpr,
482
+ PredicateExpr,
483
+ ChangeExpr,
484
+ ComparisonOp,
485
+ ArithmeticOp,
486
+ AggFn,
487
+ EvalContext,
488
+ ExprRow,
489
+ } from './expressions/expression-types'
490
+ export { default as SvExpressionEditor } from './SvExpressionEditor.svelte'
491
+
492
+ // Alert Rules engine (Pro). Declarative "when this holds / this moves -> toast /
493
+ // badge / highlight / flash / prevent-edit / log". Authored at runtime,
494
+ // persisted, shareable. Surfacing reuses the grid's toast + conditional-format
495
+ // engines; `<SvGridAlerts>` is the runtime overlay the consumer mounts.
496
+ export { enableAlerts } from './alerts'
497
+ export {
498
+ createAlertEngine,
499
+ renderTemplate,
500
+ type AlertEngine,
501
+ type AlertEngineOptions,
502
+ type ValidateEditResult,
503
+ } from './alerts/alert-engine'
504
+ export {
505
+ attachAlertEngine,
506
+ applyAlertEvents,
507
+ type AlertAttachment,
508
+ type AttachAlertOptions,
509
+ type FlashTarget,
510
+ } from './alerts/alert-engine-attach'
511
+ export {
512
+ memoryAlertRules,
513
+ localStorageAlertRules,
514
+ createAlertRules,
515
+ type AlertRulesStorage,
516
+ type AlertRulesManager,
517
+ } from './alerts/alert-storage'
518
+ export {
519
+ createAlertObserver,
520
+ type AlertObserver,
521
+ type AlertObserverOptions,
522
+ } from './alerts/alert-observer'
523
+ export {
524
+ createAlertScheduler,
525
+ type AlertScheduler,
526
+ type AlertSchedulerMode,
527
+ } from './alerts/alert-scheduler'
528
+ export { toConditionalFormats, rulesToConditionalFormats, type StylingContext } from './alerts/alert-formats'
529
+ export { alertStore } from './alerts/alert-store.svelte'
530
+ export type {
531
+ AlertRule,
532
+ AlertEvent,
533
+ AlertTrigger,
534
+ AlertAction,
535
+ AlertActionKind,
536
+ AlertActionStyle,
537
+ AlertScope,
538
+ AlertSeverity,
539
+ } from './alerts/alert-types'
540
+ export { default as SvGridAlerts } from './SvGridAlerts.svelte'
541
+ export { default as SvAlertRuleEditor } from './SvAlertRuleEditor.svelte'
542
+ export { default as SvAlertsManager } from './SvAlertsManager.svelte'
543
+ export { default as SvAlertsPanel } from './SvAlertsPanel.svelte'
package/src/install.ts CHANGED
@@ -10,9 +10,11 @@ import { printGrid, type PrintOptions } from './print'
10
10
  import { importData, type ImportOptions, type ImportResult } from './import'
11
11
  import { isLicenseKeySet } from './license'
12
12
  import { emitUnlicensedNudge } from './watermark'
13
+ // AI helpers are built-in + free in @svgrid/grid now; enterprise just wires them
14
+ // into the Pro API and registers its export engine so AI exports can write xlsx/pdf.
13
15
  import {
14
16
  aiFilter, aiSmartFill, aiSummarize, aiClassify, aiExport, aiFindAnomalies,
15
- aiChart, enableAiCharting,
17
+ aiChart, enableAiCharting, registerExportProvider,
16
18
  type AIChartOptions, type AIChartPlan,
17
19
  type AIFilterOptions, type AIFilterResult,
18
20
  type AISmartFillOptions, type AISmartFillResult,
@@ -20,13 +22,15 @@ import {
20
22
  type AIClassifyOptions, type AIClassifyResult,
21
23
  type AIExportOptions, type AIExportPlan,
22
24
  type AIAnomalyOptions, type AIAnomalyResult,
23
- } from './ai'
25
+ } from '@svgrid/grid'
24
26
  import {
25
27
  createPivotModel,
26
28
  type PivotConfig,
27
29
  type PivotResult,
28
30
  } from './pivot'
29
31
  import { enableSchedulerView } from './scheduler'
32
+ import { enableBoardView } from './board'
33
+ import { enablePivot } from './pivot-enable'
30
34
 
31
35
  export type EnterpriseAIApi<TData extends RowData> = {
32
36
  /** Natural-language -> filter + sort plan (and optionally apply it). */
@@ -128,6 +132,12 @@ export function installEnterprise<
128
132
  enableAiCharting(pro)
129
133
  // Register the scheduler / calendar view (no-op without the `scheduler` prop).
130
134
  enableSchedulerView()
135
+ // Register the Kanban board view (no-op without the `board` prop).
136
+ enableBoardView()
137
+ // Register the export engine so built-in AI exports (aiExport) can write the
138
+ // enterprise formats (xlsx / pdf / ...); free grids without it get the plan only.
139
+ registerExportProvider(exportGrid as never)
140
+ enablePivot()
131
141
  pro.pivot = {
132
142
  build: (config) =>
133
143
  createPivotModel<TFeatures, TData>(pro.getData(), config),
@@ -0,0 +1,44 @@
1
+ /**
2
+ * In-grid pivot (Pro). `enablePivot()` registers the pivot ENGINE with the grid
3
+ * so `<SvGrid pivot={...}>` renders a pivot table in place of the flat table -
4
+ * the same grid, aggregated with nested column headers. The engine (which turns
5
+ * rows into a pivot result) is `createPivotModel`; the grid ships the `pivot`
6
+ * prop + config types for free and looks the engine up through the
7
+ * `registerPivotEngine` seam.
8
+ *
9
+ * Soft-gated like the rest of Enterprise: it works without a license key but the
10
+ * grid shows the unlicensed watermark + a one-time console nudge.
11
+ *
12
+ * ```ts
13
+ * import { setLicenseKey, enablePivot } from '@svgrid/enterprise'
14
+ * setLicenseKey('YOUR-KEY')
15
+ * enablePivot()
16
+ * // then: <SvGrid {data} {columns} pivot={{ rows: ['region'], cols: ['quarter'], values: [{ field: 'sales', agg: 'sum' }] }} />
17
+ * ```
18
+ */
19
+ import { registerPivotEngine, type PivotEngine } from '@svgrid/grid'
20
+ import { isLicenseKeySet } from './license'
21
+ import { emitUnlicensedNudge } from './watermark'
22
+ import { createPivotModel, type PivotConfig } from './pivot'
23
+
24
+ let enabled = false
25
+
26
+ // The grid's `PivotEngine` seam is generic over row type; `createPivotModel` is
27
+ // the concrete implementation. Bridge them with a single boundary cast.
28
+ const pivotEngine: PivotEngine = (data, config) =>
29
+ createPivotModel(
30
+ data as ReadonlyArray<Record<string, unknown>>,
31
+ config as unknown as PivotConfig<Record<string, unknown>>,
32
+ ) as never
33
+
34
+ /**
35
+ * Register the Enterprise pivot engine. Idempotent - safe to call from every
36
+ * component that uses `pivot`, or once at app start. Also invoked by
37
+ * {@link installEnterprise} so wiring the Pro API turns pivot on too.
38
+ */
39
+ export function enablePivot(): void {
40
+ if (enabled) return
41
+ enabled = true
42
+ registerPivotEngine(pivotEngine)
43
+ if (!isLicenseKeySet()) emitUnlicensedNudge()
44
+ }
@@ -0,0 +1,97 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import {
3
+ assignedMinutes,
4
+ expandAssignments,
5
+ overallocations,
6
+ resourceLoad,
7
+ type SchedulerAssignment,
8
+ } from './scheduler-assignments'
9
+
10
+ type Ev = { key: string; start: Date; end: Date; resource?: string }
11
+ const D = (h: number) => new Date(2026, 0, 5, h, 0, 0, 0)
12
+ const ev = (key: string, s: number, e: number, resource?: string): Ev => ({ key, start: D(s), end: D(e), resource })
13
+
14
+ const opts = {
15
+ keyOf: (e: Ev) => e.key,
16
+ startOf: (e: Ev) => e.start,
17
+ endOf: (e: Ev) => e.end,
18
+ resourceOf: (e: Ev) => e.resource,
19
+ }
20
+
21
+ describe('expandAssignments', () => {
22
+ it('renders one event once per assigned resource', () => {
23
+ const events = [ev('e1', 9, 11)]
24
+ const assigns: SchedulerAssignment[] = [
25
+ { id: 'a1', eventId: 'e1', resourceId: 'r1' },
26
+ { id: 'a2', eventId: 'e1', resourceId: 'r2' },
27
+ ]
28
+ const out = expandAssignments(events, assigns, opts)
29
+ expect(out.map((a) => a.resourceId).sort()).toEqual(['r1', 'r2'])
30
+ expect(out.every((a) => a.units === 1)).toBe(true)
31
+ })
32
+
33
+ it('dedupes duplicate resource assignments to the max units', () => {
34
+ const out = expandAssignments([ev('e1', 9, 11)], [
35
+ { id: 'a1', eventId: 'e1', resourceId: 'r1', units: 0.5 },
36
+ { id: 'a2', eventId: 'e1', resourceId: 'r1', units: 0.8 },
37
+ ], opts)
38
+ expect(out).toHaveLength(1)
39
+ expect(out[0]!.units).toBe(0.8)
40
+ })
41
+
42
+ it('falls back to resourceOf when the event has no assignment', () => {
43
+ const out = expandAssignments([ev('e1', 9, 11, 'rX')], [], opts)
44
+ expect(out).toEqual([expect.objectContaining({ resourceId: 'rX', units: 1 })])
45
+ })
46
+
47
+ it('drops an event with neither assignment nor resource', () => {
48
+ const out = expandAssignments([ev('e1', 9, 11)], [], opts)
49
+ expect(out).toHaveLength(0)
50
+ })
51
+ })
52
+
53
+ describe('resourceLoad', () => {
54
+ it('sums units of overlapping allocations on the resource', () => {
55
+ const events = [ev('e1', 9, 11), ev('e2', 10, 12)]
56
+ const allocs = expandAssignments(events, [
57
+ { id: 'a1', eventId: 'e1', resourceId: 'r1', units: 1 },
58
+ { id: 'a2', eventId: 'e2', resourceId: 'r1', units: 0.5 },
59
+ ], opts)
60
+ expect(resourceLoad(allocs, 'r1', D(10), D(11))).toBe(1.5) // both overlap 10-11
61
+ expect(resourceLoad(allocs, 'r1', D(9), D(10))).toBe(1) // only e1
62
+ expect(resourceLoad(allocs, 'r2', D(9), D(12))).toBe(0)
63
+ })
64
+ })
65
+
66
+ describe('overallocations', () => {
67
+ it('flags buckets where load exceeds capacity', () => {
68
+ const events = [ev('e1', 9, 12), ev('e2', 9, 12)]
69
+ const allocs = expandAssignments(events, [
70
+ { id: 'a1', eventId: 'e1', resourceId: 'r1' },
71
+ { id: 'a2', eventId: 'e2', resourceId: 'r1' },
72
+ ], opts)
73
+ const buckets = [{ start: D(9), end: D(10) }, { start: D(10), end: D(11) }]
74
+ const over = overallocations(allocs, ['r1'], buckets, 1)
75
+ expect(over).toHaveLength(2) // 2 concurrent > capacity 1, in both buckets
76
+ expect(over[0]).toMatchObject({ resourceId: 'r1', load: 2, capacity: 1 })
77
+ })
78
+
79
+ it('respects a per-resource capacity function', () => {
80
+ const events = [ev('e1', 9, 12), ev('e2', 9, 12)]
81
+ const allocs = expandAssignments(events, [
82
+ { id: 'a1', eventId: 'e1', resourceId: 'r1' },
83
+ { id: 'a2', eventId: 'e2', resourceId: 'r1' },
84
+ ], opts)
85
+ const over = overallocations(allocs, ['r1'], [{ start: D(9), end: D(10) }], () => 2)
86
+ expect(over).toHaveLength(0) // capacity 2 absorbs 2 concurrent
87
+ })
88
+ })
89
+
90
+ describe('assignedMinutes', () => {
91
+ it('totals weighted minutes across allocations', () => {
92
+ const allocs = expandAssignments([ev('e1', 9, 11)], [
93
+ { id: 'a1', eventId: 'e1', resourceId: 'r1', units: 0.5 },
94
+ ], opts)
95
+ expect(assignedMinutes(allocs, 'r1')).toBe(60) // 120 min * 0.5
96
+ })
97
+ })
@@ -0,0 +1,134 @@
1
+ /**
2
+ * scheduler-assignments - the pure core behind Scheduler Pro *multi-assignment*
3
+ * (one event assigned to several resources) and the *resource utilization
4
+ * histogram*. No Svelte, no DOM. Generic over the event shape via accessor
5
+ * callbacks so it needs no dependency on the grid's ResolvedEvent.
6
+ */
7
+
8
+ /** A many-to-many event <-> resource link, with an optional allocation (0..1). */
9
+ export type SchedulerAssignment = {
10
+ id: string
11
+ eventId: string
12
+ resourceId: string
13
+ /** Allocation fraction, default 1 (full). Summed for histogram / overallocation. */
14
+ units?: number
15
+ }
16
+
17
+ /** One event placed on one resource (the flattened render unit). */
18
+ export type Allocation<E = unknown> = {
19
+ event: E
20
+ key: string
21
+ resourceId: string
22
+ units: number
23
+ start: Date
24
+ end: Date
25
+ }
26
+
27
+ export type ExpandOptions<E> = {
28
+ keyOf: (e: E) => string
29
+ startOf: (e: E) => Date
30
+ endOf: (e: E) => Date
31
+ /** Single-resource fallback when an event has no assignment. */
32
+ resourceOf?: (e: E) => string | undefined
33
+ }
34
+
35
+ const MS_MIN = 60_000
36
+
37
+ /**
38
+ * Flatten events into per-resource allocations. An event with assignments renders
39
+ * once per assigned resource (deduped by eventId+resourceId, keeping the max
40
+ * units); an event with none falls back to its single `resourceOf` (units 1), or
41
+ * is dropped when it has neither. The result drives the resource-column bucketing.
42
+ */
43
+ export function expandAssignments<E>(
44
+ events: ReadonlyArray<E>,
45
+ assignments: ReadonlyArray<SchedulerAssignment>,
46
+ opts: ExpandOptions<E>,
47
+ ): Allocation<E>[] {
48
+ // Group assignments by eventId, collapsing duplicate resources to the max units.
49
+ const byEvent = new Map<string, Map<string, number>>()
50
+ for (const a of assignments) {
51
+ if (!a || !a.eventId || !a.resourceId) continue
52
+ let m = byEvent.get(a.eventId)
53
+ if (!m) byEvent.set(a.eventId, (m = new Map()))
54
+ const units = a.units == null ? 1 : a.units
55
+ m.set(a.resourceId, Math.max(m.get(a.resourceId) ?? 0, units))
56
+ }
57
+
58
+ const out: Allocation<E>[] = []
59
+ for (const e of events) {
60
+ const key = opts.keyOf(e)
61
+ const start = opts.startOf(e)
62
+ const end = opts.endOf(e)
63
+ const m = byEvent.get(key)
64
+ if (m && m.size) {
65
+ for (const [resourceId, units] of m) out.push({ event: e, key, resourceId, units, start, end })
66
+ } else {
67
+ const resourceId = opts.resourceOf?.(e)
68
+ if (resourceId != null) out.push({ event: e, key, resourceId, units: 1, start, end })
69
+ }
70
+ }
71
+ return out
72
+ }
73
+
74
+ /** True when `[aStart,aEnd)` overlaps `[bStart,bEnd)`. */
75
+ function overlaps(aStart: Date, aEnd: Date, bStart: Date, bEnd: Date): boolean {
76
+ return aStart.getTime() < bEnd.getTime() && aEnd.getTime() > bStart.getTime()
77
+ }
78
+
79
+ /**
80
+ * Summed allocation units on `resourceId` overlapping the bucket
81
+ * `[bucketStart, bucketEnd)` - the height of one histogram bar.
82
+ */
83
+ export function resourceLoad<E>(
84
+ allocs: ReadonlyArray<Allocation<E>>,
85
+ resourceId: string,
86
+ bucketStart: Date,
87
+ bucketEnd: Date,
88
+ ): number {
89
+ let load = 0
90
+ for (const a of allocs) {
91
+ if (a.resourceId !== resourceId) continue
92
+ if (overlaps(a.start, a.end, bucketStart, bucketEnd)) load += a.units
93
+ }
94
+ return load
95
+ }
96
+
97
+ /** One over-capacity bucket (summed load exceeds the resource capacity). */
98
+ export type Overallocation = { resourceId: string; start: Date; end: Date; load: number; capacity: number }
99
+
100
+ /**
101
+ * Buckets where a resource's summed load exceeds its capacity. `buckets` are the
102
+ * axis intervals to test; `capacity` is a flat number or a per-resource function
103
+ * (default 1). Used to tint histogram bars / flag conflicts.
104
+ */
105
+ export function overallocations<E>(
106
+ allocs: ReadonlyArray<Allocation<E>>,
107
+ resourceIds: ReadonlyArray<string>,
108
+ buckets: ReadonlyArray<{ start: Date; end: Date }>,
109
+ capacity: number | ((resourceId: string) => number) = 1,
110
+ ): Overallocation[] {
111
+ const capOf = typeof capacity === 'function' ? capacity : () => capacity
112
+ const out: Overallocation[] = []
113
+ for (const resourceId of resourceIds) {
114
+ const cap = capOf(resourceId)
115
+ for (const b of buckets) {
116
+ const load = resourceLoad(allocs, resourceId, b.start, b.end)
117
+ if (load > cap + 1e-9) out.push({ resourceId, start: b.start, end: b.end, load, capacity: cap })
118
+ }
119
+ }
120
+ return out
121
+ }
122
+
123
+ /**
124
+ * Total assigned minutes per resource across `allocs` (a simple utilization
125
+ * roll-up for a legend / header, independent of buckets).
126
+ */
127
+ export function assignedMinutes<E>(allocs: ReadonlyArray<Allocation<E>>, resourceId: string): number {
128
+ let mins = 0
129
+ for (const a of allocs) {
130
+ if (a.resourceId !== resourceId) continue
131
+ mins += ((a.end.getTime() - a.start.getTime()) / MS_MIN) * a.units
132
+ }
133
+ return mins
134
+ }
@@ -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
+ })