@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.
Files changed (145) hide show
  1. package/README.md +6 -5
  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 +49 -9
  8. package/dist/cli/cli.js.map +1 -1
  9. package/dist/cli/format.d.ts +2 -0
  10. package/dist/cli/format.d.ts.map +1 -1
  11. package/dist/cli/format.js +7 -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 +10 -9
  15. package/dist/cli/help.js.map +1 -1
  16. package/dist/client.d.ts +14 -5
  17. package/dist/client.d.ts.map +1 -1
  18. package/dist/client.js +13 -1
  19. package/dist/client.js.map +1 -1
  20. package/dist/components/board.d.ts +1 -4
  21. package/dist/components/board.d.ts.map +1 -1
  22. package/dist/components/board.js +6 -13
  23. package/dist/components/board.js.map +1 -1
  24. package/dist/components/calendar.d.ts +21 -1
  25. package/dist/components/calendar.d.ts.map +1 -1
  26. package/dist/components/calendar.js +69 -9
  27. package/dist/components/calendar.js.map +1 -1
  28. package/dist/components/decision-list.d.ts.map +1 -1
  29. package/dist/components/decision-list.js +11 -9
  30. package/dist/components/decision-list.js.map +1 -1
  31. package/dist/components/event-feed.d.ts.map +1 -1
  32. package/dist/components/event-feed.js +11 -7
  33. package/dist/components/event-feed.js.map +1 -1
  34. package/dist/components/paged.d.ts +14 -0
  35. package/dist/components/paged.d.ts.map +1 -0
  36. package/dist/components/paged.js +28 -0
  37. package/dist/components/paged.js.map +1 -0
  38. package/dist/components/people.js +2 -2
  39. package/dist/components/people.js.map +1 -1
  40. package/dist/components/projects.d.ts +22 -2
  41. package/dist/components/projects.d.ts.map +1 -1
  42. package/dist/components/projects.js +37 -9
  43. package/dist/components/projects.js.map +1 -1
  44. package/dist/components/runs.d.ts.map +1 -1
  45. package/dist/components/runs.js +3 -3
  46. package/dist/components/runs.js.map +1 -1
  47. package/dist/components/shell.d.ts +2 -1
  48. package/dist/components/shell.d.ts.map +1 -1
  49. package/dist/components/shell.js +3 -3
  50. package/dist/components/shell.js.map +1 -1
  51. package/dist/components/task-form.js +2 -2
  52. package/dist/components/task-form.js.map +1 -1
  53. package/dist/components/task-list.d.ts +1 -1
  54. package/dist/components/task-list.d.ts.map +1 -1
  55. package/dist/components/task-list.js +6 -4
  56. package/dist/components/task-list.js.map +1 -1
  57. package/dist/components/task-view.js +1 -1
  58. package/dist/components/task-view.js.map +1 -1
  59. package/dist/components/ui.d.ts +7 -2
  60. package/dist/components/ui.d.ts.map +1 -1
  61. package/dist/components/ui.js +9 -4
  62. package/dist/components/ui.js.map +1 -1
  63. package/dist/components/work.d.ts.map +1 -1
  64. package/dist/components/work.js +7 -3
  65. package/dist/components/work.js.map +1 -1
  66. package/dist/console.css +149 -2
  67. package/dist/console.d.ts +38 -4
  68. package/dist/console.d.ts.map +1 -1
  69. package/dist/console.js +84 -23
  70. package/dist/console.js.map +1 -1
  71. package/dist/data.d.ts +5 -2
  72. package/dist/data.d.ts.map +1 -1
  73. package/dist/data.js +15 -2
  74. package/dist/data.js.map +1 -1
  75. package/dist/i18n/en.d.ts +17 -11
  76. package/dist/i18n/en.d.ts.map +1 -1
  77. package/dist/i18n/en.js +18 -14
  78. package/dist/i18n/en.js.map +1 -1
  79. package/dist/i18n/ko.d.ts.map +1 -1
  80. package/dist/i18n/ko.js +18 -14
  81. package/dist/i18n/ko.js.map +1 -1
  82. package/dist/index.d.ts +6 -5
  83. package/dist/index.d.ts.map +1 -1
  84. package/dist/index.js +6 -5
  85. package/dist/index.js.map +1 -1
  86. package/dist/limits.d.ts +6 -0
  87. package/dist/limits.d.ts.map +1 -1
  88. package/dist/limits.js +6 -0
  89. package/dist/limits.js.map +1 -1
  90. package/dist/marks.d.ts +12 -0
  91. package/dist/marks.d.ts.map +1 -0
  92. package/dist/marks.js +31 -0
  93. package/dist/marks.js.map +1 -0
  94. package/dist/router.d.ts +19 -3
  95. package/dist/router.d.ts.map +1 -1
  96. package/dist/router.js +28 -5
  97. package/dist/router.js.map +1 -1
  98. package/dist/routes.d.ts +7 -0
  99. package/dist/routes.d.ts.map +1 -1
  100. package/dist/routes.js +8 -0
  101. package/dist/routes.js.map +1 -1
  102. package/dist/schemas.d.ts +123 -3
  103. package/dist/schemas.d.ts.map +1 -1
  104. package/dist/schemas.js +42 -10
  105. package/dist/schemas.js.map +1 -1
  106. package/dist/vocabulary.d.ts +10 -4
  107. package/dist/vocabulary.d.ts.map +1 -1
  108. package/dist/vocabulary.js +8 -3
  109. package/dist/vocabulary.js.map +1 -1
  110. package/docs/specs/cli.md +7 -5
  111. package/docs/specs/rest.md +18 -12
  112. package/package.json +1 -1
  113. package/skills/building-a-console/SKILL.md +2 -2
  114. package/skills/working-the-board/SKILL.md +1 -1
  115. package/src/api.ts +12 -0
  116. package/src/cli/cli.ts +51 -8
  117. package/src/cli/format.ts +8 -6
  118. package/src/cli/help.ts +10 -10
  119. package/src/client.ts +29 -6
  120. package/src/components/board.tsx +6 -30
  121. package/src/components/calendar.tsx +168 -56
  122. package/src/components/decision-list.tsx +38 -33
  123. package/src/components/event-feed.tsx +34 -27
  124. package/src/components/paged.tsx +39 -0
  125. package/src/components/people.tsx +5 -5
  126. package/src/components/projects.tsx +132 -12
  127. package/src/components/runs.tsx +9 -3
  128. package/src/components/shell.tsx +8 -5
  129. package/src/components/task-form.tsx +2 -2
  130. package/src/components/task-list.tsx +35 -32
  131. package/src/components/task-view.tsx +1 -1
  132. package/src/components/ui.tsx +14 -5
  133. package/src/components/work.tsx +12 -2
  134. package/src/console.tsx +158 -25
  135. package/src/data.ts +20 -4
  136. package/src/i18n/en.ts +19 -14
  137. package/src/i18n/ko.ts +19 -14
  138. package/src/index.ts +9 -0
  139. package/src/limits.ts +6 -0
  140. package/src/marks.ts +40 -0
  141. package/src/router.ts +45 -7
  142. package/src/routes.ts +9 -0
  143. package/src/schemas.ts +48 -8
  144. package/src/styles/console.css +149 -2
  145. package/src/vocabulary.ts +14 -4
@@ -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 { useLanguage, useWords } from "../i18n/index.js";
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, and whose agent is at it. It takes its
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 { tag } = useLanguage();
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.login}
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, each task on the day it is due
17
- // and, lightly, on the day it starts; and who has what, so that whoever runs the project sees
18
- // at a glance whether the work is on its days. Takes its data as props and nothing from the
19
- // network; the month shown is the address's (ADR-0010).
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
- {weeks.map((week) => (
106
- <tr key={week[0]?.day}>
107
- {week.map((cell) => (
108
- <td
109
- key={cell.day}
110
- className={cx(
111
- "sc-calendar-cell",
112
- !cell.inMonth && "sc-calendar-cell-outside",
113
- cell.day === now && "sc-calendar-cell-today",
114
- )}
115
- >
116
- <span className="sc-calendar-day">{Number(cell.day.slice(8))}</span>
117
- {(due.get(cell.day) ?? []).map((task) => (
118
- <a
119
- key={task.id}
120
- className={cx(
121
- "sc-calendar-task",
122
- !isOpen(task) && "sc-calendar-task-over",
123
- isOpen(task) && cell.day < now && "sc-calendar-task-late",
124
- )}
125
- href={props.taskHref(task)}
126
- title={`#${task.number} ${task.title}`}
127
- >
128
- <span
129
- className={cx(
130
- "sc-calendar-dot",
131
- `sc-category-${task.category?.color ?? "gray"}`,
132
- )}
133
- />
134
- <span className="sc-calendar-task-title">{task.title}</span>
135
- </a>
136
- ))}
137
- {(starting.get(cell.day) ?? []).map((task) => (
138
- <a
139
- key={task.id}
140
- className="sc-calendar-task sc-calendar-task-start"
141
- href={props.taskHref(task)}
142
- title={`#${task.number} ${task.title}: ${words.starts}`}
143
- >
144
- <span className="sc-calendar-task-title">{task.title}</span>
145
- </a>
146
- ))}
147
- </td>
148
- ))}
149
- </tr>
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.login.localeCompare(b.person.login);
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
- <ol className="sc-decision-log" aria-label={words.decisions.record}>
259
- {props.decisions.map((decision) => {
260
- const task = decision.taskId === null ? undefined : props.tasks?.get(decision.taskId);
261
- const href = props.decisionHref?.(decision);
262
- const chosen =
263
- decision.options.find((option) => option.id === decision.answer?.option)?.label ??
264
- decision.answer?.option ??
265
- "";
266
- return (
267
- <li key={decision.id} className="sc-decision-row">
268
- <span className="sc-decision-row-question">
269
- {href === undefined ? decision.question : <a href={href}>{decision.question}</a>}
270
- </span>
271
- <span className="sc-decision-row-answer">{chosen}</span>
272
- <span className="sc-decision-row-meta">
273
- {decision.answer !== null && (
274
- <>
275
- <PersonChip person={decision.answer.by} /> <Time iso={decision.answer.at} />
276
- </>
277
- )}
278
- {task !== undefined &&
279
- (props.taskHref === undefined ? (
280
- <span className="sc-card-number">#{task.number}</span>
281
- ) : (
282
- <a className="sc-card-number" href={props.taskHref(task)}>
283
- #{task.number}
284
- </a>
285
- ))}
286
- </span>
287
- </li>
288
- );
289
- })}
290
- </ol>
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
- <ol className="sc-feed" aria-label={words.feed.list}>
115
- {newestFirst.map((event) => {
116
- const href = props.href?.(event);
117
- const said = describeEvent(event, words);
118
- return (
119
- <li key={event.id} className="sc-feed-item">
120
- <span className="sc-feed-actor">
121
- {event.actor === null ? (
122
- <span className="sc-person-login">{words.feed.console}</span>
123
- ) : (
124
- <PersonChip person={event.actor} />
125
- )}
126
- {event.agent !== null && (
127
- <span className="sc-feed-agent" title={words.feed.asTitle}>
128
- {words.feed.as(event.agent)}
129
- </span>
130
- )}
131
- </span>
132
- <span className="sc-feed-words">
133
- {href === undefined ? said : <a href={href}>{said}</a>}
134
- </span>
135
- <Time iso={event.createdAt} />
136
- </li>
137
- );
138
- })}
139
- </ol>
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 className="sc-muted"> {words.common.you}</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.login)}</span>
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}