@skillcdn/console 0.1.9 → 0.1.10

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 (138) hide show
  1. package/README.md +5 -5
  2. package/dist/api.d.ts +5 -5
  3. package/dist/api.d.ts.map +1 -1
  4. package/dist/api.js +4 -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 +255 -10
  8. package/dist/cli/cli.js.map +1 -1
  9. package/dist/cli/format.d.ts +9 -1
  10. package/dist/cli/format.d.ts.map +1 -1
  11. package/dist/cli/format.js +80 -0
  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 +62 -7
  15. package/dist/cli/help.js.map +1 -1
  16. package/dist/client.d.ts +31 -4
  17. package/dist/client.d.ts.map +1 -1
  18. package/dist/client.js +19 -3
  19. package/dist/client.js.map +1 -1
  20. package/dist/components/board.d.ts +27 -1
  21. package/dist/components/board.d.ts.map +1 -1
  22. package/dist/components/board.js +75 -7
  23. package/dist/components/board.js.map +1 -1
  24. package/dist/components/calendar.d.ts +34 -0
  25. package/dist/components/calendar.d.ts.map +1 -0
  26. package/dist/components/calendar.js +96 -0
  27. package/dist/components/calendar.js.map +1 -0
  28. package/dist/components/categories.d.ts +13 -0
  29. package/dist/components/categories.d.ts.map +1 -0
  30. package/dist/components/categories.js +52 -0
  31. package/dist/components/categories.js.map +1 -0
  32. package/dist/components/decision-list.d.ts +10 -0
  33. package/dist/components/decision-list.d.ts.map +1 -1
  34. package/dist/components/decision-list.js +14 -1
  35. package/dist/components/decision-list.js.map +1 -1
  36. package/dist/components/event-feed.d.ts.map +1 -1
  37. package/dist/components/event-feed.js +12 -0
  38. package/dist/components/event-feed.js.map +1 -1
  39. package/dist/components/goals.d.ts +35 -0
  40. package/dist/components/goals.d.ts.map +1 -0
  41. package/dist/components/goals.js +45 -0
  42. package/dist/components/goals.js.map +1 -0
  43. package/dist/components/task-form.d.ts +5 -1
  44. package/dist/components/task-form.d.ts.map +1 -1
  45. package/dist/components/task-form.js +13 -1
  46. package/dist/components/task-form.js.map +1 -1
  47. package/dist/components/task-list.d.ts +16 -0
  48. package/dist/components/task-list.d.ts.map +1 -0
  49. package/dist/components/task-list.js +14 -0
  50. package/dist/components/task-list.js.map +1 -0
  51. package/dist/components/task-view.d.ts +9 -1
  52. package/dist/components/task-view.d.ts.map +1 -1
  53. package/dist/components/task-view.js +10 -4
  54. package/dist/components/task-view.js.map +1 -1
  55. package/dist/components/ui.d.ts +30 -1
  56. package/dist/components/ui.d.ts.map +1 -1
  57. package/dist/components/ui.js +60 -0
  58. package/dist/components/ui.js.map +1 -1
  59. package/dist/components/work.d.ts +10 -0
  60. package/dist/components/work.d.ts.map +1 -0
  61. package/dist/components/work.js +29 -0
  62. package/dist/components/work.js.map +1 -0
  63. package/dist/console.css +511 -0
  64. package/dist/console.d.ts +8 -0
  65. package/dist/console.d.ts.map +1 -1
  66. package/dist/console.js +124 -8
  67. package/dist/console.js.map +1 -1
  68. package/dist/data.d.ts +14 -1
  69. package/dist/data.d.ts.map +1 -1
  70. package/dist/data.js +36 -2
  71. package/dist/data.js.map +1 -1
  72. package/dist/dates.d.ts +39 -0
  73. package/dist/dates.d.ts.map +1 -0
  74. package/dist/dates.js +120 -0
  75. package/dist/dates.js.map +1 -0
  76. package/dist/i18n/en.d.ts +162 -1
  77. package/dist/i18n/en.d.ts.map +1 -1
  78. package/dist/i18n/en.js +174 -1
  79. package/dist/i18n/en.js.map +1 -1
  80. package/dist/i18n/ko.d.ts.map +1 -1
  81. package/dist/i18n/ko.js +169 -1
  82. package/dist/i18n/ko.js.map +1 -1
  83. package/dist/index.d.ts +10 -4
  84. package/dist/index.d.ts.map +1 -1
  85. package/dist/index.js +10 -4
  86. package/dist/index.js.map +1 -1
  87. package/dist/limits.d.ts +5 -0
  88. package/dist/limits.d.ts.map +1 -1
  89. package/dist/limits.js +5 -0
  90. package/dist/limits.js.map +1 -1
  91. package/dist/router.d.ts +50 -1
  92. package/dist/router.d.ts.map +1 -1
  93. package/dist/router.js +74 -1
  94. package/dist/router.js.map +1 -1
  95. package/dist/routes.d.ts +7 -1
  96. package/dist/routes.d.ts.map +1 -1
  97. package/dist/routes.js +9 -0
  98. package/dist/routes.js.map +1 -1
  99. package/dist/schemas.d.ts +366 -0
  100. package/dist/schemas.d.ts.map +1 -1
  101. package/dist/schemas.js +112 -3
  102. package/dist/schemas.js.map +1 -1
  103. package/dist/vocabulary.d.ts +15 -1
  104. package/dist/vocabulary.d.ts.map +1 -1
  105. package/dist/vocabulary.js +26 -0
  106. package/dist/vocabulary.js.map +1 -1
  107. package/docs/specs/cli.md +11 -4
  108. package/docs/specs/rest.md +33 -8
  109. package/package.json +1 -1
  110. package/skills/working-the-board/SKILL.md +7 -6
  111. package/src/api.ts +30 -0
  112. package/src/cli/cli.ts +311 -10
  113. package/src/cli/format.ts +88 -0
  114. package/src/cli/help.ts +69 -7
  115. package/src/client.ts +70 -2
  116. package/src/components/board.tsx +205 -12
  117. package/src/components/calendar.tsx +255 -0
  118. package/src/components/categories.tsx +209 -0
  119. package/src/components/decision-list.tsx +62 -5
  120. package/src/components/event-feed.tsx +12 -0
  121. package/src/components/goals.tsx +264 -0
  122. package/src/components/task-form.tsx +78 -1
  123. package/src/components/task-list.tsx +61 -0
  124. package/src/components/task-view.tsx +74 -3
  125. package/src/components/ui.tsx +116 -1
  126. package/src/components/work.tsx +83 -0
  127. package/src/console.tsx +337 -4
  128. package/src/data.ts +63 -8
  129. package/src/dates.ts +143 -0
  130. package/src/i18n/en.ts +184 -1
  131. package/src/i18n/ko.ts +175 -1
  132. package/src/index.ts +58 -1
  133. package/src/limits.ts +5 -0
  134. package/src/router.ts +119 -6
  135. package/src/routes.ts +10 -0
  136. package/src/schemas.ts +141 -1
  137. package/src/styles/console.css +511 -0
  138. package/src/vocabulary.ts +33 -0
package/src/client.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import {
2
2
  AUTH_ROUTES,
3
3
  CLAIM_PATH,
4
+ categoryPath,
4
5
  connectPath,
5
6
  personTokensPath,
6
7
  projectPath,
@@ -10,6 +11,10 @@ import {
10
11
  import {
11
12
  type RestAnswerInput,
12
13
  type RestArtifactInput,
14
+ type RestCategories,
15
+ type RestCategory,
16
+ type RestCategoryInput,
17
+ type RestCategoryPatch,
13
18
  type RestClaim,
14
19
  type RestConnectInput,
15
20
  type RestConnection,
@@ -22,6 +27,10 @@ import {
22
27
  type RestDocumentInput,
23
28
  type RestDocuments,
24
29
  type RestEvents,
30
+ type RestGoal,
31
+ type RestGoalInput,
32
+ type RestGoalPatch,
33
+ type RestGoals,
25
34
  type RestMe,
26
35
  type RestMember,
27
36
  type RestMemberInput,
@@ -50,6 +59,9 @@ import {
50
59
  type RestTokens,
51
60
  type RestVersion,
52
61
  type RestVersions,
62
+ type RestWork,
63
+ restCategoriesSchema,
64
+ restCategorySchema,
53
65
  restClaimSchema,
54
66
  restConnectionSchema,
55
67
  restConnectRequestSchema,
@@ -59,6 +71,8 @@ import {
59
71
  restDocumentsSchema,
60
72
  restErrorSchema,
61
73
  restEventsSchema,
74
+ restGoalSchema,
75
+ restGoalsSchema,
62
76
  restMemberSchema,
63
77
  restMembersSchema,
64
78
  restMeSchema,
@@ -75,6 +89,7 @@ import {
75
89
  restTokensSchema,
76
90
  restVersionSchema,
77
91
  restVersionsSchema,
92
+ restWorkSchema,
78
93
  } from "./schemas.js";
79
94
  import type { TaskState } from "./vocabulary.js";
80
95
 
@@ -135,6 +150,21 @@ export interface EventFilter {
135
150
  readonly document?: string | undefined;
136
151
  }
137
152
 
153
+ /** What the board is narrowed to: one state, the open ones, a category, a goal, an assignee, or what is due by a day. */
154
+ export interface TaskFilter {
155
+ readonly state?: TaskState | undefined;
156
+ /** True for the tasks not done or dropped; false for the rest. */
157
+ readonly open?: boolean | undefined;
158
+ /** The key of a category. */
159
+ readonly category?: string | undefined;
160
+ /** The id of a goal. */
161
+ readonly goal?: string | undefined;
162
+ /** The id of the person the tasks are assigned to. */
163
+ readonly assignee?: string | undefined;
164
+ /** A day (`2026-10-20`): the tasks due on it or before it. */
165
+ readonly dueBy?: string | undefined;
166
+ }
167
+
138
168
  /** What a folder's listing, or a search, of the documents is narrowed to. */
139
169
  export interface DocumentFilter {
140
170
  /** The folder to list, `""` or left out for the root. */
@@ -159,7 +189,7 @@ export interface ProjectClient {
159
189
  addMember(input: RestMemberInput): Promise<RestMember>;
160
190
  updateMember(personId: string, patch: RestMemberPatch): Promise<RestMember>;
161
191
  removeMember(personId: string): Promise<void>;
162
- tasks(filter?: { readonly state?: TaskState }, signal?: AbortSignal): Promise<RestTasks>;
192
+ tasks(filter?: TaskFilter, signal?: AbortSignal): Promise<RestTasks>;
163
193
  /** One task, by its id or by its number. */
164
194
  task(ref: string, signal?: AbortSignal): Promise<RestTask>;
165
195
  createTask(input: RestTaskInput): Promise<RestTask>;
@@ -221,6 +251,12 @@ export interface ProjectClient {
221
251
  attachFile(path: string, input: FileUpload): Promise<RestDocument>;
222
252
  /** Where the bytes of a file attached to a document are read, by the file's id. */
223
253
  documentFileUrl(path: string, fileId: string): string;
254
+ /** The roadmap (ADR-0019): the goals, the active first, each with its tasks counted by state. */
255
+ goals(signal?: AbortSignal): Promise<RestGoals>;
256
+ goal(id: string, signal?: AbortSignal): Promise<RestGoal>;
257
+ /** Sets a goal; for an owner, with a session or a token. */
258
+ createGoal(input: RestGoalInput): Promise<RestGoal>;
259
+ updateGoal(id: string, patch: RestGoalPatch): Promise<RestGoal>;
224
260
  }
225
261
 
226
262
  export interface ConsoleClient {
@@ -238,6 +274,15 @@ export interface ConsoleClient {
238
274
  project(key: string): ProjectClient;
239
275
  /** The workspace's own events, the ones about no project, after event number `after`. */
240
276
  events(after: number, signal?: AbortSignal): Promise<RestEvents>;
277
+ /** The categories tasks are sorted into (ADR-0019), by name. */
278
+ categories(signal?: AbortSignal): Promise<RestCategories>;
279
+ /** Makes a category; for an administrator signed in. */
280
+ createCategory(input: RestCategoryInput): Promise<RestCategory>;
281
+ updateCategory(key: string, patch: RestCategoryPatch): Promise<RestCategory>;
282
+ /** Removes a category no task uses; for an administrator signed in. */
283
+ removeCategory(key: string): Promise<void>;
284
+ /** What is on the asker's plate across the projects they may see: their open tasks, and the decisions waiting. */
285
+ work(signal?: AbortSignal): Promise<RestWork>;
241
286
  /** The tokens of whoever asks. */
242
287
  tokens(signal?: AbortSignal): Promise<RestTokens>;
243
288
  /** The tokens of another person: for an administrator. */
@@ -370,7 +415,14 @@ export function createClient(options: ClientOptions = {}): ConsoleClient {
370
415
  tasks: (filter = {}, signal) =>
371
416
  request(
372
417
  "GET",
373
- withQuery(at("tasks"), { state: filter.state }),
418
+ withQuery(at("tasks"), {
419
+ state: filter.state,
420
+ open: flag(filter.open),
421
+ category: filter.category,
422
+ goal: filter.goal,
423
+ assignee: filter.assignee,
424
+ dueBy: filter.dueBy,
425
+ }),
374
426
  undefined,
375
427
  restTasksSchema,
376
428
  signal,
@@ -485,6 +537,13 @@ export function createClient(options: ClientOptions = {}): ConsoleClient {
485
537
  request("POST", `${base}${at("docs", path)}/files`, formOf(input), restDocumentSchema),
486
538
  documentFileUrl: (path, fileId) =>
487
539
  `${base}${at("docs", path)}/files/${encodeURIComponent(fileId)}`,
540
+ goals: (signal) =>
541
+ request("GET", `${base}${at("goals")}`, undefined, restGoalsSchema, signal),
542
+ goal: (id, signal) =>
543
+ request("GET", `${base}${at("goals", id)}`, undefined, restGoalSchema, signal),
544
+ createGoal: (input) => request("POST", `${base}${at("goals")}`, input, restGoalSchema),
545
+ updateGoal: (id, patch) =>
546
+ request("PATCH", `${base}${at("goals", id)}`, patch, restGoalSchema),
488
547
  };
489
548
  };
490
549
 
@@ -507,6 +566,15 @@ export function createClient(options: ClientOptions = {}): ConsoleClient {
507
566
  restEventsSchema,
508
567
  signal,
509
568
  ),
569
+ categories: (signal) =>
570
+ request("GET", `${base}${REST_ROUTES.categories}`, undefined, restCategoriesSchema, signal),
571
+ createCategory: (input) =>
572
+ request("POST", `${base}${REST_ROUTES.categories}`, input, restCategorySchema),
573
+ updateCategory: (key, patch) =>
574
+ request("PATCH", `${base}${categoryPath(key)}`, patch, restCategorySchema),
575
+ removeCategory: (key) => request("DELETE", `${base}${categoryPath(key)}`, undefined, undefined),
576
+ work: (signal) =>
577
+ request("GET", `${base}${REST_ROUTES.work}`, undefined, restWorkSchema, signal),
510
578
  tokens: (signal) =>
511
579
  request("GET", `${base}${REST_ROUTES.tokens}`, undefined, restTokensSchema, signal),
512
580
  createToken: (input) =>
@@ -1,10 +1,30 @@
1
1
  import type { ReactNode } from "react";
2
- import { type RestTask, TASK_STATES, type TaskState } from "../api.js";
3
- import { useWords } from "../i18n/index.js";
4
- import { Avatar, Badge, cx, PriorityBadge } from "./ui.js";
2
+ import {
3
+ type RestCategory,
4
+ type RestGoal,
5
+ type RestPerson,
6
+ type RestRun,
7
+ type RestTask,
8
+ TASK_STATES,
9
+ type TaskState,
10
+ } from "../api.js";
11
+ import { useLanguage, useWords } from "../i18n/index.js";
12
+ import type { BoardFilter } from "../router.js";
13
+ import {
14
+ Avatar,
15
+ Badge,
16
+ Button,
17
+ CategoryBadge,
18
+ cx,
19
+ DueBadge,
20
+ formatInstant,
21
+ PriorityBadge,
22
+ } from "./ui.js";
5
23
 
6
- // The board: one column per state, the tasks in it, newest first. It takes its data as props
7
- // and nothing from the network; what it is told on a click goes back up.
24
+ // The board: one column per state, the tasks in it, the sooner due first and the newest after.
25
+ // A card says what a person needs at a glance and nothing more: the number and the title, the
26
+ // category, what is due, who is on it, what blocks it, and whose agent is at it. It takes its
27
+ // data as props and nothing from the network; what it is told on a click goes back up.
8
28
 
9
29
  export interface BoardProps {
10
30
  readonly tasks: readonly RestTask[];
@@ -13,18 +33,32 @@ export interface BoardProps {
13
33
  readonly onOpen: (task: RestTask) => void;
14
34
  /** Called when a person moves a task to another column. Left out, the board is read-only. */
15
35
  readonly onMove?: ((task: RestTask, state: TaskState) => void) | undefined;
36
+ /** The runs of the project, for a card to name the agent at work on its task. */
37
+ readonly runs?: readonly RestRun[] | undefined;
38
+ /** The day to count due days from; the browser's today when left out. */
39
+ readonly today?: string | undefined;
16
40
  /** What a column shows when it has nothing; the way to write the first task, for example. */
17
41
  readonly empty?: ReactNode;
18
42
  }
19
43
 
44
+ /** The open run on a task, when an agent is at work on it: whose, and since when. */
45
+ function runOn(task: RestTask, runs: readonly RestRun[] | undefined): RestRun | undefined {
46
+ return runs?.find(
47
+ (run) => run.taskId === task.id && (run.status === "running" || run.status === "waiting"),
48
+ );
49
+ }
50
+
20
51
  export function TaskCard(props: {
21
52
  readonly task: RestTask;
22
53
  readonly href: string;
23
54
  readonly onOpen: (task: RestTask) => void;
24
55
  readonly onMove?: ((task: RestTask, state: TaskState) => void) | undefined;
56
+ readonly run?: RestRun | undefined;
57
+ readonly today?: string | undefined;
25
58
  }) {
26
59
  const words = useWords();
27
- const { task } = props;
60
+ const { tag } = useLanguage();
61
+ const { task, run } = props;
28
62
  return (
29
63
  <article className="sc-card" aria-label={`#${task.number} ${task.title}`}>
30
64
  <a
@@ -40,20 +74,28 @@ export function TaskCard(props: {
40
74
  <span className="sc-card-number">#{task.number}</span> {task.title}
41
75
  </a>
42
76
  <div className="sc-card-meta">
77
+ {task.category !== null && <CategoryBadge category={task.category} />}
43
78
  <PriorityBadge priority={task.priority} />
79
+ {task.dueOn !== null && (
80
+ <DueBadge dueOn={task.dueOn} state={task.state} today={props.today} />
81
+ )}
44
82
  {task.openDecisions > 0 && (
45
83
  <Badge tone="warning" title={words.board.decisionsTitle}>
46
84
  {words.board.decisions(task.openDecisions)}
47
85
  </Badge>
48
86
  )}
49
87
  {task.openRuns > 0 && (
50
- <Badge tone="accent" title={words.board.agentAtWorkTitle}>
51
- {words.board.agentAtWork}
88
+ <Badge
89
+ tone="accent"
90
+ title={
91
+ run === undefined
92
+ ? words.board.agentAtWorkTitle
93
+ : words.board.agentSince(run.agent, formatInstant(run.startedAt, tag))
94
+ }
95
+ >
96
+ {run === undefined ? words.board.agentAtWork : run.agent}
52
97
  </Badge>
53
98
  )}
54
- {task.links.length > 0 && (
55
- <Badge tone="neutral">{words.board.links(task.links.length)}</Badge>
56
- )}
57
99
  <span className="sc-card-spacer" />
58
100
  {task.assignee !== null && <Avatar person={task.assignee} size="sm" />}
59
101
  </div>
@@ -76,12 +118,26 @@ export function TaskCard(props: {
76
118
  );
77
119
  }
78
120
 
121
+ /** The order a column reads in: what is due soonest first, what has no day after, the newest first among those. */
122
+ export function byDue(a: RestTask, b: RestTask): number {
123
+ if (a.dueOn !== b.dueOn) {
124
+ if (a.dueOn === null) {
125
+ return 1;
126
+ }
127
+ if (b.dueOn === null) {
128
+ return -1;
129
+ }
130
+ return a.dueOn.localeCompare(b.dueOn);
131
+ }
132
+ return b.number - a.number;
133
+ }
134
+
79
135
  export function Board(props: BoardProps) {
80
136
  const words = useWords();
81
137
  return (
82
138
  <div className="sc-board">
83
139
  {TASK_STATES.map((state) => {
84
- const tasks = props.tasks.filter((task) => task.state === state);
140
+ const tasks = props.tasks.filter((task) => task.state === state).sort(byDue);
85
141
  return (
86
142
  <section
87
143
  key={state}
@@ -100,6 +156,8 @@ export function Board(props: BoardProps) {
100
156
  href={props.taskHref(task)}
101
157
  onOpen={props.onOpen}
102
158
  onMove={props.onMove}
159
+ run={runOn(task, props.runs)}
160
+ today={props.today}
103
161
  />
104
162
  ))}
105
163
  {tasks.length === 0 && state === "idea" && props.empty}
@@ -110,3 +168,138 @@ export function Board(props: BoardProps) {
110
168
  </div>
111
169
  );
112
170
  }
171
+
172
+ /** What the filter keeps of the board: a category or none, a goal or none, a person, one's own. */
173
+ export function filterTasks(
174
+ tasks: readonly RestTask[],
175
+ filter: BoardFilter,
176
+ me?: RestPerson | undefined,
177
+ ): RestTask[] {
178
+ return tasks.filter((task) => {
179
+ if (filter.category !== undefined && (task.category?.key ?? "") !== filter.category) {
180
+ return false;
181
+ }
182
+ if (filter.goal !== undefined && (task.goalId ?? "") !== filter.goal) {
183
+ return false;
184
+ }
185
+ if (filter.person !== undefined && task.assignee?.id !== filter.person) {
186
+ return false;
187
+ }
188
+ if (filter.mine === true && (me === undefined || task.assignee?.id !== me.id)) {
189
+ return false;
190
+ }
191
+ return true;
192
+ });
193
+ }
194
+
195
+ export interface BoardFiltersProps {
196
+ readonly filter: BoardFilter;
197
+ readonly categories: readonly RestCategory[];
198
+ readonly goals: readonly RestGoal[];
199
+ readonly people: readonly RestPerson[];
200
+ /** Whoever is looking, for "mine". */
201
+ readonly me?: RestPerson | undefined;
202
+ /** How many tasks the filter keeps, of how many. */
203
+ readonly shown: number;
204
+ readonly total: number;
205
+ /** Called with the filter as it is to be: the composition shows the board at its address. */
206
+ readonly onChange: (filter: BoardFilter) => void;
207
+ }
208
+
209
+ /** The ways to narrow the board, each a change of its address (ADR-0010). */
210
+ export function BoardFilters(props: BoardFiltersProps) {
211
+ const words = useWords().board.filters;
212
+ const { filter } = props;
213
+ const narrowed = Object.keys(filter).length > 0;
214
+ const goals = props.goals.filter((goal) => goal.state !== "dropped");
215
+ const change = (patch: BoardFilter, drop?: keyof BoardFilter) => {
216
+ const next: Record<string, unknown> = { ...filter, ...patch };
217
+ if (drop !== undefined) {
218
+ delete next[drop];
219
+ }
220
+ props.onChange(next as BoardFilter);
221
+ };
222
+ return (
223
+ <form
224
+ className="sc-filters"
225
+ aria-label={words.aria}
226
+ onSubmit={(event) => event.preventDefault()}
227
+ >
228
+ <label className="sc-field sc-field-inline">
229
+ <span className="sc-visually-hidden">{words.anyCategory}</span>
230
+ <select
231
+ className="sc-input"
232
+ value={filter.category ?? "*"}
233
+ onChange={(event) =>
234
+ event.target.value === "*"
235
+ ? change({}, "category")
236
+ : change({ category: event.target.value })
237
+ }
238
+ >
239
+ <option value="*">{words.anyCategory}</option>
240
+ <option value="">{words.noCategory}</option>
241
+ {props.categories.map((category) => (
242
+ <option key={category.key} value={category.key}>
243
+ {category.name}
244
+ </option>
245
+ ))}
246
+ </select>
247
+ </label>
248
+ {goals.length > 0 && (
249
+ <label className="sc-field sc-field-inline">
250
+ <span className="sc-visually-hidden">{words.anyGoal}</span>
251
+ <select
252
+ className="sc-input"
253
+ value={filter.goal ?? "*"}
254
+ onChange={(event) =>
255
+ event.target.value === "*" ? change({}, "goal") : change({ goal: event.target.value })
256
+ }
257
+ >
258
+ <option value="*">{words.anyGoal}</option>
259
+ <option value="">{words.noGoal}</option>
260
+ {goals.map((goal) => (
261
+ <option key={goal.id} value={goal.id}>
262
+ {goal.title}
263
+ </option>
264
+ ))}
265
+ </select>
266
+ </label>
267
+ )}
268
+ <label className="sc-field sc-field-inline">
269
+ <span className="sc-visually-hidden">{words.everyone}</span>
270
+ <select
271
+ className="sc-input"
272
+ value={filter.mine === true ? "mine" : (filter.person ?? "*")}
273
+ onChange={(event) => {
274
+ const value = event.target.value;
275
+ if (value === "*") {
276
+ change({ mine: undefined, person: undefined });
277
+ return;
278
+ }
279
+ if (value === "mine") {
280
+ change({ mine: true, person: undefined });
281
+ return;
282
+ }
283
+ change({ person: value, mine: undefined });
284
+ }}
285
+ >
286
+ <option value="*">{words.everyone}</option>
287
+ {props.me !== undefined && <option value="mine">{words.mine}</option>}
288
+ {props.people
289
+ .filter((person) => person.id !== props.me?.id)
290
+ .map((person) => (
291
+ <option key={person.id} value={person.id}>
292
+ {person.login}
293
+ </option>
294
+ ))}
295
+ </select>
296
+ </label>
297
+ <span className="sc-filters-count sc-muted">{words.showing(props.shown, props.total)}</span>
298
+ {narrowed && (
299
+ <Button variant="ghost" size="sm" onClick={() => props.onChange({})}>
300
+ {words.clear}
301
+ </Button>
302
+ )}
303
+ </form>
304
+ );
305
+ }
@@ -0,0 +1,255 @@
1
+ import { OPEN_TASK_STATES, type RestPerson, type RestTask } from "../api.js";
2
+ import {
3
+ addMonths,
4
+ daysBetween,
5
+ firstDayOfWeek,
6
+ formatMonth,
7
+ formatWeekday,
8
+ monthGrid,
9
+ monthOf,
10
+ today as todayOf,
11
+ weekdays,
12
+ } from "../dates.js";
13
+ import { useLanguage, useWords } from "../i18n/index.js";
14
+ import { cx, PersonChip } from "./ui.js";
15
+
16
+ // The calendar of a project (ADR-0019): the month as a grid, each task on the day it is due
17
+ // and, lightly, on the day it starts; and who has what, so that whoever runs the project sees
18
+ // at a glance whether the work is on its days. Takes its data as props and nothing from the
19
+ // network; the month shown is the address's (ADR-0010).
20
+
21
+ const isOpen = (task: RestTask): boolean => OPEN_TASK_STATES.includes(task.state);
22
+
23
+ export interface CalendarProps {
24
+ readonly tasks: readonly RestTask[];
25
+ /** The month shown: `2026-10`. */
26
+ readonly month: string;
27
+ /** The day to count from; the browser's today when left out. */
28
+ readonly today?: string | undefined;
29
+ readonly taskHref: (task: RestTask) => string;
30
+ /** Where the calendar is on another month. */
31
+ readonly monthHref: (month: string) => string;
32
+ }
33
+
34
+ function push(map: Map<string, RestTask[]>, day: string, task: RestTask): void {
35
+ const list = map.get(day);
36
+ if (list === undefined) {
37
+ map.set(day, [task]);
38
+ } else {
39
+ list.push(task);
40
+ }
41
+ }
42
+
43
+ export function Calendar(props: CalendarProps) {
44
+ const words = useWords().calendar;
45
+ const { tag } = useLanguage();
46
+ const now = props.today ?? todayOf();
47
+ const firstDay = firstDayOfWeek(tag);
48
+ const weeks = monthGrid(props.month, firstDay);
49
+ const due = new Map<string, RestTask[]>();
50
+ const starting = new Map<string, RestTask[]>();
51
+ for (const task of props.tasks) {
52
+ if (task.dueOn !== null) {
53
+ push(due, task.dueOn, task);
54
+ }
55
+ if (task.startsOn !== null && task.startsOn !== task.dueOn) {
56
+ push(starting, task.startsOn, task);
57
+ }
58
+ }
59
+ // Within a day, what is still open comes first; the rest reads as what was.
60
+ for (const list of due.values()) {
61
+ list.sort((a, b) => Number(isOpen(b)) - Number(isOpen(a)) || a.number - b.number);
62
+ }
63
+ const inMonth = (day: string) => monthOf(day) === props.month;
64
+ const nothing =
65
+ [...due.keys()].every((day) => !inMonth(day)) &&
66
+ [...starting.keys()].every((day) => !inMonth(day));
67
+ return (
68
+ <div className="sc-calendar">
69
+ <div className="sc-calendar-head">
70
+ <h2 className="sc-calendar-month">{formatMonth(props.month, tag)}</h2>
71
+ <nav className="sc-calendar-nav" aria-label={words.grid}>
72
+ <a
73
+ className="sc-button sc-button-ghost sc-button-sm"
74
+ href={props.monthHref(addMonths(props.month, -1))}
75
+ aria-label={words.previous}
76
+ >
77
+ {"<"}
78
+ </a>
79
+ <a
80
+ className="sc-button sc-button-ghost sc-button-sm"
81
+ href={props.monthHref(monthOf(now))}
82
+ >
83
+ {words.thisMonth}
84
+ </a>
85
+ <a
86
+ className="sc-button sc-button-ghost sc-button-sm"
87
+ href={props.monthHref(addMonths(props.month, 1))}
88
+ aria-label={words.next}
89
+ >
90
+ {">"}
91
+ </a>
92
+ </nav>
93
+ </div>
94
+ <table className="sc-calendar-grid" aria-label={words.grid}>
95
+ <thead>
96
+ <tr>
97
+ {weekdays(firstDay).map((weekday) => (
98
+ <th key={weekday} scope="col">
99
+ {formatWeekday(weekday, tag)}
100
+ </th>
101
+ ))}
102
+ </tr>
103
+ </thead>
104
+ <tbody>
105
+ {weeks.map((week) => (
106
+ <tr key={week[0]?.day}>
107
+ {week.map((cell) => (
108
+ <td
109
+ key={cell.day}
110
+ className={cx(
111
+ "sc-calendar-cell",
112
+ !cell.inMonth && "sc-calendar-cell-outside",
113
+ cell.day === now && "sc-calendar-cell-today",
114
+ )}
115
+ >
116
+ <span className="sc-calendar-day">{Number(cell.day.slice(8))}</span>
117
+ {(due.get(cell.day) ?? []).map((task) => (
118
+ <a
119
+ key={task.id}
120
+ className={cx(
121
+ "sc-calendar-task",
122
+ !isOpen(task) && "sc-calendar-task-over",
123
+ isOpen(task) && cell.day < now && "sc-calendar-task-late",
124
+ )}
125
+ href={props.taskHref(task)}
126
+ title={`#${task.number} ${task.title}`}
127
+ >
128
+ <span
129
+ className={cx(
130
+ "sc-calendar-dot",
131
+ `sc-category-${task.category?.color ?? "gray"}`,
132
+ )}
133
+ />
134
+ <span className="sc-calendar-task-title">{task.title}</span>
135
+ </a>
136
+ ))}
137
+ {(starting.get(cell.day) ?? []).map((task) => (
138
+ <a
139
+ key={task.id}
140
+ className="sc-calendar-task sc-calendar-task-start"
141
+ href={props.taskHref(task)}
142
+ title={`#${task.number} ${task.title}: ${words.starts}`}
143
+ >
144
+ <span className="sc-calendar-task-title">{task.title}</span>
145
+ </a>
146
+ ))}
147
+ </td>
148
+ ))}
149
+ </tr>
150
+ ))}
151
+ </tbody>
152
+ </table>
153
+ {nothing && <p className="sc-muted">{words.nothingDue}</p>}
154
+ </div>
155
+ );
156
+ }
157
+
158
+ /** One person's share of the open work, as the table of who has what says it. */
159
+ export interface WorkloadRow {
160
+ /** The person, or nobody for the tasks assigned to no one. */
161
+ readonly person: RestPerson | undefined;
162
+ readonly open: number;
163
+ readonly inProgress: number;
164
+ /** Open, and due within the next seven days from today. */
165
+ readonly dueSoon: number;
166
+ /** Open, and past its day. */
167
+ readonly overdue: number;
168
+ }
169
+
170
+ /** Who has what, from the open tasks: by login, with the unassigned last. */
171
+ export function workloadOf(tasks: readonly RestTask[], today: string): WorkloadRow[] {
172
+ const rows = new Map<string, { person: RestPerson | undefined; tasks: RestTask[] }>();
173
+ for (const task of tasks.filter(isOpen)) {
174
+ const key = task.assignee?.id ?? "";
175
+ const row = rows.get(key);
176
+ if (row === undefined) {
177
+ rows.set(key, { person: task.assignee ?? undefined, tasks: [task] });
178
+ } else {
179
+ row.tasks.push(task);
180
+ }
181
+ }
182
+ const count = (list: readonly RestTask[]): Omit<WorkloadRow, "person"> => ({
183
+ open: list.length,
184
+ inProgress: list.filter((task) => task.state === "in_progress" || task.state === "in_review")
185
+ .length,
186
+ dueSoon: list.filter((task) => {
187
+ if (task.dueOn === null) {
188
+ return false;
189
+ }
190
+ const days = daysBetween(today, task.dueOn);
191
+ return days >= 0 && days <= 7;
192
+ }).length,
193
+ overdue: list.filter((task) => task.dueOn !== null && task.dueOn < today).length,
194
+ });
195
+ return [...rows.values()]
196
+ .map((row) => ({ person: row.person, ...count(row.tasks) }))
197
+ .sort((a, b) => {
198
+ if (a.person === undefined) {
199
+ return 1;
200
+ }
201
+ if (b.person === undefined) {
202
+ return -1;
203
+ }
204
+ return a.person.login.localeCompare(b.person.login);
205
+ });
206
+ }
207
+
208
+ export interface WorkloadProps {
209
+ readonly tasks: readonly RestTask[];
210
+ readonly today?: string | undefined;
211
+ /** Where the board is, narrowed to one person's tasks, or to the unassigned. */
212
+ readonly personHref?: ((person: RestPerson | undefined) => string) | undefined;
213
+ }
214
+
215
+ /** Who has what: a row per person, and what of theirs is late or due soon. */
216
+ export function Workload(props: WorkloadProps) {
217
+ const words = useWords().calendar.workload;
218
+ const rows = workloadOf(props.tasks, props.today ?? todayOf());
219
+ if (rows.length === 0) {
220
+ return null;
221
+ }
222
+ return (
223
+ <table className="sc-workload" aria-label={words.title}>
224
+ <thead>
225
+ <tr>
226
+ <th scope="col">{words.person}</th>
227
+ <th scope="col">{words.open}</th>
228
+ <th scope="col">{words.inProgress}</th>
229
+ <th scope="col">{words.dueSoon}</th>
230
+ <th scope="col">{words.overdue}</th>
231
+ </tr>
232
+ </thead>
233
+ <tbody>
234
+ {rows.map((row) => {
235
+ const href = props.personHref?.(row.person);
236
+ const who =
237
+ row.person === undefined ? (
238
+ <span className="sc-muted">{words.unassigned}</span>
239
+ ) : (
240
+ <PersonChip person={row.person} />
241
+ );
242
+ return (
243
+ <tr key={row.person?.id ?? ""}>
244
+ <th scope="row">{href === undefined ? who : <a href={href}>{who}</a>}</th>
245
+ <td>{row.open}</td>
246
+ <td>{row.inProgress}</td>
247
+ <td className={cx(row.dueSoon > 0 && "sc-workload-soon")}>{row.dueSoon}</td>
248
+ <td className={cx(row.overdue > 0 && "sc-workload-late")}>{row.overdue}</td>
249
+ </tr>
250
+ );
251
+ })}
252
+ </tbody>
253
+ </table>
254
+ );
255
+ }