@bongos/core 1.21.53 → 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 +33 -28
- package/docs/file-map.md +1 -0
- package/docs/module-api-changelog.md +4 -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.css +18 -0
- package/modules/hall-ui/public/street-card.js +285 -5
- package/modules/hall-ui/public/street-desk.js +51 -10
- package/modules/hall-ui/public/street.css +2 -0
- package/modules/hall-ui/public/street.html +4 -4
- package/modules/hall-ui/public/street.js +180 -18
- package/modules/hall-ui/public/street.states.json +41 -1
- package/modules/hall-ui/records/governor-street.md +28 -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 +16 -0
- package/src/module-api.js +1 -1
- package/tests/goal_category.mjs +16 -0
- package/tests/hall_street_desk.mjs +541 -9
- package/tests/privacy_matrix.mjs +689 -0
|
@@ -51,6 +51,14 @@
|
|
|
51
51
|
// for the day in this browser), a row that clears away (a CSS class, then the
|
|
52
52
|
// redraw), a cleared floor that lights its windows and shows a tick in the
|
|
53
53
|
// drawing, and the toast after every act.
|
|
54
|
+
//
|
|
55
|
+
// THE RECORDED SIGN-OFF (task 1004579). 'record it' on a promise that needs a
|
|
56
|
+
// recording opens its one line on the desk row or the card: pick or drop the
|
|
57
|
+
// video, "tested it live" sends it (its progress painted on that line in place)
|
|
58
|
+
// and signs off, through GovMatterCard.recordRun and the goal page's own two
|
|
59
|
+
// routes. Signed off, it is answered like every other act: the row clears, the
|
|
60
|
+
// meter takes a pip, the toast speaks, the record gains the line. Refused, the
|
|
61
|
+
// line says why and offers to try again; nothing is counted.
|
|
54
62
|
|
|
55
63
|
(function () {
|
|
56
64
|
'use strict';
|
|
@@ -67,6 +75,7 @@
|
|
|
67
75
|
tasks: function (goalId) { return '/api/bongos/tasks?goal_id=' + encodeURIComponent(goalId); },
|
|
68
76
|
room: '/api/bongos/government/board/items',
|
|
69
77
|
constitution: '/api/bongos/government/constitution',
|
|
78
|
+
uat: function (id) { return '/api/bongos/done-when/' + encodeURIComponent(id) + '/uat'; },
|
|
70
79
|
};
|
|
71
80
|
var NARROW = 900;
|
|
72
81
|
var I = window.GovInside;
|
|
@@ -88,6 +97,10 @@
|
|
|
88
97
|
// the floors you cleared, the row whose facts or chips are open
|
|
89
98
|
// order: the Priorities desk's order as the viewer has moved it, per floor (task 1004560)
|
|
90
99
|
var desk = { done: {}, mine: {}, cleared: {}, order: {}, open: null, ask: null, busy: false, roomAsked: false, model: null };
|
|
100
|
+
// the recorded sign-off (task 1004579), per matter: its file and phase
|
|
101
|
+
// (GovMatterCard.recordLine), the painter of its line as last drawn, and the
|
|
102
|
+
// checks read from GET /done-when/:id/uat (null reading, false unreadable)
|
|
103
|
+
var rec = { state: {}, paint: {}, checks: {} };
|
|
91
104
|
var TODAY_KEY = 'gov-desk-today';
|
|
92
105
|
var today = { day: '', n: 0 };
|
|
93
106
|
var todayDrawn = null; // the count the meter's pips last drew: they are rebuilt only when it moves
|
|
@@ -623,7 +636,11 @@
|
|
|
623
636
|
name.setAttribute('data-matter', r.matter);
|
|
624
637
|
name.appendChild(node('span', 'desk-name__t', r.name));
|
|
625
638
|
name.appendChild(node('span', 'desk-name__f', r.fact));
|
|
626
|
-
name.addEventListener('click', function () {
|
|
639
|
+
name.addEventListener('click', function () {
|
|
640
|
+
desk.open = desk.open === r.key ? null : r.key; desk.ask = null;
|
|
641
|
+
if (desk.open && recorded(r)) readChecks(criterionOf(r.card));
|
|
642
|
+
draw(); focusRow(r.key, 'name');
|
|
643
|
+
});
|
|
627
644
|
main.appendChild(name);
|
|
628
645
|
// promises kept of total, or a vote's seats: one pip each
|
|
629
646
|
if (r.pips && r.pips.length) {
|
|
@@ -660,9 +677,27 @@
|
|
|
660
677
|
});
|
|
661
678
|
main.appendChild(acts);
|
|
662
679
|
row.appendChild(main);
|
|
680
|
+
if (recorded(r)) {
|
|
681
|
+
// a recording dropped anywhere on its row is taken, and its line opens
|
|
682
|
+
row.addEventListener('dragover', function (e) { if (e && e.preventDefault) e.preventDefault(); });
|
|
683
|
+
row.addEventListener('drop', function (e) {
|
|
684
|
+
if (e && e.preventDefault) e.preventDefault();
|
|
685
|
+
var f = e && e.dataTransfer && e.dataTransfer.files ? e.dataTransfer.files[0] : null;
|
|
686
|
+
if (f && !desk.busy) deskPick(r, f);
|
|
687
|
+
});
|
|
688
|
+
}
|
|
663
689
|
if (desk.open === r.key) {
|
|
664
690
|
var facts = node('div', 'desk-facts');
|
|
665
|
-
|
|
691
|
+
// a recorded sign-off's detail is the goal page's three checks, once read
|
|
692
|
+
var checks = recorded(r) && rec.checks[criterionOf(r.card)];
|
|
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
|
+
}
|
|
666
701
|
row.appendChild(facts);
|
|
667
702
|
}
|
|
668
703
|
var asked = desk.ask && desk.ask.indexOf(r.key + '|') === 0 ? (r.choices || {})[desk.ask.slice(r.key.length + 1)] : null;
|
|
@@ -671,14 +706,24 @@
|
|
|
671
706
|
if (asked) {
|
|
672
707
|
var chips = node('div', 'desk-choices');
|
|
673
708
|
chips.appendChild(node('span', 'desk-choices__ask', asked.ask));
|
|
709
|
+
if (asked.file) {
|
|
710
|
+
var strip = window.GovMatterCard.recordStrip(document, recState(r.matter), {
|
|
711
|
+
pick: function (f) { deskPick(r, f); },
|
|
712
|
+
send: function () { deskRecord(r); },
|
|
713
|
+
}, 'desk', desk.busy);
|
|
714
|
+
rec.paint[r.matter] = strip;
|
|
715
|
+
chips.appendChild(strip.node);
|
|
716
|
+
}
|
|
674
717
|
asked.options.forEach(function (c) { chips.appendChild(chipBtn('desk-chip--choice', c.label, function () { deskAct(r, asked.for, c.input); })); });
|
|
675
718
|
if (asked.field) {
|
|
676
|
-
// 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
|
|
677
721
|
var input = node('input', 'desk-field');
|
|
678
722
|
input.setAttribute('type', 'text');
|
|
679
723
|
input.setAttribute('aria-label', asked.ask);
|
|
680
724
|
input.setAttribute('autocomplete', 'off');
|
|
681
725
|
input.setAttribute('maxlength', '60');
|
|
726
|
+
if (asked.value) { input.setAttribute('value', asked.value); input.value = asked.value; }
|
|
682
727
|
var send = function () { var v = input.value.trim(); if (v) deskAct(r, asked.for, { option: v, label: v }); };
|
|
683
728
|
input.addEventListener('keydown', function (e) { if (e.key === 'Enter') send(); });
|
|
684
729
|
chips.appendChild(input);
|
|
@@ -688,6 +733,40 @@
|
|
|
688
733
|
}
|
|
689
734
|
return row;
|
|
690
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
|
+
}
|
|
691
770
|
// The Priorities desk (task 1004560): a move is kept in this visit until saved.
|
|
692
771
|
function moveRow(r, by) {
|
|
693
772
|
var D = window.GovDesk, st = view.inside;
|
|
@@ -751,38 +830,115 @@
|
|
|
751
830
|
pause: 'paused', resume: 'resumed', tier: 'new tier', deactivate: 'deactivated', give: 'gave', take: 'took back',
|
|
752
831
|
check: 'checked', rot: 'set', module_on: 'turned on', module_off: 'turned off', backup: 'backed up', invite: 'invited',
|
|
753
832
|
decide: 'decided', ratify: 'ratified', ratify_goal: 'made a goal of',
|
|
754
|
-
budget: 'set the limit of', unsign: 'undid', withdraw: 'withdrew',
|
|
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',
|
|
755
836
|
};
|
|
837
|
+
// ---------- the recorded sign-off (task 1004579) ----------
|
|
838
|
+
function recState(key) { return rec.state[key] || (rec.state[key] = { file: null, phase: 'empty', pct: 0 }); }
|
|
839
|
+
function recorded(r) { return !!(r && r.choices && r.choices.record && r.choices.record.file); }
|
|
840
|
+
function criterionOf(cv) { return cv && cv.subject && cv.subject.kind === 'criteria_uat' ? cv.subject.id : null; }
|
|
841
|
+
// the goal page's three checks, read once per promise for its detail
|
|
842
|
+
function readChecks(id) {
|
|
843
|
+
if (id == null || id === '' || rec.checks[id] !== undefined) return Promise.resolve();
|
|
844
|
+
rec.checks[id] = null;
|
|
845
|
+
return getJson(FEEDS.uat(id)).then(function (v) {
|
|
846
|
+
rec.checks[id] = v && v.checks ? v : false;
|
|
847
|
+
draw();
|
|
848
|
+
if (view.open) fillCard();
|
|
849
|
+
});
|
|
850
|
+
}
|
|
851
|
+
function upload(url, file, onPct) { return window.GovMatterCard.uploadXhr(window.XMLHttpRequest, url, file, onPct); }
|
|
852
|
+
// send it, then sign off; a phase change redraws its surface, progress only paints its line
|
|
853
|
+
function runRecord(key, id, redraw) {
|
|
854
|
+
return window.GovMatterCard.recordRun(id, recState(key), {
|
|
855
|
+
upload: upload, send: send,
|
|
856
|
+
change: function () { redraw(); },
|
|
857
|
+
progress: function (st) { var p = rec.paint[key]; if (p) p.paint(st); },
|
|
858
|
+
});
|
|
859
|
+
}
|
|
860
|
+
function focusAct(host, act) {
|
|
861
|
+
var b = findAct(host, act);
|
|
862
|
+
if (b && b.tagName && String(b.tagName).toLowerCase() === 'label' && b.children && b.children[1]) b = b.children[1];
|
|
863
|
+
if (b && b.focus) b.focus();
|
|
864
|
+
}
|
|
865
|
+
function rowEl(key) {
|
|
866
|
+
var list = deskRowsEl();
|
|
867
|
+
var rows = list ? list.children : [];
|
|
868
|
+
for (var i = 0; i < rows.length; i++) if (rows[i].getAttribute('data-row') === key) return rows[i];
|
|
869
|
+
return null;
|
|
870
|
+
}
|
|
871
|
+
function deskPick(r, f) {
|
|
872
|
+
window.GovMatterCard.recordPick(recState(r.matter), f);
|
|
873
|
+
desk.ask = r.key + '|record'; desk.open = null;
|
|
874
|
+
draw();
|
|
875
|
+
var at = rowEl(r.key);
|
|
876
|
+
focusAct(at, findAct(at, 'record-send') ? 'record-send' : 'record-pick');
|
|
877
|
+
}
|
|
878
|
+
function deskRecord(r) {
|
|
879
|
+
return deskAct(r, 'record', {}, function () { return runRecord(r.matter, criterionOf(r.card), draw); }).then(function (done) {
|
|
880
|
+
if (!done) { var at = rowEl(r.key); focusAct(at, findAct(at, 'record-send') ? 'record-send' : 'record-pick'); }
|
|
881
|
+
return done;
|
|
882
|
+
});
|
|
883
|
+
}
|
|
884
|
+
// the card's handlers for the same line, on the card's own matter
|
|
885
|
+
function cardRecord(cv) {
|
|
886
|
+
var subj = cv.row && cv.row.card ? cv.row.card : cv;
|
|
887
|
+
var key = String(cv.key);
|
|
888
|
+
var refocus = function () { focusAct($('streetCardBody'), findAct($('streetCardBody'), 'record-send') ? 'record-send' : 'record-pick'); };
|
|
889
|
+
return {
|
|
890
|
+
state: function () { return recState(key); },
|
|
891
|
+
painted: function (s) { rec.paint[key] = s; },
|
|
892
|
+
pick: function (f) { window.GovMatterCard.recordPick(recState(key), f); fillCard(); refocus(); },
|
|
893
|
+
send: function () {
|
|
894
|
+
return actOn('record', {}, function () { return runRecord(key, criterionOf(subj), function () { if (view.open) fillCard(); }); })
|
|
895
|
+
.then(function (done) { if (!done && view.open) refocus(); return done; });
|
|
896
|
+
},
|
|
897
|
+
};
|
|
898
|
+
}
|
|
756
899
|
// One act from a row: checked, sent through the card's own request (the
|
|
757
900
|
// server's permission checks decide), then the row clears, the floor counts
|
|
758
901
|
// it, the meter takes a pip and the toast says what happened. An act that
|
|
759
902
|
// records nothing (hold) clears the row for this visit and says it still waits.
|
|
760
|
-
|
|
903
|
+
// go: the act's own sender in place of its one request (the recorded
|
|
904
|
+
// sign-off's upload then sign-off); its refusal is said on its own line.
|
|
905
|
+
function deskAct(r, key, input, go) {
|
|
761
906
|
var C = window.GovMatterCard, st = view.inside;
|
|
762
907
|
if (!C || !st || !r.card || desk.busy) return Promise.resolve(false);
|
|
763
908
|
var refused = C.check(r.card, key, input);
|
|
764
909
|
if (refused) { say(refused, true); return Promise.resolve(false); }
|
|
765
910
|
var k = st.key + ':' + (view.insideModel ? view.insideModel.selFloor : 0);
|
|
766
|
-
var req = C.request(r.card, key, input);
|
|
911
|
+
var req = go ? null : C.request(r.card, key, input);
|
|
767
912
|
var settle = function (body) {
|
|
768
913
|
var a = C.answer(r.card, key, body);
|
|
769
914
|
// an act at a building a sitting decides went to it as a matter (task 1004561)
|
|
770
915
|
var sent = r.card.subject && r.card.subject.council && req && /\/government\/board\/matters$/.test(req.url);
|
|
771
|
-
|
|
772
|
-
|
|
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;
|
|
773
921
|
desk.open = null; desk.ask = null;
|
|
774
|
-
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;
|
|
775
923
|
if (st.floor == null && view.insideModel) st.floor = view.insideModel.selFloor;
|
|
776
924
|
bumpToday();
|
|
777
925
|
if (a.building && a.kind) card.answer = { building: a.building, matter: r.matter, kind: a.kind, at: Date.now() };
|
|
778
926
|
say(a.toast || 'Done.', true);
|
|
779
927
|
};
|
|
780
|
-
|
|
928
|
+
// a row being edited with nothing to send (an empty name) does nothing
|
|
929
|
+
if (!req && !go && r.stay) return Promise.resolve(false);
|
|
930
|
+
if (!req && !go) return new Promise(function (res) { clearRow(r.key, function () { settle({}); draw(); res(true); }); });
|
|
781
931
|
desk.busy = true;
|
|
782
932
|
draw();
|
|
783
|
-
return send(req).then(function (out) {
|
|
933
|
+
return (go ? go() : send(req)).then(function (out) {
|
|
784
934
|
desk.busy = false;
|
|
785
|
-
if (!out.ok) { say(C.refusal(out.status, out.body), true); draw(); return false; }
|
|
935
|
+
if (!out.ok) { if (!go) say(C.refusal(out.status, out.body), true); draw(); return false; }
|
|
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
|
+
}
|
|
786
942
|
return new Promise(function (res) {
|
|
787
943
|
clearRow(r.key, function () {
|
|
788
944
|
settle(out.body);
|
|
@@ -1081,12 +1237,14 @@
|
|
|
1081
1237
|
var ctx = { room: data.room, constitution: data.constitution, now: Date.now(), goals: (data.goals || []).filter(function (g) { return g && g.status === 'open'; }), pick: card.pick };
|
|
1082
1238
|
var cv = D && D.cardFor ? D.cardFor(view.open, data.docket, ctx) : C ? C.model({ docket: data.docket, key: view.open, room: data.room, constitution: data.constitution, now: Date.now() }) : null;
|
|
1083
1239
|
if (!cv) { closeCard(false); return null; }
|
|
1240
|
+
var recordable = !!(cv.row && cv.row.choices && cv.row.choices.record && cv.row.choices.record.file);
|
|
1241
|
+
if (recordable) { var cid = criterionOf(cv.row.card); C.withChecks(cv, rec.checks[cid] || null, rec.checks[cid] === false); }
|
|
1084
1242
|
card.cv = cv;
|
|
1085
1243
|
$('streetCardWhere').textContent = cv.where;
|
|
1086
1244
|
$('streetCardWhen').textContent = cv.when;
|
|
1087
1245
|
$('streetCardTitle').textContent = cv.title;
|
|
1088
1246
|
var pager = C.pager ? C.pager(card.page, view.open) : null;
|
|
1089
|
-
C.render(document, $('streetCardBody'), cv, { act: actOn, openForm: openForm, ask: askFor, more: toggleMore, pick: pickQueued, page: turnPage }, { form: card.form, ask: card.ask, more: card.more, error: card.error, busy: card.busy, fresh: card.fresh, pager: pager });
|
|
1247
|
+
C.render(document, $('streetCardBody'), cv, { act: actOn, openForm: openForm, ask: askFor, more: toggleMore, pick: pickQueued, page: turnPage, record: recordable ? cardRecord(cv) : null }, { form: card.form, ask: card.ask, more: card.more, error: card.error, busy: card.busy, fresh: card.fresh, pager: pager });
|
|
1090
1248
|
var frame = $('streetCard'); if (frame) frame.setAttribute('data-more', card.more ? 'open' : 'closed');
|
|
1091
1249
|
placeCard();
|
|
1092
1250
|
return cv;
|
|
@@ -1100,6 +1258,8 @@
|
|
|
1100
1258
|
}
|
|
1101
1259
|
function toggleMore() {
|
|
1102
1260
|
card.more = !card.more;
|
|
1261
|
+
var cv = card.cv;
|
|
1262
|
+
if (card.more && cv && cv.row && cv.row.choices && cv.row.choices.record) readChecks(criterionOf(cv.row.card));
|
|
1103
1263
|
fillCard();
|
|
1104
1264
|
var mb = $('streetCardBody'), t = mb && mb.querySelector ? mb.querySelector('[data-act="more"]') : null;
|
|
1105
1265
|
if (t && t.focus) t.focus();
|
|
@@ -1206,20 +1366,22 @@
|
|
|
1206
1366
|
// answered without a request.
|
|
1207
1367
|
// An act from the card goes with the desk row's own card when a row stands
|
|
1208
1368
|
// behind it (its subject: which route, the council's), else the model's.
|
|
1209
|
-
|
|
1369
|
+
// go: the act's own sender (the recorded sign-off), whose refusal its line says
|
|
1370
|
+
function actOn(key, input, go) {
|
|
1210
1371
|
var C = window.GovMatterCard, cv = card.cv;
|
|
1211
1372
|
if (!C || !cv || card.busy) return Promise.resolve(false);
|
|
1212
1373
|
var subj = cv.row && cv.row.card ? cv.row.card : cv;
|
|
1213
1374
|
var refused = C.check(subj, key, input);
|
|
1214
1375
|
if (refused) { card.error = refused; fillCard(); return Promise.resolve(false); }
|
|
1215
1376
|
card.error = '';
|
|
1216
|
-
var req = C.request(subj, key, input);
|
|
1217
|
-
if (!req) return Promise.resolve(answered(subj, key, {}, false));
|
|
1377
|
+
var req = go ? null : C.request(subj, key, input);
|
|
1378
|
+
if (!req && !go) return Promise.resolve(answered(subj, key, {}, false));
|
|
1218
1379
|
card.busy = true;
|
|
1219
1380
|
fillCard();
|
|
1220
|
-
return send(req).then(function (out) {
|
|
1381
|
+
return (go ? go() : send(req)).then(function (out) {
|
|
1221
1382
|
card.busy = false;
|
|
1222
|
-
if (!out.ok) { card.error = C.refusal(out.status, out.body); if (view.open) fillCard(); return false; }
|
|
1383
|
+
if (!out.ok) { if (!go) card.error = C.refusal(out.status, out.body); if (view.open) fillCard(); return false; }
|
|
1384
|
+
if (go) { delete rec.state[String(cv.key)]; desk.done[String(cv.key)] = true; }
|
|
1223
1385
|
return answered(subj, key, out.body, true);
|
|
1224
1386
|
});
|
|
1225
1387
|
}
|
|
@@ -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.",
|
|
@@ -68,3 +68,31 @@ Left on purpose, not counted as differences:
|
|
|
68
68
|
- **The city sits inside the hall's shell** (sidebar and top bar). The prototype's home page carries only the wordmark, the switch, one button and one line; standalone hall pages were retired by task 1002868, and the city page adopted the shell after its record was written.
|
|
69
69
|
- **The home line never says "pieces of work stuck at Review".** The prototype adds it while Review is a vacant lot; the city draws Review as held in this project (the header of `city-draw.js`), so the clause has nothing to stand on.
|
|
70
70
|
- **Open owner choices met on the walk, untouched:** the "1" on a single-item pin, the light-mode legend hint wording, the recording sign-off going to its page, failed passed-vote actions on the record, a Discord update on withdraw, and the Work categories link.
|
|
71
|
+
|
|
72
|
+
## The recorded sign-off, from the card (task 1004579)
|
|
73
|
+
|
|
74
|
+
Owner decision 2026-10-04: "all these processes should be integrated". The recorded sign-off was the one Review row whose act still left for the goal page (the open choice both walkthroughs above left as found). It is done in place now, on the desk row and on the street card alike, through the same two routes the goal page uses (`POST /done-when/:id/uat/recording` with the raw video, then `POST /done-when/:id/uat` with the stored name). No new server path and no database change: the docket still hands the row `record`; only the page reads it differently.
|
|
75
|
+
|
|
76
|
+
- **One line, the boards' chip style.** The boards draw no upload, so the card was extended in their own words: "record it" opens the row's choice line ("the recording"), the way a reason's chips open. The line says one thing at a time: "mp4 or webm, up to 100 MB" with "pick or drop it"; then the file and its size with "pick another" and "tested it live"; then "42% sent" over a hairline bar; then "sent, signing off…". The file chip is a label wrapping the real file input, so a tap opens the chooser and the keyboard reaches it; a file dropped anywhere on the row (or on the card's line) is taken too.
|
|
77
|
+
- **"tested it live" is the attestation and the send.** The server needs the signer's word that they tried it on the live site when it cannot read the deploy itself; the goal page asked with a checkbox, the card asks with the same one-tap chip the backend-only sign-off already uses. The goal page's optional note is not carried: a typed note is not a chip.
|
|
78
|
+
- **Progress paints the line in place.** The upload goes by XMLHttpRequest (fetch cannot report an upload's progress), and each tick moves only the line's text and the bar, never a redraw of the desk. The bar stops at 99% until the server answers.
|
|
79
|
+
- **A refusal is one sentence on the line.** A wrong file is caught before anything is sent; the server's refusals map to short words ("Over the 100 MB cap. Record a shorter pass.", "You built this, so someone else signs it off.", "It is not on the live site yet. Sign off once it is.", "The upload broke off. Try again."). The line offers "try again" when trying again can work, "pick another" when the file was the problem, and nothing when no try can (you built it, already signed off). An upload that went through is kept, so a retry after a refused sign-off does not send the video twice. A refusal counts nothing and claims nothing in a toast.
|
|
80
|
+
- **Signed off, it is answered like every other act.** The row clears away, the today meter takes a pip, the desk's toast says "Signed off with the recording. The promise counts as kept." (or that the goal is achieved), and the record gains "signed off …". From the street card the card closes and the Review building rises.
|
|
81
|
+
- **"more" is the goal page's detail.** Opening the row's name, or "more" on the card, reads `GET /done-when/:id/uat` once and shows its three checks: the code (every linked task done), live (running, not yet, or the site cannot tell), and the sign-off (not yet, or an earlier one that lapsed). The earlier recording's video player is not drawn on the card.
|
|
82
|
+
|
|
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
|
+
|
|
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
|
@@ -9086,5 +9086,21 @@
|
|
|
9086
9086
|
"id": "1004498",
|
|
9087
9087
|
"text": "Ideating mode now has its own tool. An \"Ideas\" button in the top bar opens a side panel where an ideator writes about the page they are on. \"Turn into ideas\" has an AI split the text into separate ideas, each filed under their"
|
|
9088
9088
|
}
|
|
9089
|
+
],
|
|
9090
|
+
"1.21.54": [
|
|
9091
|
+
{
|
|
9092
|
+
"id": "1004579",
|
|
9093
|
+
"text": "A recorded sign-off is now done from the card, in place, inside the Review building. \"record it\" no longer sends you to the goal page: it opens one line where you pick or drop the walkthrough video, tap \"tested it live\", watch"
|
|
9094
|
+
},
|
|
9095
|
+
{
|
|
9096
|
+
"id": "1002973",
|
|
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
|
+
}
|
|
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
|
+
}
|
|
9089
9105
|
]
|
|
9090
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
|
+
});
|