@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.
- package/README.md +18 -6
- package/dist/cdn/svgrid-enterprise.svelte-external.js +14024 -6835
- package/dist/node/studio.js +7888 -2459
- package/package.json +9 -4
- package/src/SvGridMasterDetail.svelte +24 -3
- package/src/SvGridScheduler.svelte +4410 -0
- package/src/SvPivotDesigner.svelte +1990 -1045
- package/src/SvSchemaChart.svelte +10 -9
- package/src/ai.test.ts +522 -522
- package/src/ai.ts +202 -2
- package/src/index.ts +409 -384
- package/src/install.ts +10 -0
- package/src/pivot-chart.test.ts +86 -0
- package/src/pivot-chart.ts +112 -0
- package/src/scheduler.ts +37 -0
- package/src/scheduling.test.ts +194 -0
- package/src/scheduling.ts +293 -0
- package/src/sources/filters.ts +6 -0
- package/src/studio/HANDLERS-DESIGN.md +142 -0
- package/src/studio/cli.ts +7 -2
- package/src/studio/emit-project.test.ts +1447 -13
- package/src/studio/emit-project.ts +3995 -1273
- package/src/studio/emit-schema.ts +146 -29
- package/src/studio/index.ts +320 -195
- package/src/studio/project.test.ts +370 -0
- package/src/studio/project.ts +1146 -26
- package/src/studio/sample-data.ts +4 -1
- package/src/studio/samples/ats.ts +2 -2
- package/src/studio/samples/clinic.ts +4 -2
- package/src/studio/samples/crm.ts +16 -8
- package/src/studio/samples/events.ts +4 -2
- package/src/studio/samples/fleet.ts +4 -2
- package/src/studio/samples/gym.ts +4 -2
- package/src/studio/samples/hr.ts +3 -1
- package/src/studio/samples/live-data.ts +308 -308
- package/src/studio/samples/projects.ts +2 -2
- package/src/studio/samples/restaurant.ts +4 -2
- package/src/studio/samples/samples.test.ts +13 -5
- package/src/studio/samples/shared.ts +346 -305
- package/src/studio/samples/support.ts +3 -1
- package/src/studio/scaffold.test.ts +15 -1
- package/src/studio/scaffold.ts +16 -0
- package/src/studio/themes.ts +7 -0
- package/src/studio/ui-components.ts +472 -0
- package/src/sveltekit/transport.test.ts +26 -0
- package/src/sveltekit/transport.ts +50 -5
- package/dist/designer/assets/index-Dp44bTid.js +0 -939
- package/dist/designer/assets/index-RJp6x8tw.css +0 -1
- package/dist/designer/assets/jszip.min-CjMo-QGg.js +0 -2
- 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
|
+
}
|
package/src/scheduler.ts
ADDED
|
@@ -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
|
+
})
|