@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
|
@@ -5,6 +5,8 @@ import {
|
|
|
5
5
|
MAX_LINKS,
|
|
6
6
|
MAX_TITLE_LENGTH,
|
|
7
7
|
MAX_URL_LENGTH,
|
|
8
|
+
type RestCategory,
|
|
9
|
+
type RestGoal,
|
|
8
10
|
type RestPerson,
|
|
9
11
|
type RestTask,
|
|
10
12
|
type RestTaskInput,
|
|
@@ -15,6 +17,8 @@ import { useWords } from "../i18n/index.js";
|
|
|
15
17
|
import { Button } from "./ui.js";
|
|
16
18
|
|
|
17
19
|
// Writing a task, or changing one. The bounds are the package's, said before a request is made.
|
|
20
|
+
// The plan is two days (ADR-0019); what happened to the task is the board's to keep, not a
|
|
21
|
+
// field here.
|
|
18
22
|
|
|
19
23
|
export interface TaskFormProps {
|
|
20
24
|
readonly people: readonly RestPerson[];
|
|
@@ -22,6 +26,10 @@ export interface TaskFormProps {
|
|
|
22
26
|
readonly task?: RestTask | undefined;
|
|
23
27
|
/** The tasks a new one may be part of. */
|
|
24
28
|
readonly parents?: readonly RestTask[] | undefined;
|
|
29
|
+
/** The categories of the workspace, to sort the task into one. */
|
|
30
|
+
readonly categories?: readonly RestCategory[] | undefined;
|
|
31
|
+
/** The goals of the project, for the task to work towards one. */
|
|
32
|
+
readonly goals?: readonly RestGoal[] | undefined;
|
|
25
33
|
readonly busy?: boolean | undefined;
|
|
26
34
|
readonly error?: string | undefined;
|
|
27
35
|
readonly onSubmit: (input: RestTaskInput) => void;
|
|
@@ -43,6 +51,10 @@ export function TaskForm(props: TaskFormProps) {
|
|
|
43
51
|
const [priority, setPriority] = useState(task?.priority ?? "normal");
|
|
44
52
|
const [assigneeId, setAssigneeId] = useState(task?.assignee?.id ?? "");
|
|
45
53
|
const [parentId, setParentId] = useState(task?.parentId ?? "");
|
|
54
|
+
const [category, setCategory] = useState(task?.category?.key ?? "");
|
|
55
|
+
const [goalId, setGoalId] = useState(task?.goalId ?? "");
|
|
56
|
+
const [startsOn, setStartsOn] = useState(task?.startsOn ?? "");
|
|
57
|
+
const [dueOn, setDueOn] = useState(task?.dueOn ?? "");
|
|
46
58
|
const [links, setLinks] = useState<readonly LinkDraft[]>(
|
|
47
59
|
task?.links.map((link) => ({ url: link.url, label: link.label ?? "" })) ?? [],
|
|
48
60
|
);
|
|
@@ -56,6 +68,10 @@ export function TaskForm(props: TaskFormProps) {
|
|
|
56
68
|
priority,
|
|
57
69
|
assigneeId: assigneeId === "" ? null : assigneeId,
|
|
58
70
|
parentId: parentId === "" ? null : parentId,
|
|
71
|
+
category: category === "" ? null : category,
|
|
72
|
+
goalId: goalId === "" ? null : goalId,
|
|
73
|
+
startsOn: startsOn === "" ? null : startsOn,
|
|
74
|
+
dueOn: dueOn === "" ? null : dueOn,
|
|
59
75
|
links: links
|
|
60
76
|
.filter((link) => link.url.trim().length > 0)
|
|
61
77
|
.map((link) => ({
|
|
@@ -66,6 +82,12 @@ export function TaskForm(props: TaskFormProps) {
|
|
|
66
82
|
};
|
|
67
83
|
|
|
68
84
|
const parents = (props.parents ?? []).filter((candidate) => candidate.id !== task?.id);
|
|
85
|
+
const categories = props.categories ?? [];
|
|
86
|
+
// A goal let go is not offered, unless the task is on it already.
|
|
87
|
+
const goals = (props.goals ?? []).filter(
|
|
88
|
+
(goal) => goal.state !== "dropped" || goal.id === task?.goalId,
|
|
89
|
+
);
|
|
90
|
+
const datesInOrder = startsOn === "" || dueOn === "" || startsOn <= dueOn;
|
|
69
91
|
|
|
70
92
|
return (
|
|
71
93
|
<form className="sc-form" onSubmit={submit}>
|
|
@@ -134,6 +156,61 @@ export function TaskForm(props: TaskFormProps) {
|
|
|
134
156
|
))}
|
|
135
157
|
</select>
|
|
136
158
|
</label>
|
|
159
|
+
{categories.length > 0 && (
|
|
160
|
+
<label className="sc-field">
|
|
161
|
+
<span className="sc-field-label">{form.category}</span>
|
|
162
|
+
<select
|
|
163
|
+
className="sc-input"
|
|
164
|
+
value={category}
|
|
165
|
+
onChange={(event) => setCategory(event.target.value)}
|
|
166
|
+
>
|
|
167
|
+
<option value="">{form.noCategory}</option>
|
|
168
|
+
{categories.map((candidate) => (
|
|
169
|
+
<option key={candidate.key} value={candidate.key}>
|
|
170
|
+
{candidate.name}
|
|
171
|
+
</option>
|
|
172
|
+
))}
|
|
173
|
+
</select>
|
|
174
|
+
</label>
|
|
175
|
+
)}
|
|
176
|
+
</div>
|
|
177
|
+
<div className="sc-field-row">
|
|
178
|
+
<label className="sc-field">
|
|
179
|
+
<span className="sc-field-label">{form.startsOn}</span>
|
|
180
|
+
<input
|
|
181
|
+
className="sc-input"
|
|
182
|
+
type="date"
|
|
183
|
+
value={startsOn}
|
|
184
|
+
onChange={(event) => setStartsOn(event.target.value)}
|
|
185
|
+
/>
|
|
186
|
+
</label>
|
|
187
|
+
<label className="sc-field">
|
|
188
|
+
<span className="sc-field-label">{form.dueOn}</span>
|
|
189
|
+
<input
|
|
190
|
+
className="sc-input"
|
|
191
|
+
type="date"
|
|
192
|
+
value={dueOn}
|
|
193
|
+
min={startsOn === "" ? undefined : startsOn}
|
|
194
|
+
onChange={(event) => setDueOn(event.target.value)}
|
|
195
|
+
/>
|
|
196
|
+
</label>
|
|
197
|
+
{goals.length > 0 && (
|
|
198
|
+
<label className="sc-field">
|
|
199
|
+
<span className="sc-field-label">{form.goal}</span>
|
|
200
|
+
<select
|
|
201
|
+
className="sc-input"
|
|
202
|
+
value={goalId}
|
|
203
|
+
onChange={(event) => setGoalId(event.target.value)}
|
|
204
|
+
>
|
|
205
|
+
<option value="">{form.noGoal}</option>
|
|
206
|
+
{goals.map((goal) => (
|
|
207
|
+
<option key={goal.id} value={goal.id}>
|
|
208
|
+
{goal.title}
|
|
209
|
+
</option>
|
|
210
|
+
))}
|
|
211
|
+
</select>
|
|
212
|
+
</label>
|
|
213
|
+
)}
|
|
137
214
|
{parents.length > 0 && (
|
|
138
215
|
<label className="sc-field">
|
|
139
216
|
<span className="sc-field-label">{form.partOf}</span>
|
|
@@ -214,7 +291,7 @@ export function TaskForm(props: TaskFormProps) {
|
|
|
214
291
|
<Button
|
|
215
292
|
type="submit"
|
|
216
293
|
variant="primary"
|
|
217
|
-
disabled={props.busy === true || title.trim() === ""}
|
|
294
|
+
disabled={props.busy === true || title.trim() === "" || !datesInOrder}
|
|
218
295
|
>
|
|
219
296
|
{task === undefined ? form.write : words.common.save}
|
|
220
297
|
</Button>
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import type { RestTask } from "../api.js";
|
|
3
|
+
import { useWords } from "../i18n/index.js";
|
|
4
|
+
import { Avatar, Badge, CategoryBadge, DueBadge, StateBadge } from "./ui.js";
|
|
5
|
+
|
|
6
|
+
// A list of tasks as rows, where the board's columns are not the point: the calendar's late
|
|
7
|
+
// and unscheduled tasks, a goal's tasks, a person's own work. Each row says what a card says,
|
|
8
|
+
// in one line. Takes its data as props and nothing from the network.
|
|
9
|
+
|
|
10
|
+
export interface TaskListProps {
|
|
11
|
+
readonly tasks: readonly RestTask[];
|
|
12
|
+
readonly taskHref: (task: RestTask) => string;
|
|
13
|
+
/** Whether each row says the task's state: where the rows are not grouped by it. */
|
|
14
|
+
readonly showState?: boolean | undefined;
|
|
15
|
+
/** What the row says the task belongs to, where tasks of several projects are listed together. */
|
|
16
|
+
readonly projectOf?: ((task: RestTask) => string | undefined) | undefined;
|
|
17
|
+
/** The day to count due days from; the browser's today when left out. */
|
|
18
|
+
readonly today?: string | undefined;
|
|
19
|
+
readonly label?: string | undefined;
|
|
20
|
+
readonly empty?: ReactNode;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function TaskList(props: TaskListProps) {
|
|
24
|
+
const words = useWords();
|
|
25
|
+
if (props.tasks.length === 0) {
|
|
26
|
+
return <>{props.empty ?? null}</>;
|
|
27
|
+
}
|
|
28
|
+
return (
|
|
29
|
+
<ul className="sc-task-rows" aria-label={props.label}>
|
|
30
|
+
{props.tasks.map((task) => {
|
|
31
|
+
const project = props.projectOf?.(task);
|
|
32
|
+
return (
|
|
33
|
+
<li key={task.id} className="sc-task-row">
|
|
34
|
+
<a className="sc-task-row-title" href={props.taskHref(task)}>
|
|
35
|
+
<span className="sc-card-number">#{task.number}</span> {task.title}
|
|
36
|
+
</a>
|
|
37
|
+
{project !== undefined && <span className="sc-task-row-project">{project}</span>}
|
|
38
|
+
<span className="sc-task-row-meta">
|
|
39
|
+
{task.category !== null && <CategoryBadge category={task.category} />}
|
|
40
|
+
{props.showState === true && <StateBadge state={task.state} />}
|
|
41
|
+
{task.openDecisions > 0 && (
|
|
42
|
+
<Badge tone="warning" title={words.board.decisionsTitle}>
|
|
43
|
+
{words.board.decisions(task.openDecisions)}
|
|
44
|
+
</Badge>
|
|
45
|
+
)}
|
|
46
|
+
{task.openRuns > 0 && (
|
|
47
|
+
<Badge tone="accent" title={words.board.agentAtWorkTitle}>
|
|
48
|
+
{words.board.agentAtWork}
|
|
49
|
+
</Badge>
|
|
50
|
+
)}
|
|
51
|
+
{task.dueOn !== null && (
|
|
52
|
+
<DueBadge dueOn={task.dueOn} state={task.state} today={props.today} />
|
|
53
|
+
)}
|
|
54
|
+
{task.assignee !== null && <Avatar person={task.assignee} size="sm" />}
|
|
55
|
+
</span>
|
|
56
|
+
</li>
|
|
57
|
+
);
|
|
58
|
+
})}
|
|
59
|
+
</ul>
|
|
60
|
+
);
|
|
61
|
+
}
|
|
@@ -1,17 +1,20 @@
|
|
|
1
1
|
import type {
|
|
2
2
|
RestAnswerInput,
|
|
3
3
|
RestArtifact,
|
|
4
|
+
RestCategory,
|
|
4
5
|
RestDecision,
|
|
5
6
|
RestDecisionInput,
|
|
6
7
|
RestDecisionPatch,
|
|
7
8
|
RestEvent,
|
|
9
|
+
RestGoal,
|
|
8
10
|
RestPerson,
|
|
9
11
|
RestRun,
|
|
10
12
|
RestTask,
|
|
11
13
|
RestTaskInput,
|
|
12
14
|
TaskState,
|
|
13
15
|
} from "../api.js";
|
|
14
|
-
import {
|
|
16
|
+
import { formatDay } from "../dates.js";
|
|
17
|
+
import { useLanguage, useWords } from "../i18n/index.js";
|
|
15
18
|
import type { TaskFormKind } from "../router.js";
|
|
16
19
|
import { TASK_STATES } from "../vocabulary.js";
|
|
17
20
|
import { DecisionForm } from "./decision-form.js";
|
|
@@ -20,12 +23,13 @@ import { EventFeed } from "./event-feed.js";
|
|
|
20
23
|
import { Markdown } from "./markdown.js";
|
|
21
24
|
import { RunList } from "./runs.js";
|
|
22
25
|
import { TaskForm } from "./task-form.js";
|
|
23
|
-
import { PersonChip, PriorityBadge, StateBadge, Time } from "./ui.js";
|
|
26
|
+
import { CategoryBadge, DueBadge, PersonChip, PriorityBadge, StateBadge, Time } from "./ui.js";
|
|
24
27
|
|
|
25
28
|
// One task: what it is, who it is on, what it links to, the agents at work on it, the decisions
|
|
26
29
|
// about it, the tasks that are part of it, everything that happened to it, and the ways to
|
|
27
30
|
// change it. Takes its data as props and nothing from the network; which of its forms is open
|
|
28
|
-
// is told by the page, since each form has an address of its own (ADR-0010).
|
|
31
|
+
// is told by the page, since each form has an address of its own (ADR-0010). The facts beside
|
|
32
|
+
// the text say only what is set: a plan, a category or a goal the task has none of is not a row.
|
|
29
33
|
|
|
30
34
|
export interface TaskViewProps {
|
|
31
35
|
readonly task: RestTask;
|
|
@@ -37,13 +41,21 @@ export interface TaskViewProps {
|
|
|
37
41
|
readonly runs?: readonly RestRun[] | undefined;
|
|
38
42
|
/** Everything that happened to the task, oldest first, when the page has read it. */
|
|
39
43
|
readonly history?: readonly RestEvent[] | undefined;
|
|
44
|
+
/** The categories of the workspace, for the form. */
|
|
45
|
+
readonly categories?: readonly RestCategory[] | undefined;
|
|
46
|
+
/** The goals of the project, for the form and for naming the one the task works towards. */
|
|
47
|
+
readonly goals?: readonly RestGoal[] | undefined;
|
|
40
48
|
readonly taskHref: (task: RestTask) => string;
|
|
41
49
|
/** Where a decision is answered, for a run that waits for one. */
|
|
42
50
|
readonly decisionHref?: ((decisionId: string) => string) | undefined;
|
|
51
|
+
/** Where a goal of the roadmap is read. */
|
|
52
|
+
readonly goalHref?: ((goalId: string) => string) | undefined;
|
|
43
53
|
/** Where a file a run handed in is read. */
|
|
44
54
|
readonly fileHref?: ((artifact: RestArtifact) => string) | undefined;
|
|
45
55
|
/** Where a document of the project is read, by its path, for the links in what was written. */
|
|
46
56
|
readonly docHref?: ((path: string) => string) | undefined;
|
|
57
|
+
/** The day to count due days from; the browser's today when left out. */
|
|
58
|
+
readonly today?: string | undefined;
|
|
47
59
|
/** The form open on the page, if one: the task edited, or a decision raised about it. */
|
|
48
60
|
readonly form?: TaskFormKind | undefined;
|
|
49
61
|
/** Where each form is opened; left out, the forms cannot be opened from here. */
|
|
@@ -65,6 +77,7 @@ export interface TaskViewProps {
|
|
|
65
77
|
|
|
66
78
|
export function TaskView(props: TaskViewProps) {
|
|
67
79
|
const words = useWords();
|
|
80
|
+
const { tag } = useLanguage();
|
|
68
81
|
const { task } = props;
|
|
69
82
|
const parent =
|
|
70
83
|
task.parentId === null
|
|
@@ -74,6 +87,11 @@ export function TaskView(props: TaskViewProps) {
|
|
|
74
87
|
const about = props.decisions.filter((decision) => decision.taskId === task.id);
|
|
75
88
|
const runs = (props.runs ?? []).filter((run) => run.taskId === task.id);
|
|
76
89
|
const tasksById = new Map(props.tasks.map((candidate) => [candidate.id, candidate]));
|
|
90
|
+
const goal =
|
|
91
|
+
task.goalId === null
|
|
92
|
+
? undefined
|
|
93
|
+
: props.goals?.find((candidate) => candidate.id === task.goalId);
|
|
94
|
+
const day = (value: string) => formatDay(value, tag);
|
|
77
95
|
|
|
78
96
|
if (props.form === "edit") {
|
|
79
97
|
return (
|
|
@@ -85,6 +103,8 @@ export function TaskView(props: TaskViewProps) {
|
|
|
85
103
|
people={props.people}
|
|
86
104
|
task={task}
|
|
87
105
|
parents={props.tasks}
|
|
106
|
+
categories={props.categories}
|
|
107
|
+
goals={props.goals}
|
|
88
108
|
busy={props.busy}
|
|
89
109
|
error={props.error}
|
|
90
110
|
onSubmit={(input) => props.onChange(task, input)}
|
|
@@ -110,7 +130,11 @@ export function TaskView(props: TaskViewProps) {
|
|
|
110
130
|
</h1>
|
|
111
131
|
<div className="sc-task-badges">
|
|
112
132
|
<StateBadge state={task.state} />
|
|
133
|
+
{task.category !== null && <CategoryBadge category={task.category} />}
|
|
113
134
|
<PriorityBadge priority={task.priority} />
|
|
135
|
+
{task.dueOn !== null && (
|
|
136
|
+
<DueBadge dueOn={task.dueOn} state={task.state} today={props.today} />
|
|
137
|
+
)}
|
|
114
138
|
</div>
|
|
115
139
|
</header>
|
|
116
140
|
<div className="sc-task-body">
|
|
@@ -226,6 +250,26 @@ export function TaskView(props: TaskViewProps) {
|
|
|
226
250
|
<dt>{words.task.form.priority}</dt>
|
|
227
251
|
<dd>{words.vocabulary.priority[task.priority]}</dd>
|
|
228
252
|
</div>
|
|
253
|
+
{task.category !== null && (
|
|
254
|
+
<div>
|
|
255
|
+
<dt>{words.task.category}</dt>
|
|
256
|
+
<dd>
|
|
257
|
+
<CategoryBadge category={task.category} />
|
|
258
|
+
</dd>
|
|
259
|
+
</div>
|
|
260
|
+
)}
|
|
261
|
+
{task.goalId !== null && (
|
|
262
|
+
<div>
|
|
263
|
+
<dt>{words.task.goal}</dt>
|
|
264
|
+
<dd>
|
|
265
|
+
{props.goalHref === undefined ? (
|
|
266
|
+
(goal?.title ?? task.goalId)
|
|
267
|
+
) : (
|
|
268
|
+
<a href={props.goalHref(task.goalId)}>{goal?.title ?? task.goalId}</a>
|
|
269
|
+
)}
|
|
270
|
+
</dd>
|
|
271
|
+
</div>
|
|
272
|
+
)}
|
|
229
273
|
<div>
|
|
230
274
|
<dt>{words.task.owner}</dt>
|
|
231
275
|
<dd>
|
|
@@ -242,6 +286,33 @@ export function TaskView(props: TaskViewProps) {
|
|
|
242
286
|
)}
|
|
243
287
|
</dd>
|
|
244
288
|
</div>
|
|
289
|
+
{(task.startsOn !== null || task.dueOn !== null) && (
|
|
290
|
+
<div>
|
|
291
|
+
<dt>{words.task.plan}</dt>
|
|
292
|
+
<dd>
|
|
293
|
+
{words.task.planned(
|
|
294
|
+
task.startsOn === null ? undefined : day(task.startsOn),
|
|
295
|
+
task.dueOn === null ? undefined : day(task.dueOn),
|
|
296
|
+
)}
|
|
297
|
+
</dd>
|
|
298
|
+
</div>
|
|
299
|
+
)}
|
|
300
|
+
{task.startedAt !== null && (
|
|
301
|
+
<div>
|
|
302
|
+
<dt>{words.task.started}</dt>
|
|
303
|
+
<dd>
|
|
304
|
+
<Time iso={task.startedAt} />
|
|
305
|
+
</dd>
|
|
306
|
+
</div>
|
|
307
|
+
)}
|
|
308
|
+
{task.doneAt !== null && (
|
|
309
|
+
<div>
|
|
310
|
+
<dt>{words.task.done}</dt>
|
|
311
|
+
<dd>
|
|
312
|
+
<Time iso={task.doneAt} />
|
|
313
|
+
</dd>
|
|
314
|
+
</div>
|
|
315
|
+
)}
|
|
245
316
|
<div>
|
|
246
317
|
<dt>{words.task.written}</dt>
|
|
247
318
|
<dd>
|
package/src/components/ui.tsx
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
import type { ButtonHTMLAttributes, ReactNode } from "react";
|
|
2
|
-
import
|
|
2
|
+
import {
|
|
3
|
+
type CategoryColor,
|
|
4
|
+
type GoalState,
|
|
5
|
+
OPEN_TASK_STATES,
|
|
6
|
+
type PersonRole,
|
|
7
|
+
type RestPerson,
|
|
8
|
+
type RestTaskCounts,
|
|
9
|
+
type TaskPriority,
|
|
10
|
+
type TaskState,
|
|
11
|
+
} from "../api.js";
|
|
12
|
+
import { daysBetween, formatDay, today } from "../dates.js";
|
|
3
13
|
import { useLanguage, useWords } from "../i18n/index.js";
|
|
4
14
|
|
|
5
15
|
// Small building blocks. Each one is a class in styles/console.css and nothing more. Classes
|
|
@@ -157,6 +167,111 @@ export function PriorityBadge(props: { readonly priority: TaskPriority }) {
|
|
|
157
167
|
return <Badge tone={tone}>{words.vocabulary.priority[props.priority]}</Badge>;
|
|
158
168
|
}
|
|
159
169
|
|
|
170
|
+
/** A category as a badge, in its colour: a class the stylesheet paints, never a value typed by hand. */
|
|
171
|
+
export function CategoryBadge(props: {
|
|
172
|
+
readonly category: { readonly name: string; readonly color: CategoryColor };
|
|
173
|
+
}) {
|
|
174
|
+
return (
|
|
175
|
+
<span className={cx("sc-badge", "sc-badge-category", `sc-category-${props.category.color}`)}>
|
|
176
|
+
{props.category.name}
|
|
177
|
+
</span>
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function GoalStateBadge(props: { readonly state: GoalState }) {
|
|
182
|
+
const words = useWords();
|
|
183
|
+
const tone =
|
|
184
|
+
props.state === "done"
|
|
185
|
+
? "success"
|
|
186
|
+
: props.state === "active"
|
|
187
|
+
? "accent"
|
|
188
|
+
: props.state === "dropped"
|
|
189
|
+
? "neutral"
|
|
190
|
+
: "neutral";
|
|
191
|
+
return <Badge tone={tone}>{words.vocabulary.goalState[props.state]}</Badge>;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* When a task is due, as a person needs to read it: how late it is while it is open and past
|
|
196
|
+
* its day, how soon it is due within the week, and the day itself otherwise. A task that is
|
|
197
|
+
* over keeps its day as a plain fact.
|
|
198
|
+
*/
|
|
199
|
+
export function DueBadge(props: {
|
|
200
|
+
readonly dueOn: string;
|
|
201
|
+
readonly state: TaskState;
|
|
202
|
+
/** The day to count from; the browser's today when left out. */
|
|
203
|
+
readonly today?: string | undefined;
|
|
204
|
+
}) {
|
|
205
|
+
const words = useWords();
|
|
206
|
+
const { tag } = useLanguage();
|
|
207
|
+
const now = props.today ?? today();
|
|
208
|
+
const open = OPEN_TASK_STATES.includes(props.state);
|
|
209
|
+
const days = daysBetween(now, props.dueOn);
|
|
210
|
+
const day = formatDay(props.dueOn, tag);
|
|
211
|
+
if (!open) {
|
|
212
|
+
return <Badge tone="neutral">{words.board.due.on(day)}</Badge>;
|
|
213
|
+
}
|
|
214
|
+
if (days < 0) {
|
|
215
|
+
return (
|
|
216
|
+
<Badge tone="danger" title={day}>
|
|
217
|
+
{words.board.due.overdue(-days)}
|
|
218
|
+
</Badge>
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
if (days === 0) {
|
|
222
|
+
return <Badge tone="warning">{words.board.due.today}</Badge>;
|
|
223
|
+
}
|
|
224
|
+
if (days === 1) {
|
|
225
|
+
return <Badge tone="warning">{words.board.due.tomorrow}</Badge>;
|
|
226
|
+
}
|
|
227
|
+
if (days <= 7) {
|
|
228
|
+
return (
|
|
229
|
+
<Badge tone="point" title={day}>
|
|
230
|
+
{words.board.due.inDays(days)}
|
|
231
|
+
</Badge>
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
return <Badge tone="neutral">{words.board.due.on(day)}</Badge>;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** How far a goal is, read from its tasks: the done ones, the ones at work, and the rest. */
|
|
238
|
+
export function ProgressBar(props: { readonly counts: RestTaskCounts }) {
|
|
239
|
+
const words = useWords();
|
|
240
|
+
const { counts } = props;
|
|
241
|
+
const total = counts.idea + counts.ready + counts.in_progress + counts.in_review + counts.done;
|
|
242
|
+
const working = counts.in_progress + counts.in_review;
|
|
243
|
+
const share = (count: number) => (total === 0 ? 0 : (count / total) * 100);
|
|
244
|
+
return (
|
|
245
|
+
<div className="sc-progress">
|
|
246
|
+
<div
|
|
247
|
+
className="sc-progress-bar"
|
|
248
|
+
role="progressbar"
|
|
249
|
+
aria-valuemin={0}
|
|
250
|
+
aria-valuemax={total}
|
|
251
|
+
aria-valuenow={counts.done}
|
|
252
|
+
aria-label={words.roadmap.progress(counts.done, total)}
|
|
253
|
+
>
|
|
254
|
+
<span className="sc-progress-done" style={{ width: `${share(counts.done)}%` }} />
|
|
255
|
+
<span className="sc-progress-working" style={{ width: `${share(working)}%` }} />
|
|
256
|
+
</div>
|
|
257
|
+
<span className="sc-progress-words">
|
|
258
|
+
{words.roadmap.progress(counts.done, total)}
|
|
259
|
+
{working > 0 && ` · ${words.roadmap.inProgress(working)}`}
|
|
260
|
+
</span>
|
|
261
|
+
</div>
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** A day as people read one, in the language of the page. */
|
|
266
|
+
export function Day(props: { readonly day: string }) {
|
|
267
|
+
const { tag } = useLanguage();
|
|
268
|
+
return (
|
|
269
|
+
<time className="sc-time" dateTime={props.day}>
|
|
270
|
+
{formatDay(props.day, tag)}
|
|
271
|
+
</time>
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
|
|
160
275
|
export function Spinner(props: { readonly label: string }) {
|
|
161
276
|
return (
|
|
162
277
|
<span className="sc-spinner" role="status">
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import type { RestTask, RestWork } from "../api.js";
|
|
2
|
+
import { addDays, today as todayOf } from "../dates.js";
|
|
3
|
+
import { useWords } from "../i18n/index.js";
|
|
4
|
+
import { TaskList } from "./task-list.js";
|
|
5
|
+
import { PersonChip, Time } from "./ui.js";
|
|
6
|
+
|
|
7
|
+
// What is on a person's plate (ADR-0019), across the projects they may see: the open tasks
|
|
8
|
+
// assigned to them, by when they are due, and the decisions that wait for a person. Takes its
|
|
9
|
+
// data as props and nothing from the network.
|
|
10
|
+
|
|
11
|
+
export interface WorkListProps {
|
|
12
|
+
readonly work: RestWork;
|
|
13
|
+
/** The day to count from; the browser's today when left out. */
|
|
14
|
+
readonly today?: string | undefined;
|
|
15
|
+
readonly taskHref: (projectKey: string, task: RestTask) => string;
|
|
16
|
+
readonly decisionHref: (projectKey: string, decisionId: string) => string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function WorkList(props: WorkListProps) {
|
|
20
|
+
const words = useWords();
|
|
21
|
+
const work = words.work;
|
|
22
|
+
const now = props.today ?? todayOf();
|
|
23
|
+
const weekEnd = addDays(now, 6);
|
|
24
|
+
const { tasks, decisions } = props.work;
|
|
25
|
+
const projectOf = new Map(tasks.map((item) => [item.task.id, item.project]));
|
|
26
|
+
const hrefOf = (task: RestTask) => props.taskHref(projectOf.get(task.id)?.key ?? "", task);
|
|
27
|
+
const nameOf = (task: RestTask) => projectOf.get(task.id)?.name;
|
|
28
|
+
const groups: { readonly title: string; readonly tasks: RestTask[] }[] = [
|
|
29
|
+
{ title: work.overdue, tasks: [] },
|
|
30
|
+
{ title: work.thisWeek, tasks: [] },
|
|
31
|
+
{ title: work.later, tasks: [] },
|
|
32
|
+
{ title: work.noDay, tasks: [] },
|
|
33
|
+
];
|
|
34
|
+
for (const { task } of tasks) {
|
|
35
|
+
const index = task.dueOn === null ? 3 : task.dueOn < now ? 0 : task.dueOn <= weekEnd ? 1 : 2;
|
|
36
|
+
groups[index]?.tasks.push(task);
|
|
37
|
+
}
|
|
38
|
+
return (
|
|
39
|
+
<div className="sc-work">
|
|
40
|
+
{groups
|
|
41
|
+
.filter((group) => group.tasks.length > 0)
|
|
42
|
+
.map((group) => (
|
|
43
|
+
<section key={group.title} className="sc-task-section" aria-label={group.title}>
|
|
44
|
+
<h2 className="sc-section-title">
|
|
45
|
+
{group.title} <span className="sc-column-count">{group.tasks.length}</span>
|
|
46
|
+
</h2>
|
|
47
|
+
<TaskList
|
|
48
|
+
tasks={group.tasks}
|
|
49
|
+
taskHref={hrefOf}
|
|
50
|
+
projectOf={nameOf}
|
|
51
|
+
showState
|
|
52
|
+
today={now}
|
|
53
|
+
/>
|
|
54
|
+
</section>
|
|
55
|
+
))}
|
|
56
|
+
<section className="sc-task-section" aria-label={work.decisions}>
|
|
57
|
+
<h2 className="sc-section-title">
|
|
58
|
+
{work.decisions} <span className="sc-column-count">{decisions.length}</span>
|
|
59
|
+
</h2>
|
|
60
|
+
{decisions.length === 0 ? (
|
|
61
|
+
<p className="sc-muted">{work.noDecisions}</p>
|
|
62
|
+
) : (
|
|
63
|
+
<ol className="sc-decision-log">
|
|
64
|
+
{decisions.map(({ project, decision }) => (
|
|
65
|
+
<li key={decision.id} className="sc-decision-row">
|
|
66
|
+
<a
|
|
67
|
+
className="sc-decision-row-question"
|
|
68
|
+
href={props.decisionHref(project.key, decision.id)}
|
|
69
|
+
>
|
|
70
|
+
{decision.question}
|
|
71
|
+
</a>
|
|
72
|
+
<span className="sc-task-row-project">{work.inProject(project.name)}</span>
|
|
73
|
+
<span className="sc-decision-row-meta">
|
|
74
|
+
<PersonChip person={decision.raisedBy} /> <Time iso={decision.createdAt} />
|
|
75
|
+
</span>
|
|
76
|
+
</li>
|
|
77
|
+
))}
|
|
78
|
+
</ol>
|
|
79
|
+
)}
|
|
80
|
+
</section>
|
|
81
|
+
</div>
|
|
82
|
+
);
|
|
83
|
+
}
|