@skillcdn/console 0.3.1 → 0.4.0

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 -4
  2. package/dist/api.d.ts +4 -4
  3. package/dist/api.d.ts.map +1 -1
  4. package/dist/api.js +4 -4
  5. package/dist/api.js.map +1 -1
  6. package/dist/cli/cli.d.ts +4 -0
  7. package/dist/cli/cli.d.ts.map +1 -1
  8. package/dist/cli/cli.js +201 -25
  9. package/dist/cli/cli.js.map +1 -1
  10. package/dist/cli/format.d.ts.map +1 -1
  11. package/dist/cli/format.js +15 -11
  12. package/dist/cli/format.js.map +1 -1
  13. package/dist/cli/help.d.ts.map +1 -1
  14. package/dist/cli/help.js +59 -26
  15. package/dist/cli/help.js.map +1 -1
  16. package/dist/cli/main.js +1 -0
  17. package/dist/cli/main.js.map +1 -1
  18. package/dist/cli/pack.d.ts +7 -2
  19. package/dist/cli/pack.d.ts.map +1 -1
  20. package/dist/cli/pack.js +16 -2
  21. package/dist/cli/pack.js.map +1 -1
  22. package/dist/client.d.ts +20 -10
  23. package/dist/client.d.ts.map +1 -1
  24. package/dist/client.js +17 -3
  25. package/dist/client.js.map +1 -1
  26. package/dist/compat.d.ts +28 -6
  27. package/dist/compat.d.ts.map +1 -1
  28. package/dist/compat.js +55 -14
  29. package/dist/compat.js.map +1 -1
  30. package/dist/components/board.d.ts +12 -1
  31. package/dist/components/board.d.ts.map +1 -1
  32. package/dist/components/board.js +57 -7
  33. package/dist/components/board.js.map +1 -1
  34. package/dist/components/calendar.d.ts +17 -7
  35. package/dist/components/calendar.d.ts.map +1 -1
  36. package/dist/components/calendar.js +89 -38
  37. package/dist/components/calendar.js.map +1 -1
  38. package/dist/components/decision-form.d.ts +12 -4
  39. package/dist/components/decision-form.d.ts.map +1 -1
  40. package/dist/components/decision-form.js +24 -6
  41. package/dist/components/decision-form.js.map +1 -1
  42. package/dist/components/decision-list.d.ts +4 -0
  43. package/dist/components/decision-list.d.ts.map +1 -1
  44. package/dist/components/decision-list.js +9 -6
  45. package/dist/components/decision-list.js.map +1 -1
  46. package/dist/components/documents.d.ts.map +1 -1
  47. package/dist/components/documents.js +3 -3
  48. package/dist/components/documents.js.map +1 -1
  49. package/dist/components/event-feed.d.ts.map +1 -1
  50. package/dist/components/event-feed.js +63 -8
  51. package/dist/components/event-feed.js.map +1 -1
  52. package/dist/components/my-decisions.d.ts +20 -0
  53. package/dist/components/my-decisions.d.ts.map +1 -0
  54. package/dist/components/my-decisions.js +36 -0
  55. package/dist/components/my-decisions.js.map +1 -0
  56. package/dist/components/people.d.ts.map +1 -1
  57. package/dist/components/people.js +1 -1
  58. package/dist/components/people.js.map +1 -1
  59. package/dist/components/picker.d.ts +39 -0
  60. package/dist/components/picker.d.ts.map +1 -0
  61. package/dist/components/picker.js +155 -0
  62. package/dist/components/picker.js.map +1 -0
  63. package/dist/components/projects.d.ts.map +1 -1
  64. package/dist/components/projects.js +3 -3
  65. package/dist/components/projects.js.map +1 -1
  66. package/dist/components/roadmap.js +1 -1
  67. package/dist/components/roadmap.js.map +1 -1
  68. package/dist/components/runs.d.ts.map +1 -1
  69. package/dist/components/runs.js +1 -1
  70. package/dist/components/runs.js.map +1 -1
  71. package/dist/components/shell.d.ts.map +1 -1
  72. package/dist/components/shell.js +36 -1
  73. package/dist/components/shell.js.map +1 -1
  74. package/dist/components/task-form.d.ts +18 -4
  75. package/dist/components/task-form.d.ts.map +1 -1
  76. package/dist/components/task-form.js +56 -13
  77. package/dist/components/task-form.js.map +1 -1
  78. package/dist/components/task-list.js +2 -2
  79. package/dist/components/task-list.js.map +1 -1
  80. package/dist/components/task-view.d.ts +7 -1
  81. package/dist/components/task-view.d.ts.map +1 -1
  82. package/dist/components/task-view.js +7 -6
  83. package/dist/components/task-view.js.map +1 -1
  84. package/dist/components/ui.d.ts +17 -0
  85. package/dist/components/ui.d.ts.map +1 -1
  86. package/dist/components/ui.js +26 -11
  87. package/dist/components/ui.js.map +1 -1
  88. package/dist/components/work.d.ts.map +1 -1
  89. package/dist/components/work.js +5 -3
  90. package/dist/components/work.js.map +1 -1
  91. package/dist/console.css +1794 -433
  92. package/dist/console.d.ts.map +1 -1
  93. package/dist/console.js +93 -10
  94. package/dist/console.js.map +1 -1
  95. package/dist/data.d.ts +21 -1
  96. package/dist/data.d.ts.map +1 -1
  97. package/dist/data.js +24 -1
  98. package/dist/data.js.map +1 -1
  99. package/dist/i18n/en.d.ts +45 -3
  100. package/dist/i18n/en.d.ts.map +1 -1
  101. package/dist/i18n/en.js +52 -10
  102. package/dist/i18n/en.js.map +1 -1
  103. package/dist/i18n/ko.d.ts.map +1 -1
  104. package/dist/i18n/ko.js +54 -12
  105. package/dist/i18n/ko.js.map +1 -1
  106. package/dist/index.d.ts +5 -3
  107. package/dist/index.d.ts.map +1 -1
  108. package/dist/index.js +5 -3
  109. package/dist/index.js.map +1 -1
  110. package/dist/limits.d.ts +2 -0
  111. package/dist/limits.d.ts.map +1 -1
  112. package/dist/limits.js +2 -0
  113. package/dist/limits.js.map +1 -1
  114. package/dist/router.d.ts +9 -1
  115. package/dist/router.d.ts.map +1 -1
  116. package/dist/router.js +12 -0
  117. package/dist/router.js.map +1 -1
  118. package/dist/routes.d.ts +3 -1
  119. package/dist/routes.d.ts.map +1 -1
  120. package/dist/routes.js +3 -1
  121. package/dist/routes.js.map +1 -1
  122. package/dist/rules.js +1 -1
  123. package/dist/rules.js.map +1 -1
  124. package/dist/schemas.d.ts +252 -12
  125. package/dist/schemas.d.ts.map +1 -1
  126. package/dist/schemas.js +27 -10
  127. package/dist/schemas.js.map +1 -1
  128. package/dist/vocabulary.d.ts +2 -0
  129. package/dist/vocabulary.d.ts.map +1 -1
  130. package/dist/vocabulary.js +2 -0
  131. package/dist/vocabulary.js.map +1 -1
  132. package/docs/specs/cli.md +14 -13
  133. package/docs/specs/rest.md +24 -19
  134. package/package.json +2 -2
  135. package/skills/building-a-console/SKILL.md +4 -4
  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/deploying-a-console/SKILL.md +2 -2
  139. package/skills/deploying-a-console/references/resources.md +1 -1
  140. package/skills/working-the-board/SKILL.md +8 -6
  141. package/skills/working-the-board/references/terms.md +3 -3
  142. package/src/api.ts +7 -0
  143. package/src/cli/cli.ts +254 -25
  144. package/src/cli/format.ts +22 -11
  145. package/src/cli/help.ts +63 -26
  146. package/src/cli/main.ts +1 -0
  147. package/src/cli/pack.ts +17 -1
  148. package/src/client.ts +44 -11
  149. package/src/compat.ts +63 -14
  150. package/src/components/board.tsx +124 -25
  151. package/src/components/calendar.tsx +258 -117
  152. package/src/components/decision-form.tsx +71 -23
  153. package/src/components/decision-list.tsx +30 -3
  154. package/src/components/documents.tsx +27 -11
  155. package/src/components/event-feed.tsx +116 -33
  156. package/src/components/my-decisions.tsx +138 -0
  157. package/src/components/people.tsx +3 -1
  158. package/src/components/picker.tsx +297 -0
  159. package/src/components/projects.tsx +7 -5
  160. package/src/components/roadmap.tsx +1 -1
  161. package/src/components/runs.tsx +5 -1
  162. package/src/components/shell.tsx +36 -1
  163. package/src/components/task-form.tsx +149 -63
  164. package/src/components/task-list.tsx +3 -3
  165. package/src/components/task-view.tsx +71 -56
  166. package/src/components/ui.tsx +89 -11
  167. package/src/components/work.tsx +6 -4
  168. package/src/console.tsx +157 -29
  169. package/src/data.ts +61 -1
  170. package/src/i18n/en.ts +54 -10
  171. package/src/i18n/ko.ts +56 -12
  172. package/src/index.ts +19 -1
  173. package/src/limits.ts +2 -0
  174. package/src/router.ts +18 -1
  175. package/src/routes.ts +3 -1
  176. package/src/rules.ts +1 -1
  177. package/src/schemas.ts +29 -9
  178. package/src/styles/console.css +1794 -433
  179. package/src/vocabulary.ts +3 -0
@@ -1,4 +1,4 @@
1
- import { type ReactNode, type SyntheticEvent, useState } from "react";
1
+ import { type CSSProperties, type ReactNode, type SyntheticEvent, useState } from "react";
2
2
  import {
3
3
  OPEN_TASK_STATES,
4
4
  type RestCategory,
@@ -25,7 +25,8 @@ import {
25
25
  weekOf,
26
26
  } from "../dates.js";
27
27
  import { useLanguage, useWords } from "../i18n/index.js";
28
- import { Avatar, CategoryBadge, cx, PersonChip, personName, StateBadge, Time } from "./ui.js";
28
+ import { TaskList } from "./task-list.js";
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
31
32
  // planned from a start to a due day lies across its days as a bar, in a lane of its own within
@@ -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(
@@ -262,12 +274,18 @@ export function TaskTip(props: TaskTipProps) {
262
274
  </dd>
263
275
  </div>
264
276
  <div>
265
- <dt>{words.task.assignee}</dt>
277
+ <dt>{words.task.workers}</dt>
266
278
  <dd>
267
- {task.assignee === null ? (
279
+ {task.workers.length === 0 ? (
268
280
  <span className="sc-muted">{words.common.nobody}</span>
269
281
  ) : (
270
- <PersonChip person={task.assignee} />
282
+ <ul className="sc-worker-list">
283
+ {task.workers.map((worker) => (
284
+ <li key={worker.id}>
285
+ <PersonChip person={worker} />
286
+ </li>
287
+ ))}
288
+ </ul>
271
289
  )}
272
290
  </dd>
273
291
  </div>
@@ -337,6 +355,10 @@ interface WeekRowProps {
337
355
  readonly onPoint: (task: RestTask, day: string, item: HTMLElement) => void;
338
356
  /** It left. */
339
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;
340
362
  }
341
363
 
342
364
  /** The goals that aim at each day, by day: those with a target day, still planned or worked towards. */
@@ -356,9 +378,29 @@ function goalsOnDays(goals: readonly RestGoal[]): ReadonlyMap<string, readonly R
356
378
  return on;
357
379
  }
358
380
 
381
+ /** How many days a bar has in the week, for its title to run along: a custom property the stylesheet reads. */
382
+ const spanStyle = (span: number): CSSProperties => ({ "--sc-span": span }) as CSSProperties;
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
+
359
387
  /** One week as a row of cells: the bars across the days in their lanes, then the tasks on each day. */
360
388
  function WeekRow(props: WeekRowProps) {
361
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
+ });
362
404
  const pointing = (task: RestTask, day: string) => ({
363
405
  onMouseEnter: (event: SyntheticEvent<HTMLElement>) =>
364
406
  props.onPoint(task, day, event.currentTarget),
@@ -371,99 +413,158 @@ function WeekRow(props: WeekRowProps) {
371
413
  <TaskTip task={task} goals={props.goals} place={props.pointed.place} />
372
414
  ) : null;
373
415
  const who = (task: RestTask): ReactNode =>
374
- props.people === true && task.assignee !== null ? (
375
- <Avatar person={task.assignee} size="xs" />
376
- ) : null;
416
+ props.people === true ? <Workers workers={task.workers} size="xs" shown={2} /> : null;
377
417
  const lanes = segments.reduce((most, segment) => Math.max(most, segment.lane + 1), 0);
378
418
  return (
379
419
  <tr>
380
- {week.map((cell, column) => (
381
- <td
382
- key={cell.day}
383
- className={cx(
384
- "sc-calendar-cell",
385
- !cell.inMonth && "sc-calendar-cell-outside",
386
- cell.day === now && "sc-calendar-cell-today",
387
- )}
388
- >
389
- <span className="sc-calendar-day">{Number(cell.day.slice(8))}</span>
390
- {props.goalHref !== undefined &&
391
- (props.goalsOn?.get(cell.day) ?? []).map((goal) => (
392
- <a
393
- key={goal.id}
394
- className={cx(
395
- "sc-calendar-goal",
396
- `sc-category-${goal.category?.color ?? "gray"}`,
397
- goal.state === "done" && "sc-calendar-over",
398
- )}
399
- href={props.goalHref?.(goal)}
400
- >
401
- <span className="sc-calendar-flag" aria-hidden="true" />
402
- <span className="sc-calendar-task-title">{goal.title}</span>
403
- </a>
404
- ))}
405
- {Array.from({ length: lanes }, (_, lane) => {
406
- const segment = segments.find(
407
- (candidate) =>
408
- candidate.lane === lane && candidate.from <= column && column <= candidate.to,
409
- );
410
- if (segment === undefined) {
411
- // The lane is someone else's on this day: the bars below keep their height.
412
- // biome-ignore lint/suspicious/noArrayIndexKey: an empty lane is nothing but its position, and the lanes of a week never reorder
413
- return <span key={lane} className="sc-calendar-lane" aria-hidden="true" />;
414
- }
415
- const { task } = segment;
416
- return (
417
- <a
418
- key={task.id}
419
- className={cx(
420
- "sc-calendar-bar",
421
- `sc-category-${task.category?.color ?? "gray"}`,
422
- segment.from === column && segment.begins && "sc-calendar-bar-begins",
423
- segment.to === column && segment.ends && "sc-calendar-bar-ends",
424
- tone(task, now),
425
- )}
426
- href={props.taskHref(task)}
427
- {...pointing(task, cell.day)}
428
- >
429
- {segment.from === column && (
430
- <>
431
- {props.markOf?.(task)}
432
- {who(task)}
433
- <span className="sc-calendar-task-title">{task.title}</span>
434
- </>
435
- )}
436
- {tipIn(task, cell.day)}
437
- </a>
438
- );
439
- })}
440
- {(props.on.get(cell.day) ?? []).map((task) => {
441
- const mark = props.markOf?.(task);
442
- return (
443
- <a
444
- key={task.id}
445
- className={cx("sc-calendar-task", tone(task, now))}
446
- href={props.taskHref(task)}
447
- {...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)}
448
445
  >
449
- {mark === undefined || mark === null ? (
450
- <span
451
- className={cx(
452
- "sc-calendar-dot",
453
- `sc-category-${task.category?.color ?? "gray"}`,
454
- )}
455
- />
456
- ) : (
457
- mark
458
- )}
459
- {who(task)}
460
- <span className="sc-calendar-task-title">{task.title}</span>
461
- {tipIn(task, cell.day)}
462
- </a>
463
- );
464
- })}
465
- </td>
466
- ))}
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)}
553
+ >
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
+ })}
467
568
  </tr>
468
569
  );
469
570
  }
@@ -472,9 +573,12 @@ export function Calendar(props: CalendarProps) {
472
573
  const words = useWords().calendar;
473
574
  const { tag } = useLanguage();
474
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);
475
578
  const now = props.today ?? todayOf();
476
579
  const firstDay = firstDayOfWeek(deviceLanguage(tag), props.weekStart);
477
580
  const weeks = monthGrid(props.month, firstDay);
581
+ const onPicked = picked === null ? [] : tasksOn(picked, props.tasks);
478
582
  const on = onDays(props.tasks);
479
583
  const goalsOn = props.goalHref === undefined ? undefined : goalsOnDays(props.goals ?? []);
480
584
  const inMonth = (day: string) => monthOf(day) === props.month;
@@ -539,10 +643,35 @@ export function Calendar(props: CalendarProps) {
539
643
  onPoint={point}
540
644
  onLeave={leave}
541
645
  people
646
+ picked={picked}
647
+ onPick={(day) => setPicked((before) => (before === day ? null : day))}
542
648
  />
543
649
  ))}
544
650
  </tbody>
545
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
+ )}
546
675
  {(props.categories ?? []).length > 0 && (
547
676
  <dl className="sc-calendar-legend" aria-label={words.legend.title}>
548
677
  {(props.categories ?? []).map((category) => (
@@ -644,39 +773,44 @@ export function Week(props: WeekProps) {
644
773
 
645
774
  /** One person's share of the open work, as the table of who has what says it. */
646
775
  export interface WorkloadRow {
647
- /** The person, or nobody for the tasks assigned to no one. */
776
+ /** The person, or nobody for the tasks with no worker. */
648
777
  readonly person: RestPerson | undefined;
649
778
  readonly open: number;
650
779
  readonly inProgress: number;
651
- /** Open, and due within the next seven days from today. */
652
- readonly dueSoon: number;
780
+ /** Open, and due from today through the last day of this week, as the workspace begins its weeks (ADR-0039). */
781
+ readonly dueThisWeek: number;
653
782
  /** Open, and past its day. */
654
783
  readonly overdue: number;
655
784
  }
656
785
 
657
- /** Who has what, from the open tasks: by login, with the unassigned last. */
658
- export function workloadOf(tasks: readonly RestTask[], today: string): WorkloadRow[] {
786
+ /**
787
+ * Who has what, from the open tasks: a task counted for each of its workers, by name, and the
788
+ * tasks with none last; this week is the days from today through the week's last day, the week
789
+ * beginning on `firstDay` (1 for Monday, 7 for Sunday).
790
+ */
791
+ export function workloadOf(tasks: readonly RestTask[], today: string, firstDay = 1): WorkloadRow[] {
792
+ const weekEnd = weekOf(today, firstDay).at(-1) ?? today;
659
793
  const rows = new Map<string, { person: RestPerson | undefined; tasks: RestTask[] }>();
660
794
  for (const task of tasks.filter(isOpen)) {
661
- const key = task.assignee?.id ?? "";
662
- const row = rows.get(key);
663
- if (row === undefined) {
664
- rows.set(key, { person: task.assignee ?? undefined, tasks: [task] });
665
- } else {
666
- row.tasks.push(task);
795
+ const workers: readonly (RestPerson | undefined)[] =
796
+ task.workers.length === 0 ? [undefined] : task.workers;
797
+ for (const worker of workers) {
798
+ const key = worker?.id ?? "";
799
+ const row = rows.get(key);
800
+ if (row === undefined) {
801
+ rows.set(key, { person: worker, tasks: [task] });
802
+ } else {
803
+ row.tasks.push(task);
804
+ }
667
805
  }
668
806
  }
669
807
  const count = (list: readonly RestTask[]): Omit<WorkloadRow, "person"> => ({
670
808
  open: list.length,
671
809
  inProgress: list.filter((task) => task.state === "in_progress" || task.state === "in_review")
672
810
  .length,
673
- dueSoon: list.filter((task) => {
674
- if (task.dueOn === null) {
675
- return false;
676
- }
677
- const days = daysBetween(today, task.dueOn);
678
- return days >= 0 && days <= 7;
679
- }).length,
811
+ dueThisWeek: list.filter(
812
+ (task) => task.dueOn !== null && task.dueOn >= today && task.dueOn <= weekEnd,
813
+ ).length,
680
814
  overdue: list.filter((task) => task.dueOn !== null && task.dueOn < today).length,
681
815
  });
682
816
  return [...rows.values()]
@@ -695,14 +829,21 @@ export function workloadOf(tasks: readonly RestTask[], today: string): WorkloadR
695
829
  export interface WorkloadProps {
696
830
  readonly tasks: readonly RestTask[];
697
831
  readonly today?: string | undefined;
698
- /** Where the board is, narrowed to one person's tasks, or to the unassigned. */
832
+ /** The day the week begins on, as the workspace says (ADR-0035); the language's when left out. */
833
+ readonly weekStart?: WeekStart | undefined;
834
+ /** Where the board is, narrowed to one person's tasks, or to those with no worker. */
699
835
  readonly personHref?: ((person: RestPerson | undefined) => string) | undefined;
700
836
  }
701
837
 
702
- /** Who has what: a row per person, and what of theirs is late or due soon. */
838
+ /** Who has what: a row per person, and what of theirs is late or due this week. */
703
839
  export function Workload(props: WorkloadProps) {
704
840
  const words = useWords().calendar.workload;
705
- const rows = workloadOf(props.tasks, props.today ?? todayOf());
841
+ const { tag } = useLanguage();
842
+ const rows = workloadOf(
843
+ props.tasks,
844
+ props.today ?? todayOf(),
845
+ firstDayOfWeek(deviceLanguage(tag), props.weekStart),
846
+ );
706
847
  if (rows.length === 0) {
707
848
  return null;
708
849
  }
@@ -713,7 +854,7 @@ export function Workload(props: WorkloadProps) {
713
854
  <th scope="col">{words.person}</th>
714
855
  <th scope="col">{words.open}</th>
715
856
  <th scope="col">{words.inProgress}</th>
716
- <th scope="col">{words.dueSoon}</th>
857
+ <th scope="col">{words.dueThisWeek}</th>
717
858
  <th scope="col">{words.overdue}</th>
718
859
  </tr>
719
860
  </thead>
@@ -731,7 +872,7 @@ export function Workload(props: WorkloadProps) {
731
872
  <th scope="row">{href === undefined ? who : <a href={href}>{who}</a>}</th>
732
873
  <td>{row.open}</td>
733
874
  <td>{row.inProgress}</td>
734
- <td className={cx(row.dueSoon > 0 && "sc-workload-soon")}>{row.dueSoon}</td>
875
+ <td className={cx(row.dueThisWeek > 0 && "sc-workload-soon")}>{row.dueThisWeek}</td>
735
876
  <td className={cx(row.overdue > 0 && "sc-workload-late")}>{row.overdue}</td>
736
877
  </tr>
737
878
  );
@@ -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>