@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
package/src/cli/format.ts CHANGED
@@ -1,15 +1,18 @@
1
1
  import type {
2
2
  RestArtifact,
3
+ RestCategory,
3
4
  RestDecision,
4
5
  RestDocument,
5
6
  RestDocumentSummary,
6
7
  RestDocuments,
8
+ RestGoal,
7
9
  RestProject,
8
10
  RestRun,
9
11
  RestSkills,
10
12
  RestTask,
11
13
  RestVersion,
12
14
  RestVersionSummary,
15
+ RestWork,
13
16
  } from "../schemas.js";
14
17
 
15
18
  // What the command prints: one line per thing in a list, a few lines for one thing in full.
@@ -47,6 +50,12 @@ export function formatTaskLine(task: RestTask): string {
47
50
  if (task.assignee !== null) {
48
51
  notes.push(`assigned to ${task.assignee.login}`);
49
52
  }
53
+ if (task.category !== null) {
54
+ notes.push(task.category.key);
55
+ }
56
+ if (task.dueOn !== null) {
57
+ notes.push(`due ${task.dueOn}`);
58
+ }
50
59
  if (task.openDecisions > 0) {
51
60
  notes.push(`${plural(task.openDecisions, "decision")} waiting`);
52
61
  }
@@ -56,6 +65,28 @@ export function formatTaskLine(task: RestTask): string {
56
65
  return `#${task.number} ${task.state} ${task.priority} ${task.title} (${notes.join("; ")})`;
57
66
  }
58
67
 
68
+ /** The plan and what happened, as a line each, only where there is something to say. */
69
+ function scheduleLines(task: RestTask): string[] {
70
+ const lines: string[] = [];
71
+ const plan = [
72
+ task.category === null ? undefined : `category: ${task.category.key}`,
73
+ task.goalId === null ? undefined : `goal: ${task.goalId}`,
74
+ task.startsOn === null ? undefined : `starts: ${task.startsOn}`,
75
+ task.dueOn === null ? undefined : `due: ${task.dueOn}`,
76
+ ].filter((part) => part !== undefined);
77
+ if (plan.length > 0) {
78
+ lines.push(plan.join(" "));
79
+ }
80
+ const happened = [
81
+ task.startedAt === null ? undefined : `started: ${task.startedAt}`,
82
+ task.doneAt === null ? undefined : `done: ${task.doneAt}`,
83
+ ].filter((part) => part !== undefined);
84
+ if (happened.length > 0) {
85
+ lines.push(happened.join(" "));
86
+ }
87
+ return lines;
88
+ }
89
+
59
90
  /** One task in full; with the decisions about it and the runs on it, when given. */
60
91
  export function formatTask(
61
92
  task: RestTask,
@@ -66,6 +97,7 @@ export function formatTask(
66
97
  `#${task.number} ${task.title}`,
67
98
  `state: ${task.state} priority: ${task.priority} owner: ${task.owner.login} assignee: ${task.assignee?.login ?? "nobody"}`,
68
99
  `id: ${task.id}${task.parentId === null ? "" : ` part of: ${task.parentId}`}`,
100
+ ...scheduleLines(task),
69
101
  ];
70
102
  if (task.links.length > 0) {
71
103
  lines.push("links:");
@@ -165,6 +197,62 @@ export function formatSkills(skills: RestSkills): string {
165
197
  return lines.join("\n");
166
198
  }
167
199
 
200
+ /** How far a goal is, from its tasks: the done, the at work, and the rest, in one phrase. */
201
+ function progressOf(goal: RestGoal): string {
202
+ const counts = goal.taskCounts;
203
+ const total = counts.idea + counts.ready + counts.in_progress + counts.in_review + counts.done;
204
+ const working = counts.in_progress + counts.in_review;
205
+ return `${counts.done}/${total} done${working === 0 ? "" : `, ${working} in progress`}${counts.dropped === 0 ? "" : `, ${counts.dropped} dropped`}`;
206
+ }
207
+
208
+ /** One goal: its id, its state, its title, when it is due, and how far it is. */
209
+ export function formatGoalLine(goal: RestGoal): string {
210
+ const notes = [goal.dueOn === null ? "no target day" : `due ${goal.dueOn}`, progressOf(goal)];
211
+ return `${goal.id} ${goal.state} ${goal.title} (${notes.join("; ")})`;
212
+ }
213
+
214
+ /** One goal in full; with the tasks that work towards it, when given. */
215
+ export function formatGoal(goal: RestGoal, tasks?: readonly RestTask[]): string {
216
+ const lines = [
217
+ `goal ${goal.id}: ${goal.title}`,
218
+ `state: ${goal.state} due: ${goal.dueOn ?? "none"} set by: ${goal.createdBy.login}`,
219
+ `progress: ${progressOf(goal)}`,
220
+ ];
221
+ if (goal.body.trim().length > 0) {
222
+ lines.push("", goal.body.trimEnd());
223
+ }
224
+ if (tasks !== undefined) {
225
+ lines.push("", tasks.length === 0 ? "tasks: none" : "tasks:");
226
+ for (const task of tasks) {
227
+ lines.push(` ${formatTaskLine(task)}`);
228
+ }
229
+ }
230
+ return lines.join("\n");
231
+ }
232
+
233
+ /** One category: its key, what people call it, and its colour. */
234
+ export function formatCategoryLine(category: RestCategory): string {
235
+ return `${category.key} ${category.name} (${category.color})`;
236
+ }
237
+
238
+ /** What is on a person's plate: their open tasks by project, and the decisions waiting. */
239
+ export function formatWork(work: RestWork): string {
240
+ const lines: string[] = [];
241
+ if (work.tasks.length === 0) {
242
+ lines.push("No open task is assigned to you.");
243
+ } else {
244
+ lines.push("tasks assigned to you:");
245
+ for (const { project, task } of work.tasks) {
246
+ lines.push(` ${project.key} ${formatTaskLine(task)}`);
247
+ }
248
+ }
249
+ lines.push("", work.decisions.length === 0 ? "decisions waiting: none" : "decisions waiting:");
250
+ for (const { project, decision } of work.decisions) {
251
+ lines.push(` ${project.key} ${formatDecisionLine(decision)}`);
252
+ }
253
+ return lines.join("\n");
254
+ }
255
+
168
256
  export function formatDecisionLine(decision: RestDecision): string {
169
257
  const state = decision.answer === null ? "waiting" : `answered ${decision.answer.option}`;
170
258
  return `${decision.id} ${state} ${decision.question} (asked by ${asker(decision)})`;
package/src/cli/help.ts CHANGED
@@ -51,6 +51,22 @@ const MARKDOWN = `Markdown, up to ${MAX_BODY_LENGTH} characters`;
51
51
  const DOCUMENT_PATH = `a path of lowercase letters, digits and hyphens, with slashes between its folders (guides/onboarding), up to ${MAX_DOCUMENT_PATH_LENGTH} characters; it does not change once written`;
52
52
  const DOCUMENT_REFUSALS = ["document.not_found: no page has that path in this project"];
53
53
 
54
+ const DAY = "a day as 2026-10-24, today, tomorrow, or +7 for seven days from today";
55
+ const TASK_FIELD_FLAGS =
56
+ "[--state <state>] [--priority <priority>] [--category <key>] [--goal <id>] [--starts <day>] [--due <day>] [--assignee <login|me>] [--parent <number|id>]";
57
+ const TASK_FIELD_LIMITS = [
58
+ `starts, due: ${DAY}; the due day is not before the start`,
59
+ "category: the key of one of the workspace's categories (console categories); goal: the id of one of the project's goals (console goals)",
60
+ "assignee: a login of the workspace, or me; parent: a task of the project",
61
+ ];
62
+ const TASK_FIELD_REFUSALS = [
63
+ "task.invalid_category: no category has that key",
64
+ "task.invalid_goal: the goal is not one of this project",
65
+ "task.invalid_dates: the due day is before the start",
66
+ "task.invalid_assignee: the person may not work in the project",
67
+ "task.invalid_parent: the parent is not a task of the project, or would make a loop",
68
+ ];
69
+
54
70
  export const COMMAND_HELP: Readonly<Record<string, CommandHelp>> = {
55
71
  login: {
56
72
  usage: "login --url <origin> [--agent <name>] [--token-stdin]",
@@ -82,18 +98,52 @@ export const COMMAND_HELP: Readonly<Record<string, CommandHelp>> = {
82
98
  "Says which project this directory works in: writes .skillcdn-console.json here, which the commands read from this directory or one above it, and which is meant to be committed. --project <key> on any command, or CONSOLE_PROJECT in the environment, wins over it.",
83
99
  refusals: ["project.not_found: no project has the key, or it is not yours to see"],
84
100
  },
101
+ work: {
102
+ usage: "work",
103
+ about:
104
+ "What is on your plate across the projects you may see: the open tasks assigned to you, the sooner due first, each with its project; and the decisions waiting for a person. Needs no project.",
105
+ },
85
106
  tasks: {
86
- usage: "tasks [--state <state>]",
107
+ usage:
108
+ "tasks [--state <state>] [--open] [--mine] [--category <key>] [--goal <id>] [--due <day|today|week|overdue>]",
87
109
  about:
88
- "The tasks on the board, newest first: number, state, priority, title, who owns and works on each, and what waits on it. States: idea, ready, in_progress, in_review, done, dropped.",
110
+ "The tasks on the board, newest first: number, state, priority, title, who owns and works on each, its category and its due day, and what waits on it. --open keeps the ones not done or dropped, --mine those assigned to you, --category and --goal those sorted into one, --due those due by a day: today, within the week, or overdue (the open ones past their day). States: idea, ready, in_progress, in_review, done, dropped.",
89
111
  },
90
112
  task: {
113
+ usage: `task <number|id> | task new <title> [--body <markdown>|--file <path>] ${TASK_FIELD_FLAGS} | task edit <number|id> [--title <title>] [--body <markdown>|--file <path>] ${TASK_FIELD_FLAGS}`,
114
+ about:
115
+ "One task in full, with its plan (category, goal, start and due days), what happened to it (when it was started and done, which the board keeps itself), the decisions about it and the runs on it; or a new task written down; or a task changed, only in what the flags say, where none clears a field (priorities: low, normal, high, urgent).",
116
+ limits: [
117
+ `title: one line, up to ${MAX_TITLE_LENGTH} characters`,
118
+ `body: ${MARKDOWN}`,
119
+ ...TASK_FIELD_LIMITS,
120
+ ],
121
+ refusals: ["task.not_found: no task has that number or id", ...TASK_FIELD_REFUSALS],
122
+ },
123
+ goals: {
124
+ usage: "goals",
125
+ about:
126
+ "The roadmap of the project: its goals, the active first, each with its id, its state (planned, active, done, dropped), its target day, and how far it is, counted from the tasks that work towards it.",
127
+ },
128
+ goal: {
91
129
  usage:
92
- "task <number|id> | task new <title> [--body <markdown>|--file <path>] [--state <state>] [--priority <priority>]",
130
+ 'goal <id> | goal new "<title>" [--body <markdown>|--file <path>] [--state <state>] [--due <day>] | goal edit <id> [--title <title>] [--body <markdown>|--file <path>] [--state <state>] [--due <day|none>]',
93
131
  about:
94
- "One task in full, with the decisions about it and the runs on it; or a new task written down (priorities: low, normal, high, urgent).",
95
- limits: [`title: one line, up to ${MAX_TITLE_LENGTH} characters`, `body: ${MARKDOWN}`],
96
- refusals: ["task.not_found: no task has that number or id"],
132
+ "One goal in full, with the tasks that work towards it; or a goal set on the roadmap, for an owner of the project; or one changed, only in what the flags say. A task works towards a goal through its --goal.",
133
+ limits: [
134
+ `title: one line, up to ${MAX_TITLE_LENGTH} characters`,
135
+ `body: ${MARKDOWN}`,
136
+ `due: ${DAY}`,
137
+ ],
138
+ refusals: [
139
+ "goal.not_found: no goal of this project has that id",
140
+ "auth.forbidden: only an owner of the project sets its roadmap",
141
+ ],
142
+ },
143
+ categories: {
144
+ usage: "categories",
145
+ about:
146
+ "The categories tasks are sorted into, the same in every project: each with its key, which --category takes, its name and its colour. An administrator keeps them on the console's Categories page.",
97
147
  },
98
148
  skills: {
99
149
  usage: "skills",
@@ -283,7 +333,19 @@ const SECTIONS: readonly { readonly title: string; readonly commands: readonly s
283
333
  },
284
334
  {
285
335
  title: "The board",
286
- commands: ["tasks", "task", "decisions", "decision", "runs", "run", "skills"],
336
+ commands: [
337
+ "work",
338
+ "tasks",
339
+ "task",
340
+ "goals",
341
+ "goal",
342
+ "categories",
343
+ "decisions",
344
+ "decision",
345
+ "runs",
346
+ "run",
347
+ "skills",
348
+ ],
287
349
  },
288
350
  { title: "Working", commands: ["take", "report", "hand-in", "ask", "finish", "fail", "abandon"] },
289
351
  {
package/src/client.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import {
2
2
  AUTH_ROUTES,
3
3
  CLAIM_PATH,
4
+ categoryPath,
4
5
  connectPath,
5
6
  personTokensPath,
6
7
  projectPath,
@@ -10,6 +11,10 @@ import {
10
11
  import {
11
12
  type RestAnswerInput,
12
13
  type RestArtifactInput,
14
+ type RestCategories,
15
+ type RestCategory,
16
+ type RestCategoryInput,
17
+ type RestCategoryPatch,
13
18
  type RestClaim,
14
19
  type RestConnectInput,
15
20
  type RestConnection,
@@ -22,6 +27,10 @@ import {
22
27
  type RestDocumentInput,
23
28
  type RestDocuments,
24
29
  type RestEvents,
30
+ type RestGoal,
31
+ type RestGoalInput,
32
+ type RestGoalPatch,
33
+ type RestGoals,
25
34
  type RestMe,
26
35
  type RestMember,
27
36
  type RestMemberInput,
@@ -50,6 +59,9 @@ import {
50
59
  type RestTokens,
51
60
  type RestVersion,
52
61
  type RestVersions,
62
+ type RestWork,
63
+ restCategoriesSchema,
64
+ restCategorySchema,
53
65
  restClaimSchema,
54
66
  restConnectionSchema,
55
67
  restConnectRequestSchema,
@@ -59,6 +71,8 @@ import {
59
71
  restDocumentsSchema,
60
72
  restErrorSchema,
61
73
  restEventsSchema,
74
+ restGoalSchema,
75
+ restGoalsSchema,
62
76
  restMemberSchema,
63
77
  restMembersSchema,
64
78
  restMeSchema,
@@ -75,6 +89,7 @@ import {
75
89
  restTokensSchema,
76
90
  restVersionSchema,
77
91
  restVersionsSchema,
92
+ restWorkSchema,
78
93
  } from "./schemas.js";
79
94
  import type { TaskState } from "./vocabulary.js";
80
95
 
@@ -135,6 +150,21 @@ export interface EventFilter {
135
150
  readonly document?: string | undefined;
136
151
  }
137
152
 
153
+ /** What the board is narrowed to: one state, the open ones, a category, a goal, an assignee, or what is due by a day. */
154
+ export interface TaskFilter {
155
+ readonly state?: TaskState | undefined;
156
+ /** True for the tasks not done or dropped; false for the rest. */
157
+ readonly open?: boolean | undefined;
158
+ /** The key of a category. */
159
+ readonly category?: string | undefined;
160
+ /** The id of a goal. */
161
+ readonly goal?: string | undefined;
162
+ /** The id of the person the tasks are assigned to. */
163
+ readonly assignee?: string | undefined;
164
+ /** A day (`2026-10-20`): the tasks due on it or before it. */
165
+ readonly dueBy?: string | undefined;
166
+ }
167
+
138
168
  /** What a folder's listing, or a search, of the documents is narrowed to. */
139
169
  export interface DocumentFilter {
140
170
  /** The folder to list, `""` or left out for the root. */
@@ -159,7 +189,7 @@ export interface ProjectClient {
159
189
  addMember(input: RestMemberInput): Promise<RestMember>;
160
190
  updateMember(personId: string, patch: RestMemberPatch): Promise<RestMember>;
161
191
  removeMember(personId: string): Promise<void>;
162
- tasks(filter?: { readonly state?: TaskState }, signal?: AbortSignal): Promise<RestTasks>;
192
+ tasks(filter?: TaskFilter, signal?: AbortSignal): Promise<RestTasks>;
163
193
  /** One task, by its id or by its number. */
164
194
  task(ref: string, signal?: AbortSignal): Promise<RestTask>;
165
195
  createTask(input: RestTaskInput): Promise<RestTask>;
@@ -221,6 +251,12 @@ export interface ProjectClient {
221
251
  attachFile(path: string, input: FileUpload): Promise<RestDocument>;
222
252
  /** Where the bytes of a file attached to a document are read, by the file's id. */
223
253
  documentFileUrl(path: string, fileId: string): string;
254
+ /** The roadmap (ADR-0019): the goals, the active first, each with its tasks counted by state. */
255
+ goals(signal?: AbortSignal): Promise<RestGoals>;
256
+ goal(id: string, signal?: AbortSignal): Promise<RestGoal>;
257
+ /** Sets a goal; for an owner, with a session or a token. */
258
+ createGoal(input: RestGoalInput): Promise<RestGoal>;
259
+ updateGoal(id: string, patch: RestGoalPatch): Promise<RestGoal>;
224
260
  }
225
261
 
226
262
  export interface ConsoleClient {
@@ -238,6 +274,15 @@ export interface ConsoleClient {
238
274
  project(key: string): ProjectClient;
239
275
  /** The workspace's own events, the ones about no project, after event number `after`. */
240
276
  events(after: number, signal?: AbortSignal): Promise<RestEvents>;
277
+ /** The categories tasks are sorted into (ADR-0019), by name. */
278
+ categories(signal?: AbortSignal): Promise<RestCategories>;
279
+ /** Makes a category; for an administrator signed in. */
280
+ createCategory(input: RestCategoryInput): Promise<RestCategory>;
281
+ updateCategory(key: string, patch: RestCategoryPatch): Promise<RestCategory>;
282
+ /** Removes a category no task uses; for an administrator signed in. */
283
+ removeCategory(key: string): Promise<void>;
284
+ /** What is on the asker's plate across the projects they may see: their open tasks, and the decisions waiting. */
285
+ work(signal?: AbortSignal): Promise<RestWork>;
241
286
  /** The tokens of whoever asks. */
242
287
  tokens(signal?: AbortSignal): Promise<RestTokens>;
243
288
  /** The tokens of another person: for an administrator. */
@@ -370,7 +415,14 @@ export function createClient(options: ClientOptions = {}): ConsoleClient {
370
415
  tasks: (filter = {}, signal) =>
371
416
  request(
372
417
  "GET",
373
- withQuery(at("tasks"), { state: filter.state }),
418
+ withQuery(at("tasks"), {
419
+ state: filter.state,
420
+ open: flag(filter.open),
421
+ category: filter.category,
422
+ goal: filter.goal,
423
+ assignee: filter.assignee,
424
+ dueBy: filter.dueBy,
425
+ }),
374
426
  undefined,
375
427
  restTasksSchema,
376
428
  signal,
@@ -485,6 +537,13 @@ export function createClient(options: ClientOptions = {}): ConsoleClient {
485
537
  request("POST", `${base}${at("docs", path)}/files`, formOf(input), restDocumentSchema),
486
538
  documentFileUrl: (path, fileId) =>
487
539
  `${base}${at("docs", path)}/files/${encodeURIComponent(fileId)}`,
540
+ goals: (signal) =>
541
+ request("GET", `${base}${at("goals")}`, undefined, restGoalsSchema, signal),
542
+ goal: (id, signal) =>
543
+ request("GET", `${base}${at("goals", id)}`, undefined, restGoalSchema, signal),
544
+ createGoal: (input) => request("POST", `${base}${at("goals")}`, input, restGoalSchema),
545
+ updateGoal: (id, patch) =>
546
+ request("PATCH", `${base}${at("goals", id)}`, patch, restGoalSchema),
488
547
  };
489
548
  };
490
549
 
@@ -507,6 +566,15 @@ export function createClient(options: ClientOptions = {}): ConsoleClient {
507
566
  restEventsSchema,
508
567
  signal,
509
568
  ),
569
+ categories: (signal) =>
570
+ request("GET", `${base}${REST_ROUTES.categories}`, undefined, restCategoriesSchema, signal),
571
+ createCategory: (input) =>
572
+ request("POST", `${base}${REST_ROUTES.categories}`, input, restCategorySchema),
573
+ updateCategory: (key, patch) =>
574
+ request("PATCH", `${base}${categoryPath(key)}`, patch, restCategorySchema),
575
+ removeCategory: (key) => request("DELETE", `${base}${categoryPath(key)}`, undefined, undefined),
576
+ work: (signal) =>
577
+ request("GET", `${base}${REST_ROUTES.work}`, undefined, restWorkSchema, signal),
510
578
  tokens: (signal) =>
511
579
  request("GET", `${base}${REST_ROUTES.tokens}`, undefined, restTokensSchema, signal),
512
580
  createToken: (input) =>
@@ -1,10 +1,30 @@
1
1
  import type { ReactNode } from "react";
2
- import { type RestTask, TASK_STATES, type TaskState } from "../api.js";
3
- import { useWords } from "../i18n/index.js";
4
- import { Avatar, Badge, cx, PriorityBadge } from "./ui.js";
2
+ import {
3
+ type RestCategory,
4
+ type RestGoal,
5
+ type RestPerson,
6
+ type RestRun,
7
+ type RestTask,
8
+ TASK_STATES,
9
+ type TaskState,
10
+ } from "../api.js";
11
+ import { useLanguage, useWords } from "../i18n/index.js";
12
+ import type { BoardFilter } from "../router.js";
13
+ import {
14
+ Avatar,
15
+ Badge,
16
+ Button,
17
+ CategoryBadge,
18
+ cx,
19
+ DueBadge,
20
+ formatInstant,
21
+ PriorityBadge,
22
+ } from "./ui.js";
5
23
 
6
- // The board: one column per state, the tasks in it, newest first. It takes its data as props
7
- // and nothing from the network; what it is told on a click goes back up.
24
+ // The board: one column per state, the tasks in it, the sooner due first and the newest after.
25
+ // A card says what a person needs at a glance and nothing more: the number and the title, the
26
+ // category, what is due, who is on it, what blocks it, and whose agent is at it. It takes its
27
+ // data as props and nothing from the network; what it is told on a click goes back up.
8
28
 
9
29
  export interface BoardProps {
10
30
  readonly tasks: readonly RestTask[];
@@ -13,18 +33,32 @@ export interface BoardProps {
13
33
  readonly onOpen: (task: RestTask) => void;
14
34
  /** Called when a person moves a task to another column. Left out, the board is read-only. */
15
35
  readonly onMove?: ((task: RestTask, state: TaskState) => void) | undefined;
36
+ /** The runs of the project, for a card to name the agent at work on its task. */
37
+ readonly runs?: readonly RestRun[] | undefined;
38
+ /** The day to count due days from; the browser's today when left out. */
39
+ readonly today?: string | undefined;
16
40
  /** What a column shows when it has nothing; the way to write the first task, for example. */
17
41
  readonly empty?: ReactNode;
18
42
  }
19
43
 
44
+ /** The open run on a task, when an agent is at work on it: whose, and since when. */
45
+ function runOn(task: RestTask, runs: readonly RestRun[] | undefined): RestRun | undefined {
46
+ return runs?.find(
47
+ (run) => run.taskId === task.id && (run.status === "running" || run.status === "waiting"),
48
+ );
49
+ }
50
+
20
51
  export function TaskCard(props: {
21
52
  readonly task: RestTask;
22
53
  readonly href: string;
23
54
  readonly onOpen: (task: RestTask) => void;
24
55
  readonly onMove?: ((task: RestTask, state: TaskState) => void) | undefined;
56
+ readonly run?: RestRun | undefined;
57
+ readonly today?: string | undefined;
25
58
  }) {
26
59
  const words = useWords();
27
- const { task } = props;
60
+ const { tag } = useLanguage();
61
+ const { task, run } = props;
28
62
  return (
29
63
  <article className="sc-card" aria-label={`#${task.number} ${task.title}`}>
30
64
  <a
@@ -40,20 +74,28 @@ export function TaskCard(props: {
40
74
  <span className="sc-card-number">#{task.number}</span> {task.title}
41
75
  </a>
42
76
  <div className="sc-card-meta">
77
+ {task.category !== null && <CategoryBadge category={task.category} />}
43
78
  <PriorityBadge priority={task.priority} />
79
+ {task.dueOn !== null && (
80
+ <DueBadge dueOn={task.dueOn} state={task.state} today={props.today} />
81
+ )}
44
82
  {task.openDecisions > 0 && (
45
83
  <Badge tone="warning" title={words.board.decisionsTitle}>
46
84
  {words.board.decisions(task.openDecisions)}
47
85
  </Badge>
48
86
  )}
49
87
  {task.openRuns > 0 && (
50
- <Badge tone="accent" title={words.board.agentAtWorkTitle}>
51
- {words.board.agentAtWork}
88
+ <Badge
89
+ tone="accent"
90
+ title={
91
+ run === undefined
92
+ ? words.board.agentAtWorkTitle
93
+ : words.board.agentSince(run.agent, formatInstant(run.startedAt, tag))
94
+ }
95
+ >
96
+ {run === undefined ? words.board.agentAtWork : run.agent}
52
97
  </Badge>
53
98
  )}
54
- {task.links.length > 0 && (
55
- <Badge tone="neutral">{words.board.links(task.links.length)}</Badge>
56
- )}
57
99
  <span className="sc-card-spacer" />
58
100
  {task.assignee !== null && <Avatar person={task.assignee} size="sm" />}
59
101
  </div>
@@ -76,12 +118,26 @@ export function TaskCard(props: {
76
118
  );
77
119
  }
78
120
 
121
+ /** The order a column reads in: what is due soonest first, what has no day after, the newest first among those. */
122
+ export function byDue(a: RestTask, b: RestTask): number {
123
+ if (a.dueOn !== b.dueOn) {
124
+ if (a.dueOn === null) {
125
+ return 1;
126
+ }
127
+ if (b.dueOn === null) {
128
+ return -1;
129
+ }
130
+ return a.dueOn.localeCompare(b.dueOn);
131
+ }
132
+ return b.number - a.number;
133
+ }
134
+
79
135
  export function Board(props: BoardProps) {
80
136
  const words = useWords();
81
137
  return (
82
138
  <div className="sc-board">
83
139
  {TASK_STATES.map((state) => {
84
- const tasks = props.tasks.filter((task) => task.state === state);
140
+ const tasks = props.tasks.filter((task) => task.state === state).sort(byDue);
85
141
  return (
86
142
  <section
87
143
  key={state}
@@ -100,6 +156,8 @@ export function Board(props: BoardProps) {
100
156
  href={props.taskHref(task)}
101
157
  onOpen={props.onOpen}
102
158
  onMove={props.onMove}
159
+ run={runOn(task, props.runs)}
160
+ today={props.today}
103
161
  />
104
162
  ))}
105
163
  {tasks.length === 0 && state === "idea" && props.empty}
@@ -110,3 +168,138 @@ export function Board(props: BoardProps) {
110
168
  </div>
111
169
  );
112
170
  }
171
+
172
+ /** What the filter keeps of the board: a category or none, a goal or none, a person, one's own. */
173
+ export function filterTasks(
174
+ tasks: readonly RestTask[],
175
+ filter: BoardFilter,
176
+ me?: RestPerson | undefined,
177
+ ): RestTask[] {
178
+ return tasks.filter((task) => {
179
+ if (filter.category !== undefined && (task.category?.key ?? "") !== filter.category) {
180
+ return false;
181
+ }
182
+ if (filter.goal !== undefined && (task.goalId ?? "") !== filter.goal) {
183
+ return false;
184
+ }
185
+ if (filter.person !== undefined && task.assignee?.id !== filter.person) {
186
+ return false;
187
+ }
188
+ if (filter.mine === true && (me === undefined || task.assignee?.id !== me.id)) {
189
+ return false;
190
+ }
191
+ return true;
192
+ });
193
+ }
194
+
195
+ export interface BoardFiltersProps {
196
+ readonly filter: BoardFilter;
197
+ readonly categories: readonly RestCategory[];
198
+ readonly goals: readonly RestGoal[];
199
+ readonly people: readonly RestPerson[];
200
+ /** Whoever is looking, for "mine". */
201
+ readonly me?: RestPerson | undefined;
202
+ /** How many tasks the filter keeps, of how many. */
203
+ readonly shown: number;
204
+ readonly total: number;
205
+ /** Called with the filter as it is to be: the composition shows the board at its address. */
206
+ readonly onChange: (filter: BoardFilter) => void;
207
+ }
208
+
209
+ /** The ways to narrow the board, each a change of its address (ADR-0010). */
210
+ export function BoardFilters(props: BoardFiltersProps) {
211
+ const words = useWords().board.filters;
212
+ const { filter } = props;
213
+ const narrowed = Object.keys(filter).length > 0;
214
+ const goals = props.goals.filter((goal) => goal.state !== "dropped");
215
+ const change = (patch: BoardFilter, drop?: keyof BoardFilter) => {
216
+ const next: Record<string, unknown> = { ...filter, ...patch };
217
+ if (drop !== undefined) {
218
+ delete next[drop];
219
+ }
220
+ props.onChange(next as BoardFilter);
221
+ };
222
+ return (
223
+ <form
224
+ className="sc-filters"
225
+ aria-label={words.aria}
226
+ onSubmit={(event) => event.preventDefault()}
227
+ >
228
+ <label className="sc-field sc-field-inline">
229
+ <span className="sc-visually-hidden">{words.anyCategory}</span>
230
+ <select
231
+ className="sc-input"
232
+ value={filter.category ?? "*"}
233
+ onChange={(event) =>
234
+ event.target.value === "*"
235
+ ? change({}, "category")
236
+ : change({ category: event.target.value })
237
+ }
238
+ >
239
+ <option value="*">{words.anyCategory}</option>
240
+ <option value="">{words.noCategory}</option>
241
+ {props.categories.map((category) => (
242
+ <option key={category.key} value={category.key}>
243
+ {category.name}
244
+ </option>
245
+ ))}
246
+ </select>
247
+ </label>
248
+ {goals.length > 0 && (
249
+ <label className="sc-field sc-field-inline">
250
+ <span className="sc-visually-hidden">{words.anyGoal}</span>
251
+ <select
252
+ className="sc-input"
253
+ value={filter.goal ?? "*"}
254
+ onChange={(event) =>
255
+ event.target.value === "*" ? change({}, "goal") : change({ goal: event.target.value })
256
+ }
257
+ >
258
+ <option value="*">{words.anyGoal}</option>
259
+ <option value="">{words.noGoal}</option>
260
+ {goals.map((goal) => (
261
+ <option key={goal.id} value={goal.id}>
262
+ {goal.title}
263
+ </option>
264
+ ))}
265
+ </select>
266
+ </label>
267
+ )}
268
+ <label className="sc-field sc-field-inline">
269
+ <span className="sc-visually-hidden">{words.everyone}</span>
270
+ <select
271
+ className="sc-input"
272
+ value={filter.mine === true ? "mine" : (filter.person ?? "*")}
273
+ onChange={(event) => {
274
+ const value = event.target.value;
275
+ if (value === "*") {
276
+ change({ mine: undefined, person: undefined });
277
+ return;
278
+ }
279
+ if (value === "mine") {
280
+ change({ mine: true, person: undefined });
281
+ return;
282
+ }
283
+ change({ person: value, mine: undefined });
284
+ }}
285
+ >
286
+ <option value="*">{words.everyone}</option>
287
+ {props.me !== undefined && <option value="mine">{words.mine}</option>}
288
+ {props.people
289
+ .filter((person) => person.id !== props.me?.id)
290
+ .map((person) => (
291
+ <option key={person.id} value={person.id}>
292
+ {person.login}
293
+ </option>
294
+ ))}
295
+ </select>
296
+ </label>
297
+ <span className="sc-filters-count sc-muted">{words.showing(props.shown, props.total)}</span>
298
+ {narrowed && (
299
+ <Button variant="ghost" size="sm" onClick={() => props.onChange({})}>
300
+ {words.clear}
301
+ </Button>
302
+ )}
303
+ </form>
304
+ );
305
+ }