@bongos/core 1.19.1008 → 1.19.1009
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/.bongos-core.json +32 -12
- package/docs/module-api-changelog.md +2 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/module-api.js +1 -1
- package/tests/fixtures/ideator-prefold/README.md +36 -0
- package/tests/fixtures/ideator-prefold/ideas.html.baseline +210 -0
- package/tests/fixtures/ideator-prefold/ideas.js.baseline +962 -0
- package/tests/ideator_one_line_path_proof.mjs +338 -0
|
@@ -0,0 +1,962 @@
|
|
|
1
|
+
// modules/hall-ui/public/ideas.js — the hall "Ideas" tab (/builders/ideas).
|
|
2
|
+
// Task 1002928 / BV1.R152 of goal 1000062 (three grades of idea).
|
|
3
|
+
//
|
|
4
|
+
// WHY THE HALL AT ALL (the owner's principle, recorded on the goal): anything
|
|
5
|
+
// fundamentally simple, that does not need the Claude terminal, should be doable
|
|
6
|
+
// inside the builders hall. Writing down a thought is exactly that — so a Full
|
|
7
|
+
// Idea can be authored and filed here without a terminal, which is this task's
|
|
8
|
+
// first done-when.
|
|
9
|
+
//
|
|
10
|
+
// THE ONE RULE THIS FILE OBEYS: IT CONTAINS NO RULES.
|
|
11
|
+
// Not the five questions, not their character floors, not the scoring
|
|
12
|
+
// arithmetic. The done-when says the meter must reflect the REAL scorer, "not a
|
|
13
|
+
// second copy of the rules", and the cheapest way to violate that is not a
|
|
14
|
+
// re-implemented formula — it is five hand-written <textarea> elements, which
|
|
15
|
+
// quietly fork the template one step before any scoring happens. So:
|
|
16
|
+
//
|
|
17
|
+
// the FORM is rendered from GET /inbox/template (the R141 declaration)
|
|
18
|
+
// the METER is filled from POST /inbox/score (the R142 scorer itself)
|
|
19
|
+
//
|
|
20
|
+
// Both are pure server calls with no DB behind them, which is what makes a
|
|
21
|
+
// debounced call per keystroke reasonable.
|
|
22
|
+
//
|
|
23
|
+
// WHAT THE METER DELIBERATELY DOES NOT SAY: whether this will PASS. The pass bar
|
|
24
|
+
// lives in the economy module with the credit it gates (and the board applies its
|
|
25
|
+
// own), and the ideas scorer is documented as bar-AGNOSTIC. Printing a threshold
|
|
26
|
+
// here would be a second copy of a DIFFERENT rule — the exact mistake this file
|
|
27
|
+
// is organised to avoid — so the gauge reports completeness and stops there.
|
|
28
|
+
//
|
|
29
|
+
// TWO MODES, ONE FORM (task 1002930 / R154). Opened plainly, the composer FILES a
|
|
30
|
+
// new Full Idea. Opened as ?develop=<id> from the spark queue below, it DEVELOPS
|
|
31
|
+
// an existing spark: the spark's own words are shown above the fields, its title
|
|
32
|
+
// becomes a read-only fact rather than an editable input (developing an idea must
|
|
33
|
+
// not quietly rename it out from under its author), and filing calls
|
|
34
|
+
// POST /inbox/:id/develop instead of POST /inbox. The five fields, the meter, the
|
|
35
|
+
// validation and the draft behaviour are identical — which is the point of reusing
|
|
36
|
+
// this surface rather than building a second one that drifts from it.
|
|
37
|
+
//
|
|
38
|
+
// DRAFTS ARE LOCAL. "A partial draft is not lost on navigation" is a done-when,
|
|
39
|
+
// and a half-written thought is not something to put in the database: it has no
|
|
40
|
+
// title yet, no grade, and nothing downstream should be able to see it. It lives
|
|
41
|
+
// in localStorage under one key, is restored on load, and is cleared only when
|
|
42
|
+
// the idea is actually filed.
|
|
43
|
+
|
|
44
|
+
(() => {
|
|
45
|
+
'use strict';
|
|
46
|
+
|
|
47
|
+
// NO baseUrl OVERRIDE — this page calls the GENERATED namespaced methods
|
|
48
|
+
// (api.inbox.*, api.me.*), whose path templates are API-RELATIVE ('/inbox/template').
|
|
49
|
+
// baseUrl:'' means "these paths are already absolute" and passes them through
|
|
50
|
+
// unchanged, which sent every request to the SITE ROOT and 404'd — this page was
|
|
51
|
+
// dead in production from the day it shipped. Letting it default resolves them
|
|
52
|
+
// against '/api/bongos/v1' (api-prefix.js VERSIONED_API_PREFIX), where the router
|
|
53
|
+
// actually mounts. A page that DOES pass baseUrl:'' must build full paths itself
|
|
54
|
+
// via api.request('GET', `${API}/...`) as work.js/copy-desk.js do — never mix the two.
|
|
55
|
+
const api = window.BongosClient.createClient({ credentials: 'same-origin', throwOnError: false });
|
|
56
|
+
const { escapeHtml, $, toast } = window.OTB;
|
|
57
|
+
|
|
58
|
+
// One key per THING BEING WRITTEN. A new idea and a spark being developed are
|
|
59
|
+
// different drafts, and sharing one key would let a half-written new idea
|
|
60
|
+
// overwrite someone's development work (or restore itself into it). Versioned by
|
|
61
|
+
// template key at save time too, so a project that changes its template (R156)
|
|
62
|
+
// cannot restore a draft shaped for the old one.
|
|
63
|
+
const developId = () => {
|
|
64
|
+
const m = /[?&]develop=(\d+)\b/.exec(location.search);
|
|
65
|
+
return m ? m[1] : null;
|
|
66
|
+
};
|
|
67
|
+
const DEVELOP_ID = developId();
|
|
68
|
+
const DRAFT_KEY = DEVELOP_ID ? `hall-full-idea-draft:develop:${DEVELOP_ID}` : 'hall-full-idea-draft';
|
|
69
|
+
const SCORE_DEBOUNCE_MS = 300;
|
|
70
|
+
const DRAFT_DEBOUNCE_MS = 500;
|
|
71
|
+
|
|
72
|
+
let template = null; // { key, label, fields: [{key,label,required,substance_min}] }
|
|
73
|
+
let lastScore = null;
|
|
74
|
+
let scoreTimer = null;
|
|
75
|
+
let draftTimer = null;
|
|
76
|
+
let filing = false;
|
|
77
|
+
|
|
78
|
+
// QUICK FILING (task 1003069 / R08, goal 1000071 "Idea routing"). A second,
|
|
79
|
+
// independent capture path on this page: title + kind + optional body + a
|
|
80
|
+
// goal picker fed by the R07 suggester (GET /goals/suggestions). It lives
|
|
81
|
+
// beside the Full Idea form above rather than inside it — a routed capture
|
|
82
|
+
// must never carry grade='full' (routing.js NEVER_ROUTE_GRADE), so the two
|
|
83
|
+
// cannot share one submit. Quick-mode only: hidden while developing a spark.
|
|
84
|
+
const QUICK_SUGGEST_DEBOUNCE_MS = 300;
|
|
85
|
+
let buildingVersionIds = []; // fetched once — the suggester is scoped per version
|
|
86
|
+
let allOpenGoals = []; // the picker's "browse everything" half
|
|
87
|
+
let allGoalsById = new Map(); // for naming the goal a routed capture landed in
|
|
88
|
+
let goalTouchedByUser = false; // once the builder picks, stop clobbering it with fresh suggestions
|
|
89
|
+
let quickSuggestTimer = null;
|
|
90
|
+
let quickFiling = false;
|
|
91
|
+
// GRAVITY (task 1004083 / spec R02). The landing verdict for the draft as it
|
|
92
|
+
// currently stands, read from POST /inbox/preview-landing. A monotonic token
|
|
93
|
+
// guards against the out-of-order arrival a debounced-but-concurrent read
|
|
94
|
+
// invites: two previews in flight can land in either order, and the older one
|
|
95
|
+
// winning would leave the line describing a draft that no longer exists.
|
|
96
|
+
let gravitySeq = 0;
|
|
97
|
+
|
|
98
|
+
// ---------------------------------------------------------------------------
|
|
99
|
+
// draft persistence
|
|
100
|
+
// ---------------------------------------------------------------------------
|
|
101
|
+
|
|
102
|
+
// Every localStorage access is wrapped: a private window, cleared site data or
|
|
103
|
+
// a browser set to block storage all THROW on read, and losing the draft
|
|
104
|
+
// feature must never take the composer down with it (the shell.js NAV_COLLAPSE
|
|
105
|
+
// precedent, which guards the same way).
|
|
106
|
+
function readDraft() {
|
|
107
|
+
try {
|
|
108
|
+
const raw = window.localStorage.getItem(DRAFT_KEY);
|
|
109
|
+
if (!raw) return null;
|
|
110
|
+
const d = JSON.parse(raw);
|
|
111
|
+
return (d && typeof d === 'object') ? d : null;
|
|
112
|
+
} catch (_) { return null; }
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function writeDraft() {
|
|
116
|
+
if (!template) return;
|
|
117
|
+
try {
|
|
118
|
+
window.localStorage.setItem(DRAFT_KEY, JSON.stringify({
|
|
119
|
+
template_key: template.key,
|
|
120
|
+
title: titleEl().value,
|
|
121
|
+
field_values: collect(),
|
|
122
|
+
// BV2.R13: a half-written thought includes the scope its author has in
|
|
123
|
+
// mind for it; losing that on navigation is the same loss as losing a
|
|
124
|
+
// paragraph.
|
|
125
|
+
recommendation: collectRecommendation(),
|
|
126
|
+
at: new Date().toISOString(),
|
|
127
|
+
}));
|
|
128
|
+
note('Draft saved in this browser.');
|
|
129
|
+
} catch (_) {
|
|
130
|
+
// Storage refused. Say so once rather than pretending it saved — a builder
|
|
131
|
+
// who believes a draft is safe and navigates away loses the thought.
|
|
132
|
+
note('This browser is not storing drafts — keep the tab open.');
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function clearDraft() {
|
|
137
|
+
try { window.localStorage.removeItem(DRAFT_KEY); } catch (_) { /* nothing to clear */ }
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// ---------------------------------------------------------------------------
|
|
141
|
+
// filing receipts (task 1004082 / spec R01 — "the receipt persists")
|
|
142
|
+
// ---------------------------------------------------------------------------
|
|
143
|
+
|
|
144
|
+
// WHAT WAS ACTUALLY BROKEN. Not the keystroke clearing the line — nothing does
|
|
145
|
+
// that, and the task text was amended at build time to say so. The gaps were
|
|
146
|
+
// that only ONE outcome ever existed (the next filing overwrote it), and that
|
|
147
|
+
// it was DOM-only, so a reload lost it. So this is not a guard added to
|
|
148
|
+
// #fq-result; it is the record #fq-result never was.
|
|
149
|
+
//
|
|
150
|
+
// WHY LOCAL, and why that is honest here. A receipt is "what I was told when I
|
|
151
|
+
// filed", which is per-person and per-browser by nature — and the durable
|
|
152
|
+
// record of the ideas themselves already exists server-side on the profile
|
|
153
|
+
// trail. Re-reading the trail here would be a second copy of a read this page
|
|
154
|
+
// does not own (the same reasoning that keeps the scorer out of this file).
|
|
155
|
+
// Guarded exactly like the draft above: storage that throws must never take
|
|
156
|
+
// the composer down, and must never be reported as having saved.
|
|
157
|
+
const RECEIPTS_KEY = 'hall-filing-receipts';
|
|
158
|
+
const RECEIPTS_MAX = 5;
|
|
159
|
+
let receiptsStorageRefused = false;
|
|
160
|
+
|
|
161
|
+
function readReceipts() {
|
|
162
|
+
try {
|
|
163
|
+
const raw = window.localStorage.getItem(RECEIPTS_KEY);
|
|
164
|
+
if (!raw) return [];
|
|
165
|
+
const list = JSON.parse(raw);
|
|
166
|
+
if (!Array.isArray(list)) return [];
|
|
167
|
+
// Shape-check every row rather than trusting the key: a stale or
|
|
168
|
+
// hand-edited value must degrade to "no receipts", never to a thrown
|
|
169
|
+
// render that takes the filing form with it.
|
|
170
|
+
return list.filter((r) => r && typeof r === 'object' && typeof r.title === 'string')
|
|
171
|
+
.slice(0, RECEIPTS_MAX);
|
|
172
|
+
} catch (_) { return []; }
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function pushReceipt(receipt) {
|
|
176
|
+
const next = [receipt].concat(readReceipts()).slice(0, RECEIPTS_MAX);
|
|
177
|
+
try {
|
|
178
|
+
window.localStorage.setItem(RECEIPTS_KEY, JSON.stringify(next));
|
|
179
|
+
receiptsStorageRefused = false;
|
|
180
|
+
} catch (_) {
|
|
181
|
+
// Same discipline as writeDraft: say it is not being kept rather than
|
|
182
|
+
// letting someone believe a receipt will be there after a reload.
|
|
183
|
+
receiptsStorageRefused = true;
|
|
184
|
+
}
|
|
185
|
+
return next;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function receiptLine(r) {
|
|
189
|
+
const when = r.at ? `<time datetime="${escapeHtml(String(r.at))}">${escapeHtml(formatReceiptTime(r.at))}</time> · ` : '';
|
|
190
|
+
const what = `<span class="fi-receipts__what">${escapeHtml(r.title)}</span>`;
|
|
191
|
+
if (r.task_id && r.href) {
|
|
192
|
+
const goal = r.goal_name ? ` under ${escapeHtml(r.goal_name)}` : '';
|
|
193
|
+
return `${when}${what} — filed as <a href="${escapeHtml(r.href)}">task ${escapeHtml(String(r.task_id))}</a>${goal}.`;
|
|
194
|
+
}
|
|
195
|
+
return `${when}${what} — filed to the inbox.`;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function formatReceiptTime(iso) {
|
|
199
|
+
const d = new Date(iso);
|
|
200
|
+
if (Number.isNaN(d.getTime())) return '';
|
|
201
|
+
try {
|
|
202
|
+
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
|
203
|
+
} catch (_) { return ''; }
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function renderReceipts(list) {
|
|
207
|
+
const wrap = $('#fq-receipts');
|
|
208
|
+
const ol = $('#fq-receipts-list');
|
|
209
|
+
const noteEl = $('#fq-receipts-note');
|
|
210
|
+
if (!wrap || !ol) return;
|
|
211
|
+
const receipts = Array.isArray(list) ? list : readReceipts();
|
|
212
|
+
if (!receipts.length) { wrap.hidden = true; ol.innerHTML = ''; return; }
|
|
213
|
+
wrap.hidden = false;
|
|
214
|
+
ol.innerHTML = receipts
|
|
215
|
+
.map((r) => `<li class="fi-receipts__item">${receiptLine(r)}</li>`)
|
|
216
|
+
.join('');
|
|
217
|
+
if (noteEl) {
|
|
218
|
+
noteEl.hidden = !receiptsStorageRefused;
|
|
219
|
+
noteEl.textContent = receiptsStorageRefused
|
|
220
|
+
? 'This browser is not keeping receipts — they will go when the tab does.'
|
|
221
|
+
: '';
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function note(msg) {
|
|
226
|
+
const el = $('#fi-draft');
|
|
227
|
+
if (el) el.textContent = msg;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// ---------------------------------------------------------------------------
|
|
231
|
+
// the form, rendered from the template declaration
|
|
232
|
+
// ---------------------------------------------------------------------------
|
|
233
|
+
|
|
234
|
+
const titleEl = () => $('#fi-title');
|
|
235
|
+
const fieldEl = (key) => document.getElementById(`fi-f-${key}`);
|
|
236
|
+
|
|
237
|
+
function renderForm(draft) {
|
|
238
|
+
const host = $('#fi-fields');
|
|
239
|
+
host.innerHTML = template.fields.map((f) => `
|
|
240
|
+
<div class="fi-field" data-key="${escapeHtml(f.key)}">
|
|
241
|
+
<label class="fi-label" for="fi-f-${escapeHtml(f.key)}">
|
|
242
|
+
${escapeHtml(f.label)}${f.required ? '' : ' <span class="fi-hint">(optional)</span>'}
|
|
243
|
+
</label>
|
|
244
|
+
<textarea id="fi-f-${escapeHtml(f.key)}" class="fi-text" rows="4"
|
|
245
|
+
data-key="${escapeHtml(f.key)}"
|
|
246
|
+
placeholder="…"></textarea>
|
|
247
|
+
<p class="fi-fieldnote" id="fi-n-${escapeHtml(f.key)}"></p>
|
|
248
|
+
</div>
|
|
249
|
+
`).join('');
|
|
250
|
+
|
|
251
|
+
// Restore BEFORE wiring listeners, so restoring does not itself fire a save.
|
|
252
|
+
if (draft && draft.field_values) {
|
|
253
|
+
for (const f of template.fields) {
|
|
254
|
+
const v = draft.field_values[f.key];
|
|
255
|
+
if (typeof v === 'string') fieldEl(f.key).value = v;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
if (draft && typeof draft.title === 'string') titleEl().value = draft.title;
|
|
259
|
+
|
|
260
|
+
for (const f of template.fields) {
|
|
261
|
+
fieldEl(f.key).addEventListener('input', onEdit);
|
|
262
|
+
}
|
|
263
|
+
titleEl().addEventListener('input', onEdit);
|
|
264
|
+
|
|
265
|
+
renderRecommendation(draft);
|
|
266
|
+
|
|
267
|
+
$('#fi-meter').hidden = false;
|
|
268
|
+
renderMeterSkeleton();
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function collect() {
|
|
272
|
+
const out = {};
|
|
273
|
+
for (const f of template.fields) out[f.key] = fieldEl(f.key).value;
|
|
274
|
+
return out;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// ---------------------------------------------------------------------------
|
|
278
|
+
// the scope recommendation (task 1004092 / BV2.R13, spec R22)
|
|
279
|
+
// ---------------------------------------------------------------------------
|
|
280
|
+
//
|
|
281
|
+
// NOT A SIXTH TEXTAREA, and deliberately not collected into `field_values`:
|
|
282
|
+
// the five answers are prose the scorer measures, and this is a two-value
|
|
283
|
+
// choice nothing scores. It travels as its own body field, so the meter
|
|
284
|
+
// above never moves when it is picked.
|
|
285
|
+
//
|
|
286
|
+
// THE OPTIONS COME FROM THE SERVER, like the fields. The vocabulary is a
|
|
287
|
+
// database CHECK constraint; hard-coding the pair here would be the "second
|
|
288
|
+
// copy of the rules" defect this whole file is organised against, and would
|
|
289
|
+
// silently omit any value a later migration admits.
|
|
290
|
+
//
|
|
291
|
+
// REQUIRED HERE, OPTIONAL AT THE API — which is not a contradiction. The
|
|
292
|
+
// hall is where the question is actually put to a person, so filing from it
|
|
293
|
+
// without answering would be filing an unanswered question. The route stays
|
|
294
|
+
// permissive because hundreds of already-filed Full Ideas carry no
|
|
295
|
+
// recommendation and a terse CLI filing may carry none either; every reader
|
|
296
|
+
// therefore has to render the absence honestly regardless, and a required
|
|
297
|
+
// body field would 400 every already-shipped client.
|
|
298
|
+
|
|
299
|
+
function recSpec() {
|
|
300
|
+
return (template && template.recommendation) || null;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function renderRecommendation(draft) {
|
|
304
|
+
const host = $('#fi-rec');
|
|
305
|
+
if (!host) return;
|
|
306
|
+
const spec = recSpec();
|
|
307
|
+
// An instance whose template read predates this question simply has no
|
|
308
|
+
// control — the composer keeps working and files without one.
|
|
309
|
+
if (!spec || !Array.isArray(spec.options) || !spec.options.length) {
|
|
310
|
+
host.innerHTML = '';
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
const picked = draft && typeof draft.recommendation === 'string' ? draft.recommendation : '';
|
|
314
|
+
host.innerHTML = `
|
|
315
|
+
<fieldset class="fi-rec__set">
|
|
316
|
+
<legend class="fi-label">${escapeHtml(spec.label || 'What do you think this is')}</legend>
|
|
317
|
+
${spec.hint ? `<p class="fi-hint fi-rec__hint">${escapeHtml(spec.hint)}</p>` : ''}
|
|
318
|
+
${spec.options.map((o) => `
|
|
319
|
+
<label class="fi-rec__opt">
|
|
320
|
+
<input type="radio" name="fi-rec-choice" value="${escapeHtml(o.value)}"${o.value === picked ? ' checked' : ''}>
|
|
321
|
+
<span>${escapeHtml(o.label || o.value)}</span>
|
|
322
|
+
</label>`).join('')}
|
|
323
|
+
</fieldset>`;
|
|
324
|
+
// onEdit, not a bespoke handler: picking an option has to save the draft
|
|
325
|
+
// and re-evaluate the file button exactly as typing does. It cannot change
|
|
326
|
+
// the score, and scoreNow is a no-op for it beyond one wasted call — which
|
|
327
|
+
// the debounce already collapses.
|
|
328
|
+
for (const el of host.querySelectorAll('input[name="fi-rec-choice"]')) {
|
|
329
|
+
el.addEventListener('change', onEdit);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function collectRecommendation() {
|
|
334
|
+
const host = $('#fi-rec');
|
|
335
|
+
const picked = host && host.querySelector('input[name="fi-rec-choice"]:checked');
|
|
336
|
+
return picked ? picked.value : '';
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function clearRecommendation() {
|
|
340
|
+
const host = $('#fi-rec');
|
|
341
|
+
if (!host) return;
|
|
342
|
+
for (const el of host.querySelectorAll('input[name="fi-rec-choice"]')) el.checked = false;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// Whether the composer may file. Only true when the server actually offered
|
|
346
|
+
// the choice — an instance that serves no `recommendation` spec must not have
|
|
347
|
+
// its file button held hostage by a control that was never rendered.
|
|
348
|
+
function recommendationMissing() {
|
|
349
|
+
const spec = recSpec();
|
|
350
|
+
if (!spec || !Array.isArray(spec.options) || !spec.options.length) return false;
|
|
351
|
+
return !collectRecommendation();
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// ---------------------------------------------------------------------------
|
|
355
|
+
// the meter — every number in it came from the server
|
|
356
|
+
// ---------------------------------------------------------------------------
|
|
357
|
+
|
|
358
|
+
function renderMeterSkeleton() {
|
|
359
|
+
$('#fi-meter-list').innerHTML = template.fields.map((f) => `
|
|
360
|
+
<li class="fi-mrow" id="fi-m-${escapeHtml(f.key)}">
|
|
361
|
+
<span class="fi-mrow__l">${escapeHtml(f.label)}</span>
|
|
362
|
+
<span class="fi-mrow__bar"><span class="fi-mrow__fill" style="width:0%"></span></span>
|
|
363
|
+
</li>
|
|
364
|
+
`).join('');
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function renderScore(payload) {
|
|
368
|
+
lastScore = payload;
|
|
369
|
+
const score = payload && Number.isInteger(payload.score) ? payload.score : null;
|
|
370
|
+
const fill = $('#fi-gauge-fill');
|
|
371
|
+
const num = $('#fi-gauge-n');
|
|
372
|
+
fill.style.width = `${score == null ? 0 : score}%`;
|
|
373
|
+
// Three bands, and they describe the TEXT rather than a verdict: this number
|
|
374
|
+
// is not a pass mark (see the header note), so the words must not imply one.
|
|
375
|
+
fill.dataset.band = score == null ? 'none' : (score >= 80 ? 'full' : score >= 40 ? 'part' : 'thin');
|
|
376
|
+
num.textContent = score == null ? '—' : `${score}/100`;
|
|
377
|
+
|
|
378
|
+
const byKey = new Map((payload && payload.fields ? payload.fields : []).map((f) => [f.key, f]));
|
|
379
|
+
for (const f of template.fields) {
|
|
380
|
+
const row = document.getElementById(`fi-m-${f.key}`);
|
|
381
|
+
const s = byKey.get(f.key);
|
|
382
|
+
const pct = s ? Math.round(s.ratio * 100) : 0;
|
|
383
|
+
if (row) row.querySelector('.fi-mrow__fill').style.width = `${pct}%`;
|
|
384
|
+
// Per-field prose under the textarea: how much more text this answer wants
|
|
385
|
+
// before it stops costing the total. `floor` and `substance` are the
|
|
386
|
+
// server's numbers — this only subtracts them.
|
|
387
|
+
const n = document.getElementById(`fi-n-${f.key}`);
|
|
388
|
+
if (!n) continue;
|
|
389
|
+
if (!s || s.substance === 0) { n.textContent = ''; continue; }
|
|
390
|
+
const missing = Math.max(0, s.floor - s.substance);
|
|
391
|
+
n.textContent = missing > 0
|
|
392
|
+
? `about ${missing} more characters before this answer counts in full`
|
|
393
|
+
: 'counts in full';
|
|
394
|
+
n.dataset.ok = missing > 0 ? '0' : '1';
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// The file button turns on when every REQUIRED field has something in it —
|
|
398
|
+
// capture validation's actual rule (R143 refuses a missing required field and
|
|
399
|
+
// does NOT enforce the substance floor, because a thin answer is a scoring
|
|
400
|
+
// problem, not a capture error). Matching that exactly means the button never
|
|
401
|
+
// promises an outcome the server will refuse, and never blocks one it accepts.
|
|
402
|
+
//
|
|
403
|
+
// THE SCOPE RECOMMENDATION IS THE ONE PLACE THIS BUTTON IS STRICTER THAN
|
|
404
|
+
// THE ROUTE (BV2.R13). The route accepts a Full Idea without one, because
|
|
405
|
+
// the historical rows have none; the hall does not, because the hall is
|
|
406
|
+
// where the question was actually asked. See renderRecommendation.
|
|
407
|
+
const missingRequired = template.fields.some((f) => f.required && !fieldEl(f.key).value.trim())
|
|
408
|
+
|| recommendationMissing();
|
|
409
|
+
// In develop mode the title is the existing idea's and is not editable, so it
|
|
410
|
+
// cannot be a reason to keep the button off.
|
|
411
|
+
$('#fi-submit').disabled = filing || missingRequired || (!DEVELOP_ID && !titleEl().value.trim());
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
async function scoreNow() {
|
|
415
|
+
const body = { field_values: collect() };
|
|
416
|
+
if (template.key) body.template_key = template.key;
|
|
417
|
+
const r = await api.inbox.postInboxScore({ body });
|
|
418
|
+
// A scoring hiccup must not stop someone writing. The gauge simply stops
|
|
419
|
+
// updating; the composer keeps working and filing still validates server-side.
|
|
420
|
+
if (!r.ok) { note('Could not reach the scorer just now — you can still file.'); return; }
|
|
421
|
+
renderScore(r.data);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function onEdit() {
|
|
425
|
+
clearTimeout(scoreTimer);
|
|
426
|
+
clearTimeout(draftTimer);
|
|
427
|
+
scoreTimer = setTimeout(() => { scoreNow(); }, SCORE_DEBOUNCE_MS);
|
|
428
|
+
draftTimer = setTimeout(writeDraft, DRAFT_DEBOUNCE_MS);
|
|
429
|
+
// Re-evaluate the button on the keystroke rather than waiting for the score
|
|
430
|
+
// round-trip: it depends only on what is typed.
|
|
431
|
+
if (lastScore) renderScore(lastScore);
|
|
432
|
+
else $('#fi-submit').disabled = filing
|
|
433
|
+
|| (!DEVELOP_ID && !titleEl().value.trim())
|
|
434
|
+
|| template.fields.some((f) => f.required && !fieldEl(f.key).value.trim())
|
|
435
|
+
|| recommendationMissing();
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// ---------------------------------------------------------------------------
|
|
439
|
+
// filing
|
|
440
|
+
// ---------------------------------------------------------------------------
|
|
441
|
+
|
|
442
|
+
function showErr(msg) {
|
|
443
|
+
const el = $('#fi-err');
|
|
444
|
+
el.textContent = msg || '';
|
|
445
|
+
el.dataset.visible = msg ? '1' : '0';
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// The server names the field it refused (R143's done-when). Render that rather
|
|
449
|
+
// than a generic failure, and point at the field so it can be found.
|
|
450
|
+
function explainFiling(status, data) {
|
|
451
|
+
const err = data && data.error;
|
|
452
|
+
const code = err && typeof err === 'object' ? err.code : err;
|
|
453
|
+
const details = (err && typeof err === 'object' && err.details) || {};
|
|
454
|
+
if (code === 'invalid_field_values') {
|
|
455
|
+
const errors = Array.isArray(details) ? details : (details.errors || []);
|
|
456
|
+
const labelOf = (k) => {
|
|
457
|
+
const f = template.fields.find((x) => x.key === k);
|
|
458
|
+
return f ? f.label : (k || 'the idea');
|
|
459
|
+
};
|
|
460
|
+
return errors.length
|
|
461
|
+
? `Not filed — ${errors.map((e) => `${labelOf(e.field)}: ${String(e.reason || '').replace(/_/g, ' ')}`).join('; ')}`
|
|
462
|
+
: 'Not filed — the five answers were refused.';
|
|
463
|
+
}
|
|
464
|
+
if (code === 'validation_failed') return 'Not filed — the title is required and must be under 200 characters.';
|
|
465
|
+
if (status === 401 || status === 403) return 'Not filed — your session may have expired. Reload and try again.';
|
|
466
|
+
return `Not filed (${status}). Nothing was saved; your draft is still here.`;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
async function file(ev) {
|
|
470
|
+
ev.preventDefault();
|
|
471
|
+
if (filing) return;
|
|
472
|
+
filing = true;
|
|
473
|
+
showErr('');
|
|
474
|
+
$('#fi-submit').disabled = true;
|
|
475
|
+
|
|
476
|
+
// The one behavioural fork between the two modes. Developing is an act on an
|
|
477
|
+
// EXISTING idea, so it carries no title and no grade: the row already has
|
|
478
|
+
// both, and R146 preserves them along with its author.
|
|
479
|
+
// BV2.R13: the recommendation travels on BOTH paths, and only when one was
|
|
480
|
+
// actually picked — sending '' would be asserting an opinion nobody gave.
|
|
481
|
+
const rec = collectRecommendation();
|
|
482
|
+
const r = DEVELOP_ID
|
|
483
|
+
? await api.inbox.postInboxIdDevelop({
|
|
484
|
+
id: DEVELOP_ID,
|
|
485
|
+
body: { field_values: collect(), template_key: template.key, ...(rec ? { recommendation: rec } : {}) },
|
|
486
|
+
})
|
|
487
|
+
: await api.inbox.postInbox({
|
|
488
|
+
body: {
|
|
489
|
+
title: titleEl().value.trim(),
|
|
490
|
+
grade: 'full',
|
|
491
|
+
template_key: template.key,
|
|
492
|
+
field_values: collect(),
|
|
493
|
+
...(rec ? { recommendation: rec } : {}),
|
|
494
|
+
},
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
filing = false;
|
|
498
|
+
if (!r.ok) {
|
|
499
|
+
showErr(explainFiling(r.status, r.data));
|
|
500
|
+
renderScore(lastScore);
|
|
501
|
+
return;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// Filed. NOW the draft goes — not before, so a failed POST never costs the text.
|
|
505
|
+
clearDraft();
|
|
506
|
+
const idea = r.data && r.data.idea;
|
|
507
|
+
const score = idea && Number.isInteger(idea.completeness_score) ? idea.completeness_score : null;
|
|
508
|
+
// The capture path is fail-open on grading (R143): the idea lands even if the
|
|
509
|
+
// grade write did not, and says so. Repeat that here instead of claiming a
|
|
510
|
+
// Full Idea was stored when the response says otherwise.
|
|
511
|
+
const landedFull = idea && idea.grade === 'full';
|
|
512
|
+
if (DEVELOP_ID) {
|
|
513
|
+
// The split is the developer's business: they earned a share of it and the
|
|
514
|
+
// author kept theirs, and saying so is how the handoff stops feeling like
|
|
515
|
+
// giving work away (R147 / ADR 0185).
|
|
516
|
+
const credited = r.data && r.data.idea && r.data.idea.ideator_credit;
|
|
517
|
+
toast(`Developed${score == null ? '' : ` — ${score}/100`}${credited ? `. ${credited} credits split with its author.` : '.'}`);
|
|
518
|
+
} else {
|
|
519
|
+
toast(landedFull
|
|
520
|
+
? `Filed as a Full Idea${score == null ? '' : ` — ${score}/100`}.`
|
|
521
|
+
: 'Filed, but stored as a quick idea — the grade could not be written.');
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
if (!DEVELOP_ID) titleEl().value = '';
|
|
525
|
+
for (const f of template.fields) fieldEl(f.key).value = '';
|
|
526
|
+
clearRecommendation();
|
|
527
|
+
lastScore = null;
|
|
528
|
+
renderScore(null);
|
|
529
|
+
note('');
|
|
530
|
+
if (idea && idea.id) {
|
|
531
|
+
// The detail PAGE (task 1002929 / R153), not the #/idea/N overlay: the
|
|
532
|
+
// thing just filed has five prose answers, which is what the page is for.
|
|
533
|
+
const base = location.hostname.startsWith('builders.') ? '' : '/builders';
|
|
534
|
+
$('#fi-sub').innerHTML = `Filed <a href="${base}/idea/${encodeURIComponent(idea.id)}">idea ${escapeHtml(String(idea.id))}</a>. Write another below.`;
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
function discard() {
|
|
539
|
+
if (!template) return;
|
|
540
|
+
clearDraft();
|
|
541
|
+
titleEl().value = '';
|
|
542
|
+
for (const f of template.fields) fieldEl(f.key).value = '';
|
|
543
|
+
clearRecommendation();
|
|
544
|
+
lastScore = null;
|
|
545
|
+
renderScore(null);
|
|
546
|
+
showErr('');
|
|
547
|
+
note('Draft discarded.');
|
|
548
|
+
toast('Draft discarded.');
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
// ---------------------------------------------------------------------------
|
|
552
|
+
// quick filing — kind + goal picker (task 1003069 / R08)
|
|
553
|
+
// ---------------------------------------------------------------------------
|
|
554
|
+
|
|
555
|
+
const fqTitleEl = () => $('#fq-title');
|
|
556
|
+
const fqKindEl = () => $('#fq-kind');
|
|
557
|
+
const fqBodyEl = () => $('#fq-body');
|
|
558
|
+
const fqGoalEl = () => $('#fq-goal');
|
|
559
|
+
|
|
560
|
+
// The picker's options: "no goal" first (the default — a filer never has to
|
|
561
|
+
// know the goal map, C5), then whatever the suggester found, then every open
|
|
562
|
+
// goal it didn't. Suggested ids are excluded from the second list rather than
|
|
563
|
+
// shown twice.
|
|
564
|
+
function renderGoalOptions(suggested) {
|
|
565
|
+
const sel = fqGoalEl();
|
|
566
|
+
const suggestedIds = new Set((suggested || []).map((g) => String(g.id)));
|
|
567
|
+
const rest = allOpenGoals.filter((g) => !suggestedIds.has(String(g.id)));
|
|
568
|
+
const optionHtml = (g, why) => `<option value="${escapeHtml(String(g.id))}">${escapeHtml(g.title || `goal ${g.id}`)}${why ? ` — matched: ${escapeHtml(why)}` : ''}</option>`;
|
|
569
|
+
let html = `<option value="" selected>No goal (homeless)</option>`;
|
|
570
|
+
if (suggested && suggested.length) {
|
|
571
|
+
html += `<optgroup label="Suggested">${suggested.map((g) => optionHtml(g, (g.matched_terms || []).join(', '))).join('')}</optgroup>`;
|
|
572
|
+
}
|
|
573
|
+
html += `<optgroup label="All open goals">${rest.map((g) => optionHtml(g)).join('')}</optgroup>`;
|
|
574
|
+
sel.innerHTML = html;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
// The suggester is ranked per version (GET /goals/suggestions requires one),
|
|
578
|
+
// so a filer whose instance has more than one version building gets both
|
|
579
|
+
// consulted and merged by score. An outage — or no building version at all —
|
|
580
|
+
// degrades to "no suggestions"; the homeless option and the full goal list
|
|
581
|
+
// are still there either way (goal-suggest.js's own fail-open contract).
|
|
582
|
+
async function refreshGoalSuggestions() {
|
|
583
|
+
if (goalTouchedByUser) return; // never yank a manual pick out from under someone
|
|
584
|
+
const title = fqTitleEl().value.trim();
|
|
585
|
+
if (!title || !buildingVersionIds.length) { renderGoalOptions([]); return; }
|
|
586
|
+
const kind = fqKindEl().value;
|
|
587
|
+
try {
|
|
588
|
+
const results = await Promise.all(buildingVersionIds.map((versionId) => api.goals.getGoalsSuggestions({
|
|
589
|
+
query: { version: versionId, title, kind, limit: 5 },
|
|
590
|
+
})));
|
|
591
|
+
const byId = new Map();
|
|
592
|
+
for (const r of results) {
|
|
593
|
+
if (!r.ok || !r.data || !Array.isArray(r.data.suggestions)) continue;
|
|
594
|
+
for (const g of r.data.suggestions) {
|
|
595
|
+
const prev = byId.get(String(g.id));
|
|
596
|
+
if (!prev || g.score > prev.score) byId.set(String(g.id), g);
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
const merged = Array.from(byId.values()).sort((a, b) => b.score - a.score).slice(0, 5);
|
|
600
|
+
renderGoalOptions(merged);
|
|
601
|
+
} catch (_) {
|
|
602
|
+
renderGoalOptions([]);
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
// ---------------------------------------------------------------------------
|
|
607
|
+
// gravity — which goal will catch this thought, said before it is released
|
|
608
|
+
// (task 1004083 / spec R02)
|
|
609
|
+
// ---------------------------------------------------------------------------
|
|
610
|
+
|
|
611
|
+
const fqGravityEl = () => $('#fq-gravity');
|
|
612
|
+
|
|
613
|
+
// THE SENTENCES, AND NOTHING ELSE. Every branch below READS a field of the
|
|
614
|
+
// server's verdict; not one of them decides a landing. That split is the whole
|
|
615
|
+
// point of the endpoint: modules/ideas/routing.js `decideLanding` stays the one
|
|
616
|
+
// place the matrix lives, and a second copy of it in this file is precisely the
|
|
617
|
+
// failure the #fq-kind comment above already records ("this markup used to
|
|
618
|
+
// spell the whole vocabulary and had gone stale ... a third copy nobody was
|
|
619
|
+
// watching"). If a sentence here ever needs an `if` over kind or rank, the
|
|
620
|
+
// verdict is missing a field and the fix belongs on the server.
|
|
621
|
+
//
|
|
622
|
+
// Returns null for "say nothing" — an unscorable draft, or a read that failed.
|
|
623
|
+
// Silence is the honest degraded state: it leaves the filer exactly where they
|
|
624
|
+
// were before this feature, rather than replacing a quiet lie with a loud one.
|
|
625
|
+
function gravitySentence(v) {
|
|
626
|
+
if (!v || !v.outcome) return null;
|
|
627
|
+
const goalName = v.goal ? (v.goal.title || `goal ${v.goal.id}`) : null;
|
|
628
|
+
const claimable = v.status === 'ready';
|
|
629
|
+
|
|
630
|
+
if (v.outcome === 'refused') {
|
|
631
|
+
// The protected-scope wall. The server wrote the reason for a human; it is
|
|
632
|
+
// the one branch that already reads as a sentence, so it is passed through.
|
|
633
|
+
return { tone: 'refused', text: v.refusal && v.refusal.reason ? v.refusal.reason : `${goalName} will not take this filing.` };
|
|
634
|
+
}
|
|
635
|
+
if (v.outcome === 'routed') {
|
|
636
|
+
return {
|
|
637
|
+
tone: 'lands',
|
|
638
|
+
text: claimable
|
|
639
|
+
? `This lands in ${goalName} as a task anyone can claim.`
|
|
640
|
+
: `This lands in ${goalName}, waiting for a nod before anyone can claim it.`,
|
|
641
|
+
};
|
|
642
|
+
}
|
|
643
|
+
if (v.outcome === 'routed_home') {
|
|
644
|
+
// The label on the picker says "homeless"; since ADR 0262 that is not true
|
|
645
|
+
// of a bug, and this line is the correction. Naming the version rather than
|
|
646
|
+
// the goal is deliberate — the maintenance goal is created by the capture,
|
|
647
|
+
// so the preview has no row to name without making one (landing-preview.js).
|
|
648
|
+
return {
|
|
649
|
+
tone: 'lands',
|
|
650
|
+
text: claimable
|
|
651
|
+
? `A bug always lands. With no goal picked this goes to ${v.version_id}'s maintenance goal, claimable straight away.`
|
|
652
|
+
: `A bug always lands. With no goal picked this goes to ${v.version_id}'s maintenance goal, waiting for a nod.`,
|
|
653
|
+
};
|
|
654
|
+
}
|
|
655
|
+
// outcome === 'floats' — the inbox. Two ways to get here, and they are
|
|
656
|
+
// different facts: nobody named a goal, or a goal was named and the matrix
|
|
657
|
+
// declined it. The second is the one the picker currently lies about.
|
|
658
|
+
if (v.reason === 'judgment_kind') {
|
|
659
|
+
return { tone: 'floats', text: `${goalName} will not catch this — this kind is judged, not queued. It waits in the inbox for triage.` };
|
|
660
|
+
}
|
|
661
|
+
if (v.reason === 'full_idea_never_routes') {
|
|
662
|
+
return { tone: 'floats', text: 'A Full Idea is ratified by the board rather than routed. It waits in the inbox.' };
|
|
663
|
+
}
|
|
664
|
+
return { tone: 'floats', text: 'This floats in the inbox until someone triages it.' };
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
function renderGravity(v) {
|
|
668
|
+
const el = fqGravityEl();
|
|
669
|
+
if (!el) return;
|
|
670
|
+
const said = gravitySentence(v);
|
|
671
|
+
// dataset, not setAttribute: it writes the same data-tone the sheet selects
|
|
672
|
+
// on, and it is the half of the element a DOM the page is rendered into and
|
|
673
|
+
// a test stub both really carry.
|
|
674
|
+
if (!said) { el.hidden = true; el.textContent = ''; el.dataset.tone = ''; return; }
|
|
675
|
+
el.textContent = said.text;
|
|
676
|
+
el.dataset.tone = said.tone;
|
|
677
|
+
el.hidden = false;
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
// The read. Shares the suggester's debounce rather than adding a second clock:
|
|
681
|
+
// the two answer the same question at the same moment ("given what is typed so
|
|
682
|
+
// far, where does this go?"), and spec decision 12 — filing never gets slower —
|
|
683
|
+
// is a reason not to grow the number of things a keystroke sets running.
|
|
684
|
+
//
|
|
685
|
+
// A draft with no title is not previewed: it cannot be filed either, so there
|
|
686
|
+
// is no outcome to state.
|
|
687
|
+
async function refreshGravity() {
|
|
688
|
+
const seq = ++gravitySeq;
|
|
689
|
+
const title = fqTitleEl().value.trim();
|
|
690
|
+
if (!title) { renderGravity(null); return; }
|
|
691
|
+
const goalId = fqGoalEl().value;
|
|
692
|
+
const body = { kind: fqKindEl().value };
|
|
693
|
+
if (goalId) body.goal_id = Number(goalId);
|
|
694
|
+
let r;
|
|
695
|
+
try {
|
|
696
|
+
r = await api.inbox.postInboxPreviewLanding({ body });
|
|
697
|
+
} catch (_) {
|
|
698
|
+
r = null;
|
|
699
|
+
}
|
|
700
|
+
// A stale answer never paints. See gravitySeq above.
|
|
701
|
+
if (seq !== gravitySeq) return;
|
|
702
|
+
renderGravity(r && r.ok ? r.data : null);
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
function onQuickTitleEdit() {
|
|
706
|
+
clearTimeout(quickSuggestTimer);
|
|
707
|
+
quickSuggestTimer = setTimeout(() => { refreshGoalSuggestions(); refreshGravity(); }, QUICK_SUGGEST_DEBOUNCE_MS);
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
function showQuickErr(msg) {
|
|
711
|
+
$('#fq-err').textContent = msg || '';
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
// The server names why a routed capture was refused (task 1003066's landing
|
|
715
|
+
// gate) or names the refusal code when it doesn't — read it, never re-derive
|
|
716
|
+
// the landing matrix here (routing.js stays the one place that decides).
|
|
717
|
+
function explainQuickFiling(status, data) {
|
|
718
|
+
const err = data && data.error;
|
|
719
|
+
const code = err && (typeof err === 'object' ? err.code : err);
|
|
720
|
+
const details = (err && typeof err === 'object' && err.details) || {};
|
|
721
|
+
if (details.reason) return `Not filed — ${details.reason}`;
|
|
722
|
+
if (code) return `Not filed — ${code}.`;
|
|
723
|
+
if (status === 401 || status === 403) return 'Not filed — your session may have expired. Reload and try again.';
|
|
724
|
+
return `Not filed (${status}). Nothing was saved — try again.`;
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
async function fileQuick(ev) {
|
|
728
|
+
ev.preventDefault();
|
|
729
|
+
if (quickFiling) return;
|
|
730
|
+
const title = fqTitleEl().value.trim();
|
|
731
|
+
showQuickErr('');
|
|
732
|
+
$('#fq-result').innerHTML = '';
|
|
733
|
+
if (!title) { showQuickErr('Not filed — a title is required.'); return; }
|
|
734
|
+
quickFiling = true;
|
|
735
|
+
$('#fq-submit').disabled = true;
|
|
736
|
+
|
|
737
|
+
const goalId = fqGoalEl().value;
|
|
738
|
+
const body = { title, body_md: fqBodyEl().value, kind: fqKindEl().value };
|
|
739
|
+
if (goalId) body.goal_id = Number(goalId);
|
|
740
|
+
|
|
741
|
+
const r = await api.inbox.postInbox({ body });
|
|
742
|
+
quickFiling = false;
|
|
743
|
+
$('#fq-submit').disabled = false;
|
|
744
|
+
|
|
745
|
+
if (!r.ok) {
|
|
746
|
+
showQuickErr(explainQuickFiling(r.status, r.data));
|
|
747
|
+
return;
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
// The routing verdict travels on idea.routing; a routed capture also
|
|
751
|
+
// carries the task it became at the top level (routed_task) — see
|
|
752
|
+
// routes/inbox.js. Not routed (no goal picked, or the matrix declined
|
|
753
|
+
// one) is exactly today's inbox behaviour.
|
|
754
|
+
const routedTask = r.data && r.data.routed_task;
|
|
755
|
+
const routing = r.data && r.data.idea && r.data.idea.routing;
|
|
756
|
+
if (routedTask && routedTask.id && routing && routing.routed) {
|
|
757
|
+
const href = window.OTBTaskDetail && typeof window.OTBTaskDetail.href === 'function'
|
|
758
|
+
? window.OTBTaskDetail.href(routedTask.id)
|
|
759
|
+
: `/task/${encodeURIComponent(routedTask.id)}`;
|
|
760
|
+
const goal = allGoalsById.get(String(routing.goal_id));
|
|
761
|
+
const goalName = goal ? goal.title : `goal ${routing.goal_id}`;
|
|
762
|
+
$('#fq-result').innerHTML = `Filed as <a href="${href}">task ${escapeHtml(String(routedTask.id))}</a> under ${escapeHtml(goalName)}.`;
|
|
763
|
+
renderReceipts(pushReceipt({ title, task_id: routedTask.id, goal_name: goalName, href, at: new Date().toISOString() }));
|
|
764
|
+
} else {
|
|
765
|
+
$('#fq-result').textContent = 'Filed to the inbox.';
|
|
766
|
+
renderReceipts(pushReceipt({ title, task_id: null, goal_name: null, href: null, at: new Date().toISOString() }));
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
fqTitleEl().value = '';
|
|
770
|
+
fqBodyEl().value = '';
|
|
771
|
+
fqKindEl().value = 'bug';
|
|
772
|
+
goalTouchedByUser = false;
|
|
773
|
+
renderGoalOptions([]);
|
|
774
|
+
// The form is empty again, so the line has nothing to describe. The RECEIPT
|
|
775
|
+
// above is what survives the filing (task 1004082); gravity is a statement
|
|
776
|
+
// about a draft, and this draft is gone.
|
|
777
|
+
renderGravity(null);
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
async function bootQuickComposer() {
|
|
781
|
+
fqKindEl().value = 'bug';
|
|
782
|
+
renderGoalOptions([]);
|
|
783
|
+
fqTitleEl().addEventListener('input', onQuickTitleEdit);
|
|
784
|
+
fqKindEl().addEventListener('change', onQuickTitleEdit);
|
|
785
|
+
// Changing the goal is the one edit that changes gravity WITHOUT changing
|
|
786
|
+
// what the suggester would return, so it redraws the line directly rather
|
|
787
|
+
// than going through onQuickTitleEdit. Deliberately undebounced: a select is a
|
|
788
|
+
// deliberate single act, not a stream of keystrokes.
|
|
789
|
+
fqGoalEl().addEventListener('change', () => { goalTouchedByUser = true; refreshGravity(); });
|
|
790
|
+
$('#fq-form').addEventListener('submit', fileQuick);
|
|
791
|
+
|
|
792
|
+
// Before any network call: the receipts are local, so a builder who reloads
|
|
793
|
+
// sees what became of earlier thoughts even if every read below fails.
|
|
794
|
+
renderReceipts();
|
|
795
|
+
|
|
796
|
+
try {
|
|
797
|
+
const [versionsRes, goalsRes] = await Promise.all([
|
|
798
|
+
api.versions.getVersions({}),
|
|
799
|
+
api.goals.getGoals({ query: { status: 'open' } }),
|
|
800
|
+
]);
|
|
801
|
+
buildingVersionIds = (versionsRes.ok && versionsRes.data && Array.isArray(versionsRes.data.versions))
|
|
802
|
+
? versionsRes.data.versions.filter((v) => v.status === 'building').map((v) => v.id)
|
|
803
|
+
: [];
|
|
804
|
+
allOpenGoals = (goalsRes.ok && goalsRes.data && Array.isArray(goalsRes.data.goals)) ? goalsRes.data.goals : [];
|
|
805
|
+
allGoalsById = new Map(allOpenGoals.map((g) => [String(g.id), g]));
|
|
806
|
+
renderGoalOptions([]);
|
|
807
|
+
} catch (_) {
|
|
808
|
+
// The picker degrades to "homeless only" — filing itself never depends
|
|
809
|
+
// on this read succeeding.
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
// task 1003453 — the kind vocabulary, so this select is not a third hardcoded
|
|
813
|
+
// copy. Its OWN try, deliberately not folded into the Promise.all above: the
|
|
814
|
+
// goal picker is what this form is FOR and the kind list is cosmetic, so a
|
|
815
|
+
// vocabulary read must never be able to take the picker down with it. (It can:
|
|
816
|
+
// put it in that Promise.all and one rejection strands buildingVersionIds and
|
|
817
|
+
// allOpenGoals in the shared catch — two suites caught exactly that.)
|
|
818
|
+
// keepFirst 0 clears the markup's fallback and rebuilds with the served list,
|
|
819
|
+
// keeping 'bug' as the default this form has always opened on.
|
|
820
|
+
try {
|
|
821
|
+
const meRes = await api.me.getMe({});
|
|
822
|
+
window.OTB.fillKindFilter?.(fqKindEl(), meRes.ok && meRes.data && meRes.data.task_kinds, {
|
|
823
|
+
keepFirst: 0, label: window.OTB.kindLabel, selected: 'bug',
|
|
824
|
+
});
|
|
825
|
+
} catch (_) { /* the markup's Bug option stands — a report can still be filed */ }
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
// ---------------------------------------------------------------------------
|
|
829
|
+
// boot
|
|
830
|
+
// ---------------------------------------------------------------------------
|
|
831
|
+
|
|
832
|
+
// ---------------------------------------------------------------------------
|
|
833
|
+
// the spark queue (task 1002930 / R154)
|
|
834
|
+
// ---------------------------------------------------------------------------
|
|
835
|
+
|
|
836
|
+
const hallBase = () => (location.hostname.startsWith('builders.') ? '' : '/builders');
|
|
837
|
+
|
|
838
|
+
function renderQueue(sparks, count) {
|
|
839
|
+
const host = $('#fi-queue');
|
|
840
|
+
if (!sparks || sparks.length === 0) {
|
|
841
|
+
// An answer, not a blank space — and it says how one gets here, since the
|
|
842
|
+
// only way to fill this queue is for an author to offer something up.
|
|
843
|
+
host.innerHTML = '<p class="fi-queue-empty">Nothing is waiting. A spark appears here when its author offers a quick idea up for development.</p>';
|
|
844
|
+
return;
|
|
845
|
+
}
|
|
846
|
+
const total = Number.isInteger(count) ? count : sparks.length;
|
|
847
|
+
const more = total > sparks.length ? `<p class="fi-note">Showing ${sparks.length} of ${total}.</p>` : '';
|
|
848
|
+
host.innerHTML = `<div class="fi-queue">${sparks.map((sp) => {
|
|
849
|
+
const who = sp.filed_by_login ? `@${escapeHtml(sp.filed_by_login)}` : 'an unnamed builder';
|
|
850
|
+
const when = sp.captured_at ? ` · ${escapeHtml(String(sp.captured_at).slice(0, 10))}` : '';
|
|
851
|
+
return `
|
|
852
|
+
<div class="fi-spark">
|
|
853
|
+
<p class="fi-spark__title"><a href="${hallBase()}/idea/${encodeURIComponent(sp.id)}">${escapeHtml(sp.title || `Idea ${sp.id}`)}</a></p>
|
|
854
|
+
<span class="fi-spark__who">had by ${who}${when}</span>
|
|
855
|
+
<a class="fi-spark__go btn btn--ghost" href="?develop=${encodeURIComponent(sp.id)}">Develop this →</a>
|
|
856
|
+
</div>`;
|
|
857
|
+
}).join('')}</div>${more}`;
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
async function loadQueue() {
|
|
861
|
+
const r = await api.inbox.getInboxSparks({});
|
|
862
|
+
// The queue is secondary to the composer: an outage here must not stop
|
|
863
|
+
// someone writing, so it degrades to a line rather than an error state.
|
|
864
|
+
if (!r.ok || !r.data) {
|
|
865
|
+
$('#fi-queue').innerHTML = '<p class="fi-queue-empty">Could not load the queue just now.</p>';
|
|
866
|
+
return;
|
|
867
|
+
}
|
|
868
|
+
renderQueue(r.data.sparks, r.data.count);
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
// ---------------------------------------------------------------------------
|
|
872
|
+
// develop mode
|
|
873
|
+
// ---------------------------------------------------------------------------
|
|
874
|
+
|
|
875
|
+
// Show the spark's own words above the fields, and make the title a FACT rather
|
|
876
|
+
// than an input. Returns false when the idea cannot be developed, so boot can
|
|
877
|
+
// say why instead of presenting a form that will 409.
|
|
878
|
+
function enterDevelopMode(idea) {
|
|
879
|
+
if (idea.grade !== 'spark') {
|
|
880
|
+
$('#fi-sub').textContent = idea.grade === 'full'
|
|
881
|
+
? `Idea ${idea.id} has already been developed into a Full Idea.`
|
|
882
|
+
: `Idea ${idea.id} is not a Big Idea Spark — only its author can offer it up for development.`;
|
|
883
|
+
$('#fi-form').hidden = true;
|
|
884
|
+
return false;
|
|
885
|
+
}
|
|
886
|
+
$('#fi-develop').hidden = false;
|
|
887
|
+
$('#fi-develop-title').textContent = idea.title || `Idea ${idea.id}`;
|
|
888
|
+
$('#fi-develop-body').textContent = String(idea.body_md || '').trim();
|
|
889
|
+
$('#fi-develop-who').textContent = idea.filed_by_login
|
|
890
|
+
? `had by @${idea.filed_by_login}${idea.captured_at ? ` · ${String(idea.captured_at).slice(0, 10)}` : ''} — they keep the credit for the thought`
|
|
891
|
+
: 'they keep the credit for the thought';
|
|
892
|
+
// The title is the existing idea's. Shown, not editable: developing an idea
|
|
893
|
+
// must not quietly rename it out from under its author.
|
|
894
|
+
const t = titleEl();
|
|
895
|
+
t.value = idea.title || '';
|
|
896
|
+
t.readOnly = true;
|
|
897
|
+
t.tabIndex = -1;
|
|
898
|
+
$('#fi-title-label').hidden = true;
|
|
899
|
+
t.hidden = true;
|
|
900
|
+
$('#fi-submit').textContent = 'Develop this idea';
|
|
901
|
+
$('#fi-sub').textContent = 'Answer the five questions and this spark becomes a Full Idea.';
|
|
902
|
+
return true;
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
async function main() {
|
|
906
|
+
// THE QUEUE IS LOADED FIRST, AND UNCONDITIONALLY. The composer and the spark
|
|
907
|
+
// queue are independent surfaces that happen to share a page: the queue needs
|
|
908
|
+
// no template. Loading it after the template check meant one failed fetch took
|
|
909
|
+
// BOTH down — the reported symptom was "Could not load the idea template" AND a
|
|
910
|
+
// queue stuck on "Loading…" forever, which read as two bugs and was one.
|
|
911
|
+
// A surface that does not depend on the failure must not be hostage to it.
|
|
912
|
+
loadQueue();
|
|
913
|
+
|
|
914
|
+
// Quick filing (R08) is a THIRD independent surface on this page, offered
|
|
915
|
+
// only outside develop mode — developing a spark always produces a Full
|
|
916
|
+
// Idea, never a fresh quick report (see the section's own header comment).
|
|
917
|
+
if (DEVELOP_ID) {
|
|
918
|
+
const scroll = $('#fq-compose-scroll');
|
|
919
|
+
if (scroll) scroll.hidden = true;
|
|
920
|
+
} else {
|
|
921
|
+
bootQuickComposer();
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
const r = await api.inbox.getInboxTemplate({});
|
|
925
|
+
if (!r.ok || !r.data || !r.data.template) {
|
|
926
|
+
$('#fi-sub').textContent = 'Could not load the idea template. Reload, or file from the terminal with capture.js.';
|
|
927
|
+
return;
|
|
928
|
+
}
|
|
929
|
+
template = r.data.template;
|
|
930
|
+
|
|
931
|
+
const draft = readDraft();
|
|
932
|
+
// A draft written against a different template is not restorable — the keys
|
|
933
|
+
// may not exist any more. Drop it rather than half-restoring it.
|
|
934
|
+
const usable = draft && (!draft.template_key || draft.template_key === template.key) ? draft : null;
|
|
935
|
+
|
|
936
|
+
$('#fi-sub').textContent = 'Five questions, and a score that counts how much of each you actually answered.';
|
|
937
|
+
renderForm(usable);
|
|
938
|
+
$('#fi-form').addEventListener('submit', file);
|
|
939
|
+
$('#fi-clear').addEventListener('click', discard);
|
|
940
|
+
|
|
941
|
+
if (DEVELOP_ID) {
|
|
942
|
+
const d = await api.inbox.getInboxId({ id: DEVELOP_ID });
|
|
943
|
+
if (!d.ok || !d.data || !d.data.idea) {
|
|
944
|
+
$('#fi-sub').textContent = `Could not load idea ${DEVELOP_ID}.`;
|
|
945
|
+
$('#fi-form').hidden = true;
|
|
946
|
+
return;
|
|
947
|
+
}
|
|
948
|
+
if (!enterDevelopMode(d.data.idea)) return;
|
|
949
|
+
// Re-run the button rule now that the title requirement is lifted.
|
|
950
|
+
onEdit();
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
if (usable) {
|
|
954
|
+
note('Draft restored.');
|
|
955
|
+
scoreNow();
|
|
956
|
+
} else {
|
|
957
|
+
renderScore(null);
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
main();
|
|
962
|
+
})();
|