@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/cli/help.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import {
2
+ DEFAULT_PAGE_LIMIT,
2
3
  MAX_AGENT_LENGTH,
3
4
  MAX_ARTIFACTS_PER_RUN,
4
5
  MAX_BODY_LENGTH,
@@ -51,6 +52,22 @@ const MARKDOWN = `Markdown, up to ${MAX_BODY_LENGTH} characters`;
51
52
  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
53
  const DOCUMENT_REFUSALS = ["document.not_found: no page has that path in this project"];
53
54
 
55
+ const DAY = "a day as 2026-10-24, today, tomorrow, or +7 for seven days from today";
56
+ const TASK_FIELD_FLAGS =
57
+ "[--state <state>] [--priority <priority>] [--category <key>] [--goal <id>] [--starts <day>] [--due <day>] [--assignee <login|me>] [--parent <number|id>]";
58
+ const TASK_FIELD_LIMITS = [
59
+ `starts, due: ${DAY}; the due day is not before the start`,
60
+ "category: the key of one of the workspace's categories (console categories); goal: the id of one of the project's goals (console goals)",
61
+ "assignee: a login of the workspace, or me; parent: a task of the project",
62
+ ];
63
+ const TASK_FIELD_REFUSALS = [
64
+ "task.invalid_category: no category has that key",
65
+ "task.invalid_goal: the goal is not one of this project",
66
+ "task.invalid_dates: the due day is before the start",
67
+ "task.invalid_assignee: the person may not work in the project",
68
+ "task.invalid_parent: the parent is not a task of the project, or would make a loop",
69
+ ];
70
+
54
71
  export const COMMAND_HELP: Readonly<Record<string, CommandHelp>> = {
55
72
  login: {
56
73
  usage: "login --url <origin> [--agent <name>] [--token-stdin]",
@@ -82,18 +99,51 @@ export const COMMAND_HELP: Readonly<Record<string, CommandHelp>> = {
82
99
  "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
100
  refusals: ["project.not_found: no project has the key, or it is not yours to see"],
84
101
  },
85
- tasks: {
86
- usage: "tasks [--state <state>]",
102
+ work: {
103
+ usage: "work",
87
104
  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.",
105
+ "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.",
106
+ },
107
+ tasks: {
108
+ usage:
109
+ "tasks [--state <state>] [--open] [--mine] [--category <key>] [--goal <id>] [--due <day|today|week|overdue>] [--limit <n>] [--offset <n>]",
110
+ about: `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). A page at a time: ${DEFAULT_PAGE_LIMIT} unless --limit says otherwise, from --offset on; a last line says when there is more. 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>]',
131
+ about:
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",
93
145
  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"],
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",
@@ -103,11 +153,10 @@ export const COMMAND_HELP: Readonly<Record<string, CommandHelp>> = {
103
153
  take: {
104
154
  usage: "take <number|id> [--agent <name>]",
105
155
  about:
106
- "Takes a task: a run begins, and the task is in progress and the person's. One agent at a time per task. --agent is what people see at work; left out, it is what the person called the token.",
156
+ "Takes a task: a run begins, and the task is in progress and the person's. A run still open on the task is taken over, since the board cannot know whether its agent still runs: it ends as abandoned, the feed says who took over, and that agent is refused on its next command. Look before taking (console task <number> lists the runs, with when each was last heard from), and take a task another agent was heard from on lately only when told to. --agent is what people see at work; left out, it is what the person called the token.",
107
157
  limits: [`agent: one line, up to ${MAX_AGENT_LENGTH} characters`],
108
158
  refusals: [
109
159
  "task.not_found: no task has that number or id",
110
- "run.task_taken: a run is open on the task already; console runs --open --mine shows whether it is yours",
111
160
  "run.task_closed: the task is done or dropped",
112
161
  ],
113
162
  },
@@ -157,8 +206,8 @@ export const COMMAND_HELP: Readonly<Record<string, CommandHelp>> = {
157
206
  refusals: ["decision.not_found: no decision has that id"],
158
207
  },
159
208
  decisions: {
160
- usage: "decisions [--open]",
161
- about: "The decisions on the board, the waiting first; --open keeps only those.",
209
+ usage: "decisions [--open] [--limit <n>] [--offset <n>]",
210
+ about: `The decisions on the board, the waiting first; --open keeps only those. A page at a time: ${DEFAULT_PAGE_LIMIT} unless --limit says otherwise, from --offset on.`,
162
211
  },
163
212
  finish: {
164
213
  usage: 'finish [--summary "<markdown>"|--file <path>]',
@@ -181,9 +230,8 @@ export const COMMAND_HELP: Readonly<Record<string, CommandHelp>> = {
181
230
  refusals: RUN_REFUSALS,
182
231
  },
183
232
  runs: {
184
- usage: "runs [--task <number|id>] [--open] [--mine]",
185
- about:
186
- "The runs, newest first: agents at work and what they did. --mine keeps those begun with this token.",
233
+ usage: "runs [--task <number|id>] [--open] [--mine] [--limit <n>] [--offset <n>]",
234
+ about: `The runs, newest first, each with when its agent was last heard from: --open keeps the ones not over, --mine those begun with this token. A page at a time: ${DEFAULT_PAGE_LIMIT} unless --limit says otherwise, from --offset on.`,
187
235
  },
188
236
  run: {
189
237
  usage: "run <id>",
@@ -262,6 +310,8 @@ Usage: console <command> [arguments] [--json] [--url <origin>] [--project <key>]
262
310
 
263
311
  const CLOSING = `Options everywhere: --json answers with the console's own JSON; --url names another console;
264
312
  --project names the project to work in, over CONSOLE_PROJECT and over what console use kept here.
313
+ A listing (tasks, decisions, runs) is a page at a time: --limit <n> and --offset <n>, and a last line
314
+ says when there is more, so that nothing is read that is not needed.
265
315
  Commands on a run (report, hand-in, ask, finish, fail) act on your one open run in the project;
266
316
  with several open, say which with --run <id>. A Markdown body given as - is read from standard input.
267
317
  console help <command> says what each command takes, the limits the console holds it to, and
@@ -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,27 @@ export interface EventFilter {
135
150
  readonly document?: string | undefined;
136
151
  }
137
152
 
153
+ /** One page of a listing: how many items, from which one on; the server's bounds apply. */
154
+ export interface Page {
155
+ readonly limit?: number | undefined;
156
+ readonly offset?: number | undefined;
157
+ }
158
+
159
+ /** What the board is narrowed to: one state, the open ones, a category, a goal, an assignee, or what is due by a day. */
160
+ export interface TaskFilter extends Page {
161
+ readonly state?: TaskState | undefined;
162
+ /** True for the tasks not done or dropped; false for the rest. */
163
+ readonly open?: boolean | undefined;
164
+ /** The key of a category. */
165
+ readonly category?: string | undefined;
166
+ /** The id of a goal. */
167
+ readonly goal?: string | undefined;
168
+ /** The id of the person the tasks are assigned to. */
169
+ readonly assignee?: string | undefined;
170
+ /** A day (`2026-10-20`): the tasks due on it or before it. */
171
+ readonly dueBy?: string | undefined;
172
+ }
173
+
138
174
  /** What a folder's listing, or a search, of the documents is narrowed to. */
139
175
  export interface DocumentFilter {
140
176
  /** The folder to list, `""` or left out for the root. */
@@ -153,20 +189,24 @@ export interface ProjectClient {
153
189
  get(signal?: AbortSignal): Promise<RestProject>;
154
190
  /** Changes the project's settings; for an owner. */
155
191
  update(patch: RestProjectPatch): Promise<RestProject>;
192
+ /** Sets the project's own image for its mark, sent as a form; for an owner on the console's own pages. */
193
+ setIcon(input: FileUpload): Promise<RestProject>;
194
+ /** Takes the image away: the mark is the symbol on the colour again. */
195
+ removeIcon(): Promise<RestProject>;
156
196
  /** Those listed in the project, by login. */
157
197
  members(signal?: AbortSignal): Promise<RestMembers>;
158
198
  /** Lists a person of the workspace in the project; for an owner. */
159
199
  addMember(input: RestMemberInput): Promise<RestMember>;
160
200
  updateMember(personId: string, patch: RestMemberPatch): Promise<RestMember>;
161
201
  removeMember(personId: string): Promise<void>;
162
- tasks(filter?: { readonly state?: TaskState }, signal?: AbortSignal): Promise<RestTasks>;
202
+ tasks(filter?: TaskFilter, signal?: AbortSignal): Promise<RestTasks>;
163
203
  /** One task, by its id or by its number. */
164
204
  task(ref: string, signal?: AbortSignal): Promise<RestTask>;
165
205
  createTask(input: RestTaskInput): Promise<RestTask>;
166
206
  updateTask(id: string, patch: RestTaskPatch): Promise<RestTask>;
167
- /** The decisions, or only those that wait, or only those about one task. */
207
+ /** The decisions, or only those that wait, or only those about one task; a page of them. */
168
208
  decisions(
169
- filter?: { readonly open?: boolean; readonly task?: string },
209
+ filter?: { readonly open?: boolean; readonly task?: string } & Page,
170
210
  signal?: AbortSignal,
171
211
  ): Promise<RestDecisions>;
172
212
  decision(id: string, signal?: AbortSignal): Promise<RestDecision>;
@@ -179,9 +219,9 @@ export interface ProjectClient {
179
219
  answerDecision(id: string, input: RestAnswerInput): Promise<RestDecision>;
180
220
  /** Grows a decision's record: its context, or what followed. */
181
221
  updateDecision(id: string, patch: RestDecisionPatch): Promise<RestDecision>;
182
- /** The runs, newest first; or only those on one task, only the open ones, only the asker's own. */
222
+ /** The runs, newest first; or only those on one task, only the open ones, only the asker's own; a page of them. */
183
223
  runs(
184
- filter?: { readonly task?: string; readonly open?: boolean; readonly mine?: boolean },
224
+ filter?: { readonly task?: string; readonly open?: boolean; readonly mine?: boolean } & Page,
185
225
  signal?: AbortSignal,
186
226
  ): Promise<RestRuns>;
187
227
  run(id: string, signal?: AbortSignal): Promise<RestRun>;
@@ -221,6 +261,12 @@ export interface ProjectClient {
221
261
  attachFile(path: string, input: FileUpload): Promise<RestDocument>;
222
262
  /** Where the bytes of a file attached to a document are read, by the file's id. */
223
263
  documentFileUrl(path: string, fileId: string): string;
264
+ /** The roadmap (ADR-0019): the goals, the active first, each with its tasks counted by state. */
265
+ goals(signal?: AbortSignal): Promise<RestGoals>;
266
+ goal(id: string, signal?: AbortSignal): Promise<RestGoal>;
267
+ /** Sets a goal; for an owner, with a session or a token. */
268
+ createGoal(input: RestGoalInput): Promise<RestGoal>;
269
+ updateGoal(id: string, patch: RestGoalPatch): Promise<RestGoal>;
224
270
  }
225
271
 
226
272
  export interface ConsoleClient {
@@ -238,6 +284,15 @@ export interface ConsoleClient {
238
284
  project(key: string): ProjectClient;
239
285
  /** The workspace's own events, the ones about no project, after event number `after`. */
240
286
  events(after: number, signal?: AbortSignal): Promise<RestEvents>;
287
+ /** The categories tasks are sorted into (ADR-0019), by name. */
288
+ categories(signal?: AbortSignal): Promise<RestCategories>;
289
+ /** Makes a category; for an administrator signed in. */
290
+ createCategory(input: RestCategoryInput): Promise<RestCategory>;
291
+ updateCategory(key: string, patch: RestCategoryPatch): Promise<RestCategory>;
292
+ /** Removes a category no task uses; for an administrator signed in. */
293
+ removeCategory(key: string): Promise<void>;
294
+ /** What is on the asker's plate across the projects they may see: their open tasks, and the decisions waiting. */
295
+ work(signal?: AbortSignal): Promise<RestWork>;
241
296
  /** The tokens of whoever asks. */
242
297
  tokens(signal?: AbortSignal): Promise<RestTokens>;
243
298
  /** The tokens of another person: for an administrator. */
@@ -336,6 +391,8 @@ export function createClient(options: ClientOptions = {}): ConsoleClient {
336
391
 
337
392
  const flag = (value: boolean | undefined): string | undefined =>
338
393
  value === undefined ? undefined : String(value);
394
+ const number = (value: number | undefined): string | undefined =>
395
+ value === undefined ? undefined : String(value);
339
396
 
340
397
  /** A file as the parts of a form: the bytes with their name and type, and the label. */
341
398
  const formOf = (input: FileUpload): FormData => {
@@ -360,6 +417,8 @@ export function createClient(options: ClientOptions = {}): ConsoleClient {
360
417
  key,
361
418
  get: (signal) => request("GET", `${base}${at()}`, undefined, restProjectSchema, signal),
362
419
  update: (patch) => request("PATCH", `${base}${at()}`, patch, restProjectSchema),
420
+ setIcon: (input) => request("POST", `${base}${at()}/icon`, formOf(input), restProjectSchema),
421
+ removeIcon: () => request("DELETE", `${base}${at()}/icon`, undefined, restProjectSchema),
363
422
  members: (signal) =>
364
423
  request("GET", `${base}${at("members")}`, undefined, restMembersSchema, signal),
365
424
  addMember: (input) => request("POST", `${base}${at("members")}`, input, restMemberSchema),
@@ -370,7 +429,16 @@ export function createClient(options: ClientOptions = {}): ConsoleClient {
370
429
  tasks: (filter = {}, signal) =>
371
430
  request(
372
431
  "GET",
373
- withQuery(at("tasks"), { state: filter.state }),
432
+ withQuery(at("tasks"), {
433
+ state: filter.state,
434
+ open: flag(filter.open),
435
+ category: filter.category,
436
+ goal: filter.goal,
437
+ assignee: filter.assignee,
438
+ dueBy: filter.dueBy,
439
+ limit: number(filter.limit),
440
+ offset: number(filter.offset),
441
+ }),
374
442
  undefined,
375
443
  restTasksSchema,
376
444
  signal,
@@ -383,7 +451,12 @@ export function createClient(options: ClientOptions = {}): ConsoleClient {
383
451
  decisions: (filter = {}, signal) =>
384
452
  request(
385
453
  "GET",
386
- withQuery(at("decisions"), { open: flag(filter.open), task: filter.task }),
454
+ withQuery(at("decisions"), {
455
+ open: flag(filter.open),
456
+ task: filter.task,
457
+ limit: number(filter.limit),
458
+ offset: number(filter.offset),
459
+ }),
387
460
  undefined,
388
461
  restDecisionsSchema,
389
462
  signal,
@@ -411,6 +484,8 @@ export function createClient(options: ClientOptions = {}): ConsoleClient {
411
484
  task: filter.task,
412
485
  open: flag(filter.open),
413
486
  mine: flag(filter.mine),
487
+ limit: number(filter.limit),
488
+ offset: number(filter.offset),
414
489
  }),
415
490
  undefined,
416
491
  restRunsSchema,
@@ -485,6 +560,13 @@ export function createClient(options: ClientOptions = {}): ConsoleClient {
485
560
  request("POST", `${base}${at("docs", path)}/files`, formOf(input), restDocumentSchema),
486
561
  documentFileUrl: (path, fileId) =>
487
562
  `${base}${at("docs", path)}/files/${encodeURIComponent(fileId)}`,
563
+ goals: (signal) =>
564
+ request("GET", `${base}${at("goals")}`, undefined, restGoalsSchema, signal),
565
+ goal: (id, signal) =>
566
+ request("GET", `${base}${at("goals", id)}`, undefined, restGoalSchema, signal),
567
+ createGoal: (input) => request("POST", `${base}${at("goals")}`, input, restGoalSchema),
568
+ updateGoal: (id, patch) =>
569
+ request("PATCH", `${base}${at("goals", id)}`, patch, restGoalSchema),
488
570
  };
489
571
  };
490
572
 
@@ -507,6 +589,15 @@ export function createClient(options: ClientOptions = {}): ConsoleClient {
507
589
  restEventsSchema,
508
590
  signal,
509
591
  ),
592
+ categories: (signal) =>
593
+ request("GET", `${base}${REST_ROUTES.categories}`, undefined, restCategoriesSchema, signal),
594
+ createCategory: (input) =>
595
+ request("POST", `${base}${REST_ROUTES.categories}`, input, restCategorySchema),
596
+ updateCategory: (key, patch) =>
597
+ request("PATCH", `${base}${categoryPath(key)}`, patch, restCategorySchema),
598
+ removeCategory: (key) => request("DELETE", `${base}${categoryPath(key)}`, undefined, undefined),
599
+ work: (signal) =>
600
+ request("GET", `${base}${REST_ROUTES.work}`, undefined, restWorkSchema, signal),
510
601
  tokens: (signal) =>
511
602
  request("GET", `${base}${REST_ROUTES.tokens}`, undefined, restTokensSchema, signal),
512
603
  createToken: (input) =>
@@ -1,10 +1,21 @@
1
1
  import type { ReactNode } from "react";
2
- import { type RestTask, TASK_STATES, type TaskState } from "../api.js";
2
+ import {
3
+ type RestCategory,
4
+ type RestGoal,
5
+ type RestPerson,
6
+ type RestTask,
7
+ TASK_STATES,
8
+ type TaskState,
9
+ } from "../api.js";
3
10
  import { useWords } from "../i18n/index.js";
4
- import { Avatar, Badge, cx, PriorityBadge } from "./ui.js";
11
+ import type { BoardFilter } from "../router.js";
12
+ import { Avatar, Badge, Button, CategoryBadge, cx, DueBadge, PriorityBadge } from "./ui.js";
5
13
 
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.
14
+ // The board: one column per state, the tasks in it, the sooner due first and the newest after.
15
+ // A card says what a person needs at a glance and nothing more: the number and the title, the
16
+ // category, what is due, who is on it, and what blocks it. Whether an agent is at it is not
17
+ // said, since the board cannot know (ADR-0020); the task's page shows its runs. It takes its
18
+ // data as props and nothing from the network; what it is told on a click goes back up.
8
19
 
9
20
  export interface BoardProps {
10
21
  readonly tasks: readonly RestTask[];
@@ -13,6 +24,8 @@ export interface BoardProps {
13
24
  readonly onOpen: (task: RestTask) => void;
14
25
  /** Called when a person moves a task to another column. Left out, the board is read-only. */
15
26
  readonly onMove?: ((task: RestTask, state: TaskState) => void) | undefined;
27
+ /** The day to count due days from; the browser's today when left out. */
28
+ readonly today?: string | undefined;
16
29
  /** What a column shows when it has nothing; the way to write the first task, for example. */
17
30
  readonly empty?: ReactNode;
18
31
  }
@@ -22,6 +35,7 @@ export function TaskCard(props: {
22
35
  readonly href: string;
23
36
  readonly onOpen: (task: RestTask) => void;
24
37
  readonly onMove?: ((task: RestTask, state: TaskState) => void) | undefined;
38
+ readonly today?: string | undefined;
25
39
  }) {
26
40
  const words = useWords();
27
41
  const { task } = props;
@@ -40,20 +54,16 @@ export function TaskCard(props: {
40
54
  <span className="sc-card-number">#{task.number}</span> {task.title}
41
55
  </a>
42
56
  <div className="sc-card-meta">
57
+ {task.category !== null && <CategoryBadge category={task.category} />}
43
58
  <PriorityBadge priority={task.priority} />
59
+ {task.dueOn !== null && (
60
+ <DueBadge dueOn={task.dueOn} state={task.state} today={props.today} />
61
+ )}
44
62
  {task.openDecisions > 0 && (
45
63
  <Badge tone="warning" title={words.board.decisionsTitle}>
46
64
  {words.board.decisions(task.openDecisions)}
47
65
  </Badge>
48
66
  )}
49
- {task.openRuns > 0 && (
50
- <Badge tone="accent" title={words.board.agentAtWorkTitle}>
51
- {words.board.agentAtWork}
52
- </Badge>
53
- )}
54
- {task.links.length > 0 && (
55
- <Badge tone="neutral">{words.board.links(task.links.length)}</Badge>
56
- )}
57
67
  <span className="sc-card-spacer" />
58
68
  {task.assignee !== null && <Avatar person={task.assignee} size="sm" />}
59
69
  </div>
@@ -76,12 +86,26 @@ export function TaskCard(props: {
76
86
  );
77
87
  }
78
88
 
89
+ /** The order a column reads in: what is due soonest first, what has no day after, the newest first among those. */
90
+ export function byDue(a: RestTask, b: RestTask): number {
91
+ if (a.dueOn !== b.dueOn) {
92
+ if (a.dueOn === null) {
93
+ return 1;
94
+ }
95
+ if (b.dueOn === null) {
96
+ return -1;
97
+ }
98
+ return a.dueOn.localeCompare(b.dueOn);
99
+ }
100
+ return b.number - a.number;
101
+ }
102
+
79
103
  export function Board(props: BoardProps) {
80
104
  const words = useWords();
81
105
  return (
82
106
  <div className="sc-board">
83
107
  {TASK_STATES.map((state) => {
84
- const tasks = props.tasks.filter((task) => task.state === state);
108
+ const tasks = props.tasks.filter((task) => task.state === state).sort(byDue);
85
109
  return (
86
110
  <section
87
111
  key={state}
@@ -100,6 +124,7 @@ export function Board(props: BoardProps) {
100
124
  href={props.taskHref(task)}
101
125
  onOpen={props.onOpen}
102
126
  onMove={props.onMove}
127
+ today={props.today}
103
128
  />
104
129
  ))}
105
130
  {tasks.length === 0 && state === "idea" && props.empty}
@@ -110,3 +135,138 @@ export function Board(props: BoardProps) {
110
135
  </div>
111
136
  );
112
137
  }
138
+
139
+ /** What the filter keeps of the board: a category or none, a goal or none, a person, one's own. */
140
+ export function filterTasks(
141
+ tasks: readonly RestTask[],
142
+ filter: BoardFilter,
143
+ me?: RestPerson | undefined,
144
+ ): RestTask[] {
145
+ return tasks.filter((task) => {
146
+ if (filter.category !== undefined && (task.category?.key ?? "") !== filter.category) {
147
+ return false;
148
+ }
149
+ if (filter.goal !== undefined && (task.goalId ?? "") !== filter.goal) {
150
+ return false;
151
+ }
152
+ if (filter.person !== undefined && task.assignee?.id !== filter.person) {
153
+ return false;
154
+ }
155
+ if (filter.mine === true && (me === undefined || task.assignee?.id !== me.id)) {
156
+ return false;
157
+ }
158
+ return true;
159
+ });
160
+ }
161
+
162
+ export interface BoardFiltersProps {
163
+ readonly filter: BoardFilter;
164
+ readonly categories: readonly RestCategory[];
165
+ readonly goals: readonly RestGoal[];
166
+ readonly people: readonly RestPerson[];
167
+ /** Whoever is looking, for "mine". */
168
+ readonly me?: RestPerson | undefined;
169
+ /** How many tasks the filter keeps, of how many. */
170
+ readonly shown: number;
171
+ readonly total: number;
172
+ /** Called with the filter as it is to be: the composition shows the board at its address. */
173
+ readonly onChange: (filter: BoardFilter) => void;
174
+ }
175
+
176
+ /** The ways to narrow the board, each a change of its address (ADR-0010). */
177
+ export function BoardFilters(props: BoardFiltersProps) {
178
+ const words = useWords().board.filters;
179
+ const { filter } = props;
180
+ const narrowed = Object.keys(filter).length > 0;
181
+ const goals = props.goals.filter((goal) => goal.state !== "dropped");
182
+ const change = (patch: BoardFilter, drop?: keyof BoardFilter) => {
183
+ const next: Record<string, unknown> = { ...filter, ...patch };
184
+ if (drop !== undefined) {
185
+ delete next[drop];
186
+ }
187
+ props.onChange(next as BoardFilter);
188
+ };
189
+ return (
190
+ <form
191
+ className="sc-filters"
192
+ aria-label={words.aria}
193
+ onSubmit={(event) => event.preventDefault()}
194
+ >
195
+ <label className="sc-field sc-field-inline">
196
+ <span className="sc-visually-hidden">{words.anyCategory}</span>
197
+ <select
198
+ className="sc-input"
199
+ value={filter.category ?? "*"}
200
+ onChange={(event) =>
201
+ event.target.value === "*"
202
+ ? change({}, "category")
203
+ : change({ category: event.target.value })
204
+ }
205
+ >
206
+ <option value="*">{words.anyCategory}</option>
207
+ <option value="">{words.noCategory}</option>
208
+ {props.categories.map((category) => (
209
+ <option key={category.key} value={category.key}>
210
+ {category.name}
211
+ </option>
212
+ ))}
213
+ </select>
214
+ </label>
215
+ {goals.length > 0 && (
216
+ <label className="sc-field sc-field-inline">
217
+ <span className="sc-visually-hidden">{words.anyGoal}</span>
218
+ <select
219
+ className="sc-input"
220
+ value={filter.goal ?? "*"}
221
+ onChange={(event) =>
222
+ event.target.value === "*" ? change({}, "goal") : change({ goal: event.target.value })
223
+ }
224
+ >
225
+ <option value="*">{words.anyGoal}</option>
226
+ <option value="">{words.noGoal}</option>
227
+ {goals.map((goal) => (
228
+ <option key={goal.id} value={goal.id}>
229
+ {goal.title}
230
+ </option>
231
+ ))}
232
+ </select>
233
+ </label>
234
+ )}
235
+ <label className="sc-field sc-field-inline">
236
+ <span className="sc-visually-hidden">{words.everyone}</span>
237
+ <select
238
+ className="sc-input"
239
+ value={filter.mine === true ? "mine" : (filter.person ?? "*")}
240
+ onChange={(event) => {
241
+ const value = event.target.value;
242
+ if (value === "*") {
243
+ change({ mine: undefined, person: undefined });
244
+ return;
245
+ }
246
+ if (value === "mine") {
247
+ change({ mine: true, person: undefined });
248
+ return;
249
+ }
250
+ change({ person: value, mine: undefined });
251
+ }}
252
+ >
253
+ <option value="*">{words.everyone}</option>
254
+ {props.me !== undefined && <option value="mine">{words.mine}</option>}
255
+ {props.people
256
+ .filter((person) => person.id !== props.me?.id)
257
+ .map((person) => (
258
+ <option key={person.id} value={person.id}>
259
+ {person.login}
260
+ </option>
261
+ ))}
262
+ </select>
263
+ </label>
264
+ <span className="sc-filters-count sc-muted">{words.showing(props.shown, props.total)}</span>
265
+ {narrowed && (
266
+ <Button variant="ghost" size="sm" onClick={() => props.onChange({})}>
267
+ {words.clear}
268
+ </Button>
269
+ )}
270
+ </form>
271
+ );
272
+ }