@svgrid/enterprise 2.0.4 → 2.2.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 (50) hide show
  1. package/README.md +18 -6
  2. package/dist/cdn/svgrid-enterprise.svelte-external.js +14024 -6835
  3. package/dist/node/studio.js +7888 -2459
  4. package/package.json +9 -4
  5. package/src/SvGridMasterDetail.svelte +24 -3
  6. package/src/SvGridScheduler.svelte +4410 -0
  7. package/src/SvPivotDesigner.svelte +1990 -1045
  8. package/src/SvSchemaChart.svelte +10 -9
  9. package/src/ai.test.ts +522 -522
  10. package/src/ai.ts +202 -2
  11. package/src/index.ts +409 -384
  12. package/src/install.ts +10 -0
  13. package/src/pivot-chart.test.ts +86 -0
  14. package/src/pivot-chart.ts +112 -0
  15. package/src/scheduler.ts +37 -0
  16. package/src/scheduling.test.ts +194 -0
  17. package/src/scheduling.ts +293 -0
  18. package/src/sources/filters.ts +6 -0
  19. package/src/studio/HANDLERS-DESIGN.md +142 -0
  20. package/src/studio/cli.ts +7 -2
  21. package/src/studio/emit-project.test.ts +1447 -13
  22. package/src/studio/emit-project.ts +3995 -1273
  23. package/src/studio/emit-schema.ts +146 -29
  24. package/src/studio/index.ts +320 -195
  25. package/src/studio/project.test.ts +370 -0
  26. package/src/studio/project.ts +1146 -26
  27. package/src/studio/sample-data.ts +4 -1
  28. package/src/studio/samples/ats.ts +2 -2
  29. package/src/studio/samples/clinic.ts +4 -2
  30. package/src/studio/samples/crm.ts +16 -8
  31. package/src/studio/samples/events.ts +4 -2
  32. package/src/studio/samples/fleet.ts +4 -2
  33. package/src/studio/samples/gym.ts +4 -2
  34. package/src/studio/samples/hr.ts +3 -1
  35. package/src/studio/samples/live-data.ts +308 -308
  36. package/src/studio/samples/projects.ts +2 -2
  37. package/src/studio/samples/restaurant.ts +4 -2
  38. package/src/studio/samples/samples.test.ts +13 -5
  39. package/src/studio/samples/shared.ts +346 -305
  40. package/src/studio/samples/support.ts +3 -1
  41. package/src/studio/scaffold.test.ts +15 -1
  42. package/src/studio/scaffold.ts +16 -0
  43. package/src/studio/themes.ts +7 -0
  44. package/src/studio/ui-components.ts +472 -0
  45. package/src/sveltekit/transport.test.ts +26 -0
  46. package/src/sveltekit/transport.ts +50 -5
  47. package/dist/designer/assets/index-Dp44bTid.js +0 -939
  48. package/dist/designer/assets/index-RJp6x8tw.css +0 -1
  49. package/dist/designer/assets/jszip.min-CjMo-QGg.js +0 -2
  50. package/dist/designer/index.html +0 -13
package/src/install.ts CHANGED
@@ -12,6 +12,8 @@ import { isLicenseKeySet } from './license'
12
12
  import { emitUnlicensedNudge } from './watermark'
13
13
  import {
14
14
  aiFilter, aiSmartFill, aiSummarize, aiClassify, aiExport, aiFindAnomalies,
15
+ aiChart, enableAiCharting,
16
+ type AIChartOptions, type AIChartPlan,
15
17
  type AIFilterOptions, type AIFilterResult,
16
18
  type AISmartFillOptions, type AISmartFillResult,
17
19
  type AISummarizeOptions, type AISummary,
@@ -24,6 +26,7 @@ import {
24
26
  type PivotConfig,
25
27
  type PivotResult,
26
28
  } from './pivot'
29
+ import { enableSchedulerView } from './scheduler'
27
30
 
28
31
  export type EnterpriseAIApi<TData extends RowData> = {
29
32
  /** Natural-language -> filter + sort plan (and optionally apply it). */
@@ -38,6 +41,8 @@ export type EnterpriseAIApi<TData extends RowData> = {
38
41
  export(query: string, opts?: AIExportOptions): Promise<AIExportPlan>
39
42
  /** Scan a slice for anomalies / outliers, returning a structured list. */
40
43
  findAnomalies(opts?: AIAnomalyOptions): Promise<AIAnomalyResult>
44
+ /** Natural-language chart: "revenue by region, stacked by product". */
45
+ chart(query: string, opts?: AIChartOptions): Promise<AIChartPlan>
41
46
  // TData is referenced so the type stays bound to the row shape even
42
47
  // though the helpers all read through `api.getData()`. Lets callers
43
48
  // get correct inference downstream without explicit generics.
@@ -117,7 +122,12 @@ export function installEnterprise<
117
122
  classify: (opts) => aiClassify(pro, opts),
118
123
  export: (query, opts) => aiExport(pro, query, opts),
119
124
  findAnomalies: (opts) => aiFindAnomalies(pro, opts),
125
+ chart: (query, opts) => aiChart(pro, query, opts),
120
126
  }
127
+ // Wire the built-in chart panel's AI button (no-op without the `charting` prop).
128
+ enableAiCharting(pro)
129
+ // Register the scheduler / calendar view (no-op without the `scheduler` prop).
130
+ enableSchedulerView()
121
131
  pro.pivot = {
122
132
  build: (config) =>
123
133
  createPivotModel<TFeatures, TData>(pro.getData(), config),
@@ -0,0 +1,86 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { createPivotModel } from './pivot'
3
+ import { pivotToChartSpec } from './pivot-chart'
4
+
5
+ const data = [
6
+ { region: 'EMEA', product: 'A', revenue: 100 },
7
+ { region: 'EMEA', product: 'B', revenue: 200 },
8
+ { region: 'APAC', product: 'A', revenue: 50 },
9
+ { region: 'APAC', product: 'B', revenue: 70 },
10
+ ]
11
+
12
+ describe('pivotToChartSpec', () => {
13
+ it('maps row leaves to categories and column leaves to series', () => {
14
+ const result = createPivotModel(data, {
15
+ rows: ['region'],
16
+ cols: ['product'],
17
+ values: [{ field: 'revenue', agg: 'sum' }],
18
+ grandTotalRow: false,
19
+ grandTotalCol: false,
20
+ })
21
+ const spec = pivotToChartSpec(result, { type: 'bar' })
22
+ expect(spec.type).toBe('bar')
23
+ expect([...spec.categories].sort()).toEqual(['APAC', 'EMEA'])
24
+ // One series per product.
25
+ expect(spec.series.map((s) => s.label).sort()).toEqual(['A', 'B'])
26
+ const emeaIdx = spec.categories.indexOf('EMEA')
27
+ expect(spec.series.find((s) => s.label === 'A')!.values[emeaIdx]).toBe(100)
28
+ expect(spec.series.find((s) => s.label === 'B')!.values[emeaIdx]).toBe(200)
29
+ })
30
+
31
+ it('excludes the grand-total column / row and stacks when asked', () => {
32
+ const result = createPivotModel(data, {
33
+ rows: ['region'],
34
+ cols: ['product'],
35
+ values: [{ field: 'revenue', agg: 'sum' }],
36
+ grandTotalRow: true,
37
+ grandTotalCol: true,
38
+ })
39
+ const spec = pivotToChartSpec(result, { stacked: true })
40
+ expect(spec.series.some((s) => /total/i.test(s.label))).toBe(false)
41
+ expect(spec.categories.some((c) => /total/i.test(c))).toBe(false)
42
+ expect(spec.stacked).toBe(true)
43
+ })
44
+
45
+ it('labels nested row leaves by their full path', () => {
46
+ const nested = [
47
+ { region: 'EMEA', country: 'UK', revenue: 10 },
48
+ { region: 'EMEA', country: 'France', revenue: 20 },
49
+ { region: 'APAC', country: 'Japan', revenue: 30 },
50
+ ]
51
+ const result = createPivotModel(nested, {
52
+ rows: ['region', 'country'],
53
+ cols: [],
54
+ values: [{ field: 'revenue', agg: 'sum', label: 'Revenue' }],
55
+ grandTotalRow: false,
56
+ grandTotalCol: false,
57
+ rowSubtotals: false,
58
+ })
59
+ const spec = pivotToChartSpec(result)
60
+ // Leaf categories are the own labels...
61
+ expect(spec.categories).toContain('UK')
62
+ expect(spec.categories).toContain('France')
63
+ expect(spec.categories).toContain('Japan')
64
+ // ...and a parent group tier spans them.
65
+ expect(spec.categoryGroups).toBeTruthy()
66
+ const spans = spec.categoryGroups!.reduce((a, g) => a + g.span, 0)
67
+ expect(spans).toBe(spec.categories.length)
68
+ expect(spec.categoryGroups!.map((g) => g.label).sort()).toEqual(['APAC', 'EMEA'])
69
+ expect(spec.categoryGroups!.find((g) => g.label === 'EMEA')!.span).toBe(2)
70
+ })
71
+
72
+ it('handles no column dims (one series per measure)', () => {
73
+ const result = createPivotModel(data, {
74
+ rows: ['region'],
75
+ cols: [],
76
+ values: [{ field: 'revenue', agg: 'sum', label: 'Revenue' }],
77
+ grandTotalRow: false,
78
+ grandTotalCol: false,
79
+ })
80
+ const spec = pivotToChartSpec(result)
81
+ expect(spec.series.length).toBe(1)
82
+ expect(spec.series[0]!.label).toBe('Revenue')
83
+ const emeaIdx = spec.categories.indexOf('EMEA')
84
+ expect(spec.series[0]!.values[emeaIdx]).toBe(300)
85
+ })
86
+ })
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Pivot -> chart bridge.
3
+ *
4
+ * Turns a computed {@link PivotResult} (the row/column/value matrix the pivot
5
+ * engine produces) into a core {@link ChartSpec} the free `SvGridChart` can
6
+ * render - so one pivot layout drives both a table and a chart (the Excel
7
+ * PivotTable <-> PivotChart pairing).
8
+ *
9
+ * Mapping:
10
+ * - categories = the row-axis LEAF rows (deepest grouping; subtotals and the
11
+ * grand-total row are skipped). With no row dims, the single total row.
12
+ * - series = the column-axis leaf value columns (one per col-path x
13
+ * measure). The grand-total column is excluded unless `includeTotals`.
14
+ * - values = each leaf row's aggregated cell for that series column.
15
+ */
16
+ import type { ChartSpec, ChartType, TableFeatures } from '@svgrid/grid'
17
+ import type { PivotResult } from './pivot'
18
+
19
+ export type PivotChartOptions = {
20
+ /** Chart type. Default `'bar'`. */
21
+ type?: ChartType
22
+ /** Stack the series. Default false. */
23
+ stacked?: boolean
24
+ /** Include the grand-total column / row as a series / category. Default false. */
25
+ includeTotals?: boolean
26
+ /** Cap the number of categories (row leaves) charted. */
27
+ maxCategories?: number
28
+ }
29
+
30
+ type LooseColumn = {
31
+ id?: string
32
+ header?: unknown
33
+ columns?: LooseColumn[]
34
+ }
35
+
36
+ export function pivotToChartSpec<TFeatures extends TableFeatures>(
37
+ result: PivotResult<TFeatures>,
38
+ opts: PivotChartOptions = {},
39
+ ): ChartSpec {
40
+ const type: ChartType = opts.type ?? 'bar'
41
+
42
+ // Category rows: the row-axis leaves. Fall back to the grand-total (single
43
+ // "All") row when no row dims are configured, then to every row.
44
+ let catRows = result.rows.filter((r) => r.__pivotKind === 'leaf')
45
+ if (!catRows.length) catRows = result.rows.filter((r) => r.__pivotKind === 'grandTotal')
46
+ if (!catRows.length) catRows = result.rows.slice()
47
+ if (opts.maxCategories && catRows.length > opts.maxCategories) {
48
+ catRows = catRows.slice(0, opts.maxCategories)
49
+ }
50
+ // Categories are the leaf's OWN label; when rows are nested, emit a parent
51
+ // tier (categoryGroups) so the axis reads "Americas | Canada · USA ..." as a
52
+ // grouped axis rather than colliding on a bare repeated leaf label.
53
+ const byId = new Map(result.rows.map((r) => [r.__pivotId, r]))
54
+ const ownLabel = (r: (typeof result.rows)[number]) => String(r.__pivotLabel ?? '').trim() || '(blank)'
55
+ const categories = catRows.map(ownLabel)
56
+
57
+ const parentOf = (r: (typeof result.rows)[number]) =>
58
+ r.__pivotParentId ? byId.get(r.__pivotParentId) : undefined
59
+ const nested = catRows.length > 0 && catRows.every((r) => parentOf(r) !== undefined)
60
+ let categoryGroups: Array<{ label: string; span: number }> | undefined
61
+ if (nested) {
62
+ categoryGroups = []
63
+ let prevId: string | null | undefined
64
+ for (const r of catRows) {
65
+ const pid = r.__pivotParentId
66
+ const last = categoryGroups[categoryGroups.length - 1]
67
+ if (last && pid === prevId) last.span += 1
68
+ else {
69
+ categoryGroups.push({ label: ownLabel(parentOf(r)!), span: 1 })
70
+ prevId = pid
71
+ }
72
+ }
73
+ }
74
+
75
+ // Collect the leaf value columns, carrying the accumulated column-group path
76
+ // so a series can be labelled by its column path (e.g. "Q1", "Q1 · Revenue").
77
+ type Leaf = { id: string; path: string[]; header: string }
78
+ const leaves: Leaf[] = []
79
+ const walk = (cols: LooseColumn[] | undefined, path: string[]): void => {
80
+ for (const c of cols ?? []) {
81
+ if (!c || c.id === '__pivotRowHeader') continue
82
+ // Skip the grand-total column group (and its leaves) unless asked for.
83
+ if (!opts.includeTotals && typeof c.id === 'string' && c.id.startsWith('pv_group__grand')) continue
84
+ if (Array.isArray(c.columns)) walk(c.columns, [...path, String(c.header ?? '')])
85
+ else if (typeof c.id === 'string') leaves.push({ id: c.id, path, header: String(c.header ?? '') })
86
+ }
87
+ }
88
+ walk(result.columns as unknown as LooseColumn[], [])
89
+
90
+ const filtered = opts.includeTotals ? leaves : leaves.filter((l) => !l.id.includes('__total'))
91
+ const uniqueHeaders = new Set(filtered.map((l) => l.header))
92
+ const series = filtered.map((l) => ({
93
+ label:
94
+ l.path.length === 0
95
+ ? l.header
96
+ : uniqueHeaders.size > 1
97
+ ? [...l.path, l.header].join(' · ')
98
+ : l.path.join(' · '),
99
+ values: catRows.map((r) => {
100
+ const v = r[l.id]
101
+ return typeof v === 'number' ? v : Number(v) || 0
102
+ }),
103
+ }))
104
+
105
+ return {
106
+ type,
107
+ categories,
108
+ series,
109
+ ...(categoryGroups && categoryGroups.length > 1 ? { categoryGroups } : {}),
110
+ ...(opts.stacked ? { stacked: true } : {}),
111
+ }
112
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * scheduler (view) - registers the Enterprise calendar / scheduler renderer with
3
+ * the grid so `<SvGrid scheduler={...}>` renders a full Month / Week / Day /
4
+ * Agenda calendar instead of the table. The grid ships the `scheduler` prop and
5
+ * its config types for free; the *renderer* (SvGridScheduler) is Pro and plugs
6
+ * in through the grid's `registerSchedulerView` seam.
7
+ *
8
+ * Soft-gated like the rest of Enterprise: it works without a license key but the
9
+ * grid shows the "unlicensed" watermark + a one-time console nudge.
10
+ *
11
+ * ```ts
12
+ * import { setLicenseKey, enableSchedulerView } from '@svgrid/enterprise'
13
+ * setLicenseKey('YOUR-KEY')
14
+ * enableSchedulerView()
15
+ * // then: <SvGrid {data} {columns} scheduler={{ startField: 'start', endField: 'end' }} />
16
+ * ```
17
+ */
18
+ import { registerSchedulerView } from '@svgrid/grid'
19
+ import { isLicenseKeySet } from './license'
20
+ import { emitUnlicensedNudge } from './watermark'
21
+ import SvGridScheduler from './SvGridScheduler.svelte'
22
+
23
+ let enabled = false
24
+
25
+ /**
26
+ * Register the Enterprise scheduler view. Idempotent - safe to call from every
27
+ * component that uses `scheduler`, or once at app start. Also invoked by
28
+ * {@link installEnterprise} so wiring the Pro API turns the view on too.
29
+ */
30
+ export function enableSchedulerView(): void {
31
+ if (enabled) return
32
+ enabled = true
33
+ registerSchedulerView(SvGridScheduler as never)
34
+ if (!isLicenseKeySet()) emitUnlicensedNudge()
35
+ }
36
+
37
+ export { default as SvGridScheduler } from './SvGridScheduler.svelte'
@@ -0,0 +1,194 @@
1
+ import { describe, expect, it, vi } from 'vitest'
2
+ import {
3
+ parseCron,
4
+ cronMatches,
5
+ isScheduleDue,
6
+ nextRun,
7
+ createScheduler,
8
+ CRON_PRESETS,
9
+ type Schedule,
10
+ } from './scheduling'
11
+
12
+ // A fixed reference instant: Monday 2026-07-27, 17:30 local time.
13
+ // (2026-07-27 is a Monday; getDay() === 1.)
14
+ const MON_1730 = new Date(2026, 6, 27, 17, 30, 0)
15
+
16
+ describe('parseCron', () => {
17
+ it('rejects the wrong field count', () => {
18
+ expect(() => parseCron('* * * *')).toThrow(/5 fields/)
19
+ expect(() => parseCron('0 9 * * * *')).toThrow(/5 fields/)
20
+ })
21
+
22
+ it('rejects an unparseable step', () => {
23
+ expect(() => parseCron('*/0 * * * *')).toThrow(/step/)
24
+ })
25
+ })
26
+
27
+ describe('cronMatches', () => {
28
+ it('matches an exact minute/hour', () => {
29
+ expect(cronMatches('30 17 * * *', MON_1730)).toBe(true)
30
+ expect(cronMatches('31 17 * * *', MON_1730)).toBe(false)
31
+ expect(cronMatches('30 16 * * *', MON_1730)).toBe(false)
32
+ })
33
+
34
+ it('matches weekday ranges (1-5 = Mon-Fri)', () => {
35
+ expect(cronMatches('30 17 * * 1-5', MON_1730)).toBe(true)
36
+ const sun = new Date(2026, 6, 26, 17, 30) // Sunday
37
+ expect(cronMatches('30 17 * * 1-5', sun)).toBe(false)
38
+ })
39
+
40
+ it('accepts 7 as an alias for Sunday', () => {
41
+ const sun = new Date(2026, 6, 26, 9, 0)
42
+ expect(cronMatches('0 9 * * 7', sun)).toBe(true)
43
+ expect(cronMatches('0 9 * * 0', sun)).toBe(true)
44
+ })
45
+
46
+ it('honors step values (*/15)', () => {
47
+ expect(cronMatches('*/15 * * * *', new Date(2026, 6, 27, 10, 0))).toBe(true)
48
+ expect(cronMatches('*/15 * * * *', new Date(2026, 6, 27, 10, 15))).toBe(true)
49
+ expect(cronMatches('*/15 * * * *', new Date(2026, 6, 27, 10, 7))).toBe(false)
50
+ })
51
+
52
+ it('honors lists (0,30)', () => {
53
+ expect(cronMatches('0,30 * * * *', new Date(2026, 6, 27, 10, 0))).toBe(true)
54
+ expect(cronMatches('0,30 * * * *', new Date(2026, 6, 27, 10, 30))).toBe(true)
55
+ expect(cronMatches('0,30 * * * *', new Date(2026, 6, 27, 10, 15))).toBe(false)
56
+ })
57
+
58
+ it('uses UNION semantics when both day-of-month and day-of-week are set', () => {
59
+ // Fires on the 1st OR on any Monday. 2026-07-27 is a Monday (not the 1st).
60
+ expect(cronMatches('0 9 1 * 1', new Date(2026, 6, 27, 9, 0))).toBe(true)
61
+ // 2026-07-15 is a Wednesday and not the 1st -> neither matches.
62
+ expect(cronMatches('0 9 1 * 1', new Date(2026, 6, 15, 9, 0))).toBe(false)
63
+ // The 1st of the month, a Wednesday -> dom matches.
64
+ expect(cronMatches('0 9 1 * 1', new Date(2026, 6, 1, 9, 0))).toBe(true)
65
+ })
66
+
67
+ it('every preset parses and matches at least one time this year', () => {
68
+ for (const p of CRON_PRESETS) {
69
+ expect(() => parseCron(p.cron)).not.toThrow()
70
+ expect(nextRun({ id: p.label, cron: p.cron }, MON_1730)).toBeInstanceOf(Date)
71
+ }
72
+ })
73
+ })
74
+
75
+ describe('isScheduleDue', () => {
76
+ it('is false when disabled', () => {
77
+ expect(isScheduleDue({ id: 'a', cron: '30 17 * * *', enabled: false }, MON_1730)).toBe(false)
78
+ })
79
+
80
+ it('fires a one-off in its exact minute only', () => {
81
+ const s: Schedule = { id: 'once', runAt: MON_1730.toISOString() }
82
+ expect(isScheduleDue(s, MON_1730)).toBe(true)
83
+ expect(isScheduleDue(s, new Date(2026, 6, 27, 17, 31))).toBe(false)
84
+ })
85
+
86
+ it('prefers runAt over cron when both are present', () => {
87
+ const s: Schedule = { id: 'both', runAt: MON_1730.toISOString(), cron: '0 0 * * *' }
88
+ expect(isScheduleDue(s, MON_1730)).toBe(true)
89
+ expect(isScheduleDue(s, new Date(2026, 6, 27, 0, 0))).toBe(false)
90
+ })
91
+ })
92
+
93
+ describe('nextRun', () => {
94
+ it('finds the next cron occurrence', () => {
95
+ const from = new Date(2026, 6, 27, 17, 31) // just after 17:30
96
+ const next = nextRun({ id: 'eod', cron: '30 17 * * 1-5' }, from)
97
+ // Next weekday 17:30 is Tuesday the 28th.
98
+ expect(next).toEqual(new Date(2026, 6, 28, 17, 30))
99
+ })
100
+
101
+ it('returns null for a one-off already in the past', () => {
102
+ const past = new Date(2020, 0, 1, 0, 0).toISOString()
103
+ expect(nextRun({ id: 'old', runAt: past }, MON_1730)).toBeNull()
104
+ })
105
+
106
+ it('returns the one-off instant when still in the future', () => {
107
+ const future = new Date(2026, 6, 27, 18, 0)
108
+ expect(nextRun({ id: 'soon', runAt: future.toISOString() }, MON_1730)).toEqual(future)
109
+ })
110
+ })
111
+
112
+ describe('createScheduler', () => {
113
+ it('fires a due schedule exactly once per minute across repeated ticks', () => {
114
+ const onFire = vi.fn()
115
+ let clock = MON_1730
116
+ const scheduler = createScheduler({
117
+ schedules: [{ id: 'eod', cron: '30 17 * * 1-5' }],
118
+ onFire,
119
+ now: () => clock,
120
+ })
121
+ // Two ticks in the same minute (the 30s interval fires twice a minute).
122
+ scheduler.tick()
123
+ scheduler.tick()
124
+ expect(onFire).toHaveBeenCalledTimes(1)
125
+ expect(onFire).toHaveBeenCalledWith(expect.objectContaining({ id: 'eod' }), clock)
126
+
127
+ // Advance a minute past the match: no new fire.
128
+ clock = new Date(2026, 6, 27, 17, 31)
129
+ scheduler.tick()
130
+ expect(onFire).toHaveBeenCalledTimes(1)
131
+ })
132
+
133
+ it('fires a one-off only once, ever', () => {
134
+ const onFire = vi.fn()
135
+ let clock = MON_1730
136
+ const scheduler = createScheduler({
137
+ schedules: [{ id: 'once', runAt: MON_1730.toISOString() }],
138
+ onFire,
139
+ now: () => clock,
140
+ })
141
+ scheduler.tick()
142
+ // Same minute again, and a later matching-looking minute: still once.
143
+ scheduler.tick()
144
+ clock = new Date(MON_1730.getTime() + 60_000)
145
+ scheduler.tick()
146
+ expect(onFire).toHaveBeenCalledTimes(1)
147
+ })
148
+
149
+ it('skips disabled schedules', () => {
150
+ const onFire = vi.fn()
151
+ const scheduler = createScheduler({
152
+ schedules: [{ id: 'x', cron: '30 17 * * *', enabled: false }],
153
+ onFire,
154
+ now: () => MON_1730,
155
+ })
156
+ scheduler.tick()
157
+ expect(onFire).not.toHaveBeenCalled()
158
+ })
159
+
160
+ it('start()/stop() drive ticks on the interval', () => {
161
+ vi.useFakeTimers()
162
+ try {
163
+ const onFire = vi.fn()
164
+ const scheduler = createScheduler({
165
+ schedules: [{ id: 'min', cron: '* * * * *' }],
166
+ onFire,
167
+ now: () => new Date(2026, 6, 27, 17, 30, 0),
168
+ intervalMs: 30_000,
169
+ })
170
+ scheduler.start()
171
+ vi.advanceTimersByTime(30_000)
172
+ expect(onFire).toHaveBeenCalledTimes(1) // once per minute despite 2 ticks
173
+ scheduler.stop()
174
+ vi.advanceTimersByTime(120_000)
175
+ expect(onFire).toHaveBeenCalledTimes(1) // stopped: no more fires
176
+ } finally {
177
+ vi.useRealTimers()
178
+ }
179
+ })
180
+
181
+ it('upcoming() reports the next run per schedule', () => {
182
+ const scheduler = createScheduler({
183
+ schedules: [
184
+ { id: 'eod', cron: '30 17 * * 1-5' },
185
+ { id: 'old', runAt: new Date(2020, 0, 1).toISOString() },
186
+ ],
187
+ onFire: () => {},
188
+ now: () => new Date(2026, 6, 27, 17, 31),
189
+ })
190
+ const up = scheduler.upcoming()
191
+ expect(up.find((u) => u.id === 'eod')?.at).toEqual(new Date(2026, 6, 28, 17, 30))
192
+ expect(up.find((u) => u.id === 'old')?.at).toBeNull()
193
+ })
194
+ })