@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
@@ -43,11 +43,23 @@ export function Button(
43
43
 
44
44
  export function Badge(props: {
45
45
  readonly tone?: "neutral" | "accent" | "point" | "success" | "warning" | "danger";
46
+ /** What the badge says, which the stylesheet shows in its shape: a state carries a dot, a priority is outlined, a due day is a square-cornered tag, a count is figures. */
47
+ readonly kind?: "state" | "priority" | "due" | "count" | undefined;
48
+ /** A class of the badge's own beside the tone's and the kind's, for a step within a kind. */
49
+ readonly className?: string | undefined;
46
50
  readonly title?: string;
47
51
  readonly children: ReactNode;
48
52
  }) {
49
53
  return (
50
- <span className={cx("sc-badge", `sc-badge-${props.tone ?? "neutral"}`)} title={props.title}>
54
+ <span
55
+ className={cx(
56
+ "sc-badge",
57
+ `sc-badge-${props.tone ?? "neutral"}`,
58
+ props.kind !== undefined && `sc-badge-kind-${props.kind}`,
59
+ props.className,
60
+ )}
61
+ title={props.title}
62
+ >
51
63
  {props.children}
52
64
  </span>
53
65
  );
@@ -145,6 +157,31 @@ export function PersonChip(props: { readonly person: RestPerson; readonly size?:
145
157
  );
146
158
  }
147
159
 
160
+ /** How many of a task's workers are shown as pictures before the rest are a count. */
161
+ const WORKERS_SHOWN = 3;
162
+
163
+ /** The people at work on a task (ADR-0037), as pictures side by side with a count of the rest; nothing while nobody is. */
164
+ export function Workers(props: {
165
+ readonly workers: readonly RestPerson[];
166
+ readonly size?: "xs" | "sm";
167
+ /** How many are shown as pictures; the rest are a count. */
168
+ readonly shown?: number;
169
+ }) {
170
+ if (props.workers.length === 0) {
171
+ return null;
172
+ }
173
+ const shown = props.shown ?? WORKERS_SHOWN;
174
+ const rest = props.workers.length - shown;
175
+ return (
176
+ <span className="sc-workers" title={props.workers.map(personName).join(", ")}>
177
+ {props.workers.slice(0, shown).map((worker) => (
178
+ <Avatar key={worker.id} person={worker} size={props.size ?? "sm"} />
179
+ ))}
180
+ {rest > 0 && <span className="sc-workers-more">+{rest}</span>}
181
+ </span>
182
+ );
183
+ }
184
+
148
185
  /** The words of the vocabulary as people read them, in English: the default pack's. */
149
186
  export const STATE_LABELS: Readonly<Record<TaskState, string>> = {
150
187
  idea: "Idea",
@@ -170,7 +207,7 @@ export const ROLE_LABELS: Readonly<Record<PersonRole, string>> = {
170
207
  export function RoleBadge(props: { readonly role: PersonRole }) {
171
208
  const words = useWords();
172
209
  return (
173
- <Badge tone={props.role === "admin" ? "point" : "neutral"}>
210
+ <Badge tone={props.role === "admin" ? "accent" : "neutral"}>
174
211
  {words.vocabulary.role[props.role]}
175
212
  </Badge>
176
213
  );
@@ -186,7 +223,11 @@ export function StateBadge(props: { readonly state: TaskState }) {
186
223
  : props.state === "in_progress" || props.state === "in_review"
187
224
  ? "accent"
188
225
  : "neutral";
189
- return <Badge tone={tone}>{words.vocabulary.state[props.state]}</Badge>;
226
+ return (
227
+ <Badge tone={tone} kind="state">
228
+ {words.vocabulary.state[props.state]}
229
+ </Badge>
230
+ );
190
231
  }
191
232
 
192
233
  export function PriorityBadge(props: { readonly priority: TaskPriority }) {
@@ -196,7 +237,11 @@ export function PriorityBadge(props: { readonly priority: TaskPriority }) {
196
237
  }
197
238
  const tone =
198
239
  props.priority === "urgent" ? "danger" : props.priority === "high" ? "point" : "neutral";
199
- return <Badge tone={tone}>{words.vocabulary.priority[props.priority]}</Badge>;
240
+ return (
241
+ <Badge tone={tone} kind="priority">
242
+ {words.vocabulary.priority[props.priority]}
243
+ </Badge>
244
+ );
200
245
  }
201
246
 
202
247
  /** A category as a badge, in its colour: a class the stylesheet paints, never a value typed by hand. */
@@ -220,7 +265,11 @@ export function GoalStateBadge(props: { readonly state: GoalState }) {
220
265
  : props.state === "dropped"
221
266
  ? "neutral"
222
267
  : "neutral";
223
- return <Badge tone={tone}>{words.vocabulary.goalState[props.state]}</Badge>;
268
+ return (
269
+ <Badge tone={tone} kind="state">
270
+ {words.vocabulary.goalState[props.state]}
271
+ </Badge>
272
+ );
224
273
  }
225
274
 
226
275
  /**
@@ -241,29 +290,45 @@ export function DueBadge(props: {
241
290
  const days = daysBetween(now, props.dueOn);
242
291
  const day = formatDay(props.dueOn, tag);
243
292
  if (!open) {
244
- return <Badge tone="neutral">{words.board.due.on(day)}</Badge>;
293
+ return (
294
+ <Badge tone="neutral" kind="due">
295
+ {words.board.due.on(day)}
296
+ </Badge>
297
+ );
245
298
  }
246
299
  if (days < 0) {
247
300
  return (
248
- <Badge tone="danger" title={day}>
301
+ <Badge tone="danger" kind="due" title={day}>
249
302
  {words.board.due.overdue(-days)}
250
303
  </Badge>
251
304
  );
252
305
  }
253
306
  if (days === 0) {
254
- return <Badge tone="warning">{words.board.due.today}</Badge>;
307
+ return (
308
+ <Badge tone="point" kind="due" className="sc-badge-due-today">
309
+ {words.board.due.today}
310
+ </Badge>
311
+ );
255
312
  }
256
313
  if (days === 1) {
257
- return <Badge tone="warning">{words.board.due.tomorrow}</Badge>;
314
+ return (
315
+ <Badge tone="point" kind="due">
316
+ {words.board.due.tomorrow}
317
+ </Badge>
318
+ );
258
319
  }
259
320
  if (days <= 7) {
260
321
  return (
261
- <Badge tone="point" title={day}>
322
+ <Badge tone="warning" kind="due" title={day}>
262
323
  {words.board.due.inDays(days)}
263
324
  </Badge>
264
325
  );
265
326
  }
266
- return <Badge tone="neutral">{words.board.due.on(day)}</Badge>;
327
+ return (
328
+ <Badge tone="neutral" kind="due">
329
+ {words.board.due.on(day)}
330
+ </Badge>
331
+ );
267
332
  }
268
333
 
269
334
  /** How far a goal is, read from its tasks: the done ones, the ones at work, and the rest. */
@@ -377,3 +442,16 @@ export function Time(props: {
377
442
  </time>
378
443
  );
379
444
  }
445
+
446
+ /** A link that reads as a button: where a form is, for instance. */
447
+ export function LinkButton(props: {
448
+ readonly href: string;
449
+ readonly variant?: "primary" | "secondary";
450
+ readonly children: ReactNode;
451
+ }) {
452
+ return (
453
+ <a className={`sc-button sc-button-${props.variant ?? "secondary"}`} href={props.href}>
454
+ {props.children}
455
+ </a>
456
+ );
457
+ }
@@ -1,13 +1,13 @@
1
1
  import type { RestTask, RestWork, WeekStart } from "../api.js";
2
- import { addDays, today as todayOf } from "../dates.js";
3
- import { useWords } from "../i18n/index.js";
2
+ import { deviceLanguage, firstDayOfWeek, today as todayOf, weekOf } from "../dates.js";
3
+ import { useLanguage, useWords } from "../i18n/index.js";
4
4
  import { Week } from "./calendar.js";
5
5
  import { ProjectMark } from "./projects.js";
6
6
  import { TaskList } from "./task-list.js";
7
7
  import { PersonChip, Time } from "./ui.js";
8
8
 
9
9
  // What is on a person's plate (ADR-0019), across the projects they may see: the week at the
10
- // top, with the open tasks assigned to them on its days, and what of theirs was done or
10
+ // top, with the open tasks they work on on its days, and what of theirs was done or
11
11
  // dropped on them, as a project's calendar shows it; below, the open tasks by when they are
12
12
  // due, and the decisions that wait for a person. Takes its data as props and nothing from the
13
13
  // network; the week shown is the address's (ADR-0010).
@@ -35,8 +35,10 @@ export interface WorkListProps {
35
35
  export function WorkList(props: WorkListProps) {
36
36
  const words = useWords();
37
37
  const work = words.work;
38
+ const { tag } = useLanguage();
38
39
  const now = props.today ?? todayOf();
39
- const weekEnd = addDays(now, 6);
40
+ // This week ends with the week's last day, as the workspace begins its weeks (ADR-0039).
41
+ const weekEnd = weekOf(now, firstDayOfWeek(deviceLanguage(tag), props.weekStart)).at(-1) ?? now;
40
42
  const { tasks, decisions } = props.work;
41
43
  const closed = props.work.closed ?? [];
42
44
  const projectOf = new Map([...tasks, ...closed].map((item) => [item.task.id, item.project]));
package/src/console.tsx CHANGED
@@ -22,6 +22,7 @@ import {
22
22
  type RestDecisionInput,
23
23
  type RestDecisionPatch,
24
24
  type RestEvent,
25
+ type RestMyDecisions,
25
26
  type RestProject,
26
27
  type RestProjectInput,
27
28
  type RestProjectPatch,
@@ -45,6 +46,7 @@ import { DecisionForm } from "./components/decision-form.js";
45
46
  import { DecisionCard, DecisionList, type DecisionListProps } from "./components/decision-list.js";
46
47
  import { EventFeed, type EventFeedProps } from "./components/event-feed.js";
47
48
  import { GoalForm, GoalList, type GoalListProps, GoalView, goalMonth } from "./components/goals.js";
49
+ import { MyDecisions } from "./components/my-decisions.js";
48
50
  import { PackTokenWays } from "./components/pack-token.js";
49
51
  import { PAGE_SIZE } from "./components/paged.js";
50
52
  import { ListingForm, PeopleList, type PeopleListProps } from "./components/people.js";
@@ -76,6 +78,7 @@ import {
76
78
  Callout,
77
79
  CategoryBadge,
78
80
  EmptyState,
81
+ LinkButton,
79
82
  personName,
80
83
  Spinner,
81
84
  StateBadge,
@@ -385,19 +388,6 @@ function NotWithToken() {
385
388
  return <EmptyState title={words.title} body={words.body} />;
386
389
  }
387
390
 
388
- /** A link that reads as a button: where a form is, for instance. */
389
- function LinkButton(props: {
390
- readonly href: string;
391
- readonly variant?: "primary" | "secondary";
392
- readonly children: React.ReactNode;
393
- }) {
394
- return (
395
- <a className={`sc-button sc-button-${props.variant ?? "secondary"}`} href={props.href}>
396
- {props.children}
397
- </a>
398
- );
399
- }
400
-
401
391
  function ProjectsPage(props: {
402
392
  readonly data: ConsoleData;
403
393
  readonly components: ConsoleComponents;
@@ -600,6 +590,28 @@ function TaskPage(props: {
600
590
  (fetched !== undefined && (fetched.id === route.id || fetched.number === route.number)
601
591
  ? fetched
602
592
  : undefined);
593
+ // The task this one is part of: among the board's, or asked for by itself when it was put away.
594
+ const parentId = task?.parentId ?? null;
595
+ const parentOnBoard =
596
+ parentId === null ? undefined : data.tasks.find((candidate) => candidate.id === parentId);
597
+ const [parentFetched, setParentFetched] = useState<RestTask | undefined>(undefined);
598
+ const parentWanted =
599
+ parentId !== null && parentOnBoard === undefined && data.projectLoaded ? parentId : undefined;
600
+ useEffect(() => {
601
+ if (parentWanted === undefined) {
602
+ return;
603
+ }
604
+ const controller = new AbortController();
605
+ fetchTask(parentWanted, controller.signal)
606
+ .then((found) => {
607
+ if (!controller.signal.aborted) {
608
+ setParentFetched(found);
609
+ }
610
+ })
611
+ .catch(() => undefined);
612
+ return () => controller.abort();
613
+ }, [parentWanted, fetchTask]);
614
+ const parent = parentOnBoard ?? (parentFetched?.id === parentId ? parentFetched : undefined);
603
615
  const [history, setHistory] = useState<readonly RestEvent[] | undefined>(undefined);
604
616
  const [comments, setComments] = useState<readonly RestComment[] | undefined>(undefined);
605
617
  const { taskHistory, taskComments } = data.actions;
@@ -653,6 +665,9 @@ function TaskPage(props: {
653
665
  task={task}
654
666
  people={data.people.filter(inWorkspace)}
655
667
  tasks={data.tasks}
668
+ parent={parent}
669
+ findTasks={data.actions.findTasks}
670
+ findGoals={data.actions.findGoals}
656
671
  decisions={data.decisions}
657
672
  runs={data.runs}
658
673
  comments={comments}
@@ -805,9 +820,9 @@ function ProjectPage(props: {
805
820
  <section className="sc-panel sc-panel-form" aria-label={words.task.newAria}>
806
821
  <TaskForm
807
822
  people={data.people.filter(inWorkspace)}
808
- parents={data.tasks}
823
+ findTasks={data.actions.findTasks}
809
824
  categories={data.categories}
810
- goals={data.goals}
825
+ findGoals={data.actions.findGoals}
811
826
  busy={busy}
812
827
  onSubmit={(input: RestTaskInput) =>
813
828
  void act(async () => {
@@ -888,6 +903,19 @@ function ProjectPage(props: {
888
903
  empty={<p className="sc-muted">{calendar.nothingOverdue}</p>}
889
904
  />
890
905
  </section>
906
+ {open.length > 0 && (
907
+ <section className="sc-task-section" aria-label={calendar.workload.title}>
908
+ <h2 className="sc-section-title">{calendar.workload.title}</h2>
909
+ <Workload
910
+ tasks={shown}
911
+ today={now}
912
+ weekStart={weekStartOf(data.me?.workspace)}
913
+ personHref={(person) =>
914
+ boardHref(key, person === undefined ? {} : { person: person.id })
915
+ }
916
+ />
917
+ </section>
918
+ )}
891
919
  <section className="sc-task-section" aria-label={calendar.unscheduled}>
892
920
  <h2 className="sc-section-title">
893
921
  {calendar.unscheduled} <span className="sc-column-count">{unscheduled.length}</span>
@@ -901,18 +929,6 @@ function ProjectPage(props: {
901
929
  />
902
930
  </section>
903
931
  </div>
904
- {open.length > 0 && (
905
- <section className="sc-task-section" aria-label={calendar.workload.title}>
906
- <h2 className="sc-section-title">{calendar.workload.title}</h2>
907
- <Workload
908
- tasks={shown}
909
- today={now}
910
- personHref={(person) =>
911
- boardHref(key, person === undefined ? {} : { person: person.id })
912
- }
913
- />
914
- </section>
915
- )}
916
932
  </>
917
933
  );
918
934
  }
@@ -1143,7 +1159,9 @@ function ProjectPage(props: {
1143
1159
  {error !== undefined && <Callout tone="danger">{error}</Callout>}
1144
1160
  <section className="sc-panel sc-panel-form" aria-label={words.decisions.raiseAria}>
1145
1161
  <DecisionForm
1146
- tasks={data.tasks}
1162
+ findTasks={data.actions.findTasks}
1163
+ deciders={data.members.map((member) => member.person)}
1164
+ me={data.me?.person ?? undefined}
1147
1165
  busy={busy}
1148
1166
  onSubmit={(input: RestDecisionInput) =>
1149
1167
  void act(async () => {
@@ -1796,7 +1814,7 @@ function WorkspaceSettingsPage(props: {
1796
1814
  }
1797
1815
 
1798
1816
  /** A file a person chose, as the client sends it: its name, its bytes and the type the browser says. */
1799
- /** Whether a person is in: who a picker of assignees and of members offers (ADR-0022). */
1817
+ /** Whether a person is in: who a picker of workers and of members offers (ADR-0022). */
1800
1818
  const inWorkspace = (person: { readonly status: "active" | "deactivated" }): boolean =>
1801
1819
  person.status === "active";
1802
1820
 
@@ -1986,6 +2004,108 @@ function PlatePage(props: {
1986
2004
  }
1987
2005
 
1988
2006
  /** What is on the person's own plate, across their projects. */
2007
+ /** How many of a person's decisions are read at a time. */
2008
+ const MY_DECISIONS_PAGE = 50;
2009
+
2010
+ /** One kind of a person's decisions, read a page at a time, as the page shows it. */
2011
+ interface DecisionsRead {
2012
+ readonly items: RestMyDecisions["items"] | undefined;
2013
+ readonly more: boolean;
2014
+ readonly busy: boolean;
2015
+ readonly problem: string | undefined;
2016
+ readonly readMore: () => void;
2017
+ /** Reads the first page again, after an answer changed what waits. */
2018
+ readonly reload: () => void;
2019
+ }
2020
+
2021
+ /** The decisions addressed to the person, or, with `raised`, those they raised for someone else (ADR-0042). */
2022
+ function useMyDecisions(
2023
+ myDecisions: ConsoleData["actions"]["myDecisions"],
2024
+ raised: boolean,
2025
+ couldNotLoad: string,
2026
+ ): DecisionsRead {
2027
+ const [items, setItems] = useState<RestMyDecisions["items"] | undefined>(undefined);
2028
+ const [more, setMore] = useState(false);
2029
+ const [busy, setBusy] = useState(false);
2030
+ const [problem, setProblem] = useState<string | undefined>(undefined);
2031
+ const read = useCallback(
2032
+ async (offset: number, signal?: AbortSignal) => {
2033
+ setBusy(true);
2034
+ try {
2035
+ const page = await myDecisions({ raised, limit: MY_DECISIONS_PAGE, offset }, signal);
2036
+ if (signal?.aborted === true) {
2037
+ return;
2038
+ }
2039
+ setItems((before) => (offset === 0 ? page.items : [...(before ?? []), ...page.items]));
2040
+ setMore(page.more);
2041
+ } catch (failure: unknown) {
2042
+ if (signal?.aborted !== true) {
2043
+ setProblem(errorWords(failure, couldNotLoad));
2044
+ }
2045
+ } finally {
2046
+ if (signal?.aborted !== true) {
2047
+ setBusy(false);
2048
+ }
2049
+ }
2050
+ },
2051
+ [myDecisions, raised, couldNotLoad],
2052
+ );
2053
+ useEffect(() => {
2054
+ const controller = new AbortController();
2055
+ setItems(undefined);
2056
+ setProblem(undefined);
2057
+ void read(0, controller.signal);
2058
+ return () => controller.abort();
2059
+ }, [read]);
2060
+ const readMore = useCallback(() => void read(items?.length ?? 0), [read, items]);
2061
+ const reload = useCallback(() => void read(0), [read]);
2062
+ return { items, more, busy, problem, readMore, reload };
2063
+ }
2064
+
2065
+ /**
2066
+ * A person's decisions (ADR-0042), laid out as a project's decisions page is: those waiting for
2067
+ * their answer, answered here, those they asked of others, and the record of both.
2068
+ */
2069
+ function MyDecisionsPage(props: { readonly data: ConsoleData }) {
2070
+ const words = useWords();
2071
+ const { myDecisions, answerDecisionIn } = props.data.actions;
2072
+ const addressed = useMyDecisions(myDecisions, false, words.myDecisions.couldNotLoad);
2073
+ const raised = useMyDecisions(myDecisions, true, words.myDecisions.couldNotLoad);
2074
+ const { busy, error, act } = useAction();
2075
+ const problem = addressed.problem ?? raised.problem ?? error;
2076
+ const onAnswer = (projectKey: string, decision: RestDecision, input: RestAnswerInput) =>
2077
+ void act(async () => {
2078
+ await answerDecisionIn(projectKey, decision.id, input);
2079
+ addressed.reload();
2080
+ raised.reload();
2081
+ });
2082
+ return (
2083
+ <>
2084
+ <div className="sc-page-head">
2085
+ <h1 className="sc-page-title">{words.myDecisions.title}</h1>
2086
+ </div>
2087
+ {problem !== undefined && <Callout tone="danger">{problem}</Callout>}
2088
+ {addressed.items === undefined || raised.items === undefined ? (
2089
+ problem === undefined && (
2090
+ <div className="sc-loading">
2091
+ <Spinner label={words.myDecisions.loading} />
2092
+ </div>
2093
+ )
2094
+ ) : addressed.items.length === 0 && raised.items.length === 0 ? (
2095
+ <EmptyState title={words.myDecisions.empty.title} body={words.myDecisions.empty.body} />
2096
+ ) : (
2097
+ <MyDecisions
2098
+ addressed={{ items: addressed.items, more: addressed.more, onMore: addressed.readMore }}
2099
+ raised={{ items: raised.items, more: raised.more, onMore: raised.readMore }}
2100
+ decisionHref={decisionHref}
2101
+ onAnswer={onAnswer}
2102
+ busy={busy || addressed.busy || raised.busy}
2103
+ />
2104
+ )}
2105
+ </>
2106
+ );
2107
+ }
2108
+
1989
2109
  function WorkPage(props: {
1990
2110
  readonly data: ConsoleData;
1991
2111
  readonly components: ConsoleComponents;
@@ -2052,6 +2172,9 @@ function WorkspacePage(props: {
2052
2172
  if (route.name === "work") {
2053
2173
  return <WorkPage data={data} components={components} week={route.week} />;
2054
2174
  }
2175
+ if (route.name === "my-decisions") {
2176
+ return <MyDecisionsPage data={data} />;
2177
+ }
2055
2178
  if (route.name === "person-work") {
2056
2179
  return <PersonWorkPage id={route.id} data={data} components={components} week={route.week} />;
2057
2180
  }
@@ -2295,6 +2418,11 @@ export function createConsole(config: ConsoleConfig = {}): ConsoleApp {
2295
2418
  current: route.name === "projects" || route.name === "new-project",
2296
2419
  },
2297
2420
  { href: PATHS.work, label: words.nav.work, current: route.name === "work" },
2421
+ {
2422
+ href: PATHS.decisions,
2423
+ label: words.nav.myDecisions,
2424
+ current: route.name === "my-decisions",
2425
+ },
2298
2426
  {
2299
2427
  href: PATHS.people,
2300
2428
  label: words.nav.people,
package/src/data.ts CHANGED
@@ -24,6 +24,7 @@ import {
24
24
  type RestGoal,
25
25
  type RestGoalInput,
26
26
  type RestGoalPatch,
27
+ type RestGoals,
27
28
  type RestListing,
28
29
  type RestListingInput,
29
30
  type RestMe,
@@ -31,6 +32,7 @@ import {
31
32
  type RestMemberInput,
32
33
  type RestMemberPatch,
33
34
  type RestMePatch,
35
+ type RestMyDecisions,
34
36
  type RestOrganization,
35
37
  type RestPackConnect,
36
38
  type RestPackConnectStarted,
@@ -107,8 +109,22 @@ export interface ConsoleActions {
107
109
  page: { readonly limit: number; readonly offset: number },
108
110
  signal?: AbortSignal,
109
111
  ): Promise<RestTasks>;
112
+ /** What words find among the current tasks of the project, a page at a time, for a picker (ADR-0037); the first page of them for no words. */
113
+ findTasks(
114
+ query: string,
115
+ page: { readonly limit: number; readonly offset: number },
116
+ signal?: AbortSignal,
117
+ ): Promise<RestTasks>;
118
+ /** What words find among the open goals of the project, a page at a time, for a picker. */
119
+ findGoals(
120
+ query: string,
121
+ page: { readonly limit: number; readonly offset: number },
122
+ signal?: AbortSignal,
123
+ ): Promise<RestGoals>;
110
124
  raiseDecision(input: RestDecisionInput): Promise<RestDecision>;
111
125
  answerDecision(id: string, input: RestAnswerInput): Promise<RestDecision>;
126
+ /** Answers a decision of any project the person may see, by its project's key: for the person's own page of decisions (ADR-0042). */
127
+ answerDecisionIn(projectKey: string, id: string, input: RestAnswerInput): Promise<RestDecision>;
112
128
  /** Grows a decision's record: its context, or what followed. */
113
129
  updateDecision(id: string, patch: RestDecisionPatch): Promise<RestDecision>;
114
130
  /** Sets a goal on the roadmap of the project the page is on; for an owner. */
@@ -116,6 +132,17 @@ export interface ConsoleActions {
116
132
  updateGoal(id: string, patch: RestGoalPatch): Promise<RestGoal>;
117
133
  /** What is on the person's plate across their projects, read when the page asks. */
118
134
  work(signal?: AbortSignal): Promise<RestWork>;
135
+ /** The decisions addressed to the person (ADR-0042) across their projects, a page at a time, read when the page asks. */
136
+ myDecisions(
137
+ filter: {
138
+ readonly open?: boolean;
139
+ /** Those the person raised for someone else, in place of those addressed to them. */
140
+ readonly raised?: boolean;
141
+ readonly limit: number;
142
+ readonly offset: number;
143
+ },
144
+ signal?: AbortSignal,
145
+ ): Promise<RestMyDecisions>;
119
146
  /** The categories tasks are sorted into: kept by an administrator. */
120
147
  createCategory(input: RestCategoryInput): Promise<RestCategory>;
121
148
  updateCategory(key: string, patch: RestCategoryPatch): Promise<RestCategory>;
@@ -265,6 +292,32 @@ async function readTasks(
265
292
  return { items };
266
293
  }
267
294
 
295
+ /** How many pages of the roadmap are read before it is shown as it stands. */
296
+ const GOALS_MAX_PAGES = 20;
297
+
298
+ /**
299
+ * The goals of a project, whole: page after page while the server says there is more, since
300
+ * the roadmap, the calendar and the filters draw them all.
301
+ */
302
+ async function readGoals(
303
+ scope: ProjectClient,
304
+ signal: AbortSignal | undefined,
305
+ ): Promise<{ readonly items: RestGoal[] }> {
306
+ const items: RestGoal[] = [];
307
+ for (let page = 0; page < GOALS_MAX_PAGES; page += 1) {
308
+ const result = await scope.goals({ limit: LIST_LIMIT, offset: page * LIST_LIMIT }, signal);
309
+ items.push(...result.items);
310
+ if (!result.more) {
311
+ break;
312
+ }
313
+ }
314
+ return { items };
315
+ }
316
+
317
+ /** The words of a search as the API takes them: none for a blank. */
318
+ const searchWordsOf = (query: string): { readonly q?: string } =>
319
+ query.trim() === "" ? {} : { q: query.trim() };
320
+
268
321
  /** How many pages of the decisions are read before the record is shown as it stands. */
269
322
  const DECISIONS_MAX_PAGES = 20;
270
323
 
@@ -353,7 +406,7 @@ export function useConsoleData(client: ConsoleClient, projectKey: string | undef
353
406
  scope.get(signal),
354
407
  readTasks(scope, signal),
355
408
  readDecisions(scope, signal),
356
- scope.goals({}, signal),
409
+ readGoals(scope, signal),
357
410
  scope.runs(undefined, signal),
358
411
  scope.members(signal),
359
412
  ]);
@@ -608,11 +661,17 @@ export function useConsoleData(client: ConsoleClient, projectKey: string | undef
608
661
  return archived;
609
662
  },
610
663
  archivedTasks: (page, signal) => scope().tasks({ archived: true, ...page }, signal),
664
+ findTasks: (query, page, signal) =>
665
+ scope().tasks({ archived: false, ...searchWordsOf(query), ...page }, signal),
666
+ findGoals: (query, page, signal) =>
667
+ scope().goals({ open: true, ...searchWordsOf(query), ...page }, signal),
611
668
  async raiseDecision(input) {
612
669
  const decision = await scope().raiseDecision(input);
613
670
  await refreshProject();
614
671
  return decision;
615
672
  },
673
+ answerDecisionIn: (projectKey, id, input) =>
674
+ client.project(projectKey).answerDecision(id, input),
616
675
  async answerDecision(id, input) {
617
676
  const decision = await scope().answerDecision(id, input);
618
677
  await refreshProject();
@@ -636,6 +695,7 @@ export function useConsoleData(client: ConsoleClient, projectKey: string | undef
636
695
  return goal;
637
696
  },
638
697
  work: (signal) => client.work(signal),
698
+ myDecisions: (filter, signal) => client.myDecisions(filter, signal),
639
699
  async createCategory(input) {
640
700
  const category = await client.createCategory(input);
641
701
  setCategories((await client.categories()).items);