@bongos/core 1.19.903 → 1.19.905
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 +45 -20
- package/docs/copy-inventory.md +10 -11
- package/docs/copy-registry.json +13 -22
- package/docs/module-api-changelog.md +4 -0
- package/migrations/core_251_credit_log_idea_size_index.sql +61 -0
- package/modules/economy/credits.js +8 -0
- package/modules/economy/idea-size-historical.js +149 -0
- package/modules/economy/idea-size.js +381 -0
- package/modules/economy/reward.js +17 -0
- package/modules/hall-ui/public/collab-lib.js +22 -0
- package/modules/hall-ui/public/collab.js +17 -2
- package/modules/hall-ui/public/profile.js +42 -5
- package/modules/ideas/routes/inbox.js +9 -0
- package/modules/ideas/trail-size.js +88 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/module-api.js +1 -1
- package/tests/collab_page.mjs +44 -0
- package/tests/idea_size.mjs +776 -0
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// modules/economy/idea-size.js — SIZE: an idea's total ideator payout from its
|
|
4
|
+
// descent (task 1004093, BV2.R12, goal 1000095; spec
|
|
5
|
+
// docs/specs/bongos-v2-ideator-surface.md decisions 4, 5 and 7).
|
|
6
|
+
//
|
|
7
|
+
// WHAT SIZE IS. Stream 1 pays an idea's people every time a task under it ships
|
|
8
|
+
// — the direct task at full share, and since BV2.R09 every task DESCENDED from
|
|
9
|
+
// it at `decay^depth` (modules/economy/credits.js: awardIdeaTaskShipCredit and
|
|
10
|
+
// awardIdeaDescentShipCredit). Size is the sum of those payouts. Nothing else:
|
|
11
|
+
// not the Full-Idea pass (stream 2), not the board ratification (stream 3), not
|
|
12
|
+
// the promotion bonus. Those pay for the THOUGHT; size is what its WORK earned,
|
|
13
|
+
// and mixing them would make a well-written idea that caused nothing draw as
|
|
14
|
+
// large as one that caused a version.
|
|
15
|
+
//
|
|
16
|
+
// WHY IT IS CREDITS AND NOT KARMA (spec decision 5). `karma_log` carries no task
|
|
17
|
+
// key and no idea key, and migrations/040_builder_karma.sql:13 says that is
|
|
18
|
+
// deliberate — so "the karma a thought caused" is not a quantity that exists to
|
|
19
|
+
// be summed. Credits are already idea-attributed at the moment they are booked.
|
|
20
|
+
//
|
|
21
|
+
// WHY NO MIGRATION. scripts/gds/sequence.js flagged this task `needs_migration`,
|
|
22
|
+
// but that flag is a keyword scan of the title and description
|
|
23
|
+
// (scripts/gds/sequence.js:169), not a fact about the schema. The reason key
|
|
24
|
+
// stream 1 books under ALREADY embeds the idea:
|
|
25
|
+
//
|
|
26
|
+
// idea.credit.task_ship:<ideaId>:<taskId>
|
|
27
|
+
//
|
|
28
|
+
// so size is a pure read over credit_log, needing no column and no table. A
|
|
29
|
+
// schema change of that kind would be a second copy of a fact the ledger
|
|
30
|
+
// already holds, and a second copy of money is how the number and the ledger
|
|
31
|
+
// drift.
|
|
32
|
+
//
|
|
33
|
+
// IT DOES NEED AN INDEX, though — which is a different thing from a schema
|
|
34
|
+
// change and was missed on the first round of this task. credit_log is the
|
|
35
|
+
// whole-instance, append-only ledger, and this read runs on every view of a
|
|
36
|
+
// builder's idea trail, so unindexed it is a sequential scan of every credit
|
|
37
|
+
// event the instance has ever booked. `migrations/core_251_credit_log_idea_
|
|
38
|
+
// size_index.sql` adds a partial expression index matching this query exactly;
|
|
39
|
+
// see the note on TASK_SHIP_PREFIX_SQL below for why the predicate has to be a
|
|
40
|
+
// literal on both sides for that index to ever be chosen.
|
|
41
|
+
//
|
|
42
|
+
// SIZE NEVER FALLS, and the reason is in the writers, not in a clamp here.
|
|
43
|
+
// credit_log is append-only; both stream-1 writers skip a payment whose rounded
|
|
44
|
+
// delta is `<= 0`, and `splitIdeaCredit` independently returns no payees for a
|
|
45
|
+
// non-positive total — so the property is held in TWO places and survives either
|
|
46
|
+
// one being removed (probed: removing one guard reds nothing, removing both reds
|
|
47
|
+
// the test below). No non-positive row is ever booked under this prefix; and
|
|
48
|
+
// ADR 0313 settles that a removal claws nothing back — `shipped` stays terminal.
|
|
49
|
+
// The sum is therefore monotonic by construction. This file does NOT clamp the
|
|
50
|
+
// result to zero, because a clamp would hide exactly the anomaly it claims to
|
|
51
|
+
// prevent: if a reversal ever appeared under this prefix, the honest answer is
|
|
52
|
+
// the smaller number, not a floor that quietly disagrees with the ledger.
|
|
53
|
+
// tests/idea_size.mjs drives the real writers and pins that they book nothing
|
|
54
|
+
// non-positive, which is where that invariant actually lives.
|
|
55
|
+
|
|
56
|
+
const api = require('../../src/module-api');
|
|
57
|
+
const { pool, resolveOptional } = api;
|
|
58
|
+
// The historical half (task 1004097 / BV2.R18) — the pricing of a descent the
|
|
59
|
+
// ledger was never there to pay for. Its own file because it is arithmetic
|
|
60
|
+
// rather than a read, and because it must import credits.js's payout helpers
|
|
61
|
+
// directly rather than growing a second copy of them here.
|
|
62
|
+
const { priceDescentForIdea } = require('./idea-size-historical');
|
|
63
|
+
const { rewardConfig } = require('./credits');
|
|
64
|
+
// The prefix, taken from the writers rather than retyped. It is the idempotency
|
|
65
|
+
// predicate stream 1 books under, so a literal here that drifted from it would
|
|
66
|
+
// read an empty ledger and every star would go out at once.
|
|
67
|
+
const { IDEA_STREAM_REASON_PREFIXES } = require('./credits');
|
|
68
|
+
|
|
69
|
+
// THE PREFIX GOES INTO THE SQL TEXT AS A LITERAL, NOT AS A PARAMETER, and that
|
|
70
|
+
// is the one unobvious line in this file.
|
|
71
|
+
//
|
|
72
|
+
// `migrations/core_251_credit_log_idea_size_index.sql` indexes this read with a
|
|
73
|
+
// PARTIAL index predicated on `reason LIKE 'idea.credit.task_ship:%'`. Postgres
|
|
74
|
+
// uses a partial index only when it can prove the query's WHERE implies the
|
|
75
|
+
// index predicate — and it cannot prove that about `reason LIKE $1 || '%'`,
|
|
76
|
+
// because the parameter has no value when a generic plan is built. Parameterised,
|
|
77
|
+
// the index is simply never chosen and the sequential scan the migration exists
|
|
78
|
+
// to remove comes straight back, silently.
|
|
79
|
+
//
|
|
80
|
+
// Interpolating a string into SQL is the thing one does not do, so it is fenced:
|
|
81
|
+
// this is a module-owned constant that never touches a request, and the assert
|
|
82
|
+
// below refuses anything that is not plain prefix characters. A constant that
|
|
83
|
+
// grew a quote would fail at module load — at boot, loudly — rather than
|
|
84
|
+
// becoming an injection point.
|
|
85
|
+
//
|
|
86
|
+
// THE UNDERSCORE IN `task_ship` IS A LIKE WILDCARD. `_` matches any single
|
|
87
|
+
// character, so an unescaped prefix would also match `idea.credit.taskXship:`.
|
|
88
|
+
// No such key exists today, which is exactly why it would never be noticed —
|
|
89
|
+
// so it is escaped, the way credits.js `creditLedger` already escapes a caller's
|
|
90
|
+
// reasonPrefix. Backslash is LIKE's default escape in Postgres, so no ESCAPE
|
|
91
|
+
// clause is needed; `migrations/core_251_*.sql` carries the identical string.
|
|
92
|
+
if (!/^[a-z0-9_.]+:$/i.test(IDEA_STREAM_REASON_PREFIXES.task_ship)) {
|
|
93
|
+
throw new Error('economy/idea-size: the stream-1 reason prefix is not a plain literal; refusing to inline it into SQL');
|
|
94
|
+
}
|
|
95
|
+
const TASK_SHIP_PREFIX_SQL = `'${IDEA_STREAM_REASON_PREFIXES.task_ship.replace(/([\\%_])/g, '\\$1')}%'`;
|
|
96
|
+
|
|
97
|
+
// The credits at which the drawn scale reaches 1. At the base 5c per shipped
|
|
98
|
+
// task this is on the order of a hundred shipped tasks under one thought — far
|
|
99
|
+
// beyond anything an instance has today, so the compression spends its range on
|
|
100
|
+
// the ideas that exist rather than saturating them all at the top.
|
|
101
|
+
const IDEA_SIZE_SCALE_REF = 500;
|
|
102
|
+
|
|
103
|
+
// compressIdeaSize — the drawn magnitude of a size, in [0, 1]. Pure.
|
|
104
|
+
//
|
|
105
|
+
// Payout sums are long-tailed: most ideas cause one task and earn 5, a handful
|
|
106
|
+
// cause a version and earn hundreds. Drawn linearly, the tail is the only thing
|
|
107
|
+
// visible and everything else is the same dot. log1p spends the range where the
|
|
108
|
+
// ideas are, and log1p (not log) is what lets an idea that earned NOTHING sit at
|
|
109
|
+
// a clean 0 instead of at negative infinity.
|
|
110
|
+
//
|
|
111
|
+
// ABSOLUTE, NOT RELATIVE TO THE SET — and this is the decision in this file.
|
|
112
|
+
// Normalising against the largest idea on screen would draw better, and it would
|
|
113
|
+
// break the one property size exists to have: a thought's size would FALL the
|
|
114
|
+
// moment a bigger thought appeared beside it, without a credit changing hands.
|
|
115
|
+
// Size is supposed to be the channel that never falls (brightness, BV2.R16, is
|
|
116
|
+
// the one that does). So the reference is a constant, the same for every idea in
|
|
117
|
+
// every frame, and a set of ideas can be compared across two different pages.
|
|
118
|
+
//
|
|
119
|
+
// Monotonic and saturating: more credits never draws smaller, and past the
|
|
120
|
+
// reference the scale holds at 1 while the TRUE figure — which the panel shows —
|
|
121
|
+
// keeps climbing.
|
|
122
|
+
function compressIdeaSize(credits) {
|
|
123
|
+
const c = Number(credits);
|
|
124
|
+
if (!Number.isFinite(c) || c <= 0) return 0;
|
|
125
|
+
const scale = Math.log1p(c) / Math.log1p(IDEA_SIZE_SCALE_REF);
|
|
126
|
+
return scale >= 1 ? 1 : scale;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// A positive integer id as it arrives off a row or a caller — where it may be a
|
|
130
|
+
// bigint serialised to a string. Anything else is dropped rather than sent to
|
|
131
|
+
// the database, and never coerced with Number() (bigint precision).
|
|
132
|
+
function idOf(v) {
|
|
133
|
+
return /^[1-9]\d*$/.test(String(v)) ? String(v) : null;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ideaSizes — size for MANY ideas in ONE query, keyed by idea id (string).
|
|
137
|
+
//
|
|
138
|
+
// The grouping key is split_part(reason, ':', 2), which is the idea id the
|
|
139
|
+
// writer embedded. It is read INSTEAD of a per-idea LIKE for two reasons, and
|
|
140
|
+
// the second is the one that would have been a bug:
|
|
141
|
+
//
|
|
142
|
+
// • one scan for a whole page of ideas, not one scan per idea;
|
|
143
|
+
// • a prefix match has a boundary. `reason LIKE 'idea.credit.task_ship:1:%'`
|
|
144
|
+
// is right only because of the trailing colon — drop it and idea 1 absorbs
|
|
145
|
+
// every payout of ideas 10, 100 and 1000. Splitting on the separator has no
|
|
146
|
+
// such edge to get wrong. tests/idea_size.mjs pins the 1-vs-10 case anyway,
|
|
147
|
+
// because the next person to "simplify" this back to a LIKE needs to be
|
|
148
|
+
// told by a red test rather than by an ideator's inflated star.
|
|
149
|
+
//
|
|
150
|
+
// The stream-1 prefix stays in the WHERE regardless: split_part would happily
|
|
151
|
+
// pull the same idea id out of an `idea.credit.full_pass:<ideaId>` row, and that
|
|
152
|
+
// row is stream 2 — the thought, not the work it caused.
|
|
153
|
+
//
|
|
154
|
+
// Returns Map<ideaIdString, { credits, paid, estimated, payments, scale,
|
|
155
|
+
// historical }>. Every id asked for is present, at zero when it has been paid
|
|
156
|
+
// nothing: absent and zero are different answers to a renderer, and an idea that
|
|
157
|
+
// caused nothing yet is a real state the surface has to draw, not a gap.
|
|
158
|
+
//
|
|
159
|
+
// THE PAID / ESTIMATED SPLIT (task 1004097 / BV2.R18, spec decision 17).
|
|
160
|
+
// `paid` is money that moved. `estimated` is what an idea's descent WOULD have
|
|
161
|
+
// earned for the hops that shipped before there was a descent payout to earn it
|
|
162
|
+
// — real work, never paid, and deliberately never paid retroactively. `credits`
|
|
163
|
+
// is the two together, which is the figure size means, and `historical` says
|
|
164
|
+
// whether any of it is the second kind. A total with no marker is the quiet lie
|
|
165
|
+
// the spec names; carrying the split is what lets every surface say which kind
|
|
166
|
+
// of number it is showing.
|
|
167
|
+
//
|
|
168
|
+
// `historical` is DERIVED, never stored — it is simply `estimated > 0`. No
|
|
169
|
+
// column, no cutoff date, no flag to rot: an idea stops being historical the
|
|
170
|
+
// moment its descent has actually been paid for, which is the true condition and
|
|
171
|
+
// not a date that approximates it. And when the descent could not be established
|
|
172
|
+
// at all, `estimated` and `historical` are NULL rather than 0 and false, because
|
|
173
|
+
// "nothing was estimated" and "we could not look" are different answers and only
|
|
174
|
+
// one of them may be rendered as a plain figure.
|
|
175
|
+
//
|
|
176
|
+
// ideaIds — array of ids (bigint-safe strings or numbers)
|
|
177
|
+
// client — optional tx client; falls back to the pool
|
|
178
|
+
// descentByIdea — Map<ideaIdString, [{ task_id, depth, status }]>. The
|
|
179
|
+
// caller's already-walked descent. The trail has one in hand
|
|
180
|
+
// (trail-descent.js ran first), so passing it makes the
|
|
181
|
+
// historical half cost ZERO extra round trips. Omit it and
|
|
182
|
+
// the walk is done here, through the `ideas.descent` port.
|
|
183
|
+
async function ideaSizes(ideaIds, { client = null, descentByIdea = null } = {}) {
|
|
184
|
+
const ids = [...new Set((Array.isArray(ideaIds) ? ideaIds : []).map(idOf).filter(Boolean))];
|
|
185
|
+
const out = new Map();
|
|
186
|
+
for (const id of ids) {
|
|
187
|
+
out.set(id, { credits: 0, paid: 0, estimated: 0, payments: 0, scale: 0, historical: false });
|
|
188
|
+
}
|
|
189
|
+
if (ids.length === 0) return out;
|
|
190
|
+
|
|
191
|
+
const q = client || pool;
|
|
192
|
+
// PER (idea, task), not per idea. The totals still fall out of it, and the
|
|
193
|
+
// per-task breakdown is what tells the backfill which hops the ledger already
|
|
194
|
+
// paid for — without it, an estimate would be added on top of a payment that
|
|
195
|
+
// really happened and every long-lived idea would read double.
|
|
196
|
+
const { rows } = await q.query(
|
|
197
|
+
`SELECT split_part(reason, ':', 2) AS idea_id,
|
|
198
|
+
task_id,
|
|
199
|
+
COALESCE(SUM(delta), 0)::int AS credits,
|
|
200
|
+
COUNT(*)::int AS payments
|
|
201
|
+
FROM credit_log
|
|
202
|
+
WHERE reason LIKE ${TASK_SHIP_PREFIX_SQL}
|
|
203
|
+
AND split_part(reason, ':', 2) = ANY($1::text[])
|
|
204
|
+
GROUP BY 1, 2`,
|
|
205
|
+
[ids]
|
|
206
|
+
);
|
|
207
|
+
|
|
208
|
+
// Map<ideaId, { total, payments, byTask: Map<taskId, credits> }>
|
|
209
|
+
const ledger = new Map();
|
|
210
|
+
for (const row of rows) {
|
|
211
|
+
const key = idOf(row.idea_id);
|
|
212
|
+
if (!key || !out.has(key)) continue;
|
|
213
|
+
const acc = ledger.get(key) || { total: 0, payments: 0, byTask: new Map() };
|
|
214
|
+
const credits = Number(row.credits) || 0;
|
|
215
|
+
acc.total += credits;
|
|
216
|
+
acc.payments += Number(row.payments) || 0;
|
|
217
|
+
// A stream-1 row always carries its task_id; a null would mean a row this
|
|
218
|
+
// read does not understand, and it is counted in the total but cannot key
|
|
219
|
+
// the backfill, which is the conservative direction (it can only cause an
|
|
220
|
+
// estimate to be ADDED for a task, and that is what `historical` marks).
|
|
221
|
+
if (row.task_id != null) acc.byTask.set(String(row.task_id), credits);
|
|
222
|
+
ledger.set(key, acc);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const descent = descentByIdea || await walkDescentForIdeas(ids, { client: q, ledger });
|
|
226
|
+
const grades = descent ? await ideaGradesFor(ids, q) : null;
|
|
227
|
+
const decay = rewardConfig().ideaDescentDecay;
|
|
228
|
+
|
|
229
|
+
for (const id of ids) {
|
|
230
|
+
const acc = ledger.get(id) || { total: 0, payments: 0, byTask: new Map() };
|
|
231
|
+
if (!descent) {
|
|
232
|
+
// The descent could not be established — no port, and no map from the
|
|
233
|
+
// caller. The paid figure is still true; the historical half is unknown,
|
|
234
|
+
// and says so rather than rendering as "nothing was estimated".
|
|
235
|
+
out.set(id, {
|
|
236
|
+
credits: acc.total, paid: acc.total, estimated: null,
|
|
237
|
+
payments: acc.payments, scale: compressIdeaSize(acc.total), historical: null,
|
|
238
|
+
});
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
const g = (grades && grades.get(id)) || { grade: 'quick', completeness: null };
|
|
242
|
+
const priced = priceDescentForIdea({
|
|
243
|
+
tasks: descent.get(id) || [],
|
|
244
|
+
grade: g.grade,
|
|
245
|
+
completeness: g.completeness,
|
|
246
|
+
decay,
|
|
247
|
+
paidByTask: acc.byTask,
|
|
248
|
+
});
|
|
249
|
+
// The PAID total comes off the ledger, not off the pricing loop: a payment
|
|
250
|
+
// for a task the descent no longer reaches (a re-parented task, a deleted
|
|
251
|
+
// edge) is still money this idea was paid, and dropping it would make size
|
|
252
|
+
// fall.
|
|
253
|
+
const credits = acc.total + priced.estimated;
|
|
254
|
+
out.set(id, {
|
|
255
|
+
credits,
|
|
256
|
+
paid: acc.total,
|
|
257
|
+
estimated: priced.estimated,
|
|
258
|
+
payments: acc.payments + priced.estimatedPayments,
|
|
259
|
+
scale: compressIdeaSize(credits),
|
|
260
|
+
historical: priced.estimated > 0,
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
return out;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// ideaGradesFor — what each idea's work is worth before depth, in one query.
|
|
267
|
+
// LEFT JOIN + COALESCE to 'quick', the rule every idea read in the ideas module
|
|
268
|
+
// is built from: an idea with no ideas_grades row IS quick, and an inner join
|
|
269
|
+
// here would silently price every un-graded idea at zero.
|
|
270
|
+
async function ideaGradesFor(ids, q) {
|
|
271
|
+
const out = new Map();
|
|
272
|
+
if (!ids.length) return out;
|
|
273
|
+
const { rows } = await q.query(
|
|
274
|
+
`SELECT i.id,
|
|
275
|
+
COALESCE(g.grade, 'quick') AS grade,
|
|
276
|
+
g.completeness_score AS completeness
|
|
277
|
+
FROM idea_inbox i
|
|
278
|
+
LEFT JOIN ideas_grades g ON g.idea_id = i.id
|
|
279
|
+
WHERE i.id = ANY($1::bigint[])`,
|
|
280
|
+
[ids.map(Number)]
|
|
281
|
+
);
|
|
282
|
+
for (const row of rows) out.set(String(row.id), { grade: row.grade, completeness: row.completeness });
|
|
283
|
+
return out;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// walkDescentForIdeas — the fallback for a caller with no descent in hand.
|
|
287
|
+
//
|
|
288
|
+
// Returns null when the walk is unavailable, which is what makes `historical`
|
|
289
|
+
// null rather than false upstream. The ideas module owns the walk and economy
|
|
290
|
+
// reaches it through the `ideas.descent` port — never a cross-module require
|
|
291
|
+
// (ADR 0083 rule 1), and never a second copy of a breadth-first shortest-depth
|
|
292
|
+
// walk, because a drift there is a different amount of money.
|
|
293
|
+
//
|
|
294
|
+
// The trail passes its own map instead, so this path is for a single-idea panel
|
|
295
|
+
// rather than a page: it is one walk per direct task, which is right for the one
|
|
296
|
+
// or two an idea has and would be wrong for a page of thirty. That asymmetry is
|
|
297
|
+
// why the parameter exists at all.
|
|
298
|
+
// THE WALK RETURNS NO STATUS, and that is the trap in this function. Its rows
|
|
299
|
+
// are { id, from_id, relation, depth } off ideas_task_descent — an edge table
|
|
300
|
+
// with no join to `tasks` — so reading `row.status` gets `undefined`, every
|
|
301
|
+
// descendant fails the shipped test, and the backfill silently prices nothing
|
|
302
|
+
// while looking like it worked. The statuses are hydrated here in one query,
|
|
303
|
+
// exactly as trail-descent.js hydrates its titles for the same reason.
|
|
304
|
+
async function walkDescentForIdeas(ids, { client }) {
|
|
305
|
+
const port = resolveOptional && resolveOptional('ideas.descent');
|
|
306
|
+
if (!port || typeof port.descendantsOfTask !== 'function') return null;
|
|
307
|
+
|
|
308
|
+
const roots = await client.query(
|
|
309
|
+
`SELECT l.idea_id, l.task_id, t.status
|
|
310
|
+
FROM ideas_task_lineage l
|
|
311
|
+
JOIN tasks t ON t.id = l.task_id
|
|
312
|
+
WHERE l.idea_id = ANY($1::bigint[])`,
|
|
313
|
+
[ids.map(Number)]
|
|
314
|
+
);
|
|
315
|
+
|
|
316
|
+
const byIdea = new Map();
|
|
317
|
+
for (const id of ids) byIdea.set(id, []);
|
|
318
|
+
const rootsByIdea = new Map();
|
|
319
|
+
for (const row of roots.rows) {
|
|
320
|
+
const key = String(row.idea_id);
|
|
321
|
+
if (!byIdea.has(key)) continue;
|
|
322
|
+
byIdea.get(key).push({ task_id: String(row.task_id), depth: 0, status: row.status });
|
|
323
|
+
if (!rootsByIdea.has(key)) rootsByIdea.set(key, []);
|
|
324
|
+
rootsByIdea.get(key).push(String(row.task_id));
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const bestByIdea = new Map();
|
|
328
|
+
const found = new Set();
|
|
329
|
+
for (const [ideaId, taskIds] of rootsByIdea) {
|
|
330
|
+
// Shortest depth wins when a descendant hangs below two of this idea's own
|
|
331
|
+
// tasks — the rule the walk applies within one root, applied across them.
|
|
332
|
+
const best = new Map();
|
|
333
|
+
for (const rootId of taskIds) {
|
|
334
|
+
const rows = await port.descendantsOfTask(rootId, { client });
|
|
335
|
+
for (const row of rows || []) {
|
|
336
|
+
const key = String(row.id);
|
|
337
|
+
if (taskIds.includes(key)) continue; // a root is already a depth-0 row
|
|
338
|
+
const prev = best.get(key);
|
|
339
|
+
if (prev && prev.depth <= row.depth) continue;
|
|
340
|
+
best.set(key, { depth: row.depth });
|
|
341
|
+
found.add(key);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
bestByIdea.set(ideaId, best);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
const status = new Map();
|
|
348
|
+
if (found.size > 0) {
|
|
349
|
+
const { rows } = await client.query(
|
|
350
|
+
`SELECT id, status FROM tasks WHERE id = ANY($1::bigint[])`,
|
|
351
|
+
[[...found].map(Number)]
|
|
352
|
+
);
|
|
353
|
+
for (const row of rows) status.set(String(row.id), row.status);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
for (const [ideaId, best] of bestByIdea) {
|
|
357
|
+
const rows = byIdea.get(ideaId);
|
|
358
|
+
for (const [taskId, hit] of best) {
|
|
359
|
+
rows.push({ task_id: taskId, depth: hit.depth, status: status.get(taskId) ?? null });
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
return byIdea;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// ideaSize — the same answer for one idea. A thin wrapper on purpose: two
|
|
366
|
+
// implementations of "what is this idea's size" is two answers, and this is the
|
|
367
|
+
// shape a single-idea panel wants.
|
|
368
|
+
async function ideaSize(ideaId, opts = {}) {
|
|
369
|
+
const key = idOf(ideaId);
|
|
370
|
+
const zero = { credits: 0, payments: 0, scale: 0 };
|
|
371
|
+
if (!key) return zero;
|
|
372
|
+
const sizes = await ideaSizes([key], opts);
|
|
373
|
+
return sizes.get(key) || zero;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
module.exports = {
|
|
377
|
+
ideaSize,
|
|
378
|
+
ideaSizes,
|
|
379
|
+
compressIdeaSize,
|
|
380
|
+
IDEA_SIZE_SCALE_REF,
|
|
381
|
+
};
|
|
@@ -19,6 +19,8 @@ const credits = require('./credits');
|
|
|
19
19
|
const achievements = require('./achievements');
|
|
20
20
|
const cost = require('./cost');
|
|
21
21
|
const streak = require('./streak');
|
|
22
|
+
const ideaSizeLib = require('./idea-size');
|
|
23
|
+
const ideaSizeHistorical = require('./idea-size-historical');
|
|
22
24
|
|
|
23
25
|
module.exports = {
|
|
24
26
|
// credit allocation — transaction-participant: caller passes its tx `client`
|
|
@@ -93,6 +95,21 @@ module.exports = {
|
|
|
93
95
|
// most wants it for is a HALL view — "what did this idea earn me" — and a core
|
|
94
96
|
// route must reach economy through here, never by importing the module.
|
|
95
97
|
creditLedger: (...a) => credits.creditLedger(...a),
|
|
98
|
+
// SIZE — an idea's total stream-1 payout (task 1004093 / BV2.R12). On the port
|
|
99
|
+
// for the same reason creditLedger is: credit_log is economy's table, the
|
|
100
|
+
// surface that wants the number is the ideas module's trail and the hall, and
|
|
101
|
+
// a module may only cross the wall through here (ADR 0083 / 0093 §2). ideas
|
|
102
|
+
// already declares `reward` in its `consumes`, so no new dependency edge is
|
|
103
|
+
// created by this — and `compressIdeaSize` rides along so a renderer draws
|
|
104
|
+
// with the SAME compression the read applied, never a second curve.
|
|
105
|
+
ideaSize: (...a) => ideaSizeLib.ideaSize(...a),
|
|
106
|
+
ideaSizes: (...a) => ideaSizeLib.ideaSizes(...a),
|
|
107
|
+
compressIdeaSize: (...a) => ideaSizeLib.compressIdeaSize(...a),
|
|
108
|
+
// BV2.R18 — the trail's already-walked descent, in the shape the pricing
|
|
109
|
+
// wants. On the port beside the read so a caller never hand-builds the
|
|
110
|
+
// projection and quietly drops a field the pricing needs.
|
|
111
|
+
descentRowsFromTrail: (...a) => ideaSizeHistorical.descentRowsFromTrail(...a),
|
|
112
|
+
get IDEA_SIZE_SCALE_REF() { return ideaSizeLib.IDEA_SIZE_SCALE_REF; },
|
|
96
113
|
computeStreak: (...a) => streak.computeStreak(...a),
|
|
97
114
|
wouldExtendStreak: (...a) => streak.wouldExtendStreak(...a),
|
|
98
115
|
};
|
|
@@ -313,6 +313,27 @@
|
|
|
313
313
|
return req.needs_craft ? who + ' asked ' + req.needs_craft + 's' : who + ' asked';
|
|
314
314
|
}
|
|
315
315
|
|
|
316
|
+
// "an engineer", "an artist", "an ideator" — but "a UI", which is why this cannot be a
|
|
317
|
+
// plain vowel test. roleWord() carries the same acronym exception one file over.
|
|
318
|
+
function aOrAn(word) {
|
|
319
|
+
const w = String(word || '');
|
|
320
|
+
if (/^ui$/i.test(w)) return 'a';
|
|
321
|
+
return /^[aeiou]/i.test(w) ? 'an' : 'a';
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// The mingle row's sub-line (task 1003967, artist review of 1003960). Takes the two
|
|
325
|
+
// roles ALREADY RESOLVED to their sentence words, so this stays pure and the caller
|
|
326
|
+
// owns the vocabulary — and so this can be RUN in a test rather than grepped, which is
|
|
327
|
+
// the only way a swapped article gets caught. An empty side means the pair has a role
|
|
328
|
+
// nobody set: the comparison then has nothing to compare, so it is dropped rather than
|
|
329
|
+
// rendered with the database's own word in it.
|
|
330
|
+
function pairSentence(theirWord, myWord) {
|
|
331
|
+
if (!theirWord || !myWord) return 'Mingle pairs across roles.';
|
|
332
|
+
const lead = `${aOrAn(theirWord)} ${theirWord}`;
|
|
333
|
+
return `${lead.charAt(0).toUpperCase()}${lead.slice(1)} — you are ${aOrAn(myWord)} ${myWord}. `
|
|
334
|
+
+ 'Mingle pairs across roles.';
|
|
335
|
+
}
|
|
336
|
+
|
|
316
337
|
window.OTBCollabLib = {
|
|
317
338
|
BANDS: BANDS, ageBand: ageBand, ageLabel: ageLabel,
|
|
318
339
|
byAgeOldestFirst: byAgeOldestFirst, byNewestFirst: byNewestFirst,
|
|
@@ -320,5 +341,6 @@
|
|
|
320
341
|
directionLine: directionLine, boardLine: boardLine,
|
|
321
342
|
titleOf: titleOf, summaryOf: summaryOf, clipWords: clipWords, LEAD_MAX: LEAD_MAX, groupOf: groupOf,
|
|
322
343
|
httpUrl: httpUrl, initialOf: initialOf, faceOf: faceOf, askers: askers, splitShown: splitShown,
|
|
344
|
+
aOrAn: aOrAn, pairSentence: pairSentence,
|
|
323
345
|
};
|
|
324
346
|
})();
|
|
@@ -418,11 +418,25 @@
|
|
|
418
418
|
const whoName = o.name || o.login || `builder ${o.id}`;
|
|
419
419
|
const who = escapeHtml(whoName);
|
|
420
420
|
const face = { kind: 'person', id: o.id, name: whoName, avatar: null, initial: L.initialOf(whoName) };
|
|
421
|
+
|
|
422
|
+
// A SENTENCE TAKES roleWord() — the rule dom-utils.js writes down beside the helper
|
|
423
|
+
// (task 1004041). This row took neither helper and interpolated the raw column, so a
|
|
424
|
+
// pair whose role was unset rendered the database's own word: "A unclassified — you
|
|
425
|
+
// are a engineer", which is V2's named failure and a wrong article in the same line.
|
|
426
|
+
// Where a role is missing the comparison has nothing to compare, so the row says the
|
|
427
|
+
// one thing that is still true rather than naming a value nobody chose.
|
|
428
|
+
// roleIsUnset() is how a caller asks (dom-utils.js:198). Spelling 'unclassified' here
|
|
429
|
+
// would be a second definition of the axis in a third file — the drift V1 forbids, and
|
|
430
|
+
// the exact shape this review was filed to remove.
|
|
431
|
+
const theirs = window.OTB.roleIsUnset(o.craft) ? '' : window.OTB.roleWord(o.craft);
|
|
432
|
+
const mine = window.OTB.roleIsUnset(o.myCraft) ? '' : window.OTB.roleWord(o.myCraft);
|
|
433
|
+
const pairLine = L.pairSentence(theirs, mine);
|
|
434
|
+
|
|
421
435
|
return `<li class="ov-row ov-row--lead cb-row">
|
|
422
436
|
${faceHtml(face)}
|
|
423
437
|
<div class="ov-row__main">
|
|
424
438
|
<a class="ov-row__title" href="/builders/profile?id=${escapeHtml(String(o.id))}">${who}</a>
|
|
425
|
-
<p class="ov-row__sub"
|
|
439
|
+
<p class="ov-row__sub">${escapeHtml(pairLine)}</p>
|
|
426
440
|
</div>
|
|
427
441
|
<div class="ov-row__right">
|
|
428
442
|
<div class="ov-row__actions">
|
|
@@ -511,6 +525,7 @@
|
|
|
511
525
|
|
|
512
526
|
load().catch(() => {
|
|
513
527
|
const sub = $('#cb-sub');
|
|
514
|
-
|
|
528
|
+
// V6: a message that stops someone points at the next move in the same breath.
|
|
529
|
+
if (sub) sub.textContent = 'Could not load requests — reload to try again.';
|
|
515
530
|
});
|
|
516
531
|
})();
|
|
@@ -419,6 +419,39 @@
|
|
|
419
419
|
+ cells + more;
|
|
420
420
|
}
|
|
421
421
|
|
|
422
|
+
// SIZE — what this thought's work has actually earned (task 1004093, BV2.R12;
|
|
423
|
+
// spec decision 4). Served as `size` = { credits, payments, scale }.
|
|
424
|
+
//
|
|
425
|
+
// THE TRUE FIGURE IS WHAT PRINTS HERE. `scale` is the compressed magnitude a
|
|
426
|
+
// DRAWING uses (the sky, BV2.R18) — a person reading a number wants the number,
|
|
427
|
+
// and printing a compressed one would make an ideator's earnings unverifiable
|
|
428
|
+
// against their own ledger.
|
|
429
|
+
//
|
|
430
|
+
// It renders independently of the descent, because it has to: an idea promoted
|
|
431
|
+
// straight into one task that shipped has a size and NO descent at all, and
|
|
432
|
+
// hanging this off descentHtml would show that ideator nothing. `size: null`
|
|
433
|
+
// means the ledger could not be asked (the reward port was absent), which is
|
|
434
|
+
// not the same as zero and must not print as "earned 0".
|
|
435
|
+
// THE HISTORICAL MARKER (task 1004097, BV2.R18; spec decision 17). A thought
|
|
436
|
+
// whose work shipped before the descent payout existed has a size that is
|
|
437
|
+
// partly an ESTIMATE — real work, never paid, and deliberately never paid
|
|
438
|
+
// retroactively. The done-when is that anywhere size is shown makes clear
|
|
439
|
+
// which kind of number it is, so the split is SAID, not implied: the row names
|
|
440
|
+
// the estimated part rather than folding it into one total and hoping nobody
|
|
441
|
+
// asks. `historical: null` means the estimate could not be established at all,
|
|
442
|
+
// which is a third state and must not be drawn as either of the other two.
|
|
443
|
+
function sizeHtml(idea) {
|
|
444
|
+
const size = idea.size;
|
|
445
|
+
if (!size || typeof size.credits !== 'number' || size.credits <= 0) return '';
|
|
446
|
+
const n = size.credits;
|
|
447
|
+
const unit = n === 1 ? 'credit' : 'credits';
|
|
448
|
+
const est = typeof size.estimated === 'number' ? size.estimated : null;
|
|
449
|
+
const body = (size.historical === true && est > 0)
|
|
450
|
+
? `Worth ${escapeHtml(String(n))} ${unit} — ${escapeHtml(String(est))} of it estimated from work that shipped before this was paid`
|
|
451
|
+
: `Earned ${escapeHtml(String(n))} ${unit} so far`;
|
|
452
|
+
return `<span style="display:block;margin-top:4px;font-size:12px;color:var(--ink-faint)">${body}</span>`;
|
|
453
|
+
}
|
|
454
|
+
|
|
422
455
|
// What became of one idea, in a sentence a person can read. The four outcomes
|
|
423
456
|
// the criterion names, each linked through to the thing it became.
|
|
424
457
|
function ideaOutcomeHtml(idea) {
|
|
@@ -427,6 +460,10 @@
|
|
|
427
460
|
// "nothing is lost" means), and an idea whose first task was later split
|
|
428
461
|
// has a descent whatever its own status says.
|
|
429
462
|
const descent = descentHtml(idea);
|
|
463
|
+
// Size rides beside the descent under every outcome, and for the same
|
|
464
|
+
// reason: a discarded or merged idea can still have earned, and ADR 0313
|
|
465
|
+
// claws nothing back when work is removed.
|
|
466
|
+
const size = sizeHtml(idea);
|
|
430
467
|
const note = idea.triage_note
|
|
431
468
|
? `<span style="display:block;margin-top:2px;font-size:12px;color:var(--ink-faint)">${escapeHtml(idea.triage_note)}</span>`
|
|
432
469
|
: '';
|
|
@@ -442,19 +479,19 @@
|
|
|
442
479
|
: []);
|
|
443
480
|
// 'promoted' with nothing to point at is a real (old) state, not a bug to
|
|
444
481
|
// paper over — say what is known rather than inventing a link.
|
|
445
|
-
if (!links.length) return `<span class="task__tag">Promoted</span>${note}${descent}`;
|
|
446
|
-
return `<span class="task__tag">Became ${links.length === 1 ? 'a task' : `${links.length} tasks`}</span> ${links.join(' · ')}${note}${descent}`;
|
|
482
|
+
if (!links.length) return `<span class="task__tag">Promoted</span>${note}${descent}${size}`;
|
|
483
|
+
return `<span class="task__tag">Became ${links.length === 1 ? 'a task' : `${links.length} tasks`}</span> ${links.join(' · ')}${note}${descent}${size}`;
|
|
447
484
|
}
|
|
448
485
|
if (idea.status === 'merged') {
|
|
449
486
|
const target = idea.merged_into_idea_id
|
|
450
487
|
? ` <a href="${ideaHref(idea.merged_into_idea_id)}">#${escapeHtml(String(idea.merged_into_idea_id))} ${escapeHtml(idea.merged_into_idea_title || '')}</a>`
|
|
451
488
|
: '';
|
|
452
|
-
return `<span class="task__tag">Merged into</span>${target}${note}${descent}`;
|
|
489
|
+
return `<span class="task__tag">Merged into</span>${target}${note}${descent}${size}`;
|
|
453
490
|
}
|
|
454
491
|
if (idea.status === 'discarded') {
|
|
455
|
-
return `<span class="task__tag">Discarded</span>${note}${descent}`;
|
|
492
|
+
return `<span class="task__tag">Discarded</span>${note}${descent}${size}`;
|
|
456
493
|
}
|
|
457
|
-
return `<span class="task__tag">Still open</span>${note}${descent}`;
|
|
494
|
+
return `<span class="task__tag">Still open</span>${note}${descent}${size}`;
|
|
458
495
|
}
|
|
459
496
|
|
|
460
497
|
// One ledger driven by one server-paged endpoint. `fetchPage(offset, perPage)`
|
|
@@ -39,6 +39,10 @@ const inbox = require('../inbox');
|
|
|
39
39
|
// own file because inbox.js is AT the size ratchet and because a task->task
|
|
40
40
|
// walk is a different relation from the idea->task trail.
|
|
41
41
|
const { attachDescentToTrail } = require('../trail-descent');
|
|
42
|
+
// SIZE — the sum of what that descent has PAID (task 1004093, BV2.R12). Its own
|
|
43
|
+
// file for the same two reasons trail-descent.js is: inbox.js is at the size
|
|
44
|
+
// ratchet, and a ledger read keyed by idea is a third relation again.
|
|
45
|
+
const { attachSizeToTrail } = require('../trail-size');
|
|
42
46
|
// The walk itself, published as the `ideas.descent` port below.
|
|
43
47
|
const descent = require('../descent');
|
|
44
48
|
// Full Idea capture (task 1002919 / BV1.R143): the template validator (R141)
|
|
@@ -588,6 +592,11 @@ module.exports = function buildInboxRouter() {
|
|
|
588
592
|
// and the walk is batched across the WHOLE page — so it stays one query per
|
|
589
593
|
// level however many ideas the window carries, never one walk per idea.
|
|
590
594
|
await attachDescentToTrail(ideas);
|
|
595
|
+
// SIZE — what that descent has actually PAID (BV2.R12, spec decision 4).
|
|
596
|
+
// After the descent and not inside it: the descent is the picture, size is
|
|
597
|
+
// the number, and the number is a ledger read that does not depend on the
|
|
598
|
+
// walk having run. One query for the whole page, through the `reward` port.
|
|
599
|
+
await attachSizeToTrail(ideas);
|
|
591
600
|
return res.json({ builder_id: builderId, ideas, total, limit, offset });
|
|
592
601
|
}, { errorCode: 'idea_trail_failed' }));
|
|
593
602
|
|