@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.
- package/README.md +5 -5
- package/dist/api.d.ts +5 -5
- package/dist/api.d.ts.map +1 -1
- package/dist/api.js +4 -4
- package/dist/api.js.map +1 -1
- package/dist/cli/cli.d.ts.map +1 -1
- package/dist/cli/cli.js +255 -10
- package/dist/cli/cli.js.map +1 -1
- package/dist/cli/format.d.ts +9 -1
- package/dist/cli/format.d.ts.map +1 -1
- package/dist/cli/format.js +80 -0
- package/dist/cli/format.js.map +1 -1
- package/dist/cli/help.d.ts.map +1 -1
- package/dist/cli/help.js +62 -7
- package/dist/cli/help.js.map +1 -1
- package/dist/client.d.ts +31 -4
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +19 -3
- package/dist/client.js.map +1 -1
- package/dist/components/board.d.ts +27 -1
- package/dist/components/board.d.ts.map +1 -1
- package/dist/components/board.js +75 -7
- package/dist/components/board.js.map +1 -1
- package/dist/components/calendar.d.ts +34 -0
- package/dist/components/calendar.d.ts.map +1 -0
- package/dist/components/calendar.js +96 -0
- package/dist/components/calendar.js.map +1 -0
- package/dist/components/categories.d.ts +13 -0
- package/dist/components/categories.d.ts.map +1 -0
- package/dist/components/categories.js +52 -0
- package/dist/components/categories.js.map +1 -0
- package/dist/components/decision-list.d.ts +10 -0
- package/dist/components/decision-list.d.ts.map +1 -1
- package/dist/components/decision-list.js +14 -1
- package/dist/components/decision-list.js.map +1 -1
- package/dist/components/event-feed.d.ts.map +1 -1
- package/dist/components/event-feed.js +12 -0
- package/dist/components/event-feed.js.map +1 -1
- package/dist/components/goals.d.ts +35 -0
- package/dist/components/goals.d.ts.map +1 -0
- package/dist/components/goals.js +45 -0
- package/dist/components/goals.js.map +1 -0
- package/dist/components/task-form.d.ts +5 -1
- package/dist/components/task-form.d.ts.map +1 -1
- package/dist/components/task-form.js +13 -1
- package/dist/components/task-form.js.map +1 -1
- package/dist/components/task-list.d.ts +16 -0
- package/dist/components/task-list.d.ts.map +1 -0
- package/dist/components/task-list.js +14 -0
- package/dist/components/task-list.js.map +1 -0
- package/dist/components/task-view.d.ts +9 -1
- package/dist/components/task-view.d.ts.map +1 -1
- package/dist/components/task-view.js +10 -4
- package/dist/components/task-view.js.map +1 -1
- package/dist/components/ui.d.ts +30 -1
- package/dist/components/ui.d.ts.map +1 -1
- package/dist/components/ui.js +60 -0
- package/dist/components/ui.js.map +1 -1
- package/dist/components/work.d.ts +10 -0
- package/dist/components/work.d.ts.map +1 -0
- package/dist/components/work.js +29 -0
- package/dist/components/work.js.map +1 -0
- package/dist/console.css +511 -0
- package/dist/console.d.ts +8 -0
- package/dist/console.d.ts.map +1 -1
- package/dist/console.js +124 -8
- package/dist/console.js.map +1 -1
- package/dist/data.d.ts +14 -1
- package/dist/data.d.ts.map +1 -1
- package/dist/data.js +36 -2
- package/dist/data.js.map +1 -1
- package/dist/dates.d.ts +39 -0
- package/dist/dates.d.ts.map +1 -0
- package/dist/dates.js +120 -0
- package/dist/dates.js.map +1 -0
- package/dist/i18n/en.d.ts +162 -1
- package/dist/i18n/en.d.ts.map +1 -1
- package/dist/i18n/en.js +174 -1
- package/dist/i18n/en.js.map +1 -1
- package/dist/i18n/ko.d.ts.map +1 -1
- package/dist/i18n/ko.js +169 -1
- package/dist/i18n/ko.js.map +1 -1
- package/dist/index.d.ts +10 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +10 -4
- package/dist/index.js.map +1 -1
- package/dist/limits.d.ts +5 -0
- package/dist/limits.d.ts.map +1 -1
- package/dist/limits.js +5 -0
- package/dist/limits.js.map +1 -1
- package/dist/router.d.ts +50 -1
- package/dist/router.d.ts.map +1 -1
- package/dist/router.js +74 -1
- package/dist/router.js.map +1 -1
- package/dist/routes.d.ts +7 -1
- package/dist/routes.d.ts.map +1 -1
- package/dist/routes.js +9 -0
- package/dist/routes.js.map +1 -1
- package/dist/schemas.d.ts +366 -0
- package/dist/schemas.d.ts.map +1 -1
- package/dist/schemas.js +112 -3
- package/dist/schemas.js.map +1 -1
- package/dist/vocabulary.d.ts +15 -1
- package/dist/vocabulary.d.ts.map +1 -1
- package/dist/vocabulary.js +26 -0
- package/dist/vocabulary.js.map +1 -1
- package/docs/specs/cli.md +11 -4
- package/docs/specs/rest.md +33 -8
- package/package.json +1 -1
- package/skills/building-a-console/SKILL.md +4 -4
- package/skills/building-a-console/references/the-default-console.md +1 -1
- package/skills/deploying-a-console/SKILL.md +11 -10
- package/skills/deploying-a-console/assets/deployer-policy.json +13 -12
- package/skills/deploying-a-console/assets/secrets.example.json +4 -4
- package/skills/deploying-a-console/references/access.md +10 -9
- package/skills/deploying-a-console/references/resources.md +107 -50
- package/skills/deploying-a-console/references/sizing.md +1 -1
- package/skills/deploying-a-console/scripts/store-secrets.mjs +1 -15
- package/skills/working-the-board/SKILL.md +7 -6
- package/src/api.ts +30 -0
- package/src/cli/cli.ts +311 -10
- package/src/cli/format.ts +88 -0
- package/src/cli/help.ts +69 -7
- package/src/client.ts +70 -2
- package/src/components/board.tsx +205 -12
- package/src/components/calendar.tsx +255 -0
- package/src/components/categories.tsx +209 -0
- package/src/components/decision-list.tsx +62 -5
- package/src/components/event-feed.tsx +12 -0
- package/src/components/goals.tsx +264 -0
- package/src/components/task-form.tsx +78 -1
- package/src/components/task-list.tsx +61 -0
- package/src/components/task-view.tsx +74 -3
- package/src/components/ui.tsx +116 -1
- package/src/components/work.tsx +83 -0
- package/src/console.tsx +337 -4
- package/src/data.ts +63 -8
- package/src/dates.ts +143 -0
- package/src/i18n/en.ts +184 -1
- package/src/i18n/ko.ts +175 -1
- package/src/index.ts +58 -1
- package/src/limits.ts +5 -0
- package/src/router.ts +119 -6
- package/src/routes.ts +10 -0
- package/src/schemas.ts +141 -1
- package/src/styles/console.css +511 -0
- 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:
|
|
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
|
-
|
|
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
|
|
95
|
-
limits: [
|
|
96
|
-
|
|
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: [
|
|
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?:
|
|
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"), {
|
|
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) =>
|
package/src/components/board.tsx
CHANGED
|
@@ -1,10 +1,30 @@
|
|
|
1
1
|
import type { ReactNode } from "react";
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
|
|
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,
|
|
7
|
-
//
|
|
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 {
|
|
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
|
|
51
|
-
|
|
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
|
+
}
|