@skillcdn/console 0.1.16 → 0.1.18

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 (143) hide show
  1. package/README.md +4 -3
  2. package/dist/api.d.ts +4 -3
  3. package/dist/api.d.ts.map +1 -1
  4. package/dist/api.js +3 -2
  5. package/dist/api.js.map +1 -1
  6. package/dist/cli/cli.d.ts +12 -0
  7. package/dist/cli/cli.d.ts.map +1 -1
  8. package/dist/cli/cli.js +226 -21
  9. package/dist/cli/cli.js.map +1 -1
  10. package/dist/cli/format.d.ts +8 -3
  11. package/dist/cli/format.d.ts.map +1 -1
  12. package/dist/cli/format.js +31 -5
  13. package/dist/cli/format.js.map +1 -1
  14. package/dist/cli/help.d.ts.map +1 -1
  15. package/dist/cli/help.js +34 -8
  16. package/dist/cli/help.js.map +1 -1
  17. package/dist/cli/install.d.ts +21 -0
  18. package/dist/cli/install.d.ts.map +1 -0
  19. package/dist/cli/install.js +87 -0
  20. package/dist/cli/install.js.map +1 -0
  21. package/dist/cli/main.js +24 -4
  22. package/dist/cli/main.js.map +1 -1
  23. package/dist/cli/notices.d.ts +13 -0
  24. package/dist/cli/notices.d.ts.map +1 -0
  25. package/dist/cli/notices.js +31 -0
  26. package/dist/cli/notices.js.map +1 -0
  27. package/dist/cli/terminal.d.ts +5 -0
  28. package/dist/cli/terminal.d.ts.map +1 -1
  29. package/dist/cli/terminal.js +11 -1
  30. package/dist/cli/terminal.js.map +1 -1
  31. package/dist/client.d.ts +43 -5
  32. package/dist/client.d.ts.map +1 -1
  33. package/dist/client.js +69 -4
  34. package/dist/client.js.map +1 -1
  35. package/dist/compat.d.ts +66 -0
  36. package/dist/compat.d.ts.map +1 -0
  37. package/dist/compat.js +115 -0
  38. package/dist/compat.js.map +1 -0
  39. package/dist/components/calendar.d.ts +3 -1
  40. package/dist/components/calendar.d.ts.map +1 -1
  41. package/dist/components/calendar.js +21 -2
  42. package/dist/components/calendar.js.map +1 -1
  43. package/dist/components/comments.d.ts +59 -0
  44. package/dist/components/comments.d.ts.map +1 -0
  45. package/dist/components/comments.js +138 -0
  46. package/dist/components/comments.js.map +1 -0
  47. package/dist/components/event-feed.d.ts.map +1 -1
  48. package/dist/components/event-feed.js +2 -0
  49. package/dist/components/event-feed.js.map +1 -1
  50. package/dist/components/goals.d.ts +3 -1
  51. package/dist/components/goals.d.ts.map +1 -1
  52. package/dist/components/goals.js +23 -11
  53. package/dist/components/goals.js.map +1 -1
  54. package/dist/components/markdown.d.ts +6 -0
  55. package/dist/components/markdown.d.ts.map +1 -1
  56. package/dist/components/markdown.js +2 -1
  57. package/dist/components/markdown.js.map +1 -1
  58. package/dist/components/projects.d.ts.map +1 -1
  59. package/dist/components/projects.js +6 -1
  60. package/dist/components/projects.js.map +1 -1
  61. package/dist/components/roadmap.d.ts +37 -0
  62. package/dist/components/roadmap.d.ts.map +1 -0
  63. package/dist/components/roadmap.js +76 -0
  64. package/dist/components/roadmap.js.map +1 -0
  65. package/dist/components/task-view.d.ts +8 -3
  66. package/dist/components/task-view.d.ts.map +1 -1
  67. package/dist/components/task-view.js +6 -4
  68. package/dist/components/task-view.js.map +1 -1
  69. package/dist/components/tokens.d.ts +2 -0
  70. package/dist/components/tokens.d.ts.map +1 -1
  71. package/dist/components/tokens.js +13 -3
  72. package/dist/components/tokens.js.map +1 -1
  73. package/dist/console.css +314 -2
  74. package/dist/console.d.ts.map +1 -1
  75. package/dist/console.js +30 -11
  76. package/dist/console.js.map +1 -1
  77. package/dist/data.d.ts +5 -1
  78. package/dist/data.d.ts.map +1 -1
  79. package/dist/data.js +3 -1
  80. package/dist/data.js.map +1 -1
  81. package/dist/i18n/en.d.ts +38 -2
  82. package/dist/i18n/en.d.ts.map +1 -1
  83. package/dist/i18n/en.js +38 -2
  84. package/dist/i18n/en.js.map +1 -1
  85. package/dist/i18n/ko.d.ts.map +1 -1
  86. package/dist/i18n/ko.js +38 -2
  87. package/dist/i18n/ko.js.map +1 -1
  88. package/dist/index.d.ts +3 -1
  89. package/dist/index.d.ts.map +1 -1
  90. package/dist/index.js +3 -1
  91. package/dist/index.js.map +1 -1
  92. package/dist/limits.d.ts +3 -0
  93. package/dist/limits.d.ts.map +1 -1
  94. package/dist/limits.js +3 -0
  95. package/dist/limits.js.map +1 -1
  96. package/dist/router.d.ts +6 -1
  97. package/dist/router.d.ts.map +1 -1
  98. package/dist/router.js +10 -1
  99. package/dist/router.js.map +1 -1
  100. package/dist/rules.js +2 -2
  101. package/dist/rules.js.map +1 -1
  102. package/dist/schemas.d.ts +147 -0
  103. package/dist/schemas.d.ts.map +1 -1
  104. package/dist/schemas.js +50 -1
  105. package/dist/schemas.js.map +1 -1
  106. package/dist/vocabulary.d.ts +1 -1
  107. package/dist/vocabulary.d.ts.map +1 -1
  108. package/dist/vocabulary.js +1 -0
  109. package/dist/vocabulary.js.map +1 -1
  110. package/docs/specs/cli.md +17 -10
  111. package/docs/specs/rest.md +17 -6
  112. package/package.json +3 -2
  113. package/skills/working-the-board/SKILL.md +8 -5
  114. package/src/api.ts +35 -0
  115. package/src/cli/cli.ts +273 -22
  116. package/src/cli/format.ts +36 -4
  117. package/src/cli/help.ts +38 -7
  118. package/src/cli/install.ts +111 -0
  119. package/src/cli/main.ts +25 -4
  120. package/src/cli/notices.ts +48 -0
  121. package/src/cli/terminal.ts +12 -1
  122. package/src/client.ts +142 -8
  123. package/src/compat.ts +173 -0
  124. package/src/components/calendar.tsx +41 -1
  125. package/src/components/comments.tsx +342 -0
  126. package/src/components/event-feed.tsx +2 -0
  127. package/src/components/goals.tsx +86 -7
  128. package/src/components/markdown.tsx +9 -1
  129. package/src/components/projects.tsx +16 -3
  130. package/src/components/roadmap.tsx +242 -0
  131. package/src/components/task-view.tsx +32 -33
  132. package/src/components/tokens.tsx +38 -1
  133. package/src/console.tsx +45 -3
  134. package/src/data.ts +9 -1
  135. package/src/i18n/en.ts +40 -2
  136. package/src/i18n/ko.ts +40 -2
  137. package/src/index.ts +20 -0
  138. package/src/limits.ts +3 -0
  139. package/src/router.ts +13 -2
  140. package/src/rules.ts +2 -2
  141. package/src/schemas.ts +60 -1
  142. package/src/styles/console.css +314 -2
  143. package/src/vocabulary.ts +1 -0
@@ -48,9 +48,11 @@ export interface CalendarProps {
48
48
  readonly today?: string | undefined;
49
49
  /** The workspace's categories, for the legend to say what the colours mean. */
50
50
  readonly categories?: readonly RestCategory[] | undefined;
51
- /** The project's goals, so that the task under the pointer is shown with its goal's title. */
51
+ /** The project's goals, so that the task under the pointer is shown with its goal's title, and, given `goalHref`, so that each goal marks its target day. */
52
52
  readonly goals?: readonly RestGoal[] | undefined;
53
53
  readonly taskHref: (task: RestTask) => string;
54
+ /** Where a goal's page is: given, the goals mark their target days on the month (ADR-0026). */
55
+ readonly goalHref?: ((goal: RestGoal) => string) | undefined;
54
56
  /** Where the calendar is on another month. */
55
57
  readonly monthHref: (month: string) => string;
56
58
  }
@@ -324,12 +326,32 @@ interface WeekRowProps {
324
326
  readonly pointed: Pointed | null;
325
327
  /** The project's goals, so that the item under the pointer names its goal; none across projects. */
326
328
  readonly goals?: readonly RestGoal[] | undefined;
329
+ /** The goals whose target day is each day, where the goals mark the month. */
330
+ readonly goalsOn?: ReadonlyMap<string, readonly RestGoal[]> | undefined;
331
+ readonly goalHref?: ((goal: RestGoal) => string) | undefined;
327
332
  /** The pointer, or the focus, arrived on a task's item on a day. */
328
333
  readonly onPoint: (task: RestTask, day: string, item: HTMLElement) => void;
329
334
  /** It left. */
330
335
  readonly onLeave: () => void;
331
336
  }
332
337
 
338
+ /** The goals that aim at each day, by day: those with a target day, still planned or worked towards. */
339
+ function goalsOnDays(goals: readonly RestGoal[]): ReadonlyMap<string, readonly RestGoal[]> {
340
+ const on = new Map<string, RestGoal[]>();
341
+ for (const goal of goals) {
342
+ if (goal.dueOn === null || goal.state === "dropped") {
343
+ continue;
344
+ }
345
+ const list = on.get(goal.dueOn);
346
+ if (list === undefined) {
347
+ on.set(goal.dueOn, [goal]);
348
+ } else {
349
+ list.push(goal);
350
+ }
351
+ }
352
+ return on;
353
+ }
354
+
333
355
  /** One week as a row of cells: the bars across the days in their lanes, then the tasks on each day. */
334
356
  function WeekRow(props: WeekRowProps) {
335
357
  const { week, segments, now } = props;
@@ -361,6 +383,21 @@ function WeekRow(props: WeekRowProps) {
361
383
  )}
362
384
  >
363
385
  <span className="sc-calendar-day">{Number(cell.day.slice(8))}</span>
386
+ {props.goalHref !== undefined &&
387
+ (props.goalsOn?.get(cell.day) ?? []).map((goal) => (
388
+ <a
389
+ key={goal.id}
390
+ className={cx(
391
+ "sc-calendar-goal",
392
+ `sc-category-${goal.category?.color ?? "gray"}`,
393
+ goal.state === "done" && "sc-calendar-over",
394
+ )}
395
+ href={props.goalHref?.(goal)}
396
+ >
397
+ <span className="sc-calendar-flag" aria-hidden="true" />
398
+ <span className="sc-calendar-task-title">{goal.title}</span>
399
+ </a>
400
+ ))}
364
401
  {Array.from({ length: lanes }, (_, lane) => {
365
402
  const segment = segments.find(
366
403
  (candidate) =>
@@ -435,6 +472,7 @@ export function Calendar(props: CalendarProps) {
435
472
  const firstDay = firstDayOfWeek(tag);
436
473
  const weeks = monthGrid(props.month, firstDay);
437
474
  const on = onDays(props.tasks);
475
+ const goalsOn = props.goalHref === undefined ? undefined : goalsOnDays(props.goals ?? []);
438
476
  const inMonth = (day: string) => monthOf(day) === props.month;
439
477
  const monthWeeks = weeks.map((week) => ({ week, segments: weekSegments(week, props.tasks) }));
440
478
  const nothing =
@@ -492,6 +530,8 @@ export function Calendar(props: CalendarProps) {
492
530
  taskHref={props.taskHref}
493
531
  pointed={pointed}
494
532
  goals={props.goals}
533
+ goalsOn={goalsOn}
534
+ goalHref={props.goalHref}
495
535
  onPoint={point}
496
536
  onLeave={leave}
497
537
  people
@@ -0,0 +1,342 @@
1
+ import { type ClipboardEvent, type FormEvent, type ReactNode, useState } from "react";
2
+ import {
3
+ agentLabel,
4
+ MAX_BODY_LENGTH,
5
+ MAX_FILES_PER_COMMENT,
6
+ type RestComment,
7
+ type RestCommentFile,
8
+ type RestDecision,
9
+ type RestRun,
10
+ type RestTask,
11
+ } from "../api.js";
12
+ import { useWords } from "../i18n/index.js";
13
+ import { DecisionCard, type DecisionCardProps } from "./decision-list.js";
14
+ import { Markdown } from "./markdown.js";
15
+ import { RunCard } from "./runs.js";
16
+ import { Button, formatBytes, PersonChip, personName, Time } from "./ui.js";
17
+
18
+ // The discussion on a task (ADR-0024): what people and agents said about it, each word with
19
+ // the files put in it, read in one thread with the runs on the task and the decisions about
20
+ // it, in the order they came. Takes its data as props and nothing from the network.
21
+
22
+ /** The kinds of picture the pages show in place: what a browser renders and cannot run. */
23
+ const PICTURE_TYPES: ReadonlySet<string> = new Set([
24
+ "image/png",
25
+ "image/jpeg",
26
+ "image/gif",
27
+ "image/webp",
28
+ ]);
29
+
30
+ /** Whether a file is a picture the pages show in place, by the media type the console kept. */
31
+ export function isPicture(contentType: string): boolean {
32
+ return PICTURE_TYPES.has(contentType);
33
+ }
34
+
35
+ /** Whether the text places a file by its name, as `![words](name)` or `![words](./name)`. */
36
+ function placed(body: string, name: string): boolean {
37
+ return body.includes(`](${name})`) || body.includes(`](./${name})`);
38
+ }
39
+
40
+ export interface CommentCardProps {
41
+ readonly comment: RestComment;
42
+ /** Where a file put in the comment is read: the project's address for it. Left out, a file is named and not linked. */
43
+ readonly fileHref?: ((file: RestCommentFile) => string) | undefined;
44
+ /** Where a document of the project is read, by its path, for the links in what was written. */
45
+ readonly docHref?: ((path: string) => string) | undefined;
46
+ }
47
+
48
+ export function CommentCard(props: CommentCardProps) {
49
+ const words = useWords();
50
+ const { comment } = props;
51
+ const hrefOf = (file: RestCommentFile) => props.fileHref?.(file);
52
+ // A picture the text names by its file name is shown where the text puts it; the rest of
53
+ // the files follow the text, pictures in place and everything else as a link.
54
+ const named = (source: string): string | undefined => {
55
+ const name = source.startsWith("./") ? source.slice(2) : source;
56
+ const file = comment.files.find(
57
+ (candidate) => candidate.file.name === name && isPicture(candidate.file.contentType),
58
+ );
59
+ return file === undefined ? undefined : hrefOf(file);
60
+ };
61
+ const following = comment.files.filter((file) => !placed(comment.body, file.file.name));
62
+ return (
63
+ <article
64
+ className="sc-comment"
65
+ aria-label={words.comments.label(personName(comment.author))}
66
+ id={comment.id}
67
+ >
68
+ <header className="sc-comment-header">
69
+ <PersonChip person={comment.author} />
70
+ {comment.agent !== null && (
71
+ <span className="sc-feed-agent" title={comment.agent}>
72
+ {words.feed.as(agentLabel(comment.agent))}
73
+ </span>
74
+ )}
75
+ <span className="sc-comment-meta">
76
+ <Time iso={comment.createdAt} />
77
+ </span>
78
+ </header>
79
+ {comment.body.trim().length > 0 && (
80
+ <Markdown source={comment.body} docHref={props.docHref} fileSrc={named} />
81
+ )}
82
+ {following.length > 0 && (
83
+ <ul className="sc-comment-files" aria-label={words.comments.files}>
84
+ {following.map((attached) => {
85
+ const href = hrefOf(attached);
86
+ const { file } = attached;
87
+ if (href !== undefined && isPicture(file.contentType)) {
88
+ return (
89
+ <li key={attached.id} className="sc-comment-picture">
90
+ <a href={href} rel="noopener nofollow ugc" target="_blank">
91
+ <img
92
+ src={href}
93
+ alt={words.comments.picture(file.name)}
94
+ loading="lazy"
95
+ decoding="async"
96
+ />
97
+ </a>
98
+ <span className="sc-muted">
99
+ {file.name}, {formatBytes(file.size)}
100
+ </span>
101
+ </li>
102
+ );
103
+ }
104
+ return (
105
+ <li key={attached.id}>
106
+ {href === undefined ? (
107
+ <span>{file.name}</span>
108
+ ) : (
109
+ <a href={href} rel="nofollow ugc">
110
+ {file.name}
111
+ </a>
112
+ )}{" "}
113
+ <span className="sc-muted">{formatBytes(file.size)}</span>
114
+ </li>
115
+ );
116
+ })}
117
+ </ul>
118
+ )}
119
+ </article>
120
+ );
121
+ }
122
+
123
+ /** What a comment form hands over: the word, and the files chosen or pasted. */
124
+ export interface CommentDraft {
125
+ readonly body: string;
126
+ readonly files: readonly File[];
127
+ }
128
+
129
+ export interface CommentFormProps {
130
+ readonly onSubmit: (draft: CommentDraft) => void;
131
+ readonly busy?: boolean | undefined;
132
+ }
133
+
134
+ /** A name for a file among those chosen already: the same, or with a count before its extension. */
135
+ function unusedName(name: string, taken: readonly File[]): string {
136
+ const names = new Set(taken.map((file) => file.name));
137
+ if (!names.has(name)) {
138
+ return name;
139
+ }
140
+ const dot = name.lastIndexOf(".");
141
+ const stem = dot > 0 ? name.slice(0, dot) : name;
142
+ const extension = dot > 0 ? name.slice(dot) : "";
143
+ for (let n = 2; ; n += 1) {
144
+ const candidate = `${stem}-${n}${extension}`;
145
+ if (!names.has(candidate)) {
146
+ return candidate;
147
+ }
148
+ }
149
+ }
150
+
151
+ export function CommentForm(props: CommentFormProps) {
152
+ const words = useWords().comments;
153
+ const [body, setBody] = useState("");
154
+ const [files, setFiles] = useState<readonly File[]>([]);
155
+ const empty = body.trim().length === 0 && files.length === 0;
156
+
157
+ const add = (chosen: readonly File[]): File[] => {
158
+ const added: File[] = [];
159
+ let held = files;
160
+ for (const file of chosen) {
161
+ if (held.length >= MAX_FILES_PER_COMMENT) {
162
+ break;
163
+ }
164
+ const name = unusedName(file.name, held);
165
+ const kept = name === file.name ? file : new File([file], name, { type: file.type });
166
+ held = [...held, kept];
167
+ added.push(kept);
168
+ }
169
+ setFiles(held);
170
+ return added;
171
+ };
172
+
173
+ // A picture pasted in goes with the comment, placed in the text where pictures are read.
174
+ const paste = (event: ClipboardEvent<HTMLTextAreaElement>) => {
175
+ const pasted = [...event.clipboardData.files].filter((file) => isPicture(file.type));
176
+ if (pasted.length === 0) {
177
+ return;
178
+ }
179
+ event.preventDefault();
180
+ const added = add(pasted);
181
+ if (added.length > 0) {
182
+ const lines = added.map((file) => `![](${file.name})`).join("\n");
183
+ setBody((current) =>
184
+ current.trim().length === 0 ? lines : `${current.trimEnd()}\n\n${lines}`,
185
+ );
186
+ }
187
+ };
188
+
189
+ const submit = (event: FormEvent) => {
190
+ event.preventDefault();
191
+ if (empty || props.busy === true) {
192
+ return;
193
+ }
194
+ props.onSubmit({ body, files });
195
+ setBody("");
196
+ setFiles([]);
197
+ };
198
+
199
+ return (
200
+ <form className="sc-comment-form" aria-label={words.write} onSubmit={submit}>
201
+ <textarea
202
+ className="sc-input sc-textarea"
203
+ aria-label={words.write}
204
+ placeholder={words.placeholder}
205
+ maxLength={MAX_BODY_LENGTH}
206
+ rows={4}
207
+ value={body}
208
+ disabled={props.busy === true}
209
+ onChange={(event) => setBody(event.target.value)}
210
+ onPaste={paste}
211
+ />
212
+ {files.length > 0 && (
213
+ <ul className="sc-comment-chosen" aria-label={words.files}>
214
+ {files.map((file) => (
215
+ <li key={file.name} className="sc-comment-chosen-file">
216
+ <span>{file.name}</span> <span className="sc-muted">{formatBytes(file.size)}</span>{" "}
217
+ <Button
218
+ variant="ghost"
219
+ size="sm"
220
+ aria-label={words.removeFile(file.name)}
221
+ onClick={() => setFiles((current) => current.filter((held) => held !== file))}
222
+ >
223
+ ×
224
+ </Button>
225
+ </li>
226
+ ))}
227
+ </ul>
228
+ )}
229
+ <div className="sc-comment-form-actions">
230
+ <label className="sc-button sc-button-ghost sc-button-sm">
231
+ {words.chooseFiles}
232
+ <input
233
+ className="sc-comment-file-input"
234
+ type="file"
235
+ multiple
236
+ onChange={(event) => {
237
+ add([...(event.target.files ?? [])]);
238
+ event.target.value = "";
239
+ }}
240
+ />
241
+ </label>
242
+ <Button type="submit" variant="primary" disabled={empty || props.busy === true}>
243
+ {words.one}
244
+ </Button>
245
+ </div>
246
+ <p className="sc-field-hint">{words.hint}</p>
247
+ </form>
248
+ );
249
+ }
250
+
251
+ /** One thing said or done about a task, with when, for the thread's order. */
252
+ export type ThreadItem =
253
+ | { readonly kind: "comment"; readonly at: string; readonly comment: RestComment }
254
+ | { readonly kind: "run"; readonly at: string; readonly run: RestRun }
255
+ | { readonly kind: "decision"; readonly at: string; readonly decision: RestDecision };
256
+
257
+ /** The comments, the runs and the decisions of a task as one thread, in the order they came. */
258
+ export function threadOf(
259
+ comments: readonly RestComment[],
260
+ runs: readonly RestRun[],
261
+ decisions: readonly RestDecision[],
262
+ ): ThreadItem[] {
263
+ const items: ThreadItem[] = [
264
+ ...comments.map((comment) => ({ kind: "comment" as const, at: comment.createdAt, comment })),
265
+ ...runs.map((run) => ({ kind: "run" as const, at: run.startedAt, run })),
266
+ ...decisions.map((decision) => ({
267
+ kind: "decision" as const,
268
+ at: decision.createdAt,
269
+ decision,
270
+ })),
271
+ ];
272
+ return items.sort((a, b) => (a.at < b.at ? -1 : a.at > b.at ? 1 : 0));
273
+ }
274
+
275
+ export interface TaskThreadProps {
276
+ readonly task: RestTask;
277
+ readonly comments: readonly RestComment[];
278
+ readonly runs: readonly RestRun[];
279
+ readonly decisions: readonly RestDecision[];
280
+ /** Where a file of the project is read, by its id: a run's as a comment's. */
281
+ readonly fileHref?: ((fileId: string) => string) | undefined;
282
+ /** Where a decision is answered, for a run that waits for one. */
283
+ readonly decisionHref?: ((decisionId: string) => string) | undefined;
284
+ readonly docHref?: ((path: string) => string) | undefined;
285
+ readonly onAnswer?: DecisionCardProps["onAnswer"];
286
+ readonly onUpdateDecision?: DecisionCardProps["onUpdate"];
287
+ readonly onAbandonRun?: ((run: RestRun) => void) | undefined;
288
+ /** Called with what was written; left out, nothing can be said here. */
289
+ readonly onComment?: ((draft: CommentDraft) => void) | undefined;
290
+ readonly busy?: boolean | undefined;
291
+ readonly empty?: ReactNode;
292
+ }
293
+
294
+ export function TaskThread(props: TaskThreadProps) {
295
+ const items = threadOf(props.comments, props.runs, props.decisions);
296
+ const fileHref = props.fileHref;
297
+ const shown = (item: ThreadItem): ReactNode => {
298
+ if (item.kind === "comment") {
299
+ return (
300
+ <CommentCard
301
+ key={item.comment.id}
302
+ comment={item.comment}
303
+ fileHref={fileHref === undefined ? undefined : (file) => fileHref(file.id)}
304
+ docHref={props.docHref}
305
+ />
306
+ );
307
+ }
308
+ if (item.kind === "run") {
309
+ return (
310
+ <RunCard
311
+ key={item.run.id}
312
+ run={item.run}
313
+ decisionHref={props.decisionHref}
314
+ fileHref={fileHref === undefined ? undefined : (artifact) => fileHref(artifact.id)}
315
+ docHref={props.docHref}
316
+ onAbandon={props.onAbandonRun}
317
+ busy={props.busy}
318
+ />
319
+ );
320
+ }
321
+ return (
322
+ <DecisionCard
323
+ key={item.decision.id}
324
+ decision={item.decision}
325
+ task={props.task}
326
+ docHref={props.docHref}
327
+ onAnswer={props.onAnswer}
328
+ onUpdate={props.onUpdateDecision}
329
+ busy={props.busy}
330
+ />
331
+ );
332
+ };
333
+ return (
334
+ <div className="sc-thread">
335
+ {items.length === 0 && props.empty}
336
+ {items.map(shown)}
337
+ {props.onComment !== undefined && (
338
+ <CommentForm onSubmit={props.onComment} busy={props.busy} />
339
+ )}
340
+ </div>
341
+ );
342
+ }
@@ -76,6 +76,8 @@ export function describeEvent(event: RestEvent, words: Messages = en): string {
76
76
  );
77
77
  case "task.updated":
78
78
  return feed.event.taskUpdated(fields, task);
79
+ case "task.commented":
80
+ return feed.event.taskCommented(task, data.excerpt);
79
81
  case "decision.raised":
80
82
  return feed.event.decisionRaised(question);
81
83
  case "decision.answered":
@@ -4,20 +4,32 @@ import {
4
4
  type GoalState,
5
5
  MAX_BODY_LENGTH,
6
6
  MAX_TITLE_LENGTH,
7
+ type RestCategory,
7
8
  type RestGoal,
8
9
  type RestGoalInput,
9
10
  type RestTask,
10
11
  TASK_STATES,
11
12
  } from "../api.js";
12
- import { useWords } from "../i18n/index.js";
13
+ import { formatDays } from "../dates.js";
14
+ import { useLanguage, useWords } from "../i18n/index.js";
13
15
  import { Markdown } from "./markdown.js";
14
16
  import { TaskList } from "./task-list.js";
15
- import { Button, Day, DueBadge, GoalStateBadge, PersonChip, ProgressBar, Time } from "./ui.js";
17
+ import {
18
+ Button,
19
+ CategoryBadge,
20
+ Day,
21
+ DueBadge,
22
+ GoalStateBadge,
23
+ PersonChip,
24
+ ProgressBar,
25
+ Time,
26
+ } from "./ui.js";
16
27
 
17
- // The roadmap of a project (ADR-0019): its goals, each with the day it is meant to be reached
18
- // by and how far its tasks are, read from the board as it stands. The list, one goal with its
19
- // tasks, and the form an owner sets one with. Each takes its data as props and nothing from
20
- // the network; what people wrote is shown as text, never as HTML.
28
+ // The roadmap of a project (ADR-0019): its goals, each planned in days as a task is, a start
29
+ // and a target, sorted into a category of the workspace (ADR-0026), with how far its tasks
30
+ // are, read from the board as it stands. The list, one goal with its tasks, and the form an
31
+ // owner sets one with. Each takes its data as props and nothing from the network; what people
32
+ // wrote is shown as text, never as HTML.
21
33
 
22
34
  /** A goal's day on the calendar's terms: reached or let go, it is a plain fact; else it may be late. */
23
35
  const dueState = (goal: RestGoal) =>
@@ -33,6 +45,7 @@ export interface GoalListProps {
33
45
 
34
46
  export function GoalList(props: GoalListProps) {
35
47
  const words = useWords().roadmap;
48
+ const { tag } = useLanguage();
36
49
  if (props.goals.length === 0) {
37
50
  return <>{props.empty ?? null}</>;
38
51
  }
@@ -44,7 +57,11 @@ export function GoalList(props: GoalListProps) {
44
57
  <a className="sc-goal-title" href={props.goalHref(goal)}>
45
58
  {goal.title}
46
59
  </a>
60
+ {goal.category !== null && <CategoryBadge category={goal.category} />}
47
61
  <GoalStateBadge state={goal.state} />
62
+ {goal.startsOn !== null && goal.dueOn !== null && goal.startsOn < goal.dueOn && (
63
+ <span className="sc-goal-days">{formatDays(goal.startsOn, goal.dueOn, tag)}</span>
64
+ )}
48
65
  {goal.dueOn !== null && (
49
66
  <DueBadge dueOn={goal.dueOn} state={dueState(goal)} today={props.today} />
50
67
  )}
@@ -59,6 +76,8 @@ export function GoalList(props: GoalListProps) {
59
76
  export interface GoalFormProps {
60
77
  /** The goal to change; left out, the form sets a new one. */
61
78
  readonly goal?: RestGoal | undefined;
79
+ /** The workspace's categories to sort the goal into; left out, the form offers none. */
80
+ readonly categories?: readonly RestCategory[] | undefined;
62
81
  readonly busy?: boolean | undefined;
63
82
  readonly error?: string | undefined;
64
83
  readonly onSubmit: (input: RestGoalInput) => void;
@@ -72,11 +91,20 @@ export function GoalForm(props: GoalFormProps) {
72
91
  const [title, setTitle] = useState(goal?.title ?? "");
73
92
  const [body, setBody] = useState(goal?.body ?? "");
74
93
  const [state, setState] = useState<GoalState>(goal?.state ?? "planned");
94
+ const [category, setCategory] = useState(goal?.category?.key ?? "");
95
+ const [startsOn, setStartsOn] = useState(goal?.startsOn ?? "");
75
96
  const [dueOn, setDueOn] = useState(goal?.dueOn ?? "");
76
97
 
77
98
  const submit = (event: FormEvent) => {
78
99
  event.preventDefault();
79
- props.onSubmit({ title: title.trim(), body, state, dueOn: dueOn === "" ? null : dueOn });
100
+ props.onSubmit({
101
+ title: title.trim(),
102
+ body,
103
+ state,
104
+ category: category === "" ? null : category,
105
+ startsOn: startsOn === "" ? null : startsOn,
106
+ dueOn: dueOn === "" ? null : dueOn,
107
+ });
80
108
  };
81
109
 
82
110
  return (
@@ -117,12 +145,42 @@ export function GoalForm(props: GoalFormProps) {
117
145
  ))}
118
146
  </select>
119
147
  </label>
148
+ {props.categories !== undefined && (
149
+ <label className="sc-field">
150
+ <span className="sc-field-label">{form.category}</span>
151
+ <select
152
+ className="sc-input"
153
+ value={category}
154
+ onChange={(event) => setCategory(event.target.value)}
155
+ >
156
+ <option value="">{form.noCategory}</option>
157
+ {props.categories.map((candidate) => (
158
+ <option key={candidate.key} value={candidate.key}>
159
+ {candidate.name}
160
+ </option>
161
+ ))}
162
+ </select>
163
+ </label>
164
+ )}
165
+ </div>
166
+ <div className="sc-field-row">
167
+ <label className="sc-field">
168
+ <span className="sc-field-label">{form.startsOn}</span>
169
+ <input
170
+ className="sc-input"
171
+ type="date"
172
+ value={startsOn}
173
+ max={dueOn === "" ? undefined : dueOn}
174
+ onChange={(event) => setStartsOn(event.target.value)}
175
+ />
176
+ </label>
120
177
  <label className="sc-field">
121
178
  <span className="sc-field-label">{form.dueOn}</span>
122
179
  <input
123
180
  className="sc-input"
124
181
  type="date"
125
182
  value={dueOn}
183
+ min={startsOn === "" ? undefined : startsOn}
126
184
  onChange={(event) => setDueOn(event.target.value)}
127
185
  />
128
186
  </label>
@@ -175,6 +233,7 @@ export function GoalView(props: GoalViewProps) {
175
233
  <header className="sc-task-header">
176
234
  <h1 className="sc-task-title">{goal.title}</h1>
177
235
  <div className="sc-task-badges">
236
+ {goal.category !== null && <CategoryBadge category={goal.category} />}
178
237
  <GoalStateBadge state={goal.state} />
179
238
  {goal.dueOn !== null && (
180
239
  <DueBadge dueOn={goal.dueOn} state={dueState(goal)} today={props.today} />
@@ -233,6 +292,26 @@ export function GoalView(props: GoalViewProps) {
233
292
  )}
234
293
  </dd>
235
294
  </div>
295
+ <div>
296
+ <dt>{roadmap.category}</dt>
297
+ <dd>
298
+ {goal.category === null ? (
299
+ <span className="sc-muted">{roadmap.noCategory}</span>
300
+ ) : (
301
+ <CategoryBadge category={goal.category} />
302
+ )}
303
+ </dd>
304
+ </div>
305
+ <div>
306
+ <dt>{roadmap.starts}</dt>
307
+ <dd>
308
+ {goal.startsOn === null ? (
309
+ <span className="sc-muted">{roadmap.noStart}</span>
310
+ ) : (
311
+ <Day day={goal.startsOn} />
312
+ )}
313
+ </dd>
314
+ </div>
236
315
  <div>
237
316
  <dt>{roadmap.target}</dt>
238
317
  <dd>
@@ -14,6 +14,12 @@ export interface MarkdownProps {
14
14
  readonly source: string;
15
15
  /** Where a document of the project is read, by its path; left out, such links stay text. */
16
16
  readonly docHref?: ((path: string) => string) | undefined;
17
+ /**
18
+ * Where a picture named in the text is read, when the text comes with files: given the
19
+ * source of `![words](source)`, the address of the file it names, or nothing. A picture that
20
+ * is neither one of those nor over https stays text.
21
+ */
22
+ readonly fileSrc?: ((source: string) => string | undefined) | undefined;
17
23
  }
18
24
 
19
25
  export function Markdown(props: MarkdownProps) {
@@ -48,7 +54,9 @@ export function Markdown(props: MarkdownProps) {
48
54
  return <span>{children}</span>;
49
55
  },
50
56
  img({ src, alt, title }) {
51
- const url = typeof src === "string" && SAFE_IMAGE.test(src) ? src : undefined;
57
+ const named = typeof src === "string" ? props.fileSrc?.(src) : undefined;
58
+ const url =
59
+ named ?? (typeof src === "string" && SAFE_IMAGE.test(src) ? src : undefined);
52
60
  if (url === undefined) {
53
61
  return <span className="sc-prose-image-text">{alt ?? ""}</span>;
54
62
  }
@@ -46,18 +46,31 @@ export function ProjectMark(props: {
46
46
  const icon = props.project.icon ?? null;
47
47
  const src =
48
48
  props.iconSrc ?? (icon === null ? undefined : projectIconPath(props.project.key, icon));
49
+ // The symbol stands in until the image is there, and comes back should the image fail; once
50
+ // the image is shown, the symbol goes, since an image with clear parts would show it through.
51
+ const [shown, setShown] = useState<string | undefined>(undefined);
52
+ const [failed, setFailed] = useState<string | undefined>(undefined);
53
+ const pictured = src !== undefined && failed !== src;
49
54
  return (
50
55
  <span
51
56
  className={cx(
52
57
  "sc-mark",
53
58
  `sc-mark-${size}`,
54
59
  `sc-category-${projectColorOf(props.project)}`,
55
- src !== undefined && "sc-mark-pictured",
60
+ pictured && "sc-mark-pictured",
56
61
  )}
57
62
  aria-hidden="true"
58
63
  >
59
- {projectSymbolOf(props.project)}
60
- {src !== undefined && <img className="sc-mark-image" src={src} alt="" />}
64
+ {(!pictured || shown !== src) && projectSymbolOf(props.project)}
65
+ {pictured && (
66
+ <img
67
+ className="sc-mark-image"
68
+ src={src}
69
+ alt=""
70
+ onLoad={() => setShown(src)}
71
+ onError={() => setFailed(src)}
72
+ />
73
+ )}
61
74
  </span>
62
75
  );
63
76
  }