@skillcdn/console 0.1.9 → 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 (138) 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/working-the-board/SKILL.md +7 -6
  111. package/src/api.ts +30 -0
  112. package/src/cli/cli.ts +311 -10
  113. package/src/cli/format.ts +88 -0
  114. package/src/cli/help.ts +69 -7
  115. package/src/client.ts +70 -2
  116. package/src/components/board.tsx +205 -12
  117. package/src/components/calendar.tsx +255 -0
  118. package/src/components/categories.tsx +209 -0
  119. package/src/components/decision-list.tsx +62 -5
  120. package/src/components/event-feed.tsx +12 -0
  121. package/src/components/goals.tsx +264 -0
  122. package/src/components/task-form.tsx +78 -1
  123. package/src/components/task-list.tsx +61 -0
  124. package/src/components/task-view.tsx +74 -3
  125. package/src/components/ui.tsx +116 -1
  126. package/src/components/work.tsx +83 -0
  127. package/src/console.tsx +337 -4
  128. package/src/data.ts +63 -8
  129. package/src/dates.ts +143 -0
  130. package/src/i18n/en.ts +184 -1
  131. package/src/i18n/ko.ts +175 -1
  132. package/src/index.ts +58 -1
  133. package/src/limits.ts +5 -0
  134. package/src/router.ts +119 -6
  135. package/src/routes.ts +10 -0
  136. package/src/schemas.ts +141 -1
  137. package/src/styles/console.css +511 -0
  138. package/src/vocabulary.ts +33 -0
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
  >
package/src/data.ts CHANGED
@@ -6,6 +6,9 @@ import {
6
6
  EVENT_KINDS,
7
7
  type FileUpload,
8
8
  type RestAnswerInput,
9
+ type RestCategory,
10
+ type RestCategoryInput,
11
+ type RestCategoryPatch,
9
12
  type RestConnectRequest,
10
13
  type RestDecision,
11
14
  type RestDecisionInput,
@@ -14,6 +17,9 @@ import {
14
17
  type RestDocumentInput,
15
18
  type RestDocuments,
16
19
  type RestEvent,
20
+ type RestGoal,
21
+ type RestGoalInput,
22
+ type RestGoalPatch,
17
23
  type RestMe,
18
24
  type RestMember,
19
25
  type RestMemberInput,
@@ -35,6 +41,7 @@ import {
35
41
  type RestTokens,
36
42
  type RestVersion,
37
43
  type RestVersions,
44
+ type RestWork,
38
45
  restEventSchema,
39
46
  } from "./api.js";
40
47
 
@@ -64,6 +71,15 @@ export interface ConsoleActions {
64
71
  answerDecision(id: string, input: RestAnswerInput): Promise<RestDecision>;
65
72
  /** Grows a decision's record: its context, or what followed. */
66
73
  updateDecision(id: string, patch: RestDecisionPatch): Promise<RestDecision>;
74
+ /** Sets a goal on the roadmap of the project the page is on; for an owner. */
75
+ createGoal(input: RestGoalInput): Promise<RestGoal>;
76
+ updateGoal(id: string, patch: RestGoalPatch): Promise<RestGoal>;
77
+ /** What is on the person's plate across their projects, read when the page asks. */
78
+ work(signal?: AbortSignal): Promise<RestWork>;
79
+ /** The categories tasks are sorted into: kept by an administrator. */
80
+ createCategory(input: RestCategoryInput): Promise<RestCategory>;
81
+ updateCategory(key: string, patch: RestCategoryPatch): Promise<RestCategory>;
82
+ removeCategory(key: string): Promise<void>;
67
83
  /** A folder's pages and folders, or the pages a search finds, in the project the page is on. */
68
84
  documents(filter?: DocumentFilter, signal?: AbortSignal): Promise<RestDocuments>;
69
85
  document(path: string, signal?: AbortSignal): Promise<RestDocument>;
@@ -104,6 +120,8 @@ export interface ConsoleData {
104
120
  /** The projects the person may see, by name. */
105
121
  readonly projects: readonly RestProject[];
106
122
  readonly people: readonly RestPerson[];
123
+ /** The categories tasks are sorted into, by name. */
124
+ readonly categories: readonly RestCategory[];
107
125
  /** The tokens of whoever is signed in, newest first. */
108
126
  readonly tokens: readonly RestToken[];
109
127
  /** The project the page is on, once loaded; `undefined` while loading, or when the page is on none. */
@@ -116,6 +134,8 @@ export interface ConsoleData {
116
134
  readonly live: boolean;
117
135
  readonly tasks: readonly RestTask[];
118
136
  readonly decisions: readonly RestDecision[];
137
+ /** The roadmap of the project: its goals, the active first. */
138
+ readonly goals: readonly RestGoal[];
119
139
  /** The runs of the project, newest first. */
120
140
  readonly runs: readonly RestRun[];
121
141
  /** The project's events, oldest first. */
@@ -161,6 +181,7 @@ export function useConsoleData(client: ConsoleClient, projectKey: string | undef
161
181
  const [loaded, setLoaded] = useState(false);
162
182
  const [projects, setProjects] = useState<readonly RestProject[]>([]);
163
183
  const [people, setPeople] = useState<readonly RestPerson[]>([]);
184
+ const [categories, setCategories] = useState<readonly RestCategory[]>([]);
164
185
  const [tokens, setTokens] = useState<readonly RestToken[]>([]);
165
186
  const [project, setProject] = useState<RestProject | undefined>(undefined);
166
187
  const [projectLoaded, setProjectLoaded] = useState(false);
@@ -168,6 +189,7 @@ export function useConsoleData(client: ConsoleClient, projectKey: string | undef
168
189
  const [live, setLive] = useState(false);
169
190
  const [tasks, setTasks] = useState<readonly RestTask[]>([]);
170
191
  const [decisions, setDecisions] = useState<readonly RestDecision[]>([]);
192
+ const [goals, setGoals] = useState<readonly RestGoal[]>([]);
171
193
  const [runs, setRuns] = useState<readonly RestRun[]>([]);
172
194
  const [events, setEvents] = useState<readonly RestEvent[]>([]);
173
195
  const [members, setMembers] = useState<readonly RestMember[]>([]);
@@ -181,15 +203,17 @@ export function useConsoleData(client: ConsoleClient, projectKey: string | undef
181
203
  /** Loads the workspace's lists again. What fails stays as it was. */
182
204
  const refreshWorkspace = useCallback(
183
205
  async (signal?: AbortSignal) => {
184
- const [nextProjects, nextPeople] = await Promise.all([
206
+ const [nextProjects, nextPeople, nextCategories] = await Promise.all([
185
207
  client.projects(signal),
186
208
  client.people(signal),
209
+ client.categories(signal),
187
210
  ]);
188
211
  if (signal?.aborted === true) {
189
212
  return;
190
213
  }
191
214
  setProjects(nextProjects.items);
192
215
  setPeople(nextPeople.items);
216
+ setCategories(nextCategories.items);
193
217
  },
194
218
  [client],
195
219
  );
@@ -201,19 +225,22 @@ export function useConsoleData(client: ConsoleClient, projectKey: string | undef
201
225
  return;
202
226
  }
203
227
  const scope = client.project(projectKey);
204
- const [nextProject, nextTasks, nextDecisions, nextRuns, nextMembers] = await Promise.all([
205
- scope.get(signal),
206
- scope.tasks(undefined, signal),
207
- scope.decisions(undefined, signal),
208
- scope.runs(undefined, signal),
209
- scope.members(signal),
210
- ]);
228
+ const [nextProject, nextTasks, nextDecisions, nextGoals, nextRuns, nextMembers] =
229
+ await Promise.all([
230
+ scope.get(signal),
231
+ scope.tasks(undefined, signal),
232
+ scope.decisions(undefined, signal),
233
+ scope.goals(signal),
234
+ scope.runs(undefined, signal),
235
+ scope.members(signal),
236
+ ]);
211
237
  if (signal?.aborted === true) {
212
238
  return;
213
239
  }
214
240
  setProject(nextProject);
215
241
  setTasks(nextTasks.items);
216
242
  setDecisions(nextDecisions.items);
243
+ setGoals(nextGoals.items);
217
244
  setRuns(nextRuns.items);
218
245
  setMembers(nextMembers.items);
219
246
  },
@@ -274,6 +301,7 @@ export function useConsoleData(client: ConsoleClient, projectKey: string | undef
274
301
  setProjectError(undefined);
275
302
  setTasks([]);
276
303
  setDecisions([]);
304
+ setGoals([]);
277
305
  setRuns([]);
278
306
  setEvents([]);
279
307
  setMembers([]);
@@ -411,6 +439,31 @@ export function useConsoleData(client: ConsoleClient, projectKey: string | undef
411
439
  );
412
440
  return decision;
413
441
  },
442
+ async createGoal(input) {
443
+ const goal = await scope().createGoal(input);
444
+ await refreshProject();
445
+ return goal;
446
+ },
447
+ async updateGoal(id, patch) {
448
+ const goal = await scope().updateGoal(id, patch);
449
+ setGoals((current) => current.map((candidate) => (candidate.id === id ? goal : candidate)));
450
+ return goal;
451
+ },
452
+ work: (signal) => client.work(signal),
453
+ async createCategory(input) {
454
+ const category = await client.createCategory(input);
455
+ setCategories((await client.categories()).items);
456
+ return category;
457
+ },
458
+ async updateCategory(key, patch) {
459
+ const category = await client.updateCategory(key, patch);
460
+ setCategories((await client.categories()).items);
461
+ return category;
462
+ },
463
+ async removeCategory(key) {
464
+ await client.removeCategory(key);
465
+ setCategories((current) => current.filter((category) => category.key !== key));
466
+ },
414
467
  documents: (filter, signal) => scope().documents(filter, signal),
415
468
  document: (path, signal) => scope().document(path, signal),
416
469
  writeDocument: (path, input) => scope().writeDocument(path, input),
@@ -476,6 +529,7 @@ export function useConsoleData(client: ConsoleClient, projectKey: string | undef
476
529
  loaded,
477
530
  projects,
478
531
  people,
532
+ categories,
479
533
  tokens,
480
534
  project,
481
535
  projectLoaded,
@@ -483,6 +537,7 @@ export function useConsoleData(client: ConsoleClient, projectKey: string | undef
483
537
  live,
484
538
  tasks,
485
539
  decisions,
540
+ goals,
486
541
  runs,
487
542
  events,
488
543
  members,