@bongos/core 1.20.58 → 1.20.59
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 +80 -40
- package/clients/bongos-client/index.d.ts +2 -2
- package/docs/api/openapi.json +9 -3
- package/docs/api-reference.md +1 -1
- package/docs/architecture.md +1 -0
- package/docs/copy-inventory.md +1 -1
- package/docs/copy-registry.json +9 -9
- package/docs/module-api-changelog.md +2 -0
- package/docs/page-readings.json +30 -30
- package/migrations/core_268_builders_main_discipline.sql +35 -0
- package/modules/copy-desk/artist-stats.js +56 -0
- package/modules/copy-desk/module.json +1 -1
- package/modules/copy-desk/page-status.js +39 -0
- package/modules/copy-desk/routes/copy-desk.js +7 -16
- package/modules/copy-desk/tests/copy_no_cms.mjs +10 -1
- package/modules/economy/credits-by-craft.js +208 -0
- package/modules/economy/reward.js +8 -0
- package/modules/hall-ui/public/hall-render.js +2 -2
- package/modules/hall-ui/public/profile.js +1 -1
- package/modules/ideas/ideator-stats.js +149 -0
- package/modules/ideas/module.json +2 -1
- package/modules/ideas/routes/inbox.js +7 -0
- package/modules/lifecycle/activity.js +5 -2
- package/modules/lifecycle/db-analytics.js +4 -2
- package/modules/lifecycle/db-tasks.js +49 -1
- package/modules/lifecycle/db.js +3 -2
- package/modules/lifecycle/page-tweak-reads.js +23 -5
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +6 -0
- package/scripts/gds/run-unit-tests.js +5 -0
- package/src/bongos/db.js +44 -6
- package/src/bongos/role-stats.js +155 -0
- package/src/bongos/routes/builders.js +15 -2
- package/src/bongos/routes/me.js +11 -3
- package/src/module-api.js +1 -1
- package/tests/copy_desk_page_reads.mjs +1 -1
- package/tests/fixtures/page-ledger.mjs +1 -1
- package/tests/main_discipline.mjs +81 -0
- package/tests/profile_role_stats.mjs +416 -0
- package/tests/role_stats_db.mjs +237 -0
- package/tests/tweak_replaces_artist_review.mjs +1 -1
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// modules/economy/credits-by-craft.js — WHICH CRAFT EARNED A BUILDER'S CREDITS
|
|
4
|
+
// (task 1004433 / WA6.RP03, goal 1000095, criterion wa6-roles-read-true-in-public).
|
|
5
|
+
//
|
|
6
|
+
// WHY. builders.total_credits is one number, and every public surface read it as
|
|
7
|
+
// "payout from shipped work". That is true for an engineer and false for the
|
|
8
|
+
// other two crafts: an artist is paid per approved page (`tweak.page_approved:`),
|
|
9
|
+
// an ideator per idea stream (`idea.credit.*`), and neither has to ship a task to
|
|
10
|
+
// earn. The owner's 2026-09-30 rules need the split: a role other than the main
|
|
11
|
+
// one shows (chip, Activity tab, leaderboard entry) only once it has EARNED
|
|
12
|
+
// credits, and a leaderboard filtered to one role ranks by the credits earned in
|
|
13
|
+
// that craft. So the split is a ledger fact, read here, once.
|
|
14
|
+
//
|
|
15
|
+
// THE RULE, per credit_log row:
|
|
16
|
+
//
|
|
17
|
+
// tweak.page_approved:<task> artist (ADR 0341 D10, the artist lane)
|
|
18
|
+
// idea.credit.* · idea_promotion_bonus ideator (ADR 0172, every idea stream,
|
|
19
|
+
// the spark/developer split too)
|
|
20
|
+
// task.shipped · task.confirmed · the craft of the TASK's discipline
|
|
21
|
+
// ship.net_negative
|
|
22
|
+
// session.token_reward the craft of the tasks that session
|
|
23
|
+
// shipped, split evenly across them; a
|
|
24
|
+
// session that names none is engineering
|
|
25
|
+
// (an AI build session is, by default)
|
|
26
|
+
// anything else (achievement.*, `other` — credited, but to no craft
|
|
27
|
+
// rank_change, a hand grant)
|
|
28
|
+
//
|
|
29
|
+
// A task's discipline maps to a craft through DISCIPLINE_CRAFT. `ui` is the
|
|
30
|
+
// module-contributed design discipline (ADR 0079, 0272) and counts as ARTIST:
|
|
31
|
+
// the artist's craft is how the project looks and reads (docs/packs/artist.md),
|
|
32
|
+
// and UI design is that. A discipline this table does not know (a module may
|
|
33
|
+
// contribute one later) counts as engineering, the default craft, rather than
|
|
34
|
+
// vanishing from every total.
|
|
35
|
+
//
|
|
36
|
+
// THE SUM IS EXACT. A session's reward split across three tasks is a third each,
|
|
37
|
+
// so the per-craft figures arrive as fractions. They are rounded by LARGEST
|
|
38
|
+
// REMAINDER against the builder's true ledger total, so the crafts and `other`
|
|
39
|
+
// always add up to exactly what the ledger holds. Rounding each craft on its own
|
|
40
|
+
// would let the parts disagree with the whole by a credit, on a page that shows
|
|
41
|
+
// both.
|
|
42
|
+
//
|
|
43
|
+
// ONLY READS. Nothing here writes, and nothing is reachable from a ship path.
|
|
44
|
+
|
|
45
|
+
const api = require('../../src/module-api');
|
|
46
|
+
const { pool } = api;
|
|
47
|
+
const { ARTIST_PAGE_REASON_PREFIX } = require('./credits');
|
|
48
|
+
|
|
49
|
+
const CRAFTS = Object.freeze(['engineer', 'artist', 'ideator']);
|
|
50
|
+
const DEFAULT_CRAFT = 'engineer';
|
|
51
|
+
const OTHER = 'other';
|
|
52
|
+
|
|
53
|
+
const DISCIPLINE_CRAFT = Object.freeze({
|
|
54
|
+
engineer: 'engineer',
|
|
55
|
+
artist: 'artist',
|
|
56
|
+
ui: 'artist',
|
|
57
|
+
ideator: 'ideator',
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
// The per-task reasons whose craft is the task's own. The same three the
|
|
61
|
+
// lifecycle's CREDITS_AWARDED_SUBQUERY sums as a task's payout.
|
|
62
|
+
const TASK_REASONS = Object.freeze(['task.shipped', 'task.confirmed', 'ship.net_negative']);
|
|
63
|
+
|
|
64
|
+
// The craft a builder's PREFERRED discipline names, or null for one that is not
|
|
65
|
+
// a craft at all. The same table, read the other way round: an unknown
|
|
66
|
+
// discipline is engineering money (above) but is no role chip. core's
|
|
67
|
+
// src/bongos/role-stats.js asks this through the reward port, so the ui-is-artist
|
|
68
|
+
// rule has exactly one home.
|
|
69
|
+
function craftForPreference(discipline) {
|
|
70
|
+
return DISCIPLINE_CRAFT[discipline] || null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function craftOfDiscipline(discipline) {
|
|
74
|
+
if (discipline === OTHER) return OTHER;
|
|
75
|
+
return DISCIPLINE_CRAFT[discipline] || DEFAULT_CRAFT;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// The artist prefix goes into the SQL as a LIKE pattern, so its underscore (a
|
|
79
|
+
// LIKE wildcard) is escaped, the idea-size.js rule. Checked at load: the prefix
|
|
80
|
+
// is economy's own constant, never a request value.
|
|
81
|
+
if (!/^[a-z0-9_.]+:$/i.test(ARTIST_PAGE_REASON_PREFIX)) {
|
|
82
|
+
throw new Error('economy/credits-by-craft: the artist reason prefix is not a plain literal');
|
|
83
|
+
}
|
|
84
|
+
const ARTIST_LIKE = ARTIST_PAGE_REASON_PREFIX.replace(/([\\%_])/g, '\\$1') + '%';
|
|
85
|
+
|
|
86
|
+
// One statement for the whole set of builders. Each ledger row becomes one or
|
|
87
|
+
// more (builder, discipline, amount) parts, then they are grouped. The session
|
|
88
|
+
// branch is the only one that fans out: one row per task its session shipped.
|
|
89
|
+
//
|
|
90
|
+
// THE INDEXES IT RIDES, all pre-existing, so this needs no migration: both
|
|
91
|
+
// branches filter `cl.builder_id = ANY($1)`, served by idx_credit_log_builder
|
|
92
|
+
// (builder_id, recorded_at) from migrations/003_pms.sql, so the read is that
|
|
93
|
+
// person's rows, never the whole ledger; the task join is the tasks primary
|
|
94
|
+
// key, and the session join is session_records' UNIQUE (session_id) from
|
|
95
|
+
// migrations/062_session_records.sql. tests/profile_role_stats.mjs pins both
|
|
96
|
+
// index definitions, so dropping either reds a test rather than going quiet.
|
|
97
|
+
const CREDITS_BY_CRAFT_SQL = `
|
|
98
|
+
WITH parts AS (
|
|
99
|
+
SELECT cl.builder_id,
|
|
100
|
+
cl.delta::numeric AS amount,
|
|
101
|
+
CASE
|
|
102
|
+
WHEN cl.reason LIKE $2 THEN 'artist'
|
|
103
|
+
WHEN cl.reason LIKE 'idea.credit.%' OR cl.reason = 'idea_promotion_bonus' THEN 'ideator'
|
|
104
|
+
WHEN cl.reason = ANY($3::text[]) THEN COALESCE(t.discipline, '${DEFAULT_CRAFT}')
|
|
105
|
+
ELSE '${OTHER}'
|
|
106
|
+
END AS discipline
|
|
107
|
+
FROM credit_log cl
|
|
108
|
+
LEFT JOIN tasks t ON t.id = cl.task_id
|
|
109
|
+
WHERE cl.builder_id = ANY($1::bigint[])
|
|
110
|
+
AND cl.reason <> 'session.token_reward'
|
|
111
|
+
UNION ALL
|
|
112
|
+
SELECT cl.builder_id,
|
|
113
|
+
cl.delta::numeric / GREATEST(COALESCE(cardinality(sr.task_ids), 0), 1) AS amount,
|
|
114
|
+
COALESCE(st.discipline, '${DEFAULT_CRAFT}') AS discipline
|
|
115
|
+
FROM credit_log cl
|
|
116
|
+
LEFT JOIN session_records sr ON sr.session_id = cl.session_id
|
|
117
|
+
LEFT JOIN LATERAL unnest(COALESCE(sr.task_ids, '{}'::bigint[])) AS u(task_id) ON true
|
|
118
|
+
LEFT JOIN tasks st ON st.id = u.task_id
|
|
119
|
+
WHERE cl.builder_id = ANY($1::bigint[])
|
|
120
|
+
AND cl.reason = 'session.token_reward'
|
|
121
|
+
)
|
|
122
|
+
SELECT builder_id, discipline, SUM(amount)::float8 AS amount
|
|
123
|
+
FROM parts
|
|
124
|
+
GROUP BY builder_id, discipline`;
|
|
125
|
+
|
|
126
|
+
// apportion — round fractional parts to integers that sum to `total` exactly
|
|
127
|
+
// (largest remainder; ties go to the earlier key, so the answer is stable).
|
|
128
|
+
// Pure. `parts` is { key: number }, in the order keys should win ties.
|
|
129
|
+
function apportion(parts, total) {
|
|
130
|
+
const keys = Object.keys(parts);
|
|
131
|
+
const out = {};
|
|
132
|
+
let floorSum = 0;
|
|
133
|
+
const rema = [];
|
|
134
|
+
keys.forEach((k, i) => {
|
|
135
|
+
const v = Number(parts[k]) || 0;
|
|
136
|
+
const f = Math.floor(v);
|
|
137
|
+
out[k] = f;
|
|
138
|
+
floorSum += f;
|
|
139
|
+
rema.push({ k, r: v - f, i });
|
|
140
|
+
});
|
|
141
|
+
// When `total` is the rounded sum of the parts (the only way this file calls
|
|
142
|
+
// it), `left` is round(sum of remainders), so 0 <= left <= keys.length and one
|
|
143
|
+
// pass hands out every unit.
|
|
144
|
+
const left = Math.round(Number(total) || 0) - floorSum;
|
|
145
|
+
rema.sort((a, b) => b.r - a.r || a.i - b.i);
|
|
146
|
+
for (let j = 0; j < left && j < rema.length; j++) out[rema[j].k] += 1;
|
|
147
|
+
return out;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// foldCraftRows — the grouped SQL rows into one exact row per builder. Pure,
|
|
151
|
+
// so the rounding and the discipline mapping are testable without a database.
|
|
152
|
+
// Returns Map<builderIdString, { engineer, artist, ideator, other, total }>,
|
|
153
|
+
// with every builder asked for present (at zero when they have earned nothing:
|
|
154
|
+
// "no credits" is an answer, not a gap).
|
|
155
|
+
function foldCraftRows(ids, rows) {
|
|
156
|
+
const raw = new Map();
|
|
157
|
+
for (const id of ids) {
|
|
158
|
+
const parts = {};
|
|
159
|
+
for (const c of CRAFTS) parts[c] = 0;
|
|
160
|
+
parts[OTHER] = 0;
|
|
161
|
+
raw.set(id, parts);
|
|
162
|
+
}
|
|
163
|
+
for (const r of rows || []) {
|
|
164
|
+
const parts = raw.get(String(r.builder_id));
|
|
165
|
+
if (!parts) continue;
|
|
166
|
+
parts[craftOfDiscipline(r.discipline)] += Number(r.amount) || 0;
|
|
167
|
+
}
|
|
168
|
+
const out = new Map();
|
|
169
|
+
for (const [id, parts] of raw) {
|
|
170
|
+
const total = Math.round(Object.values(parts).reduce((a, b) => a + b, 0));
|
|
171
|
+
out.set(id, { ...apportion(parts, total), total });
|
|
172
|
+
}
|
|
173
|
+
return out;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function idOf(v) {
|
|
177
|
+
return /^[1-9]\d*$/.test(String(v)) ? String(v) : null;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// creditsByCraft — the split for MANY builders in ONE query. The leaderboard's
|
|
181
|
+
// role toggle (RP07) asks for a page of builders at once, the profile for one.
|
|
182
|
+
async function creditsByCraft(builderIds, { client = null } = {}) {
|
|
183
|
+
const ids = [...new Set((Array.isArray(builderIds) ? builderIds : [builderIds]).map(idOf).filter(Boolean))];
|
|
184
|
+
if (ids.length === 0) return new Map();
|
|
185
|
+
const q = client || pool;
|
|
186
|
+
const { rows } = await q.query(CREDITS_BY_CRAFT_SQL, [ids, ARTIST_LIKE, TASK_REASONS]);
|
|
187
|
+
return foldCraftRows(ids, rows);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// The crafts a builder has EARNED in, in CRAFTS order. The owner's rule is that
|
|
191
|
+
// a role other than the main one shows only once it has earned credits; this is
|
|
192
|
+
// the "has earned" half, and the only place it is decided.
|
|
193
|
+
function earnedCrafts(row) {
|
|
194
|
+
if (!row) return [];
|
|
195
|
+
return CRAFTS.filter((c) => Number(row[c]) > 0);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
module.exports = {
|
|
199
|
+
creditsByCraft,
|
|
200
|
+
foldCraftRows,
|
|
201
|
+
apportion,
|
|
202
|
+
craftOfDiscipline,
|
|
203
|
+
craftForPreference,
|
|
204
|
+
earnedCrafts,
|
|
205
|
+
CRAFTS,
|
|
206
|
+
DISCIPLINE_CRAFT,
|
|
207
|
+
CREDITS_BY_CRAFT_SQL,
|
|
208
|
+
};
|
|
@@ -22,6 +22,7 @@ const streak = require('./streak');
|
|
|
22
22
|
const ideaSizeLib = require('./idea-size');
|
|
23
23
|
const ideaSizeHistorical = require('./idea-size-historical');
|
|
24
24
|
const ideaBrightnessLib = require('./idea-brightness');
|
|
25
|
+
const creditsByCraftLib = require('./credits-by-craft');
|
|
25
26
|
|
|
26
27
|
module.exports = {
|
|
27
28
|
// credit allocation — transaction-participant: caller passes its tx `client`
|
|
@@ -125,6 +126,13 @@ module.exports = {
|
|
|
125
126
|
// still alive.
|
|
126
127
|
ideaBrightness: (...a) => ideaBrightnessLib.ideaBrightness(...a),
|
|
127
128
|
ideaBrightnesses: (...a) => ideaBrightnessLib.ideaBrightnesses(...a),
|
|
129
|
+
// WHICH CRAFT EARNED IT (task 1004433 / WA6.RP03). total_credits split into
|
|
130
|
+
// engineer / artist / ideator / other, exact to the ledger. The profile reads
|
|
131
|
+
// it to decide which roles show; the leaderboard's role toggle ranks by it.
|
|
132
|
+
creditsByCraft: (...a) => creditsByCraftLib.creditsByCraft(...a),
|
|
133
|
+
earnedCrafts: (...a) => creditsByCraftLib.earnedCrafts(...a),
|
|
134
|
+
craftForPreference: (...a) => creditsByCraftLib.craftForPreference(...a),
|
|
135
|
+
get CRAFTS() { return creditsByCraftLib.CRAFTS; },
|
|
128
136
|
computeStreak: (...a) => streak.computeStreak(...a),
|
|
129
137
|
wouldExtendStreak: (...a) => streak.wouldExtendStreak(...a),
|
|
130
138
|
};
|
|
@@ -510,8 +510,8 @@
|
|
|
510
510
|
? `+${fmtNum(karma30d)} in 30d`
|
|
511
511
|
: (karma30d < 0 ? `${fmtNum(karma30d)} in 30d` : '±0 in 30d');
|
|
512
512
|
const karmaTrend = `<span class="karma-badge__trend ${signClasses(karma30d, 'karma-badge__trend')}">${karmaTrendText}</span>`;
|
|
513
|
-
const karmaTooltip = `Karma = peer recognition and approvals. ${curLabel(true)} =
|
|
514
|
-
const drachmaeTooltip = `${curLabel(true)} =
|
|
513
|
+
const karmaTooltip = `Karma = peer recognition and approvals. ${curLabel(true)} = everything earned. The two counters move independently — see the karma feed for the audit trail.`;
|
|
514
|
+
const drachmaeTooltip = `${curLabel(true)} = everything earned: shipped work, approved pages and ideas. Karma is a separate counter for peer recognition and approvals.`;
|
|
515
515
|
|
|
516
516
|
// V3.R84 (#302), criterion C3: monthly-budget banner. `warn` (>=80%) is a
|
|
517
517
|
// heads-up; `over` (>=100%) means cost-incurring actions (ship, art pipeline)
|
|
@@ -105,7 +105,7 @@
|
|
|
105
105
|
${disciplines}
|
|
106
106
|
</div>
|
|
107
107
|
<div class="profile__credits">
|
|
108
|
-
<span class="profile__credits-num" title="${escapeHtml(curLabel(true))} —
|
|
108
|
+
<span class="profile__credits-num" title="${escapeHtml(curLabel(true))} — everything earned: shipped work, approved pages and ideas.">${fmtNum(builder.total_credits)}</span>
|
|
109
109
|
<span class="profile__credits-label">${escapeHtml(curLabel())}</span>
|
|
110
110
|
<span class="profile__karma" title="Karma — peer recognition and approvals, a separate counter from ${escapeHtml(curLabel())}.">
|
|
111
111
|
<span class="profile__karma-num">${fmtNum(builder.karma)}</span>
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// modules/ideas/ideator-stats.js — THE IDEATOR'S NUMBERS for the profile
|
|
4
|
+
// (task 1004433 / WA6.RP03, goal 1000095, criterion wa6-roles-read-true-in-public;
|
|
5
|
+
// the owner-approved mock is docs/design/mocks/profile-roles/, task 1004431).
|
|
6
|
+
//
|
|
7
|
+
// The profile's header and "By the numbers" lead with each person's own craft.
|
|
8
|
+
// For an ideator that is six numbers, and every one of them is a COUNT or a SUM
|
|
9
|
+
// over the sky's own derivation, never a second one:
|
|
10
|
+
//
|
|
11
|
+
// filed thoughts filed (the person's trail, skill-friction rows left out)
|
|
12
|
+
// ignited thoughts that became paid work (state.hasWork, the sky's ignition)
|
|
13
|
+
// full Full Ideas (grade 'full')
|
|
14
|
+
// ratified Full Ideas the Board Room carried (government.boardPassForIdea)
|
|
15
|
+
// size credits earned from ideas across EVERY idea stream, plus the
|
|
16
|
+
// historical estimate, with the marker that says so
|
|
17
|
+
// alive the share of the work their thoughts caused that is still alive
|
|
18
|
+
//
|
|
19
|
+
// WHERE EACH COMES FROM. The trail is `readSkyMine` (./sky-mine.js), which walks
|
|
20
|
+
// the whole trail and runs the three attachers in the trail route's order, so a
|
|
21
|
+
// thought's size, brightness and state here are the ones the sky draws. Nothing
|
|
22
|
+
// in this file derives a state.
|
|
23
|
+
//
|
|
24
|
+
// SIZE IS EVERY STREAM, NOT STREAM 1. A thought's drawn size is its stream-1
|
|
25
|
+
// payout (economy/idea-size.js: the work it caused). The PERSON's total is a
|
|
26
|
+
// different question, "what has thinking earned you", and the owner's mock
|
|
27
|
+
// answers it across every way ideas are paid: the task ship and its descent, the
|
|
28
|
+
// Full Idea pass, the board pass and the spark/developer split. That is the
|
|
29
|
+
// ledger's ideator column (economy/credits-by-craft.js, through the `reward`
|
|
30
|
+
// port), so the headline agrees with the earnings chart to the credit. The
|
|
31
|
+
// historical estimate (spec decision 17) rides on top, from the per-thought
|
|
32
|
+
// sizes the sky already priced: money the ledger was never there to pay, so it
|
|
33
|
+
// is reported apart as `size.estimated` and marked, never folded in silently.
|
|
34
|
+
//
|
|
35
|
+
// ALIVE IS WEIGHTED BY SIZE, like a single thought's brightness: the live
|
|
36
|
+
// credits over all credits, across every thought that ignited. A thought that
|
|
37
|
+
// could not be read (`attributable: false`) makes the whole share unknown
|
|
38
|
+
// (null) rather than quietly counting it as alive, and a person whose thoughts
|
|
39
|
+
// caused nothing yet has no share at all (null, `has_work: false`): zero over
|
|
40
|
+
// zero is not "nothing survived". The two nulls are told apart by `has_work`.
|
|
41
|
+
//
|
|
42
|
+
// PUBLIC BY THE OWNER'S DECISION (a) of 2026-09-30: the identity band, the
|
|
43
|
+
// tally and "By the numbers" stay public, the trail itself does not. So these
|
|
44
|
+
// are counts and sums only. No title, no id, no triage note leaves this file,
|
|
45
|
+
// and the read is the same for every viewer: the skill-friction gate is applied
|
|
46
|
+
// at the least-privileged real rank, because those rows are a routine's
|
|
47
|
+
// filings, not a person's thoughts, and a count must not change with who looks.
|
|
48
|
+
|
|
49
|
+
const api = require('../../src/module-api');
|
|
50
|
+
const { readSkyMine } = require('./sky-mine');
|
|
51
|
+
|
|
52
|
+
// The rank the counts are read at. xenos is the one rank the skill-friction gate
|
|
53
|
+
// hides those rows from (./skill-friction.js); reading at it makes the public
|
|
54
|
+
// count the count of the person's own thoughts.
|
|
55
|
+
const PUBLIC_VIEWER_RANK = 'xenos';
|
|
56
|
+
|
|
57
|
+
function int(v) {
|
|
58
|
+
const n = Number(v);
|
|
59
|
+
return Number.isFinite(n) ? Math.round(n) : 0;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// summarizeIdeatorTrail — the six numbers from a trail that already carries
|
|
63
|
+
// size and state. Pure. `ideatorCredits` is the ledger's ideator column (null
|
|
64
|
+
// when economy could not be asked), `ratifiedIds` the set of idea ids the board
|
|
65
|
+
// carried (null when the government port is absent).
|
|
66
|
+
function summarizeIdeatorTrail(ideas, { ideatorCredits = null, ratifiedIds = null, truncated = false, total = null } = {}) {
|
|
67
|
+
const list = Array.isArray(ideas) ? ideas : [];
|
|
68
|
+
let ignited = 0;
|
|
69
|
+
let full = 0;
|
|
70
|
+
let ratified = 0;
|
|
71
|
+
let estimated = 0;
|
|
72
|
+
let estimateKnown = true;
|
|
73
|
+
let credits = 0;
|
|
74
|
+
let live = 0;
|
|
75
|
+
let attributable = true;
|
|
76
|
+
for (const idea of list) {
|
|
77
|
+
if (!idea) continue;
|
|
78
|
+
const st = idea.state || {};
|
|
79
|
+
if (st.hasWork === true) ignited++;
|
|
80
|
+
if (idea.grade === 'full') {
|
|
81
|
+
full++;
|
|
82
|
+
if (ratifiedIds && ratifiedIds.has(String(idea.id))) ratified++;
|
|
83
|
+
}
|
|
84
|
+
const size = idea.size;
|
|
85
|
+
if (!size) { estimateKnown = false; continue; }
|
|
86
|
+
if (size.estimated == null) estimateKnown = false;
|
|
87
|
+
else estimated += int(size.estimated);
|
|
88
|
+
if (st.hasWork === true) {
|
|
89
|
+
const c = Number(size.credits) || 0;
|
|
90
|
+
credits += c;
|
|
91
|
+
if (st.attributable !== true || st.brightness == null) attributable = false;
|
|
92
|
+
else live += c * Number(st.brightness);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
const paid = ideatorCredits == null ? null : int(ideatorCredits);
|
|
96
|
+
const est = estimateKnown ? estimated : null;
|
|
97
|
+
return {
|
|
98
|
+
// The trail's own count, not the page in hand: past the sky's cap the list
|
|
99
|
+
// is the newest thousand and `truncated` says the other numbers are partial.
|
|
100
|
+
filed: Number.isInteger(total) ? total : list.length,
|
|
101
|
+
ignited,
|
|
102
|
+
full_ideas: full,
|
|
103
|
+
ratified: ratifiedIds ? ratified : null,
|
|
104
|
+
size: {
|
|
105
|
+
total: paid == null ? null : paid + (est || 0),
|
|
106
|
+
paid,
|
|
107
|
+
estimated: est,
|
|
108
|
+
historical: est == null ? null : est > 0,
|
|
109
|
+
},
|
|
110
|
+
alive: {
|
|
111
|
+
share: credits > 0 && attributable ? live / credits : null,
|
|
112
|
+
has_work: credits > 0,
|
|
113
|
+
attributable: credits > 0 ? attributable : null,
|
|
114
|
+
},
|
|
115
|
+
truncated: truncated === true,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// readIdeatorStats — the async half: the trail, the ledger column, the board.
|
|
120
|
+
// `deps` lets a test hand in each port; production resolves them optionally, so
|
|
121
|
+
// an instance without economy or the board reads `null` for those numbers
|
|
122
|
+
// (could not ask) rather than 0 (asked, and nothing was there).
|
|
123
|
+
async function readIdeatorStats(builderId, deps = {}) {
|
|
124
|
+
const read = deps.readSkyMine || readSkyMine;
|
|
125
|
+
const reward = 'reward' in deps ? deps.reward : (api.resolveOptional && api.resolveOptional('reward'));
|
|
126
|
+
const government = 'government' in deps ? deps.government : (api.resolveOptional && api.resolveOptional('government'));
|
|
127
|
+
|
|
128
|
+
const sky = await read({ builderId, viewerRank: PUBLIC_VIEWER_RANK });
|
|
129
|
+
const ideas = (sky && sky.ideas) || [];
|
|
130
|
+
|
|
131
|
+
let ideatorCredits = null;
|
|
132
|
+
if (reward && typeof reward.creditsByCraft === 'function') {
|
|
133
|
+
const byCraft = await reward.creditsByCraft([builderId]);
|
|
134
|
+
const row = byCraft.get(String(builderId));
|
|
135
|
+
ideatorCredits = row ? row.ideator : 0;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
let ratifiedIds = null;
|
|
139
|
+
if (government && typeof government.boardPassForIdea === 'function') {
|
|
140
|
+
ratifiedIds = new Set();
|
|
141
|
+
const fulls = ideas.filter((i) => i && i.grade === 'full');
|
|
142
|
+
const passes = await Promise.all(fulls.map((i) => government.boardPassForIdea(i.id)));
|
|
143
|
+
fulls.forEach((i, k) => { if (passes[k]) ratifiedIds.add(String(i.id)); });
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return summarizeIdeatorTrail(ideas, { ideatorCredits, ratifiedIds, truncated: sky && sky.truncated, total: sky && sky.total });
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
module.exports = { readIdeatorStats, summarizeIdeatorTrail, PUBLIC_VIEWER_RANK };
|
|
@@ -36,6 +36,7 @@ const express = require('express');
|
|
|
36
36
|
const api = require('../../../src/module-api');
|
|
37
37
|
const inbox = require('../inbox');
|
|
38
38
|
const bornOf = require('../born-of');
|
|
39
|
+
const ideatorStats = require('../ideator-stats');
|
|
39
40
|
// The trail read to the FULL depth of the descent (task 1004089, BV2.R09). Its
|
|
40
41
|
// own file because inbox.js is AT the size ratchet and because a task->task
|
|
41
42
|
// walk is a different relation from the idea->task trail.
|
|
@@ -108,6 +109,12 @@ function registerSeams() {
|
|
|
108
109
|
descendantsOfTask: descent.descendantsOfTask,
|
|
109
110
|
});
|
|
110
111
|
}
|
|
112
|
+
// task 1004433 (WA6.RP03): the ideator's numbers for the profile, counts and
|
|
113
|
+
// sums only, so core's profile route can show them without reading an ideas_
|
|
114
|
+
// table or deriving a state (../ideator-stats.js).
|
|
115
|
+
if (!api.hasProvider('ideas.ideatorStats')) {
|
|
116
|
+
api.registerProvider('ideas.ideatorStats', ideatorStats.readIdeatorStats);
|
|
117
|
+
}
|
|
111
118
|
}
|
|
112
119
|
|
|
113
120
|
// The author-facing shape of a ratification window this request just opened —
|
|
@@ -36,7 +36,10 @@
|
|
|
36
36
|
const api = require('../../src/module-api');
|
|
37
37
|
const { pool } = api;
|
|
38
38
|
const { SMOKE_NOT_LIKE } = require('./db-shared.js');
|
|
39
|
-
|
|
39
|
+
// What THIS person was paid for each row, not the task's whole payout (task
|
|
40
|
+
// 1004433): on a page tweak the artist and the applier are two people's pay, and
|
|
41
|
+
// under cost-plus-only the per-task estimate books nothing at all.
|
|
42
|
+
const { creditsEarnedBySql } = require('./db-tasks.js');
|
|
40
43
|
|
|
41
44
|
// The craft vocabulary this read accepts. Mirrors tasks_discipline_check
|
|
42
45
|
// (migrations 086 + 176). Anything else is treated as "no filter" rather than
|
|
@@ -97,7 +100,7 @@ async function activityTasksByBuilder(builderId, { role = null, limit = DEFAULT_
|
|
|
97
100
|
`SELECT t.id, t.title, t.value_summary, t.visual_url, t.visual_alt,
|
|
98
101
|
t.version_id, t.goal_id, t.kind, t.status, t.shipped_at,
|
|
99
102
|
COALESCE(t.discipline, 'unclassified') AS discipline,
|
|
100
|
-
t.credits_reward, ${
|
|
103
|
+
t.credits_reward, ${creditsEarnedBySql('$1')} AS credits_awarded
|
|
101
104
|
FROM tasks t
|
|
102
105
|
WHERE ${where}
|
|
103
106
|
ORDER BY t.shipped_at DESC NULLS LAST, t.id DESC
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
const api = require('../../src/module-api');
|
|
14
14
|
const { pool, withTx } = api;
|
|
15
15
|
const { SMOKE_NOT_LIKE, gradePassCount, gradeQualityWhere, median } = require('./db-shared.js');
|
|
16
|
-
const { CREDITS_AWARDED_SUBQUERY } = require('./db-tasks.js');
|
|
16
|
+
const { CREDITS_AWARDED_SUBQUERY, creditsEarnedBySql } = require('./db-tasks.js');
|
|
17
17
|
const { helperJoinSql } = require('./page-tweak-artist.js');
|
|
18
18
|
|
|
19
19
|
// -------------------------------------------------------------------------
|
|
@@ -137,8 +137,10 @@ async function estimationDriftSummary() {
|
|
|
137
137
|
|
|
138
138
|
async function recentShippedTasksByBuilder(builderId, limit = 10) {
|
|
139
139
|
const { rows } = await pool.query(
|
|
140
|
+
// task 1004433: the profile's recent works show what THIS builder was paid
|
|
141
|
+
// (creditsEarnedBySql), not the task's whole payout across everyone.
|
|
140
142
|
`SELECT t.id, t.title, t.value_summary, t.visual_url, t.visual_alt, t.version_id, t.status, t.shipped_at, t.credits_reward,
|
|
141
|
-
${
|
|
143
|
+
${creditsEarnedBySql('$1')} AS credits_awarded
|
|
142
144
|
FROM tasks t
|
|
143
145
|
WHERE t.shipped_by = $1
|
|
144
146
|
AND t.status = 'shipped'
|
|
@@ -676,14 +676,61 @@ async function updateTaskModuleKey(id, moduleKey) { return updateTaskColumn(id,
|
|
|
676
676
|
// CASE still short-circuits to 0 for unsettled tasks; note a 'confirmed' task
|
|
677
677
|
// now shows 0 until it LANDS (the credit no longer exists at confirm) — the UI
|
|
678
678
|
// renders the "Est." label until the 'task.shipped' row books at land.
|
|
679
|
+
//
|
|
680
|
+
// task 1004433 (WA6.RP03): the allowlist missed two of the per-task streams, so
|
|
681
|
+
// an approved page read "0 credits" and an idea's payout for the task it caused
|
|
682
|
+
// never counted. Both are booked against the task's own id and are payment FOR
|
|
683
|
+
// that task: `tweak.page_approved:<task>` (the artist lane, ADR 0341 D10, paid
|
|
684
|
+
// INSTEAD of the per-task estimate) and every `idea.credit.*` row carrying this
|
|
685
|
+
// task_id (stream 1 and its descent, ADR 0172 / 0312). Streams 2 and 3 carry no
|
|
686
|
+
// task_id and stay out, as they pay for the thought, not for any task. Matched
|
|
687
|
+
// as prefixes, never by equality: each key embeds the ids it was paid for.
|
|
688
|
+
const TASK_PAYOUT_REASON_SQL =
|
|
689
|
+
`(cl.reason IN ('task.confirmed', 'task.shipped', 'idea_promotion_bonus', 'ship.net_negative')
|
|
690
|
+
OR cl.reason LIKE 'tweak.page\\_approved:%'
|
|
691
|
+
OR cl.reason LIKE 'idea.credit.%')`;
|
|
679
692
|
const CREDITS_AWARDED_SUBQUERY =
|
|
680
693
|
`CASE WHEN t.status IN ('confirmed', 'shipped')
|
|
681
694
|
THEN (SELECT COALESCE(SUM(cl.delta), 0)::int
|
|
682
695
|
FROM credit_log cl
|
|
683
696
|
WHERE cl.task_id = t.id
|
|
684
|
-
AND
|
|
697
|
+
AND ${TASK_PAYOUT_REASON_SQL})
|
|
685
698
|
ELSE 0 END`;
|
|
686
699
|
|
|
700
|
+
// creditsEarnedBySql — what ONE person earned for a task, for the lists that
|
|
701
|
+
// say "this is what <person> was paid" (the profile's recent works and its
|
|
702
|
+
// Activity tab). CREDITS_AWARDED_SUBQUERY above is the TASK's payout across
|
|
703
|
+
// everyone, which was the wrong number there twice over: on a page tweak the
|
|
704
|
+
// artist's page credit and the applier's session reward are two people's pay,
|
|
705
|
+
// and under cost-plus-only (ADR 0146) the per-task estimate books nothing, so a
|
|
706
|
+
// builder who was paid normally read 0 on every row.
|
|
707
|
+
//
|
|
708
|
+
// Two halves, the two attribution keys sumSessionEarnings names (task 1003634):
|
|
709
|
+
// • every per-task payout row this builder holds against the task;
|
|
710
|
+
// • the cost-plus SESSION reward (task_id NULL, keyed by session) for each of
|
|
711
|
+
// this builder's sessions that shipped the task, split evenly across the
|
|
712
|
+
// tasks that session shipped. The session pays for its whole token spend,
|
|
713
|
+
// not for one task, so an even share is the honest per-task figure.
|
|
714
|
+
// `builderRef` is a bound parameter reference such as '$1', never a value.
|
|
715
|
+
// session_records is read through idx_session_records_builder.
|
|
716
|
+
function creditsEarnedBySql(builderRef) {
|
|
717
|
+
if (!/^\$\d+$/.test(builderRef)) throw new Error('creditsEarnedBySql: builderRef must be a bind parameter like $1');
|
|
718
|
+
return `CASE WHEN t.status IN ('confirmed', 'shipped')
|
|
719
|
+
THEN (COALESCE((SELECT SUM(cl.delta)
|
|
720
|
+
FROM credit_log cl
|
|
721
|
+
WHERE cl.task_id = t.id
|
|
722
|
+
AND cl.builder_id = ${builderRef}
|
|
723
|
+
AND ${TASK_PAYOUT_REASON_SQL}), 0)
|
|
724
|
+
+ COALESCE((SELECT ROUND(SUM(cl.delta::numeric / GREATEST(cardinality(sr.task_ids), 1)))
|
|
725
|
+
FROM session_records sr
|
|
726
|
+
JOIN credit_log cl ON cl.session_id = sr.session_id
|
|
727
|
+
AND cl.reason = 'session.token_reward'
|
|
728
|
+
AND cl.builder_id = ${builderRef}
|
|
729
|
+
WHERE sr.builder_id = ${builderRef}
|
|
730
|
+
AND t.id = ANY(sr.task_ids)), 0))::int
|
|
731
|
+
ELSE 0 END`;
|
|
732
|
+
}
|
|
733
|
+
|
|
687
734
|
async function getTask(id) {
|
|
688
735
|
const { rows } = await pool.query(
|
|
689
736
|
`SELECT t.*, ${CREDITS_AWARDED_SUBQUERY} AS credits_awarded,
|
|
@@ -1343,6 +1390,7 @@ async function listOpenArtistReviews({ source, refPrefix, resolvedStatuses = [],
|
|
|
1343
1390
|
|
|
1344
1391
|
module.exports = {
|
|
1345
1392
|
CREDITS_AWARDED_SUBQUERY,
|
|
1393
|
+
creditsEarnedBySql,
|
|
1346
1394
|
REWARD_SUGGESTION_CAP,
|
|
1347
1395
|
createCriterion,
|
|
1348
1396
|
createTask,
|
package/modules/lifecycle/db.js
CHANGED
|
@@ -123,15 +123,16 @@ const {
|
|
|
123
123
|
// the next setter costs nothing.
|
|
124
124
|
//
|
|
125
125
|
// TASK_FACADE_OMIT is what keeps this surface-PRESERVING rather than quietly
|
|
126
|
-
// wider.
|
|
126
|
+
// wider. Three of db-tasks.js's 34 exports were deliberately never re-exported
|
|
127
127
|
// here, and a bare spread would publish them — which ADR 0091 §3 treats as a
|
|
128
128
|
// real surface change, not a tidy-up. Naming them means dropping one has to be
|
|
129
129
|
// somebody's decision. tests/lifecycle_facade_surface.mjs pins the resulting key
|
|
130
130
|
// set, so this can never silently widen or narrow.
|
|
131
131
|
const dbTasks = require('./db-tasks.js');
|
|
132
132
|
const TASK_FACADE_OMIT = new Set([
|
|
133
|
-
// Internal to
|
|
133
|
+
// Internal to the lifecycle's own queries — SQL fragments, not callables.
|
|
134
134
|
'CREDITS_AWARDED_SUBQUERY',
|
|
135
|
+
'creditsEarnedBySql',
|
|
135
136
|
// The version-gate predicate; routes reach it through db-versions, not here.
|
|
136
137
|
'versionAcceptsNewWork',
|
|
137
138
|
]);
|
|
@@ -45,11 +45,27 @@ const artistRule = require('./page-tweak-artist');
|
|
|
45
45
|
// autosave runs this on every debounced save, and a page's rounds are a handful
|
|
46
46
|
// while the instance's grow without bound. The prefix is matched with left(),
|
|
47
47
|
// not LIKE, the page claim's own rule (an `_` in an id is never a wildcard).
|
|
48
|
-
|
|
48
|
+
//
|
|
49
|
+
// `artistId` narrows it to ONE artist's rounds (task 1004433 / WA6.RP03): the
|
|
50
|
+
// profile's artist numbers read this on a public page, and an artist's rounds
|
|
51
|
+
// are a handful while the instance's grow without bound. The rounds are found
|
|
52
|
+
// FROM that builder's own web claims (claims by builder_id), never by listing
|
|
53
|
+
// every round and filtering, and a round counts only while its LATEST web
|
|
54
|
+
// claim is still theirs: the D10 payee rule the unscoped read applies.
|
|
55
|
+
// `client` lets a caller run it inside its own transaction (tests/role_stats_db.mjs
|
|
56
|
+
// runs the artist branch against real Postgres, rolled back); default the pool.
|
|
57
|
+
async function listPageTweakTasks({ pageId = null, artistId = null, client = null } = {}) {
|
|
49
58
|
const prefix = pageId ? `page-tweak/${pageId}/` : null;
|
|
50
|
-
const
|
|
59
|
+
const artist = artistId == null ? null : String(artistId);
|
|
60
|
+
const { rows } = await (client || api.pool).query(
|
|
51
61
|
`WITH pt AS (
|
|
52
|
-
SELECT t.id FROM tasks t WHERE t.source = 'page-tweak'
|
|
62
|
+
SELECT t.id FROM tasks t WHERE $2::bigint IS NULL AND t.source = 'page-tweak'
|
|
63
|
+
AND ($1::text IS NULL OR left(t.source_ref, length($1::text)) = $1::text)
|
|
64
|
+
UNION
|
|
65
|
+
SELECT t.id FROM claims c JOIN tasks t ON t.id = c.task_id
|
|
66
|
+
WHERE $2::bigint IS NOT NULL AND c.builder_id = $2::bigint
|
|
67
|
+
AND ${artistRule.webClaimPredicate('c')}
|
|
68
|
+
AND t.source = 'page-tweak'
|
|
53
69
|
AND ($1::text IS NULL OR left(t.source_ref, length($1::text)) = $1::text)
|
|
54
70
|
), web AS (
|
|
55
71
|
SELECT DISTINCT ON (c.task_id) c.task_id, c.builder_id
|
|
@@ -63,7 +79,8 @@ async function listPageTweakTasks({ pageId = null } = {}) {
|
|
|
63
79
|
ac.builder_id AS holder_id, hb.github_login AS holder_login,
|
|
64
80
|
(ac.worktree_name IS NOT NULL) AS holder_worktree,
|
|
65
81
|
wc.builder_id AS artist_id, ab.github_login AS artist_login
|
|
66
|
-
FROM
|
|
82
|
+
FROM pt
|
|
83
|
+
JOIN tasks t ON t.id = pt.id
|
|
67
84
|
LEFT JOIN task_grades g ON g.task_id = t.id
|
|
68
85
|
LEFT JOIN claims ac ON ac.task_id = t.id AND ac.released_at IS NULL
|
|
69
86
|
LEFT JOIN builders hb ON hb.id = ac.builder_id
|
|
@@ -71,8 +88,9 @@ async function listPageTweakTasks({ pageId = null } = {}) {
|
|
|
71
88
|
LEFT JOIN builders ab ON ab.id = wc.builder_id
|
|
72
89
|
WHERE t.source = 'page-tweak'
|
|
73
90
|
AND ($1::text IS NULL OR left(t.source_ref, length($1::text)) = $1::text)
|
|
91
|
+
AND ($2::bigint IS NULL OR wc.builder_id = $2::bigint)
|
|
74
92
|
ORDER BY t.id ASC`,
|
|
75
|
-
[prefix]
|
|
93
|
+
[prefix, artist]
|
|
76
94
|
);
|
|
77
95
|
return rows;
|
|
78
96
|
}
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.20.
|
|
3
|
+
"version": "1.20.59",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.20.
|
|
9
|
+
"version": "1.20.59",
|
|
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.20.
|
|
3
|
+
"version": "1.20.59",
|
|
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",
|