@bongos/core 1.24.11 → 1.24.12
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 +138 -73
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +6 -0
- package/clients/bongos-client/index.cjs +6 -0
- package/clients/bongos-client/index.d.ts +8 -0
- package/clients/bongos-client/index.mjs +6 -0
- package/docs/api/openapi.json +175 -3
- package/docs/api-reference.md +5 -2
- package/docs/copy-inventory.md +92 -20
- package/docs/copy-registry.json +777 -33
- package/docs/module-api-changelog.md +2 -0
- package/docs/page-inventory.json +68 -4
- package/docs/page-readings.json +856 -802
- package/modules/hall-ui/public/ai-studio.html +1 -1
- package/modules/hall-ui/public/atlas.html +1 -1
- package/modules/hall-ui/public/blockers.html +1 -1
- package/modules/hall-ui/public/board-room.html +1 -1
- package/modules/hall-ui/public/city.html +1 -1
- package/modules/hall-ui/public/collab.html +1 -1
- package/modules/hall-ui/public/commands.html +1 -1
- package/modules/hall-ui/public/copy-desk.html +1 -1
- package/modules/hall-ui/public/craft-mode.js +2 -2
- package/modules/hall-ui/public/deploy.html +1 -1
- package/modules/hall-ui/public/diagrams.html +1 -1
- package/modules/hall-ui/public/drachmae.html +1 -1
- package/modules/hall-ui/public/fleet.html +1 -1
- package/modules/hall-ui/public/gate.html +1 -1
- package/modules/hall-ui/public/goal-map.html +1 -1
- package/modules/hall-ui/public/goals.html +1 -1
- package/modules/hall-ui/public/government.html +1 -1
- package/modules/hall-ui/public/idea.html +1 -1
- package/modules/hall-ui/public/ideas.html +1 -1
- package/modules/hall-ui/public/index.html +1 -1
- package/modules/hall-ui/public/market-watch-item.html +83 -0
- package/modules/hall-ui/public/market-watch-item.js +150 -0
- package/modules/hall-ui/public/market-watch-item.states.json +143 -0
- package/modules/hall-ui/public/market-watch-lib.js +243 -0
- package/modules/hall-ui/public/market-watch-rules.html +1 -1
- package/modules/hall-ui/public/market-watch-settings.html +1 -1
- package/modules/hall-ui/public/market-watch.css +169 -0
- package/modules/hall-ui/public/market-watch.html +111 -0
- package/modules/hall-ui/public/market-watch.js +129 -0
- package/modules/hall-ui/public/market-watch.states.json +14 -0
- package/modules/hall-ui/public/modules.html +1 -1
- package/modules/hall-ui/public/my-agents.html +1 -1
- package/modules/hall-ui/public/primer.html +1 -1
- package/modules/hall-ui/public/profile.html +1 -1
- package/modules/hall-ui/public/project-settings.html +1 -1
- package/modules/hall-ui/public/ranks.html +1 -1
- package/modules/hall-ui/public/roadmap.html +1 -1
- package/modules/hall-ui/public/roster.html +1 -1
- package/modules/hall-ui/public/sessions.html +1 -1
- package/modules/hall-ui/public/settings.html +1 -1
- package/modules/hall-ui/public/shell.js +3 -2
- package/modules/hall-ui/public/street.html +1 -1
- package/modules/hall-ui/public/studio.html +1 -1
- package/modules/hall-ui/public/task.html +1 -1
- package/modules/hall-ui/public/thinking.html +1 -1
- package/modules/hall-ui/public/tweak-editor.html +1 -1
- package/modules/hall-ui/public/watch.html +1 -1
- package/modules/hall-ui/public/work.html +1 -1
- package/modules/hall-ui/records/market-watch.md +40 -0
- package/modules/market-watch/db.js +10 -8
- package/modules/market-watch/module.json +2 -1
- package/modules/market-watch/pages.js +323 -0
- package/modules/market-watch/proposals.js +66 -4
- package/modules/market-watch/routes/pages.js +116 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +6 -0
- package/src/bongos/module-scope-map.js +1 -0
- package/src/module-api.js +1 -1
- package/tests/fixtures/market-watch-seed-rows.cjs +60 -0
- package/tests/hall_nav.mjs +4 -1
- package/tests/hall_page_gate_map.mjs +4 -0
- package/tests/market_watch_module.mjs +2 -1
- package/tests/market_watch_pages.mjs +356 -0
- package/tests/market_watch_rules.mjs +63 -3
- package/tests/nav_permission_atoms.mjs +1 -1
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// modules/market-watch/pages.js — what the Market Watch home (the watchtower desk) and
|
|
4
|
+
// the tracked-item page (the dossier) read and write (task 1004939, goal 1000137). The
|
|
5
|
+
// designs of record are docs/design/mocks/market-watch/home.html (option D) and item.html
|
|
6
|
+
// (option B).
|
|
7
|
+
//
|
|
8
|
+
// READS go through db.js (levels, streaks, run numbers, sparklines, spend and the next
|
|
9
|
+
// due date are derived there) plus the two queries below that only these pages need: the
|
|
10
|
+
// latest score per item and dimension (the landscape scope's dots) and the entries found
|
|
11
|
+
// in the last seven days (this week's reports). Every query filters on the project key.
|
|
12
|
+
//
|
|
13
|
+
// SHAPES are pure (shapeHome, shapeItem): the routes call them on what the reads return,
|
|
14
|
+
// and tests/market_watch_pages.mjs calls them on the committed seed, so the page's
|
|
15
|
+
// numbers are checked without a database.
|
|
16
|
+
//
|
|
17
|
+
// THE ONE WRITE is a proposal (market_watch_proposals, origin 'member'). Every button on
|
|
18
|
+
// the item page that would change what is watched files one: re-score, move between
|
|
19
|
+
// partner and competitor, add a source, correct an entry, and stop tracking (a
|
|
20
|
+
// remove_item). The home's "send a new scout" files an add_item. Approving them is the
|
|
21
|
+
// rules page's (task 1004941); nothing here changes an item, a score or an entry.
|
|
22
|
+
|
|
23
|
+
const db = require('./db');
|
|
24
|
+
|
|
25
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
26
|
+
const WEEK_DAYS = 7;
|
|
27
|
+
const KINDS = Object.freeze(['competitor', 'partner', 'topic']);
|
|
28
|
+
const REPORT_LIMIT = 8;
|
|
29
|
+
const STREAK_SLOTS = 9;
|
|
30
|
+
const REASON_MAX = 280;
|
|
31
|
+
const NAME_MAX = 120;
|
|
32
|
+
const URL_MAX = 500;
|
|
33
|
+
const DIM_RE = /^[a-z][a-z0-9_]{0,59}$/;
|
|
34
|
+
// The four the item page's "Propose a change" offers, then the two the other buttons file.
|
|
35
|
+
const ITEM_KINDS = Object.freeze(['rescore', 'move_kind', 'add_source', 'correct_entry', 'remove_item']);
|
|
36
|
+
const PROPOSAL_KINDS = Object.freeze([...ITEM_KINDS, 'add_item']);
|
|
37
|
+
|
|
38
|
+
const num = (v) => (v == null ? null : Number(v));
|
|
39
|
+
const isoDay = (d) => (d instanceof Date ? d.toISOString().slice(0, 10) : String(d).slice(0, 10));
|
|
40
|
+
|
|
41
|
+
// ---- reads only these pages need -------------------------------------------
|
|
42
|
+
|
|
43
|
+
const LATEST_SCORES_SQL = `
|
|
44
|
+
SELECT DISTINCT ON (s.item_id, s.dimension) s.item_id, s.dimension, s.value
|
|
45
|
+
FROM market_watch_score_snapshots s
|
|
46
|
+
JOIN market_watch_items i ON i.id = s.item_id
|
|
47
|
+
JOIN market_watch_runs r ON r.id = s.run_id
|
|
48
|
+
WHERE i.project = $1 AND i.status <> 'removed'
|
|
49
|
+
ORDER BY s.item_id, s.dimension, r.created_at DESC, r.id DESC`;
|
|
50
|
+
|
|
51
|
+
async function latestScores(pool, { project }) {
|
|
52
|
+
const { rows } = await pool.query(LATEST_SCORES_SQL, [project]);
|
|
53
|
+
return rows;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const WEEK_ENTRIES_SQL = `
|
|
57
|
+
SELECT e.id, e.item_id, e.run_id, e.type, e.found_on, e.takeaway, e.confidence,
|
|
58
|
+
e.source_url, e.source_label, e.pinned
|
|
59
|
+
FROM market_watch_log_entries e
|
|
60
|
+
JOIN market_watch_items i ON i.id = e.item_id
|
|
61
|
+
WHERE i.project = $1 AND i.status <> 'removed' AND e.found_on >= $2::date
|
|
62
|
+
ORDER BY e.found_on DESC, e.id DESC
|
|
63
|
+
LIMIT 1000`;
|
|
64
|
+
|
|
65
|
+
async function weekEntries(pool, { project, now = new Date() }) {
|
|
66
|
+
const since = isoDay(new Date(new Date(now).getTime() - WEEK_DAYS * DAY_MS));
|
|
67
|
+
const { rows } = await pool.query(WEEK_ENTRIES_SQL, [project, since]);
|
|
68
|
+
return rows;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ---- pure shapes ------------------------------------------------------------
|
|
72
|
+
|
|
73
|
+
// The labels a kind's dimensions carry, from the rules in use: { <key>: label }.
|
|
74
|
+
function rubricLabels(rules, kind) {
|
|
75
|
+
const rubric = (rules && rules.active && rules.active.rubric) || {};
|
|
76
|
+
const out = {};
|
|
77
|
+
for (const d of Array.isArray(rubric[kind]) ? rubric[kind] : []) if (d && d.key) out[d.key] = String(d.label || d.key);
|
|
78
|
+
return out;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// One report per item: a pinned entry first, then the surest, then an insight over a
|
|
82
|
+
// datapoint, then the newest. Items ordered by their overall score, best first.
|
|
83
|
+
const TYPE_RANK = { insight: 0, news: 1, datapoint: 2 };
|
|
84
|
+
function pickReports(entries, itemsById, limit = REPORT_LIMIT) {
|
|
85
|
+
const best = new Map();
|
|
86
|
+
const better = (a, b) => (Number(!!b.pinned) - Number(!!a.pinned))
|
|
87
|
+
|| (Number(b.confidence) - Number(a.confidence))
|
|
88
|
+
|| ((TYPE_RANK[a.type] ?? 3) - (TYPE_RANK[b.type] ?? 3))
|
|
89
|
+
|| (isoDay(b.found_on) > isoDay(a.found_on) ? 1 : isoDay(b.found_on) < isoDay(a.found_on) ? -1 : 0)
|
|
90
|
+
|| (Number(b.id) - Number(a.id));
|
|
91
|
+
for (const e of entries) {
|
|
92
|
+
const it = itemsById.get(String(e.item_id));
|
|
93
|
+
if (!it) continue;
|
|
94
|
+
const k = String(e.item_id);
|
|
95
|
+
if (!best.has(k) || better(e, best.get(k)) < 0) best.set(k, e);
|
|
96
|
+
}
|
|
97
|
+
const all = [...best.values()]
|
|
98
|
+
.map((e) => {
|
|
99
|
+
const it = itemsById.get(String(e.item_id));
|
|
100
|
+
return {
|
|
101
|
+
id: Number(e.id),
|
|
102
|
+
item_id: Number(e.item_id),
|
|
103
|
+
item_name: it.name,
|
|
104
|
+
kind: it.kind,
|
|
105
|
+
overall: it.overall,
|
|
106
|
+
type: e.type,
|
|
107
|
+
takeaway: e.takeaway,
|
|
108
|
+
confidence: Number(e.confidence),
|
|
109
|
+
found_on: isoDay(e.found_on),
|
|
110
|
+
source_url: e.source_url,
|
|
111
|
+
};
|
|
112
|
+
})
|
|
113
|
+
.sort((a, b) => ((b.overall ?? -1) - (a.overall ?? -1)) || a.item_name.localeCompare(b.item_name));
|
|
114
|
+
// Competitors and partners are scored on different scales, so one ranking across both
|
|
115
|
+
// would be a false comparison: take each kind's best in turn instead.
|
|
116
|
+
const queues = KINDS.map((k) => all.filter((r) => r.kind === k));
|
|
117
|
+
const out = [];
|
|
118
|
+
while (out.length < limit && queues.some((q) => q.length)) {
|
|
119
|
+
for (const q of queues) if (q.length && out.length < limit) out.push(q.shift());
|
|
120
|
+
}
|
|
121
|
+
return out;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// shapeHome — the watchtower desk: scouts, the run streak, the next sweep and budget,
|
|
125
|
+
// the landscape's axes and dots, and this week's reports.
|
|
126
|
+
function shapeHome({ items, runs, settings, scores, entries, rules, now = new Date() }) {
|
|
127
|
+
const live = items.filter((it) => it.status !== 'removed');
|
|
128
|
+
const itemsById = new Map(live.map((it) => [String(it.id), it]));
|
|
129
|
+
const reports = pickReports(entries, itemsById);
|
|
130
|
+
const weekByKind = {};
|
|
131
|
+
for (const e of entries) {
|
|
132
|
+
const it = itemsById.get(String(e.item_id));
|
|
133
|
+
if (it) (weekByKind[it.kind] = weekByKind[it.kind] || new Set()).add(String(it.id));
|
|
134
|
+
}
|
|
135
|
+
const scouts = KINDS.map((kind) => {
|
|
136
|
+
const mine = live.filter((it) => it.kind === kind);
|
|
137
|
+
return {
|
|
138
|
+
kind,
|
|
139
|
+
watched: mine.filter((it) => it.status === 'active').length,
|
|
140
|
+
level: mine.reduce((m, it) => Math.max(m, it.level || 0), 0),
|
|
141
|
+
reports: weekByKind[kind] ? weekByKind[kind].size : 0,
|
|
142
|
+
};
|
|
143
|
+
});
|
|
144
|
+
const ranRuns = runs.filter((r) => r.n != null);
|
|
145
|
+
const streak = runs.slice(-STREAK_SLOTS).map((r) => ({ n: r.n, ran: r.n != null, date: isoDay(r.created_at), reason: r.reason || null }));
|
|
146
|
+
|
|
147
|
+
const labels = rubricLabels(rules, 'competitor');
|
|
148
|
+
const byItem = new Map();
|
|
149
|
+
for (const s of scores) {
|
|
150
|
+
const k = String(s.item_id);
|
|
151
|
+
if (!byItem.has(k)) byItem.set(k, {});
|
|
152
|
+
byItem.get(k)[s.dimension] = num(s.value);
|
|
153
|
+
}
|
|
154
|
+
const seen = new Set(Object.keys(labels));
|
|
155
|
+
const dots = live.filter((it) => it.kind === 'competitor').map((it) => {
|
|
156
|
+
const sc = byItem.get(String(it.id)) || {};
|
|
157
|
+
for (const k of Object.keys(sc)) if (k !== 'overall') seen.add(k);
|
|
158
|
+
return { id: it.id, name: it.name, overall: it.overall, level: it.level, scores: sc };
|
|
159
|
+
});
|
|
160
|
+
const axes = [...seen].map((key) => ({ key, label: labels[key] || key.replace(/_/g, ' ') }));
|
|
161
|
+
axes.push({ key: 'overall', label: 'overall likeness' });
|
|
162
|
+
|
|
163
|
+
const s = settings.settings;
|
|
164
|
+
const cap = Number(s.monthly_cap_usd) || 0;
|
|
165
|
+
const spent = Number(settings.spentThisMonthUsd) || 0;
|
|
166
|
+
return {
|
|
167
|
+
generated_at: new Date(now).toISOString(),
|
|
168
|
+
watch: { enabled: !!s.enabled, configured: !!s.configured, cadence: s.cadence },
|
|
169
|
+
weeks_watched: ranRuns.length,
|
|
170
|
+
scouts,
|
|
171
|
+
streak,
|
|
172
|
+
next_sweep: { due: settings.nextRunDue ? new Date(settings.nextRunDue).toISOString() : null, enabled: !!s.enabled },
|
|
173
|
+
budget: { cap_usd: cap, spent_usd: Math.round(spent * 100) / 100, left_usd: Math.max(0, Math.round((cap - spent) * 100) / 100) },
|
|
174
|
+
axes,
|
|
175
|
+
dots,
|
|
176
|
+
reports,
|
|
177
|
+
hot: reports.map((r) => r.item_id),
|
|
178
|
+
// Every tracked item by name: the desk's "open a file" picker, the way to any item
|
|
179
|
+
// page that is not a dot (a partner, a topic) or a report this week.
|
|
180
|
+
index: live.map((it) => ({ id: it.id, kind: it.kind, name: it.name }))
|
|
181
|
+
.sort((a, b) => (KINDS.indexOf(a.kind) - KINDS.indexOf(b.kind)) || a.name.localeCompare(b.name)),
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// shapeItem — the dossier: the card (level, streak, overall line, one sparkline per
|
|
186
|
+
// dimension), why it is watched, its known facts and the sortable case file.
|
|
187
|
+
function shapeItem({ item, entries, history, rules, openProposals = 0 }) {
|
|
188
|
+
const labels = rubricLabels(rules, item.kind);
|
|
189
|
+
const series = history.series || {};
|
|
190
|
+
const keys = [...Object.keys(labels), ...Object.keys(series).filter((k) => k !== 'overall' && !(k in labels))];
|
|
191
|
+
const point = (vals) => {
|
|
192
|
+
const v = (vals || []).filter((x) => x != null);
|
|
193
|
+
return { first: v.length ? v[0] : null, latest: v.length ? v[v.length - 1] : null };
|
|
194
|
+
};
|
|
195
|
+
const dimensions = keys.map((key) => ({ key, label: labels[key] || key.replace(/_/g, ' '), series: series[key] || [], ...point(series[key]) }));
|
|
196
|
+
return {
|
|
197
|
+
item: {
|
|
198
|
+
id: item.id, kind: item.kind, name: item.name, url: item.url, category: item.category,
|
|
199
|
+
description: item.description, why_tracked: item.why_tracked, facts: item.facts || {},
|
|
200
|
+
status: item.status, added_via: item.added_via, created_at: item.created_at,
|
|
201
|
+
overall: item.overall, level: item.level, streak: item.streak,
|
|
202
|
+
},
|
|
203
|
+
runs: (history.runs || []).map((r) => ({ n: r.n, date: isoDay(r.date) })),
|
|
204
|
+
overall: { series: series.overall || [], ...point(series.overall) },
|
|
205
|
+
dimensions,
|
|
206
|
+
moved: dimensions.filter((d) => d.first != null && d.latest != null && d.first !== d.latest).length,
|
|
207
|
+
entries: entries.map((e) => ({
|
|
208
|
+
id: Number(e.id), run: e.run, type: e.type, found_on: isoDay(e.found_on), takeaway: e.takeaway,
|
|
209
|
+
dimension: e.dimension, source_url: e.source_url, source_label: e.source_label,
|
|
210
|
+
confidence: Number(e.confidence), pinned: !!e.pinned,
|
|
211
|
+
})),
|
|
212
|
+
open_proposals: Number(openProposals) || 0,
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// ---- the views --------------------------------------------------------------
|
|
217
|
+
|
|
218
|
+
// Each view reads the numbered runs ONCE and hands them to every db.js read that would
|
|
219
|
+
// otherwise read market_watch_runs again (listItems, getItem, caseFile, scoreHistory).
|
|
220
|
+
async function homeView(pool, { project, now = new Date() }) {
|
|
221
|
+
const runs = await db.numberedRuns(pool, { project });
|
|
222
|
+
const [items, settings, scores, entries, rules] = await Promise.all([
|
|
223
|
+
db.listItems(pool, { project, knownRuns: runs }),
|
|
224
|
+
db.settingsView(pool, { project, now }),
|
|
225
|
+
latestScores(pool, { project }),
|
|
226
|
+
weekEntries(pool, { project, now }),
|
|
227
|
+
db.rules(pool, { project }),
|
|
228
|
+
]);
|
|
229
|
+
return shapeHome({ items, runs, settings, scores, entries, rules, now });
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
async function itemView(pool, { project, id }) {
|
|
233
|
+
const runs = await db.numberedRuns(pool, { project });
|
|
234
|
+
const item = await db.getItem(pool, { project, id, knownRuns: runs });
|
|
235
|
+
if (!item) return null;
|
|
236
|
+
const [entries, history, rules, open] = await Promise.all([
|
|
237
|
+
db.caseFile(pool, { project, itemId: id, knownRuns: runs }),
|
|
238
|
+
db.scoreHistory(pool, { project, itemId: id, knownRuns: runs }),
|
|
239
|
+
db.rules(pool, { project }),
|
|
240
|
+
db.proposals(pool, { project, status: 'open', itemId: id, limit: 100 }),
|
|
241
|
+
]);
|
|
242
|
+
return shapeItem({ item, entries, history, rules, openProposals: open.length });
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// ---- proposals --------------------------------------------------------------
|
|
246
|
+
|
|
247
|
+
const oneLine = (s) => String(s == null ? '' : s).replace(/[\u0000-\u001f\u007f]+/g, ' ').replace(/\s+/g, ' ').trim();
|
|
248
|
+
const isHttp = (u) => /^https?:\/\/[^\s/]+\.[^\s]+$/i.test(u);
|
|
249
|
+
|
|
250
|
+
// normalizeProposal(body, { item, rubricKeys }) → { ok, value } | { ok: false, error, message }.
|
|
251
|
+
// `item` is the tracked item the proposal is about (null for add_item); rubricKeys is the
|
|
252
|
+
// set of dimensions the item's kind is scored on. Everything a member typed is trimmed
|
|
253
|
+
// to one line and bounded; nothing here trusts the client's idea of the item's kind.
|
|
254
|
+
function normalizeProposal(body, { item = null, rubricKeys = null } = {}) {
|
|
255
|
+
const b = body && typeof body === 'object' ? body : {};
|
|
256
|
+
const kind = String(b.kind || '');
|
|
257
|
+
if (!PROPOSAL_KINDS.includes(kind)) return { ok: false, error: 'invalid_kind', message: 'Pick what should change.' };
|
|
258
|
+
const reason = oneLine(b.reason);
|
|
259
|
+
if (!reason) return { ok: false, error: 'reason_required', message: 'Say why, in one line.' };
|
|
260
|
+
if (reason.length > REASON_MAX) return { ok: false, error: 'reason_too_long', message: `Keep the reason to ${REASON_MAX} characters.` };
|
|
261
|
+
if (kind === 'add_item') {
|
|
262
|
+
const name = oneLine(b.name);
|
|
263
|
+
const scout = String(b.scout || '');
|
|
264
|
+
const url = oneLine(b.url);
|
|
265
|
+
if (!KINDS.includes(scout)) return { ok: false, error: 'invalid_scout', message: 'Pick a competitor, a partner or a topic.' };
|
|
266
|
+
if (!name || name.length > NAME_MAX) return { ok: false, error: 'invalid_name', message: `Name it in up to ${NAME_MAX} characters.` };
|
|
267
|
+
if (url && (url.length > URL_MAX || !isHttp(url))) return { ok: false, error: 'invalid_url', message: 'A link starts with http:// or https://.' };
|
|
268
|
+
return { ok: true, value: { kind, item_id: null, log_entry_id: null, reason, payload: { kind: scout, name, url: url || null } } };
|
|
269
|
+
}
|
|
270
|
+
if (!item) return { ok: false, error: 'item_not_found', message: 'That item is not tracked here.' };
|
|
271
|
+
const base = { kind, item_id: item.id, log_entry_id: null, reason };
|
|
272
|
+
if (kind === 'rescore') {
|
|
273
|
+
const dimension = String(b.dimension || '');
|
|
274
|
+
const value = Number(b.value);
|
|
275
|
+
const known = dimension === 'overall' || (rubricKeys ? rubricKeys.has(dimension) : DIM_RE.test(dimension));
|
|
276
|
+
if (!DIM_RE.test(dimension) || !known) return { ok: false, error: 'invalid_dimension', message: 'Pick a score to change.' };
|
|
277
|
+
if (b.value === '' || b.value == null || !Number.isFinite(value) || value < 0 || value > 10) return { ok: false, error: 'invalid_value', message: 'A score is from 0 to 10.' };
|
|
278
|
+
return { ok: true, value: { ...base, payload: { dimension, value: Math.round(value * 10) / 10 } } };
|
|
279
|
+
}
|
|
280
|
+
if (kind === 'move_kind') {
|
|
281
|
+
if (item.kind === 'topic') return { ok: false, error: 'cannot_move_topic', message: 'A topic is not a partner or a competitor.' };
|
|
282
|
+
return { ok: true, value: { ...base, payload: { from: item.kind, to: item.kind === 'partner' ? 'competitor' : 'partner' } } };
|
|
283
|
+
}
|
|
284
|
+
if (kind === 'add_source') {
|
|
285
|
+
const url = oneLine(b.url);
|
|
286
|
+
if (!url || url.length > URL_MAX || !isHttp(url)) return { ok: false, error: 'invalid_url', message: 'A source is a link that starts with http:// or https://.' };
|
|
287
|
+
return { ok: true, value: { ...base, payload: { source_url: url } } };
|
|
288
|
+
}
|
|
289
|
+
if (kind === 'correct_entry') {
|
|
290
|
+
const entry = String(b.log_entry_id || '');
|
|
291
|
+
if (!/^[1-9]\d{0,17}$/.test(entry)) return { ok: false, error: 'invalid_entry', message: 'Pick the entry to correct.' };
|
|
292
|
+
return { ok: true, value: { ...base, log_entry_id: Number(entry), payload: {} } };
|
|
293
|
+
}
|
|
294
|
+
return { ok: true, value: { ...base, payload: {} } }; // remove_item: stop tracking
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// The INSERT checks ownership in SQL: the item must be this project's, and an entry to
|
|
298
|
+
// correct must be that item's. A row from another project inserts nothing.
|
|
299
|
+
const INSERT_ITEM_PROPOSAL_SQL = `
|
|
300
|
+
INSERT INTO market_watch_proposals (project, kind, origin, item_id, log_entry_id, payload, reason, proposed_by)
|
|
301
|
+
SELECT $1, $2, 'member', i.id, $4, $5::jsonb, $6, $7
|
|
302
|
+
FROM market_watch_items i
|
|
303
|
+
WHERE i.id = $3 AND i.project = $1
|
|
304
|
+
AND ($4::bigint IS NULL OR EXISTS (SELECT 1 FROM market_watch_log_entries e WHERE e.id = $4 AND e.item_id = i.id))
|
|
305
|
+
RETURNING id, kind, status, created_at`;
|
|
306
|
+
const INSERT_NEW_ITEM_PROPOSAL_SQL = `
|
|
307
|
+
INSERT INTO market_watch_proposals (project, kind, origin, payload, reason, proposed_by)
|
|
308
|
+
VALUES ($1, 'add_item', 'member', $2::jsonb, $3, $4)
|
|
309
|
+
RETURNING id, kind, status, created_at`;
|
|
310
|
+
|
|
311
|
+
async function insertProposal(pool, { project, proposal, builderId }) {
|
|
312
|
+
const p = proposal;
|
|
313
|
+
const { rows: [row] } = p.kind === 'add_item'
|
|
314
|
+
? await pool.query(INSERT_NEW_ITEM_PROPOSAL_SQL, [project, JSON.stringify(p.payload), p.reason, builderId])
|
|
315
|
+
: await pool.query(INSERT_ITEM_PROPOSAL_SQL, [project, p.kind, p.item_id, p.log_entry_id, JSON.stringify(p.payload), p.reason, builderId]);
|
|
316
|
+
return row ? { ...row, id: Number(row.id) } : null;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
module.exports = {
|
|
320
|
+
homeView, itemView, insertProposal,
|
|
321
|
+
shapeHome, shapeItem, pickReports, rubricLabels, normalizeProposal,
|
|
322
|
+
PROPOSAL_KINDS, KINDS,
|
|
323
|
+
};
|
|
@@ -15,9 +15,17 @@
|
|
|
15
15
|
// add_item the tracked item is created (added_via 'proposal', added_by the proposer).
|
|
16
16
|
// An item already there under the same key (a removed one) is re-activated.
|
|
17
17
|
// remove_item the item's status becomes 'removed' (never a DELETE; its log stays).
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
18
|
+
// rescore payload { dimension, value }: the item's score on that dimension in the
|
|
19
|
+
// latest run that ran is set to the value (a snapshot upsert), so the card,
|
|
20
|
+
// the landscape and the sparkline's last point read it.
|
|
21
|
+
// move_kind payload { from, to }: the item moves between partner and competitor. It
|
|
22
|
+
// does not apply when the item is no longer the 'from' kind, or the same
|
|
23
|
+
// name and link are already tracked as the 'to' kind.
|
|
24
|
+
// add_source payload { source_url }: the link joins the item's facts.watch_sources
|
|
25
|
+
// (shown on the item page; the weekly run does not read it yet).
|
|
26
|
+
// correct_entry payload {} with log_entry_id: the reason IS the correction, written for
|
|
27
|
+
// a person to act on, so the decision is recorded as 'approved' only.
|
|
28
|
+
// The four item-page payloads are written by modules/market-watch/pages.js (task 1004939).
|
|
21
29
|
// A carried-out approval is status 'applied'; a recorded one is 'approved'. A decline is
|
|
22
30
|
// 'rejected'. Every decision names who made it and when (decided_by, decided_at).
|
|
23
31
|
//
|
|
@@ -86,6 +94,29 @@ const SQL = Object.freeze({
|
|
|
86
94
|
getOpen: `-- mw:proposal-get-open
|
|
87
95
|
SELECT id, kind, payload, reason FROM market_watch_proposals
|
|
88
96
|
WHERE id = $1 AND project = $2 AND status = 'open'`,
|
|
97
|
+
itemRescore: `-- mw:item-rescore
|
|
98
|
+
INSERT INTO market_watch_score_snapshots (item_id, run_id, dimension, value)
|
|
99
|
+
SELECT i.id, r.id, $3, $4
|
|
100
|
+
FROM market_watch_items i
|
|
101
|
+
JOIN market_watch_runs r ON r.project = i.project AND r.status IN ('done', 'partial')
|
|
102
|
+
WHERE i.id = $1 AND i.project = $2 AND i.status <> 'removed'
|
|
103
|
+
ORDER BY r.created_at DESC, r.id DESC
|
|
104
|
+
LIMIT 1
|
|
105
|
+
ON CONFLICT (item_id, run_id, dimension) DO UPDATE SET value = EXCLUDED.value
|
|
106
|
+
RETURNING item_id`,
|
|
107
|
+
itemMove: `-- mw:item-move
|
|
108
|
+
UPDATE market_watch_items i SET kind = $3, updated_at = now()
|
|
109
|
+
WHERE i.id = $1 AND i.project = $2 AND i.kind = $4 AND i.status <> 'removed'
|
|
110
|
+
AND NOT EXISTS (SELECT 1 FROM market_watch_items o
|
|
111
|
+
WHERE o.project = i.project AND o.kind = $3 AND o.url = i.url AND o.name = i.name)
|
|
112
|
+
RETURNING i.id`,
|
|
113
|
+
itemAddSource: `-- mw:item-add-source
|
|
114
|
+
UPDATE market_watch_items
|
|
115
|
+
SET facts = CASE WHEN COALESCE(facts->'watch_sources', '[]'::jsonb) ? $3 THEN facts
|
|
116
|
+
ELSE jsonb_set(facts, '{watch_sources}', COALESCE(facts->'watch_sources', '[]'::jsonb) || to_jsonb($3::text)) END,
|
|
117
|
+
updated_at = now()
|
|
118
|
+
WHERE id = $1 AND project = $2 AND status <> 'removed'
|
|
119
|
+
RETURNING id`,
|
|
89
120
|
atVote: `-- mw:proposal-at-vote
|
|
90
121
|
UPDATE market_watch_proposals SET payload = payload || jsonb_build_object('matter_id', $3::text)
|
|
91
122
|
WHERE id = $1 AND project = $2 AND status = 'open'`,
|
|
@@ -152,6 +183,29 @@ function itemFromPayload(payload) {
|
|
|
152
183
|
return name ? { name, kind, url: /^https?:\/\//i.test(url) ? url : '', why } : null;
|
|
153
184
|
}
|
|
154
185
|
|
|
186
|
+
// itemChange(kind, payload) → { sql, params } | null — the item page's three kinds an
|
|
187
|
+
// approval applies, re-checked here: a payload is only as good as the row it was read from.
|
|
188
|
+
const DIM_RE = /^[a-z][a-z0-9_]{0,59}$/;
|
|
189
|
+
function itemChange(kind, payload) {
|
|
190
|
+
const p = payload || {};
|
|
191
|
+
if (kind === 'rescore') {
|
|
192
|
+
const v = Number(p.value);
|
|
193
|
+
if (!DIM_RE.test(String(p.dimension || '')) || p.value == null || !Number.isFinite(v) || v < 0 || v > 10) return null;
|
|
194
|
+
return { sql: 'itemRescore', params: [p.dimension, Math.round(v * 10) / 10] };
|
|
195
|
+
}
|
|
196
|
+
if (kind === 'move_kind') {
|
|
197
|
+
const pair = ['competitor', 'partner'];
|
|
198
|
+
if (!pair.includes(p.from) || !pair.includes(p.to) || p.from === p.to) return null;
|
|
199
|
+
return { sql: 'itemMove', params: [p.to, p.from] };
|
|
200
|
+
}
|
|
201
|
+
if (kind === 'add_source') {
|
|
202
|
+
const u = String(p.source_url || '').trim();
|
|
203
|
+
if (!/^https?:\/\/\S+$/i.test(u) || u.length > 500) return null;
|
|
204
|
+
return { sql: 'itemAddSource', params: [u] };
|
|
205
|
+
}
|
|
206
|
+
return null;
|
|
207
|
+
}
|
|
208
|
+
|
|
155
209
|
// decideProposal(pool, { project, id, decision, decidedBy, via, withTx }) →
|
|
156
210
|
// { ok: true, status, applied, version?, item_id? } |
|
|
157
211
|
// { ok: false, reason: 'not_found' | 'not_open' | 'no_active_rules' | 'no_longer_applies' | 'bad_payload' | 'item_gone' }
|
|
@@ -206,9 +260,17 @@ async function decideProposal({ project, id, decision, decidedBy, via = null, wi
|
|
|
206
260
|
await decide('applied');
|
|
207
261
|
return { ok: true, status: 'applied', applied: true, item_id: Number(it.id) };
|
|
208
262
|
}
|
|
263
|
+
if (p.kind === 'rescore' || p.kind === 'move_kind' || p.kind === 'add_source') {
|
|
264
|
+
const a = itemChange(p.kind, p.payload);
|
|
265
|
+
if (!a) return { ok: false, reason: 'bad_payload' };
|
|
266
|
+
const { rows: [it] } = await client.query(SQL[a.sql], [p.item_id, project, ...a.params]);
|
|
267
|
+
if (!it) return { ok: false, reason: p.kind === 'move_kind' ? 'no_longer_applies' : 'item_gone' };
|
|
268
|
+
await decide('applied');
|
|
269
|
+
return { ok: true, status: 'applied', applied: true, item_id: Number(p.item_id) };
|
|
270
|
+
}
|
|
209
271
|
await decide('approved');
|
|
210
272
|
return { ok: true, status: 'approved', applied: false };
|
|
211
273
|
});
|
|
212
274
|
}
|
|
213
275
|
|
|
214
|
-
module.exports = { createProposal, decideProposal, getOpen, markAtVote, itemFromPayload, MAX_OPEN_PER_MEMBER };
|
|
276
|
+
module.exports = { createProposal, decideProposal, getOpen, markAtVote, itemFromPayload, itemChange, MAX_OPEN_PER_MEMBER };
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// modules/market-watch/routes/pages.js — the reads and the one write behind the Market
|
|
2
|
+
// Watch home and tracked-item pages (task 1004939, goal 1000137).
|
|
3
|
+
//
|
|
4
|
+
// GET /market-watch/home the watchtower desk: scouts, streak, next sweep,
|
|
5
|
+
// budget, the landscape and this week's reports
|
|
6
|
+
// GET /market-watch/items/:id one tracked item: its card, sparklines and case file
|
|
7
|
+
// POST /market-watch/proposals a member proposes a change (re-score, move between
|
|
8
|
+
// partner and competitor, add a source, correct an
|
|
9
|
+
// entry, stop tracking, or a new scout)
|
|
10
|
+
//
|
|
11
|
+
// rank: any-builder — every route needs a signed-in builder, and every builder on this
|
|
12
|
+
// instance is a member of its project (each project is its own instance, ADR 0393). A
|
|
13
|
+
// visitor with no session gets a 401 and no data (tests/market_watch_pages.mjs). These
|
|
14
|
+
// routes mount only while the module is on; where it is off they do not exist, and the
|
|
15
|
+
// page shows its quiet off state instead.
|
|
16
|
+
//
|
|
17
|
+
// A proposal changes nothing it names. It is a row in market_watch_proposals for the
|
|
18
|
+
// government to decide on the rules page (task 1004941). Its text is untrusted member
|
|
19
|
+
// input: it is stored as typed, bounded to one line, and escaped wherever it is shown.
|
|
20
|
+
|
|
21
|
+
'use strict';
|
|
22
|
+
|
|
23
|
+
const express = require('express');
|
|
24
|
+
const api = require('../../../src/module-api');
|
|
25
|
+
const pages = require('../pages');
|
|
26
|
+
const db = require('../db');
|
|
27
|
+
const { projectKey } = require('../seed');
|
|
28
|
+
|
|
29
|
+
const ID_RE = /^[1-9]\d{0,17}$/;
|
|
30
|
+
|
|
31
|
+
function currentProject() {
|
|
32
|
+
try { return projectKey(api.branding().domains.publicOrigin); } catch { return null; }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
module.exports = function buildMarketWatchPagesRouter(deps = {}) {
|
|
36
|
+
const router = express.Router();
|
|
37
|
+
const pool = () => deps.pool || api.pool;
|
|
38
|
+
const project = () => (deps.project !== undefined ? deps.project : currentProject());
|
|
39
|
+
|
|
40
|
+
// The in-handler membership check: every active builder of this instance is a member of
|
|
41
|
+
// its project (ADR 0393), so a member is a signed-in builder with an id who is not
|
|
42
|
+
// offboarded. requireBuilder already refuses 'inactive'; this holds it per handler too.
|
|
43
|
+
const isMember = (req, res) => {
|
|
44
|
+
const b = req.builder;
|
|
45
|
+
if (!b || b.id == null || b.status === 'inactive') { res.fail('not_a_member', 403); return false; }
|
|
46
|
+
return true;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const needProject = (res) => {
|
|
50
|
+
const p = project();
|
|
51
|
+
if (!p) res.fail('no_project_origin', { status: 503, message: 'This project has no public address set, so Market Watch cannot tell its rows apart.' });
|
|
52
|
+
return p;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
// rank: any-builder — every member reads the desk.
|
|
56
|
+
router.get('/market-watch/home', api.requireBuilder, api.asyncHandler('GET /market-watch/home', async (req, res) => {
|
|
57
|
+
if (!isMember(req, res)) return;
|
|
58
|
+
const p = needProject(res);
|
|
59
|
+
if (!p) return;
|
|
60
|
+
res.set('Cache-Control', 'no-store');
|
|
61
|
+
res.json(await pages.homeView(pool(), { project: p }));
|
|
62
|
+
}));
|
|
63
|
+
|
|
64
|
+
// rank: any-builder — every member reads an item's case file.
|
|
65
|
+
router.get('/market-watch/items/:id', api.requireBuilder, api.asyncHandler('GET /market-watch/items/:id', async (req, res) => {
|
|
66
|
+
if (!isMember(req, res)) return;
|
|
67
|
+
const id = String(req.params.id || '');
|
|
68
|
+
if (!ID_RE.test(id)) return res.fail('invalid_item_id', 400);
|
|
69
|
+
const p = needProject(res);
|
|
70
|
+
if (!p) return;
|
|
71
|
+
const view = await pages.itemView(pool(), { project: p, id: Number(id) });
|
|
72
|
+
if (!view) return res.fail('item_not_found', 404);
|
|
73
|
+
res.set('Cache-Control', 'no-store');
|
|
74
|
+
res.json(view);
|
|
75
|
+
}));
|
|
76
|
+
|
|
77
|
+
// rank: any-builder — every member may propose; the government decides (task 1004941).
|
|
78
|
+
router.post('/market-watch/proposals', api.requireBuilder, api.asyncHandler('POST /market-watch/proposals', async (req, res) => {
|
|
79
|
+
if (!isMember(req, res)) return;
|
|
80
|
+
// Strict: an unknown field is refused. The ids and the score arrive as a number or as
|
|
81
|
+
// the form's string, so they carry no type here; normalizeProposal checks each.
|
|
82
|
+
if (api.validateOrRespond(req, res, {
|
|
83
|
+
kind: { required: true, type: 'string', enum: pages.PROPOSAL_KINDS },
|
|
84
|
+
reason: { type: 'string', maxLength: 2000 },
|
|
85
|
+
item_id: {},
|
|
86
|
+
dimension: { type: 'string', maxLength: 60 },
|
|
87
|
+
value: {},
|
|
88
|
+
url: { type: 'string', maxLength: 2000 },
|
|
89
|
+
log_entry_id: {},
|
|
90
|
+
scout: { type: 'string', enum: pages.KINDS },
|
|
91
|
+
name: { type: 'string', maxLength: 2000 },
|
|
92
|
+
})) return;
|
|
93
|
+
const p = needProject(res);
|
|
94
|
+
if (!p) return;
|
|
95
|
+
const body = req.body && typeof req.body === 'object' ? req.body : {};
|
|
96
|
+
let item = null;
|
|
97
|
+
let rubricKeys = null;
|
|
98
|
+
if (body.kind !== 'add_item') {
|
|
99
|
+
const id = String(body.item_id || '');
|
|
100
|
+
if (!ID_RE.test(id)) return res.fail('invalid_item_id', 400);
|
|
101
|
+
item = await db.getItem(pool(), { project: p, id: Number(id) });
|
|
102
|
+
if (!item || item.status === 'removed') return res.fail('item_not_found', 404);
|
|
103
|
+
if (body.kind === 'rescore') {
|
|
104
|
+
rubricKeys = new Set(Object.keys(pages.rubricLabels(await db.rules(pool(), { project: p }), item.kind)));
|
|
105
|
+
if (!rubricKeys.size) rubricKeys = null;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
const n = pages.normalizeProposal(body, { item, rubricKeys });
|
|
109
|
+
if (!n.ok) return res.fail(n.error, { status: 400, message: n.message });
|
|
110
|
+
const row = await pages.insertProposal(pool(), { project: p, proposal: n.value, builderId: req.builder.id });
|
|
111
|
+
if (!row) return res.fail('item_not_found', 404); // the entry to correct is not this item's
|
|
112
|
+
res.status(201).json({ proposal: { id: row.id, kind: row.kind, status: row.status, created_at: row.created_at } });
|
|
113
|
+
}));
|
|
114
|
+
|
|
115
|
+
return router;
|
|
116
|
+
};
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.24.
|
|
3
|
+
"version": "1.24.12",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.24.
|
|
9
|
+
"version": "1.24.12",
|
|
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.24.
|
|
3
|
+
"version": "1.24.12",
|
|
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",
|
package/release-notes.json
CHANGED
|
@@ -10901,5 +10901,11 @@
|
|
|
10901
10901
|
"id": "1004990",
|
|
10902
10902
|
"text": "A project can now say in its own words what a task pays, and set its standing pay terms on the settings page with a record of who changed them; builders see the pay on the task page and in their task list before claiming, and"
|
|
10903
10903
|
}
|
|
10904
|
+
],
|
|
10905
|
+
"1.24.12": [
|
|
10906
|
+
{
|
|
10907
|
+
"id": "1004939",
|
|
10908
|
+
"text": "Every member of a project can now open Market Watch to see its scouts on competitors, partners and topics, the landscape chart, the next sweep, the budget left and this week's reports, and open any tracked item's card and case"
|
|
10909
|
+
}
|
|
10904
10910
|
]
|
|
10905
10911
|
}
|
|
@@ -202,6 +202,7 @@ const MODULE_GLOBS = {
|
|
|
202
202
|
'tests/market_watch_rules.mjs', 'tests/market_watch_rules_db.mjs',
|
|
203
203
|
'tests/market_watch_ask.mjs', 'tests/market_watch_ask_db.mjs',
|
|
204
204
|
'tests/market_watch_settings.mjs',
|
|
205
|
+
'tests/market_watch_pages.mjs', 'tests/fixtures/market-watch-seed-rows.cjs',
|
|
205
206
|
],
|
|
206
207
|
// --- builder prefs (wandering/skill/sound/render) + builder-needs ---
|
|
207
208
|
// Carved into modules/builder-settings/ (BV1.R80 / ADR 0093 §1): the five pref
|
package/src/module-api.js
CHANGED
|
@@ -81,7 +81,7 @@ const { sleep } = require('./sleep');
|
|
|
81
81
|
// MAJOR (see allowBoxScope below): passes the request through untouched.
|
|
82
82
|
function deprecatedNoopMiddleware(_req, _res, next) { next(); }
|
|
83
83
|
|
|
84
|
-
const CORE_VERSION = '1.24.
|
|
84
|
+
const CORE_VERSION = '1.24.12'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
85
85
|
|
|
86
86
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
87
87
|
// Usage: const log = api.logger('discord'); log.info('mounted');
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// tests/fixtures/market-watch-seed-rows.cjs — the committed 2026-10-28 research as the
|
|
4
|
+
// rows seed.js writes to the hub (task 1004939): one seed run, one item per researched
|
|
5
|
+
// company or partner, their score snapshots and their case-file entries, with ids handed
|
|
6
|
+
// out in seed order as a fresh database would. tests/market_watch_pages.mjs runs the
|
|
7
|
+
// Market Watch page shapes on these, and the hall-preview fixtures market-watch__*.json
|
|
8
|
+
// were generated from them, so the pages are checked against the real seed without a
|
|
9
|
+
// database.
|
|
10
|
+
|
|
11
|
+
const RESEARCH_DATE = '2026-10-28';
|
|
12
|
+
const RESEARCH_AT = `${RESEARCH_DATE}T12:00:00.000Z`;
|
|
13
|
+
|
|
14
|
+
function seedRows(seed) {
|
|
15
|
+
const built = seed.buildSeed({ market: seed.readSeed('market-data.json'), partners: seed.readSeed('partners-data.json') });
|
|
16
|
+
const runs = [{ id: 1, status: 'done', reason: null, trigger: 'seed', created_at: RESEARCH_AT }];
|
|
17
|
+
const items = [];
|
|
18
|
+
const scores = [];
|
|
19
|
+
const entries = [];
|
|
20
|
+
let entryId = 0;
|
|
21
|
+
built.items.forEach((it, i) => {
|
|
22
|
+
const id = i + 1;
|
|
23
|
+
items.push({
|
|
24
|
+
id, kind: it.kind, name: it.name, url: it.url, category: it.category, description: it.description,
|
|
25
|
+
why_tracked: it.whyTracked, facts: it.facts, status: 'active', added_via: 'seed', created_at: RESEARCH_AT,
|
|
26
|
+
});
|
|
27
|
+
for (const [dimension, value] of Object.entries(it.scores)) scores.push({ item_id: id, run_id: 1, dimension, value });
|
|
28
|
+
for (const e of it.entries) {
|
|
29
|
+
entries.push({
|
|
30
|
+
id: ++entryId, item_id: id, run_id: 1, type: e.type, found_on: RESEARCH_DATE, takeaway: e.takeaway,
|
|
31
|
+
dimension: e.dimension, source_url: e.sourceUrl, source_label: e.sourceLabel, confidence: e.confidence,
|
|
32
|
+
pinned: false, created_at: RESEARCH_AT,
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
return { rules: { version: 1, status: 'active', rubric: built.rules.rubric }, runs, items, scores, entries };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// What db.listItems returns for these rows: latest overall, level and streak.
|
|
40
|
+
function listItemsFrom(r, numbered, db) {
|
|
41
|
+
return r.items.map((it) => {
|
|
42
|
+
const overall = r.scores.find((s) => s.item_id === it.id && s.dimension === 'overall');
|
|
43
|
+
const foundIn = [...new Set(r.entries.filter((e) => e.item_id === it.id).map((e) => e.run_id))];
|
|
44
|
+
return { ...it, overall: overall ? Number(overall.value) : null, ...db.progress(numbered, foundIn) };
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function latestScoresFrom(r) {
|
|
49
|
+
return r.scores.map((s) => ({ item_id: s.item_id, dimension: s.dimension, value: s.value }));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// What db.caseFile returns for one item: pinned first, newest first, with its run number.
|
|
53
|
+
function caseFileFrom(r, numbered, itemId) {
|
|
54
|
+
const runN = new Map(numbered.map((x) => [String(x.id), x.n]));
|
|
55
|
+
return r.entries.filter((e) => e.item_id === itemId)
|
|
56
|
+
.sort((a, b) => (Number(b.pinned) - Number(a.pinned)) || (b.found_on > a.found_on ? 1 : b.found_on < a.found_on ? -1 : 0) || (b.id - a.id))
|
|
57
|
+
.map((e) => ({ ...e, run: runN.get(String(e.run_id)) ?? null }));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
module.exports = { seedRows, listItemsFrom, latestScoresFrom, caseFileFrom, RESEARCH_DATE };
|