@skillcdn/console 0.1.9 → 0.1.11

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 (162) hide show
  1. package/README.md +6 -6
  2. package/dist/api.d.ts +6 -5
  3. package/dist/api.d.ts.map +1 -1
  4. package/dist/api.js +5 -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 +303 -18
  8. package/dist/cli/cli.js.map +1 -1
  9. package/dist/cli/format.d.ts +11 -1
  10. package/dist/cli/format.d.ts.map +1 -1
  11. package/dist/cli/format.js +87 -6
  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 +70 -14
  15. package/dist/cli/help.js.map +1 -1
  16. package/dist/client.d.ts +44 -8
  17. package/dist/client.d.ts.map +1 -1
  18. package/dist/client.js +32 -4
  19. package/dist/client.js.map +1 -1
  20. package/dist/components/board.d.ts +24 -1
  21. package/dist/components/board.d.ts.map +1 -1
  22. package/dist/components/board.js +66 -5
  23. package/dist/components/board.js.map +1 -1
  24. package/dist/components/calendar.d.ts +54 -0
  25. package/dist/components/calendar.d.ts.map +1 -0
  26. package/dist/components/calendar.js +156 -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 +16 -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 +23 -7
  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/paged.d.ts +14 -0
  44. package/dist/components/paged.d.ts.map +1 -0
  45. package/dist/components/paged.js +28 -0
  46. package/dist/components/paged.js.map +1 -0
  47. package/dist/components/projects.d.ts +22 -2
  48. package/dist/components/projects.d.ts.map +1 -1
  49. package/dist/components/projects.js +35 -7
  50. package/dist/components/projects.js.map +1 -1
  51. package/dist/components/runs.d.ts.map +1 -1
  52. package/dist/components/runs.js +2 -2
  53. package/dist/components/runs.js.map +1 -1
  54. package/dist/components/shell.d.ts +2 -1
  55. package/dist/components/shell.d.ts.map +1 -1
  56. package/dist/components/shell.js +1 -1
  57. package/dist/components/shell.js.map +1 -1
  58. package/dist/components/task-form.d.ts +5 -1
  59. package/dist/components/task-form.d.ts.map +1 -1
  60. package/dist/components/task-form.js +13 -1
  61. package/dist/components/task-form.js.map +1 -1
  62. package/dist/components/task-list.d.ts +16 -0
  63. package/dist/components/task-list.d.ts.map +1 -0
  64. package/dist/components/task-list.js +16 -0
  65. package/dist/components/task-list.js.map +1 -0
  66. package/dist/components/task-view.d.ts +9 -1
  67. package/dist/components/task-view.d.ts.map +1 -1
  68. package/dist/components/task-view.js +10 -4
  69. package/dist/components/task-view.js.map +1 -1
  70. package/dist/components/ui.d.ts +30 -1
  71. package/dist/components/ui.d.ts.map +1 -1
  72. package/dist/components/ui.js +60 -0
  73. package/dist/components/ui.js.map +1 -1
  74. package/dist/components/work.d.ts +10 -0
  75. package/dist/components/work.d.ts.map +1 -0
  76. package/dist/components/work.js +33 -0
  77. package/dist/components/work.js.map +1 -0
  78. package/dist/console.css +658 -0
  79. package/dist/console.d.ts +8 -0
  80. package/dist/console.d.ts.map +1 -1
  81. package/dist/console.js +145 -13
  82. package/dist/console.js.map +1 -1
  83. package/dist/data.d.ts +19 -3
  84. package/dist/data.d.ts.map +1 -1
  85. package/dist/data.js +51 -4
  86. package/dist/data.js.map +1 -1
  87. package/dist/dates.d.ts +39 -0
  88. package/dist/dates.d.ts.map +1 -0
  89. package/dist/dates.js +120 -0
  90. package/dist/dates.js.map +1 -0
  91. package/dist/i18n/en.d.ts +172 -5
  92. package/dist/i18n/en.d.ts.map +1 -1
  93. package/dist/i18n/en.js +183 -6
  94. package/dist/i18n/en.js.map +1 -1
  95. package/dist/i18n/ko.d.ts.map +1 -1
  96. package/dist/i18n/ko.js +178 -6
  97. package/dist/i18n/ko.js.map +1 -1
  98. package/dist/index.d.ts +12 -5
  99. package/dist/index.d.ts.map +1 -1
  100. package/dist/index.js +12 -5
  101. package/dist/index.js.map +1 -1
  102. package/dist/limits.d.ts +11 -0
  103. package/dist/limits.d.ts.map +1 -1
  104. package/dist/limits.js +11 -0
  105. package/dist/limits.js.map +1 -1
  106. package/dist/marks.d.ts +12 -0
  107. package/dist/marks.d.ts.map +1 -0
  108. package/dist/marks.js +31 -0
  109. package/dist/marks.js.map +1 -0
  110. package/dist/router.d.ts +51 -1
  111. package/dist/router.d.ts.map +1 -1
  112. package/dist/router.js +80 -1
  113. package/dist/router.js.map +1 -1
  114. package/dist/routes.d.ts +14 -1
  115. package/dist/routes.d.ts.map +1 -1
  116. package/dist/routes.js +17 -0
  117. package/dist/routes.js.map +1 -1
  118. package/dist/schemas.d.ts +489 -3
  119. package/dist/schemas.d.ts.map +1 -1
  120. package/dist/schemas.js +150 -9
  121. package/dist/schemas.js.map +1 -1
  122. package/dist/vocabulary.d.ts +21 -1
  123. package/dist/vocabulary.d.ts.map +1 -1
  124. package/dist/vocabulary.js +31 -0
  125. package/dist/vocabulary.js.map +1 -1
  126. package/docs/specs/cli.md +17 -8
  127. package/docs/specs/rest.md +48 -17
  128. package/package.json +1 -1
  129. package/skills/working-the-board/SKILL.md +7 -6
  130. package/src/api.ts +42 -0
  131. package/src/cli/cli.ts +362 -18
  132. package/src/cli/format.ts +96 -6
  133. package/src/cli/help.ts +77 -15
  134. package/src/client.ts +98 -7
  135. package/src/components/board.tsx +173 -13
  136. package/src/components/calendar.tsx +367 -0
  137. package/src/components/categories.tsx +209 -0
  138. package/src/components/decision-list.tsx +67 -5
  139. package/src/components/event-feed.tsx +46 -27
  140. package/src/components/goals.tsx +264 -0
  141. package/src/components/paged.tsx +39 -0
  142. package/src/components/projects.tsx +128 -10
  143. package/src/components/runs.tsx +7 -1
  144. package/src/components/shell.tsx +5 -2
  145. package/src/components/task-form.tsx +78 -1
  146. package/src/components/task-list.tsx +64 -0
  147. package/src/components/task-view.tsx +75 -4
  148. package/src/components/ui.tsx +116 -1
  149. package/src/components/work.tsx +93 -0
  150. package/src/console.tsx +376 -9
  151. package/src/data.ts +83 -12
  152. package/src/dates.ts +143 -0
  153. package/src/i18n/en.ts +193 -6
  154. package/src/i18n/ko.ts +184 -6
  155. package/src/index.ts +62 -1
  156. package/src/limits.ts +11 -0
  157. package/src/marks.ts +40 -0
  158. package/src/router.ts +131 -6
  159. package/src/routes.ts +19 -0
  160. package/src/schemas.ts +187 -7
  161. package/src/styles/console.css +658 -0
  162. package/src/vocabulary.ts +43 -0
package/src/console.tsx CHANGED
@@ -3,6 +3,8 @@ import { createRoot, type Root } from "react-dom/client";
3
3
  import {
4
4
  type ConsoleClient,
5
5
  createClient,
6
+ type FileUpload,
7
+ OPEN_TASK_STATES,
6
8
  type RestAnswerInput,
7
9
  type RestDecision,
8
10
  type RestDecisionInput,
@@ -15,13 +17,17 @@ import {
15
17
  type RestTaskInput,
16
18
  type RestToken,
17
19
  type RestTokenCreated,
20
+ type RestWork,
18
21
  type TaskState,
19
22
  } from "./api.js";
20
- import { Board, type BoardProps } from "./components/board.js";
23
+ import { Board, BoardFilters, type BoardProps, byDue, filterTasks } from "./components/board.js";
24
+ import { Calendar, type CalendarProps, Workload } from "./components/calendar.js";
25
+ import { CategoryList, type CategoryListProps } from "./components/categories.js";
21
26
  import { ConnectWords } from "./components/connect.js";
22
27
  import { DecisionForm } from "./components/decision-form.js";
23
28
  import { DecisionCard, DecisionList, type DecisionListProps } from "./components/decision-list.js";
24
29
  import { EventFeed, type EventFeedProps } from "./components/event-feed.js";
30
+ import { GoalForm, GoalList, type GoalListProps, GoalView } from "./components/goals.js";
25
31
  import { PeopleList, type PeopleListProps } from "./components/people.js";
26
32
  import {
27
33
  MemberList,
@@ -29,6 +35,7 @@ import {
29
35
  ProjectForm,
30
36
  ProjectList,
31
37
  type ProjectListProps,
38
+ ProjectMark,
32
39
  } from "./components/projects.js";
33
40
  import {
34
41
  type Brand,
@@ -40,11 +47,14 @@ import {
40
47
  import { SignIn, type SignInProps } from "./components/sign-in.js";
41
48
  import { SkillList, type SkillListProps } from "./components/skills.js";
42
49
  import { TaskForm } from "./components/task-form.js";
50
+ import { TaskList } from "./components/task-list.js";
43
51
  import { TaskView, type TaskViewProps } from "./components/task-view.js";
44
52
  import { NewToken, TokenForm, TokenList, type TokenListProps } from "./components/tokens.js";
45
53
  import { Button, Callout, EmptyState, Spinner } from "./components/ui.js";
54
+ import { WorkList, type WorkListProps } from "./components/work.js";
46
55
  import { ConnectPage } from "./connect-page.js";
47
56
  import { type ConsoleData, useConsoleData } from "./data.js";
57
+ import { monthOf, today } from "./dates.js";
48
58
  import { DocsPage } from "./docs-page.js";
49
59
  import {
50
60
  chooseLanguage,
@@ -54,11 +64,15 @@ import {
54
64
  useWords,
55
65
  } from "./i18n/index.js";
56
66
  import {
67
+ boardHref,
68
+ calendarHref,
57
69
  decisionHref,
58
70
  docHref,
71
+ goalHref,
59
72
  matchRoute,
60
73
  type Navigation,
61
74
  newDecisionHref,
75
+ newGoalHref,
62
76
  newTaskHref,
63
77
  PATHS,
64
78
  personAgentsHref,
@@ -69,7 +83,7 @@ import {
69
83
  withoutSignInParam,
70
84
  } from "./router.js";
71
85
  import { projectPath } from "./routes.js";
72
- import { useAction } from "./use-action.js";
86
+ import { errorWords, useAction } from "./use-action.js";
73
87
 
74
88
  // The composition of the default console: the pages assembled from the components, with the
75
89
  // places a team may replace named. The UI the image serves is this, with the default config.
@@ -90,6 +104,10 @@ export interface ConsoleComponents {
90
104
  readonly SkillList: ComponentType<SkillListProps>;
91
105
  readonly ProjectList: ComponentType<ProjectListProps>;
92
106
  readonly MemberList: ComponentType<MemberListProps>;
107
+ readonly Calendar: ComponentType<CalendarProps>;
108
+ readonly GoalList: ComponentType<GoalListProps>;
109
+ readonly WorkList: ComponentType<WorkListProps>;
110
+ readonly CategoryList: ComponentType<CategoryListProps>;
93
111
  }
94
112
 
95
113
  export const DEFAULT_COMPONENTS: ConsoleComponents = {
@@ -104,6 +122,10 @@ export const DEFAULT_COMPONENTS: ConsoleComponents = {
104
122
  SkillList,
105
123
  ProjectList,
106
124
  MemberList,
125
+ Calendar,
126
+ GoalList,
127
+ WorkList,
128
+ CategoryList,
107
129
  };
108
130
 
109
131
  export interface ConsoleConfig {
@@ -334,9 +356,12 @@ function ProjectsPage(props: {
334
356
  <section className="sc-panel sc-panel-form" aria-label={words.form.aria}>
335
357
  <ProjectForm
336
358
  busy={busy}
337
- onSubmit={(input) =>
359
+ onSubmit={(input, icon) =>
338
360
  void act(async () => {
339
- const made = await data.actions.createProject(input as RestProjectInput);
361
+ const made = await data.actions.createProject(
362
+ input as RestProjectInput,
363
+ icon instanceof File ? uploadOf(icon) : undefined,
364
+ );
340
365
  navigation.replace(projectHref(made.key));
341
366
  })
342
367
  }
@@ -426,8 +451,11 @@ function TaskPage(props: {
426
451
  decisions={data.decisions}
427
452
  runs={data.runs}
428
453
  history={history}
454
+ categories={data.categories}
455
+ goals={data.goals}
429
456
  taskHref={(candidate) => taskHref(key, candidate.number)}
430
457
  decisionHref={(decisionId) => decisionHref(key, decisionId)}
458
+ goalHref={(goalId) => goalHref(key, goalId)}
431
459
  fileHref={(artifact) => projectPath(key, "files", artifact.id)}
432
460
  docHref={(path) => docHref(key, path)}
433
461
  form={route.form}
@@ -483,6 +511,8 @@ function ProjectPage(props: {
483
511
  void act(() => data.actions.updateDecision(decision.id, patch));
484
512
 
485
513
  if (route.name === "board") {
514
+ const me = data.me?.person ?? undefined;
515
+ const shown = filterTasks(data.tasks, route.filter, me);
486
516
  return (
487
517
  <>
488
518
  <div className="sc-page-head">
@@ -493,8 +523,20 @@ function ProjectPage(props: {
493
523
  </div>
494
524
  {project.description.length > 0 && <p className="sc-lead">{project.description}</p>}
495
525
  {error !== undefined && <Callout tone="danger">{error}</Callout>}
526
+ {data.tasks.length > 0 && (
527
+ <BoardFilters
528
+ filter={route.filter}
529
+ categories={data.categories}
530
+ goals={data.goals}
531
+ people={data.people}
532
+ me={me}
533
+ shown={shown.length}
534
+ total={data.tasks.length}
535
+ onChange={(filter) => navigation.replace(boardHref(key, filter))}
536
+ />
537
+ )}
496
538
  <components.Board
497
- tasks={data.tasks}
539
+ tasks={shown}
498
540
  taskHref={href}
499
541
  onOpen={(task) => navigation.go(href(task))}
500
542
  onMove={onMove}
@@ -522,6 +564,8 @@ function ProjectPage(props: {
522
564
  <TaskForm
523
565
  people={data.people}
524
566
  parents={data.tasks}
567
+ categories={data.categories}
568
+ goals={data.goals}
525
569
  busy={busy}
526
570
  onSubmit={(input: RestTaskInput) =>
527
571
  void act(async () => {
@@ -549,6 +593,198 @@ function ProjectPage(props: {
549
593
  />
550
594
  );
551
595
  }
596
+ if (route.name === "calendar") {
597
+ const now = today();
598
+ const month = route.month ?? monthOf(now);
599
+ const me = data.me?.person ?? undefined;
600
+ const shown = filterTasks(data.tasks, route.filter, me);
601
+ const open = shown.filter((task) => OPEN_TASK_STATES.includes(task.state));
602
+ const overdue = open.filter((task) => task.dueOn !== null && task.dueOn < now).sort(byDue);
603
+ const unscheduled = open.filter((task) => task.dueOn === null).sort(byDue);
604
+ const calendar = words.calendar;
605
+ return (
606
+ <>
607
+ <div className="sc-page-head">
608
+ <h1 className="sc-page-title">{calendar.title}</h1>
609
+ <LinkButton href={newTaskHref(key)} variant="primary">
610
+ {words.board.writeTask}
611
+ </LinkButton>
612
+ </div>
613
+ <p className="sc-lead">{calendar.lead}</p>
614
+ {data.tasks.length > 0 && (
615
+ <BoardFilters
616
+ filter={route.filter}
617
+ categories={data.categories}
618
+ goals={data.goals}
619
+ people={data.people}
620
+ me={me}
621
+ shown={shown.length}
622
+ total={data.tasks.length}
623
+ onChange={(filter) => navigation.replace(calendarHref(key, route.month, filter))}
624
+ />
625
+ )}
626
+ <components.Calendar
627
+ tasks={shown}
628
+ month={month}
629
+ today={now}
630
+ categories={data.categories}
631
+ taskHref={href}
632
+ monthHref={(wanted) => calendarHref(key, wanted, route.filter)}
633
+ />
634
+ <div className="sc-calendar-below">
635
+ <section className="sc-task-section" aria-label={calendar.overdue}>
636
+ <h2 className="sc-section-title">
637
+ {calendar.overdue} <span className="sc-column-count">{overdue.length}</span>
638
+ </h2>
639
+ <TaskList
640
+ tasks={overdue}
641
+ taskHref={href}
642
+ today={now}
643
+ showState
644
+ empty={<p className="sc-muted">{calendar.nothingOverdue}</p>}
645
+ />
646
+ </section>
647
+ <section className="sc-task-section" aria-label={calendar.unscheduled}>
648
+ <h2 className="sc-section-title">
649
+ {calendar.unscheduled} <span className="sc-column-count">{unscheduled.length}</span>
650
+ </h2>
651
+ <TaskList
652
+ tasks={unscheduled}
653
+ taskHref={href}
654
+ today={now}
655
+ showState
656
+ empty={<p className="sc-muted">{calendar.allScheduled}</p>}
657
+ />
658
+ </section>
659
+ </div>
660
+ {open.length > 0 && (
661
+ <section className="sc-task-section" aria-label={calendar.workload.title}>
662
+ <h2 className="sc-section-title">{calendar.workload.title}</h2>
663
+ <p className="sc-lead">{calendar.workload.lead}</p>
664
+ <Workload
665
+ tasks={shown}
666
+ today={now}
667
+ personHref={(person) =>
668
+ boardHref(key, person === undefined ? {} : { person: person.id })
669
+ }
670
+ />
671
+ </section>
672
+ )}
673
+ </>
674
+ );
675
+ }
676
+ if (route.name === "roadmap" || route.name === "new-goal") {
677
+ const roadmap = words.roadmap;
678
+ const making = route.name === "new-goal";
679
+ if (making && !owner) {
680
+ return <EmptyState title={roadmap.onlyOwner.title} body={roadmap.onlyOwner.body} />;
681
+ }
682
+ return (
683
+ <>
684
+ <div className="sc-page-head">
685
+ <h1 className="sc-page-title">{roadmap.title}</h1>
686
+ {!making && owner && (
687
+ <LinkButton href={newGoalHref(key)} variant="primary">
688
+ {roadmap.setGoal}
689
+ </LinkButton>
690
+ )}
691
+ </div>
692
+ <p className="sc-lead">{roadmap.lead}</p>
693
+ {error !== undefined && <Callout tone="danger">{error}</Callout>}
694
+ {making && (
695
+ <section className="sc-panel sc-panel-form" aria-label={roadmap.setGoalAria}>
696
+ <GoalForm
697
+ busy={busy}
698
+ onSubmit={(input) =>
699
+ void act(async () => {
700
+ const made = await data.actions.createGoal(input);
701
+ navigation.replace(goalHref(key, made.id));
702
+ })
703
+ }
704
+ onCancel={() => navigation.back(projectHref(key, "roadmap"))}
705
+ />
706
+ </section>
707
+ )}
708
+ <components.GoalList
709
+ goals={data.goals}
710
+ goalHref={(goal) => goalHref(key, goal.id)}
711
+ empty={
712
+ <EmptyState
713
+ title={roadmap.empty.title}
714
+ body={roadmap.empty.body}
715
+ action={
716
+ owner ? (
717
+ <LinkButton href={newGoalHref(key)}>{roadmap.setGoal}</LinkButton>
718
+ ) : undefined
719
+ }
720
+ />
721
+ }
722
+ />
723
+ </>
724
+ );
725
+ }
726
+ if (route.name === "goal") {
727
+ const roadmap = words.roadmap;
728
+ const goal = data.goals.find((candidate) => candidate.id === route.id);
729
+ const here = goalHref(key, route.id);
730
+ if (goal === undefined) {
731
+ return (
732
+ <EmptyState
733
+ title={roadmap.noSuch.title}
734
+ body={roadmap.noSuch.body}
735
+ action={<LinkButton href={projectHref(key, "roadmap")}>{roadmap.all}</LinkButton>}
736
+ />
737
+ );
738
+ }
739
+ if (route.form === "edit") {
740
+ if (!owner) {
741
+ return <EmptyState title={roadmap.onlyOwner.title} body={roadmap.onlyOwner.body} />;
742
+ }
743
+ return (
744
+ <>
745
+ <div className="sc-page-head">
746
+ <h1 className="sc-page-title">{goal.title}</h1>
747
+ </div>
748
+ {error !== undefined && <Callout tone="danger">{error}</Callout>}
749
+ <section className="sc-panel sc-panel-form" aria-label={roadmap.editAria}>
750
+ <GoalForm
751
+ goal={goal}
752
+ busy={busy}
753
+ onSubmit={(input) =>
754
+ void act(async () => {
755
+ await data.actions.updateGoal(goal.id, input);
756
+ navigation.replace(here);
757
+ })
758
+ }
759
+ onCancel={() => navigation.back(here)}
760
+ />
761
+ </section>
762
+ </>
763
+ );
764
+ }
765
+ return (
766
+ <>
767
+ <div className="sc-page-head">
768
+ <h1 className="sc-page-title">{roadmap.one}</h1>
769
+ <LinkButton href={projectHref(key, "roadmap")}>{roadmap.all}</LinkButton>
770
+ </div>
771
+ {error !== undefined && <Callout tone="danger">{error}</Callout>}
772
+ <GoalView
773
+ goal={goal}
774
+ tasks={data.tasks}
775
+ taskHref={href}
776
+ docHref={toDoc}
777
+ editHref={owner ? goalHref(key, goal.id, "edit") : undefined}
778
+ onMove={
779
+ owner
780
+ ? (target, state) => void act(() => data.actions.updateGoal(target.id, { state }))
781
+ : undefined
782
+ }
783
+ busy={busy}
784
+ />
785
+ </>
786
+ );
787
+ }
552
788
  if (route.name === "docs") {
553
789
  return (
554
790
  <DocsPage
@@ -650,6 +886,9 @@ function ProjectPage(props: {
650
886
  if (event.decisionId !== null) {
651
887
  return decisionHref(key, event.decisionId);
652
888
  }
889
+ if (event.goalId !== null) {
890
+ return goalHref(key, event.goalId);
891
+ }
653
892
  return event.data.path === undefined ? undefined : toDoc(event.data.path);
654
893
  }}
655
894
  empty={<EmptyState title={words.feed.empty} />}
@@ -730,8 +969,15 @@ function ProjectPage(props: {
730
969
  <ProjectForm
731
970
  project={project}
732
971
  busy={busy}
733
- onSubmit={(input) =>
734
- void act(() => data.actions.updateProject(input as RestProjectPatch))
972
+ onSubmit={(input, icon) =>
973
+ void act(async () => {
974
+ await data.actions.updateProject(input as RestProjectPatch);
975
+ if (icon === null) {
976
+ await data.actions.removeProjectIcon();
977
+ } else if (icon !== undefined) {
978
+ await data.actions.setProjectIcon(uploadOf(icon));
979
+ }
980
+ })
735
981
  }
736
982
  />
737
983
  </section>
@@ -741,6 +987,13 @@ function ProjectPage(props: {
741
987
  return <NothingHere />;
742
988
  }
743
989
 
990
+ /** A file a person chose, as the client sends it: its name, its bytes and the type the browser says. */
991
+ const uploadOf = (file: File): FileUpload => ({
992
+ name: file.name,
993
+ bytes: file,
994
+ ...(file.type === "" ? {} : { contentType: file.type }),
995
+ });
996
+
744
997
  function NothingHere() {
745
998
  const words = useWords();
746
999
  return (
@@ -822,6 +1075,55 @@ function PersonAgentsPage(props: {
822
1075
  );
823
1076
  }
824
1077
 
1078
+ /** What is on the person's plate, across their projects: read when the page is opened. */
1079
+ function WorkPage(props: { readonly data: ConsoleData; readonly components: ConsoleComponents }) {
1080
+ const words = useWords();
1081
+ const { data, components } = props;
1082
+ const [work, setWork] = useState<RestWork | undefined>(undefined);
1083
+ const [problem, setProblem] = useState<string | undefined>(undefined);
1084
+ const { work: load } = data.actions;
1085
+ const couldNotLoad = words.work.couldNotLoad;
1086
+ useEffect(() => {
1087
+ const controller = new AbortController();
1088
+ load(controller.signal)
1089
+ .then((answer) => {
1090
+ if (!controller.signal.aborted) {
1091
+ setWork(answer);
1092
+ }
1093
+ })
1094
+ .catch((failure: unknown) => {
1095
+ if (!controller.signal.aborted) {
1096
+ setProblem(errorWords(failure, couldNotLoad));
1097
+ }
1098
+ });
1099
+ return () => controller.abort();
1100
+ }, [load, couldNotLoad]);
1101
+ return (
1102
+ <>
1103
+ <div className="sc-page-head">
1104
+ <h1 className="sc-page-title">{words.work.title}</h1>
1105
+ </div>
1106
+ <p className="sc-lead">{words.work.lead}</p>
1107
+ {problem !== undefined && <Callout tone="danger">{problem}</Callout>}
1108
+ {work === undefined ? (
1109
+ problem === undefined && (
1110
+ <div className="sc-loading">
1111
+ <Spinner label={words.work.loading} />
1112
+ </div>
1113
+ )
1114
+ ) : work.tasks.length === 0 && work.decisions.length === 0 ? (
1115
+ <EmptyState title={words.work.empty.title} body={words.work.empty.body} />
1116
+ ) : (
1117
+ <components.WorkList
1118
+ work={work}
1119
+ taskHref={(projectKey, task) => taskHref(projectKey, task.number)}
1120
+ decisionHref={(projectKey, decisionId) => decisionHref(projectKey, decisionId)}
1121
+ />
1122
+ )}
1123
+ </>
1124
+ );
1125
+ }
1126
+
825
1127
  function WorkspacePage(props: {
826
1128
  readonly route: Route;
827
1129
  readonly data: ConsoleData;
@@ -833,6 +1135,48 @@ function WorkspacePage(props: {
833
1135
  const [fresh, setFresh] = useState<RestTokenCreated | undefined>(undefined);
834
1136
  const { busy, error, act } = useAction();
835
1137
 
1138
+ if (route.name === "work") {
1139
+ return <WorkPage data={data} components={components} />;
1140
+ }
1141
+ if (route.name === "categories") {
1142
+ const me = data.me?.person ?? undefined;
1143
+ const administrator = me?.role === "admin" && !holdsToken(data);
1144
+ const categories = words.categories;
1145
+ return (
1146
+ <>
1147
+ <div className="sc-page-head">
1148
+ <h1 className="sc-page-title">{categories.title}</h1>
1149
+ </div>
1150
+ <p className="sc-lead">
1151
+ {categories.lead} {categories.onlyAdmin}
1152
+ </p>
1153
+ {error !== undefined && <Callout tone="danger">{error}</Callout>}
1154
+ <components.CategoryList
1155
+ categories={data.categories}
1156
+ busy={busy}
1157
+ onAdd={
1158
+ administrator
1159
+ ? (input) => void act(() => data.actions.createCategory(input))
1160
+ : undefined
1161
+ }
1162
+ onChange={
1163
+ administrator
1164
+ ? (category, patch) =>
1165
+ void act(() => data.actions.updateCategory(category.key, patch))
1166
+ : undefined
1167
+ }
1168
+ onRemove={
1169
+ administrator
1170
+ ? (category) => void act(() => data.actions.removeCategory(category.key))
1171
+ : undefined
1172
+ }
1173
+ empty={<EmptyState title={categories.empty.title} body={categories.empty.body} />}
1174
+ />
1175
+ {administrator && <p className="sc-muted">{categories.inUse}</p>}
1176
+ </>
1177
+ );
1178
+ }
1179
+
836
1180
  if (route.name === "people") {
837
1181
  const me = data.me?.person ?? undefined;
838
1182
  const administrator = me?.role === "admin" && !holdsToken(data);
@@ -998,6 +1342,7 @@ export function createConsole(config: ConsoleConfig = {}): ConsoleApp {
998
1342
  label: words.nav.projects,
999
1343
  current: route.name === "projects" || route.name === "new-project",
1000
1344
  },
1345
+ { href: PATHS.work, label: words.nav.work, current: route.name === "work" },
1001
1346
  {
1002
1347
  href: PATHS.people,
1003
1348
  label: words.nav.people,
@@ -1012,6 +1357,12 @@ export function createConsole(config: ConsoleConfig = {}): ConsoleApp {
1012
1357
  current: route.name === "agents" || route.name === "new-token",
1013
1358
  },
1014
1359
  ]),
1360
+ // The categories are read by everyone, for what the colours mean, and kept by an administrator.
1361
+ {
1362
+ href: PATHS.categories,
1363
+ label: words.nav.categories,
1364
+ current: route.name === "categories",
1365
+ },
1015
1366
  ];
1016
1367
  const nav: NavItem[] =
1017
1368
  projectKey === undefined
@@ -1023,6 +1374,17 @@ export function createConsole(config: ConsoleConfig = {}): ConsoleApp {
1023
1374
  current:
1024
1375
  route.name === "board" || route.name === "task" || route.name === "new-task",
1025
1376
  },
1377
+ {
1378
+ href: projectHref(projectKey, "roadmap"),
1379
+ label: words.nav.roadmap,
1380
+ current:
1381
+ route.name === "roadmap" || route.name === "goal" || route.name === "new-goal",
1382
+ },
1383
+ {
1384
+ href: projectHref(projectKey, "calendar"),
1385
+ label: words.nav.calendar,
1386
+ current: route.name === "calendar",
1387
+ },
1026
1388
  {
1027
1389
  href: projectHref(projectKey, "docs"),
1028
1390
  label: words.nav.docs,
@@ -1121,7 +1483,12 @@ export function createConsole(config: ConsoleConfig = {}): ConsoleApp {
1121
1483
  project={
1122
1484
  projectKey === undefined
1123
1485
  ? undefined
1124
- : { name: project?.name ?? projectKey, href: projectHref(projectKey) }
1486
+ : {
1487
+ name: project?.name ?? projectKey,
1488
+ href: projectHref(projectKey),
1489
+ mark:
1490
+ project === undefined ? undefined : <ProjectMark project={project} size="sm" />,
1491
+ }
1125
1492
  }
1126
1493
  nav={nav}
1127
1494
  menu={workspaceNav}
@@ -1140,7 +1507,7 @@ export function createConsole(config: ConsoleConfig = {}): ConsoleApp {
1140
1507
  }}
1141
1508
  person={data.me.person}
1142
1509
  live={projectKey === undefined ? undefined : data.live}
1143
- wide={route.name === "board"}
1510
+ wide={route.name === "board" || route.name === "calendar"}
1144
1511
  onNavigate={(href) => navigation.go(href)}
1145
1512
  onSignOut={viaToken ? undefined : () => void data.actions.signOut()}
1146
1513
  >