@bongos/core 1.21.19 → 1.21.20
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 +35 -25
- package/docs/module-api-changelog.md +2 -0
- package/docs/page-readings.json +195 -196
- package/modules/autonomy/governor-docket.js +118 -4
- package/modules/economy/cost.js +36 -0
- package/modules/economy/governor-docket.js +81 -0
- package/modules/economy/routes/cost.js +2 -0
- package/modules/government/docket-floors.js +222 -0
- package/modules/government/docket-vocab.js +20 -0
- package/modules/government/docket.js +186 -5
- package/modules/hall-ui/public/street-inside.js +99 -43
- package/modules/ideas/governor-docket.js +42 -1
- package/modules/lifecycle/governor-docket.js +197 -2
- package/modules/security/routes/reports.js +16 -0
- package/modules/security/security.js +5 -2
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +6 -0
- package/src/module-api.js +1 -1
- package/tests/governor_docket.mjs +186 -2
- package/tests/hall_street_inside.mjs +97 -3
|
@@ -29,6 +29,17 @@
|
|
|
29
29
|
//
|
|
30
30
|
// { reading: 'flow', gate: …, read: async (ctx) => { figure, line, value, hot } }
|
|
31
31
|
//
|
|
32
|
+
// { floor: 'release.live_month', gate: …, // one of docket-vocab FLOORS
|
|
33
|
+
// read: async (ctx) => { count, line, lines: [[text, meta]], hot } }
|
|
34
|
+
//
|
|
35
|
+
// { history: 'releases', building: 'release', gate: …, // the record drawers
|
|
36
|
+
// read: async (ctx) => [{ id, title, at, outcome }, …] }
|
|
37
|
+
//
|
|
38
|
+
// A FLOOR (task 1004527) is a state rather than a queue — who sits, what the
|
|
39
|
+
// month cost, what went live — read by the module that owns it, under the same
|
|
40
|
+
// atom as its own route. HISTORY is what closed: rulings, amendments, releases,
|
|
41
|
+
// sign-offs, payouts; the building interior files it into drawers by month.
|
|
42
|
+
//
|
|
32
43
|
// `ctx` is { builderId, rank, has(permission) } — `has` answers from the
|
|
33
44
|
// viewer's permission set resolved ONCE for this request, server-side and
|
|
34
45
|
// uncached (ADR 0016). A contribution with no recognisable gate is DROPPED, never
|
|
@@ -61,9 +72,15 @@ const db = require('./db');
|
|
|
61
72
|
const { resolveBoardMembers } = require('./board-membership');
|
|
62
73
|
const resolver = require('./resolver');
|
|
63
74
|
const decisionRules = require('./decision-rules');
|
|
75
|
+
const docketFloors = require('./docket-floors');
|
|
76
|
+
const governmentConfig = require('./config');
|
|
64
77
|
|
|
65
78
|
const log = api.logger('government');
|
|
66
79
|
|
|
80
|
+
// This module's own floors are answered under the docket route's own atom (the
|
|
81
|
+
// same one GET /government/constitution and the genesis stages require).
|
|
82
|
+
const GOVERNMENT_GATE = Object.freeze({ permission: 'board.vote.cast' });
|
|
83
|
+
|
|
67
84
|
const CONTRIBUTION_POINT = 'governor.docket';
|
|
68
85
|
|
|
69
86
|
// Every source the reference design names, with the building it stands in. The
|
|
@@ -94,6 +111,13 @@ const COMING_SOURCES = Object.freeze([
|
|
|
94
111
|
reason: 'The project has no budget or spending votes recorded yet.' }),
|
|
95
112
|
]);
|
|
96
113
|
|
|
114
|
+
// Floors the design names whose engine does not exist yet. Drawn, and say why.
|
|
115
|
+
const COMING_FLOORS = Object.freeze([
|
|
116
|
+
Object.freeze({ floor: 'charter.purpose', reason: 'The charter has no written purpose yet. A section for it is coming.' }),
|
|
117
|
+
]);
|
|
118
|
+
const MAX_FLOOR_LINES = 6;
|
|
119
|
+
const MAX_HISTORY_PER_SOURCE = 60;
|
|
120
|
+
|
|
97
121
|
|
|
98
122
|
// A source read that has not answered by now is reported unavailable rather than
|
|
99
123
|
// holding the whole city hostage (one source calls GitHub, one the npm registry).
|
|
@@ -151,6 +175,31 @@ function normalizeMatter(raw, { source, building }) {
|
|
|
151
175
|
};
|
|
152
176
|
}
|
|
153
177
|
|
|
178
|
+
// One floor as a contributor handed it → { count, line, lines, hot }. Pure.
|
|
179
|
+
// Anything that is not a plain object is not a floor (→ null, read as
|
|
180
|
+
// unavailable); a count is kept only as the short figure it was given.
|
|
181
|
+
function normalizeFloor(raw) {
|
|
182
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
|
|
183
|
+
const str = (v, n) => (v == null ? '' : String(v).slice(0, n));
|
|
184
|
+
const count = raw.count == null || raw.count === '' ? '' : str(raw.count, 12);
|
|
185
|
+
const lines = (Array.isArray(raw.lines) ? raw.lines : [])
|
|
186
|
+
.filter((l) => Array.isArray(l) && l[0] != null && String(l[0]).trim())
|
|
187
|
+
.slice(0, MAX_FLOOR_LINES)
|
|
188
|
+
.map((l) => [str(l[0], 200), str(l[1], 40)]);
|
|
189
|
+
return { count, line: str(raw.line, 90) || null, lines, hot: raw.hot === true };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// One closed thing for the record → the docket's one shape. Pure. A row with no
|
|
193
|
+
// title or no readable date is dropped: an undated record is not a record.
|
|
194
|
+
function normalizeHistory(raw, { source, building }) {
|
|
195
|
+
if (!raw || typeof raw !== 'object' || raw.id == null || !raw.title) return null;
|
|
196
|
+
const at = iso(raw.at);
|
|
197
|
+
if (!at) return null;
|
|
198
|
+
const b = vocab.BUILDING_KEYS.includes(raw.building) ? raw.building : building;
|
|
199
|
+
if (!vocab.BUILDING_KEYS.includes(b)) return null;
|
|
200
|
+
return { id: `${source}:${raw.id}`, source, building: b, title: String(raw.title).slice(0, 200), at, outcome: raw.outcome ? String(raw.outcome).slice(0, 40) : null };
|
|
201
|
+
}
|
|
202
|
+
|
|
154
203
|
// Does this viewer pass this gate? Fail-closed: anything unrecognised is false.
|
|
155
204
|
function gateAllows(gate, ctx) {
|
|
156
205
|
if (!gate || typeof gate !== 'object') return false;
|
|
@@ -346,12 +395,16 @@ function withTimeout(promise, ms) {
|
|
|
346
395
|
function registeredContributions() {
|
|
347
396
|
const sources = new Map();
|
|
348
397
|
const readings = new Map();
|
|
398
|
+
const floors = new Map();
|
|
399
|
+
const histories = new Map();
|
|
349
400
|
for (const c of api.contributions(CONTRIBUTION_POINT)) {
|
|
350
401
|
if (!c || typeof c !== 'object' || typeof c.read !== 'function' || !hasValidGate(c.gate)) continue;
|
|
351
402
|
if (typeof c.source === 'string' && c.source) sources.set(c.source, c);
|
|
352
403
|
else if (typeof c.reading === 'string' && c.reading) readings.set(c.reading, c);
|
|
404
|
+
else if (typeof c.floor === 'string' && c.floor) floors.set(c.floor, c);
|
|
405
|
+
else if (typeof c.history === 'string' && c.history && vocab.BUILDING_KEYS.includes(c.building)) histories.set(c.history, c);
|
|
353
406
|
}
|
|
354
|
-
return { sources, readings };
|
|
407
|
+
return { sources, readings, floors, histories };
|
|
355
408
|
}
|
|
356
409
|
|
|
357
410
|
// ── the collector ────────────────────────────────────────────────────────────
|
|
@@ -362,13 +415,22 @@ function registeredContributions() {
|
|
|
362
415
|
// `decisions` is the resolved decision-rules table (board.decisionRulesView), or
|
|
363
416
|
// null when it could not be read — each building's `decision` is then null
|
|
364
417
|
// rather than a guess.
|
|
365
|
-
|
|
418
|
+
// `government` is what docketFor() read once for this module's own floors and
|
|
419
|
+
// record (loadGovernment below); null leaves those floors `unavailable`.
|
|
420
|
+
async function buildDocket({ viewer, permissions, contributions = registeredContributions(), now = Date.now(), timeoutMs = SOURCE_TIMEOUT_MS, decisions = null, government = null } = {}) {
|
|
366
421
|
const held = new Set(permissions || []);
|
|
367
422
|
const ctx = Object.freeze({
|
|
368
423
|
builderId: String(viewer.id),
|
|
369
424
|
rank: viewer.rank || null,
|
|
370
425
|
has: (p) => held.has(p),
|
|
371
426
|
});
|
|
427
|
+
// A per-request memo every read may use, so two floors that need the same rows
|
|
428
|
+
// share one query (task 1004527). Scoped to this call: nothing outlives it.
|
|
429
|
+
const memo = new Map();
|
|
430
|
+
const once = (key, fn) => {
|
|
431
|
+
if (!memo.has(key)) memo.set(key, Promise.resolve().then(fn));
|
|
432
|
+
return memo.get(key);
|
|
433
|
+
};
|
|
372
434
|
const sources = new Map(contributions.sources);
|
|
373
435
|
sources.set('board', BOARD_SOURCE); // the board is read here, never contributed
|
|
374
436
|
const comingKeys = new Set(COMING_SOURCES.map((c) => c.source));
|
|
@@ -392,7 +454,7 @@ async function buildDocket({ viewer, permissions, contributions = registeredCont
|
|
|
392
454
|
}
|
|
393
455
|
if (!gateAllows(c.gate, ctx)) { row.status = 'not_yours'; return; }
|
|
394
456
|
try {
|
|
395
|
-
const raw = await withTimeout(Promise.resolve(c.read(ctx, { now })), timeoutMs);
|
|
457
|
+
const raw = await withTimeout(Promise.resolve(c.read(ctx, { now, once })), timeoutMs);
|
|
396
458
|
const list = (Array.isArray(raw) ? raw : []).map((m) => normalizeMatter(m, { source: decl.source, building: c.building || decl.building })).filter(Boolean);
|
|
397
459
|
row.status = 'live';
|
|
398
460
|
row.count = list.length;
|
|
@@ -421,7 +483,7 @@ async function buildDocket({ viewer, permissions, contributions = registeredCont
|
|
|
421
483
|
if (!impl) return { ...base, status: 'off', reason: 'The module that holds this is not enabled here.' };
|
|
422
484
|
if (!gateAllows(impl.gate, ctx)) return { ...base, status: 'not_yours' };
|
|
423
485
|
try {
|
|
424
|
-
const r = await withTimeout(Promise.resolve(impl.read(ctx, { now, matters })), timeoutMs);
|
|
486
|
+
const r = await withTimeout(Promise.resolve(impl.read(ctx, { now, matters, once })), timeoutMs);
|
|
425
487
|
if (!r) return { ...base, status: 'unavailable', reason: 'It could not be read just now.' };
|
|
426
488
|
return {
|
|
427
489
|
...base,
|
|
@@ -437,17 +499,83 @@ async function buildDocket({ viewer, permissions, contributions = registeredCont
|
|
|
437
499
|
}
|
|
438
500
|
}));
|
|
439
501
|
|
|
502
|
+
// Floors (task 1004527): contributed, this module's own (answered from
|
|
503
|
+
// `government`, under the docket's own atom), and Ideas to ratify (from the
|
|
504
|
+
// board's matters, so it can never disagree with the pins).
|
|
505
|
+
const govAllowed = gateAllows(GOVERNMENT_GATE, ctx);
|
|
506
|
+
const comingFloors = new Map(COMING_FLOORS.map((c) => [c.floor, c.reason]));
|
|
507
|
+
const floorImpls = new Map(contributions.floors || []);
|
|
508
|
+
const boardRow = sourceStatus.find((s) => s.source === 'board');
|
|
509
|
+
const floorsP = Promise.all(vocab.FLOORS.map(async (decl) => {
|
|
510
|
+
const base = { key: decl.key, building: decl.building, status: null, count: '', line: null, lines: [], hot: false, reason: null };
|
|
511
|
+
const live = (f) => (f ? { ...base, status: 'live', ...f } : { ...base, status: 'unavailable', reason: 'It could not be read just now.' });
|
|
512
|
+
if (decl.key === 'planning.ideas') {
|
|
513
|
+
if (!boardRow || boardRow.status !== 'live') return { ...base, status: boardRow ? boardRow.status : 'unavailable', reason: boardRow ? boardRow.reason : null };
|
|
514
|
+
return live(normalizeFloor(docketFloors.ideasFloor(matters)));
|
|
515
|
+
}
|
|
516
|
+
const own = docketFloors.BUILDERS[decl.key];
|
|
517
|
+
if (own) {
|
|
518
|
+
if (!govAllowed) return { ...base, status: 'not_yours' };
|
|
519
|
+
let f = null;
|
|
520
|
+
try { f = government ? normalizeFloor(own(government)) : null; } catch (err) { log.warn({ floor: decl.key, err: err && err.message }, 'governor docket floor failed'); }
|
|
521
|
+
return live(f);
|
|
522
|
+
}
|
|
523
|
+
const impl = floorImpls.get(decl.key);
|
|
524
|
+
if (!impl) {
|
|
525
|
+
return comingFloors.has(decl.key)
|
|
526
|
+
? { ...base, status: 'coming', reason: comingFloors.get(decl.key) }
|
|
527
|
+
: { ...base, status: 'off', reason: 'The module that holds this is not enabled here.' };
|
|
528
|
+
}
|
|
529
|
+
if (!gateAllows(impl.gate, ctx)) return { ...base, status: 'not_yours' };
|
|
530
|
+
try {
|
|
531
|
+
return live(normalizeFloor(await withTimeout(Promise.resolve(impl.read(ctx, { now, once })), timeoutMs)));
|
|
532
|
+
} catch (err) {
|
|
533
|
+
log.warn({ floor: decl.key, err: err && err.message }, 'governor docket floor failed');
|
|
534
|
+
return { ...base, status: 'unavailable', reason: err && err.message === 'timeout' ? 'It did not answer in time.' : 'It could not be read just now.' };
|
|
535
|
+
}
|
|
536
|
+
}));
|
|
537
|
+
|
|
538
|
+
// The record: what closed, from every module that keeps it, newest first.
|
|
539
|
+
const history = [];
|
|
540
|
+
const historySources = [];
|
|
541
|
+
if (govAllowed && government) {
|
|
542
|
+
history.push(...docketFloors.governmentHistory(government).map((h) => normalizeHistory(h, { source: 'government', building: h.building })).filter(Boolean));
|
|
543
|
+
historySources.push({ source: 'government', status: 'live' });
|
|
544
|
+
}
|
|
545
|
+
const historyP = Promise.all([...(contributions.histories || new Map())].map(async ([key, c]) => {
|
|
546
|
+
if (!gateAllows(c.gate, ctx)) { historySources.push({ source: key, building: c.building, status: 'not_yours' }); return; }
|
|
547
|
+
try {
|
|
548
|
+
const raw = await withTimeout(Promise.resolve(c.read(ctx, { now, once })), timeoutMs);
|
|
549
|
+
const list = (Array.isArray(raw) ? raw : []).slice(0, MAX_HISTORY_PER_SOURCE).map((h) => normalizeHistory(h, { source: key, building: c.building })).filter(Boolean);
|
|
550
|
+
history.push(...list);
|
|
551
|
+
historySources.push({ source: key, building: c.building, status: 'live' });
|
|
552
|
+
} catch (err) {
|
|
553
|
+
log.warn({ history: key, err: err && err.message }, 'governor docket history failed');
|
|
554
|
+
historySources.push({ source: key, building: c.building, status: 'unavailable' });
|
|
555
|
+
}
|
|
556
|
+
}));
|
|
557
|
+
const [floors] = await Promise.all([floorsP, historyP]);
|
|
558
|
+
history.sort((a, b) => (a.at < b.at ? 1 : a.at > b.at ? -1 : a.id < b.id ? -1 : 1));
|
|
559
|
+
historySources.sort((a, b) => (a.source < b.source ? -1 : 1));
|
|
560
|
+
|
|
440
561
|
const counts = countStates(matters);
|
|
441
562
|
const decisionBy = new Map((Array.isArray(decisions) ? decisions : []).filter(Boolean).map((d) => [d.area, d]));
|
|
563
|
+
const holdersBy = govAllowed && government && government.holders ? government.holders : {};
|
|
442
564
|
const buildings = vocab.BUILDINGS.map((b) => {
|
|
443
565
|
const here = matters.filter((m) => m.building === b.key);
|
|
444
566
|
const d = decisionBy.get(b.key) || null;
|
|
567
|
+
const held = Array.isArray(holdersBy[b.key]) ? holdersBy[b.key] : null;
|
|
445
568
|
return {
|
|
446
569
|
key: b.key, name: b.name, group: b.group, counts: countStates(here), matter_ids: here.map((m) => m.id),
|
|
447
570
|
decision: d ? {
|
|
448
571
|
who: d.who, rule: d.rule, rule_words: d.rule_words, decided_by: d.decided_by,
|
|
449
572
|
as_today: d.as_today, sitting: d.sitting, set_by_item_id: d.set_by_item_id,
|
|
450
573
|
} : null,
|
|
574
|
+
// Who holds the seat now, as the row seats them (task 1004527): the board's
|
|
575
|
+
// members, a sitting's predicate, or the holders of the fixed gate's atom.
|
|
576
|
+
// null when it could not be resolved — never an invented seat.
|
|
577
|
+
holders: held ? held.slice(0, 12).map((h) => person({ id: h.builder_id ?? h.id, login: h.github_login ?? h.login, name: h.display_name ?? h.name })).filter(Boolean) : null,
|
|
578
|
+
holder_count: held ? held.length : null,
|
|
451
579
|
};
|
|
452
580
|
});
|
|
453
581
|
return {
|
|
@@ -459,6 +587,9 @@ async function buildDocket({ viewer, permissions, contributions = registeredCont
|
|
|
459
587
|
buildings,
|
|
460
588
|
sources: sourceStatus,
|
|
461
589
|
readings,
|
|
590
|
+
floors,
|
|
591
|
+
history,
|
|
592
|
+
history_sources: historySources,
|
|
462
593
|
vocabulary: {
|
|
463
594
|
buildings: vocab.BUILDINGS, rules: vocab.RULES, states: vocab.STATES,
|
|
464
595
|
// The table's rule set — the matter glyphs plus vacant (no one holds it).
|
|
@@ -477,14 +608,64 @@ async function docketFor(builder) {
|
|
|
477
608
|
log.warn({ err: err && err.message }, 'governor docket: decision rules unreadable');
|
|
478
609
|
return null;
|
|
479
610
|
});
|
|
480
|
-
|
|
611
|
+
const government = permissions.includes(GOVERNMENT_GATE.permission) ? await loadGovernment({ decisions }) : null;
|
|
612
|
+
return buildDocket({ viewer: builder, permissions, decisions, government });
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
// This module's own floors and record, read ONCE per request (task 1004527),
|
|
616
|
+
// each piece isolated: a read that fails leaves its piece null, which the pure
|
|
617
|
+
// builders in docket-floors.js turn into an `unavailable` floor.
|
|
618
|
+
// holders per building, the people its decision-rules row seats now: the
|
|
619
|
+
// board's members (charter), a sitting's predicate, or the active
|
|
620
|
+
// holders of a rank that grants the fixed gate's atom; [] when vacant.
|
|
621
|
+
async function loadGovernment({ decisions }) {
|
|
622
|
+
const safe = (p, what) => Promise.resolve().then(p).catch((err) => {
|
|
623
|
+
log.warn({ err: err && err.message, what }, 'governor docket: a government read failed');
|
|
624
|
+
return null;
|
|
625
|
+
});
|
|
626
|
+
const { board: live } = governmentConfig.loadGovernmentConfig();
|
|
627
|
+
const [members, genesis, rankRows, assignments, amendments, room] = await Promise.all([
|
|
628
|
+
safe(() => resolveBoardMembers(live), 'members'),
|
|
629
|
+
safe(() => board.genesisStageStatus(), 'genesis'),
|
|
630
|
+
safe(() => db.listRanksWithGrants(), 'ranks'),
|
|
631
|
+
safe(() => db.listAssignments(), 'assignments'),
|
|
632
|
+
safe(() => db.listAmendmentHistory(50), 'amendments'),
|
|
633
|
+
safe(() => board.boardRoomView({ closedLimit: 50 }), 'closed'),
|
|
634
|
+
]);
|
|
635
|
+
const ranks = rankRows && assignments ? rankRows.map((r) => ({
|
|
636
|
+
rank_key: r.rank_key, label: r.label,
|
|
637
|
+
holders: assignments.filter((a) => a.rank_key === r.rank_key).length,
|
|
638
|
+
})) : null;
|
|
639
|
+
let holders = null;
|
|
640
|
+
if (Array.isArray(decisions)) {
|
|
641
|
+
holders = {};
|
|
642
|
+
await Promise.all(decisions.filter(Boolean).map(async (d) => {
|
|
643
|
+
if (d.decided_by === 'board') { holders[d.area] = members; return; }
|
|
644
|
+
if (d.decided_by === 'nobody') { holders[d.area] = []; return; }
|
|
645
|
+
if (d.decided_by === 'gate') {
|
|
646
|
+
if (!rankRows || !d.gate) { holders[d.area] = null; return; }
|
|
647
|
+
const keys = rankRows.filter((r) => (r.permissions || []).includes(d.gate)).map((r) => r.rank_key);
|
|
648
|
+
holders[d.area] = keys.length ? await safe(() => db.getActiveBuildersWithRank(keys), `holders:${d.area}`) : [];
|
|
649
|
+
return;
|
|
650
|
+
}
|
|
651
|
+
holders[d.area] = await safe(() => resolveBoardMembers({ membership: d.decided_by }), `holders:${d.area}`);
|
|
652
|
+
}));
|
|
653
|
+
}
|
|
654
|
+
return {
|
|
655
|
+
board: live, decisions, members, genesis, ranks, assignments, amendments, holders,
|
|
656
|
+
closed: room && Array.isArray(room.closed) ? room.closed : null,
|
|
657
|
+
};
|
|
481
658
|
}
|
|
482
659
|
|
|
483
660
|
module.exports = {
|
|
484
661
|
CONTRIBUTION_POINT,
|
|
485
662
|
EXPECTED_SOURCES,
|
|
486
663
|
COMING_SOURCES,
|
|
664
|
+
COMING_FLOORS,
|
|
487
665
|
SOURCE_TIMEOUT_MS,
|
|
666
|
+
normalizeFloor,
|
|
667
|
+
normalizeHistory,
|
|
668
|
+
loadGovernment,
|
|
488
669
|
SETTLED_WINDOW_MS,
|
|
489
670
|
normalizeMatter,
|
|
490
671
|
gateAllows,
|
|
@@ -24,20 +24,21 @@
|
|
|
24
24
|
// are about the government rather than the sample project). A floor the
|
|
25
25
|
// governor docket (GET /government/docket) already reads is BOUND to it: a
|
|
26
26
|
// source (its open matters, their count, needs you when one does) or a
|
|
27
|
-
// reading (its figure and line), or
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
//
|
|
27
|
+
// reading (its figure and line), or a FLOOR (task 1004527: docket.floors, a
|
|
28
|
+
// state the owning module reads live — who sits, what the month cost, what
|
|
29
|
+
// went live). A floor whose engine does not exist yet comes back `coming`
|
|
30
|
+
// and stands faint with "not recorded yet" and the docket's reason; nothing
|
|
31
|
+
// here ever writes a number the docket did not send.
|
|
32
|
+
// * Who holds a building and how it decides: the building's `decision` row
|
|
33
|
+
// (task 1004529's table) and the `holders` that row seats now. Without a row
|
|
34
|
+
// the seat is drawn empty and says so, and the rule is the building's own
|
|
35
|
+
// matters' rule, else the design's default, named as the default
|
|
36
|
+
// (holderOf()).
|
|
37
37
|
// * On the table here: the docket's matters at this building. Each row opens
|
|
38
38
|
// the short card (GovStreet.cardFor(); task 1004528 makes it act).
|
|
39
|
-
// * The record:
|
|
40
|
-
//
|
|
39
|
+
// * The record: the building's history (docket.history: rulings, amendments,
|
|
40
|
+
// sign-offs, releases, payouts) and its matters, filed by month, one drawer
|
|
41
|
+
// a month, newest first.
|
|
41
42
|
// * The annex lot stands when the building is full, as the prototype's
|
|
42
43
|
// "growth" lines say it would.
|
|
43
44
|
// * A district's work is the goal's tasks (GET /tasks?goal_id=N): shape from
|
|
@@ -268,7 +269,7 @@
|
|
|
268
269
|
sym: f ? Object.assign(symbolPath(floorSymbol(f.name)), { c: '#fb9569' }) : { d: '', f: '', c: 'none' },
|
|
269
270
|
flipA: ((st.picks || 0) % 2) === 0, flipB: ((st.picks || 0) % 2) === 1,
|
|
270
271
|
openTitle: f ? f.name : '', openLines: f ? f.lines.map((l, i) => ({ key: 'l' + i, text: l[0], meta: l[1] })) : [],
|
|
271
|
-
drawers: record.map((r, i) => ({ key: r.label, index: i, label: r.label, count: r.items.length, aria: 'Open the ' + r.label + ' drawer', selected: i === selDrawer, bg: i === selDrawer ? T.wash : 'transparent', border: i === selDrawer ? T.ink : T.ruleStrong, handle: i === selDrawer ? T.ink : T.ruleStrong, color: T.ink })),
|
|
272
|
+
drawers: record.map((r, i) => ({ key: r.label, index: i, label: r.label, count: r.count == null ? r.items.length : r.count, aria: 'Open the ' + r.label + ' drawer', selected: i === selDrawer, bg: i === selDrawer ? T.wash : 'transparent', border: i === selDrawer ? T.ink : T.ruleStrong, handle: i === selDrawer ? T.ink : T.ruleStrong, color: T.ink })),
|
|
272
273
|
drawerItems: selDrawer >= 0 ? record[selDrawer].items.map((it, i) => ({ key: 'r' + i, text: it[0], meta: it[1] })) : [],
|
|
273
274
|
onFile: b.onFile, shapes: shapes, texts: texts, syms: syms, floors: floors,
|
|
274
275
|
lift: { x: X - 38, y: GY - FH + 6, ty: -(selFloor * FH), h: FH - 16 },
|
|
@@ -314,7 +315,7 @@
|
|
|
314
315
|
// its sample project: what it holds, the rule the design gives it, its
|
|
315
316
|
// capacity, its growth line and its annex.
|
|
316
317
|
const GROUPS = Object.freeze({ charter: 'how we decide', rules: 'how we decide', disputes: 'how we decide', people: 'who and what', planning: 'who and what', automation: 'the work', review: 'checks and going live', release: 'checks and going live', treasury: 'money' });
|
|
317
|
-
const RULE_WORDS = Object.freeze({ decide: 'one person decides', first: 'first yes wins', consent: 'passes unless someone objects', majority: 'more than half must say yes', unanimous: 'everyone has to agree', signoff: 'signed off by someone who did not build it', rule: 'settled by a rule' });
|
|
318
|
+
const RULE_WORDS = Object.freeze({ decide: 'one person decides', first: 'first yes wins', consent: 'passes unless someone objects', majority: 'more than half must say yes', unanimous: 'everyone has to agree', signoff: 'signed off by someone who did not build it', rule: 'settled by a rule', vacant: 'its matters wait until a seat is filled' });
|
|
318
319
|
|
|
319
320
|
// A floor: [name, what it holds, binding]. A binding is { source }, { reading }
|
|
320
321
|
// or { goals: true }; no binding is a floor not recorded yet.
|
|
@@ -322,12 +323,12 @@
|
|
|
322
323
|
charter: {
|
|
323
324
|
holds: 'The form of government. Seven sections say who holds each building and how it decides.', rule: 'unanimous', cap: 9,
|
|
324
325
|
growth: 'Room for more sections before the charter needs a second volume.', annex: 'room for a second volume',
|
|
325
|
-
floors: [['Founding', 'the starting government'], ['§1 Purpose', 'what this project is for'], ['§2 Membership', 'who belongs, and how they join'], ['§3 The council', 'who sits on the council'], ['§4 Rules', 'how a rule passes'], ['§5 Money', 'who may spend, and how much'], ['§6 Review', 'who signs off finished work'], ['§7 Release', 'who puts work live']],
|
|
326
|
+
floors: [['Founding', 'the starting government', { floor: 'charter.founding' }], ['§1 Purpose', 'what this project is for', { floor: 'charter.purpose' }], ['§2 Membership', 'who belongs, and how they join', { floor: 'charter.membership' }], ['§3 The council', 'who sits on the council', { floor: 'charter.council' }], ['§4 Rules', 'how a rule passes', { floor: 'charter.rules' }], ['§5 Money', 'who may spend, and how much', { floor: 'charter.money' }], ['§6 Review', 'who signs off finished work', { floor: 'charter.review' }], ['§7 Release', 'who puts work live', { floor: 'charter.release' }]],
|
|
326
327
|
},
|
|
327
328
|
rules: {
|
|
328
329
|
holds: 'Standing rules. Each one settles a kind of matter so the council never votes on it again.', rule: 'majority', cap: 8,
|
|
329
330
|
growth: 'When the hall fills, the council can open an annex for one area, such as money rules.', annex: 'room for an annex, such as money rules',
|
|
330
|
-
floors: [['Rules in force', 'the rules settling matters now', { reading: 'rules' }], ['New rules', 'a rule put to the council', { source: 'standing_rules' }]],
|
|
331
|
+
floors: [['Rules in force', 'the rules settling matters now', { floor: 'rules.in_force', reading: 'rules' }], ['New rules', 'a rule put to the council', { source: 'standing_rules' }]],
|
|
331
332
|
},
|
|
332
333
|
disputes: {
|
|
333
334
|
holds: 'Grade appeals, removal requests and security reports. Every ruling becomes precedent for the next one.', rule: 'decide', cap: 5,
|
|
@@ -337,32 +338,32 @@
|
|
|
337
338
|
people: {
|
|
338
339
|
holds: 'Who belongs, the rank each person holds, and who sits in which seat.', rule: 'consent', cap: 8,
|
|
339
340
|
growth: 'A new floor opens whenever the charter creates a new seat.', annex: 'room for an annex of seats',
|
|
340
|
-
floors: [['The council', 'who sits, and for how long'], ['Testers', 'who signs off finished work'], ['Builders', 'who builds here', { reading: 'people' }], ['Newcomers', 'builders on newcomer tasks'], ['Waiting to join', 'asked to join the project', { source: 'access_requests' }], ['Ranks', 'the ranks, and who holds each'], ['Goal teams', 'asked to join a goal team', { source: 'goal_join_requests' }]],
|
|
341
|
+
floors: [['The council', 'who sits, and for how long', { floor: 'people.council' }], ['Testers', 'who signs off finished work', { floor: 'people.testers' }], ['Builders', 'who builds here', { reading: 'people' }], ['Newcomers', 'builders on newcomer tasks', { floor: 'people.newcomers' }], ['Waiting to join', 'asked to join the project', { source: 'access_requests' }], ['Ranks', 'the ranks, and who holds each', { floor: 'people.ranks' }], ['Goal teams', 'asked to join a goal team', { source: 'goal_join_requests' }]],
|
|
341
342
|
},
|
|
342
343
|
planning: {
|
|
343
344
|
holds: 'Goals, and the promises that say when each one is done. Kept promises are what the project owes its users.', rule: 'majority', cap: 9,
|
|
344
345
|
growth: 'When it fills, the next new goal opens a second planning office.', annex: 'room for a second planning office',
|
|
345
|
-
floors: [['Goals', 'the open goals', { goals: true }], ['Promises', 'what the goals say they deliver', { reading: 'promises' }], ['Ideas to ratify', 'full ideas waiting for a vote'], ['Priorities', 'the order goals are worked in'], ['Versions', 'what each version holds'], ['Waiting on a decision', 'blockers holding work', { source: 'blockers' }]],
|
|
346
|
+
floors: [['Goals', 'the open goals', { floor: 'planning.goals', goals: true }], ['Promises', 'what the goals say they deliver', { reading: 'promises' }], ['Ideas to ratify', 'full ideas waiting for a vote', { floor: 'planning.ideas' }], ['Priorities', 'the order goals are worked in', { floor: 'planning.priorities' }], ['Versions', 'what each version holds', { floor: 'planning.versions' }], ['Waiting on a decision', 'blockers holding work', { source: 'blockers' }]],
|
|
346
347
|
},
|
|
347
348
|
automation: {
|
|
348
349
|
holds: 'AI builders working on their own. You choose which goals they may build and how much they may spend, and you can stop them at any time.', rule: 'decide', cap: 6,
|
|
349
350
|
growth: 'A new floor opens for each goal you let it build.', annex: 'room for an annex',
|
|
350
|
-
floors: [['The switch', 'unattended building, on or off', { source: 'autobongos_fence' }], ['Allowed goals', 'the goals it may build'], ['Running now', 'builds in progress'], ['Spending limit', 'what it may spend
|
|
351
|
+
floors: [['The switch', 'unattended building, on or off', { source: 'autobongos_fence' }], ['Allowed goals', 'the goals it may build', { floor: 'automation.allowed_goals' }], ['Running now', 'builds in progress', { floor: 'automation.running' }], ['Spending limit', 'what it may spend', { floor: 'automation.spending_limit' }], ['Reports', 'what it did, told without asking', { floor: 'automation.reports' }]],
|
|
351
352
|
},
|
|
352
353
|
review: {
|
|
353
354
|
holds: 'Testing finished work and signing it off. Work does not count until someone who did not build it says it works.', rule: 'signoff', cap: 5,
|
|
354
355
|
growth: 'When one queue of testers is not enough, an annex can take design reviews.', vacantGrowth: 'Nothing is built here until the charter says who holds the seat.', annex: 'room for an annex, such as design review',
|
|
355
|
-
floors: [['Testers', 'who signs off finished work'], ['Waiting outside', 'finished work waiting for a tester', { source: 'criteria_uat' }], ['Signed off', 'work a tester has signed off']],
|
|
356
|
+
floors: [['Testers', 'who signs off finished work', { floor: 'review.testers' }], ['Waiting outside', 'finished work waiting for a tester', { source: 'criteria_uat', held: 'Being tested' }], ['Signed off', 'work a tester has signed off', { floor: 'review.signed_off' }]],
|
|
356
357
|
},
|
|
357
358
|
release: {
|
|
358
359
|
holds: 'Putting finished work live, and taking updates to Bongos itself.', rule: 'decide', cap: 7,
|
|
359
360
|
growth: 'Room for a second gate if releases are ever split by area.', annex: 'room for a second gate',
|
|
360
|
-
floors: [['Overrides', 'past a failed check or grade', { source: 'override_requests' }], ['Modules', 'listed modules, and their re-checks', { source: 'module_rechecks' }], ['Held back', 'work held from going live'], ['Core update', 'a new version of Bongos', { source: 'core_update' }], ['Live this month', 'what went live'], ['Ready to go', 'signed off, waiting to go live', { source: 'gate_approvals' }]],
|
|
361
|
+
floors: [['Overrides', 'past a failed check or grade', { source: 'override_requests' }], ['Modules', 'listed modules, and their re-checks', { source: 'module_rechecks' }], ['Held back', 'work held from going live', { floor: 'release.held_back' }], ['Core update', 'a new version of Bongos', { source: 'core_update' }], ['Live this month', 'what went live', { floor: 'release.live_month' }], ['Ready to go', 'signed off, waiting to go live', { source: 'gate_approvals' }]],
|
|
361
362
|
},
|
|
362
363
|
treasury: {
|
|
363
364
|
holds: 'The budget, and who gets paid for what.', rule: 'majority', cap: 7,
|
|
364
365
|
growth: 'Each new spending line takes a floor.', annex: 'room for an annex of spending lines',
|
|
365
|
-
floors: [['Builder rewards', 'paid for shipped work'], ['AI grading', 'the reviewer that grades every ship'], ['Hosting', 'servers and storage'], ['Reserve', 'what is left this month', { reading: 'money' }], ['Spending matters', 'spending put to a vote', { source: 'treasury_spending' }]],
|
|
366
|
+
floors: [['Builder rewards', 'paid for shipped work', { floor: 'treasury.rewards' }], ['AI grading', 'the reviewer that grades every ship', { floor: 'treasury.grading' }], ['Hosting', 'servers and storage', { floor: 'treasury.hosting' }], ['Reserve', 'what is left this month', { reading: 'money' }], ['Spending matters', 'spending put to a vote', { source: 'treasury_spending' }]],
|
|
366
367
|
},
|
|
367
368
|
});
|
|
368
369
|
const MONTHS = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec'];
|
|
@@ -396,6 +397,18 @@
|
|
|
396
397
|
const docket = ctx.docket, now = ctx.now;
|
|
397
398
|
if (!bind) return F(name, what, '', 'coming', [['What this floor holds is not recorded in Bongos yet.', 'coming']]);
|
|
398
399
|
if (!docket) return F(name, what, '', 'unavailable', [['The docket could not be read just now.', '']]);
|
|
400
|
+
if (bind.floor) {
|
|
401
|
+
const fl = (Array.isArray(docket.floors) ? docket.floors : []).find((x) => x && x.key === bind.floor);
|
|
402
|
+
if (fl && fl.status === 'live') {
|
|
403
|
+
const titles = new Map((ctx.goals || []).map((g) => [String(g.id), String(g.title || '')]));
|
|
404
|
+
// a line that names a goal by id ('goal 12') is lettered with its title
|
|
405
|
+
const word = (t) => { const m = /^goal (\d+)$/.exec(String(t)); return m && titles.get(m[1]) ? titles.get(m[1]) : String(t); };
|
|
406
|
+
const lines = (Array.isArray(fl.lines) ? fl.lines : []).map((l) => [word(l[0]), String(l[1] || '')]);
|
|
407
|
+
return F(name, String(fl.line || what), fl.count == null ? '' : String(fl.count), fl.hot ? 'pending' : 'ok', lines.length ? lines : [[String(fl.line || what), '']]);
|
|
408
|
+
}
|
|
409
|
+
if (fl) return quietFloor(name, what, fl.status, fl.reason);
|
|
410
|
+
if (!bind.goals && !bind.reading) return F(name, what, '', 'unavailable', [['This could not be read just now.', '']]);
|
|
411
|
+
}
|
|
399
412
|
if (bind.goals) {
|
|
400
413
|
const goals = ctx.goals || [];
|
|
401
414
|
const lines = goals.slice(0, MAX_LINES).map((g) => [String(g.title || 'Goal ' + g.id), 'open']);
|
|
@@ -423,36 +436,73 @@
|
|
|
423
436
|
return F(name, what, '', 'unavailable', [[String(reason || 'It could not be read just now.'), '']]);
|
|
424
437
|
}
|
|
425
438
|
|
|
426
|
-
// Who holds the building and how it decides.
|
|
427
|
-
//
|
|
428
|
-
//
|
|
429
|
-
//
|
|
430
|
-
|
|
439
|
+
// Who holds the building and how it decides. The building's decision row
|
|
440
|
+
// (task 1004529's table) is read first, with the people it seats now
|
|
441
|
+
// (`holders`); a vacant row is an empty seat that says so. A building with no
|
|
442
|
+
// row falls back to the legacy `holders` / `who` / `rule`, then its matters'
|
|
443
|
+
// own rule, then the design's default (and the words say 'by default', so a
|
|
444
|
+
// default is never read as a decision).
|
|
445
|
+
function holderOf(key, info, matters, viewerId) {
|
|
431
446
|
const plan = PLAN[key];
|
|
432
447
|
const holders = info && Array.isArray(info.holders) ? info.holders.filter((h) => h && (h.name || h.login)) : [];
|
|
433
|
-
const
|
|
434
|
-
const
|
|
448
|
+
const nameOf = (h) => String(h.name || h.login);
|
|
449
|
+
const seatOf = (h) => (viewerId != null && h.id != null && String(h.id) === String(viewerId) ? 'you' : nameOf(h).charAt(0).toUpperCase());
|
|
450
|
+
const d = info && info.decision && typeof info.decision === 'object' ? info.decision : null;
|
|
451
|
+
if (d) {
|
|
452
|
+
if (d.rule === 'vacant') return { seats: ['—'], who: 'no one holds it yet', rule: 'vacant', how: RULE_WORDS.vacant };
|
|
453
|
+
const rule = RULE_WORDS[d.rule] ? d.rule : plan.rule;
|
|
454
|
+
const n = info.holder_count == null ? holders.length : Number(info.holder_count) || 0;
|
|
455
|
+
const named = holders.slice(0, 3).map(nameOf);
|
|
456
|
+
const people = !n || !named.length ? '' : n <= 3 ? (named.length > 1 ? named.slice(0, -1).join(', ') + ' and ' + named[named.length - 1] : named[0]) : named.join(', ') + ' and ' + (n - 3) + ' more';
|
|
457
|
+
return {
|
|
458
|
+
seats: holders.length ? holders.slice(0, 5).map(seatOf) : ['—'],
|
|
459
|
+
who: people ? people + ' · ' + String(d.who || '') : String(d.who || 'who holds it is not set on this project yet'),
|
|
460
|
+
rule: rule,
|
|
461
|
+
how: RULE_WORDS[rule] + (RULE_WORDS[d.rule] ? '' : ' · by default'),
|
|
462
|
+
};
|
|
463
|
+
}
|
|
464
|
+
const seats = holders.length ? holders.slice(0, 5).map(seatOf) : ['—'];
|
|
465
|
+
const who = info && info.who ? String(info.who) : holders.length ? holders.map(nameOf).join(', ') : 'who holds it is not set on this project yet';
|
|
435
466
|
const fromMatter = matters.find((m) => m && RULE_WORDS[m.rule]);
|
|
436
467
|
const rule = info && RULE_WORDS[info.rule] ? info.rule : fromMatter ? fromMatter.rule : plan.rule;
|
|
437
468
|
const how = RULE_WORDS[rule] + (info && RULE_WORDS[info.rule] ? '' : fromMatter ? '' : ' · by default');
|
|
438
469
|
return { seats: seats, who: who, rule: rule, how: how };
|
|
439
470
|
}
|
|
440
471
|
|
|
441
|
-
// The record: the building's
|
|
442
|
-
|
|
472
|
+
// The record: the building's history (what closed, dated by when it closed)
|
|
473
|
+
// and its matters (dated by when each was opened), filed by month, newest
|
|
474
|
+
// drawer first. A settled sitting that is also in the history is filed once.
|
|
475
|
+
// A drawer shows MAX_DRAWER items and says how many more; its count is all.
|
|
476
|
+
const MAX_DRAWER = 12;
|
|
477
|
+
function recordOf(matters, now, history) {
|
|
443
478
|
const by = new Map();
|
|
444
|
-
|
|
445
|
-
const t =
|
|
479
|
+
const file = (at, item) => {
|
|
480
|
+
const t = at ? new Date(at) : null;
|
|
446
481
|
const ok = t && !isNaN(t.getTime());
|
|
447
482
|
const k = ok ? t.getUTCFullYear() * 12 + t.getUTCMonth() : -1;
|
|
448
483
|
if (!by.has(k)) by.set(k, []);
|
|
449
|
-
by.get(k).push(
|
|
484
|
+
by.get(k).push(item);
|
|
485
|
+
};
|
|
486
|
+
const filed = new Set();
|
|
487
|
+
(history || []).forEach((h) => {
|
|
488
|
+
const m = /:(?:board|amendment|founding):(.+)$/.exec(String(h.id || ''));
|
|
489
|
+
if (m) filed.add(m[1]);
|
|
490
|
+
});
|
|
491
|
+
(history || []).forEach((h) => file(h.at, [String(h.title || ''), String(h.outcome || '')]));
|
|
492
|
+
matters.forEach((m) => {
|
|
493
|
+
if (m.ref && m.ref.type === 'board_item' && filed.has(String(m.ref.id))) return;
|
|
494
|
+
file(m.opened_at, [String(m.title || m.short || ''), STATE_WORDS[stateOf(m)]]);
|
|
450
495
|
});
|
|
451
496
|
const thisYear = new Date(now || 0).getUTCFullYear();
|
|
452
|
-
return [...by.keys()].sort((a, b) => b - a).map((k) =>
|
|
453
|
-
|
|
454
|
-
items
|
|
455
|
-
|
|
497
|
+
return [...by.keys()].sort((a, b) => b - a).map((k) => {
|
|
498
|
+
const all = by.get(k);
|
|
499
|
+
const items = all.slice(0, MAX_DRAWER);
|
|
500
|
+
if (all.length > MAX_DRAWER) items.push(['and ' + (all.length - MAX_DRAWER) + ' more', '']);
|
|
501
|
+
return {
|
|
502
|
+
label: k < 0 ? 'undated' : MONTHS[k % 12] + (Math.floor(k / 12) === thisYear ? '' : ' ' + String(Math.floor(k / 12)).slice(2)),
|
|
503
|
+
items: items, count: all.length,
|
|
504
|
+
};
|
|
505
|
+
});
|
|
456
506
|
}
|
|
457
507
|
|
|
458
508
|
// The whole building as civicInfo() shaped it, from the real feeds.
|
|
@@ -464,21 +514,27 @@
|
|
|
464
514
|
const c = { key: key, docket: docket, goals: (ctx && ctx.goals) || [], now: (ctx && ctx.now) || 0 };
|
|
465
515
|
const matters = docket ? docket.matters.filter((m) => m && m.building === key) : [];
|
|
466
516
|
const info = docket && Array.isArray(docket.buildings) ? docket.buildings.find((b) => b && b.key === key) : null;
|
|
467
|
-
const h = holderOf(key, info, matters);
|
|
468
|
-
const vacant = key === 'review' && !S.reviewHeldOf(docket);
|
|
469
|
-
|
|
517
|
+
const h = holderOf(key, info, matters, docket && docket.viewer ? docket.viewer.id : null);
|
|
518
|
+
const vacant = key === 'review' && (!S.reviewHeldOf(docket) || !!(info && info.decision && info.decision.rule === 'vacant'));
|
|
519
|
+
// a held Review's waiting floor is the prototype's 'Being tested'
|
|
520
|
+
const floors = plan.floors.map((spec) => {
|
|
521
|
+
const fl = floorFor(spec, c);
|
|
522
|
+
if (!vacant && spec[2] && spec[2].held) fl.name = spec[2].held;
|
|
523
|
+
return fl;
|
|
524
|
+
});
|
|
470
525
|
const order = { needs: 0, moving: 1, settled: 2 };
|
|
471
526
|
const table = matters.slice().sort((a, b) => order[stateOf(a)] - order[stateOf(b)]).map((m) => {
|
|
472
527
|
const s = stateOf(m), when = S.whenWord(m.deadline, c.now);
|
|
473
528
|
return { key: String(m.id), matter: String(m.id), label: String(m.title || m.short || ''), status: s === 'needs' ? 'needs you' + (when ? ' · ' + when : '') : s === 'moving' ? 'moving' + (when ? ' · ' + when : '') : (when || 'settled'), state: s, color: s === 'needs' ? 'accent' : 'ink' };
|
|
474
529
|
});
|
|
475
|
-
const
|
|
530
|
+
const history = docket && Array.isArray(docket.history) ? docket.history.filter((x) => x && x.building === key) : [];
|
|
531
|
+
const record = recordOf(matters, c.now, history);
|
|
476
532
|
return {
|
|
477
533
|
key: key, group: GROUPS[key], name: info && info.name ? String(info.name) : key.charAt(0).toUpperCase() + key.slice(1), holds: plan.holds,
|
|
478
534
|
seats: h.seats, who: h.who, rule: h.rule, how: h.how, cap: plan.cap,
|
|
479
535
|
growth: vacant ? plan.vacantGrowth : plan.growth,
|
|
480
536
|
floors: floors, record: record,
|
|
481
|
-
onFile: !docket ? 'the docket could not be read just now' : matters.length ? matters.length + (matters.length === 1 ? ' matter' : ' matters') + ' on the docket' : 'nothing on file yet',
|
|
537
|
+
onFile: !docket ? 'the docket could not be read just now' : matters.length ? matters.length + (matters.length === 1 ? ' matter' : ' matters') + ' on the docket' + (history.length ? ' · ' + history.length + ' on the record' : '') : history.length ? history.length + ' on the record' : 'nothing on file yet',
|
|
482
538
|
table: table, annex: !vacant && floors.length >= plan.cap ? plan.annex : null, vacant: vacant,
|
|
483
539
|
};
|
|
484
540
|
}
|
|
@@ -57,6 +57,37 @@ function rulesReading(settledThisMonth) {
|
|
|
57
57
|
};
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
+
// Rules · Rules in force (task 1004527): each standing rule and how many
|
|
61
|
+
// matters it settled this month. The project records one standing rule today,
|
|
62
|
+
// the landing rule; a rule put to the council has nowhere to live yet (the
|
|
63
|
+
// docket's standing_rules source says coming). Pure.
|
|
64
|
+
const LANDING_RULE = 'Every filing lands in its goal without a vote.';
|
|
65
|
+
function rulesFloor(settledThisMonth) {
|
|
66
|
+
const n = Number(settledThisMonth) || 0;
|
|
67
|
+
return {
|
|
68
|
+
count: '1',
|
|
69
|
+
line: `1 rule · settled ${n} this month`,
|
|
70
|
+
lines: [[LANDING_RULE, `settled ${n}`], ['A new rule put to the council is coming.', 'coming']],
|
|
71
|
+
hot: false,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
// The Rules record: what the landing rule settled, month by month. Pure.
|
|
75
|
+
function rulesHistory(months) {
|
|
76
|
+
return (months || []).filter((m) => Number(m.n) > 0).map((m) => ({
|
|
77
|
+
id: `landing:${m.month}`, title: `The landing rule settled ${m.n} ${Number(m.n) === 1 ? 'filing' : 'filings'}`,
|
|
78
|
+
at: `${m.month}-01T00:00:00.000Z`, outcome: 'without a vote',
|
|
79
|
+
}));
|
|
80
|
+
}
|
|
81
|
+
async function routedByMonth(months = 6) {
|
|
82
|
+
const { rows } = await api.pool.query(
|
|
83
|
+
`SELECT to_char(created_at, 'YYYY-MM') AS month, count(*)::int AS n FROM ideas_task_lineage
|
|
84
|
+
WHERE origin = 'route' AND created_at >= date_trunc('month', now()) - (($1::int - 1) * interval '1 month')
|
|
85
|
+
GROUP BY month ORDER BY month DESC`,
|
|
86
|
+
[months],
|
|
87
|
+
);
|
|
88
|
+
return rows;
|
|
89
|
+
}
|
|
90
|
+
|
|
60
91
|
// Filings the routing rule landed this month (origin 'route').
|
|
61
92
|
async function countRoutedThisMonth() {
|
|
62
93
|
const { rows } = await api.pool.query(
|
|
@@ -66,6 +97,12 @@ async function countRoutedThisMonth() {
|
|
|
66
97
|
return rows[0] ? rows[0].n : 0;
|
|
67
98
|
}
|
|
68
99
|
|
|
100
|
+
// One read per docket request: the collector hands every read a per-request
|
|
101
|
+
// `once(key, fn)` (docket.js buildDocket), so two floors that need the same rows
|
|
102
|
+
// share one query. Without it (a direct call) the read simply runs.
|
|
103
|
+
const shared = (once, key, fn) => (typeof once === 'function' ? once(key, fn) : fn());
|
|
104
|
+
const routedFor = (once) => shared(once, 'ideas.routedThisMonth', () => countRoutedThisMonth());
|
|
105
|
+
|
|
69
106
|
function contributeIdeasToDocket() {
|
|
70
107
|
api.contribute(POINT, {
|
|
71
108
|
source: 'blockers',
|
|
@@ -81,13 +118,17 @@ function contributeIdeasToDocket() {
|
|
|
81
118
|
api.contribute(POINT, {
|
|
82
119
|
reading: 'rules',
|
|
83
120
|
gate: { anyBuilder: true },
|
|
84
|
-
read: async () => rulesReading(await
|
|
121
|
+
read: async (_ctx, { once } = {}) => rulesReading(await routedFor(once)),
|
|
85
122
|
});
|
|
123
|
+
api.contribute(POINT, { floor: 'rules.in_force', gate: { anyBuilder: true }, read: async (_ctx, { once } = {}) => rulesFloor(await routedFor(once)) });
|
|
124
|
+
api.contribute(POINT, { history: 'rules', building: 'rules', gate: { anyBuilder: true }, read: async () => rulesHistory(await routedByMonth()) });
|
|
86
125
|
}
|
|
87
126
|
|
|
88
127
|
module.exports = {
|
|
89
128
|
contributeIdeasToDocket,
|
|
90
129
|
blockerDocketMatter,
|
|
91
130
|
rulesReading,
|
|
131
|
+
rulesFloor,
|
|
132
|
+
rulesHistory,
|
|
92
133
|
countRoutedThisMonth,
|
|
93
134
|
};
|