@skillcdn/console 0.1.9 → 0.1.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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/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/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
|
+
}
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import { OPEN_TASK_STATES, type RestPerson, type RestTask } from "../api.js";
|
|
2
|
+
import {
|
|
3
|
+
addMonths,
|
|
4
|
+
daysBetween,
|
|
5
|
+
firstDayOfWeek,
|
|
6
|
+
formatMonth,
|
|
7
|
+
formatWeekday,
|
|
8
|
+
monthGrid,
|
|
9
|
+
monthOf,
|
|
10
|
+
today as todayOf,
|
|
11
|
+
weekdays,
|
|
12
|
+
} from "../dates.js";
|
|
13
|
+
import { useLanguage, useWords } from "../i18n/index.js";
|
|
14
|
+
import { cx, PersonChip } from "./ui.js";
|
|
15
|
+
|
|
16
|
+
// The calendar of a project (ADR-0019): the month as a grid, each task on the day it is due
|
|
17
|
+
// and, lightly, on the day it starts; and who has what, so that whoever runs the project sees
|
|
18
|
+
// at a glance whether the work is on its days. Takes its data as props and nothing from the
|
|
19
|
+
// network; the month shown is the address's (ADR-0010).
|
|
20
|
+
|
|
21
|
+
const isOpen = (task: RestTask): boolean => OPEN_TASK_STATES.includes(task.state);
|
|
22
|
+
|
|
23
|
+
export interface CalendarProps {
|
|
24
|
+
readonly tasks: readonly RestTask[];
|
|
25
|
+
/** The month shown: `2026-10`. */
|
|
26
|
+
readonly month: string;
|
|
27
|
+
/** The day to count from; the browser's today when left out. */
|
|
28
|
+
readonly today?: string | undefined;
|
|
29
|
+
readonly taskHref: (task: RestTask) => string;
|
|
30
|
+
/** Where the calendar is on another month. */
|
|
31
|
+
readonly monthHref: (month: string) => string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function push(map: Map<string, RestTask[]>, day: string, task: RestTask): void {
|
|
35
|
+
const list = map.get(day);
|
|
36
|
+
if (list === undefined) {
|
|
37
|
+
map.set(day, [task]);
|
|
38
|
+
} else {
|
|
39
|
+
list.push(task);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function Calendar(props: CalendarProps) {
|
|
44
|
+
const words = useWords().calendar;
|
|
45
|
+
const { tag } = useLanguage();
|
|
46
|
+
const now = props.today ?? todayOf();
|
|
47
|
+
const firstDay = firstDayOfWeek(tag);
|
|
48
|
+
const weeks = monthGrid(props.month, firstDay);
|
|
49
|
+
const due = new Map<string, RestTask[]>();
|
|
50
|
+
const starting = new Map<string, RestTask[]>();
|
|
51
|
+
for (const task of props.tasks) {
|
|
52
|
+
if (task.dueOn !== null) {
|
|
53
|
+
push(due, task.dueOn, task);
|
|
54
|
+
}
|
|
55
|
+
if (task.startsOn !== null && task.startsOn !== task.dueOn) {
|
|
56
|
+
push(starting, task.startsOn, task);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
// Within a day, what is still open comes first; the rest reads as what was.
|
|
60
|
+
for (const list of due.values()) {
|
|
61
|
+
list.sort((a, b) => Number(isOpen(b)) - Number(isOpen(a)) || a.number - b.number);
|
|
62
|
+
}
|
|
63
|
+
const inMonth = (day: string) => monthOf(day) === props.month;
|
|
64
|
+
const nothing =
|
|
65
|
+
[...due.keys()].every((day) => !inMonth(day)) &&
|
|
66
|
+
[...starting.keys()].every((day) => !inMonth(day));
|
|
67
|
+
return (
|
|
68
|
+
<div className="sc-calendar">
|
|
69
|
+
<div className="sc-calendar-head">
|
|
70
|
+
<h2 className="sc-calendar-month">{formatMonth(props.month, tag)}</h2>
|
|
71
|
+
<nav className="sc-calendar-nav" aria-label={words.grid}>
|
|
72
|
+
<a
|
|
73
|
+
className="sc-button sc-button-ghost sc-button-sm"
|
|
74
|
+
href={props.monthHref(addMonths(props.month, -1))}
|
|
75
|
+
aria-label={words.previous}
|
|
76
|
+
>
|
|
77
|
+
{"<"}
|
|
78
|
+
</a>
|
|
79
|
+
<a
|
|
80
|
+
className="sc-button sc-button-ghost sc-button-sm"
|
|
81
|
+
href={props.monthHref(monthOf(now))}
|
|
82
|
+
>
|
|
83
|
+
{words.thisMonth}
|
|
84
|
+
</a>
|
|
85
|
+
<a
|
|
86
|
+
className="sc-button sc-button-ghost sc-button-sm"
|
|
87
|
+
href={props.monthHref(addMonths(props.month, 1))}
|
|
88
|
+
aria-label={words.next}
|
|
89
|
+
>
|
|
90
|
+
{">"}
|
|
91
|
+
</a>
|
|
92
|
+
</nav>
|
|
93
|
+
</div>
|
|
94
|
+
<table className="sc-calendar-grid" aria-label={words.grid}>
|
|
95
|
+
<thead>
|
|
96
|
+
<tr>
|
|
97
|
+
{weekdays(firstDay).map((weekday) => (
|
|
98
|
+
<th key={weekday} scope="col">
|
|
99
|
+
{formatWeekday(weekday, tag)}
|
|
100
|
+
</th>
|
|
101
|
+
))}
|
|
102
|
+
</tr>
|
|
103
|
+
</thead>
|
|
104
|
+
<tbody>
|
|
105
|
+
{weeks.map((week) => (
|
|
106
|
+
<tr key={week[0]?.day}>
|
|
107
|
+
{week.map((cell) => (
|
|
108
|
+
<td
|
|
109
|
+
key={cell.day}
|
|
110
|
+
className={cx(
|
|
111
|
+
"sc-calendar-cell",
|
|
112
|
+
!cell.inMonth && "sc-calendar-cell-outside",
|
|
113
|
+
cell.day === now && "sc-calendar-cell-today",
|
|
114
|
+
)}
|
|
115
|
+
>
|
|
116
|
+
<span className="sc-calendar-day">{Number(cell.day.slice(8))}</span>
|
|
117
|
+
{(due.get(cell.day) ?? []).map((task) => (
|
|
118
|
+
<a
|
|
119
|
+
key={task.id}
|
|
120
|
+
className={cx(
|
|
121
|
+
"sc-calendar-task",
|
|
122
|
+
!isOpen(task) && "sc-calendar-task-over",
|
|
123
|
+
isOpen(task) && cell.day < now && "sc-calendar-task-late",
|
|
124
|
+
)}
|
|
125
|
+
href={props.taskHref(task)}
|
|
126
|
+
title={`#${task.number} ${task.title}`}
|
|
127
|
+
>
|
|
128
|
+
<span
|
|
129
|
+
className={cx(
|
|
130
|
+
"sc-calendar-dot",
|
|
131
|
+
`sc-category-${task.category?.color ?? "gray"}`,
|
|
132
|
+
)}
|
|
133
|
+
/>
|
|
134
|
+
<span className="sc-calendar-task-title">{task.title}</span>
|
|
135
|
+
</a>
|
|
136
|
+
))}
|
|
137
|
+
{(starting.get(cell.day) ?? []).map((task) => (
|
|
138
|
+
<a
|
|
139
|
+
key={task.id}
|
|
140
|
+
className="sc-calendar-task sc-calendar-task-start"
|
|
141
|
+
href={props.taskHref(task)}
|
|
142
|
+
title={`#${task.number} ${task.title}: ${words.starts}`}
|
|
143
|
+
>
|
|
144
|
+
<span className="sc-calendar-task-title">{task.title}</span>
|
|
145
|
+
</a>
|
|
146
|
+
))}
|
|
147
|
+
</td>
|
|
148
|
+
))}
|
|
149
|
+
</tr>
|
|
150
|
+
))}
|
|
151
|
+
</tbody>
|
|
152
|
+
</table>
|
|
153
|
+
{nothing && <p className="sc-muted">{words.nothingDue}</p>}
|
|
154
|
+
</div>
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** One person's share of the open work, as the table of who has what says it. */
|
|
159
|
+
export interface WorkloadRow {
|
|
160
|
+
/** The person, or nobody for the tasks assigned to no one. */
|
|
161
|
+
readonly person: RestPerson | undefined;
|
|
162
|
+
readonly open: number;
|
|
163
|
+
readonly inProgress: number;
|
|
164
|
+
/** Open, and due within the next seven days from today. */
|
|
165
|
+
readonly dueSoon: number;
|
|
166
|
+
/** Open, and past its day. */
|
|
167
|
+
readonly overdue: number;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Who has what, from the open tasks: by login, with the unassigned last. */
|
|
171
|
+
export function workloadOf(tasks: readonly RestTask[], today: string): WorkloadRow[] {
|
|
172
|
+
const rows = new Map<string, { person: RestPerson | undefined; tasks: RestTask[] }>();
|
|
173
|
+
for (const task of tasks.filter(isOpen)) {
|
|
174
|
+
const key = task.assignee?.id ?? "";
|
|
175
|
+
const row = rows.get(key);
|
|
176
|
+
if (row === undefined) {
|
|
177
|
+
rows.set(key, { person: task.assignee ?? undefined, tasks: [task] });
|
|
178
|
+
} else {
|
|
179
|
+
row.tasks.push(task);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
const count = (list: readonly RestTask[]): Omit<WorkloadRow, "person"> => ({
|
|
183
|
+
open: list.length,
|
|
184
|
+
inProgress: list.filter((task) => task.state === "in_progress" || task.state === "in_review")
|
|
185
|
+
.length,
|
|
186
|
+
dueSoon: list.filter((task) => {
|
|
187
|
+
if (task.dueOn === null) {
|
|
188
|
+
return false;
|
|
189
|
+
}
|
|
190
|
+
const days = daysBetween(today, task.dueOn);
|
|
191
|
+
return days >= 0 && days <= 7;
|
|
192
|
+
}).length,
|
|
193
|
+
overdue: list.filter((task) => task.dueOn !== null && task.dueOn < today).length,
|
|
194
|
+
});
|
|
195
|
+
return [...rows.values()]
|
|
196
|
+
.map((row) => ({ person: row.person, ...count(row.tasks) }))
|
|
197
|
+
.sort((a, b) => {
|
|
198
|
+
if (a.person === undefined) {
|
|
199
|
+
return 1;
|
|
200
|
+
}
|
|
201
|
+
if (b.person === undefined) {
|
|
202
|
+
return -1;
|
|
203
|
+
}
|
|
204
|
+
return a.person.login.localeCompare(b.person.login);
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export interface WorkloadProps {
|
|
209
|
+
readonly tasks: readonly RestTask[];
|
|
210
|
+
readonly today?: string | undefined;
|
|
211
|
+
/** Where the board is, narrowed to one person's tasks, or to the unassigned. */
|
|
212
|
+
readonly personHref?: ((person: RestPerson | undefined) => string) | undefined;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Who has what: a row per person, and what of theirs is late or due soon. */
|
|
216
|
+
export function Workload(props: WorkloadProps) {
|
|
217
|
+
const words = useWords().calendar.workload;
|
|
218
|
+
const rows = workloadOf(props.tasks, props.today ?? todayOf());
|
|
219
|
+
if (rows.length === 0) {
|
|
220
|
+
return null;
|
|
221
|
+
}
|
|
222
|
+
return (
|
|
223
|
+
<table className="sc-workload" aria-label={words.title}>
|
|
224
|
+
<thead>
|
|
225
|
+
<tr>
|
|
226
|
+
<th scope="col">{words.person}</th>
|
|
227
|
+
<th scope="col">{words.open}</th>
|
|
228
|
+
<th scope="col">{words.inProgress}</th>
|
|
229
|
+
<th scope="col">{words.dueSoon}</th>
|
|
230
|
+
<th scope="col">{words.overdue}</th>
|
|
231
|
+
</tr>
|
|
232
|
+
</thead>
|
|
233
|
+
<tbody>
|
|
234
|
+
{rows.map((row) => {
|
|
235
|
+
const href = props.personHref?.(row.person);
|
|
236
|
+
const who =
|
|
237
|
+
row.person === undefined ? (
|
|
238
|
+
<span className="sc-muted">{words.unassigned}</span>
|
|
239
|
+
) : (
|
|
240
|
+
<PersonChip person={row.person} />
|
|
241
|
+
);
|
|
242
|
+
return (
|
|
243
|
+
<tr key={row.person?.id ?? ""}>
|
|
244
|
+
<th scope="row">{href === undefined ? who : <a href={href}>{who}</a>}</th>
|
|
245
|
+
<td>{row.open}</td>
|
|
246
|
+
<td>{row.inProgress}</td>
|
|
247
|
+
<td className={cx(row.dueSoon > 0 && "sc-workload-soon")}>{row.dueSoon}</td>
|
|
248
|
+
<td className={cx(row.overdue > 0 && "sc-workload-late")}>{row.overdue}</td>
|
|
249
|
+
</tr>
|
|
250
|
+
);
|
|
251
|
+
})}
|
|
252
|
+
</tbody>
|
|
253
|
+
</table>
|
|
254
|
+
);
|
|
255
|
+
}
|