@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
@@ -0,0 +1,107 @@
1
+ /**
2
+ * scheduler-resource-tree - the pure core behind Scheduler Pro *grouped / tree
3
+ * resources*. No Svelte, no DOM. Resources are flat in the base grid; this builds
4
+ * a hierarchy of collapsible GROUP header rows (departments -> teams -> people)
5
+ * interleaved with the resource rows, so the timeline gutter can render a tree.
6
+ *
7
+ * Groups nest via `parentId`; each resource is mapped to a group by a `groupOf`
8
+ * accessor. Resources whose group is unknown / unset fall into a trailing
9
+ * "ungrouped" section (rendered without a header). Collapsing a group drops its
10
+ * whole subtree from the output (the header stays).
11
+ */
12
+
13
+ /** A group node in the resource tree. `parentId` nests it under another group. */
14
+ export type SchedulerResourceGroup = {
15
+ id: string
16
+ title: string
17
+ parentId?: string
18
+ color?: string
19
+ }
20
+
21
+ /** One rendered row: a group header, or a leaf resource. */
22
+ export type ResourceRow<R = unknown> = {
23
+ kind: 'group' | 'resource'
24
+ id: string
25
+ title: string
26
+ /** Indent depth (0 = top level). */
27
+ depth: number
28
+ color?: string
29
+ /** Present for `kind: 'resource'`. */
30
+ resource?: R
31
+ /** Present for `kind: 'group'`: whether it is collapsed. */
32
+ collapsed?: boolean
33
+ /** Present for `kind: 'group'`: every leaf resource id in its subtree (for roll-ups). */
34
+ childResourceIds?: string[]
35
+ }
36
+
37
+ type MinResource = { id: string; title?: string; color?: string }
38
+
39
+ /**
40
+ * Build the ordered tree rows. `groups` define the hierarchy; `groupOf(resource)`
41
+ * returns the group id a resource belongs to (or undefined = ungrouped). `collapsed`
42
+ * is the set of collapsed group ids. Order: for each group, its child groups
43
+ * (recursively) then its direct resources; ungrouped resources trail at the end.
44
+ */
45
+ export function buildResourceRows<R extends MinResource>(
46
+ resources: ReadonlyArray<R>,
47
+ groups: ReadonlyArray<SchedulerResourceGroup>,
48
+ groupOf: (r: R) => string | undefined,
49
+ collapsed: ReadonlySet<string> = new Set(),
50
+ ): ResourceRow<R>[] {
51
+ const groupById = new Map<string, SchedulerResourceGroup>()
52
+ for (const g of groups) if (g && g.id) groupById.set(g.id, g)
53
+
54
+ // parentId -> child groups (root groups keyed under '' ). A parentId that is not
55
+ // a known group is treated as a root.
56
+ const childGroups = new Map<string, SchedulerResourceGroup[]>()
57
+ for (const g of groupById.values()) {
58
+ const p = g.parentId && groupById.has(g.parentId) ? g.parentId : ''
59
+ ;(childGroups.get(p) ?? childGroups.set(p, []).get(p)!).push(g)
60
+ }
61
+
62
+ // group id -> resources ('' = ungrouped / unknown group).
63
+ const resByGroup = new Map<string, R[]>()
64
+ for (const r of resources) {
65
+ const gid = groupOf(r)
66
+ const key = gid && groupById.has(gid) ? gid : ''
67
+ ;(resByGroup.get(key) ?? resByGroup.set(key, []).get(key)!).push(r)
68
+ }
69
+
70
+ // Every leaf resource id under a group's subtree (recursive), for roll-ups.
71
+ const leafSeen = new Set<string>()
72
+ const leafIds = (gid: string): string[] => {
73
+ if (leafSeen.has(gid)) return [] // cycle guard
74
+ leafSeen.add(gid)
75
+ const ids: string[] = []
76
+ for (const r of resByGroup.get(gid) ?? []) ids.push(r.id)
77
+ for (const cg of childGroups.get(gid) ?? []) ids.push(...leafIds(cg.id))
78
+ leafSeen.delete(gid)
79
+ return ids
80
+ }
81
+
82
+ const out: ResourceRow<R>[] = []
83
+ const walked = new Set<string>()
84
+ const walk = (g: SchedulerResourceGroup, depth: number): void => {
85
+ if (walked.has(g.id)) return // cycle guard
86
+ walked.add(g.id)
87
+ const isCollapsed = collapsed.has(g.id)
88
+ out.push({ kind: 'group', id: g.id, title: g.title, depth, color: g.color, collapsed: isCollapsed, childResourceIds: leafIds(g.id) })
89
+ if (isCollapsed) return
90
+ for (const cg of childGroups.get(g.id) ?? []) walk(cg, depth + 1)
91
+ for (const r of resByGroup.get(g.id) ?? []) {
92
+ out.push({ kind: 'resource', id: r.id, title: r.title ?? r.id, depth: depth + 1, color: r.color, resource: r })
93
+ }
94
+ }
95
+
96
+ for (const g of childGroups.get('') ?? []) walk(g, 0)
97
+ // Ungrouped resources trail at the end (no header).
98
+ for (const r of resByGroup.get('') ?? []) {
99
+ out.push({ kind: 'resource', id: r.id, title: r.title ?? r.id, depth: 0, color: r.color, resource: r })
100
+ }
101
+ return out
102
+ }
103
+
104
+ /** The leaf resource ids actually visible (resource rows only) in tree order. */
105
+ export function visibleResourceIds<R extends MinResource>(rows: ReadonlyArray<ResourceRow<R>>): string[] {
106
+ return rows.filter((r) => r.kind === 'resource').map((r) => r.id)
107
+ }
@@ -0,0 +1,53 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { availableSlots, mergeIntervals, subtractIntervals, type Interval } from './scheduler-slots'
3
+
4
+ const D = (h: number, m = 0) => new Date(2026, 0, 5, h, m, 0, 0)
5
+ const iv = (sh: number, sm: number, eh: number, em: number): Interval => ({ start: D(sh, sm), end: D(eh, em) })
6
+ const hhmm = (d: Date) => `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
7
+ const show = (slots: { start: Date; end: Date }[]) => slots.map((s) => `${hhmm(s.start)}-${hhmm(s.end)}`)
8
+
9
+ describe('mergeIntervals', () => {
10
+ it('merges overlapping and touching intervals', () => {
11
+ expect(show(mergeIntervals([iv(9, 0, 10, 0), iv(9, 30, 11, 0), iv(11, 0, 12, 0)]))).toEqual(['09:00-12:00'])
12
+ })
13
+ })
14
+
15
+ describe('subtractIntervals', () => {
16
+ it('cuts occupied blocks out of a base window', () => {
17
+ const free = subtractIntervals(iv(9, 0, 17, 0), [iv(12, 0, 13, 0)])
18
+ expect(show(free)).toEqual(['09:00-12:00', '13:00-17:00'])
19
+ })
20
+ })
21
+
22
+ describe('availableSlots', () => {
23
+ it('fills an empty working window with back-to-back slots', () => {
24
+ const slots = availableSlots({ working: [iv(9, 0, 12, 0)], busy: [], durationMin: 60 })
25
+ expect(show(slots)).toEqual(['09:00-10:00', '10:00-11:00', '11:00-12:00'])
26
+ })
27
+
28
+ it('removes slots that hit a busy booking', () => {
29
+ const slots = availableSlots({ working: [iv(9, 0, 12, 0)], busy: [iv(10, 0, 11, 0)], durationMin: 60 })
30
+ expect(show(slots)).toEqual(['09:00-10:00', '11:00-12:00'])
31
+ })
32
+
33
+ it('buffers widen what a booking blocks', () => {
34
+ // busy 10:00-11:00 with a 15-min buffer blocks 09:45-11:15, so the 09:00 slot
35
+ // (ends 10:00 > 09:45) is gone and only 11:15+ is free (no 60-min slot fits before 12:00).
36
+ const slots = availableSlots({ working: [iv(9, 0, 12, 0)], busy: [iv(10, 0, 11, 0)], durationMin: 60, bufferBeforeMin: 15, bufferAfterMin: 15 })
37
+ expect(show(slots)).toEqual([]) // 09:00-09:45 too short for 60m; 11:15-12:00 too short
38
+ })
39
+
40
+ it('honours a smaller step (overlapping start times)', () => {
41
+ const slots = availableSlots({ working: [iv(9, 0, 10, 30)], busy: [], durationMin: 60, stepMin: 30 })
42
+ expect(show(slots)).toEqual(['09:00-10:00', '09:30-10:30'])
43
+ })
44
+
45
+ it('drops slots before minStart (lead time)', () => {
46
+ const slots = availableSlots({ working: [iv(9, 0, 12, 0)], busy: [], durationMin: 60, minStart: D(10, 0) })
47
+ expect(show(slots)).toEqual(['10:00-11:00', '11:00-12:00'])
48
+ })
49
+
50
+ it('returns nothing when the duration cannot fit', () => {
51
+ expect(availableSlots({ working: [iv(9, 0, 9, 45)], busy: [], durationMin: 60 })).toEqual([])
52
+ })
53
+ })
@@ -0,0 +1,94 @@
1
+ /**
2
+ * scheduler-slots - the pure core behind Scheduler Pro's *bookable slots /
3
+ * find-a-time* (Calendly-style). No Svelte, no DOM. Given a resource's working
4
+ * windows and its busy intervals, it produces the open, bookable slots of a fixed
5
+ * duration - subtracting busy time (widened by buffers) and anything before a
6
+ * minimum start (now + lead). Deterministic: the caller supplies all dates.
7
+ */
8
+
9
+ export type Interval = { start: Date; end: Date }
10
+ export type Slot = { start: Date; end: Date }
11
+
12
+ const MS_MIN = 60_000
13
+
14
+ /** Merge overlapping / touching intervals into a sorted, disjoint list. */
15
+ export function mergeIntervals(intervals: ReadonlyArray<Interval>): Interval[] {
16
+ const sorted = [...intervals]
17
+ .filter((i) => i.end.getTime() > i.start.getTime())
18
+ .sort((a, b) => a.start.getTime() - b.start.getTime())
19
+ const out: Interval[] = []
20
+ for (const iv of sorted) {
21
+ const last = out[out.length - 1]
22
+ if (last && iv.start.getTime() <= last.end.getTime()) {
23
+ if (iv.end.getTime() > last.end.getTime()) last.end = iv.end
24
+ } else out.push({ start: iv.start, end: iv.end })
25
+ }
26
+ return out
27
+ }
28
+
29
+ /** The parts of `[base]` not covered by any of the (merged) `occupied` intervals. */
30
+ export function subtractIntervals(base: Interval, occupied: ReadonlyArray<Interval>): Interval[] {
31
+ const occ = mergeIntervals(occupied)
32
+ const out: Interval[] = []
33
+ let cursor = base.start.getTime()
34
+ const end = base.end.getTime()
35
+ for (const o of occ) {
36
+ const os = o.start.getTime()
37
+ const oe = o.end.getTime()
38
+ if (oe <= cursor || os >= end) continue // outside base
39
+ if (os > cursor) out.push({ start: new Date(cursor), end: new Date(Math.min(os, end)) })
40
+ cursor = Math.max(cursor, oe)
41
+ if (cursor >= end) break
42
+ }
43
+ if (cursor < end) out.push({ start: new Date(cursor), end: new Date(end) })
44
+ return out
45
+ }
46
+
47
+ export type SlotOptions = {
48
+ working: ReadonlyArray<Interval>
49
+ busy: ReadonlyArray<Interval>
50
+ durationMin: number
51
+ /** Slot start granularity (minutes). Default = `durationMin` (back-to-back slots). */
52
+ stepMin?: number
53
+ /** Clear minutes required before / after a busy booking (widens what it blocks). */
54
+ bufferBeforeMin?: number
55
+ bufferAfterMin?: number
56
+ /** Earliest allowed slot start (e.g. now + lead time). Slots before it are dropped. */
57
+ minStart?: Date
58
+ }
59
+
60
+ /**
61
+ * The bookable slots. For each working window, subtract busy time (each busy
62
+ * interval widened by the buffers) and anything before `minStart`, then step
63
+ * through the remaining free space emitting `durationMin` slots every `stepMin`.
64
+ */
65
+ export function availableSlots(opts: SlotOptions): Slot[] {
66
+ const durMs = Math.max(1, opts.durationMin) * MS_MIN
67
+ const stepMs = Math.max(1, opts.stepMin ?? opts.durationMin) * MS_MIN
68
+ const bufB = (opts.bufferBeforeMin ?? 0) * MS_MIN
69
+ const bufA = (opts.bufferAfterMin ?? 0) * MS_MIN
70
+ const floor = opts.minStart ? opts.minStart.getTime() : -Infinity
71
+
72
+ const occupied: Interval[] = opts.busy.map((b) => ({
73
+ start: new Date(b.start.getTime() - bufB),
74
+ end: new Date(b.end.getTime() + bufA),
75
+ }))
76
+
77
+ const out: Slot[] = []
78
+ for (const w of mergeIntervals(opts.working)) {
79
+ // Clip the window to `minStart` before subtracting busy.
80
+ const wStart = Math.max(w.start.getTime(), floor)
81
+ if (wStart >= w.end.getTime()) continue
82
+ const free = subtractIntervals({ start: new Date(wStart), end: w.end }, occupied)
83
+ for (const gap of free) {
84
+ let s = gap.start.getTime()
85
+ const gEnd = gap.end.getTime()
86
+ let guard = 0
87
+ while (s + durMs <= gEnd && guard++ < 5000) {
88
+ out.push({ start: new Date(s), end: new Date(s + durMs) })
89
+ s += stepMs
90
+ }
91
+ }
92
+ }
93
+ return out
94
+ }
@@ -0,0 +1,47 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { busyMinutesReducer, columnSummaries, sumReducer } from './scheduler-summary'
3
+
4
+ type Row = { id: string; amount: number }
5
+ type Ev = { row: Row; start: Date; end: Date }
6
+ const D = (h: number) => new Date(2026, 0, 5, h, 0, 0, 0)
7
+ const ev = (id: string, s: number, e: number, amount: number): Ev => ({ row: { id, amount }, start: D(s), end: D(e) })
8
+
9
+ const opts = { startOf: (e: Ev) => e.start, endOf: (e: Ev) => e.end, rowOf: (e: Ev) => e.row }
10
+ const cols = [
11
+ { start: D(9), end: D(10) },
12
+ { start: D(10), end: D(11) },
13
+ { start: D(11), end: D(12) },
14
+ ]
15
+
16
+ describe('columnSummaries', () => {
17
+ it('counts events overlapping each column', () => {
18
+ const events = [ev('a', 9, 11, 5), ev('b', 10, 12, 3)] // a in cols 0,1; b in cols 1,2
19
+ expect(columnSummaries(events, cols, 'count', opts)).toEqual([1, 2, 1])
20
+ })
21
+
22
+ it('sums a numeric field per column', () => {
23
+ const events = [ev('a', 9, 11, 5), ev('b', 10, 12, 3)]
24
+ expect(columnSummaries(events, cols, sumReducer<Row>('amount'), opts)).toEqual([5, 8, 3])
25
+ })
26
+
27
+ it('yields 0 for empty columns', () => {
28
+ expect(columnSummaries([], cols, 'count', opts)).toEqual([0, 0, 0])
29
+ expect(columnSummaries([], cols, sumReducer<Row>('amount'), opts)).toEqual([0, 0, 0])
30
+ })
31
+
32
+ it('clips events to the column for a busy-minutes reducer', () => {
33
+ const events = [ev('a', 9, 12, 0)] // spans all three hours
34
+ // Each column is 1h; the event contributes 60 clipped minutes to each.
35
+ expect(columnSummaries(events, cols, busyMinutesReducer<Row>(), opts)).toEqual([60, 60, 60])
36
+ })
37
+
38
+ it('passes column-clipped items to a custom reducer', () => {
39
+ const events = [ev('a', 9, 12, 0)]
40
+ const seen: number[] = []
41
+ columnSummaries(events, cols, (items) => {
42
+ seen.push(items.length ? (items[0]!.end.getTime() - items[0]!.start.getTime()) / 60000 : 0)
43
+ return items.length
44
+ }, opts)
45
+ expect(seen).toEqual([60, 60, 60]) // clipped to each 1h column
46
+ })
47
+ })
@@ -0,0 +1,81 @@
1
+ /**
2
+ * scheduler-summary - the pure core behind Scheduler Pro's *per-time-column
3
+ * summary* strip (counts / sums / a custom roll-up aligned to the timeline axis).
4
+ * No Svelte, no DOM. Each event is clipped to the column it falls in before the
5
+ * reducer runs, so duration-based reducers (busy minutes) are per-column.
6
+ */
7
+
8
+ const MS_MIN = 60_000
9
+
10
+ /** One event clipped to a column: its row plus the clipped [start, end). */
11
+ export type SummaryItem<TData> = { row: TData; start: Date; end: Date }
12
+
13
+ /**
14
+ * How a column's overlapping events roll up to one cell value:
15
+ * - `'count'` - number of events touching the column
16
+ * - `{ sum }` - sum of a numeric row field over those events
17
+ * - function - custom, receives the column-clipped items
18
+ */
19
+ export type ColumnReducer<TData> =
20
+ | 'count'
21
+ | { sum: keyof TData & string }
22
+ | ((items: SummaryItem<TData>[]) => string | number)
23
+
24
+ export type SummaryOptions<E, TData> = {
25
+ startOf: (e: E) => Date
26
+ endOf: (e: E) => Date
27
+ rowOf: (e: E) => TData
28
+ }
29
+
30
+ function overlaps(aStart: Date, aEnd: Date, bStart: Date, bEnd: Date): boolean {
31
+ return aStart.getTime() < bEnd.getTime() && aEnd.getTime() > bStart.getTime()
32
+ }
33
+
34
+ /**
35
+ * One summary value per column. For each column, collect the events overlapping
36
+ * it, clip each to the column bounds, then apply `reducer`. Empty columns yield
37
+ * `0` for `count` / `sum` and `reducer([])` for the function form.
38
+ */
39
+ export function columnSummaries<E, TData>(
40
+ events: ReadonlyArray<E>,
41
+ columns: ReadonlyArray<{ start: Date; end: Date }>,
42
+ reducer: ColumnReducer<TData>,
43
+ opts: SummaryOptions<E, TData>,
44
+ ): Array<string | number> {
45
+ return columns.map((col) => {
46
+ const items: SummaryItem<TData>[] = []
47
+ for (const e of events) {
48
+ const s = opts.startOf(e)
49
+ const en = opts.endOf(e)
50
+ if (!overlaps(s, en, col.start, col.end)) continue
51
+ items.push({
52
+ row: opts.rowOf(e),
53
+ start: new Date(Math.max(s.getTime(), col.start.getTime())),
54
+ end: new Date(Math.min(en.getTime(), col.end.getTime())),
55
+ })
56
+ }
57
+ if (reducer === 'count') return items.length
58
+ if (typeof reducer === 'function') return reducer(items)
59
+ // { sum: field }
60
+ const field = reducer.sum
61
+ let total = 0
62
+ for (const it of items) {
63
+ const v = (it.row as Record<string, unknown>)[field as string]
64
+ if (typeof v === 'number' && Number.isFinite(v)) total += v
65
+ }
66
+ return total
67
+ })
68
+ }
69
+
70
+ /** Built-in reducer: sum a numeric row field. */
71
+ export function sumReducer<TData>(field: keyof TData & string): ColumnReducer<TData> {
72
+ return { sum: field }
73
+ }
74
+
75
+ /** Built-in reducer: count events. */
76
+ export const countReducer = 'count' as const
77
+
78
+ /** Built-in reducer: total column-clipped busy minutes across the column's events. */
79
+ export function busyMinutesReducer<TData>(): (items: SummaryItem<TData>[]) => number {
80
+ return (items) => items.reduce((sum, it) => sum + (it.end.getTime() - it.start.getTime()) / MS_MIN, 0)
81
+ }
@@ -60,7 +60,7 @@ export function updateField(
60
60
  if (f.field !== fieldName) {
61
61
  return makesPrimary && f.primaryKey ? { ...f, primaryKey: false } : f
62
62
  }
63
- let next: EntityField = { ...f, ...patch }
63
+ const next: EntityField = { ...f, ...patch }
64
64
  if (patch.type && patch.type !== f.type) {
65
65
  if (patch.type !== 'enum') delete next.options
66
66
  if (patch.type !== 'relation') delete next.relation
@@ -20,7 +20,7 @@ export {
20
20
  type RestAdapter,
21
21
  type OffsetLimitOptions,
22
22
  } from './rest-adapters'
23
- export { introspectSupabaseTable, type IntrospectSupabaseOptions } from './introspect-supabase'
23
+ export { introspectSupabaseTable, listSupabaseTables, type IntrospectSupabaseOptions } from './introspect-supabase'
24
24
  export {
25
25
  createSupabaseRealtime,
26
26
  type SupabaseRealtimeConfig,
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it } from 'vitest'
2
- import { introspectSupabaseTable } from './introspect-supabase'
2
+ import { introspectSupabaseTable, listSupabaseTables } from './introspect-supabase'
3
3
 
4
4
  type Parts = { openapi?: unknown; openapiStatus?: number; csv?: string; sample?: unknown }
5
5
 
@@ -98,3 +98,15 @@ describe('introspectSupabaseTable', () => {
98
98
  expect(schema).toBeNull()
99
99
  })
100
100
  })
101
+
102
+ describe('listSupabaseTables', () => {
103
+ it('lists table names from the OpenAPI definitions (sorted)', async () => {
104
+ const openapi = { definitions: { orders: {}, customers: {}, deals: {} } }
105
+ const tables = await listSupabaseTables('https://x.supabase.co', 'anon', fetchFor({ openapi }))
106
+ expect(tables).toEqual(['customers', 'deals', 'orders'])
107
+ })
108
+
109
+ it('returns [] when the OpenAPI doc is blocked or the key is wrong', async () => {
110
+ expect(await listSupabaseTables('https://x.supabase.co', 'anon', fetchFor({ openapiStatus: 401 }))).toEqual([])
111
+ })
112
+ })
@@ -106,6 +106,26 @@ async function viaSample(o: Required<IntrospectSupabaseOptions>, base: string):
106
106
  }))
107
107
  }
108
108
 
109
+ /** List a Supabase project's tables from its PostgREST OpenAPI doc (URL + anon key).
110
+ * Returns the exposed table names (sorted), or `[]` when the doc can't be read
111
+ * (CORS-blocked, bad key). Lets the Studio wizard offer a table picker instead of
112
+ * making the user hand-type the table name. */
113
+ export async function listSupabaseTables(url: string, key: string, fetchFn: typeof fetch = globalThis.fetch): Promise<string[]> {
114
+ const base = url.replace(/\/+$/, '')
115
+ let res: Response
116
+ try {
117
+ res = await fetchFn(`${base}/rest/v1/`, { headers: { apikey: key, Authorization: `Bearer ${key}`, Accept: 'application/json' } })
118
+ } catch {
119
+ return []
120
+ }
121
+ if (!res.ok) return []
122
+ let spec: any
123
+ try { spec = await res.json() } catch { return [] }
124
+ const defs: Record<string, unknown> = spec?.definitions ?? spec?.components?.schemas ?? {}
125
+ // PostgREST also lists RPCs under `paths` as `/rpc/...`; `definitions` are the tables/views.
126
+ return Object.keys(defs).sort((a, b) => a.localeCompare(b))
127
+ }
128
+
109
129
  /** Introspect a Supabase/PostgREST table into an EntitySchema, or null. */
110
130
  export async function introspectSupabaseTable<TData extends RowData = RowData>(
111
131
  options: IntrospectSupabaseOptions,
@@ -0,0 +1,45 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { buildCopilotMessages, projectFromModelText } from './copilot-core'
3
+ import { createProject, serializeProject } from './project'
4
+ import type { EntitySchema } from '../schema'
5
+
6
+ const customers: EntitySchema = {
7
+ name: 'customers',
8
+ label: 'Customers',
9
+ idField: 'id',
10
+ fields: [
11
+ { field: 'id', label: 'Id', type: 'text', primaryKey: true },
12
+ { field: 'name', label: 'Name', type: 'text' },
13
+ ],
14
+ }
15
+
16
+ describe('copilot core', () => {
17
+ it('builds a request carrying the model rules, the current project, and the instruction', () => {
18
+ const p = createProject([customers])
19
+ const { system, messages } = buildCopilotMessages('Add a status enum field to customers', p)
20
+ expect(system).toMatch(/COMPLETE updated project as JSON/)
21
+ expect(system).toMatch(/PRESERVE everything/)
22
+ expect(system).toContain('board') // block kinds listed from the palette
23
+ expect(system).toContain('radio-group') // component keys listed from the registry
24
+ expect(messages).toHaveLength(1)
25
+ expect(messages[0]!.content).toContain('"customers"') // current project inlined
26
+ expect(messages[0]!.content).toContain('Add a status enum field to customers')
27
+ })
28
+
29
+ it('parses a clean JSON reply into a validated project', () => {
30
+ const p = createProject([customers])
31
+ const out = projectFromModelText(serializeProject(p))
32
+ expect(out.entities[0]!.name).toBe('customers')
33
+ })
34
+
35
+ it('tolerates fenced and prose-wrapped replies', () => {
36
+ const json = serializeProject(createProject([customers]))
37
+ expect(projectFromModelText('```json\n' + json + '\n```').entities).toHaveLength(1)
38
+ expect(projectFromModelText('Here is the updated project:\n' + json + '\nDone!').entities).toHaveLength(1)
39
+ })
40
+
41
+ it('rejects non-JSON and invalid projects with useful errors', () => {
42
+ expect(() => projectFromModelText('Sorry, I cannot do that.')).toThrow(/no JSON object/)
43
+ expect(() => projectFromModelText('{"entities": [], "screens": []}')).toThrow(/invalid project|entity/i)
44
+ })
45
+ })
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Studio Copilot - the pure, testable core. Builds the model request for "edit
3
+ * this project per the user's instruction" and parses/validates the model's
4
+ * reply back into a StudioProject. No network here: the CLI designer server
5
+ * does the actual Anthropic call (keys stay server-side), and the designer
6
+ * additionally re-validates + puts the result on the undo stack, so a bad
7
+ * edit is one Ctrl+Z away.
8
+ */
9
+ import { parseProject, serializeProject, validateProject, blockPalette, type StudioProject } from './project.js'
10
+ import { UI_COMPONENT_REGISTRY } from './ui-components.js'
11
+
12
+ export type CopilotMessages = {
13
+ system: string
14
+ messages: Array<{ role: 'user'; content: string }>
15
+ }
16
+
17
+ /** The system + user messages for one copilot edit round. */
18
+ export function buildCopilotMessages(prompt: string, project: StudioProject): CopilotMessages {
19
+ const kinds = blockPalette.map((p) => p.kind).join(', ')
20
+ const components = UI_COMPONENT_REGISTRY.map((c) => c.key).join(', ')
21
+ const system = `You are SvGrid Studio's copilot. You edit a Studio project - the JSON model of a data app - according to the user's instruction, and return the COMPLETE updated project as JSON.
22
+
23
+ The model, briefly:
24
+ - entities: EntitySchema[] - { name, label, idField, fields: [{ field, label, type, primaryKey?, required?, formula?, options?: [{value,label,color?}], relation?: { entity, labelField } }] }. Field types: text | number | boolean | date | datetime | enum | relation | json.
25
+ - screens: [{ id, title, route, entity?, blocks: [{ id, config: { kind, ... } }], nav?: { show, label?, order? } }]. Block kinds: ${kinds}. A 'component' block is { kind: 'component', component: <key>, props: {...} } with component keys: ${components}.
26
+ - dataSources: { [entityName]: { kind: 'memory'|'sql'|'rest'|'supabase'|'pglite', ... } }. theme, auth, access (RBAC), i18n, deploy as configured.
27
+ - validations on an entity: [{ field, op, value|compareTo, message }].
28
+
29
+ Rules:
30
+ - Return ONLY the full updated project JSON (no prose, no markdown fence).
31
+ - PRESERVE everything the instruction does not ask to change - ids, screens, entities, config. Never renumber or drop existing ids.
32
+ - New ids: short kebab/lower-camel strings unique within the project.
33
+ - New entities need at least an id/primary-key field and a label-ish field; give enum fields options with value+label; seed realistic demo data is NOT required.
34
+ - New screens need a unique route and at least one block bound to their entity.
35
+ - Prefer editing over rebuilding; keep the user's structure and naming style.`
36
+ return {
37
+ system,
38
+ messages: [
39
+ {
40
+ role: 'user',
41
+ content: `Current project JSON:\n${serializeProject(project)}\n\nInstruction: ${prompt}\n\nReturn the complete updated project JSON.`,
42
+ },
43
+ ],
44
+ }
45
+ }
46
+
47
+ /** Parse the model's reply into a validated StudioProject. Tolerates a fenced
48
+ * code block or stray prose around the JSON; throws with a useful message when
49
+ * the reply isn't a valid project. */
50
+ export function projectFromModelText(text: string): StudioProject {
51
+ let candidate = text.trim()
52
+ const fence = candidate.match(/```(?:json)?\s*([\s\S]*?)```/)
53
+ if (fence) candidate = fence[1]!.trim()
54
+ if (!candidate.startsWith('{')) {
55
+ // Stray prose: cut to the outermost object literal.
56
+ const start = candidate.indexOf('{')
57
+ const end = candidate.lastIndexOf('}')
58
+ if (start === -1 || end <= start) throw new Error('Copilot reply contained no JSON object.')
59
+ candidate = candidate.slice(start, end + 1)
60
+ }
61
+ const project = parseProject(candidate) // throws on malformed/invalid shapes
62
+ const errors = validateProject(project).filter((i) => i.level === 'error')
63
+ if (errors.length) throw new Error(`Copilot returned an invalid project: ${errors.map((e) => e.message).join(' ')}`)
64
+ return project
65
+ }
@@ -0,0 +1,56 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { deployCommands, missingEnvKeys, resolveDeployTarget } from './deploy-cli'
3
+
4
+ describe('resolveDeployTarget', () => {
5
+ it('an explicit --target wins and is validated', () => {
6
+ expect(resolveDeployTarget({ flag: 'netlify', configJson: '{"deploy":"vercel"}' })).toEqual({ ok: true, provider: 'netlify', source: 'flag' })
7
+ const bad = resolveDeployTarget({ flag: 'heroku' })
8
+ expect(bad.ok).toBe(false)
9
+ if (!bad.ok) expect(bad.reason).toMatch(/Unknown --target "heroku"/)
10
+ })
11
+
12
+ it('falls back to studio.config.json deploy, ignoring auto/unset (never guesses)', () => {
13
+ expect(resolveDeployTarget({ configJson: '{"deploy":"cloudflare"}' })).toEqual({ ok: true, provider: 'cloudflare', source: 'config' })
14
+ expect(resolveDeployTarget({ configJson: '{"deploy":"auto"}' }).ok).toBe(false)
15
+ expect(resolveDeployTarget({ configJson: '{}' }).ok).toBe(false)
16
+ expect(resolveDeployTarget({ configJson: 'not json' }).ok).toBe(false)
17
+ })
18
+
19
+ it('detects the provider from a pinned SvelteKit adapter', () => {
20
+ const cfg = "import adapter from '@sveltejs/adapter-vercel'\nexport default { kit: { adapter: adapter() } }"
21
+ expect(resolveDeployTarget({ svelteConfig: cfg })).toEqual({ ok: true, provider: 'vercel', source: 'adapter' })
22
+ // adapter-auto is not a concrete provider - refuse with guidance.
23
+ const auto = resolveDeployTarget({ svelteConfig: "import adapter from '@sveltejs/adapter-auto'" })
24
+ expect(auto.ok).toBe(false)
25
+ if (!auto.ok) expect(auto.reason).toMatch(/No deploy target configured/)
26
+ })
27
+
28
+ it('config beats adapter; flag beats both', () => {
29
+ const svelteConfig = "import adapter from '@sveltejs/adapter-node'"
30
+ expect(resolveDeployTarget({ configJson: '{"deploy":"netlify"}', svelteConfig })).toMatchObject({ provider: 'netlify', source: 'config' })
31
+ expect(resolveDeployTarget({ flag: 'cloudflare', configJson: '{"deploy":"netlify"}', svelteConfig })).toMatchObject({ provider: 'cloudflare', source: 'flag' })
32
+ })
33
+ })
34
+
35
+ describe('deployCommands', () => {
36
+ it('providers build first, then run the same one-liners the generated deploy script uses', () => {
37
+ expect(deployCommands('vercel')).toMatchObject({ build: 'npm run build', deploy: 'npx vercel deploy --prod' })
38
+ expect(deployCommands('netlify').deploy).toBe('npx netlify deploy --build --prod')
39
+ expect(deployCommands('cloudflare').deploy).toBe('npx wrangler pages deploy')
40
+ })
41
+
42
+ it('node has no provider command - instructions only', () => {
43
+ const plan = deployCommands('node')
44
+ expect(plan.deploy).toBeNull()
45
+ expect(plan.notes.join('\n')).toMatch(/docker build/)
46
+ })
47
+ })
48
+
49
+ describe('missingEnvKeys', () => {
50
+ it('flags declared-but-unset keys, ignoring comments and filled values', () => {
51
+ const example = '# note\nDATABASE_URL=\nSESSION_SECRET=change-me\n# VITE_SVPRO_KEY=\n'
52
+ expect(missingEnvKeys(example, 'DATABASE_URL=postgres://x\n')).toEqual(['SESSION_SECRET'])
53
+ expect(missingEnvKeys(example, null)).toEqual(['DATABASE_URL', 'SESSION_SECRET'])
54
+ expect(missingEnvKeys(null, null)).toEqual([])
55
+ })
56
+ })