@skillcdn/console 0.3.2 → 0.4.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.
Files changed (179) hide show
  1. package/README.md +4 -3
  2. package/dist/api.d.ts +2 -2
  3. package/dist/api.d.ts.map +1 -1
  4. package/dist/api.js +2 -2
  5. package/dist/api.js.map +1 -1
  6. package/dist/cli/cli.d.ts.map +1 -1
  7. package/dist/cli/cli.js +132 -18
  8. package/dist/cli/cli.js.map +1 -1
  9. package/dist/cli/format.d.ts.map +1 -1
  10. package/dist/cli/format.js +6 -4
  11. package/dist/cli/format.js.map +1 -1
  12. package/dist/cli/help.d.ts.map +1 -1
  13. package/dist/cli/help.js +72 -21
  14. package/dist/cli/help.js.map +1 -1
  15. package/dist/cli/pack.d.ts +7 -2
  16. package/dist/cli/pack.d.ts.map +1 -1
  17. package/dist/cli/pack.js +16 -2
  18. package/dist/cli/pack.js.map +1 -1
  19. package/dist/client.d.ts +19 -3
  20. package/dist/client.d.ts.map +1 -1
  21. package/dist/client.js +11 -1
  22. package/dist/client.js.map +1 -1
  23. package/dist/compat.d.ts +1 -1
  24. package/dist/compat.js +2 -2
  25. package/dist/components/board.d.ts +12 -1
  26. package/dist/components/board.d.ts.map +1 -1
  27. package/dist/components/board.js +52 -4
  28. package/dist/components/board.js.map +1 -1
  29. package/dist/components/calendar.d.ts +4 -0
  30. package/dist/components/calendar.d.ts.map +1 -1
  31. package/dist/components/calendar.js +61 -22
  32. package/dist/components/calendar.js.map +1 -1
  33. package/dist/components/decision-form.d.ts +12 -4
  34. package/dist/components/decision-form.d.ts.map +1 -1
  35. package/dist/components/decision-form.js +24 -6
  36. package/dist/components/decision-form.js.map +1 -1
  37. package/dist/components/decision-list.d.ts +4 -0
  38. package/dist/components/decision-list.d.ts.map +1 -1
  39. package/dist/components/decision-list.js +9 -6
  40. package/dist/components/decision-list.js.map +1 -1
  41. package/dist/components/documents.d.ts.map +1 -1
  42. package/dist/components/documents.js +3 -3
  43. package/dist/components/documents.js.map +1 -1
  44. package/dist/components/event-feed.d.ts.map +1 -1
  45. package/dist/components/event-feed.js +63 -8
  46. package/dist/components/event-feed.js.map +1 -1
  47. package/dist/components/markdown.d.ts.map +1 -1
  48. package/dist/components/markdown.js +6 -3
  49. package/dist/components/markdown.js.map +1 -1
  50. package/dist/components/my-decisions.d.ts +20 -0
  51. package/dist/components/my-decisions.d.ts.map +1 -0
  52. package/dist/components/my-decisions.js +36 -0
  53. package/dist/components/my-decisions.js.map +1 -0
  54. package/dist/components/pack-connect.d.ts +3 -1
  55. package/dist/components/pack-connect.d.ts.map +1 -1
  56. package/dist/components/pack-connect.js +4 -4
  57. package/dist/components/pack-connect.js.map +1 -1
  58. package/dist/components/pack-token.d.ts +3 -1
  59. package/dist/components/pack-token.d.ts.map +1 -1
  60. package/dist/components/pack-token.js +1 -1
  61. package/dist/components/pack-token.js.map +1 -1
  62. package/dist/components/people.d.ts.map +1 -1
  63. package/dist/components/people.js +14 -3
  64. package/dist/components/people.js.map +1 -1
  65. package/dist/components/projects.d.ts.map +1 -1
  66. package/dist/components/projects.js +3 -3
  67. package/dist/components/projects.js.map +1 -1
  68. package/dist/components/remote-pictures.d.ts +6 -0
  69. package/dist/components/remote-pictures.d.ts.map +1 -0
  70. package/dist/components/remote-pictures.js +8 -0
  71. package/dist/components/remote-pictures.js.map +1 -0
  72. package/dist/components/roadmap.js +1 -1
  73. package/dist/components/roadmap.js.map +1 -1
  74. package/dist/components/runs.d.ts.map +1 -1
  75. package/dist/components/runs.js +1 -1
  76. package/dist/components/runs.js.map +1 -1
  77. package/dist/components/shell.d.ts.map +1 -1
  78. package/dist/components/shell.js +36 -1
  79. package/dist/components/shell.js.map +1 -1
  80. package/dist/components/sign-in.d.ts.map +1 -1
  81. package/dist/components/sign-in.js +49 -2
  82. package/dist/components/sign-in.js.map +1 -1
  83. package/dist/components/task-list.js +1 -1
  84. package/dist/components/task-list.js.map +1 -1
  85. package/dist/components/task-view.d.ts.map +1 -1
  86. package/dist/components/task-view.js +2 -2
  87. package/dist/components/task-view.js.map +1 -1
  88. package/dist/components/ui.d.ts +13 -2
  89. package/dist/components/ui.d.ts.map +1 -1
  90. package/dist/components/ui.js +21 -14
  91. package/dist/components/ui.js.map +1 -1
  92. package/dist/console.css +1686 -425
  93. package/dist/console.d.ts.map +1 -1
  94. package/dist/console.js +83 -13
  95. package/dist/console.js.map +1 -1
  96. package/dist/data.d.ts +15 -2
  97. package/dist/data.d.ts.map +1 -1
  98. package/dist/data.js +18 -19
  99. package/dist/data.js.map +1 -1
  100. package/dist/i18n/en.d.ts +48 -1
  101. package/dist/i18n/en.d.ts.map +1 -1
  102. package/dist/i18n/en.js +50 -3
  103. package/dist/i18n/en.js.map +1 -1
  104. package/dist/i18n/ko.d.ts.map +1 -1
  105. package/dist/i18n/ko.js +47 -4
  106. package/dist/i18n/ko.js.map +1 -1
  107. package/dist/index.d.ts +3 -2
  108. package/dist/index.d.ts.map +1 -1
  109. package/dist/index.js +3 -2
  110. package/dist/index.js.map +1 -1
  111. package/dist/limits.d.ts +14 -0
  112. package/dist/limits.d.ts.map +1 -1
  113. package/dist/limits.js +14 -0
  114. package/dist/limits.js.map +1 -1
  115. package/dist/router.d.ts +9 -1
  116. package/dist/router.d.ts.map +1 -1
  117. package/dist/router.js +12 -0
  118. package/dist/router.js.map +1 -1
  119. package/dist/routes.d.ts +5 -1
  120. package/dist/routes.d.ts.map +1 -1
  121. package/dist/routes.js +5 -1
  122. package/dist/routes.js.map +1 -1
  123. package/dist/rules.js +1 -1
  124. package/dist/rules.js.map +1 -1
  125. package/dist/schemas.d.ts +272 -4
  126. package/dist/schemas.d.ts.map +1 -1
  127. package/dist/schemas.js +33 -8
  128. package/dist/schemas.js.map +1 -1
  129. package/dist/web.d.ts.map +1 -1
  130. package/dist/web.js +3 -2
  131. package/dist/web.js.map +1 -1
  132. package/docs/specs/cli.md +11 -10
  133. package/docs/specs/rest.md +33 -17
  134. package/package.json +2 -2
  135. package/skills/building-a-console/SKILL.md +5 -5
  136. package/skills/building-a-console/references/the-default-console.md +4 -4
  137. package/skills/building-a-console/references/the-look-from-a-website.md +5 -5
  138. package/skills/working-the-board/SKILL.md +6 -4
  139. package/skills/working-the-board/references/setting-up.md +1 -1
  140. package/skills/working-the-board/references/terms.md +1 -1
  141. package/src/api.ts +10 -0
  142. package/src/cli/cli.ts +162 -19
  143. package/src/cli/format.ts +11 -4
  144. package/src/cli/help.ts +79 -20
  145. package/src/cli/pack.ts +17 -1
  146. package/src/client.ts +51 -2
  147. package/src/compat.ts +2 -2
  148. package/src/components/board.tsx +114 -21
  149. package/src/components/calendar.tsx +207 -102
  150. package/src/components/decision-form.tsx +71 -23
  151. package/src/components/decision-list.tsx +30 -3
  152. package/src/components/documents.tsx +27 -11
  153. package/src/components/event-feed.tsx +116 -33
  154. package/src/components/markdown.tsx +6 -3
  155. package/src/components/my-decisions.tsx +138 -0
  156. package/src/components/pack-connect.tsx +10 -7
  157. package/src/components/pack-token.tsx +4 -1
  158. package/src/components/people.tsx +20 -8
  159. package/src/components/projects.tsx +7 -5
  160. package/src/components/remote-pictures.ts +19 -0
  161. package/src/components/roadmap.tsx +1 -1
  162. package/src/components/runs.tsx +5 -1
  163. package/src/components/shell.tsx +36 -1
  164. package/src/components/sign-in.tsx +114 -13
  165. package/src/components/task-list.tsx +1 -1
  166. package/src/components/task-view.tsx +48 -47
  167. package/src/components/ui.tsx +70 -14
  168. package/src/console.tsx +153 -32
  169. package/src/data.ts +37 -20
  170. package/src/i18n/en.ts +53 -3
  171. package/src/i18n/ko.ts +50 -4
  172. package/src/index.ts +3 -0
  173. package/src/limits.ts +14 -0
  174. package/src/router.ts +18 -1
  175. package/src/routes.ts +5 -1
  176. package/src/rules.ts +1 -1
  177. package/src/schemas.ts +37 -8
  178. package/src/styles/console.css +1686 -425
  179. package/src/web.ts +3 -2
@@ -25,6 +25,7 @@ import {
25
25
  weekOf,
26
26
  } from "../dates.js";
27
27
  import { useLanguage, useWords } from "../i18n/index.js";
28
+ import { TaskList } from "./task-list.js";
28
29
  import { CategoryBadge, cx, PersonChip, personName, StateBadge, Time, Workers } from "./ui.js";
29
30
 
30
31
  // The calendar of a project (ADR-0019): the month as a grid, or one week on its own. A task
@@ -140,6 +141,17 @@ function onDays(tasks: readonly RestTask[]): ReadonlyMap<string, readonly RestTa
140
141
  return on;
141
142
  }
142
143
 
144
+ /** The tasks of one day: those on it alone and those lying across it, the open ones first. */
145
+ export function tasksOn(day: string, tasks: readonly RestTask[]): RestTask[] {
146
+ return tasks
147
+ .filter((task) =>
148
+ spans(task)
149
+ ? (task.startsOn ?? day) <= day && day <= (task.dueOn ?? day)
150
+ : (task.dueOn ?? task.startsOn) === day,
151
+ )
152
+ .sort((a, b) => Number(isOpen(b)) - Number(isOpen(a)) || a.number - b.number);
153
+ }
154
+
143
155
  /** How a task reads: struck through once it is over, and marked once its due day is past. */
144
156
  const tone = (task: RestTask, now: string): string =>
145
157
  cx(
@@ -343,6 +355,10 @@ interface WeekRowProps {
343
355
  readonly onPoint: (task: RestTask, day: string, item: HTMLElement) => void;
344
356
  /** It left. */
345
357
  readonly onLeave: () => void;
358
+ /** The day whose tasks are listed under the month, where a day can be picked. */
359
+ readonly picked?: string | null | undefined;
360
+ /** A day's number was pressed: given, each number is a button. */
361
+ readonly onPick?: ((day: string) => void) | undefined;
346
362
  }
347
363
 
348
364
  /** The goals that aim at each day, by day: those with a target day, still planned or worked towards. */
@@ -365,9 +381,26 @@ function goalsOnDays(goals: readonly RestGoal[]): ReadonlyMap<string, readonly R
365
381
  /** How many days a bar has in the week, for its title to run along: a custom property the stylesheet reads. */
366
382
  const spanStyle = (span: number): CSSProperties => ({ "--sc-span": span }) as CSSProperties;
367
383
 
384
+ /** How many of a day's own tasks a cell shows before the way to the rest. */
385
+ export const DAY_SHOWN = 3;
386
+
368
387
  /** One week as a row of cells: the bars across the days in their lanes, then the tasks on each day. */
369
388
  function WeekRow(props: WeekRowProps) {
370
389
  const { week, segments, now } = props;
390
+ const words = useWords().calendar;
391
+ const { tag } = useLanguage();
392
+ // The days a person opened in this week, each showing all of its tasks.
393
+ const [opened, setOpened] = useState<ReadonlySet<string>>(() => new Set());
394
+ const toggle = (day: string) =>
395
+ setOpened((before) => {
396
+ const after = new Set(before);
397
+ if (after.has(day)) {
398
+ after.delete(day);
399
+ } else {
400
+ after.add(day);
401
+ }
402
+ return after;
403
+ });
371
404
  const pointing = (task: RestTask, day: string) => ({
372
405
  onMouseEnter: (event: SyntheticEvent<HTMLElement>) =>
373
406
  props.onPoint(task, day, event.currentTarget),
@@ -384,110 +417,154 @@ function WeekRow(props: WeekRowProps) {
384
417
  const lanes = segments.reduce((most, segment) => Math.max(most, segment.lane + 1), 0);
385
418
  return (
386
419
  <tr>
387
- {week.map((cell, column) => (
388
- <td
389
- key={cell.day}
390
- className={cx(
391
- "sc-calendar-cell",
392
- !cell.inMonth && "sc-calendar-cell-outside",
393
- cell.day === now && "sc-calendar-cell-today",
394
- )}
395
- >
396
- <span className="sc-calendar-day">{Number(cell.day.slice(8))}</span>
397
- {props.goalHref !== undefined &&
398
- (props.goalsOn?.get(cell.day) ?? []).map((goal) => (
399
- <a
400
- key={goal.id}
401
- className={cx(
402
- "sc-calendar-goal",
403
- `sc-category-${goal.category?.color ?? "gray"}`,
404
- goal.state === "done" && "sc-calendar-over",
405
- )}
406
- href={props.goalHref?.(goal)}
407
- >
408
- <span className="sc-calendar-flag" aria-hidden="true" />
409
- <span className="sc-calendar-task-title">{goal.title}</span>
410
- </a>
411
- ))}
412
- {Array.from({ length: lanes }, (_, lane) => {
413
- const segment = segments.find(
414
- (candidate) =>
415
- candidate.lane === lane && candidate.from <= column && column <= candidate.to,
416
- );
417
- if (segment === undefined) {
418
- // The lane is someone else's on this day: the bars below keep their height.
419
- // biome-ignore lint/suspicious/noArrayIndexKey: an empty lane is nothing but its position, and the lanes of a week never reorder
420
- return <span key={lane} className="sc-calendar-lane" aria-hidden="true" />;
421
- }
422
- const { task } = segment;
423
- // The first piece carries the title; with more days in the week, the title runs along
424
- // the bar as far as they reach, over the pieces after it, instead of stopping at the
425
- // first day's edge.
426
- const span = segment.to - segment.from + 1;
427
- const spanning = segment.from === column && span > 1;
428
- // Every piece of the task under the pointer lights together, in every week it crosses.
429
- const pointed = props.pointed !== null && props.pointed.task.id === task.id;
430
- return (
431
- <a
432
- key={task.id}
433
- className={cx(
434
- "sc-calendar-bar",
435
- `sc-category-${task.category?.color ?? "gray"}`,
436
- segment.from === column && segment.begins && "sc-calendar-bar-begins",
437
- segment.to === column && segment.ends && "sc-calendar-bar-ends",
438
- spanning && "sc-calendar-bar-spanning",
439
- pointed && "sc-calendar-bar-pointed",
440
- tone(task, now),
441
- )}
442
- style={spanning ? spanStyle(span) : undefined}
443
- href={props.taskHref(task)}
444
- {...pointing(task, cell.day)}
420
+ {week.map((cell, column) => {
421
+ const number = Number(cell.day.slice(8));
422
+ // Everything on the day: its own tasks and the bars crossing it, for a phone's count.
423
+ const total =
424
+ (props.on.get(cell.day)?.length ?? 0) +
425
+ segments.filter((segment) => segment.from <= column && column <= segment.to).length;
426
+ return (
427
+ <td
428
+ key={cell.day}
429
+ className={cx(
430
+ "sc-calendar-cell",
431
+ !cell.inMonth && "sc-calendar-cell-outside",
432
+ cell.day === now && "sc-calendar-cell-today",
433
+ props.picked === cell.day && "sc-calendar-cell-picked",
434
+ )}
435
+ >
436
+ {props.onPick === undefined ? (
437
+ <span className="sc-calendar-day">{number}</span>
438
+ ) : (
439
+ <button
440
+ type="button"
441
+ className="sc-calendar-day"
442
+ aria-pressed={props.picked === cell.day}
443
+ aria-label={words.onDay(formatDay(cell.day, tag))}
444
+ onClick={() => props.onPick?.(cell.day)}
445
445
  >
446
- {segment.from === column && (
447
- <>
448
- {props.markOf?.(task)}
449
- {who(task)}
450
- <span
451
- className={cx(
452
- "sc-calendar-task-title",
453
- spanning && "sc-calendar-task-title-spanning",
454
- )}
446
+ {number}
447
+ </button>
448
+ )}
449
+ {props.goalHref !== undefined &&
450
+ (props.goalsOn?.get(cell.day) ?? []).map((goal) => (
451
+ <a
452
+ key={goal.id}
453
+ className={cx(
454
+ "sc-calendar-goal",
455
+ `sc-category-${goal.category?.color ?? "gray"}`,
456
+ goal.state === "done" && "sc-calendar-over",
457
+ )}
458
+ href={props.goalHref?.(goal)}
459
+ >
460
+ <span className="sc-calendar-flag" aria-hidden="true" />
461
+ <span className="sc-calendar-task-title">{goal.title}</span>
462
+ </a>
463
+ ))}
464
+ {Array.from({ length: lanes }, (_, lane) => {
465
+ const segment = segments.find(
466
+ (candidate) =>
467
+ candidate.lane === lane && candidate.from <= column && column <= candidate.to,
468
+ );
469
+ if (segment === undefined) {
470
+ // The lane is someone else's on this day: the bars below keep their height.
471
+ // biome-ignore lint/suspicious/noArrayIndexKey: an empty lane is nothing but its position, and the lanes of a week never reorder
472
+ return <span key={lane} className="sc-calendar-lane" aria-hidden="true" />;
473
+ }
474
+ const { task } = segment;
475
+ // The first piece carries the title; with more days in the week, the title runs along
476
+ // the bar as far as they reach, over the pieces after it, instead of stopping at the
477
+ // first day's edge.
478
+ const span = segment.to - segment.from + 1;
479
+ const spanning = segment.from === column && span > 1;
480
+ // Every piece of the task under the pointer lights together, in every week it crosses.
481
+ const pointed = props.pointed !== null && props.pointed.task.id === task.id;
482
+ return (
483
+ <a
484
+ key={task.id}
485
+ className={cx(
486
+ "sc-calendar-bar",
487
+ `sc-category-${task.category?.color ?? "gray"}`,
488
+ segment.from === column && segment.begins && "sc-calendar-bar-begins",
489
+ segment.to === column && segment.ends && "sc-calendar-bar-ends",
490
+ spanning && "sc-calendar-bar-spanning",
491
+ pointed && "sc-calendar-bar-pointed",
492
+ tone(task, now),
493
+ )}
494
+ style={spanning ? spanStyle(span) : undefined}
495
+ href={props.taskHref(task)}
496
+ {...pointing(task, cell.day)}
497
+ >
498
+ {segment.from === column && (
499
+ <>
500
+ {props.markOf?.(task)}
501
+ {who(task)}
502
+ <span
503
+ className={cx(
504
+ "sc-calendar-task-title",
505
+ spanning && "sc-calendar-task-title-spanning",
506
+ )}
507
+ >
508
+ {task.title}
509
+ </span>
510
+ </>
511
+ )}
512
+ {tipIn(task, cell.day)}
513
+ </a>
514
+ );
515
+ })}
516
+ {(() => {
517
+ const all = props.on.get(cell.day) ?? [];
518
+ const open = opened.has(cell.day);
519
+ const shown = open || all.length <= DAY_SHOWN + 1 ? all : all.slice(0, DAY_SHOWN);
520
+ return (
521
+ <>
522
+ {shown.map((task) => {
523
+ const mark = props.markOf?.(task);
524
+ return (
525
+ <a
526
+ key={task.id}
527
+ className={cx("sc-calendar-task", tone(task, now))}
528
+ href={props.taskHref(task)}
529
+ {...pointing(task, cell.day)}
530
+ >
531
+ {mark === undefined || mark === null ? (
532
+ <span
533
+ className={cx(
534
+ "sc-calendar-dot",
535
+ `sc-category-${task.category?.color ?? "gray"}`,
536
+ )}
537
+ />
538
+ ) : (
539
+ mark
540
+ )}
541
+ {who(task)}
542
+ <span className="sc-calendar-task-title">{task.title}</span>
543
+ {tipIn(task, cell.day)}
544
+ </a>
545
+ );
546
+ })}
547
+ {all.length > DAY_SHOWN + 1 && (
548
+ <button
549
+ type="button"
550
+ className="sc-calendar-more"
551
+ aria-expanded={open}
552
+ onClick={() => toggle(cell.day)}
455
553
  >
456
- {task.title}
457
- </span>
458
- </>
459
- )}
460
- {tipIn(task, cell.day)}
461
- </a>
462
- );
463
- })}
464
- {(props.on.get(cell.day) ?? []).map((task) => {
465
- const mark = props.markOf?.(task);
466
- return (
467
- <a
468
- key={task.id}
469
- className={cx("sc-calendar-task", tone(task, now))}
470
- href={props.taskHref(task)}
471
- {...pointing(task, cell.day)}
472
- >
473
- {mark === undefined || mark === null ? (
474
- <span
475
- className={cx(
476
- "sc-calendar-dot",
477
- `sc-category-${task.category?.color ?? "gray"}`,
478
- )}
479
- />
480
- ) : (
481
- mark
482
- )}
483
- {who(task)}
484
- <span className="sc-calendar-task-title">{task.title}</span>
485
- {tipIn(task, cell.day)}
486
- </a>
487
- );
488
- })}
489
- </td>
490
- ))}
554
+ {open ? words.fewer : words.more(all.length - DAY_SHOWN)}
555
+ </button>
556
+ )}
557
+ </>
558
+ );
559
+ })()}
560
+ {total > DAY_SHOWN && (
561
+ <span className="sc-calendar-count" aria-hidden="true">
562
+ {total}
563
+ </span>
564
+ )}
565
+ </td>
566
+ );
567
+ })}
491
568
  </tr>
492
569
  );
493
570
  }
@@ -496,9 +573,12 @@ export function Calendar(props: CalendarProps) {
496
573
  const words = useWords().calendar;
497
574
  const { tag } = useLanguage();
498
575
  const [pointed, point, leave] = usePointed();
576
+ // The day whose tasks are listed under the month, once its number is pressed.
577
+ const [picked, setPicked] = useState<string | null>(null);
499
578
  const now = props.today ?? todayOf();
500
579
  const firstDay = firstDayOfWeek(deviceLanguage(tag), props.weekStart);
501
580
  const weeks = monthGrid(props.month, firstDay);
581
+ const onPicked = picked === null ? [] : tasksOn(picked, props.tasks);
502
582
  const on = onDays(props.tasks);
503
583
  const goalsOn = props.goalHref === undefined ? undefined : goalsOnDays(props.goals ?? []);
504
584
  const inMonth = (day: string) => monthOf(day) === props.month;
@@ -563,10 +643,35 @@ export function Calendar(props: CalendarProps) {
563
643
  onPoint={point}
564
644
  onLeave={leave}
565
645
  people
646
+ picked={picked}
647
+ onPick={(day) => setPicked((before) => (before === day ? null : day))}
566
648
  />
567
649
  ))}
568
650
  </tbody>
569
651
  </table>
652
+ {picked !== null && (
653
+ <section className="sc-calendar-day-list" aria-label={words.onDay(formatDay(picked, tag))}>
654
+ <div className="sc-calendar-day-list-head">
655
+ <h3 className="sc-calendar-day-list-title">
656
+ {formatDay(picked, tag)} <span className="sc-column-count">{onPicked.length}</span>
657
+ </h3>
658
+ <button
659
+ type="button"
660
+ className="sc-button sc-button-ghost sc-button-sm"
661
+ onClick={() => setPicked(null)}
662
+ >
663
+ {words.close}
664
+ </button>
665
+ </div>
666
+ <TaskList
667
+ tasks={onPicked}
668
+ taskHref={props.taskHref}
669
+ today={now}
670
+ showState
671
+ empty={<p className="sc-muted">{words.nothingOnDay}</p>}
672
+ />
673
+ </section>
674
+ )}
570
675
  {(props.categories ?? []).length > 0 && (
571
676
  <dl className="sc-calendar-legend" aria-label={words.legend.title}>
572
677
  {(props.categories ?? []).map((category) => (
@@ -1,4 +1,4 @@
1
- import { type FormEvent, useState } from "react";
1
+ import { type FormEvent, useMemo, useState } from "react";
2
2
  import {
3
3
  MAX_BODY_LENGTH,
4
4
  MAX_OPTION_LABEL_LENGTH,
@@ -6,31 +6,60 @@ import {
6
6
  MAX_QUESTION_LENGTH,
7
7
  MIN_OPTIONS,
8
8
  type RestDecisionInput,
9
+ type RestPerson,
9
10
  type RestTask,
10
11
  } from "../api.js";
11
12
  import { useWords } from "../i18n/index.js";
12
- import { Button } from "./ui.js";
13
+ import { Picker, type PickerSearch } from "./picker.js";
14
+ import { type TaskSearch, taskLabel } from "./task-form.js";
15
+ import { Button, PersonChip, personName } from "./ui.js";
13
16
 
14
- // Raising a decision: the question, what a person needs to know, and the options to choose from.
17
+ // Raising a decision: the question, what a person needs to know, the options to choose from,
18
+ // and who is to answer (ADR-0042): the person raising it, unless they name another of the
19
+ // project. The task it is about is the page's, when raised from a task; otherwise it is found
20
+ // by searching the board a page at a time (ADR-0037), never picked from the board whole.
15
21
 
16
22
  export interface DecisionFormProps {
17
- /** The tasks the decision may be about. */
18
- readonly tasks: readonly RestTask[];
19
- /** The task it is about from the start, when raised from a task's page. */
23
+ /** The task it is about from the start, when raised from a task's page; the field is not shown then. */
20
24
  readonly taskId?: string | undefined;
25
+ /** What words find among the tasks of the project, a page at a time; left out, the decision is about no task in particular. */
26
+ readonly findTasks?: TaskSearch | undefined;
27
+ /** Who may be named to answer: the people of the project; left out, the field is not shown and the raiser answers. */
28
+ readonly deciders?: readonly RestPerson[] | undefined;
29
+ /** Whoever raises it: who answers unless another is named. */
30
+ readonly me?: RestPerson | undefined;
21
31
  readonly busy?: boolean | undefined;
22
32
  readonly error?: string | undefined;
23
33
  readonly onSubmit: (input: RestDecisionInput) => void;
24
34
  readonly onCancel?: (() => void) | undefined;
25
35
  }
26
36
 
37
+ /** The people of the project found by the words typed, a page at a time, as a picker searches. */
38
+ export function peopleSearch(people: readonly RestPerson[]): PickerSearch<RestPerson> {
39
+ return async (query, page) => {
40
+ const words = query.trim().toLowerCase();
41
+ const found = people.filter(
42
+ (person) =>
43
+ words.length === 0 ||
44
+ personName(person).toLowerCase().includes(words) ||
45
+ person.login.toLowerCase().includes(words),
46
+ );
47
+ return {
48
+ items: found.slice(page.offset, page.offset + page.limit),
49
+ more: found.length > page.offset + page.limit,
50
+ };
51
+ };
52
+ }
53
+
27
54
  export function DecisionForm(props: DecisionFormProps) {
28
55
  const words = useWords();
29
56
  const form = words.decisions.form;
30
57
  const [question, setQuestion] = useState("");
31
58
  const [body, setBody] = useState("");
32
- const [taskId, setTaskId] = useState(props.taskId ?? "");
59
+ const [about, setAbout] = useState<RestTask | undefined>(undefined);
33
60
  const [options, setOptions] = useState<readonly string[]>(["", ""]);
61
+ const [decider, setDecider] = useState<RestPerson | undefined>(props.me);
62
+ const findDeciders = useMemo(() => peopleSearch(props.deciders ?? []), [props.deciders]);
34
63
 
35
64
  const labels = options.map((option) => option.trim()).filter((option) => option.length > 0);
36
65
  const ready = question.trim().length > 0 && labels.length >= MIN_OPTIONS;
@@ -41,7 +70,8 @@ export function DecisionForm(props: DecisionFormProps) {
41
70
  question: question.trim(),
42
71
  body,
43
72
  options: labels.map((label) => ({ label })),
44
- taskId: taskId === "" ? null : taskId,
73
+ taskId: props.taskId ?? about?.id ?? null,
74
+ ...(decider === undefined || decider.id === props.me?.id ? {} : { deciderId: decider.id }),
45
75
  });
46
76
  };
47
77
 
@@ -68,22 +98,40 @@ export function DecisionForm(props: DecisionFormProps) {
68
98
  onChange={(event) => setBody(event.target.value)}
69
99
  />
70
100
  </label>
71
- {props.taskId === undefined && props.tasks.length > 0 && (
72
- <label className="sc-field">
101
+ {props.taskId === undefined && props.findTasks !== undefined && (
102
+ <div className="sc-field">
73
103
  <span className="sc-field-label">{form.about}</span>
74
- <select
75
- className="sc-input"
76
- value={taskId}
77
- onChange={(event) => setTaskId(event.target.value)}
78
- >
79
- <option value="">{form.noTask}</option>
80
- {props.tasks.map((task) => (
81
- <option key={task.id} value={task.id}>
82
- #{task.number} {task.title}
83
- </option>
84
- ))}
85
- </select>
86
- </label>
104
+ <Picker
105
+ value={about}
106
+ search={props.findTasks}
107
+ keyOf={(task) => task.id}
108
+ render={taskLabel}
109
+ label={form.about}
110
+ placeholder={form.noTask}
111
+ disabled={props.busy}
112
+ onChange={setAbout}
113
+ />
114
+ </div>
115
+ )}
116
+ {props.deciders !== undefined && (
117
+ <div className="sc-field">
118
+ <span className="sc-field-label">{form.decider}</span>
119
+ <Picker
120
+ value={decider}
121
+ search={findDeciders}
122
+ keyOf={(person) => person.id}
123
+ render={(person) => (
124
+ <>
125
+ <PersonChip person={person} size="sm" />
126
+ {person.id === props.me?.id && <span className="sc-muted"> {form.me}</span>}
127
+ </>
128
+ )}
129
+ label={form.decider}
130
+ placeholder={form.deciderPlaceholder}
131
+ disabled={props.busy}
132
+ onChange={(person) => setDecider(person ?? props.me)}
133
+ />
134
+ </div>
87
135
  )}
88
136
  <fieldset className="sc-field sc-options">
89
137
  <legend className="sc-field-label">{form.options}</legend>
@@ -12,7 +12,7 @@ import {
12
12
  import { useWords } from "../i18n/index.js";
13
13
  import { Markdown } from "./markdown.js";
14
14
  import { Pager, pageOf, RECORD_PAGE_SIZE } from "./paged.js";
15
- import { Badge, Button, cx, PersonChip, Time } from "./ui.js";
15
+ import { Badge, Button, cx, PersonChip, personName, Time } from "./ui.js";
16
16
 
17
17
  // The decisions: the ones that wait, each a card with the way to answer it, since they are what
18
18
  // a person came for; and the ones answered, the record, a row each, since they are read back
@@ -29,6 +29,8 @@ export interface DecisionCardProps {
29
29
  readonly docHref?: ((path: string) => string) | undefined;
30
30
  /** Where the decision's own page is; given, the question leads to it. */
31
31
  readonly href?: string | undefined;
32
+ /** The project the decision is in, named where a page shows the decisions of several. */
33
+ readonly project?: React.ReactNode;
32
34
  /** Called with the person's answer. Left out, a waiting decision cannot be answered here. */
33
35
  readonly onAnswer?: ((decision: RestDecision, input: RestAnswerInput) => void) | undefined;
34
36
  /** Called to grow the record, with what followed. Left out, it cannot be written here. */
@@ -36,7 +38,10 @@ export interface DecisionCardProps {
36
38
  readonly busy?: boolean | undefined;
37
39
  }
38
40
 
39
- /** Who asked, and when: the person, through their agent when a run asked. */
41
+ /**
42
+ * Who asked, and when: the person, through their agent when a run asked; and whom the decision
43
+ * is for, where that is someone else (ADR-0042).
44
+ */
40
45
  function Asked(props: { readonly decision: RestDecision; readonly day?: boolean }) {
41
46
  const words = useWords();
42
47
  const { decision } = props;
@@ -47,6 +52,20 @@ function Asked(props: { readonly decision: RestDecision; readonly day?: boolean
47
52
  ? words.decisions.asked
48
53
  : words.decisions.askedThrough(agentLabel(decision.run.agent))}{" "}
49
54
  <Time iso={decision.createdAt} precision={props.day === true ? "day" : "minute"} />
55
+ {decision.decider.id !== decision.raisedBy.id && (
56
+ <span className="sc-decision-for">
57
+ <span className="sc-visually-hidden">
58
+ {words.decisions.peopleLabel(
59
+ personName(decision.raisedBy),
60
+ personName(decision.decider),
61
+ )}
62
+ </span>
63
+ <span className="sc-decision-arrow" aria-hidden="true">
64
+ →
65
+ </span>{" "}
66
+ <PersonChip person={decision.decider} />
67
+ </span>
68
+ )}
50
69
  </>
51
70
  );
52
71
  }
@@ -264,6 +283,7 @@ export function DecisionCard(props: DecisionCardProps) {
264
283
  )}
265
284
  </>
266
285
  )}
286
+ {props.project}
267
287
  </p>
268
288
  </header>
269
289
  {decision.body.length > 0 && (
@@ -288,7 +308,9 @@ export function DecisionCard(props: DecisionCardProps) {
288
308
  </p>
289
309
  </div>
290
310
  ) : props.onAnswer === undefined ? (
291
- <p className="sc-decision-waiting">{words.decisions.waitingForPerson}</p>
311
+ <p className="sc-decision-waiting">
312
+ {words.decisions.waitingFor(personName(decision.decider))}
313
+ </p>
292
314
  ) : (
293
315
  <AnswerForm decision={decision} onAnswer={props.onAnswer} busy={props.busy} />
294
316
  )}
@@ -334,6 +356,8 @@ export interface DecisionLogProps {
334
356
  readonly page?: number | undefined;
335
357
  /** Where each page of the record is, so that a page has an address; left out, the pages turn in place. */
336
358
  readonly pageHref?: ((page: number) => string) | undefined;
359
+ /** The project a decision is in, named where the record holds the decisions of several. */
360
+ readonly projectOf?: ((decision: RestDecision) => React.ReactNode) | undefined;
337
361
  }
338
362
 
339
363
  /** One row of the record: what was asked, by whom and when; what was answered, with the word given, by whom and when. */
@@ -343,6 +367,7 @@ function RecordRow(props: {
343
367
  readonly taskHref: ((task: RestTask) => string) | undefined;
344
368
  readonly href: string | undefined;
345
369
  readonly docHref: ((path: string) => string) | undefined;
370
+ readonly project?: React.ReactNode;
346
371
  }) {
347
372
  const words = useWords();
348
373
  const { decision, task } = props;
@@ -367,6 +392,7 @@ function RecordRow(props: {
367
392
  </p>
368
393
  <p className="sc-decision-record-meta">
369
394
  <Asked decision={decision} day />
395
+ {props.project}
370
396
  </p>
371
397
  </div>
372
398
  {answer !== null && (
@@ -412,6 +438,7 @@ export function DecisionLog(props: DecisionLogProps) {
412
438
  taskHref={props.taskHref}
413
439
  href={props.decisionHref?.(decision)}
414
440
  docHref={props.docHref}
441
+ project={props.projectOf?.(decision)}
415
442
  />
416
443
  ))}
417
444
  </ol>