@bongos/core 1.19.374 → 1.19.376
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 +99 -54
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +4 -0
- package/clients/bongos-client/index.cjs +4 -0
- package/clients/bongos-client/index.d.ts +9 -1
- package/clients/bongos-client/index.mjs +4 -0
- package/docs/adr/0194-joinability-one-door-decision.md +42 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +158 -5
- package/docs/api-reference.md +6 -4
- package/docs/copy-inventory.md +373 -356
- package/docs/copy-registry.json +646 -452
- package/migrations/core_227_task_created_by.sql +83 -0
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +4 -0
- package/modules/government/catalog.js +1 -1
- package/modules/hall-ui/public/builders.js +5 -3
- package/modules/hall-ui/public/goals-lib.js +5 -5
- package/modules/hall-ui/public/goals-page.js +54 -23
- package/modules/hall-ui/public/goals-render.js +15 -0
- package/modules/hall-ui/public/hall-render.js +63 -22
- package/modules/hall-ui/public/my-projects.js +6 -1
- package/modules/hall-ui/public/task-lib.js +29 -0
- package/modules/hall-ui/public/watch.css +8 -0
- package/modules/hall-ui/public/watch.js +56 -3
- package/modules/hall-ui/public/work.js +117 -5
- package/modules/ideas/inbox.js +4 -0
- package/modules/lifecycle/db-tasks.js +61 -5
- package/modules/lifecycle/goal-rollup.js +118 -0
- package/modules/lifecycle/routes/goals.js +26 -0
- package/modules/lifecycle/routes/task-write-routes.js +3 -0
- package/modules/lifecycle/routes/tasks.js +47 -1
- package/modules/onboarding/module.json +1 -1
- package/modules/onboarding/routes/access-requests.js +87 -1
- package/modules/platform-identity/routes/my-projects.js +35 -9
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/public-landing/projects.html +169 -3
- package/scripts/gds/login.js +24 -3
- package/src/bongos/auth-admission.js +50 -5
- package/src/bongos/auth.js +2 -1
- package/src/bongos/routes/auth.js +15 -0
- package/src/branding.js +3 -2
- package/src/module-api.js +22 -1
- package/tests/access_requests_invite.mjs +165 -0
- package/tests/auth_admission_gate.mjs +42 -0
- package/tests/goal_sort_staleness.mjs +286 -0
- package/tests/goal_subtitle.mjs +6 -1
- package/tests/goals_lib.mjs +16 -4
- package/tests/join_relay_outcome.mjs +61 -0
- package/tests/joinability_apply.mjs +72 -0
- package/tests/joinability_invite_only.mjs +192 -0
- package/tests/joinability_open.mjs +89 -0
- package/tests/module_api.mjs +1 -0
- package/tests/projects_hub.mjs +76 -2
- package/tests/projects_hub_pre_uat.mjs +4 -2
- package/tests/public_landing_projects.mjs +10 -3
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
-- core_227_task_created_by.sql — a task remembers who created it (task 1003278,
|
|
2
|
+
-- goal 1000072, criterion legibility-sort-and-staleness).
|
|
3
|
+
--
|
|
4
|
+
-- THE GAP. "You can find your own work" was impossible on the task tier: tasks
|
|
5
|
+
-- carried source/source_ref (which PIPELINE filed it) and shipped_by (who landed
|
|
6
|
+
-- it), but nothing said which PERSON created it. A goal has had created_by since
|
|
7
|
+
-- migration 160; a task had nothing, so the owner could not ask the board "what
|
|
8
|
+
-- did I create" — one of the exact questions goal 1000072 exists to answer.
|
|
9
|
+
--
|
|
10
|
+
-- NULLABLE, NO BACKFILL, ON PURPOSE. History is honestly unattributable: the
|
|
11
|
+
-- ~1,357 existing BONGOS-V1 tasks were created before this column, and guessing
|
|
12
|
+
-- (from shipped_by, from a claim, from the source string) would write confident
|
|
13
|
+
-- lies into an attribution column. NULL = created before attribution existed, or
|
|
14
|
+
-- by a system path with no builder in the request — the same convention as
|
|
15
|
+
-- goals.created_by (migration 160: "NULL = system-attributed"). The UI's filter
|
|
16
|
+
-- gets a 'system / unattributed' bucket, exactly like the goals owner filter.
|
|
17
|
+
--
|
|
18
|
+
-- Additive + forward-safe: every existing reader either SELECTs an explicit
|
|
19
|
+
-- column list or `t.*` (which tolerates a new column); nothing NOT NULL, nothing
|
|
20
|
+
-- renamed (tests/migrations_forward_safe.mjs).
|
|
21
|
+
--
|
|
22
|
+
-- THE VIEWS MUST BE REBUILT WITH IT. A Postgres view created with `SELECT t.*`
|
|
23
|
+
-- pins its column list at CREATE VIEW time — core_190's own header documents six
|
|
24
|
+
-- columns that silently never reached the claim feed this way, and the Work
|
|
25
|
+
-- Board's Creator filter reads created_by off exactly that feed. So this
|
|
26
|
+
-- migration re-runs core_190's DROP+CREATE verbatim (no WHERE/CASE change), the
|
|
27
|
+
-- same way core_190 itself repaired the 044-era views.
|
|
28
|
+
|
|
29
|
+
BEGIN;
|
|
30
|
+
|
|
31
|
+
ALTER TABLE tasks
|
|
32
|
+
ADD COLUMN IF NOT EXISTS created_by bigint REFERENCES builders(id);
|
|
33
|
+
|
|
34
|
+
COMMENT ON COLUMN tasks.created_by IS
|
|
35
|
+
'The builder who created this task (task 1003278). NULL = created before attribution existed, or by a system path with no builder in the request — never backfilled, because a guessed attribution is a confident lie. The goals.created_by convention (migration 160).';
|
|
36
|
+
|
|
37
|
+
CREATE INDEX IF NOT EXISTS idx_tasks_created_by ON tasks (created_by);
|
|
38
|
+
|
|
39
|
+
-- Rebuild the two t.* views so created_by is projected (bodies identical to
|
|
40
|
+
-- core_190 — the wildcard re-expands to every current column).
|
|
41
|
+
|
|
42
|
+
DROP VIEW IF EXISTS claimable_tasks;
|
|
43
|
+
CREATE VIEW claimable_tasks AS
|
|
44
|
+
SELECT t.*,
|
|
45
|
+
pms_calibrated_minutes(t.est_minutes, t.kind) AS est_minutes_calibrated
|
|
46
|
+
FROM tasks t
|
|
47
|
+
WHERE t.status = 'ready'
|
|
48
|
+
AND t.title NOT LIKE '\_\_smoke\_\_%' ESCAPE '\'
|
|
49
|
+
AND NOT EXISTS (
|
|
50
|
+
SELECT 1 FROM task_blockers tb WHERE tb.task_id = t.id
|
|
51
|
+
)
|
|
52
|
+
AND NOT EXISTS (
|
|
53
|
+
SELECT 1 FROM claims c
|
|
54
|
+
JOIN tasks t2 ON t2.id = c.task_id
|
|
55
|
+
WHERE c.released_at IS NULL
|
|
56
|
+
AND t2.touches && t.touches
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
DROP VIEW IF EXISTS tasks_session_fit;
|
|
60
|
+
CREATE VIEW tasks_session_fit AS
|
|
61
|
+
SELECT t.*,
|
|
62
|
+
CASE
|
|
63
|
+
WHEN t.automation_tag = 'overnight-safe' AND COALESCE(t.manual_degree, 5) <= 2
|
|
64
|
+
AND NOT EXISTS (SELECT 1 FROM task_blockers tb WHERE tb.task_id = t.id)
|
|
65
|
+
THEN ARRAY['autonomous','check-in','active']
|
|
66
|
+
WHEN t.automation_tag = 'live-only' OR COALESCE(t.manual_degree, 5) >= 4
|
|
67
|
+
THEN ARRAY['active']
|
|
68
|
+
ELSE ARRAY['check-in','active']
|
|
69
|
+
END AS fits_session_modes,
|
|
70
|
+
pms_calibrated_minutes(t.est_minutes, t.kind) AS est_minutes_calibrated,
|
|
71
|
+
CASE
|
|
72
|
+
WHEN t.credits_reward > 0
|
|
73
|
+
AND pms_calibrated_minutes(t.est_minutes, t.kind) IS NOT NULL
|
|
74
|
+
AND pms_calibrated_minutes(t.est_minutes, t.kind) > 0
|
|
75
|
+
THEN ROUND(t.credits_reward::numeric / pms_calibrated_minutes(t.est_minutes, t.kind), 3)
|
|
76
|
+
ELSE NULL
|
|
77
|
+
END AS credits_per_minute
|
|
78
|
+
FROM tasks t;
|
|
79
|
+
|
|
80
|
+
COMMIT;
|
|
81
|
+
|
|
82
|
+
INSERT INTO schema_migrations (version) VALUES ('core_227_task_created_by')
|
|
83
|
+
ON CONFLICT (version) DO NOTHING;
|
|
@@ -90,6 +90,8 @@ function createClient(opts = {}) {
|
|
|
90
90
|
postAccessRequests: (args) => request("POST", "/access-requests", { hasBody: true }, args),
|
|
91
91
|
// PATCH /access-requests/{id} — rank: archon — PATCH /access-requests/:id
|
|
92
92
|
patchAccessRequestsId: (args) => request("PATCH", "/access-requests/{id}", { hasBody: true }, args),
|
|
93
|
+
// POST /access-requests/invite — rank: archon — POST /access-requests/invite
|
|
94
|
+
postAccessRequestsInvite: (args) => request("POST", "/access-requests/invite", { hasBody: true }, args),
|
|
93
95
|
// GET /access-requests/status — rank: public — GET /access-requests/status
|
|
94
96
|
getAccessRequestsStatus: (args) => request("GET", "/access-requests/status", { hasBody: false }, args),
|
|
95
97
|
},
|
|
@@ -364,6 +366,8 @@ function createClient(opts = {}) {
|
|
|
364
366
|
getGoalsInbox: (args) => request("GET", "/goals/inbox", { hasBody: false }, args),
|
|
365
367
|
// POST /goals/reorder — rank: metic+archon — POST /goals/reorder
|
|
366
368
|
postGoalsReorder: (args) => request("POST", "/goals/reorder", { hasBody: true }, args),
|
|
369
|
+
// GET /goals/rollups — rank: any-builder — GET /goals/rollups
|
|
370
|
+
getGoalsRollups: (args) => request("GET", "/goals/rollups", { hasBody: false }, args),
|
|
367
371
|
// GET /goals/suggestions — rank: any-builder — GET /goals/suggestions
|
|
368
372
|
getGoalsSuggestions: (args) => request("GET", "/goals/suggestions", { hasBody: false }, args),
|
|
369
373
|
},
|
|
@@ -127,7 +127,7 @@ const PERMISSIONS = [
|
|
|
127
127
|
// one of them would make this delegation self-reversible.
|
|
128
128
|
{ key: 'builder.status.set', system: false, floor: 'archon', guards: 'PATCH /builders/:id/status — offboard / (de)activate' },
|
|
129
129
|
{ key: 'auth.session.revoke', system: false, floor: 'archon', guards: 'POST /auth/revoke, /auth/revoke-all' },
|
|
130
|
-
{ key: 'access_request.review', system: false, floor: 'archon', guards: 'GET/PATCH /access-requests/:id — admit a builder' },
|
|
130
|
+
{ key: 'access_request.review', system: false, floor: 'archon', guards: 'GET/PATCH /access-requests/:id, POST /access-requests/invite — admit a builder' },
|
|
131
131
|
|
|
132
132
|
// ── operational — floor metic (the working rank's review/triage/authoring) ────
|
|
133
133
|
{ key: 'idea.triage', system: false, floor: 'metic', guards: 'PATCH /inbox/:id' },
|
|
@@ -79,9 +79,11 @@
|
|
|
79
79
|
let code = null;
|
|
80
80
|
try {
|
|
81
81
|
const j = res.data;
|
|
82
|
-
// Prefer the human-facing message; fall back to the machine code.
|
|
83
|
-
|
|
84
|
-
|
|
82
|
+
// Prefer the human-facing message; fall back to the machine code. The
|
|
83
|
+
// envelope (ADR 0116) nests both under `error`; older handlers answer flat.
|
|
84
|
+
const env = j.error && typeof j.error === 'object' ? j.error : null;
|
|
85
|
+
detail = (env && env.message) || j.message || j.detail || (typeof j.error === 'string' ? j.error : detail);
|
|
86
|
+
code = (env && env.code) || (typeof j.error === 'string' ? j.error : null);
|
|
85
87
|
} catch (_) { /* keep default */ }
|
|
86
88
|
const err = new Error(detail);
|
|
87
89
|
err.status = res.status;
|
|
@@ -84,11 +84,11 @@
|
|
|
84
84
|
return false;
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
-
// ms helpers for the goal ordering (task 1002965)
|
|
88
|
-
// task
|
|
89
|
-
//
|
|
90
|
-
|
|
91
|
-
const
|
|
87
|
+
// ms helpers for the goal ordering (task 1002965). The DEFINITIONS moved to
|
|
88
|
+
// task-lib.js in task 1003278 so the Tasks board and the Goals page read ONE
|
|
89
|
+
// meaning of "touched"; re-exported here so this lib's API is unchanged.
|
|
90
|
+
// goals.html loads task-lib.js before this file (the render-family order).
|
|
91
|
+
const { msOf, taskActivityMs } = window.OTBTaskLib;
|
|
92
92
|
|
|
93
93
|
// task 1003224 — on a LIST, a goal's subtitle is a TOOLTIP, never a second
|
|
94
94
|
// visible line. The board's job there is to let someone find a goal by name;
|
|
@@ -144,37 +144,60 @@
|
|
|
144
144
|
// Array#sort is stable in every browser this hall supports — so goals sharing
|
|
145
145
|
// a timestamp (or both missing one) keep their priority order as the tiebreak.
|
|
146
146
|
|
|
147
|
-
//
|
|
148
|
-
//
|
|
149
|
-
//
|
|
150
|
-
//
|
|
151
|
-
// one
|
|
147
|
+
// Per-goal rollups: readiness counters, the last-activity timestamp, and the
|
|
148
|
+
// craft set. Since task 1003278 the SERVER computes these (GET /goals/rollups),
|
|
149
|
+
// one small row per goal.
|
|
150
|
+
//
|
|
151
|
+
// WHY IT MOVED, in one number. This used to fetch every task in every version
|
|
152
|
+
// the board spans — `GET /tasks?version=…&include=deps` — and reduce them here.
|
|
153
|
+
// On the real corpus that single request measured **5,229,455 bytes for ~1,400
|
|
154
|
+
// tasks** (2026-08-25) to produce about forty counter objects. The criterion
|
|
155
|
+
// this page serves says it plainly: "~1,357 tasks on BONGOS-V1 alone. A view
|
|
156
|
+
// that does not survive that number is not a solution." It did not survive it;
|
|
157
|
+
// it just did not visibly fall over.
|
|
158
|
+
//
|
|
159
|
+
// The buckets, the activity clock and the next-ready pick are unchanged — the
|
|
160
|
+
// SQL mirrors goals-lib.js `readinessOf` exactly, and a test holds the two
|
|
161
|
+
// against each other, because a divergence would show a person different
|
|
162
|
+
// numbers depending on which surface they opened.
|
|
163
|
+
//
|
|
164
|
+
// Failure-tolerant, and deliberately NOT falling back to the old whole-version
|
|
165
|
+
// fetch: a "fallback" that quietly re-downloads 5 MB is the bug wearing a
|
|
166
|
+
// safety belt. An older server yields empty rollups and the cards render plain.
|
|
152
167
|
async function loadRollups() {
|
|
153
168
|
const versions = [...new Set(state.goals.map((g) => g.version_id).filter(Boolean))];
|
|
154
169
|
const lists = await Promise.all(versions.map((v) =>
|
|
155
|
-
getJSON(`${API}/
|
|
156
|
-
.then((r) => r.
|
|
170
|
+
getJSON(`${API}/goals/rollups?version=${encodeURIComponent(v)}`)
|
|
171
|
+
.then((r) => r.rollups || []).catch(() => [])));
|
|
157
172
|
const roll = new Map();
|
|
158
173
|
const activity = new Map();
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
const key = String(
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
174
|
+
const disciplines = new Map(); // task 1003278 — goalId -> Set(craft)
|
|
175
|
+
for (const row of lists.flat()) {
|
|
176
|
+
const key = String(row.goal_id);
|
|
177
|
+
roll.set(key, {
|
|
178
|
+
done: row.done || 0,
|
|
179
|
+
active: row.active || 0,
|
|
180
|
+
ready: row.ready || 0,
|
|
181
|
+
blocked: row.blocked || 0,
|
|
182
|
+
backlog: row.backlog || 0,
|
|
183
|
+
cut: row.cut || 0,
|
|
184
|
+
total: row.total || 0,
|
|
185
|
+
// The row shape the renderers already expect: `nextReady` is a task-like
|
|
186
|
+
// object with an id and a title, and nothing else was ever read off it.
|
|
187
|
+
nextReady: row.next_ready_id ? { id: row.next_ready_id, title: row.next_ready_title } : null,
|
|
188
|
+
});
|
|
189
|
+
const ts = msOf(row.last_activity_at);
|
|
190
|
+
if (ts) activity.set(key, ts);
|
|
191
|
+
// The goal's CRAFT set. "Role" here is the craft axis ADR 0174 reserves the
|
|
192
|
+
// word for (engineer | artist | ideator | ui), never a goal membership —
|
|
193
|
+
// that is `membership_kind` (ADR 0177). A goal has no discipline column and
|
|
194
|
+
// must not get one: its craft set is what its tasks carry, which is why the
|
|
195
|
+
// server aggregates it rather than the goal row storing it.
|
|
196
|
+
disciplines.set(key, new Set(row.disciplines || []));
|
|
175
197
|
}
|
|
176
198
|
state.rollups = roll;
|
|
177
199
|
state.activity = activity;
|
|
200
|
+
state.disciplines = disciplines;
|
|
178
201
|
}
|
|
179
202
|
|
|
180
203
|
/* ---------------- list view ---------------- */
|
|
@@ -893,6 +916,14 @@
|
|
|
893
916
|
// task 1003276 — filled from GET /work-categories after boot
|
|
894
917
|
// (populateCategoryFilter), the populateOwnerFilter pattern.
|
|
895
918
|
{ id: 'f-goal-category', label: 'Category', options: [{ value: '', label: '— any —' }] },
|
|
919
|
+
// task 1003278 — the craft axis (ADR 0174 reserves "role" for the crafts;
|
|
920
|
+
// a goal membership is a membership_kind, ADR 0177). Values are the
|
|
921
|
+
// disciplines a goal's tasks carry, derived in loadRollups. Staleness
|
|
922
|
+
// buckets are the shared STALE_CHOICES from task-lib.js so this page and
|
|
923
|
+
// the Tasks board offer the same lenses.
|
|
924
|
+
{ id: 'f-goal-role', label: 'Craft', options: [ // ADR 0174: "role" means a craft
|
|
925
|
+
{ value: '', label: '— any —' }, 'engineer', 'artist', 'ideator', 'ui'] },
|
|
926
|
+
{ id: 'f-goal-stale', label: 'Age', options: window.OTBTaskLib.STALE_CHOICES },
|
|
896
927
|
{ id: 'f-goal-order',
|
|
897
928
|
label: 'Sort',
|
|
898
929
|
value: GOAL_SORTS[savedGoalSort] ? savedGoalSort : undefined,
|
|
@@ -60,6 +60,9 @@
|
|
|
60
60
|
newest: { label: 'newest first', cmp: (a, b) => goalCreatedMs(b) - goalCreatedMs(a), stamp: (g) => ({ label: 'created', ms: goalCreatedMs(g) }) },
|
|
61
61
|
oldest: { label: 'oldest first', cmp: (a, b) => goalCreatedMs(a) - goalCreatedMs(b), stamp: (g) => ({ label: 'created', ms: goalCreatedMs(g) }) },
|
|
62
62
|
active: { label: 'recently active', cmp: (a, b) => goalActivityMs(b) - goalActivityMs(a), stamp: (g) => ({ label: 'last work', ms: goalActivityMs(g) }) },
|
|
63
|
+
// task 1003278 — the rot view: least-recently-touched first, on the SAME
|
|
64
|
+
// clock as 'recently active' so the two can never disagree.
|
|
65
|
+
stale: { label: 'stalest first', cmp: (a, b) => goalActivityMs(a) - goalActivityMs(b), stamp: (g) => ({ label: 'last work', ms: goalActivityMs(g) }) },
|
|
63
66
|
};
|
|
64
67
|
const currentGoalSort = () => GOAL_SORTS[$('#f-goal-order')?.value] || GOAL_SORTS.priority;
|
|
65
68
|
function sortGoals(rows) {
|
|
@@ -93,6 +96,16 @@
|
|
|
93
96
|
|| (owner === 'none' ? g.created_by == null : String(g.created_by) === owner);
|
|
94
97
|
const matchesCategory = (g) => !cat
|
|
95
98
|
|| (cat === 'none' ? !g.category : (g.category?.slug === cat));
|
|
99
|
+
// task 1003278 — role + staleness. A goal's role is the discipline set its
|
|
100
|
+
// tasks carry (loadRollups derives it from the per-version task fetch); its
|
|
101
|
+
// clock is the same activity timestamp the 'recently active' sort reads, so
|
|
102
|
+
// sorting and filtering can never disagree about what "touched" means.
|
|
103
|
+
const role = $('#f-goal-role')?.value || '';
|
|
104
|
+
const staleDays = $('#f-goal-stale')?.value || '';
|
|
105
|
+
const { isStale } = window.OTBTaskLib;
|
|
106
|
+
const matchesRole = (g) => !role
|
|
107
|
+
|| !!(state.disciplines && state.disciplines.get(String(g.id))?.has(role));
|
|
108
|
+
const matchesStale = (g) => !staleDays || isStale(goalActivityMs(g), staleDays);
|
|
96
109
|
const myId = selfId();
|
|
97
110
|
const iAmMember = (g) => {
|
|
98
111
|
const d = state.details.get(String(g.id));
|
|
@@ -104,6 +117,8 @@
|
|
|
104
117
|
matchesSearch(g) &&
|
|
105
118
|
matchesOwner(g) &&
|
|
106
119
|
matchesCategory(g) &&
|
|
120
|
+
matchesRole(g) &&
|
|
121
|
+
matchesStale(g) &&
|
|
107
122
|
(!mineOnly || iAmMember(g)));
|
|
108
123
|
}
|
|
109
124
|
function goalStampHtml(g) {
|
|
@@ -238,23 +238,43 @@
|
|
|
238
238
|
else standingRail = window.OTBKit.makeSectionRail({ mount: rail, items, mode: 'scrollspy' });
|
|
239
239
|
}
|
|
240
240
|
}
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
const
|
|
247
|
-
|
|
241
|
+
// The project's door, as the platform pushed it into this instance's pack
|
|
242
|
+
// (window.__BRANDING__.project.joinability, task 1003044): what a stranger is
|
|
243
|
+
// told to do next depends on it. An older pack reports none → 'apply', today's
|
|
244
|
+
// behaviour. Cosmetic — the server refuses on its own terms either way.
|
|
245
|
+
function joinabilityMode() {
|
|
246
|
+
const p = window.__BRANDING__ && window.__BRANDING__.project;
|
|
247
|
+
const v = p && p.joinability;
|
|
248
|
+
return v === 'open' || v === 'invite_only' ? v : 'apply';
|
|
249
|
+
}
|
|
248
250
|
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
251
|
+
// One landing per door: open → sign in and you're in; invite_only → nothing to
|
|
252
|
+
// apply for, sign in once invited; apply → the request form.
|
|
253
|
+
function landingDoorHtml(mode) {
|
|
254
|
+
const hub = `This is the builders' hub for ${brandWorld()} — where builders claim work, earn ${curLabel()}, and track their standing.`;
|
|
255
|
+
const admitter = escapeHtml(rankLabel('archon'));
|
|
256
|
+
const signIn = `<p class="landing__cta"><a class="landing__btn" href="${API}/auth/web/start">Sign in with GitHub</a></p>`;
|
|
257
|
+
if (mode === 'open') {
|
|
258
|
+
return `
|
|
259
|
+
<p class="landing__lede">${hub} Anyone with a GitHub account can join: sign in and you're in, starting at the newcomer rank.</p>
|
|
260
|
+
<div class="landing__request" id="landing-request">
|
|
261
|
+
<h3 class="landing__request-h">New here — sign in to join</h3>
|
|
262
|
+
<p class="landing__request-lede">No request and no invite needed. Sign in with GitHub, and a step-by-step guide takes you the rest of the way.</p>
|
|
263
|
+
${signIn}
|
|
264
|
+
</div>`;
|
|
265
|
+
}
|
|
266
|
+
if (mode === 'invite_only') {
|
|
267
|
+
return `
|
|
268
|
+
<p class="landing__lede">${hub} This project is invite-only: the ${admitter} invites each builder, and there is nothing to apply for here.</p>
|
|
269
|
+
<div class="landing__request" id="landing-request">
|
|
270
|
+
<h3 class="landing__request-h">Invited? Sign in</h3>
|
|
271
|
+
<p class="landing__request-lede">If the ${admitter} has invited you, sign in with GitHub and you're in. If not, ask them for an invite — this project doesn't take requests.</p>
|
|
272
|
+
${signIn}
|
|
273
|
+
</div>`;
|
|
274
|
+
}
|
|
275
|
+
return `
|
|
255
276
|
<p class="landing__lede">
|
|
256
|
-
|
|
257
|
-
work, earn ${curLabel()}, and track their standing. Access is
|
|
277
|
+
${hub} Access is
|
|
258
278
|
invite-based: an Archon approves each new builder. To begin, request
|
|
259
279
|
access below.
|
|
260
280
|
</p>
|
|
@@ -290,7 +310,24 @@
|
|
|
290
310
|
<button type="submit" class="landing__btn landing__btn--request" id="ar-submit">Request access</button>
|
|
291
311
|
<p class="landing__request-status" id="ar-status" aria-live="polite"></p>
|
|
292
312
|
</form>
|
|
293
|
-
</div
|
|
313
|
+
</div>`;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function renderLanding() {
|
|
317
|
+
// task 1723: collapse the shell nav to the signed-out minimum (Home + a
|
|
318
|
+
// Sign-in button). Cosmetic — the server gates every page regardless.
|
|
319
|
+
window.OTBShell?.applyAccess({ authed: false });
|
|
320
|
+
|
|
321
|
+
const welcome = $('#welcome-line');
|
|
322
|
+
if (welcome) welcome.textContent = '';
|
|
323
|
+
|
|
324
|
+
const landing = $('#landing-root');
|
|
325
|
+
if (landing) {
|
|
326
|
+
landing.hidden = false;
|
|
327
|
+
landing.innerHTML = `
|
|
328
|
+
<div class="scroll landing">
|
|
329
|
+
<h2 class="scroll__h">Welcome</h2>
|
|
330
|
+
${landingDoorHtml(joinabilityMode())}
|
|
294
331
|
|
|
295
332
|
<p class="landing__signin-note">
|
|
296
333
|
Already a member? <a href="${API}/auth/web/start">Sign in with GitHub</a>.
|
|
@@ -346,15 +383,19 @@
|
|
|
346
383
|
</p>`;
|
|
347
384
|
}
|
|
348
385
|
} catch (err) {
|
|
349
|
-
//
|
|
350
|
-
//
|
|
351
|
-
// collapse the form into
|
|
352
|
-
|
|
386
|
+
// The door's own answers — already a builder, already approved, or a
|
|
387
|
+
// project that takes no requests (invite-only) / needs none (open) —
|
|
388
|
+
// collapse the form into what to do instead: sign in. Never a red error.
|
|
389
|
+
const code = err && err.code;
|
|
390
|
+
if (code === 'already_member' || code === 'already_approved' || code === 'invite_only' || code === 'open_enrollment') {
|
|
353
391
|
const req = $('#landing-request');
|
|
354
392
|
if (req) {
|
|
393
|
+
const heading = code === 'invite_only' ? 'This project is invite-only'
|
|
394
|
+
: code === 'open_enrollment' ? 'No request needed'
|
|
395
|
+
: 'You already have access ✓';
|
|
355
396
|
req.innerHTML = `
|
|
356
|
-
<h3 class="landing__request-h"
|
|
357
|
-
<p class="landing__request-lede">${escapeHtml(err.message || '
|
|
397
|
+
<h3 class="landing__request-h">${heading}</h3>
|
|
398
|
+
<p class="landing__request-lede">${escapeHtml(err.message || 'Sign in with GitHub to continue.')}</p>
|
|
358
399
|
<p class="landing__cta">
|
|
359
400
|
<a class="landing__btn" href="${API}/auth/web/start">Sign in with GitHub</a>
|
|
360
401
|
</p>`;
|
|
@@ -130,7 +130,12 @@
|
|
|
130
130
|
.then(function (r) { return r.json().then(function (b) { return { ok: r.ok, body: b }; }); })
|
|
131
131
|
.then(function (res) {
|
|
132
132
|
joinBtn.disabled = false;
|
|
133
|
-
if (res.ok && res.body && res.body.message) {
|
|
133
|
+
if (res.ok && res.body && res.body.message) {
|
|
134
|
+
joinNote.textContent = res.body.message;
|
|
135
|
+
/* a door refusal rides a 200 (task 1003044): keep the address, and don't repaint as if a request went out */
|
|
136
|
+
var refused = res.body.status === 'invite_only' || res.body.status === 'open_enrollment' || res.body.status === 'members_only';
|
|
137
|
+
if (!refused) { joinInput.value = ''; load(); }
|
|
138
|
+
}
|
|
134
139
|
else if (res.body && res.body.error && res.body.error.code === 'unknown_project') { joinNote.textContent = 'That project isn’t on the platform.'; }
|
|
135
140
|
else { joinNote.textContent = 'Could not request access right now.'; }
|
|
136
141
|
})
|
|
@@ -108,6 +108,34 @@
|
|
|
108
108
|
// case leaked a machine code into the Goals page UI. Adding a server-side reason
|
|
109
109
|
// without adding it here degrades quietly rather than loudly, so keep this in
|
|
110
110
|
// step with the claim route's refusal codes.
|
|
111
|
+
// ---- staleness (task 1003278, criterion legibility-sort-and-staleness) ----
|
|
112
|
+
// ONE definition of "touched", shared by both tiers: updated_at moves on every
|
|
113
|
+
// task write (claim, status flip, edit); shipped_at is folded in because a ship
|
|
114
|
+
// path can stamp it without touching updated_at (the task-1002965 owner call,
|
|
115
|
+
// previously private to goals-lib.js — moved HERE so the Tasks board and the
|
|
116
|
+
// Goals page cannot disagree about what "untouched" means).
|
|
117
|
+
const msOf = (iso) => { const t = iso ? Date.parse(iso) : NaN; return Number.isNaN(t) ? 0 : t; };
|
|
118
|
+
const taskActivityMs = (t) => Math.max(msOf(t.updated_at), msOf(t.shipped_at));
|
|
119
|
+
|
|
120
|
+
// The staleness buckets both boards offer. A CLIENT-side vocabulary, not the
|
|
121
|
+
// project's configured stale timer (task 1003279 wires that into the sweep);
|
|
122
|
+
// these are lenses for a person browsing, and the sweep is the enforcement.
|
|
123
|
+
const STALE_CHOICES = [
|
|
124
|
+
{ value: '', label: '— any age —' },
|
|
125
|
+
{ value: '14', label: 'untouched 14+ days' },
|
|
126
|
+
{ value: '30', label: 'untouched 30+ days' },
|
|
127
|
+
{ value: '60', label: 'untouched 60+ days' },
|
|
128
|
+
];
|
|
129
|
+
// Does this activity timestamp fall past the chosen bucket? '' = no filter.
|
|
130
|
+
// 0 (no readable timestamp at all) is treated as STALE, not fresh — a row
|
|
131
|
+
// nobody can date is exactly what a rot-hunt must not hide.
|
|
132
|
+
function isStale(activityMs, days, nowMs) {
|
|
133
|
+
const d = Number(days);
|
|
134
|
+
if (!d) return true;
|
|
135
|
+
const now = Number.isFinite(nowMs) ? nowMs : Date.now();
|
|
136
|
+
return now - activityMs >= d * 24 * 60 * 60 * 1000;
|
|
137
|
+
}
|
|
138
|
+
|
|
111
139
|
function friendlyBatchReason(item) {
|
|
112
140
|
switch (item && item.reason) {
|
|
113
141
|
case 'TASK_NOT_FOUND': return 'no longer exists';
|
|
@@ -128,6 +156,7 @@
|
|
|
128
156
|
}
|
|
129
157
|
|
|
130
158
|
const api = {
|
|
159
|
+
msOf, taskActivityMs, STALE_CHOICES, isStale,
|
|
131
160
|
priorityRank, touchesConflict, rankTagHtml, deHashRefs,
|
|
132
161
|
creditsPerMinute, buildPromptForTask, friendlyBatchReason,
|
|
133
162
|
};
|
|
@@ -182,3 +182,11 @@ table.watch tr:hover td { background: var(--surface-2); }
|
|
|
182
182
|
font-size: 13px; font-weight: 500;
|
|
183
183
|
color: var(--accent-text);
|
|
184
184
|
}
|
|
185
|
+
|
|
186
|
+
/* Invite by GitHub username (task 1003044) — the Access requests section's own
|
|
187
|
+
form: the way in on an invite-only project, a shortcut on an apply one. */
|
|
188
|
+
.watch-invite { margin: 0 0 14px; padding: 12px 14px; border: 1px solid var(--line, #e5e0d6); border-radius: 10px; }
|
|
189
|
+
.watch-invite__label { display: block; font-weight: 600; margin-bottom: 6px; }
|
|
190
|
+
.watch-invite__row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
|
|
191
|
+
.watch-invite__row input[type="text"] { flex: 1 1 200px; min-width: 0; }
|
|
192
|
+
.watch-invite__why { margin: 8px 0 0; font-size: 13px; opacity: .8; }
|
|
@@ -88,7 +88,11 @@
|
|
|
88
88
|
const res = await api.request('POST', `${API}${path}`, { body: body || {} });
|
|
89
89
|
if (res.status === 401) { window.location.assign(`${API}/auth/web/start`); throw { kind: '401' }; }
|
|
90
90
|
const data = res.data || {};
|
|
91
|
-
if (!res.ok)
|
|
91
|
+
if (!res.ok) {
|
|
92
|
+
// the envelope (ADR 0116) nests the sentence under `error`; older handlers answer flat
|
|
93
|
+
const env = data.error && typeof data.error === 'object' ? data.error : null;
|
|
94
|
+
throw new Error((env && env.message) || data.message || (typeof data.error === 'string' ? data.error : '') || `${path} → ${res.status}`);
|
|
95
|
+
}
|
|
92
96
|
return data;
|
|
93
97
|
}
|
|
94
98
|
|
|
@@ -183,14 +187,62 @@
|
|
|
183
187
|
// 1. GATE — access requests (pending)
|
|
184
188
|
// =========================================================================
|
|
185
189
|
|
|
190
|
+
// Invite by GitHub username (task 1003044): admits someone BEFORE any request
|
|
191
|
+
// exists — the only way in on an invite-only project, where nobody can apply,
|
|
192
|
+
// and a shortcut on an apply one. POST /access-requests/invite writes the same
|
|
193
|
+
// 'invited' row the sign-in gate reads. The door the project runs comes from
|
|
194
|
+
// the pack the server injected (window.__BRANDING__.project.joinability).
|
|
195
|
+
function inviteFormHtml() {
|
|
196
|
+
const p = window.__BRANDING__ && window.__BRANDING__.project;
|
|
197
|
+
const mode = (p && p.joinability) || 'apply';
|
|
198
|
+
const why = mode === 'invite_only'
|
|
199
|
+
? 'This project is invite-only — nobody can apply, so this is how people get in.'
|
|
200
|
+
: mode === 'open'
|
|
201
|
+
? 'This project is open — anyone who signs in is admitted, so an invite is rarely needed.'
|
|
202
|
+
: 'Admit someone before they ask — once invited, their sign-in gets in.';
|
|
203
|
+
return (
|
|
204
|
+
'<form class="watch-invite" id="gate-invite" novalidate>' +
|
|
205
|
+
'<label class="watch-invite__label" for="gate-invite-login">Invite by GitHub username</label>' +
|
|
206
|
+
'<div class="watch-invite__row">' +
|
|
207
|
+
'<input type="text" id="gate-invite-login" name="github_login" autocomplete="off" placeholder="e.g. octocat" maxlength="40">' +
|
|
208
|
+
'<button type="submit" class="watch-btn">Invite</button>' +
|
|
209
|
+
'</div>' +
|
|
210
|
+
`<p class="watch-invite__why">${escapeHtml(why)}</p>` +
|
|
211
|
+
'</form>'
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function wireInviteForm(target) {
|
|
216
|
+
const form = target.querySelector('#gate-invite');
|
|
217
|
+
if (!form) return;
|
|
218
|
+
form.addEventListener('submit', async (e) => {
|
|
219
|
+
e.preventDefault();
|
|
220
|
+
const input = form.querySelector('#gate-invite-login');
|
|
221
|
+
const login = ((input && input.value) || '').trim().replace(/^@/, '');
|
|
222
|
+
if (!login) { toast('A GitHub username is needed.'); return; }
|
|
223
|
+
const btn = form.querySelector('button[type="submit"]');
|
|
224
|
+
if (btn) btn.disabled = true;
|
|
225
|
+
try {
|
|
226
|
+
const r = await postJson('/access-requests/invite', { github_login: login });
|
|
227
|
+
toast(r.already_invited ? `@${login} is already invited.` : `@${login} invited — their sign-in gets in.`);
|
|
228
|
+
await loadGate();
|
|
229
|
+
} catch (err) {
|
|
230
|
+
if (err && err.kind === '401') return;
|
|
231
|
+
if (btn) btn.disabled = false;
|
|
232
|
+
toast(`Could not invite: ${err.message || err}`);
|
|
233
|
+
}
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
|
|
186
237
|
function renderGate(data, target) {
|
|
187
238
|
const requests = Array.isArray(data.requests) ? data.requests : [];
|
|
188
239
|
setAttention('att-access', requests.length); // tile count = table content
|
|
189
240
|
if (!requests.length) {
|
|
190
|
-
target.innerHTML = emptyStateHtml('No pending access requests.');
|
|
241
|
+
target.innerHTML = inviteFormHtml() + emptyStateHtml('No pending access requests.');
|
|
242
|
+
wireInviteForm(target);
|
|
191
243
|
return;
|
|
192
244
|
}
|
|
193
|
-
target.innerHTML = requests
|
|
245
|
+
target.innerHTML = inviteFormHtml() + requests
|
|
194
246
|
.map((r) => {
|
|
195
247
|
const who = r.display_name
|
|
196
248
|
? `${escapeHtml(r.display_name)} <span class="watch-mono">@${escapeHtml(r.github_login)}</span>`
|
|
@@ -221,6 +273,7 @@
|
|
|
221
273
|
toast(status === 'invited' ? 'Builder invited.' : 'Request dismissed.');
|
|
222
274
|
await loadGate();
|
|
223
275
|
}, 'Could not resolve');
|
|
276
|
+
wireInviteForm(target);
|
|
224
277
|
}
|
|
225
278
|
|
|
226
279
|
function loadGate() {
|