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.
@@ -0,0 +1,254 @@
1
+ // Sample documents from a JSON Schema. Covers the keywords that decide whether
2
+ // a document validates, and reports the ones it could not honour, so a passing
3
+ // sample is never mistaken for a complete implementation.
4
+ import {
5
+ el, toast, download, bench, stack, panel, textArea, numberInput, textInput,
6
+ button, actions, toolcard, blank, notice, output,
7
+ } from "../core.js";
8
+ import { seeded, pick, uuid, gaussian } from "../random.js";
9
+ import { zip } from "../zip.js";
10
+
11
+ const SUPPORTED = new Set([
12
+ "$schema", "$id", "title", "description", "type", "properties", "required",
13
+ "items", "enum", "const", "default", "examples", "minimum", "maximum",
14
+ "exclusiveMinimum", "exclusiveMaximum", "multipleOf", "minLength", "maxLength",
15
+ "pattern", "format", "minItems", "maxItems", "uniqueItems", "additionalProperties",
16
+ "minProperties", "maxProperties", "anyOf", "oneOf", "allOf", "not", "nullable",
17
+ ]);
18
+
19
+ const FORMATS = {
20
+ "date-time": () => new Date(Date.UTC(2025, 0, 1) + Math.floor(Math.random() * 3e10)).toISOString(),
21
+ date: (random) => new Date(Date.UTC(2025, 0, 1) + Math.floor(random() * 700) * 86400000).toISOString().slice(0, 10),
22
+ time: () => `${String(Math.floor(Math.random() * 24)).padStart(2, "0")}:00:00Z`,
23
+ email: (random) => `${pick(random, ["ani", "budi", "citra", "dewi"])}${Math.floor(random() * 99)}@example.com`,
24
+ uri: () => "https://example.com/resource",
25
+ uuid: (random) => uuid(random),
26
+ ipv4: () => `192.168.${Math.floor(Math.random() * 255)}.${Math.floor(Math.random() * 255)}`,
27
+ hostname: () => "example.com",
28
+ };
29
+
30
+ function regexFrom(pattern) {
31
+ try {
32
+ return new RegExp(pattern);
33
+ } catch {
34
+ return null;
35
+ }
36
+ }
37
+
38
+ function stringValue(schema, random) {
39
+ if (schema.format && FORMATS[schema.format]) return FORMATS[schema.format](random);
40
+ if (schema.enum) return pick(random, schema.enum);
41
+ if (schema.const !== undefined) return schema.const;
42
+
43
+ const min = Math.max(0, schema.minLength ?? 0);
44
+ const max = Math.max(min, schema.maxLength ?? Math.max(min, 12));
45
+ const regex = schema.pattern ? regexFrom(schema.pattern) : null;
46
+ const alphabet = "abcdefghijklmnopqrstuvwxyz0123456789";
47
+
48
+ // Try a few random strings against the pattern before falling back, so a
49
+ // pattern like ^[A-Z]{3}$ still gets a plausible value.
50
+ for (let attempt = 0; attempt < 60; attempt++) {
51
+ const length = min + Math.floor(random() * Math.max(1, Math.min(max, 24) - min + 1));
52
+ let out = "";
53
+ for (let i = 0; i < length; i++) out += alphabet[Math.floor(random() * alphabet.length)];
54
+ if (!regex || regex.test(out)) return out;
55
+ }
56
+ const literal = schema.pattern?.replace(/[\^$.*+?()[\]{}|\\]/g, "") ?? "";
57
+ if (literal.length >= min) return literal.slice(0, Math.max(min, Math.min(max, literal.length)));
58
+ return "sample".padEnd(min, "x").slice(0, Math.max(min, 1));
59
+ }
60
+
61
+ function numberValue(schema, random, integer) {
62
+ if (schema.enum) return pick(random, schema.enum);
63
+ if (schema.const !== undefined) return schema.const;
64
+ const min = schema.minimum ?? schema.exclusiveMinimum ?? 0;
65
+ const max = schema.maximum ?? schema.exclusiveMaximum ?? (min + 1000);
66
+ let value = min + random() * (max - min);
67
+ if (integer) value = Math.round(value);
68
+ if (schema.multipleOf) value = Math.round(value / schema.multipleOf) * schema.multipleOf;
69
+ return value;
70
+ }
71
+
72
+ function sample(schema, random, path, unknown) {
73
+ if (!schema || typeof schema !== "object") return null;
74
+
75
+ if (schema.const !== undefined) return schema.const;
76
+ if (schema.enum) return pick(random, schema.enum);
77
+ if (schema.default !== undefined) return schema.default;
78
+ if (schema.examples?.length) return schema.examples[0];
79
+ if (schema.anyOf?.length) return sample(schema.anyOf[0], random, path, unknown);
80
+ if (schema.oneOf?.length) return sample(schema.oneOf[0], random, path, unknown);
81
+ if (schema.allOf?.length) {
82
+ // Merge the object branches, which is the common shape.
83
+ const merged = schema.allOf.reduce(
84
+ (acc, branch) => ({
85
+ ...acc,
86
+ ...branch,
87
+ properties: { ...(acc.properties ?? {}), ...(branch.properties ?? {}) },
88
+ required: [...new Set([...(acc.required ?? []), ...(branch.required ?? [])])],
89
+ }),
90
+ { ...schema, allOf: undefined }
91
+ );
92
+ return sample(merged, random, path, unknown);
93
+ }
94
+
95
+ const type = Array.isArray(schema.type)
96
+ ? schema.type.find((entry) => entry !== "null") ?? "null"
97
+ : schema.type ?? (schema.properties ? "object" : schema.items ? "array" : "string");
98
+
99
+ if (schema.nullable && random() < 0.1) return null;
100
+
101
+ switch (type) {
102
+ case "object": {
103
+ const out = {};
104
+ const properties = schema.properties ?? {};
105
+ for (const [key, child] of Object.entries(properties)) {
106
+ out[key] = sample(child, random, `${path}.${key}`, unknown);
107
+ }
108
+ for (const key of schema.required ?? []) {
109
+ if (!(key in out)) out[key] = sample({}, random, `${path}.${key}`, unknown);
110
+ }
111
+ if (schema.additionalProperties && typeof schema.additionalProperties === "object") {
112
+ out.extra = sample(schema.additionalProperties, random, `${path}.extra`, unknown);
113
+ }
114
+ return out;
115
+ }
116
+ case "array": {
117
+ const min = Math.max(0, schema.minItems ?? 1);
118
+ const max = Math.max(min, schema.maxItems ?? min + 2);
119
+ const count = min + Math.floor(random() * (Math.min(max, 8) - min + 1));
120
+ const items = Array.from({ length: count }, (_, index) =>
121
+ sample(schema.items ?? {}, random, `${path}[${index}]`, unknown)
122
+ );
123
+ return schema.uniqueItems ? [...new Set(items.map((item) => JSON.stringify(item)))].map((text) => JSON.parse(text)) : items;
124
+ }
125
+ case "integer":
126
+ return numberValue(schema, random, true);
127
+ case "number":
128
+ return Number(numberValue(schema, random, false).toFixed(3));
129
+ case "boolean":
130
+ return random() > 0.5;
131
+ case "null":
132
+ return null;
133
+ default:
134
+ return stringValue(schema, random);
135
+ }
136
+ }
137
+
138
+ function collectUnknown(schema, found, path = "$") {
139
+ if (!schema || typeof schema !== "object") return;
140
+ for (const [key, value] of Object.entries(schema)) {
141
+ if (["properties", "items", "anyOf", "oneOf", "allOf"].includes(key)) continue;
142
+ if (key.startsWith("$") || key.startsWith("x-")) continue;
143
+ if (!SUPPORTED.has(key) && !found.includes(key)) found.push(key);
144
+ }
145
+ for (const [name, child] of Object.entries(schema.properties ?? {})) {
146
+ collectUnknown(child, found, `${path}.${name}`);
147
+ }
148
+ if (schema.items && typeof schema.items === "object") collectUnknown(schema.items, found, `${path}[]`);
149
+ }
150
+
151
+ export function mount(slot) {
152
+ let documents = [];
153
+ let unknown = [];
154
+
155
+ const schema = textArea({
156
+ rows: 12,
157
+ value: JSON.stringify(
158
+ {
159
+ $schema: "https://json-schema.org/draft/2020-12/schema",
160
+ type: "object",
161
+ required: ["id", "name", "email"],
162
+ properties: {
163
+ id: { type: "string", format: "uuid" },
164
+ name: { type: "string", minLength: 3, maxLength: 24 },
165
+ email: { type: "string", format: "email" },
166
+ age: { type: "integer", minimum: 18, maximum: 90 },
167
+ tags: { type: "array", items: { type: "string" }, minItems: 1, maxItems: 4 },
168
+ },
169
+ },
170
+ null,
171
+ 2
172
+ ),
173
+ "aria-label": "JSON Schema",
174
+ });
175
+
176
+ const count = numberInput({ min: "1", max: "200", step: "1", value: "3", "aria-label": "Documents" });
177
+ const seed = textInput({ value: "mevebu", "aria-label": "Seed", class: "font-mono" });
178
+ const error = notice("warn");
179
+ const summary = el("p", { class: "text-sm text-ink-2" });
180
+ const result = output({ rows: 16 });
181
+ const empty = blank("braces", "Paste a schema to generate documents.");
182
+
183
+ const generate = button("Generate", { icon: "refresh-cw", kind: "fill" });
184
+ const downloadJson = button("Download JSON", { icon: "download" });
185
+ const downloadZip = button("Download ZIP", { icon: "download" });
186
+
187
+ function run() {
188
+ error.write("");
189
+ try {
190
+ const parsed = JSON.parse(schema.value);
191
+ const random = seeded(seed.value || "mevebu");
192
+ unknown = [];
193
+ collectUnknown(parsed, unknown);
194
+ const total = Math.max(1, Math.min(200, Number(count.value) || 1));
195
+ documents = Array.from({ length: total }, () => sample(parsed, random, "$", unknown));
196
+ result.write(JSON.stringify(documents, null, 2));
197
+ summary.textContent = `${total} document${total === 1 ? "" : "s"} · seed ${seed.value || "mevebu"}`;
198
+ empty.hidden = true;
199
+ result.hidden = false;
200
+ if (unknown.length) {
201
+ error.write(`Not implemented, so left out: ${unknown.join(", ")}. Everything else follows the schema.`);
202
+ }
203
+ } catch (caught) {
204
+ documents = [];
205
+ result.write("");
206
+ result.hidden = true;
207
+ empty.hidden = false;
208
+ summary.textContent = "";
209
+ error.write(caught?.message || "That schema is not valid JSON.");
210
+ }
211
+ }
212
+
213
+ generate.addEventListener("click", () => {
214
+ run();
215
+ if (documents.length) toast("Documents ready");
216
+ });
217
+ schema.addEventListener("input", run);
218
+ count.addEventListener("input", run);
219
+ seed.addEventListener("input", run);
220
+
221
+ downloadJson.addEventListener("click", () => {
222
+ if (!documents.length) return;
223
+ download("samples.json", JSON.stringify(documents, null, 2), "application/json");
224
+ });
225
+ downloadZip.addEventListener("click", () => {
226
+ if (!documents.length) return;
227
+ const files = documents.map((doc, index) => ({
228
+ name: `sample-${String(index + 1).padStart(3, "0")}.json`,
229
+ bytes: new TextEncoder().encode(JSON.stringify(doc, null, 2)),
230
+ }));
231
+ download("samples.zip", zip(files), "application/zip");
232
+ });
233
+
234
+ run();
235
+
236
+ const shell = toolcard({
237
+ body: bench(
238
+ stack(
239
+ panel("Schema", schema),
240
+ panel("Documents", count),
241
+ panel("Seed", seed, "The same seed and schema give the same documents."),
242
+ error
243
+ ),
244
+ stack(summary, empty, result)
245
+ ),
246
+ foot: actions(downloadJson, downloadZip, generate),
247
+ });
248
+
249
+ slot.append(shell.node);
250
+
251
+ return {};
252
+ }
253
+
254
+ export default { mount };
@@ -0,0 +1,224 @@
1
+ // The JSON an endpoint returns, written once and reused as a fixture. Fields are
2
+ // defined in a small table, and the same definition can be shown as a list with
3
+ // pagination, a single item, or an error.
4
+ import {
5
+ el, icon, toast, download, bench, stack, panel, textInput, selectInput,
6
+ numberInput, button, actions, toolcard, blank, notice, output, chipRow,
7
+ } from "../core.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
+ ];
20
+
21
+ const WORDS = ["north", "cedar", "orbit", "summit", "tide", "willow", "ember", "quartz"];
22
+
23
+ function value(type, random, index) {
24
+ switch (type) {
25
+ case "word": return pick(random, WORDS);
26
+ case "number": return Math.floor(random() * 5000);
27
+ case "decimal": return Number((random() * 200).toFixed(2));
28
+ case "boolean": return random() > 0.5;
29
+ case "date": return new Date(Date.UTC(2025, 0, 1) + Math.floor(random() * 6e10)).toISOString();
30
+ case "email": return `${pick(random, WORDS)}${index + 1}@example.com`;
31
+ case "uuid": return uuid(random);
32
+ default: return `${pick(random, WORDS)} ${pick(random, WORDS)}`;
33
+ }
34
+ }
35
+
36
+ export function mount(slot) {
37
+ let fields = [
38
+ { name: "id", type: "uuid" },
39
+ { name: "title", type: "text" },
40
+ { name: "price", type: "decimal" },
41
+ { name: "in_stock", type: "boolean" },
42
+ { name: "created_at", type: "date" },
43
+ ];
44
+ let shape = "list";
45
+
46
+ const resource = textInput({ value: "items", "aria-label": "Resource name" });
47
+ const seed = textInput({ value: "mevebu", "aria-label": "Seed", class: "font-mono" });
48
+ const items = numberInput({ min: "1", max: "100", step: "1", value: "5", "aria-label": "Items" });
49
+ const status = selectInput(
50
+ [
51
+ { value: "200", label: "200 OK" },
52
+ { value: "201", label: "201 Created" },
53
+ { value: "400", label: "400 Bad Request" },
54
+ { value: "401", label: "401 Unauthorized" },
55
+ { value: "404", label: "404 Not Found" },
56
+ { value: "422", label: "422 Unprocessable" },
57
+ { value: "500", label: "500 Server Error" },
58
+ ],
59
+ { value: "200", "aria-label": "HTTP status" }
60
+ );
61
+
62
+ const shapeRow = chipRow(
63
+ [
64
+ { value: "list", label: "List" },
65
+ { value: "item", label: "Single item" },
66
+ { value: "error", label: "Error" },
67
+ ],
68
+ { value: "list", label: "Payload shape", onChange: (next) => { shape = next; sync(); run(); } }
69
+ );
70
+
71
+ const error = notice("warn");
72
+ const summary = el("p", { class: "text-sm text-ink-2" });
73
+ const result = output({ rows: 16 });
74
+ const empty = blank("braces", "Define a field to build the payload.");
75
+ const list = el("div", { class: "grid gap-2" });
76
+
77
+ const itemsPanel = panel("Items", items);
78
+ const statusPanel = panel("HTTP status", status);
79
+
80
+ function sync() {
81
+ itemsPanel.hidden = shape !== "list";
82
+ statusPanel.hidden = shape !== "error";
83
+ }
84
+
85
+ function renderFields() {
86
+ list.replaceChildren(
87
+ ...fields.map((field, index) =>
88
+ el("div", { class: "grid gap-2 rounded-r1 border border-orbit bg-deck-2 p-2 [grid-template-columns:minmax(0,1fr)_auto]" },
89
+ el("div", { class: "grid gap-2 sm:grid-cols-2" },
90
+ textInput({
91
+ value: field.name,
92
+ placeholder: `field_${index + 1}`,
93
+ "aria-label": `Field ${index + 1} name`,
94
+ oninput: (event) => { field.name = event.target.value; run(); },
95
+ }),
96
+ selectInput(TYPES.map((type) => ({ value: type.value, label: type.label })), {
97
+ value: field.type,
98
+ "aria-label": `Field ${index + 1} type`,
99
+ onchange: (event) => { field.type = event.target.value; run(); },
100
+ })),
101
+ el("button", {
102
+ 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",
103
+ type: "button",
104
+ "aria-label": `Remove field ${index + 1}`,
105
+ disabled: fields.length === 1,
106
+ onclick: () => { fields.splice(index, 1); renderFields(); run(); },
107
+ }, icon("trash", "size-4")))
108
+ )
109
+ );
110
+ }
111
+
112
+ function one(random, index) {
113
+ const out = {};
114
+ for (const field of fields) {
115
+ const key = field.name.trim() || `field_${index + 1}`;
116
+ out[key] = value(field.type, random, index);
117
+ }
118
+ return out;
119
+ }
120
+
121
+ function run() {
122
+ error.write("");
123
+ const random = seeded(seed.value || "mevebu");
124
+ const name = resource.value.trim() || "items";
125
+ const count = Math.max(1, Math.min(100, Number(items.value) || 1));
126
+ const rows = Array.from({ length: count }, (_, index) => one(random, index));
127
+
128
+ let payload;
129
+ let http = Number(status.value);
130
+ if (shape === "list") {
131
+ payload = {
132
+ data: rows,
133
+ meta: { page: 1, per_page: count, total: count * 4, has_more: true },
134
+ };
135
+ http = 200;
136
+ } else if (shape === "item") {
137
+ payload = { data: rows[0] };
138
+ http = 200;
139
+ } else {
140
+ const messages = {
141
+ 400: "The request body is not valid JSON.",
142
+ 401: "Missing or expired token.",
143
+ 404: `No ${name} with that id.`,
144
+ 422: "The title field is required.",
145
+ 500: "Something went wrong on our side.",
146
+ };
147
+ payload = {
148
+ error: { status: http, code: http === 401 ? "unauthorized" : http === 404 ? "not_found" : "bad_request", message: messages[http] ?? "Request failed." },
149
+ };
150
+ }
151
+
152
+ result.write(JSON.stringify(payload, null, 2));
153
+ empty.hidden = true;
154
+ result.hidden = false;
155
+ const bytes = new TextEncoder().encode(JSON.stringify(payload)).length;
156
+ summary.textContent = `${http} · ${shape} · ${bytes} bytes${shape === "list" ? ` · ${count} item${count === 1 ? "" : "s"}` : ""}`;
157
+ }
158
+
159
+ resource.addEventListener("input", run);
160
+ seed.addEventListener("input", run);
161
+ items.addEventListener("input", run);
162
+ status.addEventListener("change", run);
163
+
164
+ const addButton = button("Add field", {
165
+ icon: "plus",
166
+ kind: "quiet",
167
+ onclick: () => {
168
+ fields.push({ name: `field_${fields.length + 1}`, type: "text" });
169
+ renderFields();
170
+ run();
171
+ },
172
+ });
173
+
174
+ const copyButton = button("Copy JSON", {
175
+ icon: "copy",
176
+ onclick: async () => {
177
+ try {
178
+ await navigator.clipboard.writeText(result.textContent);
179
+ toast("Payload copied");
180
+ } catch {
181
+ error.write("The clipboard is blocked here. Download the file instead.");
182
+ }
183
+ },
184
+ });
185
+
186
+ const downloadButton = button("Download .json", {
187
+ icon: "download",
188
+ kind: "fill",
189
+ onclick: () => {
190
+ if (!result.textContent) return;
191
+ download(`${(resource.value.trim() || "payload").replace(/[^\w-]+/g, "-")}-${shape}.json`, result.textContent, "application/json");
192
+ },
193
+ });
194
+
195
+ renderFields();
196
+ sync();
197
+ run();
198
+
199
+ const shell = toolcard({
200
+ body: bench(
201
+ stack(
202
+ panel("Resource", resource, "Used in the error message and the file name."),
203
+ el("div", { class: "grid gap-1.5" }, el("span", { class: "text-sm font-semibold text-ink-2" }, "Shape"), shapeRow),
204
+ itemsPanel,
205
+ statusPanel,
206
+ panel("Seed", seed),
207
+ el("div", { class: "grid gap-1.5" },
208
+ el("div", { class: "flex items-center justify-between gap-2" },
209
+ el("span", { class: "text-sm font-semibold text-ink-2" }, "Fields"),
210
+ addButton),
211
+ list),
212
+ error
213
+ ),
214
+ stack(summary, empty, result)
215
+ ),
216
+ foot: actions(copyButton, downloadButton),
217
+ });
218
+
219
+ slot.append(shell.node);
220
+
221
+ return {};
222
+ }
223
+
224
+ export default { mount };
@@ -0,0 +1,195 @@
1
+ // A rotating duty roster. Names take turns across a date range, with a fixed
2
+ // number of slots per day, an optional weekend skip, and pinned dates.
3
+ import {
4
+ el, toast, download, bench, stack, panel, textArea, numberInput, button,
5
+ actions, toolcard, blank, notice, checkbox, selectInput,
6
+ } from "../core.js";
7
+
8
+ function parseNames(text) {
9
+ return text.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
10
+ }
11
+
12
+ function parsePins(text) {
13
+ const pins = new Map();
14
+ for (const line of text.split(/\r?\n/)) {
15
+ const match = /^(\d{4}-\d{2}-\d{2})\s*[:|,]\s*(.+)$/.exec(line.trim());
16
+ if (match) pins.set(match[1], match[2].split(/\s*(?:,|\|)\s*/).map((name) => name.trim()).filter(Boolean));
17
+ }
18
+ return pins;
19
+ }
20
+
21
+ function iso(date) {
22
+ // Local date parts, not toISOString: a browser east or west of UTC would
23
+ // otherwise show yesterday or tomorrow in the date field.
24
+ const year = date.getFullYear();
25
+ const month = String(date.getMonth() + 1).padStart(2, "0");
26
+ const day = String(date.getDate()).padStart(2, "0");
27
+ return `${year}-${month}-${day}`;
28
+ }
29
+
30
+ export function mount(slot) {
31
+ const names = textArea({ rows: 7, placeholder: "Ani\nBudi\nCitra\nDewi", "aria-label": "Names" });
32
+ const start = el("input", {
33
+ 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",
34
+ type: "date",
35
+ value: iso(new Date()),
36
+ "aria-label": "Start date",
37
+ });
38
+ const end = el("input", {
39
+ 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",
40
+ type: "date",
41
+ value: iso(new Date(Date.now() + 13 * 86400000)),
42
+ "aria-label": "End date",
43
+ });
44
+ const perDay = numberInput({ min: "1", max: "20", step: "1", value: "1", "aria-label": "People per day" });
45
+ const skipWeekend = checkbox("Skip weekends", { checked: true });
46
+ const pins = textArea({ rows: 3, placeholder: "2026-10-25: Ani", "aria-label": "Pinned dates" });
47
+ const order = selectInput(
48
+ [
49
+ { value: "forward", label: "Forward" },
50
+ { value: "reverse", label: "Reverse" },
51
+ ],
52
+ { value: "forward", "aria-label": "Rotation direction" }
53
+ );
54
+
55
+ const error = notice("warn");
56
+ const summary = el("p", { class: "text-sm text-ink-2" });
57
+ const table = el("div", { class: "overflow-x-auto rounded-r2 border border-orbit" });
58
+ const empty = blank("calendar-check", "Add names and a date range.");
59
+
60
+ let rows = [];
61
+
62
+ function build() {
63
+ const list = parseNames(names.value);
64
+ const pinned = parsePins(pins.value);
65
+ error.write("");
66
+ rows = [];
67
+ if (!list.length) return;
68
+
69
+ const from = new Date(`${start.value}T00:00:00`);
70
+ const to = new Date(`${end.value}T00:00:00`);
71
+ if (Number.isNaN(from.getTime()) || Number.isNaN(to.getTime())) {
72
+ error.write("Pick a start and an end date.");
73
+ return;
74
+ }
75
+ if (to < from) {
76
+ error.write("The end date is before the start date.");
77
+ return;
78
+ }
79
+
80
+ const slots = Math.max(1, Math.min(Number(perDay.value) || 1, list.length));
81
+ const sequence = order.value === "reverse" ? [...list].reverse() : [...list];
82
+ let cursor = 0;
83
+ let guard = 0;
84
+
85
+ for (let day = new Date(from); day <= to && guard < 400; day.setDate(day.getDate() + 1), guard++) {
86
+ const weekday = day.getDay();
87
+ if (skipWeekend.input.checked && (weekday === 0 || weekday === 6)) continue;
88
+ const key = iso(day);
89
+ if (pinned.has(key)) {
90
+ rows.push({ date: key, people: pinned.get(key), pinned: true });
91
+ continue;
92
+ }
93
+ const people = [];
94
+ for (let i = 0; i < slots; i++) {
95
+ people.push(sequence[cursor % sequence.length]);
96
+ cursor++;
97
+ }
98
+ rows.push({ date: key, people, pinned: false });
99
+ }
100
+ if (guard >= 400) error.write("That range is very long, so it stops at 400 days.");
101
+ }
102
+
103
+ function render() {
104
+ empty.hidden = rows.length > 0;
105
+ table.hidden = rows.length === 0;
106
+ if (!rows.length) {
107
+ summary.textContent = "";
108
+ table.replaceChildren();
109
+ return;
110
+ }
111
+ const counts = new Map();
112
+ for (const row of rows) for (const name of row.people) counts.set(name, (counts.get(name) ?? 0) + 1);
113
+ const spread = [...counts.values()];
114
+ const even = spread.length ? `${Math.min(...spread)} to ${Math.max(...spread)} turns each` : "";
115
+ summary.textContent = `${rows.length} day${rows.length === 1 ? "" : "s"} · ${counts.size} people · ${even}`;
116
+
117
+ const head = el("div", {
118
+ class: "grid gap-3 border-b border-orbit bg-deck-2 px-3 py-2 text-2xs font-bold tracking-[0.06em] text-ink-3 uppercase [grid-template-columns:minmax(110px,150px)_minmax(0,1fr)]",
119
+ }, el("span", {}, "Date"), el("span", {}, "On duty"));
120
+
121
+ const body = rows.map((row) =>
122
+ el("div", {
123
+ class: "grid gap-3 border-b border-orbit px-3 py-2 text-sm last:border-b-0 [grid-template-columns:minmax(110px,150px)_minmax(0,1fr)]",
124
+ },
125
+ el("span", { class: "font-mono text-2xs text-ink-3 tabular-nums" }, row.date),
126
+ el("span", { class: "wrap-anywhere" }, row.people.join(", "),
127
+ row.pinned ? el("span", { class: "ms-2 rounded-full bg-jade-soft px-1.5 py-0.5 text-2xs font-semibold text-jade" }, "pinned") : null))
128
+ );
129
+
130
+ table.replaceChildren(head, ...body);
131
+ }
132
+
133
+ function run() {
134
+ build();
135
+ render();
136
+ }
137
+
138
+ names.addEventListener("input", run);
139
+ start.addEventListener("change", run);
140
+ end.addEventListener("change", run);
141
+ perDay.addEventListener("input", run);
142
+ skipWeekend.input.addEventListener("change", run);
143
+ pins.addEventListener("input", run);
144
+ order.addEventListener("change", run);
145
+
146
+ const copyButton = button("Copy roster", {
147
+ icon: "copy",
148
+ onclick: async () => {
149
+ if (!rows.length) return;
150
+ const text = rows.map((row) => `${row.date}\t${row.people.join(", ")}`).join("\n");
151
+ try {
152
+ await navigator.clipboard.writeText(text);
153
+ toast("Roster copied");
154
+ } catch {
155
+ error.write("The clipboard is blocked here. Download the CSV instead.");
156
+ }
157
+ },
158
+ });
159
+
160
+ const downloadButton = button("Download CSV", {
161
+ icon: "download",
162
+ kind: "fill",
163
+ onclick: () => {
164
+ if (!rows.length) return;
165
+ const csv = [["date", "people"], ...rows.map((row) => [row.date, row.people.join("; ")])]
166
+ .map((row) => row.map((cell) => `"${String(cell).replace(/"/g, '""')}"`).join(","))
167
+ .join("\n");
168
+ download("roster.csv", csv, "text/csv");
169
+ },
170
+ });
171
+
172
+ run();
173
+
174
+ const shell = toolcard({
175
+ body: bench(
176
+ stack(
177
+ panel("Names", names, "One per line. The rotation continues in this order."),
178
+ el("div", { class: "grid grid-cols-2 gap-3" }, panel("From", start), panel("To", end)),
179
+ panel("People per day", perDay),
180
+ panel("Direction", order),
181
+ skipWeekend,
182
+ panel("Pinned dates", pins, "One per line, like 2026-10-25: Ani."),
183
+ error
184
+ ),
185
+ stack(summary, empty, table)
186
+ ),
187
+ foot: actions(copyButton, downloadButton),
188
+ });
189
+
190
+ slot.append(shell.node);
191
+
192
+ return {};
193
+ }
194
+
195
+ export default { mount };