@bongos/core 1.21.54 → 1.21.55
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 +23 -23
- package/docs/module-api-changelog.md +2 -0
- package/docs/page-inventory.json +2 -1
- package/docs/page-readings.json +88 -79
- package/modules/government/docket.js +9 -4
- package/modules/hall-ui/public/street-card.js +26 -1
- package/modules/hall-ui/public/street-desk.js +41 -4
- package/modules/hall-ui/public/street.css +2 -0
- package/modules/hall-ui/public/street.js +59 -4
- package/modules/hall-ui/public/street.states.json +41 -1
- package/modules/hall-ui/records/governor-street.md +13 -0
- package/modules/lifecycle/goal-category.js +15 -0
- package/modules/lifecycle/routes/project-settings.js +42 -7
- 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/goal_category.mjs +16 -0
- package/tests/hall_street_desk.mjs +171 -4
|
@@ -171,9 +171,11 @@
|
|
|
171
171
|
withdraw: 'withdraw',
|
|
172
172
|
// task 1004566: a module version flagged by its security check, checked again
|
|
173
173
|
recheck: 'check again',
|
|
174
|
+
// task 1004580: a work category renamed, retired, brought back, or a new one added
|
|
175
|
+
rename: 'rename', retire: 'retire', restore: 'bring back', add: 'add',
|
|
174
176
|
});
|
|
175
177
|
// What a row's chips ask, per act (a reason or an option is one tap, never text).
|
|
176
|
-
const ASKS = Object.freeze({ recheck: 'why', budget: 'monthly limit', unsign: 'why', decide: 'pick one', ratify_goal: 'which version', check: 'check', take_core: 'move to', rot: 'stale after', module_off: 'sure', invite: 'their GitHub name', object: 'why', force: 'why', switch_off: 'why', pause: 'why', deactivate: 'why', signoff: 'confirm', record: 'the recording', allow: 'which goal', tier: 'which tier', give: 'to whom', take: 'from whom' });
|
|
178
|
+
const ASKS = Object.freeze({ recheck: 'why', budget: 'monthly limit', unsign: 'why', decide: 'pick one', ratify_goal: 'which version', check: 'check', take_core: 'move to', rot: 'stale after', module_off: 'sure', invite: 'their GitHub name', object: 'why', force: 'why', switch_off: 'why', pause: 'why', deactivate: 'why', signoff: 'confirm', record: 'the recording', allow: 'which goal', tier: 'which tier', give: 'to whom', take: 'from whom', rename: 'new name', add: 'its name' });
|
|
177
179
|
// How a floor counts rows nobody is waiting on (desk rows), by its desk.
|
|
178
180
|
const DESK_NOUNS = Object.freeze({
|
|
179
181
|
'people.builders': 'builders', 'people.ranks': 'ranks', 'disputes.confirmed': 'to fix',
|
|
@@ -330,6 +332,9 @@
|
|
|
330
332
|
|
|
331
333
|
// A desk row (task 1004559): something on this floor the viewer may act on that
|
|
332
334
|
// no one is waiting on, read by the module that owns its route.
|
|
335
|
+
// Task 1004580: a row that `stay`s is edited in place, not decided, so an act
|
|
336
|
+
// leaves it on the desk; a rename's field starts from its whole name; a row
|
|
337
|
+
// with a `seq` (a place in an order) is moved from its open detail.
|
|
333
338
|
function deskRowOf(d, ctx) {
|
|
334
339
|
const titles = new Map((ctx.goals || []).map((g) => [str(g.id), str(g.title || '')]));
|
|
335
340
|
const hit = /^goal (\d+)$/.exec(str(d.name));
|
|
@@ -343,13 +348,45 @@
|
|
|
343
348
|
if (age) add('since ' + age);
|
|
344
349
|
const ref = d.ref || {};
|
|
345
350
|
const card = C && C.actsCard ? C.actsCard({ key: d.id, kind: ref.type, id: ref.id, who: full, title: full, building: d.building, href: d.href }) : null;
|
|
346
|
-
|
|
351
|
+
const acted = actedRow(d.acts || [], card || { href: null }, ctx);
|
|
352
|
+
if (acted.choices.rename && acted.choices.rename.field) acted.choices.rename.value = full;
|
|
353
|
+
return Object.assign(acted, {
|
|
347
354
|
key: str(d.id), matter: str(d.id), ini: initials(name), name: name, fact: fit(d.fact, LIMITS.fact), facts: facts,
|
|
348
355
|
needs: d.needs === true, source: d.desk, opened: d.opened_at || null, desk: true, tool: d.tool === true, over: d.over || null,
|
|
349
356
|
order: d.set || null, goalId: d.set && ref.id != null ? str(ref.id) : null, full: full,
|
|
350
357
|
pips: d.pips ? Array.from({ length: Math.min(d.pips.of, LIMITS.pips) }, (_, i) => ({ key: 'p' + i, on: i < d.pips.on })) : null,
|
|
358
|
+
stay: d.stay === true, seq: Number.isInteger(d.seq) ? d.seq : null,
|
|
351
359
|
});
|
|
352
360
|
}
|
|
361
|
+
// The rows of one floor that hold a place (`seq`), in their order: each learns
|
|
362
|
+
// whether it can move up or down (task 1004580).
|
|
363
|
+
function placed(rows) {
|
|
364
|
+
const set = rows.filter((r) => r.seq != null);
|
|
365
|
+
set.forEach((r, i) => { r.moves = { up: i > 0, down: i < set.length - 1 }; });
|
|
366
|
+
return rows;
|
|
367
|
+
}
|
|
368
|
+
// The writes that move one placed row a place up (-1) or down (+1): one, when a
|
|
369
|
+
// whole number sits between its new neighbours' places; else every place
|
|
370
|
+
// numbered again in tens, writing only those that change. [{ id, sort_order }],
|
|
371
|
+
// [] at an end. Pure (task 1004580).
|
|
372
|
+
function placeMoves(rows, key, by) {
|
|
373
|
+
const set = (rows || []).filter((r) => r && r.seq != null && r.card && r.card.subject);
|
|
374
|
+
const i = set.findIndex((r) => r.key === key);
|
|
375
|
+
const j = i + by;
|
|
376
|
+
if (i < 0 || (by !== -1 && by !== 1) || j < 0 || j >= set.length) return [];
|
|
377
|
+
const order = set.slice();
|
|
378
|
+
order.splice(i, 1);
|
|
379
|
+
order.splice(j, 0, set[i]);
|
|
380
|
+
const lo = j > 0 ? order[j - 1].seq : null;
|
|
381
|
+
const hi = j < order.length - 1 ? order[j + 1].seq : null;
|
|
382
|
+
let v = null;
|
|
383
|
+
if (lo == null) v = hi - 10;
|
|
384
|
+
else if (hi == null) v = lo + 10;
|
|
385
|
+
else if (hi - lo >= 2) v = Math.floor((lo + hi) / 2);
|
|
386
|
+
if (v != null) return [{ id: str(set[i].card.subject.id), sort_order: v }];
|
|
387
|
+
return order.map((r, k) => ({ id: str(r.card.subject.id), sort_order: (k + 1) * 10, was: r.seq }))
|
|
388
|
+
.filter((p) => p.sort_order !== p.was).map((p) => ({ id: p.id, sort_order: p.sort_order }));
|
|
389
|
+
}
|
|
353
390
|
// The desk rows of one floor: those of the desk its PLAN entry names.
|
|
354
391
|
function deskRows(key, floor, docket, ctx) {
|
|
355
392
|
const plan = I && I.PLAN[key];
|
|
@@ -364,7 +401,7 @@
|
|
|
364
401
|
function rowsFor(key, floor, docket, ctx) {
|
|
365
402
|
const c = Object.assign({ docket: docket }, ctx || {});
|
|
366
403
|
const done = c.done || {};
|
|
367
|
-
const desked = deskRows(key, floor, docket).filter((d) => !done[str(d.id)]).map((d) => deskRowOf(d, c)).filter((r) => r.actions.length);
|
|
404
|
+
const desked = placed(deskRows(key, floor, docket).filter((d) => !done[str(d.id)]).map((d) => deskRowOf(d, c)).filter((r) => r.actions.length));
|
|
368
405
|
// a desk row that stands in for a source (the core update's check-then-take)
|
|
369
406
|
// replaces that source's matter rows on this floor
|
|
370
407
|
const over = desked.map((r) => r.over).filter(Boolean);
|
|
@@ -592,7 +629,7 @@
|
|
|
592
629
|
|
|
593
630
|
const api = {
|
|
594
631
|
LIMITS: LIMITS, ACT_WORDS: ACT_WORDS, ASKS: ASKS, DESK_NOUNS: DESK_NOUNS, CHIPS: CHIPS, NOUNS: NOUNS, CLEAR: CLEAR,
|
|
595
|
-
fit: fit, ageWord: ageWord, floorMatters: floorMatters, firstNeeds: firstNeeds, rowsFor: rowsFor, cardFor: cardFor, deskModel: deskModel, breaks: breaks, todayPips: todayPips, moved: moved,
|
|
632
|
+
fit: fit, ageWord: ageWord, placeMoves: placeMoves, floorMatters: floorMatters, firstNeeds: firstNeeds, rowsFor: rowsFor, cardFor: cardFor, deskModel: deskModel, breaks: breaks, todayPips: todayPips, moved: moved,
|
|
596
633
|
};
|
|
597
634
|
root.GovDesk = api;
|
|
598
635
|
if (typeof module !== 'undefined' && module.exports) module.exports = api;
|
|
@@ -343,6 +343,8 @@ html[data-fullscreen="on"] .street-top { padding-right: 184px; }
|
|
|
343
343
|
.desk-save__line { font-size: 13.5px; color: var(--ink-soft); margin-right: auto; }
|
|
344
344
|
.desk-facts, .desk-choices { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; padding: 10px 0 2px 52px; animation: street-enter 300ms cubic-bezier(.16,1,.3,1) both; }
|
|
345
345
|
.desk-fact { font-size: 13px; color: var(--ink-soft); background: var(--street-wash); border-radius: 6px; padding: 6px 10px; white-space: nowrap; }
|
|
346
|
+
/* a place in an order moves from the open detail: its two chips wrap together (task 1004580) */
|
|
347
|
+
.desk-moves { display: inline-flex; gap: 8px; flex-wrap: wrap; }
|
|
346
348
|
.desk-choices__ask { font-size: 12.5px; color: var(--ink-faint); margin-right: 4px; }
|
|
347
349
|
.desk-quiet { min-height: 168px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 12px; border-top: 1px solid var(--street-rule); text-align: center; animation: street-enter 300ms cubic-bezier(.16,1,.3,1) both; }
|
|
348
350
|
.desk-quiet__tick { display: block; fill: none; stroke: var(--ink); stroke-width: 1.4; stroke-linecap: round; animation: desk-pop 420ms cubic-bezier(.2,.7,.3,1) both; }
|
|
@@ -691,6 +691,13 @@
|
|
|
691
691
|
// a recorded sign-off's detail is the goal page's three checks, once read
|
|
692
692
|
var checks = recorded(r) && rec.checks[criterionOf(r.card)];
|
|
693
693
|
(checks ? window.GovMatterCard.uatChecks(checks).facts : r.facts).forEach(function (f) { facts.appendChild(node('span', 'desk-fact', f)); });
|
|
694
|
+
// a row with a place in an order moves from here, one place a tap (task 1004580)
|
|
695
|
+
if (r.moves) {
|
|
696
|
+
var moves = node('span', 'desk-moves');
|
|
697
|
+
if (r.moves.up) moves.appendChild(moveChip(r, -1));
|
|
698
|
+
if (r.moves.down) moves.appendChild(moveChip(r, 1));
|
|
699
|
+
facts.appendChild(moves);
|
|
700
|
+
}
|
|
694
701
|
row.appendChild(facts);
|
|
695
702
|
}
|
|
696
703
|
var asked = desk.ask && desk.ask.indexOf(r.key + '|') === 0 ? (r.choices || {})[desk.ask.slice(r.key.length + 1)] : null;
|
|
@@ -709,12 +716,14 @@
|
|
|
709
716
|
}
|
|
710
717
|
asked.options.forEach(function (c) { chips.appendChild(chipBtn('desk-chip--choice', c.label, function () { deskAct(r, asked.for, c.input); })); });
|
|
711
718
|
if (asked.field) {
|
|
712
|
-
// the one typed value a desk takes: a name to invite
|
|
719
|
+
// the one typed value a desk takes: a name, to invite or to call a
|
|
720
|
+
// category (never a reason); a rename starts from the name it has
|
|
713
721
|
var input = node('input', 'desk-field');
|
|
714
722
|
input.setAttribute('type', 'text');
|
|
715
723
|
input.setAttribute('aria-label', asked.ask);
|
|
716
724
|
input.setAttribute('autocomplete', 'off');
|
|
717
725
|
input.setAttribute('maxlength', '60');
|
|
726
|
+
if (asked.value) { input.setAttribute('value', asked.value); input.value = asked.value; }
|
|
718
727
|
var send = function () { var v = input.value.trim(); if (v) deskAct(r, asked.for, { option: v, label: v }); };
|
|
719
728
|
input.addEventListener('keydown', function (e) { if (e.key === 'Enter') send(); });
|
|
720
729
|
chips.appendChild(input);
|
|
@@ -724,6 +733,40 @@
|
|
|
724
733
|
}
|
|
725
734
|
return row;
|
|
726
735
|
}
|
|
736
|
+
// A place in an order, moved one step (task 1004580): the chip in a row's open
|
|
737
|
+
// detail, and the writes it sends — GovDesk.placeMoves works out the places,
|
|
738
|
+
// each written through the card's own request; the first refusal stops the run.
|
|
739
|
+
function moveChip(r, by) {
|
|
740
|
+
var b = chipBtn('desk-chip--choice', by < 0 ? 'move up' : 'move down', function () { deskMove(r, by); });
|
|
741
|
+
b.setAttribute('data-act', by < 0 ? 'move-up' : 'move-down');
|
|
742
|
+
return b;
|
|
743
|
+
}
|
|
744
|
+
function deskMove(r, by) {
|
|
745
|
+
var D = window.GovDesk, C = window.GovMatterCard;
|
|
746
|
+
var writes = D && desk.model ? D.placeMoves(desk.model.rows, r.key, by) : [];
|
|
747
|
+
if (!C || !writes.length) return Promise.resolve(false);
|
|
748
|
+
var go = function () {
|
|
749
|
+
var i = 0;
|
|
750
|
+
var next = function (last) {
|
|
751
|
+
if (i >= writes.length) return Promise.resolve(last);
|
|
752
|
+
var w = writes[i++];
|
|
753
|
+
var req = C.request({ subject: { kind: 'work_category', id: w.id } }, 'move', { sort_order: w.sort_order });
|
|
754
|
+
return send(req).then(function (out) {
|
|
755
|
+
if (!out.ok) { say(C.refusal(out.status, out.body), true); return out; }
|
|
756
|
+
return next(out);
|
|
757
|
+
});
|
|
758
|
+
};
|
|
759
|
+
return next({ ok: true, status: 200, body: {} });
|
|
760
|
+
};
|
|
761
|
+
return deskAct(r, 'move', { by: by }, go).then(function (done) {
|
|
762
|
+
desk.open = r.key;
|
|
763
|
+
draw();
|
|
764
|
+
var at = rowEl(r.key);
|
|
765
|
+
var again = findAct(at, by < 0 ? 'move-up' : 'move-down') || findAct(at, by < 0 ? 'move-down' : 'move-up');
|
|
766
|
+
if (again && again.focus) again.focus();
|
|
767
|
+
return done;
|
|
768
|
+
});
|
|
769
|
+
}
|
|
727
770
|
// The Priorities desk (task 1004560): a move is kept in this visit until saved.
|
|
728
771
|
function moveRow(r, by) {
|
|
729
772
|
var D = window.GovDesk, st = view.inside;
|
|
@@ -788,6 +831,8 @@
|
|
|
788
831
|
check: 'checked', rot: 'set', module_on: 'turned on', module_off: 'turned off', backup: 'backed up', invite: 'invited',
|
|
789
832
|
decide: 'decided', ratify: 'ratified', ratify_goal: 'made a goal of',
|
|
790
833
|
budget: 'set the limit of', unsign: 'undid', withdraw: 'withdrew', record: 'signed off',
|
|
834
|
+
// task 1004580: the work categories
|
|
835
|
+
rename: 'renamed', retire: 'retired', restore: 'brought back', add: 'added', move: 'moved',
|
|
791
836
|
};
|
|
792
837
|
// ---------- the recorded sign-off (task 1004579) ----------
|
|
793
838
|
function recState(key) { return rec.state[key] || (rec.state[key] = { file: null, phase: 'empty', pct: 0 }); }
|
|
@@ -868,15 +913,20 @@
|
|
|
868
913
|
var a = C.answer(r.card, key, body);
|
|
869
914
|
// an act at a building a sitting decides went to it as a matter (task 1004561)
|
|
870
915
|
var sent = r.card.subject && r.card.subject.council && req && /\/government\/board\/matters$/.test(req.url);
|
|
871
|
-
|
|
872
|
-
|
|
916
|
+
// what was added is named by what was typed, not by the row it was added from
|
|
917
|
+
var what = key === 'add' && input && input.label ? String(input.label) : r.name;
|
|
918
|
+
desk.mine[k] = [(sent ? 'sent to council:' : DONE_WORDS[key]) + ' ' + what].concat(desk.mine[k] || []);
|
|
919
|
+
// a row that stays (a category being edited) is not cleared away (task 1004580)
|
|
920
|
+
if (!r.stay) desk.done[r.matter] = true;
|
|
873
921
|
desk.open = null; desk.ask = null;
|
|
874
|
-
if (desk.model && !desk.model.rows.some(function (x) { return x.matter !== r.matter; })) desk.cleared[k] = true;
|
|
922
|
+
if (!r.stay && desk.model && !desk.model.rows.some(function (x) { return x.matter !== r.matter; })) desk.cleared[k] = true;
|
|
875
923
|
if (st.floor == null && view.insideModel) st.floor = view.insideModel.selFloor;
|
|
876
924
|
bumpToday();
|
|
877
925
|
if (a.building && a.kind) card.answer = { building: a.building, matter: r.matter, kind: a.kind, at: Date.now() };
|
|
878
926
|
say(a.toast || 'Done.', true);
|
|
879
927
|
};
|
|
928
|
+
// a row being edited with nothing to send (an empty name) does nothing
|
|
929
|
+
if (!req && !go && r.stay) return Promise.resolve(false);
|
|
880
930
|
if (!req && !go) return new Promise(function (res) { clearRow(r.key, function () { settle({}); draw(); res(true); }); });
|
|
881
931
|
desk.busy = true;
|
|
882
932
|
draw();
|
|
@@ -884,6 +934,11 @@
|
|
|
884
934
|
desk.busy = false;
|
|
885
935
|
if (!out.ok) { if (!go) say(C.refusal(out.status, out.body), true); draw(); return false; }
|
|
886
936
|
if (go) delete rec.state[r.matter];
|
|
937
|
+
if (r.stay) {
|
|
938
|
+
settle(out.body);
|
|
939
|
+
draw();
|
|
940
|
+
return load().then(function () { return true; });
|
|
941
|
+
}
|
|
887
942
|
return new Promise(function (res) {
|
|
888
943
|
clearRow(r.key, function () {
|
|
889
944
|
settle(out.body);
|
|
@@ -652,7 +652,7 @@
|
|
|
652
652
|
},
|
|
653
653
|
"desk-rules": {
|
|
654
654
|
"auth": true,
|
|
655
|
-
"_": "Task 1004559: Rules in force. The rot timer as day chips, work
|
|
655
|
+
"_": "Task 1004559: Rules in force. The rot timer as day chips, each module on or off. Task 1004580: each work category is its own row, edited in place (no link out).",
|
|
656
656
|
"url": "/builders/street#building=rules",
|
|
657
657
|
"actions": [
|
|
658
658
|
[
|
|
@@ -682,6 +682,46 @@
|
|
|
682
682
|
]
|
|
683
683
|
}
|
|
684
684
|
},
|
|
685
|
+
"desk-rules-category": {
|
|
686
|
+
"auth": true,
|
|
687
|
+
"_": "Task 1004580: a work category opened in place: its key, its place in the order and whether it is offered, with move up and move down.",
|
|
688
|
+
"url": "/builders/street#building=rules",
|
|
689
|
+
"actions": [
|
|
690
|
+
[
|
|
691
|
+
"wait",
|
|
692
|
+
900
|
|
693
|
+
],
|
|
694
|
+
[
|
|
695
|
+
"clickIfVisible",
|
|
696
|
+
"[data-craft-close]"
|
|
697
|
+
],
|
|
698
|
+
[
|
|
699
|
+
"wait",
|
|
700
|
+
300
|
|
701
|
+
],
|
|
702
|
+
[
|
|
703
|
+
"click",
|
|
704
|
+
"[data-floor=\"0\"]"
|
|
705
|
+
],
|
|
706
|
+
[
|
|
707
|
+
"wait",
|
|
708
|
+
600
|
|
709
|
+
],
|
|
710
|
+
[
|
|
711
|
+
"click",
|
|
712
|
+
"[data-matter=\"rules.settings:cat-3\"]"
|
|
713
|
+
],
|
|
714
|
+
[
|
|
715
|
+
"wait",
|
|
716
|
+
400
|
|
717
|
+
]
|
|
718
|
+
],
|
|
719
|
+
"expect": {
|
|
720
|
+
"visible": [
|
|
721
|
+
".desk-facts"
|
|
722
|
+
]
|
|
723
|
+
}
|
|
724
|
+
},
|
|
685
725
|
"desk-planning-priorities": {
|
|
686
726
|
"auth": true,
|
|
687
727
|
"_": "Task 1004560: Planning, Priorities. The open goals in order with promise pips and two arrows; one goal moved down, so the save bar says the new order goes to the council and how many yes it needs, with undo.",
|
|
@@ -83,3 +83,16 @@ Owner decision 2026-10-04: "all these processes should be integrated". The recor
|
|
|
83
83
|
Walked in the hall preview harness at 1440 and 375, light and dark, desk and card: the empty line, a picked file, progress mid-upload, a refused file, a broken upload and its retry, the checks under "more", and the cleared row with pip, toast and record line. The harness docket holds no recorded criterion and refuses writes, so the walk added one recorded matter and answered the upload and the sign-off inside the page; nothing was signed off on the live site.
|
|
84
84
|
|
|
85
85
|
Tests: `tests/hall_street_desk.mjs` (the `1004579` cases) hold the line in every phase, the routes against the goal page's and the server's own, the short refusals, the run's two steps and its retry rules, the upload with its progress, and on the page the desk row and the card end to end: the cleared row, the pip, the toast and the record line, a refused upload and a refused sign-off that keep the row and count nothing, the drop, and the checks.
|
|
86
|
+
|
|
87
|
+
## Work categories, edited in place (task 1004580)
|
|
88
|
+
|
|
89
|
+
Owner decision 2026-10-04: Work categories was the last floor row that still left the building (the link-out both walkthroughs above left as found). The Rules in force desk now edits the category set in place, through the same two routes the Project settings page uses: `PATCH /work-categories/:id` (project.settings.manage) and `POST /work-categories` (goal.create). No new route and no database change; the docket gained one read (`countGoalsByCategory`, how many goals carry each category) and two optional desk-row fields (`seq`, `stay`), and a desk may now type a `name` as well as a GitHub name.
|
|
90
|
+
|
|
91
|
+
- **One row per category.** Its name and one fact: how many goals carry it (a retired one says "retired · N goals"). Offered ones come first, in their order, then the retired ones. Two buttons: rename and retire, or bring back and rename for a retired one. A "New category" row at the end adds one; it is offered only to a viewer holding goal.create, the add route's own atom.
|
|
92
|
+
- **Rename and add type a name, nothing else.** The field opens on the row's choice line, the rot timer's way; a rename starts from the name it has. Retire and bring back are one tap (both are undone by the other).
|
|
93
|
+
- **"more" is the page's detail.** Tapping a name opens its key, its place ("3 of 6 in order") and whether new goals are offered it, with "move up" and "move down" chips that wrap together. A move is one write when a whole number sits between its new neighbours' places, else the offered set is numbered again in tens, writing only the ones that change (`GovDesk.placeMoves`); the first refusal stops the run.
|
|
94
|
+
- **A category row stays.** It is a thing being edited, not a matter decided, so an act does not clear it away (`stay`); the meter still takes a pip, the desk's toast speaks and the record gains "renamed …", "retired …", "moved …" or "added <the typed name>". A refused act says the server's reason, keeps the row and counts nothing.
|
|
95
|
+
|
|
96
|
+
Walked in the hall preview harness at 1440 and 375, light and dark: the rows, an open detail with its move chips, the rename and add fields, and a rename sent (the write answered inside the page, since the harness refuses writes) with its pip, toast and record line. Nothing was changed on the live site. The old Project settings page keeps its own category list.
|
|
97
|
+
|
|
98
|
+
Tests: `tests/hall_street_desk.mjs` (the `1004580` cases) hold the server rows and their gates, every act's route, the move arithmetic, and on the page rename, retire and bring back, a move from the open detail, an add, and a refused act and a refused move that count nothing. `tests/goal_category.mjs` holds the grouped count.
|
|
@@ -81,6 +81,20 @@ async function listCategories({ includeInactive = false } = {}, deps = {}) {
|
|
|
81
81
|
return rows;
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
+
// How many goals carry each category, as a Map of id → count (task 1004580: the
|
|
85
|
+
// Rules desk's one fact per category row). One grouped read for the whole set; a
|
|
86
|
+
// category no goal carries is simply absent (read it as 0).
|
|
87
|
+
async function countGoalsByCategory(deps = {}) {
|
|
88
|
+
const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
|
|
89
|
+
const { rows } = await activePool.query(
|
|
90
|
+
`SELECT category_id, COUNT(*)::int AS n
|
|
91
|
+
FROM goals
|
|
92
|
+
WHERE category_id IS NOT NULL
|
|
93
|
+
GROUP BY category_id`
|
|
94
|
+
);
|
|
95
|
+
return new Map(rows.map((r) => [String(r.category_id), Number(r.n) || 0]));
|
|
96
|
+
}
|
|
97
|
+
|
|
84
98
|
async function getCategory(id, deps = {}) {
|
|
85
99
|
const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
|
|
86
100
|
const { rows } = await activePool.query(
|
|
@@ -369,6 +383,7 @@ module.exports = {
|
|
|
369
383
|
slugify,
|
|
370
384
|
isValidSlug,
|
|
371
385
|
listCategories,
|
|
386
|
+
countGoalsByCategory,
|
|
372
387
|
getCategory,
|
|
373
388
|
adviseCategory,
|
|
374
389
|
createCategory,
|
|
@@ -135,23 +135,58 @@ function upmarketRow() {
|
|
|
135
135
|
}
|
|
136
136
|
|
|
137
137
|
// The Rules desk's settings rows (task 1004559), under this page's own atom: the
|
|
138
|
-
// rot timer as one-tap day counts (PATCH /project-settings/rot-days),
|
|
139
|
-
// work categories
|
|
138
|
+
// rot timer as one-tap day counts (PATCH /project-settings/rot-days), then the
|
|
139
|
+
// work categories edited in place (task 1004580), so the desk no longer links
|
|
140
|
+
// out to this page. Each category is a row: its name, how many goals carry it,
|
|
141
|
+
// and the acts this page's category list has, each sent to the SAME route the
|
|
142
|
+
// page uses — rename (a typed name) and retire / bring back through
|
|
143
|
+
// PATCH /work-categories/:id (project.settings.manage), its place in the order
|
|
144
|
+
// (`seq`, the sort_order the desk moves it by, given only to an offered one),
|
|
145
|
+
// and a "New category" row whose name goes to POST /work-categories, offered only
|
|
146
|
+
// to a viewer holding that route's own atom (goal.create). Offered categories
|
|
147
|
+
// first, in their order, then the retired ones. `stay`: a category row stays on
|
|
148
|
+
// the desk after an act (it is a thing being edited, not a matter decided).
|
|
140
149
|
// Pure; exported for tests.
|
|
141
150
|
const ROT_CHOICES = Object.freeze([7, 14, 30, 60, 90]);
|
|
142
|
-
function
|
|
143
|
-
const
|
|
151
|
+
function categoryRows(categories, { goals = new Map(), mayAdd = false } = {}) {
|
|
152
|
+
const all = (categories || []).filter((c) => c && c.id != null && c.label);
|
|
153
|
+
const live = all.filter((c) => c.active !== false);
|
|
154
|
+
const off = all.filter((c) => c.active === false);
|
|
155
|
+
const count = (c) => Number((goals instanceof Map ? goals.get(String(c.id)) : 0) || 0);
|
|
156
|
+
const goalWords = (n) => `${n} ${n === 1 ? 'goal' : 'goals'}`;
|
|
157
|
+
const rows = live.map((c, i) => ({
|
|
158
|
+
id: `cat-${c.id}`, name: c.label, fact: goalWords(count(c)),
|
|
159
|
+
facts: [`key ${c.slug}`, `${i + 1} of ${live.length} in order`, 'offered to new goals'],
|
|
160
|
+
tool: true, stay: true, seq: Number(c.sort_order), ref: { type: 'work_category', id: c.id },
|
|
161
|
+
acts: [{ key: 'rename', field: 'name' }, { key: 'retire' }],
|
|
162
|
+
})).concat(off.map((c) => ({
|
|
163
|
+
id: `cat-${c.id}`, name: c.label, fact: `retired · ${goalWords(count(c))}`,
|
|
164
|
+
facts: [`key ${c.slug}`, 'not offered to new goals', 'goals keep it'],
|
|
165
|
+
tool: true, stay: true, ref: { type: 'work_category', id: c.id },
|
|
166
|
+
acts: [{ key: 'restore' }, { key: 'rename', field: 'name' }],
|
|
167
|
+
})));
|
|
168
|
+
if (mayAdd) {
|
|
169
|
+
rows.push({ id: 'cat-new', name: 'New category', fact: `${live.length} in use`, facts: ['it gates nothing'], tool: true, stay: true, ref: { type: 'work_category', id: 'new' }, acts: [{ key: 'add', field: 'name' }] });
|
|
170
|
+
}
|
|
171
|
+
return rows;
|
|
172
|
+
}
|
|
173
|
+
function settingsDeskRows({ days, categories, goals, mayAdd = false }) {
|
|
144
174
|
return [
|
|
145
175
|
{ id: 'rot', name: 'Rot timer', fact: `stale after ${days} ${days === 1 ? 'day' : 'days'}`, facts: ['raising it only asks a person'], tool: true, ref: { type: 'project_setting', id: 'rot_days' },
|
|
146
176
|
acts: [{ key: 'rot', options: ROT_CHOICES.filter((d) => d !== Number(days)).slice(0, 4).map((d) => ({ key: String(d), label: `${d} days` })) }] },
|
|
147
|
-
|
|
148
|
-
];
|
|
177
|
+
].concat(categoryRows(categories, { goals, mayAdd }));
|
|
149
178
|
}
|
|
150
179
|
function contributeSettingsDesk() {
|
|
151
180
|
api.contribute('governor.docket', {
|
|
152
181
|
desk: 'rules.settings',
|
|
153
182
|
gate: { permission: 'project.settings.manage' },
|
|
154
|
-
read: async () => settingsDeskRows({
|
|
183
|
+
read: async (ctx) => settingsDeskRows({
|
|
184
|
+
days: (await api.resolveRotDays()).days,
|
|
185
|
+
categories: await goalCategory.listCategories({ includeInactive: true }),
|
|
186
|
+
goals: await goalCategory.countGoalsByCategory(),
|
|
187
|
+
// the add row only for a viewer POST /work-categories would let in
|
|
188
|
+
mayAdd: !!(ctx && typeof ctx.has === 'function' && ctx.has('goal.create')),
|
|
189
|
+
}),
|
|
155
190
|
});
|
|
156
191
|
}
|
|
157
192
|
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.21.
|
|
3
|
+
"version": "1.21.55",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.21.
|
|
9
|
+
"version": "1.21.55",
|
|
10
10
|
"license": "AGPL-3.0-or-later",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"express": "^4.21.2",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.21.
|
|
3
|
+
"version": "1.21.55",
|
|
4
4
|
"description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
|
|
5
5
|
"license": "AGPL-3.0-or-later",
|
|
6
6
|
"main": "src/platform-server.js",
|
package/release-notes.json
CHANGED
|
@@ -9096,5 +9096,11 @@
|
|
|
9096
9096
|
"id": "1002973",
|
|
9097
9097
|
"text": "A single test now checks every privacy rule at once — who can see a private, hidden or not-yet-active account on every page that shows people — so a change that breaks one rule fails straight away."
|
|
9098
9098
|
}
|
|
9099
|
+
],
|
|
9100
|
+
"1.21.55": [
|
|
9101
|
+
{
|
|
9102
|
+
"id": "1004580",
|
|
9103
|
+
"text": "Work categories can now be managed right on the Rules floor of the Governor City, with no trip to the old settings page. Each category is its own line showing how many goals use it. You can rename it, retire it or bring it bac"
|
|
9104
|
+
}
|
|
9099
9105
|
]
|
|
9100
9106
|
}
|
package/src/module-api.js
CHANGED
|
@@ -75,7 +75,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
|
|
|
75
75
|
// MAJOR (see allowBoxScope below): passes the request through untouched.
|
|
76
76
|
function deprecatedNoopMiddleware(_req, _res, next) { next(); }
|
|
77
77
|
|
|
78
|
-
const CORE_VERSION = '1.21.
|
|
78
|
+
const CORE_VERSION = '1.21.55'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
79
79
|
|
|
80
80
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
81
81
|
// Usage: const log = api.logger('discord'); log.info('mounted');
|
package/tests/goal_category.mjs
CHANGED
|
@@ -225,3 +225,19 @@ test('POST /goals accepts a category but never requires one', () => {
|
|
|
225
225
|
assert.match(routes, /category_id: \{ type: 'integer', min: 1 \}/, 'accepted on create');
|
|
226
226
|
assert.equal(/category_id: \{ required: true/.test(routes), false, 'never required — that would make the prompt a refusal');
|
|
227
227
|
});
|
|
228
|
+
|
|
229
|
+
test('countGoalsByCategory: one grouped read, id to count, for the Rules desk fact (task 1004580)', async () => {
|
|
230
|
+
const before = process.env.NODE_ENV;
|
|
231
|
+
process.env.NODE_ENV = 'test';
|
|
232
|
+
const seen = [];
|
|
233
|
+
const pool = { query: async (sql) => { seen.push(sql); return { rows: [{ category_id: 3, n: 4 }, { category_id: '7', n: '1' }] }; } };
|
|
234
|
+
try {
|
|
235
|
+
const m = await gc.countGoalsByCategory({ pool });
|
|
236
|
+
assert.deepEqual([...m.entries()], [['3', 4], ['7', 1]]);
|
|
237
|
+
assert.equal(seen.length, 1);
|
|
238
|
+
assert.match(seen[0], /GROUP BY category_id/);
|
|
239
|
+
assert.match(seen[0], /WHERE category_id IS NOT NULL/);
|
|
240
|
+
} finally {
|
|
241
|
+
if (before === undefined) delete process.env.NODE_ENV; else process.env.NODE_ENV = before;
|
|
242
|
+
}
|
|
243
|
+
});
|