mevebu 1.11.0 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +16 -20
- package/mevebu/app/mevebu.js +1 -1
- package/mevebu/app/tailwind.css +34 -20
- package/mevebu/libs/js/core.js +1 -1
- package/mevebu/libs/js/csv.js +87 -0
- package/mevebu/libs/js/random.js +54 -0
- package/mevebu/libs/js/registry.js +163 -37
- package/mevebu/libs/js/tools/csv-viewer.js +1 -51
- package/mevebu/libs/js/tools/data-dictionary.js +204 -0
- package/mevebu/libs/js/tools/dataset-splitter.js +181 -0
- package/mevebu/libs/js/tools/json-schema-sample.js +254 -0
- package/mevebu/libs/js/tools/mock-api-payload.js +224 -0
- package/mevebu/libs/js/tools/roster-generator.js +195 -0
- package/mevebu/libs/js/tools/sample-csv.js +218 -0
- package/mevebu/libs/js/tools/sql-insert.js +184 -0
- package/mevebu/libs/js/tools/survey-responses.js +225 -0
- package/mevebu/libs/js/tools/team-generator.js +203 -0
- package/mevebu/libs/js/tools/time-series.js +191 -0
- package/package.json +1 -1
- package/mevebu/libs/js/tools/coin-flip.js +0 -78
- package/mevebu/libs/js/tools/name-picker.js +0 -68
- package/mevebu/libs/js/tools/spin-wheel.js +0 -168
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
// A CSV with the columns and the types you describe. The same seed always
|
|
2
|
+
// produces the same file, so a fixture can be regenerated when it drifts.
|
|
3
|
+
import {
|
|
4
|
+
el, icon, toast, download, bench, stack, panel, textInput, selectInput,
|
|
5
|
+
numberInput, button, actions, toolcard, blank, notice,
|
|
6
|
+
} from "../core.js";
|
|
7
|
+
import { toCsv } from "../csv.js";
|
|
8
|
+
import { seeded, pick, uuid } from "../random.js";
|
|
9
|
+
|
|
10
|
+
const TYPES = [
|
|
11
|
+
{ value: "text", label: "Text" },
|
|
12
|
+
{ value: "word", label: "Word" },
|
|
13
|
+
{ value: "number", label: "Number" },
|
|
14
|
+
{ value: "decimal", label: "Decimal" },
|
|
15
|
+
{ value: "boolean", label: "Boolean" },
|
|
16
|
+
{ value: "date", label: "Date" },
|
|
17
|
+
{ value: "email", label: "Email" },
|
|
18
|
+
{ value: "uuid", label: "UUID" },
|
|
19
|
+
{ value: "choice", label: "Choice list" },
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
const WORDS = [
|
|
23
|
+
"alpha", "bravo", "cedar", "delta", "ember", "field", "grove", "harbor", "ivory", "juniper",
|
|
24
|
+
"kelp", "lumen", "meadow", "north", "orbit", "pine", "quartz", "ridge", "summit", "tide",
|
|
25
|
+
"umber", "vale", "willow", "xenon", "yarrow", "zephyr",
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
function value(type, choices, random, rowIndex) {
|
|
29
|
+
switch (type) {
|
|
30
|
+
case "word":
|
|
31
|
+
return pick(random, WORDS);
|
|
32
|
+
case "number":
|
|
33
|
+
return String(Math.floor(random() * 1000));
|
|
34
|
+
case "decimal":
|
|
35
|
+
return (random() * 100).toFixed(2);
|
|
36
|
+
case "boolean":
|
|
37
|
+
return random() > 0.5 ? "true" : "false";
|
|
38
|
+
case "date": {
|
|
39
|
+
const day = new Date(Date.UTC(2025, 0, 1) + Math.floor(random() * 700) * 86400000);
|
|
40
|
+
return day.toISOString().slice(0, 10);
|
|
41
|
+
}
|
|
42
|
+
case "email":
|
|
43
|
+
return `${pick(random, WORDS)}${rowIndex + 1}@example.com`;
|
|
44
|
+
case "uuid":
|
|
45
|
+
return uuid(random);
|
|
46
|
+
case "choice":
|
|
47
|
+
return choices.length ? pick(random, choices) : "";
|
|
48
|
+
default:
|
|
49
|
+
return `${pick(random, WORDS)} ${pick(random, WORDS)}`;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function mount(slot) {
|
|
54
|
+
let columns = [
|
|
55
|
+
{ name: "id", type: "number", choices: "" },
|
|
56
|
+
{ name: "name", type: "word", choices: "" },
|
|
57
|
+
{ name: "active", type: "boolean", choices: "" },
|
|
58
|
+
];
|
|
59
|
+
|
|
60
|
+
const rows = numberInput({ min: "1", max: "10000", step: "1", value: "50", "aria-label": "Row count" });
|
|
61
|
+
const seed = textInput({ value: "mevebu", "aria-label": "Seed", class: "font-mono" });
|
|
62
|
+
const error = notice("warn");
|
|
63
|
+
const summary = el("p", { class: "text-sm text-ink-2" });
|
|
64
|
+
const list = el("div", { class: "grid gap-2" });
|
|
65
|
+
const preview = el("div", { class: "overflow-x-auto rounded-r2 border border-orbit" });
|
|
66
|
+
const empty = blank("table", "Add a column to see the preview.");
|
|
67
|
+
|
|
68
|
+
const addButton = button("Add column", { icon: "plus", kind: "quiet" });
|
|
69
|
+
const generate = button("Generate", { icon: "refresh-cw", kind: "fill" });
|
|
70
|
+
const downloadButton = button("Download CSV", { icon: "download" });
|
|
71
|
+
|
|
72
|
+
let table = [];
|
|
73
|
+
let header = [];
|
|
74
|
+
|
|
75
|
+
function renderColumns() {
|
|
76
|
+
list.replaceChildren(
|
|
77
|
+
...columns.map((column, index) =>
|
|
78
|
+
el(
|
|
79
|
+
"div",
|
|
80
|
+
{ class: "grid gap-2 rounded-r1 border border-orbit bg-deck-2 p-2 [grid-template-columns:minmax(0,1fr)_auto]" },
|
|
81
|
+
el("div", { class: "grid gap-2 sm:grid-cols-2" },
|
|
82
|
+
textInput({
|
|
83
|
+
value: column.name,
|
|
84
|
+
placeholder: `column_${index + 1}`,
|
|
85
|
+
"aria-label": `Column ${index + 1} name`,
|
|
86
|
+
oninput: (event) => {
|
|
87
|
+
column.name = event.target.value;
|
|
88
|
+
run();
|
|
89
|
+
},
|
|
90
|
+
}),
|
|
91
|
+
selectInput(
|
|
92
|
+
TYPES.map((type) => ({ value: type.value, label: type.label })),
|
|
93
|
+
{
|
|
94
|
+
value: column.type,
|
|
95
|
+
"aria-label": `Column ${index + 1} type`,
|
|
96
|
+
onchange: (event) => {
|
|
97
|
+
column.type = event.target.value;
|
|
98
|
+
renderColumns();
|
|
99
|
+
run();
|
|
100
|
+
},
|
|
101
|
+
}
|
|
102
|
+
),
|
|
103
|
+
column.type === "choice"
|
|
104
|
+
? textInput({
|
|
105
|
+
value: column.choices,
|
|
106
|
+
placeholder: "red, green, blue",
|
|
107
|
+
"aria-label": `Column ${index + 1} choices`,
|
|
108
|
+
class: "sm:col-span-2",
|
|
109
|
+
oninput: (event) => {
|
|
110
|
+
column.choices = event.target.value;
|
|
111
|
+
run();
|
|
112
|
+
},
|
|
113
|
+
})
|
|
114
|
+
: null),
|
|
115
|
+
el("button", {
|
|
116
|
+
class: "inline-flex size-9 cursor-pointer items-center justify-center self-start rounded-[5px] border-0 bg-transparent text-ink-3 hover:bg-deck hover:text-danger disabled:opacity-40",
|
|
117
|
+
type: "button",
|
|
118
|
+
"aria-label": `Remove column ${index + 1}`,
|
|
119
|
+
disabled: columns.length === 1,
|
|
120
|
+
onclick: () => {
|
|
121
|
+
columns.splice(index, 1);
|
|
122
|
+
renderColumns();
|
|
123
|
+
run();
|
|
124
|
+
},
|
|
125
|
+
}, icon("trash", "size-4"))
|
|
126
|
+
)
|
|
127
|
+
)
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function renderPreview() {
|
|
132
|
+
empty.hidden = table.length > 0;
|
|
133
|
+
preview.hidden = table.length === 0;
|
|
134
|
+
if (!table.length) {
|
|
135
|
+
preview.replaceChildren();
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
const shown = table.slice(0, 8);
|
|
139
|
+
const head = el("div", {
|
|
140
|
+
class: "grid border-b border-orbit bg-deck-2 px-3 py-2 text-2xs font-bold tracking-[0.06em] text-ink-3 uppercase",
|
|
141
|
+
style: `grid-template-columns: repeat(${header.length}, minmax(110px, 1fr))`,
|
|
142
|
+
}, ...header.map((cell) => el("span", { class: "truncate" }, cell)));
|
|
143
|
+
|
|
144
|
+
const body = shown.map((row) =>
|
|
145
|
+
el("div", {
|
|
146
|
+
class: "grid border-b border-orbit px-3 py-1.5 text-sm last:border-b-0",
|
|
147
|
+
style: `grid-template-columns: repeat(${header.length}, minmax(110px, 1fr))`,
|
|
148
|
+
}, ...row.map((cell) => el("span", { class: "truncate font-mono text-2xs text-ink-2" }, cell)))
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
preview.replaceChildren(head, ...body);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function run() {
|
|
155
|
+
error.write("");
|
|
156
|
+
const count = Math.max(1, Math.min(10000, Number(rows.value) || 1));
|
|
157
|
+
const names = columns.map((column, index) => column.name.trim() || `column_${index + 1}`);
|
|
158
|
+
if (new Set(names).size !== names.length) error.write("Two columns share a name, so the CSV will have duplicates.");
|
|
159
|
+
|
|
160
|
+
const random = seeded(seed.value || "mevebu");
|
|
161
|
+
header = names;
|
|
162
|
+
table = Array.from({ length: count }, (_, rowIndex) =>
|
|
163
|
+
columns.map((column) =>
|
|
164
|
+
value(
|
|
165
|
+
column.type,
|
|
166
|
+
column.choices.split(",").map((choice) => choice.trim()).filter(Boolean),
|
|
167
|
+
random,
|
|
168
|
+
rowIndex
|
|
169
|
+
)
|
|
170
|
+
)
|
|
171
|
+
);
|
|
172
|
+
|
|
173
|
+
summary.textContent = `${count} row${count === 1 ? "" : "s"} · ${columns.length} column${columns.length === 1 ? "" : "s"} · seed ${seed.value || "mevebu"}`;
|
|
174
|
+
renderPreview();
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
addButton.addEventListener("click", () => {
|
|
178
|
+
columns.push({ name: `column_${columns.length + 1}`, type: "text", choices: "" });
|
|
179
|
+
renderColumns();
|
|
180
|
+
run();
|
|
181
|
+
});
|
|
182
|
+
rows.addEventListener("input", run);
|
|
183
|
+
seed.addEventListener("input", run);
|
|
184
|
+
generate.addEventListener("click", () => {
|
|
185
|
+
run();
|
|
186
|
+
toast("Sample rebuilt");
|
|
187
|
+
});
|
|
188
|
+
downloadButton.addEventListener("click", () => {
|
|
189
|
+
if (!table.length) return;
|
|
190
|
+
download("sample.csv", toCsv([header, ...table]), "text/csv");
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
renderColumns();
|
|
194
|
+
run();
|
|
195
|
+
|
|
196
|
+
const shell = toolcard({
|
|
197
|
+
body: bench(
|
|
198
|
+
stack(
|
|
199
|
+
panel("Rows", rows),
|
|
200
|
+
panel("Seed", seed, "The same seed always makes the same file."),
|
|
201
|
+
el("div", { class: "grid gap-1.5" },
|
|
202
|
+
el("div", { class: "flex items-center justify-between gap-2" },
|
|
203
|
+
el("span", { class: "text-sm font-semibold text-ink-2" }, "Columns"),
|
|
204
|
+
addButton),
|
|
205
|
+
list),
|
|
206
|
+
error
|
|
207
|
+
),
|
|
208
|
+
stack(summary, empty, preview)
|
|
209
|
+
),
|
|
210
|
+
foot: actions(generate, downloadButton),
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
slot.append(shell.node);
|
|
214
|
+
|
|
215
|
+
return {};
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export default { mount };
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
// CSV to INSERT statements. Quoting follows the dialect, because Postgres and
|
|
2
|
+
// MySQL disagree about backslashes and about boolean literals.
|
|
3
|
+
import {
|
|
4
|
+
el, toast, download, bench, stack, panel, selectInput, textInput, checkbox,
|
|
5
|
+
button, actions, toolcard, blank, notice, output,
|
|
6
|
+
} from "../core.js";
|
|
7
|
+
import { dropzone, baseName, formatBytes } from "../files.js";
|
|
8
|
+
import { parseDelimited, detectDelimiter, headerKeys } from "../csv.js";
|
|
9
|
+
|
|
10
|
+
const DIALECTS = {
|
|
11
|
+
postgres: { label: "PostgreSQL", quote: "'", escape: (v) => v.replace(/'/g, "''"), bool: (v) => (/^(true|1|yes)$/i.test(v) ? "TRUE" : "FALSE") },
|
|
12
|
+
mysql: { label: "MySQL", quote: "'", escape: (v) => v.replace(/\\/g, "\\\\").replace(/'/g, "''"), bool: (v) => (/^(true|1|yes)$/i.test(v) ? "1" : "0") },
|
|
13
|
+
sqlite: { label: "SQLite", quote: "'", escape: (v) => v.replace(/'/g, "''"), bool: (v) => (/^(true|1|yes)$/i.test(v) ? "1" : "0") },
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export function mount(slot) {
|
|
17
|
+
let file = null;
|
|
18
|
+
let rows = [];
|
|
19
|
+
let header = [];
|
|
20
|
+
let headerTypes = [];
|
|
21
|
+
|
|
22
|
+
const tableName = textInput({ value: "my_table", "aria-label": "Table name" });
|
|
23
|
+
const schema = textInput({ placeholder: "public", "aria-label": "Schema" });
|
|
24
|
+
const dialect = selectInput(
|
|
25
|
+
Object.entries(DIALECTS).map(([value, config]) => ({ value, label: config.label })),
|
|
26
|
+
{ value: "postgres", "aria-label": "Dialect" }
|
|
27
|
+
);
|
|
28
|
+
const perStatement = selectInput(
|
|
29
|
+
[
|
|
30
|
+
{ value: "row", label: "One per row" },
|
|
31
|
+
{ value: "batch", label: "One multi row insert" },
|
|
32
|
+
],
|
|
33
|
+
{ value: "row", "aria-label": "Statement shape" }
|
|
34
|
+
);
|
|
35
|
+
const useNulls = checkbox("Empty cells become NULL", { checked: true });
|
|
36
|
+
const truncate = checkbox("Start with TRUNCATE", { checked: false });
|
|
37
|
+
|
|
38
|
+
const error = notice("warn");
|
|
39
|
+
const summary = el("p", { class: "text-sm text-ink-2" });
|
|
40
|
+
const result = output({ rows: 14 });
|
|
41
|
+
const empty = blank("database", "Drop a CSV to build the statements.");
|
|
42
|
+
|
|
43
|
+
const drop = dropzone({ label: "Drop a CSV here", accept: ".csv,text/csv,text/plain", iconName: "database" });
|
|
44
|
+
const clearButton = button("Clear", { icon: "trash", kind: "quiet" });
|
|
45
|
+
const copyButton = button("Copy SQL", {
|
|
46
|
+
icon: "copy",
|
|
47
|
+
onclick: async () => {
|
|
48
|
+
if (!result.textContent) return;
|
|
49
|
+
try {
|
|
50
|
+
await navigator.clipboard.writeText(result.textContent);
|
|
51
|
+
toast("SQL copied");
|
|
52
|
+
} catch {
|
|
53
|
+
error.write("The clipboard is blocked here. Download the .sql file instead.");
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
});
|
|
57
|
+
const downloadButton = button("Download .sql", { icon: "download", kind: "fill" });
|
|
58
|
+
|
|
59
|
+
function quote(value, config) {
|
|
60
|
+
if (useNulls.input.checked && value === "") return "NULL";
|
|
61
|
+
return `${config.quote}${config.escape(value)}${config.quote}`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function build() {
|
|
65
|
+
if (!rows.length) return "";
|
|
66
|
+
const config = DIALECTS[dialect.value] ?? DIALECTS.postgres;
|
|
67
|
+
const name = tableName.value.trim() || "my_table";
|
|
68
|
+
const prefix = schema.value.trim() ? `${schema.value.trim()}.${name}` : name;
|
|
69
|
+
const columns = header.map((cell) => `"${cell.replace(/"/g, '""')}"`).join(", ");
|
|
70
|
+
const lines = [];
|
|
71
|
+
|
|
72
|
+
if (truncate.input.checked) lines.push(`TRUNCATE TABLE ${prefix};`);
|
|
73
|
+
|
|
74
|
+
if (perStatement.value === "batch") {
|
|
75
|
+
const values = rows
|
|
76
|
+
.map((row) => ` (${row.map((cell, index) => {
|
|
77
|
+
const columnType = headerTypes[index];
|
|
78
|
+
return columnType === "boolean" ? config.bool(cell) : quote(cell, config);
|
|
79
|
+
}).join(", ")})`)
|
|
80
|
+
.join(",\n");
|
|
81
|
+
lines.push(`INSERT INTO ${prefix} (${columns}) VALUES\n${values};`);
|
|
82
|
+
} else {
|
|
83
|
+
for (const row of rows) {
|
|
84
|
+
const values = row.map((cell, index) => {
|
|
85
|
+
const columnType = headerTypes[index];
|
|
86
|
+
return columnType === "boolean" ? config.bool(cell) : quote(cell, config);
|
|
87
|
+
}).join(", ");
|
|
88
|
+
lines.push(`INSERT INTO ${prefix} (${columns}) VALUES (${values});`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return lines.join("\n");
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function detectTypes() {
|
|
95
|
+
headerTypes = header.map((_, index) => {
|
|
96
|
+
const sample = rows.slice(0, 20).map((row) => row[index] ?? "").filter((cell) => cell !== "");
|
|
97
|
+
if (sample.length && sample.every((cell) => /^(true|false|1|0|yes|no)$/i.test(cell))) return "boolean";
|
|
98
|
+
return "text";
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function render() {
|
|
103
|
+
empty.hidden = rows.length > 0;
|
|
104
|
+
result.hidden = rows.length === 0;
|
|
105
|
+
copyButton.disabled = rows.length === 0;
|
|
106
|
+
downloadButton.disabled = rows.length === 0;
|
|
107
|
+
if (!rows.length) {
|
|
108
|
+
summary.textContent = "";
|
|
109
|
+
result.write("");
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
detectTypes();
|
|
113
|
+
const sql = build();
|
|
114
|
+
result.write(sql);
|
|
115
|
+
const bytes = new TextEncoder().encode(sql).length;
|
|
116
|
+
summary.textContent = `${rows.length} row${rows.length === 1 ? "" : "s"} · ${header.length} column${header.length === 1 ? "" : "s"} · ${formatBytes(bytes)} of SQL`;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function open(files) {
|
|
120
|
+
const picked = Array.from(files).find((entry) => /\.(csv|tsv|txt)$/i.test(entry.name) || entry.type.startsWith("text/"));
|
|
121
|
+
if (!picked) {
|
|
122
|
+
error.write("That file is not a CSV.");
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
try {
|
|
126
|
+
const text = await picked.text();
|
|
127
|
+
const delimiter = detectDelimiter(text);
|
|
128
|
+
const parsed = parseDelimited(text, delimiter);
|
|
129
|
+
if (parsed.length < 2) throw new Error("A CSV needs a header row and at least one data row.");
|
|
130
|
+
file = picked;
|
|
131
|
+
header = headerKeys(parsed[0]);
|
|
132
|
+
rows = parsed.slice(1).filter((row) => row.some((cell) => cell !== ""));
|
|
133
|
+
error.write("");
|
|
134
|
+
render();
|
|
135
|
+
} catch (caught) {
|
|
136
|
+
error.write(caught?.message || "This file could not be read.");
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
drop.onFiles(open);
|
|
141
|
+
tableName.addEventListener("input", render);
|
|
142
|
+
schema.addEventListener("input", render);
|
|
143
|
+
dialect.addEventListener("change", render);
|
|
144
|
+
perStatement.addEventListener("change", render);
|
|
145
|
+
useNulls.input.addEventListener("change", render);
|
|
146
|
+
truncate.input.addEventListener("change", render);
|
|
147
|
+
|
|
148
|
+
downloadButton.addEventListener("click", () => {
|
|
149
|
+
if (!rows.length) return;
|
|
150
|
+
download(`${baseName(file?.name || "data")}.sql`, result.textContent, "application/sql");
|
|
151
|
+
});
|
|
152
|
+
clearButton.addEventListener("click", () => {
|
|
153
|
+
file = null;
|
|
154
|
+
rows = [];
|
|
155
|
+
header = [];
|
|
156
|
+
error.write("");
|
|
157
|
+
render();
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
render();
|
|
161
|
+
|
|
162
|
+
const shell = toolcard({
|
|
163
|
+
body: bench(
|
|
164
|
+
stack(
|
|
165
|
+
drop,
|
|
166
|
+
panel("Table", tableName),
|
|
167
|
+
panel("Schema", schema, "Optional. Leave empty for the default."),
|
|
168
|
+
panel("Dialect", dialect),
|
|
169
|
+
panel("Statement shape", perStatement),
|
|
170
|
+
useNulls,
|
|
171
|
+
truncate,
|
|
172
|
+
error
|
|
173
|
+
),
|
|
174
|
+
stack(summary, empty, result)
|
|
175
|
+
),
|
|
176
|
+
foot: actions(clearButton, copyButton, downloadButton),
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
slot.append(shell.node);
|
|
180
|
+
|
|
181
|
+
return {};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export default { mount };
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
// Fake survey responses. Each question carries weights, so a dashboard can be
|
|
2
|
+
// tested against answers that lean the way real answers lean, not uniform noise.
|
|
3
|
+
import {
|
|
4
|
+
el, icon, toast, download, bench, stack, panel, textInput, selectInput,
|
|
5
|
+
numberInput, button, actions, toolcard, blank, notice,
|
|
6
|
+
} from "../core.js";
|
|
7
|
+
import { toCsv } from "../csv.js";
|
|
8
|
+
import { seeded, pick, uuid } from "../random.js";
|
|
9
|
+
|
|
10
|
+
const TYPES = [
|
|
11
|
+
{ value: "scale", label: "Scale 1 to 5" },
|
|
12
|
+
{ value: "single", label: "Single choice" },
|
|
13
|
+
{ value: "multi", label: "Multiple choice" },
|
|
14
|
+
{ value: "text", label: "Short text" },
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
const WORDS = ["clear", "useful", "slow", "confusing", "helpful", "missing", "fast", "solid"];
|
|
18
|
+
|
|
19
|
+
function weighted(random, options) {
|
|
20
|
+
const total = options.reduce((sum, option) => sum + Math.max(0, option.weight), 0);
|
|
21
|
+
if (total <= 0) return pick(random, options).label;
|
|
22
|
+
let roll = random() * total;
|
|
23
|
+
for (const option of options) {
|
|
24
|
+
roll -= Math.max(0, option.weight);
|
|
25
|
+
if (roll <= 0) return option.label;
|
|
26
|
+
}
|
|
27
|
+
return options[options.length - 1].label;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function mount(slot) {
|
|
31
|
+
let questions = [
|
|
32
|
+
{
|
|
33
|
+
text: "How easy was it to use?",
|
|
34
|
+
type: "scale",
|
|
35
|
+
options: [
|
|
36
|
+
{ label: "1", weight: 1 },
|
|
37
|
+
{ label: "2", weight: 2 },
|
|
38
|
+
{ label: "3", weight: 5 },
|
|
39
|
+
{ label: "4", weight: 7 },
|
|
40
|
+
{ label: "5", weight: 5 },
|
|
41
|
+
],
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
text: "Which part did you use most?",
|
|
45
|
+
type: "single",
|
|
46
|
+
options: [
|
|
47
|
+
{ label: "Convert", weight: 6 },
|
|
48
|
+
{ label: "Text", weight: 3 },
|
|
49
|
+
{ label: "Data", weight: 2 },
|
|
50
|
+
],
|
|
51
|
+
},
|
|
52
|
+
];
|
|
53
|
+
|
|
54
|
+
const respondents = numberInput({ min: "1", max: "5000", step: "1", value: "50", "aria-label": "Respondents" });
|
|
55
|
+
const seed = textInput({ value: "mevebu", "aria-label": "Seed", class: "font-mono" });
|
|
56
|
+
const withIds = { input: null };
|
|
57
|
+
|
|
58
|
+
const error = notice("warn");
|
|
59
|
+
const summary = el("p", { class: "text-sm text-ink-2" });
|
|
60
|
+
const list = el("div", { class: "grid gap-2" });
|
|
61
|
+
const preview = el("div", { class: "overflow-x-auto rounded-r2 border border-orbit" });
|
|
62
|
+
const empty = blank("clipboard-list", "Add a question to see responses.");
|
|
63
|
+
|
|
64
|
+
let table = [];
|
|
65
|
+
let header = [];
|
|
66
|
+
|
|
67
|
+
function renderQuestions() {
|
|
68
|
+
list.replaceChildren(
|
|
69
|
+
...questions.map((question, index) => {
|
|
70
|
+
const optionRows = question.options.map((option, optionIndex) =>
|
|
71
|
+
el("div", { class: "flex items-center gap-2" },
|
|
72
|
+
textInput({
|
|
73
|
+
value: option.label,
|
|
74
|
+
placeholder: `Option ${optionIndex + 1}`,
|
|
75
|
+
"aria-label": `Question ${index + 1} option ${optionIndex + 1}`,
|
|
76
|
+
oninput: (event) => { option.label = event.target.value; run(); },
|
|
77
|
+
}),
|
|
78
|
+
numberInput({
|
|
79
|
+
value: String(option.weight),
|
|
80
|
+
min: "0",
|
|
81
|
+
max: "100",
|
|
82
|
+
"aria-label": `Question ${index + 1} option ${optionIndex + 1} weight`,
|
|
83
|
+
oninput: (event) => { option.weight = Number(event.target.value) || 0; run(); },
|
|
84
|
+
}),
|
|
85
|
+
el("button", {
|
|
86
|
+
class: "inline-flex size-8 flex-none cursor-pointer items-center justify-center rounded-[5px] border-0 bg-transparent text-ink-3 hover:bg-deck hover:text-danger disabled:opacity-40",
|
|
87
|
+
type: "button",
|
|
88
|
+
"aria-label": `Remove option ${optionIndex + 1}`,
|
|
89
|
+
disabled: question.options.length <= 1,
|
|
90
|
+
onclick: () => { question.options.splice(optionIndex, 1); renderQuestions(); run(); },
|
|
91
|
+
}, icon("x", "size-3.5"))));
|
|
92
|
+
|
|
93
|
+
return el(
|
|
94
|
+
"section",
|
|
95
|
+
{ class: "grid gap-2 rounded-r1 border border-orbit bg-deck-2 p-2.5" },
|
|
96
|
+
el("div", { class: "grid gap-2 [grid-template-columns:minmax(0,1fr)_auto]" },
|
|
97
|
+
textInput({
|
|
98
|
+
value: question.text,
|
|
99
|
+
placeholder: `Question ${index + 1}`,
|
|
100
|
+
"aria-label": `Question ${index + 1}`,
|
|
101
|
+
oninput: (event) => { question.text = event.target.value; run(); },
|
|
102
|
+
}),
|
|
103
|
+
el("button", {
|
|
104
|
+
class: "inline-flex size-9 cursor-pointer items-center justify-center rounded-[5px] border-0 bg-transparent text-ink-3 hover:bg-deck hover:text-danger disabled:opacity-40",
|
|
105
|
+
type: "button",
|
|
106
|
+
"aria-label": `Remove question ${index + 1}`,
|
|
107
|
+
disabled: questions.length === 1,
|
|
108
|
+
onclick: () => { questions.splice(index, 1); renderQuestions(); run(); },
|
|
109
|
+
}, icon("trash", "size-4"))),
|
|
110
|
+
selectInput(TYPES.map((type) => ({ value: type.value, label: type.label })), {
|
|
111
|
+
value: question.type,
|
|
112
|
+
"aria-label": `Question ${index + 1} type`,
|
|
113
|
+
onchange: (event) => { question.type = event.target.value; renderQuestions(); run(); },
|
|
114
|
+
}),
|
|
115
|
+
question.type === "text" ? null : el("div", { class: "grid gap-1.5" },
|
|
116
|
+
el("span", { class: "text-2xs font-semibold tracking-[0.06em] text-ink-3 uppercase" }, "Option and weight"),
|
|
117
|
+
...optionRows)
|
|
118
|
+
);
|
|
119
|
+
})
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function answer(question, random) {
|
|
124
|
+
if (question.type === "text") {
|
|
125
|
+
return `${pick(random, WORDS)} ${pick(random, WORDS)}`;
|
|
126
|
+
}
|
|
127
|
+
if (question.type === "multi") {
|
|
128
|
+
const chosen = question.options.filter(() => random() < Math.min(0.9, question.options.length ? 0.4 : 0));
|
|
129
|
+
return (chosen.length ? chosen : [pick(random, question.options)]).map((option) => option.label).join(" | ");
|
|
130
|
+
}
|
|
131
|
+
return weighted(random, question.options);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function run() {
|
|
135
|
+
error.write("");
|
|
136
|
+
const count = Math.max(1, Math.min(5000, Number(respondents.value) || 1));
|
|
137
|
+
const random = seeded(seed.value || "mevebu");
|
|
138
|
+
header = ["respondent_id", "submitted_at", ...questions.map((question) => question.text.trim() || "question")];
|
|
139
|
+
table = Array.from({ length: count }, () => {
|
|
140
|
+
const submitted = new Date(Date.UTC(2026, 0, 1) + Math.floor(random() * 90) * 86400000);
|
|
141
|
+
return [
|
|
142
|
+
uuid(random).slice(0, 8),
|
|
143
|
+
submitted.toISOString().slice(0, 10),
|
|
144
|
+
...questions.map((question) => answer(question, random)),
|
|
145
|
+
];
|
|
146
|
+
});
|
|
147
|
+
summary.textContent = `${count} respondent${count === 1 ? "" : "s"} · ${questions.length} question${questions.length === 1 ? "" : "s"} · seed ${seed.value || "mevebu"}`;
|
|
148
|
+
renderPreview();
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function renderPreview() {
|
|
152
|
+
empty.hidden = table.length > 0;
|
|
153
|
+
preview.hidden = table.length === 0;
|
|
154
|
+
if (!table.length) {
|
|
155
|
+
preview.replaceChildren();
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
const shown = table.slice(0, 8);
|
|
159
|
+
const columns = header.length;
|
|
160
|
+
const head = el("div", {
|
|
161
|
+
class: "grid border-b border-orbit bg-deck-2 px-3 py-2 text-2xs font-bold tracking-[0.06em] text-ink-3 uppercase",
|
|
162
|
+
style: `grid-template-columns: repeat(${columns}, minmax(100px, 1fr))`,
|
|
163
|
+
}, ...header.map((cell) => el("span", { class: "truncate" }, cell)));
|
|
164
|
+
const body = shown.map((row) =>
|
|
165
|
+
el("div", {
|
|
166
|
+
class: "grid border-b border-orbit px-3 py-1.5 text-sm last:border-b-0",
|
|
167
|
+
style: `grid-template-columns: repeat(${columns}, minmax(100px, 1fr))`,
|
|
168
|
+
}, ...row.map((cell) => el("span", { class: "truncate font-mono text-2xs text-ink-2" }, cell)))
|
|
169
|
+
);
|
|
170
|
+
preview.replaceChildren(head, ...body);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
respondents.addEventListener("input", run);
|
|
174
|
+
seed.addEventListener("input", run);
|
|
175
|
+
|
|
176
|
+
const addQuestion = button("Add question", {
|
|
177
|
+
icon: "plus",
|
|
178
|
+
kind: "quiet",
|
|
179
|
+
onclick: () => {
|
|
180
|
+
questions.push({ text: `Question ${questions.length + 1}`, type: "scale", options: [
|
|
181
|
+
{ label: "1", weight: 1 }, { label: "2", weight: 2 }, { label: "3", weight: 5 }, { label: "4", weight: 5 }, { label: "5", weight: 2 },
|
|
182
|
+
] });
|
|
183
|
+
renderQuestions();
|
|
184
|
+
run();
|
|
185
|
+
},
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
const downloadButton = button("Download CSV", {
|
|
189
|
+
icon: "download",
|
|
190
|
+
kind: "fill",
|
|
191
|
+
onclick: () => {
|
|
192
|
+
if (!table.length) return;
|
|
193
|
+
download("responses.csv", toCsv([header, ...table]), "text/csv");
|
|
194
|
+
toast("Responses ready");
|
|
195
|
+
},
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
renderQuestions();
|
|
199
|
+
run();
|
|
200
|
+
|
|
201
|
+
const shell = toolcard({
|
|
202
|
+
body: bench(
|
|
203
|
+
stack(
|
|
204
|
+
panel("Respondents", respondents),
|
|
205
|
+
panel("Seed", seed),
|
|
206
|
+
el("div", { class: "grid gap-1.5" },
|
|
207
|
+
el("div", { class: "flex items-center justify-between gap-2" },
|
|
208
|
+
el("span", { class: "text-sm font-semibold text-ink-2" }, "Questions"),
|
|
209
|
+
addQuestion),
|
|
210
|
+
list),
|
|
211
|
+
error
|
|
212
|
+
),
|
|
213
|
+
stack(summary, empty, preview,
|
|
214
|
+
el("p", { class: "m-0 text-2xs leading-[1.6] text-ink-3" },
|
|
215
|
+
"Weights are relative, not percentages. A weight of 7 against 3 means the first answer is about twice as likely."))
|
|
216
|
+
),
|
|
217
|
+
foot: actions(downloadButton),
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
slot.append(shell.node);
|
|
221
|
+
|
|
222
|
+
return {};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export default { mount };
|