@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
package/src/cli/format.ts CHANGED
@@ -39,12 +39,14 @@ export function formatProjectLine(project: RestProject): string {
39
39
  if (project.openDecisions > 0) {
40
40
  notes.push(`${plural(project.openDecisions, "decision")} waiting`);
41
41
  }
42
- if (project.openRuns > 0) {
43
- notes.push(`${plural(project.openRuns, "agent")} at work`);
44
- }
45
42
  return `${project.key} ${project.name} (${notes.join("; ")})`;
46
43
  }
47
44
 
45
+ /** The line under a page of a listing that has more after it: how to ask for the next page. */
46
+ export function formatMore(more: boolean, limit: number, offset: number): string {
47
+ return more ? `\n... more: --offset ${offset + limit}` : "";
48
+ }
49
+
48
50
  export function formatTaskLine(task: RestTask): string {
49
51
  const notes = [`owner ${task.owner.login}`];
50
52
  if (task.assignee !== null) {
@@ -59,9 +61,6 @@ export function formatTaskLine(task: RestTask): string {
59
61
  if (task.openDecisions > 0) {
60
62
  notes.push(`${plural(task.openDecisions, "decision")} waiting`);
61
63
  }
62
- if (task.openRuns > 0) {
63
- notes.push("an agent at work");
64
- }
65
64
  return `#${task.number} ${task.state} ${task.priority} ${task.title} (${notes.join("; ")})`;
66
65
  }
67
66
 
@@ -128,6 +127,9 @@ export function formatRunLine(run: RestRun): string {
128
127
  if (run.waitingFor !== null) {
129
128
  notes.push(`waiting for decision ${run.waitingFor}`);
130
129
  }
130
+ if (run.endedAt === null) {
131
+ notes.push(`last heard ${run.lastHeardAt}`);
132
+ }
131
133
  return `${run.id} ${run.status} ${run.agent} for ${run.person.login} task #${run.taskNumber} (${notes.join(", ")})`;
132
134
  }
133
135
 
package/src/cli/help.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import {
2
+ DEFAULT_PAGE_LIMIT,
2
3
  MAX_AGENT_LENGTH,
3
4
  MAX_ARTIFACTS_PER_RUN,
4
5
  MAX_BODY_LENGTH,
@@ -105,9 +106,8 @@ export const COMMAND_HELP: Readonly<Record<string, CommandHelp>> = {
105
106
  },
106
107
  tasks: {
107
108
  usage:
108
- "tasks [--state <state>] [--open] [--mine] [--category <key>] [--goal <id>] [--due <day|today|week|overdue>]",
109
- about:
110
- "The tasks on the board, newest first: number, state, priority, title, who owns and works on each, its category and its due day, and what waits on it. --open keeps the ones not done or dropped, --mine those assigned to you, --category and --goal those sorted into one, --due those due by a day: today, within the week, or overdue (the open ones past their day). States: idea, ready, in_progress, in_review, done, dropped.",
109
+ "tasks [--state <state>] [--open] [--mine] [--category <key>] [--goal <id>] [--due <day|today|week|overdue>] [--limit <n>] [--offset <n>]",
110
+ about: `The tasks on the board, newest first: number, state, priority, title, who owns and works on each, its category and its due day, and what waits on it. --open keeps the ones not done or dropped, --mine those assigned to you, --category and --goal those sorted into one, --due those due by a day: today, within the week, or overdue (the open ones past their day). A page at a time: ${DEFAULT_PAGE_LIMIT} unless --limit says otherwise, from --offset on; a last line says when there is more. States: idea, ready, in_progress, in_review, done, dropped.`,
111
111
  },
112
112
  task: {
113
113
  usage: `task <number|id> | task new <title> [--body <markdown>|--file <path>] ${TASK_FIELD_FLAGS} | task edit <number|id> [--title <title>] [--body <markdown>|--file <path>] ${TASK_FIELD_FLAGS}`,
@@ -153,11 +153,10 @@ export const COMMAND_HELP: Readonly<Record<string, CommandHelp>> = {
153
153
  take: {
154
154
  usage: "take <number|id> [--agent <name>]",
155
155
  about:
156
- "Takes a task: a run begins, and the task is in progress and the person's. One agent at a time per task. --agent is what people see at work; left out, it is what the person called the token.",
156
+ "Takes a task: a run begins, and the task is in progress and the person's. A run still open on the task is taken over, since the board cannot know whether its agent still runs: it ends as abandoned, the feed says who took over, and that agent is refused on its next command. Look before taking (console task <number> lists the runs, with when each was last heard from), and take a task another agent was heard from on lately only when told to. --agent is what people see at work; left out, it is what the person called the token.",
157
157
  limits: [`agent: one line, up to ${MAX_AGENT_LENGTH} characters`],
158
158
  refusals: [
159
159
  "task.not_found: no task has that number or id",
160
- "run.task_taken: a run is open on the task already; console runs --open --mine shows whether it is yours",
161
160
  "run.task_closed: the task is done or dropped",
162
161
  ],
163
162
  },
@@ -207,8 +206,8 @@ export const COMMAND_HELP: Readonly<Record<string, CommandHelp>> = {
207
206
  refusals: ["decision.not_found: no decision has that id"],
208
207
  },
209
208
  decisions: {
210
- usage: "decisions [--open]",
211
- about: "The decisions on the board, the waiting first; --open keeps only those.",
209
+ usage: "decisions [--open] [--limit <n>] [--offset <n>]",
210
+ about: `The decisions on the board, the waiting first; --open keeps only those. A page at a time: ${DEFAULT_PAGE_LIMIT} unless --limit says otherwise, from --offset on.`,
212
211
  },
213
212
  finish: {
214
213
  usage: 'finish [--summary "<markdown>"|--file <path>]',
@@ -231,9 +230,8 @@ export const COMMAND_HELP: Readonly<Record<string, CommandHelp>> = {
231
230
  refusals: RUN_REFUSALS,
232
231
  },
233
232
  runs: {
234
- usage: "runs [--task <number|id>] [--open] [--mine]",
235
- about:
236
- "The runs, newest first: agents at work and what they did. --mine keeps those begun with this token.",
233
+ usage: "runs [--task <number|id>] [--open] [--mine] [--limit <n>] [--offset <n>]",
234
+ about: `The runs, newest first, each with when its agent was last heard from: --open keeps the ones not over, --mine those begun with this token. A page at a time: ${DEFAULT_PAGE_LIMIT} unless --limit says otherwise, from --offset on.`,
237
235
  },
238
236
  run: {
239
237
  usage: "run <id>",
@@ -312,6 +310,8 @@ Usage: console <command> [arguments] [--json] [--url <origin>] [--project <key>]
312
310
 
313
311
  const CLOSING = `Options everywhere: --json answers with the console's own JSON; --url names another console;
314
312
  --project names the project to work in, over CONSOLE_PROJECT and over what console use kept here.
313
+ A listing (tasks, decisions, runs) is a page at a time: --limit <n> and --offset <n>, and a last line
314
+ says when there is more, so that nothing is read that is not needed.
315
315
  Commands on a run (report, hand-in, ask, finish, fail) act on your one open run in the project;
316
316
  with several open, say which with --run <id>. A Markdown body given as - is read from standard input.
317
317
  console help <command> says what each command takes, the limits the console holds it to, and
package/src/client.ts CHANGED
@@ -150,8 +150,14 @@ export interface EventFilter {
150
150
  readonly document?: string | undefined;
151
151
  }
152
152
 
153
+ /** One page of a listing: how many items, from which one on; the server's bounds apply. */
154
+ export interface Page {
155
+ readonly limit?: number | undefined;
156
+ readonly offset?: number | undefined;
157
+ }
158
+
153
159
  /** 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 {
160
+ export interface TaskFilter extends Page {
155
161
  readonly state?: TaskState | undefined;
156
162
  /** True for the tasks not done or dropped; false for the rest. */
157
163
  readonly open?: boolean | undefined;
@@ -183,6 +189,10 @@ export interface ProjectClient {
183
189
  get(signal?: AbortSignal): Promise<RestProject>;
184
190
  /** Changes the project's settings; for an owner. */
185
191
  update(patch: RestProjectPatch): Promise<RestProject>;
192
+ /** Sets the project's own image for its mark, sent as a form; for an owner on the console's own pages. */
193
+ setIcon(input: FileUpload): Promise<RestProject>;
194
+ /** Takes the image away: the mark is the symbol on the colour again. */
195
+ removeIcon(): Promise<RestProject>;
186
196
  /** Those listed in the project, by login. */
187
197
  members(signal?: AbortSignal): Promise<RestMembers>;
188
198
  /** Lists a person of the workspace in the project; for an owner. */
@@ -194,9 +204,9 @@ export interface ProjectClient {
194
204
  task(ref: string, signal?: AbortSignal): Promise<RestTask>;
195
205
  createTask(input: RestTaskInput): Promise<RestTask>;
196
206
  updateTask(id: string, patch: RestTaskPatch): Promise<RestTask>;
197
- /** The decisions, or only those that wait, or only those about one task. */
207
+ /** The decisions, or only those that wait, or only those about one task; a page of them. */
198
208
  decisions(
199
- filter?: { readonly open?: boolean; readonly task?: string },
209
+ filter?: { readonly open?: boolean; readonly task?: string } & Page,
200
210
  signal?: AbortSignal,
201
211
  ): Promise<RestDecisions>;
202
212
  decision(id: string, signal?: AbortSignal): Promise<RestDecision>;
@@ -209,9 +219,9 @@ export interface ProjectClient {
209
219
  answerDecision(id: string, input: RestAnswerInput): Promise<RestDecision>;
210
220
  /** Grows a decision's record: its context, or what followed. */
211
221
  updateDecision(id: string, patch: RestDecisionPatch): Promise<RestDecision>;
212
- /** The runs, newest first; or only those on one task, only the open ones, only the asker's own. */
222
+ /** The runs, newest first; or only those on one task, only the open ones, only the asker's own; a page of them. */
213
223
  runs(
214
- filter?: { readonly task?: string; readonly open?: boolean; readonly mine?: boolean },
224
+ filter?: { readonly task?: string; readonly open?: boolean; readonly mine?: boolean } & Page,
215
225
  signal?: AbortSignal,
216
226
  ): Promise<RestRuns>;
217
227
  run(id: string, signal?: AbortSignal): Promise<RestRun>;
@@ -381,6 +391,8 @@ export function createClient(options: ClientOptions = {}): ConsoleClient {
381
391
 
382
392
  const flag = (value: boolean | undefined): string | undefined =>
383
393
  value === undefined ? undefined : String(value);
394
+ const number = (value: number | undefined): string | undefined =>
395
+ value === undefined ? undefined : String(value);
384
396
 
385
397
  /** A file as the parts of a form: the bytes with their name and type, and the label. */
386
398
  const formOf = (input: FileUpload): FormData => {
@@ -405,6 +417,8 @@ export function createClient(options: ClientOptions = {}): ConsoleClient {
405
417
  key,
406
418
  get: (signal) => request("GET", `${base}${at()}`, undefined, restProjectSchema, signal),
407
419
  update: (patch) => request("PATCH", `${base}${at()}`, patch, restProjectSchema),
420
+ setIcon: (input) => request("POST", `${base}${at()}/icon`, formOf(input), restProjectSchema),
421
+ removeIcon: () => request("DELETE", `${base}${at()}/icon`, undefined, restProjectSchema),
408
422
  members: (signal) =>
409
423
  request("GET", `${base}${at("members")}`, undefined, restMembersSchema, signal),
410
424
  addMember: (input) => request("POST", `${base}${at("members")}`, input, restMemberSchema),
@@ -422,6 +436,8 @@ export function createClient(options: ClientOptions = {}): ConsoleClient {
422
436
  goal: filter.goal,
423
437
  assignee: filter.assignee,
424
438
  dueBy: filter.dueBy,
439
+ limit: number(filter.limit),
440
+ offset: number(filter.offset),
425
441
  }),
426
442
  undefined,
427
443
  restTasksSchema,
@@ -435,7 +451,12 @@ export function createClient(options: ClientOptions = {}): ConsoleClient {
435
451
  decisions: (filter = {}, signal) =>
436
452
  request(
437
453
  "GET",
438
- withQuery(at("decisions"), { open: flag(filter.open), task: filter.task }),
454
+ withQuery(at("decisions"), {
455
+ open: flag(filter.open),
456
+ task: filter.task,
457
+ limit: number(filter.limit),
458
+ offset: number(filter.offset),
459
+ }),
439
460
  undefined,
440
461
  restDecisionsSchema,
441
462
  signal,
@@ -463,6 +484,8 @@ export function createClient(options: ClientOptions = {}): ConsoleClient {
463
484
  task: filter.task,
464
485
  open: flag(filter.open),
465
486
  mine: flag(filter.mine),
487
+ limit: number(filter.limit),
488
+ offset: number(filter.offset),
466
489
  }),
467
490
  undefined,
468
491
  restRunsSchema,
@@ -3,27 +3,18 @@ import {
3
3
  type RestCategory,
4
4
  type RestGoal,
5
5
  type RestPerson,
6
- type RestRun,
7
6
  type RestTask,
8
7
  TASK_STATES,
9
8
  type TaskState,
10
9
  } from "../api.js";
11
- import { useLanguage, useWords } from "../i18n/index.js";
10
+ import { useWords } from "../i18n/index.js";
12
11
  import type { BoardFilter } from "../router.js";
13
- import {
14
- Avatar,
15
- Badge,
16
- Button,
17
- CategoryBadge,
18
- cx,
19
- DueBadge,
20
- formatInstant,
21
- PriorityBadge,
22
- } from "./ui.js";
12
+ import { Avatar, Badge, Button, CategoryBadge, cx, DueBadge, PriorityBadge } from "./ui.js";
23
13
 
24
14
  // The board: one column per state, the tasks in it, the sooner due first and the newest after.
25
15
  // 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
16
+ // category, what is due, who is on it, and what blocks it. Whether an agent is at it is not
17
+ // said, since the board cannot know (ADR-0020); the task's page shows its runs. It takes its
27
18
  // data as props and nothing from the network; what it is told on a click goes back up.
28
19
 
29
20
  export interface BoardProps {
@@ -33,32 +24,21 @@ export interface BoardProps {
33
24
  readonly onOpen: (task: RestTask) => void;
34
25
  /** Called when a person moves a task to another column. Left out, the board is read-only. */
35
26
  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
27
  /** The day to count due days from; the browser's today when left out. */
39
28
  readonly today?: string | undefined;
40
29
  /** What a column shows when it has nothing; the way to write the first task, for example. */
41
30
  readonly empty?: ReactNode;
42
31
  }
43
32
 
44
- /** The open run on a task, when an agent is at work on it: whose, and since when. */
45
- function runOn(task: RestTask, runs: readonly RestRun[] | undefined): RestRun | undefined {
46
- return runs?.find(
47
- (run) => run.taskId === task.id && (run.status === "running" || run.status === "waiting"),
48
- );
49
- }
50
-
51
33
  export function TaskCard(props: {
52
34
  readonly task: RestTask;
53
35
  readonly href: string;
54
36
  readonly onOpen: (task: RestTask) => void;
55
37
  readonly onMove?: ((task: RestTask, state: TaskState) => void) | undefined;
56
- readonly run?: RestRun | undefined;
57
38
  readonly today?: string | undefined;
58
39
  }) {
59
40
  const words = useWords();
60
- const { tag } = useLanguage();
61
- const { task, run } = props;
41
+ const { task } = props;
62
42
  return (
63
43
  <article className="sc-card" aria-label={`#${task.number} ${task.title}`}>
64
44
  <a
@@ -84,18 +64,6 @@ export function TaskCard(props: {
84
64
  {words.board.decisions(task.openDecisions)}
85
65
  </Badge>
86
66
  )}
87
- {task.openRuns > 0 && (
88
- <Badge
89
- tone="accent"
90
- title={
91
- run === undefined
92
- ? words.board.agentAtWorkTitle
93
- : words.board.agentSince(run.agent, formatInstant(run.startedAt, tag))
94
- }
95
- >
96
- {run === undefined ? words.board.agentAtWork : run.agent}
97
- </Badge>
98
- )}
99
67
  <span className="sc-card-spacer" />
100
68
  {task.assignee !== null && <Avatar person={task.assignee} size="sm" />}
101
69
  </div>
@@ -156,7 +124,6 @@ export function Board(props: BoardProps) {
156
124
  href={props.taskHref(task)}
157
125
  onOpen={props.onOpen}
158
126
  onMove={props.onMove}
159
- run={runOn(task, props.runs)}
160
127
  today={props.today}
161
128
  />
162
129
  ))}
@@ -1,10 +1,11 @@
1
- import { OPEN_TASK_STATES, type RestPerson, type RestTask } from "../api.js";
1
+ import { OPEN_TASK_STATES, type RestCategory, type RestPerson, type RestTask } from "../api.js";
2
2
  import {
3
3
  addMonths,
4
4
  daysBetween,
5
5
  firstDayOfWeek,
6
6
  formatMonth,
7
7
  formatWeekday,
8
+ type GridDay,
8
9
  monthGrid,
9
10
  monthOf,
10
11
  today as todayOf,
@@ -13,24 +14,82 @@ import {
13
14
  import { useLanguage, useWords } from "../i18n/index.js";
14
15
  import { cx, PersonChip } from "./ui.js";
15
16
 
16
- // The calendar of a project (ADR-0019): the month as a grid, each task on the day it is due
17
- // and, lightly, on the day it starts; and who has what, so that whoever runs the project sees
18
- // at a glance whether the work is on its days. Takes its data as props and nothing from the
19
- // network; the month shown is the address's (ADR-0010).
17
+ // The calendar of a project (ADR-0019): the month as a grid. A task planned from a start to a
18
+ // due day lies across its days as a bar, in a lane of its own within each week so that the bars
19
+ // of one week never cross; a task with a due day alone sits on it as a dot, and one with a
20
+ // start alone is marked on that day. Who has what says whether the work is on its days. Takes
21
+ // its data as props and nothing from the network; the month shown is the address's (ADR-0010).
20
22
 
21
23
  const isOpen = (task: RestTask): boolean => OPEN_TASK_STATES.includes(task.state);
22
24
 
25
+ /** Whether a task lies across days: a start and a due day, and the due day after the start. */
26
+ const spans = (task: RestTask): boolean =>
27
+ task.startsOn !== null && task.dueOn !== null && task.startsOn < task.dueOn;
28
+
23
29
  export interface CalendarProps {
24
30
  readonly tasks: readonly RestTask[];
25
31
  /** The month shown: `2026-10`. */
26
32
  readonly month: string;
27
33
  /** The day to count from; the browser's today when left out. */
28
34
  readonly today?: string | undefined;
35
+ /** The workspace's categories, for the legend to say what the colours mean. */
36
+ readonly categories?: readonly RestCategory[] | undefined;
29
37
  readonly taskHref: (task: RestTask) => string;
30
38
  /** Where the calendar is on another month. */
31
39
  readonly monthHref: (month: string) => string;
32
40
  }
33
41
 
42
+ /** One task's stretch across one week: which columns it covers, and in which lane it lies. */
43
+ export interface WeekSegment {
44
+ readonly task: RestTask;
45
+ /** The first and the last column (0 to 6) the task covers in this week. */
46
+ readonly from: number;
47
+ readonly to: number;
48
+ readonly lane: number;
49
+ /** Whether the task begins in this week, and whether it ends in it. */
50
+ readonly begins: boolean;
51
+ readonly ends: boolean;
52
+ }
53
+
54
+ /**
55
+ * The bars of one week: every task lying across days that touches the week, cut to it, each
56
+ * in the first lane where it crosses nothing that was laid before it. The longest and earliest
57
+ * go first, so that the lanes stay few.
58
+ */
59
+ export function weekSegments(week: readonly GridDay[], tasks: readonly RestTask[]): WeekSegment[] {
60
+ const first = week[0]?.day;
61
+ const last = week[week.length - 1]?.day;
62
+ if (first === undefined || last === undefined) {
63
+ return [];
64
+ }
65
+ const cut = tasks
66
+ .filter(spans)
67
+ .flatMap((task) => {
68
+ const startsOn = task.startsOn ?? first;
69
+ const dueOn = task.dueOn ?? last;
70
+ if (dueOn < first || startsOn > last) {
71
+ return [];
72
+ }
73
+ const from = startsOn < first ? 0 : daysBetween(first, startsOn);
74
+ const to = dueOn > last ? week.length - 1 : daysBetween(first, dueOn);
75
+ return [{ task, from, to, begins: startsOn >= first, ends: dueOn <= last }];
76
+ })
77
+ .sort(
78
+ (a, b) => a.from - b.from || b.to - b.from - (a.to - a.from) || a.task.number - b.task.number,
79
+ );
80
+ const ends: number[] = [];
81
+ return cut.map((segment) => {
82
+ let lane = ends.findIndex((end) => end < segment.from);
83
+ if (lane === -1) {
84
+ lane = ends.length;
85
+ ends.push(segment.to);
86
+ } else {
87
+ ends[lane] = segment.to;
88
+ }
89
+ return { ...segment, lane };
90
+ });
91
+ }
92
+
34
93
  function push(map: Map<string, RestTask[]>, day: string, task: RestTask): void {
35
94
  const list = map.get(day);
36
95
  if (list === undefined) {
@@ -49,10 +108,12 @@ export function Calendar(props: CalendarProps) {
49
108
  const due = new Map<string, RestTask[]>();
50
109
  const starting = new Map<string, RestTask[]>();
51
110
  for (const task of props.tasks) {
111
+ if (spans(task)) {
112
+ continue;
113
+ }
52
114
  if (task.dueOn !== null) {
53
115
  push(due, task.dueOn, task);
54
- }
55
- if (task.startsOn !== null && task.startsOn !== task.dueOn) {
116
+ } else if (task.startsOn !== null) {
56
117
  push(starting, task.startsOn, task);
57
118
  }
58
119
  }
@@ -61,9 +122,17 @@ export function Calendar(props: CalendarProps) {
61
122
  list.sort((a, b) => Number(isOpen(b)) - Number(isOpen(a)) || a.number - b.number);
62
123
  }
63
124
  const inMonth = (day: string) => monthOf(day) === props.month;
125
+ const monthWeeks = weeks.map((week) => ({ week, segments: weekSegments(week, props.tasks) }));
64
126
  const nothing =
65
127
  [...due.keys()].every((day) => !inMonth(day)) &&
66
- [...starting.keys()].every((day) => !inMonth(day));
128
+ [...starting.keys()].every((day) => !inMonth(day)) &&
129
+ monthWeeks.every(({ week, segments }) =>
130
+ segments.every(
131
+ (segment) => !week.slice(segment.from, segment.to + 1).some((cell) => cell.inMonth),
132
+ ),
133
+ );
134
+ const tone = (task: RestTask, day: string) =>
135
+ cx(!isOpen(task) && "sc-calendar-over", isOpen(task) && day < now && "sc-calendar-late");
67
136
  return (
68
137
  <div className="sc-calendar">
69
138
  <div className="sc-calendar-head">
@@ -102,54 +171,97 @@ export function Calendar(props: CalendarProps) {
102
171
  </tr>
103
172
  </thead>
104
173
  <tbody>
105
- {weeks.map((week) => (
106
- <tr key={week[0]?.day}>
107
- {week.map((cell) => (
108
- <td
109
- key={cell.day}
110
- className={cx(
111
- "sc-calendar-cell",
112
- !cell.inMonth && "sc-calendar-cell-outside",
113
- cell.day === now && "sc-calendar-cell-today",
114
- )}
115
- >
116
- <span className="sc-calendar-day">{Number(cell.day.slice(8))}</span>
117
- {(due.get(cell.day) ?? []).map((task) => (
118
- <a
119
- key={task.id}
120
- className={cx(
121
- "sc-calendar-task",
122
- !isOpen(task) && "sc-calendar-task-over",
123
- isOpen(task) && cell.day < now && "sc-calendar-task-late",
124
- )}
125
- href={props.taskHref(task)}
126
- title={`#${task.number} ${task.title}`}
127
- >
128
- <span
129
- className={cx(
130
- "sc-calendar-dot",
131
- `sc-category-${task.category?.color ?? "gray"}`,
132
- )}
133
- />
134
- <span className="sc-calendar-task-title">{task.title}</span>
135
- </a>
136
- ))}
137
- {(starting.get(cell.day) ?? []).map((task) => (
138
- <a
139
- key={task.id}
140
- className="sc-calendar-task sc-calendar-task-start"
141
- href={props.taskHref(task)}
142
- title={`#${task.number} ${task.title}: ${words.starts}`}
143
- >
144
- <span className="sc-calendar-task-title">{task.title}</span>
145
- </a>
146
- ))}
147
- </td>
148
- ))}
149
- </tr>
150
- ))}
174
+ {monthWeeks.map(({ week, segments }) => {
175
+ const lanes = segments.reduce((most, segment) => Math.max(most, segment.lane + 1), 0);
176
+ return (
177
+ <tr key={week[0]?.day}>
178
+ {week.map((cell, column) => (
179
+ <td
180
+ key={cell.day}
181
+ className={cx(
182
+ "sc-calendar-cell",
183
+ !cell.inMonth && "sc-calendar-cell-outside",
184
+ cell.day === now && "sc-calendar-cell-today",
185
+ )}
186
+ >
187
+ <span className="sc-calendar-day">{Number(cell.day.slice(8))}</span>
188
+ {Array.from({ length: lanes }, (_, lane) => {
189
+ const segment = segments.find(
190
+ (candidate) =>
191
+ candidate.lane === lane &&
192
+ candidate.from <= column &&
193
+ column <= candidate.to,
194
+ );
195
+ if (segment === undefined) {
196
+ // The lane is someone else's on this day: the bars below keep their height.
197
+ // biome-ignore lint/suspicious/noArrayIndexKey: an empty lane is nothing but its position, and the lanes of a week never reorder
198
+ return <span key={lane} className="sc-calendar-lane" aria-hidden="true" />;
199
+ }
200
+ const { task } = segment;
201
+ return (
202
+ <a
203
+ key={task.id}
204
+ className={cx(
205
+ "sc-calendar-bar",
206
+ `sc-category-${task.category?.color ?? "gray"}`,
207
+ segment.from === column && segment.begins && "sc-calendar-bar-begins",
208
+ segment.to === column && segment.ends && "sc-calendar-bar-ends",
209
+ tone(task, task.dueOn ?? cell.day),
210
+ )}
211
+ href={props.taskHref(task)}
212
+ title={`#${task.number} ${task.title}`}
213
+ >
214
+ {segment.from === column && (
215
+ <span className="sc-calendar-task-title">{task.title}</span>
216
+ )}
217
+ </a>
218
+ );
219
+ })}
220
+ {(due.get(cell.day) ?? []).map((task) => (
221
+ <a
222
+ key={task.id}
223
+ className={cx("sc-calendar-task", tone(task, cell.day))}
224
+ href={props.taskHref(task)}
225
+ title={`#${task.number} ${task.title}`}
226
+ >
227
+ <span
228
+ className={cx(
229
+ "sc-calendar-dot",
230
+ `sc-category-${task.category?.color ?? "gray"}`,
231
+ )}
232
+ />
233
+ <span className="sc-calendar-task-title">{task.title}</span>
234
+ </a>
235
+ ))}
236
+ {(starting.get(cell.day) ?? []).map((task) => (
237
+ <a
238
+ key={task.id}
239
+ className="sc-calendar-task sc-calendar-task-start"
240
+ href={props.taskHref(task)}
241
+ title={`#${task.number} ${task.title}: ${words.startsOnly}`}
242
+ >
243
+ <span className="sc-calendar-task-title">{task.title}</span>
244
+ </a>
245
+ ))}
246
+ </td>
247
+ ))}
248
+ </tr>
249
+ );
250
+ })}
151
251
  </tbody>
152
252
  </table>
253
+ {(props.categories ?? []).length > 0 && (
254
+ <dl className="sc-calendar-legend" aria-label={words.legend.title}>
255
+ {(props.categories ?? []).map((category) => (
256
+ <div key={category.key}>
257
+ <dt>
258
+ <span className={cx("sc-calendar-dot", `sc-category-${category.color}`)} />
259
+ </dt>
260
+ <dd>{category.name}</dd>
261
+ </div>
262
+ ))}
263
+ </dl>
264
+ )}
153
265
  {nothing && <p className="sc-muted">{words.nothingDue}</p>}
154
266
  </div>
155
267
  );
@@ -9,6 +9,7 @@ import {
9
9
  } from "../api.js";
10
10
  import { useWords } from "../i18n/index.js";
11
11
  import { Markdown } from "./markdown.js";
12
+ import { ShowMore, usePaged } from "./paged.js";
12
13
  import { Button, cx, PersonChip, Time } from "./ui.js";
13
14
 
14
15
  // The decisions: the ones that wait, each a card with the way to answer it, since they are what
@@ -254,40 +255,44 @@ export interface DecisionLogProps {
254
255
  /** The record: one row per decision answered, what was asked, what was chosen, by whom, when. */
255
256
  export function DecisionLog(props: DecisionLogProps) {
256
257
  const words = useWords();
258
+ const { shown, rest, more } = usePaged(props.decisions);
257
259
  return (
258
- <ol className="sc-decision-log" aria-label={words.decisions.record}>
259
- {props.decisions.map((decision) => {
260
- const task = decision.taskId === null ? undefined : props.tasks?.get(decision.taskId);
261
- const href = props.decisionHref?.(decision);
262
- const chosen =
263
- decision.options.find((option) => option.id === decision.answer?.option)?.label ??
264
- decision.answer?.option ??
265
- "";
266
- return (
267
- <li key={decision.id} className="sc-decision-row">
268
- <span className="sc-decision-row-question">
269
- {href === undefined ? decision.question : <a href={href}>{decision.question}</a>}
270
- </span>
271
- <span className="sc-decision-row-answer">{chosen}</span>
272
- <span className="sc-decision-row-meta">
273
- {decision.answer !== null && (
274
- <>
275
- <PersonChip person={decision.answer.by} /> <Time iso={decision.answer.at} />
276
- </>
277
- )}
278
- {task !== undefined &&
279
- (props.taskHref === undefined ? (
280
- <span className="sc-card-number">#{task.number}</span>
281
- ) : (
282
- <a className="sc-card-number" href={props.taskHref(task)}>
283
- #{task.number}
284
- </a>
285
- ))}
286
- </span>
287
- </li>
288
- );
289
- })}
290
- </ol>
260
+ <>
261
+ <ol className="sc-decision-log" aria-label={words.decisions.record}>
262
+ {shown.map((decision) => {
263
+ const task = decision.taskId === null ? undefined : props.tasks?.get(decision.taskId);
264
+ const href = props.decisionHref?.(decision);
265
+ const chosen =
266
+ decision.options.find((option) => option.id === decision.answer?.option)?.label ??
267
+ decision.answer?.option ??
268
+ "";
269
+ return (
270
+ <li key={decision.id} className="sc-decision-row">
271
+ <span className="sc-decision-row-question">
272
+ {href === undefined ? decision.question : <a href={href}>{decision.question}</a>}
273
+ </span>
274
+ <span className="sc-decision-row-answer">{chosen}</span>
275
+ <span className="sc-decision-row-meta">
276
+ {decision.answer !== null && (
277
+ <>
278
+ <PersonChip person={decision.answer.by} /> <Time iso={decision.answer.at} />
279
+ </>
280
+ )}
281
+ {task !== undefined &&
282
+ (props.taskHref === undefined ? (
283
+ <span className="sc-card-number">#{task.number}</span>
284
+ ) : (
285
+ <a className="sc-card-number" href={props.taskHref(task)}>
286
+ #{task.number}
287
+ </a>
288
+ ))}
289
+ </span>
290
+ </li>
291
+ );
292
+ })}
293
+ </ol>
294
+ <ShowMore rest={rest} onMore={more} />
295
+ </>
291
296
  );
292
297
  }
293
298