@marktiderman/genesis-forms-studio 0.0.0-stage → 0.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/LICENSE +21 -0
- package/README.md +97 -2
- package/dist/chunk-HAEZ7G3G.js +705 -0
- package/dist/chunk-HAEZ7G3G.js.map +1 -0
- package/dist/index.d.ts +297 -0
- package/dist/index.js +1011 -0
- package/dist/index.js.map +1 -0
- package/dist/rules-BoOPNHuo.d.ts +475 -0
- package/dist/rules.d.ts +4 -0
- package/dist/rules.js +3 -0
- package/dist/rules.js.map +1 -0
- package/dist/styles.css +10 -0
- package/package.json +66 -3
|
@@ -0,0 +1,705 @@
|
|
|
1
|
+
import { summarizeValue, isOtherValue, otherOption, choiceOptions } from '@marktiderman/genesis-forms';
|
|
2
|
+
|
|
3
|
+
// src/answers.ts
|
|
4
|
+
function carryAnswers(from, to, answers, extra = {}) {
|
|
5
|
+
const byKey = /* @__PURE__ */ new Map();
|
|
6
|
+
for (const block of from.blocks) {
|
|
7
|
+
if (block.kind === "field" && answers[block.id] !== void 0)
|
|
8
|
+
byKey.set(block.key, answers[block.id]);
|
|
9
|
+
}
|
|
10
|
+
for (const [key, value] of Object.entries(extra)) {
|
|
11
|
+
if (value !== null && value !== void 0 && !byKey.has(key)) byKey.set(key, value);
|
|
12
|
+
}
|
|
13
|
+
const out = {};
|
|
14
|
+
for (const block of to.blocks) {
|
|
15
|
+
if (block.kind === "field" && byKey.has(block.key)) out[block.id] = byKey.get(block.key);
|
|
16
|
+
}
|
|
17
|
+
return out;
|
|
18
|
+
}
|
|
19
|
+
function answerCell(field, value) {
|
|
20
|
+
if (value === void 0 || value === null) return "";
|
|
21
|
+
if (field.type === "yes-no")
|
|
22
|
+
return value === true ? "Yes" : value === false ? "No" : String(value);
|
|
23
|
+
if (field.type === "consent")
|
|
24
|
+
return value === true ? "Agreed" : value === false ? "Didn't agree" : String(value);
|
|
25
|
+
const options = Array.isArray(field.props?.["options"]) ? field.props["options"] : [];
|
|
26
|
+
const label = (v) => options.find((o) => o.value === v)?.label ?? String(v);
|
|
27
|
+
const structured = Array.isArray(value) ? value.some((v) => v !== null && typeof v === "object") : typeof value === "object";
|
|
28
|
+
if (structured) {
|
|
29
|
+
const summary = summarizeValue(value, field);
|
|
30
|
+
return summary && !summary.includes("[object Object]") ? summary : JSON.stringify(value);
|
|
31
|
+
}
|
|
32
|
+
if (Array.isArray(value)) return value.map(label).join(", ");
|
|
33
|
+
if (options.length) return label(value);
|
|
34
|
+
return typeof value === "number" || typeof value === "boolean" ? value : String(value);
|
|
35
|
+
}
|
|
36
|
+
var plainLabel = (label) => label.replace(/\{\{[^}|]*\|?([^}]*)\}\}/g, (_, fallback) => fallback || "\u2026");
|
|
37
|
+
function answerColumns(definitions, skip = /* @__PURE__ */ new Set()) {
|
|
38
|
+
const columns = [];
|
|
39
|
+
const seen = /* @__PURE__ */ new Set();
|
|
40
|
+
for (const definition of definitions) {
|
|
41
|
+
for (const block of definition.blocks) {
|
|
42
|
+
if (block.kind !== "field" || seen.has(block.key) || skip.has(block.key)) continue;
|
|
43
|
+
seen.add(block.key);
|
|
44
|
+
columns.push({ key: block.key, label: plainLabel(block.label) });
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return columns;
|
|
48
|
+
}
|
|
49
|
+
function answersByKey(definition, answers) {
|
|
50
|
+
const cells = {};
|
|
51
|
+
for (const block of definition.blocks) {
|
|
52
|
+
if (block.kind !== "field") continue;
|
|
53
|
+
const value = answers[block.id];
|
|
54
|
+
if (value !== void 0) cells[block.key] = answerCell(block, value);
|
|
55
|
+
}
|
|
56
|
+
return cells;
|
|
57
|
+
}
|
|
58
|
+
function valuesByKey(definition, answers) {
|
|
59
|
+
const values = /* @__PURE__ */ new Map();
|
|
60
|
+
for (const block of definition.blocks) {
|
|
61
|
+
if (block.kind !== "field") continue;
|
|
62
|
+
const value = answers[block.id];
|
|
63
|
+
if (value !== void 0 && value !== null) values.set(block.key, { field: block, value });
|
|
64
|
+
}
|
|
65
|
+
return values;
|
|
66
|
+
}
|
|
67
|
+
function toCsv(header, rows) {
|
|
68
|
+
const quote = (v) => {
|
|
69
|
+
const raw = String(v);
|
|
70
|
+
const s = typeof v === "string" && /^[=+\-@\t\r]/.test(raw) ? `'${raw}` : raw;
|
|
71
|
+
return /[",\r\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
|
|
72
|
+
};
|
|
73
|
+
return [header, ...rows].map((r) => r.map(quote).join(",")).join("\r\n") + "\r\n";
|
|
74
|
+
}
|
|
75
|
+
var STATUS_FILTER_LABEL = {
|
|
76
|
+
counted: "Sent and partial",
|
|
77
|
+
completed: "Sent",
|
|
78
|
+
partial: "Partial",
|
|
79
|
+
in_progress: "In progress"
|
|
80
|
+
};
|
|
81
|
+
var RESPONSE_STATUS_LABEL = {
|
|
82
|
+
in_progress: "In progress",
|
|
83
|
+
partial: "Partial",
|
|
84
|
+
completed: "Sent"
|
|
85
|
+
};
|
|
86
|
+
function matchesStatus(item, filter = "counted") {
|
|
87
|
+
const status = item.status ?? "completed";
|
|
88
|
+
return filter === "counted" ? status !== "in_progress" : status === filter;
|
|
89
|
+
}
|
|
90
|
+
var CHOICE_TYPES = /* @__PURE__ */ new Set([
|
|
91
|
+
"single-choice",
|
|
92
|
+
"multi-choice",
|
|
93
|
+
"dropdown",
|
|
94
|
+
"yes-no",
|
|
95
|
+
"consent"
|
|
96
|
+
]);
|
|
97
|
+
var YES_NO = [
|
|
98
|
+
{ value: "true", label: "Yes" },
|
|
99
|
+
{ value: "false", label: "No" }
|
|
100
|
+
];
|
|
101
|
+
var CONSENT = [
|
|
102
|
+
{ value: "true", label: "Agreed" },
|
|
103
|
+
{ value: "false", label: "Didn't agree" }
|
|
104
|
+
];
|
|
105
|
+
var OTHER_BUCKET = "__other__";
|
|
106
|
+
var optionsOf = (field) => field.type === "yes-no" ? YES_NO : field.type === "consent" ? CONSENT : choiceOptions(field).map((o) => ({ value: o.value, label: o.label ?? o.value }));
|
|
107
|
+
var ratingMax = (field) => {
|
|
108
|
+
const max = field.props?.["max"];
|
|
109
|
+
return typeof max === "number" && Number.isInteger(max) && max >= 1 && max <= 100 ? max : 5;
|
|
110
|
+
};
|
|
111
|
+
var scaleOf = (field) => field.type === "nps" ? { min: 0, max: 10 } : field.type === "rating" ? { min: 1, max: ratingMax(field) } : null;
|
|
112
|
+
var newestFirst = (items) => items.map((item, index) => ({ item, index })).sort(
|
|
113
|
+
(x, y) => x.item.date < y.item.date ? 1 : x.item.date > y.item.date ? -1 : x.index - y.index
|
|
114
|
+
).map((x) => x.item);
|
|
115
|
+
function summarizeResponses(items, definitions, skip = /* @__PURE__ */ new Set()) {
|
|
116
|
+
const newest = /* @__PURE__ */ new Map();
|
|
117
|
+
for (const definition of definitions) {
|
|
118
|
+
for (const block of definition.blocks) {
|
|
119
|
+
if (block.kind === "field" && !newest.has(block.key)) newest.set(block.key, block);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
const answered = newestFirst(items).map((item) => ({
|
|
123
|
+
item,
|
|
124
|
+
values: valuesByKey(item.definition, item.answers),
|
|
125
|
+
cells: answersByKey(item.definition, item.answers)
|
|
126
|
+
}));
|
|
127
|
+
return answerColumns(definitions, skip).flatMap((column) => {
|
|
128
|
+
const field = newest.get(column.key);
|
|
129
|
+
if (!field) return [];
|
|
130
|
+
const { key, label } = column;
|
|
131
|
+
if (field.type === "rating" || field.type === "nps") {
|
|
132
|
+
const nps = field.type === "nps";
|
|
133
|
+
const min = nps ? 0 : 1;
|
|
134
|
+
const max = nps ? 10 : ratingMax(field);
|
|
135
|
+
const counts = new Array(max - min + 1).fill(0);
|
|
136
|
+
let count = 0;
|
|
137
|
+
let sum = 0;
|
|
138
|
+
let promoters = 0;
|
|
139
|
+
let detractors = 0;
|
|
140
|
+
let rescaled = 0;
|
|
141
|
+
let excluded = 0;
|
|
142
|
+
for (const { values } of answered) {
|
|
143
|
+
const answer = values.get(key);
|
|
144
|
+
if (!answer) continue;
|
|
145
|
+
const v = answer.value;
|
|
146
|
+
const own = scaleOf(answer.field);
|
|
147
|
+
if (!own || typeof v !== "number" || !Number.isInteger(v) || v < own.min || v > own.max) {
|
|
148
|
+
excluded++;
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
let x = v;
|
|
152
|
+
if (own.min !== min || own.max !== max) {
|
|
153
|
+
x = own.max === own.min ? max : min + (v - own.min) * (max - min) / (own.max - own.min);
|
|
154
|
+
rescaled++;
|
|
155
|
+
}
|
|
156
|
+
const point = Math.round(x);
|
|
157
|
+
counts[point - min] += 1;
|
|
158
|
+
count++;
|
|
159
|
+
sum += x;
|
|
160
|
+
if (point >= 9) promoters++;
|
|
161
|
+
else if (point <= 6) detractors++;
|
|
162
|
+
}
|
|
163
|
+
return [
|
|
164
|
+
{
|
|
165
|
+
kind: "scale",
|
|
166
|
+
key,
|
|
167
|
+
label,
|
|
168
|
+
type: field.type,
|
|
169
|
+
fieldType: field.type,
|
|
170
|
+
count,
|
|
171
|
+
min,
|
|
172
|
+
max,
|
|
173
|
+
average: count ? sum / count : null,
|
|
174
|
+
buckets: counts.map((c, i) => ({ value: min + i, count: c })),
|
|
175
|
+
rescaled,
|
|
176
|
+
excluded,
|
|
177
|
+
nps: nps && count ? {
|
|
178
|
+
promoters,
|
|
179
|
+
passives: count - promoters - detractors,
|
|
180
|
+
detractors,
|
|
181
|
+
score: Math.round((promoters - detractors) / count * 100)
|
|
182
|
+
} : null
|
|
183
|
+
}
|
|
184
|
+
];
|
|
185
|
+
}
|
|
186
|
+
if (CHOICE_TYPES.has(field.type)) {
|
|
187
|
+
const options = optionsOf(field).map((o) => ({ ...o, count: 0 }));
|
|
188
|
+
const find = (value, from) => {
|
|
189
|
+
if (isOtherValue(from, value) && !options.some((o) => o.value === value)) {
|
|
190
|
+
value = OTHER_BUCKET;
|
|
191
|
+
let bucket = options.find((o) => o.value === OTHER_BUCKET);
|
|
192
|
+
if (!bucket) {
|
|
193
|
+
bucket = { value: OTHER_BUCKET, label: (otherOption(field) ?? otherOption(from)).label, count: 0 };
|
|
194
|
+
options.push(bucket);
|
|
195
|
+
}
|
|
196
|
+
return bucket;
|
|
197
|
+
}
|
|
198
|
+
let option = options.find((o) => o.value === value);
|
|
199
|
+
if (!option) {
|
|
200
|
+
const old = optionsOf(from).find((o) => o.value === value);
|
|
201
|
+
option = { value, label: old?.label ?? value, count: 0 };
|
|
202
|
+
options.push(option);
|
|
203
|
+
}
|
|
204
|
+
return option;
|
|
205
|
+
};
|
|
206
|
+
let count = 0;
|
|
207
|
+
for (const { values } of answered) {
|
|
208
|
+
const answer = values.get(key);
|
|
209
|
+
if (!answer) continue;
|
|
210
|
+
const chosen = (Array.isArray(answer.value) ? answer.value : [answer.value]).filter(
|
|
211
|
+
(v) => typeof v === "string" || typeof v === "boolean"
|
|
212
|
+
);
|
|
213
|
+
if (!chosen.length) continue;
|
|
214
|
+
count++;
|
|
215
|
+
for (const v of new Set(chosen.map(String))) find(v, answer.field).count++;
|
|
216
|
+
}
|
|
217
|
+
return [
|
|
218
|
+
{
|
|
219
|
+
kind: "choice",
|
|
220
|
+
key,
|
|
221
|
+
label,
|
|
222
|
+
fieldType: field.type,
|
|
223
|
+
count,
|
|
224
|
+
multiple: field.type === "multi-choice",
|
|
225
|
+
options: options.map((o) => ({ ...o, percent: count ? o.count / count * 100 : 0 }))
|
|
226
|
+
}
|
|
227
|
+
];
|
|
228
|
+
}
|
|
229
|
+
const answers = answered.flatMap(({ item, cells }) => {
|
|
230
|
+
const text = String(cells[key] ?? "").trim();
|
|
231
|
+
return text ? [{ id: item.id, date: item.date, text }] : [];
|
|
232
|
+
});
|
|
233
|
+
return [{ kind: "text", key, label, fieldType: field.type, count: answers.length, answers }];
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
function highestRating(definitions) {
|
|
237
|
+
let max = null;
|
|
238
|
+
for (const definition of definitions) {
|
|
239
|
+
for (const block of definition.blocks) {
|
|
240
|
+
if (block.kind === "field" && block.type === "rating")
|
|
241
|
+
max = Math.max(max ?? 0, ratingMax(block));
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
return max;
|
|
245
|
+
}
|
|
246
|
+
function lowestRating(item) {
|
|
247
|
+
let lowest = null;
|
|
248
|
+
for (const { field, value } of valuesByKey(item.definition, item.answers).values()) {
|
|
249
|
+
if (field.type === "rating" && typeof value === "number" && (lowest === null || value < lowest))
|
|
250
|
+
lowest = value;
|
|
251
|
+
}
|
|
252
|
+
return lowest;
|
|
253
|
+
}
|
|
254
|
+
var LOW_SHARE = 0.4;
|
|
255
|
+
var NPS_LOW = 6;
|
|
256
|
+
function lowRatingAtMost(field) {
|
|
257
|
+
if (field.type === "nps") return NPS_LOW;
|
|
258
|
+
if (field.type !== "rating") return null;
|
|
259
|
+
return lowScaleAtMost("rating", ratingMax(field));
|
|
260
|
+
}
|
|
261
|
+
function lowScaleAtMost(type, max) {
|
|
262
|
+
return type === "nps" ? NPS_LOW : Math.max(1, Math.floor(max * LOW_SHARE));
|
|
263
|
+
}
|
|
264
|
+
function hasLowRating(item) {
|
|
265
|
+
return hasLowRatingExcept(item, /* @__PURE__ */ new Set());
|
|
266
|
+
}
|
|
267
|
+
function hasLowRatingExcept(item, skip) {
|
|
268
|
+
for (const [key, { field, value }] of valuesByKey(item.definition, item.answers)) {
|
|
269
|
+
if (skip.has(key)) continue;
|
|
270
|
+
const atMost = lowRatingAtMost(field);
|
|
271
|
+
if (atMost !== null && typeof value === "number" && value <= atMost) return true;
|
|
272
|
+
}
|
|
273
|
+
return false;
|
|
274
|
+
}
|
|
275
|
+
function lowRatingLabel(definitions) {
|
|
276
|
+
const scales = /* @__PURE__ */ new Set();
|
|
277
|
+
let nps = false;
|
|
278
|
+
for (const definition of definitions) {
|
|
279
|
+
for (const block of definition.blocks) {
|
|
280
|
+
if (block.kind !== "field") continue;
|
|
281
|
+
if (block.type === "nps") nps = true;
|
|
282
|
+
else if (block.type === "rating") scales.add(ratingMax(block));
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
const several = scales.size + (nps ? 1 : 0) > 1;
|
|
286
|
+
const parts = [...scales].sort((a, b) => a - b).map((max) => {
|
|
287
|
+
const atMost = Math.max(1, Math.floor(max * LOW_SHARE));
|
|
288
|
+
return several ? `${atMost} or below out of ${max}` : `${atMost} or below`;
|
|
289
|
+
});
|
|
290
|
+
if (nps) parts.push(several ? `0\u2013${NPS_LOW} on NPS` : `0\u2013${NPS_LOW}`);
|
|
291
|
+
return parts.length ? parts.join(", ") : null;
|
|
292
|
+
}
|
|
293
|
+
function isFiltered(filter) {
|
|
294
|
+
return Boolean(
|
|
295
|
+
filter.from || filter.to || filter.ratingAtMost != null || filter.low || filter.status && filter.status !== "counted" || Object.values(filter.match ?? {}).some((v) => v != null && v !== "")
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
function filterResponses(items, filter) {
|
|
299
|
+
const match = Object.entries(filter.match ?? {}).filter(
|
|
300
|
+
(e) => e[1] != null && e[1] !== ""
|
|
301
|
+
);
|
|
302
|
+
return items.filter((item) => {
|
|
303
|
+
if (!matchesStatus(item, filter.status)) return false;
|
|
304
|
+
if (filter.from && item.date < filter.from) return false;
|
|
305
|
+
if (filter.to && item.date > filter.to) return false;
|
|
306
|
+
if (match.some(([key, value]) => String(item.extra?.[key] ?? "") !== value)) return false;
|
|
307
|
+
if (filter.ratingAtMost != null) {
|
|
308
|
+
const lowest = lowestRating(item);
|
|
309
|
+
if (lowest === null || lowest > filter.ratingAtMost) return false;
|
|
310
|
+
}
|
|
311
|
+
if (filter.low && !hasLowRating(item)) return false;
|
|
312
|
+
return true;
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
var addDays = (day, n) => {
|
|
316
|
+
const t = Date.parse(`${day}T00:00:00Z`) + n * 864e5;
|
|
317
|
+
return new Date(t).toISOString().slice(0, 10);
|
|
318
|
+
};
|
|
319
|
+
function presetRange(preset, today) {
|
|
320
|
+
switch (preset) {
|
|
321
|
+
case "all":
|
|
322
|
+
return { from: null, to: null };
|
|
323
|
+
case "today":
|
|
324
|
+
return { from: today, to: today };
|
|
325
|
+
case "week": {
|
|
326
|
+
const weekday = (/* @__PURE__ */ new Date(`${today}T00:00:00Z`)).getUTCDay();
|
|
327
|
+
return { from: addDays(today, -((weekday + 6) % 7)), to: today };
|
|
328
|
+
}
|
|
329
|
+
case "7d":
|
|
330
|
+
return { from: addDays(today, -6), to: today };
|
|
331
|
+
case "30d":
|
|
332
|
+
return { from: addDays(today, -29), to: today };
|
|
333
|
+
case "month":
|
|
334
|
+
return { from: `${today.slice(0, 7)}-01`, to: today };
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
var NO_FILTERS = {
|
|
338
|
+
when: "all",
|
|
339
|
+
from: "",
|
|
340
|
+
to: "",
|
|
341
|
+
match: {},
|
|
342
|
+
ratingAtMost: null,
|
|
343
|
+
low: false
|
|
344
|
+
};
|
|
345
|
+
function toResponseFilter(value, today) {
|
|
346
|
+
const range = value.when === "custom" ? { from: value.from || null, to: value.to || null } : presetRange(value.when, today);
|
|
347
|
+
return {
|
|
348
|
+
...range,
|
|
349
|
+
match: value.match,
|
|
350
|
+
ratingAtMost: value.ratingAtMost,
|
|
351
|
+
low: value.low,
|
|
352
|
+
...value.status ? { status: value.status } : {}
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
var lowThisWeek = (value) => ({
|
|
356
|
+
...value,
|
|
357
|
+
when: "week",
|
|
358
|
+
low: true
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
// src/headline.ts
|
|
362
|
+
function headlineNumbers(items, definitions, options = {}) {
|
|
363
|
+
const previous = options.previous ?? null;
|
|
364
|
+
const summaries = summarizeResponses(items, definitions, options.skip);
|
|
365
|
+
const partial = items.filter((i) => i.status === "partial").length;
|
|
366
|
+
const before = previous ? summarizeResponses(previous, definitions, options.skip) : null;
|
|
367
|
+
const scale = summaries.find((s) => s.kind === "scale" && s.type === "rating");
|
|
368
|
+
let rating = null;
|
|
369
|
+
if (scale && scale.kind === "scale") {
|
|
370
|
+
const old = before?.find((s) => s.kind === "scale" && s.key === scale.key);
|
|
371
|
+
const previousAverage = old && old.kind === "scale" ? old.average : null;
|
|
372
|
+
rating = {
|
|
373
|
+
key: scale.key,
|
|
374
|
+
label: scale.label,
|
|
375
|
+
max: scale.max,
|
|
376
|
+
average: scale.average,
|
|
377
|
+
previousAverage,
|
|
378
|
+
change: scale.average !== null && previousAverage !== null ? Math.round((scale.average - previousAverage) * 10) / 10 : null
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
const scored = summaries.some((s) => s.kind === "scale");
|
|
382
|
+
const choice = summaries.find((s) => s.kind === "choice" && s.fieldType === "consent") ?? summaries.find((s) => s.kind === "choice" && s.fieldType === "yes-no");
|
|
383
|
+
let rate = null;
|
|
384
|
+
if (choice && choice.kind === "choice") {
|
|
385
|
+
const yes = choice.options.find((o) => o.value === "true")?.count ?? 0;
|
|
386
|
+
rate = {
|
|
387
|
+
key: choice.key,
|
|
388
|
+
label: choice.label,
|
|
389
|
+
type: choice.fieldType,
|
|
390
|
+
yes,
|
|
391
|
+
count: choice.count,
|
|
392
|
+
percent: choice.count ? Math.round(yes / choice.count * 100) : null
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
return {
|
|
396
|
+
responses: {
|
|
397
|
+
count: items.length,
|
|
398
|
+
previous: previous ? previous.length : null,
|
|
399
|
+
change: previous ? items.length - previous.length : null,
|
|
400
|
+
...partial ? { partial } : {}
|
|
401
|
+
},
|
|
402
|
+
rating,
|
|
403
|
+
// The same skip as needsFollowUp, so the Low ratings card and the follow-up list agree.
|
|
404
|
+
low: scored ? { count: items.filter((i) => hasLowRatingExcept(i, options.skip ?? /* @__PURE__ */ new Set())).length } : null,
|
|
405
|
+
rate
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
var addDays2 = (day, n) => new Date(Date.parse(`${day}T00:00:00Z`) + n * 864e5).toISOString().slice(0, 10);
|
|
409
|
+
var daysBetween = (from, to) => Math.round((Date.parse(`${to}T00:00:00Z`) - Date.parse(`${from}T00:00:00Z`)) / 864e5);
|
|
410
|
+
function comparisonFilter(value, today) {
|
|
411
|
+
const range = value.when === "custom" ? { from: value.from || null, to: value.to || null } : presetRange(value.when, today);
|
|
412
|
+
if (!range.from || !range.to || range.to < range.from) return null;
|
|
413
|
+
let from;
|
|
414
|
+
let to;
|
|
415
|
+
if (value.when === "month") {
|
|
416
|
+
const first = `${range.from.slice(0, 7)}-01`;
|
|
417
|
+
const lastMonth = addDays2(first, -1);
|
|
418
|
+
from = `${lastMonth.slice(0, 7)}-01`;
|
|
419
|
+
const day = Math.min(Number(range.to.slice(8, 10)), Number(lastMonth.slice(8, 10)));
|
|
420
|
+
to = `${lastMonth.slice(0, 7)}-${String(day).padStart(2, "0")}`;
|
|
421
|
+
} else if (value.when === "week") {
|
|
422
|
+
from = addDays2(range.from, -7);
|
|
423
|
+
to = addDays2(range.to, -7);
|
|
424
|
+
} else {
|
|
425
|
+
const length = daysBetween(range.from, range.to) + 1;
|
|
426
|
+
to = addDays2(range.from, -1);
|
|
427
|
+
from = addDays2(range.from, -length);
|
|
428
|
+
}
|
|
429
|
+
const label = value.when === "today" ? "yesterday" : value.when === "week" ? "last week" : value.when === "month" ? "last month" : value.when === "7d" ? "the 7 days before" : value.when === "30d" ? "the 30 days before" : "the days before";
|
|
430
|
+
return {
|
|
431
|
+
filter: {
|
|
432
|
+
from,
|
|
433
|
+
to,
|
|
434
|
+
match: value.match,
|
|
435
|
+
ratingAtMost: value.ratingAtMost,
|
|
436
|
+
low: value.low,
|
|
437
|
+
...value.status ? { status: value.status } : {}
|
|
438
|
+
},
|
|
439
|
+
label
|
|
440
|
+
};
|
|
441
|
+
}
|
|
442
|
+
var signed = (n, digits = 0) => {
|
|
443
|
+
const s = Math.abs(n).toFixed(digits);
|
|
444
|
+
return n > 0 ? `+${s}` : n < 0 ? `\u2212${s}` : s;
|
|
445
|
+
};
|
|
446
|
+
function headlineCards(headline, options = {}) {
|
|
447
|
+
const cards = [];
|
|
448
|
+
const { responses, rating, low, rate } = headline;
|
|
449
|
+
const partial = responses.partial ? `${responses.partial} partial` : null;
|
|
450
|
+
cards.push({
|
|
451
|
+
key: "responses",
|
|
452
|
+
label: "Responses",
|
|
453
|
+
value: responses.count,
|
|
454
|
+
...responses.change !== null && options.period ? {
|
|
455
|
+
detail: (responses.change === 0 ? `Same as ${options.period}` : `${signed(responses.change)} vs ${options.period}`) + (partial ? ` \xB7 ${partial}` : ""),
|
|
456
|
+
tone: responses.change > 0 ? "positive" : responses.change < 0 ? "negative" : "muted"
|
|
457
|
+
} : partial ? { detail: `Including ${partial}` } : {}
|
|
458
|
+
});
|
|
459
|
+
if (rating) {
|
|
460
|
+
const change = rating.change;
|
|
461
|
+
cards.push({
|
|
462
|
+
key: "rating",
|
|
463
|
+
label: "Average rating",
|
|
464
|
+
value: rating.average === null ? "\u2013" : `${rating.average.toFixed(1)} / ${rating.max}`,
|
|
465
|
+
...change !== null && options.period ? {
|
|
466
|
+
detail: change === 0 ? `Same as ${options.period}` : `${change > 0 ? "Up" : "Down"} ${Math.abs(change).toFixed(1)}`,
|
|
467
|
+
tone: change > 0 ? "positive" : change < 0 ? "warning" : "muted"
|
|
468
|
+
} : rating.average === null ? { detail: "No ratings yet" } : {}
|
|
469
|
+
});
|
|
470
|
+
}
|
|
471
|
+
if (low) {
|
|
472
|
+
const open = options.lowOpen ?? low.count;
|
|
473
|
+
cards.push({
|
|
474
|
+
key: "low",
|
|
475
|
+
label: "Low ratings",
|
|
476
|
+
value: low.count,
|
|
477
|
+
...open ? { detail: open === 1 ? "Needs a follow-up" : `${open} need a follow-up`, tone: "negative" } : { detail: low.count ? "All followed up" : "None" }
|
|
478
|
+
});
|
|
479
|
+
}
|
|
480
|
+
if (rate) {
|
|
481
|
+
cards.push({
|
|
482
|
+
key: "rate",
|
|
483
|
+
label: rate.label || (rate.type === "consent" ? "Agreed" : "Yes"),
|
|
484
|
+
value: rate.percent === null ? "\u2013" : `${rate.percent}%`,
|
|
485
|
+
detail: rate.count ? `${rate.yes} of ${rate.count} ${rate.type === "consent" ? "agreed" : "said yes"}` : "No answers yet"
|
|
486
|
+
});
|
|
487
|
+
}
|
|
488
|
+
return cards;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// src/dropoff.ts
|
|
492
|
+
function dropOffByQuestion(items, definitions, skip = /* @__PURE__ */ new Set()) {
|
|
493
|
+
const views = /* @__PURE__ */ new Map();
|
|
494
|
+
const stopped = /* @__PURE__ */ new Map();
|
|
495
|
+
const add = (map, key) => map.set(key, (map.get(key) ?? 0) + 1);
|
|
496
|
+
for (const item of items) {
|
|
497
|
+
const status = item.status ?? "completed";
|
|
498
|
+
if (status === "in_progress") continue;
|
|
499
|
+
const keys = item.definition.blocks.flatMap((b) => b.kind === "field" ? [{ id: b.id, key: b.key }] : []);
|
|
500
|
+
if (status === "completed") {
|
|
501
|
+
for (const { key } of keys) add(views, key);
|
|
502
|
+
continue;
|
|
503
|
+
}
|
|
504
|
+
const at = item.furthest ? keys.findIndex((k) => k.id === item.furthest) : -1;
|
|
505
|
+
if (at < 0) continue;
|
|
506
|
+
for (const { key } of keys.slice(0, at + 1)) add(views, key);
|
|
507
|
+
add(stopped, keys[at].key);
|
|
508
|
+
}
|
|
509
|
+
return answerColumns(definitions, skip).map(({ key, label }) => {
|
|
510
|
+
const reached = views.get(key) ?? 0;
|
|
511
|
+
const left = stopped.get(key) ?? 0;
|
|
512
|
+
return {
|
|
513
|
+
key,
|
|
514
|
+
label,
|
|
515
|
+
views: reached,
|
|
516
|
+
stopped: left,
|
|
517
|
+
dropOff: reached ? Math.round(left / reached * 100) : null
|
|
518
|
+
};
|
|
519
|
+
});
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
// src/followups.ts
|
|
523
|
+
var TEXT_TYPES = /* @__PURE__ */ new Set(["long-text", "short-text", "text"]);
|
|
524
|
+
var scaleMax = (type, props) => type === "nps" ? 10 : typeof props?.["max"] === "number" ? props["max"] : 5;
|
|
525
|
+
function followUpOf(item, skip = /* @__PURE__ */ new Set()) {
|
|
526
|
+
let rating = null;
|
|
527
|
+
let comment = null;
|
|
528
|
+
for (const [key, { field, value }] of valuesByKey(item.definition, item.answers)) {
|
|
529
|
+
if (skip.has(key)) continue;
|
|
530
|
+
const atMost = lowRatingAtMost(field);
|
|
531
|
+
if (atMost !== null && typeof value === "number" && value <= atMost) {
|
|
532
|
+
const max = scaleMax(field.type, field.props);
|
|
533
|
+
if (!rating || value / max < rating.value / rating.max)
|
|
534
|
+
rating = { value, max, type: field.type };
|
|
535
|
+
} else if (comment === null && TEXT_TYPES.has(field.type)) {
|
|
536
|
+
const text = String(answerCell(field, value)).trim();
|
|
537
|
+
if (text) comment = text;
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
return rating ? { item, rating, comment } : null;
|
|
541
|
+
}
|
|
542
|
+
function needsFollowUp(items, options = {}) {
|
|
543
|
+
return items.map((item, index) => ({ index, f: options.isHandled?.(item) ? null : followUpOf(item, options.skip) })).filter((x) => x.f !== null).sort(
|
|
544
|
+
(x, y) => x.f.item.date < y.f.item.date ? 1 : x.f.item.date > y.f.item.date ? -1 : x.index - y.index
|
|
545
|
+
).map((x) => x.f);
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
// src/generic/days.ts
|
|
549
|
+
var MONTH_DAY = new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", timeZone: "UTC" });
|
|
550
|
+
var MONTH_DAY_YEAR = new Intl.DateTimeFormat("en-US", {
|
|
551
|
+
month: "short",
|
|
552
|
+
day: "numeric",
|
|
553
|
+
year: "numeric",
|
|
554
|
+
timeZone: "UTC"
|
|
555
|
+
});
|
|
556
|
+
function dayLabel(day, today) {
|
|
557
|
+
const t = Date.parse(`${day}T00:00:00Z`);
|
|
558
|
+
if (Number.isNaN(t)) return day;
|
|
559
|
+
if (today) {
|
|
560
|
+
const diff = Math.round((Date.parse(`${today}T00:00:00Z`) - t) / 864e5);
|
|
561
|
+
if (diff === 0) return "Today";
|
|
562
|
+
if (diff === 1) return "Yesterday";
|
|
563
|
+
if (day.slice(0, 4) !== today.slice(0, 4)) return MONTH_DAY_YEAR.format(t);
|
|
564
|
+
}
|
|
565
|
+
return MONTH_DAY.format(t);
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
// src/generic/share.ts
|
|
569
|
+
var RELATIVE_BASE = "http://relative.invalid";
|
|
570
|
+
function prefilledUrl(url, values) {
|
|
571
|
+
let absolute = true;
|
|
572
|
+
let u;
|
|
573
|
+
try {
|
|
574
|
+
u = new URL(url);
|
|
575
|
+
} catch {
|
|
576
|
+
absolute = false;
|
|
577
|
+
u = new URL(url, RELATIVE_BASE);
|
|
578
|
+
}
|
|
579
|
+
for (const [k, v] of Object.entries(values)) if (v) u.searchParams.set(k, v);
|
|
580
|
+
if (absolute) return u.toString();
|
|
581
|
+
const path = url.startsWith("/") ? u.pathname : u.pathname.replace(/^\//, "");
|
|
582
|
+
return `${path}${u.search}${u.hash}`;
|
|
583
|
+
}
|
|
584
|
+
var attr = (s) => s.replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<").replace(/>/g, ">");
|
|
585
|
+
function embedSnippet(url, title) {
|
|
586
|
+
return `<iframe src="${attr(url)}" title="${attr(title)}" width="100%" height="720" style="border:0;max-width:100%" loading="lazy"></iframe>`;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
// src/availability.ts
|
|
590
|
+
function isFull(form) {
|
|
591
|
+
if (form.full !== void 0) return form.full;
|
|
592
|
+
return form.responseCap != null && form.completedResponses != null && form.completedResponses >= form.responseCap;
|
|
593
|
+
}
|
|
594
|
+
var INSTANT = /^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:?\d{2})$/i;
|
|
595
|
+
function parseInstant(value) {
|
|
596
|
+
const v = value.trim();
|
|
597
|
+
return INSTANT.test(v) ? Date.parse(v.replace(" ", "T")) : Number.NaN;
|
|
598
|
+
}
|
|
599
|
+
function formAvailability(form, at = /* @__PURE__ */ new Date()) {
|
|
600
|
+
if (form.status === "archived") return "archived";
|
|
601
|
+
if (form.status === "draft" || form.published === false) return "draft";
|
|
602
|
+
if (form.paused) return "paused";
|
|
603
|
+
const t = at.getTime();
|
|
604
|
+
if (form.opensAt) {
|
|
605
|
+
const opens = parseInstant(form.opensAt);
|
|
606
|
+
if (Number.isNaN(opens)) return "closed";
|
|
607
|
+
if (t < opens) return "scheduled";
|
|
608
|
+
}
|
|
609
|
+
if (form.closesAt) {
|
|
610
|
+
const closes = parseInstant(form.closesAt);
|
|
611
|
+
if (Number.isNaN(closes) || t >= closes) return "closed";
|
|
612
|
+
}
|
|
613
|
+
if (isFull(form)) return "full";
|
|
614
|
+
return "live";
|
|
615
|
+
}
|
|
616
|
+
function takesResponses(form, at = /* @__PURE__ */ new Date()) {
|
|
617
|
+
return formAvailability(form, at) === "live";
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
// src/status.ts
|
|
621
|
+
function formStatusChip(availability, version) {
|
|
622
|
+
const v = version ? ` \xB7 version ${version}` : "";
|
|
623
|
+
switch (availability) {
|
|
624
|
+
case "live":
|
|
625
|
+
return { label: `Live${v}`, tone: "success" };
|
|
626
|
+
case "draft":
|
|
627
|
+
return { label: "Draft", tone: "muted" };
|
|
628
|
+
case "paused":
|
|
629
|
+
return { label: `Paused${v}`, tone: "warning" };
|
|
630
|
+
case "scheduled":
|
|
631
|
+
return { label: `Scheduled${v}`, tone: "info" };
|
|
632
|
+
case "closed":
|
|
633
|
+
return { label: `Closed${v}`, tone: "muted" };
|
|
634
|
+
case "full":
|
|
635
|
+
return { label: `Full${v}`, tone: "warning" };
|
|
636
|
+
case "archived":
|
|
637
|
+
return { label: "Archived", tone: "muted" };
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
function formCounts(definition) {
|
|
641
|
+
if (!definition) return { questions: 0, logic: 0 };
|
|
642
|
+
return {
|
|
643
|
+
questions: definition.blocks.filter((b) => b.kind === "field").length,
|
|
644
|
+
logic: (definition.logic?.length ?? 0) + (definition.endings?.length ?? 0)
|
|
645
|
+
};
|
|
646
|
+
}
|
|
647
|
+
function formNavItems(at, counts = {}, icons) {
|
|
648
|
+
const to = typeof at === "function" ? at : (tab) => tab === "questions" ? at : `${at}?tab=${tab}`;
|
|
649
|
+
const item = (id, label, badge) => {
|
|
650
|
+
const icon = icons?.[id];
|
|
651
|
+
return {
|
|
652
|
+
id,
|
|
653
|
+
to: to(id),
|
|
654
|
+
label,
|
|
655
|
+
...badge == null ? {} : { badge },
|
|
656
|
+
...icon ? { icon } : {}
|
|
657
|
+
};
|
|
658
|
+
};
|
|
659
|
+
return [
|
|
660
|
+
item("questions", "Questions", counts.questions),
|
|
661
|
+
item("logic", "Logic and endings", counts.logic),
|
|
662
|
+
item("responses", "Responses", counts.responses),
|
|
663
|
+
item("settings", "Settings")
|
|
664
|
+
];
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
// src/generic/status.ts
|
|
668
|
+
var SAVE_STATE_LABEL = {
|
|
669
|
+
saved: "Changes saved",
|
|
670
|
+
saving: "Saving\u2026",
|
|
671
|
+
unsaved: "Unsaved changes",
|
|
672
|
+
failed: "Couldn't save; trying again",
|
|
673
|
+
stopped: "Couldn't save",
|
|
674
|
+
conflict: "Changed elsewhere; reload"
|
|
675
|
+
};
|
|
676
|
+
function readinessOf(findings, options = {}) {
|
|
677
|
+
if (findings.some((f) => f.code === "no-questions")) return { state: "empty", count: 0 };
|
|
678
|
+
const errors = findings.filter((f) => f.level === "error").length;
|
|
679
|
+
if (errors) return { state: "problems", count: errors };
|
|
680
|
+
if (options.pending) return { state: "pending", count: 0 };
|
|
681
|
+
const warnings = findings.filter((f) => f.level === "warning").length;
|
|
682
|
+
if (warnings) return { state: "warnings", count: warnings };
|
|
683
|
+
return { state: "ready", count: 0 };
|
|
684
|
+
}
|
|
685
|
+
function readinessChip(readiness) {
|
|
686
|
+
switch (readiness.state) {
|
|
687
|
+
case "ready":
|
|
688
|
+
return { label: "Ready to publish", tone: "success" };
|
|
689
|
+
case "empty":
|
|
690
|
+
return { label: "Add a question", tone: "muted" };
|
|
691
|
+
case "pending":
|
|
692
|
+
return { label: "In progress", tone: "muted" };
|
|
693
|
+
case "problems":
|
|
694
|
+
return {
|
|
695
|
+
label: `${readiness.count} ${readiness.count === 1 ? "problem" : "problems"} to fix`,
|
|
696
|
+
tone: "danger"
|
|
697
|
+
};
|
|
698
|
+
case "warnings":
|
|
699
|
+
return { label: `${readiness.count} to check`, tone: "warning" };
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
export { NO_FILTERS, RESPONSE_STATUS_LABEL, SAVE_STATE_LABEL, STATUS_FILTER_LABEL, answerCell, answerColumns, answersByKey, carryAnswers, comparisonFilter, dayLabel, dropOffByQuestion, embedSnippet, filterResponses, followUpOf, formAvailability, formCounts, formNavItems, formStatusChip, hasLowRating, hasLowRatingExcept, headlineCards, headlineNumbers, highestRating, isFiltered, isFull, lowRatingAtMost, lowRatingLabel, lowScaleAtMost, lowThisWeek, lowestRating, matchesStatus, needsFollowUp, parseInstant, plainLabel, prefilledUrl, presetRange, readinessChip, readinessOf, summarizeResponses, takesResponses, toCsv, toResponseFilter, valuesByKey };
|
|
704
|
+
//# sourceMappingURL=chunk-HAEZ7G3G.js.map
|
|
705
|
+
//# sourceMappingURL=chunk-HAEZ7G3G.js.map
|