@bongos/core 1.24.29 → 1.24.31

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.
Files changed (113) hide show
  1. package/.bongos-core.json +198 -108
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +12 -0
  4. package/clients/bongos-client/index.cjs +12 -0
  5. package/clients/bongos-client/index.d.ts +12 -0
  6. package/clients/bongos-client/index.mjs +12 -0
  7. package/docs/api/openapi.json +174 -1
  8. package/docs/api-reference.md +12 -2
  9. package/docs/copy-inventory.md +226 -224
  10. package/docs/copy-registry.json +583 -549
  11. package/docs/module-api-changelog.md +2 -0
  12. package/docs/page-inventory.json +18 -37
  13. package/docs/page-readings.json +306 -361
  14. package/modules/copy-desk/module.json +1 -1
  15. package/modules/copy-desk/routes/copy-desk-studio.js +103 -0
  16. package/modules/copy-desk/tests/copy_no_cms.mjs +2 -2
  17. package/modules/government/db.js +29 -0
  18. package/modules/government/docket.js +61 -4
  19. package/modules/government/routes/government.js +2 -0
  20. package/modules/hall-ui/public/ai-studio.html +1 -1
  21. package/modules/hall-ui/public/atlas.html +1 -1
  22. package/modules/hall-ui/public/blockers.html +1 -1
  23. package/modules/hall-ui/public/board-room.html +1 -1
  24. package/modules/hall-ui/public/builders.js +3 -79
  25. package/modules/hall-ui/public/city.html +1 -1
  26. package/modules/hall-ui/public/collab.html +1 -1
  27. package/modules/hall-ui/public/commands.html +1 -1
  28. package/modules/hall-ui/public/copy-desk.html +1 -1
  29. package/modules/hall-ui/public/craft-mode.js +1 -1
  30. package/modules/hall-ui/public/deploy.html +1 -1
  31. package/modules/hall-ui/public/diagrams.html +1 -1
  32. package/modules/hall-ui/public/drachmae.html +1 -1
  33. package/modules/hall-ui/public/fleet.html +1 -1
  34. package/modules/hall-ui/public/gate.html +1 -1
  35. package/modules/hall-ui/public/goal-map.html +1 -1
  36. package/modules/hall-ui/public/goals.html +1 -1
  37. package/modules/hall-ui/public/government.html +1 -1
  38. package/modules/hall-ui/public/hall-render.js +68 -167
  39. package/modules/hall-ui/public/hall-widgets.js +4 -45
  40. package/modules/hall-ui/public/home-city-draw.js +583 -0
  41. package/modules/hall-ui/public/home-city.css +102 -0
  42. package/modules/hall-ui/public/home-city.js +339 -0
  43. package/modules/hall-ui/public/home-gallery.css +129 -0
  44. package/modules/hall-ui/public/home-gallery.js +349 -0
  45. package/modules/hall-ui/public/home-studio.css +72 -0
  46. package/modules/hall-ui/public/home-studio.js +203 -0
  47. package/modules/hall-ui/public/idea.html +1 -1
  48. package/modules/hall-ui/public/ideas.html +1 -1
  49. package/modules/hall-ui/public/index.html +9 -7
  50. package/modules/hall-ui/public/index.states.json +73 -8
  51. package/modules/hall-ui/public/market-watch-item.html +1 -1
  52. package/modules/hall-ui/public/market-watch-rules.html +1 -1
  53. package/modules/hall-ui/public/market-watch-settings.js +6 -1
  54. package/modules/hall-ui/public/market-watch.html +141 -54
  55. package/modules/hall-ui/public/market-watch.js +54 -0
  56. package/modules/hall-ui/public/market-watch.states.json +2 -1
  57. package/modules/hall-ui/public/modules.html +1 -1
  58. package/modules/hall-ui/public/my-agents.html +1 -1
  59. package/modules/hall-ui/public/primer.html +1 -1
  60. package/modules/hall-ui/public/profile.html +1 -1
  61. package/modules/hall-ui/public/project-settings.html +1 -1
  62. package/modules/hall-ui/public/ranks.html +1 -1
  63. package/modules/hall-ui/public/roadmap.html +1 -1
  64. package/modules/hall-ui/public/roster.html +1 -1
  65. package/modules/hall-ui/public/sessions.html +1 -1
  66. package/modules/hall-ui/public/settings.html +1 -1
  67. package/modules/hall-ui/public/shell.js +2 -3
  68. package/modules/hall-ui/public/street.html +1 -1
  69. package/modules/hall-ui/public/studio.html +9 -1
  70. package/modules/hall-ui/public/style.css +74 -27
  71. package/modules/hall-ui/public/task.html +1 -1
  72. package/modules/hall-ui/public/thinking.html +1 -1
  73. package/modules/hall-ui/public/tweak-editor.html +1 -1
  74. package/modules/hall-ui/public/watch.html +1 -1
  75. package/modules/hall-ui/public/work.html +1 -1
  76. package/modules/hall-ui/records/home-city.md +189 -0
  77. package/modules/hall-ui/records/loader.md +1 -1
  78. package/modules/hall-ui/records/market-watch-settings.md +16 -3
  79. package/modules/hall-ui/records/market-watch.md +3 -1
  80. package/modules/lifecycle/home-city.js +388 -0
  81. package/modules/lifecycle/home-gallery.js +320 -0
  82. package/modules/lifecycle/migrations/lifecycle_020_work_appreciations.sql +36 -0
  83. package/modules/lifecycle/module.json +2 -0
  84. package/modules/lifecycle/routes/home-city.js +29 -0
  85. package/modules/lifecycle/routes/home-gallery.js +37 -0
  86. package/modules/market-watch/routes/settings.js +1 -1
  87. package/package-lock.json +2 -2
  88. package/package.json +1 -1
  89. package/release-notes.json +38 -0
  90. package/scripts/bongos/leak-scan-allowlist.js +8 -0
  91. package/scripts/hall-preview/README.md +1 -0
  92. package/scripts/hall-preview/server.js +10 -0
  93. package/src/bongos/serve-internal.js +17 -3
  94. package/src/module-api.js +1 -1
  95. package/tests/claim_from_task_and_home.mjs +8 -43
  96. package/tests/hall_board_world.mjs +10 -12
  97. package/tests/hall_builders_page_pass.mjs +18 -27
  98. package/tests/hall_home_ships_bounded.mjs +4 -3
  99. package/tests/hall_loading.mjs +1 -1
  100. package/tests/hall_nav.mjs +11 -4
  101. package/tests/hall_page_gate_map.mjs +7 -4
  102. package/tests/home_bounded_reads.mjs +16 -31
  103. package/tests/home_city.mjs +580 -0
  104. package/tests/home_city_widget.mjs +260 -0
  105. package/tests/home_gallery.mjs +255 -0
  106. package/tests/home_gallery_widget.mjs +220 -0
  107. package/tests/home_studio.mjs +169 -0
  108. package/tests/home_studio_widget.mjs +157 -0
  109. package/tests/leaderboard_roles.mjs +3 -2
  110. package/tests/market_watch_pages.mjs +162 -0
  111. package/tests/nav_permission_atoms.mjs +2 -2
  112. package/modules/hall-ui/public/market-watch-settings.html +0 -115
  113. package/modules/hall-ui/public/market-watch-settings.states.json +0 -14
@@ -0,0 +1,388 @@
1
+ 'use strict';
2
+
3
+ // modules/lifecycle/home-city.js — the weekly summary behind Home's project city
4
+ // (task 1005054, the owner's Home redesign).
5
+ //
6
+ // WHY. Home became one scene (modules/hall-ui/public/home-city.js): a tower per
7
+ // working area as tall as what has shipped there, a ladder of the week's builders,
8
+ // and a slider that replays the last few weeks. Drawing that from the browser would
9
+ // mean downloading every shipped task, idea and vote and slicing it — the read the
10
+ // hall's bounded-reads rule forbids (modules/hall-ui/CLAUDE.md: a Home card asks
11
+ // the server for what it draws). So it is counted here, in SQL, grouped by week:
12
+ // the response grows with the number of areas, builders and weeks, never with the
13
+ // corpus.
14
+ //
15
+ // WHAT IT ANSWERS, for the last N weeks (Monday-start, UTC; the last is this week):
16
+ // areas — each open, non-maintenance goal of the version being built (the focus
17
+ // version, else the newest one building): what it had shipped before the
18
+ // window, then per week, split into art (artist and ui work — economy's
19
+ // credits-by-craft rule) and build, and the builder who shipped most there.
20
+ // people — per builder per week: tasks shipped (build / art), ideas filed, and
21
+ // govern acts (board votes cast, blockers resolved).
22
+ // ideas — the window's ideas for the sky, newest first, bounded, each with the
23
+ // goal of the task it became, the area it is aimed at (that goal, else
24
+ // the filer's suggested goal), the week it became work, its net votes,
25
+ // and whether it waits on THIS viewer (task 1005059, the ideator's sky:
26
+ // a new idea no area holds yet, not the viewer's own, that they have not
27
+ // voted on — "to place or vote on").
28
+ // people — (as above) plus the idea votes each builder cast per week, for the
29
+ // ideator's ladder.
30
+ // version.tasks — every task under the areas, the moon's whole (how far along).
31
+ // now — who holds an open claim in each area (an Autobongos runner working it
32
+ // marks the site as an agent's), and how many matters in each area
33
+ // wait on THIS viewer.
34
+ //
35
+ // "WAITS ON YOU" IS THE DOCKET'S OWN ANSWER (task 1005056). The government port's
36
+ // yoursByGoal(viewer) routes the docket's goal-shaped matters (blockers, criteria
37
+ // awaiting sign-off, goal join requests) with modules/government/routing.js and
38
+ // counts, per goal, those whose route is the viewer's — so a seated Lead, a board
39
+ // member or an access holder sees their own dots, and the city never disagrees
40
+ // with the docket. The port is resolved optionally (not declared in `consumes`):
41
+ // with government switched off, an older government without the method, or seats
42
+ // it could not read, the city falls back to the old approximation — open blockers
43
+ // whose stored ask waits on `owner` (or that carry none), shown only to an Archon.
44
+ //
45
+ // MODULE BOUNDARY: reaches core only through the published doorway. The ideas,
46
+ // blockers, government and autonomy tables are READ in SQL (a cross-table read
47
+ // crosses the wall, a cross-module require does not — modules/ideas/CLAUDE.md);
48
+ // the two module tables are probed with to_regclass first, so a project with
49
+ // government or autonomy switched off answers without them rather than failing.
50
+
51
+ const api = require('../../src/module-api');
52
+
53
+ const log = api.logger('lifecycle');
54
+
55
+ const MIN_WEEKS = 4;
56
+ const MAX_WEEKS = 26;
57
+ const DEFAULT_WEEKS = 10;
58
+ const MAX_AREAS = 30;
59
+ const MAX_IDEAS = 120;
60
+ const ART_DISCIPLINES = Object.freeze(['artist', 'ui']);
61
+ // modules/ideas/skill-friction.js holds the original; a module may not require a
62
+ // sibling, so the two markers are mirrored here (an Xenos viewer never sees them).
63
+ const SKILL_FRICTION_KIND = 'skill-friction';
64
+ const SKILL_FRICTION_TAG = '[skill-friction]';
65
+ const SMOKE_TITLE = '\\_\\_smoke\\_\\_%';
66
+
67
+ function clampWeeks(v) {
68
+ const n = Number.parseInt(v, 10);
69
+ if (!Number.isFinite(n)) return DEFAULT_WEEKS;
70
+ return Math.max(MIN_WEEKS, Math.min(MAX_WEEKS, n));
71
+ }
72
+
73
+ // week index of a timestamp column against the window start ($ref), as SQL
74
+ const wk = (col, ref) => `floor(extract(epoch FROM (${col} - ${ref}::timestamptz)) / 604800)::int`;
75
+
76
+ async function tableExists(pool, name) {
77
+ const { rows } = await pool.query('SELECT to_regclass($1) IS NOT NULL AS ok', [`public.${name}`]);
78
+ return !!(rows[0] && rows[0].ok);
79
+ }
80
+
81
+ // The project's focus version when one is set, else db-versions.js
82
+ // currentBuildingVersionId's rule (building only, newest started first, id breaks a
83
+ // tie) — the version the roadmap and Goals already lead with.
84
+ async function versionBeingBuilt(pool, deps) {
85
+ const focus = await (deps.getFocusVersionId || api.projectSettings.getFocusVersionId)().catch(() => null);
86
+ const { rows } = await pool.query(
87
+ `SELECT id, name FROM versions
88
+ WHERE ($1::text IS NOT NULL AND id = $1) OR ($1::text IS NULL AND status = 'building')
89
+ ORDER BY started_at DESC NULLS LAST, id
90
+ LIMIT 1`,
91
+ [focus],
92
+ );
93
+ return rows[0] || null;
94
+ }
95
+
96
+ // The docket's answer, held per viewer for WAITING_TTL_MS. Home is the most-loaded
97
+ // page and the docket's routing is several reads deep, so a reload inside the
98
+ // window costs no reads. The dots are a display count that gates nothing, so the
99
+ // uncached-authority rule (ADR 0016) is untouched; the docket itself stays live.
100
+ // Keyed by port so a swapped port (a test, a reload) never sees another's answers.
101
+ const WAITING_TTL_MS = 30 * 1000;
102
+ const WAITING_MAX = 500;
103
+ const waitingMemo = new WeakMap();
104
+ async function yoursByGoalHeld(gov, viewer, now) {
105
+ const memo = waitingMemo.get(gov) || new Map();
106
+ waitingMemo.set(gov, memo);
107
+ const key = `${viewer.id}:${String(viewer.rank || '').toLowerCase()}`;
108
+ const hit = memo.get(key);
109
+ if (hit && now - hit.at < WAITING_TTL_MS) return hit.r;
110
+ const r = await Promise.resolve().then(() => gov.yoursByGoal(viewer)).catch((err) => {
111
+ log.warn({ err: err && err.message }, 'home city: the docket could not say what waits on the viewer');
112
+ return null;
113
+ });
114
+ if (r && r.available === true) {
115
+ if (memo.size >= WAITING_MAX) memo.delete(memo.keys().next().value);
116
+ memo.set(key, { at: now, r });
117
+ }
118
+ return r;
119
+ }
120
+
121
+ // Per area, how many matters wait on the viewer → [{ goal_id, n }]. The docket's
122
+ // routing through the government port when it answers; else the approximation
123
+ // (Archon only), so Home never loses its dots to a missing or failing port.
124
+ async function waitingOnViewer(pool, goalIds, viewer, deps) {
125
+ if (!viewer || viewer.id == null) return [];
126
+ const gov = deps.government !== undefined ? deps.government : api.resolveOptional('government');
127
+ if (gov && typeof gov.yoursByGoal === 'function') {
128
+ const r = await yoursByGoalHeld(gov, viewer, deps.now ? deps.now() : Date.now());
129
+ if (r && r.available === true) {
130
+ const areas = new Set(goalIds);
131
+ return Object.entries(r.goals || {})
132
+ .filter(([goalId, n]) => areas.has(String(goalId)) && Number(n) > 0)
133
+ .map(([goalId, n]) => ({ goal_id: String(goalId), n: Number(n) }));
134
+ }
135
+ }
136
+ if (String(viewer.rank || '').toLowerCase() !== 'archon') return [];
137
+ const hasAsks = await tableExists(pool, 'government_asks');
138
+ return (await pool.query(
139
+ `SELECT t.goal_id::text AS goal_id, count(DISTINCT b.id)::int AS n
140
+ FROM blockers b
141
+ JOIN task_blockers tb ON tb.blocker_id = b.id
142
+ JOIN tasks t ON t.id = tb.task_id
143
+ ${hasAsks ? `LEFT JOIN government_asks a ON a.matter_id = 'blockers:' || b.id` : ''}
144
+ WHERE b.status = 'open' AND t.goal_id = ANY($1::bigint[])
145
+ ${hasAsks ? `AND (a.matter_id IS NULL OR a.waits_on = 'owner')` : ''}
146
+ GROUP BY 1`,
147
+ [goalIds],
148
+ )).rows;
149
+ }
150
+
151
+ // → the raw rows, every query bounded by the window, the areas or a LIMIT.
152
+ // The reads that do not depend on each other run together (Promise.all): the
153
+ // window and the version first, then the areas, then everything else at once.
154
+ async function readCity({ weeks, viewer }, deps = {}) {
155
+ const pool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : api.pool;
156
+ const N = clampWeeks(weeks);
157
+ const rank = viewer ? String(viewer.rank || '').toLowerCase() : '';
158
+ const isArchon = rank === 'archon';
159
+ const viewerId = viewer && viewer.id != null ? String(viewer.id) : null;
160
+ const [{ rows: [win] }, version, hasVotes, hasRunners, hasIdeaVotes] = await Promise.all([
161
+ pool.query(
162
+ `SELECT ((date_trunc('week', now() AT TIME ZONE 'UTC') AT TIME ZONE 'UTC') - make_interval(weeks => $1 - 1)) AS start`,
163
+ [N],
164
+ ),
165
+ versionBeingBuilt(pool, deps),
166
+ tableExists(pool, 'government_board_votes'),
167
+ tableExists(pool, 'autonomy_runner_heartbeat'),
168
+ tableExists(pool, 'ideas_votes'),
169
+ ]);
170
+ const start = win.start;
171
+ const out = { N, start, version, areas: [], areaWeeks: [], leads: [], peopleWeeks: [], ideaCounts: [], ideas: [], gov: [], builders: [], sites: [], waiting: [], ideaVotes: [], taskTotal: null };
172
+
173
+ // VISIBILITY (ADR 0112's leak rule, as goal-graph.js applies it): a private
174
+ // goal the viewer is neither a member of nor an Archon over stays a tower (its
175
+ // counts are structure) but its title, and the titles of ideas tied to it, are
176
+ // dropped in shapeCity. Visibility comes from the signed-in viewer, never the query.
177
+ if (version) {
178
+ out.areas = (await pool.query(
179
+ `SELECT g.id::text AS goal_id, g.title,
180
+ (g.visibility IS DISTINCT FROM 'private'
181
+ OR $2::boolean
182
+ OR EXISTS (SELECT 1 FROM goal_members gm WHERE gm.goal_id = g.id AND gm.builder_id = $3::bigint)) AS visible
183
+ FROM goals g
184
+ WHERE g.version_id = $1 AND g.status = 'open' AND NOT g.is_maintenance
185
+ ORDER BY g.sort_order, g.id
186
+ LIMIT ${MAX_AREAS}`,
187
+ [version.id, isArchon, viewerId],
188
+ )).rows;
189
+ }
190
+ const goalIds = out.areas.map((a) => a.goal_id);
191
+ const none = Promise.resolve({ rows: [] });
192
+ const friction = rank === 'xenos' ? `AND i.kind <> '${SKILL_FRICTION_KIND}' AND i.title NOT LIKE '${SKILL_FRICTION_TAG}%'` : '';
193
+
194
+ // the idea's net votes and the viewer's own vote in ONE look-up per idea (at most
195
+ // MAX_IDEAS of them, each an index read on idx_ideas_votes_target), read only when the
196
+ // ideas module's votes table is there (a project with ideas switched off answers without them)
197
+ const ideaVoteCols = hasIdeaVotes ? 'iv.votes, iv.voted' : '0 AS votes, false AS voted';
198
+ const ideaVoteJoin = hasIdeaVotes
199
+ ? `LEFT JOIN LATERAL (SELECT coalesce(sum(v.vote), 0)::int AS votes, coalesce(bool_or(v.voter_builder_id = $2::bigint), false) AS voted
200
+ FROM ideas_votes v WHERE v.target_idea_id = i.id) iv ON true`
201
+ : '';
202
+ const [areaWeeks, leads, peopleWeeks, ideaCounts, ideas, gov, sites, waiting, ideaVotes, taskTotal] = await Promise.all([
203
+ goalIds.length ? pool.query(
204
+ `SELECT t.goal_id::text AS goal_id,
205
+ CASE WHEN t.shipped_at < $2 THEN -1 ELSE ${wk('t.shipped_at', '$2')} END AS wk,
206
+ (t.discipline = ANY($3::text[])) AS art,
207
+ count(*)::int AS n
208
+ FROM tasks t
209
+ WHERE t.status = 'shipped' AND t.shipped_at IS NOT NULL
210
+ AND t.goal_id = ANY($1::bigint[])
211
+ AND t.title NOT LIKE '${SMOKE_TITLE}' ESCAPE '\\'
212
+ GROUP BY 1, 2, 3`,
213
+ [goalIds, start, ART_DISCIPLINES],
214
+ ) : none,
215
+ goalIds.length ? pool.query(
216
+ `SELECT DISTINCT ON (goal_id, wk) goal_id, wk, shipped_by
217
+ FROM (SELECT t.goal_id::text AS goal_id, ${wk('t.shipped_at', '$2')} AS wk, t.shipped_by::text AS shipped_by, count(*) AS n
218
+ FROM tasks t
219
+ WHERE t.status = 'shipped' AND t.shipped_at >= $2 AND t.shipped_by IS NOT NULL
220
+ AND t.goal_id = ANY($1::bigint[])
221
+ GROUP BY 1, 2, 3) x
222
+ ORDER BY goal_id, wk, n DESC, shipped_by`,
223
+ [goalIds, start],
224
+ ) : none,
225
+ pool.query(
226
+ `SELECT t.shipped_by::text AS id, ${wk('t.shipped_at', '$1')} AS wk, (t.discipline = ANY($2::text[])) AS art, count(*)::int AS n
227
+ FROM tasks t
228
+ WHERE t.status = 'shipped' AND t.shipped_at >= $1 AND t.shipped_by IS NOT NULL
229
+ AND t.title NOT LIKE '${SMOKE_TITLE}' ESCAPE '\\'
230
+ GROUP BY 1, 2, 3`,
231
+ [start, ART_DISCIPLINES],
232
+ ),
233
+ pool.query(
234
+ `SELECT i.captured_by::text AS id, ${wk('i.captured_at', '$1')} AS wk, count(*)::int AS n
235
+ FROM idea_inbox i
236
+ WHERE i.captured_at >= $1 AND i.captured_by IS NOT NULL ${friction}
237
+ GROUP BY 1, 2`,
238
+ [start],
239
+ ),
240
+ pool.query(
241
+ `SELECT i.id::text AS id, i.title, i.captured_by::text AS by, ${wk('i.captured_at', '$1')} AS wk, pt.goal_id::text AS goal_id,
242
+ i.suggested_goal_id::text AS aim_id, i.status,
243
+ CASE WHEN pt.id IS NULL THEN NULL ELSE ${wk('pt.created_at', '$1')} END AS became_wk,
244
+ ${ideaVoteCols}
245
+ FROM idea_inbox i
246
+ LEFT JOIN tasks pt ON pt.id = i.promoted_to_task_id
247
+ ${ideaVoteJoin}
248
+ WHERE i.captured_at >= $1 ${friction}
249
+ ORDER BY i.captured_at DESC
250
+ LIMIT ${MAX_IDEAS}`,
251
+ hasIdeaVotes ? [start, viewerId] : [start],
252
+ ),
253
+ pool.query(
254
+ `SELECT id, wk, sum(n)::int AS n FROM (
255
+ ${hasVotes ? `SELECT v.voter_id::text AS id, ${wk('v.cast_at', '$1')} AS wk, count(*) AS n
256
+ FROM government_board_votes v WHERE v.cast_at >= $1 GROUP BY 1, 2
257
+ UNION ALL` : ''}
258
+ SELECT b.resolved_by::text AS id, ${wk('b.resolved_at', '$1')} AS wk, count(*) AS n
259
+ FROM blockers b WHERE b.status = 'resolved' AND b.resolved_at >= $1 AND b.resolved_by IS NOT NULL GROUP BY 1, 2
260
+ ) x GROUP BY 1, 2`,
261
+ [start],
262
+ ),
263
+ goalIds.length ? pool.query(
264
+ `SELECT DISTINCT ON (t.goal_id) t.goal_id::text AS goal_id, c.builder_id::text AS builder_id,
265
+ ${hasRunners ? `EXISTS (SELECT 1 FROM autonomy_runner_heartbeat h
266
+ WHERE h.working_task_id = t.id AND h.last_seen_at > now() - interval '15 minutes')` : 'false'} AS agent
267
+ FROM claims c
268
+ JOIN tasks t ON t.id = c.task_id
269
+ WHERE c.released_at IS NULL AND t.goal_id = ANY($1::bigint[])
270
+ ORDER BY t.goal_id, c.claimed_at DESC`,
271
+ [goalIds],
272
+ ) : none,
273
+ goalIds.length ? waitingOnViewer(pool, goalIds, viewer, deps) : Promise.resolve([]),
274
+ hasIdeaVotes ? pool.query(
275
+ `SELECT v.voter_builder_id::text AS id, ${wk('v.recorded_at', '$1')} AS wk, count(*)::int AS n
276
+ FROM ideas_votes v WHERE v.recorded_at >= $1 GROUP BY 1, 2`,
277
+ [start],
278
+ ) : none,
279
+ goalIds.length ? pool.query(
280
+ `SELECT t.goal_id::text AS goal_id, count(*)::int AS n FROM tasks t
281
+ WHERE t.goal_id = ANY($1::bigint[]) AND t.status <> 'abandoned'
282
+ AND t.title NOT LIKE '${SMOKE_TITLE}' ESCAPE '\\'
283
+ GROUP BY 1`,
284
+ [goalIds],
285
+ ) : none,
286
+ ]);
287
+ Object.assign(out, {
288
+ areaWeeks: areaWeeks.rows, leads: leads.rows, peopleWeeks: peopleWeeks.rows, ideaCounts: ideaCounts.rows,
289
+ ideas: ideas.rows, gov: gov.rows, sites: sites.rows, waiting,
290
+ ideaVotes: ideaVotes.rows, taskTotal: goalIds.length ? taskTotal.rows.reduce((n, r) => n + (Number(r.n) || 0), 0) : null,
291
+ });
292
+
293
+ const ids = new Set();
294
+ [out.leads.map((r) => r.shipped_by), out.peopleWeeks.map((r) => r.id), out.ideaCounts.map((r) => r.id), out.ideas.map((r) => r.by), out.gov.map((r) => r.id), out.sites.map((r) => r.builder_id), out.ideaVotes.map((r) => r.id)]
295
+ .forEach((list) => list.forEach((id) => { if (id != null) ids.add(String(id)); }));
296
+ if (viewerId) ids.add(viewerId);
297
+ if (ids.size) {
298
+ out.builders = (await pool.query(
299
+ `SELECT b.id::text AS id, b.github_login, b.display_name, b.avatar_url
300
+ FROM builders b
301
+ WHERE b.id = ANY($1::bigint[]) AND b.system_role IS NULL`,
302
+ [[...ids]],
303
+ )).rows;
304
+ }
305
+ return out;
306
+ }
307
+
308
+ // The raw rows → the response Home draws. Pure: a test feeds it rows with no
309
+ // database. Rows outside the window (a clock skew, a bad week index) are dropped,
310
+ // never counted at the edge.
311
+ function shapeCity(raw, viewer) {
312
+ const N = raw.N;
313
+ const inWeek = (w) => Number.isInteger(w) && w >= 0 && w < N;
314
+ const zeros = () => Array(N).fill(0);
315
+ const start = raw.start instanceof Date ? raw.start : new Date(raw.start);
316
+ const weeks = Array.from({ length: N }, (_, i) => ({ start: new Date(start.getTime() + i * 7 * 864e5).toISOString().slice(0, 10) }));
317
+
318
+ const areas = raw.areas.map((a) => ({ goal_id: a.goal_id, title: a.visible === false ? null : a.title, base_build: 0, base_art: 0, build: zeros(), art: zeros(), lead: Array(N).fill(null) }));
319
+ const byGoal = new Map(areas.map((a) => [a.goal_id, a]));
320
+ const hiddenGoals = new Set(raw.areas.filter((a) => a.visible === false).map((a) => String(a.goal_id)));
321
+ raw.areaWeeks.forEach((r) => {
322
+ const a = byGoal.get(String(r.goal_id)); if (!a) return;
323
+ const n = Number(r.n) || 0;
324
+ if (r.wk === -1) { if (r.art) a.base_art += n; else a.base_build += n; return; }
325
+ if (inWeek(r.wk)) (r.art ? a.art : a.build)[r.wk] += n;
326
+ });
327
+ raw.leads.forEach((r) => { const a = byGoal.get(String(r.goal_id)); if (a && inWeek(r.wk)) a.lead[r.wk] = String(r.shipped_by); });
328
+
329
+ const known = new Map(raw.builders.map((b) => [String(b.id), b]));
330
+ const people = new Map();
331
+ const person = (id) => {
332
+ const key = String(id);
333
+ if (!known.has(key)) return null; // a machine principal or a deleted builder is never drawn
334
+ if (!people.has(key)) {
335
+ const b = known.get(key);
336
+ people.set(key, { id: key, login: b.github_login || '', name: b.display_name || b.github_login || 'Builder', avatar_url: b.avatar_url || null, agent: false, build: zeros(), art: zeros(), idea: zeros(), gov: zeros(), vote: zeros() });
337
+ }
338
+ return people.get(key);
339
+ };
340
+ raw.peopleWeeks.forEach((r) => { const p = person(r.id); if (p && inWeek(r.wk)) (r.art ? p.art : p.build)[r.wk] += Number(r.n) || 0; });
341
+ raw.ideaCounts.forEach((r) => { const p = person(r.id); if (p && inWeek(r.wk)) p.idea[r.wk] += Number(r.n) || 0; });
342
+ raw.gov.forEach((r) => { const p = person(r.id); if (p && inWeek(r.wk)) p.gov[r.wk] += Number(r.n) || 0; });
343
+ (raw.ideaVotes || []).forEach((r) => { const p = person(r.id); if (p && inWeek(r.wk)) p.vote[r.wk] += Number(r.n) || 0; });
344
+ if (viewer && viewer.id != null) person(viewer.id);
345
+ areas.forEach((a) => { a.lead = a.lead.map((id) => (id && known.has(id) ? id : null)); });
346
+
347
+ // The sky's facts per idea (task 1005059). Its AREA is the goal of the work it became,
348
+ // else the goal its filer aimed it at — an area of this version, or none ("not placed
349
+ // yet"). It BECAME work the week its task was made, never before it was filed and never
350
+ // past this week. It WAITS on the viewer when it is new (these two weeks), open, in no
351
+ // area, not theirs, and they have not voted on it.
352
+ const viewerKey = viewer && viewer.id != null ? String(viewer.id) : null;
353
+ const ideas = raw.ideas.filter((r) => inWeek(r.wk)).map((r) => {
354
+ const goal = r.goal_id && byGoal.has(String(r.goal_id)) ? String(r.goal_id) : null;
355
+ const aim = r.aim_id && byGoal.has(String(r.aim_id)) ? String(r.aim_id) : null;
356
+ const area = goal || aim;
357
+ const became = Number.isInteger(r.became_wk) && goal ? Math.max(r.wk, Math.min(N - 1, r.became_wk)) : null;
358
+ const hidden = hiddenGoals.has(String(r.goal_id)) || hiddenGoals.has(String(r.aim_id));
359
+ const waits = !!viewerKey && r.status === 'open' && !area && r.wk >= N - 2 && String(r.by) !== viewerKey && !r.voted;
360
+ return { id: String(r.id), week: r.wk, by: r.by && known.has(String(r.by)) ? String(r.by) : null, title: hidden ? null : (r.title || ''), goal_id: goal, area_id: area, became, votes: Number(r.votes) || 0, waits };
361
+ });
362
+ const sites = raw.sites.filter((s) => byGoal.has(String(s.goal_id)) && known.has(String(s.builder_id)))
363
+ .map((s) => { person(s.builder_id); return { goal_id: String(s.goal_id), builder_id: String(s.builder_id), agent: !!s.agent }; });
364
+ const waiting = raw.waiting.filter((w) => byGoal.has(String(w.goal_id)) && Number(w.n) > 0).map((w) => ({ goal_id: String(w.goal_id), count: Number(w.n) }));
365
+
366
+ return {
367
+ viewer: viewer && viewer.id != null ? { id: String(viewer.id) } : null,
368
+ version: raw.version ? { id: raw.version.id, name: raw.version.name || raw.version.id, tasks: Number.isFinite(raw.taskTotal) ? raw.taskTotal : null } : null,
369
+ weeks, areas, people: [...people.values()], ideas, now: { sites, waiting },
370
+ };
371
+ }
372
+
373
+ // The GET /home/city handler, injectable so a test drives it with no express.
374
+ function homeCityHandler({ log, read = readCity } = {}) {
375
+ return async function homeCityRoute(req, res) {
376
+ try {
377
+ const viewer = req.builder ? { id: req.builder.id, rank: req.builder.rank } : null;
378
+ const raw = await read({ weeks: req.query && req.query.weeks, viewer });
379
+ res.set('Cache-Control', 'private, max-age=60');
380
+ res.json(shapeCity(raw, viewer));
381
+ } catch (err) {
382
+ if (log) log.error('GET /home/city', err);
383
+ res.fail('home_city_failed', { status: 500, message: 'internal error' });
384
+ }
385
+ };
386
+ }
387
+
388
+ module.exports = { WAITING_TTL_MS, DEFAULT_WEEKS, MAX_AREAS, MAX_IDEAS, clampWeeks, readCity, shapeCity, homeCityHandler };