@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.
Files changed (162) hide show
  1. package/README.md +6 -6
  2. package/dist/api.d.ts +6 -5
  3. package/dist/api.d.ts.map +1 -1
  4. package/dist/api.js +5 -4
  5. package/dist/api.js.map +1 -1
  6. package/dist/cli/cli.d.ts.map +1 -1
  7. package/dist/cli/cli.js +303 -18
  8. package/dist/cli/cli.js.map +1 -1
  9. package/dist/cli/format.d.ts +11 -1
  10. package/dist/cli/format.d.ts.map +1 -1
  11. package/dist/cli/format.js +87 -6
  12. package/dist/cli/format.js.map +1 -1
  13. package/dist/cli/help.d.ts.map +1 -1
  14. package/dist/cli/help.js +70 -14
  15. package/dist/cli/help.js.map +1 -1
  16. package/dist/client.d.ts +44 -8
  17. package/dist/client.d.ts.map +1 -1
  18. package/dist/client.js +32 -4
  19. package/dist/client.js.map +1 -1
  20. package/dist/components/board.d.ts +24 -1
  21. package/dist/components/board.d.ts.map +1 -1
  22. package/dist/components/board.js +66 -5
  23. package/dist/components/board.js.map +1 -1
  24. package/dist/components/calendar.d.ts +54 -0
  25. package/dist/components/calendar.d.ts.map +1 -0
  26. package/dist/components/calendar.js +156 -0
  27. package/dist/components/calendar.js.map +1 -0
  28. package/dist/components/categories.d.ts +13 -0
  29. package/dist/components/categories.d.ts.map +1 -0
  30. package/dist/components/categories.js +52 -0
  31. package/dist/components/categories.js.map +1 -0
  32. package/dist/components/decision-list.d.ts +10 -0
  33. package/dist/components/decision-list.d.ts.map +1 -1
  34. package/dist/components/decision-list.js +16 -1
  35. package/dist/components/decision-list.js.map +1 -1
  36. package/dist/components/event-feed.d.ts.map +1 -1
  37. package/dist/components/event-feed.js +23 -7
  38. package/dist/components/event-feed.js.map +1 -1
  39. package/dist/components/goals.d.ts +35 -0
  40. package/dist/components/goals.d.ts.map +1 -0
  41. package/dist/components/goals.js +45 -0
  42. package/dist/components/goals.js.map +1 -0
  43. package/dist/components/paged.d.ts +14 -0
  44. package/dist/components/paged.d.ts.map +1 -0
  45. package/dist/components/paged.js +28 -0
  46. package/dist/components/paged.js.map +1 -0
  47. package/dist/components/projects.d.ts +22 -2
  48. package/dist/components/projects.d.ts.map +1 -1
  49. package/dist/components/projects.js +35 -7
  50. package/dist/components/projects.js.map +1 -1
  51. package/dist/components/runs.d.ts.map +1 -1
  52. package/dist/components/runs.js +2 -2
  53. package/dist/components/runs.js.map +1 -1
  54. package/dist/components/shell.d.ts +2 -1
  55. package/dist/components/shell.d.ts.map +1 -1
  56. package/dist/components/shell.js +1 -1
  57. package/dist/components/shell.js.map +1 -1
  58. package/dist/components/task-form.d.ts +5 -1
  59. package/dist/components/task-form.d.ts.map +1 -1
  60. package/dist/components/task-form.js +13 -1
  61. package/dist/components/task-form.js.map +1 -1
  62. package/dist/components/task-list.d.ts +16 -0
  63. package/dist/components/task-list.d.ts.map +1 -0
  64. package/dist/components/task-list.js +16 -0
  65. package/dist/components/task-list.js.map +1 -0
  66. package/dist/components/task-view.d.ts +9 -1
  67. package/dist/components/task-view.d.ts.map +1 -1
  68. package/dist/components/task-view.js +10 -4
  69. package/dist/components/task-view.js.map +1 -1
  70. package/dist/components/ui.d.ts +30 -1
  71. package/dist/components/ui.d.ts.map +1 -1
  72. package/dist/components/ui.js +60 -0
  73. package/dist/components/ui.js.map +1 -1
  74. package/dist/components/work.d.ts +10 -0
  75. package/dist/components/work.d.ts.map +1 -0
  76. package/dist/components/work.js +33 -0
  77. package/dist/components/work.js.map +1 -0
  78. package/dist/console.css +658 -0
  79. package/dist/console.d.ts +8 -0
  80. package/dist/console.d.ts.map +1 -1
  81. package/dist/console.js +145 -13
  82. package/dist/console.js.map +1 -1
  83. package/dist/data.d.ts +19 -3
  84. package/dist/data.d.ts.map +1 -1
  85. package/dist/data.js +51 -4
  86. package/dist/data.js.map +1 -1
  87. package/dist/dates.d.ts +39 -0
  88. package/dist/dates.d.ts.map +1 -0
  89. package/dist/dates.js +120 -0
  90. package/dist/dates.js.map +1 -0
  91. package/dist/i18n/en.d.ts +172 -5
  92. package/dist/i18n/en.d.ts.map +1 -1
  93. package/dist/i18n/en.js +183 -6
  94. package/dist/i18n/en.js.map +1 -1
  95. package/dist/i18n/ko.d.ts.map +1 -1
  96. package/dist/i18n/ko.js +178 -6
  97. package/dist/i18n/ko.js.map +1 -1
  98. package/dist/index.d.ts +12 -5
  99. package/dist/index.d.ts.map +1 -1
  100. package/dist/index.js +12 -5
  101. package/dist/index.js.map +1 -1
  102. package/dist/limits.d.ts +11 -0
  103. package/dist/limits.d.ts.map +1 -1
  104. package/dist/limits.js +11 -0
  105. package/dist/limits.js.map +1 -1
  106. package/dist/marks.d.ts +12 -0
  107. package/dist/marks.d.ts.map +1 -0
  108. package/dist/marks.js +31 -0
  109. package/dist/marks.js.map +1 -0
  110. package/dist/router.d.ts +51 -1
  111. package/dist/router.d.ts.map +1 -1
  112. package/dist/router.js +80 -1
  113. package/dist/router.js.map +1 -1
  114. package/dist/routes.d.ts +14 -1
  115. package/dist/routes.d.ts.map +1 -1
  116. package/dist/routes.js +17 -0
  117. package/dist/routes.js.map +1 -1
  118. package/dist/schemas.d.ts +489 -3
  119. package/dist/schemas.d.ts.map +1 -1
  120. package/dist/schemas.js +150 -9
  121. package/dist/schemas.js.map +1 -1
  122. package/dist/vocabulary.d.ts +21 -1
  123. package/dist/vocabulary.d.ts.map +1 -1
  124. package/dist/vocabulary.js +31 -0
  125. package/dist/vocabulary.js.map +1 -1
  126. package/docs/specs/cli.md +17 -8
  127. package/docs/specs/rest.md +48 -17
  128. package/package.json +1 -1
  129. package/skills/working-the-board/SKILL.md +7 -6
  130. package/src/api.ts +42 -0
  131. package/src/cli/cli.ts +362 -18
  132. package/src/cli/format.ts +96 -6
  133. package/src/cli/help.ts +77 -15
  134. package/src/client.ts +98 -7
  135. package/src/components/board.tsx +173 -13
  136. package/src/components/calendar.tsx +367 -0
  137. package/src/components/categories.tsx +209 -0
  138. package/src/components/decision-list.tsx +67 -5
  139. package/src/components/event-feed.tsx +46 -27
  140. package/src/components/goals.tsx +264 -0
  141. package/src/components/paged.tsx +39 -0
  142. package/src/components/projects.tsx +128 -10
  143. package/src/components/runs.tsx +7 -1
  144. package/src/components/shell.tsx +5 -2
  145. package/src/components/task-form.tsx +78 -1
  146. package/src/components/task-list.tsx +64 -0
  147. package/src/components/task-view.tsx +75 -4
  148. package/src/components/ui.tsx +116 -1
  149. package/src/components/work.tsx +93 -0
  150. package/src/console.tsx +376 -9
  151. package/src/data.ts +83 -12
  152. package/src/dates.ts +143 -0
  153. package/src/i18n/en.ts +193 -6
  154. package/src/i18n/ko.ts +184 -6
  155. package/src/index.ts +62 -1
  156. package/src/limits.ts +11 -0
  157. package/src/marks.ts +40 -0
  158. package/src/router.ts +131 -6
  159. package/src/routes.ts +19 -0
  160. package/src/schemas.ts +187 -7
  161. package/src/styles/console.css +658 -0
  162. 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 each, and the ones answered, with
15
- // who answered and when; each a record (ADR-0009) of its context, its answer with the
16
- // rationale, and what followed. Each takes its data as props and nothing from the network.
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.answered} <span className="sc-column-count">{answered.length}</span>
327
+ {words.decisions.record} <span className="sc-column-count">{answered.length}</span>
272
328
  </h2>
273
- {answered.map(card)}
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>