@bongos/core 1.19.299 → 1.19.300

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,203 @@
1
+ // modules/hall-ui/public/idea.js — the idea-detail PAGE (/idea/:id).
2
+ // Task 1002929 / BV1.R153 of goal 1000062 (three grades of idea).
3
+ //
4
+ // A real page rather than a wider modal, following task 751: that task replaced
5
+ // the #/task/N overlay with /task/:id so a record got a shareable URL and working
6
+ // back/forward. A Full Idea is five prose answers — the thing an overlay is worst
7
+ // at — so it earns the same treatment.
8
+ //
9
+ // THE OVERLAY IS NOT REPLACED. task-detail.js keeps owning #/idea/N, because ~320
10
+ // of its ~650 lines are the Metic+ triage panel (promote → POST /tasks, merge,
11
+ // discard, with goal hints and version pickers). Moving that machinery is its own
12
+ // task; breaking a shipped triage flow to make a read prettier would be a bad
13
+ // trade. So the overlay stays the fast peek and links here, and this page stays a
14
+ // READ — the summary→detail relationship the hall already has between the Work
15
+ // board and /task/:id.
16
+ //
17
+ // WHAT THE GRADE DECIDES. The grade is not decoration on this page, it selects
18
+ // what the page contains:
19
+ // full → the five answers, each under its own question, plus the score
20
+ // spark → the body, plus "someone is invited to develop this"
21
+ // quick → the body, and NOTHING about the five fields
22
+ // The done-when names that last case explicitly: a quick idea must render with no
23
+ // empty five-field skeleton. Five headings over five dashes is not an empty
24
+ // state, it is a page telling a builder they did something wrong by filing a
25
+ // quick idea — which is the opposite of what a quick idea is for.
26
+ //
27
+ // The five QUESTIONS come from GET /inbox/template, not from this file, for the
28
+ // same reason the composer's do (R152): the labels live in the R141 declaration,
29
+ // and a copy here would silently ignore a per-project template.
30
+
31
+ (() => {
32
+ 'use strict';
33
+
34
+ const API = '/api/gds';
35
+ const api = window.BongosClient.createClient({ baseUrl: '', credentials: 'same-origin', throwOnError: false });
36
+ const { escapeHtml, $, fmtDate } = window.OTB;
37
+
38
+ const ideaId = () => {
39
+ const m = /\/idea\/(\d+)/.exec(location.pathname);
40
+ return m ? m[1] : null;
41
+ };
42
+
43
+ const hallBase = () => (location.hostname.startsWith('builders.') ? '' : '/builders');
44
+
45
+ // ---------------------------------------------------------------------------
46
+ // pieces
47
+ // ---------------------------------------------------------------------------
48
+
49
+ // Colour AND word. A badge distinguished only by hue tells a colour-blind
50
+ // reader nothing, and this badge decides how to read the whole page.
51
+ const GRADE_WORD = { full: 'Full Idea', spark: 'Big Idea Spark', quick: 'Quick idea' };
52
+
53
+ function gradeBadge(grade) {
54
+ const g = GRADE_WORD[grade] ? grade : 'quick';
55
+ return `<span class="ig-grade ig-grade--${escapeHtml(g)}">${escapeHtml(GRADE_WORD[g])}</span>`;
56
+ }
57
+
58
+ function scoreRow(score) {
59
+ if (!Number.isInteger(score)) {
60
+ // A Full Idea with no score is a real state (R142 returns null for
61
+ // anything it cannot score, and that is stored as-is). Say so rather than
62
+ // drawing an empty bar that reads as zero.
63
+ return '<p class="ig-empty">Not scored.</p>';
64
+ }
65
+ const band = score >= 80 ? 'full' : score >= 40 ? 'part' : 'thin';
66
+ return `
67
+ <div class="ig-score">
68
+ <span class="ig-score__track"><span class="ig-score__fill" data-band="${band}" style="width:${score}%"></span></span>
69
+ <span class="ig-score__n">${score}/100 complete</span>
70
+ </div>`;
71
+ }
72
+
73
+ // Who had the thought, and who wrote it out. Two rows when they are two people
74
+ // — R146's whole point is that the author survives being developed by someone
75
+ // else, so this never collapses them into one line.
76
+ function credit(idea) {
77
+ const who = (login, id) => (login ? `@${escapeHtml(login)}` : (id ? `builder ${escapeHtml(String(id))}` : 'someone'));
78
+ const rows = [];
79
+ rows.push(`
80
+ <div class="ig-credit__row">
81
+ <span class="ig-credit__who">${who(idea.filed_by_login, idea.captured_by)}</span>
82
+ <span class="ig-credit__role">had the thought${idea.captured_at ? ` · ${escapeHtml(fmtDate(idea.captured_at))}` : ''}</span>
83
+ </div>`);
84
+ if (idea.developed_by) {
85
+ const same = String(idea.developed_by) === String(idea.captured_by);
86
+ rows.push(`
87
+ <div class="ig-credit__row">
88
+ <span class="ig-credit__who">${who(idea.developed_by_login, idea.developed_by)}</span>
89
+ <span class="ig-credit__role">${same ? 'developed it themselves' : 'developed it into a Full Idea'}${idea.developed_at ? ` · ${escapeHtml(fmtDate(idea.developed_at))}` : ''}</span>
90
+ </div>`);
91
+ }
92
+ return `<div class="ig-credit">${rows.join('')}</div>`;
93
+ }
94
+
95
+ function prose(text) {
96
+ const s = String(text == null ? '' : text).trim();
97
+ return s
98
+ ? `<p class="ig-body">${escapeHtml(s)}</p>`
99
+ : '<p class="ig-body" data-empty="1">No description.</p>';
100
+ }
101
+
102
+ // The five answers, in TEMPLATE order, each under its own question. Missing
103
+ // answers are marked rather than hidden: on a Full Idea a blank answer is
104
+ // information (it is why the score is what it is).
105
+ function answers(idea, template) {
106
+ const values = (idea.field_values && typeof idea.field_values === 'object') ? idea.field_values : {};
107
+ return `<div class="ig-answers">${template.fields.map((f) => {
108
+ const v = String(values[f.key] == null ? '' : values[f.key]).trim();
109
+ return `
110
+ <div class="ig-answer">
111
+ <p class="ig-answer__q">${escapeHtml(f.label)}</p>
112
+ <p class="ig-answer__a"${v ? '' : ' data-empty="1"'}>${v ? escapeHtml(v) : 'Not answered.'}</p>
113
+ </div>`;
114
+ }).join('')}</div>`;
115
+ }
116
+
117
+ function statusLine(idea) {
118
+ const bits = [];
119
+ bits.push(`<span><strong>${escapeHtml(idea.status || 'open')}</strong></span>`);
120
+ if (idea.kind) bits.push(`<span>${escapeHtml(idea.kind)}</span>`);
121
+ if (idea.promoted_to_task_id) {
122
+ bits.push(`<span>became <a href="${hallBase()}/task/${encodeURIComponent(idea.promoted_to_task_id)}">task ${escapeHtml(String(idea.promoted_to_task_id))}</a></span>`);
123
+ }
124
+ if (idea.merged_into_idea_id) {
125
+ bits.push(`<span>merged into <a href="${hallBase()}/idea/${encodeURIComponent(idea.merged_into_idea_id)}">idea ${escapeHtml(String(idea.merged_into_idea_id))}</a></span>`);
126
+ }
127
+ return `<p class="ig-meta">${bits.join('')}</p>`;
128
+ }
129
+
130
+ // ---------------------------------------------------------------------------
131
+ // render
132
+ // ---------------------------------------------------------------------------
133
+
134
+ function render(idea, template) {
135
+ const grade = GRADE_WORD[idea.grade] ? idea.grade : 'quick';
136
+ const parts = [];
137
+
138
+ parts.push(`<div class="ig-head">${gradeBadge(grade)}</div>`);
139
+ parts.push(`<h1 class="ig-title" id="idea-h">${escapeHtml(idea.title || `Idea ${idea.id}`)}</h1>`);
140
+ parts.push(statusLine(idea));
141
+ parts.push(credit(idea));
142
+
143
+ if (grade === 'full') {
144
+ parts.push(scoreRow(idea.completeness_score));
145
+ if (template) {
146
+ parts.push(answers(idea, template));
147
+ } else {
148
+ // The template read failed. Show the raw answers rather than nothing —
149
+ // but WITHOUT inventing an order, since jsonb key order is not the
150
+ // template's order and pretending otherwise would misreport the idea.
151
+ parts.push('<p class="ig-empty">Could not load the question labels; showing the answers as stored.</p>');
152
+ parts.push(`<div class="ig-answers">${Object.keys(idea.field_values || {}).map((k) =>
153
+ `<div class="ig-answer"><p class="ig-answer__q">${escapeHtml(k)}</p><p class="ig-answer__a">${escapeHtml(String(idea.field_values[k] || ''))}</p></div>`
154
+ ).join('')}</div>`);
155
+ }
156
+ // A Full Idea's body_md is the original quick-idea text it grew out of, when
157
+ // it had one. Shown after the answers, subordinate to them.
158
+ if (String(idea.body_md || '').trim()) {
159
+ parts.push(`<div class="ig-sub"><h2 class="ig-sub__h">As first filed</h2>${prose(idea.body_md)}</div>`);
160
+ }
161
+ } else {
162
+ // quick AND spark: the body, and NOT a five-field skeleton.
163
+ parts.push(prose(idea.body_md));
164
+ if (grade === 'spark') {
165
+ parts.push(`<p class="ig-note">Its author marked this as needing development — anyone can turn it into a Full Idea, and they keep the credit for having had it. <a href="${hallBase()}/ideas">Write one</a>.</p>`);
166
+ } else {
167
+ parts.push('<p class="ig-note">A quick idea is a thought filed fast. It is not an unfinished Full Idea — only its author can offer it up for development.</p>');
168
+ }
169
+ }
170
+
171
+ $('#idea-body').innerHTML = parts.join('');
172
+ $('#idea-sub').textContent = `Idea ${idea.id}`;
173
+ document.title = `${idea.title || `Idea ${idea.id}`} — idea ${idea.id}`;
174
+ }
175
+
176
+ function fail(msg) {
177
+ $('#idea-body').innerHTML = `<p class="deeplink-card__title" id="idea-h">${escapeHtml(msg)}</p>`;
178
+ $('#idea-sub').textContent = '';
179
+ }
180
+
181
+ async function main() {
182
+ const id = ideaId();
183
+ if (!id) return fail('No idea id in the address.');
184
+
185
+ const r = await api.inbox.getInboxId({ id });
186
+ if (r.status === 401) { window.location.assign(`${API}/auth/web/start`); return; }
187
+ if (r.status === 404) return fail(`No idea ${id}.`);
188
+ if (!r.ok || !r.data || !r.data.idea) return fail('Could not load this idea.');
189
+ const idea = r.data.idea;
190
+
191
+ // The labels are only needed for a Full Idea, so a quick idea costs one
192
+ // request, not two — and a template outage cannot stop a quick idea rendering.
193
+ let template = null;
194
+ if (idea.grade === 'full') {
195
+ const key = idea.template_key;
196
+ const t = await api.inbox.getInboxTemplate(key ? { query: { template_key: key } } : {});
197
+ if (t.ok && t.data && t.data.template) template = t.data.template;
198
+ }
199
+ render(idea, template);
200
+ }
201
+
202
+ main();
203
+ })();
@@ -285,7 +285,10 @@
285
285
  renderScore(null);
286
286
  note('');
287
287
  if (idea && idea.id) {
288
- $('#fi-sub').innerHTML = `Filed <a href="/#/idea/${encodeURIComponent(idea.id)}">idea ${escapeHtml(String(idea.id))}</a>. Write another below.`;
288
+ // The detail PAGE (task 1002929 / R153), not the #/idea/N overlay: the
289
+ // thing just filed has five prose answers, which is what the page is for.
290
+ const base = location.hostname.startsWith('builders.') ? '' : '/builders';
291
+ $('#fi-sub').innerHTML = `Filed <a href="${base}/idea/${encodeURIComponent(idea.id)}">idea ${escapeHtml(String(idea.id))}</a>. Write another below.`;
289
292
  }
290
293
  }
291
294
 
@@ -118,6 +118,19 @@
118
118
  }
119
119
  if (r.shipped_at) nodes.push(h('p', 'deeplink-card__note', 'Shipped ' + fmtRel(r.shipped_at)));
120
120
 
121
+ // task 1002929 (R153): an idea now has a real page, and this card cannot show
122
+ // a Full Idea's five prose answers legibly — so it stops pretending to and
123
+ // points at the page instead. The overlay KEEPS the triage panel below (~320
124
+ // lines of promote/merge/discard); this is the summary→detail relationship the
125
+ // Work board already has with /task/:id, not a replacement.
126
+ if (kind === 'idea') {
127
+ var fv = h('p', 'deeplink-card__note');
128
+ var fl = h('a', 'deeplink-card__link', r.grade === 'full' ? 'Read the full idea →' : 'Open this idea →');
129
+ fl.href = hallBase() + '/idea/' + encodeURIComponent(r.id);
130
+ fv.appendChild(fl);
131
+ nodes.push(fv);
132
+ }
133
+
121
134
  // task 759 → task 1002447: Metic+ idea triage, right in the overlay (no
122
135
  // /idea-triage skill and no LLM in the loop). 759 shipped Discard alone;
123
136
  // 1002447 adds the other two verbs so the whole verdict set is reachable
@@ -507,11 +507,24 @@ async function listOpenIdeas({ kind = null, limit = 50, viewerRank = null } = {}
507
507
  return rows;
508
508
  }
509
509
 
510
+ // The by-id read. It carries the two people's LOGINS as well as their ids
511
+ // (task 1002929 / R153): an idea's attribution is a claim about humans, and
512
+ // "captured_by: 41" is not attribution to anyone reading it. Named to match the
513
+ // R145 spark queue's `filed_by_login` so the two idea surfaces agree.
514
+ //
515
+ // Both joins are LEFT: a builder row can be missing (an FK that went
516
+ // ON DELETE SET NULL), and an idea nobody developed has no developer at all —
517
+ // which is the common case, not an error. A plain JOIN here would drop most of
518
+ // the inbox, the same trap the grade join documents above.
510
519
  async function getIdea(id) {
511
520
  const { rows } = await pool.query(
512
- `SELECT ${IDEA_COLS_Q}, ${GRADE_COLS_Q}
521
+ `SELECT ${IDEA_COLS_Q}, ${GRADE_COLS_Q},
522
+ ba.github_login AS filed_by_login,
523
+ bd.github_login AS developed_by_login
513
524
  FROM idea_inbox i
514
525
  ${IDEA_JOIN_GRADE}
526
+ LEFT JOIN builders ba ON ba.id = i.captured_by
527
+ LEFT JOIN builders bd ON bd.id = g.developed_by
515
528
  WHERE i.id = $1`,
516
529
  [id]
517
530
  );
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.299",
3
+ "version": "1.19.300",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.299",
9
+ "version": "1.19.300",
10
10
  "license": "AGPL-3.0-or-later",
11
11
  "dependencies": {
12
12
  "express": "^4.21.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.299",
3
+ "version": "1.19.300",
4
4
  "description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "main": "src/platform-server.js",
@@ -881,9 +881,15 @@ function mountInternalSurfaces(app) {
881
881
  // needs its own two-segment match ahead of the single-segment resolution
882
882
  // below. Same gate tier as Work/Roadmap/Goals (SIGNEDIN_BUILDERS_PAGE_RE).
883
883
  const TASK_DETAIL_PAGE_RE = /^\/builders\/task\/(\d+)\/?$/;
884
+ // task 1002929 (R153 of goal 1000062): the idea-detail page is the SECOND
885
+ // two-segment hall page — /builders/idea/:id — for the same reason task 751 gave
886
+ // the task one: a record with real prose in it deserves a shareable URL and
887
+ // working back/forward, not a modal. Same gate tier as the task page.
888
+ const IDEA_DETAIL_PAGE_RE = /^\/builders\/idea\/(\d+)\/?$/;
884
889
  function buildersHtmlName(reqPath) {
885
890
  if (reqPath === '/builders' || reqPath === '/builders/') return 'index.html';
886
891
  if (TASK_DETAIL_PAGE_RE.test(reqPath)) return buildersHtml['task.html'] ? 'task.html' : null;
892
+ if (IDEA_DETAIL_PAGE_RE.test(reqPath)) return buildersHtml['idea.html'] ? 'idea.html' : null;
887
893
  // e.g. /builders/ranks or /builders/ranks.html → ranks.html
888
894
  const m = reqPath.match(BUILDERS_PAGE_RE);
889
895
  if (m) {
@@ -956,7 +962,7 @@ function mountInternalSurfaces(app) {
956
962
  // behind the proxy's path normalization.
957
963
  const norm = path.posix.normalize(req.path);
958
964
  if (SIGNEDIN_BUILDERS_PAGE_RE.test(norm) || SIGNEDIN_BUILDERS_PREFIX_RE.test(norm)
959
- || TASK_DETAIL_PAGE_RE.test(norm)) {
965
+ || TASK_DETAIL_PAGE_RE.test(norm) || IDEA_DETAIL_PAGE_RE.test(norm)) {
960
966
  return gdsAuth.requireBuilderPage(req, res, next);
961
967
  }
962
968
  next();
package/src/module-api.js CHANGED
@@ -1025,8 +1025,10 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
1025
1025
  // landed since 1.19.297 with no explicit bump. run 32597859474. (task 1002620)
1026
1026
  // 1.19.299 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1027
1027
  // landed since 1.19.298 with no explicit bump. run 32600322268. (task 1002620)
1028
+ // 1.19.300 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1029
+ // landed since 1.19.299 with no explicit bump. run 32601521458. (task 1002620)
1028
1030
  // ---------------------------------------------------------------------------
1029
- const CORE_VERSION = '1.19.299'; // CI auto-patch carrier (ADR 0161); see changelog above
1031
+ const CORE_VERSION = '1.19.300'; // CI auto-patch carrier (ADR 0161); see changelog above
1030
1032
 
1031
1033
  // A namespaced logger so a module's log lines are attributable + consistent.
1032
1034
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -0,0 +1,271 @@
1
+ // tests/idea_detail_page.mjs
2
+ //
3
+ // task 1002929 (BV1.R153, goal 1000062) — the idea-detail page (/idea/:id).
4
+ //
5
+ // The done-when, as assertions:
6
+ // 1. a Full Idea is legible end to end — every question and every answer, in
7
+ // TEMPLATE order, plus the score;
8
+ // 2. a QUICK idea renders on the same page with NO empty five-field skeleton —
9
+ // five headings over five dashes is not an empty state, it is a page telling
10
+ // a builder they were wrong to file a quick idea;
11
+ // 3. spark AND developer attribution are both visible — R146's whole point is
12
+ // that the author survives being developed by someone else, so a developed
13
+ // idea must name two people.
14
+ //
15
+ // The page renders by string-building into #idea-body, so this executes the real
16
+ // render path in a minimal DOM stub (the hall_nav.mjs / hall_signout.mjs pattern)
17
+ // and asserts over the HTML a viewer would actually get. Pure vm + file read.
18
+ //
19
+ // Run: node tests/idea_detail_page.mjs
20
+
21
+ import { strict as assert } from 'node:assert';
22
+ import { createRequire } from 'node:module';
23
+ import fs from 'node:fs';
24
+ import path from 'node:path';
25
+ import vm from 'node:vm';
26
+ import { fileURLToPath } from 'node:url';
27
+
28
+ const require = createRequire(import.meta.url);
29
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
30
+ const HALL = path.join(ROOT, 'modules/hall-ui/public');
31
+ const SRC = fs.readFileSync(path.join(HALL, 'idea.js'), 'utf8');
32
+
33
+ const { BASE_TEMPLATE } = require('../modules/ideas/templates.js');
34
+
35
+ // The template as GET /inbox/template projects it (R152's shape).
36
+ const TEMPLATE = {
37
+ key: BASE_TEMPLATE.key,
38
+ label: BASE_TEMPLATE.label,
39
+ fields: BASE_TEMPLATE.fields.map((f) => ({ key: f.key, label: f.label, required: !!f.required, substance_min: f.substanceMin || 0 })),
40
+ };
41
+
42
+ // Boot idea.js against a stub DOM + a stub client that answers with `idea`, and
43
+ // return the HTML it wrote into #idea-body.
44
+ function render(idea, { template = TEMPLATE, templateFails = false, ideaStatus = 200 } = {}) {
45
+ const els = new Map();
46
+ const makeEl = () => ({
47
+ dataset: {}, style: {}, textContent: '', hidden: false, _html: '',
48
+ classList: { add() {}, remove() {}, toggle() { return false; }, contains() { return false; } },
49
+ setAttribute() {}, getAttribute() { return null; }, addEventListener() {},
50
+ querySelector() { return makeEl(); }, querySelectorAll() { return []; }, appendChild() {},
51
+ set innerHTML(v) { this._html = String(v); }, get innerHTML() { return this._html; },
52
+ });
53
+
54
+ const documentObj = {
55
+ title: '',
56
+ body: { dataset: { page: 'idea' } },
57
+ documentElement: { dataset: {}, style: {}, classList: { add() {}, remove() {} } },
58
+ getElementById(id) { if (!els.has(id)) els.set(id, makeEl()); return els.get(id); },
59
+ querySelector(sel) { return this.getElementById(String(sel).replace(/^#/, '')); },
60
+ querySelectorAll() { return []; },
61
+ createElement() { return makeEl(); },
62
+ addEventListener() {},
63
+ };
64
+
65
+ const calls = [];
66
+ const client = {
67
+ inbox: {
68
+ getInboxId: async (args) => { calls.push(['idea', args]); return { status: ideaStatus, ok: ideaStatus === 200, data: { idea } }; },
69
+ getInboxTemplate: async (args) => {
70
+ calls.push(['template', args]);
71
+ if (templateFails) return { status: 500, ok: false, data: null };
72
+ return { status: 200, ok: true, data: { template } };
73
+ },
74
+ },
75
+ };
76
+
77
+ const windowObj = {
78
+ __BRANDING__: {}, __MODULES__: { enabled: {} },
79
+ BongosClient: { createClient: () => client },
80
+ OTB: {
81
+ escapeHtml: (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c])),
82
+ $: (sel) => documentObj.querySelector(sel),
83
+ fmtDate: (iso) => String(iso).slice(0, 10),
84
+ toast() {},
85
+ },
86
+ location: { hostname: 'builders.cloudbongos.com', pathname: `/idea/${idea && idea.id ? idea.id : '1'}`, assign() {} },
87
+ addEventListener() {},
88
+ };
89
+
90
+ const sandbox = { window: windowObj, document: documentObj, location: windowObj.location, console, setTimeout, clearTimeout };
91
+ sandbox.globalThis = sandbox;
92
+ vm.createContext(sandbox);
93
+ vm.runInContext(SRC, sandbox, { filename: 'idea.js' });
94
+
95
+ return { html: () => documentObj.getElementById('idea-body').innerHTML, calls, documentObj };
96
+ }
97
+
98
+ // The render is async (two awaits at most), so let the microtask queue drain.
99
+ const settle = () => new Promise((r) => setTimeout(r, 0));
100
+
101
+ const FULL_VALUES = Object.fromEntries(BASE_TEMPLATE.fields.map((f, i) => [f.key, `Answer number ${i + 1}, with enough words in it to read like a real answer.`]));
102
+
103
+ const fullIdea = {
104
+ id: '812', title: 'A shared changelog', status: 'open', kind: 'feature',
105
+ body_md: 'the original quick thought', captured_by: '9', captured_at: '2026-08-01T00:00:00Z',
106
+ grade: 'full', template_key: 'base-v1', field_values: FULL_VALUES, completeness_score: 88,
107
+ filed_by_login: 'thinker', developed_by: '42', developed_at: '2026-08-20T00:00:00Z', developed_by_login: 'someone',
108
+ };
109
+
110
+ let passed = 0;
111
+ let failed = 0;
112
+ async function test(name, fn) {
113
+ try { await fn(); passed++; console.log(` ok ${name}`); }
114
+ catch (err) { failed++; console.error(` FAIL ${name}\n ${err.message}`); }
115
+ }
116
+
117
+ // --- done-when 1: a Full Idea is legible end to end ---------------------
118
+
119
+ await test('a Full Idea shows every question and every answer, in template order', async () => {
120
+ const r = render(fullIdea);
121
+ await settle();
122
+ const html = r.html();
123
+ let cursor = -1;
124
+ for (const f of TEMPLATE.fields) {
125
+ const qAt = html.indexOf(f.label);
126
+ assert.ok(qAt > -1, `the question "${f.label}" is on the page`);
127
+ assert.ok(qAt > cursor, 'questions appear in TEMPLATE order, not jsonb key order');
128
+ cursor = qAt;
129
+ assert.ok(html.includes(FULL_VALUES[f.key]), `the answer for ${f.key} is on the page`);
130
+ }
131
+ });
132
+
133
+ await test('the score is shown as a number and a bar', async () => {
134
+ const r = render(fullIdea);
135
+ await settle();
136
+ assert.match(r.html(), /88\/100 complete/);
137
+ assert.match(r.html(), /width:88%/);
138
+ });
139
+
140
+ await test('a Full Idea with no score says so instead of drawing a zero', async () => {
141
+ const r = render({ ...fullIdea, completeness_score: null });
142
+ await settle();
143
+ assert.match(r.html(), /Not scored/);
144
+ assert.doesNotMatch(r.html(), /0\/100/);
145
+ });
146
+
147
+ await test('a blank answer on a Full Idea is marked, not hidden', async () => {
148
+ const { fit, ...four } = FULL_VALUES;
149
+ const r = render({ ...fullIdea, field_values: four });
150
+ await settle();
151
+ // It is information: it is WHY the score is what it is.
152
+ assert.ok(r.html().includes('Not answered'));
153
+ const label = TEMPLATE.fields.find((f) => f.key === 'fit').label;
154
+ assert.ok(r.html().includes(label), 'the unanswered question is still shown');
155
+ });
156
+
157
+ // --- done-when 2: a quick idea gets NO five-field skeleton --------------
158
+
159
+ await test('a QUICK idea shows its body and NOT one of the five questions', async () => {
160
+ const r = render({
161
+ id: '5', title: 'a quick thought', status: 'open', body_md: 'just the thought',
162
+ captured_by: '9', captured_at: '2026-08-01T00:00:00Z', grade: 'quick',
163
+ template_key: null, field_values: null, completeness_score: null,
164
+ filed_by_login: 'thinker', developed_by: null, developed_at: null, developed_by_login: null,
165
+ });
166
+ await settle();
167
+ const html = r.html();
168
+ assert.ok(html.includes('just the thought'));
169
+ for (const f of TEMPLATE.fields) {
170
+ assert.ok(!html.includes(f.label), `a quick idea must not show the question "${f.label}"`);
171
+ }
172
+ assert.doesNotMatch(html, /Not answered/, 'and no empty answer rows either');
173
+ assert.doesNotMatch(html, /\/100 complete/, 'nor a completeness bar it has no score for');
174
+ });
175
+
176
+ await test('a quick idea costs ONE request — the labels are only needed for a full one', async () => {
177
+ const r = render({ id: '5', title: 't', status: 'open', body_md: 'b', grade: 'quick', captured_by: '9' });
178
+ await settle();
179
+ assert.deepEqual(r.calls.map((c) => c[0]), ['idea'], 'no template fetch for a quick idea');
180
+ });
181
+
182
+ await test('a SPARK shows its body, no skeleton, and says it is open for development', async () => {
183
+ const r = render({
184
+ id: '7', title: 'a spark', status: 'open', body_md: 'the seed', grade: 'spark',
185
+ captured_by: '9', captured_at: '2026-08-01T00:00:00Z', filed_by_login: 'thinker',
186
+ });
187
+ await settle();
188
+ const html = r.html();
189
+ assert.ok(html.includes('the seed'));
190
+ assert.match(html, /Big Idea Spark/);
191
+ assert.match(html, /needing development/);
192
+ for (const f of TEMPLATE.fields) assert.ok(!html.includes(f.label), 'a spark has no five-field skeleton either');
193
+ });
194
+
195
+ // --- done-when 3: both attributions visible ----------------------------
196
+
197
+ await test('a developed idea names TWO people, in their two roles', async () => {
198
+ const r = render(fullIdea);
199
+ await settle();
200
+ const html = r.html();
201
+ assert.match(html, /@thinker/);
202
+ assert.match(html, /had the thought/);
203
+ assert.match(html, /@someone/);
204
+ assert.match(html, /developed it into a Full Idea/);
205
+ // The author must come first: the thought precedes the development, and R146
206
+ // exists so being developed does not displace the person who had it.
207
+ assert.ok(html.indexOf('@thinker') < html.indexOf('@someone'));
208
+ });
209
+
210
+ await test('an UNdeveloped idea names one person and invents no developer', async () => {
211
+ const r = render({ ...fullIdea, developed_by: null, developed_at: null, developed_by_login: null });
212
+ await settle();
213
+ const html = r.html();
214
+ assert.match(html, /had the thought/);
215
+ assert.doesNotMatch(html, /developed it/);
216
+ });
217
+
218
+ await test('self-development reads as one person doing both, not a handoff', async () => {
219
+ const r = render({ ...fullIdea, developed_by: '9', developed_by_login: 'thinker' });
220
+ await settle();
221
+ assert.match(r.html(), /developed it themselves/);
222
+ });
223
+
224
+ await test('a missing login degrades to the builder id, never "@undefined"', async () => {
225
+ const r = render({ ...fullIdea, filed_by_login: null, developed_by_login: null });
226
+ await settle();
227
+ const html = r.html();
228
+ assert.match(html, /builder 9/);
229
+ assert.match(html, /builder 42/);
230
+ assert.doesNotMatch(html, /@undefined|@null|undefined/);
231
+ });
232
+
233
+ // --- grade badge + plumbing --------------------------------------------
234
+
235
+ await test('the grade is named in WORDS, not only in colour', async () => {
236
+ for (const [grade, word] of [['full', 'Full Idea'], ['spark', 'Big Idea Spark'], ['quick', 'Quick idea']]) {
237
+ const r = render({ ...fullIdea, grade, ...(grade === 'full' ? {} : { field_values: null }) });
238
+ await settle();
239
+ assert.ok(r.html().includes(word), `${grade} is spelled "${word}"`);
240
+ }
241
+ });
242
+
243
+ await test('an unknown grade falls back to quick rather than rendering nothing', async () => {
244
+ const r = render({ id: '1', title: 't', body_md: 'b', grade: 'something-new', captured_by: '9' });
245
+ await settle();
246
+ assert.match(r.html(), /Quick idea/);
247
+ assert.ok(r.html().includes('b'));
248
+ });
249
+
250
+ await test('a template outage still renders the answers, without inventing an order', async () => {
251
+ const r = render(fullIdea, { templateFails: true });
252
+ await settle();
253
+ const html = r.html();
254
+ assert.match(html, /Could not load the question labels/);
255
+ for (const v of Object.values(FULL_VALUES)) assert.ok(html.includes(v), 'the answers are still readable');
256
+ });
257
+
258
+ await test('a promoted idea links to the task it became', async () => {
259
+ const r = render({ ...fullIdea, status: 'promoted', promoted_to_task_id: '1234' });
260
+ await settle();
261
+ assert.match(r.html(), /\/task\/1234/);
262
+ });
263
+
264
+ await test('a 404 says which idea is missing rather than failing blank', async () => {
265
+ const r = render({ id: '99' }, { ideaStatus: 404 });
266
+ await settle();
267
+ assert.match(r.html(), /No idea 99/);
268
+ });
269
+
270
+ console.log(`\nidea_detail_page: ${passed} passed, ${failed} failed`);
271
+ if (failed > 0) process.exit(1);