@warren-dean/code-kit 0.10.0 → 0.11.1

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.
@@ -1,6 +1,7 @@
1
- // Teams in the mod (docs/specs/16-teams.md, TEAM-7, TEAM-9), the pure parts: what of the team's state
2
- // needs this person, as band lines, and the Team tab. It reads `code-kit team --json` and
3
- // `code-kit team since --json`; the CLI and the hooks do the rest.
1
+ // Teams in the mod (docs/specs/16-teams.md, TEAM-7, TEAM-9, TEAM-13), the pure parts: what of the team's
2
+ // state needs this person, as band lines, and the Team pane. It reads `code-kit team --json` and
3
+ // `code-kit team since --json`; the CLI and the hooks do the rest. Whatever coordinates the team (GitHub,
4
+ // or a hosted provider) is only named as its state names itself.
4
5
  import { globsOverlap, laneOfBranch } from '../lib/teamfacts.mjs';
5
6
 
6
7
  const day = (iso) => {
@@ -38,6 +39,12 @@ export function teamLines(team, lanes = {}) {
38
39
  text: "team: the shared repository can't be reached, so claims aren't checked",
39
40
  actions: [],
40
41
  });
42
+ else if (team.fallback)
43
+ lines.push({
44
+ kind: 'teamDown',
45
+ text: "team: the team's provider can't be reached, so claims are checked through GitHub until it's back",
46
+ actions: [],
47
+ });
41
48
  const me = team.me;
42
49
  if (!me) return lines;
43
50
  const mine = team.claims.filter((c) => c.by === me);
@@ -83,6 +90,20 @@ export function teamLines(team, lanes = {}) {
83
90
  : [{ id: 'team', label: 'Team' }],
84
91
  });
85
92
  }
93
+ // A provider's drift check (the hub's): findings on this person's branches or claimed stories.
94
+ const myBranches = new Set([
95
+ ...team.pulls.filter((p) => p.by === me).map((p) => p.branch),
96
+ ...(team.sessions ?? []).filter((s) => s.login === me).map((s) => s.branch),
97
+ ]);
98
+ for (const f of (team.findings ?? []).filter(
99
+ (x) => x.severity !== 'note' && (x.by === me || myBranches.has(x.branch)),
100
+ ))
101
+ lines.push({
102
+ kind: 'teamDrift',
103
+ text: `drift on ${f.branch}: ${f.text}`,
104
+ finding: f,
105
+ actions: [{ id: 'team', label: 'Team' }],
106
+ });
86
107
  for (const p of team.pulls.filter((x) => x.by !== me && !x.draft)) {
87
108
  const lane = laneOfBranch(p.branch);
88
109
  if (lane && team.owners?.[lane]?.includes(me))
@@ -104,113 +125,386 @@ export function peopleOf(team) {
104
125
  ...team.claims.map((c) => c.by),
105
126
  ...team.intents.map((i) => i.by),
106
127
  ...team.pulls.map((p) => p.by),
128
+ ...(team.sessions ?? []).map((s) => s.login),
107
129
  team.me,
108
130
  ].filter(Boolean);
109
131
  return [...new Set(named)];
110
132
  }
111
133
 
134
+ export const TEAM_ID = 'code-kit-team';
135
+
136
+ /** TEAM-7: the Team pane's tabs. Drift is there only when the provider checks for it. */
137
+ export const TEAM_TABS = [
138
+ { id: 'people', label: 'People', hotkey: '1' },
139
+ { id: 'activity', label: 'Since you looked', hotkey: '2' },
140
+ { id: 'claims', label: 'Claims', hotkey: '3' },
141
+ { id: 'intents', label: 'Intents', hotkey: '4' },
142
+ { id: 'drift', label: 'Drift', hotkey: '5' },
143
+ { id: 'setup', label: 'Setup', hotkey: '6' },
144
+ ];
145
+
146
+ export const DEFAULT_TEAM_UI = { tab: 'people', reassign: null, handle: '' };
147
+
148
+ const clock = (iso) =>
149
+ new Date(iso).toLocaleTimeString('en-AU', {
150
+ hour: '2-digit',
151
+ minute: '2-digit',
152
+ timeZone: 'UTC',
153
+ });
154
+
155
+ /** What coordinates the team, and how it's doing, in a line: the provider's own name for itself. */
156
+ export function providerLine(team) {
157
+ const p = team.provider ?? null;
158
+ const name = p?.name ?? 'the team provider';
159
+ if (team.reachable === false)
160
+ return {
161
+ text: `${name} can't be reached${team.why ? ` (${team.why})` : ''}: claims and leases aren't checked until it can be.`,
162
+ color: 'yellow',
163
+ };
164
+ if (team.fallback)
165
+ return {
166
+ text: `${name} can't be reached (${team.fallback}): claims are checked through GitHub until it's back.`,
167
+ color: 'yellow',
168
+ };
169
+ const parts = [
170
+ `Through ${name}`,
171
+ p?.live ? 'live' : null,
172
+ p?.account ? `signed in as ${p.account}` : null,
173
+ ].filter(Boolean);
174
+ return {
175
+ text: `${parts.join(' · ')}${p?.detail ? `: ${p.detail}` : ''}`,
176
+ color: p?.live ? 'green' : null,
177
+ };
178
+ }
179
+
112
180
  /**
113
- * TEAM-6, TEAM-7: the Team tab: since you last looked, then each person with the lanes they own, their
114
- * claims (stale ones marked, with Keep and Release for yours), their pull requests and their intents
115
- * (Agree and Drop for the tech lead). `on.onNeed(id, line)` acts as the band's buttons do.
181
+ * TEAM-6, TEAM-7, TEAM-13: the Team pane. `view` is { team, since, config, ui }: `team` is
182
+ * `code-kit team --json`, `since` is `team since --json` (read when that tab opens), `config` the team
183
+ * section and lanes as `code-kit check --json` reports them. `on` acts: onTab(id), onNeed(id, line) as
184
+ * the band's buttons do, onReassign(story | null), onHandle(text), onReassignTo(story, handle) and
185
+ * onSetup() (asks the lead to run the team's init).
116
186
  */
117
- export function teamTab(team, since, { Box, Text, Button }, on) {
187
+ export function teamFrame(view, els, on) {
188
+ const { Box, Text, Button, Input } = els;
118
189
  const text = (value, style = {}) => Text({ ...style, children: [value] });
119
- if (!team) return [];
120
- const rows = [];
121
- if (team.reachable === false)
122
- rows.push(
123
- text(
124
- "The shared repository can't be reached: claims and leases aren't checked until it can be.",
125
- {
126
- color: 'yellow',
127
- },
128
- ),
129
- );
130
- const sincePeople = Object.entries(since?.people ?? {});
131
- rows.push(text('Since you last looked', { bold: true }));
132
- rows.push(
133
- Box({
134
- key: 'team-since',
190
+ const { team, since = null, config = null, ui = DEFAULT_TEAM_UI } = view;
191
+ if (!team)
192
+ return Box({
135
193
  flexDirection: 'column',
136
- children: sincePeople.length
137
- ? sincePeople.map(([who, events]) =>
194
+ rowGap: 1,
195
+ children: [
196
+ text("The team's state couldn't be read: run code-kit team to see why.", {
197
+ color: 'yellow',
198
+ }),
199
+ Button({
200
+ key: 'team-setup',
201
+ label: 'Set up the team',
202
+ variant: 'primary',
203
+ onPress: () => on.onSetup(),
204
+ }),
205
+ ],
206
+ });
207
+ const me = team.me;
208
+ const lead = team.techLead;
209
+ const drifts = Array.isArray(team.findings);
210
+ const tabs = TEAM_TABS.filter((t) => t.id !== 'drift' || drifts);
211
+ const tab = tabs.some((t) => t.id === ui.tab) ? ui.tab : 'people';
212
+ const myLanes = Object.entries(team.owners ?? {})
213
+ .filter(([, people]) => people.includes(me))
214
+ .map(([lane]) => lane);
215
+ const provider = providerLine(team);
216
+ const setup = team.setup ?? [];
217
+ const header = Box({
218
+ key: 'team-header',
219
+ flexDirection: 'column',
220
+ children: [
221
+ Box({
222
+ flexDirection: 'row',
223
+ columnGap: 2,
224
+ flexWrap: 'wrap',
225
+ children: [
226
+ text(`tech lead ${lead}`, { bold: true }),
227
+ text(
228
+ me
229
+ ? `you are ${me}${me === lead ? ', the tech lead' : ''}${myLanes.length ? ` · own ${myLanes.join(', ')}` : ''}`
230
+ : "you aren't signed in to GitHub (gh auth login)",
231
+ me ? { dimColor: true } : { color: 'yellow' },
232
+ ),
233
+ text(
234
+ `${team.claims.length} claimed · ${team.intents.length} intent${team.intents.length === 1 ? '' : 's'} open · ${team.pulls.length} pull request${team.pulls.length === 1 ? '' : 's'}`,
235
+ { dimColor: true },
236
+ ),
237
+ ],
238
+ }),
239
+ Box({
240
+ key: 'team-provider',
241
+ children: [
242
+ text(provider.text, {
243
+ wrap: 'wrap',
244
+ ...(provider.color ? { color: provider.color } : { dimColor: true }),
245
+ }),
246
+ ],
247
+ }),
248
+ ...(setup.length
249
+ ? [
138
250
  Box({
139
- key: `since-${who}`,
251
+ key: 'team-setup-count',
140
252
  children: [
141
253
  text(
142
- ` ${who}${who === team.me ? ' (you)' : ''}: ${events.map((e) => e.text).join('; ')}`,
143
- {
144
- wrap: 'wrap',
145
- },
254
+ `${setup.length} thing${setup.length === 1 ? '' : 's'} to set up: see Setup (6).`,
255
+ { color: 'yellow' },
146
256
  ),
147
257
  ],
148
258
  }),
149
- )
150
- : [text(' Nothing new.', { dimColor: true })],
151
- }),
152
- );
153
- for (const who of peopleOf(team)) {
259
+ ]
260
+ : []),
261
+ ],
262
+ });
263
+ const tabRow = Box({
264
+ key: 'team-tabs',
265
+ flexDirection: 'row',
266
+ columnGap: 1,
267
+ children: tabs.map((t) =>
268
+ Button({
269
+ key: `team-tab-${t.id}`,
270
+ label: t.label,
271
+ hotkey: t.hotkey,
272
+ ...(tab === t.id ? { variant: 'primary' } : { dimColor: true }),
273
+ onPress: () => on.onTab(t.id),
274
+ }),
275
+ ),
276
+ });
277
+ const body =
278
+ tab === 'activity'
279
+ ? activityTab(team, since, els)
280
+ : tab === 'claims'
281
+ ? claimsTab(team, ui, els, on)
282
+ : tab === 'intents'
283
+ ? intentsTab(team, els, on)
284
+ : tab === 'drift'
285
+ ? driftTab(team, els)
286
+ : tab === 'setup'
287
+ ? setupTab(team, config, els, on)
288
+ : peopleTab(team, els, on);
289
+ return Box({ flexDirection: 'column', rowGap: 1, children: [header, tabRow, ...body] });
290
+ }
291
+
292
+ /** Each person, the tech lead first: where they're at work, their claims, pull requests and intents. */
293
+ function peopleTab(team, { Box, Text }, on) {
294
+ const text = (value, style = {}) => Text({ ...style, children: [value] });
295
+ return peopleOf(team).map((who) => {
154
296
  const lanes = Object.entries(team.owners ?? {})
155
297
  .filter(([, people]) => people.includes(who))
156
298
  .map(([lane]) => lane);
157
- const claims = team.claims.filter((c) => c.by === who);
158
- const pulls = team.pulls.filter((p) => p.by === who);
159
- const intents = team.intents.filter((i) => i.by === who);
160
299
  const role = [
161
300
  who === team.techLead ? 'tech lead' : null,
162
301
  who === team.me ? 'you' : null,
163
302
  lanes.length ? `owns ${lanes.join(', ')}` : null,
164
303
  ].filter(Boolean);
304
+ const line = (key, value, style = {}) =>
305
+ Box({ key, children: [text(` ${value}`, { wrap: 'truncate-end', ...style })] });
165
306
  const items = [
166
- ...claims.map((c) =>
307
+ // A live provider's presence: where each of their sessions is, and what its agents are doing.
308
+ ...(team.sessions ?? [])
309
+ .filter((x) => x.login === who)
310
+ .map((x, n) =>
311
+ line(
312
+ `team-session-${who}-${n}`,
313
+ x.away
314
+ ? `away (last seen ${clock(x.seen)})`
315
+ : `at work on ${x.branch ?? 'no branch'}${x.agents.length ? `: ${x.agents.map((a) => `${a.type ?? 'an agent'}${a.story ? ` on ${a.story}` : ''}${a.tool ? ` (${a.tool})` : ''}`).join(', ')}` : ''}`,
316
+ x.away ? { dimColor: true } : { color: 'green' },
317
+ ),
318
+ ),
319
+ ...(team.findings ?? [])
320
+ .filter((f) => f.by === who)
321
+ .map((f) =>
322
+ line(`team-finding-${f.id}`, `drift on ${f.branch} (${f.severity}): ${f.text}`, {
323
+ wrap: 'wrap',
324
+ color: f.severity === 'blocker' ? 'red' : f.severity === 'note' ? undefined : 'yellow',
325
+ }),
326
+ ),
327
+ ...team.claims
328
+ .filter((c) => c.by === who)
329
+ .map((c) =>
330
+ line(
331
+ `team-claim-${c.story}`,
332
+ `${c.story}${c.stale ? ` · stale since ${day(c.active)}` : ''}${c.lease.length ? ` · ${c.lease.join(', ')}` : ''}`,
333
+ c.stale ? { color: 'yellow' } : {},
334
+ ),
335
+ ),
336
+ ...team.pulls
337
+ .filter((p) => p.by === who)
338
+ .map((p) =>
339
+ line(
340
+ `team-pull-${p.number}`,
341
+ `#${p.number} ${p.title} (${p.branch}${p.draft ? ', draft' : ''})`,
342
+ ),
343
+ ),
344
+ ...team.intents
345
+ .filter((i) => i.by === who)
346
+ .map((i) =>
347
+ line(
348
+ `team-intent-${i.id}`,
349
+ `intends: ${i.about}${i.touches.length ? ` · ${i.touches.join(', ')}` : ''}`,
350
+ {
351
+ color: 'cyan',
352
+ },
353
+ ),
354
+ ),
355
+ ];
356
+ return Box({
357
+ key: `team-person-${who}`,
358
+ flexDirection: 'column',
359
+ children: [
360
+ text(`${who}${role.length ? ` · ${role.join(' · ')}` : ''}`, { bold: true }),
361
+ ...(items.length ? items : [text(' nothing open', { dimColor: true })]),
362
+ ],
363
+ });
364
+ });
365
+ }
366
+
367
+ /** TEAM-6: what everyone did since you last looked; opening the tab marks it read. */
368
+ function activityTab(team, since, { Box, Text }) {
369
+ const text = (value, style = {}) => Text({ ...style, children: [value] });
370
+ const people = Object.entries(since?.people ?? {});
371
+ return [
372
+ Box({
373
+ key: 'team-since',
374
+ flexDirection: 'column',
375
+ children: [
376
+ text(
377
+ since?.since
378
+ ? `Since ${new Date(since.since).toLocaleString('en-AU', { timeZone: 'UTC' })}`
379
+ : 'Since you last looked',
380
+ { bold: true },
381
+ ),
382
+ ...(since?.github === false
383
+ ? [text("GitHub's activity couldn't be read: claims only.", { color: 'yellow' })]
384
+ : []),
385
+ ...(people.length
386
+ ? people.map(([who, events]) =>
387
+ Box({
388
+ key: `since-${who}`,
389
+ flexDirection: 'column',
390
+ children: [
391
+ text(`${who}${who === team.me ? ' (you)' : ''}`, { bold: true }),
392
+ ...events.map((e, n) =>
393
+ Box({
394
+ key: `since-${who}-${n}`,
395
+ children: [text(` ${e.text}`, { wrap: 'wrap' })],
396
+ }),
397
+ ),
398
+ ],
399
+ }),
400
+ )
401
+ : [text('Nothing new.', { dimColor: true })]),
402
+ ],
403
+ }),
404
+ ];
405
+ }
406
+
407
+ /**
408
+ * TEAM-9: every claim, with Keep and Release on yours, and for the tech lead Release and Reassign on
409
+ * anyone's. Reassign asks for the new holder's handle.
410
+ */
411
+ function claimsTab(team, ui, { Box, Text, Button, Input }, on) {
412
+ const text = (value, style = {}) => Text({ ...style, children: [value] });
413
+ if (!team.claims.length) return [text('Nothing is claimed.', { dimColor: true })];
414
+ const leading = team.me && team.me === team.techLead;
415
+ return team.claims.map((c) => {
416
+ const mine = c.by === team.me;
417
+ const line = { kind: 'teamStale', claim: c };
418
+ return Box({
419
+ key: `team-claims-${c.story}`,
420
+ flexDirection: 'column',
421
+ children: [
167
422
  Box({
168
- key: `team-claim-${c.story}`,
169
423
  flexDirection: 'row',
170
424
  columnGap: 1,
171
425
  children: [
426
+ text(c.story, { bold: true }),
427
+ text(`${c.by}${mine ? ' (you)' : ''}`),
172
428
  text(
173
- ` ${c.story}${c.stale ? ` · stale since ${day(c.active)}` : ''}${c.lease.length ? ` · ${c.lease.join(', ')}` : ''}`,
174
- { wrap: 'truncate-end', ...(c.stale ? { color: 'yellow' } : {}) },
429
+ c.stale ? `stale since ${day(c.active)}` : `since ${day(c.at)}`,
430
+ c.stale ? { color: 'yellow' } : { dimColor: true },
175
431
  ),
176
- ...(who === team.me && c.stale
432
+ ...(mine
177
433
  ? [
178
434
  Button({
179
435
  key: `team-keep-${c.story}`,
180
436
  label: 'Keep',
181
- onPress: () => on.onNeed('keepClaim', { kind: 'teamStale', claim: c }),
437
+ onPress: () => on.onNeed('keepClaim', line),
182
438
  }),
439
+ ]
440
+ : []),
441
+ ...(mine || leading
442
+ ? [
183
443
  Button({
184
444
  key: `team-release-${c.story}`,
185
445
  label: 'Release',
186
- onPress: () => on.onNeed('releaseClaim', { kind: 'teamStale', claim: c }),
446
+ onPress: () => on.onNeed('releaseClaim', line),
447
+ }),
448
+ ]
449
+ : []),
450
+ ...(leading
451
+ ? [
452
+ Button({
453
+ key: `team-reassign-${c.story}`,
454
+ label: 'Reassign',
455
+ onPress: () => on.onReassign(ui.reassign === c.story ? null : c.story),
187
456
  }),
188
457
  ]
189
458
  : []),
190
459
  ],
191
460
  }),
192
- ),
193
- ...pulls.map((p) =>
194
- Box({
195
- key: `team-pull-${p.number}`,
196
- children: [
197
- text(` #${p.number} ${p.title} (${p.branch}${p.draft ? ', draft' : ''})`, {
198
- wrap: 'truncate-end',
199
- }),
200
- ],
461
+ text(` ${c.lease.length ? `leases ${c.lease.join(', ')}` : 'no lease'}`, {
462
+ dimColor: true,
463
+ wrap: 'truncate-end',
201
464
  }),
465
+ ...(leading && ui.reassign === c.story
466
+ ? [
467
+ Input({
468
+ key: `team-reassign-to-${c.story}`,
469
+ label: `Give ${c.story} to`,
470
+ value: ui.handle ?? '',
471
+ placeholder: 'their GitHub handle',
472
+ submitLabel: 'Reassign',
473
+ autoFocus: true,
474
+ onInput: (v) => on.onHandle(v),
475
+ onSubmit: (v) => on.onReassignTo(c.story, v),
476
+ }),
477
+ ]
478
+ : []),
479
+ ],
480
+ });
481
+ });
482
+ }
483
+
484
+ /** TEAM-5: the open intents, with Agree and Drop for the tech lead. */
485
+ function intentsTab(team, { Box, Text, Button }, on) {
486
+ const text = (value, style = {}) => Text({ ...style, children: [value] });
487
+ if (!team.intents.length)
488
+ return [
489
+ text(
490
+ 'No open intents. An agent raises one (code-kit intend) before changing what others rely on.',
491
+ { dimColor: true },
202
492
  ),
203
- ...intents.map((i) =>
493
+ ];
494
+ const leading = team.me && team.me === team.techLead;
495
+ return team.intents.map((i) =>
496
+ Box({
497
+ key: `team-intents-${i.id}`,
498
+ flexDirection: 'column',
499
+ children: [
204
500
  Box({
205
- key: `team-intent-${i.id}`,
206
501
  flexDirection: 'row',
207
502
  columnGap: 1,
208
503
  children: [
209
- text(` intends: ${i.about}${i.touches.length ? ` · ${i.touches.join(', ')}` : ''}`, {
210
- wrap: 'truncate-end',
211
- color: 'cyan',
212
- }),
213
- ...(team.me === team.techLead && i.by !== team.me
504
+ text(`#${i.id}`, { bold: true }),
505
+ text(i.about, { color: 'cyan', wrap: 'truncate-end' }),
506
+ text(`${i.by}${i.by === team.me ? ' (you)' : ''}`, { dimColor: true }),
507
+ ...(leading && i.by !== team.me
214
508
  ? [
215
509
  Button({
216
510
  key: `team-agree-${i.id}`,
@@ -226,18 +520,119 @@ export function teamTab(team, since, { Box, Text, Button }, on) {
226
520
  : []),
227
521
  ],
228
522
  }),
523
+ text(
524
+ ` ${i.touches.length ? `touches ${i.touches.join(', ')}` : 'touches nothing named'}${i.url ? ` · ${i.url}` : ''}`,
525
+ {
526
+ dimColor: true,
527
+ wrap: 'truncate-end',
528
+ },
529
+ ),
530
+ ],
531
+ }),
532
+ );
533
+ }
534
+
535
+ /** A provider's drift checks (the hub's): each branch's findings, worst first. */
536
+ function driftTab(team, { Box, Text }) {
537
+ const text = (value, style = {}) => Text({ ...style, children: [value] });
538
+ const rank = { blocker: 0, concern: 1, note: 2 };
539
+ const findings = [...team.findings].sort((a, b) => rank[a.severity] - rank[b.severity]);
540
+ if (!findings.length)
541
+ return [text('No drift found on the branches at work.', { dimColor: true })];
542
+ const branches = [...new Set(findings.map((f) => f.branch))];
543
+ return branches.map((branch) =>
544
+ Box({
545
+ key: `team-drift-${branch}`,
546
+ flexDirection: 'column',
547
+ children: [
548
+ text(
549
+ `${branch}${findings.find((f) => f.branch === branch)?.by ? ` · ${findings.find((f) => f.branch === branch).by}` : ''}`,
550
+ { bold: true },
551
+ ),
552
+ ...findings
553
+ .filter((f) => f.branch === branch)
554
+ .map((f) =>
555
+ Box({
556
+ key: `team-drift-${f.id}`,
557
+ children: [
558
+ text(` ${f.severity} · ${f.conflictsWith}: ${f.text}`, {
559
+ wrap: 'wrap',
560
+ color:
561
+ f.severity === 'blocker'
562
+ ? 'red'
563
+ : f.severity === 'concern'
564
+ ? 'yellow'
565
+ : undefined,
566
+ dimColor: f.severity === 'note',
567
+ }),
568
+ ],
569
+ }),
570
+ ),
571
+ ],
572
+ }),
573
+ );
574
+ }
575
+
576
+ /** TEAM-13: the team's setup as the config has it, what's still missing, and a way to change it. */
577
+ function setupTab(team, config, { Box, Text, Button }, on) {
578
+ const text = (value, style = {}) => Text({ ...style, children: [value] });
579
+ const t = config ?? {};
580
+ const owners = Object.entries(team.owners ?? {});
581
+ const unowned = Object.keys(config?.lanes ?? {}).filter((l) => !(team.owners ?? {})[l]);
582
+ const rows = [
583
+ ['Tech lead', team.techLead],
584
+ ['Stale after', `${team.staleDays ?? t.staleDays ?? 3} working days without a push`],
585
+ ['Intents', `discussions in "${t.intents ?? 'Intents'}"`],
586
+ [
587
+ 'Provider',
588
+ team.provider?.name ?? (typeof t.provider === 'object' ? t.provider.command : 'GitHub'),
589
+ ],
590
+ ...(typeof t.provider === 'object' && t.provider.fallback
591
+ ? [['Fallback', 'GitHub, while the provider is down']]
592
+ : []),
593
+ ...(t.board ? [['Board', `${t.board.owner}/${t.board.number}`]] : []),
594
+ ...owners.map(([lane, people]) => [`Lane ${lane}`, people.join(', ')]),
595
+ ...(unowned.length
596
+ ? [['Unowned lanes', `${unowned.join(', ')}: whoever claims a story first`]]
597
+ : []),
598
+ ];
599
+ const setup = team.setup ?? [];
600
+ return [
601
+ Box({
602
+ key: 'team-config',
603
+ flexDirection: 'column',
604
+ children: rows.map(([k, v]) =>
605
+ Box({
606
+ key: `team-config-${k}`,
607
+ flexDirection: 'row',
608
+ columnGap: 1,
609
+ children: [text(k.padEnd(14), { dimColor: true }), text(v, { wrap: 'truncate-end' })],
610
+ }),
229
611
  ),
230
- ];
231
- rows.push(
232
- Box({
233
- key: `team-person-${who}`,
234
- flexDirection: 'column',
235
- children: [
236
- text(`${who}${role.length ? ` · ${role.join(' · ')}` : ''}`, { bold: true }),
237
- ...(items.length ? items : [text(' nothing open', { dimColor: true })]),
238
- ],
239
- }),
240
- );
241
- }
242
- return rows;
612
+ }),
613
+ Box({
614
+ key: 'team-todo',
615
+ flexDirection: 'column',
616
+ children: setup.length
617
+ ? [
618
+ text('Still to set up', { bold: true }),
619
+ ...setup.map((s, n) =>
620
+ Box({
621
+ key: `team-todo-${n}`,
622
+ children: [text(` ${s}`, { wrap: 'wrap', color: 'yellow' })],
623
+ }),
624
+ ),
625
+ ]
626
+ : [
627
+ text('Nothing missing: claims and intents work for everyone signed in.', {
628
+ color: 'green',
629
+ }),
630
+ ],
631
+ }),
632
+ Button({
633
+ key: 'team-setup',
634
+ label: 'Change the team setup',
635
+ onPress: () => on.onSetup(),
636
+ }),
637
+ ];
243
638
  }