@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
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,
@@ -125,6 +201,7 @@ export {
125
201
  type PlanOp,
126
202
  type KitDataSourceOptions,
127
203
  type KitHandlerOptions,
204
+ type KitScope,
128
205
  type KitHandlers,
129
206
  type KitMessage,
130
207
  type SqlDialect,
@@ -141,6 +218,7 @@ export {
141
218
  dummyJsonAdapter,
142
219
  jsonServerAdapter,
143
220
  introspectSupabaseTable,
221
+ listSupabaseTables,
144
222
  createSupabaseRealtime,
145
223
  createRelationLookup,
146
224
  createSupabaseAuth,
@@ -240,9 +318,21 @@ export {
240
318
  emitStudioApp,
241
319
  sampleApps,
242
320
  getSampleApp,
321
+ starterProject,
243
322
  liveDataSamples,
244
323
  getLiveDataSample,
245
324
  type SampleApp,
325
+ starterDatasets,
326
+ getStarterDataset,
327
+ type StarterDataset,
328
+ crudSuiteScreens,
329
+ addCrudSuite,
330
+ crudAppFromSchemas,
331
+ type CrudScreenKind,
332
+ type CrudEditingMode,
333
+ type CrudSuiteOptions,
334
+ type CrudAppOptions,
335
+ introspectJson,
246
336
  sanitizeStudioProject,
247
337
  buildStudioBugReport,
248
338
  type BugReport,
@@ -360,50 +450,106 @@ export {
360
450
  type Well,
361
451
  } from './pivot-designer'
362
452
 
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'
453
+ // The AI helpers (setAIProvider, aiFilter, aiSmartFill, aiSummarize, aiClassify,
454
+ // aiExport, aiFindAnomalies, aiChart, enableAiCharting, ...) are now BUILT-IN and
455
+ // FREE in @svgrid/grid - import them from there. Enterprise only registers its
456
+ // export engine so AI-planned Excel/PDF exports can run (see install.ts).
409
457
  export { pivotToChartSpec, type PivotChartOptions } from './pivot-chart'
458
+
459
+ // Expression / query language (Pro). One predicate/scalar/change model that
460
+ // powers alerts (and, in time, styled + calculated columns). Leaf comparisons
461
+ // reuse the grid's own `applyExcelFilter`, so operators match the filter row.
462
+ export {
463
+ evaluatePredicate,
464
+ evaluateScalar,
465
+ evaluateChange,
466
+ BUILTIN_FUNCTION_NAMES,
467
+ isBuiltinFunction,
468
+ } from './expressions/evaluate'
469
+ export {
470
+ parsePredicate,
471
+ stringifyPredicate,
472
+ stringifyScalar,
473
+ validateExpression,
474
+ collectColumnRefs,
475
+ ExpressionParseError,
476
+ } from './expressions/parse'
477
+ export {
478
+ OPERATORS,
479
+ operatorMeta,
480
+ operatorsForType,
481
+ isValueless,
482
+ isSetOperator,
483
+ isRangeOperator,
484
+ columnById,
485
+ resolveColumnRef,
486
+ columnLabel,
487
+ columnRefText,
488
+ type ExprColumn,
489
+ type ExprColumnType,
490
+ type OperatorMeta,
491
+ } from './expressions/expression-columns'
492
+ export type {
493
+ ScalarExpr,
494
+ PredicateExpr,
495
+ ChangeExpr,
496
+ ComparisonOp,
497
+ ArithmeticOp,
498
+ AggFn,
499
+ EvalContext,
500
+ ExprRow,
501
+ } from './expressions/expression-types'
502
+ export { default as SvExpressionEditor } from './SvExpressionEditor.svelte'
503
+
504
+ // Alert Rules engine (Pro). Declarative "when this holds / this moves -> toast /
505
+ // badge / highlight / flash / prevent-edit / log". Authored at runtime,
506
+ // persisted, shareable. Surfacing reuses the grid's toast + conditional-format
507
+ // engines; `<SvGridAlerts>` is the runtime overlay the consumer mounts.
508
+ export { enableAlerts } from './alerts'
509
+ export {
510
+ createAlertEngine,
511
+ renderTemplate,
512
+ type AlertEngine,
513
+ type AlertEngineOptions,
514
+ type ValidateEditResult,
515
+ } from './alerts/alert-engine'
516
+ export {
517
+ attachAlertEngine,
518
+ applyAlertEvents,
519
+ type AlertAttachment,
520
+ type AttachAlertOptions,
521
+ type FlashTarget,
522
+ } from './alerts/alert-engine-attach'
523
+ export {
524
+ memoryAlertRules,
525
+ localStorageAlertRules,
526
+ createAlertRules,
527
+ type AlertRulesStorage,
528
+ type AlertRulesManager,
529
+ } from './alerts/alert-storage'
530
+ export {
531
+ createAlertObserver,
532
+ type AlertObserver,
533
+ type AlertObserverOptions,
534
+ } from './alerts/alert-observer'
535
+ export {
536
+ createAlertScheduler,
537
+ type AlertScheduler,
538
+ type AlertSchedulerMode,
539
+ } from './alerts/alert-scheduler'
540
+ export { toConditionalFormats, rulesToConditionalFormats, type StylingContext } from './alerts/alert-formats'
541
+ export { alertStore } from './alerts/alert-store.svelte'
542
+ export type {
543
+ AlertRule,
544
+ AlertEvent,
545
+ AlertTrigger,
546
+ AlertAction,
547
+ AlertActionKind,
548
+ AlertActionStyle,
549
+ AlertScope,
550
+ AlertSeverity,
551
+ } from './alerts/alert-types'
552
+ export { default as SvGridAlerts } from './SvGridAlerts.svelte'
553
+ export { default as SvAlertRuleEditor } from './SvAlertRuleEditor.svelte'
554
+ export { default as SvAlertsManager } from './SvAlertsManager.svelte'
555
+ 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
+ }
package/src/pivot.test.ts CHANGED
@@ -8,7 +8,6 @@ import {
8
8
  filterCollapsedPivotRows,
9
9
  pivotAggregators,
10
10
  type PivotConfig,
11
- type PivotRow,
12
11
  } from './pivot'
13
12
 
14
13
  type Sale = {
@@ -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
+ }