wizards 0.2.25 → 0.2.27

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/apps/runtime/dist/agents/architect.js +5 -0
  2. package/apps/runtime/dist/agents/project-assistant.js +10 -1
  3. package/apps/runtime/dist/authoring/guide.js +61 -8
  4. package/apps/runtime/dist/connectors/builtin.js +17 -1
  5. package/apps/runtime/dist/connectors/index.js +43 -14
  6. package/apps/runtime/dist/connectors/plugin-connections.js +248 -0
  7. package/apps/runtime/dist/connectors/via-account.js +63 -0
  8. package/apps/runtime/dist/credits/estimate.js +12 -2
  9. package/apps/runtime/dist/db/migrations/tenant/0010_plugin_connection.sql +15 -0
  10. package/apps/runtime/dist/db/migrations/tenant/meta/0010_snapshot.json +2942 -0
  11. package/apps/runtime/dist/db/migrations/tenant/meta/_journal.json +7 -0
  12. package/apps/runtime/dist/db/schema.js +21 -0
  13. package/apps/runtime/dist/engenty/connections-google/connectors/calendar.js +25 -3
  14. package/apps/runtime/dist/engenty/connections-microsoft/outlook.js +29 -1
  15. package/apps/runtime/dist/engine/decide.js +82 -0
  16. package/apps/runtime/dist/engine/input.js +21 -5
  17. package/apps/runtime/dist/engine/requirements.js +80 -15
  18. package/apps/runtime/dist/engine/runner.js +252 -6
  19. package/apps/runtime/dist/engine/steps.js +206 -18
  20. package/apps/runtime/dist/engine/surface.js +90 -0
  21. package/apps/runtime/dist/mcp/tools.js +3 -2
  22. package/apps/runtime/dist/plugins/loader.js +22 -1
  23. package/apps/runtime/dist/plugins/registry.js +10 -0
  24. package/apps/runtime/dist/render/downloads.js +7 -0
  25. package/apps/runtime/dist/routes/runs.js +28 -7
  26. package/apps/runtime/dist/routes/studio.js +3 -1
  27. package/apps/runtime/dist/services/marketplace.js +134 -3
  28. package/apps/runtime/dist/services/relevance.js +17 -49
  29. package/apps/runtime/dist/services/runs.js +43 -15
  30. package/apps/runtime/dist/services/wizards.js +4 -2
  31. package/apps/runtime/dist/tools/pages.js +31 -6
  32. package/apps/runtime/dist/tools/plugin.js +29 -1
  33. package/apps/web/dist/assets/Account-DW396ig5.js +1 -0
  34. package/apps/web/dist/assets/{ChangelogDialog-B7C0r9dF.js → ChangelogDialog-BdZ1SmEd.js} +1 -1
  35. package/apps/web/dist/assets/CreditsDialog-fOYdoev3.js +1 -0
  36. package/apps/web/dist/assets/EditorPage-CAARFsaI.js +3 -0
  37. package/apps/web/dist/assets/{FlowPreview-D0m5Qk6S.js → FlowPreview-U2AEnKlX.js} +1 -1
  38. package/apps/web/dist/assets/{Harness-bIL3pHB_.js → Harness-CtDlm_2b.js} +2 -2
  39. package/apps/web/dist/assets/HomePage-Bd-mkLxW.js +1 -0
  40. package/apps/web/dist/assets/ImportWizard-BMPGjd_t.js +1 -0
  41. package/apps/web/dist/assets/LocalRuntime-DjSNCRnV.js +1 -0
  42. package/apps/web/dist/assets/NewWizardPage-DZPcDR9i.js +2 -0
  43. package/apps/web/dist/assets/ProjectPage-_LAtG3tn.js +2 -0
  44. package/apps/web/dist/assets/PublicRunner-6Iqp0DmU.js +1 -0
  45. package/apps/web/dist/assets/RunDrawer-CtQoY3Ha.js +4 -0
  46. package/apps/web/dist/assets/RunnerView-BLOCNZaZ.js +2 -0
  47. package/apps/web/dist/assets/SettingsPage-Z6skBf6I.js +1 -0
  48. package/apps/web/dist/assets/SetupPage-4-Lic4pT.js +1 -0
  49. package/apps/web/dist/assets/SharePage-DfdhXxZm.js +1 -0
  50. package/apps/web/dist/assets/arrow-right-BOtqnsA1.js +1 -0
  51. package/apps/web/dist/assets/brand-CCAI9p0f.js +2 -0
  52. package/apps/web/dist/assets/changelog-DQM0oz9S.js +1 -0
  53. package/apps/web/dist/assets/circle-alert-BC6xmJ9r.js +1 -0
  54. package/apps/web/dist/assets/definition-D5V79jHA.js +1 -0
  55. package/apps/web/dist/assets/esm-FEWi61F_.js +7 -0
  56. package/apps/web/dist/assets/external-link-wKL1HE6G.js +1 -0
  57. package/apps/web/dist/assets/image-BMdSChOR.js +1 -0
  58. package/apps/web/dist/assets/index-BOp2fz-2.css +1 -0
  59. package/apps/web/dist/assets/index-YJMooz0J.js +132 -0
  60. package/apps/web/dist/assets/lock-BpqzLMuv.js +1 -0
  61. package/apps/web/dist/assets/meta-BqZ7SlZz.js +1 -0
  62. package/apps/web/dist/assets/pen-line-DM-u0dES.js +1 -0
  63. package/apps/web/dist/assets/refresh-cw-Bk7HkOIM.js +1 -0
  64. package/apps/web/dist/assets/settings-menu-BM_NfZfn.js +1 -0
  65. package/apps/web/dist/assets/star-JHzYYl5R.js +1 -0
  66. package/apps/web/dist/assets/trash-CdLg-Ela.js +1 -0
  67. package/apps/web/dist/assets/ui-8QmvuC9u.js +9 -0
  68. package/apps/web/dist/assets/upload-CL1R6shM.js +1 -0
  69. package/apps/web/dist/assets/video-BioLmXNO.js +1 -0
  70. package/apps/web/dist/assets/volume-2-_IOfPNCv.js +1 -0
  71. package/apps/web/dist/assets/wand-sparkles-DYtDQSJZ.js +1 -0
  72. package/apps/web/dist/index.html +4 -5
  73. package/apps/web/dist/mcp/flow.html +61 -61
  74. package/node_modules/@engenty-wizards/plugin-sdk/dist/index.d.ts +85 -0
  75. package/node_modules/@engenty-wizards/plugin-sdk/dist/studio.d.ts +23 -0
  76. package/node_modules/@engenty-wizards/shared/dist/decision.d.ts +76 -0
  77. package/node_modules/@engenty-wizards/shared/dist/decision.js +147 -0
  78. package/node_modules/@engenty-wizards/shared/dist/definition.d.ts +1076 -51
  79. package/node_modules/@engenty-wizards/shared/dist/definition.js +597 -44
  80. package/node_modules/@engenty-wizards/shared/dist/marketplace-entry.js +19 -2
  81. package/node_modules/@engenty-wizards/shared/dist/marketplace.d.ts +6 -1
  82. package/node_modules/@engenty-wizards/shared/dist/run.d.ts +55 -0
  83. package/node_modules/@engenty-wizards/shared/dist/run.js +3 -0
  84. package/node_modules/@engenty-wizards/shared/dist/surface.d.ts +89 -0
  85. package/node_modules/@engenty-wizards/shared/dist/surface.js +198 -0
  86. package/package.json +1 -1
  87. package/apps/web/dist/assets/Account-CpxsROpe.js +0 -1
  88. package/apps/web/dist/assets/CreditsDialog-Com2Y3g8.js +0 -1
  89. package/apps/web/dist/assets/EditorPage-BCagWjrz.js +0 -2
  90. package/apps/web/dist/assets/HomePage-DPE_Z9HB.js +0 -1
  91. package/apps/web/dist/assets/ImportWizard-Dz2uKznp.js +0 -1
  92. package/apps/web/dist/assets/LocalRuntime-wMKUVZOu.js +0 -1
  93. package/apps/web/dist/assets/NewWizardPage-BOxU2jWq.js +0 -2
  94. package/apps/web/dist/assets/ProjectPage-BJp2wqIk.js +0 -5
  95. package/apps/web/dist/assets/PublicRunner-BsrqVK0J.js +0 -1
  96. package/apps/web/dist/assets/RunDrawer-CLfJ0W_V.js +0 -1
  97. package/apps/web/dist/assets/RunnerView-BXMELFC4.js +0 -70
  98. package/apps/web/dist/assets/SettingsPage-BkuUm-bf.js +0 -1
  99. package/apps/web/dist/assets/SetupPage-CoyXpcvS.js +0 -1
  100. package/apps/web/dist/assets/SharePage-BuR-he1L.js +0 -1
  101. package/apps/web/dist/assets/api-rJBQEu1G.js +0 -10
  102. package/apps/web/dist/assets/arrow-right-6FyN3e7D.js +0 -1
  103. package/apps/web/dist/assets/changelog-Bmw6dg-T.js +0 -1
  104. package/apps/web/dist/assets/check-CUpby_J9.js +0 -1
  105. package/apps/web/dist/assets/chevron-down-DymW-2BP.js +0 -1
  106. package/apps/web/dist/assets/circle-alert-D2SCwFT-.js +0 -1
  107. package/apps/web/dist/assets/createLucideIcon-q3MZMmnf.js +0 -1
  108. package/apps/web/dist/assets/definition-BznNdlhf.js +0 -55
  109. package/apps/web/dist/assets/esm-BrGYYg2h.js +0 -7
  110. package/apps/web/dist/assets/external-link-MAL7vJYj.js +0 -1
  111. package/apps/web/dist/assets/image-CC_2mJd6.js +0 -1
  112. package/apps/web/dist/assets/index-BZs1IxAk.css +0 -1
  113. package/apps/web/dist/assets/index-Co5QOA9_.js +0 -10
  114. package/apps/web/dist/assets/lock-DZL2N67v.js +0 -1
  115. package/apps/web/dist/assets/meta-B3jzXP27.js +0 -1
  116. package/apps/web/dist/assets/pen-line-NK46C_nG.js +0 -1
  117. package/apps/web/dist/assets/plus-Du-Jx8aI.js +0 -1
  118. package/apps/web/dist/assets/refresh-cw-BNtHis1x.js +0 -1
  119. package/apps/web/dist/assets/settings-menu-CCNUb0Fd.js +0 -1
  120. package/apps/web/dist/assets/table-2-BdDuBcF2.js +0 -1
  121. package/apps/web/dist/assets/trash-0IafyhZB.js +0 -1
  122. package/apps/web/dist/assets/ui-DJOZSSKo.js +0 -1
  123. package/apps/web/dist/assets/upload-DNRENWzQ.js +0 -1
  124. package/apps/web/dist/assets/video-9fDPRPCv.js +0 -1
  125. package/apps/web/dist/assets/volume-2-DE67_rue.js +0 -1
  126. package/apps/web/dist/assets/wand-sparkles-Clj3VRB5.js +0 -1
@@ -1,6 +1,7 @@
1
1
  import { z } from "zod";
2
2
  import { tableColumnsSchema } from "./engenty/data-tables/index.js";
3
3
  import { CONNECTION_KINDS } from "./store.js";
4
+ import { surfaceComponentsSchema, validateSurface } from "./surface.js";
4
5
  /** The engenty avatars a wizard can wear. Same cast as engenty's ui-core. */
5
6
  export const ENGENTY_KINDS = [
6
7
  "round",
@@ -33,6 +34,7 @@ export const FIELD_KINDS = [
33
34
  "location",
34
35
  "audio",
35
36
  "signature",
37
+ "slot",
36
38
  ];
37
39
  export function isLocationValue(v) {
38
40
  if (!v || typeof v !== "object" || Array.isArray(v)) {
@@ -117,6 +119,27 @@ export const itemColumnSchema = z.object({
117
119
  label: z.string().min(1),
118
120
  kind: z.enum(["text", "number", "money"]),
119
121
  });
122
+ export const CONDITION_OPS = [
123
+ "equals",
124
+ "notEquals",
125
+ "in",
126
+ "notEmpty",
127
+ "empty",
128
+ "gt",
129
+ "lt",
130
+ "contains",
131
+ ];
132
+ /**
133
+ * One test on what the run knows: an answer by field id, an output of an agent step as
134
+ * "steps.<step>.<field>", or how many rows a stored list has as "lists.<list>.count".
135
+ */
136
+ export const conditionSchema = z.object({
137
+ field: z.string(),
138
+ op: z.enum(CONDITION_OPS),
139
+ value: z.union([z.string(), z.number(), z.boolean(), z.array(z.string())]).optional(),
140
+ });
141
+ /** One condition, or several that must all hold. */
142
+ export const whenSchema = z.union([conditionSchema, z.array(conditionSchema).min(1)]);
120
143
  export const fieldSchema = z.object({
121
144
  id,
122
145
  label: z.string().min(1),
@@ -124,7 +147,10 @@ export const fieldSchema = z.object({
124
147
  required: z.boolean().optional(),
125
148
  placeholder: z.string().optional(),
126
149
  help: z.string().optional(),
127
- /** Choices for select / multiselect. */
150
+ /**
151
+ * Choices for select / multiselect. slot: the appointment times offered, as ISO 8601 date-times
152
+ * with their offset ("2026-10-12T09:00:00+02:00").
153
+ */
128
154
  options: z.array(z.string()).optional(),
129
155
  default: z.union([z.string(), z.number(), z.boolean(), z.array(z.string())]).optional(),
130
156
  /** Line-item columns for `items`. A row's amount is the product of its number and money columns. */
@@ -157,15 +183,54 @@ export const fieldSchema = z.object({
157
183
  * `items` field takes a table whose columns are its column ids; the person corrects it.
158
184
  */
159
185
  prefill: z.string().optional(),
186
+ /**
187
+ * The field is shown only while this holds. It reads an answer of this page (decided as the
188
+ * person types) or anything known before the page. A hidden field is not asked and not kept.
189
+ */
190
+ when: whenSchema.optional(),
191
+ /**
192
+ * select / multiselect / slot: the choices come from earlier data — a list output
193
+ * ("steps.<id>.<key>"), a column of a table output ("steps.<id>.<key>.<column>") or a column
194
+ * of a stored list ("lists.<id>.<column>"). `options` stand in while that data is empty.
195
+ */
196
+ optionsFrom: z.string().optional(),
197
+ /**
198
+ * The field is shown when a decision, made as the run reaches the page, finds this statement
199
+ * true of the run ("The complaint is about a damaged article").
200
+ */
201
+ ask: z.string().min(1).optional(),
202
+ /** The id of one of the page's `groups`: of a group's fields a decision shows one (or none). */
203
+ group: z.string().optional(),
204
+ });
205
+ export const fieldGroupSchema = z.object({
206
+ id,
207
+ /** What the decision picks: "How should we reach the person?" */
208
+ instructions: z.string().min(1),
209
+ /** True when showing none of the group's fields is a valid answer. */
210
+ optional: z.boolean().optional(),
160
211
  });
161
- export const nextRuleSchema = z.object({
162
- when: z.object({
163
- field: z.string(),
164
- op: z.enum(["equals", "notEquals", "in", "notEmpty", "empty"]),
165
- value: z.union([z.string(), z.number(), z.boolean(), z.array(z.string())]).optional(),
166
- }),
167
- /** A step id, or "end". */
212
+ export const nextRuleSchema = z
213
+ .object({
214
+ /** The rule holds when this holds … */
215
+ when: whenSchema.optional(),
216
+ /**
217
+ * … or, instead, when a decision finds this statement true of the run ("The person wants a
218
+ * refund"). All `ask` rules of a step are decided together, after no `when` rule matched.
219
+ */
220
+ ask: z.string().min(1).optional(),
221
+ /**
222
+ * A step id, or "end". A rule with neither `when` nor `ask` is the step's "otherwise": where
223
+ * the run goes when no other rule holds, in place of the next step in the list.
224
+ */
168
225
  goto: z.string(),
226
+ /**
227
+ * How often this rule may send the run on in one run; afterwards it is skipped. Meant for a
228
+ * branch back to an earlier step (a loop), which without it is taken at most ten times.
229
+ */
230
+ max: z.number().int().min(1).max(50).optional(),
231
+ })
232
+ .refine((rule) => rule.when === undefined || rule.ask === undefined, {
233
+ message: 'A branch has "when" or "ask", not both.',
169
234
  });
170
235
  const stepBase = {
171
236
  id,
@@ -176,22 +241,25 @@ const stepBase = {
176
241
  export const outputFieldSchema = z
177
242
  .object({
178
243
  id,
179
- kind: z.enum(["text", "number", "list", "table", "yesno", "choice"]),
180
- /** What the field holds; for `yesno` and `choice` the question it answers. */
244
+ kind: z.enum(["text", "number", "list", "table", "yesno", "choice", "score"]),
245
+ /** What the field holds; for `yesno`, `choice` and `score` the question it answers. */
181
246
  description: z.string().optional(),
182
247
  /** Table only: the exact column keys every row has — what a widget reads. */
183
248
  columns: z.array(z.string().min(1)).optional(),
184
- /** Choice only: the options, one of which is the answer. */
249
+ /** Choice: the options, one of which is the answer. Score: the levels, lowest first. */
185
250
  options: z.array(z.string().min(1)).min(2).max(40).optional(),
186
251
  })
187
- .refine((field) => field.kind !== "choice" || Boolean(field.options?.length), {
188
- message: "A choice field lists its options.",
252
+ .refine((field) => (field.kind !== "choice" && field.kind !== "score") || Boolean(field.options?.length), { message: "A choice or score field lists its options.", path: ["options"] })
253
+ .refine((field) => field.kind !== "score" || (field.options?.length ?? 0) <= 10, {
254
+ message: "A score has at most ten levels.",
189
255
  path: ["options"],
190
256
  });
191
257
  export const pageStepSchema = z.object({
192
258
  ...stepBase,
193
259
  type: z.literal("page"),
194
260
  fields: z.array(fieldSchema).min(1),
261
+ /** Fields of which a decision shows one: see `group` on a field. */
262
+ groups: z.array(fieldGroupSchema).max(6).optional(),
195
263
  cta: z.string().optional(),
196
264
  });
197
265
  export const agentStepSchema = z.object({
@@ -213,12 +281,24 @@ export const agentStepSchema = z.object({
213
281
  .object({
214
282
  format: z.enum(["text", "markdown", "json"]),
215
283
  fields: z.array(outputFieldSchema).optional(),
284
+ /**
285
+ * json only: the step also composes a view of its result from the surface catalog, bound
286
+ * to its own fields; reviews and the result show that view instead of a plain table.
287
+ */
288
+ surface: z.boolean().optional(),
216
289
  })
217
290
  .default({ format: "markdown" }),
218
291
  /** The kind of model the step needs; default `high`. The model itself is bound elsewhere. */
219
292
  model: z.enum(TEXT_CLASSES).optional(),
220
293
  effort: z.enum(EFFORTS).optional(),
221
294
  /** Shown to the person while it works. */
295
+ /**
296
+ * One run of the step per entry, at most twenty: the rows of a table output
297
+ * ("steps.<id>.<key>"), the entries of a list output, or the rows of a stored list
298
+ * ("lists.<id>"). The instructions read the entry as {{item.<column>}}, {{index}}, {{count}}.
299
+ * The output is a table: the entry's columns and the step's output fields, one row per entry.
300
+ */
301
+ each: z.string().optional(),
222
302
  working: z.string().optional(),
223
303
  });
224
304
  export const generateStepSchema = z.object({
@@ -319,6 +399,48 @@ export const filmStepSchema = z.object({
319
399
  effort: z.enum(EFFORTS).optional(),
320
400
  working: z.string().optional(),
321
401
  });
402
+ /** A piece a decided surface may show: its components (the first has the piece's id) and what it shows. */
403
+ export const surfaceCandidateSchema = z.object({
404
+ id,
405
+ /** What this piece shows, for the decision: "A chart of prices per supplier". */
406
+ description: z.string().min(1),
407
+ components: surfaceComponentsSchema,
408
+ /** Pieces of a group exclude each other: the decision shows one (or none, if optional). */
409
+ group: z.string().optional(),
410
+ /** Always shown: a title, the main list. */
411
+ required: z.boolean().optional(),
412
+ });
413
+ export const surfaceStepSchema = z
414
+ .object({
415
+ ...stepBase,
416
+ type: z.literal("surface"),
417
+ /** A view from the surface catalog, written once like a widget; every run brings new data. */
418
+ components: surfaceComponentsSchema.optional(),
419
+ /**
420
+ * Instead of `components`: pieces a decision picks from per run (no chart when there is
421
+ * nothing to chart), drawn in this order under one column.
422
+ */
423
+ candidates: z.array(surfaceCandidateSchema).max(16).optional(),
424
+ groups: z.array(fieldGroupSchema).max(6).optional(),
425
+ /** What the surface reads as data: key → a field id, steps.id, steps.id.key, lists.id, brand.name or today. */
426
+ data: z.record(z.string(), z.string()).default({}),
427
+ working: z.string().optional(),
428
+ })
429
+ .refine((step) => Boolean(step.components) !== Boolean(step.candidates), {
430
+ message: 'A surface step has either "components" or "candidates".',
431
+ });
432
+ /**
433
+ * The components of a decided surface for the pieces kept: one root column holding them in
434
+ * their order. Every piece kept is the same as all of them for the validator.
435
+ */
436
+ export function assembleSurface(candidates, kept) {
437
+ const keep = new Set(kept);
438
+ const chosen = candidates.filter((c) => keep.has(c.id));
439
+ return [
440
+ { id: "root", component: "Column", children: chosen.map((c) => c.components[0]?.id ?? c.id) },
441
+ ...chosen.flatMap((c) => c.components),
442
+ ];
443
+ }
322
444
  export const reviewStepSchema = z.object({
323
445
  ...stepBase,
324
446
  type: z.literal("review"),
@@ -343,6 +465,7 @@ export const stepSchema = z.discriminatedUnion("type", [
343
465
  agentStepSchema,
344
466
  generateStepSchema,
345
467
  widgetStepSchema,
468
+ surfaceStepSchema,
346
469
  filmStepSchema,
347
470
  reviewStepSchema,
348
471
  resultStepSchema,
@@ -357,7 +480,8 @@ export function isDecisionStep(step) {
357
480
  return (step.model === "classifier" &&
358
481
  step.output.format === "json" &&
359
482
  fields.length > 0 &&
360
- fields.every((f) => f.kind === "yesno" || f.kind === "choice") &&
483
+ fields.every((f) => f.kind === "yesno" || f.kind === "choice" || f.kind === "score") &&
484
+ !step.each &&
361
485
  step.tools.length === 0 &&
362
486
  !step.mcp?.length &&
363
487
  !step.connections?.length);
@@ -444,6 +568,9 @@ export function formatsFor(step) {
444
568
  if (step.type === "widget") {
445
569
  return step.video ? ["mp4", "png"] : ["html", "png", "pdf", "mp4", "json"];
446
570
  }
571
+ if (step.type === "surface") {
572
+ return ["json"];
573
+ }
447
574
  if (step.type === "agent") {
448
575
  if (step.output.format === "json") {
449
576
  return ["json", "csv", "xlsx", "md", "zip"];
@@ -536,9 +663,20 @@ export function validateWizard(def, files) {
536
663
  issues.push({ stepId: step.id, message: `Duplicate field id "${field.id}".` });
537
664
  }
538
665
  fieldIds.add(field.id);
539
- if ((field.kind === "select" || field.kind === "multiselect") && !field.options?.length) {
666
+ if ((field.kind === "select" || field.kind === "multiselect" || field.kind === "slot") &&
667
+ !field.options?.length &&
668
+ !field.optionsFrom) {
540
669
  issues.push({ stepId: step.id, message: `Field "${field.id}" needs options.` });
541
670
  }
671
+ if (field.kind === "slot") {
672
+ const wrong = (field.options ?? []).find((o) => !slotTime(o));
673
+ if (wrong !== undefined) {
674
+ issues.push({
675
+ stepId: step.id,
676
+ message: `Field "${field.id}": "${wrong}" is not a date and time with its offset, like "2026-10-12T09:00:00+02:00".`,
677
+ });
678
+ }
679
+ }
542
680
  if (field.kind === "items" && !field.columns?.length) {
543
681
  issues.push({ stepId: step.id, message: `Field "${field.id}" needs columns.` });
544
682
  }
@@ -614,10 +752,10 @@ export function validateWizard(def, files) {
614
752
  // provided by the runner
615
753
  }
616
754
  else if (head === "item" || head === "index" || head === "count") {
617
- if (step.type !== "generate" || !step.each) {
755
+ if ((step.type !== "generate" && step.type !== "agent") || !step.each) {
618
756
  issues.push({
619
757
  stepId: step.id,
620
- message: `"{{${ref}}}" only exists in a generate step with "each".`,
758
+ message: `"{{${ref}}}" only exists in a generate or agent step with "each".`,
621
759
  });
622
760
  }
623
761
  }
@@ -634,10 +772,134 @@ export function validateWizard(def, files) {
634
772
  }
635
773
  }
636
774
  };
775
+ /**
776
+ * What a condition may read before `step`: an answer asked earlier, an output field of an
777
+ * earlier agent step ("steps.<step>.<field>", the step itself for a branch), or how many rows
778
+ * a stored list has ("lists.<list>.count").
779
+ */
780
+ const knownBefore = (step, ref, self = false) => {
781
+ if (seenFields.has(ref)) {
782
+ return true;
783
+ }
784
+ const [head, from, key, ...more] = ref.split(".");
785
+ if (head === "lists") {
786
+ return key === "count" && more.length === 0 && listIds.has(from ?? "");
787
+ }
788
+ // `steps.<step>.<field>.p`: how probable the decision's answer is.
789
+ const probability = more.length === 1 && more[0] === "p";
790
+ if (head !== "steps" || !key || (more.length > 0 && !probability)) {
791
+ return false;
792
+ }
793
+ if (!(seenSteps.has(from) || (self && from === step.id))) {
794
+ return false;
795
+ }
796
+ const source = def.steps.find((s) => s.id === from);
797
+ return (source?.type === "agent" &&
798
+ (!probability || isDecisionStep(source)) &&
799
+ ((source.output.fields ?? []).some((f) => f.id === key) ||
800
+ (Boolean(source.each) && key === "rows" && !probability)));
801
+ };
802
+ const conditionIssues = (stepId, c) => {
803
+ if ((c.op === "gt" || c.op === "lt") && typeof c.value !== "number") {
804
+ return [{ stepId, message: `"${c.op}" on "${c.field}" compares with a number value.` }];
805
+ }
806
+ if (c.op === "in" && !Array.isArray(c.value)) {
807
+ return [{ stepId, message: `"in" on "${c.field}" needs a list of values.` }];
808
+ }
809
+ if (c.op !== "empty" && c.op !== "notEmpty" && c.value === undefined) {
810
+ return [{ stepId, message: `"${c.op}" on "${c.field}" needs a value.` }];
811
+ }
812
+ return [];
813
+ };
814
+ /** Why an `optionsFrom` reference finds nothing, or null when it is fine. */
815
+ const optionsFromProblem = (raw) => {
816
+ const [head, from, key, column, ...more] = dataRef(raw).split(".");
817
+ if (head === "lists" && from && key && !column) {
818
+ const list = def.lists?.find((l) => l.id === from);
819
+ if (!list) {
820
+ return `"optionsFrom" names an unknown list "${from}".`;
821
+ }
822
+ if (sharedLists.has(from)) {
823
+ return notShown(from);
824
+ }
825
+ return list.columns.some((c) => c.id === key)
826
+ ? null
827
+ : `"optionsFrom": list "${from}" has no column "${key}".`;
828
+ }
829
+ if (head === "steps" && from && key && more.length === 0) {
830
+ const source = seenSteps.has(from) ? def.steps.find((s) => s.id === from) : undefined;
831
+ // An agent step with `each` hands on one table, `rows`: the entry's columns and its fields.
832
+ if (source?.type === "agent" && source.each && key === "rows") {
833
+ return column ? null : `"optionsFrom": "${from}.rows" is a table; name one of its columns.`;
834
+ }
835
+ const out = source?.type === "agent"
836
+ ? (source.output.fields ?? []).find((f) => f.id === key)
837
+ : undefined;
838
+ if (!out) {
839
+ return `"optionsFrom" names "${from}.${key}", which is not an output of an earlier agent step.`;
840
+ }
841
+ if (column) {
842
+ return out.kind === "table" && (!out.columns?.length || out.columns.includes(column))
843
+ ? null
844
+ : `"optionsFrom": "${from}.${key}" is not a table with the column "${column}".`;
845
+ }
846
+ return out.kind === "list" ? null : `"optionsFrom": "${from}.${key}" is not a list.`;
847
+ }
848
+ return `"optionsFrom" is "steps.<id>.<key>", "steps.<id>.<key>.<column>" or "lists.<id>.<column>".`;
849
+ };
637
850
  for (const step of def.steps) {
638
851
  switch (step.type) {
639
- case "page":
852
+ case "page": {
853
+ const groups = new Set();
854
+ for (const g of step.groups ?? []) {
855
+ if (groups.has(g.id)) {
856
+ issues.push({ stepId: step.id, message: `Duplicate field group "${g.id}".` });
857
+ }
858
+ groups.add(g.id);
859
+ if (!step.fields.some((f) => f.group === g.id)) {
860
+ issues.push({ stepId: step.id, message: `Field group "${g.id}" has no fields.` });
861
+ }
862
+ }
863
+ const decided = step.fields.some((f) => f.ask || f.group);
864
+ if (decided && step.fields.length > MAX_DECIDED_FIELDS) {
865
+ issues.push({
866
+ stepId: step.id,
867
+ message: `A page whose fields are decided has at most ${MAX_DECIDED_FIELDS} fields.`,
868
+ });
869
+ }
640
870
  for (const field of step.fields) {
871
+ if (field.ask && field.group) {
872
+ issues.push({
873
+ stepId: step.id,
874
+ message: `Field "${field.id}" has "ask" or "group", not both.`,
875
+ });
876
+ }
877
+ if (field.group && !groups.has(field.group)) {
878
+ issues.push({
879
+ stepId: step.id,
880
+ message: `Field "${field.id}" names an unknown group "${field.group}".`,
881
+ });
882
+ }
883
+ }
884
+ for (const field of step.fields) {
885
+ for (const c of conditionsOf(field.when)) {
886
+ const own = step.fields.some((f) => f.id === c.field && f.id !== field.id);
887
+ if (!own && !knownBefore(step, c.field)) {
888
+ issues.push({
889
+ stepId: step.id,
890
+ message: `Field "${field.id}": "when" reads "${c.field}", which is neither on this page nor known before it.`,
891
+ });
892
+ }
893
+ issues.push(...conditionIssues(step.id, c));
894
+ }
895
+ if (field.optionsFrom) {
896
+ const problem = field.kind !== "select" && field.kind !== "multiselect" && field.kind !== "slot"
897
+ ? `"optionsFrom" fills the choices of a select, multiselect or slot; kind is "${field.kind}".`
898
+ : optionsFromProblem(field.optionsFrom);
899
+ if (problem) {
900
+ issues.push({ stepId: step.id, message: `Field "${field.id}": ${problem}` });
901
+ }
902
+ }
641
903
  if (field.prefill) {
642
904
  const ref = dataRef(field.prefill);
643
905
  if (ref.split(".")[0] !== "steps") {
@@ -653,6 +915,7 @@ export function validateWizard(def, files) {
653
915
  seenFields.add(field.id);
654
916
  }
655
917
  break;
918
+ }
656
919
  case "agent":
657
920
  checkTemplate(step, step.instructions);
658
921
  for (const c of step.connections ?? []) {
@@ -660,6 +923,30 @@ export function validateWizard(def, files) {
660
923
  issues.push({ stepId: step.id, message: `Step uses unknown connection "${c}".` });
661
924
  }
662
925
  }
926
+ if (step.output.surface && step.output.format !== "json") {
927
+ issues.push({
928
+ stepId: step.id,
929
+ message: 'A step composes a view ("surface") of json output only.',
930
+ });
931
+ }
932
+ if (step.each) {
933
+ const ref = dataRef(step.each);
934
+ const list = ref.match(/^lists\.([a-zA-Z][a-zA-Z0-9_]*)$/)?.[1];
935
+ if (list) {
936
+ if (!listIds.has(list)) {
937
+ issues.push({ stepId: step.id, message: `each names an unknown list "${list}".` });
938
+ }
939
+ }
940
+ else if (ref.startsWith("steps.") && ref.split(".").length === 3) {
941
+ checkTemplate(step, `{{${ref}}}`);
942
+ }
943
+ else {
944
+ issues.push({
945
+ stepId: step.id,
946
+ message: `each "${step.each}" is "steps.<id>.<key>" (a table or list output) or "lists.<id>".`,
947
+ });
948
+ }
949
+ }
663
950
  break;
664
951
  case "generate":
665
952
  checkTemplate(step, step.prompt);
@@ -711,6 +998,49 @@ export function validateWizard(def, files) {
711
998
  });
712
999
  }
713
1000
  break;
1001
+ case "surface": {
1002
+ for (const [key, ref] of Object.entries(step.data)) {
1003
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) {
1004
+ issues.push({
1005
+ stepId: step.id,
1006
+ message: `Surface data key "${key}" is not an identifier.`,
1007
+ });
1008
+ }
1009
+ const list = listRef(dataRef(ref));
1010
+ if (list) {
1011
+ if (!listIds.has(list)) {
1012
+ issues.push({
1013
+ stepId: step.id,
1014
+ message: `Surface data reads an unknown list "${list}".`,
1015
+ });
1016
+ }
1017
+ }
1018
+ else {
1019
+ checkTemplate(step, `{{${dataRef(ref)}}}`);
1020
+ }
1021
+ }
1022
+ const groups = new Set((step.groups ?? []).map((g) => g.id));
1023
+ for (const c of step.candidates ?? []) {
1024
+ if (c.components[0]?.id !== c.id) {
1025
+ issues.push({
1026
+ stepId: step.id,
1027
+ message: `Surface piece "${c.id}": its first component carries the piece's id.`,
1028
+ });
1029
+ }
1030
+ if (c.group && !groups.has(c.group)) {
1031
+ issues.push({
1032
+ stepId: step.id,
1033
+ message: `Surface piece "${c.id}" names an unknown group "${c.group}".`,
1034
+ });
1035
+ }
1036
+ }
1037
+ const components = step.components ??
1038
+ assembleSurface(step.candidates ?? [], (step.candidates ?? []).map((c) => c.id));
1039
+ for (const issue of validateSurface(components, Object.keys(step.data))) {
1040
+ issues.push({ stepId: step.id, message: `Surface: ${issue.message}` });
1041
+ }
1042
+ break;
1043
+ }
714
1044
  case "film":
715
1045
  checkTemplate(step, step.brief);
716
1046
  for (const [name, ref] of Object.entries(step.inputs)) {
@@ -778,23 +1108,23 @@ export function validateWizard(def, files) {
778
1108
  }
779
1109
  break;
780
1110
  }
1111
+ if ((step.next ?? []).filter(isOtherwise).length > 1) {
1112
+ issues.push({
1113
+ stepId: step.id,
1114
+ message: 'A step has at most one "otherwise" branch (one without "when" and "ask").',
1115
+ });
1116
+ }
781
1117
  for (const rule of step.next ?? []) {
782
1118
  if (rule.goto !== "end" && !stepIds.has(rule.goto)) {
783
1119
  issues.push({ stepId: step.id, message: `Branch goes to unknown step "${rule.goto}".` });
784
1120
  }
785
- // A branch reads what the person answered, or an output field of this or an earlier
786
- // agent step as `steps.<step>.<field>`.
787
- const [head, from, key, ...more] = rule.when.field.split(".");
788
- const source = head === "steps" && key && more.length === 0 && (from === step.id || seenSteps.has(from))
789
- ? def.steps.find((s) => s.id === from)
790
- : undefined;
791
- const known = fieldIds.has(rule.when.field) ||
792
- (source?.type === "agent" && (source.output.fields ?? []).some((f) => f.id === key));
793
- if (!known) {
794
- issues.push({
795
- stepId: step.id,
796
- message: `Branch reads unknown field "${rule.when.field}".`,
797
- });
1121
+ // A branch reads what the person answered (on any page), an output field of this or an
1122
+ // earlier agent step, or the row count of a stored list.
1123
+ for (const c of conditionsOf(rule.when)) {
1124
+ if (!fieldIds.has(c.field) && !knownBefore(step, c.field, true)) {
1125
+ issues.push({ stepId: step.id, message: `Branch reads unknown field "${c.field}".` });
1126
+ }
1127
+ issues.push(...conditionIssues(step.id, c));
798
1128
  }
799
1129
  }
800
1130
  seenSteps.add(step.id);
@@ -817,48 +1147,271 @@ export function parseWizard(input, files) {
817
1147
  * What branch rules can read in a run: the person's answers by field id, and the structured
818
1148
  * output of the steps run so far as `steps.<step>.<field>`.
819
1149
  */
820
- export function branchValues(values, outputs) {
1150
+ export function branchValues(values, outputs,
1151
+ /** Rows per stored list, read as `lists.<list>.count`. */
1152
+ listCounts = {}) {
821
1153
  const out = { ...values };
1154
+ for (const [list, count] of Object.entries(listCounts)) {
1155
+ out[`lists.${list}.count`] = count;
1156
+ }
822
1157
  for (const [stepId, output] of Object.entries(outputs)) {
823
1158
  if (output.json && typeof output.json === "object" && !Array.isArray(output.json)) {
824
1159
  for (const [key, value] of Object.entries(output.json)) {
825
1160
  out[`steps.${stepId}.${key}`] = value;
826
1161
  }
827
1162
  }
1163
+ for (const [key, p] of Object.entries(output.decided ?? {})) {
1164
+ out[`steps.${stepId}.${key}.p`] = p;
1165
+ }
828
1166
  }
829
1167
  return out;
830
1168
  }
831
- /** The step after `step`, honouring its branch rules. `null` = the end. */
832
- export function nextStepId(def, stepId, values) {
1169
+ /** A branch back to an earlier step without `max` is taken at most this often in one run. */
1170
+ export const DEFAULT_LOOP_MAX = 10;
1171
+ /** How a run counts the times it took a branch: "<step>→<goto>". */
1172
+ export function loopKey(stepId, goto) {
1173
+ return `${stepId}→${goto}`;
1174
+ }
1175
+ /** Whether a rule may still send the run on: one with `max`, or one back, stops after so many. */
1176
+ export function ruleOpen(def, stepId, rule, loops = {}) {
1177
+ const from = def.steps.findIndex((s) => s.id === stepId);
1178
+ const to = rule.goto === "end" ? def.steps.length : def.steps.findIndex((s) => s.id === rule.goto);
1179
+ const max = rule.max ?? (to >= 0 && to <= from ? DEFAULT_LOOP_MAX : Number.POSITIVE_INFINITY);
1180
+ return (loops[loopKey(stepId, rule.goto)] ?? 0) < max;
1181
+ }
1182
+ export function followRules(def, stepId, values, loops = {}) {
833
1183
  const index = def.steps.findIndex((s) => s.id === stepId);
834
1184
  const step = def.steps[index];
835
1185
  if (!step) {
836
- return null;
1186
+ return { kind: "go", cursor: null, rule: null };
837
1187
  }
838
- for (const rule of step.next ?? []) {
839
- if (ruleMatches(rule, values)) {
840
- return rule.goto === "end" ? null : rule.goto;
1188
+ const rules = step.next ?? [];
1189
+ for (const [i, rule] of rules.entries()) {
1190
+ if (ruleOpen(def, stepId, rule, loops) && ruleMatches(rule, values)) {
1191
+ return { kind: "go", cursor: rule.goto === "end" ? null : rule.goto, rule: i };
841
1192
  }
842
1193
  }
1194
+ const asks = rules.flatMap((rule, i) => rule.ask && ruleOpen(def, stepId, rule, loops) ? [i] : []);
1195
+ if (asks.length) {
1196
+ return { kind: "decide", rules: asks };
1197
+ }
1198
+ return { kind: "go", ...otherwiseOf(def, stepId, loops) };
1199
+ }
1200
+ /** A rule with neither a condition nor a statement: where the run goes when no other rule holds. */
1201
+ export function isOtherwise(rule) {
1202
+ return rule.when === undefined && rule.ask === undefined;
1203
+ }
1204
+ /**
1205
+ * Where a step goes when none of its rules holds: its "otherwise" rule (`rule` is its index in
1206
+ * `next`), else the next step in the list. `cursor` null = the end.
1207
+ */
1208
+ export function otherwiseOf(def, stepId, loops = {}) {
1209
+ const index = def.steps.findIndex((s) => s.id === stepId);
1210
+ const rules = def.steps[index]?.next ?? [];
1211
+ const i = rules.findIndex((r) => isOtherwise(r) && ruleOpen(def, stepId, r, loops));
1212
+ if (i >= 0) {
1213
+ return { cursor: rules[i].goto === "end" ? null : rules[i].goto, rule: i };
1214
+ }
1215
+ return { cursor: def.steps[index + 1]?.id ?? null, rule: null };
1216
+ }
1217
+ /** The step after `step` by its `when` rules alone. `null` = the end. */
1218
+ export function nextStepId(def, stepId, values) {
1219
+ const outcome = followRules(def, stepId, values);
1220
+ if (outcome.kind === "go") {
1221
+ return outcome.cursor;
1222
+ }
1223
+ const index = def.steps.findIndex((s) => s.id === stepId);
843
1224
  return def.steps[index + 1]?.id ?? null;
844
1225
  }
845
1226
  function isEmptyValue(v) {
846
1227
  return v === undefined || v === null || v === "" || (Array.isArray(v) && v.length === 0);
847
1228
  }
848
- export function ruleMatches(rule, values) {
849
- const v = values[rule.when.field];
850
- switch (rule.when.op) {
1229
+ /** The conditions of a rule or a field, as a list: one, several, or none. */
1230
+ export function conditionsOf(when) {
1231
+ return when === undefined ? [] : Array.isArray(when) ? when : [when];
1232
+ }
1233
+ /** A typed number as a number: "89,90" from a German number pad counts as 89.9. */
1234
+ function numberOf(v) {
1235
+ if (typeof v === "number") {
1236
+ return Number.isFinite(v) ? v : null;
1237
+ }
1238
+ if (typeof v !== "string" || !v.trim()) {
1239
+ return null;
1240
+ }
1241
+ const n = Number(v.replace(/\s/g, "").replace(",", "."));
1242
+ return Number.isFinite(n) ? n : null;
1243
+ }
1244
+ export function conditionMatches(c, values) {
1245
+ const v = values[c.field];
1246
+ switch (c.op) {
851
1247
  case "empty":
852
1248
  return isEmptyValue(v);
853
1249
  case "notEmpty":
854
1250
  return !isEmptyValue(v);
855
1251
  case "equals":
856
- return String(v) === String(rule.when.value);
1252
+ return String(v) === String(c.value);
857
1253
  case "notEquals":
858
- return String(v) !== String(rule.when.value);
1254
+ return String(v) !== String(c.value);
859
1255
  case "in":
860
- return Array.isArray(rule.when.value) && rule.when.value.map(String).includes(String(v));
1256
+ return Array.isArray(c.value) && c.value.map(String).includes(String(v));
1257
+ case "gt":
1258
+ case "lt": {
1259
+ const a = numberOf(v);
1260
+ const b = numberOf(c.value);
1261
+ return a !== null && b !== null && (c.op === "gt" ? a > b : a < b);
1262
+ }
1263
+ case "contains":
1264
+ if (Array.isArray(v)) {
1265
+ return v.map(String).includes(String(c.value));
1266
+ }
1267
+ return (typeof v === "string" &&
1268
+ c.value !== undefined &&
1269
+ v.toLowerCase().includes(String(c.value).toLowerCase()));
1270
+ }
1271
+ }
1272
+ /** Whether every condition holds; no conditions hold trivially. */
1273
+ export function whenHolds(when, values) {
1274
+ return conditionsOf(when).every((c) => conditionMatches(c, values));
1275
+ }
1276
+ /** Whether a `when` rule holds. An `ask` rule never holds here: a decision takes it (see decide). */
1277
+ export function ruleMatches(rule, values) {
1278
+ return rule.when !== undefined && whenHolds(rule.when, values);
1279
+ }
1280
+ /** A page with decided fields has at most this many; it shows at most MAX_SHOWN_FIELDS of them. */
1281
+ export const MAX_DECIDED_FIELDS = 12;
1282
+ export const MAX_SHOWN_FIELDS = 5;
1283
+ /** Whether a field is shown only when a decision keeps it. */
1284
+ export function isDecidedField(field) {
1285
+ return Boolean(field.ask || field.group);
1286
+ }
1287
+ /**
1288
+ * The fields of a page that are shown: those whose `when` holds on what was known before the
1289
+ * page (`known`) and what the page holds so far (`page`). A hidden field's value does not count
1290
+ * for the fields after it.
1291
+ */
1292
+ export function shownFields(fields, known, page) {
1293
+ const values = { ...known, ...page };
1294
+ const out = [];
1295
+ for (const field of fields) {
1296
+ if (whenHolds(field.when, values)) {
1297
+ out.push(field);
1298
+ }
1299
+ else {
1300
+ delete values[field.id];
1301
+ }
1302
+ }
1303
+ return out;
1304
+ }
1305
+ /** The references the `when` of a page's fields read outside the page: what the page must know. */
1306
+ export function pageNeeds(fields) {
1307
+ const own = new Set(fields.map((f) => f.id));
1308
+ return [
1309
+ ...new Set(fields.flatMap((f) => conditionsOf(f.when).map((c) => c.field)).filter((r) => !own.has(r))),
1310
+ ];
1311
+ }
1312
+ /** At most this many choices come from data. */
1313
+ export const MAX_DATA_OPTIONS = 100;
1314
+ /**
1315
+ * The choices an `optionsFrom` reference finds in a run: the entries of a list output, one
1316
+ * column of a table output, or one column of a stored list — as text, without duplicates and
1317
+ * empties. Empty when the data is not there (yet).
1318
+ */
1319
+ export function optionsFromData(ref, outputs, lists = {}) {
1320
+ const [head, id, key, column, ...more] = dataRef(ref).split(".");
1321
+ let raw = [];
1322
+ if (head === "lists" && id && key && !column) {
1323
+ raw = (lists[id]?.rows ?? []).map((r) => r.cells[key]);
1324
+ }
1325
+ else if (head === "steps" && id && key && more.length === 0) {
1326
+ const json = outputs[id]?.json;
1327
+ const value = json && typeof json === "object" ? json[key] : undefined;
1328
+ if (Array.isArray(value)) {
1329
+ raw = column
1330
+ ? value.map((row) => row && typeof row === "object" ? row[column] : undefined)
1331
+ : value;
1332
+ }
1333
+ }
1334
+ const seen = new Set();
1335
+ for (const v of raw) {
1336
+ if (v === null || v === undefined || typeof v === "object") {
1337
+ continue;
1338
+ }
1339
+ const text = String(v).trim().slice(0, 200);
1340
+ if (text) {
1341
+ seen.add(text);
1342
+ }
1343
+ if (seen.size >= MAX_DATA_OPTIONS) {
1344
+ break;
1345
+ }
1346
+ }
1347
+ return [...seen];
1348
+ }
1349
+ // ---------------------------------------------------------------------------
1350
+ // Appointment times
1351
+ const SLOT_RE = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2})(?:\.\d+)?)?(Z|[+-]\d{2}:\d{2})$/;
1352
+ /**
1353
+ * A slot option as day and time in its own offset (the business's time zone, not the reader's).
1354
+ * Null when it is not an ISO 8601 date-time with an offset.
1355
+ */
1356
+ export function slotTime(iso) {
1357
+ const m = SLOT_RE.exec(iso.trim());
1358
+ if (!m) {
1359
+ return null;
1360
+ }
1361
+ const [, y, mo, d, h, mi] = m.map(Number);
1362
+ const wall = new Date(Date.UTC(y, mo - 1, d, h, mi));
1363
+ if (wall.getUTCMonth() !== mo - 1 ||
1364
+ wall.getUTCDate() !== d ||
1365
+ h > 23 ||
1366
+ mi > 59 ||
1367
+ !Number.isFinite(Date.parse(iso))) {
1368
+ return null;
1369
+ }
1370
+ return { day: `${m[1]}-${m[2]}-${m[3]}`, time: `${m[4]}:${m[5]}`, wall };
1371
+ }
1372
+ /** The weekday and date of a slot's day: "Montag, 12. Oktober" / "Monday, 12 October". */
1373
+ export function slotDayText(iso, lang = "de") {
1374
+ const slot = slotTime(iso);
1375
+ if (!slot) {
1376
+ return iso;
1377
+ }
1378
+ const locale = LOCALES[lang];
1379
+ const weekday = slot.wall.toLocaleDateString(locale, { weekday: "long", timeZone: "UTC" });
1380
+ const date = slot.wall.toLocaleDateString(locale, {
1381
+ day: "numeric",
1382
+ month: "long",
1383
+ timeZone: "UTC",
1384
+ });
1385
+ return `${weekday}, ${date}`;
1386
+ }
1387
+ /** A chosen slot as a person reads it back: "Di 13.10.2026, 10:30" / "Tue 13/10/2026, 10:30". */
1388
+ export function slotText(iso, lang = "de") {
1389
+ const slot = slotTime(iso);
1390
+ if (!slot) {
1391
+ return iso;
1392
+ }
1393
+ const locale = LOCALES[lang];
1394
+ const weekday = slot.wall
1395
+ .toLocaleDateString(locale, { weekday: "short", timeZone: "UTC" })
1396
+ .replace(/\.$/, "");
1397
+ const date = slot.wall.toLocaleDateString(locale, {
1398
+ day: "2-digit",
1399
+ month: "2-digit",
1400
+ year: "numeric",
1401
+ timeZone: "UTC",
1402
+ });
1403
+ return `${weekday} ${date}, ${slot.time}`;
1404
+ }
1405
+ /**
1406
+ * The slot among `options` that is the same moment as `value`, written as offered — so an answer
1407
+ * in another offset ("…T07:00:00Z" for "…T09:00:00+02:00") picks the offered time.
1408
+ */
1409
+ export function offeredSlot(value, options) {
1410
+ if (options.includes(value)) {
1411
+ return value;
861
1412
  }
1413
+ const at = slotTime(value) ? Date.parse(value) : Number.NaN;
1414
+ return Number.isFinite(at) ? options.find((o) => slotTime(o) && Date.parse(o) === at) : undefined;
862
1415
  }
863
1416
  export function itemsTotals(field, value, values) {
864
1417
  const rows = Array.isArray(value) ? value : [];