@skillcdn/console 0.1.8 → 0.1.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -5
- package/dist/api.d.ts +5 -5
- package/dist/api.d.ts.map +1 -1
- package/dist/api.js +4 -4
- package/dist/api.js.map +1 -1
- package/dist/cli/cli.d.ts.map +1 -1
- package/dist/cli/cli.js +255 -10
- package/dist/cli/cli.js.map +1 -1
- package/dist/cli/format.d.ts +9 -1
- package/dist/cli/format.d.ts.map +1 -1
- package/dist/cli/format.js +80 -0
- package/dist/cli/format.js.map +1 -1
- package/dist/cli/help.d.ts.map +1 -1
- package/dist/cli/help.js +62 -7
- package/dist/cli/help.js.map +1 -1
- package/dist/client.d.ts +31 -4
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +19 -3
- package/dist/client.js.map +1 -1
- package/dist/components/board.d.ts +27 -1
- package/dist/components/board.d.ts.map +1 -1
- package/dist/components/board.js +75 -7
- package/dist/components/board.js.map +1 -1
- package/dist/components/calendar.d.ts +34 -0
- package/dist/components/calendar.d.ts.map +1 -0
- package/dist/components/calendar.js +96 -0
- package/dist/components/calendar.js.map +1 -0
- package/dist/components/categories.d.ts +13 -0
- package/dist/components/categories.d.ts.map +1 -0
- package/dist/components/categories.js +52 -0
- package/dist/components/categories.js.map +1 -0
- package/dist/components/decision-list.d.ts +10 -0
- package/dist/components/decision-list.d.ts.map +1 -1
- package/dist/components/decision-list.js +14 -1
- package/dist/components/decision-list.js.map +1 -1
- package/dist/components/event-feed.d.ts.map +1 -1
- package/dist/components/event-feed.js +12 -0
- package/dist/components/event-feed.js.map +1 -1
- package/dist/components/goals.d.ts +35 -0
- package/dist/components/goals.d.ts.map +1 -0
- package/dist/components/goals.js +45 -0
- package/dist/components/goals.js.map +1 -0
- package/dist/components/task-form.d.ts +5 -1
- package/dist/components/task-form.d.ts.map +1 -1
- package/dist/components/task-form.js +13 -1
- package/dist/components/task-form.js.map +1 -1
- package/dist/components/task-list.d.ts +16 -0
- package/dist/components/task-list.d.ts.map +1 -0
- package/dist/components/task-list.js +14 -0
- package/dist/components/task-list.js.map +1 -0
- package/dist/components/task-view.d.ts +9 -1
- package/dist/components/task-view.d.ts.map +1 -1
- package/dist/components/task-view.js +10 -4
- package/dist/components/task-view.js.map +1 -1
- package/dist/components/ui.d.ts +30 -1
- package/dist/components/ui.d.ts.map +1 -1
- package/dist/components/ui.js +60 -0
- package/dist/components/ui.js.map +1 -1
- package/dist/components/work.d.ts +10 -0
- package/dist/components/work.d.ts.map +1 -0
- package/dist/components/work.js +29 -0
- package/dist/components/work.js.map +1 -0
- package/dist/console.css +511 -0
- package/dist/console.d.ts +8 -0
- package/dist/console.d.ts.map +1 -1
- package/dist/console.js +124 -8
- package/dist/console.js.map +1 -1
- package/dist/data.d.ts +14 -1
- package/dist/data.d.ts.map +1 -1
- package/dist/data.js +36 -2
- package/dist/data.js.map +1 -1
- package/dist/dates.d.ts +39 -0
- package/dist/dates.d.ts.map +1 -0
- package/dist/dates.js +120 -0
- package/dist/dates.js.map +1 -0
- package/dist/i18n/en.d.ts +162 -1
- package/dist/i18n/en.d.ts.map +1 -1
- package/dist/i18n/en.js +174 -1
- package/dist/i18n/en.js.map +1 -1
- package/dist/i18n/ko.d.ts.map +1 -1
- package/dist/i18n/ko.js +169 -1
- package/dist/i18n/ko.js.map +1 -1
- package/dist/index.d.ts +10 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +10 -4
- package/dist/index.js.map +1 -1
- package/dist/limits.d.ts +5 -0
- package/dist/limits.d.ts.map +1 -1
- package/dist/limits.js +5 -0
- package/dist/limits.js.map +1 -1
- package/dist/router.d.ts +50 -1
- package/dist/router.d.ts.map +1 -1
- package/dist/router.js +74 -1
- package/dist/router.js.map +1 -1
- package/dist/routes.d.ts +7 -1
- package/dist/routes.d.ts.map +1 -1
- package/dist/routes.js +9 -0
- package/dist/routes.js.map +1 -1
- package/dist/schemas.d.ts +366 -0
- package/dist/schemas.d.ts.map +1 -1
- package/dist/schemas.js +112 -3
- package/dist/schemas.js.map +1 -1
- package/dist/vocabulary.d.ts +15 -1
- package/dist/vocabulary.d.ts.map +1 -1
- package/dist/vocabulary.js +26 -0
- package/dist/vocabulary.js.map +1 -1
- package/docs/specs/cli.md +11 -4
- package/docs/specs/rest.md +33 -8
- package/package.json +1 -1
- package/skills/building-a-console/SKILL.md +4 -4
- package/skills/building-a-console/references/the-default-console.md +1 -1
- package/skills/deploying-a-console/SKILL.md +11 -10
- package/skills/deploying-a-console/assets/deployer-policy.json +13 -12
- package/skills/deploying-a-console/assets/secrets.example.json +4 -4
- package/skills/deploying-a-console/references/access.md +10 -9
- package/skills/deploying-a-console/references/resources.md +107 -50
- package/skills/deploying-a-console/references/sizing.md +1 -1
- package/skills/deploying-a-console/scripts/store-secrets.mjs +1 -15
- package/skills/working-the-board/SKILL.md +7 -6
- package/src/api.ts +30 -0
- package/src/cli/cli.ts +311 -10
- package/src/cli/format.ts +88 -0
- package/src/cli/help.ts +69 -7
- package/src/client.ts +70 -2
- package/src/components/board.tsx +205 -12
- package/src/components/calendar.tsx +255 -0
- package/src/components/categories.tsx +209 -0
- package/src/components/decision-list.tsx +62 -5
- package/src/components/event-feed.tsx +12 -0
- package/src/components/goals.tsx +264 -0
- package/src/components/task-form.tsx +78 -1
- package/src/components/task-list.tsx +61 -0
- package/src/components/task-view.tsx +74 -3
- package/src/components/ui.tsx +116 -1
- package/src/components/work.tsx +83 -0
- package/src/console.tsx +337 -4
- package/src/data.ts +63 -8
- package/src/dates.ts +143 -0
- package/src/i18n/en.ts +184 -1
- package/src/i18n/ko.ts +175 -1
- package/src/index.ts +58 -1
- package/src/limits.ts +5 -0
- package/src/router.ts +119 -6
- package/src/routes.ts +10 -0
- package/src/schemas.ts +141 -1
- package/src/styles/console.css +511 -0
- package/src/vocabulary.ts +33 -0
package/src/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
|
+
}
|
package/src/console.tsx
CHANGED
|
@@ -3,6 +3,7 @@ import { createRoot, type Root } from "react-dom/client";
|
|
|
3
3
|
import {
|
|
4
4
|
type ConsoleClient,
|
|
5
5
|
createClient,
|
|
6
|
+
OPEN_TASK_STATES,
|
|
6
7
|
type RestAnswerInput,
|
|
7
8
|
type RestDecision,
|
|
8
9
|
type RestDecisionInput,
|
|
@@ -15,13 +16,17 @@ import {
|
|
|
15
16
|
type RestTaskInput,
|
|
16
17
|
type RestToken,
|
|
17
18
|
type RestTokenCreated,
|
|
19
|
+
type RestWork,
|
|
18
20
|
type TaskState,
|
|
19
21
|
} from "./api.js";
|
|
20
|
-
import { Board, type BoardProps } from "./components/board.js";
|
|
22
|
+
import { Board, BoardFilters, type BoardProps, byDue, filterTasks } from "./components/board.js";
|
|
23
|
+
import { Calendar, type CalendarProps, Workload } from "./components/calendar.js";
|
|
24
|
+
import { CategoryList, type CategoryListProps } from "./components/categories.js";
|
|
21
25
|
import { ConnectWords } from "./components/connect.js";
|
|
22
26
|
import { DecisionForm } from "./components/decision-form.js";
|
|
23
27
|
import { DecisionCard, DecisionList, type DecisionListProps } from "./components/decision-list.js";
|
|
24
28
|
import { EventFeed, type EventFeedProps } from "./components/event-feed.js";
|
|
29
|
+
import { GoalForm, GoalList, type GoalListProps, GoalView } from "./components/goals.js";
|
|
25
30
|
import { PeopleList, type PeopleListProps } from "./components/people.js";
|
|
26
31
|
import {
|
|
27
32
|
MemberList,
|
|
@@ -40,11 +45,14 @@ import {
|
|
|
40
45
|
import { SignIn, type SignInProps } from "./components/sign-in.js";
|
|
41
46
|
import { SkillList, type SkillListProps } from "./components/skills.js";
|
|
42
47
|
import { TaskForm } from "./components/task-form.js";
|
|
48
|
+
import { TaskList } from "./components/task-list.js";
|
|
43
49
|
import { TaskView, type TaskViewProps } from "./components/task-view.js";
|
|
44
50
|
import { NewToken, TokenForm, TokenList, type TokenListProps } from "./components/tokens.js";
|
|
45
51
|
import { Button, Callout, EmptyState, Spinner } from "./components/ui.js";
|
|
52
|
+
import { WorkList, type WorkListProps } from "./components/work.js";
|
|
46
53
|
import { ConnectPage } from "./connect-page.js";
|
|
47
54
|
import { type ConsoleData, useConsoleData } from "./data.js";
|
|
55
|
+
import { monthOf, today } from "./dates.js";
|
|
48
56
|
import { DocsPage } from "./docs-page.js";
|
|
49
57
|
import {
|
|
50
58
|
chooseLanguage,
|
|
@@ -54,11 +62,15 @@ import {
|
|
|
54
62
|
useWords,
|
|
55
63
|
} from "./i18n/index.js";
|
|
56
64
|
import {
|
|
65
|
+
boardHref,
|
|
66
|
+
calendarHref,
|
|
57
67
|
decisionHref,
|
|
58
68
|
docHref,
|
|
69
|
+
goalHref,
|
|
59
70
|
matchRoute,
|
|
60
71
|
type Navigation,
|
|
61
72
|
newDecisionHref,
|
|
73
|
+
newGoalHref,
|
|
62
74
|
newTaskHref,
|
|
63
75
|
PATHS,
|
|
64
76
|
personAgentsHref,
|
|
@@ -69,7 +81,7 @@ import {
|
|
|
69
81
|
withoutSignInParam,
|
|
70
82
|
} from "./router.js";
|
|
71
83
|
import { projectPath } from "./routes.js";
|
|
72
|
-
import { useAction } from "./use-action.js";
|
|
84
|
+
import { errorWords, useAction } from "./use-action.js";
|
|
73
85
|
|
|
74
86
|
// The composition of the default console: the pages assembled from the components, with the
|
|
75
87
|
// places a team may replace named. The UI the image serves is this, with the default config.
|
|
@@ -90,6 +102,10 @@ export interface ConsoleComponents {
|
|
|
90
102
|
readonly SkillList: ComponentType<SkillListProps>;
|
|
91
103
|
readonly ProjectList: ComponentType<ProjectListProps>;
|
|
92
104
|
readonly MemberList: ComponentType<MemberListProps>;
|
|
105
|
+
readonly Calendar: ComponentType<CalendarProps>;
|
|
106
|
+
readonly GoalList: ComponentType<GoalListProps>;
|
|
107
|
+
readonly WorkList: ComponentType<WorkListProps>;
|
|
108
|
+
readonly CategoryList: ComponentType<CategoryListProps>;
|
|
93
109
|
}
|
|
94
110
|
|
|
95
111
|
export const DEFAULT_COMPONENTS: ConsoleComponents = {
|
|
@@ -104,6 +120,10 @@ export const DEFAULT_COMPONENTS: ConsoleComponents = {
|
|
|
104
120
|
SkillList,
|
|
105
121
|
ProjectList,
|
|
106
122
|
MemberList,
|
|
123
|
+
Calendar,
|
|
124
|
+
GoalList,
|
|
125
|
+
WorkList,
|
|
126
|
+
CategoryList,
|
|
107
127
|
};
|
|
108
128
|
|
|
109
129
|
export interface ConsoleConfig {
|
|
@@ -426,8 +446,11 @@ function TaskPage(props: {
|
|
|
426
446
|
decisions={data.decisions}
|
|
427
447
|
runs={data.runs}
|
|
428
448
|
history={history}
|
|
449
|
+
categories={data.categories}
|
|
450
|
+
goals={data.goals}
|
|
429
451
|
taskHref={(candidate) => taskHref(key, candidate.number)}
|
|
430
452
|
decisionHref={(decisionId) => decisionHref(key, decisionId)}
|
|
453
|
+
goalHref={(goalId) => goalHref(key, goalId)}
|
|
431
454
|
fileHref={(artifact) => projectPath(key, "files", artifact.id)}
|
|
432
455
|
docHref={(path) => docHref(key, path)}
|
|
433
456
|
form={route.form}
|
|
@@ -483,6 +506,8 @@ function ProjectPage(props: {
|
|
|
483
506
|
void act(() => data.actions.updateDecision(decision.id, patch));
|
|
484
507
|
|
|
485
508
|
if (route.name === "board") {
|
|
509
|
+
const me = data.me?.person ?? undefined;
|
|
510
|
+
const shown = filterTasks(data.tasks, route.filter, me);
|
|
486
511
|
return (
|
|
487
512
|
<>
|
|
488
513
|
<div className="sc-page-head">
|
|
@@ -493,8 +518,21 @@ function ProjectPage(props: {
|
|
|
493
518
|
</div>
|
|
494
519
|
{project.description.length > 0 && <p className="sc-lead">{project.description}</p>}
|
|
495
520
|
{error !== undefined && <Callout tone="danger">{error}</Callout>}
|
|
521
|
+
{data.tasks.length > 0 && (
|
|
522
|
+
<BoardFilters
|
|
523
|
+
filter={route.filter}
|
|
524
|
+
categories={data.categories}
|
|
525
|
+
goals={data.goals}
|
|
526
|
+
people={data.people}
|
|
527
|
+
me={me}
|
|
528
|
+
shown={shown.length}
|
|
529
|
+
total={data.tasks.length}
|
|
530
|
+
onChange={(filter) => navigation.replace(boardHref(key, filter))}
|
|
531
|
+
/>
|
|
532
|
+
)}
|
|
496
533
|
<components.Board
|
|
497
|
-
tasks={
|
|
534
|
+
tasks={shown}
|
|
535
|
+
runs={data.runs}
|
|
498
536
|
taskHref={href}
|
|
499
537
|
onOpen={(task) => navigation.go(href(task))}
|
|
500
538
|
onMove={onMove}
|
|
@@ -522,6 +560,8 @@ function ProjectPage(props: {
|
|
|
522
560
|
<TaskForm
|
|
523
561
|
people={data.people}
|
|
524
562
|
parents={data.tasks}
|
|
563
|
+
categories={data.categories}
|
|
564
|
+
goals={data.goals}
|
|
525
565
|
busy={busy}
|
|
526
566
|
onSubmit={(input: RestTaskInput) =>
|
|
527
567
|
void act(async () => {
|
|
@@ -549,6 +589,183 @@ function ProjectPage(props: {
|
|
|
549
589
|
/>
|
|
550
590
|
);
|
|
551
591
|
}
|
|
592
|
+
if (route.name === "calendar") {
|
|
593
|
+
const now = today();
|
|
594
|
+
const month = route.month ?? monthOf(now);
|
|
595
|
+
const open = data.tasks.filter((task) => OPEN_TASK_STATES.includes(task.state));
|
|
596
|
+
const overdue = open.filter((task) => task.dueOn !== null && task.dueOn < now).sort(byDue);
|
|
597
|
+
const unscheduled = open.filter((task) => task.dueOn === null).sort(byDue);
|
|
598
|
+
const calendar = words.calendar;
|
|
599
|
+
return (
|
|
600
|
+
<>
|
|
601
|
+
<div className="sc-page-head">
|
|
602
|
+
<h1 className="sc-page-title">{calendar.title}</h1>
|
|
603
|
+
<LinkButton href={newTaskHref(key)} variant="primary">
|
|
604
|
+
{words.board.writeTask}
|
|
605
|
+
</LinkButton>
|
|
606
|
+
</div>
|
|
607
|
+
<p className="sc-lead">{calendar.lead}</p>
|
|
608
|
+
<components.Calendar
|
|
609
|
+
tasks={data.tasks}
|
|
610
|
+
month={month}
|
|
611
|
+
today={now}
|
|
612
|
+
taskHref={href}
|
|
613
|
+
monthHref={(wanted) => calendarHref(key, wanted)}
|
|
614
|
+
/>
|
|
615
|
+
<div className="sc-calendar-below">
|
|
616
|
+
<section className="sc-task-section" aria-label={calendar.overdue}>
|
|
617
|
+
<h2 className="sc-section-title">
|
|
618
|
+
{calendar.overdue} <span className="sc-column-count">{overdue.length}</span>
|
|
619
|
+
</h2>
|
|
620
|
+
<TaskList
|
|
621
|
+
tasks={overdue}
|
|
622
|
+
taskHref={href}
|
|
623
|
+
today={now}
|
|
624
|
+
showState
|
|
625
|
+
empty={<p className="sc-muted">{calendar.nothingOverdue}</p>}
|
|
626
|
+
/>
|
|
627
|
+
</section>
|
|
628
|
+
<section className="sc-task-section" aria-label={calendar.unscheduled}>
|
|
629
|
+
<h2 className="sc-section-title">
|
|
630
|
+
{calendar.unscheduled} <span className="sc-column-count">{unscheduled.length}</span>
|
|
631
|
+
</h2>
|
|
632
|
+
<TaskList
|
|
633
|
+
tasks={unscheduled}
|
|
634
|
+
taskHref={href}
|
|
635
|
+
today={now}
|
|
636
|
+
showState
|
|
637
|
+
empty={<p className="sc-muted">{calendar.allScheduled}</p>}
|
|
638
|
+
/>
|
|
639
|
+
</section>
|
|
640
|
+
</div>
|
|
641
|
+
{open.length > 0 && (
|
|
642
|
+
<section className="sc-task-section" aria-label={calendar.workload.title}>
|
|
643
|
+
<h2 className="sc-section-title">{calendar.workload.title}</h2>
|
|
644
|
+
<p className="sc-lead">{calendar.workload.lead}</p>
|
|
645
|
+
<Workload
|
|
646
|
+
tasks={data.tasks}
|
|
647
|
+
today={now}
|
|
648
|
+
personHref={(person) =>
|
|
649
|
+
boardHref(key, person === undefined ? {} : { person: person.id })
|
|
650
|
+
}
|
|
651
|
+
/>
|
|
652
|
+
</section>
|
|
653
|
+
)}
|
|
654
|
+
</>
|
|
655
|
+
);
|
|
656
|
+
}
|
|
657
|
+
if (route.name === "roadmap" || route.name === "new-goal") {
|
|
658
|
+
const roadmap = words.roadmap;
|
|
659
|
+
const making = route.name === "new-goal";
|
|
660
|
+
if (making && !owner) {
|
|
661
|
+
return <EmptyState title={roadmap.onlyOwner.title} body={roadmap.onlyOwner.body} />;
|
|
662
|
+
}
|
|
663
|
+
return (
|
|
664
|
+
<>
|
|
665
|
+
<div className="sc-page-head">
|
|
666
|
+
<h1 className="sc-page-title">{roadmap.title}</h1>
|
|
667
|
+
{!making && owner && (
|
|
668
|
+
<LinkButton href={newGoalHref(key)} variant="primary">
|
|
669
|
+
{roadmap.setGoal}
|
|
670
|
+
</LinkButton>
|
|
671
|
+
)}
|
|
672
|
+
</div>
|
|
673
|
+
<p className="sc-lead">{roadmap.lead}</p>
|
|
674
|
+
{error !== undefined && <Callout tone="danger">{error}</Callout>}
|
|
675
|
+
{making && (
|
|
676
|
+
<section className="sc-panel sc-panel-form" aria-label={roadmap.setGoalAria}>
|
|
677
|
+
<GoalForm
|
|
678
|
+
busy={busy}
|
|
679
|
+
onSubmit={(input) =>
|
|
680
|
+
void act(async () => {
|
|
681
|
+
const made = await data.actions.createGoal(input);
|
|
682
|
+
navigation.replace(goalHref(key, made.id));
|
|
683
|
+
})
|
|
684
|
+
}
|
|
685
|
+
onCancel={() => navigation.back(projectHref(key, "roadmap"))}
|
|
686
|
+
/>
|
|
687
|
+
</section>
|
|
688
|
+
)}
|
|
689
|
+
<components.GoalList
|
|
690
|
+
goals={data.goals}
|
|
691
|
+
goalHref={(goal) => goalHref(key, goal.id)}
|
|
692
|
+
empty={
|
|
693
|
+
<EmptyState
|
|
694
|
+
title={roadmap.empty.title}
|
|
695
|
+
body={roadmap.empty.body}
|
|
696
|
+
action={
|
|
697
|
+
owner ? (
|
|
698
|
+
<LinkButton href={newGoalHref(key)}>{roadmap.setGoal}</LinkButton>
|
|
699
|
+
) : undefined
|
|
700
|
+
}
|
|
701
|
+
/>
|
|
702
|
+
}
|
|
703
|
+
/>
|
|
704
|
+
</>
|
|
705
|
+
);
|
|
706
|
+
}
|
|
707
|
+
if (route.name === "goal") {
|
|
708
|
+
const roadmap = words.roadmap;
|
|
709
|
+
const goal = data.goals.find((candidate) => candidate.id === route.id);
|
|
710
|
+
const here = goalHref(key, route.id);
|
|
711
|
+
if (goal === undefined) {
|
|
712
|
+
return (
|
|
713
|
+
<EmptyState
|
|
714
|
+
title={roadmap.noSuch.title}
|
|
715
|
+
body={roadmap.noSuch.body}
|
|
716
|
+
action={<LinkButton href={projectHref(key, "roadmap")}>{roadmap.all}</LinkButton>}
|
|
717
|
+
/>
|
|
718
|
+
);
|
|
719
|
+
}
|
|
720
|
+
if (route.form === "edit") {
|
|
721
|
+
if (!owner) {
|
|
722
|
+
return <EmptyState title={roadmap.onlyOwner.title} body={roadmap.onlyOwner.body} />;
|
|
723
|
+
}
|
|
724
|
+
return (
|
|
725
|
+
<>
|
|
726
|
+
<div className="sc-page-head">
|
|
727
|
+
<h1 className="sc-page-title">{goal.title}</h1>
|
|
728
|
+
</div>
|
|
729
|
+
{error !== undefined && <Callout tone="danger">{error}</Callout>}
|
|
730
|
+
<section className="sc-panel sc-panel-form" aria-label={roadmap.editAria}>
|
|
731
|
+
<GoalForm
|
|
732
|
+
goal={goal}
|
|
733
|
+
busy={busy}
|
|
734
|
+
onSubmit={(input) =>
|
|
735
|
+
void act(async () => {
|
|
736
|
+
await data.actions.updateGoal(goal.id, input);
|
|
737
|
+
navigation.replace(here);
|
|
738
|
+
})
|
|
739
|
+
}
|
|
740
|
+
onCancel={() => navigation.back(here)}
|
|
741
|
+
/>
|
|
742
|
+
</section>
|
|
743
|
+
</>
|
|
744
|
+
);
|
|
745
|
+
}
|
|
746
|
+
return (
|
|
747
|
+
<>
|
|
748
|
+
<div className="sc-page-head">
|
|
749
|
+
<h1 className="sc-page-title">{roadmap.one}</h1>
|
|
750
|
+
<LinkButton href={projectHref(key, "roadmap")}>{roadmap.all}</LinkButton>
|
|
751
|
+
</div>
|
|
752
|
+
{error !== undefined && <Callout tone="danger">{error}</Callout>}
|
|
753
|
+
<GoalView
|
|
754
|
+
goal={goal}
|
|
755
|
+
tasks={data.tasks}
|
|
756
|
+
taskHref={href}
|
|
757
|
+
docHref={toDoc}
|
|
758
|
+
editHref={owner ? goalHref(key, goal.id, "edit") : undefined}
|
|
759
|
+
onMove={
|
|
760
|
+
owner
|
|
761
|
+
? (target, state) => void act(() => data.actions.updateGoal(target.id, { state }))
|
|
762
|
+
: undefined
|
|
763
|
+
}
|
|
764
|
+
busy={busy}
|
|
765
|
+
/>
|
|
766
|
+
</>
|
|
767
|
+
);
|
|
768
|
+
}
|
|
552
769
|
if (route.name === "docs") {
|
|
553
770
|
return (
|
|
554
771
|
<DocsPage
|
|
@@ -650,6 +867,9 @@ function ProjectPage(props: {
|
|
|
650
867
|
if (event.decisionId !== null) {
|
|
651
868
|
return decisionHref(key, event.decisionId);
|
|
652
869
|
}
|
|
870
|
+
if (event.goalId !== null) {
|
|
871
|
+
return goalHref(key, event.goalId);
|
|
872
|
+
}
|
|
653
873
|
return event.data.path === undefined ? undefined : toDoc(event.data.path);
|
|
654
874
|
}}
|
|
655
875
|
empty={<EmptyState title={words.feed.empty} />}
|
|
@@ -822,6 +1042,55 @@ function PersonAgentsPage(props: {
|
|
|
822
1042
|
);
|
|
823
1043
|
}
|
|
824
1044
|
|
|
1045
|
+
/** What is on the person's plate, across their projects: read when the page is opened. */
|
|
1046
|
+
function WorkPage(props: { readonly data: ConsoleData; readonly components: ConsoleComponents }) {
|
|
1047
|
+
const words = useWords();
|
|
1048
|
+
const { data, components } = props;
|
|
1049
|
+
const [work, setWork] = useState<RestWork | undefined>(undefined);
|
|
1050
|
+
const [problem, setProblem] = useState<string | undefined>(undefined);
|
|
1051
|
+
const { work: load } = data.actions;
|
|
1052
|
+
const couldNotLoad = words.work.couldNotLoad;
|
|
1053
|
+
useEffect(() => {
|
|
1054
|
+
const controller = new AbortController();
|
|
1055
|
+
load(controller.signal)
|
|
1056
|
+
.then((answer) => {
|
|
1057
|
+
if (!controller.signal.aborted) {
|
|
1058
|
+
setWork(answer);
|
|
1059
|
+
}
|
|
1060
|
+
})
|
|
1061
|
+
.catch((failure: unknown) => {
|
|
1062
|
+
if (!controller.signal.aborted) {
|
|
1063
|
+
setProblem(errorWords(failure, couldNotLoad));
|
|
1064
|
+
}
|
|
1065
|
+
});
|
|
1066
|
+
return () => controller.abort();
|
|
1067
|
+
}, [load, couldNotLoad]);
|
|
1068
|
+
return (
|
|
1069
|
+
<>
|
|
1070
|
+
<div className="sc-page-head">
|
|
1071
|
+
<h1 className="sc-page-title">{words.work.title}</h1>
|
|
1072
|
+
</div>
|
|
1073
|
+
<p className="sc-lead">{words.work.lead}</p>
|
|
1074
|
+
{problem !== undefined && <Callout tone="danger">{problem}</Callout>}
|
|
1075
|
+
{work === undefined ? (
|
|
1076
|
+
problem === undefined && (
|
|
1077
|
+
<div className="sc-loading">
|
|
1078
|
+
<Spinner label={words.work.loading} />
|
|
1079
|
+
</div>
|
|
1080
|
+
)
|
|
1081
|
+
) : work.tasks.length === 0 && work.decisions.length === 0 ? (
|
|
1082
|
+
<EmptyState title={words.work.empty.title} body={words.work.empty.body} />
|
|
1083
|
+
) : (
|
|
1084
|
+
<components.WorkList
|
|
1085
|
+
work={work}
|
|
1086
|
+
taskHref={(projectKey, task) => taskHref(projectKey, task.number)}
|
|
1087
|
+
decisionHref={(projectKey, decisionId) => decisionHref(projectKey, decisionId)}
|
|
1088
|
+
/>
|
|
1089
|
+
)}
|
|
1090
|
+
</>
|
|
1091
|
+
);
|
|
1092
|
+
}
|
|
1093
|
+
|
|
825
1094
|
function WorkspacePage(props: {
|
|
826
1095
|
readonly route: Route;
|
|
827
1096
|
readonly data: ConsoleData;
|
|
@@ -833,6 +1102,48 @@ function WorkspacePage(props: {
|
|
|
833
1102
|
const [fresh, setFresh] = useState<RestTokenCreated | undefined>(undefined);
|
|
834
1103
|
const { busy, error, act } = useAction();
|
|
835
1104
|
|
|
1105
|
+
if (route.name === "work") {
|
|
1106
|
+
return <WorkPage data={data} components={components} />;
|
|
1107
|
+
}
|
|
1108
|
+
if (route.name === "categories") {
|
|
1109
|
+
const me = data.me?.person ?? undefined;
|
|
1110
|
+
const administrator = me?.role === "admin" && !holdsToken(data);
|
|
1111
|
+
const categories = words.categories;
|
|
1112
|
+
return (
|
|
1113
|
+
<>
|
|
1114
|
+
<div className="sc-page-head">
|
|
1115
|
+
<h1 className="sc-page-title">{categories.title}</h1>
|
|
1116
|
+
</div>
|
|
1117
|
+
<p className="sc-lead">
|
|
1118
|
+
{categories.lead} {categories.onlyAdmin}
|
|
1119
|
+
</p>
|
|
1120
|
+
{error !== undefined && <Callout tone="danger">{error}</Callout>}
|
|
1121
|
+
<components.CategoryList
|
|
1122
|
+
categories={data.categories}
|
|
1123
|
+
busy={busy}
|
|
1124
|
+
onAdd={
|
|
1125
|
+
administrator
|
|
1126
|
+
? (input) => void act(() => data.actions.createCategory(input))
|
|
1127
|
+
: undefined
|
|
1128
|
+
}
|
|
1129
|
+
onChange={
|
|
1130
|
+
administrator
|
|
1131
|
+
? (category, patch) =>
|
|
1132
|
+
void act(() => data.actions.updateCategory(category.key, patch))
|
|
1133
|
+
: undefined
|
|
1134
|
+
}
|
|
1135
|
+
onRemove={
|
|
1136
|
+
administrator
|
|
1137
|
+
? (category) => void act(() => data.actions.removeCategory(category.key))
|
|
1138
|
+
: undefined
|
|
1139
|
+
}
|
|
1140
|
+
empty={<EmptyState title={categories.empty.title} body={categories.empty.body} />}
|
|
1141
|
+
/>
|
|
1142
|
+
{administrator && <p className="sc-muted">{categories.inUse}</p>}
|
|
1143
|
+
</>
|
|
1144
|
+
);
|
|
1145
|
+
}
|
|
1146
|
+
|
|
836
1147
|
if (route.name === "people") {
|
|
837
1148
|
const me = data.me?.person ?? undefined;
|
|
838
1149
|
const administrator = me?.role === "admin" && !holdsToken(data);
|
|
@@ -998,6 +1309,7 @@ export function createConsole(config: ConsoleConfig = {}): ConsoleApp {
|
|
|
998
1309
|
label: words.nav.projects,
|
|
999
1310
|
current: route.name === "projects" || route.name === "new-project",
|
|
1000
1311
|
},
|
|
1312
|
+
{ href: PATHS.work, label: words.nav.work, current: route.name === "work" },
|
|
1001
1313
|
{
|
|
1002
1314
|
href: PATHS.people,
|
|
1003
1315
|
label: words.nav.people,
|
|
@@ -1012,6 +1324,16 @@ export function createConsole(config: ConsoleConfig = {}): ConsoleApp {
|
|
|
1012
1324
|
current: route.name === "agents" || route.name === "new-token",
|
|
1013
1325
|
},
|
|
1014
1326
|
]),
|
|
1327
|
+
// The categories are read by everyone and kept by an administrator; only they need the page.
|
|
1328
|
+
...(data.me.person.role === "admin" && !viaToken
|
|
1329
|
+
? [
|
|
1330
|
+
{
|
|
1331
|
+
href: PATHS.categories,
|
|
1332
|
+
label: words.nav.categories,
|
|
1333
|
+
current: route.name === "categories",
|
|
1334
|
+
},
|
|
1335
|
+
]
|
|
1336
|
+
: []),
|
|
1015
1337
|
];
|
|
1016
1338
|
const nav: NavItem[] =
|
|
1017
1339
|
projectKey === undefined
|
|
@@ -1023,6 +1345,17 @@ export function createConsole(config: ConsoleConfig = {}): ConsoleApp {
|
|
|
1023
1345
|
current:
|
|
1024
1346
|
route.name === "board" || route.name === "task" || route.name === "new-task",
|
|
1025
1347
|
},
|
|
1348
|
+
{
|
|
1349
|
+
href: projectHref(projectKey, "roadmap"),
|
|
1350
|
+
label: words.nav.roadmap,
|
|
1351
|
+
current:
|
|
1352
|
+
route.name === "roadmap" || route.name === "goal" || route.name === "new-goal",
|
|
1353
|
+
},
|
|
1354
|
+
{
|
|
1355
|
+
href: projectHref(projectKey, "calendar"),
|
|
1356
|
+
label: words.nav.calendar,
|
|
1357
|
+
current: route.name === "calendar",
|
|
1358
|
+
},
|
|
1026
1359
|
{
|
|
1027
1360
|
href: projectHref(projectKey, "docs"),
|
|
1028
1361
|
label: words.nav.docs,
|
|
@@ -1140,7 +1473,7 @@ export function createConsole(config: ConsoleConfig = {}): ConsoleApp {
|
|
|
1140
1473
|
}}
|
|
1141
1474
|
person={data.me.person}
|
|
1142
1475
|
live={projectKey === undefined ? undefined : data.live}
|
|
1143
|
-
wide={route.name === "board"}
|
|
1476
|
+
wide={route.name === "board" || route.name === "calendar"}
|
|
1144
1477
|
onNavigate={(href) => navigation.go(href)}
|
|
1145
1478
|
onSignOut={viaToken ? undefined : () => void data.actions.signOut()}
|
|
1146
1479
|
>
|