@skillcdn/console 0.1.10 → 0.1.12
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 +6 -5
- package/dist/api.d.ts +6 -5
- package/dist/api.d.ts.map +1 -1
- package/dist/api.js +5 -4
- package/dist/api.js.map +1 -1
- package/dist/cli/cli.d.ts.map +1 -1
- package/dist/cli/cli.js +49 -9
- package/dist/cli/cli.js.map +1 -1
- package/dist/cli/format.d.ts +2 -0
- package/dist/cli/format.d.ts.map +1 -1
- package/dist/cli/format.js +7 -6
- package/dist/cli/format.js.map +1 -1
- package/dist/cli/help.d.ts.map +1 -1
- package/dist/cli/help.js +10 -9
- package/dist/cli/help.js.map +1 -1
- package/dist/client.d.ts +14 -5
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +13 -1
- package/dist/client.js.map +1 -1
- package/dist/components/board.d.ts +1 -4
- package/dist/components/board.d.ts.map +1 -1
- package/dist/components/board.js +6 -13
- package/dist/components/board.js.map +1 -1
- package/dist/components/calendar.d.ts +21 -1
- package/dist/components/calendar.d.ts.map +1 -1
- package/dist/components/calendar.js +69 -9
- package/dist/components/calendar.js.map +1 -1
- package/dist/components/decision-list.d.ts.map +1 -1
- package/dist/components/decision-list.js +11 -9
- package/dist/components/decision-list.js.map +1 -1
- package/dist/components/event-feed.d.ts.map +1 -1
- package/dist/components/event-feed.js +11 -7
- package/dist/components/event-feed.js.map +1 -1
- package/dist/components/paged.d.ts +14 -0
- package/dist/components/paged.d.ts.map +1 -0
- package/dist/components/paged.js +28 -0
- package/dist/components/paged.js.map +1 -0
- package/dist/components/people.js +2 -2
- package/dist/components/people.js.map +1 -1
- package/dist/components/projects.d.ts +22 -2
- package/dist/components/projects.d.ts.map +1 -1
- package/dist/components/projects.js +37 -9
- package/dist/components/projects.js.map +1 -1
- package/dist/components/runs.d.ts.map +1 -1
- package/dist/components/runs.js +3 -3
- package/dist/components/runs.js.map +1 -1
- package/dist/components/shell.d.ts +2 -1
- package/dist/components/shell.d.ts.map +1 -1
- package/dist/components/shell.js +3 -3
- package/dist/components/shell.js.map +1 -1
- package/dist/components/task-form.js +2 -2
- package/dist/components/task-form.js.map +1 -1
- package/dist/components/task-list.d.ts +1 -1
- package/dist/components/task-list.d.ts.map +1 -1
- package/dist/components/task-list.js +6 -4
- package/dist/components/task-list.js.map +1 -1
- package/dist/components/task-view.js +1 -1
- package/dist/components/task-view.js.map +1 -1
- package/dist/components/ui.d.ts +7 -2
- package/dist/components/ui.d.ts.map +1 -1
- package/dist/components/ui.js +9 -4
- package/dist/components/ui.js.map +1 -1
- package/dist/components/work.d.ts.map +1 -1
- package/dist/components/work.js +7 -3
- package/dist/components/work.js.map +1 -1
- package/dist/console.css +149 -2
- package/dist/console.d.ts +38 -4
- package/dist/console.d.ts.map +1 -1
- package/dist/console.js +84 -23
- package/dist/console.js.map +1 -1
- package/dist/data.d.ts +5 -2
- package/dist/data.d.ts.map +1 -1
- package/dist/data.js +15 -2
- package/dist/data.js.map +1 -1
- package/dist/i18n/en.d.ts +17 -11
- package/dist/i18n/en.d.ts.map +1 -1
- package/dist/i18n/en.js +18 -14
- package/dist/i18n/en.js.map +1 -1
- package/dist/i18n/ko.d.ts.map +1 -1
- package/dist/i18n/ko.js +18 -14
- package/dist/i18n/ko.js.map +1 -1
- package/dist/index.d.ts +6 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -5
- package/dist/index.js.map +1 -1
- package/dist/limits.d.ts +6 -0
- package/dist/limits.d.ts.map +1 -1
- package/dist/limits.js +6 -0
- package/dist/limits.js.map +1 -1
- package/dist/marks.d.ts +12 -0
- package/dist/marks.d.ts.map +1 -0
- package/dist/marks.js +31 -0
- package/dist/marks.js.map +1 -0
- package/dist/router.d.ts +19 -3
- package/dist/router.d.ts.map +1 -1
- package/dist/router.js +28 -5
- package/dist/router.js.map +1 -1
- package/dist/routes.d.ts +7 -0
- package/dist/routes.d.ts.map +1 -1
- package/dist/routes.js +8 -0
- package/dist/routes.js.map +1 -1
- package/dist/schemas.d.ts +123 -3
- package/dist/schemas.d.ts.map +1 -1
- package/dist/schemas.js +42 -10
- package/dist/schemas.js.map +1 -1
- package/dist/vocabulary.d.ts +10 -4
- package/dist/vocabulary.d.ts.map +1 -1
- package/dist/vocabulary.js +8 -3
- package/dist/vocabulary.js.map +1 -1
- package/docs/specs/cli.md +7 -5
- package/docs/specs/rest.md +18 -12
- package/package.json +1 -1
- package/skills/building-a-console/SKILL.md +2 -2
- package/skills/working-the-board/SKILL.md +1 -1
- package/src/api.ts +12 -0
- package/src/cli/cli.ts +51 -8
- package/src/cli/format.ts +8 -6
- package/src/cli/help.ts +10 -10
- package/src/client.ts +29 -6
- package/src/components/board.tsx +6 -30
- package/src/components/calendar.tsx +168 -56
- package/src/components/decision-list.tsx +38 -33
- package/src/components/event-feed.tsx +34 -27
- package/src/components/paged.tsx +39 -0
- package/src/components/people.tsx +5 -5
- package/src/components/projects.tsx +132 -12
- package/src/components/runs.tsx +9 -3
- package/src/components/shell.tsx +8 -5
- package/src/components/task-form.tsx +2 -2
- package/src/components/task-list.tsx +35 -32
- package/src/components/task-view.tsx +1 -1
- package/src/components/ui.tsx +14 -5
- package/src/components/work.tsx +12 -2
- package/src/console.tsx +158 -25
- package/src/data.ts +20 -4
- package/src/i18n/en.ts +19 -14
- package/src/i18n/ko.ts +19 -14
- package/src/index.ts +9 -0
- package/src/limits.ts +6 -0
- package/src/marks.ts +40 -0
- package/src/router.ts +45 -7
- package/src/routes.ts +9 -0
- package/src/schemas.ts +48 -8
- package/src/styles/console.css +149 -2
- package/src/vocabulary.ts +14 -4
package/src/components/board.tsx
CHANGED
|
@@ -3,12 +3,11 @@ import {
|
|
|
3
3
|
type RestCategory,
|
|
4
4
|
type RestGoal,
|
|
5
5
|
type RestPerson,
|
|
6
|
-
type RestRun,
|
|
7
6
|
type RestTask,
|
|
8
7
|
TASK_STATES,
|
|
9
8
|
type TaskState,
|
|
10
9
|
} from "../api.js";
|
|
11
|
-
import {
|
|
10
|
+
import { useWords } from "../i18n/index.js";
|
|
12
11
|
import type { BoardFilter } from "../router.js";
|
|
13
12
|
import {
|
|
14
13
|
Avatar,
|
|
@@ -17,13 +16,14 @@ import {
|
|
|
17
16
|
CategoryBadge,
|
|
18
17
|
cx,
|
|
19
18
|
DueBadge,
|
|
20
|
-
formatInstant,
|
|
21
19
|
PriorityBadge,
|
|
20
|
+
personName,
|
|
22
21
|
} from "./ui.js";
|
|
23
22
|
|
|
24
23
|
// The board: one column per state, the tasks in it, the sooner due first and the newest after.
|
|
25
24
|
// 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
|
|
25
|
+
// category, what is due, who is on it, and what blocks it. Whether an agent is at it is not
|
|
26
|
+
// said, since the board cannot know (ADR-0020); the task's page shows its runs. It takes its
|
|
27
27
|
// data as props and nothing from the network; what it is told on a click goes back up.
|
|
28
28
|
|
|
29
29
|
export interface BoardProps {
|
|
@@ -33,32 +33,21 @@ export interface BoardProps {
|
|
|
33
33
|
readonly onOpen: (task: RestTask) => void;
|
|
34
34
|
/** Called when a person moves a task to another column. Left out, the board is read-only. */
|
|
35
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
36
|
/** The day to count due days from; the browser's today when left out. */
|
|
39
37
|
readonly today?: string | undefined;
|
|
40
38
|
/** What a column shows when it has nothing; the way to write the first task, for example. */
|
|
41
39
|
readonly empty?: ReactNode;
|
|
42
40
|
}
|
|
43
41
|
|
|
44
|
-
/** The open run on a task, when an agent is at work on it: whose, and since when. */
|
|
45
|
-
function runOn(task: RestTask, runs: readonly RestRun[] | undefined): RestRun | undefined {
|
|
46
|
-
return runs?.find(
|
|
47
|
-
(run) => run.taskId === task.id && (run.status === "running" || run.status === "waiting"),
|
|
48
|
-
);
|
|
49
|
-
}
|
|
50
|
-
|
|
51
42
|
export function TaskCard(props: {
|
|
52
43
|
readonly task: RestTask;
|
|
53
44
|
readonly href: string;
|
|
54
45
|
readonly onOpen: (task: RestTask) => void;
|
|
55
46
|
readonly onMove?: ((task: RestTask, state: TaskState) => void) | undefined;
|
|
56
|
-
readonly run?: RestRun | undefined;
|
|
57
47
|
readonly today?: string | undefined;
|
|
58
48
|
}) {
|
|
59
49
|
const words = useWords();
|
|
60
|
-
const {
|
|
61
|
-
const { task, run } = props;
|
|
50
|
+
const { task } = props;
|
|
62
51
|
return (
|
|
63
52
|
<article className="sc-card" aria-label={`#${task.number} ${task.title}`}>
|
|
64
53
|
<a
|
|
@@ -84,18 +73,6 @@ export function TaskCard(props: {
|
|
|
84
73
|
{words.board.decisions(task.openDecisions)}
|
|
85
74
|
</Badge>
|
|
86
75
|
)}
|
|
87
|
-
{task.openRuns > 0 && (
|
|
88
|
-
<Badge
|
|
89
|
-
tone="accent"
|
|
90
|
-
title={
|
|
91
|
-
run === undefined
|
|
92
|
-
? words.board.agentAtWorkTitle
|
|
93
|
-
: words.board.agentSince(run.agent, formatInstant(run.startedAt, tag))
|
|
94
|
-
}
|
|
95
|
-
>
|
|
96
|
-
{run === undefined ? words.board.agentAtWork : run.agent}
|
|
97
|
-
</Badge>
|
|
98
|
-
)}
|
|
99
76
|
<span className="sc-card-spacer" />
|
|
100
77
|
{task.assignee !== null && <Avatar person={task.assignee} size="sm" />}
|
|
101
78
|
</div>
|
|
@@ -156,7 +133,6 @@ export function Board(props: BoardProps) {
|
|
|
156
133
|
href={props.taskHref(task)}
|
|
157
134
|
onOpen={props.onOpen}
|
|
158
135
|
onMove={props.onMove}
|
|
159
|
-
run={runOn(task, props.runs)}
|
|
160
136
|
today={props.today}
|
|
161
137
|
/>
|
|
162
138
|
))}
|
|
@@ -289,7 +265,7 @@ export function BoardFilters(props: BoardFiltersProps) {
|
|
|
289
265
|
.filter((person) => person.id !== props.me?.id)
|
|
290
266
|
.map((person) => (
|
|
291
267
|
<option key={person.id} value={person.id}>
|
|
292
|
-
{person
|
|
268
|
+
{personName(person)}
|
|
293
269
|
</option>
|
|
294
270
|
))}
|
|
295
271
|
</select>
|
|
@@ -1,36 +1,95 @@
|
|
|
1
|
-
import { OPEN_TASK_STATES, type RestPerson, type RestTask } from "../api.js";
|
|
1
|
+
import { OPEN_TASK_STATES, type RestCategory, type RestPerson, type RestTask } from "../api.js";
|
|
2
2
|
import {
|
|
3
3
|
addMonths,
|
|
4
4
|
daysBetween,
|
|
5
5
|
firstDayOfWeek,
|
|
6
6
|
formatMonth,
|
|
7
7
|
formatWeekday,
|
|
8
|
+
type GridDay,
|
|
8
9
|
monthGrid,
|
|
9
10
|
monthOf,
|
|
10
11
|
today as todayOf,
|
|
11
12
|
weekdays,
|
|
12
13
|
} from "../dates.js";
|
|
13
14
|
import { useLanguage, useWords } from "../i18n/index.js";
|
|
14
|
-
import { cx, PersonChip } from "./ui.js";
|
|
15
|
+
import { cx, PersonChip, personName } from "./ui.js";
|
|
15
16
|
|
|
16
|
-
// The calendar of a project (ADR-0019): the month as a grid
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
17
|
+
// The calendar of a project (ADR-0019): the month as a grid. A task planned from a start to a
|
|
18
|
+
// due day lies across its days as a bar, in a lane of its own within each week so that the bars
|
|
19
|
+
// of one week never cross; a task with a due day alone sits on it as a dot, and one with a
|
|
20
|
+
// start alone is marked on that day. Who has what says whether the work is on its days. Takes
|
|
21
|
+
// its data as props and nothing from the network; the month shown is the address's (ADR-0010).
|
|
20
22
|
|
|
21
23
|
const isOpen = (task: RestTask): boolean => OPEN_TASK_STATES.includes(task.state);
|
|
22
24
|
|
|
25
|
+
/** Whether a task lies across days: a start and a due day, and the due day after the start. */
|
|
26
|
+
const spans = (task: RestTask): boolean =>
|
|
27
|
+
task.startsOn !== null && task.dueOn !== null && task.startsOn < task.dueOn;
|
|
28
|
+
|
|
23
29
|
export interface CalendarProps {
|
|
24
30
|
readonly tasks: readonly RestTask[];
|
|
25
31
|
/** The month shown: `2026-10`. */
|
|
26
32
|
readonly month: string;
|
|
27
33
|
/** The day to count from; the browser's today when left out. */
|
|
28
34
|
readonly today?: string | undefined;
|
|
35
|
+
/** The workspace's categories, for the legend to say what the colours mean. */
|
|
36
|
+
readonly categories?: readonly RestCategory[] | undefined;
|
|
29
37
|
readonly taskHref: (task: RestTask) => string;
|
|
30
38
|
/** Where the calendar is on another month. */
|
|
31
39
|
readonly monthHref: (month: string) => string;
|
|
32
40
|
}
|
|
33
41
|
|
|
42
|
+
/** One task's stretch across one week: which columns it covers, and in which lane it lies. */
|
|
43
|
+
export interface WeekSegment {
|
|
44
|
+
readonly task: RestTask;
|
|
45
|
+
/** The first and the last column (0 to 6) the task covers in this week. */
|
|
46
|
+
readonly from: number;
|
|
47
|
+
readonly to: number;
|
|
48
|
+
readonly lane: number;
|
|
49
|
+
/** Whether the task begins in this week, and whether it ends in it. */
|
|
50
|
+
readonly begins: boolean;
|
|
51
|
+
readonly ends: boolean;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The bars of one week: every task lying across days that touches the week, cut to it, each
|
|
56
|
+
* in the first lane where it crosses nothing that was laid before it. The longest and earliest
|
|
57
|
+
* go first, so that the lanes stay few.
|
|
58
|
+
*/
|
|
59
|
+
export function weekSegments(week: readonly GridDay[], tasks: readonly RestTask[]): WeekSegment[] {
|
|
60
|
+
const first = week[0]?.day;
|
|
61
|
+
const last = week[week.length - 1]?.day;
|
|
62
|
+
if (first === undefined || last === undefined) {
|
|
63
|
+
return [];
|
|
64
|
+
}
|
|
65
|
+
const cut = tasks
|
|
66
|
+
.filter(spans)
|
|
67
|
+
.flatMap((task) => {
|
|
68
|
+
const startsOn = task.startsOn ?? first;
|
|
69
|
+
const dueOn = task.dueOn ?? last;
|
|
70
|
+
if (dueOn < first || startsOn > last) {
|
|
71
|
+
return [];
|
|
72
|
+
}
|
|
73
|
+
const from = startsOn < first ? 0 : daysBetween(first, startsOn);
|
|
74
|
+
const to = dueOn > last ? week.length - 1 : daysBetween(first, dueOn);
|
|
75
|
+
return [{ task, from, to, begins: startsOn >= first, ends: dueOn <= last }];
|
|
76
|
+
})
|
|
77
|
+
.sort(
|
|
78
|
+
(a, b) => a.from - b.from || b.to - b.from - (a.to - a.from) || a.task.number - b.task.number,
|
|
79
|
+
);
|
|
80
|
+
const ends: number[] = [];
|
|
81
|
+
return cut.map((segment) => {
|
|
82
|
+
let lane = ends.findIndex((end) => end < segment.from);
|
|
83
|
+
if (lane === -1) {
|
|
84
|
+
lane = ends.length;
|
|
85
|
+
ends.push(segment.to);
|
|
86
|
+
} else {
|
|
87
|
+
ends[lane] = segment.to;
|
|
88
|
+
}
|
|
89
|
+
return { ...segment, lane };
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
34
93
|
function push(map: Map<string, RestTask[]>, day: string, task: RestTask): void {
|
|
35
94
|
const list = map.get(day);
|
|
36
95
|
if (list === undefined) {
|
|
@@ -49,10 +108,12 @@ export function Calendar(props: CalendarProps) {
|
|
|
49
108
|
const due = new Map<string, RestTask[]>();
|
|
50
109
|
const starting = new Map<string, RestTask[]>();
|
|
51
110
|
for (const task of props.tasks) {
|
|
111
|
+
if (spans(task)) {
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
52
114
|
if (task.dueOn !== null) {
|
|
53
115
|
push(due, task.dueOn, task);
|
|
54
|
-
}
|
|
55
|
-
if (task.startsOn !== null && task.startsOn !== task.dueOn) {
|
|
116
|
+
} else if (task.startsOn !== null) {
|
|
56
117
|
push(starting, task.startsOn, task);
|
|
57
118
|
}
|
|
58
119
|
}
|
|
@@ -61,9 +122,17 @@ export function Calendar(props: CalendarProps) {
|
|
|
61
122
|
list.sort((a, b) => Number(isOpen(b)) - Number(isOpen(a)) || a.number - b.number);
|
|
62
123
|
}
|
|
63
124
|
const inMonth = (day: string) => monthOf(day) === props.month;
|
|
125
|
+
const monthWeeks = weeks.map((week) => ({ week, segments: weekSegments(week, props.tasks) }));
|
|
64
126
|
const nothing =
|
|
65
127
|
[...due.keys()].every((day) => !inMonth(day)) &&
|
|
66
|
-
[...starting.keys()].every((day) => !inMonth(day))
|
|
128
|
+
[...starting.keys()].every((day) => !inMonth(day)) &&
|
|
129
|
+
monthWeeks.every(({ week, segments }) =>
|
|
130
|
+
segments.every(
|
|
131
|
+
(segment) => !week.slice(segment.from, segment.to + 1).some((cell) => cell.inMonth),
|
|
132
|
+
),
|
|
133
|
+
);
|
|
134
|
+
const tone = (task: RestTask, day: string) =>
|
|
135
|
+
cx(!isOpen(task) && "sc-calendar-over", isOpen(task) && day < now && "sc-calendar-late");
|
|
67
136
|
return (
|
|
68
137
|
<div className="sc-calendar">
|
|
69
138
|
<div className="sc-calendar-head">
|
|
@@ -102,54 +171,97 @@ export function Calendar(props: CalendarProps) {
|
|
|
102
171
|
</tr>
|
|
103
172
|
</thead>
|
|
104
173
|
<tbody>
|
|
105
|
-
{
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
<
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
className=
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
174
|
+
{monthWeeks.map(({ week, segments }) => {
|
|
175
|
+
const lanes = segments.reduce((most, segment) => Math.max(most, segment.lane + 1), 0);
|
|
176
|
+
return (
|
|
177
|
+
<tr key={week[0]?.day}>
|
|
178
|
+
{week.map((cell, column) => (
|
|
179
|
+
<td
|
|
180
|
+
key={cell.day}
|
|
181
|
+
className={cx(
|
|
182
|
+
"sc-calendar-cell",
|
|
183
|
+
!cell.inMonth && "sc-calendar-cell-outside",
|
|
184
|
+
cell.day === now && "sc-calendar-cell-today",
|
|
185
|
+
)}
|
|
186
|
+
>
|
|
187
|
+
<span className="sc-calendar-day">{Number(cell.day.slice(8))}</span>
|
|
188
|
+
{Array.from({ length: lanes }, (_, lane) => {
|
|
189
|
+
const segment = segments.find(
|
|
190
|
+
(candidate) =>
|
|
191
|
+
candidate.lane === lane &&
|
|
192
|
+
candidate.from <= column &&
|
|
193
|
+
column <= candidate.to,
|
|
194
|
+
);
|
|
195
|
+
if (segment === undefined) {
|
|
196
|
+
// The lane is someone else's on this day: the bars below keep their height.
|
|
197
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: an empty lane is nothing but its position, and the lanes of a week never reorder
|
|
198
|
+
return <span key={lane} className="sc-calendar-lane" aria-hidden="true" />;
|
|
199
|
+
}
|
|
200
|
+
const { task } = segment;
|
|
201
|
+
return (
|
|
202
|
+
<a
|
|
203
|
+
key={task.id}
|
|
204
|
+
className={cx(
|
|
205
|
+
"sc-calendar-bar",
|
|
206
|
+
`sc-category-${task.category?.color ?? "gray"}`,
|
|
207
|
+
segment.from === column && segment.begins && "sc-calendar-bar-begins",
|
|
208
|
+
segment.to === column && segment.ends && "sc-calendar-bar-ends",
|
|
209
|
+
tone(task, task.dueOn ?? cell.day),
|
|
210
|
+
)}
|
|
211
|
+
href={props.taskHref(task)}
|
|
212
|
+
title={`#${task.number} ${task.title}`}
|
|
213
|
+
>
|
|
214
|
+
{segment.from === column && (
|
|
215
|
+
<span className="sc-calendar-task-title">{task.title}</span>
|
|
216
|
+
)}
|
|
217
|
+
</a>
|
|
218
|
+
);
|
|
219
|
+
})}
|
|
220
|
+
{(due.get(cell.day) ?? []).map((task) => (
|
|
221
|
+
<a
|
|
222
|
+
key={task.id}
|
|
223
|
+
className={cx("sc-calendar-task", tone(task, cell.day))}
|
|
224
|
+
href={props.taskHref(task)}
|
|
225
|
+
title={`#${task.number} ${task.title}`}
|
|
226
|
+
>
|
|
227
|
+
<span
|
|
228
|
+
className={cx(
|
|
229
|
+
"sc-calendar-dot",
|
|
230
|
+
`sc-category-${task.category?.color ?? "gray"}`,
|
|
231
|
+
)}
|
|
232
|
+
/>
|
|
233
|
+
<span className="sc-calendar-task-title">{task.title}</span>
|
|
234
|
+
</a>
|
|
235
|
+
))}
|
|
236
|
+
{(starting.get(cell.day) ?? []).map((task) => (
|
|
237
|
+
<a
|
|
238
|
+
key={task.id}
|
|
239
|
+
className="sc-calendar-task sc-calendar-task-start"
|
|
240
|
+
href={props.taskHref(task)}
|
|
241
|
+
title={`#${task.number} ${task.title}: ${words.startsOnly}`}
|
|
242
|
+
>
|
|
243
|
+
<span className="sc-calendar-task-title">{task.title}</span>
|
|
244
|
+
</a>
|
|
245
|
+
))}
|
|
246
|
+
</td>
|
|
247
|
+
))}
|
|
248
|
+
</tr>
|
|
249
|
+
);
|
|
250
|
+
})}
|
|
151
251
|
</tbody>
|
|
152
252
|
</table>
|
|
253
|
+
{(props.categories ?? []).length > 0 && (
|
|
254
|
+
<dl className="sc-calendar-legend" aria-label={words.legend.title}>
|
|
255
|
+
{(props.categories ?? []).map((category) => (
|
|
256
|
+
<div key={category.key}>
|
|
257
|
+
<dt>
|
|
258
|
+
<span className={cx("sc-calendar-dot", `sc-category-${category.color}`)} />
|
|
259
|
+
</dt>
|
|
260
|
+
<dd>{category.name}</dd>
|
|
261
|
+
</div>
|
|
262
|
+
))}
|
|
263
|
+
</dl>
|
|
264
|
+
)}
|
|
153
265
|
{nothing && <p className="sc-muted">{words.nothingDue}</p>}
|
|
154
266
|
</div>
|
|
155
267
|
);
|
|
@@ -201,7 +313,7 @@ export function workloadOf(tasks: readonly RestTask[], today: string): WorkloadR
|
|
|
201
313
|
if (b.person === undefined) {
|
|
202
314
|
return -1;
|
|
203
315
|
}
|
|
204
|
-
return a.person.
|
|
316
|
+
return personName(a.person).localeCompare(personName(b.person));
|
|
205
317
|
});
|
|
206
318
|
}
|
|
207
319
|
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
} from "../api.js";
|
|
10
10
|
import { useWords } from "../i18n/index.js";
|
|
11
11
|
import { Markdown } from "./markdown.js";
|
|
12
|
+
import { ShowMore, usePaged } from "./paged.js";
|
|
12
13
|
import { Button, cx, PersonChip, Time } from "./ui.js";
|
|
13
14
|
|
|
14
15
|
// The decisions: the ones that wait, each a card with the way to answer it, since they are what
|
|
@@ -254,40 +255,44 @@ export interface DecisionLogProps {
|
|
|
254
255
|
/** The record: one row per decision answered, what was asked, what was chosen, by whom, when. */
|
|
255
256
|
export function DecisionLog(props: DecisionLogProps) {
|
|
256
257
|
const words = useWords();
|
|
258
|
+
const { shown, rest, more } = usePaged(props.decisions);
|
|
257
259
|
return (
|
|
258
|
-
|
|
259
|
-
{
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
<
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
260
|
+
<>
|
|
261
|
+
<ol className="sc-decision-log" aria-label={words.decisions.record}>
|
|
262
|
+
{shown.map((decision) => {
|
|
263
|
+
const task = decision.taskId === null ? undefined : props.tasks?.get(decision.taskId);
|
|
264
|
+
const href = props.decisionHref?.(decision);
|
|
265
|
+
const chosen =
|
|
266
|
+
decision.options.find((option) => option.id === decision.answer?.option)?.label ??
|
|
267
|
+
decision.answer?.option ??
|
|
268
|
+
"";
|
|
269
|
+
return (
|
|
270
|
+
<li key={decision.id} className="sc-decision-row">
|
|
271
|
+
<span className="sc-decision-row-question">
|
|
272
|
+
{href === undefined ? decision.question : <a href={href}>{decision.question}</a>}
|
|
273
|
+
</span>
|
|
274
|
+
<span className="sc-decision-row-answer">{chosen}</span>
|
|
275
|
+
<span className="sc-decision-row-meta">
|
|
276
|
+
{decision.answer !== null && (
|
|
277
|
+
<>
|
|
278
|
+
<PersonChip person={decision.answer.by} /> <Time iso={decision.answer.at} />
|
|
279
|
+
</>
|
|
280
|
+
)}
|
|
281
|
+
{task !== undefined &&
|
|
282
|
+
(props.taskHref === undefined ? (
|
|
283
|
+
<span className="sc-card-number">#{task.number}</span>
|
|
284
|
+
) : (
|
|
285
|
+
<a className="sc-card-number" href={props.taskHref(task)}>
|
|
286
|
+
#{task.number}
|
|
287
|
+
</a>
|
|
288
|
+
))}
|
|
289
|
+
</span>
|
|
290
|
+
</li>
|
|
291
|
+
);
|
|
292
|
+
})}
|
|
293
|
+
</ol>
|
|
294
|
+
<ShowMore rest={rest} onMore={more} />
|
|
295
|
+
</>
|
|
291
296
|
);
|
|
292
297
|
}
|
|
293
298
|
|
|
@@ -2,6 +2,7 @@ import type { ReactNode } from "react";
|
|
|
2
2
|
import type { RestEvent } from "../api.js";
|
|
3
3
|
import { en, type Messages } from "../i18n/en.js";
|
|
4
4
|
import { useWords } from "../i18n/index.js";
|
|
5
|
+
import { ShowMore, usePaged } from "./paged.js";
|
|
5
6
|
import { PersonChip, Time } from "./ui.js";
|
|
6
7
|
|
|
7
8
|
// The feed: one line per event, newest first, as a sentence a person reads at a glance: who,
|
|
@@ -79,6 +80,8 @@ export function describeEvent(event: RestEvent, words: Messages = en): string {
|
|
|
79
80
|
return feed.event.runReported(task, data.excerpt);
|
|
80
81
|
case "run.handed_in":
|
|
81
82
|
return feed.event.runHandedIn(data.label ?? feed.something, task);
|
|
83
|
+
case "run.taken_over":
|
|
84
|
+
return feed.event.runTakenOver(task, data.agent ?? feed.someone);
|
|
82
85
|
case "run.ended":
|
|
83
86
|
return data.status === "finished"
|
|
84
87
|
? feed.event.runFinished(task)
|
|
@@ -106,36 +109,40 @@ export interface EventFeedProps {
|
|
|
106
109
|
|
|
107
110
|
export function EventFeed(props: EventFeedProps) {
|
|
108
111
|
const words = useWords();
|
|
112
|
+
const newestFirst = [...props.events].reverse();
|
|
113
|
+
const { shown, rest, more } = usePaged(newestFirst);
|
|
109
114
|
if (props.events.length === 0) {
|
|
110
115
|
return <div className="sc-feed">{props.empty ?? null}</div>;
|
|
111
116
|
}
|
|
112
|
-
const newestFirst = [...props.events].reverse();
|
|
113
117
|
return (
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
<
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
{words.feed.
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
118
|
+
<>
|
|
119
|
+
<ol className="sc-feed" aria-label={words.feed.list}>
|
|
120
|
+
{shown.map((event) => {
|
|
121
|
+
const href = props.href?.(event);
|
|
122
|
+
const said = describeEvent(event, words);
|
|
123
|
+
return (
|
|
124
|
+
<li key={event.id} className="sc-feed-item">
|
|
125
|
+
<span className="sc-feed-actor">
|
|
126
|
+
{event.actor === null ? (
|
|
127
|
+
<span className="sc-person-login">{words.feed.console}</span>
|
|
128
|
+
) : (
|
|
129
|
+
<PersonChip person={event.actor} />
|
|
130
|
+
)}
|
|
131
|
+
{event.agent !== null && (
|
|
132
|
+
<span className="sc-feed-agent" title={words.feed.asTitle}>
|
|
133
|
+
{words.feed.as(event.agent)}
|
|
134
|
+
</span>
|
|
135
|
+
)}
|
|
136
|
+
</span>
|
|
137
|
+
<span className="sc-feed-words">
|
|
138
|
+
{href === undefined ? said : <a href={href}>{said}</a>}
|
|
139
|
+
</span>
|
|
140
|
+
<Time iso={event.createdAt} />
|
|
141
|
+
</li>
|
|
142
|
+
);
|
|
143
|
+
})}
|
|
144
|
+
</ol>
|
|
145
|
+
<ShowMore rest={rest} onMore={more} />
|
|
146
|
+
</>
|
|
140
147
|
);
|
|
141
148
|
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import { useWords } from "../i18n/index.js";
|
|
3
|
+
import { Button } from "./ui.js";
|
|
4
|
+
|
|
5
|
+
// A long list shown a page at a time, from what the page already holds: the record of the
|
|
6
|
+
// decisions, the feed, the tasks with no day. The next page is a click away and nothing is
|
|
7
|
+
// asked of the server; the API's own pages (`limit`, `offset`) are for the command and scripts.
|
|
8
|
+
|
|
9
|
+
/** How many items a list shows before it asks to be told to show more. */
|
|
10
|
+
export const PAGE_SIZE = 50;
|
|
11
|
+
|
|
12
|
+
/** The first `shown` items of a list, and the way to show the next page of it. */
|
|
13
|
+
export function usePaged<T>(
|
|
14
|
+
items: readonly T[],
|
|
15
|
+
pageSize = PAGE_SIZE,
|
|
16
|
+
): { readonly shown: readonly T[]; readonly rest: number; readonly more: () => void } {
|
|
17
|
+
const [pages, setPages] = useState(1);
|
|
18
|
+
const shown = items.slice(0, pages * pageSize);
|
|
19
|
+
return {
|
|
20
|
+
shown,
|
|
21
|
+
rest: items.length - shown.length,
|
|
22
|
+
more: () => setPages((current) => current + 1),
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The button under a paged list, saying how many more there are; nothing when there are none. */
|
|
27
|
+
export function ShowMore(props: { readonly rest: number; readonly onMore: () => void }) {
|
|
28
|
+
const words = useWords();
|
|
29
|
+
if (props.rest <= 0) {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
return (
|
|
33
|
+
<div className="sc-show-more">
|
|
34
|
+
<Button variant="secondary" size="sm" onClick={props.onMore}>
|
|
35
|
+
{words.common.showMore(Math.min(props.rest, PAGE_SIZE), props.rest)}
|
|
36
|
+
</Button>
|
|
37
|
+
</div>
|
|
38
|
+
);
|
|
39
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { PersonRole, RestPerson } from "../api.js";
|
|
2
2
|
import { useWords } from "../i18n/index.js";
|
|
3
3
|
import { PERSON_ROLES } from "../vocabulary.js";
|
|
4
|
-
import { PersonChip, RoleBadge } from "./ui.js";
|
|
4
|
+
import { PersonChip, personName, RoleBadge } from "./ui.js";
|
|
5
5
|
|
|
6
6
|
// The people of the board, with what each may do. An administrator changes roles here; a
|
|
7
7
|
// member reads. Takes its data as props and nothing from the network.
|
|
@@ -24,15 +24,15 @@ export function PeopleList(props: PeopleListProps) {
|
|
|
24
24
|
{props.people.map((person) => (
|
|
25
25
|
<li key={person.id} className="sc-person-row">
|
|
26
26
|
<PersonChip person={person} size="md" />
|
|
27
|
-
<span className="sc-person-name">
|
|
28
|
-
{person.name
|
|
29
|
-
{props.me?.id === person.id && <span
|
|
27
|
+
<span className="sc-person-name sc-muted">
|
|
28
|
+
{person.name === null ? "" : person.login}
|
|
29
|
+
{props.me?.id === person.id && <span> {words.common.you}</span>}
|
|
30
30
|
</span>
|
|
31
31
|
{props.onChangeRole === undefined ? (
|
|
32
32
|
<RoleBadge role={person.role} />
|
|
33
33
|
) : (
|
|
34
34
|
<label className="sc-field sc-field-inline">
|
|
35
|
-
<span className="sc-visually-hidden">{words.people.roleOf(person
|
|
35
|
+
<span className="sc-visually-hidden">{words.people.roleOf(personName(person))}</span>
|
|
36
36
|
<select
|
|
37
37
|
className="sc-input"
|
|
38
38
|
value={person.role}
|