@bongos/core 1.19.485 → 1.19.487
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 +78 -43
- 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 +7 -0
- package/clients/bongos-client/index.mjs +6 -0
- package/docs/adr/0232-rot-is-derived-not-swept-and-water-is-the-only-new-verb.md +155 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +148 -4
- package/docs/api-reference.md +7 -4
- package/docs/copy-inventory.md +64 -61
- package/docs/copy-registry.json +97 -70
- package/docs/module-api-changelog.md +4 -0
- package/migrations/core_231_project_settings_and_attention_water.sql +163 -0
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +6 -0
- package/modules/hall-ui/public/dom-utils.js +23 -1
- package/modules/hall-ui/public/goal-inbox.js +119 -1
- package/modules/hall-ui/public/hall-render.js +7 -8
- package/modules/hall-ui/public/settings.js +8 -7
- package/modules/lifecycle/help-requests.js +3 -3
- package/modules/lifecycle/migrations/lifecycle_006_help_requests_ui_craft.sql +38 -0
- package/modules/lifecycle/module.json +2 -1
- package/modules/lifecycle/rot.js +210 -0
- package/modules/lifecycle/routes/rot.js +160 -0
- package/modules/lifecycle/ship-card.js +4 -1
- package/modules/onboarding/newcomer-restock.js +53 -22
- package/modules/ui-design/module.json +1 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/fitness.js +9 -0
- package/scripts/gds/newcomer-floor.config.json +2 -1
- package/scripts/gds/newcomer-floor.js +7 -1
- package/scripts/gds/newcomer-floor.templates.json +43 -1
- package/src/bongos/db.js +3 -3
- package/src/bongos/project-settings.js +193 -0
- package/src/bongos/routes/me.js +2 -1
- package/src/module-api.js +29 -1
- package/tests/help_requests.mjs +21 -2
- package/tests/module_api.mjs +1 -0
- package/tests/module_contributions.mjs +67 -0
- package/tests/modules.mjs +19 -1
- package/tests/newcomer_restock.mjs +76 -0
- package/tests/rot_inbox.mjs +532 -0
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
-- core_231_project_settings_and_attention_water.sql — the rot clock and the knob
|
|
2
|
+
-- that sets it (task 1003279, goal 1000072, criterion legibility-sort-and-staleness).
|
|
3
|
+
--
|
|
4
|
+
-- THE GAP. Goal 1000072's rotting half asks that "untouched-for-X triggers a
|
|
5
|
+
-- configured action rather than a silent decay". Two things were missing to make
|
|
6
|
+
-- that sentence true, and this migration adds exactly those two:
|
|
7
|
+
--
|
|
8
|
+
-- 1. X had nowhere to live. src/stale-timer.js (task 1003476, ADR 0226) owns the
|
|
9
|
+
-- CLAIM-silence timer — hours, env-configured, and the sweep that ACTS on it
|
|
10
|
+
-- releases someone's claim. The rot timer is a different measurement with a
|
|
11
|
+
-- different unit and a different consequence (days; it asks a human a
|
|
12
|
+
-- question), and task 1003280 requires it be "live, DB-backed" and warns
|
|
13
|
+
-- "do not fork a second store". So: one generic project_settings table, with
|
|
14
|
+
-- the rot timer as its first key.
|
|
15
|
+
--
|
|
16
|
+
-- 2. "Water" had nothing to write. The owner's four verbs are prune, kill,
|
|
17
|
+
-- prioritise and WATER — leave the thing alone and reset its clock. Without
|
|
18
|
+
-- somewhere to record that, watering would have to fake a real edit, and the
|
|
19
|
+
-- same line would come back on the next read; the inbox teaches people to
|
|
20
|
+
-- ignore it within a week. attention_watered_at IS the reset.
|
|
21
|
+
--
|
|
22
|
+
-- WHY A COLUMN AND NOT AN ITEMS TABLE. There is deliberately no `attention_items`
|
|
23
|
+
-- row anywhere in this design. The inbox line is DERIVED on read from the rot clock
|
|
24
|
+
-- (below) vs the timer, which makes "exactly one open item per stale thing, not one
|
|
25
|
+
-- per sweep" true by construction rather than by a de-duplicating sweep that has to
|
|
26
|
+
-- be got right.
|
|
27
|
+
-- Acting on the line (prune/kill/prioritise) changes status and the row stops
|
|
28
|
+
-- matching; watering pushes the clock forward and it stops matching. Nothing to
|
|
29
|
+
-- reconcile, nothing to garbage-collect, and no way to accrete duplicates.
|
|
30
|
+
--
|
|
31
|
+
-- Additive + forward-safe: every column is nullable with no backfill, nothing is
|
|
32
|
+
-- renamed, and no existing reader changes meaning (tests/migrations_forward_safe.mjs).
|
|
33
|
+
|
|
34
|
+
BEGIN;
|
|
35
|
+
|
|
36
|
+
-- ---------------------------------------------------------------------------
|
|
37
|
+
-- 1. project_settings — the instance's live, DB-backed knobs.
|
|
38
|
+
-- ---------------------------------------------------------------------------
|
|
39
|
+
--
|
|
40
|
+
-- Deliberately a generic key/value table rather than one column per knob: task
|
|
41
|
+
-- 1003280 puts five project fundamentals on one page, and a table that grows a
|
|
42
|
+
-- migration per knob would make that page a schema change every time. Values are
|
|
43
|
+
-- stored as TEXT and parsed by a typed accessor at the read edge
|
|
44
|
+
-- (src/bongos/project-settings.js), the same shape src/stale-timer.js already uses
|
|
45
|
+
-- for env values — one parser, one range check, one documented default.
|
|
46
|
+
--
|
|
47
|
+
-- A key that has never been set has NO ROW. Absence means "use the code default",
|
|
48
|
+
-- which is what lets a fresh instance boot correctly with an empty table, and what
|
|
49
|
+
-- lets a settings page distinguish "never configured" from "deliberately set to
|
|
50
|
+
-- the same value the default happens to be".
|
|
51
|
+
CREATE TABLE IF NOT EXISTS project_settings (
|
|
52
|
+
key text PRIMARY KEY,
|
|
53
|
+
value text NOT NULL,
|
|
54
|
+
updated_at timestamptz NOT NULL DEFAULT now(),
|
|
55
|
+
updated_by bigint REFERENCES builders(id)
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
COMMENT ON TABLE project_settings IS
|
|
59
|
+
'Live, DB-backed per-project knobs (task 1003279). Key/value so task 1003280''s settings page can grow a row without a migration. No row = never configured = use the code default; that absence is meaningful, so do not seed defaults here.';
|
|
60
|
+
COMMENT ON COLUMN project_settings.value IS
|
|
61
|
+
'Stored as text and parsed by a typed accessor at the read edge (src/bongos/project-settings.js). A value that fails its range check is IGNORED with a receipt, never coerced silently — the ADR 0226 posture.';
|
|
62
|
+
COMMENT ON COLUMN project_settings.updated_by IS
|
|
63
|
+
'The builder who last set this knob. NULL = set by a system path with no builder in the request (the goals.created_by convention, migration 160).';
|
|
64
|
+
|
|
65
|
+
-- ---------------------------------------------------------------------------
|
|
66
|
+
-- 2. The water mark, on both tiers that can rot.
|
|
67
|
+
-- ---------------------------------------------------------------------------
|
|
68
|
+
--
|
|
69
|
+
-- NULL = never watered, and the thing's own activity timestamp is the whole clock.
|
|
70
|
+
-- This is why the read expression is a GREATEST and not a bare COALESCE: a real edit
|
|
71
|
+
-- after a watering must move the clock forward again, and a watering must never make
|
|
72
|
+
-- a thing look OLDER than its last genuine touch.
|
|
73
|
+
--
|
|
74
|
+
-- THE ROT CLOCK, AND WHY IT IS SHAPED LIKE THIS:
|
|
75
|
+
--
|
|
76
|
+
-- tasks: GREATEST(updated_at, COALESCE(shipped_at, updated_at),
|
|
77
|
+
-- COALESCE(attention_watered_at, updated_at))
|
|
78
|
+
-- goals: GREATEST(updated_at, COALESCE(attention_watered_at, updated_at))
|
|
79
|
+
--
|
|
80
|
+
-- The first two terms are NOT a new definition of "touched" — they are exactly the
|
|
81
|
+
-- one modules/hall-ui/public/task-lib.js already publishes for the Tasks board and
|
|
82
|
+
-- the Goals page (`taskActivityMs = max(updated_at, shipped_at)`, task 1003278, this
|
|
83
|
+
-- same criterion), which folds in shipped_at because a ship path can stamp it
|
|
84
|
+
-- without touching updated_at. This migration adds a THIRD term to that definition
|
|
85
|
+
-- and nothing else. Had the server invented its own two-term clock, the board and
|
|
86
|
+
-- the inbox would disagree about which rows are stale — which is the 6h-vs-24h
|
|
87
|
+
-- failure of ADR 0226 reappearing one tier up, and the reason the indexes below
|
|
88
|
+
-- spell the expression out in full rather than approximating it.
|
|
89
|
+
-- (Goals carry no shipped_at column, so their clock has two terms, not three.)
|
|
90
|
+
ALTER TABLE tasks
|
|
91
|
+
ADD COLUMN IF NOT EXISTS attention_watered_at timestamptz,
|
|
92
|
+
ADD COLUMN IF NOT EXISTS attention_watered_by bigint REFERENCES builders(id);
|
|
93
|
+
|
|
94
|
+
ALTER TABLE goals
|
|
95
|
+
ADD COLUMN IF NOT EXISTS attention_watered_at timestamptz,
|
|
96
|
+
ADD COLUMN IF NOT EXISTS attention_watered_by bigint REFERENCES builders(id);
|
|
97
|
+
|
|
98
|
+
COMMENT ON COLUMN tasks.attention_watered_at IS
|
|
99
|
+
'When a human last said "leave it, it is fine" about this task rotting (task 1003279). Resets the rot clock without touching the work. NULL = never watered.';
|
|
100
|
+
COMMENT ON COLUMN goals.attention_watered_at IS
|
|
101
|
+
'When a human last said "leave it, it is fine" about this goal rotting (task 1003279). Resets the rot clock without touching the work. NULL = never watered.';
|
|
102
|
+
|
|
103
|
+
-- The rot read is "open things whose clock is older than N days", so the index is
|
|
104
|
+
-- on (status, clock) — status first because it is the equality predicate and it
|
|
105
|
+
-- cuts the table to the open rows before the range scan ever runs.
|
|
106
|
+
CREATE INDEX IF NOT EXISTS idx_tasks_attention_rot
|
|
107
|
+
ON tasks (status, (GREATEST(updated_at,
|
|
108
|
+
COALESCE(shipped_at, updated_at),
|
|
109
|
+
COALESCE(attention_watered_at, updated_at))));
|
|
110
|
+
CREATE INDEX IF NOT EXISTS idx_goals_attention_rot
|
|
111
|
+
ON goals (status, (GREATEST(updated_at, COALESCE(attention_watered_at, updated_at))));
|
|
112
|
+
|
|
113
|
+
-- ---------------------------------------------------------------------------
|
|
114
|
+
-- 3. Rebuild the two `t.*` views so the new columns are projected.
|
|
115
|
+
-- ---------------------------------------------------------------------------
|
|
116
|
+
--
|
|
117
|
+
-- A Postgres view created with `SELECT t.*` pins its column list at CREATE VIEW
|
|
118
|
+
-- time. core_227's header records six columns that silently never reached the claim
|
|
119
|
+
-- feed exactly this way; bodies below are identical to core_227's (the wildcard
|
|
120
|
+
-- re-expands to every current column).
|
|
121
|
+
|
|
122
|
+
DROP VIEW IF EXISTS claimable_tasks;
|
|
123
|
+
CREATE VIEW claimable_tasks AS
|
|
124
|
+
SELECT t.*,
|
|
125
|
+
pms_calibrated_minutes(t.est_minutes, t.kind) AS est_minutes_calibrated
|
|
126
|
+
FROM tasks t
|
|
127
|
+
WHERE t.status = 'ready'
|
|
128
|
+
AND t.title NOT LIKE '\_\_smoke\_\_%' ESCAPE '\'
|
|
129
|
+
AND NOT EXISTS (
|
|
130
|
+
SELECT 1 FROM task_blockers tb WHERE tb.task_id = t.id
|
|
131
|
+
)
|
|
132
|
+
AND NOT EXISTS (
|
|
133
|
+
SELECT 1 FROM claims c
|
|
134
|
+
JOIN tasks t2 ON t2.id = c.task_id
|
|
135
|
+
WHERE c.released_at IS NULL
|
|
136
|
+
AND t2.touches && t.touches
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
DROP VIEW IF EXISTS tasks_session_fit;
|
|
140
|
+
CREATE VIEW tasks_session_fit AS
|
|
141
|
+
SELECT t.*,
|
|
142
|
+
CASE
|
|
143
|
+
WHEN t.automation_tag = 'overnight-safe' AND COALESCE(t.manual_degree, 5) <= 2
|
|
144
|
+
AND NOT EXISTS (SELECT 1 FROM task_blockers tb WHERE tb.task_id = t.id)
|
|
145
|
+
THEN ARRAY['autonomous','check-in','active']
|
|
146
|
+
WHEN t.automation_tag = 'live-only' OR COALESCE(t.manual_degree, 5) >= 4
|
|
147
|
+
THEN ARRAY['active']
|
|
148
|
+
ELSE ARRAY['check-in','active']
|
|
149
|
+
END AS fits_session_modes,
|
|
150
|
+
pms_calibrated_minutes(t.est_minutes, t.kind) AS est_minutes_calibrated,
|
|
151
|
+
CASE
|
|
152
|
+
WHEN t.credits_reward > 0
|
|
153
|
+
AND pms_calibrated_minutes(t.est_minutes, t.kind) IS NOT NULL
|
|
154
|
+
AND pms_calibrated_minutes(t.est_minutes, t.kind) > 0
|
|
155
|
+
THEN ROUND(t.credits_reward::numeric / pms_calibrated_minutes(t.est_minutes, t.kind), 3)
|
|
156
|
+
ELSE NULL
|
|
157
|
+
END AS credits_per_minute
|
|
158
|
+
FROM tasks t;
|
|
159
|
+
|
|
160
|
+
COMMIT;
|
|
161
|
+
|
|
162
|
+
INSERT INTO schema_migrations (version) VALUES ('core_231_project_settings_and_attention_water')
|
|
163
|
+
ON CONFLICT (version) DO NOTHING;
|
|
@@ -386,6 +386,8 @@ function createClient(opts = {}) {
|
|
|
386
386
|
postGoalsIdTransfer: (args) => request("POST", "/goals/{id}/transfer", { hasBody: true }, args),
|
|
387
387
|
// PATCH /goals/{id}/visibility — rank: any-builder — PATCH /goals/:id/visibility
|
|
388
388
|
patchGoalsIdVisibility: (args) => request("PATCH", "/goals/{id}/visibility", { hasBody: true }, args),
|
|
389
|
+
// POST /goals/{id}/water — rank: any-builder — POST /goals/:id/water
|
|
390
|
+
postGoalsIdWater: (args) => request("POST", "/goals/{id}/water", { hasBody: true }, args),
|
|
389
391
|
// GET /goals/inbox — rank: any-builder — GET /goals/inbox
|
|
390
392
|
getGoalsInbox: (args) => request("GET", "/goals/inbox", { hasBody: false }, args),
|
|
391
393
|
// POST /goals/reorder — rank: metic+archon — POST /goals/reorder
|
|
@@ -466,6 +468,8 @@ function createClient(opts = {}) {
|
|
|
466
468
|
getInboxAwaitingNod: (args) => request("GET", "/inbox/awaiting-nod", { hasBody: false }, args),
|
|
467
469
|
// GET /inbox/by-builder/{id} — rank: any-builder — GET /inbox/by-builder/:id
|
|
468
470
|
getInboxByBuilderId: (args) => request("GET", "/inbox/by-builder/{id}", { hasBody: false }, args),
|
|
471
|
+
// GET /inbox/rotting — rank: any-builder — GET /inbox/rotting
|
|
472
|
+
getInboxRotting: (args) => request("GET", "/inbox/rotting", { hasBody: false }, args),
|
|
469
473
|
// POST /inbox/score — rank: any-builder — POST /inbox/score
|
|
470
474
|
postInboxScore: (args) => request("POST", "/inbox/score", { hasBody: true }, args),
|
|
471
475
|
// GET /inbox/sparks — rank: any-builder — GET /inbox/sparks
|
|
@@ -838,6 +842,8 @@ function createClient(opts = {}) {
|
|
|
838
842
|
postTasksIdVisual: (args) => request("POST", "/tasks/{id}/visual", { hasBody: true }, args),
|
|
839
843
|
// POST /tasks/{id}/vote — rank: metic+archon — POST /tasks/:id/vote
|
|
840
844
|
postTasksIdVote: (args) => request("POST", "/tasks/{id}/vote", { hasBody: true }, args),
|
|
845
|
+
// POST /tasks/{id}/water — rank: any-builder — POST /tasks/:id/water
|
|
846
|
+
postTasksIdWater: (args) => request("POST", "/tasks/{id}/water", { hasBody: true }, args),
|
|
841
847
|
// GET /tasks/claimable — rank: any-builder — GET /tasks/claimable
|
|
842
848
|
getTasksClaimable: (args) => request("GET", "/tasks/claimable", { hasBody: false }, args),
|
|
843
849
|
// GET /tasks/newcomer-floor — rank: any-builder — GET /tasks/newcomer-floor
|
|
@@ -106,6 +106,28 @@
|
|
|
106
106
|
return m.enabled[name] === true;
|
|
107
107
|
}
|
|
108
108
|
|
|
109
|
+
// The crafts THIS instance offers, in the server's own order (task 1003487).
|
|
110
|
+
// serve-internal.js injects it beside `enabled` from clientModules(), and it is
|
|
111
|
+
// the very list `db.updateBuilderDisciplines` validates a PATCH against — so a
|
|
112
|
+
// picker built from it can never offer a craft the API would 422. A module adds
|
|
113
|
+
// its own via `contributes.disciplines` (art-pipeline → artist, ui-design → ui),
|
|
114
|
+
// which is why no picker needs a moduleOn() branch per craft any more. Falls back
|
|
115
|
+
// to the two core crafts when an older server sent no list, never to an empty
|
|
116
|
+
// picker.
|
|
117
|
+
function disciplines() {
|
|
118
|
+
const m = window.__MODULES__;
|
|
119
|
+
const list = m && Array.isArray(m.disciplines) ? m.disciplines : null;
|
|
120
|
+
return list && list.length ? list.slice() : ['engineer', 'ideator'];
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// How a craft is named in a picker. An acronym stays upper-case ('ui' → 'UI');
|
|
124
|
+
// anything else is Title Case, so a craft a module adds later needs no new entry.
|
|
125
|
+
function disciplineLabel(d) {
|
|
126
|
+
const s = String(d || '');
|
|
127
|
+
if (!s) return '';
|
|
128
|
+
return s === 'ui' ? 'UI' : s.charAt(0).toUpperCase() + s.slice(1);
|
|
129
|
+
}
|
|
130
|
+
|
|
109
131
|
// Module→core settings-panel contribution seam (BV1.R42, ADR 0083) — the
|
|
110
132
|
// browser twin of the server's 'profile.fields' seam (src/module-seams.js). A
|
|
111
133
|
// module that has left core registers a Settings panel from its own client
|
|
@@ -301,7 +323,7 @@
|
|
|
301
323
|
}
|
|
302
324
|
|
|
303
325
|
window.OTB = {
|
|
304
|
-
escapeHtml, $, fmtNum, num, fmtInt, fmtCompact, fmtDate, rankLabel, toast, moduleOn,
|
|
326
|
+
escapeHtml, $, fmtNum, num, fmtInt, fmtCompact, fmtDate, rankLabel, toast, moduleOn, disciplines, disciplineLabel,
|
|
305
327
|
errCode, errMessage, errDetails, // task 1002462 — ADR 0116 envelope readers
|
|
306
328
|
contributeSettingsPanel, settingsPanels,
|
|
307
329
|
contributeHallWidget, hallWidgets, hallWidgetIds,
|
|
@@ -167,6 +167,19 @@
|
|
|
167
167
|
}
|
|
168
168
|
|
|
169
169
|
function onClick(e) {
|
|
170
|
+
// task 1003279: a rot verb. Handled FIRST because its rows carry no data-goal —
|
|
171
|
+
// the path is server-declared and already tier-correct, so this branch needs to
|
|
172
|
+
// know nothing about which of the four verbs was pressed beyond what to say
|
|
173
|
+
// afterwards. Refreshes the ROT slice only: repainting the goals list would
|
|
174
|
+
// leave the acted-on row sitting there (the task-1003066 lesson).
|
|
175
|
+
const rotBtn = e.target.closest('[data-rot-path]');
|
|
176
|
+
if (rotBtn) {
|
|
177
|
+
const verb = rotBtn.dataset.rotVerb;
|
|
178
|
+
act(() => postJSON(`${API}${rotBtn.dataset.rotPath}`, {}),
|
|
179
|
+
ROT_TOASTS[verb] || 'Done.', loadRot);
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
|
|
170
183
|
// task 1003066 (BV1.R05): the nod. It reuses the EXISTING promote route —
|
|
171
184
|
// POST /tasks/:id/promote, already Metic+ via task.promote — so giving a nod
|
|
172
185
|
// adds no endpoint and no permission. Handled before the membership branches
|
|
@@ -262,6 +275,104 @@
|
|
|
262
275
|
.catch(() => { /* a non-triager's 403, or a hiccup: never break the hall */ });
|
|
263
276
|
}
|
|
264
277
|
|
|
278
|
+
// ---- the rot queue (task 1003279, goal 1000072) ---------------------------
|
|
279
|
+
//
|
|
280
|
+
// "Untouched-for-X triggers a configured action rather than a silent decay."
|
|
281
|
+
// The owner's decision (2026-08-25): the action is a LINE IN THIS CARD, not an
|
|
282
|
+
// automatic status change and not a nightly digest. Auto-demoting mutates real
|
|
283
|
+
// work with no human in the loop, and a wrong timer then silently freezes it; a
|
|
284
|
+
// digest is a notification, not an action, and gets scrolled past.
|
|
285
|
+
//
|
|
286
|
+
// THE BUTTONS ARE SERVER-DECLARED. Each row arrives carrying its own `verbs`
|
|
287
|
+
// array — label, method and path — and this renders exactly those. It is not
|
|
288
|
+
// decoration: three of the four verbs reuse EXISTING routes (promote, demote,
|
|
289
|
+
// abandon, archive) whose permissions differ by tier, and a hardcoded button list
|
|
290
|
+
// here would be a second copy of that mapping, free to drift into offering a
|
|
291
|
+
// button the server will refuse. A goal legitimately gets two verbs where a task
|
|
292
|
+
// gets four; this file does not need to know why.
|
|
293
|
+
//
|
|
294
|
+
// No client-side rank gate, for the same reason the nod slice has none: the read
|
|
295
|
+
// is scoped server-side, so a builder is only ever handed rows that are theirs to
|
|
296
|
+
// act on and the button list is already narrowed to what they can press.
|
|
297
|
+
const MAX_ROT_ROWS = 5;
|
|
298
|
+
|
|
299
|
+
function untouchedFor(iso) {
|
|
300
|
+
const t = iso ? Date.parse(iso) : NaN;
|
|
301
|
+
if (Number.isNaN(t)) return 'untouched';
|
|
302
|
+
const days = Math.floor((Date.now() - t) / 86400000);
|
|
303
|
+
if (days < 1) return 'untouched today';
|
|
304
|
+
return `untouched ${days} day${days === 1 ? '' : 's'}`;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function rotRowHtml(r) {
|
|
308
|
+
const href = r.tier === 'goal'
|
|
309
|
+
? goalHref(r.id)
|
|
310
|
+
: `/task/${encodeURIComponent(r.id)}`;
|
|
311
|
+
const where = r.tier === 'goal'
|
|
312
|
+
? 'goal'
|
|
313
|
+
: (r.goal_title ? escapeHtml(r.goal_title) : `goal ${escapeHtml(String(r.goal_id || '—'))}`);
|
|
314
|
+
// `watered` is worth showing: a row that is back DESPITE having been watered is
|
|
315
|
+
// a different message from one nobody has ever looked at, and hiding that would
|
|
316
|
+
// make the second watering feel like the first one did nothing.
|
|
317
|
+
const again = r.ever_watered ? ' · watered before' : '';
|
|
318
|
+
const buttons = (Array.isArray(r.verbs) ? r.verbs : []).map((v) => {
|
|
319
|
+
const path = String(v.path || '').replace(':id', encodeURIComponent(r.id));
|
|
320
|
+
return `<button class="btn-ghost" type="button" data-rot-path="${escapeHtml(path)}"`
|
|
321
|
+
+ ` data-rot-verb="${escapeHtml(String(v.verb || ''))}"`
|
|
322
|
+
+ ` title="${escapeHtml(String(v.hint || ''))}">${escapeHtml(String(v.label || v.verb || '?'))}</button>`;
|
|
323
|
+
}).join('');
|
|
324
|
+
return `<li class="ginbox__item" data-rot-tier="${escapeHtml(String(r.tier))}">
|
|
325
|
+
<div class="ginbox__main">
|
|
326
|
+
<a class="ginbox__goal" href="${href}">${escapeHtml(r.title || `${r.tier} #${r.id}`)}</a>
|
|
327
|
+
<span class="ginbox__sub">${escapeHtml(String(r.status || ''))} · ${where} · ${escapeHtml(untouchedFor(r.last_touched_at))}${again}</span>
|
|
328
|
+
</div>
|
|
329
|
+
<div class="ginbox__acts">${buttons}</div>
|
|
330
|
+
</li>`;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// What each verb says when it lands. Keyed by the SERVER's verb name so a new verb
|
|
334
|
+
// degrades to a neutral confirmation instead of a blank toast.
|
|
335
|
+
const ROT_TOASTS = {
|
|
336
|
+
prioritise: 'Prioritised — it’s claimable now.',
|
|
337
|
+
prune: 'Pruned — back in the backlog.',
|
|
338
|
+
kill: 'Killed — it won’t come back.',
|
|
339
|
+
water: 'Watered — the clock is reset.',
|
|
340
|
+
};
|
|
341
|
+
|
|
342
|
+
function renderRot(data) {
|
|
343
|
+
const box = host && host.querySelector('#attention-rot');
|
|
344
|
+
if (!box) return;
|
|
345
|
+
const rows = (data && Array.isArray(data.rows)) ? data.rows : [];
|
|
346
|
+
if (!rows.length) {
|
|
347
|
+
box.innerHTML = '';
|
|
348
|
+
window.OTBAttention?.refresh();
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
const shown = rows.slice(0, MAX_ROT_ROWS);
|
|
352
|
+
const more = rows.length > shown.length
|
|
353
|
+
? `<p class="ginbox__pending">…${rows.length - shown.length} more going quiet.</p>`
|
|
354
|
+
: '';
|
|
355
|
+
// The heading names the TIMER, not just the count. A reader who disagrees with
|
|
356
|
+
// the line ("30 days is too short for a backlog") needs to know which number
|
|
357
|
+
// produced it before they can go change it — and when that number came from a
|
|
358
|
+
// value the server could not use, the receipt says so here, on the screen of
|
|
359
|
+
// somebody who can fix it.
|
|
360
|
+
const t = (data && data.timer) || {};
|
|
361
|
+
const timerNote = t.invalid
|
|
362
|
+
? `<span class="ginbox__sub"> — ${escapeHtml(String(t.describe || ''))}</span>`
|
|
363
|
+
: `<span class="ginbox__sub"> — untouched ${escapeHtml(String(t.rot_days ?? '?'))}+ days</span>`;
|
|
364
|
+
box.innerHTML = '<h3 class="ginbox__sec-h">Going quiet '
|
|
365
|
+
+ `<span class="count-pill">${rows.length}</span>${timerNote}</h3>`
|
|
366
|
+
+ `<ul class="ginbox__list">${shown.map(rotRowHtml).join('')}</ul>${more}`;
|
|
367
|
+
window.OTBAttention?.refresh();
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function loadRot() {
|
|
371
|
+
return getJSON(`${API}/inbox/rotting`)
|
|
372
|
+
.then((data) => renderRot(data))
|
|
373
|
+
.catch(() => { /* signed out, older server, or a hiccup: never break the hall */ });
|
|
374
|
+
}
|
|
375
|
+
|
|
265
376
|
// task 2169: this widget IS the "Needs your attention" card — ONE capped
|
|
266
377
|
// summary hosting every actionable item class: goal invitations +
|
|
267
378
|
// join-requests (this file) and ideas awaiting triage (builders.js renders
|
|
@@ -294,10 +405,17 @@
|
|
|
294
405
|
// awaiting a nod inside a goal sits nearer the goals slice than idea
|
|
295
406
|
// triage. Its rows carry the shared .ginbox__item class, so refresh()
|
|
296
407
|
// counts them with no change to the selector below.
|
|
297
|
-
'<div id="attention-nod"></div>'
|
|
408
|
+
'<div id="attention-nod"></div>' +
|
|
409
|
+
// task 1003279 (goal 1000072): work going quiet — a goal or task untouched
|
|
410
|
+
// past the project's rot timer, with the owner's four verbs on it. LAST in
|
|
411
|
+
// the card on purpose: every slice above is somebody waiting on YOU right
|
|
412
|
+
// now, while rot is a standing question that has already waited weeks and
|
|
413
|
+
// can wait until they are read.
|
|
414
|
+
'<div id="attention-rot"></div>';
|
|
298
415
|
host.addEventListener('click', onClick);
|
|
299
416
|
load();
|
|
300
417
|
loadNodQueue();
|
|
418
|
+
loadRot();
|
|
301
419
|
// task 1002802 (P06): the profile slice lives in its own file (builders.js is
|
|
302
420
|
// over the size budget); this card owns its div, so it boots it here.
|
|
303
421
|
window.OTBProfileNudge?.load();
|
|
@@ -923,17 +923,16 @@
|
|
|
923
923
|
? ctx.lastMeState.builder.preferred_disciplines : [];
|
|
924
924
|
const discBtn = (value, label) => {
|
|
925
925
|
const on = prefs.includes(value);
|
|
926
|
-
return `<button type="button" class="btn-ghost onboarding-item__disc${on ? ' onboarding-item__disc--on' : ''}" data-disc="${value}" aria-pressed="${on}">${label}</button>`;
|
|
926
|
+
return `<button type="button" class="btn-ghost onboarding-item__disc${on ? ' onboarding-item__disc--on' : ''}" data-disc="${escapeHtml(value)}" aria-pressed="${on}">${escapeHtml(label)}</button>`;
|
|
927
927
|
};
|
|
928
|
-
//
|
|
929
|
-
//
|
|
930
|
-
//
|
|
931
|
-
//
|
|
928
|
+
// The offered crafts come from the server (task 1003487): window.__MODULES__
|
|
929
|
+
// .disciplines IS enabledDisciplines(), the same list the PATCH validates
|
|
930
|
+
// against, so this can never render a button the API would reject. Was a
|
|
931
|
+
// hardcoded triple with a moduleOn('art-pipeline') branch for 'artist', so
|
|
932
|
+
// a module-contributed craft (ui-design → ui) never appeared here at all.
|
|
932
933
|
action = `
|
|
933
934
|
<div class="onboarding-item__action onboarding-item__disc-pick">
|
|
934
|
-
${discBtn(
|
|
935
|
-
${OTB.moduleOn('art-pipeline') ? discBtn('artist', 'Artist') : ''}
|
|
936
|
-
${discBtn('ideator', 'Ideator')}
|
|
935
|
+
${OTB.disciplines().map((d) => discBtn(d, OTB.disciplineLabel(d))).join('')}
|
|
937
936
|
</div>`;
|
|
938
937
|
} else if (s.id === 'ship_three') {
|
|
939
938
|
action = `
|
|
@@ -732,12 +732,13 @@
|
|
|
732
732
|
|
|
733
733
|
// ---- section 2: CRAFT (discipline preferences) ---------------------------
|
|
734
734
|
|
|
735
|
-
//
|
|
736
|
-
//
|
|
737
|
-
//
|
|
738
|
-
//
|
|
739
|
-
|
|
740
|
-
|
|
735
|
+
// The crafts this instance offers, straight from the server (task 1003487):
|
|
736
|
+
// window.__MODULES__.disciplines IS enabledDisciplines(), which is also what
|
|
737
|
+
// db.updateBuilderDisciplines validates against, so what this renders and what
|
|
738
|
+
// the PATCH accepts cannot drift. Was a hardcoded triple gated on
|
|
739
|
+
// moduleOn('art-pipeline'), which silently dropped a module-contributed craft
|
|
740
|
+
// (ui-design → ui) from BOTH the chips and the saved-state echo below.
|
|
741
|
+
const DISCIPLINES = window.OTB.disciplines();
|
|
741
742
|
|
|
742
743
|
// Current selection mutated in place as the builder toggles chips; saved on
|
|
743
744
|
// the "Save" button (the hall's profile card behaviour) via PATCH.
|
|
@@ -751,7 +752,7 @@
|
|
|
751
752
|
return (
|
|
752
753
|
`<label class="craft-chip" data-disc="${escapeHtml(d)}" data-on="${on ? '1' : '0'}">` +
|
|
753
754
|
`<input type="checkbox" class="craft-pref" value="${escapeHtml(d)}"${on ? ' checked' : ''}>` +
|
|
754
|
-
`${escapeHtml(d)}</label>`
|
|
755
|
+
`${escapeHtml(window.OTB.disciplineLabel(d))}</label>`
|
|
755
756
|
);
|
|
756
757
|
}).join('');
|
|
757
758
|
}
|
|
@@ -20,10 +20,10 @@ const { pool } = require('../../src/module-api');
|
|
|
20
20
|
// spells this same craft 'engineer', not 'builder'. THIS FUNCTION IS THE ONE PLACE
|
|
21
21
|
// that knows the mapping — the matching surface (C02) must consume it rather than
|
|
22
22
|
// re-deriving, or the two spellings drift and a builder silently matches nothing.
|
|
23
|
-
const HELP_CRAFTS = Object.freeze(['artist', 'builder', 'ideator']);
|
|
24
|
-
const CRAFT_TO_DISCIPLINE = Object.freeze({ artist: 'artist', ideator: 'ideator', builder: 'engineer' });
|
|
23
|
+
const HELP_CRAFTS = Object.freeze(['artist', 'builder', 'ideator', 'ui']);
|
|
24
|
+
const CRAFT_TO_DISCIPLINE = Object.freeze({ artist: 'artist', ideator: 'ideator', builder: 'engineer', ui: 'ui' });
|
|
25
25
|
|
|
26
|
-
const DISCIPLINE_TO_CRAFT = Object.freeze({ artist: 'artist', ideator: 'ideator', engineer: 'builder' });
|
|
26
|
+
const DISCIPLINE_TO_CRAFT = Object.freeze({ artist: 'artist', ideator: 'ideator', engineer: 'builder', ui: 'ui' });
|
|
27
27
|
|
|
28
28
|
function helpCrafts() { return [...HELP_CRAFTS]; }
|
|
29
29
|
function disciplineForCraft(craft) { return CRAFT_TO_DISCIPLINE[craft] || null; }
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
-- lifecycle_006_help_requests_ui_craft.sql — the `ui` craft becomes addressable in
|
|
2
|
+
-- a help request (task 1003487, the routing half of goal 1000025's UI-reachability
|
|
3
|
+
-- pair).
|
|
4
|
+
--
|
|
5
|
+
-- WHY THIS MIGRATION EXISTS AT ALL. lifecycle_004 fixed the craft vocabulary to
|
|
6
|
+
-- ADR 0174's three (artist, builder, ideator) in a CHECK. `ui` became a real
|
|
7
|
+
-- discipline later (migration 176 widened `builders.preferred_disciplines`, and
|
|
8
|
+
-- discipline-modes.json routes a ui task to /design), and task 1003487 makes it
|
|
9
|
+
-- selectable everywhere a builder chooses work. Every OTHER surface inherits that
|
|
10
|
+
-- automatically because it validates against enabledDisciplines() at run time —
|
|
11
|
+
-- this one does not, because a CHECK is compiled into the table. Without this the
|
|
12
|
+
-- app would accept a help request addressed to `ui` (helpCrafts() now offers it)
|
|
13
|
+
-- and Postgres would reject the INSERT, which is the worst shape of all: a
|
|
14
|
+
-- constraint violation surfacing as a 500 on a feature that reads as supported.
|
|
15
|
+
--
|
|
16
|
+
-- THE VOCABULARY SEAM IS UNCHANGED. `builders.preferred_disciplines` spells the
|
|
17
|
+
-- engineering craft 'engineer' while a help request spells it 'builder'; `ui` is
|
|
18
|
+
-- spelled the same on both sides, so it simply joins the list. help-requests.js
|
|
19
|
+
-- stays the single place that knows the mapping (CRAFT_TO_DISCIPLINE /
|
|
20
|
+
-- DISCIPLINE_TO_CRAFT), and craftMapSql() generates its SQL CASE from that same
|
|
21
|
+
-- object — so nothing here needs to restate it.
|
|
22
|
+
--
|
|
23
|
+
-- SAFE TO RE-RUN. Dropping by name and re-adding is idempotent in effect; existing
|
|
24
|
+
-- rows all carry one of the three older values (or NULL) and satisfy the wider
|
|
25
|
+
-- CHECK, so the ADD VALIDATE cannot fail on live data. No data is rewritten.
|
|
26
|
+
|
|
27
|
+
ALTER TABLE lifecycle_help_requests
|
|
28
|
+
DROP CONSTRAINT IF EXISTS lifecycle_help_requests_craft_chk;
|
|
29
|
+
|
|
30
|
+
ALTER TABLE lifecycle_help_requests
|
|
31
|
+
ADD CONSTRAINT lifecycle_help_requests_craft_chk
|
|
32
|
+
CHECK (needs_craft IS NULL OR needs_craft IN ('artist', 'builder', 'ideator', 'ui'));
|
|
33
|
+
|
|
34
|
+
COMMENT ON COLUMN lifecycle_help_requests.needs_craft IS
|
|
35
|
+
'The craft this request is addressed to: ADR 0174''s three (artist, builder, ideator) plus ui, which the ui-design module contributes as a discipline (task 1003487). helpCrafts() in modules/lifecycle/help-requests.js is the single place that knows this vocabulary and its spelling seam with builders.preferred_disciplines.';
|
|
36
|
+
|
|
37
|
+
INSERT INTO schema_migrations (version) VALUES ('lifecycle_006_help_requests_ui_craft')
|
|
38
|
+
ON CONFLICT (version) DO NOTHING;
|