@thebattlearena/corner 0.9.1-beta.70 → 0.10.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/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/ring/agents/cornerman.md +20 -3
- package/ring/agents/wrestler.md +11 -0
- package/ring/hooks/hooks.json +20 -0
- package/ring/scripts/bout-stats-render.mjs +366 -0
- package/ring/scripts/bout-stats.mjs +360 -0
- package/ring/scripts/config.mjs +1 -1
- package/ring/scripts/contract-endpoints.mjs +163 -0
- package/ring/scripts/contract-preview.mjs +34 -14
- package/ring/scripts/fight.mjs +27 -0
- package/ring/scripts/findings.mjs +3 -1
- package/ring/scripts/format-gate.mjs +723 -0
- package/ring/scripts/format-worker.mjs +120 -0
- package/ring/scripts/front-routes.mjs +345 -0
- package/ring/scripts/judges-card.mjs +324 -3
- package/ring/scripts/lookout.mjs +22 -4
- package/ring/scripts/mask-guard.mjs +34 -1
- package/ring/scripts/page-embed.mjs +112 -0
- package/ring/scripts/play-by-play.mjs +155 -7
- package/ring/scripts/preparation-collect.mjs +177 -0
- package/ring/scripts/preparation.mjs +547 -0
- package/ring/scripts/round-bell.mjs +9 -2
- package/ring/scripts/tech-plan-log.mjs +81 -0
- package/ring/scripts/tech-plan.mjs +191 -0
- package/ring/scripts/verdicts-log.mjs +75 -0
- package/ring/scripts/verdicts.mjs +205 -0
- package/ring/skills/play-by-play/SKILL.md +235 -33
- package/ring/skills/play-by-play/templates/blocks.css +196 -0
- package/ring/skills/play-by-play/templates/chronicle.html +358 -55
- package/ring/skills/play-by-play/templates/endpoints-view.js +1153 -0
- package/ring/skills/play-by-play/templates/interview-form.js +516 -0
- package/ring/skills/play-by-play/templates/now-panel.js +719 -0
- package/ring/skills/referee/SKILL.md +136 -42
- package/ring/skills/referee/references/api-diagram.md +56 -0
- package/ring/skills/referee/references/precedents.md +11 -0
- package/ring/skills/referee/references/rulebook.md +65 -4
- package/ring/skills/referee/templates/api-diagram.html +115 -0
- package/ring/skills/referee/templates/escalation.md +7 -1
- package/ring/skills/referee/templates/round-bell-body.md +4 -0
- package/ring/skills/referee/templates/wrestler-brief.md +3 -0
- package/ring/skills/sparring/SKILL.md +10 -2
- package/ring/skills/sparring/templates/contrato.md +29 -0
- package/ring/skills/sparring/templates/rudo-brief.md +7 -0
- package/ring/skills/weigh-in/SKILL.md +6 -0
|
@@ -0,0 +1,1153 @@
|
|
|
1
|
+
// «Endpoints del contrato», drawn in the chronicle page from the spec files `play-by-play.mjs embed`
|
|
2
|
+
// wrote into it (<script type="application/json" id="ring-spec-<pr>">). Parses the YAML with
|
|
3
|
+
// js-yaml, follows `$ref` across files and shows every operation added, changed or removed
|
|
4
|
+
// against the base as a card (method chip, path, summary, status), filterable, with its scopes,
|
|
5
|
+
// parameters, body and responses as compact tables when unfolded.
|
|
6
|
+
//
|
|
7
|
+
// The judge gives each added or changed operation a verdict, «Se aprueba» or «Se mejora» (a
|
|
8
|
+
// required comment, images), saved in the page's db (collection `veredictos`, doc
|
|
9
|
+
// `pr<n>-<METHOD>-<path>-<spec>`) with the fingerprint (`hash`) of the operation's contract: its
|
|
10
|
+
// block of the spec with every schema it expands. A verdict counts only while that fingerprint is
|
|
11
|
+
// the current one, so a rewritten contract asks again. The judge's view also saves the contract's
|
|
12
|
+
// state (`pr<n>-contrato`: every operation's fingerprint and the fingerprint of all the embedded
|
|
13
|
+
// files) so the referee can tell, without parsing YAML, which verdicts are current
|
|
14
|
+
// (`judges-card.mjs page-verdicts`). «Pendientes del contrato» lists the current «se mejora»; the
|
|
15
|
+
// weigh-in's scale (`[data-ring-scale="<pr>"]`) reads «N de M aprobados» until every operation is
|
|
16
|
+
// approved and the judge's approve-contract is on record (`data-approved="true"`).
|
|
17
|
+
import yaml from 'https://cdn.jsdelivr.net/npm/js-yaml@4.1.0/dist/js-yaml.mjs';
|
|
18
|
+
|
|
19
|
+
// The page's bus (see interview-form.js): each board registers its items and how to judge them.
|
|
20
|
+
const ring = (window.ringPage ??= {
|
|
21
|
+
questions: new Map(),
|
|
22
|
+
boards: new Map(),
|
|
23
|
+
scales: {},
|
|
24
|
+
changed() {
|
|
25
|
+
document.dispatchEvent(new CustomEvent('ring:change'));
|
|
26
|
+
},
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
const METHODS = [
|
|
30
|
+
'get',
|
|
31
|
+
'put',
|
|
32
|
+
'post',
|
|
33
|
+
'delete',
|
|
34
|
+
'patch',
|
|
35
|
+
'options',
|
|
36
|
+
'head',
|
|
37
|
+
'trace',
|
|
38
|
+
];
|
|
39
|
+
const MAX_DEPTH = 7;
|
|
40
|
+
const STATUS = { added: 'nueva', changed: 'cambia', removed: 'se quita' };
|
|
41
|
+
|
|
42
|
+
const el = (tag, attrs = {}, ...children) => {
|
|
43
|
+
const node = document.createElement(tag);
|
|
44
|
+
Object.entries(attrs).forEach(([key, value]) => {
|
|
45
|
+
if (value === undefined || value === null || value === false) return;
|
|
46
|
+
if (key === 'text') return void (node.textContent = value);
|
|
47
|
+
if (key.startsWith('on'))
|
|
48
|
+
return void node.addEventListener(key.slice(2), value);
|
|
49
|
+
node.setAttribute(key, value === true ? '' : value);
|
|
50
|
+
});
|
|
51
|
+
children
|
|
52
|
+
.flat(Infinity)
|
|
53
|
+
.filter((child) => child !== null && child !== undefined && child !== false)
|
|
54
|
+
.forEach((child) => node.append(child));
|
|
55
|
+
return node;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const dirOf = (path) => path.split('/').slice(0, -1).join('/');
|
|
59
|
+
const normalize = (path) => {
|
|
60
|
+
const out = [];
|
|
61
|
+
path.split('/').forEach((part) => {
|
|
62
|
+
if (part === '..') return void out.pop();
|
|
63
|
+
if (part && part !== '.') out.push(part);
|
|
64
|
+
});
|
|
65
|
+
return out.join('/');
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/** One side (base or head) of a spec root: parsed files and `$ref` resolution across them. */
|
|
69
|
+
function side(files, key) {
|
|
70
|
+
const parsed = new Map();
|
|
71
|
+
const doc = (path) => {
|
|
72
|
+
if (parsed.has(path)) return parsed.get(path);
|
|
73
|
+
const entry = files[path];
|
|
74
|
+
const text = entry ? (entry.same ? entry.base : entry[key]) : null;
|
|
75
|
+
let value = null;
|
|
76
|
+
try {
|
|
77
|
+
value = text == null ? null : yaml.load(text);
|
|
78
|
+
} catch {
|
|
79
|
+
value = null;
|
|
80
|
+
}
|
|
81
|
+
parsed.set(path, value);
|
|
82
|
+
return value;
|
|
83
|
+
};
|
|
84
|
+
const pointer = (value, hash) =>
|
|
85
|
+
hash
|
|
86
|
+
.split('/')
|
|
87
|
+
.filter(Boolean)
|
|
88
|
+
.map((part) =>
|
|
89
|
+
decodeURIComponent(part.replace(/~1/g, '/').replace(/~0/g, '~')),
|
|
90
|
+
)
|
|
91
|
+
.reduce((node, part) => (node == null ? null : node[part]), value);
|
|
92
|
+
const target = (ref, file) => {
|
|
93
|
+
const [filePart, hash = ''] = String(ref).split('#');
|
|
94
|
+
const path = filePart ? normalize(`${dirOf(file)}/${filePart}`) : file;
|
|
95
|
+
return {
|
|
96
|
+
node: pointer(doc(path), hash),
|
|
97
|
+
file: path,
|
|
98
|
+
name: hash.split('/').pop() || filePart,
|
|
99
|
+
};
|
|
100
|
+
};
|
|
101
|
+
const deref = (node, file, seen = new Set(), depth = 0) => {
|
|
102
|
+
if (Array.isArray(node))
|
|
103
|
+
return node.map((item) => deref(item, file, seen, depth));
|
|
104
|
+
if (!node || typeof node !== 'object') return node;
|
|
105
|
+
if (typeof node.$ref === 'string') {
|
|
106
|
+
const key = `${file}#${node.$ref}`;
|
|
107
|
+
const found = target(node.$ref, file);
|
|
108
|
+
if (seen.has(key) || depth > 40 || found.node == null)
|
|
109
|
+
return { $refName: found.name };
|
|
110
|
+
const resolved = deref(
|
|
111
|
+
found.node,
|
|
112
|
+
found.file,
|
|
113
|
+
new Set([...seen, key]),
|
|
114
|
+
depth + 1,
|
|
115
|
+
);
|
|
116
|
+
return resolved &&
|
|
117
|
+
typeof resolved === 'object' &&
|
|
118
|
+
!Array.isArray(resolved)
|
|
119
|
+
? { ...resolved, $refName: found.name }
|
|
120
|
+
: resolved;
|
|
121
|
+
}
|
|
122
|
+
return Object.fromEntries(
|
|
123
|
+
Object.entries(node).map(([k, v]) => [
|
|
124
|
+
k,
|
|
125
|
+
deref(v, file, seen, depth + 1),
|
|
126
|
+
]),
|
|
127
|
+
);
|
|
128
|
+
};
|
|
129
|
+
const operations = (root) => {
|
|
130
|
+
const spec = doc(root);
|
|
131
|
+
const out = {};
|
|
132
|
+
Object.entries(spec?.paths ?? {}).forEach(([path, raw]) => {
|
|
133
|
+
const item = deref(raw, root);
|
|
134
|
+
METHODS.filter((method) => item?.[method]).forEach((method) => {
|
|
135
|
+
out[`${method.toUpperCase()} ${path}`] = {
|
|
136
|
+
...item[method],
|
|
137
|
+
parameters: [
|
|
138
|
+
...(item.parameters ?? []),
|
|
139
|
+
...(item[method].parameters ?? []),
|
|
140
|
+
],
|
|
141
|
+
};
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
return { out, security: spec?.security ?? null };
|
|
145
|
+
};
|
|
146
|
+
return { operations };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Keys sorted at every level, so the same contract always gives the same fingerprint. */
|
|
150
|
+
const stable = (value) => {
|
|
151
|
+
if (Array.isArray(value)) return `[${value.map(stable).join(',')}]`;
|
|
152
|
+
if (value && typeof value === 'object')
|
|
153
|
+
return `{${Object.keys(value)
|
|
154
|
+
.sort()
|
|
155
|
+
.map((key) => `${JSON.stringify(key)}:${stable(value[key])}`)
|
|
156
|
+
.join(',')}}`;
|
|
157
|
+
return JSON.stringify(value ?? null);
|
|
158
|
+
};
|
|
159
|
+
const sha256 = async (text) => {
|
|
160
|
+
const digest = await crypto.subtle.digest(
|
|
161
|
+
'SHA-256',
|
|
162
|
+
new TextEncoder().encode(text),
|
|
163
|
+
);
|
|
164
|
+
return [...new Uint8Array(digest)]
|
|
165
|
+
.map((byte) => byte.toString(16).padStart(2, '0'))
|
|
166
|
+
.join('');
|
|
167
|
+
};
|
|
168
|
+
const slug = (text) =>
|
|
169
|
+
String(text)
|
|
170
|
+
.replace(/[{}]/g, '')
|
|
171
|
+
.replace(/[^A-Za-z0-9]+/g, '_')
|
|
172
|
+
.replace(/^_+|_+$/g, '');
|
|
173
|
+
const fmt = (iso) => {
|
|
174
|
+
try {
|
|
175
|
+
return new Date(iso).toLocaleString('es-MX', {
|
|
176
|
+
day: 'numeric',
|
|
177
|
+
month: 'short',
|
|
178
|
+
hour: '2-digit',
|
|
179
|
+
minute: '2-digit',
|
|
180
|
+
});
|
|
181
|
+
} catch {
|
|
182
|
+
return '';
|
|
183
|
+
}
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
const typeOf = (schema) => {
|
|
187
|
+
if (!schema || typeof schema !== 'object') return '';
|
|
188
|
+
const types = [].concat(schema.type ?? []);
|
|
189
|
+
const base = types.filter((type) => type !== 'null').join(' | ');
|
|
190
|
+
const name = base === 'array' ? `${typeOf(schema.items) || 'any'}[]` : base;
|
|
191
|
+
return `${name || schema.$refName || (schema.oneOf ? 'oneOf' : schema.anyOf ? 'anyOf' : schema.allOf ? 'allOf' : '')}${schema.format ? ` (${schema.format})` : ''}`;
|
|
192
|
+
};
|
|
193
|
+
const isNullable = (schema) =>
|
|
194
|
+
Boolean(schema) &&
|
|
195
|
+
(schema.nullable === true || [].concat(schema.type ?? []).includes('null'));
|
|
196
|
+
|
|
197
|
+
/** A schema flattened into rows: field (indented by depth), type, nullability, required, values. */
|
|
198
|
+
function schemaRows(schema, depth = 0, rows = [], prefix = '') {
|
|
199
|
+
if (!schema || typeof schema !== 'object' || depth > MAX_DEPTH) return rows;
|
|
200
|
+
['allOf', 'oneOf', 'anyOf']
|
|
201
|
+
.filter((key) => Array.isArray(schema[key]))
|
|
202
|
+
.forEach((key) =>
|
|
203
|
+
schema[key].forEach((part, index) => {
|
|
204
|
+
if (key !== 'allOf')
|
|
205
|
+
rows.push({
|
|
206
|
+
depth,
|
|
207
|
+
name: `${prefix}${key}[${index}]`,
|
|
208
|
+
schema: part,
|
|
209
|
+
combo: true,
|
|
210
|
+
});
|
|
211
|
+
schemaRows(part, key === 'allOf' ? depth : depth + 1, rows, prefix);
|
|
212
|
+
}),
|
|
213
|
+
);
|
|
214
|
+
const target = [].concat(schema.type ?? []).includes('array')
|
|
215
|
+
? schema.items
|
|
216
|
+
: schema;
|
|
217
|
+
const required = new Set(target?.required ?? []);
|
|
218
|
+
Object.entries(target?.properties ?? {}).forEach(([name, field]) => {
|
|
219
|
+
rows.push({
|
|
220
|
+
depth,
|
|
221
|
+
name,
|
|
222
|
+
schema: field ?? {},
|
|
223
|
+
required: required.has(name),
|
|
224
|
+
});
|
|
225
|
+
const inner = field?.items ?? field;
|
|
226
|
+
if (inner?.properties || inner?.allOf || inner?.oneOf || inner?.anyOf)
|
|
227
|
+
schemaRows(field, depth + 1, rows);
|
|
228
|
+
});
|
|
229
|
+
return rows;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function schemaTable(schema) {
|
|
233
|
+
if (!schema) return null;
|
|
234
|
+
const rows = schemaRows(schema);
|
|
235
|
+
if (!rows.length)
|
|
236
|
+
return el(
|
|
237
|
+
'p',
|
|
238
|
+
{ class: 'muted' },
|
|
239
|
+
el('code', { text: typeOf(schema) || 'sin esquema' }),
|
|
240
|
+
isNullable(schema) ? ' · nulo' : '',
|
|
241
|
+
);
|
|
242
|
+
return el(
|
|
243
|
+
'div',
|
|
244
|
+
{ class: 'tbl schema-tbl' },
|
|
245
|
+
el(
|
|
246
|
+
'table',
|
|
247
|
+
{},
|
|
248
|
+
el(
|
|
249
|
+
'thead',
|
|
250
|
+
{},
|
|
251
|
+
el(
|
|
252
|
+
'tr',
|
|
253
|
+
{},
|
|
254
|
+
['Campo', 'Tipo', 'Nulo', 'Req.', 'Valores · descripción'].map(
|
|
255
|
+
(text) => el('th', { text }),
|
|
256
|
+
),
|
|
257
|
+
),
|
|
258
|
+
),
|
|
259
|
+
el(
|
|
260
|
+
'tbody',
|
|
261
|
+
{},
|
|
262
|
+
rows.map((row) => {
|
|
263
|
+
const values = row.schema.enum ?? row.schema.items?.enum;
|
|
264
|
+
return el(
|
|
265
|
+
'tr',
|
|
266
|
+
{},
|
|
267
|
+
el(
|
|
268
|
+
'td',
|
|
269
|
+
{ style: `padding-left:${12 + row.depth * 16}px` },
|
|
270
|
+
el('code', { text: row.name }),
|
|
271
|
+
),
|
|
272
|
+
el('td', {}, el('code', { text: typeOf(row.schema) })),
|
|
273
|
+
el('td', { text: isNullable(row.schema) ? 'sí' : '' }),
|
|
274
|
+
el('td', {
|
|
275
|
+
class: row.required ? 'req' : '',
|
|
276
|
+
text: row.required ? 'sí' : '',
|
|
277
|
+
}),
|
|
278
|
+
el(
|
|
279
|
+
'td',
|
|
280
|
+
{},
|
|
281
|
+
values
|
|
282
|
+
? el('code', { text: values.map(String).join(' | ') })
|
|
283
|
+
: null,
|
|
284
|
+
values && row.schema.description ? ' · ' : '',
|
|
285
|
+
row.schema.description ?? '',
|
|
286
|
+
),
|
|
287
|
+
);
|
|
288
|
+
}),
|
|
289
|
+
),
|
|
290
|
+
),
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const bodySchema = (content) => {
|
|
295
|
+
const [type, media] = Object.entries(content ?? {})[0] ?? [];
|
|
296
|
+
return media ? { type, schema: media.schema } : null;
|
|
297
|
+
};
|
|
298
|
+
|
|
299
|
+
function operationBody(operation, security) {
|
|
300
|
+
const scopes = (operation.security ?? security ?? []).flatMap((entry) =>
|
|
301
|
+
Object.entries(entry).map(
|
|
302
|
+
([name, list]) => `${name}${list?.length ? `: ${list.join(', ')}` : ''}`,
|
|
303
|
+
),
|
|
304
|
+
);
|
|
305
|
+
const body = el('div', { class: 'op-body' });
|
|
306
|
+
if (operation.description)
|
|
307
|
+
body.append(el('p', { class: 'desc', text: operation.description }));
|
|
308
|
+
body.append(
|
|
309
|
+
el(
|
|
310
|
+
'p',
|
|
311
|
+
{},
|
|
312
|
+
el('b', { text: 'Scopes: ' }),
|
|
313
|
+
scopes.length ? scopes.join(' · ') : 'ninguno declarado',
|
|
314
|
+
),
|
|
315
|
+
);
|
|
316
|
+
if (operation.parameters?.length) {
|
|
317
|
+
body.append(el('h5', { text: 'Parámetros' }));
|
|
318
|
+
const label = (param) =>
|
|
319
|
+
`${param.name ?? param.$refName ?? '?'} · ${param.in ?? ''}`;
|
|
320
|
+
body.append(
|
|
321
|
+
schemaTable({
|
|
322
|
+
type: 'object',
|
|
323
|
+
required: operation.parameters
|
|
324
|
+
.filter((param) => param.required)
|
|
325
|
+
.map(label),
|
|
326
|
+
properties: Object.fromEntries(
|
|
327
|
+
operation.parameters.map((param) => [
|
|
328
|
+
label(param),
|
|
329
|
+
{
|
|
330
|
+
...(param.schema ?? {}),
|
|
331
|
+
description: param.description ?? param.schema?.description,
|
|
332
|
+
},
|
|
333
|
+
]),
|
|
334
|
+
),
|
|
335
|
+
}),
|
|
336
|
+
);
|
|
337
|
+
}
|
|
338
|
+
const request = bodySchema(operation.requestBody?.content);
|
|
339
|
+
if (request) {
|
|
340
|
+
body.append(
|
|
341
|
+
el('h5', {
|
|
342
|
+
text: `Cuerpo · ${request.type}${operation.requestBody.required ? ' · requerido' : ''}`,
|
|
343
|
+
}),
|
|
344
|
+
);
|
|
345
|
+
body.append(schemaTable(request.schema));
|
|
346
|
+
}
|
|
347
|
+
Object.entries(operation.responses ?? {}).forEach(([code, response]) => {
|
|
348
|
+
const media = bodySchema(response?.content);
|
|
349
|
+
body.append(
|
|
350
|
+
el(
|
|
351
|
+
'h5',
|
|
352
|
+
{ class: /^[45]|default/i.test(code) ? 'err' : '' },
|
|
353
|
+
`${code} · `,
|
|
354
|
+
response?.description ?? response?.$refName ?? '',
|
|
355
|
+
),
|
|
356
|
+
);
|
|
357
|
+
if (media) body.append(schemaTable(media.schema));
|
|
358
|
+
});
|
|
359
|
+
return body;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
const waitClaude = () =>
|
|
363
|
+
new Promise((resolve) => {
|
|
364
|
+
let waited = 0;
|
|
365
|
+
const tick = () => {
|
|
366
|
+
if (window.claude && window.claude.use) return resolve(window.claude);
|
|
367
|
+
if ((waited += 200) > 10000) return resolve(null);
|
|
368
|
+
setTimeout(tick, 200);
|
|
369
|
+
};
|
|
370
|
+
tick();
|
|
371
|
+
});
|
|
372
|
+
const capabilities = (async () => {
|
|
373
|
+
const claude = await waitClaude();
|
|
374
|
+
if (!claude) return { db: null, user: null, assets: null, canEdit: false };
|
|
375
|
+
const db = await claude.use('db');
|
|
376
|
+
const user = await claude.use('user');
|
|
377
|
+
const assets = await claude.use('assets');
|
|
378
|
+
const canEdit = user ? await user.canEdit() : false;
|
|
379
|
+
return { db, user, assets, canEdit };
|
|
380
|
+
})();
|
|
381
|
+
|
|
382
|
+
/** Saves the judge's verdict on one operation or route; throws a message the form can show. */
|
|
383
|
+
async function writeVerdict(op, pr, verdict, comment = '', attachments = []) {
|
|
384
|
+
const caps = await capabilities;
|
|
385
|
+
if (!caps.db || !caps.canEdit)
|
|
386
|
+
throw new Error('Sólo el juez (quien edita la página) da el veredicto');
|
|
387
|
+
if (verdict === 'improve' && !String(comment).trim())
|
|
388
|
+
throw new Error('Escribe qué hay que mejorar');
|
|
389
|
+
try {
|
|
390
|
+
await caps.db
|
|
391
|
+
.collection('veredictos')
|
|
392
|
+
.doc(op.key)
|
|
393
|
+
.set({
|
|
394
|
+
kind: 'verdict',
|
|
395
|
+
key: op.key,
|
|
396
|
+
pr,
|
|
397
|
+
method: op.method,
|
|
398
|
+
path: op.path,
|
|
399
|
+
specFile: op.specFile,
|
|
400
|
+
hash: op.hash,
|
|
401
|
+
verdict,
|
|
402
|
+
comment: verdict === 'improve' ? String(comment).trim() : '',
|
|
403
|
+
attachments: verdict === 'improve' ? attachments.slice() : [],
|
|
404
|
+
at: new Date().toISOString(),
|
|
405
|
+
by: caps.user ? await caps.user.id() : null,
|
|
406
|
+
});
|
|
407
|
+
} catch (error) {
|
|
408
|
+
throw new Error(`No se guardó (${error.code || 'error'})`);
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/** The verdict box of one card: its state and, for the judge, the two buttons and the form. */
|
|
413
|
+
function verdictBox(op, ctx) {
|
|
414
|
+
const box = el('div', { class: 'verdict' });
|
|
415
|
+
const state = el('p', { class: 'verdict-state' });
|
|
416
|
+
const form = el('div', { class: 'ans-form improve', hidden: true });
|
|
417
|
+
const comment = el('textarea', {
|
|
418
|
+
placeholder: '¿Qué hay que mejorar? (obligatorio)',
|
|
419
|
+
});
|
|
420
|
+
const file = el('input', {
|
|
421
|
+
type: 'file',
|
|
422
|
+
accept: 'image/*',
|
|
423
|
+
multiple: true,
|
|
424
|
+
hidden: true,
|
|
425
|
+
});
|
|
426
|
+
const thumbs = el('div', { class: 'thumbs' });
|
|
427
|
+
const status = el('span', { class: 'status' });
|
|
428
|
+
const attached = [];
|
|
429
|
+
const save = async (verdict) => {
|
|
430
|
+
status.textContent = 'Guardando…';
|
|
431
|
+
try {
|
|
432
|
+
await writeVerdict(op, ctx.pr, verdict, comment.value, attached);
|
|
433
|
+
status.textContent = 'Guardado';
|
|
434
|
+
form.hidden = true;
|
|
435
|
+
} catch (error) {
|
|
436
|
+
status.textContent = error.message;
|
|
437
|
+
}
|
|
438
|
+
};
|
|
439
|
+
const addFiles = async (files) => {
|
|
440
|
+
const caps = await capabilities;
|
|
441
|
+
if (!caps.assets)
|
|
442
|
+
return void (status.textContent =
|
|
443
|
+
'Adjuntar sólo funciona para quien puede editar la página');
|
|
444
|
+
for (const picked of files) {
|
|
445
|
+
if (!picked.type.startsWith('image/')) continue;
|
|
446
|
+
status.textContent = `Subiendo ${picked.name}…`;
|
|
447
|
+
try {
|
|
448
|
+
const up = await caps.assets.upload(picked);
|
|
449
|
+
attached.push(up.id);
|
|
450
|
+
thumbs.append(el('img', { src: up.url, alt: picked.name }));
|
|
451
|
+
status.textContent = '';
|
|
452
|
+
} catch (error) {
|
|
453
|
+
status.textContent = `No se pudo subir ${picked.name} (${error.code || 'error'})`;
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
};
|
|
457
|
+
const drop = el('div', {
|
|
458
|
+
class: 'drop',
|
|
459
|
+
tabindex: '0',
|
|
460
|
+
text: 'Arrastra imágenes aquí o haz clic para adjuntar',
|
|
461
|
+
onclick: () => file.click(),
|
|
462
|
+
onkeydown: (event) => ['Enter', ' '].includes(event.key) && file.click(),
|
|
463
|
+
ondragover: (event) => {
|
|
464
|
+
event.preventDefault();
|
|
465
|
+
drop.classList.add('over');
|
|
466
|
+
},
|
|
467
|
+
ondragleave: () => drop.classList.remove('over'),
|
|
468
|
+
ondrop: (event) => {
|
|
469
|
+
event.preventDefault();
|
|
470
|
+
drop.classList.remove('over');
|
|
471
|
+
addFiles([...event.dataTransfer.files]);
|
|
472
|
+
},
|
|
473
|
+
});
|
|
474
|
+
file.addEventListener('change', () => addFiles([...file.files]));
|
|
475
|
+
form.append(
|
|
476
|
+
comment,
|
|
477
|
+
drop,
|
|
478
|
+
file,
|
|
479
|
+
thumbs,
|
|
480
|
+
el(
|
|
481
|
+
'div',
|
|
482
|
+
{ class: 'row' },
|
|
483
|
+
el('button', {
|
|
484
|
+
type: 'button',
|
|
485
|
+
class: 'save',
|
|
486
|
+
text: 'Guardar «se mejora»',
|
|
487
|
+
onclick: () => save('improve'),
|
|
488
|
+
}),
|
|
489
|
+
),
|
|
490
|
+
);
|
|
491
|
+
const buttons = el(
|
|
492
|
+
'div',
|
|
493
|
+
{ class: 'verdict-buttons', hidden: true },
|
|
494
|
+
el('button', {
|
|
495
|
+
type: 'button',
|
|
496
|
+
class: 'approve',
|
|
497
|
+
text: 'Se aprueba',
|
|
498
|
+
onclick: () => save('approved'),
|
|
499
|
+
}),
|
|
500
|
+
el('button', {
|
|
501
|
+
type: 'button',
|
|
502
|
+
class: 'improve-btn',
|
|
503
|
+
text: 'Se mejora',
|
|
504
|
+
onclick: () => (form.hidden = !form.hidden),
|
|
505
|
+
}),
|
|
506
|
+
status,
|
|
507
|
+
);
|
|
508
|
+
box.append(state, buttons, form);
|
|
509
|
+
capabilities.then((caps) => (buttons.hidden = !(caps.db && caps.canEdit)));
|
|
510
|
+
const paint = (verdict) => {
|
|
511
|
+
state.textContent = '';
|
|
512
|
+
if (!verdict)
|
|
513
|
+
return void state.append(
|
|
514
|
+
el('span', { class: 'v none', text: 'Sin veredicto' }),
|
|
515
|
+
);
|
|
516
|
+
const current = verdict.hash === op.hash;
|
|
517
|
+
const label = verdict.verdict === 'approved' ? 'Se aprueba' : 'Se mejora';
|
|
518
|
+
state.append(
|
|
519
|
+
el('span', {
|
|
520
|
+
class: `v ${current ? verdict.verdict : 'stale'}`,
|
|
521
|
+
text: current ? label : `${label} · desactualizado`,
|
|
522
|
+
}),
|
|
523
|
+
` Juez · ${fmt(verdict.at)}`,
|
|
524
|
+
verdict.comment ? ` — ${verdict.comment}` : '',
|
|
525
|
+
);
|
|
526
|
+
(verdict.attachments ?? []).forEach((id) =>
|
|
527
|
+
state.append(el('img', { src: `/_blob/${id}`, alt: 'Adjunto del juez' })),
|
|
528
|
+
);
|
|
529
|
+
};
|
|
530
|
+
paint(null);
|
|
531
|
+
return { box, paint };
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/** The endpoints of a spec block as board items. */
|
|
535
|
+
async function specItems(data) {
|
|
536
|
+
const items = [];
|
|
537
|
+
for (const { root, files } of data.roots) {
|
|
538
|
+
const base = side(files, 'base').operations(root);
|
|
539
|
+
const head = side(files, 'head').operations(root);
|
|
540
|
+
const status = (key) =>
|
|
541
|
+
!(key in base.out)
|
|
542
|
+
? 'added'
|
|
543
|
+
: !(key in head.out)
|
|
544
|
+
? 'removed'
|
|
545
|
+
: stable(base.out[key]) === stable(head.out[key])
|
|
546
|
+
? null
|
|
547
|
+
: 'changed';
|
|
548
|
+
for (const key of [
|
|
549
|
+
...new Set([...Object.keys(head.out), ...Object.keys(base.out)]),
|
|
550
|
+
]) {
|
|
551
|
+
const kind = status(key);
|
|
552
|
+
if (!kind) continue;
|
|
553
|
+
const [method, path] = key.split(' ');
|
|
554
|
+
const operation = kind === 'removed' ? base.out[key] : head.out[key];
|
|
555
|
+
const security = (kind === 'removed' ? base : head).security;
|
|
556
|
+
items.push({
|
|
557
|
+
key: `pr${data.pr}-${method}-${slug(path)}-${slug(
|
|
558
|
+
root
|
|
559
|
+
.split('/')
|
|
560
|
+
.pop()
|
|
561
|
+
.replace(/\.[^.]+$/, ''),
|
|
562
|
+
)}`,
|
|
563
|
+
method,
|
|
564
|
+
chip: method,
|
|
565
|
+
chipClass: method.toLowerCase(),
|
|
566
|
+
path,
|
|
567
|
+
specFile: root,
|
|
568
|
+
tag: data.roots.length > 1 ? root.split('/').pop() : null,
|
|
569
|
+
summary: operation.summary ?? '',
|
|
570
|
+
status: kind,
|
|
571
|
+
body: () => operationBody(operation, security),
|
|
572
|
+
hash: (await sha256(stable(operation))).slice(0, 16),
|
|
573
|
+
});
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
return items;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
const list = (title, values, code = true) =>
|
|
580
|
+
values.length
|
|
581
|
+
? [
|
|
582
|
+
el('h5', { text: title }),
|
|
583
|
+
el(
|
|
584
|
+
'p',
|
|
585
|
+
{},
|
|
586
|
+
values.flatMap((value, index) => [
|
|
587
|
+
index ? ', ' : '',
|
|
588
|
+
code ? el('code', { text: value }) : value,
|
|
589
|
+
]),
|
|
590
|
+
),
|
|
591
|
+
]
|
|
592
|
+
: [];
|
|
593
|
+
|
|
594
|
+
/** A route's detail: what it reads from the URL, the files it reaches, what it calls in the back. */
|
|
595
|
+
function routeBody(route, data) {
|
|
596
|
+
const body = el('div', { class: 'op-body' });
|
|
597
|
+
if (route.kind === 'global')
|
|
598
|
+
body.append(
|
|
599
|
+
el('p', { class: 'desc', text: 'Cambia para todas las rutas.' }),
|
|
600
|
+
);
|
|
601
|
+
if (route.sparringOnly)
|
|
602
|
+
body.append(
|
|
603
|
+
el('p', {
|
|
604
|
+
class: 'desc warn',
|
|
605
|
+
text: 'Es del sparring: no debería llegar a la entrega.',
|
|
606
|
+
}),
|
|
607
|
+
);
|
|
608
|
+
const lines = route.files.reduce(
|
|
609
|
+
(sum, file) => ({
|
|
610
|
+
added: sum.added + file.added,
|
|
611
|
+
deleted: sum.deleted + file.deleted,
|
|
612
|
+
}),
|
|
613
|
+
{ added: 0, deleted: 0 },
|
|
614
|
+
);
|
|
615
|
+
body.append(
|
|
616
|
+
el(
|
|
617
|
+
'p',
|
|
618
|
+
{},
|
|
619
|
+
el('b', { text: 'Archivo: ' }),
|
|
620
|
+
el('code', { text: route.file }),
|
|
621
|
+
` · ${route.files.length} archivo(s) que cambian, +${lines.added} / −${lines.deleted} líneas`,
|
|
622
|
+
),
|
|
623
|
+
);
|
|
624
|
+
body.append(
|
|
625
|
+
...list('Segmentos dinámicos', route.dynamic),
|
|
626
|
+
...list(
|
|
627
|
+
'Query params',
|
|
628
|
+
route.params.map((name) => `?${name}=`),
|
|
629
|
+
),
|
|
630
|
+
);
|
|
631
|
+
body.append(
|
|
632
|
+
...list('Componentes que cambian', route.components),
|
|
633
|
+
...list('Hooks que cambian', route.hooks),
|
|
634
|
+
...list('Servicios que cambian', route.services),
|
|
635
|
+
);
|
|
636
|
+
body.append(
|
|
637
|
+
...(route.calls.length
|
|
638
|
+
? list('Llamadas al back', route.calls)
|
|
639
|
+
: [
|
|
640
|
+
el('h5', { text: 'Llamadas al back' }),
|
|
641
|
+
el('p', {
|
|
642
|
+
class: 'muted',
|
|
643
|
+
text: 'No se pudieron leer de estos archivos; ver los servicios.',
|
|
644
|
+
}),
|
|
645
|
+
]),
|
|
646
|
+
);
|
|
647
|
+
if (data.previewUrl && route.kind === 'page' && route.status !== 'removed')
|
|
648
|
+
body.append(
|
|
649
|
+
el(
|
|
650
|
+
'p',
|
|
651
|
+
{},
|
|
652
|
+
el('a', {
|
|
653
|
+
href: `${data.previewUrl.replace(/\/$/, '')}${route.url.replace(/ \(layout\)$/, '')}`,
|
|
654
|
+
target: '_blank',
|
|
655
|
+
rel: 'noopener',
|
|
656
|
+
text: 'Abrir en el preview',
|
|
657
|
+
}),
|
|
658
|
+
),
|
|
659
|
+
);
|
|
660
|
+
return body;
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
/** The routes of a frontend block as board items, and its navigation changes. */
|
|
664
|
+
function routeItems(data) {
|
|
665
|
+
return data.routes.map((route) => ({
|
|
666
|
+
key: route.key,
|
|
667
|
+
method:
|
|
668
|
+
route.kind === 'api'
|
|
669
|
+
? 'API ROUTE'
|
|
670
|
+
: route.kind === 'global'
|
|
671
|
+
? 'GLOBAL'
|
|
672
|
+
: 'PÁGINA',
|
|
673
|
+
chip:
|
|
674
|
+
route.kind === 'api'
|
|
675
|
+
? 'API ROUTE'
|
|
676
|
+
: route.kind === 'global'
|
|
677
|
+
? 'GLOBAL'
|
|
678
|
+
: 'PÁGINA',
|
|
679
|
+
chipClass: `route-${route.kind}`,
|
|
680
|
+
path: route.url,
|
|
681
|
+
specFile: route.file,
|
|
682
|
+
tag: route.sparringOnly ? 'sparring' : null,
|
|
683
|
+
summary: route.params.length
|
|
684
|
+
? route.params.map((name) => `?${name}=`).join(' ')
|
|
685
|
+
: '',
|
|
686
|
+
status: route.status,
|
|
687
|
+
body: () => routeBody(route, data),
|
|
688
|
+
hash: route.hash,
|
|
689
|
+
}));
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
// The weigh-in's scale counts every board of its bout (endpoints and routes).
|
|
693
|
+
const scaleTotals = {};
|
|
694
|
+
function updateScale(pr) {
|
|
695
|
+
const scale = document.querySelector(`[data-ring-scale="${pr}"]`);
|
|
696
|
+
if (!scale) return;
|
|
697
|
+
const boards = Object.values(scaleTotals[pr] ?? {});
|
|
698
|
+
const approved = boards.reduce((sum, board) => sum + board.approved, 0);
|
|
699
|
+
const total = boards.reduce((sum, board) => sum + board.total, 0);
|
|
700
|
+
const what = boards.map((board) => board.noun).join(' y ');
|
|
701
|
+
const judge = scale.dataset.approved === 'true';
|
|
702
|
+
const complete = total > 0 && approved === total;
|
|
703
|
+
scale.className = `scale ${complete && judge ? 'green' : 'red'}`;
|
|
704
|
+
scale.querySelector('b').textContent =
|
|
705
|
+
complete && judge ? 'Da el peso' : 'No da el peso';
|
|
706
|
+
scale.querySelector('span').textContent = complete
|
|
707
|
+
? judge
|
|
708
|
+
? `${approved} de ${total} aprobados (${what}) y el contrato aprobado por el juez.`
|
|
709
|
+
: `${approved} de ${total} aprobados (${what}); falta que el juez apruebe el contrato (approve-contract).`
|
|
710
|
+
: `${approved} de ${total} aprobados (${what}).`;
|
|
711
|
+
ring.scales[pr] = { approved, total, green: complete && judge, judge };
|
|
712
|
+
ring.changed();
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
/** A board of items with the judge's verdict on each: filters, progress, pending list, cards. */
|
|
716
|
+
async function board(
|
|
717
|
+
container,
|
|
718
|
+
{ pr, block, noun, sourcesHash, items, before = [] },
|
|
719
|
+
) {
|
|
720
|
+
container.textContent = '';
|
|
721
|
+
const ctx = { pr };
|
|
722
|
+
const judged = items.filter((item) => item.status !== 'removed');
|
|
723
|
+
const verdicts = {};
|
|
724
|
+
let filter = 'all';
|
|
725
|
+
const progress = el(
|
|
726
|
+
'div',
|
|
727
|
+
{ class: 'ep-progress' },
|
|
728
|
+
el('b'),
|
|
729
|
+
el('span'),
|
|
730
|
+
el('div', { class: 'bar' }, el('i')),
|
|
731
|
+
);
|
|
732
|
+
const pending = el('div', { class: 'ep-pending' });
|
|
733
|
+
const filters = [
|
|
734
|
+
['all', 'Todas'],
|
|
735
|
+
['added', 'Nuevas'],
|
|
736
|
+
['changed', 'Cambian'],
|
|
737
|
+
['removed', 'Se quitan'],
|
|
738
|
+
['none', 'Sin veredicto'],
|
|
739
|
+
['approved', 'Se aprueba'],
|
|
740
|
+
['improve', 'Se mejora'],
|
|
741
|
+
['stale', 'Desactualizadas'],
|
|
742
|
+
];
|
|
743
|
+
const toolbar = el(
|
|
744
|
+
'div',
|
|
745
|
+
{ class: 'ep-filters', role: 'group', 'aria-label': `Filtrar ${noun}` },
|
|
746
|
+
filters.map(([value, label]) =>
|
|
747
|
+
el('button', {
|
|
748
|
+
type: 'button',
|
|
749
|
+
'data-filter': value,
|
|
750
|
+
'aria-pressed': value === filter ? 'true' : 'false',
|
|
751
|
+
text: label,
|
|
752
|
+
onclick: () => {
|
|
753
|
+
filter = value;
|
|
754
|
+
toolbar
|
|
755
|
+
.querySelectorAll('button')
|
|
756
|
+
.forEach((button) =>
|
|
757
|
+
button.setAttribute(
|
|
758
|
+
'aria-pressed',
|
|
759
|
+
button.dataset.filter === value ? 'true' : 'false',
|
|
760
|
+
),
|
|
761
|
+
);
|
|
762
|
+
refresh();
|
|
763
|
+
},
|
|
764
|
+
}),
|
|
765
|
+
),
|
|
766
|
+
);
|
|
767
|
+
container.append(progress, pending, ...before, toolbar);
|
|
768
|
+
const verdictOf = (item) => {
|
|
769
|
+
const verdict = verdicts[item.key];
|
|
770
|
+
if (!verdict) return 'none';
|
|
771
|
+
return verdict.hash === item.hash ? verdict.verdict : 'stale';
|
|
772
|
+
};
|
|
773
|
+
const cards = items.map((item) => {
|
|
774
|
+
const verdict = item.status === 'removed' ? null : verdictBox(item, ctx);
|
|
775
|
+
const card = el(
|
|
776
|
+
'details',
|
|
777
|
+
{ class: `op ${item.status}`, 'data-status': item.status, id: item.key },
|
|
778
|
+
el(
|
|
779
|
+
'summary',
|
|
780
|
+
{},
|
|
781
|
+
el('b', { class: `m ${item.chipClass}`, text: item.chip }),
|
|
782
|
+
el('code', { class: 'path', text: item.path }),
|
|
783
|
+
el('span', { class: 'sum', text: item.summary }),
|
|
784
|
+
item.tag ? el('span', { class: 'spec-tag', text: item.tag }) : null,
|
|
785
|
+
el('span', {
|
|
786
|
+
class: `badge ${item.status}`,
|
|
787
|
+
text: STATUS[item.status],
|
|
788
|
+
}),
|
|
789
|
+
verdict ? el('span', { class: 'vdot' }) : null,
|
|
790
|
+
),
|
|
791
|
+
item.body(),
|
|
792
|
+
verdict?.box,
|
|
793
|
+
);
|
|
794
|
+
return { item, card, verdict };
|
|
795
|
+
});
|
|
796
|
+
cards.forEach(({ card }) => container.append(card));
|
|
797
|
+
|
|
798
|
+
function refresh() {
|
|
799
|
+
const count = (value) =>
|
|
800
|
+
judged.filter((item) => verdictOf(item) === value).length;
|
|
801
|
+
const approved = count('approved');
|
|
802
|
+
const total = judged.length;
|
|
803
|
+
progress.children[0].textContent = `${approved} de ${total} ${noun} ${noun === 'rutas' ? 'aprobadas' : 'aprobados'}`;
|
|
804
|
+
progress.children[1].textContent = ` · ${count('improve')} se mejora · ${count('stale')} desactualizadas · ${count('none')} sin veredicto`;
|
|
805
|
+
progress.querySelector('i').style.width =
|
|
806
|
+
`${total ? (approved / total) * 100 : 0}%`;
|
|
807
|
+
cards.forEach(({ item, card, verdict }) => {
|
|
808
|
+
const value = item.status === 'removed' ? 'removed' : verdictOf(item);
|
|
809
|
+
card.dataset.verdict = value;
|
|
810
|
+
card.hidden = !(
|
|
811
|
+
filter === 'all' ||
|
|
812
|
+
filter === item.status ||
|
|
813
|
+
filter === value
|
|
814
|
+
);
|
|
815
|
+
if (verdict) verdict.paint(verdicts[item.key] ?? null);
|
|
816
|
+
});
|
|
817
|
+
pending.textContent = '';
|
|
818
|
+
const improving = judged.filter((item) => verdictOf(item) === 'improve');
|
|
819
|
+
if (improving.length)
|
|
820
|
+
pending.append(
|
|
821
|
+
el('h4', { text: `Pendientes del contrato · ${improving.length}` }),
|
|
822
|
+
el(
|
|
823
|
+
'ul',
|
|
824
|
+
{},
|
|
825
|
+
improving.map((item) => {
|
|
826
|
+
const verdict = verdicts[item.key];
|
|
827
|
+
return el(
|
|
828
|
+
'li',
|
|
829
|
+
{},
|
|
830
|
+
el('b', { class: `m ${item.chipClass}`, text: item.chip }),
|
|
831
|
+
' ',
|
|
832
|
+
el('code', { text: item.path }),
|
|
833
|
+
` · ${fmt(verdict.at)} — ${verdict.comment}`,
|
|
834
|
+
(verdict.attachments ?? []).map((id) =>
|
|
835
|
+
el('img', { src: `/_blob/${id}`, alt: 'Adjunto del juez' }),
|
|
836
|
+
),
|
|
837
|
+
);
|
|
838
|
+
}),
|
|
839
|
+
),
|
|
840
|
+
);
|
|
841
|
+
scaleTotals[pr] = {
|
|
842
|
+
...(scaleTotals[pr] ?? {}),
|
|
843
|
+
[block]: { approved, total, noun },
|
|
844
|
+
};
|
|
845
|
+
ring.boards.set(`${pr}-${block}`, {
|
|
846
|
+
pr,
|
|
847
|
+
block,
|
|
848
|
+
noun,
|
|
849
|
+
approved,
|
|
850
|
+
total,
|
|
851
|
+
items: judged.map((item) => ({
|
|
852
|
+
key: item.key,
|
|
853
|
+
chip: item.chip,
|
|
854
|
+
chipClass: item.chipClass,
|
|
855
|
+
path: item.path,
|
|
856
|
+
summary: item.summary,
|
|
857
|
+
status: item.status,
|
|
858
|
+
verdict: verdictOf(item),
|
|
859
|
+
save: (verdict, comment, attachments) =>
|
|
860
|
+
writeVerdict(item, pr, verdict, comment, attachments),
|
|
861
|
+
})),
|
|
862
|
+
});
|
|
863
|
+
updateScale(pr);
|
|
864
|
+
}
|
|
865
|
+
refresh();
|
|
866
|
+
|
|
867
|
+
const caps = await capabilities;
|
|
868
|
+
if (!caps.db) return;
|
|
869
|
+
const collection = caps.db.collection('veredictos');
|
|
870
|
+
const stateId = `pr${pr}-${block === 'routes' ? 'rutas' : 'contrato'}`;
|
|
871
|
+
collection.where('pr', '==', pr).onSnapshot(
|
|
872
|
+
(snap) => {
|
|
873
|
+
let state = null;
|
|
874
|
+
snap.docs.forEach((doc) => {
|
|
875
|
+
const value = doc.data();
|
|
876
|
+
if (value?.kind === 'verdict') verdicts[value.key] = value;
|
|
877
|
+
if (
|
|
878
|
+
value?.kind === 'contract-state' &&
|
|
879
|
+
(value.block ?? 'api') === block
|
|
880
|
+
)
|
|
881
|
+
state = value;
|
|
882
|
+
});
|
|
883
|
+
refresh();
|
|
884
|
+
if (!caps.canEdit || (state && state.sourcesHash === sourcesHash)) return;
|
|
885
|
+
const ops = Object.fromEntries(
|
|
886
|
+
items.map((item) => [
|
|
887
|
+
item.key,
|
|
888
|
+
{
|
|
889
|
+
method: item.method,
|
|
890
|
+
path: item.path,
|
|
891
|
+
specFile: item.specFile,
|
|
892
|
+
status: item.status,
|
|
893
|
+
hash: item.hash,
|
|
894
|
+
},
|
|
895
|
+
]),
|
|
896
|
+
);
|
|
897
|
+
collection
|
|
898
|
+
.doc(stateId)
|
|
899
|
+
.set({
|
|
900
|
+
kind: 'contract-state',
|
|
901
|
+
block,
|
|
902
|
+
pr,
|
|
903
|
+
sourcesHash,
|
|
904
|
+
ops,
|
|
905
|
+
computedAt: new Date().toISOString(),
|
|
906
|
+
})
|
|
907
|
+
.catch(() => {});
|
|
908
|
+
},
|
|
909
|
+
() => {},
|
|
910
|
+
);
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
const failed = (container, error) =>
|
|
914
|
+
container.append(
|
|
915
|
+
el('p', {
|
|
916
|
+
class: 'muted',
|
|
917
|
+
text: `No se pudo dibujar el contrato: ${error.message}`,
|
|
918
|
+
}),
|
|
919
|
+
);
|
|
920
|
+
|
|
921
|
+
document.querySelectorAll('[data-ring-spec]').forEach((container) => {
|
|
922
|
+
const source = document.getElementById(
|
|
923
|
+
`ring-spec-${container.dataset.ringSpec}`,
|
|
924
|
+
);
|
|
925
|
+
(async () => {
|
|
926
|
+
const data = JSON.parse(source.textContent);
|
|
927
|
+
const items = await specItems(data);
|
|
928
|
+
await board(container, {
|
|
929
|
+
pr: data.pr,
|
|
930
|
+
block: 'api',
|
|
931
|
+
noun: 'endpoints',
|
|
932
|
+
sourcesHash: await sha256(source.textContent),
|
|
933
|
+
items,
|
|
934
|
+
});
|
|
935
|
+
})().catch((error) => failed(container, error));
|
|
936
|
+
});
|
|
937
|
+
|
|
938
|
+
document.querySelectorAll('[data-ring-routes]').forEach((container) => {
|
|
939
|
+
const source = document.getElementById(
|
|
940
|
+
`ring-routes-${container.dataset.ringRoutes}`,
|
|
941
|
+
);
|
|
942
|
+
(async () => {
|
|
943
|
+
const data = JSON.parse(source.textContent);
|
|
944
|
+
const navigation = data.navigation.length
|
|
945
|
+
? [
|
|
946
|
+
el(
|
|
947
|
+
'div',
|
|
948
|
+
{ class: 'nav-changes' },
|
|
949
|
+
el('h4', { text: 'Navegación que cambia' }),
|
|
950
|
+
el(
|
|
951
|
+
'ul',
|
|
952
|
+
{},
|
|
953
|
+
data.navigation.map((entry) =>
|
|
954
|
+
el(
|
|
955
|
+
'li',
|
|
956
|
+
{},
|
|
957
|
+
el('code', { text: entry.path }),
|
|
958
|
+
entry.added.length
|
|
959
|
+
? ` · entra ${entry.added.join(', ')}`
|
|
960
|
+
: '',
|
|
961
|
+
entry.removed.length
|
|
962
|
+
? ` · sale ${entry.removed.join(', ')}`
|
|
963
|
+
: '',
|
|
964
|
+
entry.moved.length
|
|
965
|
+
? ` · se mueve ${entry.moved.join(', ')}`
|
|
966
|
+
: '',
|
|
967
|
+
),
|
|
968
|
+
),
|
|
969
|
+
),
|
|
970
|
+
),
|
|
971
|
+
]
|
|
972
|
+
: [];
|
|
973
|
+
await board(container, {
|
|
974
|
+
pr: data.pr,
|
|
975
|
+
block: 'routes',
|
|
976
|
+
noun: 'rutas',
|
|
977
|
+
sourcesHash: await sha256(source.textContent),
|
|
978
|
+
items: routeItems(data),
|
|
979
|
+
before: navigation,
|
|
980
|
+
});
|
|
981
|
+
})().catch((error) => failed(container, error));
|
|
982
|
+
});
|
|
983
|
+
|
|
984
|
+
// «El plan del técnico»: the points the wrestler has to do, by group, with their state, filterable,
|
|
985
|
+
// each origin linked to the card, question or element of the page that bears its id.
|
|
986
|
+
const PLAN_STATES = [
|
|
987
|
+
['pendiente', 'Pendiente'],
|
|
988
|
+
['espera al juez', 'Espera al juez'],
|
|
989
|
+
['en curso', 'En curso'],
|
|
990
|
+
['hecho', 'Hecho'],
|
|
991
|
+
];
|
|
992
|
+
/** Inline Markdown as DOM nodes: bold (which may hold code), code spans, italics; text stays text. */
|
|
993
|
+
function inlineNodes(text) {
|
|
994
|
+
const plain = (part) =>
|
|
995
|
+
part.split(/(`[^`]+`)/).flatMap((piece) => {
|
|
996
|
+
if (/^`[^`]+`$/.test(piece))
|
|
997
|
+
return [el('code', { text: piece.slice(1, -1) })];
|
|
998
|
+
return piece
|
|
999
|
+
.split(/(\*[^*\s][^*]*\*)/)
|
|
1000
|
+
.map((bit) =>
|
|
1001
|
+
/^\*[^*\s][^*]*\*$/.test(bit)
|
|
1002
|
+
? el('em', { text: bit.slice(1, -1) })
|
|
1003
|
+
: bit,
|
|
1004
|
+
);
|
|
1005
|
+
});
|
|
1006
|
+
return String(text ?? '')
|
|
1007
|
+
.split(/(\*\*.+?\*\*)/)
|
|
1008
|
+
.flatMap((part) =>
|
|
1009
|
+
/^\*\*.+\*\*$/.test(part)
|
|
1010
|
+
? [el('strong', {}, plain(part.slice(2, -2)))]
|
|
1011
|
+
: plain(part),
|
|
1012
|
+
)
|
|
1013
|
+
.filter((node) => node !== '');
|
|
1014
|
+
}
|
|
1015
|
+
const stateClass = (state) => `st-${state.replace(/\s+/g, '-')}`;
|
|
1016
|
+
function originLink(pr, id) {
|
|
1017
|
+
const question = /^(E\d+)-(\d+)$/.exec(id);
|
|
1018
|
+
const target = question ? `pr${pr}-${question[1]}-q${question[2]}` : id;
|
|
1019
|
+
return document.getElementById(target)
|
|
1020
|
+
? el('a', { href: `#${target}`, text: id })
|
|
1021
|
+
: el('span', { text: id });
|
|
1022
|
+
}
|
|
1023
|
+
function renderPlan(container, plan) {
|
|
1024
|
+
container.textContent = '';
|
|
1025
|
+
let filter = 'all';
|
|
1026
|
+
const items = plan.groups.flatMap((group) => group.items);
|
|
1027
|
+
const toolbar = el(
|
|
1028
|
+
'div',
|
|
1029
|
+
{ class: 'ep-filters', role: 'group', 'aria-label': 'Filtrar el plan' },
|
|
1030
|
+
[['all', 'Todos'], ...PLAN_STATES].map(([value, label]) =>
|
|
1031
|
+
el('button', {
|
|
1032
|
+
type: 'button',
|
|
1033
|
+
'data-filter': value,
|
|
1034
|
+
'aria-pressed': value === 'all' ? 'true' : 'false',
|
|
1035
|
+
text: `${label} · ${value === 'all' ? items.length : items.filter((item) => item.state === value).length}`,
|
|
1036
|
+
onclick: () => {
|
|
1037
|
+
filter = value;
|
|
1038
|
+
toolbar
|
|
1039
|
+
.querySelectorAll('button')
|
|
1040
|
+
.forEach((button) =>
|
|
1041
|
+
button.setAttribute(
|
|
1042
|
+
'aria-pressed',
|
|
1043
|
+
button.dataset.filter === value ? 'true' : 'false',
|
|
1044
|
+
),
|
|
1045
|
+
);
|
|
1046
|
+
container
|
|
1047
|
+
.querySelectorAll('li.plan-item')
|
|
1048
|
+
.forEach(
|
|
1049
|
+
(row) =>
|
|
1050
|
+
(row.hidden = !(
|
|
1051
|
+
filter === 'all' || row.dataset.state === filter
|
|
1052
|
+
)),
|
|
1053
|
+
);
|
|
1054
|
+
container
|
|
1055
|
+
.querySelectorAll('section.plan-group')
|
|
1056
|
+
.forEach(
|
|
1057
|
+
(section) =>
|
|
1058
|
+
(section.hidden = ![
|
|
1059
|
+
...section.querySelectorAll('li.plan-item'),
|
|
1060
|
+
].some((row) => !row.hidden)),
|
|
1061
|
+
);
|
|
1062
|
+
},
|
|
1063
|
+
}),
|
|
1064
|
+
),
|
|
1065
|
+
);
|
|
1066
|
+
container.append(toolbar);
|
|
1067
|
+
plan.groups.forEach((group) => {
|
|
1068
|
+
container.append(
|
|
1069
|
+
el(
|
|
1070
|
+
'section',
|
|
1071
|
+
{ class: 'plan-group' },
|
|
1072
|
+
el(
|
|
1073
|
+
'div',
|
|
1074
|
+
{ class: 'ep-progress' },
|
|
1075
|
+
el('b', { text: group.name }),
|
|
1076
|
+
el('span', { text: ` · ${group.done} de ${group.total} hechos` }),
|
|
1077
|
+
el(
|
|
1078
|
+
'div',
|
|
1079
|
+
{ class: 'bar' },
|
|
1080
|
+
el('i', {
|
|
1081
|
+
style: `width:${group.total ? (group.done / group.total) * 100 : 0}%`,
|
|
1082
|
+
}),
|
|
1083
|
+
),
|
|
1084
|
+
),
|
|
1085
|
+
el(
|
|
1086
|
+
'ul',
|
|
1087
|
+
{ class: 'plan-items' },
|
|
1088
|
+
group.items.map((item) =>
|
|
1089
|
+
el(
|
|
1090
|
+
'li',
|
|
1091
|
+
{ class: 'plan-item', 'data-state': item.state },
|
|
1092
|
+
el('span', {
|
|
1093
|
+
class: `st ${stateClass(item.state)}`,
|
|
1094
|
+
text: item.state,
|
|
1095
|
+
}),
|
|
1096
|
+
el('b', {}, `${item.n}. `, inlineNodes(item.title)),
|
|
1097
|
+
item.detail ? [' ', ...inlineNodes(item.detail)] : '',
|
|
1098
|
+
item.origins.length
|
|
1099
|
+
? el(
|
|
1100
|
+
'span',
|
|
1101
|
+
{ class: 'origins' },
|
|
1102
|
+
' · origen: ',
|
|
1103
|
+
item.origins.flatMap((id, index) => [
|
|
1104
|
+
index ? ', ' : '',
|
|
1105
|
+
originLink(plan.pr, id),
|
|
1106
|
+
]),
|
|
1107
|
+
)
|
|
1108
|
+
: null,
|
|
1109
|
+
item.since
|
|
1110
|
+
? el('span', {
|
|
1111
|
+
class: 'since',
|
|
1112
|
+
text: ` · ${fmt(item.since)}${item.ref ? ` · ${item.ref}` : ''}`,
|
|
1113
|
+
})
|
|
1114
|
+
: null,
|
|
1115
|
+
),
|
|
1116
|
+
),
|
|
1117
|
+
),
|
|
1118
|
+
),
|
|
1119
|
+
);
|
|
1120
|
+
});
|
|
1121
|
+
if (plan.tickets.length)
|
|
1122
|
+
container.append(
|
|
1123
|
+
el(
|
|
1124
|
+
'div',
|
|
1125
|
+
{ class: 'plan-tickets' },
|
|
1126
|
+
el('h4', { text: 'Fuera de esta entrega (tickets)' }),
|
|
1127
|
+
el(
|
|
1128
|
+
'ul',
|
|
1129
|
+
{},
|
|
1130
|
+
plan.tickets.map((ticket) => el('li', {}, inlineNodes(ticket))),
|
|
1131
|
+
),
|
|
1132
|
+
),
|
|
1133
|
+
);
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
document.querySelectorAll('[data-ring-plan]').forEach((container) => {
|
|
1137
|
+
try {
|
|
1138
|
+
renderPlan(
|
|
1139
|
+
container,
|
|
1140
|
+
JSON.parse(
|
|
1141
|
+
document.getElementById(`ring-plan-${container.dataset.ringPlan}`)
|
|
1142
|
+
.textContent,
|
|
1143
|
+
),
|
|
1144
|
+
);
|
|
1145
|
+
} catch (error) {
|
|
1146
|
+
container.append(
|
|
1147
|
+
el('p', {
|
|
1148
|
+
class: 'muted',
|
|
1149
|
+
text: `No se pudo dibujar el plan: ${error.message}`,
|
|
1150
|
+
}),
|
|
1151
|
+
);
|
|
1152
|
+
}
|
|
1153
|
+
});
|