@skillcdn/console 0.1.10 → 0.1.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (132) hide show
  1. package/README.md +4 -4
  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 +5 -12
  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 +67 -7
  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/projects.d.ts +22 -2
  39. package/dist/components/projects.d.ts.map +1 -1
  40. package/dist/components/projects.js +35 -7
  41. package/dist/components/projects.js.map +1 -1
  42. package/dist/components/runs.d.ts.map +1 -1
  43. package/dist/components/runs.js +2 -2
  44. package/dist/components/runs.js.map +1 -1
  45. package/dist/components/shell.d.ts +2 -1
  46. package/dist/components/shell.d.ts.map +1 -1
  47. package/dist/components/shell.js +1 -1
  48. package/dist/components/shell.js.map +1 -1
  49. package/dist/components/task-list.d.ts +1 -1
  50. package/dist/components/task-list.d.ts.map +1 -1
  51. package/dist/components/task-list.js +6 -4
  52. package/dist/components/task-list.js.map +1 -1
  53. package/dist/components/task-view.js +1 -1
  54. package/dist/components/task-view.js.map +1 -1
  55. package/dist/components/work.d.ts.map +1 -1
  56. package/dist/components/work.js +7 -3
  57. package/dist/components/work.js.map +1 -1
  58. package/dist/console.css +149 -2
  59. package/dist/console.d.ts.map +1 -1
  60. package/dist/console.js +34 -18
  61. package/dist/console.js.map +1 -1
  62. package/dist/data.d.ts +5 -2
  63. package/dist/data.d.ts.map +1 -1
  64. package/dist/data.js +15 -2
  65. package/dist/data.js.map +1 -1
  66. package/dist/i18n/en.d.ts +12 -6
  67. package/dist/i18n/en.d.ts.map +1 -1
  68. package/dist/i18n/en.js +12 -8
  69. package/dist/i18n/en.js.map +1 -1
  70. package/dist/i18n/ko.d.ts.map +1 -1
  71. package/dist/i18n/ko.js +12 -8
  72. package/dist/i18n/ko.js.map +1 -1
  73. package/dist/index.d.ts +3 -2
  74. package/dist/index.d.ts.map +1 -1
  75. package/dist/index.js +3 -2
  76. package/dist/index.js.map +1 -1
  77. package/dist/limits.d.ts +6 -0
  78. package/dist/limits.d.ts.map +1 -1
  79. package/dist/limits.js +6 -0
  80. package/dist/limits.js.map +1 -1
  81. package/dist/marks.d.ts +12 -0
  82. package/dist/marks.d.ts.map +1 -0
  83. package/dist/marks.js +31 -0
  84. package/dist/marks.js.map +1 -0
  85. package/dist/router.d.ts +4 -3
  86. package/dist/router.d.ts.map +1 -1
  87. package/dist/router.js +11 -5
  88. package/dist/router.js.map +1 -1
  89. package/dist/routes.d.ts +7 -0
  90. package/dist/routes.d.ts.map +1 -1
  91. package/dist/routes.js +8 -0
  92. package/dist/routes.js.map +1 -1
  93. package/dist/schemas.d.ts +123 -3
  94. package/dist/schemas.d.ts.map +1 -1
  95. package/dist/schemas.js +42 -10
  96. package/dist/schemas.js.map +1 -1
  97. package/dist/vocabulary.d.ts +10 -4
  98. package/dist/vocabulary.d.ts.map +1 -1
  99. package/dist/vocabulary.js +8 -3
  100. package/dist/vocabulary.js.map +1 -1
  101. package/docs/specs/cli.md +7 -5
  102. package/docs/specs/rest.md +18 -12
  103. package/package.json +1 -1
  104. package/skills/working-the-board/SKILL.md +1 -1
  105. package/src/api.ts +12 -0
  106. package/src/cli/cli.ts +51 -8
  107. package/src/cli/format.ts +8 -6
  108. package/src/cli/help.ts +10 -10
  109. package/src/client.ts +29 -6
  110. package/src/components/board.tsx +5 -38
  111. package/src/components/calendar.tsx +166 -54
  112. package/src/components/decision-list.tsx +38 -33
  113. package/src/components/event-feed.tsx +34 -27
  114. package/src/components/paged.tsx +39 -0
  115. package/src/components/projects.tsx +128 -10
  116. package/src/components/runs.tsx +7 -1
  117. package/src/components/shell.tsx +5 -2
  118. package/src/components/task-list.tsx +35 -32
  119. package/src/components/task-view.tsx +1 -1
  120. package/src/components/work.tsx +12 -2
  121. package/src/console.tsx +54 -20
  122. package/src/data.ts +20 -4
  123. package/src/i18n/en.ts +12 -8
  124. package/src/i18n/ko.ts +12 -8
  125. package/src/index.ts +4 -0
  126. package/src/limits.ts +6 -0
  127. package/src/marks.ts +40 -0
  128. package/src/router.ts +19 -7
  129. package/src/routes.ts +9 -0
  130. package/src/schemas.ts +48 -8
  131. package/src/styles/console.css +149 -2
  132. package/src/vocabulary.ts +14 -4
@@ -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,13 +1,20 @@
1
- import { type FormEvent, type ReactNode, useState } from "react";
1
+ import { type FormEvent, type ReactNode, useEffect, useMemo, useState } from "react";
2
2
  import {
3
+ COLOR_NAMES,
4
+ type ColorName,
3
5
  MAX_PROJECT_DESCRIPTION_LENGTH,
4
6
  MAX_PROJECT_KEY_LENGTH,
5
7
  MAX_PROJECT_NAME_LENGTH,
8
+ MAX_PROJECT_SYMBOL_LENGTH,
6
9
  MAX_SKILLS_ADDRESS_LENGTH,
10
+ PROJECT_ICON_TYPES,
7
11
  PROJECT_ROLES,
8
12
  PROJECT_VISIBILITIES,
9
13
  type ProjectRole,
10
14
  type ProjectVisibility,
15
+ projectColorOf,
16
+ projectIconPath,
17
+ projectSymbolOf,
11
18
  type RestMember,
12
19
  type RestMemberInput,
13
20
  type RestPerson,
@@ -16,7 +23,44 @@ import {
16
23
  type RestProjectPatch,
17
24
  } from "../api.js";
18
25
  import { useWords } from "../i18n/index.js";
19
- import { Badge, Button, PersonChip, Time } from "./ui.js";
26
+ import { Badge, Button, cx, PersonChip, Time } from "./ui.js";
27
+
28
+ /** A project's mark: its symbol on its colour, so that projects tell apart at a glance. */
29
+ export function ProjectMark(props: {
30
+ readonly project: {
31
+ readonly key: string;
32
+ readonly name: string;
33
+ readonly color: ColorName | null;
34
+ readonly symbol: string | null;
35
+ /** The project's own image, when it has one; the symbol on the colour otherwise. */
36
+ readonly icon?: { readonly sha256: string } | null | undefined;
37
+ };
38
+ readonly size?: "sm" | "md" | "lg";
39
+ /** Where the image is read from; the console's own path for it when left out. */
40
+ readonly iconSrc?: string | undefined;
41
+ }) {
42
+ const size = props.size ?? "md";
43
+ const icon = props.project.icon ?? null;
44
+ const src =
45
+ props.iconSrc ?? (icon === null ? undefined : projectIconPath(props.project.key, icon));
46
+ return (
47
+ <span
48
+ className={cx(
49
+ "sc-mark",
50
+ `sc-mark-${size}`,
51
+ `sc-category-${projectColorOf(props.project)}`,
52
+ src !== undefined && "sc-mark-pictured",
53
+ )}
54
+ aria-hidden="true"
55
+ >
56
+ {src === undefined ? (
57
+ projectSymbolOf(props.project)
58
+ ) : (
59
+ <img className="sc-mark-image" src={src} alt="" />
60
+ )}
61
+ </span>
62
+ );
63
+ }
20
64
 
21
65
  // The projects of the workspace (ADR-0008): the list a person may see, the way to make one,
22
66
  // a project's settings for its owners, and those listed in it. Each takes its data as props
@@ -60,6 +104,7 @@ export function ProjectList(props: ProjectListProps) {
60
104
  {props.projects.map((project) => (
61
105
  <li key={project.id} className="sc-project">
62
106
  <div className="sc-project-head">
107
+ <ProjectMark project={project} size="md" />
63
108
  <a
64
109
  className="sc-project-name"
65
110
  href={props.projectHref(project)}
@@ -79,14 +124,11 @@ export function ProjectList(props: ProjectListProps) {
79
124
  {project.description.length > 0 && (
80
125
  <p className="sc-project-description">{project.description}</p>
81
126
  )}
82
- <p className="sc-project-meta">
83
- {project.openDecisions > 0 && (
127
+ {project.openDecisions > 0 && (
128
+ <p className="sc-project-meta">
84
129
  <Badge tone="warning">{words.decisionsWaiting(project.openDecisions)}</Badge>
85
- )}
86
- {project.openRuns > 0 && (
87
- <Badge tone="accent">{words.agentsAtWork(project.openRuns)}</Badge>
88
- )}
89
- </p>
130
+ </p>
131
+ )}
90
132
  </li>
91
133
  ))}
92
134
  </ul>
@@ -98,7 +140,11 @@ export interface ProjectFormProps {
98
140
  readonly project?: RestProject | undefined;
99
141
  readonly busy?: boolean | undefined;
100
142
  readonly error?: string | undefined;
101
- readonly onSubmit: (input: RestProjectInput | RestProjectPatch) => void;
143
+ /**
144
+ * Called with the settings as they are to be, and the image for the mark: a file chosen,
145
+ * `null` to take the image away, or nothing to keep what the project has.
146
+ */
147
+ readonly onSubmit: (input: RestProjectInput | RestProjectPatch, icon?: File | null) => void;
102
148
  readonly onCancel?: (() => void) | undefined;
103
149
  }
104
150
 
@@ -121,6 +167,31 @@ export function ProjectForm(props: ProjectFormProps) {
121
167
  const [description, setDescription] = useState(project?.description ?? "");
122
168
  const [visibility, setVisibility] = useState<ProjectVisibility>(project?.visibility ?? "private");
123
169
  const [skillsAddress, setSkillsAddress] = useState(project?.skillsAddress ?? "");
170
+ const [color, setColor] = useState<ColorName | "">(project?.color ?? "");
171
+ const [symbol, setSymbol] = useState(project?.symbol ?? "");
172
+ /** The image for the mark: a file chosen, `null` for none any more, nothing to keep the project's. */
173
+ const [icon, setIcon] = useState<File | null | undefined>(undefined);
174
+ const chosenSrc = useMemo(
175
+ () => (icon instanceof File ? URL.createObjectURL(icon) : undefined),
176
+ [icon],
177
+ );
178
+ useEffect(
179
+ () => () => {
180
+ if (chosenSrc !== undefined) {
181
+ URL.revokeObjectURL(chosenSrc);
182
+ }
183
+ },
184
+ [chosenSrc],
185
+ );
186
+ const kept = icon === undefined ? (project?.icon ?? null) : null;
187
+ /** The mark as it will be, from what is typed and chosen so far. */
188
+ const preview = {
189
+ key: key.trim() === "" ? keyOf(name) : key.trim(),
190
+ name: name.trim() === "" ? "?" : name.trim(),
191
+ color: color === "" ? null : color,
192
+ symbol: symbol.trim() === "" ? null : symbol.trim(),
193
+ icon: icon instanceof File ? { sha256: "chosen" } : kept,
194
+ };
124
195
 
125
196
  const submit = (event: FormEvent) => {
126
197
  event.preventDefault();
@@ -129,9 +200,12 @@ export function ProjectForm(props: ProjectFormProps) {
129
200
  description: description.trim(),
130
201
  visibility,
131
202
  skillsAddress: skillsAddress.trim() === "" ? null : skillsAddress.trim(),
203
+ color: preview.color,
204
+ symbol: preview.symbol,
132
205
  };
133
206
  props.onSubmit(
134
207
  project === undefined ? { key: key.trim(), ...settings, name: name.trim() } : settings,
208
+ icon,
135
209
  );
136
210
  };
137
211
 
@@ -207,6 +281,50 @@ export function ProjectForm(props: ProjectFormProps) {
207
281
  />
208
282
  </label>
209
283
  </div>
284
+ <div className="sc-field-row sc-mark-row">
285
+ <ProjectMark project={preview} size="lg" iconSrc={chosenSrc} />
286
+ <label className="sc-field">
287
+ <span className="sc-field-label">{form.symbol}</span>
288
+ <input
289
+ className="sc-input"
290
+ value={symbol}
291
+ maxLength={MAX_PROJECT_SYMBOL_LENGTH}
292
+ pattern="\S*"
293
+ placeholder={projectSymbolOf({ name: preview.name, symbol: null })}
294
+ onChange={(event) => setSymbol(event.target.value)}
295
+ />
296
+ </label>
297
+ <label className="sc-field">
298
+ <span className="sc-field-label">{form.color}</span>
299
+ <select
300
+ className="sc-input"
301
+ value={color}
302
+ onChange={(event) => setColor(event.target.value as ColorName | "")}
303
+ >
304
+ <option value="">{form.colorAuto}</option>
305
+ {COLOR_NAMES.map((candidate) => (
306
+ <option key={candidate} value={candidate}>
307
+ {words.vocabulary.categoryColor[candidate]}
308
+ </option>
309
+ ))}
310
+ </select>
311
+ </label>
312
+ <label className="sc-field">
313
+ <span className="sc-field-label">{form.image}</span>
314
+ <input
315
+ key={icon === null ? "none" : "some"}
316
+ className="sc-input sc-input-file"
317
+ type="file"
318
+ accept={PROJECT_ICON_TYPES.join(",")}
319
+ onChange={(event) => setIcon(event.target.files?.[0] ?? undefined)}
320
+ />
321
+ </label>
322
+ {(icon instanceof File || kept !== null) && (
323
+ <Button variant="ghost" size="sm" onClick={() => setIcon(null)}>
324
+ {form.imageRemove}
325
+ </Button>
326
+ )}
327
+ </div>
210
328
  {props.error !== undefined && (
211
329
  <p className="sc-form-error" role="alert">
212
330
  {props.error}
@@ -9,7 +9,7 @@ import { Badge, Button, formatBytes, PersonChip, Time } from "./ui.js";
9
9
 
10
10
  /** The words of a run's status in English: the default pack's. */
11
11
  export const RUN_STATUS_LABELS: Readonly<Record<RunStatus, string>> = {
12
- running: "At work",
12
+ running: "Open",
13
13
  waiting: "Waiting for a decision",
14
14
  finished: "Finished",
15
15
  failed: "Failed",
@@ -61,6 +61,12 @@ export function RunCard(props: RunCardProps) {
61
61
  {words.until} <Time iso={run.endedAt} />
62
62
  </>
63
63
  )}
64
+ {open && (
65
+ <>
66
+ {" "}
67
+ · {words.lastHeard} <Time iso={run.lastHeardAt} />
68
+ </>
69
+ )}
64
70
  </span>
65
71
  {open && props.onAbandon !== undefined && (
66
72
  <Button
@@ -34,8 +34,10 @@ export interface ShellProps {
34
34
  readonly title: string;
35
35
  /** The marks to show beside the name; none shows the name alone. */
36
36
  readonly brand?: Brand | undefined;
37
- /** The project the page is on, named after the workspace, or nothing on the workspace's own pages. */
38
- readonly project?: { readonly name: string; readonly href: string } | undefined;
37
+ /** The project the page is on, named after the workspace, with its mark when the page has it, or nothing on the workspace's own pages. */
38
+ readonly project?:
39
+ | { readonly name: string; readonly href: string; readonly mark?: ReactNode }
40
+ | undefined;
39
41
  /** The pages of the level the person is on, as tabs: the project's, or the workspace's. */
40
42
  readonly nav: readonly NavItem[];
41
43
  /** The workspace's pages, reached from anywhere through the person's menu. */
@@ -228,6 +230,7 @@ export function Shell(props: ShellProps) {
228
230
  href={props.project.href}
229
231
  onClick={follow(props.project.href)}
230
232
  >
233
+ {props.project.mark}
231
234
  {props.project.name}
232
235
  </a>
233
236
  </span>
@@ -1,11 +1,13 @@
1
1
  import type { ReactNode } from "react";
2
2
  import type { RestTask } from "../api.js";
3
3
  import { useWords } from "../i18n/index.js";
4
+ import { ShowMore, usePaged } from "./paged.js";
4
5
  import { Avatar, Badge, CategoryBadge, DueBadge, StateBadge } from "./ui.js";
5
6
 
6
7
  // A list of tasks as rows, where the board's columns are not the point: the calendar's late
7
8
  // and unscheduled tasks, a goal's tasks, a person's own work. Each row says what a card says,
8
- // in one line. Takes its data as props and nothing from the network.
9
+ // in one line; a long list shows a page at a time. Takes its data as props and nothing from
10
+ // the network.
9
11
 
10
12
  export interface TaskListProps {
11
13
  readonly tasks: readonly RestTask[];
@@ -13,7 +15,7 @@ export interface TaskListProps {
13
15
  /** Whether each row says the task's state: where the rows are not grouped by it. */
14
16
  readonly showState?: boolean | undefined;
15
17
  /** What the row says the task belongs to, where tasks of several projects are listed together. */
16
- readonly projectOf?: ((task: RestTask) => string | undefined) | undefined;
18
+ readonly projectOf?: ((task: RestTask) => ReactNode) | undefined;
17
19
  /** The day to count due days from; the browser's today when left out. */
18
20
  readonly today?: string | undefined;
19
21
  readonly label?: string | undefined;
@@ -22,40 +24,41 @@ export interface TaskListProps {
22
24
 
23
25
  export function TaskList(props: TaskListProps) {
24
26
  const words = useWords();
27
+ const { shown, rest, more } = usePaged(props.tasks);
25
28
  if (props.tasks.length === 0) {
26
29
  return <>{props.empty ?? null}</>;
27
30
  }
28
31
  return (
29
- <ul className="sc-task-rows" aria-label={props.label}>
30
- {props.tasks.map((task) => {
31
- const project = props.projectOf?.(task);
32
- return (
33
- <li key={task.id} className="sc-task-row">
34
- <a className="sc-task-row-title" href={props.taskHref(task)}>
35
- <span className="sc-card-number">#{task.number}</span> {task.title}
36
- </a>
37
- {project !== undefined && <span className="sc-task-row-project">{project}</span>}
38
- <span className="sc-task-row-meta">
39
- {task.category !== null && <CategoryBadge category={task.category} />}
40
- {props.showState === true && <StateBadge state={task.state} />}
41
- {task.openDecisions > 0 && (
42
- <Badge tone="warning" title={words.board.decisionsTitle}>
43
- {words.board.decisions(task.openDecisions)}
44
- </Badge>
32
+ <>
33
+ <ul className="sc-task-rows" aria-label={props.label}>
34
+ {shown.map((task) => {
35
+ const project = props.projectOf?.(task);
36
+ return (
37
+ <li key={task.id} className="sc-task-row">
38
+ <a className="sc-task-row-title" href={props.taskHref(task)}>
39
+ <span className="sc-card-number">#{task.number}</span> {task.title}
40
+ </a>
41
+ {project !== undefined && project !== null && (
42
+ <span className="sc-task-row-project">{project}</span>
45
43
  )}
46
- {task.openRuns > 0 && (
47
- <Badge tone="accent" title={words.board.agentAtWorkTitle}>
48
- {words.board.agentAtWork}
49
- </Badge>
50
- )}
51
- {task.dueOn !== null && (
52
- <DueBadge dueOn={task.dueOn} state={task.state} today={props.today} />
53
- )}
54
- {task.assignee !== null && <Avatar person={task.assignee} size="sm" />}
55
- </span>
56
- </li>
57
- );
58
- })}
59
- </ul>
44
+ <span className="sc-task-row-meta">
45
+ {task.category !== null && <CategoryBadge category={task.category} />}
46
+ {props.showState === true && <StateBadge state={task.state} />}
47
+ {task.openDecisions > 0 && (
48
+ <Badge tone="warning" title={words.board.decisionsTitle}>
49
+ {words.board.decisions(task.openDecisions)}
50
+ </Badge>
51
+ )}
52
+ {task.dueOn !== null && (
53
+ <DueBadge dueOn={task.dueOn} state={task.state} today={props.today} />
54
+ )}
55
+ {task.assignee !== null && <Avatar person={task.assignee} size="sm" />}
56
+ </span>
57
+ </li>
58
+ );
59
+ })}
60
+ </ul>
61
+ <ShowMore rest={rest} onMore={more} />
62
+ </>
60
63
  );
61
64
  }
@@ -177,7 +177,7 @@ export function TaskView(props: TaskViewProps) {
177
177
  )}
178
178
  {runs.length > 0 && (
179
179
  <section className="sc-task-section" aria-label={words.task.runs}>
180
- <h2 className="sc-section-title">{words.task.agentsAtWork}</h2>
180
+ <h2 className="sc-section-title">{words.task.runs}</h2>
181
181
  <RunList
182
182
  runs={runs}
183
183
  decisionHref={props.decisionHref}
@@ -1,6 +1,7 @@
1
1
  import type { RestTask, RestWork } from "../api.js";
2
2
  import { addDays, today as todayOf } from "../dates.js";
3
3
  import { useWords } from "../i18n/index.js";
4
+ import { ProjectMark } from "./projects.js";
4
5
  import { TaskList } from "./task-list.js";
5
6
  import { PersonChip, Time } from "./ui.js";
6
7
 
@@ -24,7 +25,14 @@ export function WorkList(props: WorkListProps) {
24
25
  const { tasks, decisions } = props.work;
25
26
  const projectOf = new Map(tasks.map((item) => [item.task.id, item.project]));
26
27
  const hrefOf = (task: RestTask) => props.taskHref(projectOf.get(task.id)?.key ?? "", task);
27
- const nameOf = (task: RestTask) => projectOf.get(task.id)?.name;
28
+ const nameOf = (task: RestTask) => {
29
+ const project = projectOf.get(task.id);
30
+ return project === undefined ? undefined : (
31
+ <>
32
+ <ProjectMark project={project} size="sm" /> {project.name}
33
+ </>
34
+ );
35
+ };
28
36
  const groups: { readonly title: string; readonly tasks: RestTask[] }[] = [
29
37
  { title: work.overdue, tasks: [] },
30
38
  { title: work.thisWeek, tasks: [] },
@@ -69,7 +77,9 @@ export function WorkList(props: WorkListProps) {
69
77
  >
70
78
  {decision.question}
71
79
  </a>
72
- <span className="sc-task-row-project">{work.inProject(project.name)}</span>
80
+ <span className="sc-task-row-project">
81
+ <ProjectMark project={project} size="sm" /> {work.inProject(project.name)}
82
+ </span>
73
83
  <span className="sc-decision-row-meta">
74
84
  <PersonChip person={decision.raisedBy} /> <Time iso={decision.createdAt} />
75
85
  </span>
package/src/console.tsx CHANGED
@@ -3,6 +3,7 @@ import { createRoot, type Root } from "react-dom/client";
3
3
  import {
4
4
  type ConsoleClient,
5
5
  createClient,
6
+ type FileUpload,
6
7
  OPEN_TASK_STATES,
7
8
  type RestAnswerInput,
8
9
  type RestDecision,
@@ -34,6 +35,7 @@ import {
34
35
  ProjectForm,
35
36
  ProjectList,
36
37
  type ProjectListProps,
38
+ ProjectMark,
37
39
  } from "./components/projects.js";
38
40
  import {
39
41
  type Brand,
@@ -354,9 +356,12 @@ function ProjectsPage(props: {
354
356
  <section className="sc-panel sc-panel-form" aria-label={words.form.aria}>
355
357
  <ProjectForm
356
358
  busy={busy}
357
- onSubmit={(input) =>
359
+ onSubmit={(input, icon) =>
358
360
  void act(async () => {
359
- const made = await data.actions.createProject(input as RestProjectInput);
361
+ const made = await data.actions.createProject(
362
+ input as RestProjectInput,
363
+ icon instanceof File ? uploadOf(icon) : undefined,
364
+ );
360
365
  navigation.replace(projectHref(made.key));
361
366
  })
362
367
  }
@@ -532,7 +537,6 @@ function ProjectPage(props: {
532
537
  )}
533
538
  <components.Board
534
539
  tasks={shown}
535
- runs={data.runs}
536
540
  taskHref={href}
537
541
  onOpen={(task) => navigation.go(href(task))}
538
542
  onMove={onMove}
@@ -592,7 +596,9 @@ function ProjectPage(props: {
592
596
  if (route.name === "calendar") {
593
597
  const now = today();
594
598
  const month = route.month ?? monthOf(now);
595
- const open = data.tasks.filter((task) => OPEN_TASK_STATES.includes(task.state));
599
+ const me = data.me?.person ?? undefined;
600
+ const shown = filterTasks(data.tasks, route.filter, me);
601
+ const open = shown.filter((task) => OPEN_TASK_STATES.includes(task.state));
596
602
  const overdue = open.filter((task) => task.dueOn !== null && task.dueOn < now).sort(byDue);
597
603
  const unscheduled = open.filter((task) => task.dueOn === null).sort(byDue);
598
604
  const calendar = words.calendar;
@@ -605,12 +611,25 @@ function ProjectPage(props: {
605
611
  </LinkButton>
606
612
  </div>
607
613
  <p className="sc-lead">{calendar.lead}</p>
614
+ {data.tasks.length > 0 && (
615
+ <BoardFilters
616
+ filter={route.filter}
617
+ categories={data.categories}
618
+ goals={data.goals}
619
+ people={data.people}
620
+ me={me}
621
+ shown={shown.length}
622
+ total={data.tasks.length}
623
+ onChange={(filter) => navigation.replace(calendarHref(key, route.month, filter))}
624
+ />
625
+ )}
608
626
  <components.Calendar
609
- tasks={data.tasks}
627
+ tasks={shown}
610
628
  month={month}
611
629
  today={now}
630
+ categories={data.categories}
612
631
  taskHref={href}
613
- monthHref={(wanted) => calendarHref(key, wanted)}
632
+ monthHref={(wanted) => calendarHref(key, wanted, route.filter)}
614
633
  />
615
634
  <div className="sc-calendar-below">
616
635
  <section className="sc-task-section" aria-label={calendar.overdue}>
@@ -643,7 +662,7 @@ function ProjectPage(props: {
643
662
  <h2 className="sc-section-title">{calendar.workload.title}</h2>
644
663
  <p className="sc-lead">{calendar.workload.lead}</p>
645
664
  <Workload
646
- tasks={data.tasks}
665
+ tasks={shown}
647
666
  today={now}
648
667
  personHref={(person) =>
649
668
  boardHref(key, person === undefined ? {} : { person: person.id })
@@ -950,8 +969,15 @@ function ProjectPage(props: {
950
969
  <ProjectForm
951
970
  project={project}
952
971
  busy={busy}
953
- onSubmit={(input) =>
954
- void act(() => data.actions.updateProject(input as RestProjectPatch))
972
+ onSubmit={(input, icon) =>
973
+ void act(async () => {
974
+ await data.actions.updateProject(input as RestProjectPatch);
975
+ if (icon === null) {
976
+ await data.actions.removeProjectIcon();
977
+ } else if (icon !== undefined) {
978
+ await data.actions.setProjectIcon(uploadOf(icon));
979
+ }
980
+ })
955
981
  }
956
982
  />
957
983
  </section>
@@ -961,6 +987,13 @@ function ProjectPage(props: {
961
987
  return <NothingHere />;
962
988
  }
963
989
 
990
+ /** A file a person chose, as the client sends it: its name, its bytes and the type the browser says. */
991
+ const uploadOf = (file: File): FileUpload => ({
992
+ name: file.name,
993
+ bytes: file,
994
+ ...(file.type === "" ? {} : { contentType: file.type }),
995
+ });
996
+
964
997
  function NothingHere() {
965
998
  const words = useWords();
966
999
  return (
@@ -1324,16 +1357,12 @@ export function createConsole(config: ConsoleConfig = {}): ConsoleApp {
1324
1357
  current: route.name === "agents" || route.name === "new-token",
1325
1358
  },
1326
1359
  ]),
1327
- // The categories are read by everyone and kept by an administrator; only they need the page.
1328
- ...(data.me.person.role === "admin" && !viaToken
1329
- ? [
1330
- {
1331
- href: PATHS.categories,
1332
- label: words.nav.categories,
1333
- current: route.name === "categories",
1334
- },
1335
- ]
1336
- : []),
1360
+ // The categories are read by everyone, for what the colours mean, and kept by an administrator.
1361
+ {
1362
+ href: PATHS.categories,
1363
+ label: words.nav.categories,
1364
+ current: route.name === "categories",
1365
+ },
1337
1366
  ];
1338
1367
  const nav: NavItem[] =
1339
1368
  projectKey === undefined
@@ -1454,7 +1483,12 @@ export function createConsole(config: ConsoleConfig = {}): ConsoleApp {
1454
1483
  project={
1455
1484
  projectKey === undefined
1456
1485
  ? undefined
1457
- : { name: project?.name ?? projectKey, href: projectHref(projectKey) }
1486
+ : {
1487
+ name: project?.name ?? projectKey,
1488
+ href: projectHref(projectKey),
1489
+ mark:
1490
+ project === undefined ? undefined : <ProjectMark project={project} size="sm" />,
1491
+ }
1458
1492
  }
1459
1493
  nav={nav}
1460
1494
  menu={workspaceNav}