@skillcdn/console 0.1.23 → 0.3.0

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 (97) hide show
  1. package/README.md +6 -5
  2. package/dist/api.d.ts +1 -1
  3. package/dist/api.d.ts.map +1 -1
  4. package/dist/api.js +1 -1
  5. package/dist/api.js.map +1 -1
  6. package/dist/cli/cli.d.ts.map +1 -1
  7. package/dist/cli/cli.js +80 -54
  8. package/dist/cli/cli.js.map +1 -1
  9. package/dist/cli/format.d.ts +4 -28
  10. package/dist/cli/format.d.ts.map +1 -1
  11. package/dist/cli/format.js +7 -260
  12. package/dist/cli/format.js.map +1 -1
  13. package/dist/cli/help.d.ts +6 -1
  14. package/dist/cli/help.d.ts.map +1 -1
  15. package/dist/cli/help.js +47 -24
  16. package/dist/cli/help.js.map +1 -1
  17. package/dist/cli/pack.d.ts +46 -0
  18. package/dist/cli/pack.d.ts.map +1 -0
  19. package/dist/cli/pack.js +250 -0
  20. package/dist/cli/pack.js.map +1 -0
  21. package/dist/compat.d.ts +1 -1
  22. package/dist/compat.js +1 -1
  23. package/dist/components/decision-list.d.ts +13 -1
  24. package/dist/components/decision-list.d.ts.map +1 -1
  25. package/dist/components/decision-list.js +74 -29
  26. package/dist/components/decision-list.js.map +1 -1
  27. package/dist/components/paged.d.ts +24 -0
  28. package/dist/components/paged.d.ts.map +1 -1
  29. package/dist/components/paged.js +40 -4
  30. package/dist/components/paged.js.map +1 -1
  31. package/dist/components/skills.d.ts.map +1 -1
  32. package/dist/components/skills.js +8 -4
  33. package/dist/components/skills.js.map +1 -1
  34. package/dist/components/ui.d.ts +2 -0
  35. package/dist/components/ui.d.ts.map +1 -1
  36. package/dist/components/ui.js +7 -2
  37. package/dist/components/ui.js.map +1 -1
  38. package/dist/components/work.d.ts +2 -0
  39. package/dist/components/work.d.ts.map +1 -1
  40. package/dist/components/work.js +3 -2
  41. package/dist/components/work.js.map +1 -1
  42. package/dist/console.css +112 -5
  43. package/dist/console.d.ts.map +1 -1
  44. package/dist/console.js +5 -3
  45. package/dist/console.js.map +1 -1
  46. package/dist/data.d.ts.map +1 -1
  47. package/dist/data.js +27 -2
  48. package/dist/data.js.map +1 -1
  49. package/dist/i18n/en.d.ts +13 -0
  50. package/dist/i18n/en.d.ts.map +1 -1
  51. package/dist/i18n/en.js +15 -0
  52. package/dist/i18n/en.js.map +1 -1
  53. package/dist/i18n/ko.d.ts.map +1 -1
  54. package/dist/i18n/ko.js +15 -0
  55. package/dist/i18n/ko.js.map +1 -1
  56. package/dist/index.d.ts +2 -2
  57. package/dist/index.d.ts.map +1 -1
  58. package/dist/index.js +2 -2
  59. package/dist/index.js.map +1 -1
  60. package/dist/router.d.ts +6 -1
  61. package/dist/router.d.ts.map +1 -1
  62. package/dist/router.js +13 -7
  63. package/dist/router.js.map +1 -1
  64. package/dist/rules.js +3 -3
  65. package/dist/rules.js.map +1 -1
  66. package/dist/schemas.d.ts +557 -162
  67. package/dist/schemas.d.ts.map +1 -1
  68. package/dist/schemas.js +72 -158
  69. package/dist/schemas.js.map +1 -1
  70. package/docs/specs/cli.md +12 -12
  71. package/docs/specs/rest.md +7 -15
  72. package/package.json +2 -1
  73. package/skills/deploying-a-console/SKILL.md +2 -2
  74. package/skills/working-the-board/SKILL.md +9 -14
  75. package/skills/working-the-board/references/setting-up.md +3 -0
  76. package/skills/working-the-board/references/terms.md +9 -0
  77. package/skills/working-the-board/references/the-sentence.md +5 -0
  78. package/src/api.ts +2 -7
  79. package/src/cli/cli.ts +113 -62
  80. package/src/cli/format.ts +10 -324
  81. package/src/cli/help.ts +53 -27
  82. package/src/cli/pack.ts +356 -0
  83. package/src/compat.ts +1 -1
  84. package/src/components/decision-list.tsx +227 -94
  85. package/src/components/paged.tsx +72 -3
  86. package/src/components/skills.tsx +51 -33
  87. package/src/components/ui.tsx +16 -3
  88. package/src/components/work.tsx +9 -5
  89. package/src/console.tsx +8 -2
  90. package/src/data.ts +32 -2
  91. package/src/i18n/en.ts +18 -0
  92. package/src/i18n/ko.ts +17 -0
  93. package/src/index.ts +9 -1
  94. package/src/router.ts +20 -9
  95. package/src/rules.ts +3 -3
  96. package/src/schemas.ts +87 -184
  97. package/src/styles/console.css +112 -5
@@ -3,6 +3,7 @@ import {
3
3
  agentLabel,
4
4
  MAX_BODY_LENGTH,
5
5
  MAX_NOTE_LENGTH,
6
+ MAX_OPTION_LABEL_LENGTH,
6
7
  type RestAnswerInput,
7
8
  type RestDecision,
8
9
  type RestDecisionPatch,
@@ -10,8 +11,8 @@ import {
10
11
  } from "../api.js";
11
12
  import { useWords } from "../i18n/index.js";
12
13
  import { Markdown } from "./markdown.js";
13
- import { ShowMore, usePaged } from "./paged.js";
14
- import { Button, cx, PersonChip, Time } from "./ui.js";
14
+ import { Pager, pageOf, RECORD_PAGE_SIZE } from "./paged.js";
15
+ import { Badge, Button, cx, PersonChip, Time } from "./ui.js";
15
16
 
16
17
  // The decisions: the ones that wait, each a card with the way to answer it, since they are what
17
18
  // a person came for; and the ones answered, the record, a row each, since they are read back
@@ -35,6 +36,131 @@ export interface DecisionCardProps {
35
36
  readonly busy?: boolean | undefined;
36
37
  }
37
38
 
39
+ /** Who asked, and when: the person, through their agent when a run asked. */
40
+ function Asked(props: { readonly decision: RestDecision; readonly day?: boolean }) {
41
+ const words = useWords();
42
+ const { decision } = props;
43
+ return (
44
+ <>
45
+ <PersonChip person={decision.raisedBy} />{" "}
46
+ {decision.run === null
47
+ ? words.decisions.asked
48
+ : words.decisions.askedThrough(agentLabel(decision.run.agent))}{" "}
49
+ <Time iso={decision.createdAt} precision={props.day === true ? "day" : "minute"} />
50
+ </>
51
+ );
52
+ }
53
+
54
+ /** What was chosen: the option's label, marked when it is the person's own words. */
55
+ function Chosen(props: { readonly decision: RestDecision }) {
56
+ const words = useWords();
57
+ const { decision } = props;
58
+ const chosen = decision.options.find((option) => option.id === decision.answer?.option);
59
+ return (
60
+ <>
61
+ <span>{chosen?.label ?? decision.answer?.option ?? ""}</span>
62
+ {chosen?.own === true && <Badge>{words.decisions.ownAnswer}</Badge>}
63
+ </>
64
+ );
65
+ }
66
+
67
+ /**
68
+ * The way to answer: one of the options, or a line of one's own, which is chosen by writing
69
+ * on it; and a word about either.
70
+ */
71
+ function AnswerForm(props: {
72
+ readonly decision: RestDecision;
73
+ readonly onAnswer: (decision: RestDecision, input: RestAnswerInput) => void;
74
+ readonly busy: boolean | undefined;
75
+ }) {
76
+ const words = useWords();
77
+ const { decision } = props;
78
+ const [chosen, setChosen] = useState<string | undefined>(undefined);
79
+ const [own, setOwn] = useState(false);
80
+ const [ownText, setOwnText] = useState("");
81
+ const [note, setNote] = useState("");
82
+ const chooseOwn = () => {
83
+ setOwn(true);
84
+ setChosen(undefined);
85
+ };
86
+ const answer: RestAnswerInput | undefined = own
87
+ ? ownText.trim().length > 0
88
+ ? { own: ownText.trim() }
89
+ : undefined
90
+ : chosen === undefined
91
+ ? undefined
92
+ : { option: chosen };
93
+ const name = `decision-${decision.id}`;
94
+ return (
95
+ <form
96
+ className="sc-answer-form"
97
+ onSubmit={(event) => {
98
+ event.preventDefault();
99
+ if (answer !== undefined) {
100
+ props.onAnswer(decision, {
101
+ ...answer,
102
+ ...(note.trim() === "" ? {} : { note: note.trim() }),
103
+ });
104
+ }
105
+ }}
106
+ >
107
+ <div className="sc-options-list" role="radiogroup" aria-label={words.decisions.options}>
108
+ {decision.options.map((option) => (
109
+ <label
110
+ key={option.id}
111
+ className={cx("sc-option", chosen === option.id && "sc-option-chosen")}
112
+ >
113
+ <input
114
+ type="radio"
115
+ name={name}
116
+ value={option.id}
117
+ checked={chosen === option.id}
118
+ onChange={() => {
119
+ setChosen(option.id);
120
+ setOwn(false);
121
+ }}
122
+ />
123
+ <span>{option.label}</span>
124
+ </label>
125
+ ))}
126
+ <label className={cx("sc-option", "sc-option-own", own && "sc-option-chosen")}>
127
+ <input type="radio" name={name} value="own" checked={own} onChange={chooseOwn} />
128
+ <span>{words.decisions.own}</span>
129
+ <input
130
+ className="sc-input"
131
+ aria-label={words.decisions.own}
132
+ placeholder={words.decisions.ownPlaceholder}
133
+ maxLength={MAX_OPTION_LABEL_LENGTH}
134
+ value={ownText}
135
+ onFocus={chooseOwn}
136
+ onChange={(event) => {
137
+ setOwnText(event.target.value);
138
+ chooseOwn();
139
+ }}
140
+ />
141
+ </label>
142
+ </div>
143
+ <input
144
+ className="sc-input"
145
+ placeholder={words.decisions.notePlaceholder}
146
+ maxLength={MAX_NOTE_LENGTH}
147
+ value={note}
148
+ onChange={(event) => setNote(event.target.value)}
149
+ />
150
+ <div className="sc-form-actions">
151
+ <Button
152
+ type="submit"
153
+ variant="primary"
154
+ size="sm"
155
+ disabled={answer === undefined || props.busy === true}
156
+ >
157
+ {words.decisions.answer}
158
+ </Button>
159
+ </div>
160
+ </form>
161
+ );
162
+ }
163
+
38
164
  /** What followed the decision: shown, and written or changed by whoever may. */
39
165
  function Outcome(props: {
40
166
  readonly decision: RestDecision;
@@ -109,11 +235,7 @@ function Outcome(props: {
109
235
  export function DecisionCard(props: DecisionCardProps) {
110
236
  const words = useWords();
111
237
  const { decision, task } = props;
112
- const [chosen, setChosen] = useState<string | undefined>(undefined);
113
- const [note, setNote] = useState("");
114
238
  const answered = decision.answer !== null;
115
- const chosenLabel = (id: string) =>
116
- decision.options.find((option) => option.id === id)?.label ?? id;
117
239
 
118
240
  return (
119
241
  <article id={decision.id} className={cx("sc-decision", answered && "sc-decision-answered")}>
@@ -126,11 +248,7 @@ export function DecisionCard(props: DecisionCardProps) {
126
248
  )}
127
249
  </h3>
128
250
  <p className="sc-decision-meta">
129
- <PersonChip person={decision.raisedBy} />{" "}
130
- {decision.run === null
131
- ? words.decisions.asked
132
- : words.decisions.askedThrough(agentLabel(decision.run.agent))}{" "}
133
- <Time iso={decision.createdAt} />
251
+ <Asked decision={decision} />
134
252
  {task !== undefined && (
135
253
  <>
136
254
  {" "}
@@ -156,7 +274,9 @@ export function DecisionCard(props: DecisionCardProps) {
156
274
  )}
157
275
  {decision.answer !== null ? (
158
276
  <div className="sc-answer">
159
- <p className="sc-answer-option">{chosenLabel(decision.answer.option)}</p>
277
+ <p className="sc-answer-option">
278
+ <Chosen decision={decision} />
279
+ </p>
160
280
  {decision.answer.note !== null && (
161
281
  <div className="sc-answer-note">
162
282
  <Markdown source={decision.answer.note} docHref={props.docHref} />
@@ -170,53 +290,7 @@ export function DecisionCard(props: DecisionCardProps) {
170
290
  ) : props.onAnswer === undefined ? (
171
291
  <p className="sc-decision-waiting">{words.decisions.waitingForPerson}</p>
172
292
  ) : (
173
- <form
174
- className="sc-answer-form"
175
- onSubmit={(event) => {
176
- event.preventDefault();
177
- if (chosen !== undefined) {
178
- props.onAnswer?.(decision, {
179
- option: chosen,
180
- ...(note.trim() === "" ? {} : { note: note.trim() }),
181
- });
182
- }
183
- }}
184
- >
185
- <div className="sc-options-list" role="radiogroup" aria-label={words.decisions.options}>
186
- {decision.options.map((option) => (
187
- <label
188
- key={option.id}
189
- className={cx("sc-option", chosen === option.id && "sc-option-chosen")}
190
- >
191
- <input
192
- type="radio"
193
- name={`decision-${decision.id}`}
194
- value={option.id}
195
- checked={chosen === option.id}
196
- onChange={() => setChosen(option.id)}
197
- />
198
- <span>{option.label}</span>
199
- </label>
200
- ))}
201
- </div>
202
- <input
203
- className="sc-input"
204
- placeholder={words.decisions.notePlaceholder}
205
- maxLength={MAX_NOTE_LENGTH}
206
- value={note}
207
- onChange={(event) => setNote(event.target.value)}
208
- />
209
- <div className="sc-form-actions">
210
- <Button
211
- type="submit"
212
- variant="primary"
213
- size="sm"
214
- disabled={chosen === undefined || props.busy === true}
215
- >
216
- {words.decisions.answer}
217
- </Button>
218
- </div>
219
- </form>
293
+ <AnswerForm decision={decision} onAnswer={props.onAnswer} busy={props.busy} />
220
294
  )}
221
295
  {answered && (
222
296
  <Outcome
@@ -238,6 +312,9 @@ export interface DecisionListProps {
238
312
  readonly docHref?: ((path: string) => string) | undefined;
239
313
  /** Where each decision's own page is. */
240
314
  readonly decisionHref?: ((decision: RestDecision) => string) | undefined;
315
+ /** The page of the record shown, from 1, and where each page is (ADR-0010); left out, the record turns its pages in place. */
316
+ readonly page?: number | undefined;
317
+ readonly pageHref?: ((page: number) => string) | undefined;
241
318
  readonly onAnswer?: ((decision: RestDecision, input: RestAnswerInput) => void) | undefined;
242
319
  readonly onUpdate?: ((decision: RestDecision, patch: RestDecisionPatch) => void) | undefined;
243
320
  readonly busy?: boolean | undefined;
@@ -251,48 +328,101 @@ export interface DecisionLogProps {
251
328
  readonly taskHref?: ((task: RestTask) => string) | undefined;
252
329
  /** Where each decision's own page is, with its context, its rationale and what followed. */
253
330
  readonly decisionHref?: ((decision: RestDecision) => string) | undefined;
331
+ /** Where a document the word given with an answer links to is read, by its path. */
332
+ readonly docHref?: ((path: string) => string) | undefined;
333
+ /** The page shown, from 1; the first when left out, the last for one past the end. Read with `pageHref`. */
334
+ readonly page?: number | undefined;
335
+ /** Where each page of the record is, so that a page has an address; left out, the pages turn in place. */
336
+ readonly pageHref?: ((page: number) => string) | undefined;
254
337
  }
255
338
 
256
- /** The record: one row per decision answered, what was asked, what was chosen, by whom, when. */
339
+ /** One row of the record: what was asked, by whom and when; what was answered, with the word given, by whom and when. */
340
+ function RecordRow(props: {
341
+ readonly decision: RestDecision;
342
+ readonly task: RestTask | undefined;
343
+ readonly taskHref: ((task: RestTask) => string) | undefined;
344
+ readonly href: string | undefined;
345
+ readonly docHref: ((path: string) => string) | undefined;
346
+ }) {
347
+ const words = useWords();
348
+ const { decision, task } = props;
349
+ const { answer } = decision;
350
+ return (
351
+ <li className="sc-decision-record-row">
352
+ <div className="sc-decision-record-asked">
353
+ <p className="sc-decision-record-question">
354
+ {props.href === undefined ? (
355
+ decision.question
356
+ ) : (
357
+ <a href={props.href}>{decision.question}</a>
358
+ )}
359
+ {task !== undefined &&
360
+ (props.taskHref === undefined ? (
361
+ <span className="sc-card-number">#{task.number}</span>
362
+ ) : (
363
+ <a className="sc-card-number" href={props.taskHref(task)}>
364
+ #{task.number}
365
+ </a>
366
+ ))}
367
+ </p>
368
+ <p className="sc-decision-record-meta">
369
+ <Asked decision={decision} day />
370
+ </p>
371
+ </div>
372
+ {answer !== null && (
373
+ <div className="sc-decision-record-answer">
374
+ <p className="sc-decision-record-chosen">
375
+ <Chosen decision={decision} />
376
+ </p>
377
+ {answer.note !== null && (
378
+ <div className="sc-decision-record-note">
379
+ <Markdown source={answer.note} docHref={props.docHref} />
380
+ </div>
381
+ )}
382
+ <p className="sc-decision-record-meta">
383
+ <PersonChip person={answer.by} /> {words.decisions.answeredBy}{" "}
384
+ <Time iso={answer.at} precision="day" />
385
+ </p>
386
+ </div>
387
+ )}
388
+ </li>
389
+ );
390
+ }
391
+
392
+ /**
393
+ * The record: one row per decision answered, what was asked and by whom, what was chosen with
394
+ * the word given and by whom; twenty to a page, the page named by the address or turned here.
395
+ */
257
396
  export function DecisionLog(props: DecisionLogProps) {
258
397
  const words = useWords();
259
- const { shown, rest, more } = usePaged(props.decisions);
398
+ const [turned, setTurned] = useState(1);
399
+ const { shown, page, pages } = pageOf(
400
+ props.decisions,
401
+ props.pageHref === undefined ? turned : props.page,
402
+ RECORD_PAGE_SIZE,
403
+ );
260
404
  return (
261
405
  <>
262
- <ol className="sc-decision-log" aria-label={words.decisions.record}>
263
- {shown.map((decision) => {
264
- const task = decision.taskId === null ? undefined : props.tasks?.get(decision.taskId);
265
- const href = props.decisionHref?.(decision);
266
- const chosen =
267
- decision.options.find((option) => option.id === decision.answer?.option)?.label ??
268
- decision.answer?.option ??
269
- "";
270
- return (
271
- <li key={decision.id} className="sc-decision-row">
272
- <span className="sc-decision-row-question">
273
- {href === undefined ? decision.question : <a href={href}>{decision.question}</a>}
274
- </span>
275
- <span className="sc-decision-row-answer">{chosen}</span>
276
- <span className="sc-decision-row-meta">
277
- {decision.answer !== null && (
278
- <>
279
- <PersonChip person={decision.answer.by} /> <Time iso={decision.answer.at} />
280
- </>
281
- )}
282
- {task !== undefined &&
283
- (props.taskHref === undefined ? (
284
- <span className="sc-card-number">#{task.number}</span>
285
- ) : (
286
- <a className="sc-card-number" href={props.taskHref(task)}>
287
- #{task.number}
288
- </a>
289
- ))}
290
- </span>
291
- </li>
292
- );
293
- })}
406
+ <ol className="sc-decision-record" aria-label={words.decisions.record}>
407
+ {shown.map((decision) => (
408
+ <RecordRow
409
+ key={decision.id}
410
+ decision={decision}
411
+ task={decision.taskId === null ? undefined : props.tasks?.get(decision.taskId)}
412
+ taskHref={props.taskHref}
413
+ href={props.decisionHref?.(decision)}
414
+ docHref={props.docHref}
415
+ />
416
+ ))}
294
417
  </ol>
295
- <ShowMore rest={rest} onMore={more} />
418
+ <Pager
419
+ page={page}
420
+ pages={pages}
421
+ total={props.decisions.length}
422
+ pageSize={RECORD_PAGE_SIZE}
423
+ pageHref={props.pageHref}
424
+ onPage={setTurned}
425
+ />
296
426
  </>
297
427
  );
298
428
  }
@@ -332,6 +462,9 @@ export function DecisionList(props: DecisionListProps) {
332
462
  tasks={props.tasks}
333
463
  taskHref={props.taskHref}
334
464
  decisionHref={props.decisionHref}
465
+ docHref={props.docHref}
466
+ page={props.page}
467
+ pageHref={props.pageHref}
335
468
  />
336
469
  </section>
337
470
  )}
@@ -2,13 +2,17 @@ import { useState } from "react";
2
2
  import { useWords } from "../i18n/index.js";
3
3
  import { Button } from "./ui.js";
4
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.
5
+ // A long list shown a page at a time, from what the page already holds: the feed and the tasks
6
+ // with no day grow by a page on a click; the record of the decisions turns its pages, each with
7
+ // an address. Nothing is asked of the server either way; the API's own pages (`limit`, `offset`)
8
+ // are for the command and scripts.
8
9
 
9
10
  /** How many items a list shows before it asks to be told to show more. */
10
11
  export const PAGE_SIZE = 50;
11
12
 
13
+ /** How many rows the record of the decisions shows on a page: each says what was asked and what was answered. */
14
+ export const RECORD_PAGE_SIZE = 20;
15
+
12
16
  /** The first `shown` items of a list, and the way to show the next page of it. */
13
17
  export function usePaged<T>(
14
18
  items: readonly T[],
@@ -37,3 +41,68 @@ export function ShowMore(props: { readonly rest: number; readonly onMore: () =>
37
41
  </div>
38
42
  );
39
43
  }
44
+
45
+ /**
46
+ * The page `page` of a list, counted from one and `pageSize` long, and which it is of how many:
47
+ * a page past the end, or no page at all, is the last or the first, and an empty list has one.
48
+ */
49
+ export function pageOf<T>(
50
+ items: readonly T[],
51
+ page: number | undefined,
52
+ pageSize = RECORD_PAGE_SIZE,
53
+ ): { readonly shown: readonly T[]; readonly page: number; readonly pages: number } {
54
+ const pages = Math.max(1, Math.ceil(items.length / pageSize));
55
+ const current =
56
+ page === undefined || !Number.isInteger(page) ? 1 : Math.min(Math.max(page, 1), pages);
57
+ return {
58
+ shown: items.slice((current - 1) * pageSize, current * pageSize),
59
+ page: current,
60
+ pages,
61
+ };
62
+ }
63
+
64
+ /**
65
+ * The way between the pages of a list: the page before, which items of how many are shown, the
66
+ * page after; nothing for a list of one page. Given `pageHref`, each page has an address and the
67
+ * steps lead there; else `onPage` turns the page in place.
68
+ */
69
+ export function Pager(props: {
70
+ readonly page: number;
71
+ readonly pages: number;
72
+ readonly total: number;
73
+ readonly pageSize: number;
74
+ readonly pageHref?: ((page: number) => string) | undefined;
75
+ readonly onPage?: ((page: number) => void) | undefined;
76
+ }) {
77
+ const words = useWords().common.pager;
78
+ if (props.pages <= 1) {
79
+ return null;
80
+ }
81
+ const from = (props.page - 1) * props.pageSize + 1;
82
+ const to = Math.min(props.page * props.pageSize, props.total);
83
+ const step = (label: string, target: number, there: boolean) => {
84
+ if (!there) {
85
+ return (
86
+ <span className="sc-button sc-button-secondary sc-button-sm" aria-disabled="true">
87
+ {label}
88
+ </span>
89
+ );
90
+ }
91
+ return props.pageHref === undefined ? (
92
+ <Button variant="secondary" size="sm" onClick={() => props.onPage?.(target)}>
93
+ {label}
94
+ </Button>
95
+ ) : (
96
+ <a className="sc-button sc-button-secondary sc-button-sm" href={props.pageHref(target)}>
97
+ {label}
98
+ </a>
99
+ );
100
+ };
101
+ return (
102
+ <nav className="sc-pager" aria-label={words.label}>
103
+ {step(words.previous, props.page - 1, props.page > 1)}
104
+ <span className="sc-pager-range">{words.range(from, to, props.total)}</span>
105
+ {step(words.next, props.page + 1, props.page < props.pages)}
106
+ </nav>
107
+ );
108
+ }
@@ -2,9 +2,10 @@ import type { RestSkills } from "../api.js";
2
2
  import { useWords } from "../i18n/index.js";
3
3
  import { Callout, EmptyState, Spinner } from "./ui.js";
4
4
 
5
- // The organization's skills, as SkillCDN serves them at the console's address: where a person
6
- // reads each, and what an agent loads it by. Takes its data as props and nothing from the
7
- // network; what the console answered is shown as text, never as HTML.
5
+ // A project's skills, as the pack lists them: at a SkillCDN deployment, where a person reads
6
+ // each and what an agent loads it by; or from a directory on the console's machine (ADR-0031),
7
+ // with nothing to link to. Takes its data as props and nothing from the network; what the
8
+ // console answered is shown as text, never as HTML.
8
9
 
9
10
  export interface SkillListProps {
10
11
  /** What the console answered, or nothing while it has not yet. */
@@ -21,35 +22,48 @@ export function SkillList(props: SkillListProps) {
21
22
  if (skills.status === "unavailable" && skills.address === null) {
22
23
  return <Callout tone="warning">{words.status.unavailable}</Callout>;
23
24
  }
24
- if (skills.status === "none" || skills.address === null || skills.page === null) {
25
+ if (skills.status === "none" || skills.address === null) {
25
26
  return <EmptyState title={words.noAddress.title} body={words.noAddress.body} />;
26
27
  }
27
- const where = (
28
- <>
29
- <p className="sc-lead">
30
- {words.servedBy}{" "}
31
- <a href={skills.source} target="_blank" rel="noopener noreferrer">
32
- SkillCDN
33
- </a>{" "}
34
- {words.at}{" "}
35
- <a href={skills.page} target="_blank" rel="noopener noreferrer">
36
- <code>{skills.address}</code>
37
- </a>
38
- </p>
39
- <p className="sc-muted">
40
- {words.connect}{" "}
41
- <a href={skills.page} target="_blank" rel="noopener noreferrer">
42
- {words.connectLink}
43
- </a>
44
- </p>
45
- </>
46
- );
28
+ const deployment =
29
+ skills.source !== null && skills.page !== null
30
+ ? { source: skills.source, page: skills.page }
31
+ : undefined;
32
+ const local = deployment === undefined;
33
+ const where =
34
+ deployment === undefined ? (
35
+ <>
36
+ <p className="sc-lead">
37
+ {words.fromMachine} <code>{skills.address}</code>
38
+ </p>
39
+ <p className="sc-muted">{words.connectLocal}</p>
40
+ </>
41
+ ) : (
42
+ <>
43
+ <p className="sc-lead">
44
+ {words.servedBy}{" "}
45
+ <a href={deployment.source} target="_blank" rel="noopener noreferrer">
46
+ SkillCDN
47
+ </a>{" "}
48
+ {words.at}{" "}
49
+ <a href={deployment.page} target="_blank" rel="noopener noreferrer">
50
+ <code>{skills.address}</code>
51
+ </a>
52
+ </p>
53
+ <p className="sc-muted">
54
+ {words.connect}{" "}
55
+ <a href={deployment.page} target="_blank" rel="noopener noreferrer">
56
+ {words.connectLink}
57
+ </a>
58
+ </p>
59
+ </>
60
+ );
47
61
  if (skills.status !== "ready") {
48
62
  return (
49
63
  <>
50
64
  {where}
51
65
  <Callout tone={skills.status === "indexing" ? "info" : "warning"}>
52
- {words.status[skills.status]}
66
+ {(local ? words.localStatus[skills.status] : undefined) ?? words.status[skills.status]}
53
67
  </Callout>
54
68
  </>
55
69
  );
@@ -69,14 +83,18 @@ export function SkillList(props: SkillListProps) {
69
83
  {skills.items.map((skill) => (
70
84
  <li key={skill.path} className="sc-skill">
71
85
  <p className="sc-skill-head">
72
- <a
73
- className="sc-skill-name"
74
- href={skill.page}
75
- target="_blank"
76
- rel="noopener noreferrer nofollow"
77
- >
78
- {skill.name}
79
- </a>
86
+ {skill.page === null ? (
87
+ <span className="sc-skill-name">{skill.name}</span>
88
+ ) : (
89
+ <a
90
+ className="sc-skill-name"
91
+ href={skill.page}
92
+ target="_blank"
93
+ rel="noopener noreferrer nofollow"
94
+ >
95
+ {skill.name}
96
+ </a>
97
+ )}
80
98
  <span className="sc-muted">{skill.directory === "" ? "/" : skill.directory}</span>
81
99
  </p>
82
100
  <p className="sc-skill-description">{skill.description}</p>
@@ -9,7 +9,7 @@ import {
9
9
  type TaskPriority,
10
10
  type TaskState,
11
11
  } from "../api.js";
12
- import { daysBetween, formatDay, today } from "../dates.js";
12
+ import { dayOf, daysBetween, formatDay, today } from "../dates.js";
13
13
  import { useLanguage, useWords } from "../i18n/index.js";
14
14
 
15
15
  // Small building blocks. Each one is a class in styles/console.css and nothing more. Classes
@@ -356,11 +356,24 @@ export function formatBytes(size: number): string {
356
356
  return `${mb < 10 ? mb.toFixed(1) : Math.round(mb)} MB`;
357
357
  }
358
358
 
359
- export function Time(props: { readonly iso: string }) {
359
+ export function Time(props: {
360
+ readonly iso: string;
361
+ /** `day` shows the day alone, with the moment as the title, where a row has no room for the hour. */
362
+ readonly precision?: "minute" | "day";
363
+ }) {
360
364
  const { tag } = useLanguage();
365
+ const full = formatInstant(props.iso, tag);
366
+ if (props.precision === "day") {
367
+ const date = new Date(props.iso);
368
+ return (
369
+ <time className="sc-time" dateTime={props.iso} title={full}>
370
+ {Number.isNaN(date.getTime()) ? props.iso : formatDay(dayOf(date), tag)}
371
+ </time>
372
+ );
373
+ }
361
374
  return (
362
375
  <time className="sc-time" dateTime={props.iso}>
363
- {formatInstant(props.iso, tag)}
376
+ {full}
364
377
  </time>
365
378
  );
366
379
  }
@@ -7,14 +7,17 @@ import { TaskList } from "./task-list.js";
7
7
  import { PersonChip, Time } from "./ui.js";
8
8
 
9
9
  // What is on a person's plate (ADR-0019), across the projects they may see: the week at the
10
- // top, with the open tasks assigned to them on its days; below, the same tasks by when they
11
- // are due, and the decisions that wait for a person. Takes its data as props and nothing from
12
- // the network; the week shown is the address's (ADR-0010).
10
+ // top, with the open tasks assigned to them on its days, and what of theirs was done or
11
+ // dropped on them, as a project's calendar shows it; below, the open tasks by when they are
12
+ // due, and the decisions that wait for a person. Takes its data as props and nothing from the
13
+ // network; the week shown is the address's (ADR-0010).
13
14
 
14
15
  export interface WorkListProps {
15
16
  /** The tasks on the plate; and the decisions waiting on the person's own plate, left out on another's. */
16
17
  readonly work: {
17
18
  readonly tasks: RestWork["tasks"];
19
+ /** The person's tasks done or dropped and not yet put away: on the week, not in the lists. */
20
+ readonly closed?: RestWork["closed"] | undefined;
18
21
  readonly decisions?: RestWork["decisions"] | undefined;
19
22
  };
20
23
  /** The day to count from; the browser's today when left out. */
@@ -33,7 +36,8 @@ export function WorkList(props: WorkListProps) {
33
36
  const now = props.today ?? todayOf();
34
37
  const weekEnd = addDays(now, 6);
35
38
  const { tasks, decisions } = props.work;
36
- const projectOf = new Map(tasks.map((item) => [item.task.id, item.project]));
39
+ const closed = props.work.closed ?? [];
40
+ const projectOf = new Map([...tasks, ...closed].map((item) => [item.task.id, item.project]));
37
41
  const hrefOf = (task: RestTask) => props.taskHref(projectOf.get(task.id)?.key ?? "", task);
38
42
  const markOf = (task: RestTask) => {
39
43
  const project = projectOf.get(task.id);
@@ -60,7 +64,7 @@ export function WorkList(props: WorkListProps) {
60
64
  return (
61
65
  <div className="sc-work">
62
66
  <Week
63
- tasks={tasks.map((item) => item.task)}
67
+ tasks={[...tasks, ...closed].map((item) => item.task)}
64
68
  day={props.week}
65
69
  today={now}
66
70
  taskHref={hrefOf}