@skillcdn/console 0.1.10 → 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.
- package/README.md +4 -4
- package/dist/api.d.ts +6 -5
- package/dist/api.d.ts.map +1 -1
- package/dist/api.js +5 -4
- package/dist/api.js.map +1 -1
- package/dist/cli/cli.d.ts.map +1 -1
- package/dist/cli/cli.js +49 -9
- package/dist/cli/cli.js.map +1 -1
- package/dist/cli/format.d.ts +2 -0
- package/dist/cli/format.d.ts.map +1 -1
- package/dist/cli/format.js +7 -6
- package/dist/cli/format.js.map +1 -1
- package/dist/cli/help.d.ts.map +1 -1
- package/dist/cli/help.js +10 -9
- package/dist/cli/help.js.map +1 -1
- package/dist/client.d.ts +14 -5
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +13 -1
- package/dist/client.js.map +1 -1
- package/dist/components/board.d.ts +1 -4
- package/dist/components/board.d.ts.map +1 -1
- package/dist/components/board.js +5 -12
- package/dist/components/board.js.map +1 -1
- package/dist/components/calendar.d.ts +21 -1
- package/dist/components/calendar.d.ts.map +1 -1
- package/dist/components/calendar.js +67 -7
- package/dist/components/calendar.js.map +1 -1
- package/dist/components/decision-list.d.ts.map +1 -1
- package/dist/components/decision-list.js +11 -9
- 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 +11 -7
- package/dist/components/event-feed.js.map +1 -1
- package/dist/components/paged.d.ts +14 -0
- package/dist/components/paged.d.ts.map +1 -0
- package/dist/components/paged.js +28 -0
- package/dist/components/paged.js.map +1 -0
- package/dist/components/projects.d.ts +22 -2
- package/dist/components/projects.d.ts.map +1 -1
- package/dist/components/projects.js +35 -7
- package/dist/components/projects.js.map +1 -1
- package/dist/components/runs.d.ts.map +1 -1
- package/dist/components/runs.js +2 -2
- package/dist/components/runs.js.map +1 -1
- package/dist/components/shell.d.ts +2 -1
- package/dist/components/shell.d.ts.map +1 -1
- package/dist/components/shell.js +1 -1
- package/dist/components/shell.js.map +1 -1
- package/dist/components/task-list.d.ts +1 -1
- package/dist/components/task-list.d.ts.map +1 -1
- package/dist/components/task-list.js +6 -4
- package/dist/components/task-list.js.map +1 -1
- package/dist/components/task-view.js +1 -1
- package/dist/components/task-view.js.map +1 -1
- package/dist/components/work.d.ts.map +1 -1
- package/dist/components/work.js +7 -3
- package/dist/components/work.js.map +1 -1
- package/dist/console.css +149 -2
- package/dist/console.d.ts.map +1 -1
- package/dist/console.js +34 -18
- package/dist/console.js.map +1 -1
- package/dist/data.d.ts +5 -2
- package/dist/data.d.ts.map +1 -1
- package/dist/data.js +15 -2
- package/dist/data.js.map +1 -1
- package/dist/i18n/en.d.ts +12 -6
- package/dist/i18n/en.d.ts.map +1 -1
- package/dist/i18n/en.js +12 -8
- package/dist/i18n/en.js.map +1 -1
- package/dist/i18n/ko.d.ts.map +1 -1
- package/dist/i18n/ko.js +12 -8
- package/dist/i18n/ko.js.map +1 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -2
- package/dist/index.js.map +1 -1
- package/dist/limits.d.ts +6 -0
- package/dist/limits.d.ts.map +1 -1
- package/dist/limits.js +6 -0
- package/dist/limits.js.map +1 -1
- package/dist/marks.d.ts +12 -0
- package/dist/marks.d.ts.map +1 -0
- package/dist/marks.js +31 -0
- package/dist/marks.js.map +1 -0
- package/dist/router.d.ts +4 -3
- package/dist/router.d.ts.map +1 -1
- package/dist/router.js +11 -5
- package/dist/router.js.map +1 -1
- package/dist/routes.d.ts +7 -0
- package/dist/routes.d.ts.map +1 -1
- package/dist/routes.js +8 -0
- package/dist/routes.js.map +1 -1
- package/dist/schemas.d.ts +123 -3
- package/dist/schemas.d.ts.map +1 -1
- package/dist/schemas.js +42 -10
- package/dist/schemas.js.map +1 -1
- package/dist/vocabulary.d.ts +10 -4
- package/dist/vocabulary.d.ts.map +1 -1
- package/dist/vocabulary.js +8 -3
- package/dist/vocabulary.js.map +1 -1
- package/docs/specs/cli.md +7 -5
- package/docs/specs/rest.md +18 -12
- package/package.json +1 -1
- package/skills/working-the-board/SKILL.md +1 -1
- package/src/api.ts +12 -0
- package/src/cli/cli.ts +51 -8
- package/src/cli/format.ts +8 -6
- package/src/cli/help.ts +10 -10
- package/src/client.ts +29 -6
- package/src/components/board.tsx +5 -38
- package/src/components/calendar.tsx +166 -54
- package/src/components/decision-list.tsx +38 -33
- package/src/components/event-feed.tsx +34 -27
- package/src/components/paged.tsx +39 -0
- package/src/components/projects.tsx +128 -10
- package/src/components/runs.tsx +7 -1
- package/src/components/shell.tsx +5 -2
- package/src/components/task-list.tsx +35 -32
- package/src/components/task-view.tsx +1 -1
- package/src/components/work.tsx +12 -2
- package/src/console.tsx +54 -20
- package/src/data.ts +20 -4
- package/src/i18n/en.ts +12 -8
- package/src/i18n/ko.ts +12 -8
- package/src/index.ts +4 -0
- package/src/limits.ts +6 -0
- package/src/marks.ts +40 -0
- package/src/router.ts +19 -7
- package/src/routes.ts +9 -0
- package/src/schemas.ts +48 -8
- package/src/styles/console.css +149 -2
- package/src/vocabulary.ts +14 -4
package/src/cli/format.ts
CHANGED
|
@@ -39,12 +39,14 @@ export function formatProjectLine(project: RestProject): string {
|
|
|
39
39
|
if (project.openDecisions > 0) {
|
|
40
40
|
notes.push(`${plural(project.openDecisions, "decision")} waiting`);
|
|
41
41
|
}
|
|
42
|
-
if (project.openRuns > 0) {
|
|
43
|
-
notes.push(`${plural(project.openRuns, "agent")} at work`);
|
|
44
|
-
}
|
|
45
42
|
return `${project.key} ${project.name} (${notes.join("; ")})`;
|
|
46
43
|
}
|
|
47
44
|
|
|
45
|
+
/** The line under a page of a listing that has more after it: how to ask for the next page. */
|
|
46
|
+
export function formatMore(more: boolean, limit: number, offset: number): string {
|
|
47
|
+
return more ? `\n... more: --offset ${offset + limit}` : "";
|
|
48
|
+
}
|
|
49
|
+
|
|
48
50
|
export function formatTaskLine(task: RestTask): string {
|
|
49
51
|
const notes = [`owner ${task.owner.login}`];
|
|
50
52
|
if (task.assignee !== null) {
|
|
@@ -59,9 +61,6 @@ export function formatTaskLine(task: RestTask): string {
|
|
|
59
61
|
if (task.openDecisions > 0) {
|
|
60
62
|
notes.push(`${plural(task.openDecisions, "decision")} waiting`);
|
|
61
63
|
}
|
|
62
|
-
if (task.openRuns > 0) {
|
|
63
|
-
notes.push("an agent at work");
|
|
64
|
-
}
|
|
65
64
|
return `#${task.number} ${task.state} ${task.priority} ${task.title} (${notes.join("; ")})`;
|
|
66
65
|
}
|
|
67
66
|
|
|
@@ -128,6 +127,9 @@ export function formatRunLine(run: RestRun): string {
|
|
|
128
127
|
if (run.waitingFor !== null) {
|
|
129
128
|
notes.push(`waiting for decision ${run.waitingFor}`);
|
|
130
129
|
}
|
|
130
|
+
if (run.endedAt === null) {
|
|
131
|
+
notes.push(`last heard ${run.lastHeardAt}`);
|
|
132
|
+
}
|
|
131
133
|
return `${run.id} ${run.status} ${run.agent} for ${run.person.login} task #${run.taskNumber} (${notes.join(", ")})`;
|
|
132
134
|
}
|
|
133
135
|
|
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,
|
|
@@ -105,9 +106,8 @@ export const COMMAND_HELP: Readonly<Record<string, CommandHelp>> = {
|
|
|
105
106
|
},
|
|
106
107
|
tasks: {
|
|
107
108
|
usage:
|
|
108
|
-
"tasks [--state <state>] [--open] [--mine] [--category <key>] [--goal <id>] [--due <day|today|week|overdue>]",
|
|
109
|
-
about:
|
|
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.",
|
|
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.`,
|
|
111
111
|
},
|
|
112
112
|
task: {
|
|
113
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}`,
|
|
@@ -153,11 +153,10 @@ export const COMMAND_HELP: Readonly<Record<string, CommandHelp>> = {
|
|
|
153
153
|
take: {
|
|
154
154
|
usage: "take <number|id> [--agent <name>]",
|
|
155
155
|
about:
|
|
156
|
-
"Takes a task: a run begins, and the task is in progress and the person's.
|
|
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.",
|
|
157
157
|
limits: [`agent: one line, up to ${MAX_AGENT_LENGTH} characters`],
|
|
158
158
|
refusals: [
|
|
159
159
|
"task.not_found: no task has that number or id",
|
|
160
|
-
"run.task_taken: a run is open on the task already; console runs --open --mine shows whether it is yours",
|
|
161
160
|
"run.task_closed: the task is done or dropped",
|
|
162
161
|
],
|
|
163
162
|
},
|
|
@@ -207,8 +206,8 @@ export const COMMAND_HELP: Readonly<Record<string, CommandHelp>> = {
|
|
|
207
206
|
refusals: ["decision.not_found: no decision has that id"],
|
|
208
207
|
},
|
|
209
208
|
decisions: {
|
|
210
|
-
usage: "decisions [--open]",
|
|
211
|
-
about:
|
|
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.`,
|
|
212
211
|
},
|
|
213
212
|
finish: {
|
|
214
213
|
usage: 'finish [--summary "<markdown>"|--file <path>]',
|
|
@@ -231,9 +230,8 @@ export const COMMAND_HELP: Readonly<Record<string, CommandHelp>> = {
|
|
|
231
230
|
refusals: RUN_REFUSALS,
|
|
232
231
|
},
|
|
233
232
|
runs: {
|
|
234
|
-
usage: "runs [--task <number|id>] [--open] [--mine]",
|
|
235
|
-
about:
|
|
236
|
-
"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.`,
|
|
237
235
|
},
|
|
238
236
|
run: {
|
|
239
237
|
usage: "run <id>",
|
|
@@ -312,6 +310,8 @@ Usage: console <command> [arguments] [--json] [--url <origin>] [--project <key>]
|
|
|
312
310
|
|
|
313
311
|
const CLOSING = `Options everywhere: --json answers with the console's own JSON; --url names another console;
|
|
314
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.
|
|
315
315
|
Commands on a run (report, hand-in, ask, finish, fail) act on your one open run in the project;
|
|
316
316
|
with several open, say which with --run <id>. A Markdown body given as - is read from standard input.
|
|
317
317
|
console help <command> says what each command takes, the limits the console holds it to, and
|
package/src/client.ts
CHANGED
|
@@ -150,8 +150,14 @@ export interface EventFilter {
|
|
|
150
150
|
readonly document?: string | undefined;
|
|
151
151
|
}
|
|
152
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
|
+
|
|
153
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. */
|
|
154
|
-
export interface TaskFilter {
|
|
160
|
+
export interface TaskFilter extends Page {
|
|
155
161
|
readonly state?: TaskState | undefined;
|
|
156
162
|
/** True for the tasks not done or dropped; false for the rest. */
|
|
157
163
|
readonly open?: boolean | undefined;
|
|
@@ -183,6 +189,10 @@ export interface ProjectClient {
|
|
|
183
189
|
get(signal?: AbortSignal): Promise<RestProject>;
|
|
184
190
|
/** Changes the project's settings; for an owner. */
|
|
185
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>;
|
|
186
196
|
/** Those listed in the project, by login. */
|
|
187
197
|
members(signal?: AbortSignal): Promise<RestMembers>;
|
|
188
198
|
/** Lists a person of the workspace in the project; for an owner. */
|
|
@@ -194,9 +204,9 @@ export interface ProjectClient {
|
|
|
194
204
|
task(ref: string, signal?: AbortSignal): Promise<RestTask>;
|
|
195
205
|
createTask(input: RestTaskInput): Promise<RestTask>;
|
|
196
206
|
updateTask(id: string, patch: RestTaskPatch): Promise<RestTask>;
|
|
197
|
-
/** 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. */
|
|
198
208
|
decisions(
|
|
199
|
-
filter?: { readonly open?: boolean; readonly task?: string },
|
|
209
|
+
filter?: { readonly open?: boolean; readonly task?: string } & Page,
|
|
200
210
|
signal?: AbortSignal,
|
|
201
211
|
): Promise<RestDecisions>;
|
|
202
212
|
decision(id: string, signal?: AbortSignal): Promise<RestDecision>;
|
|
@@ -209,9 +219,9 @@ export interface ProjectClient {
|
|
|
209
219
|
answerDecision(id: string, input: RestAnswerInput): Promise<RestDecision>;
|
|
210
220
|
/** Grows a decision's record: its context, or what followed. */
|
|
211
221
|
updateDecision(id: string, patch: RestDecisionPatch): Promise<RestDecision>;
|
|
212
|
-
/** 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. */
|
|
213
223
|
runs(
|
|
214
|
-
filter?: { readonly task?: string; readonly open?: boolean; readonly mine?: boolean },
|
|
224
|
+
filter?: { readonly task?: string; readonly open?: boolean; readonly mine?: boolean } & Page,
|
|
215
225
|
signal?: AbortSignal,
|
|
216
226
|
): Promise<RestRuns>;
|
|
217
227
|
run(id: string, signal?: AbortSignal): Promise<RestRun>;
|
|
@@ -381,6 +391,8 @@ export function createClient(options: ClientOptions = {}): ConsoleClient {
|
|
|
381
391
|
|
|
382
392
|
const flag = (value: boolean | undefined): string | undefined =>
|
|
383
393
|
value === undefined ? undefined : String(value);
|
|
394
|
+
const number = (value: number | undefined): string | undefined =>
|
|
395
|
+
value === undefined ? undefined : String(value);
|
|
384
396
|
|
|
385
397
|
/** A file as the parts of a form: the bytes with their name and type, and the label. */
|
|
386
398
|
const formOf = (input: FileUpload): FormData => {
|
|
@@ -405,6 +417,8 @@ export function createClient(options: ClientOptions = {}): ConsoleClient {
|
|
|
405
417
|
key,
|
|
406
418
|
get: (signal) => request("GET", `${base}${at()}`, undefined, restProjectSchema, signal),
|
|
407
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),
|
|
408
422
|
members: (signal) =>
|
|
409
423
|
request("GET", `${base}${at("members")}`, undefined, restMembersSchema, signal),
|
|
410
424
|
addMember: (input) => request("POST", `${base}${at("members")}`, input, restMemberSchema),
|
|
@@ -422,6 +436,8 @@ export function createClient(options: ClientOptions = {}): ConsoleClient {
|
|
|
422
436
|
goal: filter.goal,
|
|
423
437
|
assignee: filter.assignee,
|
|
424
438
|
dueBy: filter.dueBy,
|
|
439
|
+
limit: number(filter.limit),
|
|
440
|
+
offset: number(filter.offset),
|
|
425
441
|
}),
|
|
426
442
|
undefined,
|
|
427
443
|
restTasksSchema,
|
|
@@ -435,7 +451,12 @@ export function createClient(options: ClientOptions = {}): ConsoleClient {
|
|
|
435
451
|
decisions: (filter = {}, signal) =>
|
|
436
452
|
request(
|
|
437
453
|
"GET",
|
|
438
|
-
withQuery(at("decisions"), {
|
|
454
|
+
withQuery(at("decisions"), {
|
|
455
|
+
open: flag(filter.open),
|
|
456
|
+
task: filter.task,
|
|
457
|
+
limit: number(filter.limit),
|
|
458
|
+
offset: number(filter.offset),
|
|
459
|
+
}),
|
|
439
460
|
undefined,
|
|
440
461
|
restDecisionsSchema,
|
|
441
462
|
signal,
|
|
@@ -463,6 +484,8 @@ export function createClient(options: ClientOptions = {}): ConsoleClient {
|
|
|
463
484
|
task: filter.task,
|
|
464
485
|
open: flag(filter.open),
|
|
465
486
|
mine: flag(filter.mine),
|
|
487
|
+
limit: number(filter.limit),
|
|
488
|
+
offset: number(filter.offset),
|
|
466
489
|
}),
|
|
467
490
|
undefined,
|
|
468
491
|
restRunsSchema,
|
package/src/components/board.tsx
CHANGED
|
@@ -3,27 +3,18 @@ import {
|
|
|
3
3
|
type RestCategory,
|
|
4
4
|
type RestGoal,
|
|
5
5
|
type RestPerson,
|
|
6
|
-
type RestRun,
|
|
7
6
|
type RestTask,
|
|
8
7
|
TASK_STATES,
|
|
9
8
|
type TaskState,
|
|
10
9
|
} from "../api.js";
|
|
11
|
-
import {
|
|
10
|
+
import { useWords } from "../i18n/index.js";
|
|
12
11
|
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";
|
|
12
|
+
import { Avatar, Badge, Button, CategoryBadge, cx, DueBadge, PriorityBadge } from "./ui.js";
|
|
23
13
|
|
|
24
14
|
// The board: one column per state, the tasks in it, the sooner due first and the newest after.
|
|
25
15
|
// 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
|
|
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
|
|
27
18
|
// data as props and nothing from the network; what it is told on a click goes back up.
|
|
28
19
|
|
|
29
20
|
export interface BoardProps {
|
|
@@ -33,32 +24,21 @@ export interface BoardProps {
|
|
|
33
24
|
readonly onOpen: (task: RestTask) => void;
|
|
34
25
|
/** Called when a person moves a task to another column. Left out, the board is read-only. */
|
|
35
26
|
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
27
|
/** The day to count due days from; the browser's today when left out. */
|
|
39
28
|
readonly today?: string | undefined;
|
|
40
29
|
/** What a column shows when it has nothing; the way to write the first task, for example. */
|
|
41
30
|
readonly empty?: ReactNode;
|
|
42
31
|
}
|
|
43
32
|
|
|
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
|
-
|
|
51
33
|
export function TaskCard(props: {
|
|
52
34
|
readonly task: RestTask;
|
|
53
35
|
readonly href: string;
|
|
54
36
|
readonly onOpen: (task: RestTask) => void;
|
|
55
37
|
readonly onMove?: ((task: RestTask, state: TaskState) => void) | undefined;
|
|
56
|
-
readonly run?: RestRun | undefined;
|
|
57
38
|
readonly today?: string | undefined;
|
|
58
39
|
}) {
|
|
59
40
|
const words = useWords();
|
|
60
|
-
const {
|
|
61
|
-
const { task, run } = props;
|
|
41
|
+
const { task } = props;
|
|
62
42
|
return (
|
|
63
43
|
<article className="sc-card" aria-label={`#${task.number} ${task.title}`}>
|
|
64
44
|
<a
|
|
@@ -84,18 +64,6 @@ export function TaskCard(props: {
|
|
|
84
64
|
{words.board.decisions(task.openDecisions)}
|
|
85
65
|
</Badge>
|
|
86
66
|
)}
|
|
87
|
-
{task.openRuns > 0 && (
|
|
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}
|
|
97
|
-
</Badge>
|
|
98
|
-
)}
|
|
99
67
|
<span className="sc-card-spacer" />
|
|
100
68
|
{task.assignee !== null && <Avatar person={task.assignee} size="sm" />}
|
|
101
69
|
</div>
|
|
@@ -156,7 +124,6 @@ export function Board(props: BoardProps) {
|
|
|
156
124
|
href={props.taskHref(task)}
|
|
157
125
|
onOpen={props.onOpen}
|
|
158
126
|
onMove={props.onMove}
|
|
159
|
-
run={runOn(task, props.runs)}
|
|
160
127
|
today={props.today}
|
|
161
128
|
/>
|
|
162
129
|
))}
|
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
import { OPEN_TASK_STATES, type RestPerson, type RestTask } from "../api.js";
|
|
1
|
+
import { OPEN_TASK_STATES, type RestCategory, type RestPerson, type RestTask } from "../api.js";
|
|
2
2
|
import {
|
|
3
3
|
addMonths,
|
|
4
4
|
daysBetween,
|
|
5
5
|
firstDayOfWeek,
|
|
6
6
|
formatMonth,
|
|
7
7
|
formatWeekday,
|
|
8
|
+
type GridDay,
|
|
8
9
|
monthGrid,
|
|
9
10
|
monthOf,
|
|
10
11
|
today as todayOf,
|
|
@@ -13,24 +14,82 @@ import {
|
|
|
13
14
|
import { useLanguage, useWords } from "../i18n/index.js";
|
|
14
15
|
import { cx, PersonChip } from "./ui.js";
|
|
15
16
|
|
|
16
|
-
// The calendar of a project (ADR-0019): the month as a grid
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
17
|
+
// The calendar of a project (ADR-0019): the month as a grid. A task planned from a start to a
|
|
18
|
+
// due day lies across its days as a bar, in a lane of its own within each week so that the bars
|
|
19
|
+
// of one week never cross; a task with a due day alone sits on it as a dot, and one with a
|
|
20
|
+
// start alone is marked on that day. Who has what says whether the work is on its days. Takes
|
|
21
|
+
// its data as props and nothing from the network; the month shown is the address's (ADR-0010).
|
|
20
22
|
|
|
21
23
|
const isOpen = (task: RestTask): boolean => OPEN_TASK_STATES.includes(task.state);
|
|
22
24
|
|
|
25
|
+
/** Whether a task lies across days: a start and a due day, and the due day after the start. */
|
|
26
|
+
const spans = (task: RestTask): boolean =>
|
|
27
|
+
task.startsOn !== null && task.dueOn !== null && task.startsOn < task.dueOn;
|
|
28
|
+
|
|
23
29
|
export interface CalendarProps {
|
|
24
30
|
readonly tasks: readonly RestTask[];
|
|
25
31
|
/** The month shown: `2026-10`. */
|
|
26
32
|
readonly month: string;
|
|
27
33
|
/** The day to count from; the browser's today when left out. */
|
|
28
34
|
readonly today?: string | undefined;
|
|
35
|
+
/** The workspace's categories, for the legend to say what the colours mean. */
|
|
36
|
+
readonly categories?: readonly RestCategory[] | undefined;
|
|
29
37
|
readonly taskHref: (task: RestTask) => string;
|
|
30
38
|
/** Where the calendar is on another month. */
|
|
31
39
|
readonly monthHref: (month: string) => string;
|
|
32
40
|
}
|
|
33
41
|
|
|
42
|
+
/** One task's stretch across one week: which columns it covers, and in which lane it lies. */
|
|
43
|
+
export interface WeekSegment {
|
|
44
|
+
readonly task: RestTask;
|
|
45
|
+
/** The first and the last column (0 to 6) the task covers in this week. */
|
|
46
|
+
readonly from: number;
|
|
47
|
+
readonly to: number;
|
|
48
|
+
readonly lane: number;
|
|
49
|
+
/** Whether the task begins in this week, and whether it ends in it. */
|
|
50
|
+
readonly begins: boolean;
|
|
51
|
+
readonly ends: boolean;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The bars of one week: every task lying across days that touches the week, cut to it, each
|
|
56
|
+
* in the first lane where it crosses nothing that was laid before it. The longest and earliest
|
|
57
|
+
* go first, so that the lanes stay few.
|
|
58
|
+
*/
|
|
59
|
+
export function weekSegments(week: readonly GridDay[], tasks: readonly RestTask[]): WeekSegment[] {
|
|
60
|
+
const first = week[0]?.day;
|
|
61
|
+
const last = week[week.length - 1]?.day;
|
|
62
|
+
if (first === undefined || last === undefined) {
|
|
63
|
+
return [];
|
|
64
|
+
}
|
|
65
|
+
const cut = tasks
|
|
66
|
+
.filter(spans)
|
|
67
|
+
.flatMap((task) => {
|
|
68
|
+
const startsOn = task.startsOn ?? first;
|
|
69
|
+
const dueOn = task.dueOn ?? last;
|
|
70
|
+
if (dueOn < first || startsOn > last) {
|
|
71
|
+
return [];
|
|
72
|
+
}
|
|
73
|
+
const from = startsOn < first ? 0 : daysBetween(first, startsOn);
|
|
74
|
+
const to = dueOn > last ? week.length - 1 : daysBetween(first, dueOn);
|
|
75
|
+
return [{ task, from, to, begins: startsOn >= first, ends: dueOn <= last }];
|
|
76
|
+
})
|
|
77
|
+
.sort(
|
|
78
|
+
(a, b) => a.from - b.from || b.to - b.from - (a.to - a.from) || a.task.number - b.task.number,
|
|
79
|
+
);
|
|
80
|
+
const ends: number[] = [];
|
|
81
|
+
return cut.map((segment) => {
|
|
82
|
+
let lane = ends.findIndex((end) => end < segment.from);
|
|
83
|
+
if (lane === -1) {
|
|
84
|
+
lane = ends.length;
|
|
85
|
+
ends.push(segment.to);
|
|
86
|
+
} else {
|
|
87
|
+
ends[lane] = segment.to;
|
|
88
|
+
}
|
|
89
|
+
return { ...segment, lane };
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
34
93
|
function push(map: Map<string, RestTask[]>, day: string, task: RestTask): void {
|
|
35
94
|
const list = map.get(day);
|
|
36
95
|
if (list === undefined) {
|
|
@@ -49,10 +108,12 @@ export function Calendar(props: CalendarProps) {
|
|
|
49
108
|
const due = new Map<string, RestTask[]>();
|
|
50
109
|
const starting = new Map<string, RestTask[]>();
|
|
51
110
|
for (const task of props.tasks) {
|
|
111
|
+
if (spans(task)) {
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
52
114
|
if (task.dueOn !== null) {
|
|
53
115
|
push(due, task.dueOn, task);
|
|
54
|
-
}
|
|
55
|
-
if (task.startsOn !== null && task.startsOn !== task.dueOn) {
|
|
116
|
+
} else if (task.startsOn !== null) {
|
|
56
117
|
push(starting, task.startsOn, task);
|
|
57
118
|
}
|
|
58
119
|
}
|
|
@@ -61,9 +122,17 @@ export function Calendar(props: CalendarProps) {
|
|
|
61
122
|
list.sort((a, b) => Number(isOpen(b)) - Number(isOpen(a)) || a.number - b.number);
|
|
62
123
|
}
|
|
63
124
|
const inMonth = (day: string) => monthOf(day) === props.month;
|
|
125
|
+
const monthWeeks = weeks.map((week) => ({ week, segments: weekSegments(week, props.tasks) }));
|
|
64
126
|
const nothing =
|
|
65
127
|
[...due.keys()].every((day) => !inMonth(day)) &&
|
|
66
|
-
[...starting.keys()].every((day) => !inMonth(day))
|
|
128
|
+
[...starting.keys()].every((day) => !inMonth(day)) &&
|
|
129
|
+
monthWeeks.every(({ week, segments }) =>
|
|
130
|
+
segments.every(
|
|
131
|
+
(segment) => !week.slice(segment.from, segment.to + 1).some((cell) => cell.inMonth),
|
|
132
|
+
),
|
|
133
|
+
);
|
|
134
|
+
const tone = (task: RestTask, day: string) =>
|
|
135
|
+
cx(!isOpen(task) && "sc-calendar-over", isOpen(task) && day < now && "sc-calendar-late");
|
|
67
136
|
return (
|
|
68
137
|
<div className="sc-calendar">
|
|
69
138
|
<div className="sc-calendar-head">
|
|
@@ -102,54 +171,97 @@ export function Calendar(props: CalendarProps) {
|
|
|
102
171
|
</tr>
|
|
103
172
|
</thead>
|
|
104
173
|
<tbody>
|
|
105
|
-
{
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
<
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
className=
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
174
|
+
{monthWeeks.map(({ week, segments }) => {
|
|
175
|
+
const lanes = segments.reduce((most, segment) => Math.max(most, segment.lane + 1), 0);
|
|
176
|
+
return (
|
|
177
|
+
<tr key={week[0]?.day}>
|
|
178
|
+
{week.map((cell, column) => (
|
|
179
|
+
<td
|
|
180
|
+
key={cell.day}
|
|
181
|
+
className={cx(
|
|
182
|
+
"sc-calendar-cell",
|
|
183
|
+
!cell.inMonth && "sc-calendar-cell-outside",
|
|
184
|
+
cell.day === now && "sc-calendar-cell-today",
|
|
185
|
+
)}
|
|
186
|
+
>
|
|
187
|
+
<span className="sc-calendar-day">{Number(cell.day.slice(8))}</span>
|
|
188
|
+
{Array.from({ length: lanes }, (_, lane) => {
|
|
189
|
+
const segment = segments.find(
|
|
190
|
+
(candidate) =>
|
|
191
|
+
candidate.lane === lane &&
|
|
192
|
+
candidate.from <= column &&
|
|
193
|
+
column <= candidate.to,
|
|
194
|
+
);
|
|
195
|
+
if (segment === undefined) {
|
|
196
|
+
// The lane is someone else's on this day: the bars below keep their height.
|
|
197
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: an empty lane is nothing but its position, and the lanes of a week never reorder
|
|
198
|
+
return <span key={lane} className="sc-calendar-lane" aria-hidden="true" />;
|
|
199
|
+
}
|
|
200
|
+
const { task } = segment;
|
|
201
|
+
return (
|
|
202
|
+
<a
|
|
203
|
+
key={task.id}
|
|
204
|
+
className={cx(
|
|
205
|
+
"sc-calendar-bar",
|
|
206
|
+
`sc-category-${task.category?.color ?? "gray"}`,
|
|
207
|
+
segment.from === column && segment.begins && "sc-calendar-bar-begins",
|
|
208
|
+
segment.to === column && segment.ends && "sc-calendar-bar-ends",
|
|
209
|
+
tone(task, task.dueOn ?? cell.day),
|
|
210
|
+
)}
|
|
211
|
+
href={props.taskHref(task)}
|
|
212
|
+
title={`#${task.number} ${task.title}`}
|
|
213
|
+
>
|
|
214
|
+
{segment.from === column && (
|
|
215
|
+
<span className="sc-calendar-task-title">{task.title}</span>
|
|
216
|
+
)}
|
|
217
|
+
</a>
|
|
218
|
+
);
|
|
219
|
+
})}
|
|
220
|
+
{(due.get(cell.day) ?? []).map((task) => (
|
|
221
|
+
<a
|
|
222
|
+
key={task.id}
|
|
223
|
+
className={cx("sc-calendar-task", tone(task, cell.day))}
|
|
224
|
+
href={props.taskHref(task)}
|
|
225
|
+
title={`#${task.number} ${task.title}`}
|
|
226
|
+
>
|
|
227
|
+
<span
|
|
228
|
+
className={cx(
|
|
229
|
+
"sc-calendar-dot",
|
|
230
|
+
`sc-category-${task.category?.color ?? "gray"}`,
|
|
231
|
+
)}
|
|
232
|
+
/>
|
|
233
|
+
<span className="sc-calendar-task-title">{task.title}</span>
|
|
234
|
+
</a>
|
|
235
|
+
))}
|
|
236
|
+
{(starting.get(cell.day) ?? []).map((task) => (
|
|
237
|
+
<a
|
|
238
|
+
key={task.id}
|
|
239
|
+
className="sc-calendar-task sc-calendar-task-start"
|
|
240
|
+
href={props.taskHref(task)}
|
|
241
|
+
title={`#${task.number} ${task.title}: ${words.startsOnly}`}
|
|
242
|
+
>
|
|
243
|
+
<span className="sc-calendar-task-title">{task.title}</span>
|
|
244
|
+
</a>
|
|
245
|
+
))}
|
|
246
|
+
</td>
|
|
247
|
+
))}
|
|
248
|
+
</tr>
|
|
249
|
+
);
|
|
250
|
+
})}
|
|
151
251
|
</tbody>
|
|
152
252
|
</table>
|
|
253
|
+
{(props.categories ?? []).length > 0 && (
|
|
254
|
+
<dl className="sc-calendar-legend" aria-label={words.legend.title}>
|
|
255
|
+
{(props.categories ?? []).map((category) => (
|
|
256
|
+
<div key={category.key}>
|
|
257
|
+
<dt>
|
|
258
|
+
<span className={cx("sc-calendar-dot", `sc-category-${category.color}`)} />
|
|
259
|
+
</dt>
|
|
260
|
+
<dd>{category.name}</dd>
|
|
261
|
+
</div>
|
|
262
|
+
))}
|
|
263
|
+
</dl>
|
|
264
|
+
)}
|
|
153
265
|
{nothing && <p className="sc-muted">{words.nothingDue}</p>}
|
|
154
266
|
</div>
|
|
155
267
|
);
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
} from "../api.js";
|
|
10
10
|
import { useWords } from "../i18n/index.js";
|
|
11
11
|
import { Markdown } from "./markdown.js";
|
|
12
|
+
import { ShowMore, usePaged } from "./paged.js";
|
|
12
13
|
import { Button, cx, PersonChip, Time } from "./ui.js";
|
|
13
14
|
|
|
14
15
|
// The decisions: the ones that wait, each a card with the way to answer it, since they are what
|
|
@@ -254,40 +255,44 @@ export interface DecisionLogProps {
|
|
|
254
255
|
/** The record: one row per decision answered, what was asked, what was chosen, by whom, when. */
|
|
255
256
|
export function DecisionLog(props: DecisionLogProps) {
|
|
256
257
|
const words = useWords();
|
|
258
|
+
const { shown, rest, more } = usePaged(props.decisions);
|
|
257
259
|
return (
|
|
258
|
-
|
|
259
|
-
{
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
<
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
260
|
+
<>
|
|
261
|
+
<ol className="sc-decision-log" aria-label={words.decisions.record}>
|
|
262
|
+
{shown.map((decision) => {
|
|
263
|
+
const task = decision.taskId === null ? undefined : props.tasks?.get(decision.taskId);
|
|
264
|
+
const href = props.decisionHref?.(decision);
|
|
265
|
+
const chosen =
|
|
266
|
+
decision.options.find((option) => option.id === decision.answer?.option)?.label ??
|
|
267
|
+
decision.answer?.option ??
|
|
268
|
+
"";
|
|
269
|
+
return (
|
|
270
|
+
<li key={decision.id} className="sc-decision-row">
|
|
271
|
+
<span className="sc-decision-row-question">
|
|
272
|
+
{href === undefined ? decision.question : <a href={href}>{decision.question}</a>}
|
|
273
|
+
</span>
|
|
274
|
+
<span className="sc-decision-row-answer">{chosen}</span>
|
|
275
|
+
<span className="sc-decision-row-meta">
|
|
276
|
+
{decision.answer !== null && (
|
|
277
|
+
<>
|
|
278
|
+
<PersonChip person={decision.answer.by} /> <Time iso={decision.answer.at} />
|
|
279
|
+
</>
|
|
280
|
+
)}
|
|
281
|
+
{task !== undefined &&
|
|
282
|
+
(props.taskHref === undefined ? (
|
|
283
|
+
<span className="sc-card-number">#{task.number}</span>
|
|
284
|
+
) : (
|
|
285
|
+
<a className="sc-card-number" href={props.taskHref(task)}>
|
|
286
|
+
#{task.number}
|
|
287
|
+
</a>
|
|
288
|
+
))}
|
|
289
|
+
</span>
|
|
290
|
+
</li>
|
|
291
|
+
);
|
|
292
|
+
})}
|
|
293
|
+
</ol>
|
|
294
|
+
<ShowMore rest={rest} onMore={more} />
|
|
295
|
+
</>
|
|
291
296
|
);
|
|
292
297
|
}
|
|
293
298
|
|