@skillcdn/console 0.1.9 → 0.1.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -6
- 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 +303 -18
- package/dist/cli/cli.js.map +1 -1
- package/dist/cli/format.d.ts +11 -1
- package/dist/cli/format.d.ts.map +1 -1
- package/dist/cli/format.js +87 -6
- package/dist/cli/format.js.map +1 -1
- package/dist/cli/help.d.ts.map +1 -1
- package/dist/cli/help.js +70 -14
- package/dist/cli/help.js.map +1 -1
- package/dist/client.d.ts +44 -8
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +32 -4
- package/dist/client.js.map +1 -1
- package/dist/components/board.d.ts +24 -1
- package/dist/components/board.d.ts.map +1 -1
- package/dist/components/board.js +66 -5
- package/dist/components/board.js.map +1 -1
- package/dist/components/calendar.d.ts +54 -0
- package/dist/components/calendar.d.ts.map +1 -0
- package/dist/components/calendar.js +156 -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 +16 -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 +23 -7
- 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/paged.d.ts +14 -0
- package/dist/components/paged.d.ts.map +1 -0
- package/dist/components/paged.js +28 -0
- package/dist/components/paged.js.map +1 -0
- package/dist/components/projects.d.ts +22 -2
- package/dist/components/projects.d.ts.map +1 -1
- package/dist/components/projects.js +35 -7
- package/dist/components/projects.js.map +1 -1
- package/dist/components/runs.d.ts.map +1 -1
- package/dist/components/runs.js +2 -2
- package/dist/components/runs.js.map +1 -1
- package/dist/components/shell.d.ts +2 -1
- package/dist/components/shell.d.ts.map +1 -1
- package/dist/components/shell.js +1 -1
- package/dist/components/shell.js.map +1 -1
- package/dist/components/task-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 +16 -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 +33 -0
- package/dist/components/work.js.map +1 -0
- package/dist/console.css +658 -0
- package/dist/console.d.ts +8 -0
- package/dist/console.d.ts.map +1 -1
- package/dist/console.js +145 -13
- package/dist/console.js.map +1 -1
- package/dist/data.d.ts +19 -3
- package/dist/data.d.ts.map +1 -1
- package/dist/data.js +51 -4
- 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 +172 -5
- package/dist/i18n/en.d.ts.map +1 -1
- package/dist/i18n/en.js +183 -6
- package/dist/i18n/en.js.map +1 -1
- package/dist/i18n/ko.d.ts.map +1 -1
- package/dist/i18n/ko.js +178 -6
- package/dist/i18n/ko.js.map +1 -1
- package/dist/index.d.ts +12 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +12 -5
- package/dist/index.js.map +1 -1
- package/dist/limits.d.ts +11 -0
- package/dist/limits.d.ts.map +1 -1
- package/dist/limits.js +11 -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 +51 -1
- package/dist/router.d.ts.map +1 -1
- package/dist/router.js +80 -1
- package/dist/router.js.map +1 -1
- package/dist/routes.d.ts +14 -1
- package/dist/routes.d.ts.map +1 -1
- package/dist/routes.js +17 -0
- package/dist/routes.js.map +1 -1
- package/dist/schemas.d.ts +489 -3
- package/dist/schemas.d.ts.map +1 -1
- package/dist/schemas.js +150 -9
- package/dist/schemas.js.map +1 -1
- package/dist/vocabulary.d.ts +21 -1
- package/dist/vocabulary.d.ts.map +1 -1
- package/dist/vocabulary.js +31 -0
- package/dist/vocabulary.js.map +1 -1
- package/docs/specs/cli.md +17 -8
- package/docs/specs/rest.md +48 -17
- package/package.json +1 -1
- package/skills/working-the-board/SKILL.md +7 -6
- package/src/api.ts +42 -0
- package/src/cli/cli.ts +362 -18
- package/src/cli/format.ts +96 -6
- package/src/cli/help.ts +77 -15
- package/src/client.ts +98 -7
- package/src/components/board.tsx +173 -13
- package/src/components/calendar.tsx +367 -0
- package/src/components/categories.tsx +209 -0
- package/src/components/decision-list.tsx +67 -5
- package/src/components/event-feed.tsx +46 -27
- package/src/components/goals.tsx +264 -0
- package/src/components/paged.tsx +39 -0
- package/src/components/projects.tsx +128 -10
- package/src/components/runs.tsx +7 -1
- package/src/components/shell.tsx +5 -2
- package/src/components/task-form.tsx +78 -1
- package/src/components/task-list.tsx +64 -0
- package/src/components/task-view.tsx +75 -4
- package/src/components/ui.tsx +116 -1
- package/src/components/work.tsx +93 -0
- package/src/console.tsx +376 -9
- package/src/data.ts +83 -12
- package/src/dates.ts +143 -0
- package/src/i18n/en.ts +193 -6
- package/src/i18n/ko.ts +184 -6
- package/src/index.ts +62 -1
- package/src/limits.ts +11 -0
- package/src/marks.ts +40 -0
- package/src/router.ts +131 -6
- package/src/routes.ts +19 -0
- package/src/schemas.ts +187 -7
- package/src/styles/console.css +658 -0
- package/src/vocabulary.ts +43 -0
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
import { OPEN_TASK_STATES, type RestCategory, type RestPerson, type RestTask } from "../api.js";
|
|
2
|
+
import {
|
|
3
|
+
addMonths,
|
|
4
|
+
daysBetween,
|
|
5
|
+
firstDayOfWeek,
|
|
6
|
+
formatMonth,
|
|
7
|
+
formatWeekday,
|
|
8
|
+
type GridDay,
|
|
9
|
+
monthGrid,
|
|
10
|
+
monthOf,
|
|
11
|
+
today as todayOf,
|
|
12
|
+
weekdays,
|
|
13
|
+
} from "../dates.js";
|
|
14
|
+
import { useLanguage, useWords } from "../i18n/index.js";
|
|
15
|
+
import { cx, PersonChip } from "./ui.js";
|
|
16
|
+
|
|
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).
|
|
22
|
+
|
|
23
|
+
const isOpen = (task: RestTask): boolean => OPEN_TASK_STATES.includes(task.state);
|
|
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
|
+
|
|
29
|
+
export interface CalendarProps {
|
|
30
|
+
readonly tasks: readonly RestTask[];
|
|
31
|
+
/** The month shown: `2026-10`. */
|
|
32
|
+
readonly month: string;
|
|
33
|
+
/** The day to count from; the browser's today when left out. */
|
|
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;
|
|
37
|
+
readonly taskHref: (task: RestTask) => string;
|
|
38
|
+
/** Where the calendar is on another month. */
|
|
39
|
+
readonly monthHref: (month: string) => string;
|
|
40
|
+
}
|
|
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
|
+
|
|
93
|
+
function push(map: Map<string, RestTask[]>, day: string, task: RestTask): void {
|
|
94
|
+
const list = map.get(day);
|
|
95
|
+
if (list === undefined) {
|
|
96
|
+
map.set(day, [task]);
|
|
97
|
+
} else {
|
|
98
|
+
list.push(task);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function Calendar(props: CalendarProps) {
|
|
103
|
+
const words = useWords().calendar;
|
|
104
|
+
const { tag } = useLanguage();
|
|
105
|
+
const now = props.today ?? todayOf();
|
|
106
|
+
const firstDay = firstDayOfWeek(tag);
|
|
107
|
+
const weeks = monthGrid(props.month, firstDay);
|
|
108
|
+
const due = new Map<string, RestTask[]>();
|
|
109
|
+
const starting = new Map<string, RestTask[]>();
|
|
110
|
+
for (const task of props.tasks) {
|
|
111
|
+
if (spans(task)) {
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
if (task.dueOn !== null) {
|
|
115
|
+
push(due, task.dueOn, task);
|
|
116
|
+
} else if (task.startsOn !== null) {
|
|
117
|
+
push(starting, task.startsOn, task);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
// Within a day, what is still open comes first; the rest reads as what was.
|
|
121
|
+
for (const list of due.values()) {
|
|
122
|
+
list.sort((a, b) => Number(isOpen(b)) - Number(isOpen(a)) || a.number - b.number);
|
|
123
|
+
}
|
|
124
|
+
const inMonth = (day: string) => monthOf(day) === props.month;
|
|
125
|
+
const monthWeeks = weeks.map((week) => ({ week, segments: weekSegments(week, props.tasks) }));
|
|
126
|
+
const nothing =
|
|
127
|
+
[...due.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");
|
|
136
|
+
return (
|
|
137
|
+
<div className="sc-calendar">
|
|
138
|
+
<div className="sc-calendar-head">
|
|
139
|
+
<h2 className="sc-calendar-month">{formatMonth(props.month, tag)}</h2>
|
|
140
|
+
<nav className="sc-calendar-nav" aria-label={words.grid}>
|
|
141
|
+
<a
|
|
142
|
+
className="sc-button sc-button-ghost sc-button-sm"
|
|
143
|
+
href={props.monthHref(addMonths(props.month, -1))}
|
|
144
|
+
aria-label={words.previous}
|
|
145
|
+
>
|
|
146
|
+
{"<"}
|
|
147
|
+
</a>
|
|
148
|
+
<a
|
|
149
|
+
className="sc-button sc-button-ghost sc-button-sm"
|
|
150
|
+
href={props.monthHref(monthOf(now))}
|
|
151
|
+
>
|
|
152
|
+
{words.thisMonth}
|
|
153
|
+
</a>
|
|
154
|
+
<a
|
|
155
|
+
className="sc-button sc-button-ghost sc-button-sm"
|
|
156
|
+
href={props.monthHref(addMonths(props.month, 1))}
|
|
157
|
+
aria-label={words.next}
|
|
158
|
+
>
|
|
159
|
+
{">"}
|
|
160
|
+
</a>
|
|
161
|
+
</nav>
|
|
162
|
+
</div>
|
|
163
|
+
<table className="sc-calendar-grid" aria-label={words.grid}>
|
|
164
|
+
<thead>
|
|
165
|
+
<tr>
|
|
166
|
+
{weekdays(firstDay).map((weekday) => (
|
|
167
|
+
<th key={weekday} scope="col">
|
|
168
|
+
{formatWeekday(weekday, tag)}
|
|
169
|
+
</th>
|
|
170
|
+
))}
|
|
171
|
+
</tr>
|
|
172
|
+
</thead>
|
|
173
|
+
<tbody>
|
|
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
|
+
})}
|
|
251
|
+
</tbody>
|
|
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
|
+
)}
|
|
265
|
+
{nothing && <p className="sc-muted">{words.nothingDue}</p>}
|
|
266
|
+
</div>
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** One person's share of the open work, as the table of who has what says it. */
|
|
271
|
+
export interface WorkloadRow {
|
|
272
|
+
/** The person, or nobody for the tasks assigned to no one. */
|
|
273
|
+
readonly person: RestPerson | undefined;
|
|
274
|
+
readonly open: number;
|
|
275
|
+
readonly inProgress: number;
|
|
276
|
+
/** Open, and due within the next seven days from today. */
|
|
277
|
+
readonly dueSoon: number;
|
|
278
|
+
/** Open, and past its day. */
|
|
279
|
+
readonly overdue: number;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** Who has what, from the open tasks: by login, with the unassigned last. */
|
|
283
|
+
export function workloadOf(tasks: readonly RestTask[], today: string): WorkloadRow[] {
|
|
284
|
+
const rows = new Map<string, { person: RestPerson | undefined; tasks: RestTask[] }>();
|
|
285
|
+
for (const task of tasks.filter(isOpen)) {
|
|
286
|
+
const key = task.assignee?.id ?? "";
|
|
287
|
+
const row = rows.get(key);
|
|
288
|
+
if (row === undefined) {
|
|
289
|
+
rows.set(key, { person: task.assignee ?? undefined, tasks: [task] });
|
|
290
|
+
} else {
|
|
291
|
+
row.tasks.push(task);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
const count = (list: readonly RestTask[]): Omit<WorkloadRow, "person"> => ({
|
|
295
|
+
open: list.length,
|
|
296
|
+
inProgress: list.filter((task) => task.state === "in_progress" || task.state === "in_review")
|
|
297
|
+
.length,
|
|
298
|
+
dueSoon: list.filter((task) => {
|
|
299
|
+
if (task.dueOn === null) {
|
|
300
|
+
return false;
|
|
301
|
+
}
|
|
302
|
+
const days = daysBetween(today, task.dueOn);
|
|
303
|
+
return days >= 0 && days <= 7;
|
|
304
|
+
}).length,
|
|
305
|
+
overdue: list.filter((task) => task.dueOn !== null && task.dueOn < today).length,
|
|
306
|
+
});
|
|
307
|
+
return [...rows.values()]
|
|
308
|
+
.map((row) => ({ person: row.person, ...count(row.tasks) }))
|
|
309
|
+
.sort((a, b) => {
|
|
310
|
+
if (a.person === undefined) {
|
|
311
|
+
return 1;
|
|
312
|
+
}
|
|
313
|
+
if (b.person === undefined) {
|
|
314
|
+
return -1;
|
|
315
|
+
}
|
|
316
|
+
return a.person.login.localeCompare(b.person.login);
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
export interface WorkloadProps {
|
|
321
|
+
readonly tasks: readonly RestTask[];
|
|
322
|
+
readonly today?: string | undefined;
|
|
323
|
+
/** Where the board is, narrowed to one person's tasks, or to the unassigned. */
|
|
324
|
+
readonly personHref?: ((person: RestPerson | undefined) => string) | undefined;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** Who has what: a row per person, and what of theirs is late or due soon. */
|
|
328
|
+
export function Workload(props: WorkloadProps) {
|
|
329
|
+
const words = useWords().calendar.workload;
|
|
330
|
+
const rows = workloadOf(props.tasks, props.today ?? todayOf());
|
|
331
|
+
if (rows.length === 0) {
|
|
332
|
+
return null;
|
|
333
|
+
}
|
|
334
|
+
return (
|
|
335
|
+
<table className="sc-workload" aria-label={words.title}>
|
|
336
|
+
<thead>
|
|
337
|
+
<tr>
|
|
338
|
+
<th scope="col">{words.person}</th>
|
|
339
|
+
<th scope="col">{words.open}</th>
|
|
340
|
+
<th scope="col">{words.inProgress}</th>
|
|
341
|
+
<th scope="col">{words.dueSoon}</th>
|
|
342
|
+
<th scope="col">{words.overdue}</th>
|
|
343
|
+
</tr>
|
|
344
|
+
</thead>
|
|
345
|
+
<tbody>
|
|
346
|
+
{rows.map((row) => {
|
|
347
|
+
const href = props.personHref?.(row.person);
|
|
348
|
+
const who =
|
|
349
|
+
row.person === undefined ? (
|
|
350
|
+
<span className="sc-muted">{words.unassigned}</span>
|
|
351
|
+
) : (
|
|
352
|
+
<PersonChip person={row.person} />
|
|
353
|
+
);
|
|
354
|
+
return (
|
|
355
|
+
<tr key={row.person?.id ?? ""}>
|
|
356
|
+
<th scope="row">{href === undefined ? who : <a href={href}>{who}</a>}</th>
|
|
357
|
+
<td>{row.open}</td>
|
|
358
|
+
<td>{row.inProgress}</td>
|
|
359
|
+
<td className={cx(row.dueSoon > 0 && "sc-workload-soon")}>{row.dueSoon}</td>
|
|
360
|
+
<td className={cx(row.overdue > 0 && "sc-workload-late")}>{row.overdue}</td>
|
|
361
|
+
</tr>
|
|
362
|
+
);
|
|
363
|
+
})}
|
|
364
|
+
</tbody>
|
|
365
|
+
</table>
|
|
366
|
+
);
|
|
367
|
+
}
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import { type FormEvent, type ReactNode, useState } from "react";
|
|
2
|
+
import {
|
|
3
|
+
CATEGORY_COLORS,
|
|
4
|
+
type CategoryColor,
|
|
5
|
+
MAX_CATEGORY_KEY_LENGTH,
|
|
6
|
+
MAX_CATEGORY_NAME_LENGTH,
|
|
7
|
+
type RestCategory,
|
|
8
|
+
type RestCategoryInput,
|
|
9
|
+
type RestCategoryPatch,
|
|
10
|
+
} from "../api.js";
|
|
11
|
+
import { useWords } from "../i18n/index.js";
|
|
12
|
+
import { keyOf } from "./projects.js";
|
|
13
|
+
import { Button, CategoryBadge, cx } from "./ui.js";
|
|
14
|
+
|
|
15
|
+
// The categories tasks are sorted into (ADR-0019), as the workspace's page lists them: each in
|
|
16
|
+
// its colour, with the ways for an administrator to rename, recolour, add and remove one. Takes
|
|
17
|
+
// its data as props and nothing from the network.
|
|
18
|
+
|
|
19
|
+
export interface CategoryListProps {
|
|
20
|
+
readonly categories: readonly RestCategory[];
|
|
21
|
+
readonly busy?: boolean | undefined;
|
|
22
|
+
/** Called with a category to add. Left out, the list is read here, not changed. */
|
|
23
|
+
readonly onAdd?: ((input: RestCategoryInput) => void) | undefined;
|
|
24
|
+
readonly onChange?: ((category: RestCategory, patch: RestCategoryPatch) => void) | undefined;
|
|
25
|
+
readonly onRemove?: ((category: RestCategory) => void) | undefined;
|
|
26
|
+
readonly empty?: ReactNode;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** One category, with its name and its colour open to change where an administrator is looking. */
|
|
30
|
+
function CategoryRow(props: {
|
|
31
|
+
readonly category: RestCategory;
|
|
32
|
+
readonly busy: boolean | undefined;
|
|
33
|
+
readonly onChange: ((category: RestCategory, patch: RestCategoryPatch) => void) | undefined;
|
|
34
|
+
readonly onRemove: ((category: RestCategory) => void) | undefined;
|
|
35
|
+
}) {
|
|
36
|
+
const words = useWords();
|
|
37
|
+
const { category } = props;
|
|
38
|
+
const [name, setName] = useState(category.name);
|
|
39
|
+
const [color, setColor] = useState<CategoryColor>(category.color);
|
|
40
|
+
const changed = name.trim() !== category.name || color !== category.color;
|
|
41
|
+
if (props.onChange === undefined) {
|
|
42
|
+
return (
|
|
43
|
+
<li className="sc-category-row">
|
|
44
|
+
<CategoryBadge category={category} />
|
|
45
|
+
<code className="sc-project-key">{category.key}</code>
|
|
46
|
+
</li>
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
return (
|
|
50
|
+
<li className="sc-category-row">
|
|
51
|
+
<form
|
|
52
|
+
className="sc-category-form"
|
|
53
|
+
onSubmit={(event) => {
|
|
54
|
+
event.preventDefault();
|
|
55
|
+
if (changed && name.trim() !== "") {
|
|
56
|
+
props.onChange?.(category, { name: name.trim(), color });
|
|
57
|
+
}
|
|
58
|
+
}}
|
|
59
|
+
>
|
|
60
|
+
<span className={cx("sc-category-swatch", `sc-category-${color}`)} aria-hidden="true" />
|
|
61
|
+
<label className="sc-field sc-field-inline">
|
|
62
|
+
<span className="sc-visually-hidden">{words.categories.nameOf(category.key)}</span>
|
|
63
|
+
<input
|
|
64
|
+
className="sc-input"
|
|
65
|
+
value={name}
|
|
66
|
+
maxLength={MAX_CATEGORY_NAME_LENGTH}
|
|
67
|
+
required
|
|
68
|
+
onChange={(event) => setName(event.target.value)}
|
|
69
|
+
/>
|
|
70
|
+
</label>
|
|
71
|
+
<code className="sc-project-key">{category.key}</code>
|
|
72
|
+
<label className="sc-field sc-field-inline">
|
|
73
|
+
<span className="sc-visually-hidden">{words.categories.colorOf(category.key)}</span>
|
|
74
|
+
<select
|
|
75
|
+
className="sc-input"
|
|
76
|
+
value={color}
|
|
77
|
+
onChange={(event) => setColor(event.target.value as CategoryColor)}
|
|
78
|
+
>
|
|
79
|
+
{CATEGORY_COLORS.map((candidate) => (
|
|
80
|
+
<option key={candidate} value={candidate}>
|
|
81
|
+
{words.vocabulary.categoryColor[candidate]}
|
|
82
|
+
</option>
|
|
83
|
+
))}
|
|
84
|
+
</select>
|
|
85
|
+
</label>
|
|
86
|
+
<Button
|
|
87
|
+
type="submit"
|
|
88
|
+
variant="primary"
|
|
89
|
+
size="sm"
|
|
90
|
+
disabled={props.busy === true || !changed || name.trim() === ""}
|
|
91
|
+
>
|
|
92
|
+
{words.common.save}
|
|
93
|
+
</Button>
|
|
94
|
+
{props.onRemove !== undefined && (
|
|
95
|
+
<Button
|
|
96
|
+
variant="danger"
|
|
97
|
+
size="sm"
|
|
98
|
+
disabled={props.busy === true}
|
|
99
|
+
onClick={() => props.onRemove?.(category)}
|
|
100
|
+
>
|
|
101
|
+
{words.common.remove}
|
|
102
|
+
</Button>
|
|
103
|
+
)}
|
|
104
|
+
</form>
|
|
105
|
+
</li>
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function CategoryList(props: CategoryListProps) {
|
|
110
|
+
const words = useWords();
|
|
111
|
+
const form = words.categories.form;
|
|
112
|
+
const [name, setName] = useState("");
|
|
113
|
+
const [key, setKey] = useState("");
|
|
114
|
+
const [keyTouched, setKeyTouched] = useState(false);
|
|
115
|
+
const [color, setColor] = useState<CategoryColor>("gray");
|
|
116
|
+
const add = (event: FormEvent) => {
|
|
117
|
+
event.preventDefault();
|
|
118
|
+
if (name.trim() === "" || key.trim() === "") {
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
props.onAdd?.({ key: key.trim(), name: name.trim(), color });
|
|
122
|
+
setName("");
|
|
123
|
+
setKey("");
|
|
124
|
+
setKeyTouched(false);
|
|
125
|
+
setColor("gray");
|
|
126
|
+
};
|
|
127
|
+
return (
|
|
128
|
+
<div className="sc-categories">
|
|
129
|
+
{props.categories.length === 0 ? (
|
|
130
|
+
(props.empty ?? null)
|
|
131
|
+
) : (
|
|
132
|
+
<ul className="sc-category-list" aria-label={words.categories.list}>
|
|
133
|
+
{props.categories.map((category) => (
|
|
134
|
+
<CategoryRow
|
|
135
|
+
key={category.key}
|
|
136
|
+
category={category}
|
|
137
|
+
busy={props.busy}
|
|
138
|
+
onChange={props.onChange}
|
|
139
|
+
onRemove={props.onRemove}
|
|
140
|
+
/>
|
|
141
|
+
))}
|
|
142
|
+
</ul>
|
|
143
|
+
)}
|
|
144
|
+
{props.onAdd !== undefined && (
|
|
145
|
+
<form
|
|
146
|
+
className="sc-form sc-member-form"
|
|
147
|
+
aria-label={words.categories.addAria}
|
|
148
|
+
onSubmit={add}
|
|
149
|
+
>
|
|
150
|
+
<div className="sc-field-row">
|
|
151
|
+
<label className="sc-field">
|
|
152
|
+
<span className="sc-field-label">{form.name}</span>
|
|
153
|
+
<input
|
|
154
|
+
className="sc-input"
|
|
155
|
+
value={name}
|
|
156
|
+
maxLength={MAX_CATEGORY_NAME_LENGTH}
|
|
157
|
+
required
|
|
158
|
+
onChange={(event) => {
|
|
159
|
+
setName(event.target.value);
|
|
160
|
+
if (!keyTouched) {
|
|
161
|
+
setKey(keyOf(event.target.value).slice(0, MAX_CATEGORY_KEY_LENGTH));
|
|
162
|
+
}
|
|
163
|
+
}}
|
|
164
|
+
/>
|
|
165
|
+
</label>
|
|
166
|
+
<label className="sc-field">
|
|
167
|
+
<span className="sc-field-label">{form.key}</span>
|
|
168
|
+
<input
|
|
169
|
+
className="sc-input"
|
|
170
|
+
value={key}
|
|
171
|
+
maxLength={MAX_CATEGORY_KEY_LENGTH}
|
|
172
|
+
pattern="[a-z0-9]([a-z0-9-]*[a-z0-9])?"
|
|
173
|
+
required
|
|
174
|
+
title={form.keyHint}
|
|
175
|
+
onChange={(event) => {
|
|
176
|
+
setKeyTouched(true);
|
|
177
|
+
setKey(event.target.value);
|
|
178
|
+
}}
|
|
179
|
+
/>
|
|
180
|
+
</label>
|
|
181
|
+
<label className="sc-field">
|
|
182
|
+
<span className="sc-field-label">{form.color}</span>
|
|
183
|
+
<select
|
|
184
|
+
className="sc-input"
|
|
185
|
+
value={color}
|
|
186
|
+
onChange={(event) => setColor(event.target.value as CategoryColor)}
|
|
187
|
+
>
|
|
188
|
+
{CATEGORY_COLORS.map((candidate) => (
|
|
189
|
+
<option key={candidate} value={candidate}>
|
|
190
|
+
{words.vocabulary.categoryColor[candidate]}
|
|
191
|
+
</option>
|
|
192
|
+
))}
|
|
193
|
+
</select>
|
|
194
|
+
</label>
|
|
195
|
+
<div className="sc-form-actions">
|
|
196
|
+
<Button
|
|
197
|
+
type="submit"
|
|
198
|
+
variant="primary"
|
|
199
|
+
disabled={props.busy === true || name.trim() === "" || key.trim() === ""}
|
|
200
|
+
>
|
|
201
|
+
{form.add}
|
|
202
|
+
</Button>
|
|
203
|
+
</div>
|
|
204
|
+
</div>
|
|
205
|
+
</form>
|
|
206
|
+
)}
|
|
207
|
+
</div>
|
|
208
|
+
);
|
|
209
|
+
}
|
|
@@ -9,11 +9,14 @@ 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
|
-
// The decisions: the ones that wait, with the way to answer
|
|
15
|
-
//
|
|
16
|
-
//
|
|
15
|
+
// The decisions: the ones that wait, each a card with the way to answer it, since they are what
|
|
16
|
+
// a person came for; and the ones answered, the record, a row each, since they are read back
|
|
17
|
+
// rarely and looked up by what was asked. Each decision is a record (ADR-0009) of its context,
|
|
18
|
+
// its answer with the rationale, and what followed, read on its own page. Each component takes
|
|
19
|
+
// its data as props and nothing from the network.
|
|
17
20
|
|
|
18
21
|
export interface DecisionCardProps {
|
|
19
22
|
readonly decision: RestDecision;
|
|
@@ -240,6 +243,59 @@ export interface DecisionListProps {
|
|
|
240
243
|
readonly empty?: React.ReactNode;
|
|
241
244
|
}
|
|
242
245
|
|
|
246
|
+
export interface DecisionLogProps {
|
|
247
|
+
/** The decisions answered, newest first. */
|
|
248
|
+
readonly decisions: readonly RestDecision[];
|
|
249
|
+
readonly tasks?: ReadonlyMap<string, RestTask> | undefined;
|
|
250
|
+
readonly taskHref?: ((task: RestTask) => string) | undefined;
|
|
251
|
+
/** Where each decision's own page is, with its context, its rationale and what followed. */
|
|
252
|
+
readonly decisionHref?: ((decision: RestDecision) => string) | undefined;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** The record: one row per decision answered, what was asked, what was chosen, by whom, when. */
|
|
256
|
+
export function DecisionLog(props: DecisionLogProps) {
|
|
257
|
+
const words = useWords();
|
|
258
|
+
const { shown, rest, more } = usePaged(props.decisions);
|
|
259
|
+
return (
|
|
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
|
+
</>
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
|
|
243
299
|
export function DecisionList(props: DecisionListProps) {
|
|
244
300
|
const words = useWords();
|
|
245
301
|
const waiting = props.decisions.filter((decision) => decision.answer === null);
|
|
@@ -268,9 +324,15 @@ export function DecisionList(props: DecisionListProps) {
|
|
|
268
324
|
{answered.length > 0 && (
|
|
269
325
|
<section aria-label={words.decisions.answered}>
|
|
270
326
|
<h2 className="sc-section-title">
|
|
271
|
-
{words.decisions.
|
|
327
|
+
{words.decisions.record} <span className="sc-column-count">{answered.length}</span>
|
|
272
328
|
</h2>
|
|
273
|
-
{
|
|
329
|
+
<p className="sc-lead">{words.decisions.recordLead}</p>
|
|
330
|
+
<DecisionLog
|
|
331
|
+
decisions={answered}
|
|
332
|
+
tasks={props.tasks}
|
|
333
|
+
taskHref={props.taskHref}
|
|
334
|
+
decisionHref={props.decisionHref}
|
|
335
|
+
/>
|
|
274
336
|
</section>
|
|
275
337
|
)}
|
|
276
338
|
</div>
|