@skillcdn/console 0.1.8 → 0.1.10

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 (147) hide show
  1. package/README.md +5 -5
  2. package/dist/api.d.ts +5 -5
  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.map +1 -1
  7. package/dist/cli/cli.js +255 -10
  8. package/dist/cli/cli.js.map +1 -1
  9. package/dist/cli/format.d.ts +9 -1
  10. package/dist/cli/format.d.ts.map +1 -1
  11. package/dist/cli/format.js +80 -0
  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 +62 -7
  15. package/dist/cli/help.js.map +1 -1
  16. package/dist/client.d.ts +31 -4
  17. package/dist/client.d.ts.map +1 -1
  18. package/dist/client.js +19 -3
  19. package/dist/client.js.map +1 -1
  20. package/dist/components/board.d.ts +27 -1
  21. package/dist/components/board.d.ts.map +1 -1
  22. package/dist/components/board.js +75 -7
  23. package/dist/components/board.js.map +1 -1
  24. package/dist/components/calendar.d.ts +34 -0
  25. package/dist/components/calendar.d.ts.map +1 -0
  26. package/dist/components/calendar.js +96 -0
  27. package/dist/components/calendar.js.map +1 -0
  28. package/dist/components/categories.d.ts +13 -0
  29. package/dist/components/categories.d.ts.map +1 -0
  30. package/dist/components/categories.js +52 -0
  31. package/dist/components/categories.js.map +1 -0
  32. package/dist/components/decision-list.d.ts +10 -0
  33. package/dist/components/decision-list.d.ts.map +1 -1
  34. package/dist/components/decision-list.js +14 -1
  35. package/dist/components/decision-list.js.map +1 -1
  36. package/dist/components/event-feed.d.ts.map +1 -1
  37. package/dist/components/event-feed.js +12 -0
  38. package/dist/components/event-feed.js.map +1 -1
  39. package/dist/components/goals.d.ts +35 -0
  40. package/dist/components/goals.d.ts.map +1 -0
  41. package/dist/components/goals.js +45 -0
  42. package/dist/components/goals.js.map +1 -0
  43. package/dist/components/task-form.d.ts +5 -1
  44. package/dist/components/task-form.d.ts.map +1 -1
  45. package/dist/components/task-form.js +13 -1
  46. package/dist/components/task-form.js.map +1 -1
  47. package/dist/components/task-list.d.ts +16 -0
  48. package/dist/components/task-list.d.ts.map +1 -0
  49. package/dist/components/task-list.js +14 -0
  50. package/dist/components/task-list.js.map +1 -0
  51. package/dist/components/task-view.d.ts +9 -1
  52. package/dist/components/task-view.d.ts.map +1 -1
  53. package/dist/components/task-view.js +10 -4
  54. package/dist/components/task-view.js.map +1 -1
  55. package/dist/components/ui.d.ts +30 -1
  56. package/dist/components/ui.d.ts.map +1 -1
  57. package/dist/components/ui.js +60 -0
  58. package/dist/components/ui.js.map +1 -1
  59. package/dist/components/work.d.ts +10 -0
  60. package/dist/components/work.d.ts.map +1 -0
  61. package/dist/components/work.js +29 -0
  62. package/dist/components/work.js.map +1 -0
  63. package/dist/console.css +511 -0
  64. package/dist/console.d.ts +8 -0
  65. package/dist/console.d.ts.map +1 -1
  66. package/dist/console.js +124 -8
  67. package/dist/console.js.map +1 -1
  68. package/dist/data.d.ts +14 -1
  69. package/dist/data.d.ts.map +1 -1
  70. package/dist/data.js +36 -2
  71. package/dist/data.js.map +1 -1
  72. package/dist/dates.d.ts +39 -0
  73. package/dist/dates.d.ts.map +1 -0
  74. package/dist/dates.js +120 -0
  75. package/dist/dates.js.map +1 -0
  76. package/dist/i18n/en.d.ts +162 -1
  77. package/dist/i18n/en.d.ts.map +1 -1
  78. package/dist/i18n/en.js +174 -1
  79. package/dist/i18n/en.js.map +1 -1
  80. package/dist/i18n/ko.d.ts.map +1 -1
  81. package/dist/i18n/ko.js +169 -1
  82. package/dist/i18n/ko.js.map +1 -1
  83. package/dist/index.d.ts +10 -4
  84. package/dist/index.d.ts.map +1 -1
  85. package/dist/index.js +10 -4
  86. package/dist/index.js.map +1 -1
  87. package/dist/limits.d.ts +5 -0
  88. package/dist/limits.d.ts.map +1 -1
  89. package/dist/limits.js +5 -0
  90. package/dist/limits.js.map +1 -1
  91. package/dist/router.d.ts +50 -1
  92. package/dist/router.d.ts.map +1 -1
  93. package/dist/router.js +74 -1
  94. package/dist/router.js.map +1 -1
  95. package/dist/routes.d.ts +7 -1
  96. package/dist/routes.d.ts.map +1 -1
  97. package/dist/routes.js +9 -0
  98. package/dist/routes.js.map +1 -1
  99. package/dist/schemas.d.ts +366 -0
  100. package/dist/schemas.d.ts.map +1 -1
  101. package/dist/schemas.js +112 -3
  102. package/dist/schemas.js.map +1 -1
  103. package/dist/vocabulary.d.ts +15 -1
  104. package/dist/vocabulary.d.ts.map +1 -1
  105. package/dist/vocabulary.js +26 -0
  106. package/dist/vocabulary.js.map +1 -1
  107. package/docs/specs/cli.md +11 -4
  108. package/docs/specs/rest.md +33 -8
  109. package/package.json +1 -1
  110. package/skills/building-a-console/SKILL.md +4 -4
  111. package/skills/building-a-console/references/the-default-console.md +1 -1
  112. package/skills/deploying-a-console/SKILL.md +11 -10
  113. package/skills/deploying-a-console/assets/deployer-policy.json +13 -12
  114. package/skills/deploying-a-console/assets/secrets.example.json +4 -4
  115. package/skills/deploying-a-console/references/access.md +10 -9
  116. package/skills/deploying-a-console/references/resources.md +107 -50
  117. package/skills/deploying-a-console/references/sizing.md +1 -1
  118. package/skills/deploying-a-console/scripts/store-secrets.mjs +1 -15
  119. package/skills/working-the-board/SKILL.md +7 -6
  120. package/src/api.ts +30 -0
  121. package/src/cli/cli.ts +311 -10
  122. package/src/cli/format.ts +88 -0
  123. package/src/cli/help.ts +69 -7
  124. package/src/client.ts +70 -2
  125. package/src/components/board.tsx +205 -12
  126. package/src/components/calendar.tsx +255 -0
  127. package/src/components/categories.tsx +209 -0
  128. package/src/components/decision-list.tsx +62 -5
  129. package/src/components/event-feed.tsx +12 -0
  130. package/src/components/goals.tsx +264 -0
  131. package/src/components/task-form.tsx +78 -1
  132. package/src/components/task-list.tsx +61 -0
  133. package/src/components/task-view.tsx +74 -3
  134. package/src/components/ui.tsx +116 -1
  135. package/src/components/work.tsx +83 -0
  136. package/src/console.tsx +337 -4
  137. package/src/data.ts +63 -8
  138. package/src/dates.ts +143 -0
  139. package/src/i18n/en.ts +184 -1
  140. package/src/i18n/ko.ts +175 -1
  141. package/src/index.ts +58 -1
  142. package/src/limits.ts +5 -0
  143. package/src/router.ts +119 -6
  144. package/src/routes.ts +10 -0
  145. package/src/schemas.ts +141 -1
  146. package/src/styles/console.css +511 -0
  147. package/src/vocabulary.ts +33 -0
@@ -1,5 +1,15 @@
1
1
  import type { ButtonHTMLAttributes, ReactNode } from "react";
2
- import type { PersonRole, RestPerson, TaskPriority, TaskState } from "../api.js";
2
+ import {
3
+ type CategoryColor,
4
+ type GoalState,
5
+ OPEN_TASK_STATES,
6
+ type PersonRole,
7
+ type RestPerson,
8
+ type RestTaskCounts,
9
+ type TaskPriority,
10
+ type TaskState,
11
+ } from "../api.js";
12
+ import { daysBetween, formatDay, today } from "../dates.js";
3
13
  import { useLanguage, useWords } from "../i18n/index.js";
4
14
 
5
15
  // Small building blocks. Each one is a class in styles/console.css and nothing more. Classes
@@ -157,6 +167,111 @@ export function PriorityBadge(props: { readonly priority: TaskPriority }) {
157
167
  return <Badge tone={tone}>{words.vocabulary.priority[props.priority]}</Badge>;
158
168
  }
159
169
 
170
+ /** A category as a badge, in its colour: a class the stylesheet paints, never a value typed by hand. */
171
+ export function CategoryBadge(props: {
172
+ readonly category: { readonly name: string; readonly color: CategoryColor };
173
+ }) {
174
+ return (
175
+ <span className={cx("sc-badge", "sc-badge-category", `sc-category-${props.category.color}`)}>
176
+ {props.category.name}
177
+ </span>
178
+ );
179
+ }
180
+
181
+ export function GoalStateBadge(props: { readonly state: GoalState }) {
182
+ const words = useWords();
183
+ const tone =
184
+ props.state === "done"
185
+ ? "success"
186
+ : props.state === "active"
187
+ ? "accent"
188
+ : props.state === "dropped"
189
+ ? "neutral"
190
+ : "neutral";
191
+ return <Badge tone={tone}>{words.vocabulary.goalState[props.state]}</Badge>;
192
+ }
193
+
194
+ /**
195
+ * When a task is due, as a person needs to read it: how late it is while it is open and past
196
+ * its day, how soon it is due within the week, and the day itself otherwise. A task that is
197
+ * over keeps its day as a plain fact.
198
+ */
199
+ export function DueBadge(props: {
200
+ readonly dueOn: string;
201
+ readonly state: TaskState;
202
+ /** The day to count from; the browser's today when left out. */
203
+ readonly today?: string | undefined;
204
+ }) {
205
+ const words = useWords();
206
+ const { tag } = useLanguage();
207
+ const now = props.today ?? today();
208
+ const open = OPEN_TASK_STATES.includes(props.state);
209
+ const days = daysBetween(now, props.dueOn);
210
+ const day = formatDay(props.dueOn, tag);
211
+ if (!open) {
212
+ return <Badge tone="neutral">{words.board.due.on(day)}</Badge>;
213
+ }
214
+ if (days < 0) {
215
+ return (
216
+ <Badge tone="danger" title={day}>
217
+ {words.board.due.overdue(-days)}
218
+ </Badge>
219
+ );
220
+ }
221
+ if (days === 0) {
222
+ return <Badge tone="warning">{words.board.due.today}</Badge>;
223
+ }
224
+ if (days === 1) {
225
+ return <Badge tone="warning">{words.board.due.tomorrow}</Badge>;
226
+ }
227
+ if (days <= 7) {
228
+ return (
229
+ <Badge tone="point" title={day}>
230
+ {words.board.due.inDays(days)}
231
+ </Badge>
232
+ );
233
+ }
234
+ return <Badge tone="neutral">{words.board.due.on(day)}</Badge>;
235
+ }
236
+
237
+ /** How far a goal is, read from its tasks: the done ones, the ones at work, and the rest. */
238
+ export function ProgressBar(props: { readonly counts: RestTaskCounts }) {
239
+ const words = useWords();
240
+ const { counts } = props;
241
+ const total = counts.idea + counts.ready + counts.in_progress + counts.in_review + counts.done;
242
+ const working = counts.in_progress + counts.in_review;
243
+ const share = (count: number) => (total === 0 ? 0 : (count / total) * 100);
244
+ return (
245
+ <div className="sc-progress">
246
+ <div
247
+ className="sc-progress-bar"
248
+ role="progressbar"
249
+ aria-valuemin={0}
250
+ aria-valuemax={total}
251
+ aria-valuenow={counts.done}
252
+ aria-label={words.roadmap.progress(counts.done, total)}
253
+ >
254
+ <span className="sc-progress-done" style={{ width: `${share(counts.done)}%` }} />
255
+ <span className="sc-progress-working" style={{ width: `${share(working)}%` }} />
256
+ </div>
257
+ <span className="sc-progress-words">
258
+ {words.roadmap.progress(counts.done, total)}
259
+ {working > 0 && ` · ${words.roadmap.inProgress(working)}`}
260
+ </span>
261
+ </div>
262
+ );
263
+ }
264
+
265
+ /** A day as people read one, in the language of the page. */
266
+ export function Day(props: { readonly day: string }) {
267
+ const { tag } = useLanguage();
268
+ return (
269
+ <time className="sc-time" dateTime={props.day}>
270
+ {formatDay(props.day, tag)}
271
+ </time>
272
+ );
273
+ }
274
+
160
275
  export function Spinner(props: { readonly label: string }) {
161
276
  return (
162
277
  <span className="sc-spinner" role="status">
@@ -0,0 +1,83 @@
1
+ import type { RestTask, RestWork } from "../api.js";
2
+ import { addDays, today as todayOf } from "../dates.js";
3
+ import { useWords } from "../i18n/index.js";
4
+ import { TaskList } from "./task-list.js";
5
+ import { PersonChip, Time } from "./ui.js";
6
+
7
+ // What is on a person's plate (ADR-0019), across the projects they may see: the open tasks
8
+ // assigned to them, by when they are due, and the decisions that wait for a person. Takes its
9
+ // data as props and nothing from the network.
10
+
11
+ export interface WorkListProps {
12
+ readonly work: RestWork;
13
+ /** The day to count from; the browser's today when left out. */
14
+ readonly today?: string | undefined;
15
+ readonly taskHref: (projectKey: string, task: RestTask) => string;
16
+ readonly decisionHref: (projectKey: string, decisionId: string) => string;
17
+ }
18
+
19
+ export function WorkList(props: WorkListProps) {
20
+ const words = useWords();
21
+ const work = words.work;
22
+ const now = props.today ?? todayOf();
23
+ const weekEnd = addDays(now, 6);
24
+ const { tasks, decisions } = props.work;
25
+ const projectOf = new Map(tasks.map((item) => [item.task.id, item.project]));
26
+ const hrefOf = (task: RestTask) => props.taskHref(projectOf.get(task.id)?.key ?? "", task);
27
+ const nameOf = (task: RestTask) => projectOf.get(task.id)?.name;
28
+ const groups: { readonly title: string; readonly tasks: RestTask[] }[] = [
29
+ { title: work.overdue, tasks: [] },
30
+ { title: work.thisWeek, tasks: [] },
31
+ { title: work.later, tasks: [] },
32
+ { title: work.noDay, tasks: [] },
33
+ ];
34
+ for (const { task } of tasks) {
35
+ const index = task.dueOn === null ? 3 : task.dueOn < now ? 0 : task.dueOn <= weekEnd ? 1 : 2;
36
+ groups[index]?.tasks.push(task);
37
+ }
38
+ return (
39
+ <div className="sc-work">
40
+ {groups
41
+ .filter((group) => group.tasks.length > 0)
42
+ .map((group) => (
43
+ <section key={group.title} className="sc-task-section" aria-label={group.title}>
44
+ <h2 className="sc-section-title">
45
+ {group.title} <span className="sc-column-count">{group.tasks.length}</span>
46
+ </h2>
47
+ <TaskList
48
+ tasks={group.tasks}
49
+ taskHref={hrefOf}
50
+ projectOf={nameOf}
51
+ showState
52
+ today={now}
53
+ />
54
+ </section>
55
+ ))}
56
+ <section className="sc-task-section" aria-label={work.decisions}>
57
+ <h2 className="sc-section-title">
58
+ {work.decisions} <span className="sc-column-count">{decisions.length}</span>
59
+ </h2>
60
+ {decisions.length === 0 ? (
61
+ <p className="sc-muted">{work.noDecisions}</p>
62
+ ) : (
63
+ <ol className="sc-decision-log">
64
+ {decisions.map(({ project, decision }) => (
65
+ <li key={decision.id} className="sc-decision-row">
66
+ <a
67
+ className="sc-decision-row-question"
68
+ href={props.decisionHref(project.key, decision.id)}
69
+ >
70
+ {decision.question}
71
+ </a>
72
+ <span className="sc-task-row-project">{work.inProject(project.name)}</span>
73
+ <span className="sc-decision-row-meta">
74
+ <PersonChip person={decision.raisedBy} /> <Time iso={decision.createdAt} />
75
+ </span>
76
+ </li>
77
+ ))}
78
+ </ol>
79
+ )}
80
+ </section>
81
+ </div>
82
+ );
83
+ }
package/src/console.tsx CHANGED
@@ -3,6 +3,7 @@ import { createRoot, type Root } from "react-dom/client";
3
3
  import {
4
4
  type ConsoleClient,
5
5
  createClient,
6
+ OPEN_TASK_STATES,
6
7
  type RestAnswerInput,
7
8
  type RestDecision,
8
9
  type RestDecisionInput,
@@ -15,13 +16,17 @@ import {
15
16
  type RestTaskInput,
16
17
  type RestToken,
17
18
  type RestTokenCreated,
19
+ type RestWork,
18
20
  type TaskState,
19
21
  } from "./api.js";
20
- import { Board, type BoardProps } from "./components/board.js";
22
+ import { Board, BoardFilters, type BoardProps, byDue, filterTasks } from "./components/board.js";
23
+ import { Calendar, type CalendarProps, Workload } from "./components/calendar.js";
24
+ import { CategoryList, type CategoryListProps } from "./components/categories.js";
21
25
  import { ConnectWords } from "./components/connect.js";
22
26
  import { DecisionForm } from "./components/decision-form.js";
23
27
  import { DecisionCard, DecisionList, type DecisionListProps } from "./components/decision-list.js";
24
28
  import { EventFeed, type EventFeedProps } from "./components/event-feed.js";
29
+ import { GoalForm, GoalList, type GoalListProps, GoalView } from "./components/goals.js";
25
30
  import { PeopleList, type PeopleListProps } from "./components/people.js";
26
31
  import {
27
32
  MemberList,
@@ -40,11 +45,14 @@ import {
40
45
  import { SignIn, type SignInProps } from "./components/sign-in.js";
41
46
  import { SkillList, type SkillListProps } from "./components/skills.js";
42
47
  import { TaskForm } from "./components/task-form.js";
48
+ import { TaskList } from "./components/task-list.js";
43
49
  import { TaskView, type TaskViewProps } from "./components/task-view.js";
44
50
  import { NewToken, TokenForm, TokenList, type TokenListProps } from "./components/tokens.js";
45
51
  import { Button, Callout, EmptyState, Spinner } from "./components/ui.js";
52
+ import { WorkList, type WorkListProps } from "./components/work.js";
46
53
  import { ConnectPage } from "./connect-page.js";
47
54
  import { type ConsoleData, useConsoleData } from "./data.js";
55
+ import { monthOf, today } from "./dates.js";
48
56
  import { DocsPage } from "./docs-page.js";
49
57
  import {
50
58
  chooseLanguage,
@@ -54,11 +62,15 @@ import {
54
62
  useWords,
55
63
  } from "./i18n/index.js";
56
64
  import {
65
+ boardHref,
66
+ calendarHref,
57
67
  decisionHref,
58
68
  docHref,
69
+ goalHref,
59
70
  matchRoute,
60
71
  type Navigation,
61
72
  newDecisionHref,
73
+ newGoalHref,
62
74
  newTaskHref,
63
75
  PATHS,
64
76
  personAgentsHref,
@@ -69,7 +81,7 @@ import {
69
81
  withoutSignInParam,
70
82
  } from "./router.js";
71
83
  import { projectPath } from "./routes.js";
72
- import { useAction } from "./use-action.js";
84
+ import { errorWords, useAction } from "./use-action.js";
73
85
 
74
86
  // The composition of the default console: the pages assembled from the components, with the
75
87
  // places a team may replace named. The UI the image serves is this, with the default config.
@@ -90,6 +102,10 @@ export interface ConsoleComponents {
90
102
  readonly SkillList: ComponentType<SkillListProps>;
91
103
  readonly ProjectList: ComponentType<ProjectListProps>;
92
104
  readonly MemberList: ComponentType<MemberListProps>;
105
+ readonly Calendar: ComponentType<CalendarProps>;
106
+ readonly GoalList: ComponentType<GoalListProps>;
107
+ readonly WorkList: ComponentType<WorkListProps>;
108
+ readonly CategoryList: ComponentType<CategoryListProps>;
93
109
  }
94
110
 
95
111
  export const DEFAULT_COMPONENTS: ConsoleComponents = {
@@ -104,6 +120,10 @@ export const DEFAULT_COMPONENTS: ConsoleComponents = {
104
120
  SkillList,
105
121
  ProjectList,
106
122
  MemberList,
123
+ Calendar,
124
+ GoalList,
125
+ WorkList,
126
+ CategoryList,
107
127
  };
108
128
 
109
129
  export interface ConsoleConfig {
@@ -426,8 +446,11 @@ function TaskPage(props: {
426
446
  decisions={data.decisions}
427
447
  runs={data.runs}
428
448
  history={history}
449
+ categories={data.categories}
450
+ goals={data.goals}
429
451
  taskHref={(candidate) => taskHref(key, candidate.number)}
430
452
  decisionHref={(decisionId) => decisionHref(key, decisionId)}
453
+ goalHref={(goalId) => goalHref(key, goalId)}
431
454
  fileHref={(artifact) => projectPath(key, "files", artifact.id)}
432
455
  docHref={(path) => docHref(key, path)}
433
456
  form={route.form}
@@ -483,6 +506,8 @@ function ProjectPage(props: {
483
506
  void act(() => data.actions.updateDecision(decision.id, patch));
484
507
 
485
508
  if (route.name === "board") {
509
+ const me = data.me?.person ?? undefined;
510
+ const shown = filterTasks(data.tasks, route.filter, me);
486
511
  return (
487
512
  <>
488
513
  <div className="sc-page-head">
@@ -493,8 +518,21 @@ function ProjectPage(props: {
493
518
  </div>
494
519
  {project.description.length > 0 && <p className="sc-lead">{project.description}</p>}
495
520
  {error !== undefined && <Callout tone="danger">{error}</Callout>}
521
+ {data.tasks.length > 0 && (
522
+ <BoardFilters
523
+ filter={route.filter}
524
+ categories={data.categories}
525
+ goals={data.goals}
526
+ people={data.people}
527
+ me={me}
528
+ shown={shown.length}
529
+ total={data.tasks.length}
530
+ onChange={(filter) => navigation.replace(boardHref(key, filter))}
531
+ />
532
+ )}
496
533
  <components.Board
497
- tasks={data.tasks}
534
+ tasks={shown}
535
+ runs={data.runs}
498
536
  taskHref={href}
499
537
  onOpen={(task) => navigation.go(href(task))}
500
538
  onMove={onMove}
@@ -522,6 +560,8 @@ function ProjectPage(props: {
522
560
  <TaskForm
523
561
  people={data.people}
524
562
  parents={data.tasks}
563
+ categories={data.categories}
564
+ goals={data.goals}
525
565
  busy={busy}
526
566
  onSubmit={(input: RestTaskInput) =>
527
567
  void act(async () => {
@@ -549,6 +589,183 @@ function ProjectPage(props: {
549
589
  />
550
590
  );
551
591
  }
592
+ if (route.name === "calendar") {
593
+ const now = today();
594
+ const month = route.month ?? monthOf(now);
595
+ const open = data.tasks.filter((task) => OPEN_TASK_STATES.includes(task.state));
596
+ const overdue = open.filter((task) => task.dueOn !== null && task.dueOn < now).sort(byDue);
597
+ const unscheduled = open.filter((task) => task.dueOn === null).sort(byDue);
598
+ const calendar = words.calendar;
599
+ return (
600
+ <>
601
+ <div className="sc-page-head">
602
+ <h1 className="sc-page-title">{calendar.title}</h1>
603
+ <LinkButton href={newTaskHref(key)} variant="primary">
604
+ {words.board.writeTask}
605
+ </LinkButton>
606
+ </div>
607
+ <p className="sc-lead">{calendar.lead}</p>
608
+ <components.Calendar
609
+ tasks={data.tasks}
610
+ month={month}
611
+ today={now}
612
+ taskHref={href}
613
+ monthHref={(wanted) => calendarHref(key, wanted)}
614
+ />
615
+ <div className="sc-calendar-below">
616
+ <section className="sc-task-section" aria-label={calendar.overdue}>
617
+ <h2 className="sc-section-title">
618
+ {calendar.overdue} <span className="sc-column-count">{overdue.length}</span>
619
+ </h2>
620
+ <TaskList
621
+ tasks={overdue}
622
+ taskHref={href}
623
+ today={now}
624
+ showState
625
+ empty={<p className="sc-muted">{calendar.nothingOverdue}</p>}
626
+ />
627
+ </section>
628
+ <section className="sc-task-section" aria-label={calendar.unscheduled}>
629
+ <h2 className="sc-section-title">
630
+ {calendar.unscheduled} <span className="sc-column-count">{unscheduled.length}</span>
631
+ </h2>
632
+ <TaskList
633
+ tasks={unscheduled}
634
+ taskHref={href}
635
+ today={now}
636
+ showState
637
+ empty={<p className="sc-muted">{calendar.allScheduled}</p>}
638
+ />
639
+ </section>
640
+ </div>
641
+ {open.length > 0 && (
642
+ <section className="sc-task-section" aria-label={calendar.workload.title}>
643
+ <h2 className="sc-section-title">{calendar.workload.title}</h2>
644
+ <p className="sc-lead">{calendar.workload.lead}</p>
645
+ <Workload
646
+ tasks={data.tasks}
647
+ today={now}
648
+ personHref={(person) =>
649
+ boardHref(key, person === undefined ? {} : { person: person.id })
650
+ }
651
+ />
652
+ </section>
653
+ )}
654
+ </>
655
+ );
656
+ }
657
+ if (route.name === "roadmap" || route.name === "new-goal") {
658
+ const roadmap = words.roadmap;
659
+ const making = route.name === "new-goal";
660
+ if (making && !owner) {
661
+ return <EmptyState title={roadmap.onlyOwner.title} body={roadmap.onlyOwner.body} />;
662
+ }
663
+ return (
664
+ <>
665
+ <div className="sc-page-head">
666
+ <h1 className="sc-page-title">{roadmap.title}</h1>
667
+ {!making && owner && (
668
+ <LinkButton href={newGoalHref(key)} variant="primary">
669
+ {roadmap.setGoal}
670
+ </LinkButton>
671
+ )}
672
+ </div>
673
+ <p className="sc-lead">{roadmap.lead}</p>
674
+ {error !== undefined && <Callout tone="danger">{error}</Callout>}
675
+ {making && (
676
+ <section className="sc-panel sc-panel-form" aria-label={roadmap.setGoalAria}>
677
+ <GoalForm
678
+ busy={busy}
679
+ onSubmit={(input) =>
680
+ void act(async () => {
681
+ const made = await data.actions.createGoal(input);
682
+ navigation.replace(goalHref(key, made.id));
683
+ })
684
+ }
685
+ onCancel={() => navigation.back(projectHref(key, "roadmap"))}
686
+ />
687
+ </section>
688
+ )}
689
+ <components.GoalList
690
+ goals={data.goals}
691
+ goalHref={(goal) => goalHref(key, goal.id)}
692
+ empty={
693
+ <EmptyState
694
+ title={roadmap.empty.title}
695
+ body={roadmap.empty.body}
696
+ action={
697
+ owner ? (
698
+ <LinkButton href={newGoalHref(key)}>{roadmap.setGoal}</LinkButton>
699
+ ) : undefined
700
+ }
701
+ />
702
+ }
703
+ />
704
+ </>
705
+ );
706
+ }
707
+ if (route.name === "goal") {
708
+ const roadmap = words.roadmap;
709
+ const goal = data.goals.find((candidate) => candidate.id === route.id);
710
+ const here = goalHref(key, route.id);
711
+ if (goal === undefined) {
712
+ return (
713
+ <EmptyState
714
+ title={roadmap.noSuch.title}
715
+ body={roadmap.noSuch.body}
716
+ action={<LinkButton href={projectHref(key, "roadmap")}>{roadmap.all}</LinkButton>}
717
+ />
718
+ );
719
+ }
720
+ if (route.form === "edit") {
721
+ if (!owner) {
722
+ return <EmptyState title={roadmap.onlyOwner.title} body={roadmap.onlyOwner.body} />;
723
+ }
724
+ return (
725
+ <>
726
+ <div className="sc-page-head">
727
+ <h1 className="sc-page-title">{goal.title}</h1>
728
+ </div>
729
+ {error !== undefined && <Callout tone="danger">{error}</Callout>}
730
+ <section className="sc-panel sc-panel-form" aria-label={roadmap.editAria}>
731
+ <GoalForm
732
+ goal={goal}
733
+ busy={busy}
734
+ onSubmit={(input) =>
735
+ void act(async () => {
736
+ await data.actions.updateGoal(goal.id, input);
737
+ navigation.replace(here);
738
+ })
739
+ }
740
+ onCancel={() => navigation.back(here)}
741
+ />
742
+ </section>
743
+ </>
744
+ );
745
+ }
746
+ return (
747
+ <>
748
+ <div className="sc-page-head">
749
+ <h1 className="sc-page-title">{roadmap.one}</h1>
750
+ <LinkButton href={projectHref(key, "roadmap")}>{roadmap.all}</LinkButton>
751
+ </div>
752
+ {error !== undefined && <Callout tone="danger">{error}</Callout>}
753
+ <GoalView
754
+ goal={goal}
755
+ tasks={data.tasks}
756
+ taskHref={href}
757
+ docHref={toDoc}
758
+ editHref={owner ? goalHref(key, goal.id, "edit") : undefined}
759
+ onMove={
760
+ owner
761
+ ? (target, state) => void act(() => data.actions.updateGoal(target.id, { state }))
762
+ : undefined
763
+ }
764
+ busy={busy}
765
+ />
766
+ </>
767
+ );
768
+ }
552
769
  if (route.name === "docs") {
553
770
  return (
554
771
  <DocsPage
@@ -650,6 +867,9 @@ function ProjectPage(props: {
650
867
  if (event.decisionId !== null) {
651
868
  return decisionHref(key, event.decisionId);
652
869
  }
870
+ if (event.goalId !== null) {
871
+ return goalHref(key, event.goalId);
872
+ }
653
873
  return event.data.path === undefined ? undefined : toDoc(event.data.path);
654
874
  }}
655
875
  empty={<EmptyState title={words.feed.empty} />}
@@ -822,6 +1042,55 @@ function PersonAgentsPage(props: {
822
1042
  );
823
1043
  }
824
1044
 
1045
+ /** What is on the person's plate, across their projects: read when the page is opened. */
1046
+ function WorkPage(props: { readonly data: ConsoleData; readonly components: ConsoleComponents }) {
1047
+ const words = useWords();
1048
+ const { data, components } = props;
1049
+ const [work, setWork] = useState<RestWork | undefined>(undefined);
1050
+ const [problem, setProblem] = useState<string | undefined>(undefined);
1051
+ const { work: load } = data.actions;
1052
+ const couldNotLoad = words.work.couldNotLoad;
1053
+ useEffect(() => {
1054
+ const controller = new AbortController();
1055
+ load(controller.signal)
1056
+ .then((answer) => {
1057
+ if (!controller.signal.aborted) {
1058
+ setWork(answer);
1059
+ }
1060
+ })
1061
+ .catch((failure: unknown) => {
1062
+ if (!controller.signal.aborted) {
1063
+ setProblem(errorWords(failure, couldNotLoad));
1064
+ }
1065
+ });
1066
+ return () => controller.abort();
1067
+ }, [load, couldNotLoad]);
1068
+ return (
1069
+ <>
1070
+ <div className="sc-page-head">
1071
+ <h1 className="sc-page-title">{words.work.title}</h1>
1072
+ </div>
1073
+ <p className="sc-lead">{words.work.lead}</p>
1074
+ {problem !== undefined && <Callout tone="danger">{problem}</Callout>}
1075
+ {work === undefined ? (
1076
+ problem === undefined && (
1077
+ <div className="sc-loading">
1078
+ <Spinner label={words.work.loading} />
1079
+ </div>
1080
+ )
1081
+ ) : work.tasks.length === 0 && work.decisions.length === 0 ? (
1082
+ <EmptyState title={words.work.empty.title} body={words.work.empty.body} />
1083
+ ) : (
1084
+ <components.WorkList
1085
+ work={work}
1086
+ taskHref={(projectKey, task) => taskHref(projectKey, task.number)}
1087
+ decisionHref={(projectKey, decisionId) => decisionHref(projectKey, decisionId)}
1088
+ />
1089
+ )}
1090
+ </>
1091
+ );
1092
+ }
1093
+
825
1094
  function WorkspacePage(props: {
826
1095
  readonly route: Route;
827
1096
  readonly data: ConsoleData;
@@ -833,6 +1102,48 @@ function WorkspacePage(props: {
833
1102
  const [fresh, setFresh] = useState<RestTokenCreated | undefined>(undefined);
834
1103
  const { busy, error, act } = useAction();
835
1104
 
1105
+ if (route.name === "work") {
1106
+ return <WorkPage data={data} components={components} />;
1107
+ }
1108
+ if (route.name === "categories") {
1109
+ const me = data.me?.person ?? undefined;
1110
+ const administrator = me?.role === "admin" && !holdsToken(data);
1111
+ const categories = words.categories;
1112
+ return (
1113
+ <>
1114
+ <div className="sc-page-head">
1115
+ <h1 className="sc-page-title">{categories.title}</h1>
1116
+ </div>
1117
+ <p className="sc-lead">
1118
+ {categories.lead} {categories.onlyAdmin}
1119
+ </p>
1120
+ {error !== undefined && <Callout tone="danger">{error}</Callout>}
1121
+ <components.CategoryList
1122
+ categories={data.categories}
1123
+ busy={busy}
1124
+ onAdd={
1125
+ administrator
1126
+ ? (input) => void act(() => data.actions.createCategory(input))
1127
+ : undefined
1128
+ }
1129
+ onChange={
1130
+ administrator
1131
+ ? (category, patch) =>
1132
+ void act(() => data.actions.updateCategory(category.key, patch))
1133
+ : undefined
1134
+ }
1135
+ onRemove={
1136
+ administrator
1137
+ ? (category) => void act(() => data.actions.removeCategory(category.key))
1138
+ : undefined
1139
+ }
1140
+ empty={<EmptyState title={categories.empty.title} body={categories.empty.body} />}
1141
+ />
1142
+ {administrator && <p className="sc-muted">{categories.inUse}</p>}
1143
+ </>
1144
+ );
1145
+ }
1146
+
836
1147
  if (route.name === "people") {
837
1148
  const me = data.me?.person ?? undefined;
838
1149
  const administrator = me?.role === "admin" && !holdsToken(data);
@@ -998,6 +1309,7 @@ export function createConsole(config: ConsoleConfig = {}): ConsoleApp {
998
1309
  label: words.nav.projects,
999
1310
  current: route.name === "projects" || route.name === "new-project",
1000
1311
  },
1312
+ { href: PATHS.work, label: words.nav.work, current: route.name === "work" },
1001
1313
  {
1002
1314
  href: PATHS.people,
1003
1315
  label: words.nav.people,
@@ -1012,6 +1324,16 @@ export function createConsole(config: ConsoleConfig = {}): ConsoleApp {
1012
1324
  current: route.name === "agents" || route.name === "new-token",
1013
1325
  },
1014
1326
  ]),
1327
+ // The categories are read by everyone and kept by an administrator; only they need the page.
1328
+ ...(data.me.person.role === "admin" && !viaToken
1329
+ ? [
1330
+ {
1331
+ href: PATHS.categories,
1332
+ label: words.nav.categories,
1333
+ current: route.name === "categories",
1334
+ },
1335
+ ]
1336
+ : []),
1015
1337
  ];
1016
1338
  const nav: NavItem[] =
1017
1339
  projectKey === undefined
@@ -1023,6 +1345,17 @@ export function createConsole(config: ConsoleConfig = {}): ConsoleApp {
1023
1345
  current:
1024
1346
  route.name === "board" || route.name === "task" || route.name === "new-task",
1025
1347
  },
1348
+ {
1349
+ href: projectHref(projectKey, "roadmap"),
1350
+ label: words.nav.roadmap,
1351
+ current:
1352
+ route.name === "roadmap" || route.name === "goal" || route.name === "new-goal",
1353
+ },
1354
+ {
1355
+ href: projectHref(projectKey, "calendar"),
1356
+ label: words.nav.calendar,
1357
+ current: route.name === "calendar",
1358
+ },
1026
1359
  {
1027
1360
  href: projectHref(projectKey, "docs"),
1028
1361
  label: words.nav.docs,
@@ -1140,7 +1473,7 @@ export function createConsole(config: ConsoleConfig = {}): ConsoleApp {
1140
1473
  }}
1141
1474
  person={data.me.person}
1142
1475
  live={projectKey === undefined ? undefined : data.live}
1143
- wide={route.name === "board"}
1476
+ wide={route.name === "board" || route.name === "calendar"}
1144
1477
  onNavigate={(href) => navigation.go(href)}
1145
1478
  onSignOut={viaToken ? undefined : () => void data.actions.signOut()}
1146
1479
  >