@bongos/core 1.21.35 → 1.21.36

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.
Files changed (36) hide show
  1. package/.bongos-core.json +54 -39
  2. package/clients/bongos-client/index.d.ts +3 -3
  3. package/docs/api/openapi.json +25 -35
  4. package/docs/api-reference.md +1 -1
  5. package/docs/module-api-changelog.md +2 -0
  6. package/docs/page-inventory.json +4 -1
  7. package/docs/page-readings.json +350 -304
  8. package/modules/autonomy/matter-acts.js +41 -0
  9. package/modules/autonomy/routes/autonomy.js +2 -0
  10. package/modules/government/board.js +4 -0
  11. package/modules/government/db.js +14 -0
  12. package/modules/government/decision-board.js +90 -11
  13. package/modules/government/docket-vocab.js +5 -0
  14. package/modules/government/docket.js +11 -0
  15. package/modules/government/routes/government.js +2 -1
  16. package/modules/hall-ui/public/street-card.js +62 -2
  17. package/modules/hall-ui/public/street-desk.js +101 -8
  18. package/modules/hall-ui/public/street-inside.js +6 -3
  19. package/modules/hall-ui/public/street.css +15 -0
  20. package/modules/hall-ui/public/street.js +73 -4
  21. package/modules/hall-ui/public/street.states.json +124 -0
  22. package/modules/ideas/governor-docket.js +91 -0
  23. package/modules/lifecycle/governor-docket.js +69 -0
  24. package/modules/lifecycle/matter-acts.js +63 -0
  25. package/modules/lifecycle/routes/done-when.js +26 -12
  26. package/modules/lifecycle/routes/gate-approvals.js +74 -59
  27. package/modules/lifecycle/routes/lifecycle.js +2 -0
  28. package/package-lock.json +2 -2
  29. package/package.json +1 -1
  30. package/release-notes.json +10 -0
  31. package/src/bongos/route-rank-check.js +2 -0
  32. package/src/bongos/routes/builders.js +56 -1
  33. package/src/module-api.js +1 -1
  34. package/tests/governor_matter_acts.mjs +241 -0
  35. package/tests/hall_street_desk.mjs +244 -4
  36. package/tests/hall_street_inside.mjs +3 -3
@@ -163,7 +163,7 @@ test('a floor holds its own source; the ideas floor holds the open ideas; the re
163
163
  const at = (k, f) => D.floorMatters(k, f, dk).map((m) => m.id);
164
164
  assert.deepEqual(at('people', 4), ['access_requests:1'], 'Waiting to join: the open request only');
165
165
  assert.deepEqual(at('people', 0), ['board:5'], 'a sitting sits at the front desk');
166
- assert.deepEqual(at('planning', 2), ['board:6'], 'Ideas to ratify');
166
+ assert.deepEqual(at('planning', 1), ['board:6'], 'Ideas to ratify');
167
167
  assert.deepEqual(at('planning', 0), [], 'an idea is not also at the front desk');
168
168
  assert.equal(D.firstNeeds('people', dk), 0);
169
169
  assert.equal(D.firstNeeds('rules', dk), null);
@@ -234,12 +234,12 @@ function fakeDom() {
234
234
  }
235
235
  return { document: { documentElement: mk('html'), getElementById: (id) => byId.get(id) || null, createElement: (t) => mk(t), createElementNS: (_n, t) => mk(t) }, byId, focused };
236
236
  }
237
- async function bootPage(hash, ms, { refuse = false } = {}) {
237
+ async function bootPage(hash, ms, { refuse = false, over = {} } = {}) {
238
238
  const { document, byId, focused } = fakeDom();
239
239
  const sent = [];
240
240
  const store = new Map();
241
241
  let matters = ms.slice();
242
- const feeds = () => ({ '/api/bongos/government/docket': docket(matters), '/api/bongos/goals': { goals: [] }, '/api/bongos/goals/rollups': { rollups: [] } });
242
+ const feeds = () => ({ '/api/bongos/government/docket': docket(matters, over), '/api/bongos/goals': { goals: [] }, '/api/bongos/goals/rollups': { rollups: [] } });
243
243
  const location = { hash, href: '' };
244
244
  const window = {
245
245
  innerWidth: 1440, innerHeight: 900, location,
@@ -268,7 +268,7 @@ async function bootPage(hash, ms, { refuse = false } = {}) {
268
268
  }
269
269
 
270
270
  test('the page: the desk acts in place — the row clears, the floor lights with a tick, the meter takes a pip, the toast speaks', async () => {
271
- const ms = [matter({ id: 'access_requests:11', source: 'access_requests', building: 'people', state: 'needs', proposer: { name: 'Ana Ruiz' }, ref: { id: 11 }, opened_at: '2026-10-01T12:00:00.000Z' })];
271
+ const ms = [matter({ id: 'access_requests:11', source: 'access_requests', building: 'people', state: 'needs', proposer: { name: 'Ana Ruiz' }, ref: { id: 11 }, opened_at: new Date(Date.now() - 2 * 86400000 - 3600000).toISOString() })]; // two days before the real clock: the page reads real time
272
272
  const p = await bootPage('#building=people', ms);
273
273
  const html = read('street.html');
274
274
  assert.ok(html.indexOf('/builders/street-desk.js?') > html.indexOf('/builders/street-inside.js?') && html.indexOf('/builders/street-desk.js?') < html.indexOf('/builders/street.js?'), 'the desk loads after the interior and before the page');
@@ -490,10 +490,78 @@ function moreDesks({ preflight = null, longWords = false } = {}) {
490
490
  desk('rules.modules', 'rules', modulesRoute.moduleDeskRows([{ key: 'discord', title: t('Discord'), enabled: true }, { key: 'economy', title: 'Economy', enabled: false }])),
491
491
  desk('treasury.backup', 'treasury', backupRoute.backupDeskRows({ age_hours: 30, stale: false }, false)),
492
492
  desk('people.invite', 'people', [accessRoute.INVITE_DESK_ROW]),
493
+ // task 1004561: a builder's monthly limit, and a sign-off undone
494
+ desk('treasury.budgets', 'treasury', buildersRoute.budgetDeskRows(
495
+ [{ id: 7, display_name: t('Ana Ruiz'), github_login: 'ana', monthly_budget_usd: '50' }, { id: 8, github_login: 'kai', monthly_budget_usd: null }],
496
+ )),
497
+ desk('review.signed', 'review', lifeDocket.signedDeskRows([
498
+ { id: 11, criterion_md: t('Refunds land'), satisfied: true, checked_at: '2026-10-02T10:00:00.000Z' },
499
+ { id: 12, criterion_md: 'Not yet', satisfied: false, checked_at: null },
500
+ ])),
493
501
  );
494
502
  return dk;
495
503
  }
496
504
 
505
+ // A docket whose decision table puts a building to a sitting (task 1004561).
506
+ function heldBy(dk, key, rule = 'majority') {
507
+ dk.buildings = dk.buildings.map((b) => (b.key === key ? { ...b, decision: { rule, sitting: true, who: 'the council' }, holder_count: 5 } : b));
508
+ return dk;
509
+ }
510
+
511
+ test('Treasury: each builder\'s monthly limit in one tap, the limit they have left off; no limit clears it', () => {
512
+ const rows = actedRows(moreDesks(), 'treasury', 3);
513
+ const ana = rows.find((r) => r.name === 'Ana Ruiz');
514
+ assert.equal(ana.fact, '$50 a month');
515
+ assert.deepEqual(ana.actions.map((a) => a.label), ['set limit']);
516
+ assert.deepEqual(ana.choices.budget.options.map((o) => o.key), ['25', '100', '250', 'none'], 'the current limit is not offered');
517
+ assert.deepEqual(j(C.request(ana.card, 'budget', ana.choices.budget.options[1].input)), { method: 'PATCH', url: '/api/bongos/builders/7/budget', body: { monthly_budget_usd: 100 } });
518
+ assert.deepEqual(j(C.request(ana.card, 'budget', ana.choices.budget.options[3].input)).body, { monthly_budget_usd: null });
519
+ const kai = rows.find((r) => r.name === 'kai');
520
+ assert.equal(kai.fact, 'no limit');
521
+ assert.ok(!kai.choices.budget.options.some((o) => o.key === 'none'), 'no limit is not offered to someone without one');
522
+ assert.equal(C.answer(ana.card, 'budget', {}).toast, 'Ana Ruiz has a new monthly limit.');
523
+ });
524
+
525
+ test('Review: a signed-off promise is undone in place, the reason one tap', () => {
526
+ const rows = actedRows(moreDesks(), 'review', 2);
527
+ assert.deepEqual(rows.map((r) => r.name), ['Refunds land'], 'only what is signed off');
528
+ const r = rows[0];
529
+ assert.equal(r.fact, 'signed off oct 2');
530
+ assert.deepEqual(r.actions.map((a) => a.label), ['undo']);
531
+ assert.ok(r.choices.unsign.options.every((o) => o.label.length <= D.LIMITS.chip));
532
+ assert.deepEqual(j(C.request(r.card, 'unsign', r.choices.unsign.options[0].input)), { method: 'POST', url: '/api/bongos/done-when/11/unsatisfy', body: { reason: 'it broke again' } });
533
+ assert.equal(C.answer(r.card, 'unsign', {}).kind, 'settle');
534
+ });
535
+
536
+ test('a building held by a sitting: its desk acts are filed as that building\'s matter, which acts once passed', () => {
537
+ const filed = (dk, key, floor, name, act, pick) => {
538
+ const r = actedRows(dk, key, floor).find((x) => x.name === name);
539
+ assert.ok(r, `${name} on ${key}`);
540
+ const input = pick == null ? {} : r.choices[act].options[pick].input;
541
+ return { req: j(C.request(r.card, act, input)), card: r.card };
542
+ };
543
+ const t = filed(heldBy(moreDesks(), 'treasury'), 'treasury', 3, 'Ana Ruiz', 'budget', 1);
544
+ assert.equal(t.req.url, '/api/bongos/government/board/matters');
545
+ assert.deepEqual(t.req.body.action, { type: 'set_budget', builder_id: 7, monthly_usd: 100 });
546
+ assert.equal(t.req.body.area, 'treasury');
547
+ assert.equal(C.answer(t.card, 'budget', {}).toast, 'Sent to the council. It is carried out when they pass it.');
548
+ const rv = filed(heldBy(moreDesks(), 'review'), 'review', 1, 'Refunds work', 'force', 0);
549
+ assert.deepEqual(rv.req.body, { area: 'review', title: 'Sign off: Refunds work', action: { type: 'satisfy_criterion', criterion_id: 8 } });
550
+ const au = heldBy(moreDesks(), 'automation');
551
+ const sw = actedRows(au, 'automation', 0).find((x) => x.actions.some((a) => a.key === 'switch_on'));
552
+ assert.deepEqual(j(C.request(sw.card, 'switch_on', {})).body.action, { type: 'switch_on' });
553
+ // where the building decides alone, the same tap is the direct route
554
+ const alone = filed(heldBy(moreDesks(), 'treasury', 'decide'), 'treasury', 3, 'Ana Ruiz', 'budget', 1);
555
+ assert.equal(alone.req.url, '/api/bongos/builders/7/budget');
556
+ const plain = filed(moreDesks(), 'treasury', 3, 'Ana Ruiz', 'budget', 1);
557
+ assert.equal(plain.req.url, '/api/bongos/builders/7/budget');
558
+ // the new desks keep the line rule while a sitting holds them
559
+ for (const key of ['treasury', 'review', 'automation']) {
560
+ const dk = heldBy(moreDesks({ longWords: true }), key);
561
+ I.PLAN[key].floors.forEach((_f, floor) => assert.deepEqual(D.breaks(D.deskModel({ key, floor, docket: dk, goals: GOALS, now: NOW, viewerId: '95' })), []));
562
+ }
563
+ });
564
+
497
565
  test('the second-pass desks keep the line rule on every floor', () => {
498
566
  for (const longWords of [false, true]) {
499
567
  for (const preflight of [null, { target: '1.21.20', ok: true }, { target: '1.21.20', ok: false, error: LONG }]) {
@@ -555,3 +623,175 @@ test('the Treasury desk reads the newest backup without blocking (no sync disk r
555
623
  const body = src.split('async function newestDumpAge')[1].split('\nfunction latestDump')[0];
556
624
  assert.doesNotMatch(body, /Sync\(/);
557
625
  });
626
+
627
+ // ── the Planning desks (task 1004560) ─────────────────────────────────────────
628
+ // Priorities, Ideas to ratify and Waiting on you, as on the Desks board, built
629
+ // from the REAL server mappers through the docket's own normalisers.
630
+ const ideasDocket = SRV('modules/ideas/governor-docket.js');
631
+ const ORDER_GOALS = [
632
+ { id: 31, title: 'Governor city', status: 'open', version_id: 'V2' }, { id: 32, title: 'Payments', status: 'open', version_id: 'V2' },
633
+ { id: 33, title: 'Onboarding', status: 'open', version_id: 'V2' }, { id: 34, title: 'Elsewhere', status: 'open', version_id: 'V9' },
634
+ ];
635
+ const ORDER_CRITERIA = [{ goal_id: 31, satisfied: true }, { goal_id: 31, satisfied: true }, { goal_id: 32, satisfied: true }, { goal_id: 32, satisfied: false }, { goal_id: 32, satisfied: false }];
636
+ function planningDocket({ longWords = false, sitting = null } = {}) {
637
+ const t = (s) => (longWords ? LONG + ' ' + s : s);
638
+ const goals = ORDER_GOALS.map((g) => ({ ...g, title: t(g.title) }));
639
+ const desk = (key, rows) => ({ key, building: 'planning', status: 'live', rows: rows.map((r) => DK.normalizeDeskRow(r, { desk: key, building: 'planning' })).filter(Boolean) });
640
+ const ideas = [
641
+ { id: 5, status: 'open', kind: 'criterion-proposal', title: t('Calm mode promise'), body_md: JSON.stringify({ target_version: 'V2', criterion_md: t('A quiet floor shows one line') }), created_at: '2026-09-28T09:00:00.000Z' },
642
+ { id: 6, status: 'open', kind: 'idea', grade: 'full', title: t('Objection goes back'), template_key: 'base-v2', field_values: { success: 'It comes back with its reason', scope: 'the return path' }, created_at: '2026-09-25T09:00:00.000Z' },
643
+ { id: 7, status: 'open', kind: 'idea', grade: 'full', title: 'Still sitting', template_key: 'base-v2', field_values: { success: 'x' } },
644
+ { id: 8, status: 'open', kind: 'idea', grade: 'full', title: t('No shape'), template_key: 'base-v0', field_values: {} },
645
+ ];
646
+ const blockers = [
647
+ { id: 12, title: t('Payment provider'), body_md: 'Which one?\n- Option A: Stripe — cheaper\n- **Option B:** Paddle (merchant of record)\n- Option C: Wait a month', created_at: '2026-09-30T10:00:00.000Z', blocking_task_ids: [1, 2, 3, 4] },
648
+ { id: 13, title: t('Hosting'), body_md: 'No options listed.', created_at: '2026-10-01T10:00:00.000Z', blocking_task_ids: [] },
649
+ ];
650
+ const matters = blockers.map((b) => DK.normalizeMatter(ideasDocket.blockerDocketMatter(b, true), { source: 'blockers', building: 'planning' })).filter(Boolean);
651
+ const over = {
652
+ desks: [
653
+ desk('planning.order', lifeDocket.orderDeskRows(goals, ORDER_CRITERIA, 'V2', true)),
654
+ desk('planning.ratify', ideasDocket.ratifyDeskRows(ideas, [{ id: 'V2', name: 'Version two' }, { id: 'V3' }], new Set(['6', '8']))),
655
+ ],
656
+ };
657
+ const dk = docket(matters, over);
658
+ if (sitting) dk.buildings.find((b) => b.key === 'planning').decision = sitting.decision, dk.buildings.find((b) => b.key === 'planning').holder_count = sitting.holders;
659
+ return dk;
660
+ }
661
+ const planDesk = (dk, floor, extra = {}) => D.deskModel({ key: 'planning', floor, docket: dk, goals: GOALS, now: NOW, viewerId: '95', ...extra });
662
+
663
+ test('Planning: the three desks are the first three floors, as on the Desks board', () => {
664
+ assert.deepEqual(I.PLAN.planning.floors.slice(0, 3).map((f) => f[0]), ['Priorities', 'Ideas to ratify', 'Waiting on you']);
665
+ assert.deepEqual(I.PLAN.planning.floors.slice(0, 3).map((f) => f[2].desk || f[2].source), ['planning.order', 'planning.ratify', 'blockers']);
666
+ });
667
+
668
+ test('Priorities: the version\'s open goals in order with promise pips; arrows move them; a save bar with undo sets the order', () => {
669
+ assert.deepEqual(lifeDocket.orderDeskRows(ORDER_GOALS, ORDER_CRITERIA, 'V2', false), [], 'no rows for a viewer who may not set the order');
670
+ assert.deepEqual(lifeDocket.orderDeskRows(ORDER_GOALS.slice(0, 1), [], 'V2', true), [], 'one goal has no order to set');
671
+ const dk = planningDocket();
672
+ const desk = planDesk(dk, 0);
673
+ assert.deepEqual(desk.rows.map((r) => [r.pos, r.name, r.fact]), [[1, 'Governor city', '2 of 2 promises kept'], [2, 'Payments', '1 of 3 promises kept'], [3, 'Onboarding', 'no promises yet']], 'only the building version\'s open goals, in their order');
674
+ assert.deepEqual(desk.rows[1].pips.map((p) => p.on), [true, false, false], 'one pip per promise, filled when kept');
675
+ assert.deepEqual(desk.rows[0].actions.map((a) => a.label), ['move up', 'move down']);
676
+ assert.equal(desk.status, '3 goals in order');
677
+ assert.equal(desk.hot, false);
678
+ assert.deepEqual(desk.tiles.map((t) => [t.v, t.l]), [['60%', 'promises kept'], ['5', 'on record']]);
679
+ assert.equal(desk.save, null, 'nothing to save until the order changes');
680
+ const keys = desk.rows.map((r) => r.key);
681
+ assert.deepEqual(D.moved(keys, keys[0], -1), keys, 'the top cannot move up');
682
+ const order = D.moved(keys, keys[2], -1);
683
+ const moved = planDesk(dk, 0, { order });
684
+ assert.deepEqual(moved.rows.map((r) => r.name), ['Governor city', 'Onboarding', 'Payments']);
685
+ assert.deepEqual(j(moved.save), { line: 'new order · saved for everyone', label: 'save order', council: false, said: 'Saved. Goals are worked in this order now.', set: 'V2', ids: ['31', '33', '32'], names: ['Governor city', 'Onboarding', 'Payments'] });
686
+ assert.deepEqual(j(C.orderRequest(moved.save)), { method: 'POST', url: '/api/bongos/goals/reorder', body: { version_id: 'V2', ordered_ids: [31, 33, 32] } });
687
+ assert.deepEqual(D.breaks(moved), []);
688
+ });
689
+
690
+ test('Priorities: where the decision table puts Planning to a sitting, the save files a Planning matter and says so', () => {
691
+ const majority = planningDocket({ sitting: { decision: { rule: 'majority', sitting: true, who: 'the council' }, holders: 5 } });
692
+ const keys = planDesk(majority, 0).rows.map((r) => r.key);
693
+ const save = planDesk(majority, 0, { order: D.moved(keys, keys[1], -1) }).save;
694
+ assert.equal(save.line, 'new order · goes to the council · 3 yes needed');
695
+ assert.equal(save.label, 'send to council');
696
+ const req = j(C.orderRequest(save));
697
+ assert.equal(req.url, '/api/bongos/government/board/matters');
698
+ assert.equal(req.body.area, 'planning');
699
+ assert.match(req.body.detail_md, /1\. Payments\n2\. Governor city\n3\. Onboarding$/);
700
+ const alone = planningDocket({ sitting: { decision: { rule: 'decide', sitting: true }, holders: 1 } });
701
+ assert.equal(planDesk(alone, 0, { order: D.moved(keys, keys[1], -1) }).save.council, false, 'a sitting that decides alone sets the order itself');
702
+ const consent = planningDocket({ sitting: { decision: { rule: 'consent', sitting: true }, holders: 4 } });
703
+ const cs = planDesk(consent, 0, { order: D.moved(keys, keys[1], -1) }).save;
704
+ assert.match(cs.line, /unless one objects/);
705
+ assert.equal(cs.said, 'The new order is with the council. It passes unless someone objects.', 'the toast is whole sentences');
706
+ assert.equal(save.said, 'The new order is with the council. It needs 3 yes.');
707
+ });
708
+
709
+ test('Ideas to ratify: a promise ratifies in one tap; a carried Full Idea becomes a goal by a version chip, or opens its page; a sitting idea stays the board\'s vote', () => {
710
+ const dk = planningDocket();
711
+ const rows = D.rowsFor('planning', 1, dk, { now: NOW, goals: GOALS });
712
+ const promise = rows.find((r) => r.name === 'Calm mode promise');
713
+ assert.deepEqual(promise.actions.map((a) => a.label), ['ratify', 'open']);
714
+ assert.deepEqual(j(C.request(promise.card, 'ratify')), { method: 'POST', url: '/api/bongos/inbox/5/ratify', body: { notes_md: 'Ratified on the floor desk.' } });
715
+ const full = rows.find((r) => r.name === 'Objection goes back');
716
+ assert.deepEqual(full.actions.map((a) => a.label), ['make goal', 'open']);
717
+ assert.deepEqual(full.choices.ratify_goal.options.map((o) => o.label), ['V2', 'V3'], 'the target version is a chip');
718
+ assert.deepEqual(j(C.request(full.card, 'ratify_goal', full.choices.ratify_goal.options[1].input)), { method: 'POST', url: '/api/bongos/inbox/6/ratify-goal', body: { target_version: 'V3', notes_md: 'Ratified on the floor desk.' } });
719
+ assert.ok(!rows.some((r) => r.name === 'Still sitting'), 'a Full Idea the board has not carried is not ratified from here');
720
+ const shapeless = rows.find((r) => r.name === 'No shape');
721
+ assert.deepEqual(shapeless.actions.map((a) => [a.key, a.go]), [['open', '/idea/8']], 'inputs that do not fit chips: a link');
722
+ assert.match(planDesk(dk, 1).status, /^3 to ratify · oldest/, 'counted in the noun of its desk');
723
+ // the board's vote row keeps its place and shows its seats as pips
724
+ const room = { open: [{ id: '900', subject_type: 'full_idea', subject: { title: 'Watch a goal' }, constitution: { pass_rule: 'majority' }, votes: [{ voter_id: '3', direction: 'yes' }, { voter_id: '4', direction: 'yes' }], tally: { open: 3 } }], closed: [] };
725
+ const withVote = docket(dk.matters.concat([matter({ id: 'board:900', source: 'board', short: 'idea to ratify', title: 'Watch a goal', state: 'needs' })]), { desks: dk.desks });
726
+ const vote = D.rowsFor('planning', 1, withVote, { now: NOW, room, viewerId: '95' }).find((r) => r.source === 'board');
727
+ assert.ok(vote.pips && vote.pips.filter((p) => p.on).length === 2, 'a filled pip per yes');
728
+ });
729
+
730
+ test('Waiting on you: a blocker is decided by one of its own options, or go ahead / not now / need more, written as the resolution note', () => {
731
+ assert.equal(ideasDocket.blockerDocketMatter({ id: 1, title: 'x', created_at: null }, false).acts, undefined, 'no act for a viewer who may not resolve');
732
+ const dk = planningDocket();
733
+ const rows = D.rowsFor('planning', 2, dk, { now: NOW });
734
+ const pay = rows.find((r) => r.name === 'Payment provider');
735
+ assert.deepEqual(pay.actions.map((a) => a.label), ['decide']);
736
+ assert.equal(pay.show, 'decide', 'its chips stand open: deciding is picking one');
737
+ assert.deepEqual(pay.choices.decide.options.map((o) => o.label), ['Stripe', 'Paddle', 'Wait a month']);
738
+ assert.deepEqual(j(C.request(pay.card, 'decide', pay.choices.decide.options[1].input)), { method: 'POST', url: '/api/bongos/blockers/12/resolve', body: { resolution_note: 'Decided on the floor desk: Paddle.' } });
739
+ const host = rows.find((r) => r.name === 'Hosting');
740
+ assert.deepEqual(host.choices.decide.options.map((o) => o.label), ['go ahead', 'not now', 'need more']);
741
+ const desk = planDesk(dk, 2);
742
+ assert.equal(desk.hot, true);
743
+ assert.match(desk.status, /^2 waiting · oldest/);
744
+ });
745
+
746
+ test('the Planning desks keep the line rule, long words or short, with a moved order and a sitting', () => {
747
+ for (const longWords of [false, true]) {
748
+ const dk = planningDocket({ longWords, sitting: { decision: { rule: 'unanimous', sitting: true }, holders: 12 } });
749
+ const keys = planDesk(dk, 0).rows.map((r) => r.key);
750
+ const desks = [0, 1, 2, 3, 4, 5].map((f) => planDesk(dk, f, f === 0 ? { order: keys.slice().reverse() } : {}));
751
+ assert.ok(desks[0].save, 'the moved order has its save bar');
752
+ assert.deepEqual(desks.flatMap((d) => D.breaks(d)), []);
753
+ }
754
+ });
755
+
756
+ test('the page: the arrows move a goal in place, undo puts it back, save sends the order', async () => {
757
+ const dk = planningDocket();
758
+ const p = await bootPage('#building=planning', dk.matters, { over: { desks: dk.desks } });
759
+ p.byId.get('insideFloors').children[0].fire('click');
760
+ const rowsEl = () => p.part('desk-rows').children;
761
+ const arrows = (i) => rowsEl()[i].children[0].children.find((c) => c.className === 'desk-acts').children;
762
+ assert.deepEqual(rowsEl().map((r) => r.children[0].children[0].textContent), ['1', '2', '3'], 'each goal shows its place');
763
+ assert.equal(p.part('desk-save'), undefined);
764
+ assert.equal(arrows(0)[0].attrs.disabled, '', 'the top goal cannot go up');
765
+ assert.equal(arrows(2)[1].attrs.disabled, '', 'the last goal cannot go down');
766
+ assert.equal(arrows(1)[0].attrs.disabled, undefined);
767
+ rowsEl()[0].children[0].children[2].fire('click');
768
+ assert.deepEqual(rowsEl()[0].children[1].children.map((f) => f.textContent), ['version V2'], 'a goal\'s facts open in place like any row');
769
+ rowsEl()[0].children[0].children[2].fire('click');
770
+ arrows(0)[1].fire('click');
771
+ assert.deepEqual(rowsEl().map((r) => r.children[0].children[2].children[0].textContent), ['Payments', 'Governor city', 'Onboarding']);
772
+ const bar = p.part('desk-save');
773
+ assert.deepEqual(bar.children.map((c) => c.textContent), ['new order · saved for everyone', 'undo', 'save order']);
774
+ bar.children[1].fire('click');
775
+ assert.equal(p.part('desk-save'), undefined, 'undo puts the order back');
776
+ arrows(2)[0].fire('click');
777
+ p.part('desk-save').children[2].fire('click');
778
+ for (let i = 0; i < 5; i++) await new Promise((r) => setTimeout(r, 0));
779
+ assert.deepEqual(p.sent, [{ method: 'POST', url: '/api/bongos/goals/reorder', body: { version_id: 'V2', ordered_ids: [31, 33, 32] } }]);
780
+ assert.match(p.byId.get('streetToast').textContent, /worked in this order/);
781
+ assert.equal(p.byId.get('streetTodayN').textContent, '1 decided');
782
+ });
783
+
784
+ test('the page: one tap on its option decides a blocker, its options standing open with no extra button', async () => {
785
+ const dk = planningDocket();
786
+ const p = await bootPage('#building=planning', dk.matters, { over: { desks: dk.desks } });
787
+ p.byId.get('insideFloors').children[2].fire('click');
788
+ const pay = p.part('desk-rows').children.find((r) => r.children[0].children[1].children[0].textContent === 'Payment provider');
789
+ assert.equal(pay.children[0].children.find((c) => c.className === 'desk-acts').children.length, 0, 'no decide button: the chip is the act');
790
+ const chips = pay.children[1];
791
+ assert.equal(chips.className, 'desk-choices');
792
+ assert.deepEqual(chips.children.map((c) => c.textContent), ['pick one', 'Stripe', 'Paddle', 'Wait a month']);
793
+ chips.children[2].fire('click');
794
+ for (let i = 0; i < 5; i++) await new Promise((r) => setTimeout(r, 0));
795
+ assert.deepEqual(p.sent, [{ method: 'POST', url: '/api/bongos/blockers/12/resolve', body: { resolution_note: 'Decided on the floor desk: Paddle.' } }]);
796
+ assert.match(p.byId.get('streetToast').textContent, /Decided/);
797
+ });
@@ -231,7 +231,7 @@ test('a floor bound to a reading shows its figure and line when live; Planning c
231
231
  assert.equal(I.civicInfo('treasury', { docket: d, now: NOW }).floors.find((f) => f.name === 'Reserve').count, '$1,100');
232
232
  const goals = [goal(1), goal(2), goal(3), goal(4), goal(5), goal(6, 'achieved')];
233
233
  const m = I.buildingModel({ key: 'planning', docket: d, goals, mode: 'dark', now: NOW });
234
- const g = I.civicInfo('planning', { docket: d, goals: goals.filter((x) => x.status === 'open'), now: NOW }).floors[0];
234
+ const g = I.civicInfo('planning', { docket: d, goals: goals.filter((x) => x.status === 'open'), now: NOW }).floors.find((f) => f.name === 'Goals');
235
235
  assert.deepEqual([g.name, g.count, g.line], ['Goals', '5', '5 goals open']);
236
236
  assert.equal(g.lines.length, 5, 'four goals and a line for the rest');
237
237
  assert.ok(m.texts.some((t) => t.v === '5' && t.s === 20), 'only open goals are counted on the wall');
@@ -311,9 +311,9 @@ test('a floor the docket cannot answer never shows a number; Goals falls back to
311
311
  for (const [n, st] of [['Ranks', 'not_yours'], ['The council', 'off'], ['Newcomers', 'unavailable'], ['Testers', 'unavailable']]) {
312
312
  assert.deepEqual([by[n].status, by[n].count], [st, ''], `${n} says ${st} and no number`);
313
313
  }
314
- const g = I.civicInfo('planning', { docket: docket(), goals: [goal(1), goal(2)], now: NOW }).floors[0];
314
+ const g = I.civicInfo('planning', { docket: docket(), goals: [goal(1), goal(2)], now: NOW }).floors.find((f) => f.name === 'Goals');
315
315
  assert.deepEqual([g.count, g.line], ['2', '2 goals open'], 'an older docket without floors still counts the open goals');
316
- const live = I.civicInfo('planning', { docket: docket([], { floors: [floorRow('planning.goals', { count: '1', line: '1 goal open', lines: [['Hall', '5 of 5 kept']] })] }), goals: [goal(1), goal(2)], now: NOW }).floors[0];
316
+ const live = I.civicInfo('planning', { docket: docket([], { floors: [floorRow('planning.goals', { count: '1', line: '1 goal open', lines: [['Hall', '5 of 5 kept']] })] }), goals: [goal(1), goal(2)], now: NOW }).floors.find((f) => f.name === 'Goals');
317
317
  assert.deepEqual([live.count, live.lines], ['1', [['Hall', '5 of 5 kept']]], 'with the floor, its promises kept of total');
318
318
  });
319
319