mevebu 2.0.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.
@@ -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 };
@@ -0,0 +1,191 @@
1
+ // A dated series with a trend, a season and noise, so a demo chart looks like
2
+ // data instead of a random walk. Seeded, and the shape is previewed before it
3
+ // is downloaded.
4
+ import {
5
+ el, toast, download, bench, stack, panel, numberInput, textInput, selectInput,
6
+ button, actions, toolcard, notice,
7
+ } from "../core.js";
8
+ import { toCsv } from "../csv.js";
9
+ import { seeded, gaussian } from "../random.js";
10
+
11
+ const FREQUENCIES = [
12
+ { value: "hour", label: "Hourly" },
13
+ { value: "day", label: "Daily" },
14
+ { value: "week", label: "Weekly" },
15
+ { value: "month", label: "Monthly" },
16
+ ];
17
+
18
+ function step(date, frequency) {
19
+ const next = new Date(date);
20
+ if (frequency === "hour") next.setHours(next.getHours() + 1);
21
+ else if (frequency === "day") next.setDate(next.getDate() + 1);
22
+ else if (frequency === "week") next.setDate(next.getDate() + 7);
23
+ else next.setMonth(next.getMonth() + 1);
24
+ return next;
25
+ }
26
+
27
+ function iso(date, frequency) {
28
+ const year = date.getFullYear();
29
+ const month = String(date.getMonth() + 1).padStart(2, "0");
30
+ const day = String(date.getDate()).padStart(2, "0");
31
+ if (frequency === "hour") return `${year}-${month}-${day} ${String(date.getHours()).padStart(2, "0")}:00`;
32
+ return `${year}-${month}-${day}`;
33
+ }
34
+
35
+ export function mount(slot) {
36
+ const start = el("input", {
37
+ class: "w-full min-h-[var(--ctl)] rounded-r1 border border-orbit bg-deck px-3 py-2.5 text-base focus:border-jade focus:outline-none focus:ring-3 focus:ring-jade-soft",
38
+ type: "date",
39
+ value: (() => {
40
+ const now = new Date();
41
+ return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
42
+ })(),
43
+ "aria-label": "Start date",
44
+ });
45
+ const frequency = selectInput(FREQUENCIES, { value: "day", "aria-label": "Frequency" });
46
+ const length = numberInput({ min: "2", max: "2000", step: "1", value: "60", "aria-label": "Points" });
47
+ const base = numberInput({ value: "100", step: "1", "aria-label": "Base value" });
48
+ const trend = numberInput({ value: "0.4", step: "0.1", "aria-label": "Trend per point" });
49
+ const season = numberInput({ value: "12", step: "1", "aria-label": "Season amplitude" });
50
+ const period = numberInput({ min: "2", max: "365", step: "1", value: "7", "aria-label": "Season period" });
51
+ const noise = numberInput({ min: "0", max: "100", step: "0.5", value: "4", "aria-label": "Noise" });
52
+ const seed = textInput({ value: "mevebu", "aria-label": "Seed", class: "font-mono" });
53
+ const spikeDate = el("input", {
54
+ class: "w-full min-h-[var(--ctl)] rounded-r1 border border-orbit bg-deck px-3 py-2.5 text-base focus:border-jade focus:outline-none focus:ring-3 focus:ring-jade-soft",
55
+ type: "date",
56
+ "aria-label": "Spike date",
57
+ });
58
+ const spikeMultiplier = numberInput({ value: "2", step: "0.1", "aria-label": "Spike multiplier" });
59
+
60
+ const error = notice("warn");
61
+ const summary = el("p", { class: "text-sm text-ink-2" });
62
+ const chart = el("div", { class: "grid gap-2 rounded-r2 border border-orbit bg-deck-2 p-3" });
63
+ const result = el("pre", { class: "m-0 max-h-56 overflow-auto rounded-r2 border border-orbit bg-deck-2 p-3 font-mono text-2xs leading-[1.6]" });
64
+
65
+ let series = [];
66
+
67
+ function build() {
68
+ const count = Math.max(2, Math.min(2000, Number(length.value) || 2));
69
+ const random = seeded(seed.value || "mevebu");
70
+ const amp = Number(season.value) || 0;
71
+ const slope = Number(trend.value) || 0;
72
+ const spread = Number(noise.value) || 0;
73
+ const wave = Math.max(2, Number(period.value) || 7);
74
+ const startDate = new Date(`${start.value}T00:00:00`);
75
+ if (Number.isNaN(startDate.getTime())) {
76
+ error.write("Pick a start date.");
77
+ return;
78
+ }
79
+
80
+ const out = [];
81
+ let date = startDate;
82
+ for (let i = 0; i < count; i++) {
83
+ const seasonal = amp * Math.sin((2 * Math.PI * i) / wave);
84
+ const drift = slope * i;
85
+ const jitter = spread ? gaussian(random, 0, spread) : 0;
86
+ out.push({ date: iso(date, frequency.value), value: Number((Number(base.value || 0) + drift + seasonal + jitter).toFixed(2)) });
87
+ date = step(date, frequency.value);
88
+ }
89
+
90
+ if (spikeDate.value && Number(spikeMultiplier.value)) {
91
+ const target = spikeDate.value;
92
+ const hit = out.find((point) => point.date.startsWith(target));
93
+ if (hit) hit.value = Number((hit.value * Number(spikeMultiplier.value)).toFixed(2));
94
+ else error.write("The spike date is outside the series, so it was skipped.");
95
+ }
96
+
97
+ series = out;
98
+ render();
99
+ }
100
+
101
+ function render() {
102
+ if (!series.length) {
103
+ summary.textContent = "";
104
+ chart.replaceChildren();
105
+ result.textContent = "";
106
+ return;
107
+ }
108
+ const values = series.map((point) => point.value);
109
+ const min = Math.min(...values);
110
+ const max = Math.max(...values);
111
+ const span = max - min || 1;
112
+ const width = 100;
113
+ const height = 34;
114
+
115
+ // One polyline, drawn in a fixed viewBox, so the preview scales without a
116
+ // chart library.
117
+ const points = series
118
+ .map((point, index) => {
119
+ const x = (index / Math.max(1, series.length - 1)) * width;
120
+ const y = height - ((point.value - min) / span) * height;
121
+ return `${x.toFixed(2)},${y.toFixed(2)}`;
122
+ })
123
+ .join(" ");
124
+
125
+ const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
126
+ svg.setAttribute("viewBox", `0 0 ${width} ${height}`);
127
+ svg.setAttribute("preserveAspectRatio", "none");
128
+ svg.setAttribute("class", "h-40 w-full");
129
+ svg.setAttribute("role", "img");
130
+ svg.setAttribute("aria-label", `Series preview, ${series.length} points`);
131
+ const line = document.createElementNS("http://www.w3.org/2000/svg", "polyline");
132
+ line.setAttribute("points", points);
133
+ line.setAttribute("fill", "none");
134
+ line.setAttribute("stroke", "var(--jade)");
135
+ line.setAttribute("stroke-width", "0.7");
136
+ line.setAttribute("vector-effect", "non-scaling-stroke");
137
+ svg.append(line);
138
+
139
+ chart.replaceChildren(
140
+ svg,
141
+ el("div", { class: "flex justify-between text-2xs text-ink-3 tabular-nums" },
142
+ el("span", {}, series[0].date),
143
+ el("span", {}, `${min} to ${max}`),
144
+ el("span", {}, series[series.length - 1].date))
145
+ );
146
+
147
+ const preview = [["date", "value"], ...series.slice(0, 40).map((point) => [point.date, String(point.value)])];
148
+ result.textContent = toCsv(preview) + (series.length > 40 ? `\n… ${series.length - 40} more rows` : "");
149
+ summary.textContent = `${series.length} points · ${frequency.value} · ${min} to ${max} · seed ${seed.value || "mevebu"}`;
150
+ }
151
+
152
+ for (const node of [start, frequency, length, base, trend, season, period, noise, seed, spikeDate, spikeMultiplier]) {
153
+ node.addEventListener("input", build);
154
+ node.addEventListener("change", build);
155
+ }
156
+
157
+ const downloadButton = button("Download CSV", {
158
+ icon: "download",
159
+ kind: "fill",
160
+ onclick: () => {
161
+ if (!series.length) return;
162
+ download("series.csv", toCsv([["date", "value"], ...series.map((point) => [point.date, String(point.value)])]), "text/csv");
163
+ toast("Series ready");
164
+ },
165
+ });
166
+
167
+ build();
168
+
169
+ const shell = toolcard({
170
+ body: bench(
171
+ stack(
172
+ el("div", { class: "grid grid-cols-2 gap-3" }, panel("Start", start), panel("Frequency", frequency)),
173
+ panel("Points", length),
174
+ el("div", { class: "grid grid-cols-2 gap-3" }, panel("Base", base), panel("Trend per point", trend)),
175
+ el("div", { class: "grid grid-cols-2 gap-3" }, panel("Season amplitude", season), panel("Season period", period)),
176
+ panel("Noise", noise),
177
+ panel("Seed", seed),
178
+ el("div", { class: "grid grid-cols-2 gap-3" }, panel("Spike date", spikeDate), panel("Spike ×", spikeMultiplier)),
179
+ error
180
+ ),
181
+ stack(summary, chart, result)
182
+ ),
183
+ foot: actions(downloadButton),
184
+ });
185
+
186
+ slot.append(shell.node);
187
+
188
+ return {};
189
+ }
190
+
191
+ export default { mount };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mevebu",
3
- "version": "2.0.0",
3
+ "version": "2.1.0",
4
4
  "description": "Small tools that run in your browser. No accounts, no uploads, no tracking.",
5
5
  "license": "MIT",
6
6
  "type": "module",